[questions][feat] browse: add sort by, order by

pull/411/head
Jeff Sieu 3 years ago
parent 01495e8f6a
commit 2e7131583c

@ -4,28 +4,40 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { Button, Select, TextInput } from '@tih/ui'; import { Button, Select, TextInput } from '@tih/ui';
export type SortOption = { export type SortOption<Value> = {
label: string; label: string;
value: string; value: Value;
}; };
export type QuestionSearchBarProps<SortOptions extends Array<SortOption>> = { type SortOrderProps<SortOrder> = {
onSortOrderChange?: (sortValue: SortOrder) => void;
sortOrderOptions: ReadonlyArray<SortOption<SortOrder>>;
sortOrderValue: SortOrder;
};
type SortTypeProps<SortType> = {
onSortTypeChange?: (sortType: SortType) => void;
sortTypeOptions: ReadonlyArray<SortOption<SortType>>;
sortTypeValue: SortType;
};
export type QuestionSearchBarProps<SortType, SortOrder> =
SortOrderProps<SortOrder> &
SortTypeProps<SortType> & {
onFilterOptionsToggle: () => void; onFilterOptionsToggle: () => void;
onSortChange?: (sortValue: SortOptions[number]['value']) => void;
sortOptions: SortOptions;
sortValue: SortOptions[number]['value'];
}; };
export default function QuestionSearchBar< export default function QuestionSearchBar<SortType, SortOrder>({
SortOptions extends Array<SortOption>, onSortOrderChange,
>({ sortOrderOptions,
onSortChange, sortOrderValue,
sortOptions, onSortTypeChange,
sortValue, sortTypeOptions,
sortTypeValue,
onFilterOptionsToggle, onFilterOptionsToggle,
}: QuestionSearchBarProps<SortOptions>) { }: QuestionSearchBarProps<SortType, SortOrder>) {
return ( return (
<div className="flex items-center gap-4"> <div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end">
<div className="flex-1 "> <div className="flex-1 ">
<TextInput <TextInput
isLabelHidden={true} isLabelHidden={true}
@ -35,17 +47,37 @@ export default function QuestionSearchBar<
startAddOnType="icon" startAddOnType="icon"
/> />
</div> </div>
<div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span aria-hidden={true} className="align-middle text-sm font-medium">
Sort by:
</span>
<Select <Select
display="inline" display="inline"
isLabelHidden={true}
label="Sort by" label="Sort by"
options={sortOptions} options={sortTypeOptions}
value={sortValue} value={sortTypeValue}
onChange={onSortChange} onChange={(value) => {
const chosenOption = sortTypeOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortTypeChange?.(chosenOption.value);
}
}}
/>
</div>
<div className="flex items-center gap-2">
<Select
display="inline"
label="Order by"
options={sortOrderOptions}
value={sortOrderValue}
onChange={(value) => {
const chosenOption = sortOrderOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortOrderChange?.(chosenOption.value);
}
}}
/> />
</div> </div>
<div className="lg:hidden"> <div className="lg:hidden">
@ -58,5 +90,6 @@ export default function QuestionSearchBar<
/> />
</div> </div>
</div> </div>
</div>
); );
} }

