diff --git a/apps/portal/prisma/migrations/20221008165306_update_vote_enum/migration.sql b/apps/portal/prisma/migrations/20221008165306_update_vote_enum/migration.sql new file mode 100644 index 00000000..bd09564a --- /dev/null +++ b/apps/portal/prisma/migrations/20221008165306_update_vote_enum/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - Changed the type of `vote` on the `QuestionsAnswerCommentVote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Changed the type of `vote` on the `QuestionsAnswerVote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Changed the type of `vote` on the `QuestionsQuestionCommentVote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Changed the type of `vote` on the `QuestionsQuestionVote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- CreateEnum +CREATE TYPE "Vote" AS ENUM ('UPVOTE', 'DOWNVOTE'); + +-- AlterTable +ALTER TABLE "QuestionsAnswerCommentVote" DROP COLUMN "vote", +ADD COLUMN "vote" "Vote" NOT NULL; + +-- AlterTable +ALTER TABLE "QuestionsAnswerVote" DROP COLUMN "vote", +ADD COLUMN "vote" "Vote" NOT NULL; + +-- AlterTable +ALTER TABLE "QuestionsQuestionCommentVote" DROP COLUMN "vote", +ADD COLUMN "vote" "Vote" NOT NULL; + +-- AlterTable +ALTER TABLE "QuestionsQuestionVote" DROP COLUMN "vote", +ADD COLUMN "vote" "Vote" NOT NULL; + +-- DropEnum +DROP TYPE "QuestionsVote"; diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index 6b712610..13287fea 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -60,6 +60,11 @@ model User { questionsAnswerCommentVotes QuestionsAnswerCommentVote[] } +enum Vote { + UPVOTE + DOWNVOTE +} + model VerificationToken { identifier String token String @unique @@ -172,12 +177,6 @@ model ResumesCommentVote { // use camelCase for field names, and try to name them consistently // across all models in this file. -enum QuestionsVote { - NO_VOTE - UPVOTE - DOWNVOTE -} - enum QuestionsQuestionType { CODING SYSTEM_DESIGN @@ -215,12 +214,12 @@ model QuestionsQuestionEncounter { } model QuestionsQuestionVote { - id String @id @default(cuid()) + id String @id @default(cuid()) questionId String userId String? - vote QuestionsVote - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + vote Vote + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User? @relation(fields: [userId], references: [id], onDelete: SetNull) question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade) @@ -242,12 +241,12 @@ model QuestionsQuestionComment { } model QuestionsQuestionCommentVote { - id String @id @default(cuid()) + id String @id @default(cuid()) questionCommentId String userId String? - vote QuestionsVote - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + vote Vote + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User? @relation(fields: [userId], references: [id], onDelete: SetNull) comment QuestionsQuestionComment @relation(fields: [questionCommentId], references: [id], onDelete: Cascade) @@ -270,12 +269,12 @@ model QuestionsAnswer { } model QuestionsAnswerVote { - id String @id @default(cuid()) + id String @id @default(cuid()) answerId String userId String? - vote QuestionsVote - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + vote Vote + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User? @relation(fields: [userId], references: [id], onDelete: SetNull) answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade) @@ -297,12 +296,12 @@ model QuestionsAnswerComment { } model QuestionsAnswerCommentVote { - id String @id @default(cuid()) + id String @id @default(cuid()) answerCommentId String userId String? - vote QuestionsVote - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + vote Vote + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User? @relation(fields: [userId], references: [id], onDelete: SetNull) comment QuestionsAnswerComment @relation(fields: [answerCommentId], references: [id], onDelete: Cascade) diff --git a/apps/portal/public/logo.svg b/apps/portal/public/logo.svg new file mode 100644 index 00000000..ed21574f --- /dev/null +++ b/apps/portal/public/logo.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/portal/src/components/questions/ContributeQuestionCard.tsx b/apps/portal/src/components/questions/ContributeQuestionCard.tsx new file mode 100644 index 00000000..bd6c4d78 --- /dev/null +++ b/apps/portal/src/components/questions/ContributeQuestionCard.tsx @@ -0,0 +1,102 @@ +import type { ComponentProps, ForwardedRef } from 'react'; +import { useState } from 'react'; +import { forwardRef } from 'react'; +import type { UseFormRegisterReturn } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; +import { + BuildingOffice2Icon, + CalendarDaysIcon, + QuestionMarkCircleIcon, +} from '@heroicons/react/24/outline'; +import { Button, TextInput } from '@tih/ui'; + +import ContributeQuestionModal from './ContributeQuestionModal'; + +export type ContributeQuestionData = { + company: string; + date: Date; + questionContent: string; + questionType: string; +}; + +type TextInputProps = ComponentProps; + +type FormTextInputProps = Omit & + Pick, 'onChange'>; + +function FormTextInputWithRef( + props: FormTextInputProps, + ref?: ForwardedRef, +) { + const { onChange, ...rest } = props; + return ( + onChange(event)} + /> + ); +} + +const FormTextInput = forwardRef(FormTextInputWithRef); + +export type ContributeQuestionCardProps = { + onSubmit: (data: ContributeQuestionData) => void; +}; + +export default function ContributeQuestionCard({ + onSubmit, +}: ContributeQuestionCardProps) { + const { register, handleSubmit } = useForm(); + const [isOpen, setOpen] = useState(false); + return ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ + + + + ); +} diff --git a/apps/portal/src/components/questions/ContributeQuestionModal.tsx b/apps/portal/src/components/questions/ContributeQuestionModal.tsx new file mode 100644 index 00000000..1da146e8 --- /dev/null +++ b/apps/portal/src/components/questions/ContributeQuestionModal.tsx @@ -0,0 +1,96 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { Fragment, useState } from 'react'; +import { Dialog, Transition } from '@headlessui/react'; + +import Checkbox from './ui-patch/Checkbox'; + +export type ContributeQuestionModalProps = { + contributeState: boolean; + setContributeState: Dispatch>; +}; + +export default function ContributeQuestionModal({ + contributeState, + setContributeState, +}: ContributeQuestionModalProps) { + const [canSubmit, setCanSubmit] = useState(false); + + const handleCheckSimilarQuestions = (checked: boolean) => { + setCanSubmit(checked); + }; + + return ( + + setContributeState(false)}> + +
+ + +
+
+ + +
+
+
+ + Question Draft + +
+

+ Question Contribution form +

+
+
+
+
+
+
+ +
+
+ + +
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/portal/src/components/questions/NavBar.tsx b/apps/portal/src/components/questions/NavBar.tsx new file mode 100644 index 00000000..99b76457 --- /dev/null +++ b/apps/portal/src/components/questions/NavBar.tsx @@ -0,0 +1,56 @@ +import Link from 'next/link'; + +const navigation = [ + { href: '/questions/landing', name: '*Landing*' }, + { href: '/questions', name: 'Home' }, + { href: '#', name: 'My Lists' }, + { href: '#', name: 'My Questions' }, + { href: '#', name: 'History' }, +]; + +export default function NavBar() { + return ( +
+ +
+ ); +} diff --git a/apps/portal/src/components/questions/QuestionOverviewCard.tsx b/apps/portal/src/components/questions/QuestionOverviewCard.tsx new file mode 100644 index 00000000..31435c04 --- /dev/null +++ b/apps/portal/src/components/questions/QuestionOverviewCard.tsx @@ -0,0 +1,72 @@ +import { + ChatBubbleBottomCenterTextIcon, + ChevronDownIcon, + ChevronUpIcon, + EyeIcon, +} from '@heroicons/react/24/outline'; +import { Badge, Button } from '@tih/ui'; + +export type QuestionOverviewCardProps = { + answerCount: number; + content: string; + location: string; + role: string; + similarCount: number; + timestamp: string; + upvoteCount: number; +}; + +export default function QuestionOverviewCard({ + answerCount, + content, + similarCount, + upvoteCount, + timestamp, + role, + location, +}: QuestionOverviewCardProps) { + return ( +
+
+
+
+
+ +

+ {timestamp} · {location} · {role} +

+
+

{content}

+
+
+
+
+ ); +} diff --git a/apps/portal/src/components/questions/QuestionSearchBar.tsx b/apps/portal/src/components/questions/QuestionSearchBar.tsx new file mode 100644 index 00000000..02ba0a89 --- /dev/null +++ b/apps/portal/src/components/questions/QuestionSearchBar.tsx @@ -0,0 +1,42 @@ +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; +import { Select, TextInput } from '@tih/ui'; + +export type SortOption = { + label: string; + value: string; +}; + +export type QuestionSearchBarProps> = { + onSortChange?: (sortValue: SortOptions[number]['value']) => void; + sortOptions: SortOptions; + sortValue: SortOptions[number]['value']; +}; + +export default function QuestionSearchBar< + SortOptions extends Array, +>({ + onSortChange, + sortOptions, + sortValue, +}: QuestionSearchBarProps) { + return ( +
+
+ +
+ Sort by: + +
+ ); +} diff --git a/apps/portal/src/components/questions/filter/FilterSection.tsx b/apps/portal/src/components/questions/filter/FilterSection.tsx new file mode 100644 index 00000000..5954e669 --- /dev/null +++ b/apps/portal/src/components/questions/filter/FilterSection.tsx @@ -0,0 +1,62 @@ +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; +import { Collapsible, TextInput } from '@tih/ui'; + +import Checkbox from '../ui-patch/Checkbox'; + +export type FilterOptions = { + checked: boolean; + label: string; + value: string; +}; + +export type FilterSectionProps = { + label: string; + onOptionChange: (optionValue: string, checked: boolean) => void; + options: Array; +} & ( + | { + searchPlaceholder: string; + showAll?: never; + } + | { + searchPlaceholder?: never; + showAll: true; + } +); + +export default function FilterSection({ + label, + options, + searchPlaceholder, + showAll, + onOptionChange, +}: FilterSectionProps) { + return ( +
+ +
+ {!showAll && ( + + )} +
+ {options.map((option) => ( + { + onOptionChange(option.value, checked); + }} + /> + ))} +
+
+
+
+ ); +} diff --git a/apps/portal/src/components/questions/ui-patch/Checkbox.tsx b/apps/portal/src/components/questions/ui-patch/Checkbox.tsx new file mode 100644 index 00000000..413c12fe --- /dev/null +++ b/apps/portal/src/components/questions/ui-patch/Checkbox.tsx @@ -0,0 +1,25 @@ +import { useId } from 'react'; + +export type CheckboxProps = { + checked: boolean; + label: string; + onChange: (checked: boolean) => void; +}; + +export default function Checkbox({ label, checked, onChange }: CheckboxProps) { + const id = useId(); + return ( +
+ onChange(event.target.checked)} + /> + +
+ ); +} diff --git a/apps/portal/src/components/resumes/browse/BrowseListItem.tsx b/apps/portal/src/components/resumes/browse/BrowseListItem.tsx index 3588f0c0..3c10a463 100644 --- a/apps/portal/src/components/resumes/browse/BrowseListItem.tsx +++ b/apps/portal/src/components/resumes/browse/BrowseListItem.tsx @@ -1,3 +1,4 @@ +import { formatDistanceToNow } from 'date-fns'; import Link from 'next/link'; import type { UrlObject } from 'url'; import { ChevronRightIcon } from '@heroicons/react/20/solid'; @@ -13,8 +14,8 @@ type Props = Readonly<{ export default function BrowseListItem({ href, resumeInfo }: Props) { return ( -
-
+
+
{resumeInfo.title}
{resumeInfo.role} @@ -33,11 +34,11 @@ export default function BrowseListItem({ href, resumeInfo }: Props) {
-
- {/* TODO: Replace hardcoded days ago with calculated days ago*/} - Uploaded 2 days ago by {resumeInfo.user} +
+ Uploaded {formatDistanceToNow(resumeInfo.createdAt)} ago by{' '} + {resumeInfo.user}
- +
); diff --git a/apps/portal/src/components/resumes/browse/constants.ts b/apps/portal/src/components/resumes/browse/constants.ts index 6a52fc7f..18448cee 100644 --- a/apps/portal/src/components/resumes/browse/constants.ts +++ b/apps/portal/src/components/resumes/browse/constants.ts @@ -1,3 +1,9 @@ +export const BROWSE_TABS_VALUES = { + ALL: 'all', + MY: 'my', + STARRED: 'starred', +}; + export const SORT_OPTIONS = [ { current: true, href: '#', name: 'Latest' }, { current: false, href: '#', name: 'Popular' }, @@ -17,19 +23,33 @@ export const ROLES = [ 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' }, + { 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' }, + { 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: 'Fresh Grad (0-1 years)', + }, + { + checked: false, + label: 'Mid-level (2 - 5 years)', + value: 'Mid-level (2 - 5 years)', + }, + { + checked: false, + label: 'Senior (5+ years)', + value: 'Senior (5+ years)', + }, ]; export const LOCATION = [ diff --git a/apps/portal/src/components/resumes/comments/CommentsForm.tsx b/apps/portal/src/components/resumes/comments/CommentsForm.tsx index c7b7dc6a..a14f9e3e 100644 --- a/apps/portal/src/components/resumes/comments/CommentsForm.tsx +++ b/apps/portal/src/components/resumes/comments/CommentsForm.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import type { SubmitHandler } from 'react-hook-form'; import { useForm } from 'react-hook-form'; -import { Button, Dialog, TextInput } from '@tih/ui'; +import { Button, Dialog, TextArea } from '@tih/ui'; import { trpc } from '~/utils/trpc'; @@ -86,45 +86,39 @@ export default function CommentsForm({
- {/* TODO: Convert TextInput to TextArea */}
- onValueChange('general', value)} /> - onValueChange('education', value)} /> - onValueChange('experience', value)} /> - onValueChange('projects', value)} /> - onValueChange('skills', value)} />
diff --git a/apps/portal/src/pages/questions/index.tsx b/apps/portal/src/pages/questions/index.tsx index f8fbc5ec..cf6611e3 100644 --- a/apps/portal/src/pages/questions/index.tsx +++ b/apps/portal/src/pages/questions/index.tsx @@ -1,10 +1,202 @@ -import QuestionBankTitle from '~/components/questions/QuestionBankTitle'; +import { useMemo, useState } from 'react'; + +import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard'; +import type { FilterOptions } from '~/components/questions/filter/FilterSection'; +import FilterSection from '~/components/questions/filter/FilterSection'; +import NavBar from '~/components/questions/NavBar'; +import QuestionOverviewCard from '~/components/questions/QuestionOverviewCard'; +import QuestionSearchBar from '~/components/questions/QuestionSearchBar'; + +type FilterChoices = Array>; + +const companies: FilterChoices = [ + { + label: 'Google', + value: 'Google', + }, + { + label: 'Meta', + value: 'meta', + }, +]; + +// Code, design, behavioral +const questionTypes: FilterChoices = [ + { + label: 'Code', + value: 'code', + }, + { + label: 'Design', + value: 'design', + }, + { + label: 'Behavioral', + value: 'behavioral', + }, +]; + +const questionAges: FilterChoices = [ + { + label: 'Last month', + value: 'last-month', + }, + { + label: 'Last 6 months', + value: 'last-6-months', + }, + { + label: 'Last year', + value: 'last-year', + }, +]; + +const locations: FilterChoices = [ + { + label: 'Singapore', + value: 'singapore', + }, +]; export default function QuestionsHomePage() { + const [selectedCompanies, setSelectedCompanies] = useState>([]); + const [selectedQuestionTypes, setSelectedQuestionTypes] = useState< + Array + >([]); + const [selectedQuestionAges, setSelectedQuestionAges] = useState< + Array + >([]); + const [selectedLocations, setSelectedLocations] = useState>([]); + + const companyFilterOptions = useMemo(() => { + return companies.map((company) => ({ + ...company, + checked: selectedCompanies.includes(company.value), + })); + }, [selectedCompanies]); + + const questionTypeFilterOptions = useMemo(() => { + return questionTypes.map((questionType) => ({ + ...questionType, + checked: selectedQuestionTypes.includes(questionType.value), + })); + }, [selectedQuestionTypes]); + + const questionAgeFilterOptions = useMemo(() => { + return questionAges.map((questionAge) => ({ + ...questionAge, + checked: selectedQuestionAges.includes(questionAge.value), + })); + }, [selectedQuestionAges]); + + const locationFilterOptions = useMemo(() => { + return locations.map((location) => ({ + ...location, + checked: selectedLocations.includes(location.value), + })); + }, [selectedLocations]); + return ( -
-
- +
+
+ +
+
+
+

Filter by

+
+ { + if (checked) { + setSelectedCompanies((prev) => [...prev, optionValue]); + } else { + setSelectedCompanies((prev) => + prev.filter((company) => company !== optionValue), + ); + } + }} + /> + { + if (checked) { + setSelectedQuestionTypes((prev) => [...prev, optionValue]); + } else { + setSelectedQuestionTypes((prev) => + prev.filter((questionType) => questionType !== optionValue), + ); + } + }} + /> + { + if (checked) { + setSelectedQuestionAges((prev) => [...prev, optionValue]); + } else { + setSelectedQuestionAges((prev) => + prev.filter((questionAge) => questionAge !== optionValue), + ); + } + }} + /> + { + if (checked) { + setSelectedLocations((prev) => [...prev, optionValue]); + } else { + setSelectedLocations((prev) => + prev.filter((location) => location !== optionValue), + ); + } + }} + /> +
+
+
+
+
+ { + // eslint-disable-next-line no-console + console.log(data); + }} + /> + + +
+
+
); diff --git a/apps/portal/src/pages/resumes/index.tsx b/apps/portal/src/pages/resumes/index.tsx index fb0ca8b0..99891d5e 100644 --- a/apps/portal/src/pages/resumes/index.tsx +++ b/apps/portal/src/pages/resumes/index.tsx @@ -1,5 +1,7 @@ import clsx from 'clsx'; -import { Fragment, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useSession } from 'next-auth/react'; +import { Fragment, useEffect, useState } from 'react'; import { Disclosure, Menu, Transition } from '@headlessui/react'; import { ChevronDownIcon, @@ -11,6 +13,7 @@ import { Tabs, TextInput } from '@tih/ui'; import BrowseListItem from '~/components/resumes/browse/BrowseListItem'; import { + BROWSE_TABS_VALUES, EXPERIENCE, LOCATION, ROLES, @@ -19,6 +22,8 @@ import { } from '~/components/resumes/browse/constants'; import FilterPill from '~/components/resumes/browse/FilterPill'; +import type { Resume } from '~/types/resume'; + const filters = [ { id: 'roles', @@ -41,12 +46,57 @@ import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle'; import { trpc } from '~/utils/trpc'; export default function ResumeHomePage() { - const [tabsValue, setTabsValue] = useState('all'); + const { data } = useSession(); + const router = useRouter(); + const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL); const [searchValue, setSearchValue] = useState(''); - const resumesQuery = trpc.useQuery(['resumes.resume.list']); + const [resumes, setResumes] = useState(Array()); + + const allResumesQuery = trpc.useQuery(['resumes.resume.all'], { + enabled: tabsValue === BROWSE_TABS_VALUES.ALL, + }); + const starredResumesQuery = trpc.useQuery(['resumes.resume.browse.stars'], { + enabled: tabsValue === BROWSE_TABS_VALUES.STARRED, + }); + const myResumesQuery = trpc.useQuery(['resumes.resume.browse.my'], { + enabled: tabsValue === BROWSE_TABS_VALUES.MY, + }); + + useEffect(() => { + switch (tabsValue) { + case BROWSE_TABS_VALUES.ALL: { + setResumes(allResumesQuery.data ?? Array()); + break; + } + case BROWSE_TABS_VALUES.STARRED: { + setResumes(starredResumesQuery.data ?? Array()); + break; + } + case BROWSE_TABS_VALUES.MY: { + setResumes(myResumesQuery.data ?? Array()); + break; + } + default: { + setResumes(Array()); + } + } + }, [ + allResumesQuery.data, + starredResumesQuery.data, + myResumesQuery.data, + tabsValue, + ]); + + const onClickNew = () => { + if (data?.user?.id) { + router.push('/resumes/submit'); + } else { + // TODO: Handle non-logged in user behaviour + } + }; return ( -
+
@@ -64,15 +114,15 @@ export default function ResumeHomePage() { tabs={[ { label: 'All Resumes', - value: 'all', + value: BROWSE_TABS_VALUES.ALL, }, { label: 'Starred Resumes', - value: 'starred', + value: BROWSE_TABS_VALUES.STARRED, }, { label: 'My Resumes', - value: 'my', + value: BROWSE_TABS_VALUES.MY, }, ]} value={tabsValue} @@ -139,7 +189,8 @@ export default function ResumeHomePage() {
@@ -223,14 +274,19 @@ export default function ResumeHomePage() {
- {resumesQuery.isLoading ? ( + {allResumesQuery.isLoading || + starredResumesQuery.isLoading || + myResumesQuery.isLoading ? (
Loading...
) : (
    - {resumesQuery.data?.map((resumeObj) => ( + {resumes.map((resumeObj) => (
  • - +
  • ))}
diff --git a/apps/portal/src/pages/resumes/submit.tsx b/apps/portal/src/pages/resumes/submit.tsx index 54bb0e6d..b95a202a 100644 --- a/apps/portal/src/pages/resumes/submit.tsx +++ b/apps/portal/src/pages/resumes/submit.tsx @@ -1,10 +1,17 @@ +import clsx from 'clsx'; 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 { Button, Select, TextArea, TextInput } from '@tih/ui'; + +import { + EXPERIENCE, + LOCATION, + ROLES, +} from '~/components/resumes/browse/constants'; import { trpc } from '~/utils/trpc'; @@ -13,7 +20,7 @@ const TITLE_PLACEHOLDER = 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!`; const FILE_UPLOAD_ERROR = 'Please upload a PDF file that is less than 10MB.'; -const MAX_FILE_SIZE_LIMIT = 10485760; +const MAX_FILE_SIZE_LIMIT = 10000000; type IFormInput = { additionalInfo?: string; @@ -25,52 +32,6 @@ type IFormInput = { }; export default function SubmitResumeForm() { - // TODO: Use enums instead - const roleItems = [ - { - label: 'Frontend Engineer', - value: 'Frontend Engineer', - }, - { - label: 'Full-Stack Engineer', - value: 'Full-Stack Engineer', - }, - { - label: 'Backend Engineer', - value: 'Backend Engineer', - }, - ]; - - const experienceItems = [ - { - label: 'Fresh Graduate (0 - 1 years)', - value: 'Fresh Graduate (0 - 1 years)', - }, - { - label: 'Mid', - value: 'Mid', - }, - { - label: 'Senior', - value: 'Senior', - }, - ]; - - const locationItems = [ - { - label: 'United States', - value: 'United States', - }, - { - label: 'Singapore', - value: 'Singapore', - }, - { - label: 'India', - value: 'India', - }, - ]; - const resumeCreateMutation = trpc.useMutation('resumes.resume.user.create'); const router = useRouter(); @@ -126,7 +87,7 @@ export default function SubmitResumeForm() { Upload a resume -
+
@@ -139,6 +100,7 @@ export default function SubmitResumeForm() { errorMessage={errors?.title && 'Title cannot be empty!'} label="Title" placeholder={TITLE_PLACEHOLDER} + required={true} onChange={(val) => setValue('title', val)} />
@@ -146,7 +108,8 @@ export default function SubmitResumeForm() { setValue('experience', val)} />
@@ -163,27 +127,39 @@ export default function SubmitResumeForm() { {...register('location', { required: true })} label="Location" name="location" - options={locationItems} + options={LOCATION} + required={true} onChange={(val) => setValue('location', val)} />

Upload resume (PDF format) +

-
+
{resumeFile &&

{resumeFile.name}

}
-
+
-

or drag and drop

PDF up to 10MB

@@ -201,8 +176,7 @@ export default function SubmitResumeForm() { )}
- {/* TODO: Use TextInputArea instead */} - { + const resume: Resume = { + additionalInfo: rs.resume.additionalInfo, + createdAt: rs.resume.createdAt, + experience: rs.resume.experience, + id: rs.id, + location: rs.resume.location, + numComments: rs.resume._count.comments, + numStars: rs.resume._count.stars, + role: rs.resume.role, + title: rs.resume.title, + url: rs.resume.url, + user: rs.user.name!, + }; + return resume; + }); + }, + }) + .query('my', { + async resolve({ ctx }) { + const userId = ctx.session?.user?.id; + const resumesData = await ctx.prisma.resumesResume.findMany({ + include: { + _count: { + select: { + comments: true, + stars: true, + }, + }, + user: { + select: { + name: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + where: { + userId, + }, + }); + 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/server/router/resumes.ts b/apps/portal/src/server/router/resumes.ts index 2e7f9f9c..2125f446 100644 --- a/apps/portal/src/server/router/resumes.ts +++ b/apps/portal/src/server/router/resumes.ts @@ -2,7 +2,7 @@ import { createRouter } from './context'; import type { Resume } from '~/types/resume'; -export const resumesRouter = createRouter().query('list', { +export const resumesRouter = createRouter().query('all', { async resolve({ ctx }) { const resumesData = await ctx.prisma.resumesResume.findMany({ include: { diff --git a/apps/storybook/stories/checkbox-input.stories.tsx b/apps/storybook/stories/checkbox-input.stories.tsx new file mode 100644 index 00000000..637925f5 --- /dev/null +++ b/apps/storybook/stories/checkbox-input.stories.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import type { ComponentMeta } from '@storybook/react'; +import { CheckboxInput } from '@tih/ui'; + +export default { + argTypes: { + defaultValue: { + control: 'boolean', + }, + description: { + control: 'text', + }, + disabled: { + control: 'boolean', + }, + label: { + control: 'text', + }, + value: { + control: 'boolean', + }, + }, + component: CheckboxInput, + title: 'CheckboxInput', +} as ComponentMeta; + +export function Basic({ + defaultValue, + description, + disabled, + label, +}: Pick< + React.ComponentProps, + 'defaultValue' | 'description' | 'disabled' | 'label' +>) { + return ( + + ); +} + +Basic.args = { + description: 'I will be responsible for any mistakes', + disabled: false, + label: 'I have read the terms and conditions', +}; + +export function Controlled() { + const [value, setValue] = useState(true); + + return ( + { + setValue(newValue); + }} + /> + ); +} + +export function Disabled() { + return ( +
+ + + + +
+ ); +} + +export function ItemDescriptions() { + return ( + + ); +} diff --git a/apps/storybook/stories/checkbox-list.stories.tsx b/apps/storybook/stories/checkbox-list.stories.tsx new file mode 100644 index 00000000..1bfd9c00 --- /dev/null +++ b/apps/storybook/stories/checkbox-list.stories.tsx @@ -0,0 +1,259 @@ +import React, { useState } from 'react'; +import type { ComponentMeta } from '@storybook/react'; +import type { CheckboxListOrientation } from '@tih/ui'; +import { HorizontalDivider } from '@tih/ui'; +import { CheckboxInput, CheckboxList } from '@tih/ui'; + +const CheckboxListOrientations: ReadonlyArray = [ + 'horizontal', + 'vertical', +]; + +export default { + argTypes: { + description: { + control: 'text', + }, + label: { + control: 'text', + }, + orientation: { + control: { type: 'select' }, + options: CheckboxListOrientations, + }, + }, + component: CheckboxList, + title: 'CheckboxList', +} as ComponentMeta; + +export function Basic({ + description, + label, + orientation, +}: Pick< + React.ComponentProps, + 'description' | 'label' | 'orientation' +>) { + const items = [ + { + label: 'Apple', + name: 'apple', + value: true, + }, + { + label: 'Banana', + name: 'banana', + value: true, + }, + { + label: 'Orange', + name: 'orange', + value: false, + }, + ]; + + return ( + + {items.map(({ label: itemLabel, name, value: itemValue }) => ( + + ))} + + ); +} + +Basic.args = { + description: 'Selected fruits will be served after dinner', + label: 'Select your favorite fruits', + orientation: 'vertical', +}; + +export function Controlled() { + const items = [ + { + label: 'Apple', + value: 'apple', + }, + { + label: 'Banana', + value: 'banana', + }, + { + label: 'Orange', + value: 'orange', + }, + ]; + + const [values, setValues] = useState(new Set(['apple'])); + + return ( + + {items.map(({ label: itemLabel, value: itemValue }) => ( + { + if (newValue) { + setValues(new Set([...Array.from(values), itemValue])); + } else { + setValues( + new Set(Array.from(values).filter((v) => v !== itemValue)), + ); + } + }} + /> + ))} + + ); +} + +export function Disabled() { + const items = [ + { + description: 'A red fruit', + disabled: false, + label: 'Apple', + value: 'apple', + }, + { + description: 'A yellow fruit', + disabled: true, + label: 'Banana', + value: 'banana', + }, + { + description: 'An orange fruit', + disabled: false, + label: 'Orange', + value: 'orange', + }, + ]; + + const [values, setValues] = useState(new Set(['apple', 'banana'])); + + return ( +
+ + {items.map(({ disabled, label: itemLabel, value: itemValue }) => ( + { + if (newValue) { + setValues(new Set([...Array.from(values), itemValue])); + } else { + setValues( + new Set(Array.from(values).filter((v) => v !== itemValue)), + ); + } + }} + /> + ))} + +
+ ); +} + +export function ItemDescriptions() { + const items = [ + { + description: 'A red fruit', + label: 'Apple', + value: 'apple', + }, + { + description: 'A yellow fruit', + label: 'Banana', + value: 'banana', + }, + { + description: 'An orange fruit', + label: 'Orange', + value: 'orange', + }, + ]; + + const [values, setValues] = useState(new Set(['apple', 'banana'])); + + return ( +
+ + {items.map(({ description, label: itemLabel, value: itemValue }) => ( + { + if (newValue) { + setValues(new Set([...Array.from(values), itemValue])); + } else { + setValues( + new Set(Array.from(values).filter((v) => v !== itemValue)), + ); + } + }} + /> + ))} + +
+ ); +} + +export function Orientation() { + const items = [ + { + label: 'Apple', + name: 'apple', + value: true, + }, + { + label: 'Banana', + name: 'banana', + value: false, + }, + { + label: 'Orange', + name: 'orange', + value: true, + }, + ]; + + return ( +
+ + {items.map(({ label: itemLabel, name, value: itemValue }) => ( + + ))} + + + + {items.map(({ label: itemLabel, name, value: itemValue }) => ( + + ))} + +
+ ); +} diff --git a/apps/storybook/stories/radio-list.stories.tsx b/apps/storybook/stories/radio-list.stories.tsx new file mode 100644 index 00000000..91ca7b72 --- /dev/null +++ b/apps/storybook/stories/radio-list.stories.tsx @@ -0,0 +1,256 @@ +import React, { useState } from 'react'; +import type { ComponentMeta } from '@storybook/react'; +import type { RadioListOrientation } from '@tih/ui'; +import { HorizontalDivider } from '@tih/ui'; +import { RadioList } from '@tih/ui'; + +const RadioListOrientations: ReadonlyArray = [ + 'horizontal', + 'vertical', +]; + +export default { + argTypes: { + description: { + control: 'text', + }, + label: { + control: 'text', + }, + orientation: { + control: { type: 'select' }, + options: RadioListOrientations, + }, + }, + component: RadioList, + title: 'RadioList', +} as ComponentMeta; + +export function Basic({ + description, + label, + orientation, +}: Pick< + React.ComponentProps, + 'description' | 'label' | 'orientation' +>) { + const items = [ + { + label: 'Apple', + value: 'apple', + }, + { + label: 'Banana', + value: 'banana', + }, + { + label: 'Orange', + value: 'orange', + }, + ]; + + return ( + + {items.map(({ label: itemLabel, value }) => ( + + ))} + + ); +} + +Basic.args = { + description: 'Your favorite fruit', + label: 'Choose a fruit', + orientation: 'vertical', +}; + +export function Controlled() { + const items = [ + { + label: 'Apple', + value: 'apple', + }, + { + label: 'Banana', + value: 'banana', + }, + { + label: 'Orange', + value: 'orange', + }, + ]; + + const [value, setValue] = useState('apple'); + + return ( + setValue(newValue)}> + {items.map(({ label: itemLabel, value: itemValue }) => ( + + ))} + + ); +} + +export function Required() { + const items = [ + { + label: 'Apple', + value: 'apple', + }, + { + label: 'Banana', + value: 'banana', + }, + { + label: 'Orange', + value: 'orange', + }, + ]; + + const [value, setValue] = useState('apple'); + + return ( + setValue(newValue)}> + {items.map(({ label: itemLabel, value: itemValue }) => ( + + ))} + + ); +} + +export function Disabled() { + const items = [ + { + description: 'A red fruit', + disabled: false, + label: 'Apple', + value: 'apple', + }, + { + description: 'A yellow fruit', + disabled: true, + label: 'Banana', + value: 'banana', + }, + { + description: 'An orange fruit', + disabled: false, + label: 'Orange', + value: 'orange', + }, + ]; + + return ( +
+ + {items.map( + ({ description, label: itemLabel, value: itemValue, disabled }) => ( + + ), + )} + +
+ ); +} + +export function ItemDescriptions() { + const items = [ + { + description: 'A red fruit', + label: 'Apple', + value: 'apple', + }, + { + description: 'A yellow fruit', + label: 'Banana', + value: 'banana', + }, + { + description: 'An orange fruit', + label: 'Orange', + value: 'orange', + }, + ]; + + const [value, setValue] = useState('apple'); + + return ( + setValue(newValue)}> + {items.map(({ description, label: itemLabel, value: itemValue }) => ( + + ))} + + ); +} + +export function Orientation() { + const items = [ + { + label: 'Apple', + value: 'apple', + }, + { + label: 'Banana', + value: 'banana', + }, + { + label: 'Orange', + value: 'orange', + }, + ]; + + return ( +
+ + {items.map(({ label: itemLabel, value: itemValue }) => ( + + ))} + + + + {items.map(({ label: itemLabel, value: itemValue }) => ( + + ))} + +
+ ); +} diff --git a/apps/storybook/stories/select.stories.tsx b/apps/storybook/stories/select.stories.tsx index 145936ed..35c74362 100644 --- a/apps/storybook/stories/select.stories.tsx +++ b/apps/storybook/stories/select.stories.tsx @@ -177,6 +177,35 @@ export function Disabled() { ); } +export function Required() { + const [value, setValue] = useState('apple'); + + return ( +
+ { + onChange?.(event.target.checked, event); + } + : undefined + } + /> +
+
+ + {description && ( +

+ {description} +

+ )} +
+
+ ); +} diff --git a/packages/ui/src/CheckboxList/CheckboxList.tsx b/packages/ui/src/CheckboxList/CheckboxList.tsx new file mode 100644 index 00000000..cf462a22 --- /dev/null +++ b/packages/ui/src/CheckboxList/CheckboxList.tsx @@ -0,0 +1,46 @@ +import clsx from 'clsx'; +import { useId } from 'react'; + +import type CheckboxInput from '../CheckboxInput/CheckboxInput'; + +export type CheckboxListOrientation = 'horizontal' | 'vertical'; + +type Props = Readonly<{ + children: ReadonlyArray>; + description?: string; + isLabelHidden?: boolean; + label: string; + orientation?: CheckboxListOrientation; +}>; + +export default function CheckboxList({ + children, + description, + isLabelHidden, + label, + orientation = 'vertical', +}: Props) { + const labelId = useId(); + return ( +
+
+ + {description && ( +

{description}

+ )} +
+
+ {children} +
+
+ ); +} diff --git a/packages/ui/src/Collapsible/Collapsible.tsx b/packages/ui/src/Collapsible/Collapsible.tsx index 93e5ba10..211c54fd 100644 --- a/packages/ui/src/Collapsible/Collapsible.tsx +++ b/packages/ui/src/Collapsible/Collapsible.tsx @@ -23,7 +23,7 @@ export default function Collapsible({ children, defaultOpen, label }: Props) { /> {label} - + {children} diff --git a/packages/ui/src/RadioList/RadioList.tsx b/packages/ui/src/RadioList/RadioList.tsx new file mode 100644 index 00000000..6919c819 --- /dev/null +++ b/packages/ui/src/RadioList/RadioList.tsx @@ -0,0 +1,72 @@ +import clsx from 'clsx'; +import type { ChangeEvent } from 'react'; +import { useId } from 'react'; + +import { RadioListContext } from './RadioListContext'; +import RadioListItem from './RadioListItem'; + +export type RadioListOrientation = 'horizontal' | 'vertical'; + +type Props = Readonly<{ + children: ReadonlyArray>; + defaultValue?: T; + description?: string; + isLabelHidden?: boolean; + label: string; + name?: string; + onChange?: (value: T, event: ChangeEvent) => void; + orientation?: RadioListOrientation; + required?: boolean; + value?: T; +}>; + +RadioList.Item = RadioListItem; + +export default function RadioList({ + children, + defaultValue, + description, + isLabelHidden, + name, + orientation = 'vertical', + label, + required, + value, + onChange, +}: Props) { + const labelId = useId(); + return ( + +
+
+ + {description && ( +

{description}

+ )} +
+
+ {children} +
+
+
+ ); +} diff --git a/packages/ui/src/RadioList/RadioListContext.ts b/packages/ui/src/RadioList/RadioListContext.ts new file mode 100644 index 00000000..6058bc09 --- /dev/null +++ b/packages/ui/src/RadioList/RadioListContext.ts @@ -0,0 +1,20 @@ +import type { ChangeEvent } from 'react'; +import { createContext, useContext } from 'react'; + +type RadioListContextValue = { + defaultValue?: T; + name?: string; + onChange?: ( + value: T, + event: ChangeEvent, + ) => undefined | void; + value?: T; +}; + +export const RadioListContext = + createContext | null>(null); +export function useRadioListContext(): RadioListContextValue | null { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore TODO: Figure out how to type useContext with generics. + return useContext(RadioListContext); +} diff --git a/packages/ui/src/RadioList/RadioListItem.tsx b/packages/ui/src/RadioList/RadioListItem.tsx new file mode 100644 index 00000000..3f80f5f9 --- /dev/null +++ b/packages/ui/src/RadioList/RadioListItem.tsx @@ -0,0 +1,80 @@ +import clsx from 'clsx'; +import { useId } from 'react'; + +import { useRadioListContext } from './RadioListContext'; + +type Props = Readonly<{ + description?: string; + disabled?: boolean; + label: string; + value: T; +}>; + +export default function RadioListItem({ + description, + disabled = false, + label, + value, +}: Props) { + const id = useId(); + const descriptionId = useId(); + const context = useRadioListContext(); + + return ( +
+
+ { + context?.onChange?.(value, event); + } + : undefined + } + /> +
+
+ + {description && ( +

+ {description} +

+ )} +
+
+ ); +} diff --git a/packages/ui/src/Select/Select.tsx b/packages/ui/src/Select/Select.tsx index 67acf5a9..d3af3e00 100644 --- a/packages/ui/src/Select/Select.tsx +++ b/packages/ui/src/Select/Select.tsx @@ -43,6 +43,7 @@ function Select( label, isLabelHidden, options, + required, value, onChange, ...props @@ -58,6 +59,12 @@ function Select( className={clsx('mb-1 block text-sm font-medium text-slate-700')} htmlFor={id ?? undefined}> {label} + {required && ( + + )} )}