mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2026-04-28 04:22:44 +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:
@@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user