mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2026-02-03 02:24:47 +08:00
[offers][feat] add query params to offer table (#502)
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { JobType } from '@prisma/client';
|
||||
import { DropdownMenu, Spinner, useToast } from '@tih/ui';
|
||||
|
||||
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
|
||||
import OffersTablePagination from '~/components/offers/table/OffersTablePagination';
|
||||
import type { OfferTableSortByType } from '~/components/offers/table/types';
|
||||
import {
|
||||
OfferTableFilterOptions,
|
||||
OfferTableSortBy,
|
||||
OfferTableYoeOptions,
|
||||
YOE_CATEGORY,
|
||||
YOE_CATEGORY_PARAM,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
|
||||
import { Currency } from '~/utils/offers/currency/CurrencyEnum';
|
||||
import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
|
||||
import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
import OffersRow from './OffersRow';
|
||||
@@ -25,16 +26,17 @@ import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
|
||||
const NUMBER_OF_OFFERS_IN_PAGE = 10;
|
||||
export type OffersTableProps = Readonly<{
|
||||
companyFilter: string;
|
||||
companyName?: string;
|
||||
countryFilter: string;
|
||||
jobTitleFilter: string;
|
||||
}>;
|
||||
export default function OffersTable({
|
||||
countryFilter,
|
||||
companyName,
|
||||
companyFilter,
|
||||
jobTitleFilter,
|
||||
}: OffersTableProps) {
|
||||
const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location
|
||||
const [selectedYoe, setSelectedYoe] = useState('');
|
||||
const [jobType, setJobType] = useState<JobType>(JobType.FULLTIME);
|
||||
const [pagination, setPagination] = useState<Paging>({
|
||||
currentPage: 0,
|
||||
@@ -42,29 +44,62 @@ export default function OffersTable({
|
||||
numOfPages: 0,
|
||||
totalItems: 0,
|
||||
});
|
||||
|
||||
const [offers, setOffers] = useState<Array<DashboardOffer>>([]);
|
||||
const [selectedFilter, setSelectedFilter] = useState(
|
||||
OfferTableFilterOptions[0].value,
|
||||
);
|
||||
|
||||
const { event: gaEvent } = useGoogleAnalytics();
|
||||
const router = useRouter();
|
||||
const { yoeCategory = '' } = router.query;
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination({
|
||||
currentPage: 0,
|
||||
numOfItems: 0,
|
||||
numOfPages: 0,
|
||||
totalItems: 0,
|
||||
});
|
||||
setIsLoading(true);
|
||||
}, [selectedYoe, currency, countryFilter, companyFilter, jobTitleFilter]);
|
||||
const [
|
||||
selectedYoeCategory,
|
||||
setSelectedYoeCategory,
|
||||
isYoeCategoryInitialized,
|
||||
] = useSearchParamSingle<keyof typeof YOE_CATEGORY_PARAM>('yoeCategory');
|
||||
|
||||
const [selectedSortBy, setSelectedSortBy, isSortByInitialized] =
|
||||
useSearchParamSingle<OfferTableSortByType>('sortBy');
|
||||
|
||||
const areFilterParamsInitialized = useMemo(() => {
|
||||
return isYoeCategoryInitialized && isSortByInitialized;
|
||||
}, [isYoeCategoryInitialized, isSortByInitialized]);
|
||||
const { pathname } = router;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedYoe(yoeCategory as YOE_CATEGORY);
|
||||
event?.preventDefault();
|
||||
}, [yoeCategory]);
|
||||
if (areFilterParamsInitialized) {
|
||||
router.replace(
|
||||
{
|
||||
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();
|
||||
trpc.useQuery(
|
||||
@@ -76,9 +111,11 @@ export default function OffersTable({
|
||||
currency,
|
||||
limit: NUMBER_OF_OFFERS_IN_PAGE,
|
||||
offset: pagination.currentPage,
|
||||
sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived',
|
||||
sortBy: selectedSortBy ?? '-monthYearReceived',
|
||||
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"
|
||||
label={
|
||||
OfferTableYoeOptions.filter(
|
||||
({ value: itemValue }) => itemValue === selectedYoe,
|
||||
)[0].label
|
||||
({ value: itemValue }) => itemValue === selectedYoeCategory,
|
||||
).length > 0
|
||||
? OfferTableYoeOptions.filter(
|
||||
({ value: itemValue }) => itemValue === selectedYoeCategory,
|
||||
)[0].label
|
||||
: OfferTableYoeOptions[0].label
|
||||
}
|
||||
size="inherit">
|
||||
{OfferTableYoeOptions.map(({ label: itemLabel, value }) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
isSelected={value === selectedYoe}
|
||||
isSelected={value === selectedYoeCategory}
|
||||
label={itemLabel}
|
||||
onClick={() => {
|
||||
if (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 },
|
||||
);
|
||||
}
|
||||
setSelectedYoeCategory(value);
|
||||
gaEvent({
|
||||
action: `offers.table_filter_yoe_category_${value}`,
|
||||
category: 'engagement',
|
||||
@@ -161,17 +180,21 @@ export default function OffersTable({
|
||||
align="end"
|
||||
label={
|
||||
OfferTableFilterOptions.filter(
|
||||
({ value: itemValue }) => itemValue === selectedFilter,
|
||||
)[0].label
|
||||
({ 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 === selectedFilter}
|
||||
isSelected={value === selectedSortBy}
|
||||
label={itemLabel}
|
||||
onClick={() => {
|
||||
setSelectedFilter(value);
|
||||
setSelectedSortBy(value as OfferTableSortByType);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -187,7 +210,9 @@ export default function OffersTable({
|
||||
'Company',
|
||||
'Title',
|
||||
'YOE',
|
||||
selectedYoe === YOE_CATEGORY.INTERN ? 'Monthly Salary' : 'Annual TC',
|
||||
selectedYoeCategory === YOE_CATEGORY.INTERN
|
||||
? 'Monthly Salary'
|
||||
: 'Annual TC',
|
||||
'Date Offered',
|
||||
'Actions',
|
||||
];
|
||||
|
||||
@@ -36,25 +36,24 @@ export const OfferTableYoeOptions = [
|
||||
export const OfferTableFilterOptions = [
|
||||
{
|
||||
label: 'Latest Submitted',
|
||||
value: 'latest-submitted',
|
||||
value: '-monthYearReceived',
|
||||
},
|
||||
{
|
||||
label: 'Highest Salary',
|
||||
value: 'highest-salary',
|
||||
value: '-totalCompensation',
|
||||
},
|
||||
{
|
||||
label: 'Highest YOE first',
|
||||
value: 'highest-yoe-first',
|
||||
value: '-totalYoe',
|
||||
},
|
||||
{
|
||||
label: 'Lowest YOE first',
|
||||
value: 'lowest-yoe-first',
|
||||
value: '+totalYoe',
|
||||
},
|
||||
];
|
||||
|
||||
export const OfferTableSortBy: Record<string, string> = {
|
||||
'highest-salary': '-totalCompensation',
|
||||
'highest-yoe-first': '-totalYoe',
|
||||
'latest-submitted': '-monthYearReceived',
|
||||
'lowest-yoe-first': '+totalYoe',
|
||||
};
|
||||
export type OfferTableSortByType =
|
||||
| '-monthYearReceived'
|
||||
| '-totalCompensation'
|
||||
| '-totalYoe'
|
||||
| '+totalYoe';
|
||||
|
||||
@@ -9,14 +9,23 @@ import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
|
||||
import Container from '~/components/shared/Container';
|
||||
import CountriesTypeahead from '~/components/shared/CountriesTypeahead';
|
||||
import type { JobTitleType } from '~/components/shared/JobTitles';
|
||||
import { JobTitleLabels } from '~/components/shared/JobTitles';
|
||||
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
|
||||
|
||||
import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
|
||||
|
||||
export default function OffersHomePage() {
|
||||
const [jobTitleFilter, setJobTitleFilter] = useState<JobTitleType | ''>('');
|
||||
const [companyFilter, setCompanyFilter] = useState('');
|
||||
const [countryFilter, setCountryFilter] = useState('');
|
||||
const { event: gaEvent } = useGoogleAnalytics();
|
||||
|
||||
const [selectedCompanyName, setSelectedCompanyName] =
|
||||
useSearchParamSingle('companyName');
|
||||
const [selectedCompanyId, setSelectedCompanyId] =
|
||||
useSearchParamSingle('companyId');
|
||||
|
||||
const [selectedJobTitleId, setSelectedJobTitleId] =
|
||||
useSearchParamSingle<JobTitleType | null>('jobTitleId');
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Banner size="sm">
|
||||
@@ -66,16 +75,25 @@ export default function OffersHomePage() {
|
||||
isLabelHidden={true}
|
||||
placeholder="All Job Titles"
|
||||
textSize="inherit"
|
||||
value={
|
||||
selectedJobTitleId
|
||||
? {
|
||||
id: selectedJobTitleId,
|
||||
label: JobTitleLabels[selectedJobTitleId as JobTitleType],
|
||||
value: selectedJobTitleId,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onSelect={(option) => {
|
||||
if (option) {
|
||||
setJobTitleFilter(option.value as JobTitleType);
|
||||
setSelectedJobTitleId(option.id as JobTitleType);
|
||||
gaEvent({
|
||||
action: `offers.table_filter_job_title_${option.value}`,
|
||||
category: 'engagement',
|
||||
label: 'Filter by job title',
|
||||
});
|
||||
} else {
|
||||
setJobTitleFilter('');
|
||||
setSelectedJobTitleId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -84,16 +102,27 @@ export default function OffersHomePage() {
|
||||
isLabelHidden={true}
|
||||
placeholder="All Companies"
|
||||
textSize="inherit"
|
||||
value={
|
||||
selectedCompanyName
|
||||
? {
|
||||
id: selectedCompanyId,
|
||||
label: selectedCompanyName,
|
||||
value: selectedCompanyId,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onSelect={(option) => {
|
||||
if (option) {
|
||||
setCompanyFilter(option.value);
|
||||
setSelectedCompanyId(option.id);
|
||||
setSelectedCompanyName(option.label);
|
||||
gaEvent({
|
||||
action: `offers.table_filter_company_${option.value}`,
|
||||
category: 'engagement',
|
||||
label: 'Filter by company',
|
||||
});
|
||||
} else {
|
||||
setCompanyFilter('');
|
||||
setSelectedCompanyId('');
|
||||
setSelectedCompanyName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -102,9 +131,10 @@ export default function OffersHomePage() {
|
||||
</div>
|
||||
<Container className="pb-20 pt-10">
|
||||
<OffersTable
|
||||
companyFilter={companyFilter}
|
||||
companyFilter={selectedCompanyId}
|
||||
companyName={selectedCompanyName}
|
||||
countryFilter={countryFilter}
|
||||
jobTitleFilter={jobTitleFilter}
|
||||
jobTitleFilter={selectedJobTitleId ?? ''}
|
||||
/>
|
||||
</Container>
|
||||
</main>
|
||||
|
||||
79
apps/portal/src/utils/offers/useSearchParam.ts
Normal file
79
apps/portal/src/utils/offers/useSearchParam.ts
Normal file
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user