mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-28 04:33:42 +08:00
[resumes][feat] url search params (#429)
* [resumes][feat] adapt useSearchParams * [resumes][feat] clickable button from review info tags
This commit is contained in:
@ -21,9 +21,25 @@ import ResumeCommentsList from '~/components/resumes/comments/ResumeCommentsList
|
|||||||
import ResumePdf from '~/components/resumes/ResumePdf';
|
import ResumePdf from '~/components/resumes/ResumePdf';
|
||||||
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
|
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FilterOption,
|
||||||
|
LocationFilter,
|
||||||
|
} from '~/utils/resumes/resumeFilters';
|
||||||
|
import {
|
||||||
|
BROWSE_TABS_VALUES,
|
||||||
|
EXPERIENCES,
|
||||||
|
INITIAL_FILTER_STATE,
|
||||||
|
LOCATIONS,
|
||||||
|
ROLES,
|
||||||
|
SORT_OPTIONS,
|
||||||
|
} from '~/utils/resumes/resumeFilters';
|
||||||
import { trpc } from '~/utils/trpc';
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
import SubmitResumeForm from './submit';
|
import SubmitResumeForm from './submit';
|
||||||
|
import type {
|
||||||
|
ExperienceFilter,
|
||||||
|
RoleFilter,
|
||||||
|
} from '../../utils/resumes/resumeFilters';
|
||||||
|
|
||||||
export default function ResumeReviewPage() {
|
export default function ResumeReviewPage() {
|
||||||
const ErrorPage = (
|
const ErrorPage = (
|
||||||
@ -57,7 +73,8 @@ export default function ResumeReviewPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const userIsOwner =
|
const userIsOwner =
|
||||||
session?.user?.id != null && session.user.id === detailsQuery.data?.userId;
|
session?.user?.id !== undefined &&
|
||||||
|
session.user.id === detailsQuery.data?.userId;
|
||||||
|
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [showCommentsForm, setShowCommentsForm] = useState(false);
|
const [showCommentsForm, setShowCommentsForm] = useState(false);
|
||||||
@ -79,6 +96,46 @@ export default function ResumeReviewPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onInfoTagClick = ({
|
||||||
|
locationLabel,
|
||||||
|
experienceLabel,
|
||||||
|
roleLabel,
|
||||||
|
}: {
|
||||||
|
experienceLabel?: string;
|
||||||
|
locationLabel?: string;
|
||||||
|
roleLabel?: string;
|
||||||
|
}) => {
|
||||||
|
const getFilterValue = (
|
||||||
|
label: string,
|
||||||
|
filterOptions: Array<
|
||||||
|
FilterOption<ExperienceFilter | LocationFilter | RoleFilter>
|
||||||
|
>,
|
||||||
|
) => filterOptions.find((option) => option.label === label)?.value;
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
pathname: '/resumes/browse',
|
||||||
|
query: {
|
||||||
|
currentPage: JSON.stringify(1),
|
||||||
|
searchValue: JSON.stringify(''),
|
||||||
|
shortcutSelected: JSON.stringify('all'),
|
||||||
|
sortOrder: JSON.stringify(SORT_OPTIONS.LATEST),
|
||||||
|
tabsValue: JSON.stringify(BROWSE_TABS_VALUES.ALL),
|
||||||
|
userFilters: JSON.stringify({
|
||||||
|
...INITIAL_FILTER_STATE,
|
||||||
|
...(locationLabel && {
|
||||||
|
location: [getFilterValue(locationLabel, LOCATIONS)],
|
||||||
|
}),
|
||||||
|
...(roleLabel && {
|
||||||
|
role: [getFilterValue(roleLabel, ROLES)],
|
||||||
|
}),
|
||||||
|
...(experienceLabel && {
|
||||||
|
experience: [getFilterValue(experienceLabel, EXPERIENCES)],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onEditButtonClick = () => {
|
const onEditButtonClick = () => {
|
||||||
setIsEditMode(true);
|
setIsEditMode(true);
|
||||||
};
|
};
|
||||||
@ -199,21 +256,48 @@ export default function ResumeReviewPage() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
|
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
|
||||||
/>
|
/>
|
||||||
{detailsQuery.data.role}
|
<button
|
||||||
|
className="hover:text-primary-800 underline"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onInfoTagClick({
|
||||||
|
roleLabel: detailsQuery.data?.role,
|
||||||
|
})
|
||||||
|
}>
|
||||||
|
{detailsQuery.data.role}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
|
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
|
||||||
<MapPinIcon
|
<MapPinIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
|
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
|
||||||
/>
|
/>
|
||||||
{detailsQuery.data.location}
|
<button
|
||||||
|
className="hover:text-primary-800 underline"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onInfoTagClick({
|
||||||
|
locationLabel: detailsQuery.data?.location,
|
||||||
|
})
|
||||||
|
}>
|
||||||
|
{detailsQuery.data.location}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
|
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
|
||||||
<AcademicCapIcon
|
<AcademicCapIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
|
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
|
||||||
/>
|
/>
|
||||||
{detailsQuery.data.experience}
|
<button
|
||||||
|
className="hover:text-primary-800 underline"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onInfoTagClick({
|
||||||
|
experienceLabel: detailsQuery.data?.experience,
|
||||||
|
})
|
||||||
|
}>
|
||||||
|
{detailsQuery.data.experience}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
|
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
|
||||||
<CalendarIcon
|
<CalendarIcon
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useRouter } from 'next/router';
|
import Router, { useRouter } from 'next/router';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { Fragment, useEffect, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||||
import { Dialog, Disclosure, Transition } from '@headlessui/react';
|
import { Dialog, Disclosure, Transition } from '@headlessui/react';
|
||||||
import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
|
import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
|
||||||
import {
|
import {
|
||||||
@ -20,11 +20,10 @@ import {
|
|||||||
} from '@tih/ui';
|
} from '@tih/ui';
|
||||||
|
|
||||||
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
|
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
|
||||||
import type {
|
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
|
||||||
Filter,
|
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
|
||||||
FilterId,
|
|
||||||
Shortcut,
|
import type { Filter, FilterId, Shortcut } from '~/utils/resumes/resumeFilters';
|
||||||
} from '~/components/resumes/browse/resumeFilters';
|
|
||||||
import {
|
import {
|
||||||
BROWSE_TABS_VALUES,
|
BROWSE_TABS_VALUES,
|
||||||
EXPERIENCES,
|
EXPERIENCES,
|
||||||
@ -34,14 +33,12 @@ import {
|
|||||||
ROLES,
|
ROLES,
|
||||||
SHORTCUTS,
|
SHORTCUTS,
|
||||||
SORT_OPTIONS,
|
SORT_OPTIONS,
|
||||||
} from '~/components/resumes/browse/resumeFilters';
|
} from '~/utils/resumes/resumeFilters';
|
||||||
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
|
|
||||||
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
|
|
||||||
|
|
||||||
import useDebounceValue from '~/utils/resumes/useDebounceValue';
|
import useDebounceValue from '~/utils/resumes/useDebounceValue';
|
||||||
|
import useSearchParams from '~/utils/resumes/useSearchParams';
|
||||||
import { trpc } from '~/utils/trpc';
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
import type { FilterState } from '../../components/resumes/browse/resumeFilters';
|
import type { FilterState } from '../../utils/resumes/resumeFilters';
|
||||||
|
|
||||||
const STALE_TIME = 5 * 60 * 1000;
|
const STALE_TIME = 5 * 60 * 1000;
|
||||||
const DEBOUNCE_DELAY = 800;
|
const DEBOUNCE_DELAY = 800;
|
||||||
@ -101,19 +98,82 @@ const getEmptyDataText = (
|
|||||||
export default function ResumeHomePage() {
|
export default function ResumeHomePage() {
|
||||||
const { data: sessionData } = useSession();
|
const { data: sessionData } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL);
|
const [tabsValue, setTabsValue, isTabsValueInit] = useSearchParams(
|
||||||
const [sortOrder, setSortOrder] = useState('latest');
|
'tabsValue',
|
||||||
const [searchValue, setSearchValue] = useState('');
|
BROWSE_TABS_VALUES.ALL,
|
||||||
const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE);
|
);
|
||||||
const [shortcutSelected, setShortcutSelected] = useState('All');
|
const [sortOrder, setSortOrder, isSortOrderInit] = useSearchParams(
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
'sortOrder',
|
||||||
|
SORT_OPTIONS.LATEST,
|
||||||
|
);
|
||||||
|
const [searchValue, setSearchValue, isSearchValueInit] = useSearchParams(
|
||||||
|
'searchValue',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const [shortcutSelected, setShortcutSelected, isShortcutInit] =
|
||||||
|
useSearchParams('shortcutSelected', 'All');
|
||||||
|
const [currentPage, setCurrentPage, isCurrentPageInit] = useSearchParams(
|
||||||
|
'currentPage',
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const [userFilters, setUserFilters, isUserFiltersInit] = useSearchParams(
|
||||||
|
'userFilters',
|
||||||
|
INITIAL_FILTER_STATE,
|
||||||
|
);
|
||||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||||
|
|
||||||
const skip = (currentPage - 1) * PAGE_LIMIT;
|
const skip = (currentPage - 1) * PAGE_LIMIT;
|
||||||
|
const isSearchOptionsInit = useMemo(() => {
|
||||||
|
return (
|
||||||
|
isTabsValueInit &&
|
||||||
|
isSortOrderInit &&
|
||||||
|
isSearchValueInit &&
|
||||||
|
isShortcutInit &&
|
||||||
|
isCurrentPageInit &&
|
||||||
|
isUserFiltersInit
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
isTabsValueInit,
|
||||||
|
isSortOrderInit,
|
||||||
|
isSearchValueInit,
|
||||||
|
isShortcutInit,
|
||||||
|
isCurrentPageInit,
|
||||||
|
isUserFiltersInit,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [userFilters, sortOrder, searchValue]);
|
}, [userFilters, sortOrder, setCurrentPage, searchValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Router.replace used instead of router.replace to avoid
|
||||||
|
// the page reloading itself since the router.replace
|
||||||
|
// callback changes on every page load
|
||||||
|
if (!isSearchOptionsInit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Router.replace({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: {
|
||||||
|
currentPage: JSON.stringify(currentPage),
|
||||||
|
searchValue: JSON.stringify(searchValue),
|
||||||
|
shortcutSelected: JSON.stringify(shortcutSelected),
|
||||||
|
sortOrder: JSON.stringify(sortOrder),
|
||||||
|
tabsValue: JSON.stringify(tabsValue),
|
||||||
|
userFilters: JSON.stringify(userFilters),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
tabsValue,
|
||||||
|
sortOrder,
|
||||||
|
searchValue,
|
||||||
|
userFilters,
|
||||||
|
shortcutSelected,
|
||||||
|
currentPage,
|
||||||
|
router.pathname,
|
||||||
|
isSearchOptionsInit,
|
||||||
|
]);
|
||||||
|
|
||||||
const allResumesQuery = trpc.useQuery(
|
const allResumesQuery = trpc.useQuery(
|
||||||
[
|
[
|
||||||
@ -509,7 +569,7 @@ export default function ResumeHomePage() {
|
|||||||
key={key}
|
key={key}
|
||||||
isSelected={sortOrder === key}
|
isSelected={sortOrder === key}
|
||||||
label={value}
|
label={value}
|
||||||
onClick={() => setSortOrder(key)}></DropdownMenu.Item>
|
onClick={() => setSortOrder(value)}></DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,14 +19,10 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from '@tih/ui';
|
} from '@tih/ui';
|
||||||
|
|
||||||
import {
|
|
||||||
EXPERIENCES,
|
|
||||||
LOCATIONS,
|
|
||||||
ROLES,
|
|
||||||
} from '~/components/resumes/browse/resumeFilters';
|
|
||||||
import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
|
import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
|
||||||
|
|
||||||
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
|
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
|
||||||
|
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
|
||||||
import { trpc } from '~/utils/trpc';
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
const FILE_SIZE_LIMIT_MB = 3;
|
const FILE_SIZE_LIMIT_MB = 3;
|
||||||
|
@ -4,7 +4,7 @@ export type CustomFilter = {
|
|||||||
numComments: number;
|
numComments: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RoleFilter =
|
export type RoleFilter =
|
||||||
| 'Android Engineer'
|
| 'Android Engineer'
|
||||||
| 'Backend Engineer'
|
| 'Backend Engineer'
|
||||||
| 'DevOps Engineer'
|
| 'DevOps Engineer'
|
||||||
@ -12,7 +12,7 @@ type RoleFilter =
|
|||||||
| 'Full-Stack Engineer'
|
| 'Full-Stack Engineer'
|
||||||
| 'iOS Engineer';
|
| 'iOS Engineer';
|
||||||
|
|
||||||
type ExperienceFilter =
|
export type ExperienceFilter =
|
||||||
| 'Entry Level (0 - 2 years)'
|
| 'Entry Level (0 - 2 years)'
|
||||||
| 'Freshman'
|
| 'Freshman'
|
||||||
| 'Junior'
|
| 'Junior'
|
||||||
@ -21,7 +21,7 @@ type ExperienceFilter =
|
|||||||
| 'Senior'
|
| 'Senior'
|
||||||
| 'Sophomore';
|
| 'Sophomore';
|
||||||
|
|
||||||
type LocationFilter = 'India' | 'Singapore' | 'United States';
|
export type LocationFilter = 'India' | 'Singapore' | 'United States';
|
||||||
|
|
||||||
export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter;
|
export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter;
|
||||||
|
|
||||||
@ -54,10 +54,10 @@ export const BROWSE_TABS_VALUES = {
|
|||||||
STARRED: 'starred',
|
STARRED: 'starred',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SORT_OPTIONS: Record<string, string> = {
|
export const SORT_OPTIONS: Record<string, SortOrder> = {
|
||||||
latest: 'Latest',
|
LATEST: 'latest',
|
||||||
popular: 'Popular',
|
POPULAR: 'popular',
|
||||||
topComments: 'Most Comments',
|
TOPCOMMENTS: 'topComments',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ROLES: Array<FilterOption<RoleFilter>> = [
|
export const ROLES: Array<FilterOption<RoleFilter>> = [
|
26
apps/portal/src/utils/resumes/useSearchParams.ts
Normal file
26
apps/portal/src/utils/resumes/useSearchParams.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export const useSearchParams = <T>(name: string, defaultValue: T) => {
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState(defaultValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (router.isReady && !isInitialized) {
|
||||||
|
// Initialize from url query params
|
||||||
|
const query = router.query[name];
|
||||||
|
if (query) {
|
||||||
|
const parsedQuery =
|
||||||
|
typeof query === 'string' ? JSON.parse(query) : query;
|
||||||
|
setFilters(parsedQuery);
|
||||||
|
}
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
|
}, [isInitialized, name, router]);
|
||||||
|
|
||||||
|
return [filters, setFilters, isInitialized] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSearchParams;
|
Reference in New Issue
Block a user