From 9f61ecf9c23d56cf9cf734aa349b300123744e18 Mon Sep 17 00:00:00 2001 From: Keane Chan Date: Thu, 6 Oct 2022 17:40:11 +0800 Subject: [PATCH 01/19] [resumes][feat] submit resume mutation (#310) --- .../migration.sql | 5 +++- apps/portal/prisma/schema.prisma | 7 +++-- apps/portal/src/pages/resumes/submit.tsx | 21 ++++++++----- apps/portal/src/server/router/index.ts | 4 ++- .../router/resumes-resume-user-router.ts | 30 +++++++++++++++++++ 5 files changed, 56 insertions(+), 11 deletions(-) rename apps/portal/prisma/migrations/{20221006064944_add_resume_schemas => 20221006090216_add_resume_schemas}/migration.sql (95%) create mode 100644 apps/portal/src/server/router/resumes-resume-user-router.ts diff --git a/apps/portal/prisma/migrations/20221006064944_add_resume_schemas/migration.sql b/apps/portal/prisma/migrations/20221006090216_add_resume_schemas/migration.sql similarity index 95% rename from apps/portal/prisma/migrations/20221006064944_add_resume_schemas/migration.sql rename to apps/portal/prisma/migrations/20221006090216_add_resume_schemas/migration.sql index 67b66abf..fac3cdd7 100644 --- a/apps/portal/prisma/migrations/20221006064944_add_resume_schemas/migration.sql +++ b/apps/portal/prisma/migrations/20221006090216_add_resume_schemas/migration.sql @@ -6,8 +6,11 @@ CREATE TABLE "ResumesResume" ( "id" TEXT NOT NULL, "userId" TEXT NOT NULL, "title" TEXT NOT NULL, - "additionalInfo" TEXT NOT NULL, + "role" TEXT NOT NULL, + "experience" TEXT NOT NULL, + "location" TEXT NOT NULL, "url" TEXT NOT NULL, + "additionalInfo" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index b62ad310..95c4381f 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -94,9 +94,12 @@ model ResumesResume { id String @id @default(cuid()) userId String title String @db.Text - additionalInfo String @db.Text - // TODO: Add role, experience, location from Enums + // TODO: Update role, experience, location to use Enums + role String @db.Text + experience String @db.Text + location String @db.Text url String + additionalInfo String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/apps/portal/src/pages/resumes/submit.tsx b/apps/portal/src/pages/resumes/submit.tsx index e725a730..54bb0e6d 100644 --- a/apps/portal/src/pages/resumes/submit.tsx +++ b/apps/portal/src/pages/resumes/submit.tsx @@ -1,10 +1,13 @@ import Head from 'next/head'; +import { useRouter } from 'next/router'; import { useMemo, useState } from 'react'; import type { SubmitHandler } from 'react-hook-form'; import { useForm } from 'react-hook-form'; import { PaperClipIcon } from '@heroicons/react/24/outline'; import { Button, Select, TextInput } from '@tih/ui'; +import { trpc } from '~/utils/trpc'; + const TITLE_PLACEHOLDER = 'e.g. Applying for Company XYZ, please help me to review!'; const ADDITIONAL_INFO_PLACEHOLDER = `e.g. I’m applying for company XYZ. I have been resume-rejected by N companies that I have applied for. Please help me to review so company XYZ gives me an interview!`; @@ -13,7 +16,7 @@ const FILE_UPLOAD_ERROR = 'Please upload a PDF file that is less than 10MB.'; const MAX_FILE_SIZE_LIMIT = 10485760; type IFormInput = { - additionalInformation?: string; + additionalInfo?: string; experience: string; file: File; location: string; @@ -68,6 +71,9 @@ export default function SubmitResumeForm() { }, ]; + const resumeCreateMutation = trpc.useMutation('resumes.resume.user.create'); + const router = useRouter(); + const [resumeFile, setResumeFile] = useState(); const [invalidFileUploadError, setInvalidFileUploadError] = useState< string | null @@ -81,10 +87,11 @@ export default function SubmitResumeForm() { formState: { errors }, } = useForm(); - // TODO: Add Create resume mutation - const onSubmit: SubmitHandler = (data) => { - alert(JSON.stringify(data)); - onClickReset(); + const onSubmit: SubmitHandler = async (data) => { + await resumeCreateMutation.mutate({ + ...data, + }); + router.push('/resumes'); }; const onUploadFile = (event: React.ChangeEvent) => { @@ -196,10 +203,10 @@ export default function SubmitResumeForm() {
{/* TODO: Use TextInputArea instead */} setValue('additionalInformation', val)} + onChange={(val) => setValue('additionalInfo', val)} />
diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index 050f95ea..92cb47cf 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -2,6 +2,7 @@ import superjson from 'superjson'; import { createRouter } from './context'; import { protectedExampleRouter } from './protected-example-router'; +import { resumesResumeUserRouter } from './resumes-resume-user-router'; import { todosRouter } from './todos'; import { todosUserRouter } from './todos-user-router'; @@ -12,7 +13,8 @@ export const appRouter = createRouter() // Example routers. Learn more about tRPC routers: https://trpc.io/docs/v9/router .merge('auth.', protectedExampleRouter) .merge('todos.', todosRouter) - .merge('todos.user.', todosUserRouter); + .merge('todos.user.', todosUserRouter) + .merge('resumes.resume.user.', resumesResumeUserRouter); // Export type definition of API export type AppRouter = typeof appRouter; diff --git a/apps/portal/src/server/router/resumes-resume-user-router.ts b/apps/portal/src/server/router/resumes-resume-user-router.ts new file mode 100644 index 00000000..3d47ee57 --- /dev/null +++ b/apps/portal/src/server/router/resumes-resume-user-router.ts @@ -0,0 +1,30 @@ +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(), + }), + async resolve({ ctx, input }) { + const userId = ctx.session?.user.id; + + // TODO: Store file in file storage and retrieve URL + + return await ctx.prisma.resumesResume.create({ + data: { + ...input, + url: '', + userId, + }, + }); + }, + }, +); From 0062199bd6f26ecb362a7c4cffe3a69bb58b342e Mon Sep 17 00:00:00 2001 From: Yangshun Tay Date: Thu, 6 Oct 2022 18:14:57 +0800 Subject: [PATCH 02/19] [ui][text input] add asterisk to label for required fields --- apps/storybook/stories/text-input.stories.tsx | 14 ++++++++++++++ packages/ui/src/TextInput/TextInput.tsx | 3 +++ 2 files changed, 17 insertions(+) diff --git a/apps/storybook/stories/text-input.stories.tsx b/apps/storybook/stories/text-input.stories.tsx index 03bd842a..bb5a9220 100644 --- a/apps/storybook/stories/text-input.stories.tsx +++ b/apps/storybook/stories/text-input.stories.tsx @@ -30,6 +30,9 @@ export default { placeholder: { control: 'text', }, + required: { + control: 'boolean', + }, type: { control: 'text', }, @@ -111,6 +114,17 @@ export function Disabled() { ); } +export function Required() { + return ( + + ); +} + export function Error() { const [value, setValue] = useState('1234'); diff --git a/packages/ui/src/TextInput/TextInput.tsx b/packages/ui/src/TextInput/TextInput.tsx index ebb08328..192f2737 100644 --- a/packages/ui/src/TextInput/TextInput.tsx +++ b/packages/ui/src/TextInput/TextInput.tsx @@ -56,6 +56,7 @@ function TextInput( id: idParam, isLabelHidden = false, label, + required, startIcon: StartIcon, type = 'text', value, @@ -80,6 +81,7 @@ function TextInput( )} htmlFor={id}> {label} + {required && *}
{StartIcon && ( @@ -101,6 +103,7 @@ function TextInput( defaultValue={defaultValue} disabled={disabled} id={id} + required={required} type={type} value={value != null ? value : undefined} onChange={(event) => { From 2906dbdc75fc113b44ffabe2e77c2e46b01c7945 Mon Sep 17 00:00:00 2001 From: Yangshun Tay Date: Thu, 6 Oct 2022 20:02:55 +0800 Subject: [PATCH 03/19] [ui][text input] support element add ons --- apps/storybook/stories/text-input.stories.tsx | 141 ++++++++++++++-- packages/ui/src/Select/Select.tsx | 26 ++- packages/ui/src/TextInput/TextInput.tsx | 157 +++++++++++++++--- 3 files changed, 279 insertions(+), 45 deletions(-) diff --git a/apps/storybook/stories/text-input.stories.tsx b/apps/storybook/stories/text-input.stories.tsx index bb5a9220..6cac0b60 100644 --- a/apps/storybook/stories/text-input.stories.tsx +++ b/apps/storybook/stories/text-input.stories.tsx @@ -5,7 +5,7 @@ import { QuestionMarkCircleIcon, } from '@heroicons/react/24/solid'; import type { ComponentMeta } from '@storybook/react'; -import { TextInput } from '@tih/ui'; +import { Select, TextInput } from '@tih/ui'; export default { argTypes: { @@ -70,7 +70,8 @@ export function Email() { +
+ + + } + endAddOnType="element" + label="Price" + placeholder="0.00" + startAddOn="$" + startAddOnType="label" + type="text" + /> +
); } @@ -134,10 +168,97 @@ export function Error() { value.length < 6 ? 'Password must be at least 6 characters' : undefined } label="Email" - startIcon={KeyIcon} + startAddOn={KeyIcon} + startAddOnType="icon" type="password" value={value} onChange={setValue} /> ); } + +export function AddOns() { + return ( +
+ + + + + } + startAddOnType="element" + type="text" + /> + + } + endAddOnType="element" + label="Price" + placeholder="0.00" + startAddOn="$" + startAddOnType="label" + type="text" + /> +
+ ); +} diff --git a/packages/ui/src/Select/Select.tsx b/packages/ui/src/Select/Select.tsx index 294ae34d..67acf5a9 100644 --- a/packages/ui/src/Select/Select.tsx +++ b/packages/ui/src/Select/Select.tsx @@ -14,8 +14,10 @@ export type SelectItem = Readonly<{ }>; export type SelectDisplay = 'block' | 'inline'; +export type SelectBorderStyle = 'bordered' | 'borderless'; type Props = Readonly<{ + borderStyle?: SelectBorderStyle; defaultValue?: T; display?: SelectDisplay; isLabelHidden?: boolean; @@ -27,8 +29,14 @@ type Props = Readonly<{ }> & Readonly; +const borderClasses: Record = { + bordered: 'border-slate-300', + borderless: 'border-transparent bg-transparent', +}; + function Select( { + borderStyle = 'bordered', defaultValue, display, disabled, @@ -45,20 +53,20 @@ function Select( return (
- + {!isLabelHidden && ( + + )} - {EndIcon && ( -
-
- )} + {(() => { + if (endAddOnType == null) { + return; + } + + switch (endAddOnType) { + case 'label': + return ( +
+ {endAddOn} +
+ ); + case 'icon': { + const EndAddOn = endAddOn; + return ( +
+
+ ); + } + case 'element': + return endAddOn; + } + })()}
{errorMessage && (

From 1441fc90af761247fde97f33186af960f33f4097 Mon Sep 17 00:00:00 2001 From: Terence <45381509+Vielheim@users.noreply.github.com> Date: Thu, 6 Oct 2022 20:09:40 +0800 Subject: [PATCH 04/19] [resumes][feat] Add Resume Review Page (#306) * [resumes][feat] WIP: Add scaffold * [resumes][refactor] Shift comments section to its own component * [resumes][feat] Add resume pdf view * [resumes][feat] Add CommentsForm * [resumes][refactor] Refactor comments form * [resumes][fix] Fix viewport height not set * [resumes][feat] Add form validation * [resumes][refactor] Remove unused CommentsSection * [resumes][fix] Manually calculate height for pdf view instead * [resumes][refactor] Remove @tih/ui styles.scss import Co-authored-by: Wu Peirong Co-authored-by: Terence Ho <> --- apps/portal/package.json | 2 + apps/portal/public/test_resume.pdf | Bin 0 -> 64453 bytes .../src/components/resumes/ResumePdf.tsx | 48 ++++++ .../resumes/comments/CommentsForm.tsx | 149 ++++++++++++++++++ .../resumes/comments/CommentsList.tsx | 32 ++++ .../resumes/comments/CommentsSection.tsx | 14 ++ .../components/resumes/comments/constants.ts | 23 +++ apps/portal/src/pages/profile.tsx | 4 +- apps/portal/src/pages/resumes/review.tsx | 81 ++++++++++ yarn.lock | 71 ++++++++- 10 files changed, 420 insertions(+), 4 deletions(-) create mode 100644 apps/portal/public/test_resume.pdf create mode 100644 apps/portal/src/components/resumes/ResumePdf.tsx create mode 100644 apps/portal/src/components/resumes/comments/CommentsForm.tsx create mode 100644 apps/portal/src/components/resumes/comments/CommentsList.tsx create mode 100644 apps/portal/src/components/resumes/comments/CommentsSection.tsx create mode 100644 apps/portal/src/components/resumes/comments/constants.ts create mode 100644 apps/portal/src/pages/resumes/review.tsx diff --git a/apps/portal/package.json b/apps/portal/package.json index f135cab3..d57cdf43 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -27,6 +27,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.36.1", + "react-pdf": "^5.7.2", "react-query": "^3.39.2", "superjson": "^1.10.0", "zod": "^3.18.0" @@ -37,6 +38,7 @@ "@types/node": "^18.0.0", "@types/react": "^18.0.21", "@types/react-dom": "^18.0.6", + "@types/react-pdf": "^5.7.2", "autoprefixer": "^10.4.12", "postcss": "^8.4.16", "prettier-plugin-tailwindcss": "^0.1.13", diff --git a/apps/portal/public/test_resume.pdf b/apps/portal/public/test_resume.pdf new file mode 100644 index 0000000000000000000000000000000000000000..279b6a25759296e3a96159122aa8d5928c8e595f GIT binary patch literal 64453 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 { + setNumPages(pdf.numPages); + }; + + return ( +

+ ); +} diff --git a/apps/portal/src/components/resumes/comments/CommentsForm.tsx b/apps/portal/src/components/resumes/comments/CommentsForm.tsx new file mode 100644 index 00000000..a20bfd0c --- /dev/null +++ b/apps/portal/src/components/resumes/comments/CommentsForm.tsx @@ -0,0 +1,149 @@ +import { useState } from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; +import { Button, Dialog, TextInput } from '@tih/ui'; + +type CommentsFormProps = Readonly<{ + setShowCommentsForm: (show: boolean) => void; +}>; + +type IFormInput = { + education: string; + experience: string; + general: string; + projects: string; + skills: string; +}; + +type InputKeys = keyof IFormInput; + +export default function CommentsForm({ + setShowCommentsForm, +}: CommentsFormProps) { + const [showDialog, setShowDialog] = useState(false); + const { + register, + handleSubmit, + setValue, + formState: { isDirty }, + } = useForm({ + defaultValues: { + education: '', + experience: '', + general: '', + projects: '', + skills: '', + }, + }); + + // TODO: Implement mutation to database + const onSubmit: SubmitHandler = (data) => { + alert(JSON.stringify(data)); + }; + + const onCancel = () => { + if (isDirty) { + setShowDialog(true); + } else { + setShowCommentsForm(false); + } + }; + + const onValueChange = (section: InputKeys, value: string) => { + setValue(section, value.trim(), { shouldDirty: true }); + }; + + return ( + <> +

Add your review

+ +
+ {/* TODO: Convert TextInput to TextArea */} +
+ onValueChange('general', value)} + /> + + onValueChange('education', value)} + /> + + onValueChange('experience', value)} + /> + + onValueChange('projects', value)} + /> + + onValueChange('skills', value)} + /> +
+ +
+
+
+ + setShowCommentsForm(false)} + /> + } + secondaryButton={ + + + ); +} diff --git a/apps/portal/src/components/resumes/comments/CommentsList.tsx b/apps/portal/src/components/resumes/comments/CommentsList.tsx new file mode 100644 index 00000000..397c9551 --- /dev/null +++ b/apps/portal/src/components/resumes/comments/CommentsList.tsx @@ -0,0 +1,32 @@ +import { useState } from 'react'; +import { Button, Tabs } from '@tih/ui'; + +import { COMMENTS_SECTIONS } from './constants'; + +type CommentsListProps = Readonly<{ + setShowCommentsForm: (show: boolean) => void; +}>; + +export default function CommentsList({ + setShowCommentsForm, +}: CommentsListProps) { + const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value); + + return ( + <> + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+ + ); +} diff --git a/yarn.lock b/yarn.lock index baf467dc..8a87bd65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3532,6 +3532,14 @@ dependencies: "@types/react" "*" +"@types/react-pdf@^5.7.2": + version "5.7.2" + resolved "https://registry.yarnpkg.com/@types/react-pdf/-/react-pdf-5.7.2.tgz#8e0ec89efeb4e574ec62b2370495bd3ee11d8ed8" + integrity sha512-6cUselXlQSNd9pMswJGvHqki3Lq0cnls/3hNwrFizdDeHBAfTFXTScEBObfGPznEmtO2LvmZMeced43BV9Wbog== + dependencies: + "@types/react" "*" + pdfjs-dist "^2.10.377" + "@types/react-router-config@*", "@types/react-router-config@^5.0.6": version "5.0.6" resolved "https://registry.yarnpkg.com/@types/react-router-config/-/react-router-config-5.0.6.tgz#87c5c57e72d241db900d9734512c50ccec062451" @@ -6381,6 +6389,11 @@ domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" +dommatrix@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dommatrix/-/dommatrix-1.0.3.tgz#e7c18e8d6f3abdd1fef3dd4aa74c4d2e620a0525" + integrity sha512-l32Xp/TLgWb8ReqbVJAFIvXmY7go4nTxxlWiAFyhoQw9RKEOHBZNnyGvJWqDVSPmq3Y9HlM4npqF/T6VMOXhww== + domutils@^2.0.0, domutils@^2.5.2, domutils@^2.8.0: version "2.8.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" @@ -7588,7 +7601,7 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -file-loader@^6.2.0: +file-loader@^6.0.0, file-loader@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-6.2.0.tgz#baef7cf8e1840df325e4390b4484879480eebe4d" integrity sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw== @@ -9772,6 +9785,11 @@ magic-string@^0.26.1: dependencies: sourcemap-codec "^1.4.8" +make-cancellable-promise@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/make-cancellable-promise/-/make-cancellable-promise-1.1.0.tgz#b4e9fcb31db3a27417e44f80cffa598ec9ac9f4e" + integrity sha512-X5Opjm2xcZsOLuJ+Bnhb4t5yfu4ehlA3OKEYLtqUchgVzL/QaqW373ZUVxVHKwvJ38cmYuR4rAHD2yUvAIkTPA== + make-dir@^2.0.0, make-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" @@ -9792,6 +9810,11 @@ make-error@^1.1.1: resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== +make-event-props@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/make-event-props/-/make-event-props-1.3.0.tgz#2434cb390d58bcf40898d009ef5b1f936de9671b" + integrity sha512-oWiDZMcVB1/A487251hEWza1xzgCzl6MXxe9aF24l5Bt9N9UEbqTqKumEfuuLhmlhRZYnc+suVvW4vUs8bwO7Q== + makeerror@1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" @@ -9942,11 +9965,21 @@ meow@^3.1.0: redent "^1.0.0" trim-newlines "^1.0.0" +merge-class-names@^1.1.1: + version "1.4.2" + resolved "https://registry.yarnpkg.com/merge-class-names/-/merge-class-names-1.4.2.tgz#78d6d95ab259e7e647252a7988fd25a27d5a8835" + integrity sha512-bOl98VzwCGi25Gcn3xKxnR5p/WrhWFQB59MS/aGENcmUc6iSm96yrFDF0XSNurX9qN4LbJm0R9kfvsQ17i8zCw== + merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== +merge-refs@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/merge-refs/-/merge-refs-1.0.0.tgz#388348bce22e623782c6df9d3c4fc55888276120" + integrity sha512-WZ4S5wqD9FCR9hxkLgvcHJCBxzXzy3VVE6p8W2OzxRzB+hLRlcadGE2bW9xp2KSzk10rvp4y+pwwKO6JQVguMg== + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -11042,6 +11075,19 @@ pbkdf2@^3.0.3: safe-buffer "^5.0.1" sha.js "^2.4.8" +pdfjs-dist@2.12.313: + version "2.12.313" + resolved "https://registry.yarnpkg.com/pdfjs-dist/-/pdfjs-dist-2.12.313.tgz#62f2273737bb956267ae2e02cdfaddcb1099819c" + integrity sha512-1x6iXO4Qnv6Eb+YFdN5JdUzt4pAkxSp3aLAYPX93eQCyg/m7QFzXVWJHJVtoW48CI8HCXju4dSkhQZwoheL5mA== + +pdfjs-dist@^2.10.377: + version "2.16.105" + resolved "https://registry.yarnpkg.com/pdfjs-dist/-/pdfjs-dist-2.16.105.tgz#937b9c4a918f03f3979c88209d84c1ce90122c2a" + integrity sha512-J4dn41spsAwUxCpEoVf6GVoz908IAA3mYiLmNxg8J9kfRXc2jxpbUepcP0ocp0alVNLFthTAM8DZ1RaHh8sU0A== + dependencies: + dommatrix "^1.0.3" + web-streams-polyfill "^3.2.1" + picocolors@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f" @@ -12024,6 +12070,22 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1: dependencies: "@babel/runtime" "^7.10.3" +react-pdf@^5.7.2: + version "5.7.2" + resolved "https://registry.yarnpkg.com/react-pdf/-/react-pdf-5.7.2.tgz#c458dedf7983822668b40dcac1eae052c1f6e056" + integrity sha512-hdDwvf007V0i2rPCqQVS1fa70CXut17SN3laJYlRHzuqcu8sLLjEoeXihty6c0Ev5g1mw31b8OT8EwRw1s8C4g== + dependencies: + "@babel/runtime" "^7.0.0" + file-loader "^6.0.0" + make-cancellable-promise "^1.0.0" + make-event-props "^1.1.0" + merge-class-names "^1.1.1" + merge-refs "^1.0.0" + pdfjs-dist "2.12.313" + prop-types "^15.6.2" + tiny-invariant "^1.0.0" + tiny-warning "^1.0.0" + react-query@^3.39.2: version "3.39.2" resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.39.2.tgz#9224140f0296f01e9664b78ed6e4f69a0cc9216f" @@ -13712,7 +13774,7 @@ timers-browserify@^2.0.4: dependencies: setimmediate "^1.0.4" -tiny-invariant@^1.0.2: +tiny-invariant@^1.0.0, tiny-invariant@^1.0.2: version "1.3.1" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642" integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw== @@ -14509,6 +14571,11 @@ web-namespaces@^1.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.4.tgz#bc98a3de60dadd7faefc403d1076d529f5e030ec" integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw== +web-streams-polyfill@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" + integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" From 0f8ff5d349c6ccf089d63c137af5c852ba8a1091 Mon Sep 17 00:00:00 2001 From: Yangshun Tay Date: Thu, 6 Oct 2022 20:54:10 +0800 Subject: [PATCH 05/19] [ui][collapsible] initial implementation --- .../storybook/stories/collapsible.stories.tsx | 46 +++++++++++++++++++ packages/ui/src/Collapsible/Collapsible.tsx | 32 +++++++++++++ packages/ui/src/index.tsx | 3 ++ 3 files changed, 81 insertions(+) create mode 100644 apps/storybook/stories/collapsible.stories.tsx create mode 100644 packages/ui/src/Collapsible/Collapsible.tsx diff --git a/apps/storybook/stories/collapsible.stories.tsx b/apps/storybook/stories/collapsible.stories.tsx new file mode 100644 index 00000000..68826881 --- /dev/null +++ b/apps/storybook/stories/collapsible.stories.tsx @@ -0,0 +1,46 @@ +import type { ComponentMeta } from '@storybook/react'; +import { Collapsible } from '@tih/ui'; + +export default { + argTypes: { + children: { + control: { type: 'text' }, + }, + label: { + control: { type: 'text' }, + }, + }, + component: Collapsible, + title: 'Collapsible', +} as ComponentMeta; + +export const Basic = { + args: { + children: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.', + label: 'Reveal more content below', + }, +}; + +export function AccordionLayout() { + return ( +
+
+ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad + minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. + +
+
+ + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non + proident, sunt in culpa qui officia deserunt mollit anim id est + laborum. + +
+
+ ); +} diff --git a/packages/ui/src/Collapsible/Collapsible.tsx b/packages/ui/src/Collapsible/Collapsible.tsx new file mode 100644 index 00000000..a088fdb6 --- /dev/null +++ b/packages/ui/src/Collapsible/Collapsible.tsx @@ -0,0 +1,32 @@ +import clsx from 'clsx'; +import type { ReactNode } from 'react'; +import { Disclosure } from '@headlessui/react'; +import { ChevronDownIcon } from '@heroicons/react/20/solid'; + +type Props = Readonly<{ + children: ReactNode; + label: string; +}>; + +export default function Collapsible({ children, label }: Props) { + return ( + + {({ open }) => ( + <> + + + {label} + + + {children} + + + )} + + ); +} diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index f7818ae8..d2873c30 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -7,6 +7,9 @@ export { default as Badge } from './Badge/Badge'; // Button export * from './Button/Button'; export { default as Button } from './Button/Button'; +// Collapsible +export * from './Collapsible/Collapsible'; +export { default as Collapsible } from './Collapsible/Collapsible'; // Dialog export * from './Dialog/Dialog'; export { default as Dialog } from './Dialog/Dialog'; From 9de6dafef13fde3126a7d49d16990030ff708438 Mon Sep 17 00:00:00 2001 From: Su Yin <53945359+tnsyn@users.noreply.github.com> Date: Thu, 6 Oct 2022 23:07:16 +0800 Subject: [PATCH 06/19] [resume][feat] Add basic browse page (#311) * [resume][feat] Add basic browse list item * [resume][feat] Add filter pills * [resume][feat] Add starting browse page * [resume][feat] Edit resume reviews page title * [resume][feat] Update resume reviews page * [resume][feat] Add browse list item UI --- .../components/resumes/ResumeReviewsTitle.tsx | 12 +- .../resumes/browse/BrowseListItem.tsx | 51 +++ .../resumes/browse/BrowsePageBody.tsx | 326 ++++++++++++++++++ .../components/resumes/browse/FilterPill.tsx | 15 + .../components/resumes/browse/constants.ts | 69 ++++ apps/portal/src/pages/resumes/index.tsx | 8 +- 6 files changed, 478 insertions(+), 3 deletions(-) create mode 100644 apps/portal/src/components/resumes/browse/BrowseListItem.tsx create mode 100644 apps/portal/src/components/resumes/browse/BrowsePageBody.tsx create mode 100644 apps/portal/src/components/resumes/browse/FilterPill.tsx create mode 100644 apps/portal/src/components/resumes/browse/constants.ts diff --git a/apps/portal/src/components/resumes/ResumeReviewsTitle.tsx b/apps/portal/src/components/resumes/ResumeReviewsTitle.tsx index 13565a24..5e9cfda7 100644 --- a/apps/portal/src/components/resumes/ResumeReviewsTitle.tsx +++ b/apps/portal/src/components/resumes/ResumeReviewsTitle.tsx @@ -1,3 +1,13 @@ +import { Badge } from '@tih/ui'; + export default function ResumeReviewsTitle() { - return

