You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tech-interview-handbook/apps/portal/src/components/offers/table/OffersTable.tsx

307 lines
9.3 KiB

import { useRouter } from 'next/router';
import { useEffect, useMemo, useRef, useState } from 'react';
import { JobType } from '@prisma/client';
import { DropdownMenu, Spinner, useToast } from '~/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 {
OfferTableColumn,
OfferTableSortType,
} from '~/components/offers/table/types';
import {
FullTimeOfferTableColumns,
InternOfferTableColumns,
OFFER_TABLE_SORT_ORDER,
OfferTableYoeOptions,
YOE_CATEGORY_PARAM,
} from '~/components/offers/table/types';
import { getCurrencyForCountry } from '~/utils/offers/currency/CurrencyEnum';
import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
import { trpc } from '~/utils/trpc';
import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
const NUMBER_OF_OFFERS_PER_PAGE = 20;
export type OffersTableProps = Readonly<{
companyFilter: string;
companyName?: string;
country: string | null;
countryFilter: string;
jobTitleFilter: string;
onSort: (
sortDirection: OFFER_TABLE_SORT_ORDER,
sortType: OfferTableSortType | null,
) => void;
selectedSortDirection: OFFER_TABLE_SORT_ORDER;
selectedSortType: OfferTableSortType | null;
}>;
export default function OffersTable({
country,
countryFilter,
companyName,
companyFilter,
jobTitleFilter,
selectedSortDirection,
selectedSortType,
onSort,
}: OffersTableProps) {
const [currency, setCurrency] = useState(
getCurrencyForCountry(country).toString(),
);
const [jobType, setJobType] = useState<JobType>(JobType.FULLTIME);
const [pagination, setPagination] = useState<Paging>({
currentPage: 0,
numOfItems: 0,
numOfPages: 0,
totalItems: 0,
});
const [offers, setOffers] = useState<Array<DashboardOffer>>([]);
const { event: gaEvent } = useGoogleAnalytics();
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [
selectedYoeCategory,
setSelectedYoeCategory,
isYoeCategoryInitialized,
] = useSearchParamSingle<keyof typeof YOE_CATEGORY_PARAM>('yoeCategory');
const [, , isSortDirectionInitialized] =
useSearchParamSingle<OFFER_TABLE_SORT_ORDER>('sortDirection');
const [, , isSortTypeInitialized] =
useSearchParamSingle<OfferTableSortType | null>('sortType');
const areFilterParamsInitialized = useMemo(() => {
return (
isYoeCategoryInitialized &&
isSortDirectionInitialized &&
isSortTypeInitialized
);
}, [
isYoeCategoryInitialized,
isSortDirectionInitialized,
isSortTypeInitialized,
]);
const { pathname } = router;
useEffect(() => {
if (areFilterParamsInitialized) {
router.replace(
{
pathname,
query: {
companyId: companyFilter,
companyName,
jobTitleId: jobTitleFilter,
sortDirection: selectedSortDirection,
sortType: selectedSortType,
yoeCategory: selectedYoeCategory,
},
},
undefined,
{ shallow: true },
);
setPagination({
currentPage: 0,
numOfItems: 0,
numOfPages: 0,
totalItems: 0,
});
setIsLoading(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
areFilterParamsInitialized,
currency,
countryFilter,
companyFilter,
jobTitleFilter,
selectedSortDirection,
selectedSortType,
selectedYoeCategory,
pathname,
]);
const topRef = useRef<HTMLDivElement>(null);
const { showToast } = useToast();
const { isLoading: isResultsLoading } = trpc.useQuery(
[
'offers.list',
{
companyId: companyFilter,
countryId: countryFilter,
currency,
limit: NUMBER_OF_OFFERS_PER_PAGE,
offset: pagination.currentPage,
sortBy:
selectedSortDirection && selectedSortType
? `${selectedSortDirection}${selectedSortType}`
: '-monthYearReceived',
title: jobTitleFilter,
yoeCategory: selectedYoeCategory
? YOE_CATEGORY_PARAM[selectedYoeCategory as string]
: undefined,
},
],
{
onError: () => {
showToast({
title: 'Error loading the page.',
variant: 'failure',
});
setIsLoading(false);
},
onSuccess: (response: GetOffersResponse) => {
setOffers(response.data);
setPagination(response.paging);
setJobType(response.jobType);
setIsLoading(false);
},
},
);
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">
<DropdownMenu
align="start"
label={
OfferTableYoeOptions.filter(
({ value: itemValue }) => itemValue === selectedYoeCategory,
).length > 0
? OfferTableYoeOptions.filter(
({ value: itemValue }) => itemValue === selectedYoeCategory,
)[0].label
: OfferTableYoeOptions[0].label
}
size="md">
{OfferTableYoeOptions.map(({ label: itemLabel, value }) => (
<DropdownMenu.Item
key={value}
isSelected={value === selectedYoeCategory}
label={itemLabel}
onClick={() => {
setSelectedYoeCategory(value);
onSort(OFFER_TABLE_SORT_ORDER.UNSORTED, null);
gaEvent({
action: `offers.table_filter_yoe_category_${value}`,
category: 'engagement',
label: 'Filter by YOE category',
});
}}
/>
))}
</DropdownMenu>
<div className="divide-x-slate-200 col-span-3 flex items-center justify-end space-x-4 divide-x">
<div className="justify-left flex items-center space-x-2 font-medium text-slate-700">
<span className="sr-only sm:not-sr-only sm:inline">
Display offers in
</span>
<CurrencySelector
handleCurrencyChange={(value: string) => setCurrency(value)}
selectedCurrency={currency}
/>
</div>
</div>
</div>
);
}
function renderHeader() {
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) => (
<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>
);
}
const handlePageChange = (currPage: number) => {
if (0 <= currPage && currPage < pagination.numOfPages) {
setPagination({ ...pagination, currentPage: currPage });
}
};
return (
<div className="relative w-full divide-y divide-slate-200 border border-slate-200 bg-white">
<div ref={topRef}>{renderFilters()}</div>
<OffersTablePagination
endNumber={
pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + offers.length
}
handlePageChange={handlePageChange}
isInitialFetch={isLoading}
isLoading={isResultsLoading}
pagination={pagination}
startNumber={pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + 1}
/>
<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>
{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
}
handlePageChange={(number) => {
topRef?.current?.scrollIntoView({
block: 'start',
});
handlePageChange(number);
}}
isInitialFetch={isLoading}
isLoading={isResultsLoading}
pagination={pagination}
startNumber={pagination.currentPage * NUMBER_OF_OFFERS_PER_PAGE + 1}
/>
</div>
);
}