[offers][feat] add sortable columns to table (#541)

* [offers][feat] add sortable columns to table

* [offers][fix] fix mobile compatibility for sorter
pull/543/head
Zhang Ziqing 2 years ago committed by GitHub
parent 65c4254dad
commit 247a60efab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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 { useRouter } from 'next/router';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { JobType } from '@prisma/client'; import { JobType } from '@prisma/client';
import { DropdownMenu, Spinner, useToast } from '@tih/ui'; import { DropdownMenu, Spinner, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; 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 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 { import {
OfferTableFilterOptions,
OfferTableYoeOptions, OfferTableYoeOptions,
YOE_CATEGORY,
YOE_CATEGORY_PARAM, YOE_CATEGORY_PARAM,
} from '~/components/offers/table/types'; } from '~/components/offers/table/types';
@ -19,8 +24,6 @@ import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
import { useSearchParamSingle } from '~/utils/offers/useSearchParam'; import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import OffersRow from './OffersRow';
import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers'; import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
const NUMBER_OF_OFFERS_PER_PAGE = 20; const NUMBER_OF_OFFERS_PER_PAGE = 20;
@ -63,12 +66,26 @@ export default function OffersTable({
isYoeCategoryInitialized, isYoeCategoryInitialized,
] = useSearchParamSingle<keyof typeof YOE_CATEGORY_PARAM>('yoeCategory'); ] = useSearchParamSingle<keyof typeof YOE_CATEGORY_PARAM>('yoeCategory');
const [selectedSortBy, setSelectedSortBy, isSortByInitialized] = const [
useSearchParamSingle<OfferTableSortByType>('sortBy'); selectedSortDirection,
setSelectedSortDirection,
isSortDirectionInitialized,
] = useSearchParamSingle<OFFER_TABLE_SORT_ORDER>('sortDirection');
const [selectedSortType, setSelectedSortType, isSortTypeInitialized] =
useSearchParamSingle<OfferTableSortType>('sortType');
const areFilterParamsInitialized = useMemo(() => { const areFilterParamsInitialized = useMemo(() => {
return isYoeCategoryInitialized && isSortByInitialized; return (
}, [isYoeCategoryInitialized, isSortByInitialized]); isYoeCategoryInitialized &&
isSortDirectionInitialized &&
isSortTypeInitialized
);
}, [
isYoeCategoryInitialized,
isSortDirectionInitialized,
isSortTypeInitialized,
]);
const { pathname } = router; const { pathname } = router;
useEffect(() => { useEffect(() => {
@ -80,7 +97,8 @@ export default function OffersTable({
companyId: companyFilter, companyId: companyFilter,
companyName, companyName,
jobTitleId: jobTitleFilter, jobTitleId: jobTitleFilter,
sortBy: selectedSortBy, sortDirection: selectedSortDirection,
sortType: selectedSortType,
yoeCategory: selectedYoeCategory, yoeCategory: selectedYoeCategory,
}, },
}, },
@ -102,11 +120,16 @@ export default function OffersTable({
countryFilter, countryFilter,
companyFilter, companyFilter,
jobTitleFilter, jobTitleFilter,
selectedSortBy, selectedSortDirection,
selectedSortType,
selectedYoeCategory, selectedYoeCategory,
pathname, pathname,
]); ]);
useEffect(() => {
setSelectedSortDirection(OFFER_TABLE_SORT_ORDER.UNSORTED);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedYoeCategory]);
const topRef = useRef<HTMLDivElement>(null); const topRef = useRef<HTMLDivElement>(null);
const { showToast } = useToast(); const { showToast } = useToast();
const { isLoading: isResultsLoading } = trpc.useQuery( const { isLoading: isResultsLoading } = trpc.useQuery(
@ -118,7 +141,11 @@ export default function OffersTable({
currency, currency,
limit: NUMBER_OF_OFFERS_PER_PAGE, limit: NUMBER_OF_OFFERS_PER_PAGE,
offset: pagination.currentPage, offset: pagination.currentPage,
sortBy: selectedSortBy ?? '-monthYearReceived', // SortBy: selectedSortBy ?? '-monthYearReceived',
sortBy:
selectedSortDirection && selectedSortType
? `${selectedSortDirection}${selectedSortType}`
: '-monthYearReceived',
title: jobTitleFilter, title: jobTitleFilter,
yoeCategory: selectedYoeCategory yoeCategory: selectedYoeCategory
? YOE_CATEGORY_PARAM[selectedYoeCategory as string] ? YOE_CATEGORY_PARAM[selectedYoeCategory as string]
@ -131,6 +158,7 @@ export default function OffersTable({
title: 'Error loading the page.', title: 'Error loading the page.',
variant: 'failure', variant: 'failure',
}); });
setIsLoading(false);
}, },
onSuccess: (response: GetOffersResponse) => { onSuccess: (response: GetOffersResponse) => {
setOffers(response.data); 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() { function renderFilters() {
return ( 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"> <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} selectedCurrency={currency}
/> />
</div> </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>
</div> </div>
); );
} }
function renderHeader() { function renderHeader() {
let columns = [ const columns: Array<OfferTableColumn> =
'Company', jobType === JobType.FULLTIME
'Title', ? FullTimeOfferTableColumns
'YOE', : InternOfferTableColumns;
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',
];
}
return ( return (
<thead className="font-semibold"> <thead className="font-semibold">
<tr className="divide-x divide-slate-200"> <tr className="divide-x divide-slate-200">
{columns.map((header, index) => ( {columns.map((header, index) => (
<th <OffersHeader
key={header} key={header.label}
className={clsx( header={header.label}
'bg-slate-100 py-3 px-4', isLastColumn={index === columns.length - 1}
header !== 'Company' && 'whitespace-nowrap', sortDirection={
// Make last column sticky. header.sortType === selectedSortType
index === columns.length - 1 && ? selectedSortDirection
'sticky right-0 drop-shadow md:drop-shadow-none', : undefined
)} }
scope="col"> sortType={header.sortType}
{header} onSort={onSort}
</th> />
))} ))}
</tr> </tr>
</thead> </thead>
@ -276,28 +275,29 @@ export default function OffersTable({
pagination={pagination} pagination={pagination}
startNumber={pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + 1} startNumber={pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + 1}
/> />
{isLoading ? ( <div className="overflow-x-auto text-slate-600">
<div className="col-span-10 py-32"> <table className="w-full divide-y divide-slate-200 text-left text-xs text-slate-700 sm:text-sm">
<Spinner display="block" size="lg" /> {renderHeader()}
</div> {!isLoading && (
) : (
<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()}
<tbody className="divide-y divide-slate-200"> <tbody className="divide-y divide-slate-200">
{offers.map((offer) => ( {offers.map((offer) => (
<OffersRow key={offer.id} jobType={jobType} row={offer} /> <OffersRow key={offer.id} jobType={jobType} row={offer} />
))} ))}
</tbody> </tbody>
</table> )}
{!offers || </table>
(offers.length === 0 && ( {isLoading && (
<div className="py-16 text-lg"> <div className="flex justify-center py-32">
<div className="flex justify-center">No data yet 🥺</div> <Spinner display="block" size="lg" />
</div> </div>
))} )}
</div> {(!isLoading && !offers) ||
)} (offers.length === 0 && (
<div className="py-16 text-lg">
<div className="flex justify-center">No data yet 🥺</div>
</div>
))}
</div>
<OffersTablePagination <OffersTablePagination
endNumber={ endNumber={
pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + offers.length pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + offers.length

@ -33,27 +33,50 @@ export const OfferTableYoeOptions = [
}, },
]; ];
export const OfferTableFilterOptions = [ export type OfferTableSortType =
{ | 'companyName'
label: 'Latest Submitted', | 'jobTitle'
value: '-monthYearReceived', | 'monthYearReceived'
}, | 'totalCompensation'
{ | 'totalYoe';
label: 'Highest Salary',
value: '-totalCompensation', export enum OFFER_TABLE_SORT_ORDER {
}, ASC = '+',
{ DESC = '-',
label: 'Highest YOE first', UNSORTED = '',
value: '-totalYoe', }
},
{ export function getOppositeSortOrder(
label: 'Lowest YOE first', order: OFFER_TABLE_SORT_ORDER,
value: '+totalYoe', ): 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 = export const InternOfferTableColumns: Array<OfferTableColumn> = [
| '-monthYearReceived' { label: 'Company', sortType: 'companyName' },
| '-totalCompensation' { label: 'Title', sortType: 'jobTitle' },
| '-totalYoe' { label: 'YOE', sortType: 'totalYoe' },
| '+totalYoe'; { label: 'Monthly Salary', sortType: 'totalCompensation' },
{ label: 'Date Offered', sortType: 'monthYearReceived' },
{ label: 'Actions' },
];

Loading…
Cancel
Save