Resume Reviews

; + return ( +
+

Resume Reviews

+ +
+ ); } diff --git a/apps/portal/src/components/resumes/browse/BrowseListItem.tsx b/apps/portal/src/components/resumes/browse/BrowseListItem.tsx new file mode 100644 index 00000000..a9d9c438 --- /dev/null +++ b/apps/portal/src/components/resumes/browse/BrowseListItem.tsx @@ -0,0 +1,51 @@ +import Link from 'next/link'; +import type { UrlObject } from 'url'; +import { ChevronRightIcon } from '@heroicons/react/20/solid'; +import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline'; + +type ResumeInfo = Readonly<{ + createdAt: Date; + experience: string; + numComments: number; + numStars: number; + role: string; + title: string; + user: string; +}>; + +type Props = Readonly<{ + href: UrlObject | string; + resumeInfo: ResumeInfo; +}>; + +export default function BrowseListItem({ href, resumeInfo }: Props) { + return ( + +
+
+ {resumeInfo.title} +
+ {resumeInfo.role} +
+ {resumeInfo.experience} +
+
+
+
+ + {resumeInfo.numComments} comments +
+
+ + {resumeInfo.numStars} stars +
+
+
+
+ Uploaded 2 days ago by {resumeInfo.user} +
+ +
+ + ); +} diff --git a/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx b/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx new file mode 100644 index 00000000..3fa7054f --- /dev/null +++ b/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx @@ -0,0 +1,326 @@ +import { Fragment, useState } from 'react'; +import { Dialog, Disclosure, Menu, Transition } from '@headlessui/react'; +import { + ChevronDownIcon, + FunnelIcon, + MinusIcon, + PlusIcon, +} from '@heroicons/react/20/solid'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { Tabs } from '@tih/ui'; + +import BrowseListItem from './BrowseListItem'; +import { + EXPERIENCE, + LOCATION, + ROLES, + SORT_OPTIONS, + TEST_RESUMES, + TOP_HITS, +} from './constants'; +import FilterPill from './FilterPill'; + +const filters = [ + { + id: 'roles', + name: 'Roles', + options: ROLES, + }, + { + id: 'experience', + name: 'Experience', + options: EXPERIENCE, + }, + { + id: 'location', + name: 'Location', + options: LOCATION, + }, +]; + +function classNames(...classes: Array) { + return classes.filter(Boolean).join(' '); +} + +export default function BrowsePageBody() { + const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); + const [tabsValue, setTabsValue] = useState('all'); + return ( +
+
+ {/* Mobile filter dialog */} + + + +
+ + +
+ + +
+

+ Filters +

+ +
+ + {/* Filters */} +
+

Categories

+
+ + {filters.map((section) => ( + + {({ open }) => ( + <> +

+ + + {section.name} + + + {open ? ( + + +

+ +
+ {section.options.map((option, optionIdx) => ( +
+ + +
+ ))} +
+
+ + )} +
+ ))} +
+ + +
+ + + +
+
+
+ {/* Filters */} +
+

Categories

+

Filters

+
    + {TOP_HITS.map((category) => ( +
  • + {/* TODO: Replace onSelect with filtering function */} + true} /> +
  • + ))} +
+ + {filters.map((section) => ( + + {({ open }) => ( + <> +

+ + + {section.name} + + + {open ? ( + + +

+ +
+ {section.options.map((option, optionIdx) => ( +
+ + +
+ ))} +
+
+ + )} +
+ ))} +
+
+
+ +
    + {TEST_RESUMES.map((resumeObj) => ( +
  • + +
  • + ))} +
+
+ + +
+ + Sort + +
+ + + +
+ {SORT_OPTIONS.map((option) => ( + + {({ active }) => ( + + {option.name} + + )} + + ))} +
+
+
+
+
+ +
+
+
+
+
+ ); +} diff --git a/apps/portal/src/components/resumes/browse/FilterPill.tsx b/apps/portal/src/components/resumes/browse/FilterPill.tsx new file mode 100644 index 00000000..c34f4763 --- /dev/null +++ b/apps/portal/src/components/resumes/browse/FilterPill.tsx @@ -0,0 +1,15 @@ +type Props = Readonly<{ + onClick?: (event: React.MouseEvent) => void; + title: string; +}>; + +export default function FilterPill({ title, onClick }: Props) { + return ( + + ); +} diff --git a/apps/portal/src/components/resumes/browse/constants.ts b/apps/portal/src/components/resumes/browse/constants.ts new file mode 100644 index 00000000..6a52fc7f --- /dev/null +++ b/apps/portal/src/components/resumes/browse/constants.ts @@ -0,0 +1,69 @@ +export const SORT_OPTIONS = [ + { current: true, href: '#', name: 'Latest' }, + { current: false, href: '#', name: 'Popular' }, + { current: false, href: '#', name: 'Top Comments' }, +]; + +export const TOP_HITS = [ + { href: '#', name: 'Unreviewed' }, + { href: '#', name: 'Fresh Grad' }, + { href: '#', name: 'GOATs' }, + { href: '#', name: 'US Only' }, +]; + +export const ROLES = [ + { + checked: false, + label: 'Full-Stack Engineer', + value: 'Full-Stack Engineer', + }, + { checked: false, label: 'Frontend Engineer', value: 'frontend-engineer' }, + { checked: false, label: 'Backend Engineer', value: 'backend-engineer' }, + { checked: false, label: 'DevOps Engineer', value: 'devops-engineer' }, + { checked: false, label: 'iOS Engineer', value: 'ios-engineer' }, + { checked: false, label: 'Android Engineer', value: 'android-engineer' }, +]; + +export const EXPERIENCE = [ + { checked: false, label: 'Freshman', value: 'freshman' }, + { checked: false, label: 'Sophomore', value: 'sophomore' }, + { checked: false, label: 'Junior', value: 'junior' }, + { checked: false, label: 'Senior', value: 'senior' }, + { checked: false, label: 'Fresh Grad (0-1 years)', value: 'freshgrad' }, +]; + +export const LOCATION = [ + { checked: false, label: 'Singapore', value: 'singapore' }, + { checked: false, label: 'United States', value: 'usa' }, + { checked: false, label: 'India', value: 'india' }, +]; + +export const TEST_RESUMES = [ + { + createdAt: new Date(), + experience: 'Fresh Grad (0-1 years)', + numComments: 9, + numStars: 1, + role: 'Backend Engineer', + title: 'Rejected from multiple companies, please help...:(', + user: 'Git Ji Ra', + }, + { + createdAt: new Date(), + experience: 'Fresh Grad (0-1 years)', + numComments: 9, + numStars: 1, + role: 'Backend Engineer', + title: 'Rejected from multiple companies, please help...:(', + user: 'Git Ji Ra', + }, + { + createdAt: new Date(), + experience: 'Fresh Grad (0-1 years)', + numComments: 9, + numStars: 1, + role: 'Backend Engineer', + title: 'Rejected from multiple companies, please help...:(', + user: 'Git Ji Ra', + }, +]; diff --git a/apps/portal/src/pages/resumes/index.tsx b/apps/portal/src/pages/resumes/index.tsx index fbc48f49..f0f9bad1 100644 --- a/apps/portal/src/pages/resumes/index.tsx +++ b/apps/portal/src/pages/resumes/index.tsx @@ -1,11 +1,15 @@ +import BrowsePageBody from '~/components/resumes/browse/BrowsePageBody'; import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle'; export default function ResumeHomePage() { return ( -
-
+
+
+
+ +
); } From e86a7665a01141f2d7a6c4c957442cb34ede8d0f Mon Sep 17 00:00:00 2001 From: Yangshun Tay Date: Fri, 7 Oct 2022 07:09:12 +0800 Subject: [PATCH 07/19] [ui][tabs] change appearance --- packages/ui/src/Tabs/Tabs.tsx | 61 ++++++++++++++++------------------- 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/packages/ui/src/Tabs/Tabs.tsx b/packages/ui/src/Tabs/Tabs.tsx index e872da7e..ac34722e 100644 --- a/packages/ui/src/Tabs/Tabs.tsx +++ b/packages/ui/src/Tabs/Tabs.tsx @@ -19,46 +19,39 @@ export default function Tabs({ label, tabs, value, onChange }: Props) { return (
-
-
+ } + + return ( +
); From e0a3f4c15c452a55b267e037cb832f1412c6c1fc Mon Sep 17 00:00:00 2001 From: Yangshun Tay Date: Fri, 7 Oct 2022 07:28:22 +0800 Subject: [PATCH 08/19] [ui][horizontal divider] implementation --- .../stories/horizontal-divider.stories.tsx | 28 +++++++++++++++++++ .../HorizontalDivider/HorizontalDivider.tsx | 14 ++++++++++ packages/ui/src/index.tsx | 3 ++ 3 files changed, 45 insertions(+) create mode 100644 apps/storybook/stories/horizontal-divider.stories.tsx create mode 100644 packages/ui/src/HorizontalDivider/HorizontalDivider.tsx diff --git a/apps/storybook/stories/horizontal-divider.stories.tsx b/apps/storybook/stories/horizontal-divider.stories.tsx new file mode 100644 index 00000000..7bee6bda --- /dev/null +++ b/apps/storybook/stories/horizontal-divider.stories.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import type { ComponentMeta } from '@storybook/react'; +import { HorizontalDivider } from '@tih/ui'; + +export default { + argTypes: {}, + component: HorizontalDivider, + title: 'HorizontalDivider', +} as ComponentMeta; + +export function Basic() { + return ( +
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea + commodo consequat. +

+ +

+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non + proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +

+
+ ); +} diff --git a/packages/ui/src/HorizontalDivider/HorizontalDivider.tsx b/packages/ui/src/HorizontalDivider/HorizontalDivider.tsx new file mode 100644 index 00000000..c9f3d2b1 --- /dev/null +++ b/packages/ui/src/HorizontalDivider/HorizontalDivider.tsx @@ -0,0 +1,14 @@ +import clsx from 'clsx'; + +type Props = Readonly<{ + className?: string; +}>; + +export default function HorizontalDivider({ className }: Props) { + return ( +
+ ); +} diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index d2873c30..db26daba 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -16,6 +16,9 @@ export { default as Dialog } from './Dialog/Dialog'; // DropdownMenu export * from './DropdownMenu/DropdownMenu'; export { default as DropdownMenu } from './DropdownMenu/DropdownMenu'; +// HorizontalDivider +export * from './HorizontalDivider/HorizontalDivider'; +export { default as HorizontalDivider } from './HorizontalDivider/HorizontalDivider'; // Select export * from './Select/Select'; export { default as Select } from './Select/Select'; From 641a565e5c79519f0f4acd21208ec615718310bd Mon Sep 17 00:00:00 2001 From: Yangshun Tay Date: Fri, 7 Oct 2022 10:01:34 +0800 Subject: [PATCH 09/19] [ui][pagination] implementation --- apps/storybook/stories/pagination.stories.tsx | 234 ++++++++++++++++++ packages/ui/src/Pagination/Pagination.tsx | 142 +++++++++++ packages/ui/src/index.tsx | 3 + 3 files changed, 379 insertions(+) create mode 100644 apps/storybook/stories/pagination.stories.tsx create mode 100644 packages/ui/src/Pagination/Pagination.tsx diff --git a/apps/storybook/stories/pagination.stories.tsx b/apps/storybook/stories/pagination.stories.tsx new file mode 100644 index 00000000..435bed7b --- /dev/null +++ b/apps/storybook/stories/pagination.stories.tsx @@ -0,0 +1,234 @@ +import React, { useState } from 'react'; +import type { ComponentMeta } from '@storybook/react'; +import { Pagination } from '@tih/ui'; + +export default { + argTypes: {}, + component: Pagination, + title: 'Pagination', +} as ComponentMeta; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +function emptyFunction() {} + +export function Basic({ + current, + end, + start, + pagePadding, +}: Pick< + React.ComponentProps, + 'current' | 'end' | 'pagePadding' | 'start' +>) { + return ( +
+ +
+ ); +} + +Basic.args = { + current: 3, + end: 10, + pagePadding: 1, + start: 1, +}; + +export function Interaction() { + const [currentPage, setCurrentPage] = useState(5); + + return ( +
+
+ setCurrentPage(page)} + /> +
+
+ ); +} + +export function PageRanges() { + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); +} + +export function PagePadding() { + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/packages/ui/src/Pagination/Pagination.tsx b/packages/ui/src/Pagination/Pagination.tsx new file mode 100644 index 00000000..92a6cf18 --- /dev/null +++ b/packages/ui/src/Pagination/Pagination.tsx @@ -0,0 +1,142 @@ +import clsx from 'clsx'; +import type { ReactElement } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid'; + +type Props = Readonly<{ + current: number; + end: number; + label: string; + onSelect: (page: number, event: React.MouseEvent) => void; + pagePadding?: number; + start: number; +}>; + +function PaginationPage({ + isCurrent = false, + label, + onClick, +}: Readonly<{ + isCurrent?: boolean; + label: number; + onClick: (event: React.MouseEvent) => void; +}>) { + return ( + + ); +} + +function PaginationEllipsis() { + return ( + + ... + + ); +} + +export default function Pagination({ + current, + end, + label, + onSelect, + pagePadding = 1, + start = 1, +}: Props) { + const pageNumberSet = new Set(); + const pageNumberList: Array = []; + const elements: Array = []; + let lastAddedPage = 0; + + function addPage(page: number) { + if (page < start || page > end) { + return; + } + + if (!pageNumberSet.has(page)) { + lastAddedPage = page; + pageNumberList.push(page); + pageNumberSet.add(page); + elements.push( + { + onSelect(page, event); + }} + />, + ); + } + } + + for (let i = start; i <= start + pagePadding; i++) { + addPage(i); + } + + if (lastAddedPage < current - pagePadding) { + elements.push(); + } + + for (let i = current - pagePadding; i <= current + pagePadding; i++) { + addPage(i); + } + + if (lastAddedPage < end - pagePadding) { + elements.push(); + } + + for (let i = end - pagePadding; i <= end; i++) { + addPage(i); + } + + const isPrevButtonDisabled = current === start; + const isNextButtonDisabled = current === end; + + return ( + + ); +} diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index db26daba..9faff648 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -19,6 +19,9 @@ export { default as DropdownMenu } from './DropdownMenu/DropdownMenu'; // HorizontalDivider export * from './HorizontalDivider/HorizontalDivider'; export { default as HorizontalDivider } from './HorizontalDivider/HorizontalDivider'; +// Pagination +export * from './Pagination/Pagination'; +export { default as Pagination } from './Pagination/Pagination'; // Select export * from './Select/Select'; export { default as Select } from './Select/Select'; From 0933cce7b5f5bf59d18a1ec5d60314fce97f9982 Mon Sep 17 00:00:00 2001 From: Terence <45381509+Vielheim@users.noreply.github.com> Date: Fri, 7 Oct 2022 14:19:37 +0800 Subject: [PATCH 10/19] [resumes][feat] Add API to submit & query for resume reviews (#313) * [resumes][feat] Add route to submit resume reviews * [resumes][feat] Add router to query for comments * [resumes][refactor] Change limit of upvotes query * [resumes][chore] revert changes * [resumes][chore] remove comment * [resumes][chore] Use ResumesSection enum instead of hard-coded string * [resumes][refactor] Add check for user session in comments * [resumes][fix] fix linting issues Co-authored-by: Terence Ho <> --- .../resumes/comments/CommentsForm.tsx | 24 +++++++-- .../resumes/comments/CommentsList.tsx | 24 +++++---- .../resumes/comments/CommentsListButton.tsx | 48 +++++++++++++++++ .../resumes/comments/CommentsSection.tsx | 19 +++++-- .../components/resumes/comments/constants.ts | 13 ++--- apps/portal/src/pages/resumes/review.tsx | 2 +- apps/portal/src/server/router/index.ts | 6 ++- .../server/router/resumes-reviews-router.ts | 44 +++++++++++++++ .../router/resumes-reviews-user-router.ts | 54 +++++++++++++++++++ 9 files changed, 209 insertions(+), 25 deletions(-) create mode 100644 apps/portal/src/components/resumes/comments/CommentsListButton.tsx create mode 100644 apps/portal/src/server/router/resumes-reviews-router.ts create mode 100644 apps/portal/src/server/router/resumes-reviews-user-router.ts diff --git a/apps/portal/src/components/resumes/comments/CommentsForm.tsx b/apps/portal/src/components/resumes/comments/CommentsForm.tsx index a20bfd0c..98ff8d82 100644 --- a/apps/portal/src/components/resumes/comments/CommentsForm.tsx +++ b/apps/portal/src/components/resumes/comments/CommentsForm.tsx @@ -3,7 +3,10 @@ import type { SubmitHandler } from 'react-hook-form'; import { useForm } from 'react-hook-form'; import { Button, Dialog, TextInput } from '@tih/ui'; +import { trpc } from '~/utils/trpc'; + type CommentsFormProps = Readonly<{ + resumeId: string; setShowCommentsForm: (show: boolean) => void; }>; @@ -18,6 +21,7 @@ type IFormInput = { type InputKeys = keyof IFormInput; export default function CommentsForm({ + resumeId, setShowCommentsForm, }: CommentsFormProps) { const [showDialog, setShowDialog] = useState(false); @@ -35,10 +39,17 @@ export default function CommentsForm({ skills: '', }, }); + const reviewCreateMutation = trpc.useMutation('resumes.reviews.user.create'); + + // TODO: Give a feedback to the user if the action succeeds/fails + const onSubmit: SubmitHandler = async (data) => { + await reviewCreateMutation.mutate({ + resumeId, + ...data, + }); - // TODO: Implement mutation to database - const onSubmit: SubmitHandler = (data) => { - alert(JSON.stringify(data)); + // Redirect back to comments section + setShowCommentsForm(false); }; const onCancel = () => { @@ -54,8 +65,11 @@ export default function CommentsForm({ }; return ( - <> +

Add your review

+

+ Please fill in at least one section to submit your review +

Note that your review will not be saved!
- +
); } diff --git a/apps/portal/src/components/resumes/comments/CommentsList.tsx b/apps/portal/src/components/resumes/comments/CommentsList.tsx index 397c9551..0b1d2d35 100644 --- a/apps/portal/src/components/resumes/comments/CommentsList.tsx +++ b/apps/portal/src/components/resumes/comments/CommentsList.tsx @@ -1,25 +1,31 @@ import { useState } from 'react'; -import { Button, Tabs } from '@tih/ui'; +import { Tabs } from '@tih/ui'; +import { trpc } from '~/utils/trpc'; + +import CommentsListButton from './CommentsListButton'; import { COMMENTS_SECTIONS } from './constants'; type CommentsListProps = Readonly<{ + resumeId: string; setShowCommentsForm: (show: boolean) => void; }>; export default function CommentsList({ + resumeId, setShowCommentsForm, }: CommentsListProps) { const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value); + const commentsQuery = trpc.useQuery(['resumes.reviews.list', { resumeId }]); + + /* eslint-disable no-console */ + console.log(commentsQuery.data); + /* eslint-enable no-console */ + return ( - <> -
- +
diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index 92cb47cf..9c2b451b 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -3,6 +3,8 @@ import superjson from 'superjson'; import { createRouter } from './context'; import { protectedExampleRouter } from './protected-example-router'; import { resumesResumeUserRouter } from './resumes-resume-user-router'; +import { resumeReviewsRouter } from './resumes-reviews-router'; +import { resumesReviewsUserRouter } from './resumes-reviews-user-router'; import { todosRouter } from './todos'; import { todosUserRouter } from './todos-user-router'; @@ -14,7 +16,9 @@ export const appRouter = createRouter() .merge('auth.', protectedExampleRouter) .merge('todos.', todosRouter) .merge('todos.user.', todosUserRouter) - .merge('resumes.resume.user.', resumesResumeUserRouter); + .merge('resumes.resume.user.', resumesResumeUserRouter) + .merge('resumes.reviews.', resumeReviewsRouter) + .merge('resumes.reviews.user.', resumesReviewsUserRouter); // Export type definition of API export type AppRouter = typeof appRouter; diff --git a/apps/portal/src/server/router/resumes-reviews-router.ts b/apps/portal/src/server/router/resumes-reviews-router.ts new file mode 100644 index 00000000..8e681326 --- /dev/null +++ b/apps/portal/src/server/router/resumes-reviews-router.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +import { createRouter } from './context'; + +export const resumeReviewsRouter = createRouter().query('list', { + input: z.object({ + resumeId: z.string(), + }), + async resolve({ ctx, input }) { + const userId = ctx.session?.user?.id; + const { resumeId } = input; + + // For this resume, we retrieve every comment's information, along with: + // The user's name and image to render + // Number of votes, and whether the user (if-any) has voted + return await ctx.prisma.resumesComment.findMany({ + include: { + _count: { + select: { + votes: true, + }, + }, + user: { + select: { + image: true, + name: true, + }, + }, + votes: { + take: 1, + where: { + userId, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + where: { + resumeId, + }, + }); + }, +}); diff --git a/apps/portal/src/server/router/resumes-reviews-user-router.ts b/apps/portal/src/server/router/resumes-reviews-user-router.ts new file mode 100644 index 00000000..ec42a36b --- /dev/null +++ b/apps/portal/src/server/router/resumes-reviews-user-router.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; +import { ResumesSection } from '@prisma/client'; + +import { createProtectedRouter } from './context'; + +type IResumeCommentInput = Readonly<{ + description: string; + resumeId: string; + section: ResumesSection; + userId: string; +}>; + +export const resumesReviewsUserRouter = createProtectedRouter().mutation( + 'create', + { + input: z.object({ + education: z.string(), + experience: z.string(), + general: z.string(), + projects: z.string(), + resumeId: z.string(), + skills: z.string(), + }), + async resolve({ ctx, input }) { + const userId = ctx.session?.user.id; + const { resumeId, education, experience, general, projects, skills } = + input; + + // For each section, convert them into ResumesComment model if provided + const comments: Array = [ + { description: education, section: ResumesSection.EDUCATION }, + { description: experience, section: ResumesSection.EXPERIENCE }, + { description: general, section: ResumesSection.GENERAL }, + { description: projects, section: ResumesSection.PROJECTS }, + { description: skills, section: ResumesSection.SKILLS }, + ] + .filter(({ description }) => { + return description.trim().length > 0; + }) + .map(({ description, section }) => { + return { + description, + resumeId, + section, + userId, + }; + }); + + return await ctx.prisma.resumesComment.createMany({ + data: comments, + }); + }, + }, +); From b2b8f3b5535224f502639c6937679d0e2f540fa3 Mon Sep 17 00:00:00 2001 From: Keane Chan Date: Fri, 7 Oct 2022 14:30:27 +0800 Subject: [PATCH 11/19] [resumes][feat] add resumeprofiles model (#316) * [resumes][feat] add resumeprofiles model * [resumes][fix] fix typo * [resumes][chore] update migration file --- .../migration.sql | 74 ++++++++++++ apps/portal/prisma/schema.prisma | 112 ++++++++++-------- .../router/resumes-resume-user-router.ts | 11 +- 3 files changed, 146 insertions(+), 51 deletions(-) create mode 100644 apps/portal/prisma/migrations/20221007062555_add_resume_profile_model/migration.sql diff --git a/apps/portal/prisma/migrations/20221007062555_add_resume_profile_model/migration.sql b/apps/portal/prisma/migrations/20221007062555_add_resume_profile_model/migration.sql new file mode 100644 index 00000000..64ef6108 --- /dev/null +++ b/apps/portal/prisma/migrations/20221007062555_add_resume_profile_model/migration.sql @@ -0,0 +1,74 @@ +/* + Warnings: + + - You are about to drop the column `userId` on the `ResumesComment` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `ResumesCommentVote` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `ResumesResume` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `ResumesStar` table. All the data in the column will be lost. + - A unique constraint covering the columns `[commentId,resumesProfileId]` on the table `ResumesCommentVote` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[resumeId,resumesProfileId]` on the table `ResumesStar` will be added. If there are existing duplicate values, this will fail. + - Added the required column `resumesProfileId` to the `ResumesComment` table without a default value. This is not possible if the table is not empty. + - Added the required column `resumesProfileId` to the `ResumesCommentVote` table without a default value. This is not possible if the table is not empty. + - Added the required column `resumesProfileId` to the `ResumesResume` table without a default value. This is not possible if the table is not empty. + - Added the required column `resumesProfileId` to the `ResumesStar` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "ResumesComment" DROP CONSTRAINT "ResumesComment_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesCommentVote" DROP CONSTRAINT "ResumesCommentVote_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesResume" DROP CONSTRAINT "ResumesResume_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesStar" DROP CONSTRAINT "ResumesStar_userId_fkey"; + +-- AlterTable +ALTER TABLE "ResumesComment" DROP COLUMN "userId", +ADD COLUMN "resumesProfileId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesCommentVote" DROP COLUMN "userId", +ADD COLUMN "resumesProfileId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesResume" DROP COLUMN "userId", +ADD COLUMN "resumesProfileId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesStar" DROP COLUMN "userId", +ADD COLUMN "resumesProfileId" TEXT NOT NULL; + +-- CreateTable +CREATE TABLE "ResumesProfile" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "ResumesProfile_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ResumesProfile_userId_key" ON "ResumesProfile"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ResumesCommentVote_commentId_resumesProfileId_key" ON "ResumesCommentVote"("commentId", "resumesProfileId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ResumesStar_resumeId_resumesProfileId_key" ON "ResumesStar"("resumeId", "resumesProfileId"); + +-- AddForeignKey +ALTER TABLE "ResumesProfile" ADD CONSTRAINT "ResumesProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesResume" ADD CONSTRAINT "ResumesResume_resumesProfileId_fkey" FOREIGN KEY ("resumesProfileId") REFERENCES "ResumesProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesStar" ADD CONSTRAINT "ResumesStar_resumesProfileId_fkey" FOREIGN KEY ("resumesProfileId") REFERENCES "ResumesProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesComment" ADD CONSTRAINT "ResumesComment_resumesProfileId_fkey" FOREIGN KEY ("resumesProfileId") REFERENCES "ResumesProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesCommentVote" ADD CONSTRAINT "ResumesCommentVote_resumesProfileId_fkey" FOREIGN KEY ("resumesProfileId") REFERENCES "ResumesProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index 95c4381f..086eb2bc 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -37,18 +37,15 @@ model Session { } model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? - image String? - accounts Account[] - sessions Session[] - todos Todo[] - resumesResumes ResumesResume[] - resumesStars ResumesStar[] - resumesComments ResumesComment[] - resumesCommentVotes ResumesCommentVote[] + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + accounts Account[] + sessions Session[] + todos Todo[] + resumesProfile ResumesProfile? } model VerificationToken { @@ -88,45 +85,56 @@ model Company { // Add Resumes project models here, prefix all models with "Resumes", // use camelCase for field names, and try to name them consistently // across all models in this file. -// End of Resumes project models. + +model ResumesProfile { + id String @id @default(cuid()) + userId String @unique + resumesResumes ResumesResume[] + resumesStars ResumesStar[] + resumesComments ResumesComment[] + resumesCommentVotes ResumesCommentVote[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} model ResumesResume { - id String @id @default(cuid()) - userId String - title String @db.Text + id String @id @default(cuid()) + resumesProfileId String + title String @db.Text // TODO: Update role, experience, location to use Enums - role String @db.Text - experience String @db.Text - location String @db.Text - url String - additionalInfo String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - stars ResumesStar[] - comments ResumesComment[] + role String @db.Text + experience String @db.Text + location String @db.Text + url String + additionalInfo String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + resumesProfile ResumesProfile @relation(fields: [resumesProfileId], references: [id], onDelete: Cascade) + stars ResumesStar[] + comments ResumesComment[] } model ResumesStar { - id String @id @default(cuid()) - resumeId String - userId String - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + resumesProfileId String + resumeId String + createdAt DateTime @default(now()) + resumesProfile ResumesProfile @relation(fields: [resumesProfileId], references: [id], onDelete: Cascade) + resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) + + @@unique([resumeId, resumesProfileId]) } model ResumesComment { - id String @id @default(cuid()) - resumeId String - userId String - description String @db.Text - section ResumesSection - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) - votes ResumesCommentVote[] + id String @id @default(cuid()) + resumesProfileId String + resumeId String + description String @db.Text + section ResumesSection + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + resumesProfile ResumesProfile @relation(fields: [resumesProfileId], references: [id], onDelete: Cascade) + resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) + votes ResumesCommentVote[] } enum ResumesSection { @@ -138,16 +146,20 @@ enum ResumesSection { } model ResumesCommentVote { - id String @id @default(cuid()) - commentId String - userId String - value Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + resumesProfileId String + commentId String + value Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + resumesProfile ResumesProfile @relation(fields: [resumesProfileId], references: [id], onDelete: Cascade) + comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade) + + @@unique([commentId, resumesProfileId]) } +// End of Resumes project models. + // Start of Offers project models. // Add Offers project models here, prefix all models with "Offer", // use camelCase for field names, and try to name them consistently diff --git a/apps/portal/src/server/router/resumes-resume-user-router.ts b/apps/portal/src/server/router/resumes-resume-user-router.ts index 3d47ee57..e4962d0b 100644 --- a/apps/portal/src/server/router/resumes-resume-user-router.ts +++ b/apps/portal/src/server/router/resumes-resume-user-router.ts @@ -15,14 +15,23 @@ export const resumesResumeUserRouter = createProtectedRouter().mutation( }), async resolve({ ctx, input }) { const userId = ctx.session?.user.id; + const resumeProfile = await ctx.prisma.resumesProfile.upsert({ + create: { + userId, + }, + update: {}, + where: { + userId, + }, + }); // TODO: Store file in file storage and retrieve URL return await ctx.prisma.resumesResume.create({ data: { ...input, + resumesProfileId: resumeProfile.id, url: '', - userId, }, }); }, From 702811bafa66f500270de90f8ac32f70ed2bf4f6 Mon Sep 17 00:00:00 2001 From: Jeff Sieu Date: Fri, 7 Oct 2022 14:32:03 +0800 Subject: [PATCH 12/19] [ui][collapsible] add defaultOpen prop (#314) --- packages/ui/src/Collapsible/Collapsible.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/Collapsible/Collapsible.tsx b/packages/ui/src/Collapsible/Collapsible.tsx index a088fdb6..93e5ba10 100644 --- a/packages/ui/src/Collapsible/Collapsible.tsx +++ b/packages/ui/src/Collapsible/Collapsible.tsx @@ -5,12 +5,13 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'; type Props = Readonly<{ children: ReactNode; + defaultOpen?: boolean; label: string; }>; -export default function Collapsible({ children, label }: Props) { +export default function Collapsible({ children, defaultOpen, label }: Props) { return ( - + {({ open }) => ( <> From 1146c5db407d289754bf72b3a5213e280461b996 Mon Sep 17 00:00:00 2001 From: Terence <45381509+Vielheim@users.noreply.github.com> Date: Fri, 7 Oct 2022 16:09:52 +0800 Subject: [PATCH 13/19] [resumes][refactor] Change to ResumesProfile schema (#318) * [resumes][chore] Update TODOs * [resumes][refactor] Change to new schema * [resumes][refactor] Change query to findUniqueOrThrow Co-authored-by: Terence Ho <> --- .../resumes/comments/CommentsSection.tsx | 5 +--- apps/portal/src/pages/resumes/review.tsx | 3 ++- .../server/router/resumes-reviews-router.ts | 25 ++++++++++++++----- .../router/resumes-reviews-user-router.ts | 15 ++++++++--- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/apps/portal/src/components/resumes/comments/CommentsSection.tsx b/apps/portal/src/components/resumes/comments/CommentsSection.tsx index 60a7a5f5..b4e5f535 100644 --- a/apps/portal/src/components/resumes/comments/CommentsSection.tsx +++ b/apps/portal/src/components/resumes/comments/CommentsSection.tsx @@ -7,10 +7,7 @@ type ICommentsSectionProps = { resumeId: string; }; -// TODO: Retrieve resumeId for CommentsSection -export default function CommentsSection({ - resumeId = '', -}: ICommentsSectionProps) { +export default function CommentsSection({ resumeId }: ICommentsSectionProps) { const [showCommentsForm, setShowCommentsForm] = useState(false); return showCommentsForm ? ( diff --git a/apps/portal/src/pages/resumes/review.tsx b/apps/portal/src/pages/resumes/review.tsx index 3cd1cf0b..f75ae519 100644 --- a/apps/portal/src/pages/resumes/review.tsx +++ b/apps/portal/src/pages/resumes/review.tsx @@ -73,7 +73,8 @@ export default function ResumeReviewPage() {
- + {/* TODO: Update resumeId */} +
diff --git a/apps/portal/src/server/router/resumes-reviews-router.ts b/apps/portal/src/server/router/resumes-reviews-router.ts index 8e681326..2f983c92 100644 --- a/apps/portal/src/server/router/resumes-reviews-router.ts +++ b/apps/portal/src/server/router/resumes-reviews-router.ts @@ -7,9 +7,18 @@ export const resumeReviewsRouter = createRouter().query('list', { resumeId: z.string(), }), async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; const { resumeId } = input; + const { resumesProfileId } = + await ctx.prisma.resumesResume.findUniqueOrThrow({ + select: { + resumesProfileId: true, + }, + where: { + id: resumeId, + }, + }); + // For this resume, we retrieve every comment's information, along with: // The user's name and image to render // Number of votes, and whether the user (if-any) has voted @@ -20,16 +29,20 @@ export const resumeReviewsRouter = createRouter().query('list', { votes: true, }, }, - user: { - select: { - image: true, - name: true, + resumesProfile: { + include: { + user: { + select: { + image: true, + name: true, + }, + }, }, }, votes: { take: 1, where: { - userId, + resumesProfileId, }, }, }, diff --git a/apps/portal/src/server/router/resumes-reviews-user-router.ts b/apps/portal/src/server/router/resumes-reviews-user-router.ts index ec42a36b..b13e2a00 100644 --- a/apps/portal/src/server/router/resumes-reviews-user-router.ts +++ b/apps/portal/src/server/router/resumes-reviews-user-router.ts @@ -6,8 +6,8 @@ import { createProtectedRouter } from './context'; type IResumeCommentInput = Readonly<{ description: string; resumeId: string; + resumesProfileId: string; section: ResumesSection; - userId: string; }>; export const resumesReviewsUserRouter = createProtectedRouter().mutation( @@ -22,10 +22,19 @@ export const resumesReviewsUserRouter = createProtectedRouter().mutation( skills: z.string(), }), async resolve({ ctx, input }) { - const userId = ctx.session?.user.id; const { resumeId, education, experience, general, projects, skills } = input; + const { resumesProfileId } = + await ctx.prisma.resumesResume.findUniqueOrThrow({ + select: { + resumesProfileId: true, + }, + where: { + id: resumeId, + }, + }); + // For each section, convert them into ResumesComment model if provided const comments: Array = [ { description: education, section: ResumesSection.EDUCATION }, @@ -41,8 +50,8 @@ export const resumesReviewsUserRouter = createProtectedRouter().mutation( return { description, resumeId, + resumesProfileId, section, - userId, }; }); From 5a1c01d8cbfa2c159f9c1135c8c103323c1b65c5 Mon Sep 17 00:00:00 2001 From: Su Yin <53945359+tnsyn@users.noreply.github.com> Date: Fri, 7 Oct 2022 16:24:29 +0800 Subject: [PATCH 14/19] [resumes][feat] Add missing browse page UI (#319) * [resumes][chore] Edit TODO comment * [resumes][fix] Make sort dropdown bg white * [resumes][feat] Add missing browse page UI and cleanup --- .../resumes/browse/BrowsePageBody.tsx | 409 +++++++----------- 1 file changed, 156 insertions(+), 253 deletions(-) diff --git a/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx b/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx index 3fa7054f..85152270 100644 --- a/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx +++ b/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx @@ -1,13 +1,12 @@ import { Fragment, useState } from 'react'; -import { Dialog, Disclosure, Menu, Transition } from '@headlessui/react'; +import { Disclosure, Menu, Transition } from '@headlessui/react'; import { ChevronDownIcon, - FunnelIcon, MinusIcon, PlusIcon, } from '@heroicons/react/20/solid'; -import { XMarkIcon } from '@heroicons/react/24/outline'; -import { Tabs } from '@tih/ui'; +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; +import { Tabs, TextInput } from '@tih/ui'; import BrowseListItem from './BrowseListItem'; import { @@ -43,202 +42,17 @@ function classNames(...classes: Array) { } export default function BrowsePageBody() { - const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); const [tabsValue, setTabsValue] = useState('all'); + const [searchValue, setSearchValue] = useState(''); return (
-
- {/* Mobile filter dialog */} - - - -
- - -
- - -
-

- Filters -

- -
- - {/* Filters */} - -

Categories

- - - {filters.map((section) => ( - - {({ open }) => ( - <> -

- - - {section.name} - - - {open ? ( - - -

- -
- {section.options.map((option, optionIdx) => ( -
- - -
- ))} -
-
- - )} -
- ))} - -
-
-
-
-
- -
-
-
- {/* Filters */} -
-

Categories

-

Filters

-
    - {TOP_HITS.map((category) => ( -
  • - {/* TODO: Replace onSelect with filtering function */} - true} /> -
  • - ))} -
- - {filters.map((section) => ( - - {({ open }) => ( - <> -

- - - {section.name} - - - {open ? ( - - -

- -
- {section.options.map((option, optionIdx) => ( -
- - -
- ))} -
-
- - )} -
- ))} -
-
-
+
+
+

Filters

+
+
+
+
-
    - {TEST_RESUMES.map((resumeObj) => ( -
  • - -
  • - ))} -
+
+
+ + +
+
+ +
+ + Sort + +
- -
- - Sort - -
- - - -
- {SORT_OPTIONS.map((option) => ( - - {({ active }) => ( - - {option.name} - - )} - - ))} -
-
-
-
-
+ + +
+ {SORT_OPTIONS.map((option) => ( + + {({ active }) => ( + + {option.name} + + )} + + ))} +
+
+
+
+
+
-
+
+
+ +
+
+
+
+

Categories

+
    + {TOP_HITS.map((category) => ( +
  • + {/* TODO: Replace onClick with filtering function */} + true} /> +
  • + ))} +
+ + {filters.map((section) => ( + + {({ open }) => ( + <> +

+ + + {section.name} + + + {open ? ( + + +

+ +
+ {section.options.map((option, optionIdx) => ( +
+ + +
+ ))} +
+
+ + )} +
+ ))} +
+
+
+
+
    + {TEST_RESUMES.map((resumeObj) => ( +
  • + +
  • + ))} +
+
); From 5507c6a9d296953e2819eb8269bddd403619c8df Mon Sep 17 00:00:00 2001 From: Su Yin <53945359+tnsyn@users.noreply.github.com> Date: Fri, 7 Oct 2022 22:00:37 +0800 Subject: [PATCH 15/19] [resumes][fix] Use clsx instead of classnames function (#324) --- .../src/components/resumes/browse/BrowsePageBody.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx b/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx index 85152270..ee28609f 100644 --- a/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx +++ b/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import { Fragment, useState } from 'react'; import { Disclosure, Menu, Transition } from '@headlessui/react'; import { @@ -37,10 +38,6 @@ const filters = [ }, ]; -function classNames(...classes: Array) { - return classes.filter(Boolean).join(' '); -} - export default function BrowsePageBody() { const [tabsValue, setTabsValue] = useState('all'); const [searchValue, setSearchValue] = useState(''); @@ -112,7 +109,7 @@ export default function BrowsePageBody() { {({ active }) => ( Date: Fri, 7 Oct 2022 23:33:24 +0800 Subject: [PATCH 16/19] [resumes][feat] Fetch resume details from database (#322) * [resumes][feat] Add resume details router * [resumes][feat] Change review page to dynamic routing * [resumes][feat] Toggle resume star button * [resumes][refactor] Revert routers to User model --- apps/portal/package.json | 1 + .../migration.sql | 73 +++++++++ apps/portal/prisma/schema.prisma | 114 +++++++------- .../src/components/resumes/ResumePdf.tsx | 8 +- apps/portal/src/pages/resumes/[resumeId].tsx | 148 ++++++++++++++++++ apps/portal/src/pages/resumes/review.tsx | 82 ---------- apps/portal/src/server/router/index.ts | 2 + .../server/router/resumes-details-router.ts | 79 ++++++++++ .../router/resumes-resume-user-router.ts | 13 +- .../server/router/resumes-reviews-router.ts | 25 +-- .../router/resumes-reviews-user-router.ts | 15 +- yarn.lock | 2 +- 12 files changed, 373 insertions(+), 189 deletions(-) create mode 100644 apps/portal/prisma/migrations/20221007135344_remove_resumes_profile_model/migration.sql create mode 100644 apps/portal/src/pages/resumes/[resumeId].tsx delete mode 100644 apps/portal/src/pages/resumes/review.tsx create mode 100644 apps/portal/src/server/router/resumes-details-router.ts diff --git a/apps/portal/package.json b/apps/portal/package.json index d57cdf43..9948c01e 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -22,6 +22,7 @@ "@trpc/react": "^9.27.2", "@trpc/server": "^9.27.2", "clsx": "^1.2.1", + "date-fns": "^2.29.3", "next": "12.3.1", "next-auth": "~4.10.3", "react": "18.2.0", diff --git a/apps/portal/prisma/migrations/20221007135344_remove_resumes_profile_model/migration.sql b/apps/portal/prisma/migrations/20221007135344_remove_resumes_profile_model/migration.sql new file mode 100644 index 00000000..5b9baead --- /dev/null +++ b/apps/portal/prisma/migrations/20221007135344_remove_resumes_profile_model/migration.sql @@ -0,0 +1,73 @@ +/* + Warnings: + + - You are about to drop the column `resumesProfileId` on the `ResumesComment` table. All the data in the column will be lost. + - You are about to drop the column `resumesProfileId` on the `ResumesCommentVote` table. All the data in the column will be lost. + - You are about to drop the column `resumesProfileId` on the `ResumesResume` table. All the data in the column will be lost. + - You are about to drop the column `resumesProfileId` on the `ResumesStar` table. All the data in the column will be lost. + - You are about to drop the `ResumesProfile` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[userId,commentId]` on the table `ResumesCommentVote` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[userId,resumeId]` on the table `ResumesStar` will be added. If there are existing duplicate values, this will fail. + - Added the required column `userId` to the `ResumesComment` table without a default value. This is not possible if the table is not empty. + - Added the required column `userId` to the `ResumesCommentVote` table without a default value. This is not possible if the table is not empty. + - Added the required column `userId` to the `ResumesResume` table without a default value. This is not possible if the table is not empty. + - Added the required column `userId` to the `ResumesStar` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "ResumesComment" DROP CONSTRAINT "ResumesComment_resumesProfileId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesCommentVote" DROP CONSTRAINT "ResumesCommentVote_resumesProfileId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesProfile" DROP CONSTRAINT "ResumesProfile_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesResume" DROP CONSTRAINT "ResumesResume_resumesProfileId_fkey"; + +-- DropForeignKey +ALTER TABLE "ResumesStar" DROP CONSTRAINT "ResumesStar_resumesProfileId_fkey"; + +-- DropIndex +DROP INDEX "ResumesCommentVote_commentId_resumesProfileId_key"; + +-- DropIndex +DROP INDEX "ResumesStar_resumeId_resumesProfileId_key"; + +-- AlterTable +ALTER TABLE "ResumesComment" DROP COLUMN "resumesProfileId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesCommentVote" DROP COLUMN "resumesProfileId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesResume" DROP COLUMN "resumesProfileId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ResumesStar" DROP COLUMN "resumesProfileId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- DropTable +DROP TABLE "ResumesProfile"; + +-- CreateIndex +CREATE UNIQUE INDEX "ResumesCommentVote_userId_commentId_key" ON "ResumesCommentVote"("userId", "commentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ResumesStar_userId_resumeId_key" ON "ResumesStar"("userId", "resumeId"); + +-- AddForeignKey +ALTER TABLE "ResumesResume" ADD CONSTRAINT "ResumesResume_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesStar" ADD CONSTRAINT "ResumesStar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesComment" ADD CONSTRAINT "ResumesComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResumesCommentVote" ADD CONSTRAINT "ResumesCommentVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index 086eb2bc..7ece9c5d 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -37,15 +37,18 @@ model Session { } model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? - image String? - accounts Account[] - sessions Session[] - todos Todo[] - resumesProfile ResumesProfile? + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + accounts Account[] + sessions Session[] + todos Todo[] + resumesResumes ResumesResume[] + resumesStars ResumesStar[] + resumesComments ResumesComment[] + resumesCommentVotes ResumesCommentVote[] } model VerificationToken { @@ -85,56 +88,45 @@ model Company { // Add Resumes project models here, prefix all models with "Resumes", // use camelCase for field names, and try to name them consistently // across all models in this file. - -model ResumesProfile { - id String @id @default(cuid()) - userId String @unique - resumesResumes ResumesResume[] - resumesStars ResumesStar[] - resumesComments ResumesComment[] - resumesCommentVotes ResumesCommentVote[] - user User @relation(fields: [userId], references: [id], onDelete: Cascade) -} - model ResumesResume { - id String @id @default(cuid()) - resumesProfileId String - title String @db.Text + id String @id @default(cuid()) + userId String + title String @db.Text // TODO: Update role, experience, location to use Enums - role String @db.Text - experience String @db.Text - location String @db.Text - url String - additionalInfo String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - resumesProfile ResumesProfile @relation(fields: [resumesProfileId], references: [id], onDelete: Cascade) - stars ResumesStar[] - comments ResumesComment[] + role String @db.Text + experience String @db.Text + location String @db.Text + url String + additionalInfo String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + stars ResumesStar[] + comments ResumesComment[] } model ResumesStar { - id String @id @default(cuid()) - resumesProfileId String - resumeId String - createdAt DateTime @default(now()) - resumesProfile ResumesProfile @relation(fields: [resumesProfileId], references: [id], onDelete: Cascade) - resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) - - @@unique([resumeId, resumesProfileId]) + id String @id @default(cuid()) + userId String + resumeId String + createdAt DateTime @default(now()) + resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, resumeId]) } model ResumesComment { - id String @id @default(cuid()) - resumesProfileId String - resumeId String - description String @db.Text - section ResumesSection - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - resumesProfile ResumesProfile @relation(fields: [resumesProfileId], references: [id], onDelete: Cascade) - resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) - votes ResumesCommentVote[] + id String @id @default(cuid()) + userId String + resumeId String + description String @db.Text + section ResumesSection + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) + votes ResumesCommentVote[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } enum ResumesSection { @@ -146,16 +138,16 @@ enum ResumesSection { } model ResumesCommentVote { - id String @id @default(cuid()) - resumesProfileId String - commentId String - value Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - resumesProfile ResumesProfile @relation(fields: [resumesProfileId], references: [id], onDelete: Cascade) - comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade) - - @@unique([commentId, resumesProfileId]) + id String @id @default(cuid()) + userId String + commentId String + value Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, commentId]) } // End of Resumes project models. diff --git a/apps/portal/src/components/resumes/ResumePdf.tsx b/apps/portal/src/components/resumes/ResumePdf.tsx index 12debea4..82e26395 100644 --- a/apps/portal/src/components/resumes/ResumePdf.tsx +++ b/apps/portal/src/components/resumes/ResumePdf.tsx @@ -6,7 +6,11 @@ import { Button, Spinner } from '@tih/ui'; pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`; -export default function ResumePdf() { +type Props = Readonly<{ + url: string; +}>; + +export default function ResumePdf({ url }: Props) { const [numPages, setNumPages] = useState(0); const [pageNumber] = useState(1); @@ -18,7 +22,7 @@ export default function ResumePdf() {
} onLoadSuccess={onPdfLoadSuccess}> diff --git a/apps/portal/src/pages/resumes/[resumeId].tsx b/apps/portal/src/pages/resumes/[resumeId].tsx new file mode 100644 index 00000000..5a13a1c3 --- /dev/null +++ b/apps/portal/src/pages/resumes/[resumeId].tsx @@ -0,0 +1,148 @@ +import clsx from 'clsx'; +import formatDistanceToNow from 'date-fns/formatDistanceToNow'; +import Error from 'next/error'; +import { useRouter } from 'next/router'; +import { useSession } from 'next-auth/react'; +import { useEffect } from 'react'; +import { + AcademicCapIcon, + BriefcaseIcon, + CalendarIcon, + InformationCircleIcon, + MapPinIcon, + StarIcon, +} from '@heroicons/react/20/solid'; +import { Spinner } from '@tih/ui'; + +import CommentsSection from '~/components/resumes/comments/CommentsSection'; +import ResumePdf from '~/components/resumes/ResumePdf'; + +import { trpc } from '~/utils/trpc'; + +export default function ResumeReviewPage() { + const ErrorPage = ( + + ); + const { data: session } = useSession(); + const router = useRouter(); + const { resumeId } = router.query; + const utils = 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 }], + { + enabled: typeof resumeId === 'string' && session?.user?.id !== undefined, + }, + ); + const starMutation = trpc.useMutation('resumes.details.update_star', { + onSuccess() { + utils.invalidateQueries(); + }, + }); + + useEffect(() => { + if (detailsQuery.data?.stars.length) { + document.getElementById('star-button')?.focus(); + } else { + document.getElementById('star-button')?.blur(); + } + }, [detailsQuery.data?.stars]); + + const onStarButtonClick = () => { + // Star button only rendered if resume exists + // Star button only clickable if user exists + starMutation.mutate({ + resumeId: resumeId as string, + }); + }; + + return ( + <> + {detailsQuery.isError && ErrorPage} + {detailsQuery.isLoading && } + {detailsQuery.isFetched && detailsQuery.data && ( +
+
+

+ {detailsQuery.data.title} +

+ +
+
+
+
+
+
+
+
+
+
+
+ {detailsQuery.data.additionalInfo && ( +
+
+ )} +
+
+ +
+
+ {/* TODO: Update resumeId */} + +
+
+
+ )} + + ); +} diff --git a/apps/portal/src/pages/resumes/review.tsx b/apps/portal/src/pages/resumes/review.tsx deleted file mode 100644 index f75ae519..00000000 --- a/apps/portal/src/pages/resumes/review.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { - AcademicCapIcon, - BriefcaseIcon, - CalendarIcon, - InformationCircleIcon, - MapPinIcon, - StarIcon, -} from '@heroicons/react/20/solid'; - -import CommentsSection from '~/components/resumes/comments/CommentsSection'; -import ResumePdf from '~/components/resumes/ResumePdf'; - -export default function ResumeReviewPage() { - return ( -
-
-

- Please help moi, applying for medtech startups in Singapore -

- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- {/* TODO: Update resumeId */} - -
-
-
- ); -} diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index 9c2b451b..16530ff6 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -2,6 +2,7 @@ import superjson from 'superjson'; import { createRouter } from './context'; import { protectedExampleRouter } from './protected-example-router'; +import { resumesDetailsRouter } from './resumes-details-router'; import { resumesResumeUserRouter } from './resumes-resume-user-router'; import { resumeReviewsRouter } from './resumes-reviews-router'; import { resumesReviewsUserRouter } from './resumes-reviews-user-router'; @@ -16,6 +17,7 @@ export const appRouter = createRouter() .merge('auth.', protectedExampleRouter) .merge('todos.', todosRouter) .merge('todos.user.', todosUserRouter) + .merge('resumes.details.', resumesDetailsRouter) .merge('resumes.resume.user.', resumesResumeUserRouter) .merge('resumes.reviews.', resumeReviewsRouter) .merge('resumes.reviews.user.', resumesReviewsUserRouter); diff --git a/apps/portal/src/server/router/resumes-details-router.ts b/apps/portal/src/server/router/resumes-details-router.ts new file mode 100644 index 00000000..1255ac2e --- /dev/null +++ b/apps/portal/src/server/router/resumes-details-router.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; + +import { createRouter } 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', { + input: z.object({ + resumeId: z.string(), + }), + async resolve({ ctx, input }) { + const { resumeId } = input; + // Update_star will only be called if user is logged in + const userId = ctx.session!.user!.id; + + // Use the resumeId and resumeProfileId to check if star exists + const resumesStar = await ctx.prisma.resumesStar.findUnique({ + select: { + id: true, + }, + where: { + userId_resumeId: { + resumeId, + userId, + }, + }, + }); + + if (resumesStar === null) { + return await ctx.prisma.resumesStar.create({ + data: { + resumeId, + userId, + }, + }); + } + return await ctx.prisma.resumesStar.delete({ + where: { + userId_resumeId: { + resumeId, + userId, + }, + }, + }); + }, + }); diff --git a/apps/portal/src/server/router/resumes-resume-user-router.ts b/apps/portal/src/server/router/resumes-resume-user-router.ts index e4962d0b..9f014795 100644 --- a/apps/portal/src/server/router/resumes-resume-user-router.ts +++ b/apps/portal/src/server/router/resumes-resume-user-router.ts @@ -15,23 +15,12 @@ export const resumesResumeUserRouter = createProtectedRouter().mutation( }), async resolve({ ctx, input }) { const userId = ctx.session?.user.id; - const resumeProfile = await ctx.prisma.resumesProfile.upsert({ - create: { - userId, - }, - update: {}, - where: { - userId, - }, - }); - // TODO: Store file in file storage and retrieve URL - return await ctx.prisma.resumesResume.create({ data: { ...input, - resumesProfileId: resumeProfile.id, url: '', + userId, }, }); }, diff --git a/apps/portal/src/server/router/resumes-reviews-router.ts b/apps/portal/src/server/router/resumes-reviews-router.ts index 2f983c92..8e681326 100644 --- a/apps/portal/src/server/router/resumes-reviews-router.ts +++ b/apps/portal/src/server/router/resumes-reviews-router.ts @@ -7,18 +7,9 @@ export const resumeReviewsRouter = createRouter().query('list', { resumeId: z.string(), }), async resolve({ ctx, input }) { + const userId = ctx.session?.user?.id; const { resumeId } = input; - const { resumesProfileId } = - await ctx.prisma.resumesResume.findUniqueOrThrow({ - select: { - resumesProfileId: true, - }, - where: { - id: resumeId, - }, - }); - // For this resume, we retrieve every comment's information, along with: // The user's name and image to render // Number of votes, and whether the user (if-any) has voted @@ -29,20 +20,16 @@ export const resumeReviewsRouter = createRouter().query('list', { votes: true, }, }, - resumesProfile: { - include: { - user: { - select: { - image: true, - name: true, - }, - }, + user: { + select: { + image: true, + name: true, }, }, votes: { take: 1, where: { - resumesProfileId, + userId, }, }, }, diff --git a/apps/portal/src/server/router/resumes-reviews-user-router.ts b/apps/portal/src/server/router/resumes-reviews-user-router.ts index b13e2a00..5730887f 100644 --- a/apps/portal/src/server/router/resumes-reviews-user-router.ts +++ b/apps/portal/src/server/router/resumes-reviews-user-router.ts @@ -6,8 +6,8 @@ import { createProtectedRouter } from './context'; type IResumeCommentInput = Readonly<{ description: string; resumeId: string; - resumesProfileId: string; section: ResumesSection; + userId: string; }>; export const resumesReviewsUserRouter = createProtectedRouter().mutation( @@ -22,19 +22,10 @@ export const resumesReviewsUserRouter = createProtectedRouter().mutation( skills: z.string(), }), async resolve({ ctx, input }) { + const userId = ctx.session?.user?.id; const { resumeId, education, experience, general, projects, skills } = input; - const { resumesProfileId } = - await ctx.prisma.resumesResume.findUniqueOrThrow({ - select: { - resumesProfileId: true, - }, - where: { - id: resumeId, - }, - }); - // For each section, convert them into ResumesComment model if provided const comments: Array = [ { description: education, section: ResumesSection.EDUCATION }, @@ -50,8 +41,8 @@ export const resumesReviewsUserRouter = createProtectedRouter().mutation( return { description, resumeId, - resumesProfileId, section, + userId, }; }); diff --git a/yarn.lock b/yarn.lock index 8a87bd65..bd4ae44d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6049,7 +6049,7 @@ damerau-levenshtein@^1.0.8: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== -date-fns@^2.29.1: +date-fns@^2.29.1, date-fns@^2.29.3: version "2.29.3" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA== From b37aae215471c1d3d43004deaefca7c94245d7d5 Mon Sep 17 00:00:00 2001 From: Su Yin <53945359+tnsyn@users.noreply.github.com> Date: Sat, 8 Oct 2022 00:26:16 +0800 Subject: [PATCH 17/19] [resumes][feat] Fetch all resumes in browse page (#325) * [resumes][fix] Remove BrowsePageBody component * [resumes][feat] Add router to fetch all resumes * [resumes][feat] Fetch all resumes in browse page * [resumes][chore] Add todo * [resumes][fix] Remove unnecessary updatedAt field * [resumes][fix] Change from resumeProfile to user --- .../resumes/browse/BrowseListItem.tsx | 13 +- .../resumes/browse/BrowsePageBody.tsx | 226 ----------------- apps/portal/src/pages/resumes/index.tsx | 233 +++++++++++++++++- apps/portal/src/server/router/index.ts | 2 + apps/portal/src/server/router/resumes.ts | 42 ++++ apps/portal/src/types/resume.d.ts | 13 + 6 files changed, 291 insertions(+), 238 deletions(-) delete mode 100644 apps/portal/src/components/resumes/browse/BrowsePageBody.tsx create mode 100644 apps/portal/src/server/router/resumes.ts create mode 100644 apps/portal/src/types/resume.d.ts diff --git a/apps/portal/src/components/resumes/browse/BrowseListItem.tsx b/apps/portal/src/components/resumes/browse/BrowseListItem.tsx index a9d9c438..3588f0c0 100644 --- a/apps/portal/src/components/resumes/browse/BrowseListItem.tsx +++ b/apps/portal/src/components/resumes/browse/BrowseListItem.tsx @@ -3,19 +3,11 @@ import type { UrlObject } from 'url'; import { ChevronRightIcon } from '@heroicons/react/20/solid'; import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline'; -type ResumeInfo = Readonly<{ - createdAt: Date; - experience: string; - numComments: number; - numStars: number; - role: string; - title: string; - user: string; -}>; +import type { Resume } from '~/types/resume'; type Props = Readonly<{ href: UrlObject | string; - resumeInfo: ResumeInfo; + resumeInfo: Resume; }>; export default function BrowseListItem({ href, resumeInfo }: Props) { @@ -42,6 +34,7 @@ export default function BrowseListItem({ href, resumeInfo }: Props) {
+ {/* TODO: Replace hardcoded days ago with calculated days ago*/} Uploaded 2 days ago by {resumeInfo.user}
diff --git a/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx b/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx deleted file mode 100644 index ee28609f..00000000 --- a/apps/portal/src/components/resumes/browse/BrowsePageBody.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import clsx from 'clsx'; -import { Fragment, useState } from 'react'; -import { Disclosure, Menu, Transition } from '@headlessui/react'; -import { - ChevronDownIcon, - MinusIcon, - PlusIcon, -} from '@heroicons/react/20/solid'; -import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; -import { Tabs, TextInput } from '@tih/ui'; - -import BrowseListItem from './BrowseListItem'; -import { - EXPERIENCE, - LOCATION, - ROLES, - SORT_OPTIONS, - TEST_RESUMES, - TOP_HITS, -} from './constants'; -import FilterPill from './FilterPill'; - -const filters = [ - { - id: 'roles', - name: 'Roles', - options: ROLES, - }, - { - id: 'experience', - name: 'Experience', - options: EXPERIENCE, - }, - { - id: 'location', - name: 'Location', - options: LOCATION, - }, -]; - -export default function BrowsePageBody() { - const [tabsValue, setTabsValue] = useState('all'); - const [searchValue, setSearchValue] = useState(''); - return ( -
- - -
-
-
-
-

Categories

-
    - {TOP_HITS.map((category) => ( -
  • - {/* TODO: Replace onClick with filtering function */} - true} /> -
  • - ))} -
- - {filters.map((section) => ( - - {({ open }) => ( - <> -

- - - {section.name} - - - {open ? ( - - -

- -
- {section.options.map((option, optionIdx) => ( -
- - -
- ))} -
-
- - )} -
- ))} -
-
-
-
-
    - {TEST_RESUMES.map((resumeObj) => ( -
  • - -
  • - ))} -
-
-
-
- ); -} diff --git a/apps/portal/src/pages/resumes/index.tsx b/apps/portal/src/pages/resumes/index.tsx index f0f9bad1..fb0ca8b0 100644 --- a/apps/portal/src/pages/resumes/index.tsx +++ b/apps/portal/src/pages/resumes/index.tsx @@ -1,14 +1,243 @@ -import BrowsePageBody from '~/components/resumes/browse/BrowsePageBody'; +import clsx from 'clsx'; +import { Fragment, useState } from 'react'; +import { Disclosure, Menu, Transition } from '@headlessui/react'; +import { + ChevronDownIcon, + MinusIcon, + PlusIcon, +} from '@heroicons/react/20/solid'; +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; +import { Tabs, TextInput } from '@tih/ui'; + +import BrowseListItem from '~/components/resumes/browse/BrowseListItem'; +import { + EXPERIENCE, + LOCATION, + ROLES, + SORT_OPTIONS, + TOP_HITS, +} from '~/components/resumes/browse/constants'; +import FilterPill from '~/components/resumes/browse/FilterPill'; + +const filters = [ + { + id: 'roles', + name: 'Roles', + options: ROLES, + }, + { + id: 'experience', + name: 'Experience', + options: EXPERIENCE, + }, + { + id: 'location', + name: 'Location', + options: LOCATION, + }, +]; import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle'; +import { trpc } from '~/utils/trpc'; + export default function ResumeHomePage() { + const [tabsValue, setTabsValue] = useState('all'); + const [searchValue, setSearchValue] = useState(''); + const resumesQuery = trpc.useQuery(['resumes.resume.list']); + return (
- +
+
+
+

Filters

+
+
+
+
+ +
+
+
+ + +
+
+ +
+ + Sort + +
+ + + +
+ {SORT_OPTIONS.map((option) => ( + + {({ active }) => ( + + {option.name} + + )} + + ))} +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+

Categories

+
    + {TOP_HITS.map((category) => ( +
  • + {/* TODO: Replace onClick with filtering function */} + true} + /> +
  • + ))} +
