mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-28 04:33:42 +08:00
[questions][feat] add lists ui, sorting, re-design landing page (#411)
Co-authored-by: wlren <weilinwork99@gmail.com>
This commit is contained in:
@ -15,6 +15,7 @@
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@heroicons/react": "^2.0.11",
|
||||
"@next-auth/prisma-adapter": "^1.0.4",
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"@prisma/client": "^4.4.0",
|
||||
"@supabase/supabase-js": "^1.35.7",
|
||||
"@tih/ui": "*",
|
||||
@ -33,6 +34,8 @@
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.36.1",
|
||||
"react-pdf": "^5.7.2",
|
||||
"react-popper": "^2.3.0",
|
||||
"react-popper-tooltip": "^4.4.2",
|
||||
"react-query": "^3.39.2",
|
||||
"superjson": "^1.10.0",
|
||||
"zod": "^3.18.0"
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
import { TextInput } from '@tih/ui';
|
||||
|
||||
import ContributeQuestionDialog from './ContributeQuestionDialog';
|
||||
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
|
||||
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
|
||||
|
||||
export type ContributeQuestionCardProps = Pick<
|
||||
ContributeQuestionFormProps,
|
||||
|
@ -2,9 +2,9 @@ import { Fragment, useState } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { HorizontalDivider } from '@tih/ui';
|
||||
|
||||
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
|
||||
import ContributeQuestionForm from './ContributeQuestionForm';
|
||||
import DiscardDraftDialog from './DiscardDraftDialog';
|
||||
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
|
||||
import ContributeQuestionForm from './forms/ContributeQuestionForm';
|
||||
|
||||
export type ContributeQuestionDialogProps = Pick<
|
||||
ContributeQuestionFormProps,
|
||||
@ -60,14 +60,14 @@ export default function ContributeQuestionDialog({
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<Dialog.Panel className="relative max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full">
|
||||
<Dialog.Panel className="relative w-full max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8">
|
||||
<div className="bg-white p-6 pt-5 sm:pb-4">
|
||||
<div className="flex flex-1 items-stretch">
|
||||
<div className="mt-3 w-full sm:mt-0 sm:text-left">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-900">
|
||||
Question Draft
|
||||
Contribute question
|
||||
</Dialog.Title>
|
||||
<div className="w-full">
|
||||
<HorizontalDivider />
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowSmallRightIcon } from '@heroicons/react/24/outline';
|
||||
import type { QuestionsQuestionType } from '@prisma/client';
|
||||
import { Button, Select } from '@tih/ui';
|
||||
|
||||
import {
|
||||
COMPANIES,
|
||||
LOCATIONS,
|
||||
QUESTION_TYPES,
|
||||
} from '~/utils/questions/constants';
|
||||
import { QUESTION_TYPES } from '~/utils/questions/constants';
|
||||
import useDefaultCompany from '~/utils/questions/useDefaultCompany';
|
||||
import useDefaultLocation from '~/utils/questions/useDefaultLocation';
|
||||
|
||||
import type { FilterChoice } from './filter/FilterSection';
|
||||
import CompanyTypeahead from './typeahead/CompanyTypeahead';
|
||||
import LocationTypeahead from './typeahead/LocationTypeahead';
|
||||
|
||||
export type LandingQueryData = {
|
||||
company: string;
|
||||
@ -22,76 +24,109 @@ export type LandingComponentProps = {
|
||||
export default function LandingComponent({
|
||||
onLanded: handleLandingQuery,
|
||||
}: LandingComponentProps) {
|
||||
const [landingQueryData, setLandingQueryData] = useState<LandingQueryData>({
|
||||
company: 'Google',
|
||||
location: 'Singapore',
|
||||
questionType: 'CODING',
|
||||
});
|
||||
const defaultCompany = useDefaultCompany();
|
||||
const defaultLocation = useDefaultLocation();
|
||||
|
||||
const handleChangeCompany = (company: string) => {
|
||||
setLandingQueryData((prev) => ({ ...prev, company }));
|
||||
const [company, setCompany] = useState<FilterChoice | undefined>(
|
||||
defaultCompany,
|
||||
);
|
||||
const [location, setLocation] = useState<FilterChoice | undefined>(
|
||||
defaultLocation,
|
||||
);
|
||||
|
||||
const [questionType, setQuestionType] =
|
||||
useState<QuestionsQuestionType>('CODING');
|
||||
|
||||
const handleChangeCompany = (newCompany: FilterChoice) => {
|
||||
setCompany(newCompany);
|
||||
};
|
||||
|
||||
const handleChangeLocation = (location: string) => {
|
||||
setLandingQueryData((prev) => ({ ...prev, location }));
|
||||
const handleChangeLocation = (newLocation: FilterChoice) => {
|
||||
setLocation(newLocation);
|
||||
};
|
||||
|
||||
const handleChangeType = (questionType: QuestionsQuestionType) => {
|
||||
setLandingQueryData((prev) => ({ ...prev, questionType }));
|
||||
const handleChangeType = (newQuestionType: QuestionsQuestionType) => {
|
||||
setQuestionType(newQuestionType);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (company === undefined) {
|
||||
setCompany(defaultCompany);
|
||||
}
|
||||
}, [defaultCompany, company]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location === undefined) {
|
||||
setLocation(defaultLocation);
|
||||
}
|
||||
}, [defaultLocation, location]);
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col items-stretch overflow-y-auto bg-white">
|
||||
<div className="pb-4"></div>
|
||||
<div className="flex flex-1 flex-col justify-center gap-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<img alt="app logo" className=" h-20 w-20" src="/logo.svg"></img>
|
||||
<h1 className="text-primary-600 p-4 text-center text-5xl font-bold">
|
||||
Tech Interview Question Bank
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mx-auto max-w-lg p-6 text-center text-xl text-black sm:max-w-3xl">
|
||||
Get to know the latest SWE interview questions asked by top companies
|
||||
</p>
|
||||
|
||||
<div className="mx-auto flex max-w-lg items-baseline gap-3 p-4 text-center text-xl text-black sm:max-w-3xl">
|
||||
<p>Find</p>
|
||||
<div className=" space-x-2">
|
||||
<main className="flex flex-1 flex-col items-center overflow-y-auto bg-white">
|
||||
<div className="flex flex-1 flex-col items-start justify-center gap-12 px-4">
|
||||
<header className="flex flex-col items-start gap-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<h1 className="text-3xl font-semibold text-slate-900">
|
||||
Tech Interview Question Bank
|
||||
</h1>
|
||||
<img alt="app logo" className="h-20 w-20" src="/logo.svg"></img>
|
||||
</div>
|
||||
<p className="mb-2 max-w-lg text-5xl font-semibold text-slate-900 sm:max-w-3xl">
|
||||
Know the{' '}
|
||||
<span className="text-primary-700">
|
||||
latest SWE interview questions
|
||||
</span>{' '}
|
||||
asked by top companies.
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex flex-col items-start gap-3 text-xl font-semibold text-slate-900">
|
||||
<p className="text-3xl">Find questions</p>
|
||||
<div className="grid grid-cols-[auto_auto] items-baseline gap-x-4 gap-y-2">
|
||||
<p className="text-slate-600">about</p>
|
||||
<Select
|
||||
isLabelHidden={true}
|
||||
label="Type"
|
||||
options={QUESTION_TYPES}
|
||||
value={landingQueryData.questionType}
|
||||
value={questionType}
|
||||
onChange={(value) => {
|
||||
handleChangeType(value.toUpperCase() as QuestionsQuestionType);
|
||||
}}
|
||||
/>
|
||||
<p className="text-slate-600">from</p>
|
||||
<CompanyTypeahead
|
||||
isLabelHidden={true}
|
||||
value={company}
|
||||
onSelect={(value) => {
|
||||
handleChangeCompany(value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-slate-600">in</p>
|
||||
<LocationTypeahead
|
||||
isLabelHidden={true}
|
||||
value={location}
|
||||
onSelect={(value) => {
|
||||
handleChangeLocation(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p>questions from</p>
|
||||
<Select
|
||||
isLabelHidden={true}
|
||||
label="Company"
|
||||
options={COMPANIES}
|
||||
value={landingQueryData.company}
|
||||
onChange={handleChangeCompany}
|
||||
/>
|
||||
<p>in</p>
|
||||
<Select
|
||||
isLabelHidden={true}
|
||||
label="Location"
|
||||
options={LOCATIONS}
|
||||
value={landingQueryData.location}
|
||||
onChange={handleChangeLocation}
|
||||
/>
|
||||
<Button
|
||||
addonPosition="end"
|
||||
icon={ArrowSmallRightIcon}
|
||||
label="Go"
|
||||
size="md"
|
||||
variant="primary"
|
||||
onClick={() => handleLandingQuery(landingQueryData)}></Button>
|
||||
onClick={() => {
|
||||
if (company !== undefined && location !== undefined) {
|
||||
return handleLandingQuery({
|
||||
company: company.value,
|
||||
location: location.value,
|
||||
questionType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-center p-4">
|
||||
<div className="flex justify-center">
|
||||
<iframe
|
||||
height={30}
|
||||
src="https://ghbtns.com/github-btn.html?user=yangshun&repo=tech-interview-handbook&type=star&count=true&size=large"
|
||||
|
@ -0,0 +1,80 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { usePopperTooltip } from 'react-popper-tooltip';
|
||||
import { Badge } from '@tih/ui';
|
||||
|
||||
import 'react-popper-tooltip/dist/styles.css';
|
||||
|
||||
type BadgeProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export type QuestionAggregateBadgeProps = Omit<BadgeProps, 'label'> & {
|
||||
statistics: Record<string, number>;
|
||||
};
|
||||
|
||||
export default function QuestionAggregateBadge({
|
||||
statistics,
|
||||
...badgeProps
|
||||
}: QuestionAggregateBadgeProps) {
|
||||
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
|
||||
usePopperTooltip({
|
||||
interactive: true,
|
||||
placement: 'bottom-start',
|
||||
trigger: ['focus', 'hover'],
|
||||
});
|
||||
|
||||
const mostCommonStatistic = useMemo(
|
||||
() =>
|
||||
Object.entries(statistics).reduce(
|
||||
(mostCommon, [key, value]) => {
|
||||
if (value > mostCommon.value) {
|
||||
return { key, value };
|
||||
}
|
||||
return mostCommon;
|
||||
},
|
||||
{ key: '', value: 0 },
|
||||
),
|
||||
[statistics],
|
||||
);
|
||||
|
||||
const sortedStatistics = useMemo(
|
||||
() =>
|
||||
Object.entries(statistics)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([key, value]) => ({ key, value })),
|
||||
|
||||
[statistics],
|
||||
);
|
||||
|
||||
const additionalStatisticCount = Object.keys(statistics).length - 1;
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (additionalStatisticCount === 0) {
|
||||
return mostCommonStatistic.key;
|
||||
}
|
||||
return `${mostCommonStatistic.key} (+${additionalStatisticCount})`;
|
||||
}, [mostCommonStatistic, additionalStatisticCount]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={setTriggerRef} className="rounded-full" type="button">
|
||||
<Badge label={label} {...badgeProps} />
|
||||
</button>
|
||||
{visible && (
|
||||
<div ref={setTooltipRef} {...getTooltipProps()}>
|
||||
<div className="flex flex-col gap-2 rounded-md bg-white p-2 shadow-md">
|
||||
<ul>
|
||||
{sortedStatistics.map(({ key, value }) => (
|
||||
<li
|
||||
key={key}
|
||||
className="flex justify-between gap-x-4 rtl:flex-row-reverse">
|
||||
<span className="flex text-start font-semibold">{key}</span>
|
||||
<span className="float-end">{value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -4,29 +4,41 @@ import {
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Button, Select, TextInput } from '@tih/ui';
|
||||
|
||||
export type SortOption = {
|
||||
export type SortOption<Value> = {
|
||||
label: string;
|
||||
value: string;
|
||||
value: Value;
|
||||
};
|
||||
|
||||
export type QuestionSearchBarProps<SortOptions extends Array<SortOption>> = {
|
||||
onFilterOptionsToggle: () => void;
|
||||
onSortChange?: (sortValue: SortOptions[number]['value']) => void;
|
||||
sortOptions: SortOptions;
|
||||
sortValue: SortOptions[number]['value'];
|
||||
type SortOrderProps<SortOrder> = {
|
||||
onSortOrderChange?: (sortValue: SortOrder) => void;
|
||||
sortOrderOptions: ReadonlyArray<SortOption<SortOrder>>;
|
||||
sortOrderValue: SortOrder;
|
||||
};
|
||||
|
||||
export default function QuestionSearchBar<
|
||||
SortOptions extends Array<SortOption>,
|
||||
>({
|
||||
onSortChange,
|
||||
sortOptions,
|
||||
sortValue,
|
||||
type SortTypeProps<SortType> = {
|
||||
onSortTypeChange?: (sortType: SortType) => void;
|
||||
sortTypeOptions: ReadonlyArray<SortOption<SortType>>;
|
||||
sortTypeValue: SortType;
|
||||
};
|
||||
|
||||
export type QuestionSearchBarProps<SortType, SortOrder> =
|
||||
SortOrderProps<SortOrder> &
|
||||
SortTypeProps<SortType> & {
|
||||
onFilterOptionsToggle: () => void;
|
||||
};
|
||||
|
||||
export default function QuestionSearchBar<SortType, SortOrder>({
|
||||
onSortOrderChange,
|
||||
sortOrderOptions,
|
||||
sortOrderValue,
|
||||
onSortTypeChange,
|
||||
sortTypeOptions,
|
||||
sortTypeValue,
|
||||
onFilterOptionsToggle,
|
||||
}: QuestionSearchBarProps<SortOptions>) {
|
||||
}: QuestionSearchBarProps<SortType, SortOrder>) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end">
|
||||
<div className="flex-1 ">
|
||||
<TextInput
|
||||
isLabelHidden={true}
|
||||
label="Search by content"
|
||||
@ -35,27 +47,48 @@ export default function QuestionSearchBar<
|
||||
startAddOnType="icon"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden={true} className="align-middle text-sm font-medium">
|
||||
Sort by:
|
||||
</span>
|
||||
<Select
|
||||
display="inline"
|
||||
isLabelHidden={true}
|
||||
label="Sort by"
|
||||
options={sortOptions}
|
||||
value={sortValue}
|
||||
onChange={onSortChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:hidden">
|
||||
<Button
|
||||
addonPosition="start"
|
||||
icon={AdjustmentsHorizontalIcon}
|
||||
label="Filter options"
|
||||
variant="tertiary"
|
||||
onClick={onFilterOptionsToggle}
|
||||
/>
|
||||
<div className="flex items-end justify-end gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
display="inline"
|
||||
label="Sort by"
|
||||
options={sortTypeOptions}
|
||||
value={sortTypeValue}
|
||||
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 className="lg:hidden">
|
||||
<Button
|
||||
addonPosition="start"
|
||||
icon={AdjustmentsHorizontalIcon}
|
||||
label="Filter options"
|
||||
variant="tertiary"
|
||||
onClick={onFilterOptionsToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,9 +1,10 @@
|
||||
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
|
||||
|
||||
const navigation: ProductNavigationItems = [
|
||||
{ href: '/questions', name: 'My Lists' },
|
||||
{ href: '/questions', name: 'My Questions' },
|
||||
{ href: '/questions', name: 'History' },
|
||||
{ href: '/questions/browse', name: 'Browse' },
|
||||
{ href: '/questions/lists', name: 'My Lists' },
|
||||
{ href: '/questions/my-questions', name: 'My Questions' },
|
||||
{ href: '/questions/history', name: 'History' },
|
||||
];
|
||||
|
||||
const config = {
|
||||
|
@ -13,6 +13,7 @@ export type AnswerCardProps = {
|
||||
commentCount?: number;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
showHover?: boolean;
|
||||
upvoteCount: number;
|
||||
votingButtonsSize: VotingButtonsProps['size'];
|
||||
};
|
||||
@ -26,10 +27,14 @@ export default function AnswerCard({
|
||||
commentCount,
|
||||
votingButtonsSize,
|
||||
upvoteCount,
|
||||
showHover,
|
||||
}: AnswerCardProps) {
|
||||
const { handleUpvote, handleDownvote, vote } = useAnswerVote(answerId);
|
||||
const hoverClass = showHover ? 'hover:bg-slate-50' : '';
|
||||
|
||||
return (
|
||||
<article className="flex gap-4 rounded-md border bg-white p-2">
|
||||
<article
|
||||
className={`flex gap-4 rounded-md border bg-white p-2 ${hoverClass}`}>
|
||||
<VotingButtons
|
||||
size={votingButtonsSize}
|
||||
upvoteCount={upvoteCount}
|
||||
|
@ -1,26 +0,0 @@
|
||||
import type { QuestionCardProps } from './QuestionCard';
|
||||
import QuestionCard from './QuestionCard';
|
||||
|
||||
export type QuestionOverviewCardProps = Omit<
|
||||
QuestionCardProps & {
|
||||
showActionButton: false;
|
||||
showUserStatistics: false;
|
||||
showVoteButtons: true;
|
||||
},
|
||||
| 'actionButtonLabel'
|
||||
| 'onActionButtonClick'
|
||||
| 'showActionButton'
|
||||
| 'showUserStatistics'
|
||||
| 'showVoteButtons'
|
||||
>;
|
||||
|
||||
export default function FullQuestionCard(props: QuestionOverviewCardProps) {
|
||||
return (
|
||||
<QuestionCard
|
||||
{...props}
|
||||
showActionButton={false}
|
||||
showUserStatistics={false}
|
||||
showVoteButtons={true}
|
||||
/>
|
||||
);
|
||||
}
|
@ -4,11 +4,11 @@ import type { AnswerCardProps } from './AnswerCard';
|
||||
import AnswerCard from './AnswerCard';
|
||||
|
||||
export type QuestionAnswerCardProps = Required<
|
||||
Omit<AnswerCardProps, 'votingButtonsSize'>
|
||||
Omit<AnswerCardProps, 'showHover' | 'votingButtonsSize'>
|
||||
>;
|
||||
|
||||
function QuestionAnswerCardWithoutHref(props: QuestionAnswerCardProps) {
|
||||
return <AnswerCard {...props} votingButtonsSize="sm" />;
|
||||
return <AnswerCard {...props} showHover={true} votingButtonsSize="sm" />;
|
||||
}
|
||||
|
||||
const QuestionAnswerCard = withHref(QuestionAnswerCardWithoutHref);
|
||||
|
@ -1,126 +0,0 @@
|
||||
import { ChatBubbleBottomCenterTextIcon } from '@heroicons/react/24/outline';
|
||||
import type { QuestionsQuestionType } from '@prisma/client';
|
||||
import { Badge, Button } from '@tih/ui';
|
||||
|
||||
import { useQuestionVote } from '~/utils/questions/useVote';
|
||||
|
||||
import QuestionTypeBadge from '../QuestionTypeBadge';
|
||||
import VotingButtons from '../VotingButtons';
|
||||
|
||||
type UpvoteProps =
|
||||
| {
|
||||
showVoteButtons: true;
|
||||
upvoteCount: number;
|
||||
}
|
||||
| {
|
||||
showVoteButtons?: false;
|
||||
upvoteCount?: never;
|
||||
};
|
||||
|
||||
type StatisticsProps =
|
||||
| {
|
||||
answerCount: number;
|
||||
showUserStatistics: true;
|
||||
}
|
||||
| {
|
||||
answerCount?: never;
|
||||
showUserStatistics?: false;
|
||||
};
|
||||
|
||||
type ActionButtonProps =
|
||||
| {
|
||||
actionButtonLabel: string;
|
||||
onActionButtonClick: () => void;
|
||||
showActionButton: true;
|
||||
}
|
||||
| {
|
||||
actionButtonLabel?: never;
|
||||
onActionButtonClick?: never;
|
||||
showActionButton?: false;
|
||||
};
|
||||
|
||||
export type QuestionCardProps = ActionButtonProps &
|
||||
StatisticsProps &
|
||||
UpvoteProps & {
|
||||
company: string;
|
||||
content: string;
|
||||
location: string;
|
||||
questionId: string;
|
||||
receivedCount: number;
|
||||
role: string;
|
||||
timestamp: string;
|
||||
type: QuestionsQuestionType;
|
||||
};
|
||||
|
||||
export default function QuestionCard({
|
||||
questionId,
|
||||
company,
|
||||
answerCount,
|
||||
content,
|
||||
// ReceivedCount,
|
||||
type,
|
||||
showVoteButtons,
|
||||
showUserStatistics,
|
||||
showActionButton,
|
||||
actionButtonLabel,
|
||||
onActionButtonClick,
|
||||
upvoteCount,
|
||||
timestamp,
|
||||
role,
|
||||
location,
|
||||
}: QuestionCardProps) {
|
||||
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
|
||||
|
||||
return (
|
||||
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
|
||||
{showVoteButtons && (
|
||||
<VotingButtons
|
||||
upvoteCount={upvoteCount}
|
||||
vote={vote}
|
||||
onDownvote={handleDownvote}
|
||||
onUpvote={handleUpvote}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-baseline gap-2 text-slate-500">
|
||||
<Badge label={company} variant="primary" />
|
||||
<QuestionTypeBadge type={type} />
|
||||
<p className="text-xs">
|
||||
{timestamp} · {location} · {role}
|
||||
</p>
|
||||
</div>
|
||||
{showActionButton && (
|
||||
<Button
|
||||
label={actionButtonLabel}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onClick={onActionButtonClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<p className="line-clamp-2 text-ellipsis ">{content}</p>
|
||||
</div>
|
||||
{showUserStatistics && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
addonPosition="start"
|
||||
icon={ChatBubbleBottomCenterTextIcon}
|
||||
label={`${answerCount} answers`}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
/>
|
||||
{/* <Button
|
||||
addonPosition="start"
|
||||
icon={EyeIcon}
|
||||
label={`${receivedCount} received this`}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
/> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import withHref from '~/utils/questions/withHref';
|
||||
|
||||
import type { QuestionCardProps } from './QuestionCard';
|
||||
import QuestionCard from './QuestionCard';
|
||||
|
||||
export type QuestionOverviewCardProps = Omit<
|
||||
QuestionCardProps & {
|
||||
showActionButton: false;
|
||||
showUserStatistics: true;
|
||||
showVoteButtons: true;
|
||||
},
|
||||
| 'actionButtonLabel'
|
||||
| 'onActionButtonClick'
|
||||
| 'showActionButton'
|
||||
| 'showUserStatistics'
|
||||
| 'showVoteButtons'
|
||||
>;
|
||||
|
||||
function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) {
|
||||
return (
|
||||
<QuestionCard
|
||||
{...props}
|
||||
showActionButton={false}
|
||||
showUserStatistics={true}
|
||||
showVoteButtons={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const QuestionOverviewCard = withHref(QuestionOverviewCardWithoutHref);
|
||||
export default QuestionOverviewCard;
|
@ -1,31 +0,0 @@
|
||||
import type { QuestionCardProps } from './QuestionCard';
|
||||
import QuestionCard from './QuestionCard';
|
||||
|
||||
export type SimilarQuestionCardProps = Omit<
|
||||
QuestionCardProps & {
|
||||
showActionButton: true;
|
||||
showUserStatistics: false;
|
||||
showVoteButtons: false;
|
||||
},
|
||||
| 'actionButtonLabel'
|
||||
| 'answerCount'
|
||||
| 'onActionButtonClick'
|
||||
| 'showActionButton'
|
||||
| 'showUserStatistics'
|
||||
| 'showVoteButtons'
|
||||
| 'upvoteCount'
|
||||
> & {
|
||||
onSimilarQuestionClick: () => void;
|
||||
};
|
||||
|
||||
export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
|
||||
const { onSimilarQuestionClick, ...rest } = props;
|
||||
return (
|
||||
<QuestionCard
|
||||
{...rest}
|
||||
actionButtonLabel="Yes, this is my question"
|
||||
showActionButton={true}
|
||||
onActionButtonClick={onSimilarQuestionClick}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,232 @@
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
CheckIcon,
|
||||
EyeIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { QuestionsQuestionType } from '@prisma/client';
|
||||
import { Button } from '@tih/ui';
|
||||
|
||||
import { useQuestionVote } from '~/utils/questions/useVote';
|
||||
|
||||
import type { CreateQuestionEncounterData } from '../../forms/CreateQuestionEncounterForm';
|
||||
import CreateQuestionEncounterForm from '../../forms/CreateQuestionEncounterForm';
|
||||
import QuestionAggregateBadge from '../../QuestionAggregateBadge';
|
||||
import QuestionTypeBadge from '../../QuestionTypeBadge';
|
||||
import VotingButtons from '../../VotingButtons';
|
||||
|
||||
type UpvoteProps =
|
||||
| {
|
||||
showVoteButtons: true;
|
||||
upvoteCount: number;
|
||||
}
|
||||
| {
|
||||
showVoteButtons?: false;
|
||||
upvoteCount?: never;
|
||||
};
|
||||
|
||||
type DeleteProps =
|
||||
| {
|
||||
onDelete: () => void;
|
||||
showDeleteButton: true;
|
||||
}
|
||||
| {
|
||||
onDelete?: never;
|
||||
showDeleteButton?: false;
|
||||
};
|
||||
|
||||
type AnswerStatisticsProps =
|
||||
| {
|
||||
answerCount: number;
|
||||
showAnswerStatistics: true;
|
||||
}
|
||||
| {
|
||||
answerCount?: never;
|
||||
showAnswerStatistics?: false;
|
||||
};
|
||||
|
||||
type ActionButtonProps =
|
||||
| {
|
||||
actionButtonLabel: string;
|
||||
onActionButtonClick: () => void;
|
||||
showActionButton: true;
|
||||
}
|
||||
| {
|
||||
actionButtonLabel?: never;
|
||||
onActionButtonClick?: never;
|
||||
showActionButton?: false;
|
||||
};
|
||||
|
||||
type ReceivedStatisticsProps =
|
||||
| {
|
||||
receivedCount: number;
|
||||
showReceivedStatistics: true;
|
||||
}
|
||||
| {
|
||||
receivedCount?: never;
|
||||
showReceivedStatistics?: false;
|
||||
};
|
||||
|
||||
type CreateEncounterProps =
|
||||
| {
|
||||
onReceivedSubmit: (data: CreateQuestionEncounterData) => void;
|
||||
showCreateEncounterButton: true;
|
||||
}
|
||||
| {
|
||||
onReceivedSubmit?: never;
|
||||
showCreateEncounterButton?: false;
|
||||
};
|
||||
|
||||
export type BaseQuestionCardProps = ActionButtonProps &
|
||||
AnswerStatisticsProps &
|
||||
CreateEncounterProps &
|
||||
DeleteProps &
|
||||
ReceivedStatisticsProps &
|
||||
UpvoteProps & {
|
||||
companies: Record<string, number>;
|
||||
content: string;
|
||||
locations: Record<string, number>;
|
||||
questionId: string;
|
||||
roles: Record<string, number>;
|
||||
showHover?: boolean;
|
||||
timestamp: string;
|
||||
truncateContent?: boolean;
|
||||
type: QuestionsQuestionType;
|
||||
};
|
||||
|
||||
export default function BaseQuestionCard({
|
||||
questionId,
|
||||
companies,
|
||||
answerCount,
|
||||
content,
|
||||
receivedCount,
|
||||
type,
|
||||
showVoteButtons,
|
||||
showAnswerStatistics,
|
||||
showReceivedStatistics,
|
||||
showCreateEncounterButton,
|
||||
showActionButton,
|
||||
actionButtonLabel,
|
||||
onActionButtonClick,
|
||||
upvoteCount,
|
||||
timestamp,
|
||||
roles,
|
||||
locations,
|
||||
showHover,
|
||||
onReceivedSubmit,
|
||||
showDeleteButton,
|
||||
onDelete,
|
||||
truncateContent = true,
|
||||
}: BaseQuestionCardProps) {
|
||||
const [showReceivedForm, setShowReceivedForm] = useState(false);
|
||||
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
|
||||
const hoverClass = showHover ? 'hover:bg-slate-50' : '';
|
||||
const cardContent = (
|
||||
<>
|
||||
{showVoteButtons && (
|
||||
<VotingButtons
|
||||
upvoteCount={upvoteCount}
|
||||
vote={vote}
|
||||
onDownvote={handleDownvote}
|
||||
onUpvote={handleUpvote}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-baseline gap-2 text-slate-500">
|
||||
<QuestionTypeBadge type={type} />
|
||||
<QuestionAggregateBadge statistics={companies} variant="primary" />
|
||||
<QuestionAggregateBadge statistics={locations} variant="success" />
|
||||
<QuestionAggregateBadge statistics={roles} variant="danger" />
|
||||
<p className="text-xs">{timestamp}</p>
|
||||
</div>
|
||||
{showActionButton && (
|
||||
<Button
|
||||
label={actionButtonLabel}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onClick={onActionButtonClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className={clsx(truncateContent && 'line-clamp-2 text-ellipsis')}>
|
||||
{content}
|
||||
</p>
|
||||
{!showReceivedForm &&
|
||||
(showAnswerStatistics ||
|
||||
showReceivedStatistics ||
|
||||
showCreateEncounterButton) && (
|
||||
<div className="flex gap-2">
|
||||
{showAnswerStatistics && (
|
||||
<Button
|
||||
addonPosition="start"
|
||||
icon={ChatBubbleBottomCenterTextIcon}
|
||||
label={`${answerCount} answers`}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
/>
|
||||
)}
|
||||
{showReceivedStatistics && (
|
||||
<Button
|
||||
addonPosition="start"
|
||||
icon={EyeIcon}
|
||||
label={`${receivedCount} received this`}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
/>
|
||||
)}
|
||||
{showCreateEncounterButton && (
|
||||
<Button
|
||||
addonPosition="start"
|
||||
icon={CheckIcon}
|
||||
label="I received this too"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setShowReceivedForm(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showReceivedForm && (
|
||||
<CreateQuestionEncounterForm
|
||||
onCancel={() => {
|
||||
setShowReceivedForm(false);
|
||||
}}
|
||||
onSubmit={(data) => {
|
||||
onReceivedSubmit?.(data);
|
||||
setShowReceivedForm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`group flex gap-4 rounded-md border border-slate-300 bg-white p-4 ${hoverClass}`}>
|
||||
{cardContent}
|
||||
{showDeleteButton && (
|
||||
<div className="invisible self-center fill-red-700 group-hover:visible">
|
||||
<Button
|
||||
icon={TrashIcon}
|
||||
isLabelHidden={true}
|
||||
label="Delete"
|
||||
size="md"
|
||||
variant="tertiary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import type { BaseQuestionCardProps } from './BaseQuestionCard';
|
||||
import BaseQuestionCard from './BaseQuestionCard';
|
||||
|
||||
export type QuestionOverviewCardProps = Omit<
|
||||
BaseQuestionCardProps & {
|
||||
showActionButton: false;
|
||||
showAnswerStatistics: false;
|
||||
showCreateEncounterButton: true;
|
||||
showDeleteButton: false;
|
||||
showReceivedStatistics: false;
|
||||
showVoteButtons: true;
|
||||
},
|
||||
| 'actionButtonLabel'
|
||||
| 'onActionButtonClick'
|
||||
| 'showActionButton'
|
||||
| 'showAnswerStatistics'
|
||||
| 'showCreateEncounterButton'
|
||||
| 'showDeleteButton'
|
||||
| 'showReceivedStatistics'
|
||||
| 'showVoteButtons'
|
||||
>;
|
||||
|
||||
export default function FullQuestionCard(props: QuestionOverviewCardProps) {
|
||||
return (
|
||||
<BaseQuestionCard
|
||||
{...props}
|
||||
showActionButton={false}
|
||||
showAnswerStatistics={false}
|
||||
showCreateEncounterButton={true}
|
||||
showReceivedStatistics={false}
|
||||
showVoteButtons={true}
|
||||
truncateContent={false}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
import withHref from '~/utils/questions/withHref';
|
||||
|
||||
import type { BaseQuestionCardProps } from './BaseQuestionCard';
|
||||
import BaseQuestionCard from './BaseQuestionCard';
|
||||
|
||||
export type QuestionListCardProps = Omit<
|
||||
BaseQuestionCardProps & {
|
||||
showActionButton: false;
|
||||
showAnswerStatistics: false;
|
||||
showDeleteButton: true;
|
||||
showVoteButtons: false;
|
||||
},
|
||||
| 'actionButtonLabel'
|
||||
| 'onActionButtonClick'
|
||||
| 'showActionButton'
|
||||
| 'showAnswerStatistics'
|
||||
| 'showDeleteButton'
|
||||
| 'showVoteButtons'
|
||||
>;
|
||||
|
||||
function QuestionListCardWithoutHref(props: QuestionListCardProps) {
|
||||
return (
|
||||
<BaseQuestionCard
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{...(props as any)}
|
||||
showActionButton={false}
|
||||
showAnswerStatistics={false}
|
||||
showDeleteButton={true}
|
||||
showHover={true}
|
||||
showVoteButtons={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const QuestionListCard = withHref(QuestionListCardWithoutHref);
|
||||
export default QuestionListCard;
|
@ -0,0 +1,42 @@
|
||||
import withHref from '~/utils/questions/withHref';
|
||||
|
||||
import type { BaseQuestionCardProps } from './BaseQuestionCard';
|
||||
import BaseQuestionCard from './BaseQuestionCard';
|
||||
|
||||
export type QuestionOverviewCardProps = Omit<
|
||||
BaseQuestionCardProps & {
|
||||
showActionButton: false;
|
||||
showAnswerStatistics: true;
|
||||
showCreateEncounterButton: false;
|
||||
showDeleteButton: false;
|
||||
showReceivedStatistics: true;
|
||||
showVoteButtons: true;
|
||||
},
|
||||
| 'actionButtonLabel'
|
||||
| 'onActionButtonClick'
|
||||
| 'onDelete'
|
||||
| 'showActionButton'
|
||||
| 'showAnswerStatistics'
|
||||
| 'showCreateEncounterButton'
|
||||
| 'showDeleteButton'
|
||||
| 'showReceivedStatistics'
|
||||
| 'showVoteButtons'
|
||||
>;
|
||||
|
||||
function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) {
|
||||
return (
|
||||
<BaseQuestionCard
|
||||
{...props}
|
||||
showActionButton={false}
|
||||
showAnswerStatistics={true}
|
||||
showCreateEncounterButton={false}
|
||||
showDeleteButton={false}
|
||||
showHover={true}
|
||||
showReceivedStatistics={true}
|
||||
showVoteButtons={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const QuestionOverviewCard = withHref(QuestionOverviewCardWithoutHref);
|
||||
export default QuestionOverviewCard;
|
@ -0,0 +1,44 @@
|
||||
import type { BaseQuestionCardProps } from './BaseQuestionCard';
|
||||
import BaseQuestionCard from './BaseQuestionCard';
|
||||
|
||||
export type SimilarQuestionCardProps = Omit<
|
||||
BaseQuestionCardProps & {
|
||||
showActionButton: true;
|
||||
showAnswerStatistics: true;
|
||||
showCreateEncounterButton: false;
|
||||
showDeleteButton: false;
|
||||
showHover: true;
|
||||
showReceivedStatistics: false;
|
||||
showVoteButtons: false;
|
||||
},
|
||||
| 'actionButtonLabel'
|
||||
| 'onActionButtonClick'
|
||||
| 'showActionButton'
|
||||
| 'showAnswerStatistics'
|
||||
| 'showCreateEncounterButton'
|
||||
| 'showDeleteButton'
|
||||
| 'showHover'
|
||||
| 'showReceivedStatistics'
|
||||
| 'showVoteButtons'
|
||||
> & {
|
||||
onSimilarQuestionClick: () => void;
|
||||
};
|
||||
|
||||
export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
|
||||
const { onSimilarQuestionClick, ...rest } = props;
|
||||
return (
|
||||
<BaseQuestionCard
|
||||
actionButtonLabel="Yes, this is my question"
|
||||
showActionButton={true}
|
||||
showAnswerStatistics={true}
|
||||
showCreateEncounterButton={false}
|
||||
showDeleteButton={false}
|
||||
showHover={true}
|
||||
showReceivedStatistics={true}
|
||||
showVoteButtons={true}
|
||||
onActionButtonClick={onSimilarQuestionClick}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{...(rest as any)}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,14 +1,20 @@
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { CheckboxInput, Collapsible, RadioList, TextInput } from '@tih/ui';
|
||||
import { useMemo } from 'react';
|
||||
import type { UseFormRegisterReturn } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { CheckboxInput, Collapsible, RadioList } from '@tih/ui';
|
||||
|
||||
export type FilterOption<V extends string = string> = {
|
||||
checked: boolean;
|
||||
export type FilterChoice<V extends string = string> = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: V;
|
||||
};
|
||||
|
||||
export type FilterOption<V extends string = string> = FilterChoice<V> & {
|
||||
checked: boolean;
|
||||
};
|
||||
|
||||
export type FilterChoices<V extends string = string> = ReadonlyArray<
|
||||
Omit<FilterOption<V>, 'checked'>
|
||||
FilterChoice<V>
|
||||
>;
|
||||
|
||||
type FilterSectionType<FilterOptions extends Array<FilterOption>> =
|
||||
@ -30,42 +36,87 @@ export type FilterSectionProps<FilterOptions extends Array<FilterOption>> =
|
||||
options: FilterOptions;
|
||||
} & (
|
||||
| {
|
||||
searchPlaceholder: string;
|
||||
renderInput: (props: {
|
||||
field: UseFormRegisterReturn<'search'>;
|
||||
onOptionChange: FilterSectionType<FilterOptions>['onOptionChange'];
|
||||
options: FilterOptions;
|
||||
}) => React.ReactNode;
|
||||
showAll?: never;
|
||||
}
|
||||
| {
|
||||
searchPlaceholder?: never;
|
||||
renderInput?: never;
|
||||
showAll: true;
|
||||
}
|
||||
);
|
||||
|
||||
export type FilterSectionFormData = {
|
||||
search: string;
|
||||
};
|
||||
|
||||
export default function FilterSection<
|
||||
FilterOptions extends Array<FilterOption>,
|
||||
>({
|
||||
label,
|
||||
options,
|
||||
searchPlaceholder,
|
||||
showAll,
|
||||
onOptionChange,
|
||||
isSingleSelect,
|
||||
renderInput,
|
||||
}: FilterSectionProps<FilterOptions>) {
|
||||
const { register, reset } = useForm<FilterSectionFormData>();
|
||||
|
||||
const registerSearch = register('search');
|
||||
|
||||
const field: UseFormRegisterReturn<'search'> = {
|
||||
...registerSearch,
|
||||
onChange: async (event) => {
|
||||
await registerSearch.onChange(event);
|
||||
reset();
|
||||
},
|
||||
};
|
||||
|
||||
const autocompleteOptions = useMemo(() => {
|
||||
return options.filter((option) => !option.checked) as FilterOptions;
|
||||
}, [options]);
|
||||
|
||||
const selectedCount = useMemo(() => {
|
||||
return options.filter((option) => option.checked).length;
|
||||
}, [options]);
|
||||
|
||||
const collapsibleLabel = useMemo(() => {
|
||||
if (isSingleSelect) {
|
||||
return label;
|
||||
}
|
||||
if (selectedCount === 0) {
|
||||
return `${label} (all)`;
|
||||
}
|
||||
|
||||
return `${label} (${selectedCount})`;
|
||||
}, [label, selectedCount, isSingleSelect]);
|
||||
|
||||
return (
|
||||
<div className="mx-2">
|
||||
<Collapsible defaultOpen={true} label={label}>
|
||||
<div className="mx-2 py-2">
|
||||
<Collapsible defaultOpen={true} label={collapsibleLabel}>
|
||||
<div className="-mx-2 flex flex-col items-stretch gap-2">
|
||||
{!showAll && (
|
||||
<TextInput
|
||||
isLabelHidden={true}
|
||||
label={label}
|
||||
placeholder={searchPlaceholder}
|
||||
startAddOn={MagnifyingGlassIcon}
|
||||
startAddOnType="icon"
|
||||
/>
|
||||
<div className="z-10">
|
||||
{renderInput({
|
||||
field,
|
||||
onOptionChange: async (
|
||||
optionValue: FilterOptions[number]['value'],
|
||||
) => {
|
||||
reset();
|
||||
return onOptionChange(optionValue, true);
|
||||
},
|
||||
options: autocompleteOptions,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isSingleSelect ? (
|
||||
<div className="px-1.5">
|
||||
<RadioList
|
||||
label=""
|
||||
isLabelHidden={true}
|
||||
label={label}
|
||||
value={options.find((option) => option.checked)?.value}
|
||||
onChange={(value) => {
|
||||
onOptionChange(value);
|
||||
@ -81,16 +132,18 @@ export default function FilterSection<
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-1.5">
|
||||
{options.map((option) => (
|
||||
<CheckboxInput
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
value={option.checked}
|
||||
onChange={(checked) => {
|
||||
onOptionChange(option.value, checked);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{options
|
||||
.filter((option) => showAll || option.checked)
|
||||
.map((option) => (
|
||||
<CheckboxInput
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
value={option.checked}
|
||||
onChange={(checked) => {
|
||||
onOptionChange(option.value, checked);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,26 +1,26 @@
|
||||
import { startOfMonth } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { CalendarDaysIcon, UserIcon } from '@heroicons/react/24/outline';
|
||||
import type { QuestionsQuestionType } from '@prisma/client';
|
||||
import {
|
||||
Button,
|
||||
CheckboxInput,
|
||||
Collapsible,
|
||||
HorizontalDivider,
|
||||
Select,
|
||||
TextArea,
|
||||
TextInput,
|
||||
} from '@tih/ui';
|
||||
|
||||
import { QUESTION_TYPES } from '~/utils/questions/constants';
|
||||
import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants';
|
||||
import {
|
||||
useFormRegister,
|
||||
useSelectRegister,
|
||||
} from '~/utils/questions/useFormRegister';
|
||||
|
||||
import CompaniesTypeahead from '../shared/CompaniesTypeahead';
|
||||
import type { Month } from '../shared/MonthYearPicker';
|
||||
import MonthYearPicker from '../shared/MonthYearPicker';
|
||||
import CompanyTypeahead from '../typeahead/CompanyTypeahead';
|
||||
import LocationTypeahead from '../typeahead/LocationTypeahead';
|
||||
import RoleTypeahead from '../typeahead/RoleTypeahead';
|
||||
import type { Month } from '../../shared/MonthYearPicker';
|
||||
import MonthYearPicker from '../../shared/MonthYearPicker';
|
||||
|
||||
export type ContributeQuestionData = {
|
||||
company: string;
|
||||
@ -59,8 +59,17 @@ export default function ContributeQuestionForm({
|
||||
};
|
||||
return (
|
||||
<form
|
||||
className=" flex flex-1 flex-col items-stretch justify-center pb-[50px]"
|
||||
className="flex flex-1 flex-col items-stretch justify-center gap-y-4"
|
||||
onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="min-w-[113px] max-w-[113px] flex-1">
|
||||
<Select
|
||||
defaultValue="coding"
|
||||
label="Type"
|
||||
options={QUESTION_TYPES}
|
||||
required={true}
|
||||
{...selectRegister('questionType')}
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
label="Question Prompt"
|
||||
placeholder="Contribute a question"
|
||||
@ -68,40 +77,41 @@ export default function ContributeQuestionForm({
|
||||
rows={5}
|
||||
{...register('questionContent')}
|
||||
/>
|
||||
<div className="mt-3 mb-1 flex flex-wrap items-end gap-2">
|
||||
<div className="mr-2 min-w-[113px] max-w-[113px] flex-1">
|
||||
<Select
|
||||
defaultValue="coding"
|
||||
label="Type"
|
||||
options={QUESTION_TYPES}
|
||||
required={true}
|
||||
{...selectRegister('questionType')}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[150px] max-w-[300px] flex-1">
|
||||
<HorizontalDivider />
|
||||
<h2 className="text-md text-primary-800 font-semibold">
|
||||
Additional information
|
||||
</h2>
|
||||
<div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
|
||||
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
|
||||
<Controller
|
||||
control={control}
|
||||
name="company"
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<CompaniesTypeahead
|
||||
onSelect={({ id }) => {
|
||||
field.onChange(id);
|
||||
<LocationTypeahead
|
||||
required={true}
|
||||
onSelect={(option) => {
|
||||
field.onChange(option.value);
|
||||
}}
|
||||
{...field}
|
||||
value={LOCATIONS.find(
|
||||
(location) => location.value === field.value,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[150px] max-w-[300px] flex-1">
|
||||
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
|
||||
<Controller
|
||||
control={control}
|
||||
name="date"
|
||||
render={({ field }) => (
|
||||
<MonthYearPicker
|
||||
monthRequired={true}
|
||||
value={{
|
||||
month: (field.value.getMonth() + 1) as Month,
|
||||
month: ((field.value.getMonth() as number) + 1) as Month,
|
||||
year: field.value.getFullYear(),
|
||||
}}
|
||||
yearRequired={true}
|
||||
onChange={({ month, year }) =>
|
||||
field.onChange(startOfMonth(new Date(year, month - 1)))
|
||||
}
|
||||
@ -110,28 +120,38 @@ export default function ContributeQuestionForm({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible defaultOpen={true} label="Additional info">
|
||||
<div className="justify-left flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-[150px] max-w-[300px] flex-1">
|
||||
<TextInput
|
||||
label="Location"
|
||||
required={true}
|
||||
startAddOn={CalendarDaysIcon}
|
||||
startAddOnType="icon"
|
||||
{...register('location')}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[150px] max-w-[200px] flex-1">
|
||||
<TextInput
|
||||
label="Role"
|
||||
required={true}
|
||||
startAddOn={UserIcon}
|
||||
startAddOnType="icon"
|
||||
{...register('role')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
|
||||
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
|
||||
<Controller
|
||||
control={control}
|
||||
name="company"
|
||||
render={({ field }) => (
|
||||
<CompanyTypeahead
|
||||
required={true}
|
||||
onSelect={({ id }) => {
|
||||
field.onChange(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
<div className="flex-1 sm:min-w-[150px] sm:max-w-[200px]">
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<RoleTypeahead
|
||||
required={true}
|
||||
onSelect={(option) => {
|
||||
field.onChange(option.value);
|
||||
}}
|
||||
{...field}
|
||||
value={ROLES.find((role) => role.value === field.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="w-full">
|
||||
<HorizontalDivider />
|
||||
</div>
|
||||
@ -151,15 +171,20 @@ export default function ContributeQuestionForm({
|
||||
}}
|
||||
/>
|
||||
</div> */}
|
||||
<div className="bg-primary-50 fixed bottom-0 left-0 w-full px-4 py-3 sm:flex sm:flex-row sm:justify-between sm:px-6">
|
||||
<div className="mb-1 flex">
|
||||
<div
|
||||
className="bg-primary-50 flex w-full flex-col gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)] sm:flex-row sm:justify-between"
|
||||
style={{
|
||||
// Hack to make the background bleed outside the container
|
||||
clipPath: 'inset(0 -100vmax)',
|
||||
}}>
|
||||
<div className="my-2 flex sm:my-0">
|
||||
<CheckboxInput
|
||||
label="I have checked that my question is new"
|
||||
value={canSubmit}
|
||||
onChange={handleCheckSimilarQuestions}
|
||||
/>
|
||||
</div>
|
||||
<div className=" flex gap-x-2">
|
||||
<div className="flex gap-x-2">
|
||||
<button
|
||||
className="inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
type="button"
|
@ -0,0 +1,148 @@
|
||||
import { startOfMonth } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@tih/ui';
|
||||
|
||||
import type { Month } from '~/components/shared/MonthYearPicker';
|
||||
import MonthYearPicker from '~/components/shared/MonthYearPicker';
|
||||
|
||||
import CompanyTypeahead from '../typeahead/CompanyTypeahead';
|
||||
import LocationTypeahead from '../typeahead/LocationTypeahead';
|
||||
import RoleTypeahead from '../typeahead/RoleTypeahead';
|
||||
|
||||
export type CreateQuestionEncounterData = {
|
||||
company: string;
|
||||
location: string;
|
||||
role: string;
|
||||
seenAt: Date;
|
||||
};
|
||||
|
||||
export type CreateQuestionEncounterFormProps = {
|
||||
onCancel: () => void;
|
||||
onSubmit: (data: CreateQuestionEncounterData) => void;
|
||||
};
|
||||
|
||||
export default function CreateQuestionEncounterForm({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: CreateQuestionEncounterFormProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
||||
const [selectedLocation, setSelectedLocation] = useState<string | null>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(
|
||||
startOfMonth(new Date()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-md text-md text-slate-600">I saw this question at</p>
|
||||
{step === 0 && (
|
||||
<div>
|
||||
<CompanyTypeahead
|
||||
isLabelHidden={true}
|
||||
placeholder="Other company"
|
||||
suggestedCount={3}
|
||||
onSelect={({ value: company }) => {
|
||||
setSelectedCompany(company);
|
||||
}}
|
||||
onSuggestionClick={({ value: company }) => {
|
||||
setSelectedCompany(company);
|
||||
setStep(step + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<LocationTypeahead
|
||||
isLabelHidden={true}
|
||||
placeholder="Other location"
|
||||
suggestedCount={3}
|
||||
onSelect={({ value: location }) => {
|
||||
setSelectedLocation(location);
|
||||
}}
|
||||
onSuggestionClick={({ value: location }) => {
|
||||
setSelectedLocation(location);
|
||||
setStep(step + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<RoleTypeahead
|
||||
isLabelHidden={true}
|
||||
placeholder="Other role"
|
||||
suggestedCount={3}
|
||||
onSelect={({ value: role }) => {
|
||||
setSelectedRole(role);
|
||||
}}
|
||||
onSuggestionClick={({ value: role }) => {
|
||||
setSelectedRole(role);
|
||||
setStep(step + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<MonthYearPicker
|
||||
monthLabel=""
|
||||
value={{
|
||||
month: ((selectedDate?.getMonth() ?? 0) + 1) as Month,
|
||||
year: selectedDate?.getFullYear() as number,
|
||||
}}
|
||||
yearLabel=""
|
||||
onChange={(value) => {
|
||||
setSelectedDate(
|
||||
startOfMonth(new Date(value.year, value.month - 1)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step < 3 && (
|
||||
<Button
|
||||
disabled={
|
||||
(step === 0 && selectedCompany === null) ||
|
||||
(step === 1 && selectedLocation === null) ||
|
||||
(step === 2 && selectedRole === null)
|
||||
}
|
||||
label="Next"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setStep(step + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button
|
||||
label="Submit"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (
|
||||
selectedCompany &&
|
||||
selectedLocation &&
|
||||
selectedRole &&
|
||||
selectedDate
|
||||
) {
|
||||
onSubmit({
|
||||
company: selectedCompany,
|
||||
location: selectedLocation,
|
||||
role: selectedRole,
|
||||
seenAt: selectedDate,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
label="Cancel"
|
||||
variant="tertiary"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
|
||||
import ExpandedTypeahead from './ExpandedTypeahead';
|
||||
|
||||
export type CompanyTypeaheadProps = Omit<
|
||||
ExpandedTypeaheadProps,
|
||||
'label' | 'onQueryChange' | 'options'
|
||||
>;
|
||||
|
||||
export default function CompanyTypeahead(props: CompanyTypeaheadProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const { data: companies } = trpc.useQuery([
|
||||
'companies.list',
|
||||
{
|
||||
name: query,
|
||||
},
|
||||
]);
|
||||
|
||||
const companyOptions = useMemo(() => {
|
||||
return (
|
||||
companies?.map(({ id, name }) => ({
|
||||
id,
|
||||
label: name,
|
||||
value: id,
|
||||
})) ?? []
|
||||
);
|
||||
}, [companies]);
|
||||
|
||||
return (
|
||||
<ExpandedTypeahead
|
||||
{...(props as ExpandedTypeaheadProps)}
|
||||
label="Company"
|
||||
options={companyOptions}
|
||||
onQueryChange={setQuery}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import { Button, Typeahead } from '@tih/ui';
|
||||
|
||||
import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone';
|
||||
|
||||
type TypeaheadProps = ComponentProps<typeof Typeahead>;
|
||||
type TypeaheadOption = TypeaheadProps['options'][number];
|
||||
|
||||
export type ExpandedTypeaheadProps = RequireAllOrNone<{
|
||||
onSuggestionClick: (option: TypeaheadOption) => void;
|
||||
suggestedCount: number;
|
||||
}> &
|
||||
TypeaheadProps;
|
||||
|
||||
export default function ExpandedTypeahead({
|
||||
suggestedCount = 0,
|
||||
onSuggestionClick,
|
||||
...typeaheadProps
|
||||
}: ExpandedTypeaheadProps) {
|
||||
const suggestions = typeaheadProps.options.slice(0, suggestedCount);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<Button
|
||||
key={suggestion.id}
|
||||
label={suggestion.label}
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
onSuggestionClick?.(suggestion);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex-1">
|
||||
<Typeahead {...typeaheadProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { LOCATIONS } from '~/utils/questions/constants';
|
||||
|
||||
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
|
||||
import ExpandedTypeahead from './ExpandedTypeahead';
|
||||
|
||||
export type LocationTypeaheadProps = Omit<
|
||||
ExpandedTypeaheadProps,
|
||||
'label' | 'onQueryChange' | 'options'
|
||||
>;
|
||||
|
||||
export default function LocationTypeahead(props: LocationTypeaheadProps) {
|
||||
return (
|
||||
<ExpandedTypeahead
|
||||
{...(props as ExpandedTypeaheadProps)}
|
||||
label="Location"
|
||||
options={LOCATIONS}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onQueryChange={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { ROLES } from '~/utils/questions/constants';
|
||||
|
||||
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
|
||||
import ExpandedTypeahead from './ExpandedTypeahead';
|
||||
|
||||
export type RoleTypeaheadProps = Omit<
|
||||
ExpandedTypeaheadProps,
|
||||
'label' | 'onQueryChange' | 'options'
|
||||
>;
|
||||
|
||||
export default function RoleTypeahead(props: RoleTypeaheadProps) {
|
||||
return (
|
||||
<ExpandedTypeahead
|
||||
{...(props as ExpandedTypeaheadProps)}
|
||||
label="Role"
|
||||
options={ROLES}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onQueryChange={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
|
||||
@ -7,6 +8,7 @@ import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem'
|
||||
import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
|
||||
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
|
||||
|
||||
import { APP_TITLE } from '~/utils/questions/constants';
|
||||
import { useFormRegister } from '~/utils/questions/useFormRegister';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
@ -51,10 +53,6 @@ export default function QuestionPage() {
|
||||
},
|
||||
);
|
||||
|
||||
const handleBackNavigation = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleSubmitComment = (data: AnswerCommentData) => {
|
||||
resetComment();
|
||||
addComment({
|
||||
@ -68,90 +66,98 @@ export default function QuestionPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-1 items-stretch pb-4">
|
||||
<div className="flex items-baseline gap-2 py-4 pl-4">
|
||||
<Button
|
||||
addonPosition="start"
|
||||
display="inline"
|
||||
icon={ArrowSmallLeftIcon}
|
||||
label="Back"
|
||||
variant="secondary"
|
||||
onClick={handleBackNavigation}></Button>
|
||||
</div>
|
||||
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
|
||||
<div className="flex max-w-7xl flex-1 flex-col gap-2">
|
||||
<FullAnswerCard
|
||||
answerId={answer.id}
|
||||
authorImageUrl={answer.userImage}
|
||||
authorName={answer.user}
|
||||
content={answer.content}
|
||||
createdAt={answer.createdAt}
|
||||
upvoteCount={answer.numVotes}
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{answer.content} - {APP_TITLE}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="flex w-full flex-1 items-stretch pb-4">
|
||||
<div className="flex items-baseline gap-2 py-4 pl-4">
|
||||
<Button
|
||||
addonPosition="start"
|
||||
display="inline"
|
||||
href={`/questions/${router.query.questionId}/${router.query.questionSlug}`}
|
||||
icon={ArrowSmallLeftIcon}
|
||||
label="Back"
|
||||
variant="secondary"
|
||||
/>
|
||||
<div className="mx-2">
|
||||
<form
|
||||
className="mb-2"
|
||||
onSubmit={handleCommentSubmit(handleSubmitComment)}>
|
||||
<TextArea
|
||||
{...commentRegister('commentContent', {
|
||||
minLength: 1,
|
||||
required: true,
|
||||
})}
|
||||
label="Post a comment"
|
||||
required={true}
|
||||
resize="vertical"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="my-3 flex justify-between">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span aria-hidden={true} className="text-sm">
|
||||
Sort by:
|
||||
</span>
|
||||
<Select
|
||||
display="inline"
|
||||
isLabelHidden={true}
|
||||
label="Sort by"
|
||||
options={[
|
||||
{
|
||||
label: 'Most recent',
|
||||
value: 'most-recent',
|
||||
},
|
||||
{
|
||||
label: 'Most upvotes',
|
||||
value: 'most-upvotes',
|
||||
},
|
||||
]}
|
||||
value="most-recent"
|
||||
onChange={(value) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(value);
|
||||
}}
|
||||
</div>
|
||||
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
|
||||
<div className="flex max-w-7xl flex-1 flex-col gap-2">
|
||||
<FullAnswerCard
|
||||
answerId={answer.id}
|
||||
authorImageUrl={answer.userImage}
|
||||
authorName={answer.user}
|
||||
content={answer.content}
|
||||
createdAt={answer.createdAt}
|
||||
upvoteCount={answer.numVotes}
|
||||
/>
|
||||
<div className="mx-2">
|
||||
<form
|
||||
className="mb-2"
|
||||
onSubmit={handleCommentSubmit(handleSubmitComment)}>
|
||||
<TextArea
|
||||
{...commentRegister('commentContent', {
|
||||
minLength: 1,
|
||||
required: true,
|
||||
})}
|
||||
label="Post a comment"
|
||||
required={true}
|
||||
resize="vertical"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="my-3 flex justify-between">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span aria-hidden={true} className="text-sm">
|
||||
Sort by:
|
||||
</span>
|
||||
<Select
|
||||
display="inline"
|
||||
isLabelHidden={true}
|
||||
label="Sort by"
|
||||
options={[
|
||||
{
|
||||
label: 'Most recent',
|
||||
value: 'most-recent',
|
||||
},
|
||||
{
|
||||
label: 'Most upvotes',
|
||||
value: 'most-upvotes',
|
||||
},
|
||||
]}
|
||||
value="most-recent"
|
||||
onChange={(value) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={!isCommentDirty || !isCommentValid}
|
||||
label="Post"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Button
|
||||
disabled={!isCommentDirty || !isCommentValid}
|
||||
label="Post"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
{(comments ?? []).map((comment) => (
|
||||
<AnswerCommentListItem
|
||||
key={comment.id}
|
||||
answerCommentId={comment.id}
|
||||
authorImageUrl={comment.userImage}
|
||||
authorName={comment.user}
|
||||
content={comment.content}
|
||||
createdAt={comment.createdAt}
|
||||
upvoteCount={comment.numVotes}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{(comments ?? []).map((comment) => (
|
||||
<AnswerCommentListItem
|
||||
key={comment.id}
|
||||
answerCommentId={comment.id}
|
||||
authorImageUrl={comment.userImage}
|
||||
authorName={comment.user}
|
||||
content={comment.content}
|
||||
createdAt={comment.createdAt}
|
||||
upvoteCount={comment.numVotes}
|
||||
/>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,13 +1,15 @@
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
|
||||
import { Button, Collapsible, Select, TextArea } from '@tih/ui';
|
||||
|
||||
import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
|
||||
import FullQuestionCard from '~/components/questions/card/FullQuestionCard';
|
||||
import FullQuestionCard from '~/components/questions/card/question/FullQuestionCard';
|
||||
import QuestionAnswerCard from '~/components/questions/card/QuestionAnswerCard';
|
||||
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
|
||||
|
||||
import { APP_TITLE } from '~/utils/questions/constants';
|
||||
import createSlug from '~/utils/questions/createSlug';
|
||||
import { useFormRegister } from '~/utils/questions/useFormRegister';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
@ -45,6 +47,11 @@ export default function QuestionPage() {
|
||||
{ id: questionId as string },
|
||||
]);
|
||||
|
||||
const { data: aggregatedEncounters } = trpc.useQuery([
|
||||
'questions.questions.encounters.getAggregatedEncounters',
|
||||
{ questionId: questionId as string },
|
||||
]);
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const { data: comments } = trpc.useQuery([
|
||||
@ -74,9 +81,17 @@ export default function QuestionPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleBackNavigation = () => {
|
||||
router.back();
|
||||
};
|
||||
const { mutate: addEncounter } = trpc.useMutation(
|
||||
'questions.questions.encounters.create',
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(
|
||||
'questions.questions.encounters.getAggregatedEncounters',
|
||||
);
|
||||
utils.invalidateQueries('questions.questions.getQuestionById');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmitAnswer = (data: AnswerQuestionData) => {
|
||||
addAnswer({
|
||||
@ -99,44 +114,125 @@ export default function QuestionPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-1 items-stretch pb-4">
|
||||
<div className="flex items-baseline gap-2 py-4 pl-4">
|
||||
<Button
|
||||
addonPosition="start"
|
||||
display="inline"
|
||||
icon={ArrowSmallLeftIcon}
|
||||
label="Back"
|
||||
variant="secondary"
|
||||
onClick={handleBackNavigation}></Button>
|
||||
</div>
|
||||
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
|
||||
<div className="flex max-w-7xl flex-1 flex-col gap-2">
|
||||
<FullQuestionCard
|
||||
{...question}
|
||||
questionId={question.id}
|
||||
receivedCount={0}
|
||||
timestamp={question.seenAt.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
upvoteCount={question.numVotes}
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{question.content} - {APP_TITLE}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="flex w-full flex-1 items-stretch pb-4">
|
||||
<div className="flex items-baseline gap-2 py-4 pl-4">
|
||||
<Button
|
||||
addonPosition="start"
|
||||
display="inline"
|
||||
href="/questions/browse"
|
||||
icon={ArrowSmallLeftIcon}
|
||||
label="Back"
|
||||
variant="secondary"
|
||||
/>
|
||||
<div className="mx-2">
|
||||
<Collapsible label={`${(comments ?? []).length} comment(s)`}>
|
||||
<form
|
||||
className="mb-2"
|
||||
onSubmit={handleCommentSubmit(handleSubmitComment)}>
|
||||
<TextArea
|
||||
{...commentRegister('commentContent', {
|
||||
minLength: 1,
|
||||
required: true,
|
||||
})}
|
||||
label="Post a comment"
|
||||
required={true}
|
||||
resize="vertical"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="my-3 flex justify-between">
|
||||
</div>
|
||||
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
|
||||
<div className="flex max-w-7xl flex-1 flex-col gap-2">
|
||||
<FullQuestionCard
|
||||
{...question}
|
||||
companies={aggregatedEncounters?.companyCounts ?? {}}
|
||||
locations={aggregatedEncounters?.locationCounts ?? {}}
|
||||
questionId={question.id}
|
||||
receivedCount={undefined}
|
||||
roles={aggregatedEncounters?.roleCounts ?? {}}
|
||||
timestamp={question.seenAt.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
upvoteCount={question.numVotes}
|
||||
onReceivedSubmit={(data) => {
|
||||
addEncounter({
|
||||
companyId: data.company,
|
||||
location: data.location,
|
||||
questionId: questionId as string,
|
||||
role: data.role,
|
||||
seenAt: data.seenAt,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="mx-2">
|
||||
<Collapsible label={`${(comments ?? []).length} comment(s)`}>
|
||||
<form
|
||||
className="mb-2"
|
||||
onSubmit={handleCommentSubmit(handleSubmitComment)}>
|
||||
<TextArea
|
||||
{...commentRegister('commentContent', {
|
||||
minLength: 1,
|
||||
required: true,
|
||||
})}
|
||||
label="Post a comment"
|
||||
required={true}
|
||||
resize="vertical"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="my-3 flex justify-between">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span aria-hidden={true} className="text-sm">
|
||||
Sort by:
|
||||
</span>
|
||||
<Select
|
||||
display="inline"
|
||||
isLabelHidden={true}
|
||||
label="Sort by"
|
||||
options={[
|
||||
{
|
||||
label: 'Most recent',
|
||||
value: 'most-recent',
|
||||
},
|
||||
{
|
||||
label: 'Most upvotes',
|
||||
value: 'most-upvotes',
|
||||
},
|
||||
]}
|
||||
value="most-recent"
|
||||
onChange={(value) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={!isCommentDirty || !isCommentValid}
|
||||
label="Post"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{(comments ?? []).map((comment) => (
|
||||
<AnswerCommentListItem
|
||||
key={comment.id}
|
||||
answerCommentId={comment.id}
|
||||
authorImageUrl={comment.userImage}
|
||||
authorName={comment.user}
|
||||
content={comment.content}
|
||||
createdAt={comment.createdAt}
|
||||
upvoteCount={comment.numVotes}
|
||||
/>
|
||||
))}
|
||||
</Collapsible>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(handleSubmitAnswer)}>
|
||||
<TextArea
|
||||
{...answerRegister('answerContent', {
|
||||
minLength: 1,
|
||||
required: true,
|
||||
})}
|
||||
label="Contribute your answer"
|
||||
required={true}
|
||||
resize="vertical"
|
||||
rows={5}
|
||||
/>
|
||||
<div className="mt-3 mb-1 flex justify-between">
|
||||
<div className="flex items-baseline justify-start gap-2">
|
||||
<p>{(answers ?? []).length} answers</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span aria-hidden={true} className="text-sm">
|
||||
Sort by:
|
||||
@ -162,94 +258,33 @@ export default function QuestionPage() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={!isCommentDirty || !isCommentValid}
|
||||
label="Post"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{(comments ?? []).map((comment) => (
|
||||
<AnswerCommentListItem
|
||||
key={comment.id}
|
||||
answerCommentId={comment.id}
|
||||
authorImageUrl={comment.userImage}
|
||||
authorName={comment.user}
|
||||
content={comment.content}
|
||||
createdAt={comment.createdAt}
|
||||
upvoteCount={0}
|
||||
<Button
|
||||
disabled={!isDirty || !isValid}
|
||||
label="Contribute"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
/>
|
||||
))}
|
||||
</Collapsible>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(handleSubmitAnswer)}>
|
||||
<TextArea
|
||||
{...answerRegister('answerContent', {
|
||||
minLength: 1,
|
||||
required: true,
|
||||
})}
|
||||
label="Contribute your answer"
|
||||
required={true}
|
||||
resize="vertical"
|
||||
rows={5}
|
||||
/>
|
||||
<div className="mt-3 mb-1 flex justify-between">
|
||||
<div className="flex items-baseline justify-start gap-2">
|
||||
<p>{(answers ?? []).length} answers</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span aria-hidden={true} className="text-sm">
|
||||
Sort by:
|
||||
</span>
|
||||
<Select
|
||||
display="inline"
|
||||
isLabelHidden={true}
|
||||
label="Sort by"
|
||||
options={[
|
||||
{
|
||||
label: 'Most recent',
|
||||
value: 'most-recent',
|
||||
},
|
||||
{
|
||||
label: 'Most upvotes',
|
||||
value: 'most-upvotes',
|
||||
},
|
||||
]}
|
||||
value="most-recent"
|
||||
onChange={(value) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!isDirty || !isValid}
|
||||
label="Contribute"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
</form>
|
||||
{(answers ?? []).map((answer) => (
|
||||
<QuestionAnswerCard
|
||||
key={answer.id}
|
||||
answerId={answer.id}
|
||||
authorImageUrl={answer.userImage}
|
||||
authorName={answer.user}
|
||||
commentCount={answer.numComments}
|
||||
content={answer.content}
|
||||
createdAt={answer.createdAt}
|
||||
href={`${router.asPath}/answer/${answer.id}/${createSlug(
|
||||
answer.content,
|
||||
)}`}
|
||||
upvoteCount={answer.numVotes}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
{(answers ?? []).map((answer) => (
|
||||
<QuestionAnswerCard
|
||||
key={answer.id}
|
||||
answerId={answer.id}
|
||||
authorImageUrl={answer.userImage}
|
||||
authorName={answer.user}
|
||||
commentCount={answer.numComments}
|
||||
content={answer.content}
|
||||
createdAt={answer.createdAt}
|
||||
href={`${router.asPath}/answer/${answer.id}/${createSlug(
|
||||
answer.content,
|
||||
)}`}
|
||||
upvoteCount={answer.numVotes}
|
||||
/>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
498
apps/portal/src/pages/questions/browse.tsx
Normal file
498
apps/portal/src/pages/questions/browse.tsx
Normal file
@ -0,0 +1,498 @@
|
||||
import { subMonths, subYears } from 'date-fns';
|
||||
import Head from 'next/head';
|
||||
import Router, { useRouter } from 'next/router';
|
||||
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 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 { 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 createSlug from '~/utils/questions/createSlug';
|
||||
import {
|
||||
useSearchParam,
|
||||
useSearchParamSingle,
|
||||
} from '~/utils/questions/useSearchParam';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
import { SortType } from '~/types/questions.d';
|
||||
import { SortOrder } from '~/types/questions.d';
|
||||
|
||||
export default function QuestionsBrowsePage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
|
||||
useSearchParam('companies');
|
||||
const [
|
||||
selectedQuestionTypes,
|
||||
setSelectedQuestionTypes,
|
||||
areQuestionTypesInitialized,
|
||||
] = useSearchParam<QuestionsQuestionType>('questionTypes', {
|
||||
stringToParam: (param) => {
|
||||
const uppercaseParam = param.toUpperCase();
|
||||
return (
|
||||
QUESTION_TYPES.find(
|
||||
(questionType) => questionType.value.toUpperCase() === uppercaseParam,
|
||||
)?.value ?? null
|
||||
);
|
||||
},
|
||||
});
|
||||
const [
|
||||
selectedQuestionAge,
|
||||
setSelectedQuestionAge,
|
||||
isQuestionAgeInitialized,
|
||||
] = useSearchParamSingle<QuestionAge>('questionAge', {
|
||||
defaultValue: 'all',
|
||||
stringToParam: (param) => {
|
||||
const uppercaseParam = param.toUpperCase();
|
||||
return (
|
||||
QUESTION_AGES.find(
|
||||
(questionAge) => questionAge.value.toUpperCase() === uppercaseParam,
|
||||
)?.value ?? null
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [selectedRoles, setSelectedRoles, areRolesInitialized] =
|
||||
useSearchParam('roles');
|
||||
const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
|
||||
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(
|
||||
() =>
|
||||
selectedCompanies.length > 0 ||
|
||||
selectedQuestionTypes.length > 0 ||
|
||||
selectedQuestionAge !== 'all' ||
|
||||
selectedRoles.length > 0 ||
|
||||
selectedLocations.length > 0,
|
||||
[
|
||||
selectedCompanies,
|
||||
selectedQuestionTypes,
|
||||
selectedQuestionAge,
|
||||
selectedRoles,
|
||||
selectedLocations,
|
||||
],
|
||||
);
|
||||
|
||||
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,
|
||||
sortOrder,
|
||||
sortType,
|
||||
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 areSearchOptionsInitialized = useMemo(() => {
|
||||
return (
|
||||
areCompaniesInitialized &&
|
||||
areQuestionTypesInitialized &&
|
||||
isQuestionAgeInitialized &&
|
||||
areRolesInitialized &&
|
||||
areLocationsInitialized &&
|
||||
isSortTypeInitialized &&
|
||||
isSortOrderInitialized
|
||||
);
|
||||
}, [
|
||||
areCompaniesInitialized,
|
||||
areQuestionTypesInitialized,
|
||||
isQuestionAgeInitialized,
|
||||
areRolesInitialized,
|
||||
areLocationsInitialized,
|
||||
isSortTypeInitialized,
|
||||
isSortOrderInitialized,
|
||||
]);
|
||||
|
||||
const { pathname } = router;
|
||||
useEffect(() => {
|
||||
if (areSearchOptionsInitialized) {
|
||||
// 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,
|
||||
sortOrder: sortOrder === SortOrder.ASC ? 'ASC' : 'DESC',
|
||||
sortType: sortType === SortType.TOP ? 'TOP' : 'NEW',
|
||||
},
|
||||
});
|
||||
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [
|
||||
areSearchOptionsInitialized,
|
||||
loaded,
|
||||
pathname,
|
||||
selectedCompanies,
|
||||
selectedRoles,
|
||||
selectedLocations,
|
||||
selectedQuestionAge,
|
||||
selectedQuestionTypes,
|
||||
sortOrder,
|
||||
sortType,
|
||||
]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
const filterSidebar = (
|
||||
<div className="divide-y divide-slate-200 px-4">
|
||||
<Button
|
||||
addonPosition="start"
|
||||
className="my-4"
|
||||
disabled={!hasFilters}
|
||||
icon={Bars3BottomLeftIcon}
|
||||
label="Clear filters"
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
setSelectedCompanies([]);
|
||||
setSelectedQuestionTypes([]);
|
||||
setSelectedQuestionAge('all');
|
||||
setSelectedRoles([]);
|
||||
setSelectedLocations([]);
|
||||
}}
|
||||
/>
|
||||
<FilterSection
|
||||
label="Company"
|
||||
options={companyFilterOptions}
|
||||
renderInput={({
|
||||
onOptionChange,
|
||||
options,
|
||||
field: { ref: _, ...field },
|
||||
}) => (
|
||||
<Typeahead
|
||||
{...field}
|
||||
isLabelHidden={true}
|
||||
label="Companies"
|
||||
options={options}
|
||||
placeholder="Search companies"
|
||||
// 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}
|
||||
isLabelHidden={true}
|
||||
label="Roles"
|
||||
options={options}
|
||||
placeholder="Search roles"
|
||||
// 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}
|
||||
isLabelHidden={true}
|
||||
label="Locations"
|
||||
options={options}
|
||||
placeholder="Search locations"
|
||||
// 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-8">
|
||||
<ContributeQuestionCard
|
||||
onSubmit={(data) => {
|
||||
createQuestion({
|
||||
companyId: data.company,
|
||||
content: data.questionContent,
|
||||
location: data.location,
|
||||
questionType: data.questionType,
|
||||
role: data.role,
|
||||
seenAt: data.date,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<QuestionSearchBar
|
||||
sortOrderOptions={SORT_ORDERS}
|
||||
sortOrderValue={sortOrder}
|
||||
sortTypeOptions={SORT_TYPES}
|
||||
sortTypeValue={sortType}
|
||||
onFilterOptionsToggle={() => {
|
||||
setFilterDrawerOpen(!filterDrawerOpen);
|
||||
}}
|
||||
onSortOrderChange={setSortOrder}
|
||||
onSortTypeChange={setSortType}
|
||||
/>
|
||||
<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.</p>
|
||||
{hasFilters && <p>Try changing your search criteria.</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>
|
||||
</>
|
||||
);
|
||||
}
|
16
apps/portal/src/pages/questions/history.tsx
Normal file
16
apps/portal/src/pages/questions/history.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import Head from 'next/head';
|
||||
|
||||
import { APP_TITLE } from '~/utils/questions/constants';
|
||||
|
||||
export default function HistoryPage() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>History - {APP_TITLE}</title>
|
||||
</Head>
|
||||
<div className="v-full flex w-full items-center justify-center">
|
||||
<h1 className="text-center text-4xl font-bold">History</h1>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,337 +1,34 @@
|
||||
import { subMonths, subYears } from 'date-fns';
|
||||
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 } from '@tih/ui';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import QuestionOverviewCard from '~/components/questions/card/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 {
|
||||
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';
|
||||
|
||||
import { SortOrder, SortType } from '~/types/questions.d';
|
||||
import { APP_TITLE } from '~/utils/questions/constants';
|
||||
|
||||
export default function QuestionsHomePage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
|
||||
useSearchFilter('companies');
|
||||
const [
|
||||
selectedQuestionTypes,
|
||||
setSelectedQuestionTypes,
|
||||
areQuestionTypesInitialized,
|
||||
] = useSearchFilter<QuestionsQuestionType>('questionTypes', {
|
||||
queryParamToValue: (param) => {
|
||||
return param.toUpperCase() as QuestionsQuestionType;
|
||||
},
|
||||
});
|
||||
const [
|
||||
selectedQuestionAge,
|
||||
setSelectedQuestionAge,
|
||||
isQuestionAgeInitialized,
|
||||
] = useSearchFilterSingle<QuestionAge>('questionAge', {
|
||||
defaultValue: 'all',
|
||||
});
|
||||
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: [],
|
||||
// TODO: Implement sort order and sort type choices
|
||||
sortOrder: SortOrder.DESC,
|
||||
sortType: SortType.NEW,
|
||||
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 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]);
|
||||
setSelectedLocations([location]);
|
||||
setSelectedQuestionTypes([questionType as QuestionsQuestionType]);
|
||||
setHasLanded(true);
|
||||
// Go to browse page
|
||||
router.push({
|
||||
pathname: '/questions/browse',
|
||||
query: {
|
||||
companies: [company],
|
||||
locations: [location],
|
||||
questionTypes: [questionType],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const areFiltersInitialized = useMemo(() => {
|
||||
return (
|
||||
areCompaniesInitialized &&
|
||||
areQuestionTypesInitialized &&
|
||||
isQuestionAgeInitialized &&
|
||||
areLocationsInitialized
|
||||
);
|
||||
}, [
|
||||
areCompaniesInitialized,
|
||||
areQuestionTypesInitialized,
|
||||
isQuestionAgeInitialized,
|
||||
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,
|
||||
},
|
||||
});
|
||||
const hasFilter =
|
||||
selectedCompanies.length > 0 ||
|
||||
selectedLocations.length > 0 ||
|
||||
selectedQuestionAge !== 'all' ||
|
||||
selectedQuestionTypes.length > 0;
|
||||
if (hasFilter) {
|
||||
setHasLanded(true);
|
||||
}
|
||||
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [
|
||||
areFiltersInitialized,
|
||||
hasLanded,
|
||||
loaded,
|
||||
pathname,
|
||||
selectedCompanies,
|
||||
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}
|
||||
searchPlaceholder="Add company filter"
|
||||
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="Location"
|
||||
options={locationFilterOptions}
|
||||
searchPlaceholder="Add location filter"
|
||||
onOptionChange={(optionValue, checked) => {
|
||||
if (checked) {
|
||||
setSelectedLocations([...selectedLocations, optionValue]);
|
||||
} else {
|
||||
setSelectedLocations(
|
||||
selectedLocations.filter((location) => location !== optionValue),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return !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);
|
||||
}}
|
||||
/>
|
||||
{(questions ?? []).map((question) => (
|
||||
<QuestionOverviewCard
|
||||
key={question.id}
|
||||
answerCount={question.numAnswers}
|
||||
company={question.company}
|
||||
content={question.content}
|
||||
href={`/questions/${question.id}/${createSlug(
|
||||
question.content,
|
||||
)}`}
|
||||
location={question.location}
|
||||
questionId={question.id}
|
||||
receivedCount={0}
|
||||
role={question.role}
|
||||
timestamp={question.seenAt.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
type={question.type} // TODO: Implement received count
|
||||
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>
|
||||
</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>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Home - {APP_TITLE}</title>
|
||||
</Head>
|
||||
<LandingComponent onLanded={handleLandingQuery}></LandingComponent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
179
apps/portal/src/pages/questions/lists.tsx
Normal file
179
apps/portal/src/pages/questions/lists.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import Head from 'next/head';
|
||||
import { useState } from 'react';
|
||||
import { Menu } from '@headlessui/react';
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
NoSymbolIcon,
|
||||
PlusIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
import QuestionListCard from '~/components/questions/card/question/QuestionListCard';
|
||||
|
||||
import { Button } from '~/../../../packages/ui/dist';
|
||||
import { APP_TITLE } from '~/utils/questions/constants';
|
||||
import { SAMPLE_QUESTION } from '~/utils/questions/constants';
|
||||
import createSlug from '~/utils/questions/createSlug';
|
||||
|
||||
export default function ListPage() {
|
||||
const questions = [
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
SAMPLE_QUESTION,
|
||||
];
|
||||
|
||||
const lists = [
|
||||
{ id: 1, name: 'list 1', questions },
|
||||
{ id: 2, name: 'list 2', questions },
|
||||
{ id: 3, name: 'list 3', questions },
|
||||
{ id: 4, name: 'list 4', questions },
|
||||
{ id: 5, name: 'list 5', questions },
|
||||
];
|
||||
|
||||
const [selectedList, setSelectedList] = useState(
|
||||
(lists ?? []).length > 0 ? lists[0].id : '',
|
||||
);
|
||||
const listOptions = (
|
||||
<>
|
||||
<ul className="flex flex-1 flex-col divide-y divide-solid divide-slate-200">
|
||||
{lists.map((list) => (
|
||||
<li
|
||||
key={list.id}
|
||||
className={`flex items-center hover:bg-gray-50 ${
|
||||
selectedList === list.id ? 'bg-primary-100' : ''
|
||||
}`}>
|
||||
<button
|
||||
className="flex w-full flex-1 justify-between "
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedList(list.id);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(selectedList);
|
||||
}}>
|
||||
<p className="text-primary-700 text-md p-3 font-medium">
|
||||
{list.name}
|
||||
</p>
|
||||
</button>
|
||||
<div>
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button className="inline-flex w-full justify-center rounded-md p-2 text-sm font-medium text-white">
|
||||
<EllipsisVerticalIcon
|
||||
aria-hidden="true"
|
||||
className="hover:text-primary-700 mr-1 h-5 w-5 text-violet-400"
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Menu.Items className="w-18 absolute right-0 z-10 mr-2 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="px-1 py-1 ">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={`${
|
||||
active
|
||||
? 'bg-violet-500 text-white'
|
||||
: 'text-gray-900'
|
||||
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
|
||||
type="button">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{lists?.length === 0 && (
|
||||
<div className="mx-2 flex items-center justify-center gap-2 rounded-md bg-slate-200 p-4 text-slate-600">
|
||||
<p>You have yet to create a list</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>My Lists - {APP_TITLE}</title>
|
||||
</Head>
|
||||
<main className="flex flex-1 flex-col items-stretch">
|
||||
<div className="flex h-full flex-1">
|
||||
<aside className="w-[300px] overflow-y-auto border-l bg-white py-4 lg:block">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="px-4 text-xl font-semibold">My Lists</h2>
|
||||
<div className="px-4">
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
isLabelHidden={true}
|
||||
label="Create"
|
||||
size="md"
|
||||
variant="tertiary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{listOptions}
|
||||
</aside>
|
||||
<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">
|
||||
{selectedList && (
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
{(questions ?? []).map((question) => (
|
||||
<QuestionListCard
|
||||
key={question.id}
|
||||
companies={question.companies}
|
||||
content={question.content}
|
||||
href={`/questions/${question.id}/${createSlug(
|
||||
question.content,
|
||||
)}`}
|
||||
locations={question.locations}
|
||||
questionId={question.id}
|
||||
receivedCount={0}
|
||||
roles={question.roles}
|
||||
timestamp={question.seenAt.toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
},
|
||||
)}
|
||||
type={question.type}
|
||||
onDelete={() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('delete');
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{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>You have no added any questions to your list yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
16
apps/portal/src/pages/questions/my-questions.tsx
Normal file
16
apps/portal/src/pages/questions/my-questions.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import Head from 'next/head';
|
||||
|
||||
import { APP_TITLE } from '~/utils/questions/constants';
|
||||
|
||||
export default function MyQuestionsPage() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>My Questions - {APP_TITLE}</title>
|
||||
</Head>
|
||||
<div className="v-full flex w-full items-center justify-center">
|
||||
<h1 className="text-center text-4xl font-bold">My Questions</h1>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -10,6 +10,7 @@ import { protectedExampleRouter } from './protected-example-router';
|
||||
import { questionsAnswerCommentRouter } from './questions-answer-comment-router';
|
||||
import { questionsAnswerRouter } from './questions-answer-router';
|
||||
import { questionsQuestionCommentRouter } from './questions-question-comment-router';
|
||||
import { questionsQuestionEncounterRouter } from './questions-question-encounter-router';
|
||||
import { questionsQuestionRouter } from './questions-question-router';
|
||||
import { resumeCommentsRouter } from './resumes/resumes-comments-router';
|
||||
import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router';
|
||||
@ -40,6 +41,7 @@ export const appRouter = createRouter()
|
||||
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
|
||||
.merge('questions.answers.', questionsAnswerRouter)
|
||||
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
|
||||
.merge('questions.questions.encounters.', questionsQuestionEncounterRouter)
|
||||
.merge('questions.questions.', questionsQuestionRouter)
|
||||
.merge('offers.', offersRouter)
|
||||
.merge('offers.profile.', offersProfileRouter)
|
||||
|
@ -11,46 +11,46 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
|
||||
questionId: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const questionEncountersData = await ctx.prisma.questionsQuestionEncounter.findMany({
|
||||
include: {
|
||||
company : true,
|
||||
},
|
||||
where: {
|
||||
...input,
|
||||
},
|
||||
});
|
||||
const questionEncountersData =
|
||||
await ctx.prisma.questionsQuestionEncounter.findMany({
|
||||
include: {
|
||||
company: true,
|
||||
},
|
||||
where: {
|
||||
...input,
|
||||
},
|
||||
});
|
||||
|
||||
const companyCounts: Record<string, number> = {};
|
||||
const locationCounts: Record<string, number> = {};
|
||||
const roleCounts:Record<string, number> = {};
|
||||
const roleCounts: Record<string, number> = {};
|
||||
|
||||
for (let i = 0; i < questionEncountersData.length; i++) {
|
||||
const encounter = questionEncountersData[i];
|
||||
|
||||
if (!(encounter.company!.name in companyCounts)) {
|
||||
companyCounts[encounter.company!.name] = 1;
|
||||
companyCounts[encounter.company!.name] = 1;
|
||||
}
|
||||
companyCounts[encounter.company!.name] += 1;
|
||||
|
||||
if (!(encounter.location in locationCounts)) {
|
||||
locationCounts[encounter.location] = 1;
|
||||
locationCounts[encounter.location] = 1;
|
||||
}
|
||||
locationCounts[encounter.location] += 1;
|
||||
|
||||
if (!(encounter.role in roleCounts)) {
|
||||
roleCounts[encounter.role] = 1;
|
||||
roleCounts[encounter.role] = 1;
|
||||
}
|
||||
roleCounts[encounter.role] += 1;
|
||||
|
||||
}
|
||||
|
||||
const questionEncounter:AggregatedQuestionEncounter = {
|
||||
const questionEncounter: AggregatedQuestionEncounter = {
|
||||
companyCounts,
|
||||
locationCounts,
|
||||
roleCounts,
|
||||
}
|
||||
};
|
||||
return questionEncounter;
|
||||
}
|
||||
},
|
||||
})
|
||||
.mutation('create', {
|
||||
input: z.object({
|
||||
@ -58,7 +58,7 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
|
||||
location: z.string(),
|
||||
questionId: z.string(),
|
||||
role: z.string(),
|
||||
seenAt: z.date()
|
||||
seenAt: z.date(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user?.id;
|
||||
@ -83,11 +83,12 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user?.id;
|
||||
|
||||
const questionEncounterToUpdate = await ctx.prisma.questionsQuestionEncounter.findUnique({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
});
|
||||
const questionEncounterToUpdate =
|
||||
await ctx.prisma.questionsQuestionEncounter.findUnique({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (questionEncounterToUpdate?.id !== userId) {
|
||||
throw new TRPCError({
|
||||
@ -113,11 +114,12 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user?.id;
|
||||
|
||||
const questionEncounterToDelete = await ctx.prisma.questionsQuestionEncounter.findUnique({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
});
|
||||
const questionEncounterToDelete =
|
||||
await ctx.prisma.questionsQuestionEncounter.findUnique({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (questionEncounterToDelete?.id !== userId) {
|
||||
throw new TRPCError({
|
||||
@ -132,4 +134,4 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -7,8 +7,6 @@ import { createProtectedRouter } from './context';
|
||||
import type { Question } from '~/types/questions';
|
||||
import { SortOrder, SortType } from '~/types/questions.d';
|
||||
|
||||
const TWO_WEEK_IN_MS = 12096e5;
|
||||
|
||||
export const questionsQuestionRouter = createProtectedRouter()
|
||||
.query('getQuestionsByFilter', {
|
||||
input: z.object({
|
||||
@ -20,7 +18,7 @@ export const questionsQuestionRouter = createProtectedRouter()
|
||||
roles: z.string().array(),
|
||||
sortOrder: z.nativeEnum(SortOrder),
|
||||
sortType: z.nativeEnum(SortType),
|
||||
startDate: z.date().default(new Date(Date.now() - TWO_WEEK_IN_MS)),
|
||||
startDate: z.date().optional(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const sortCondition =
|
||||
@ -99,6 +97,7 @@ export const questionsQuestionRouter = createProtectedRouter()
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return questionsData.map((data) => {
|
||||
const votes: number = data.votes.reduce(
|
||||
(previousValue: number, currentValue) => {
|
||||
@ -125,6 +124,7 @@ export const questionsQuestionRouter = createProtectedRouter()
|
||||
numAnswers: data._count.answers,
|
||||
numComments: data._count.comments,
|
||||
numVotes: votes,
|
||||
receivedCount: data.encounters.length,
|
||||
role: data.encounters[0].role ?? 'Unknown role',
|
||||
seenAt: data.encounters[0].seenAt,
|
||||
type: data.questionType,
|
||||
@ -198,6 +198,7 @@ export const questionsQuestionRouter = createProtectedRouter()
|
||||
numAnswers: questionData._count.answers,
|
||||
numComments: questionData._count.comments,
|
||||
numVotes: votes,
|
||||
receivedCount: questionData.encounters.length,
|
||||
role: questionData.encounters[0].role ?? 'Unknown role',
|
||||
seenAt: questionData.encounters[0].seenAt,
|
||||
type: questionData.questionType,
|
||||
|
1
apps/portal/src/types/questions.d.ts
vendored
1
apps/portal/src/types/questions.d.ts
vendored
@ -9,6 +9,7 @@ export type Question = {
|
||||
numAnswers: number;
|
||||
numComments: number;
|
||||
numVotes: number;
|
||||
receivedCount: number;
|
||||
role: string;
|
||||
seenAt: Date;
|
||||
type: QuestionsQuestionType;
|
||||
|
1
apps/portal/src/utils/questions/RequireAllOrNone.ts
Normal file
1
apps/portal/src/utils/questions/RequireAllOrNone.ts
Normal file
@ -0,0 +1 @@
|
||||
export type RequireAllOrNone<T> = T | { [K in keyof T]?: never };
|
@ -1,13 +1,19 @@
|
||||
import type { QuestionsQuestionType } from '@prisma/client';
|
||||
import { QuestionsQuestionType } from '@prisma/client';
|
||||
|
||||
import type { FilterChoices } from '~/components/questions/filter/FilterSection';
|
||||
|
||||
import { SortOrder, SortType } from '~/types/questions.d';
|
||||
|
||||
export const APP_TITLE = 'Questions Bank';
|
||||
|
||||
export const COMPANIES: FilterChoices = [
|
||||
{
|
||||
id: 'Google',
|
||||
label: 'Google',
|
||||
value: 'Google',
|
||||
},
|
||||
{
|
||||
id: 'Meta',
|
||||
label: 'Meta',
|
||||
value: 'Meta',
|
||||
},
|
||||
@ -16,14 +22,17 @@ export const COMPANIES: FilterChoices = [
|
||||
// Code, design, behavioral
|
||||
export const QUESTION_TYPES: FilterChoices<QuestionsQuestionType> = [
|
||||
{
|
||||
id: 'CODING',
|
||||
label: 'Coding',
|
||||
value: 'CODING',
|
||||
},
|
||||
{
|
||||
id: 'SYSTEM_DESIGN',
|
||||
label: 'Design',
|
||||
value: 'SYSTEM_DESIGN',
|
||||
},
|
||||
{
|
||||
id: 'BEHAVIORAL',
|
||||
label: 'Behavioral',
|
||||
value: 'BEHAVIORAL',
|
||||
},
|
||||
@ -33,18 +42,22 @@ export type QuestionAge = 'all' | 'last-6-months' | 'last-month' | 'last-year';
|
||||
|
||||
export const QUESTION_AGES: FilterChoices<QuestionAge> = [
|
||||
{
|
||||
id: 'last-month',
|
||||
label: 'Last month',
|
||||
value: 'last-month',
|
||||
},
|
||||
{
|
||||
id: 'last-6-months',
|
||||
label: 'Last 6 months',
|
||||
value: 'last-6-months',
|
||||
},
|
||||
{
|
||||
id: 'last-year',
|
||||
label: 'Last year',
|
||||
value: 'last-year',
|
||||
},
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All',
|
||||
value: 'all',
|
||||
},
|
||||
@ -52,37 +65,82 @@ export const QUESTION_AGES: FilterChoices<QuestionAge> = [
|
||||
|
||||
export const LOCATIONS: FilterChoices = [
|
||||
{
|
||||
id: 'Singapore',
|
||||
label: 'Singapore',
|
||||
value: 'Singapore',
|
||||
},
|
||||
{
|
||||
id: 'Menlo Park',
|
||||
label: 'Menlo Park',
|
||||
value: 'Menlo Park',
|
||||
},
|
||||
{
|
||||
id: 'California',
|
||||
label: 'California',
|
||||
value: 'california',
|
||||
value: 'California',
|
||||
},
|
||||
{
|
||||
id: 'Hong Kong',
|
||||
label: 'Hong Kong',
|
||||
value: 'Hong Kong',
|
||||
},
|
||||
{
|
||||
id: 'Taiwan',
|
||||
label: 'Taiwan',
|
||||
value: 'Taiwan',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const ROLES: FilterChoices = [
|
||||
{
|
||||
id: 'Software Engineer',
|
||||
label: 'Software Engineer',
|
||||
value: 'Software Engineer',
|
||||
},
|
||||
{
|
||||
id: 'Software Engineer Intern',
|
||||
label: 'Software Engineer Intern',
|
||||
value: 'Software Engineer Intern',
|
||||
},
|
||||
] 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 = {
|
||||
answerCount: 10,
|
||||
commentCount: 10,
|
||||
company: 'Google',
|
||||
companies: { Google: 1 },
|
||||
content:
|
||||
'Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and',
|
||||
location: 'Menlo Park, CA',
|
||||
createdAt: new Date(2014, 8, 1, 11, 30, 40),
|
||||
id: '1',
|
||||
locations: { 'Menlo Park, CA': 1 },
|
||||
receivedCount: 12,
|
||||
role: 'Software Engineer',
|
||||
roles: { 'Software Engineer': 1 },
|
||||
seenAt: new Date(2014, 8, 1, 11, 30, 40),
|
||||
timestamp: 'Last month',
|
||||
|
||||
type: QuestionsQuestionType.CODING,
|
||||
upvoteCount: 5,
|
||||
};
|
||||
|
||||
|
22
apps/portal/src/utils/questions/useDefaultCompany.ts
Normal file
22
apps/portal/src/utils/questions/useDefaultCompany.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { FilterChoice } from '~/components/questions/filter/FilterSection';
|
||||
|
||||
import { trpc } from '../trpc';
|
||||
|
||||
export default function useDefaultCompany(): FilterChoice | undefined {
|
||||
const { data: companies } = trpc.useQuery([
|
||||
'companies.list',
|
||||
{
|
||||
name: '',
|
||||
},
|
||||
]);
|
||||
|
||||
const company = companies?.[0];
|
||||
if (company === undefined) {
|
||||
return company;
|
||||
}
|
||||
return {
|
||||
id: company.id,
|
||||
label: company.name,
|
||||
value: company.id,
|
||||
};
|
||||
}
|
7
apps/portal/src/utils/questions/useDefaultLocation.ts
Normal file
7
apps/portal/src/utils/questions/useDefaultLocation.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { FilterChoice } from '~/components/questions/filter/FilterSection';
|
||||
|
||||
import { LOCATIONS } from './constants';
|
||||
|
||||
export default function useDefaultLocation(): FilterChoice | undefined {
|
||||
return LOCATIONS[0];
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const useSearchFilter = <Value extends string = string>(
|
||||
name: string,
|
||||
opts: {
|
||||
defaultValues?: Array<Value>;
|
||||
queryParamToValue?: (param: string) => Value;
|
||||
} = {},
|
||||
) => {
|
||||
const { defaultValues, queryParamToValue = (param) => param } = opts;
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const [filters, setFilters] = useState<Array<Value>>(defaultValues || []);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.isReady && !isInitialized) {
|
||||
// Initialize from query params
|
||||
const query = router.query[name];
|
||||
if (query) {
|
||||
const queryValues = Array.isArray(query) ? query : [query];
|
||||
setFilters(queryValues.map(queryParamToValue) as Array<Value>);
|
||||
} else {
|
||||
// Try to load from local storage
|
||||
const localStorageValue = localStorage.getItem(name);
|
||||
if (localStorageValue !== null) {
|
||||
const loadedFilters = JSON.parse(localStorageValue);
|
||||
setFilters(loadedFilters);
|
||||
}
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [isInitialized, name, queryParamToValue, router]);
|
||||
|
||||
const setFiltersCallback = useCallback(
|
||||
(newFilters: Array<Value>) => {
|
||||
setFilters(newFilters);
|
||||
localStorage.setItem(name, JSON.stringify(newFilters));
|
||||
},
|
||||
[name],
|
||||
);
|
||||
|
||||
return [filters, setFiltersCallback, isInitialized] as const;
|
||||
};
|
||||
|
||||
export const useSearchFilterSingle = <Value extends string = string>(
|
||||
name: string,
|
||||
opts: {
|
||||
defaultValue?: Value;
|
||||
queryParamToValue?: (param: string) => Value;
|
||||
} = {},
|
||||
) => {
|
||||
const { defaultValue, queryParamToValue } = opts;
|
||||
const [filters, setFilters, isInitialized] = useSearchFilter(name, {
|
||||
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
|
||||
queryParamToValue,
|
||||
});
|
||||
|
||||
return [
|
||||
filters[0],
|
||||
(value: Value) => setFilters([value]),
|
||||
isInitialized,
|
||||
] as const;
|
||||
};
|
86
apps/portal/src/utils/questions/useSearchParam.ts
Normal file
86
apps/portal/src/utils/questions/useSearchParam.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
type SearchParamOptions<Value> = [Value] extends [string]
|
||||
? {
|
||||
defaultValues?: Array<Value>;
|
||||
paramToString?: (value: Value) => string | null;
|
||||
stringToParam?: (param: string) => Value | null;
|
||||
}
|
||||
: {
|
||||
defaultValues?: Array<Value>;
|
||||
paramToString: (value: Value) => string | null;
|
||||
stringToParam: (param: string) => Value | null;
|
||||
};
|
||||
|
||||
export const useSearchParam = <Value = string>(
|
||||
name: string,
|
||||
opts?: SearchParamOptions<Value>,
|
||||
) => {
|
||||
const {
|
||||
defaultValues,
|
||||
stringToParam = (param: string) => param,
|
||||
paramToString: valueToQueryParam = (value: Value) => String(value),
|
||||
} = opts ?? {};
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const [filters, setFilters] = useState<Array<Value>>(defaultValues || []);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.isReady && !isInitialized) {
|
||||
// Initialize from query params
|
||||
const query = router.query[name];
|
||||
if (query) {
|
||||
const queryValues = Array.isArray(query) ? query : [query];
|
||||
setFilters(
|
||||
queryValues
|
||||
.map(stringToParam)
|
||||
.filter((value) => value !== null) as Array<Value>,
|
||||
);
|
||||
} else {
|
||||
// Try to load from local storage
|
||||
const localStorageValue = localStorage.getItem(name);
|
||||
if (localStorageValue !== null) {
|
||||
const loadedFilters = JSON.parse(localStorageValue);
|
||||
setFilters(loadedFilters);
|
||||
}
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [isInitialized, name, stringToParam, router]);
|
||||
|
||||
const setFiltersCallback = useCallback(
|
||||
(newFilters: Array<Value>) => {
|
||||
setFilters(newFilters);
|
||||
localStorage.setItem(
|
||||
name,
|
||||
JSON.stringify(
|
||||
newFilters.map(valueToQueryParam).filter((param) => param !== null),
|
||||
),
|
||||
);
|
||||
},
|
||||
[name, valueToQueryParam],
|
||||
);
|
||||
|
||||
return [filters, setFiltersCallback, isInitialized] as const;
|
||||
};
|
||||
|
||||
export const useSearchParamSingle = <Value = string>(
|
||||
name: string,
|
||||
opts?: Omit<SearchParamOptions<Value>, 'defaultValues'> & {
|
||||
defaultValue?: Value;
|
||||
},
|
||||
) => {
|
||||
const { defaultValue, ...restOpts } = opts ?? {};
|
||||
const [filters, setFilters, isInitialized] = useSearchParam<Value>(name, {
|
||||
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
|
||||
...restOpts,
|
||||
} as SearchParamOptions<Value>);
|
||||
|
||||
return [
|
||||
filters[0],
|
||||
(value: Value) => setFilters([value]),
|
||||
isInitialized,
|
||||
] as const;
|
||||
};
|
@ -74,6 +74,10 @@ export const useQuestionVote = (id: string) => {
|
||||
create: 'questions.questions.createVote',
|
||||
deleteKey: 'questions.questions.deleteVote',
|
||||
idKey: 'questionId',
|
||||
invalidateKeys: [
|
||||
'questions.questions.getQuestionsByFilter',
|
||||
'questions.questions.getQuestionById',
|
||||
],
|
||||
query: 'questions.questions.getVote',
|
||||
update: 'questions.questions.updateVote',
|
||||
});
|
||||
@ -84,6 +88,10 @@ export const useAnswerVote = (id: string) => {
|
||||
create: 'questions.answers.createVote',
|
||||
deleteKey: 'questions.answers.deleteVote',
|
||||
idKey: 'answerId',
|
||||
invalidateKeys: [
|
||||
'questions.answers.getAnswers',
|
||||
'questions.answers.getAnswerById',
|
||||
],
|
||||
query: 'questions.answers.getVote',
|
||||
update: 'questions.answers.updateVote',
|
||||
});
|
||||
@ -94,6 +102,7 @@ export const useQuestionCommentVote = (id: string) => {
|
||||
create: 'questions.questions.comments.createVote',
|
||||
deleteKey: 'questions.questions.comments.deleteVote',
|
||||
idKey: 'questionCommentId',
|
||||
invalidateKeys: ['questions.questions.comments.getQuestionComments'],
|
||||
query: 'questions.questions.comments.getVote',
|
||||
update: 'questions.questions.comments.updateVote',
|
||||
});
|
||||
@ -104,6 +113,7 @@ export const useAnswerCommentVote = (id: string) => {
|
||||
create: 'questions.answers.comments.createVote',
|
||||
deleteKey: 'questions.answers.comments.deleteVote',
|
||||
idKey: 'answerCommentId',
|
||||
invalidateKeys: ['questions.answers.comments.getAnswerComments'],
|
||||
query: 'questions.answers.comments.getVote',
|
||||
update: 'questions.answers.comments.updateVote',
|
||||
});
|
||||
@ -113,29 +123,30 @@ type VoteProps<VoteQueryKey extends QueryKey = QueryKey> = {
|
||||
create: MutationKey;
|
||||
deleteKey: MutationKey;
|
||||
idKey: string;
|
||||
invalidateKeys: Array<VoteQueryKey>;
|
||||
query: VoteQueryKey;
|
||||
update: MutationKey;
|
||||
};
|
||||
|
||||
type UseVoteMutationContext = {
|
||||
currentData: any;
|
||||
previousData: any;
|
||||
};
|
||||
|
||||
export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
|
||||
id: string,
|
||||
opts: VoteProps<VoteQueryKey>,
|
||||
) => {
|
||||
const { create, deleteKey, query, update, idKey } = opts;
|
||||
const { create, deleteKey, query, update, idKey, invalidateKeys } = opts;
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const onVoteUpdate = useCallback(() => {
|
||||
// TODO: Optimise query invalidation
|
||||
utils.invalidateQueries([query, { [idKey]: id } as any]);
|
||||
utils.invalidateQueries(['questions.questions.getQuestionsByFilter']);
|
||||
utils.invalidateQueries(['questions.questions.getQuestionById']);
|
||||
utils.invalidateQueries(['questions.answers.getAnswers']);
|
||||
utils.invalidateQueries(['questions.answers.getAnswerById']);
|
||||
utils.invalidateQueries([
|
||||
'questions.questions.comments.getQuestionComments',
|
||||
]);
|
||||
utils.invalidateQueries(['questions.answers.comments.getAnswerComments']);
|
||||
}, [id, idKey, utils, query]);
|
||||
for (const invalidateKey of invalidateKeys) {
|
||||
utils.invalidateQueries([invalidateKey]);
|
||||
}
|
||||
}, [id, idKey, utils, query, invalidateKeys]);
|
||||
|
||||
const { data } = trpc.useQuery([
|
||||
query,
|
||||
@ -146,16 +157,87 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
|
||||
|
||||
const backendVote = data as BackendVote;
|
||||
|
||||
const { mutate: createVote } = trpc.useMutation(create, {
|
||||
onSuccess: onVoteUpdate,
|
||||
});
|
||||
const { mutate: updateVote } = trpc.useMutation(update, {
|
||||
onSuccess: onVoteUpdate,
|
||||
});
|
||||
const { mutate: createVote } = trpc.useMutation<any, UseVoteMutationContext>(
|
||||
create,
|
||||
{
|
||||
onError: (err, variables, context) => {
|
||||
if (context !== undefined) {
|
||||
utils.setQueryData([query], context.previousData);
|
||||
}
|
||||
},
|
||||
onMutate: async (vote) => {
|
||||
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]);
|
||||
const previousData = utils.queryClient.getQueryData<BackendVote | null>(
|
||||
[query, { [idKey]: id } as any],
|
||||
);
|
||||
|
||||
const { mutate: deleteVote } = trpc.useMutation(deleteKey, {
|
||||
onSuccess: onVoteUpdate,
|
||||
});
|
||||
utils.setQueryData(
|
||||
[
|
||||
query,
|
||||
{
|
||||
[idKey]: id,
|
||||
} as any,
|
||||
],
|
||||
vote as any,
|
||||
);
|
||||
return { currentData: vote, previousData };
|
||||
},
|
||||
onSettled: onVoteUpdate,
|
||||
},
|
||||
);
|
||||
const { mutate: updateVote } = trpc.useMutation<any, UseVoteMutationContext>(
|
||||
update,
|
||||
{
|
||||
onError: (error, variables, context) => {
|
||||
if (context !== undefined) {
|
||||
utils.setQueryData([query], context.previousData);
|
||||
}
|
||||
},
|
||||
onMutate: async (vote) => {
|
||||
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]);
|
||||
const previousData = utils.queryClient.getQueryData<BackendVote | null>(
|
||||
[query, { [idKey]: id } as any],
|
||||
);
|
||||
|
||||
utils.setQueryData(
|
||||
[
|
||||
query,
|
||||
{
|
||||
[idKey]: id,
|
||||
} as any,
|
||||
],
|
||||
vote,
|
||||
);
|
||||
return { currentData: vote, previousData };
|
||||
},
|
||||
onSettled: onVoteUpdate,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutate: deleteVote } = trpc.useMutation<any, UseVoteMutationContext>(
|
||||
deleteKey,
|
||||
{
|
||||
onError: (err, variables, context) => {
|
||||
if (context !== undefined) {
|
||||
utils.setQueryData([query], context.previousData);
|
||||
}
|
||||
},
|
||||
onMutate: async (vote) => {
|
||||
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]);
|
||||
utils.setQueryData(
|
||||
[
|
||||
query,
|
||||
{
|
||||
[idKey]: id,
|
||||
} as any,
|
||||
],
|
||||
null as any,
|
||||
);
|
||||
return { currentData: null, previousData: vote };
|
||||
},
|
||||
onSettled: onVoteUpdate,
|
||||
},
|
||||
);
|
||||
|
||||
const { handleDownvote, handleUpvote } = createVoteCallbacks(
|
||||
backendVote ?? null,
|
||||
|
Reference in New Issue
Block a user