@ -19,7 +19,6 @@ import {
import CompanyTypeahead from '../typeahead/CompanyTypeahead'; import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import LocationTypeahead from '../typeahead/LocationTypeahead'; import LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead'; import RoleTypeahead from '../typeahead/RoleTypeahead';
import CompaniesTypeahead from '../../shared/CompaniesTypeahead';
import type { Month } from '../../shared/MonthYearPicker'; import type { Month } from '../../shared/MonthYearPicker';
import MonthYearPicker from '../../shared/MonthYearPicker'; import MonthYearPicker from '../../shared/MonthYearPicker';

@ -13,6 +13,8 @@ import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar'; import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import type { QuestionAge } from '~/utils/questions/constants'; import type { QuestionAge } from '~/utils/questions/constants';
import { SORT_TYPES } from '~/utils/questions/constants';
import { SORT_ORDERS } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants'; import { APP_TITLE } from '~/utils/questions/constants';
import { ROLES } from '~/utils/questions/constants'; import { ROLES } from '~/utils/questions/constants';
import { import {
@ -23,24 +25,25 @@ import {
} from '~/utils/questions/constants'; } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import { import {
useSearchFilter, useSearchParam,
useSearchFilterSingle, useSearchParamSingle,
} from '~/utils/questions/useSearchFilter'; } from '~/utils/questions/useSearchParam';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import { SortOrder, SortType } from '~/types/questions.d'; import { SortType } from '~/types/questions.d';
import { SortOrder } from '~/types/questions.d';
export default function QuestionsBrowsePage() { export default function QuestionsBrowsePage() {
const router = useRouter(); const router = useRouter();
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] = const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
useSearchFilter('companies'); useSearchParam('companies');
const [ const [
selectedQuestionTypes, selectedQuestionTypes,
setSelectedQuestionTypes, setSelectedQuestionTypes,
areQuestionTypesInitialized, areQuestionTypesInitialized,
] = useSearchFilter<QuestionsQuestionType>('questionTypes', { ] = useSearchParam<QuestionsQuestionType>('questionTypes', {
queryParamToValue: (param) => { stringToParam: (param) => {
const uppercaseParam = param.toUpperCase(); const uppercaseParam = param.toUpperCase();
return ( return (
QUESTION_TYPES.find( QUESTION_TYPES.find(
@ -53,9 +56,9 @@ export default function QuestionsBrowsePage() {
selectedQuestionAge, selectedQuestionAge,
setSelectedQuestionAge, setSelectedQuestionAge,
isQuestionAgeInitialized, isQuestionAgeInitialized,
] = useSearchFilterSingle<QuestionAge>('questionAge', { ] = useSearchParamSingle<QuestionAge>('questionAge', {
defaultValue: 'all', defaultValue: 'all',
queryParamToValue: (param) => { stringToParam: (param) => {
const uppercaseParam = param.toUpperCase(); const uppercaseParam = param.toUpperCase();
return ( return (
QUESTION_AGES.find( QUESTION_AGES.find(
@ -66,9 +69,57 @@ export default function QuestionsBrowsePage() {
}); });
const [selectedRoles, setSelectedRoles, areRolesInitialized] = const [selectedRoles, setSelectedRoles, areRolesInitialized] =
useSearchFilter('roles'); useSearchParam('roles');
const [selectedLocations, setSelectedLocations, areLocationsInitialized] = const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchFilter('locations'); useSearchParam('locations');
const [sortOrder, setSortOrder, isSortOrderInitialized] =
useSearchParamSingle<SortOrder>('sortOrder', {
defaultValue: SortOrder.DESC,
paramToString: (value) => {
if (value === SortOrder.ASC) {
return 'ASC';
}
if (value === SortOrder.DESC) {
return 'DESC';
}
return null;
},
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'ASC') {
return SortOrder.ASC;
}
if (uppercaseParam === 'DESC') {
return SortOrder.DESC;
}
return null;
},
});
const [sortType, setSortType, isSortTypeInitialized] =
useSearchParamSingle<SortType>('sortType', {
defaultValue: SortType.TOP,
paramToString: (value) => {
if (value === SortType.NEW) {
return 'NEW';
}
if (value === SortType.TOP) {
return 'TOP';
}
return null;
},
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'NEW') {
return SortType.NEW;
}
if (uppercaseParam === 'TOP') {
return SortType.TOP;
}
return null;
},
});
const hasFilters = useMemo( const hasFilters = useMemo(
() => () =>
@ -106,8 +157,8 @@ export default function QuestionsBrowsePage() {
locations: selectedLocations, locations: selectedLocations,
questionTypes: selectedQuestionTypes, questionTypes: selectedQuestionTypes,
roles: selectedRoles, roles: selectedRoles,
sortOrder: SortOrder.DESC, sortOrder,
sortType: SortType.NEW, sortType,
startDate, startDate,
}, },
], ],
@ -164,13 +215,15 @@ export default function QuestionsBrowsePage() {
})); }));
}, [selectedLocations]); }, [selectedLocations]);
const areFiltersInitialized = useMemo(() => { const areSearchOptionsInitialized = useMemo(() => {
return ( return (
areCompaniesInitialized && areCompaniesInitialized &&
areQuestionTypesInitialized && areQuestionTypesInitialized &&
isQuestionAgeInitialized && isQuestionAgeInitialized &&
areRolesInitialized && areRolesInitialized &&
areLocationsInitialized areLocationsInitialized &&
isSortTypeInitialized &&
isSortOrderInitialized
); );
}, [ }, [
areCompaniesInitialized, areCompaniesInitialized,
@ -178,11 +231,13 @@ export default function QuestionsBrowsePage() {
isQuestionAgeInitialized, isQuestionAgeInitialized,
areRolesInitialized, areRolesInitialized,
areLocationsInitialized, areLocationsInitialized,
isSortTypeInitialized,
isSortOrderInitialized,
]); ]);
const { pathname } = router; const { pathname } = router;
useEffect(() => { useEffect(() => {
if (areFiltersInitialized) { if (areSearchOptionsInitialized) {
// Router.replace used instead of router.replace to avoid // Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace // the page reloading itself since the router.replace
// callback changes on every page load // callback changes on every page load
@ -194,13 +249,14 @@ export default function QuestionsBrowsePage() {
questionAge: selectedQuestionAge, questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes, questionTypes: selectedQuestionTypes,
roles: selectedRoles, roles: selectedRoles,
sortOrder,
}, },
}); });
setLoaded(true); setLoaded(true);
} }
}, [ }, [
areFiltersInitialized, areSearchOptionsInitialized,
loaded, loaded,
pathname, pathname,
selectedCompanies, selectedCompanies,
@ -208,6 +264,7 @@ export default function QuestionsBrowsePage() {
selectedLocations, selectedLocations,
selectedQuestionAge, selectedQuestionAge,
selectedQuestionTypes, selectedQuestionTypes,
sortOrder,
]); ]);
if (!loaded) { if (!loaded) {
@ -360,7 +417,7 @@ export default function QuestionsBrowsePage() {
<div className="flex h-full flex-1"> <div className="flex h-full flex-1">
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto"> <section className="flex min-h-0 flex-1 flex-col items-center overflow-auto">
<div className="flex min-h-0 max-w-3xl flex-1 p-4"> <div className="flex min-h-0 max-w-3xl flex-1 p-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4"> <div className="flex flex-1 flex-col items-stretch justify-start gap-8">
<ContributeQuestionCard <ContributeQuestionCard
onSubmit={(data) => { onSubmit={(data) => {
createQuestion({ createQuestion({
@ -374,24 +431,15 @@ export default function QuestionsBrowsePage() {
}} }}
/> />
<QuestionSearchBar <QuestionSearchBar
sortOptions={[ sortOrderOptions={SORT_ORDERS}
{ sortOrderValue={sortOrder}
label: 'Most recent', sortTypeOptions={SORT_TYPES}
value: 'most-recent', sortTypeValue={sortType}
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
sortValue="most-recent"
onFilterOptionsToggle={() => { onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen); setFilterDrawerOpen(!filterDrawerOpen);
}} }}
onSortChange={(value) => { onSortOrderChange={setSortOrder}
// eslint-disable-next-line no-console onSortTypeChange={setSortType}
console.log(value);
}}
/> />
<div className="flex flex-col gap-4 pb-4"> <div className="flex flex-col gap-4 pb-4">
{(questions ?? []).map((question) => ( {(questions ?? []).map((question) => (

@ -2,6 +2,8 @@ import { QuestionsQuestionType } from '@prisma/client';
import type { FilterChoices } from '~/components/questions/filter/FilterSection'; import type { FilterChoices } from '~/components/questions/filter/FilterSection';
import { SortOrder, SortType } from '~/types/questions.d';
export const APP_TITLE = 'Questions Bank'; export const APP_TITLE = 'Questions Bank';
export const COMPANIES: FilterChoices = [ export const COMPANIES: FilterChoices = [
@ -102,6 +104,28 @@ export const ROLES: FilterChoices = [
}, },
] as const; ] as const;
export const SORT_ORDERS = [
{
label: 'Ascending',
value: SortOrder.ASC,
},
{
label: 'Descending',
value: SortOrder.DESC,
},
];
export const SORT_TYPES = [
{
label: 'New',
value: SortType.NEW,
},
{
label: 'Top',
value: SortType.TOP,
},
];
export const SAMPLE_QUESTION = { export const SAMPLE_QUESTION = {
answerCount: 10, answerCount: 10,
commentCount: 10, commentCount: 10,

@ -1,14 +1,27 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
export const useSearchFilter = <Value extends string = string>( type SearchParamOptions<Value> = [Value] extends [string]
name: string, ? {
opts: { defaultValues?: Array<Value>;
paramToString?: (value: Value) => string | null;
stringToParam?: (param: string) => Value | null;
}
: {
defaultValues?: Array<Value>; defaultValues?: Array<Value>;
queryParamToValue?: (param: string) => Value | null; paramToString: (value: Value) => string | null;
} = {}, stringToParam: (param: string) => Value | null;
};
export const useSearchParam = <Value = string>(
name: string,
opts?: SearchParamOptions<Value>,
) => { ) => {
const { defaultValues, queryParamToValue = (param) => param } = opts; const {
defaultValues,
stringToParam = (param: string) => param,
paramToString: valueToQueryParam = (value: Value) => String(value),
} = opts ?? {};
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const router = useRouter(); const router = useRouter();
@ -22,7 +35,7 @@ export const useSearchFilter = <Value extends string = string>(
const queryValues = Array.isArray(query) ? query : [query]; const queryValues = Array.isArray(query) ? query : [query];
setFilters( setFilters(
queryValues queryValues
.map(queryParamToValue) .map(stringToParam)
.filter((value) => value !== null) as Array<Value>, .filter((value) => value !== null) as Array<Value>,
); );
} else { } else {
@ -35,31 +48,35 @@ export const useSearchFilter = <Value extends string = string>(
} }
setIsInitialized(true); setIsInitialized(true);
} }
}, [isInitialized, name, queryParamToValue, router]); }, [isInitialized, name, stringToParam, router]);
const setFiltersCallback = useCallback( const setFiltersCallback = useCallback(
(newFilters: Array<Value>) => { (newFilters: Array<Value>) => {
setFilters(newFilters); setFilters(newFilters);
localStorage.setItem(name, JSON.stringify(newFilters)); localStorage.setItem(
name,
JSON.stringify(
newFilters.map(valueToQueryParam).filter((param) => param !== null),
),
);
}, },
[name], [name, valueToQueryParam],
); );
return [filters, setFiltersCallback, isInitialized] as const; return [filters, setFiltersCallback, isInitialized] as const;
}; };
export const useSearchFilterSingle = <Value extends string = string>( export const useSearchParamSingle = <Value = string>(
name: string, name: string,
opts: { opts?: Omit<SearchParamOptions<Value>, 'defaultValues'> & {
defaultValue?: Value; defaultValue?: Value;
queryParamToValue?: (param: string) => Value | null; },
} = {},
) => { ) => {
const { defaultValue, queryParamToValue } = opts; const { defaultValue, ...restOpts } = opts ?? {};
const [filters, setFilters, isInitialized] = useSearchFilter(name, { const [filters, setFilters, isInitialized] = useSearchParam<Value>(name, {
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined, defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
queryParamToValue, ...restOpts,
}); } as SearchParamOptions<Value>);
return [ return [
filters[0], filters[0],
Loading…
Cancel
Save