+ + {filters.map((section) => ( + + {({ open }) => ( + <> +

+ + + {section.name} + + + {open ? ( + + +

+ +
+ {section.options.map((option, optionIdx) => ( +
+ + +
+ ))} +
+
+ + )} +
+ ))} +
+
+
+ {resumesQuery.isLoading ? ( +
Loading...
+ ) : ( +
+
    + {resumesQuery.data?.map((resumeObj) => ( +
  • + +
  • + ))} +
+
+ )} +
+
); diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index 16530ff6..ddeff431 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -2,6 +2,7 @@ import superjson from 'superjson'; import { createRouter } from './context'; import { protectedExampleRouter } from './protected-example-router'; +import { resumesRouter } from './resumes'; import { resumesDetailsRouter } from './resumes-details-router'; import { resumesResumeUserRouter } from './resumes-resume-user-router'; import { resumeReviewsRouter } from './resumes-reviews-router'; @@ -17,6 +18,7 @@ export const appRouter = createRouter() .merge('auth.', protectedExampleRouter) .merge('todos.', todosRouter) .merge('todos.user.', todosUserRouter) + .merge('resumes.resume.', resumesRouter) .merge('resumes.details.', resumesDetailsRouter) .merge('resumes.resume.user.', resumesResumeUserRouter) .merge('resumes.reviews.', resumeReviewsRouter) diff --git a/apps/portal/src/server/router/resumes.ts b/apps/portal/src/server/router/resumes.ts new file mode 100644 index 00000000..2e7f9f9c --- /dev/null +++ b/apps/portal/src/server/router/resumes.ts @@ -0,0 +1,42 @@ +import { createRouter } from './context'; + +import type { Resume } from '~/types/resume'; + +export const resumesRouter = createRouter().query('list', { + 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; + }); + }, +}); diff --git a/apps/portal/src/types/resume.d.ts b/apps/portal/src/types/resume.d.ts new file mode 100644 index 00000000..5b2a33a9 --- /dev/null +++ b/apps/portal/src/types/resume.d.ts @@ -0,0 +1,13 @@ +export type Resume = { + additionalInfo: string?; + createdAt: Date; + experience: string; + id: string; + location: string; + numComments: number; + numStars: number; + role: string; + title: string; + url: string; + user: string; +}; From d9880dbff1c0163a3b7d55a4700a1ec6c88cfa7b Mon Sep 17 00:00:00 2001 From: Terence <45381509+Vielheim@users.noreply.github.com> Date: Sat, 8 Oct 2022 00:42:27 +0800 Subject: [PATCH 18/19] [resumes][feat] fetch comments from database (#320) * [resumes][feat] Add resume-comments type * [resumes][feat] Add resume-comments type * [resumes][feat] Filter comments * [resumes][feat] Add comments render * [resumes][refactor] rename variables * [resumes][refactor] update invalidateQueries * [resumes][refactor] Use resumeId in [resumeId].tsx * [resumes][fix] fix invalidateQuery Co-authored-by: Terence Ho <> --- .../resumes/comments/CommentsForm.tsx | 28 +++++--- .../resumes/comments/CommentsList.tsx | 26 ++++++-- .../resumes/comments/comment/Comment.tsx | 18 ++++++ .../resumes/comments/comment/CommentBody.tsx | 64 +++++++++++++++++++ .../resumes/comments/comment/CommentCard.tsx | 22 +++++++ apps/portal/src/pages/resumes/[resumeId].tsx | 3 +- .../server/router/resumes-reviews-router.ts | 32 +++++++++- apps/portal/src/types/resume-comments.d.ts | 21 ++++++ 8 files changed, 197 insertions(+), 17 deletions(-) create mode 100644 apps/portal/src/components/resumes/comments/comment/Comment.tsx create mode 100644 apps/portal/src/components/resumes/comments/comment/CommentBody.tsx create mode 100644 apps/portal/src/components/resumes/comments/comment/CommentCard.tsx create mode 100644 apps/portal/src/types/resume-comments.d.ts diff --git a/apps/portal/src/components/resumes/comments/CommentsForm.tsx b/apps/portal/src/components/resumes/comments/CommentsForm.tsx index 98ff8d82..c7b7dc6a 100644 --- a/apps/portal/src/components/resumes/comments/CommentsForm.tsx +++ b/apps/portal/src/components/resumes/comments/CommentsForm.tsx @@ -39,17 +39,29 @@ export default function CommentsForm({ skills: '', }, }); - const reviewCreateMutation = trpc.useMutation('resumes.reviews.user.create'); + + const trpcContext = trpc.useContext(); + const reviewCreateMutation = trpc.useMutation('resumes.reviews.user.create', { + onSuccess: () => { + // New review added, invalidate query to trigger refetch + trpcContext.invalidateQueries(['resumes.reviews.list']); + }, + }); // TODO: Give a feedback to the user if the action succeeds/fails const onSubmit: SubmitHandler = async (data) => { - await reviewCreateMutation.mutate({ - resumeId, - ...data, - }); - - // Redirect back to comments section - setShowCommentsForm(false); + return await reviewCreateMutation.mutate( + { + resumeId, + ...data, + }, + { + onSuccess: () => { + // Redirect back to comments section + setShowCommentsForm(false); + }, + }, + ); }; const onCancel = () => { diff --git a/apps/portal/src/components/resumes/comments/CommentsList.tsx b/apps/portal/src/components/resumes/comments/CommentsList.tsx index 0b1d2d35..54843bca 100644 --- a/apps/portal/src/components/resumes/comments/CommentsList.tsx +++ b/apps/portal/src/components/resumes/comments/CommentsList.tsx @@ -1,8 +1,10 @@ +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 CommentsListButton from './CommentsListButton'; import { COMMENTS_SECTIONS } from './constants'; @@ -16,12 +18,15 @@ export default function CommentsList({ setShowCommentsForm, }: CommentsListProps) { const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value); + const { data: session } = useSession(); - const commentsQuery = trpc.useQuery(['resumes.reviews.list', { resumeId }]); + // Fetch the most updated comments to render + const commentsQuery = trpc.useQuery([ + 'resumes.reviews.list', + { resumeId, section: tab }, + ]); - /* eslint-disable no-console */ - console.log(commentsQuery.data); - /* eslint-enable no-console */ + // TODO: Add loading prompt return (
@@ -32,7 +37,18 @@ export default function CommentsList({ value={tab} onChange={(value) => setTab(value)} /> - {/* TODO: Add comments lists */} + +
+ {commentsQuery.data?.map((comment) => { + return ( + + ); + })} +
); } diff --git a/apps/portal/src/components/resumes/comments/comment/Comment.tsx b/apps/portal/src/components/resumes/comments/comment/Comment.tsx new file mode 100644 index 00000000..cb08480b --- /dev/null +++ b/apps/portal/src/components/resumes/comments/comment/Comment.tsx @@ -0,0 +1,18 @@ +import CommentBody from './CommentBody'; +import CommentCard from './CommentCard'; + +import type { ResumeComment } from '~/types/resume-comments'; + +type CommentProps = { + comment: ResumeComment; + userId?: string; +}; + +export default function Comment({ comment, userId }: CommentProps) { + const isCommentOwner = userId === comment.user.userId; + return ( + + + + ); +} diff --git a/apps/portal/src/components/resumes/comments/comment/CommentBody.tsx b/apps/portal/src/components/resumes/comments/comment/CommentBody.tsx new file mode 100644 index 00000000..69da7418 --- /dev/null +++ b/apps/portal/src/components/resumes/comments/comment/CommentBody.tsx @@ -0,0 +1,64 @@ +import { + ArrowDownCircleIcon, + ArrowUpCircleIcon, +} from '@heroicons/react/20/solid'; +import { FaceSmileIcon } from '@heroicons/react/24/outline'; + +import type { ResumeComment } from '~/types/resume-comments'; + +type CommentBodyProps = { + comment: ResumeComment; + isCommentOwner?: boolean; +}; + +export default function CommentBody({ + comment, + isCommentOwner, +}: CommentBodyProps) { + return ( +
+ {comment.user.image ? ( + {comment.user.name + ) : ( + + )} + +
+ {/* Name and creation time */} +
+
+ {comment.user.name ?? 'Reviewer ABC'} +
+
+ {comment.createdAt.toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + })} +
+
+ + {/* Description */} +
{comment.description}
+ + {/* Upvote and edit */} +
+ {/* TODO: Implement upvote */} + +
{comment.numVotes}
+ + + {/* TODO: Implement edit */} + {isCommentOwner ? ( +
+ Edit +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/portal/src/components/resumes/comments/comment/CommentCard.tsx b/apps/portal/src/components/resumes/comments/comment/CommentCard.tsx new file mode 100644 index 00000000..bbe0f840 --- /dev/null +++ b/apps/portal/src/components/resumes/comments/comment/CommentCard.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react'; + +type CommentCardProps = { + children: ReactNode; + isCommentOwner?: boolean; +}; + +export default function CommentCard({ + isCommentOwner, + children, +}: CommentCardProps) { + // Used two different
to allow customisation of owner comments + return isCommentOwner ? ( +
+ {children} +
+ ) : ( +
+ {children} +
+ ); +} diff --git a/apps/portal/src/pages/resumes/[resumeId].tsx b/apps/portal/src/pages/resumes/[resumeId].tsx index 5a13a1c3..7103655b 100644 --- a/apps/portal/src/pages/resumes/[resumeId].tsx +++ b/apps/portal/src/pages/resumes/[resumeId].tsx @@ -137,8 +137,7 @@ export default function ResumeReviewPage() {
- {/* TODO: Update resumeId */} - +
diff --git a/apps/portal/src/server/router/resumes-reviews-router.ts b/apps/portal/src/server/router/resumes-reviews-router.ts index 8e681326..8219edce 100644 --- a/apps/portal/src/server/router/resumes-reviews-router.ts +++ b/apps/portal/src/server/router/resumes-reviews-router.ts @@ -1,19 +1,23 @@ import { z } from 'zod'; +import { ResumesSection } from '@prisma/client'; import { createRouter } from './context'; +import type { ResumeComment } from '~/types/resume-comments'; + export const resumeReviewsRouter = createRouter().query('list', { input: z.object({ resumeId: z.string(), + section: z.nativeEnum(ResumesSection), }), async resolve({ ctx, input }) { const userId = ctx.session?.user?.id; - const { resumeId } = input; + const { resumeId, section } = input; // For this resume, we retrieve every comment's information, along with: // The user's name and image to render // Number of votes, and whether the user (if-any) has voted - return await ctx.prisma.resumesComment.findMany({ + const comments = await ctx.prisma.resumesComment.findMany({ include: { _count: { select: { @@ -38,7 +42,31 @@ export const resumeReviewsRouter = createRouter().query('list', { }, where: { resumeId, + section, }, }); + + return comments.map((data) => { + const hasVoted = data.votes.length > 0; + const numVotes = data._count.votes; + + const comment: ResumeComment = { + createdAt: data.createdAt, + description: data.description, + hasVoted, + id: data.id, + numVotes, + resumeId: data.resumeId, + section: data.section, + updatedAt: data.updatedAt, + user: { + image: data.user.image, + name: data.user.name, + userId: data.userId, + }, + }; + + return comment; + }); }, }); diff --git a/apps/portal/src/types/resume-comments.d.ts b/apps/portal/src/types/resume-comments.d.ts new file mode 100644 index 00000000..5a6dfff8 --- /dev/null +++ b/apps/portal/src/types/resume-comments.d.ts @@ -0,0 +1,21 @@ +import type { ResumesSection } from '@prisma/client'; + +/** + * Returned by `resumeReviewsRouter` (query for 'resumes.reviews.list') and received as prop by `Comment` in `CommentsList` + * frontend-friendly representation of the query + */ +export type ResumeComment = { + createdAt: Date; + description: string; + hasVoted: boolean; + id: string; + numVotes: number; + resumeId: string; + section: ResumesSection; + updatedAt: Date; + user: { + image: string?; + name: string?; + userId: string; + }; +}; From 2f13d5f009556c59a81c8cdbd8fad8c1e6c62bb9 Mon Sep 17 00:00:00 2001 From: Yangshun Tay Date: Sat, 8 Oct 2022 10:50:56 +0800 Subject: [PATCH 19/19] [ui][text area] implementation --- apps/storybook/stories/text-area.stories.tsx | 144 ++++++++++++++++++ apps/storybook/stories/text-input.stories.tsx | 2 +- packages/ui/src/TextArea/TextArea.tsx | 141 +++++++++++++++++ packages/ui/src/TextInput/TextInput.tsx | 3 +- packages/ui/src/index.tsx | 3 + 5 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 apps/storybook/stories/text-area.stories.tsx create mode 100644 packages/ui/src/TextArea/TextArea.tsx diff --git a/apps/storybook/stories/text-area.stories.tsx b/apps/storybook/stories/text-area.stories.tsx new file mode 100644 index 00000000..14d4acdb --- /dev/null +++ b/apps/storybook/stories/text-area.stories.tsx @@ -0,0 +1,144 @@ +import React, { useState } from 'react'; +import type { ComponentMeta } from '@storybook/react'; +import type { TextAreaResize } from '@tih/ui'; +import { TextArea } from '@tih/ui'; + +const textAreaResize: ReadonlyArray = [ + 'vertical', + 'horizontal', + 'none', + 'both', +]; + +export default { + argTypes: { + autoComplete: { + control: 'text', + }, + disabled: { + control: 'boolean', + }, + errorMessage: { + control: 'text', + }, + isLabelHidden: { + control: 'boolean', + }, + label: { + control: 'text', + }, + name: { + control: 'text', + }, + placeholder: { + control: 'text', + }, + readOnly: { + control: 'boolean', + }, + required: { + control: 'boolean', + }, + resize: { + control: { type: 'select' }, + options: textAreaResize, + }, + rows: { + control: 'number', + }, + }, + component: TextArea, + title: 'TextArea', +} as ComponentMeta; + +export const Basic = { + args: { + label: 'Comment', + placeholder: 'Type your comment here', + }, +}; + +export function HiddenLabel() { + const [value, setValue] = useState(''); + + return ( +