Merge branch 'main' into hongpo/update-question-filter

pull/339/head
hpkoh 3 years ago
commit 00dbbe0fe4

@ -25,11 +25,11 @@ export default function ProductNavigation({ items, title }: Props) {
{items.map((item) =>
item.children != null && item.children.length > 0 ? (
<Menu key={item.name} as="div" className="relative text-left">
<Menu.Button className="focus:ring-primary-600 flex items-center rounded-md text-sm font-medium text-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2">
<Menu.Button className="focus:ring-primary-600 flex items-center rounded-md text-sm font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-offset-2">
<span>{item.name}</span>
<ChevronDownIcon
aria-hidden="true"
className="ml-1 h-5 w-5 text-gray-500"
className="ml-1 h-5 w-5 text-slate-500"
/>
</Menu.Button>
<Transition
@ -47,8 +47,8 @@ export default function ProductNavigation({ items, title }: Props) {
{({ active }) => (
<Link
className={clsx(
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700',
active ? 'bg-slate-100' : '',
'block px-4 py-2 text-sm text-slate-700',
)}
href={child.href}>
{child.name}
@ -63,7 +63,7 @@ export default function ProductNavigation({ items, title }: Props) {
) : (
<Link
key={item.name}
className="hover:text-primary-600 text-sm font-medium text-gray-900"
className="hover:text-primary-600 text-sm font-medium text-slate-900"
href={item.href}>
{item.name}
</Link>

@ -35,9 +35,12 @@ export default function BrowseListItem({ href, resumeInfo }: Props) {
</div>
</div>
<div className="col-span-3 self-center text-sm text-slate-500">
<div>
Uploaded {formatDistanceToNow(resumeInfo.createdAt)} ago by{' '}
{resumeInfo.user}
</div>
<div className="mt-2 text-slate-400">{resumeInfo.location}</div>
</div>
<ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center" />
</div>
</Link>

@ -0,0 +1,35 @@
import { Spinner } from '@tih/ui';
import ResumseListItem from './ResumeListItem';
import type { Resume } from '~/types/resume';
type Props = Readonly<{
isLoading: boolean;
resumes: Array<Resume>;
}>;
export default function ResumeListItems({ isLoading, resumes }: Props) {
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
return (
<div className="col-span-10 pr-8">
<ul role="list">
{resumes.map((resumeObj: Resume) => (
<li key={resumeObj.id}>
<ResumseListItem
href={`resumes/${resumeObj.id}`}
resumeInfo={resumeObj}
/>
</li>
))}
</ul>
</div>
);
}

@ -54,7 +54,7 @@ export const EXPERIENCE = [
export const LOCATION = [
{ checked: false, label: 'Singapore', value: 'Singapore' },
{ checked: false, label: 'United States', value: 'Usa' },
{ checked: false, label: 'United States', value: 'United States' },
{ checked: false, label: 'India', value: 'India' },
];

@ -0,0 +1,35 @@
import { useSession } from 'next-auth/react';
import { Spinner } from '@tih/ui';
import Comment from './comment/Comment';
import type { ResumeComment } from '~/types/resume-comments';
type Props = Readonly<{
comments: Array<ResumeComment>;
isLoading: boolean;
}>;
export default function CommentListItems({ comments, isLoading }: Props) {
const { data: session } = useSession();
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
return (
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-scroll">
{comments.map((comment) => (
<Comment
key={comment.id}
comment={comment}
userId={session?.user?.id}
/>
))}
</div>
);
}

@ -1,10 +1,9 @@
import { useSession } from 'next-auth/react';
import { useState } from 'react';
import { Tabs } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import Comment from './comment/Comment';
import CommentListItems from './CommentListItems';
import CommentsListButton from './CommentsListButton';
import { COMMENTS_SECTIONS } from './constants';
@ -18,13 +17,9 @@ export default function CommentsList({
setShowCommentsForm,
}: CommentsListProps) {
const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value);
const { data: session } = useSession();
// Fetch the most updated comments to render
const commentsQuery = trpc.useQuery(['resumes.reviews.list', { resumeId }]);
// TODO: Add loading prompt
return (
<div className="space-y-3">
<CommentsListButton setShowCommentsForm={setShowCommentsForm} />
@ -34,20 +29,10 @@ export default function CommentsList({
value={tab}
onChange={(value) => setTab(value)}
/>
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-scroll">
{commentsQuery.data
?.filter((c) => c.section === tab)
.map((comment) => {
return (
<Comment
key={comment.id}
comment={comment}
userId={session?.user?.id}
<CommentListItems
comments={commentsQuery.data?.filter((c) => c.section === tab) ?? []}
isLoading={commentsQuery.isFetching}
/>
);
})}
</div>
</div>
);
}

@ -0,0 +1,40 @@
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
import { trpc } from '~/utils/trpc';
type Props = Readonly<{
disabled?: boolean;
onSelect: (option: TypeaheadOption) => void;
}>;
export default function CompaniesTypeahead({ disabled, onSelect }: Props) {
const [query, setQuery] = useState('');
const companies = trpc.useQuery([
'companies.list',
{
name: query,
},
]);
const { data } = companies;
return (
<Typeahead
disabled={disabled}
label="Company"
noResultsMessage="No companies found"
nullable={true}
options={
data?.map(({ id, name }) => ({
id,
label: name,
value: id,
})) ?? []
}
onQueryChange={setQuery}
onSelect={onSelect}
/>
);
}

@ -0,0 +1,96 @@
import { Select } from '@tih/ui';
type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
export type MonthYear = Readonly<{
month: Month;
year: number;
}>;
type Props = Readonly<{
onChange: (value: MonthYear) => void;
value: MonthYear;
}>;
const MONTH_OPTIONS = [
{
label: 'January',
value: 1,
},
{
label: 'February',
value: 2,
},
{
label: 'March',
value: 3,
},
{
label: 'April',
value: 4,
},
{
label: 'May',
value: 5,
},
{
label: 'June',
value: 6,
},
{
label: 'July',
value: 7,
},
{
label: 'August',
value: 8,
},
{
label: 'September',
value: 9,
},
{
label: 'October',
value: 10,
},
{
label: 'November',
value: 11,
},
{
label: 'December',
value: 12,
},
];
const NUM_YEARS = 5;
const YEAR_OPTIONS = Array.from({ length: NUM_YEARS }, (_, i) => {
const year = new Date().getFullYear() - NUM_YEARS + i + 1;
return {
label: String(year),
value: year,
};
});
export default function MonthYearPicker({ value, onChange }: Props) {
return (
<div className="flex space-x-4">
<Select
label="Month"
options={MONTH_OPTIONS}
value={value.month}
onChange={(newMonth) =>
onChange({ month: Number(newMonth) as Month, year: value.year })
}
/>
<Select
label="Year"
options={YEAR_OPTIONS}
value={value.year}
onChange={(newYear) =>
onChange({ month: value.month, year: Number(newYear) })
}
/>
</div>
);
}

@ -1,11 +1,32 @@
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import type { MonthYear } from '~/components/shared/MonthYearPicker';
import MonthYearPicker from '~/components/shared/MonthYearPicker';
export default function HomePage() {
const [selectedCompany, setSelectedCompany] =
useState<TypeaheadOption | null>(null);
const [monthYear, setMonthYear] = useState<MonthYear>({
month: new Date().getMonth() + 1,
year: new Date().getFullYear(),
});
return (
<main className="flex-1 overflow-y-auto">
<div className="flex h-full items-center justify-center">
<div>
<div className="space-y-4">
<h1 className="text-primary-600 text-center text-4xl font-bold">
Homepage
</h1>
<CompaniesTypeahead
onSelect={(option) => setSelectedCompany(option)}
/>
<pre>{JSON.stringify(selectedCompany, null, 2)}</pre>
<HorizontalDivider />
<MonthYearPicker value={monthYear} onChange={setMonthYear} />
</div>
</div>
</main>

@ -27,17 +27,17 @@ export default function ResumeReviewPage() {
const { data: session } = useSession();
const router = useRouter();
const { resumeId } = router.query;
const utils = trpc.useContext();
const trpcContext = trpc.useContext();
// Safe to assert resumeId type as string because query is only sent if so
const detailsQuery = trpc.useQuery(
['resumes.details.find', { resumeId: resumeId as string }],
['resumes.resume.findOne', { resumeId: resumeId as string }],
{
enabled: typeof resumeId === 'string' && session?.user?.id !== undefined,
enabled: typeof resumeId === 'string',
},
);
const starMutation = trpc.useMutation('resumes.details.update_star', {
const starMutation = trpc.useMutation('resumes.star.user.create_or_delete', {
onSuccess() {
utils.invalidateQueries();
trpcContext.invalidateQueries(['resumes.resume.findOne']);
},
});

@ -2,7 +2,7 @@ import clsx from 'clsx';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { Fragment, useEffect, useState } from 'react';
import { Fragment, useState } from 'react';
import { Disclosure, Menu, Transition } from '@headlessui/react';
import {
ChevronDownIcon,
@ -10,9 +10,8 @@ import {
PlusIcon,
} from '@heroicons/react/20/solid';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { Spinner, Tabs, TextInput } from '@tih/ui';
import { Tabs, TextInput } from '@tih/ui';
import BrowseListItem from '~/components/resumes/browse/BrowseListItem';
import {
BROWSE_TABS_VALUES,
EXPERIENCE,
@ -22,6 +21,10 @@ import {
TOP_HITS,
} from '~/components/resumes/browse/constants';
import FilterPill from '~/components/resumes/browse/FilterPill';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle';
import { trpc } from '~/utils/trpc';
import type { Resume } from '~/types/resume';
@ -42,54 +45,41 @@ const filters = [
options: LOCATION,
},
];
import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle';
import { trpc } from '~/utils/trpc';
export default function ResumeHomePage() {
const { data } = useSession();
const { data: sessionData } = useSession();
const router = useRouter();
const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL);
const [searchValue, setSearchValue] = useState('');
const [resumes, setResumes] = useState<Array<Resume>>([]);
const allResumesQuery = trpc.useQuery(['resumes.resume.all'], {
const allResumesQuery = trpc.useQuery(['resumes.resume.findAll'], {
enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
onSuccess: (data) => {
setResumes(data);
},
});
const starredResumesQuery = trpc.useQuery(['resumes.resume.browse.stars'], {
const starredResumesQuery = trpc.useQuery(
['resumes.resume.user.findUserStarred'],
{
enabled: tabsValue === BROWSE_TABS_VALUES.STARRED,
});
const myResumesQuery = trpc.useQuery(['resumes.resume.browse.my'], {
onSuccess: (data) => {
setResumes(data);
},
},
);
const myResumesQuery = trpc.useQuery(
['resumes.resume.user.findUserCreated'],
{
enabled: tabsValue === BROWSE_TABS_VALUES.MY,
});
useEffect(() => {
switch (tabsValue) {
case BROWSE_TABS_VALUES.ALL: {
setResumes(allResumesQuery.data ?? []);
break;
}
case BROWSE_TABS_VALUES.STARRED: {
setResumes(starredResumesQuery.data ?? []);
break;
}
case BROWSE_TABS_VALUES.MY: {
setResumes(myResumesQuery.data ?? []);
break;
}
default: {
setResumes([]);
}
}
}, [
allResumesQuery.data,
starredResumesQuery.data,
myResumesQuery.data,
tabsValue,
]);
onSuccess: (data) => {
setResumes(data);
},
},
);
const onClickNew = () => {
if (data?.user?.id) {
if (sessionData?.user?.id) {
router.push('/resumes/submit');
} else {
// TODO: Handle non-logged in user behaviour
@ -150,6 +140,7 @@ export default function ResumeHomePage() {
<div className="col-span-1 justify-self-center">
<Menu as="div" className="relative inline-block text-left">
<div>
{/* TODO: Sort logic */}
<Menu.Button className="group inline-flex justify-center text-sm font-medium text-gray-700 hover:text-gray-900">
Sort
<ChevronDownIcon
@ -279,26 +270,14 @@ export default function ResumeHomePage() {
</form>
</div>
</div>
{allResumesQuery.isLoading ||
starredResumesQuery.isLoading ||
myResumesQuery.isLoading ? (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
) : (
<div className="col-span-10 pr-8">
<ul role="list">
{resumes.map((resumeObj) => (
<li key={resumeObj.id}>
<BrowseListItem
href={`resumes/${resumeObj.id}`}
resumeInfo={resumeObj}
<ResumeListItems
isLoading={
allResumesQuery.isFetching ||
starredResumesQuery.isFetching ||
myResumesQuery.isFetching
}
resumes={resumes}
/>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>

@ -40,6 +40,7 @@ export default function SubmitResumeForm() {
const router = useRouter();
const [resumeFile, setResumeFile] = useState<File | null>();
const [isLoading, setIsLoading] = useState(false);
const [invalidFileUploadError, setInvalidFileUploadError] = useState<
string | null
>(null);
@ -50,12 +51,18 @@ export default function SubmitResumeForm() {
setValue,
reset,
formState: { errors },
} = useForm<IFormInput>();
} = useForm<IFormInput>({
defaultValues: {
isChecked: false,
},
});
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
if (resumeFile == null) {
console.error('Resume file is empty');
return;
}
setIsLoading(true);
const formData = new FormData();
formData.append('key', RESUME_STORAGE_KEY);
@ -68,15 +75,27 @@ export default function SubmitResumeForm() {
});
const { url } = res.data;
await resumeCreateMutation.mutate({
resumeCreateMutation.mutate(
{
additionalInfo: data.additionalInfo,
experience: data.experience,
location: data.location,
role: data.role,
title: data.title,
url,
});
},
{
onError: (error) => {
console.error(error);
},
onSettled: () => {
setIsLoading(false);
},
onSuccess: () => {
router.push('/resumes');
},
},
);
};
const onUploadFile = (event: React.ChangeEvent<HTMLInputElement>) => {
@ -255,6 +274,7 @@ export default function SubmitResumeForm() {
<Button
addonPosition="start"
display="inline"
isLoading={isLoading}
label="Submit"
size="md"
type="submit"

@ -0,0 +1,23 @@
import { z } from 'zod';
import { createRouter } from './context';
export const companiesRouter = createRouter().query('list', {
input: z.object({
name: z.string(),
}),
async resolve({ ctx, input }) {
return await ctx.prisma.company.findMany({
orderBy: {
name: 'desc',
},
take: 10,
where: {
name: {
contains: input.name,
mode: 'insensitive',
},
},
});
},
});

@ -1,14 +1,14 @@
import superjson from 'superjson';
import { companiesRouter } from './companies-router';
import { createRouter } from './context';
import { protectedExampleRouter } from './protected-example-router';
import { questionsQuestionRouter} from './questions-question-router';
import { resumesRouter } from './resumes';
import { resumesDetailsRouter } from './resumes-details-router';
import { resumesResumeProtectedTabsRouter } from './resumes-resume-protected-tabs-router';
import { resumesResumeUserRouter } from './resumes-resume-user-router';
import { resumeReviewsRouter } from './resumes-reviews-router';
import { resumesReviewsUserRouter } from './resumes-reviews-user-router';
import { questionsQuestionRouter } from './questions-question-router';
import { resumesRouter } from './resumes/resumes-resume-router';
import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
import { resumeReviewsRouter } from './resumes/resumes-reviews-router';
import { resumesReviewsUserRouter } from './resumes/resumes-reviews-user-router';
import { resumesStarUserRouter } from './resumes/resumes-star-user-router';
import { todosRouter } from './todos';
import { todosUserRouter } from './todos-user-router';
@ -20,10 +20,10 @@ export const appRouter = createRouter()
.merge('auth.', protectedExampleRouter)
.merge('todos.', todosRouter)
.merge('todos.user.', todosUserRouter)
.merge('companies.', companiesRouter)
.merge('resumes.resume.', resumesRouter)
.merge('resumes.details.', resumesDetailsRouter)
.merge('resumes.resume.user.', resumesResumeUserRouter)
.merge('resumes.resume.browse.', resumesResumeProtectedTabsRouter)
.merge('resumes.star.user.', resumesStarUserRouter)
.merge('resumes.reviews.', resumeReviewsRouter)
.merge('resumes.reviews.user.', resumesReviewsUserRouter)
.merge('questions.questions.', questionsQuestionRouter);

@ -1,27 +0,0 @@
import { z } from 'zod';
import { createProtectedRouter } from './context';
export const resumesResumeUserRouter = createProtectedRouter().mutation(
'create',
{
// TODO: Use enums for experience, location, role
input: z.object({
additionalInfo: z.string().optional(),
experience: z.string(),
location: z.string(),
role: z.string(),
title: z.string(),
url: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user.id;
return await ctx.prisma.resumesResume.create({
data: {
...input,
userId,
},
});
},
},
);

@ -1,42 +0,0 @@
import { createRouter } from './context';
import type { Resume } from '~/types/resume';
export const resumesRouter = createRouter().query('all', {
async resolve({ ctx }) {
const resumesData = await ctx.prisma.resumesResume.findMany({
include: {
_count: {
select: {
comments: true,
stars: true,
},
},
user: {
select: {
name: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return resumesData.map((r) => {
const resume: Resume = {
additionalInfo: r.additionalInfo,
createdAt: r.createdAt,
experience: r.experience,
id: r.id,
location: r.location,
numComments: r._count.comments,
numStars: r._count.stars,
role: r.role,
title: r.title,
url: r.url,
user: r.user.name!,
};
return resume;
});
},
});

@ -0,0 +1,79 @@
import { z } from 'zod';
import { createRouter } from '../context';
import type { Resume } from '~/types/resume';
export const resumesRouter = createRouter()
.query('findAll', {
async resolve({ ctx }) {
const resumesData = await ctx.prisma.resumesResume.findMany({
include: {
_count: {
select: {
comments: true,
stars: true,
},
},
user: {
select: {
name: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return resumesData.map((r) => {
const resume: Resume = {
additionalInfo: r.additionalInfo,
createdAt: r.createdAt,
experience: r.experience,
id: r.id,
location: r.location,
numComments: r._count.comments,
numStars: r._count.stars,
role: r.role,
title: r.title,
url: r.url,
user: r.user.name!,
};
return resume;
});
},
})
.query('findOne', {
input: z.object({
resumeId: z.string(),
}),
async resolve({ ctx, input }) {
const { resumeId } = input;
const userId = ctx.session?.user?.id;
// Use the resumeId to query all related information of a single resume
// from Resumesresume:
return await ctx.prisma.resumesResume.findUnique({
include: {
_count: {
select: {
stars: true,
},
},
stars: {
where: {
userId,
},
},
user: {
select: {
name: true,
},
},
},
where: {
id: resumeId,
},
});
},
});

@ -1,9 +1,31 @@
import { createProtectedRouter } from './context';
import { z } from 'zod';
import { createProtectedRouter } from '../context';
import type { Resume } from '~/types/resume';
export const resumesResumeProtectedTabsRouter = createProtectedRouter()
.query('stars', {
export const resumesResumeUserRouter = createProtectedRouter()
.mutation('create', {
// TODO: Use enums for experience, location, role
input: z.object({
additionalInfo: z.string().optional(),
experience: z.string(),
location: z.string(),
role: z.string(),
title: z.string(),
url: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user.id;
return await ctx.prisma.resumesResume.create({
data: {
...input,
userId,
},
});
},
})
.query('findUserStarred', {
async resolve({ ctx }) {
const userId = ctx.session?.user?.id;
const resumeStarsData = await ctx.prisma.resumesStar.findMany({
@ -49,7 +71,7 @@ export const resumesResumeProtectedTabsRouter = createProtectedRouter()
});
},
})
.query('my', {
.query('findUserCreated', {
async resolve({ ctx }) {
const userId = ctx.session?.user?.id;
const resumesData = await ctx.prisma.resumesResume.findMany({

@ -1,6 +1,6 @@
import { z } from 'zod';
import { createRouter } from './context';
import { createRouter } from '../context';
import type { ResumeComment } from '~/types/resume-comments';

@ -1,7 +1,7 @@
import { z } from 'zod';
import { ResumesSection } from '@prisma/client';
import { createProtectedRouter } from './context';
import { createProtectedRouter } from '../context';
type IResumeCommentInput = Readonly<{
description: string;

@ -1,43 +1,10 @@
import { z } from 'zod';
import { createRouter } from './context';
import { createProtectedRouter } from '../context';
export const resumesDetailsRouter = createRouter()
.query('find', {
input: z.object({
resumeId: z.string(),
}),
async resolve({ ctx, input }) {
const { resumeId } = input;
const userId = ctx.session?.user?.id;
// Use the resumeId to query all related information of a single resume
// from Resumesresume:
return await ctx.prisma.resumesResume.findUnique({
include: {
_count: {
select: {
stars: true,
},
},
stars: {
where: {
userId,
},
},
user: {
select: {
name: true,
},
},
},
where: {
id: resumeId,
},
});
},
})
.mutation('update_star', {
export const resumesStarUserRouter = createProtectedRouter().mutation(
'create_or_delete',
{
input: z.object({
resumeId: z.string(),
}),
@ -76,4 +43,5 @@ export const resumesDetailsRouter = createRouter()
},
});
},
});
},
);

@ -0,0 +1,79 @@
import React, { useState } from 'react';
import type { ComponentMeta } from '@storybook/react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
export default {
argTypes: {
disabled: {
control: 'boolean',
},
isLabelHidden: {
control: 'boolean',
},
label: {
control: 'text',
},
noResultsMessage: {
control: 'text',
},
},
component: Typeahead,
parameters: {
docs: {
iframeHeight: 400,
inlineStories: false,
},
},
title: 'Typeahead',
} as ComponentMeta<typeof Typeahead>;
export function Basic({
disabled,
isLabelHidden,
label,
}: Pick<
React.ComponentProps<typeof Typeahead>,
'disabled' | 'isLabelHidden' | 'label'
>) {
const people = [
{ id: '1', label: 'Wade Cooper', value: '1' },
{ id: '2', label: 'Arlene Mccoy', value: '2' },
{ id: '3', label: 'Devon Webb', value: '3' },
{ id: '4', label: 'Tom Cook', value: '4' },
{ id: '5', label: 'Tanya Fox', value: '5' },
{ id: '6', label: 'Hellen Schmidt', value: '6' },
];
const [selectedEntry, setSelectedEntry] = useState<TypeaheadOption>(
people[0],
);
const [query, setQuery] = useState('');
const filteredPeople =
query === ''
? people
: people.filter((person) =>
person.label
.toLowerCase()
.replace(/\s+/g, '')
.includes(query.toLowerCase().replace(/\s+/g, '')),
);
return (
<Typeahead
disabled={disabled}
isLabelHidden={isLabelHidden}
label={label}
options={filteredPeople}
value={selectedEntry}
onQueryChange={setQuery}
onSelect={setSelectedEntry}
/>
);
}
Basic.args = {
disabled: false,
isLabelHidden: false,
label: 'Author',
};

@ -4,7 +4,7 @@ import { Dialog as HeadlessDialog, Transition } from '@headlessui/react';
type Props = Readonly<{
children: React.ReactNode;
isShown?: boolean;
isShown: boolean;
onClose: () => void;
primaryButton: React.ReactNode;
secondaryButton?: React.ReactNode;

@ -8,7 +8,7 @@ export default function HorizontalDivider({ className }: Props) {
return (
<hr
aria-hidden={true}
className={clsx('my-2 h-0 border-t border-slate-200', className)}
className={clsx('my-2 h-0 border-t border-slate-100', className)}
/>
);
}

@ -0,0 +1,131 @@
import clsx from 'clsx';
import { Fragment, useState } from 'react';
import { Combobox, Transition } from '@headlessui/react';
import { ChevronUpDownIcon } from '@heroicons/react/20/solid';
export type TypeaheadOption = Readonly<{
// String value to uniquely identify the option.
id: string;
label: string;
value: string;
}>;
type Props = Readonly<{
disabled?: boolean;
isLabelHidden?: boolean;
label: string;
noResultsMessage?: string;
nullable?: boolean;
onQueryChange: (
value: string,
event: React.ChangeEvent<HTMLInputElement>,
) => void;
onSelect: (option: TypeaheadOption) => void;
options: ReadonlyArray<TypeaheadOption>;
value?: TypeaheadOption;
}>;
export default function Typeahead({
disabled = false,
isLabelHidden,
label,
noResultsMessage = 'No results',
nullable = false,
options,
onQueryChange,
value,
onSelect,
}: Props) {
const [query, setQuery] = useState('');
return (
<Combobox
by="id"
disabled={disabled}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
multiple={false}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
nullable={nullable}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
value={value}
onChange={(newValue) => {
if (newValue == null) {
return;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
onSelect(newValue as TypeaheadOption);
}}>
<Combobox.Label
className={clsx(
isLabelHidden
? 'sr-only'
: 'mb-1 block text-sm font-medium text-slate-700',
)}>
{label}
</Combobox.Label>
<div className="relative">
<div className="focus-visible:ring-offset-primary-300 relative w-full cursor-default overflow-hidden rounded-lg border border-slate-300 bg-white text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 sm:text-sm">
<Combobox.Input
className={clsx(
'w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-slate-900 focus:ring-0',
disabled && 'pointer-events-none select-none bg-slate-100',
)}
displayValue={(option) =>
(option as unknown as TypeaheadOption)?.label
}
onChange={(event) => {
setQuery(event.target.value);
onQueryChange(event.target.value, event);
}}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
aria-hidden="true"
className="h-5 w-5 text-slate-400"
/>
</Combobox.Button>
</div>
<Transition
afterLeave={() => setQuery('')}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<Combobox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{options.length === 0 && query !== '' ? (
<div className="relative cursor-default select-none py-2 px-4 text-slate-700">
{noResultsMessage}
</div>
) : (
options.map((option) => (
<Combobox.Option
key={option.id}
className={({ active }) =>
clsx(
'relative cursor-default select-none py-2 px-4 text-slate-500',
active && 'bg-slate-100',
)
}
value={option}>
{({ selected }) => (
<span
className={clsx(
'block truncate',
selected ? 'font-medium' : 'font-normal',
)}>
{option.label}
</span>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</Transition>
</div>
</Combobox>
);
}

@ -49,3 +49,6 @@ export { default as TextArea } from './TextArea/TextArea';
// TextInput
export * from './TextInput/TextInput';
export { default as TextInput } from './TextInput/TextInput';
// Typeahead
export * from './Typeahead/Typeahead';
export { default as Typeahead } from './Typeahead/Typeahead';

Loading…
Cancel
Save