From 471a28be8ae06cc3513fd2a17435626cefa9cbf2 Mon Sep 17 00:00:00 2001 From: hpkoh <53825802+hpkoh@users.noreply.github.com> Date: Mon, 24 Oct 2022 22:56:50 +0800 Subject: [PATCH] [questions][feat] pagination (#410) * [questions][feat] pagination * [questions][feat] update aggregated data * [questions][feat] add next cursors * [questions][fix] fix bug * [questions][chore] fix lint error * [questions][chore] update cursor to support adapter * [questions][feat] paginate browse queries * [questions][ui] change page size to 10 * [question][refactor] clean up router code * [questions][fix] fix type errors * [questions][feat] add upvotes tracking * [questions][chore] add default upovte value Co-authored-by: Jeff Sieu --- .../migration.sql | 14 + .../migration.sql | 2 + apps/portal/prisma/schema.prisma | 3 + .../questions/filter/FilterSection.tsx | 51 +-- .../questions/typeahead/ExpandedTypeahead.tsx | 29 +- apps/portal/src/pages/questions/browse.tsx | 308 +++++++++++------- .../router/questions-answer-comment-router.ts | 88 +++-- .../server/router/questions-answer-router.ts | 87 +++-- .../questions-question-comment-router.ts | 86 +++-- .../questions-question-encounter-router.ts | 5 + .../router/questions-question-router.ts | 147 ++++++++- apps/portal/src/types/questions.d.ts | 6 +- 12 files changed, 596 insertions(+), 230 deletions(-) create mode 100644 apps/portal/prisma/migrations/20221024123252_add_upvotes_to_schema/migration.sql create mode 100644 apps/portal/prisma/migrations/20221024123849_add_upvotes_default_value/migration.sql diff --git a/apps/portal/prisma/migrations/20221024123252_add_upvotes_to_schema/migration.sql b/apps/portal/prisma/migrations/20221024123252_add_upvotes_to_schema/migration.sql new file mode 100644 index 00000000..81d336ec --- /dev/null +++ b/apps/portal/prisma/migrations/20221024123252_add_upvotes_to_schema/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Added the required column `upvotes` to the `QuestionsAnswerComment` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "QuestionsAnswer" ADD COLUMN "upvotes" INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "QuestionsAnswerComment" ADD COLUMN "upvotes" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "QuestionsQuestionComment" ADD COLUMN "upvotes" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/portal/prisma/migrations/20221024123849_add_upvotes_default_value/migration.sql b/apps/portal/prisma/migrations/20221024123849_add_upvotes_default_value/migration.sql new file mode 100644 index 00000000..f4a342af --- /dev/null +++ b/apps/portal/prisma/migrations/20221024123849_add_upvotes_default_value/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "QuestionsAnswerComment" ALTER COLUMN "upvotes" SET DEFAULT 0; diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index fb263f80..ae5a9f16 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -454,6 +454,7 @@ model QuestionsQuestionComment { id String @id @default(cuid()) questionId String userId String? + upvotes Int @default(0) content String @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -482,6 +483,7 @@ model QuestionsAnswer { questionId String userId String? content String @db.Text + upvotes Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -510,6 +512,7 @@ model QuestionsAnswerComment { answerId String userId String? content String @db.Text + upvotes Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/portal/src/components/questions/filter/FilterSection.tsx b/apps/portal/src/components/questions/filter/FilterSection.tsx index b72bc7ff..4d878887 100644 --- a/apps/portal/src/components/questions/filter/FilterSection.tsx +++ b/apps/portal/src/components/questions/filter/FilterSection.tsx @@ -17,29 +17,26 @@ export type FilterChoices = ReadonlyArray< FilterChoice >; -type FilterSectionType> = +type FilterSectionType = | { isSingleSelect: true; - onOptionChange: (optionValue: FilterOptions[number]['value']) => void; + onOptionChange: (option: FilterOption) => void; } | { isSingleSelect?: false; - onOptionChange: ( - optionValue: FilterOptions[number]['value'], - checked: boolean, - ) => void; + onOptionChange: (option: FilterOption) => void; }; -export type FilterSectionProps> = - FilterSectionType & { +export type FilterSectionProps = + FilterSectionType & { label: string; - options: FilterOptions; + options: Array>; } & ( | { renderInput: (props: { field: UseFormRegisterReturn<'search'>; - onOptionChange: FilterSectionType['onOptionChange']; - options: FilterOptions; + onOptionChange: FilterSectionType['onOptionChange']; + options: Array>; }) => React.ReactNode; showAll?: never; } @@ -53,16 +50,14 @@ export type FilterSectionFormData = { search: string; }; -export default function FilterSection< - FilterOptions extends Array, ->({ +export default function FilterSection({ label, options, showAll, onOptionChange, isSingleSelect, renderInput, -}: FilterSectionProps) { +}: FilterSectionProps) { const { register, reset } = useForm(); const registerSearch = register('search'); @@ -76,7 +71,9 @@ export default function FilterSection< }; const autocompleteOptions = useMemo(() => { - return options.filter((option) => !option.checked) as FilterOptions; + return options.filter((option) => !option.checked) as Array< + FilterOption + >; }, [options]); const selectedCount = useMemo(() => { @@ -102,11 +99,12 @@ export default function FilterSection<
{renderInput({ field, - onOptionChange: async ( - optionValue: FilterOptions[number]['value'], - ) => { + onOptionChange: async (option: FilterOption) => { reset(); - return onOptionChange(optionValue, true); + return onOptionChange({ + ...option, + checked: true, + }); }, options: autocompleteOptions, })} @@ -119,7 +117,13 @@ export default function FilterSection< label={label} value={options.find((option) => option.checked)?.value} onChange={(value) => { - onOptionChange(value); + const changedOption = options.find( + (option) => option.value === value, + )!; + onOptionChange({ + ...changedOption, + checked: !changedOption.checked, + }); }}> {options.map((option) => ( { - onOptionChange(option.value, checked); + onOptionChange({ + ...option, + checked, + }); }} /> ))} diff --git a/apps/portal/src/components/questions/typeahead/ExpandedTypeahead.tsx b/apps/portal/src/components/questions/typeahead/ExpandedTypeahead.tsx index 6e96894d..a485c6ed 100644 --- a/apps/portal/src/components/questions/typeahead/ExpandedTypeahead.tsx +++ b/apps/portal/src/components/questions/typeahead/ExpandedTypeahead.tsx @@ -1,4 +1,6 @@ import type { ComponentProps } from 'react'; +import { useState } from 'react'; +import { useMemo } from 'react'; import { Button, Typeahead } from '@tih/ui'; import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone'; @@ -7,6 +9,8 @@ type TypeaheadProps = ComponentProps; type TypeaheadOption = TypeaheadProps['options'][number]; export type ExpandedTypeaheadProps = RequireAllOrNone<{ + clearOnSelect?: boolean; + filterOption: (option: TypeaheadOption) => boolean; onSuggestionClick: (option: TypeaheadOption) => void; suggestedCount: number; }> & @@ -15,9 +19,20 @@ export type ExpandedTypeaheadProps = RequireAllOrNone<{ export default function ExpandedTypeahead({ suggestedCount = 0, onSuggestionClick, + filterOption = () => true, + clearOnSelect = false, + options, + onSelect, ...typeaheadProps }: ExpandedTypeaheadProps) { - const suggestions = typeaheadProps.options.slice(0, suggestedCount); + const [key, setKey] = useState(0); + const filteredOptions = useMemo(() => { + return options.filter(filterOption); + }, [options, filterOption]); + const suggestions = useMemo( + () => filteredOptions.slice(0, suggestedCount), + [filteredOptions, suggestedCount], + ); return (
@@ -32,7 +47,17 @@ export default function ExpandedTypeahead({ /> ))}
- + { + if (clearOnSelect) { + setKey((key + 1) % 2); + } + onSelect(option); + }} + />
); diff --git a/apps/portal/src/pages/questions/browse.tsx b/apps/portal/src/pages/questions/browse.tsx index 163d4842..cb895f27 100644 --- a/apps/portal/src/pages/questions/browse.tsx +++ b/apps/portal/src/pages/questions/browse.tsx @@ -5,24 +5,22 @@ import { useEffect, useMemo, useState } from 'react'; import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid'; import { NoSymbolIcon } from '@heroicons/react/24/outline'; import type { QuestionsQuestionType } from '@prisma/client'; -import { Button, SlideOut, Typeahead } from '@tih/ui'; +import { Button, SlideOut } from '@tih/ui'; import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard'; import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard'; +import type { FilterOption } from '~/components/questions/filter/FilterSection'; import FilterSection from '~/components/questions/filter/FilterSection'; import QuestionSearchBar from '~/components/questions/QuestionSearchBar'; +import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead'; +import LocationTypeahead from '~/components/questions/typeahead/LocationTypeahead'; +import RoleTypeahead from '~/components/questions/typeahead/RoleTypeahead'; 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 { ROLES } from '~/utils/questions/constants'; -import { - COMPANIES, - LOCATIONS, - QUESTION_AGES, - QUESTION_TYPES, -} from '~/utils/questions/constants'; +import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants'; import createSlug from '~/utils/questions/createSlug'; import { useSearchParam, @@ -148,12 +146,18 @@ export default function QuestionsBrowsePage() { : undefined; }, [selectedQuestionAge]); - const { data: questions } = trpc.useQuery( + const { + data: questionsQueryData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = trpc.useInfiniteQuery( [ 'questions.questions.getQuestionsByFilter', { companyNames: selectedCompanies, endDate: today, + limit: 10, locations: selectedLocations, questionTypes: selectedQuestionTypes, roles: selectedRoles, @@ -163,10 +167,21 @@ export default function QuestionsBrowsePage() { }, ], { + getNextPageParam: (lastPage) => lastPage.nextCursor, keepPreviousData: true, }, ); + const questionCount = useMemo(() => { + if (!questionsQueryData) { + return undefined; + } + return questionsQueryData.pages.reduce( + (acc, page) => acc + page.data.length, + 0, + ); + }, [questionsQueryData]); + const utils = trpc.useContext(); const { mutate: createQuestion } = trpc.useMutation( 'questions.questions.create', @@ -180,12 +195,17 @@ export default function QuestionsBrowsePage() { const [loaded, setLoaded] = useState(false); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); - const companyFilterOptions = useMemo(() => { - return COMPANIES.map((company) => ({ - ...company, - checked: selectedCompanies.includes(company.value), - })); - }, [selectedCompanies]); + const [selectedCompanyOptions, setSelectedCompanyOptions] = useState< + Array + >([]); + + const [selectedRoleOptions, setSelectedRoleOptions] = useState< + Array + >([]); + + const [selectedLocationOptions, setSelectedLocationOptions] = useState< + Array + >([]); const questionTypeFilterOptions = useMemo(() => { return QUESTION_TYPES.map((questionType) => ({ @@ -201,20 +221,6 @@ export default function QuestionsBrowsePage() { })); }, [selectedQuestionAge]); - const roleFilterOptions = useMemo(() => { - return ROLES.map((role) => ({ - ...role, - checked: selectedRoles.includes(role.value), - })); - }, [selectedRoles]); - - const locationFilterOptions = useMemo(() => { - return LOCATIONS.map((location) => ({ - ...location, - checked: selectedLocations.includes(location.value), - })); - }, [selectedLocations]); - const areSearchOptionsInitialized = useMemo(() => { return ( areCompaniesInitialized && @@ -287,35 +293,89 @@ export default function QuestionsBrowsePage() { setSelectedQuestionAge('all'); setSelectedRoles([]); setSelectedLocations([]); + setSelectedCompanyOptions([]); + setSelectedRoleOptions([]); + setSelectedLocationOptions([]); }} /> ( - ( + { + return !selectedCompanyOptions.some((selectedOption) => { + return selectedOption.value === option.value; + }); + }} isLabelHidden={true} - label="Companies" - options={options} placeholder="Search companies" - // eslint-disable-next-line @typescript-eslint/no-empty-function - onQueryChange={() => {}} - onSelect={({ value }) => { - onOptionChange(value, true); + onSelect={(option) => { + onOptionChange({ + ...option, + checked: true, + }); }} /> )} - onOptionChange={(optionValue, checked) => { - if (checked) { - setSelectedCompanies([...selectedCompanies, optionValue]); + onOptionChange={(option) => { + if (option.checked) { + setSelectedCompanies([...selectedCompanies, option.label]); + setSelectedCompanyOptions((prevOptions) => [ + ...prevOptions, + { ...option, checked: true }, + ]); } else { setSelectedCompanies( - selectedCompanies.filter((company) => company !== optionValue), + selectedCompanies.filter((company) => company !== option.label), + ); + setSelectedCompanyOptions((prevOptions) => + prevOptions.filter( + (prevOption) => prevOption.label !== option.label, + ), + ); + } + }} + /> + ( + { + return !selectedRoleOptions.some((selectedOption) => { + return selectedOption.value === option.value; + }); + }} + isLabelHidden={true} + placeholder="Search roles" + onSelect={(option) => { + onOptionChange({ + ...option, + checked: true, + }); + }} + /> + )} + onOptionChange={(option) => { + if (option.checked) { + setSelectedRoles([...selectedRoles, option.value]); + setSelectedRoleOptions((prevOptions) => [ + ...prevOptions, + { ...option, checked: true }, + ]); + } else { + setSelectedRoles( + selectedCompanies.filter((role) => role !== option.value), + ); + setSelectedRoleOptions((prevOptions) => + prevOptions.filter( + (prevOption) => prevOption.value !== option.value, + ), ); } }} @@ -324,13 +384,13 @@ export default function QuestionsBrowsePage() { label="Question types" options={questionTypeFilterOptions} showAll={true} - onOptionChange={(optionValue, checked) => { - if (checked) { - setSelectedQuestionTypes([...selectedQuestionTypes, optionValue]); + onOptionChange={(option) => { + if (option.checked) { + setSelectedQuestionTypes([...selectedQuestionTypes, option.value]); } else { setSelectedQuestionTypes( selectedQuestionTypes.filter( - (questionType) => questionType !== optionValue, + (questionType) => questionType !== option.value, ), ); } @@ -341,68 +401,47 @@ export default function QuestionsBrowsePage() { label="Question age" options={questionAgeFilterOptions} showAll={true} - onOptionChange={(optionValue) => { - setSelectedQuestionAge(optionValue); + onOptionChange={({ value }) => { + setSelectedQuestionAge(value); }} /> ( - ( + {}} - onSelect={({ value }) => { - onOptionChange(value, true); + clearOnSelect={true} + filterOption={(option) => { + return !selectedLocationOptions.some((selectedOption) => { + return selectedOption.value === option.value; + }); }} - /> - )} - onOptionChange={(optionValue, checked) => { - if (checked) { - setSelectedRoles([...selectedRoles, optionValue]); - } else { - setSelectedRoles( - selectedRoles.filter((role) => role !== optionValue), - ); - } - }} - /> - ( - {}} - onSelect={({ value }) => { - onOptionChange(value, true); + onSelect={(option) => { + onOptionChange({ + ...option, + checked: true, + }); }} /> )} - onOptionChange={(optionValue, checked) => { - if (checked) { - setSelectedLocations([...selectedLocations, optionValue]); + onOptionChange={(option) => { + if (option.checked) { + setSelectedLocations([...selectedLocations, option.value]); + setSelectedLocationOptions((prevOptions) => [ + ...prevOptions, + { ...option, checked: true }, + ]); } else { setSelectedLocations( - selectedLocations.filter((location) => location !== optionValue), + selectedLocations.filter((role) => role !== option.value), + ); + setSelectedLocationOptions((prevOptions) => + prevOptions.filter( + (prevOption) => prevOption.value !== option.value, + ), ); } }} @@ -443,29 +482,50 @@ export default function QuestionsBrowsePage() { onSortOrderChange={setSortOrder} onSortTypeChange={setSortType} /> -
- {(questions ?? []).map((question) => ( - - ))} - {questions?.length === 0 && ( +
+ {(questionsQueryData?.pages ?? []).flatMap( + ({ data: questions }) => + questions.map((question) => ( + + )), + )} +