diff --git a/apps/portal/src/components/offers/table/OffersTable.tsx b/apps/portal/src/components/offers/table/OffersTable.tsx index b530466b..209a9ec8 100644 --- a/apps/portal/src/components/offers/table/OffersTable.tsx +++ b/apps/portal/src/components/offers/table/OffersTable.tsx @@ -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.FULLTIME); const [pagination, setPagination] = useState({ currentPage: 0, @@ -42,29 +44,62 @@ export default function OffersTable({ numOfPages: 0, totalItems: 0, }); + const [offers, setOffers] = useState>([]); - 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('yoeCategory'); + + const [selectedSortBy, setSelectedSortBy, isSortByInitialized] = + useSearchParamSingle('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 }) => ( { - 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 }) => ( { - 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', ]; diff --git a/apps/portal/src/components/offers/table/types.ts b/apps/portal/src/components/offers/table/types.ts index 7fb5ad17..e9a8c42e 100644 --- a/apps/portal/src/components/offers/table/types.ts +++ b/apps/portal/src/components/offers/table/types.ts @@ -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 = { - 'highest-salary': '-totalCompensation', - 'highest-yoe-first': '-totalYoe', - 'latest-submitted': '-monthYearReceived', - 'lowest-yoe-first': '+totalYoe', -}; +export type OfferTableSortByType = + | '-monthYearReceived' + | '-totalCompensation' + | '-totalYoe' + | '+totalYoe'; diff --git a/apps/portal/src/pages/offers/index.tsx b/apps/portal/src/pages/offers/index.tsx index e84173f5..b1773a28 100644 --- a/apps/portal/src/pages/offers/index.tsx +++ b/apps/portal/src/pages/offers/index.tsx @@ -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(''); - 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('jobTitleId'); + return (
@@ -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() {
diff --git a/apps/portal/src/utils/offers/useSearchParam.ts b/apps/portal/src/utils/offers/useSearchParam.ts new file mode 100644 index 00000000..4dc6595e --- /dev/null +++ b/apps/portal/src/utils/offers/useSearchParam.ts @@ -0,0 +1,79 @@ +import { useRouter } from 'next/router'; +import { useCallback, useEffect, useState } from 'react'; + +type SearchParamOptions = [Value] extends [string] + ? { + defaultValues?: Array; + paramToString?: (value: Value) => string | null; + stringToParam?: (param: string) => Value | null; + } + : { + defaultValues?: Array; + paramToString: (value: Value) => string | null; + stringToParam: (param: string) => Value | null; + }; + +export const useSearchParam = ( + name: string, + opts?: SearchParamOptions, +) => { + 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>(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, + ); + } + setIsInitialized(true); + } + }, [isInitialized, name, stringToParam, router]); + + const setParamsCallback = useCallback( + (newParams: Array) => { + 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 = ( + name: string, + opts?: Omit, 'defaultValues'> & { + defaultValue?: Value; + }, +) => { + const { defaultValue, ...restOpts } = opts ?? {}; + const [params, setParams, isInitialized] = useSearchParam(name, { + defaultValues: defaultValue !== undefined ? [defaultValue] : undefined, + ...restOpts, + } as SearchParamOptions); + + return [ + params[0], + (value: Value) => setParams([value]), + isInitialized, + ] as const; +};