[questions][ui] decouple landing and browse pages

pull/411/head
Jeff Sieu 3 years ago
parent 1361c5bfab
commit db8379bdc8

@ -1,7 +1,7 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [
{ href: '/questions', name: 'Home' },
{ href: '/questions/browse', name: 'Browse' },
{ href: '/questions/lists', name: 'My Lists' },
{ href: '/questions/my-questions', name: 'My Questions' },
{ href: '/questions/history', name: 'History' },

@ -0,0 +1,405 @@
import { subMonths, subYears } from 'date-fns';
import Head from 'next/head';
import Router, { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { SlideOut, Typeahead } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import type { QuestionAge } 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 createSlug from '~/utils/questions/createSlug';
import {
useSearchFilter,
useSearchFilterSingle,
} from '~/utils/questions/useSearchFilter';
import { trpc } from '~/utils/trpc';
export default function QuestionsBrowsePage() {
const router = useRouter();
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
useSearchFilter('companies');
const [
selectedQuestionTypes,
setSelectedQuestionTypes,
areQuestionTypesInitialized,
] = useSearchFilter<QuestionsQuestionType>('questionTypes', {
queryParamToValue: (param) => {
const uppercaseParam = param.toUpperCase();
return (
QUESTION_TYPES.find(
(questionType) => questionType.value.toUpperCase() === uppercaseParam,
)?.value ?? null
);
},
});
const [
selectedQuestionAge,
setSelectedQuestionAge,
isQuestionAgeInitialized,
] = useSearchFilterSingle<QuestionAge>('questionAge', {
defaultValue: 'all',
queryParamToValue: (param) => {
const uppercaseParam = param.toUpperCase();
return (
QUESTION_AGES.find(
(questionAge) => questionAge.value.toUpperCase() === uppercaseParam,
)?.value ?? null
);
},
});
const [selectedRoles, setSelectedRoles, areRolesInitialized] =
useSearchFilter('roles');
const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchFilter('locations');
const today = useMemo(() => new Date(), []);
const startDate = useMemo(() => {
return selectedQuestionAge === 'last-year'
? subYears(new Date(), 1)
: selectedQuestionAge === 'last-6-months'
? subMonths(new Date(), 6)
: selectedQuestionAge === 'last-month'
? subMonths(new Date(), 1)
: undefined;
}, [selectedQuestionAge]);
const { data: questions } = trpc.useQuery(
[
'questions.questions.getQuestionsByFilter',
{
companyNames: selectedCompanies,
endDate: today,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
startDate,
},
],
{
keepPreviousData: true,
},
);
const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create',
{
onSuccess: () => {
utils.invalidateQueries('questions.questions.getQuestionsByFilter');
},
},
);
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 questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({
...questionType,
checked: selectedQuestionTypes.includes(questionType.value),
}));
}, [selectedQuestionTypes]);
const questionAgeFilterOptions = useMemo(() => {
return QUESTION_AGES.map((questionAge) => ({
...questionAge,
checked: selectedQuestionAge === questionAge.value,
}));
}, [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 areFiltersInitialized = useMemo(() => {
return (
areCompaniesInitialized &&
areQuestionTypesInitialized &&
isQuestionAgeInitialized &&
areRolesInitialized &&
areLocationsInitialized
);
}, [
areCompaniesInitialized,
areQuestionTypesInitialized,
isQuestionAgeInitialized,
areRolesInitialized,
areLocationsInitialized,
]);
const { pathname } = router;
useEffect(() => {
if (areFiltersInitialized) {
// Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace
// callback changes on every page load
Router.replace({
pathname,
query: {
companies: selectedCompanies,
locations: selectedLocations,
questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
},
});
setLoaded(true);
}
}, [
areFiltersInitialized,
loaded,
pathname,
selectedCompanies,
selectedRoles,
selectedLocations,
selectedQuestionAge,
selectedQuestionTypes,
]);
if (!loaded) {
return null;
}
const filterSidebar = (
<div className="mt-2 divide-y divide-slate-200 px-4">
<FilterSection
label="Company"
options={companyFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
label=""
options={options}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedCompanies([...selectedCompanies, optionValue]);
} else {
setSelectedCompanies(
selectedCompanies.filter((company) => company !== optionValue),
);
}
}}
/>
<FilterSection
label="Question types"
options={questionTypeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionTypes([...selectedQuestionTypes, optionValue]);
} else {
setSelectedQuestionTypes(
selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue,
),
);
}
}}
/>
<FilterSection
isSingleSelect={true}
label="Question age"
options={questionAgeFilterOptions}
showAll={true}
onOptionChange={(optionValue) => {
setSelectedQuestionAge(optionValue);
}}
/>
<FilterSection
label="Roles"
options={roleFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
label=""
options={options}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedRoles([...selectedRoles, optionValue]);
} else {
setSelectedRoles(
selectedRoles.filter((role) => role !== optionValue),
);
}
}}
/>
<FilterSection
label="Location"
options={locationFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
label=""
options={options}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedLocations([...selectedLocations, optionValue]);
} else {
setSelectedLocations(
selectedLocations.filter((location) => location !== optionValue),
);
}
}}
/>
</div>
);
return (
<>
<Head>
<title>Home - {APP_TITLE}</title>
</Head>
<main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<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 flex-1 flex-col items-stretch justify-start gap-4">
<ContributeQuestionCard
onSubmit={(data) => {
createQuestion({
companyId: data.company,
content: data.questionContent,
location: data.location,
questionType: data.questionType,
role: data.role,
seenAt: data.date,
});
}}
/>
<QuestionSearchBar
sortOptions={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
sortValue="most-recent"
onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen);
}}
onSortChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
<div className="flex flex-col gap-4 pb-4">
{(questions ?? []).map((question) => (
<QuestionOverviewCard
key={question.id}
answerCount={question.numAnswers}
companies={{ [question.company]: 1 }}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={{ [question.location]: 1 }}
questionId={question.id}
receivedCount={question.receivedCount}
roles={{ [question.role]: 1 }}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
type={question.type}
upvoteCount={question.numVotes}
/>
))}
{questions?.length === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">
<NoSymbolIcon className="h-6 w-6" />
<p>Nothing found. Try changing your search filters.</p>
</div>
)}
</div>
</div>
</div>
</section>
<aside className="hidden w-[300px] overflow-y-auto border-l bg-white py-4 lg:block">
<h2 className="px-4 text-xl font-semibold">Filter by</h2>
{filterSidebar}
</aside>
<SlideOut
className="lg:hidden"
enterFrom="end"
isShown={filterDrawerOpen}
size="sm"
title="Filter by"
onClose={() => {
setFilterDrawerOpen(false);
}}>
{filterSidebar}
</SlideOut>
</div>
</main>
</>
);
}

