mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2026-04-02 02:21:00 +08:00
[offers][feat] add sortable columns to table (#541)
* [offers][feat] add sortable columns to table * [offers][fix] fix mobile compatibility for sorter
This commit is contained in:
83
apps/portal/src/components/offers/table/OffersHeader.tsx
Normal file
83
apps/portal/src/components/offers/table/OffersHeader.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import type { OfferTableSortType } from '~/components/offers/table/types';
|
||||
import {
|
||||
getOppositeSortOrder,
|
||||
OFFER_TABLE_SORT_ORDER,
|
||||
} from '~/components/offers/table/types';
|
||||
|
||||
export type OffersTableHeaderProps = Readonly<{
|
||||
header: string;
|
||||
isLastColumn: boolean;
|
||||
onSort?: (
|
||||
sortDirection: OFFER_TABLE_SORT_ORDER,
|
||||
sortType: OfferTableSortType,
|
||||
) => void;
|
||||
sortDirection?: OFFER_TABLE_SORT_ORDER;
|
||||
sortType?: OfferTableSortType;
|
||||
}>;
|
||||
|
||||
export default function OffersHeader({
|
||||
header,
|
||||
isLastColumn,
|
||||
onSort,
|
||||
sortDirection,
|
||||
sortType,
|
||||
}: OffersTableHeaderProps) {
|
||||
return (
|
||||
<th
|
||||
key={header}
|
||||
className={clsx(
|
||||
'bg-slate-100 py-3 px-4',
|
||||
sortType &&
|
||||
'hover:cursor-pointer hover:bg-slate-200 active:bg-slate-300',
|
||||
header !== 'Company' && 'whitespace-nowrap',
|
||||
(sortDirection === OFFER_TABLE_SORT_ORDER.ASC ||
|
||||
sortDirection === OFFER_TABLE_SORT_ORDER.DESC) &&
|
||||
'text-primary-600',
|
||||
// Make last column sticky.
|
||||
isLastColumn && 'sticky right-0 drop-shadow md:drop-shadow-none',
|
||||
)}
|
||||
scope="col"
|
||||
onClick={
|
||||
onSort &&
|
||||
sortType &&
|
||||
(() => {
|
||||
onSort(
|
||||
sortDirection
|
||||
? getOppositeSortOrder(sortDirection)
|
||||
: OFFER_TABLE_SORT_ORDER.ASC,
|
||||
sortType,
|
||||
);
|
||||
})
|
||||
}>
|
||||
<div className="my-auto flex items-center justify-start">
|
||||
{header}
|
||||
{onSort && sortType && (
|
||||
<span className="ml-2 grid grid-cols-1 space-y-0 text-[9px] text-gray-300">
|
||||
<div
|
||||
className={clsx(
|
||||
'-mb-2 flex items-end sm:-mb-3',
|
||||
sortDirection === OFFER_TABLE_SORT_ORDER.ASC &&
|
||||
'text-primary-500',
|
||||
sortDirection === OFFER_TABLE_SORT_ORDER.DESC &&
|
||||
'text-slate-200',
|
||||
)}>
|
||||
▲
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'-mb-3 flex items-end',
|
||||
sortDirection === OFFER_TABLE_SORT_ORDER.DESC &&
|
||||
'text-primary-500',
|
||||
sortDirection === OFFER_TABLE_SORT_ORDER.ASC &&
|
||||
'text-slate-200',
|
||||
)}>
|
||||
▼
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { JobType } from '@prisma/client';
|
||||
import { DropdownMenu, Spinner, useToast } from '@tih/ui';
|
||||
|
||||
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
|
||||
import OffersRow from '~/components/offers/table//OffersRow';
|
||||
import OffersHeader from '~/components/offers/table/OffersHeader';
|
||||
import OffersTablePagination from '~/components/offers/table/OffersTablePagination';
|
||||
import type { OfferTableSortByType } from '~/components/offers/table/types';
|
||||
import type {
|
||||
OfferTableColumn,
|
||||
OfferTableSortType,
|
||||
} from '~/components/offers/table/types';
|
||||
import { OFFER_TABLE_SORT_ORDER } from '~/components/offers/table/types';
|
||||
import { InternOfferTableColumns } from '~/components/offers/table/types';
|
||||
import { FullTimeOfferTableColumns } from '~/components/offers/table/types';
|
||||
import {
|
||||
OfferTableFilterOptions,
|
||||
OfferTableYoeOptions,
|
||||
YOE_CATEGORY,
|
||||
YOE_CATEGORY_PARAM,
|
||||
} from '~/components/offers/table/types';
|
||||
|
||||
@@ -19,8 +24,6 @@ import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
|
||||
import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
import OffersRow from './OffersRow';
|
||||
|
||||
import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
|
||||
|
||||
const NUMBER_OF_OFFERS_PER_PAGE = 20;
|
||||
@@ -63,12 +66,26 @@ export default function OffersTable({
|
||||
isYoeCategoryInitialized,
|
||||
] = useSearchParamSingle<keyof typeof YOE_CATEGORY_PARAM>('yoeCategory');
|
||||
|
||||
const [selectedSortBy, setSelectedSortBy, isSortByInitialized] =
|
||||
useSearchParamSingle<OfferTableSortByType>('sortBy');
|
||||
const [
|
||||
selectedSortDirection,
|
||||
setSelectedSortDirection,
|
||||
isSortDirectionInitialized,
|
||||
] = useSearchParamSingle<OFFER_TABLE_SORT_ORDER>('sortDirection');
|
||||
|
||||
const [selectedSortType, setSelectedSortType, isSortTypeInitialized] =
|
||||
useSearchParamSingle<OfferTableSortType>('sortType');
|
||||
|
||||
const areFilterParamsInitialized = useMemo(() => {
|
||||
return isYoeCategoryInitialized && isSortByInitialized;
|
||||
}, [isYoeCategoryInitialized, isSortByInitialized]);
|
||||
return (
|
||||
isYoeCategoryInitialized &&
|
||||
isSortDirectionInitialized &&
|
||||
isSortTypeInitialized
|
||||
);
|
||||
}, [
|
||||
isYoeCategoryInitialized,
|
||||
isSortDirectionInitialized,
|
||||
isSortTypeInitialized,
|
||||
]);
|
||||
const { pathname } = router;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,7 +97,8 @@ export default function OffersTable({
|
||||
companyId: companyFilter,
|
||||
companyName,
|
||||
jobTitleId: jobTitleFilter,
|
||||
sortBy: selectedSortBy,
|
||||
sortDirection: selectedSortDirection,
|
||||
sortType: selectedSortType,
|
||||
yoeCategory: selectedYoeCategory,
|
||||
},
|
||||
},
|
||||
@@ -102,11 +120,16 @@ export default function OffersTable({
|
||||
countryFilter,
|
||||
companyFilter,
|
||||
jobTitleFilter,
|
||||
selectedSortBy,
|
||||
selectedSortDirection,
|
||||
selectedSortType,
|
||||
selectedYoeCategory,
|
||||
pathname,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSortDirection(OFFER_TABLE_SORT_ORDER.UNSORTED);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedYoeCategory]);
|
||||
const topRef = useRef<HTMLDivElement>(null);
|
||||
const { showToast } = useToast();
|
||||
const { isLoading: isResultsLoading } = trpc.useQuery(
|
||||
@@ -118,7 +141,11 @@ export default function OffersTable({
|
||||
currency,
|
||||
limit: NUMBER_OF_OFFERS_PER_PAGE,
|
||||
offset: pagination.currentPage,
|
||||
sortBy: selectedSortBy ?? '-monthYearReceived',
|
||||
// SortBy: selectedSortBy ?? '-monthYearReceived',
|
||||
sortBy:
|
||||
selectedSortDirection && selectedSortType
|
||||
? `${selectedSortDirection}${selectedSortType}`
|
||||
: '-monthYearReceived',
|
||||
title: jobTitleFilter,
|
||||
yoeCategory: selectedYoeCategory
|
||||
? YOE_CATEGORY_PARAM[selectedYoeCategory as string]
|
||||
@@ -131,6 +158,7 @@ export default function OffersTable({
|
||||
title: 'Error loading the page.',
|
||||
variant: 'failure',
|
||||
});
|
||||
setIsLoading(false);
|
||||
},
|
||||
onSuccess: (response: GetOffersResponse) => {
|
||||
setOffers(response.data);
|
||||
@@ -141,6 +169,19 @@ export default function OffersTable({
|
||||
},
|
||||
);
|
||||
|
||||
const onSort = (
|
||||
sortDirection: OFFER_TABLE_SORT_ORDER,
|
||||
sortType: OfferTableSortType,
|
||||
) => {
|
||||
gaEvent({
|
||||
action: 'offers_table_sort',
|
||||
category: 'engagement',
|
||||
label: `${sortType} - ${sortDirection}`,
|
||||
});
|
||||
setSelectedSortType(sortType);
|
||||
setSelectedSortDirection(sortDirection);
|
||||
};
|
||||
|
||||
function renderFilters() {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 text-xs text-slate-700 sm:grid-cols-4 sm:text-sm md:text-base">
|
||||
@@ -182,75 +223,33 @@ export default function OffersTable({
|
||||
selectedCurrency={currency}
|
||||
/>
|
||||
</div>
|
||||
<div className="pl-4">
|
||||
<DropdownMenu
|
||||
align="end"
|
||||
label={
|
||||
OfferTableFilterOptions.filter(
|
||||
({ value: itemValue }) => itemValue === selectedSortBy,
|
||||
).length > 0
|
||||
? OfferTableFilterOptions.filter(
|
||||
({ value: itemValue }) => itemValue === selectedSortBy,
|
||||
)[0].label
|
||||
: OfferTableFilterOptions[0].label
|
||||
}
|
||||
size="inherit">
|
||||
{OfferTableFilterOptions.map(({ label: itemLabel, value }) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
isSelected={value === selectedSortBy}
|
||||
label={itemLabel}
|
||||
onClick={() => {
|
||||
setSelectedSortBy(value as OfferTableSortByType);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
let columns = [
|
||||
'Company',
|
||||
'Title',
|
||||
'YOE',
|
||||
selectedYoeCategory === YOE_CATEGORY.INTERN
|
||||
? 'Monthly Salary'
|
||||
: 'Annual TC',
|
||||
'Date Offered',
|
||||
'Actions',
|
||||
];
|
||||
if (jobType === JobType.FULLTIME) {
|
||||
columns = [
|
||||
'Company',
|
||||
'Title',
|
||||
'YOE',
|
||||
'Annual TC',
|
||||
'Annual Base / Bonus / Stocks',
|
||||
'Date Offered',
|
||||
'Actions',
|
||||
];
|
||||
}
|
||||
const columns: Array<OfferTableColumn> =
|
||||
jobType === JobType.FULLTIME
|
||||
? FullTimeOfferTableColumns
|
||||
: InternOfferTableColumns;
|
||||
|
||||
return (
|
||||
<thead className="font-semibold">
|
||||
<tr className="divide-x divide-slate-200">
|
||||
{columns.map((header, index) => (
|
||||
<th
|
||||
key={header}
|
||||
className={clsx(
|
||||
'bg-slate-100 py-3 px-4',
|
||||
header !== 'Company' && 'whitespace-nowrap',
|
||||
// Make last column sticky.
|
||||
index === columns.length - 1 &&
|
||||
'sticky right-0 drop-shadow md:drop-shadow-none',
|
||||
)}
|
||||
scope="col">
|
||||
{header}
|
||||
</th>
|
||||
<OffersHeader
|
||||
key={header.label}
|
||||
header={header.label}
|
||||
isLastColumn={index === columns.length - 1}
|
||||
sortDirection={
|
||||
header.sortType === selectedSortType
|
||||
? selectedSortDirection
|
||||
: undefined
|
||||
}
|
||||
sortType={header.sortType}
|
||||
onSort={onSort}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -276,28 +275,29 @@ export default function OffersTable({
|
||||
pagination={pagination}
|
||||
startNumber={pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + 1}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="col-span-10 py-32">
|
||||
<Spinner display="block" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto text-slate-600">
|
||||
<table className="w-full divide-y divide-slate-200 text-left text-xs text-slate-700 sm:text-sm">
|
||||
{renderHeader()}
|
||||
<div className="overflow-x-auto text-slate-600">
|
||||
<table className="w-full divide-y divide-slate-200 text-left text-xs text-slate-700 sm:text-sm">
|
||||
{renderHeader()}
|
||||
{!isLoading && (
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{offers.map((offer) => (
|
||||
<OffersRow key={offer.id} jobType={jobType} row={offer} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!offers ||
|
||||
(offers.length === 0 && (
|
||||
<div className="py-16 text-lg">
|
||||
<div className="flex justify-center">No data yet 🥺</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</table>
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-32">
|
||||
<Spinner display="block" size="lg" />
|
||||
</div>
|
||||
)}
|
||||
{(!isLoading && !offers) ||
|
||||
(offers.length === 0 && (
|
||||
<div className="py-16 text-lg">
|
||||
<div className="flex justify-center">No data yet 🥺</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<OffersTablePagination
|
||||
endNumber={
|
||||
pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + offers.length
|
||||
|
||||
@@ -33,27 +33,50 @@ export const OfferTableYoeOptions = [
|
||||
},
|
||||
];
|
||||
|
||||
export const OfferTableFilterOptions = [
|
||||
{
|
||||
label: 'Latest Submitted',
|
||||
value: '-monthYearReceived',
|
||||
},
|
||||
{
|
||||
label: 'Highest Salary',
|
||||
value: '-totalCompensation',
|
||||
},
|
||||
{
|
||||
label: 'Highest YOE first',
|
||||
value: '-totalYoe',
|
||||
},
|
||||
{
|
||||
label: 'Lowest YOE first',
|
||||
value: '+totalYoe',
|
||||
},
|
||||
export type OfferTableSortType =
|
||||
| 'companyName'
|
||||
| 'jobTitle'
|
||||
| 'monthYearReceived'
|
||||
| 'totalCompensation'
|
||||
| 'totalYoe';
|
||||
|
||||
export enum OFFER_TABLE_SORT_ORDER {
|
||||
ASC = '+',
|
||||
DESC = '-',
|
||||
UNSORTED = '',
|
||||
}
|
||||
|
||||
export function getOppositeSortOrder(
|
||||
order: OFFER_TABLE_SORT_ORDER,
|
||||
): OFFER_TABLE_SORT_ORDER {
|
||||
if (order === OFFER_TABLE_SORT_ORDER.UNSORTED) {
|
||||
return OFFER_TABLE_SORT_ORDER.UNSORTED;
|
||||
}
|
||||
return order === OFFER_TABLE_SORT_ORDER.ASC
|
||||
? OFFER_TABLE_SORT_ORDER.DESC
|
||||
: OFFER_TABLE_SORT_ORDER.ASC;
|
||||
}
|
||||
|
||||
export type OfferTableColumn = {
|
||||
label: string;
|
||||
sortType?: OfferTableSortType;
|
||||
};
|
||||
|
||||
export const FullTimeOfferTableColumns: Array<OfferTableColumn> = [
|
||||
{ label: 'Company', sortType: 'companyName' },
|
||||
{ label: 'Title', sortType: 'jobTitle' },
|
||||
{ label: 'YOE', sortType: 'totalYoe' },
|
||||
{ label: 'Annual TC', sortType: 'totalCompensation' },
|
||||
{ label: 'Annual Base / Bonus / Stocks' },
|
||||
{ label: 'Date Offered', sortType: 'monthYearReceived' },
|
||||
{ label: 'Actions' },
|
||||
];
|
||||
|
||||
export type OfferTableSortByType =
|
||||
| '-monthYearReceived'
|
||||
| '-totalCompensation'
|
||||
| '-totalYoe'
|
||||
| '+totalYoe';
|
||||
export const InternOfferTableColumns: Array<OfferTableColumn> = [
|
||||
{ label: 'Company', sortType: 'companyName' },
|
||||
{ label: 'Title', sortType: 'jobTitle' },
|
||||
{ label: 'YOE', sortType: 'totalYoe' },
|
||||
{ label: 'Monthly Salary', sortType: 'totalCompensation' },
|
||||
{ label: 'Date Offered', sortType: 'monthYearReceived' },
|
||||
{ label: 'Actions' },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user