[questions][feat] add lists ui, sorting, re-design landing page (#411)
Co-authored-by: wlren <weilinwork99@gmail.com>pull/413/head
parent
508eea359e
commit
11aa89353f
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -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={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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 Head from 'next/head';
|
||||||
import Router, { useRouter } from 'next/router';
|
import { 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 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 type { LandingQueryData } from '~/components/questions/LandingComponent';
|
||||||
import LandingComponent from '~/components/questions/LandingComponent';
|
import LandingComponent from '~/components/questions/LandingComponent';
|
||||||
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
|
|
||||||
|
|
||||||
import type { QuestionAge } from '~/utils/questions/constants';
|
import { APP_TITLE } from '~/utils/questions/constants';
|
||||||
import {
|
|
||||||
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';
|
|
||||||
|
|
||||||
export default function QuestionsHomePage() {
|
export default function QuestionsHomePage() {
|
||||||
const router = useRouter();
|
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 handleLandingQuery = async (data: LandingQueryData) => {
|
||||||
const { company, location, questionType } = data;
|
const { company, location, questionType } = data;
|
||||||
|
|
||||||
setSelectedCompanies([company]);
|
// Go to browse page
|
||||||
setSelectedLocations([location]);
|
router.push({
|
||||||
setSelectedQuestionTypes([questionType as QuestionsQuestionType]);
|
pathname: '/questions/browse',
|
||||||
setHasLanded(true);
|
query: {
|
||||||
|
companies: [company],
|
||||||
|
locations: [location],
|
||||||
|
questionTypes: [questionType],
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const areFiltersInitialized = useMemo(() => {
|
return (
|
||||||
return (
|
<>
|
||||||
areCompaniesInitialized &&
|
<Head>
|
||||||
areQuestionTypesInitialized &&
|
<title>Home - {APP_TITLE}</title>
|
||||||
isQuestionAgeInitialized &&
|
</Head>
|
||||||
areLocationsInitialized
|
<LandingComponent onLanded={handleLandingQuery}></LandingComponent>
|
||||||
);
|
</>
|
||||||
}, [
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
export type RequireAllOrNone<T> = T | { [K in keyof T]?: never };
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
@ -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;
|
|
||||||
};
|
|
@ -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;
|
||||||
|
};
|
Loading…
Reference in new issue