@ -1,435 +1,34 @@
import { subMonths, subYears } from 'date-fns';
import Head from 'next/head';
import Router, { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { SlideOut, Typeahead } from '@tih/ui';
import { useRouter } from 'next/router';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import FilterSection from '~/components/questions/filter/FilterSection';
import type { LandingQueryData } from '~/components/questions/LandingComponent';
import LandingComponent from '~/components/questions/LandingComponent';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import type { QuestionAge } 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 createSlug from '~/utils/questions/createSlug';
import {
useSearchFilter,
useSearchFilterSingle,
} from '~/utils/questions/useSearchFilter';
import { trpc } from '~/utils/trpc';
export default function QuestionsHomePage() {
const router = useRouter();
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
useSearchFilter('companies');
const [
selectedQuestionTypes,
setSelectedQuestionTypes,
areQuestionTypesInitialized,
] = useSearchFilter<QuestionsQuestionType>('questionTypes', {
queryParamToValue: (param) => {
const uppercaseParam = param.toUpperCase();
return (
QUESTION_TYPES.find(
(questionType) => questionType.value.toUpperCase() === uppercaseParam,
)?.value ?? null
);
},
});
const [
selectedQuestionAge,
setSelectedQuestionAge,
isQuestionAgeInitialized,
] = useSearchFilterSingle<QuestionAge>('questionAge', {
defaultValue: 'all',
queryParamToValue: (param) => {
const uppercaseParam = param.toUpperCase();
return (
QUESTION_AGES.find(
(questionAge) => questionAge.value.toUpperCase() === uppercaseParam,
)?.value ?? null
);
},
});
const [selectedRoles, setSelectedRoles, areRolesInitialized] =
useSearchFilter('roles');
const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchFilter('locations');
const today = useMemo(() => new Date(), []);
const startDate = useMemo(() => {
return selectedQuestionAge === 'last-year'
? subYears(new Date(), 1)
: selectedQuestionAge === 'last-6-months'
? subMonths(new Date(), 6)
: selectedQuestionAge === 'last-month'
? subMonths(new Date(), 1)
: undefined;
}, [selectedQuestionAge]);
const { data: questions } = trpc.useQuery(
[
'questions.questions.getQuestionsByFilter',
{
companyNames: selectedCompanies,
endDate: today,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
startDate,
},
],
{
keepPreviousData: true,
},
);
const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create',
{
onSuccess: () => {
utils.invalidateQueries('questions.questions.getQuestionsByFilter');
},
},
);
const [hasLanded, setHasLanded] = useState(false);
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 questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({
...questionType,
checked: selectedQuestionTypes.includes(questionType.value),
}));
}, [selectedQuestionTypes]);
const questionAgeFilterOptions = useMemo(() => {
return QUESTION_AGES.map((questionAge) => ({
...questionAge,
checked: selectedQuestionAge === questionAge.value,
}));
}, [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 handleLandingQuery = async (data: LandingQueryData) => {
const { company, location, questionType } = data;
setSelectedCompanies([company]);
setSelectedRoles([]);
setSelectedLocations([location]);
setSelectedQuestionTypes([questionType as QuestionsQuestionType]);
setHasLanded(true);
};
const areFiltersInitialized = useMemo(() => {
return (
areCompaniesInitialized &&
areQuestionTypesInitialized &&
isQuestionAgeInitialized &&
areRolesInitialized &&
areLocationsInitialized
);
}, [
areCompaniesInitialized,
areQuestionTypesInitialized,
isQuestionAgeInitialized,
areRolesInitialized,
areLocationsInitialized,
]);
const { pathname } = router;
useEffect(() => {
if (areFiltersInitialized) {
// Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace
// callback changes on every page load
Router.replace({
pathname,
// Go to browse page
router.push({
pathname: '/questions/browse',
query: {
companies: selectedCompanies,
locations: selectedLocations,
questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
companies: [company],
locations: [location],
questionTypes: [questionType],
},
});
const hasFilter =
selectedCompanies.length > 0 ||
selectedRoles.length > 0 ||
selectedLocations.length > 0 ||
selectedQuestionAge !== 'all' ||
selectedQuestionTypes.length > 0;
if (hasFilter) {
setHasLanded(true);
}
setLoaded(true);
}
}, [
areFiltersInitialized,
hasLanded,
loaded,
pathname,
selectedCompanies,
selectedRoles,
selectedLocations,
selectedQuestionAge,
selectedQuestionTypes,
]);
if (!loaded) {
return null;
}
const filterSidebar = (
<div className="mt-2 divide-y divide-slate-200 px-4">
<FilterSection
label="Company"
options={companyFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
label=""
options={options}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedCompanies([...selectedCompanies, optionValue]);
} else {
setSelectedCompanies(
selectedCompanies.filter((company) => company !== optionValue),
);
}
}}
/>
<FilterSection
label="Question types"
options={questionTypeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionTypes([...selectedQuestionTypes, optionValue]);
} else {
setSelectedQuestionTypes(
selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue,
),
);
}
}}
/>
<FilterSection
isSingleSelect={true}
label="Question age"
options={questionAgeFilterOptions}
showAll={true}
onOptionChange={(optionValue) => {
setSelectedQuestionAge(optionValue);
}}
/>
<FilterSection
label="Roles"
options={roleFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
label=""
options={options}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedRoles([...selectedRoles, optionValue]);
} else {
setSelectedRoles(
selectedRoles.filter((role) => role !== optionValue),
);
}
}}
/>
<FilterSection
label="Location"
options={locationFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
label=""
options={options}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedLocations([...selectedLocations, optionValue]);
} else {
setSelectedLocations(
selectedLocations.filter((location) => location !== optionValue),
);
}
}}
/>
</div>
);
};
return (
<>
<Head>
<title>Home - {APP_TITLE}</title>
</Head>
{!hasLanded ? (
<LandingComponent onLanded={handleLandingQuery}></LandingComponent>
) : (
<main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<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 flex-1 flex-col items-stretch justify-start gap-4">
<ContributeQuestionCard
onSubmit={(data) => {
createQuestion({
companyId: data.company,
content: data.questionContent,
location: data.location,
questionType: data.questionType,
role: data.role,
seenAt: data.date,
});
}}
/>
<QuestionSearchBar
sortOptions={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
sortValue="most-recent"
onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen);
}}
onSortChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
<div className="flex flex-col gap-4 pb-4">
{(questions ?? []).map((question) => (
<QuestionOverviewCard
key={question.id}
answerCount={question.numAnswers}
companies={{ [question.company]: 1 }}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={{ [question.location]: 1 }}
questionId={question.id}
receivedCount={question.receivedCount}
roles={{ [question.role]: 1 }}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
month: 'short',
year: 'numeric',
},
)}
type={question.type}
upvoteCount={question.numVotes}
/>
))}
{questions?.length === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">
<NoSymbolIcon className="h-6 w-6" />
<p>Nothing found. Try changing your search filters.</p>
</div>
)}
</div>
</div>
</div>
</section>
<aside className="hidden w-[300px] overflow-y-auto border-l bg-white py-4 lg:block">
<h2 className="px-4 text-xl font-semibold">Filter by</h2>
{filterSidebar}
</aside>
<SlideOut
className="lg:hidden"
enterFrom="end"
isShown={filterDrawerOpen}
size="sm"
title="Filter by"
onClose={() => {
setFilterDrawerOpen(false);
}}>
{filterSidebar}
</SlideOut>
</div>
</main>
)}
</>
);
}

Loading…
Cancel
Save