Merge branch 'main' into hongpo/add-question-encounter-crud

pull/343/head
hpkoh 3 years ago
commit 5f304a6477

@ -25,11 +25,11 @@ export default function ProductNavigation({ items, title }: Props) {
{items.map((item) => {items.map((item) =>
item.children != null && item.children.length > 0 ? ( item.children != null && item.children.length > 0 ? (
<Menu key={item.name} as="div" className="relative text-left"> <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> <span>{item.name}</span>
<ChevronDownIcon <ChevronDownIcon
aria-hidden="true" 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> </Menu.Button>
<Transition <Transition
@ -47,8 +47,8 @@ export default function ProductNavigation({ items, title }: Props) {
{({ active }) => ( {({ active }) => (
<Link <Link
className={clsx( className={clsx(
active ? 'bg-gray-100' : '', active ? 'bg-slate-100' : '',
'block px-4 py-2 text-sm text-gray-700', 'block px-4 py-2 text-sm text-slate-700',
)} )}
href={child.href}> href={child.href}>
{child.name} {child.name}
@ -63,7 +63,7 @@ export default function ProductNavigation({ items, title }: Props) {
) : ( ) : (
<Link <Link
key={item.name} 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}> href={item.href}>
{item.name} {item.name}
</Link> </Link>

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

@ -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 = [ export const LOCATION = [
{ checked: false, label: 'Singapore', value: 'Singapore' }, { 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' }, { 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 { useState } from 'react';
import { Tabs } from '@tih/ui'; import { Tabs } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import Comment from './comment/Comment'; import CommentListItems from './CommentListItems';
import CommentsListButton from './CommentsListButton'; import CommentsListButton from './CommentsListButton';
import { COMMENTS_SECTIONS } from './constants'; import { COMMENTS_SECTIONS } from './constants';
@ -18,13 +17,9 @@ export default function CommentsList({
setShowCommentsForm, setShowCommentsForm,
}: CommentsListProps) { }: CommentsListProps) {
const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value); 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 }]); const commentsQuery = trpc.useQuery(['resumes.reviews.list', { resumeId }]);
// TODO: Add loading prompt
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<CommentsListButton setShowCommentsForm={setShowCommentsForm} /> <CommentsListButton setShowCommentsForm={setShowCommentsForm} />
@ -34,20 +29,10 @@ export default function CommentsList({
value={tab} value={tab}
onChange={(value) => setTab(value)} onChange={(value) => setTab(value)}
/> />
<CommentListItems
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-scroll"> comments={commentsQuery.data?.filter((c) => c.section === tab) ?? []}
{commentsQuery.data isLoading={commentsQuery.isFetching}
?.filter((c) => c.section === tab) />
.map((comment) => {
return (
<Comment
key={comment.id}
comment={comment}
userId={session?.user?.id}
/>
);
})}
</div>
</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() { 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 ( return (
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
<div className="flex h-full items-center justify-center"> <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"> <h1 className="text-primary-600 text-center text-4xl font-bold">
Homepage Homepage
</h1> </h1>
<CompaniesTypeahead
onSelect={(option) => setSelectedCompany(option)}
/>
<pre>{JSON.stringify(selectedCompany, null, 2)}</pre>
<HorizontalDivider />
<MonthYearPicker value={monthYear} onChange={setMonthYear} />
</div> </div>
</div> </div>
</main> </main>

@ -27,17 +27,17 @@ export default function ResumeReviewPage() {
const { data: session } = useSession(); const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
const { resumeId } = router.query; 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 // Safe to assert resumeId type as string because query is only sent if so
const detailsQuery = trpc.useQuery( 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() { onSuccess() {
utils.invalidateQueries(); trpcContext.invalidateQueries(['resumes.resume.findOne']);
}, },
}); });

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

@ -40,6 +40,7 @@ export default function SubmitResumeForm() {
const router = useRouter(); const router = useRouter();
const [resumeFile, setResumeFile] = useState<File | null>(); const [resumeFile, setResumeFile] = useState<File | null>();
const [isLoading, setIsLoading] = useState(false);
const [invalidFileUploadError, setInvalidFileUploadError] = useState< const [invalidFileUploadError, setInvalidFileUploadError] = useState<
string | null string | null
>(null); >(null);
@ -50,12 +51,18 @@ export default function SubmitResumeForm() {
setValue, setValue,
reset, reset,
formState: { errors }, formState: { errors },
} = useForm<IFormInput>(); } = useForm<IFormInput>({
defaultValues: {
isChecked: false,
},
});
const onSubmit: SubmitHandler<IFormInput> = async (data) => { const onSubmit: SubmitHandler<IFormInput> = async (data) => {
if (resumeFile == null) { if (resumeFile == null) {
console.error('Resume file is empty');
return; return;
} }
setIsLoading(true);
const formData = new FormData(); const formData = new FormData();
formData.append('key', RESUME_STORAGE_KEY); formData.append('key', RESUME_STORAGE_KEY);
@ -68,15 +75,27 @@ export default function SubmitResumeForm() {
}); });
const { url } = res.data; const { url } = res.data;
await resumeCreateMutation.mutate({ resumeCreateMutation.mutate(
additionalInfo: data.additionalInfo, {
experience: data.experience, additionalInfo: data.additionalInfo,
location: data.location, experience: data.experience,
role: data.role, location: data.location,
title: data.title, role: data.role,
url, title: data.title,
}); url,
router.push('/resumes'); },
{
onError: (error) => {
console.error(error);
},
onSettled: () => {
setIsLoading(false);
},
onSuccess: () => {
router.push('/resumes');
},
},
);
}; };
const onUploadFile = (event: React.ChangeEvent<HTMLInputElement>) => { const onUploadFile = (event: React.ChangeEvent<HTMLInputElement>) => {
@ -255,6 +274,7 @@ export default function SubmitResumeForm() {
<Button <Button
addonPosition="start" addonPosition="start"
display="inline" display="inline"
isLoading={isLoading}
label="Submit" label="Submit"
size="md" size="md"
type="submit" 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 superjson from 'superjson';
import { companiesRouter } from './companies-router';
import { createRouter } from './context'; import { createRouter } from './context';
import { protectedExampleRouter } from './protected-example-router'; import { protectedExampleRouter } from './protected-example-router';
import { questionsQuestionRouter} from './questions-question-router'; import { questionsQuestionRouter } from './questions-question-router';
import { resumesRouter } from './resumes'; import { resumesRouter } from './resumes/resumes-resume-router';
import { resumesDetailsRouter } from './resumes-details-router'; import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
import { resumesResumeProtectedTabsRouter } from './resumes-resume-protected-tabs-router'; import { resumeReviewsRouter } from './resumes/resumes-reviews-router';
import { resumesResumeUserRouter } from './resumes-resume-user-router'; import { resumesReviewsUserRouter } from './resumes/resumes-reviews-user-router';
import { resumeReviewsRouter } from './resumes-reviews-router'; import { resumesStarUserRouter } from './resumes/resumes-star-user-router';
import { resumesReviewsUserRouter } from './resumes-reviews-user-router';
import { todosRouter } from './todos'; import { todosRouter } from './todos';
import { todosUserRouter } from './todos-user-router'; import { todosUserRouter } from './todos-user-router';
@ -20,10 +20,10 @@ export const appRouter = createRouter()
.merge('auth.', protectedExampleRouter) .merge('auth.', protectedExampleRouter)
.merge('todos.', todosRouter) .merge('todos.', todosRouter)
.merge('todos.user.', todosUserRouter) .merge('todos.user.', todosUserRouter)
.merge('companies.', companiesRouter)
.merge('resumes.resume.', resumesRouter) .merge('resumes.resume.', resumesRouter)
.merge('resumes.details.', resumesDetailsRouter)
.merge('resumes.resume.user.', resumesResumeUserRouter) .merge('resumes.resume.user.', resumesResumeUserRouter)
.merge('resumes.resume.browse.', resumesResumeProtectedTabsRouter) .merge('resumes.star.user.', resumesStarUserRouter)
.merge('resumes.reviews.', resumeReviewsRouter) .merge('resumes.reviews.', resumeReviewsRouter)
.merge('resumes.reviews.user.', resumesReviewsUserRouter) .merge('resumes.reviews.user.', resumesReviewsUserRouter)
.merge('questions.questions.', questionsQuestionRouter); .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'; import type { Resume } from '~/types/resume';
export const resumesResumeProtectedTabsRouter = createProtectedRouter() export const resumesResumeUserRouter = createProtectedRouter()
.query('stars', { .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 }) { async resolve({ ctx }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const resumeStarsData = await ctx.prisma.resumesStar.findMany({ const resumeStarsData = await ctx.prisma.resumesStar.findMany({
@ -49,7 +71,7 @@ export const resumesResumeProtectedTabsRouter = createProtectedRouter()
}); });
}, },
}) })
.query('my', { .query('findUserCreated', {
async resolve({ ctx }) { async resolve({ ctx }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const resumesData = await ctx.prisma.resumesResume.findMany({ const resumesData = await ctx.prisma.resumesResume.findMany({

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

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

@ -1,43 +1,10 @@
import { z } from 'zod'; import { z } from 'zod';
import { createRouter } from './context'; import { createProtectedRouter } from '../context';
export const resumesDetailsRouter = createRouter() export const resumesStarUserRouter = createProtectedRouter().mutation(
.query('find', { 'create_or_delete',
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', {
input: z.object({ input: z.object({
resumeId: z.string(), 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<{ type Props = Readonly<{
children: React.ReactNode; children: React.ReactNode;
isShown?: boolean; isShown: boolean;
onClose: () => void; onClose: () => void;
primaryButton: React.ReactNode; primaryButton: React.ReactNode;
secondaryButton?: React.ReactNode; secondaryButton?: React.ReactNode;

@ -8,7 +8,7 @@ export default function HorizontalDivider({ className }: Props) {
return ( return (
<hr <hr
aria-hidden={true} 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 // TextInput
export * from './TextInput/TextInput'; export * from './TextInput/TextInput';
export { default as TextInput } 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