[questions][feat] add lists ui, sorting, re-design landing page (#411)

Co-authored-by: wlren <weilinwork99@gmail.com>
This commit is contained in:
Jeff Sieu
2022-10-22 22:14:19 +08:00
committed by GitHub
parent 508eea359e
commit 11aa89353f
44 changed files with 2400 additions and 1040 deletions

View File

@@ -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,

View File

@@ -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 />

View File

@@ -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&amp;repo=tech-interview-handbook&amp;type=star&amp;count=true&amp;size=large"

View File

@@ -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>
)}
</>
);
}

View File

@@ -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>
);

View File

@@ -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 = {

View File

@@ -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}

View File

@@ -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}
/>
);
}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)}
/>
);
}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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={() => {}}
/>
);
}

View File

@@ -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={() => {}}
/>
);
}