[resumes][refactor] use typeahead for browse filters

pull/506/head
Wu Peirong 3 years ago committed by Keane Chan
parent 8c82744929
commit 5dbf2ae6aa
No known key found for this signature in database
GPG Key ID: 32718398E1E9F87C

@ -12,17 +12,7 @@ import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import type {
ExperienceFilter,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import {
EXPERIENCES,
getFilterLabel,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters';
import { getFilterLabel } from '~/utils/resumes/resumeFilters';
import type { Resume } from '~/types/resume';
@ -64,17 +54,14 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{getFilterLabel(ROLES, resumeInfo.role as RoleFilter)}
{getFilterLabel('role', resumeInfo.role)}
</div>
<div className="ml-4 flex">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{getFilterLabel(
EXPERIENCES,
resumeInfo.experience as ExperienceFilter,
)}
{getFilterLabel('experience', resumeInfo.experience)}
</div>
</div>
<div className="mt-4 flex justify-start text-xs text-slate-500">
@ -103,7 +90,7 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
})} by ${resumeInfo.user}`}
</div>
<div className="mt-2 text-slate-400">
{getFilterLabel(LOCATIONS, resumeInfo.location as LocationFilter)}
{getFilterLabel('location', resumeInfo.location)}
</div>
</div>
</div>

@ -0,0 +1,47 @@
import type { ComponentProps } from 'react';
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
import { EXPERIENCES } from '~/utils/resumes/resumeFilters';
type BaseProps = Pick<
ComponentProps<typeof Typeahead>,
| 'disabled'
| 'errorMessage'
| 'isLabelHidden'
| 'placeholder'
| 'required'
| 'textSize'
>;
type Props = BaseProps &
Readonly<{
onSelect: (option: TypeaheadOption | null) => void;
value?: TypeaheadOption | null;
}>;
export default function ResumeExperienceTypeahead({
onSelect,
value,
...props
}: Props) {
const [query, setQuery] = useState('');
const options = EXPERIENCES.filter(
({ label }) =>
label.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) > -1,
);
return (
<Typeahead
label="Experiences"
noResultsMessage="No available experiences."
nullable={true}
options={options}
value={value}
onQueryChange={setQuery}
onSelect={onSelect}
{...props}
/>
);
}

@ -3,7 +3,7 @@ import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
import { JobTitleLabels } from '../../shared/JobTitles';
import { JobTitleLabels } from '~/components/shared/JobTitles';
type BaseProps = Pick<
ComponentProps<typeof Typeahead>,

@ -15,6 +15,7 @@ import {
PencilSquareIcon,
StarIcon,
} from '@heroicons/react/20/solid';
import type { TypeaheadOption } from '@tih/ui';
import { Button, Spinner } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
@ -24,12 +25,6 @@ import ResumePdf from '~/components/resumes/ResumePdf';
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
import loginPageHref from '~/components/shared/loginPageHref';
import type {
ExperienceFilter,
FilterOption,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
@ -134,9 +129,7 @@ export default function ResumeReviewPage() {
}) => {
const getFilterValue = (
label: string,
filterOptions: Array<
FilterOption<ExperienceFilter | LocationFilter | RoleFilter>
>,
filterOptions: Array<TypeaheadOption>,
) => filterOptions.find((option) => option.label === label)?.value;
router.push({
@ -313,76 +306,92 @@ export default function ResumeReviewPage() {
<div className="hidden xl:block">{renderReviewButton()}</div>
</div>
</div>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2 lg:flex lg:flex-wrap lg:space-x-8">
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm">
<BriefcaseIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
roleLabel: detailsQuery.data?.role,
})
}>
{getFilterLabel(
ROLES,
detailsQuery.data.role as RoleFilter,
)}
</button>
</div>
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm">
<MapPinIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
locationLabel: detailsQuery.data?.location,
})
}>
{getFilterLabel(
LOCATIONS,
detailsQuery.data.location as LocationFilter,
)}
</button>
</div>
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
experienceLabel: detailsQuery.data?.experience,
})
}>
{getFilterLabel(
EXPERIENCES,
detailsQuery.data.experience as ExperienceFilter,
)}
</button>
</div>
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm">
<CalendarIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
{`Uploaded ${formatDistanceToNow(
detailsQuery.data.createdAt,
{
addSuffix: true,
},
)} by ${detailsQuery.data.user.name}`}
</div>
<div className="flex flex-col lg:mt-0 lg:flex-row lg:flex-wrap lg:space-x-8">
<div className="mt-2 flex items-center text-sm text-slate-600 xl:mt-1">
<BriefcaseIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
roleLabel: detailsQuery.data?.role,
})
}>
{getFilterLabel('role', detailsQuery.data.role)}
</button>
</div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<MapPinIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
locationLabel: detailsQuery.data?.location,
})
}>
{getFilterLabel('location', detailsQuery.data.location)}
</button>
</div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
experienceLabel: detailsQuery.data?.experience,
})
}>
{getFilterLabel('experience', detailsQuery.data.experience)}
</button>
</div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<CalendarIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
{`Uploaded ${formatDistanceToNow(detailsQuery.data.createdAt, {
addSuffix: true,
})} by ${detailsQuery.data.user.name}`}
</div>
</div>
{detailsQuery.data.additionalInfo && (
<div className="flex items-start whitespace-pre-wrap pt-2 text-sm text-slate-600 xl:pt-1">
<InformationCircleIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<ResumeExpandableText
key={detailsQuery.data.additionalInfo}
text={detailsQuery.data.additionalInfo}
/>
</div>
)}
<div className="flex w-full flex-col gap-6 py-4 xl:flex-row xl:py-0">
<div className="w-full xl:w-1/2">
<ResumePdf url={detailsQuery.data.url} />
</div>
<div className="grow">
<div className="mb-6 space-y-4 xl:hidden">
{renderReviewButton()}
<div className="flex items-center space-x-2">
<hr className="flex-grow border-slate-300" />
<span className="bg-slate-50 px-3 text-lg font-medium text-slate-900">
Reviews
</span>
<hr className="flex-grow border-slate-300" />
</div>
</div>
{detailsQuery.data.additionalInfo && (

@ -9,6 +9,7 @@ import {
NewspaperIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { TypeaheadOption } from '@tih/ui';
import {
Button,
CheckboxInput,
@ -23,6 +24,9 @@ import {
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeExperienceTypeahead from '~/components/resumes/shared/ResumeExperienceTypeahead';
import ResumeLocationTypeahead from '~/components/resumes/shared/ResumeLocationTypeahead';
import ResumeRoleTypeahead from '~/components/resumes/shared/ResumeRoleTypeahead';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import loginPageHref from '~/components/shared/loginPageHref';
@ -32,6 +36,7 @@ import type {
FilterLabel,
Shortcut,
} from '~/utils/resumes/resumeFilters';
import type { FilterState, SortOrder } from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
@ -47,8 +52,6 @@ import useDebounceValue from '~/utils/resumes/useDebounceValue';
import useSearchParams from '~/utils/resumes/useSearchParams';
import { trpc } from '~/utils/trpc';
import type { FilterState, SortOrder } from '../../utils/resumes/resumeFilters';
const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800;
const PAGE_LIMIT = 10;
@ -200,10 +203,10 @@ export default function ResumeHomePage() {
[
'resumes.resume.findAll',
{
experienceFilters: userFilters.experience,
experienceFilters: userFilters.experience.map(({ value }) => value),
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
@ -219,10 +222,10 @@ export default function ResumeHomePage() {
[
'resumes.resume.user.findUserStarred',
{
experienceFilters: userFilters.experience,
experienceFilters: userFilters.experience.map(({ value }) => value),
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
@ -239,10 +242,10 @@ export default function ResumeHomePage() {
[
'resumes.resume.user.findUserCreated',
{
experienceFilters: userFilters.experience,
experienceFilters: userFilters.experience.map(({ value }) => value),
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
@ -264,31 +267,6 @@ export default function ResumeHomePage() {
}
};
const onFilterCheckboxChange = (
isChecked: boolean,
filterSection: FilterId,
filterValue: string,
) => {
if (isChecked) {
setUserFilters({
...userFilters,
[filterSection]: [...userFilters[filterSection], filterValue],
});
} else {
setUserFilters({
...userFilters,
[filterSection]: userFilters[filterSection].filter(
(value) => value !== filterValue,
),
});
}
gaEvent({
action: 'resumes.filter_checkbox_click',
category: 'engagement',
label: 'Select Filter',
});
};
const onClearFilterClick = (filterSection: FilterId) => {
setUserFilters({
...userFilters,
@ -354,6 +332,52 @@ export default function ResumeHomePage() {
return getTabQueryData()?.filterCounts;
};
const getFilterTypeahead = (filterId: FilterId) => {
const onSelect = (option: TypeaheadOption | null) => {
if (option === null) {
return;
}
setUserFilters({
...userFilters,
[filterId]: [...userFilters[filterId], option],
});
gaEvent({
action: 'resumes.filter_typeahead_click',
category: 'engagement',
label: 'Select Filter',
});
};
switch (filterId) {
case 'experience':
return (
<ResumeExperienceTypeahead
isLabelHidden={true}
placeholder="Select experiences"
onSelect={onSelect}
/>
);
case 'location':
return (
<ResumeLocationTypeahead
isLabelHidden={true}
placeholder="Select locations"
onSelect={onSelect}
/>
);
case 'role':
return (
<ResumeRoleTypeahead
isLabelHidden={true}
placeholder="Select roles"
onSelect={onSelect}
/>
);
default:
return null;
}
};
const getFilterCount = (filter: FilterLabel, value: string) => {
const filterCountsData = getTabFilterCounts();
if (!filterCountsData) {
@ -461,21 +485,24 @@ export default function ResumeHomePage() {
</h3>
<Disclosure.Panel className="space-y-4 pt-6">
<div className="space-y-3">
{filter.options.map((option) => (
{getFilterTypeahead(filter.id)}
{userFilters[filter.id].map((option) => (
<div
key={option.value}
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
value={true}
onChange={() =>
setUserFilters({
...userFilters,
[filter.id]: userFilters[
filter.id
].filter(
({ value }) =>
value !== option.value,
),
})
}
/>
<span className="ml-1 text-slate-500">
@ -570,26 +597,28 @@ export default function ResumeHomePage() {
</Disclosure.Button>
</h3>
<Disclosure.Panel className="space-y-4 pt-4">
{getFilterTypeahead(filter.id)}
<CheckboxList
description=""
isLabelHidden={true}
label=""
orientation="vertical">
{filter.options.map((option) => (
{userFilters[filter.id].map((option) => (
<div
key={option.value}
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
value={true}
onChange={() =>
setUserFilters({
...userFilters,
[filter.id]: userFilters[
filter.id
].filter(
({ value }) => value !== option.value,
),
})
}
/>
<span className="ml-1 text-slate-500">
@ -660,7 +689,7 @@ export default function ResumeHomePage() {
</div>
<DropdownMenu
align="end"
label={getFilterLabel(SORT_OPTIONS, sortOrder)}>
label={getFilterLabel('sort', sortOrder)}>
{SORT_OPTIONS.map(({ label, value }) => (
<DropdownMenu.Item
key={value}

@ -1,3 +1,7 @@
import type { TypeaheadOption } from '@tih/ui';
import { JobTitleLabels } from '~/components/shared/JobTitles';
export type FilterId = 'experience' | 'location' | 'role';
export type FilterLabel = 'Experience' | 'Location' | 'Role';
@ -5,24 +9,6 @@ export type CustomFilter = {
isUnreviewed: boolean;
};
export type RoleFilter =
| 'Android Engineer'
| 'Backend Engineer'
| 'DevOps Engineer'
| 'Frontend Engineer'
| 'Full-Stack Engineer'
| 'iOS Engineer';
export type ExperienceFilter =
| 'Entry Level (0 - 2 years)'
| 'Internship'
| 'Mid Level (3 - 5 years)'
| 'Senior Level (5+ years)';
export type LocationFilter = 'India' | 'Singapore' | 'United States';
export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter;
export type FilterOption<T> = {
label: string;
value: T;
@ -31,10 +17,11 @@ export type FilterOption<T> = {
export type Filter = {
id: FilterId;
label: FilterLabel;
options: Array<FilterOption<FilterValue>>;
options: Array<TypeaheadOption>;
};
export type FilterState = CustomFilter & Record<FilterId, Array<FilterValue>>;
export type FilterState = CustomFilter &
Record<FilterId, Array<TypeaheadOption>>;
export type SortOrder = 'latest' | 'mostComments' | 'popular';
@ -57,45 +44,54 @@ export const SORT_OPTIONS: Array<FilterOption<SortOrder>> = [
{ label: 'Most Comments', value: 'mostComments' },
];
export const ROLES: Array<FilterOption<RoleFilter>> = [
export const ROLES: Array<TypeaheadOption> = [
{
id: 'software-engineer',
label: 'Software Engineer',
value: 'software-engineer',
},
{
id: 'back-end-engineer',
label: 'Back End Engineer',
value: 'back-end-engineer',
},
{
label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer',
id: 'front-end-engineer',
label: 'Front End Engineer',
value: 'front-end-engineer',
},
{ label: 'Frontend Engineer', value: 'Frontend Engineer' },
{ label: 'Backend Engineer', value: 'Backend Engineer' },
{ label: 'DevOps Engineer', value: 'DevOps Engineer' },
{ label: 'iOS Engineer', value: 'iOS Engineer' },
{ label: 'Android Engineer', value: 'Android Engineer' },
];
export const EXPERIENCES: Array<FilterOption<ExperienceFilter>> = [
{ label: 'Internship', value: 'Internship' },
export const EXPERIENCES: Array<TypeaheadOption> = [
{
id: 'Entry Level (0 - 2 years)',
label: 'Entry Level (0 - 2 years)',
value: 'Entry Level (0 - 2 years)',
},
{
id: 'Internship',
label: 'Internship',
value: 'Internship',
},
{
id: 'Mid Level (3 - 5 years)',
label: 'Mid Level (3 - 5 years)',
value: 'Mid Level (3 - 5 years)',
},
{
id: 'Senior Level (5+ years)',
label: 'Senior Level (5+ years)',
value: 'Senior Level (5+ years)',
},
];
export const LOCATIONS: Array<FilterOption<LocationFilter>> = [
{ label: 'Singapore', value: 'Singapore' },
{ label: 'United States', value: 'United States' },
{ label: 'India', value: 'India' },
];
export const LOCATIONS: Array<TypeaheadOption> = [];
export const INITIAL_FILTER_STATE: FilterState = {
experience: Object.values(EXPERIENCES).map(({ value }) => value),
experience: EXPERIENCES,
isUnreviewed: true,
location: Object.values(LOCATIONS).map(({ value }) => value),
role: Object.values(ROLES).map(({ value }) => value),
location: [],
role: ROLES,
};
export const SHORTCUTS: Array<Shortcut> = [
@ -118,7 +114,7 @@ export const SHORTCUTS: Array<Shortcut> = [
{
filters: {
...INITIAL_FILTER_STATE,
experience: ['Entry Level (0 - 2 years)'],
experience: [],
isUnreviewed: false,
},
name: 'Fresh Grad',
@ -136,7 +132,7 @@ export const SHORTCUTS: Array<Shortcut> = [
filters: {
...INITIAL_FILTER_STATE,
isUnreviewed: false,
location: ['United States'],
location: [],
},
name: 'US Only',
sortOrder: 'latest',
@ -154,8 +150,29 @@ export const isInitialFilterState = (filters: FilterState) =>
});
export const getFilterLabel = (
filters: Array<
FilterOption<ExperienceFilter | LocationFilter | RoleFilter | SortOrder>
>,
filterValue: ExperienceFilter | LocationFilter | RoleFilter | SortOrder,
) => filters.find(({ value }) => value === filterValue)?.label ?? filterValue;
filterId: FilterId | 'sort',
filterValue: SortOrder | string,
) => {
let filters: Array<TypeaheadOption> = [];
switch (filterId) {
case 'experience':
filters = EXPERIENCES;
break;
case 'location':
break;
case 'role':
filters = Object.entries(JobTitleLabels).map(([slug, label]) => ({
id: slug,
label,
value: slug,
}));
break;
case 'sort':
return SORT_OPTIONS.find(({ value }) => value === filterValue)?.label;
default:
break;
}
return filters.find(({ value }) => value === filterValue)?.label;
};

Loading…
Cancel
Save