[offers][feat] add query params to offer table (#502)

pull/503/head
Zhang Ziqing 2 years ago committed by GitHub
parent 70006d4115
commit bd73a0b7b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,14 +1,14 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, 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 OffersTablePagination from '~/components/offers/table/OffersTablePagination'; import OffersTablePagination from '~/components/offers/table/OffersTablePagination';
import type { OfferTableSortByType } from '~/components/offers/table/types';
import { import {
OfferTableFilterOptions, OfferTableFilterOptions,
OfferTableSortBy,
OfferTableYoeOptions, OfferTableYoeOptions,
YOE_CATEGORY, YOE_CATEGORY,
YOE_CATEGORY_PARAM, YOE_CATEGORY_PARAM,
@ -16,6 +16,7 @@ import {
import { Currency } from '~/utils/offers/currency/CurrencyEnum'; import { Currency } from '~/utils/offers/currency/CurrencyEnum';
import CurrencySelector from '~/utils/offers/currency/CurrencySelector'; import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import OffersRow from './OffersRow'; import OffersRow from './OffersRow';
@ -25,16 +26,17 @@ import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
const NUMBER_OF_OFFERS_IN_PAGE = 10; const NUMBER_OF_OFFERS_IN_PAGE = 10;
export type OffersTableProps = Readonly<{ export type OffersTableProps = Readonly<{
companyFilter: string; companyFilter: string;
companyName?: string;
countryFilter: string; countryFilter: string;
jobTitleFilter: string; jobTitleFilter: string;
}>; }>;
export default function OffersTable({ export default function OffersTable({
countryFilter, countryFilter,
companyName,
companyFilter, companyFilter,
jobTitleFilter, jobTitleFilter,
}: OffersTableProps) { }: OffersTableProps) {
const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location
const [selectedYoe, setSelectedYoe] = useState('');
const [jobType, setJobType] = useState<JobType>(JobType.FULLTIME); const [jobType, setJobType] = useState<JobType>(JobType.FULLTIME);
const [pagination, setPagination] = useState<Paging>({ const [pagination, setPagination] = useState<Paging>({
currentPage: 0, currentPage: 0,
@ -42,29 +44,62 @@ export default function OffersTable({
numOfPages: 0, numOfPages: 0,
totalItems: 0, totalItems: 0,
}); });
const [offers, setOffers] = useState<Array<DashboardOffer>>([]); const [offers, setOffers] = useState<Array<DashboardOffer>>([]);
const [selectedFilter, setSelectedFilter] = useState(
OfferTableFilterOptions[0].value,
);
const { event: gaEvent } = useGoogleAnalytics(); const { event: gaEvent } = useGoogleAnalytics();
const router = useRouter(); const router = useRouter();
const { yoeCategory = '' } = router.query;
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { const [
setPagination({ selectedYoeCategory,
currentPage: 0, setSelectedYoeCategory,
numOfItems: 0, isYoeCategoryInitialized,
numOfPages: 0, ] = useSearchParamSingle<keyof typeof YOE_CATEGORY_PARAM>('yoeCategory');
totalItems: 0,
}); const [selectedSortBy, setSelectedSortBy, isSortByInitialized] =
setIsLoading(true); useSearchParamSingle<OfferTableSortByType>('sortBy');
}, [selectedYoe, currency, countryFilter, companyFilter, jobTitleFilter]);
const areFilterParamsInitialized = useMemo(() => {
return isYoeCategoryInitialized && isSortByInitialized;
}, [isYoeCategoryInitialized, isSortByInitialized]);
const { pathname } = router;
useEffect(() => { useEffect(() => {
setSelectedYoe(yoeCategory as YOE_CATEGORY); if (areFilterParamsInitialized) {
event?.preventDefault(); router.replace(
}, [yoeCategory]); {
pathname,
query: {
companyId: companyFilter,
companyName,
jobTitleId: jobTitleFilter,
sortBy: selectedSortBy,
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,
selectedSortBy,
selectedYoeCategory,
pathname,
]);
const { showToast } = useToast(); const { showToast } = useToast();
trpc.useQuery( trpc.useQuery(
@ -76,9 +111,11 @@ export default function OffersTable({
currency, currency,
limit: NUMBER_OF_OFFERS_IN_PAGE, limit: NUMBER_OF_OFFERS_IN_PAGE,
offset: pagination.currentPage, offset: pagination.currentPage,
sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived', sortBy: selectedSortBy ?? '-monthYearReceived',
title: jobTitleFilter, title: jobTitleFilter,
yoeCategory: YOE_CATEGORY_PARAM[yoeCategory as string] ?? undefined, yoeCategory: selectedYoeCategory
? YOE_CATEGORY_PARAM[selectedYoeCategory as string]
: undefined,
}, },
], ],
{ {
@ -104,39 +141,21 @@ export default function OffersTable({
align="start" align="start"
label={ label={
OfferTableYoeOptions.filter( OfferTableYoeOptions.filter(
({ value: itemValue }) => itemValue === selectedYoe, ({ value: itemValue }) => itemValue === selectedYoeCategory,
)[0].label ).length > 0
? OfferTableYoeOptions.filter(
({ value: itemValue }) => itemValue === selectedYoeCategory,
)[0].label
: OfferTableYoeOptions[0].label
} }
size="inherit"> size="inherit">
{OfferTableYoeOptions.map(({ label: itemLabel, value }) => ( {OfferTableYoeOptions.map(({ label: itemLabel, value }) => (
<DropdownMenu.Item <DropdownMenu.Item
key={value} key={value}
isSelected={value === selectedYoe} isSelected={value === selectedYoeCategory}
label={itemLabel} label={itemLabel}
onClick={() => { onClick={() => {
if (value === '') { setSelectedYoeCategory(value);
router.replace(
{
pathname: router.pathname,
query: undefined,
},
undefined,
// Do not refresh the page
{ shallow: true },
);
} else {
const params = new URLSearchParams({
['yoeCategory']: value,
});
router.replace(
{
pathname: location.pathname,
search: params.toString(),
},
undefined,
{ shallow: true },
);
}
gaEvent({ gaEvent({
action: `offers.table_filter_yoe_category_${value}`, action: `offers.table_filter_yoe_category_${value}`,
category: 'engagement', category: 'engagement',
@ -161,17 +180,21 @@ export default function OffersTable({
align="end" align="end"
label={ label={
OfferTableFilterOptions.filter( OfferTableFilterOptions.filter(
({ value: itemValue }) => itemValue === selectedFilter, ({ value: itemValue }) => itemValue === selectedSortBy,
)[0].label ).length > 0
? OfferTableFilterOptions.filter(
({ value: itemValue }) => itemValue === selectedSortBy,
)[0].label
: OfferTableFilterOptions[0].label
} }
size="inherit"> size="inherit">
{OfferTableFilterOptions.map(({ label: itemLabel, value }) => ( {OfferTableFilterOptions.map(({ label: itemLabel, value }) => (
<DropdownMenu.Item <DropdownMenu.Item
key={value} key={value}
isSelected={value === selectedFilter} isSelected={value === selectedSortBy}
label={itemLabel} label={itemLabel}
onClick={() => { onClick={() => {
setSelectedFilter(value); setSelectedSortBy(value as OfferTableSortByType);
}} }}
/> />
))} ))}
@ -187,7 +210,9 @@ export default function OffersTable({
'Company', 'Company',
'Title', 'Title',
'YOE', 'YOE',
selectedYoe === YOE_CATEGORY.INTERN ? 'Monthly Salary' : 'Annual TC', selectedYoeCategory === YOE_CATEGORY.INTERN
? 'Monthly Salary'
: 'Annual TC',
'Date Offered', 'Date Offered',
'Actions', 'Actions',
]; ];

@ -36,25 +36,24 @@ export const OfferTableYoeOptions = [
export const OfferTableFilterOptions = [ export const OfferTableFilterOptions = [
{ {
label: 'Latest Submitted', label: 'Latest Submitted',
value: 'latest-submitted', value: '-monthYearReceived',
}, },
{ {
label: 'Highest Salary', label: 'Highest Salary',
value: 'highest-salary', value: '-totalCompensation',
}, },
{ {
label: 'Highest YOE first', label: 'Highest YOE first',
value: 'highest-yoe-first', value: '-totalYoe',
}, },
{ {
label: 'Lowest YOE first', label: 'Lowest YOE first',
value: 'lowest-yoe-first', value: '+totalYoe',
}, },
]; ];
export const OfferTableSortBy: Record<string, string> = { export type OfferTableSortByType =
'highest-salary': '-totalCompensation', | '-monthYearReceived'
'highest-yoe-first': '-totalYoe', | '-totalCompensation'
'latest-submitted': '-monthYearReceived', | '-totalYoe'
'lowest-yoe-first': '+totalYoe', | '+totalYoe';
};

@ -9,14 +9,23 @@ import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import Container from '~/components/shared/Container'; import Container from '~/components/shared/Container';
import CountriesTypeahead from '~/components/shared/CountriesTypeahead'; import CountriesTypeahead from '~/components/shared/CountriesTypeahead';
import type { JobTitleType } from '~/components/shared/JobTitles'; import type { JobTitleType } from '~/components/shared/JobTitles';
import { JobTitleLabels } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead'; import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
export default function OffersHomePage() { export default function OffersHomePage() {
const [jobTitleFilter, setJobTitleFilter] = useState<JobTitleType | ''>('');
const [companyFilter, setCompanyFilter] = useState('');
const [countryFilter, setCountryFilter] = useState(''); const [countryFilter, setCountryFilter] = useState('');
const { event: gaEvent } = useGoogleAnalytics(); const { event: gaEvent } = useGoogleAnalytics();
const [selectedCompanyName, setSelectedCompanyName] =
useSearchParamSingle('companyName');
const [selectedCompanyId, setSelectedCompanyId] =
useSearchParamSingle('companyId');
const [selectedJobTitleId, setSelectedJobTitleId] =
useSearchParamSingle<JobTitleType | null>('jobTitleId');
return ( return (
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
<Banner size="sm"> <Banner size="sm">
@ -66,16 +75,25 @@ export default function OffersHomePage() {
isLabelHidden={true} isLabelHidden={true}
placeholder="All Job Titles" placeholder="All Job Titles"
textSize="inherit" textSize="inherit"
value={
selectedJobTitleId
? {
id: selectedJobTitleId,
label: JobTitleLabels[selectedJobTitleId as JobTitleType],
value: selectedJobTitleId,
}
: null
}
onSelect={(option) => { onSelect={(option) => {
if (option) { if (option) {
setJobTitleFilter(option.value as JobTitleType); setSelectedJobTitleId(option.id as JobTitleType);
gaEvent({ gaEvent({
action: `offers.table_filter_job_title_${option.value}`, action: `offers.table_filter_job_title_${option.value}`,
category: 'engagement', category: 'engagement',
label: 'Filter by job title', label: 'Filter by job title',
}); });
} else { } else {
setJobTitleFilter(''); setSelectedJobTitleId(null);
} }
}} }}
/> />
@ -84,16 +102,27 @@ export default function OffersHomePage() {
isLabelHidden={true} isLabelHidden={true}
placeholder="All Companies" placeholder="All Companies"
textSize="inherit" textSize="inherit"
value={
selectedCompanyName
? {
id: selectedCompanyId,
label: selectedCompanyName,
value: selectedCompanyId,
}
: null
}
onSelect={(option) => { onSelect={(option) => {
if (option) { if (option) {
setCompanyFilter(option.value); setSelectedCompanyId(option.id);
setSelectedCompanyName(option.label);
gaEvent({ gaEvent({
action: `offers.table_filter_company_${option.value}`, action: `offers.table_filter_company_${option.value}`,
category: 'engagement', category: 'engagement',
label: 'Filter by company', label: 'Filter by company',
}); });
} else { } else {
setCompanyFilter(''); setSelectedCompanyId('');
setSelectedCompanyName('');
} }
}} }}
/> />
@ -102,9 +131,10 @@ export default function OffersHomePage() {
</div> </div>
<Container className="pb-20 pt-10"> <Container className="pb-20 pt-10">
<OffersTable <OffersTable
companyFilter={companyFilter} companyFilter={selectedCompanyId}
companyName={selectedCompanyName}
countryFilter={countryFilter} countryFilter={countryFilter}
jobTitleFilter={jobTitleFilter} jobTitleFilter={selectedJobTitleId ?? ''}
/> />
</Container> </Container>
</main> </main>

@ -0,0 +1,79 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
type SearchParamOptions<Value> = [Value] extends [string]
? {
defaultValues?: Array<Value>;
paramToString?: (value: Value) => string | null;
stringToParam?: (param: string) => Value | null;
}
: {
defaultValues?: Array<Value>;
paramToString: (value: Value) => string | null;
stringToParam: (param: string) => Value | null;
};
export const useSearchParam = <Value = string>(
name: string,
opts?: SearchParamOptions<Value>,
) => {
const {
defaultValues,
stringToParam = (param: string) => param,
paramToString: valueToQueryParam = (value: Value) => String(value),
} = opts ?? {};
const [isInitialized, setIsInitialized] = useState(false);
const router = useRouter();
const [params, setParams] = useState<Array<Value>>(defaultValues || []);
useEffect(() => {
if (router.isReady && !isInitialized) {
// Initialize from query params
const query = router.query[name];
if (query) {
const queryValues = Array.isArray(query) ? query : [query];
setParams(
queryValues
.map(stringToParam)
.filter((value) => value !== null) as Array<Value>,
);
}
setIsInitialized(true);
}
}, [isInitialized, name, stringToParam, router]);
const setParamsCallback = useCallback(
(newParams: Array<Value>) => {
setParams(newParams);
localStorage.setItem(
name,
JSON.stringify(
newParams.map(valueToQueryParam).filter((param) => param !== null),
),
);
},
[name, valueToQueryParam],
);
return [params, setParamsCallback, isInitialized] as const;
};
export const useSearchParamSingle = <Value = string>(
name: string,
opts?: Omit<SearchParamOptions<Value>, 'defaultValues'> & {
defaultValue?: Value;
},
) => {
const { defaultValue, ...restOpts } = opts ?? {};
const [params, setParams, isInitialized] = useSearchParam<Value>(name, {
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
...restOpts,
} as SearchParamOptions<Value>);
return [
params[0],
(value: Value) => setParams([value]),
isInitialized,
] as const;
};
Loading…
Cancel
Save