Merge branch 'main' into hongpo/questionbank-db-schema

pull/323/head
hpkoh 3 years ago
commit 0dae9fd7cb

@ -22,11 +22,13 @@
"@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",
"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 +39,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",

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

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

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

@ -97,15 +97,16 @@ 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 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)
@ -115,24 +116,26 @@ model ResumesResume {
model ResumesStar {
id String @id @default(cuid())
resumeId String
userId String
resumeId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
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())
resumeId String
userId String
resumeId 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[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum ResumesSection {
@ -145,15 +148,19 @@ enum ResumesSection {
model ResumesCommentVote {
id String @id @default(cuid())
commentId String
userId String
commentId 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)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, commentId])
}
// 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

@ -0,0 +1,52 @@
import { useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button, Spinner } from '@tih/ui';
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`;
type Props = Readonly<{
url: string;
}>;
export default function ResumePdf({ url }: Props) {
const [numPages, setNumPages] = useState(0);
const [pageNumber] = useState(1);
const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => {
setNumPages(pdf.numPages);
};
return (
<div>
<Document
className="h-[calc(100vh-17rem)] overflow-scroll"
file={url}
loading={<Spinner display="block" label="" size="lg" />}
onLoadSuccess={onPdfLoadSuccess}>
<Page pageNumber={pageNumber} />
</Document>
<div className="flex flex-row items-center justify-between p-4">
<Button
disabled={pageNumber === 1}
icon={ArrowLeftIcon}
isLabelHidden={true}
label="Previous"
variant="tertiary"
/>
<p className="text-md text-gray-600">
Page {pageNumber} of {numPages}
</p>
<Button
disabled={pageNumber === numPages}
icon={ArrowRightIcon}
isLabelHidden={true}
label="Next"
variant="tertiary"
/>
</div>
</div>
);
}

@ -1,3 +1,13 @@
import { Badge } from '@tih/ui';
export default function ResumeReviewsTitle() {
return <h1 className="text-center text-4xl font-bold">Resume Reviews</h1>;
return (
<div>
<h1 className="text-2xl font-bold">Resume Reviews</h1>
<Badge
label="Check out reviewed resumes or look for resumes to review"
variant="info"
/>
</div>
);
}

@ -0,0 +1,44 @@
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';
import type { Resume } from '~/types/resume';
type Props = Readonly<{
href: UrlObject | string;
resumeInfo: Resume;
}>;
export default function BrowseListItem({ href, resumeInfo }: Props) {
return (
<Link href={href}>
<div className="flex justify-between border-b border-slate-200 p-4">
<div>
{resumeInfo.title}
<div className="mt-2 flex items-center justify-start text-xs text-indigo-500">
{resumeInfo.role}
<div className="ml-6 rounded-md border border-indigo-500 p-1">
{resumeInfo.experience}
</div>
</div>
<div className="mt-2 flex justify-start text-xs text-slate-500">
<div className="flex gap-2 pr-8">
<ChatBubbleLeftIcon className="w-4" />
{resumeInfo.numComments} comments
</div>
<div className="flex gap-2">
<StarIcon className="w-4" />
{resumeInfo.numStars} stars
</div>
</div>
</div>
<div className="self-center text-sm text-slate-500">
{/* TODO: Replace hardcoded days ago with calculated days ago*/}
Uploaded 2 days ago by {resumeInfo.user}
</div>
<ChevronRightIcon className="w-8" />
</div>
</Link>
);
}

@ -0,0 +1,15 @@
type Props = Readonly<{
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
title: string;
}>;
export default function FilterPill({ title, onClick }: Props) {
return (
<button
className="rounded-xl border border-indigo-500 border-transparent bg-white px-2 py-1 text-xs font-medium text-indigo-500 focus:bg-indigo-500 focus:text-white"
type="button"
onClick={onClick}>
{title}
</button>
);
}

@ -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',
},
];

@ -0,0 +1,175 @@
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 { trpc } from '~/utils/trpc';
type CommentsFormProps = Readonly<{
resumeId: string;
setShowCommentsForm: (show: boolean) => void;
}>;
type IFormInput = {
education: string;
experience: string;
general: string;
projects: string;
skills: string;
};
type InputKeys = keyof IFormInput;
export default function CommentsForm({
resumeId,
setShowCommentsForm,
}: CommentsFormProps) {
const [showDialog, setShowDialog] = useState(false);
const {
register,
handleSubmit,
setValue,
formState: { isDirty },
} = useForm<IFormInput>({
defaultValues: {
education: '',
experience: '',
general: '',
projects: '',
skills: '',
},
});
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<IFormInput> = async (data) => {
return await reviewCreateMutation.mutate(
{
resumeId,
...data,
},
{
onSuccess: () => {
// Redirect back to comments section
setShowCommentsForm(false);
},
},
);
};
const onCancel = () => {
if (isDirty) {
setShowDialog(true);
} else {
setShowCommentsForm(false);
}
};
const onValueChange = (section: InputKeys, value: string) => {
setValue(section, value.trim(), { shouldDirty: true });
};
return (
<div className="h-[calc(100vh-13rem)] overflow-y-scroll">
<h2 className="text-xl font-semibold text-gray-800">Add your review</h2>
<p className="text-gray-800">
Please fill in at least one section to submit your review
</p>
<form
className="w-full space-y-8 divide-y divide-gray-200"
onSubmit={handleSubmit(onSubmit)}>
{/* TODO: Convert TextInput to TextArea */}
<div className="mt-4 space-y-4">
<TextInput
{...(register('general'), {})}
label="General"
placeholder="General comments about the resume"
type="text"
onChange={(value) => onValueChange('general', value)}
/>
<TextInput
{...(register('education'), {})}
label="Education"
placeholder="Comments about the Education section"
type="text"
onChange={(value) => onValueChange('education', value)}
/>
<TextInput
{...(register('experience'), {})}
label="Experience"
placeholder="Comments about the Experience section"
type="text"
onChange={(value) => onValueChange('experience', value)}
/>
<TextInput
{...(register('projects'), {})}
label="Projects"
placeholder="Comments about the Projects section"
type="text"
onChange={(value) => onValueChange('projects', value)}
/>
<TextInput
{...(register('skills'), {})}
label="Skills"
placeholder="Comments about the Skills section"
type="text"
onChange={(value) => onValueChange('skills', value)}
/>
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button
label="Cancel"
type="button"
variant="tertiary"
onClick={onCancel}
/>
<Button
disabled={!isDirty}
label="Submit"
type="submit"
variant="primary"
/>
</div>
</form>
<Dialog
isShown={showDialog}
primaryButton={
<Button
display="block"
label="OK"
variant="primary"
onClick={() => setShowCommentsForm(false)}
/>
}
secondaryButton={
<Button
display="block"
label="Cancel"
variant="tertiary"
onClick={() => setShowDialog(false)}
/>
}
title="Are you sure you want to leave?"
onClose={() => {
setShowDialog(false);
}}>
<div>Note that your review will not be saved!</div>
</Dialog>
</div>
);
}

@ -0,0 +1,54 @@
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';
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 { data: session } = useSession();
// Fetch the most updated comments to render
const commentsQuery = trpc.useQuery([
'resumes.reviews.list',
{ resumeId, section: tab },
]);
// TODO: Add loading prompt
return (
<div className="space-y-3">
<CommentsListButton setShowCommentsForm={setShowCommentsForm} />
<Tabs
label="comments"
tabs={COMMENTS_SECTIONS}
value={tab}
onChange={(value) => setTab(value)}
/>
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-scroll">
{commentsQuery.data?.map((comment) => {
return (
<Comment
key={comment.id}
comment={comment}
userId={session?.user?.id}
/>
);
})}
</div>
</div>
);
}

@ -0,0 +1,48 @@
import { signIn, useSession } from 'next-auth/react';
import { Button } from '@tih/ui';
type CommentsListButtonProps = {
setShowCommentsForm: (show: boolean) => void;
};
export default function CommentsListButton({
setShowCommentsForm,
}: CommentsListButtonProps) {
const { data: session, status } = useSession();
const isSessionLoading = status === 'loading';
// Don't render anything
if (isSessionLoading) {
return null;
}
// Not signed in
if (session == null) {
return (
<div className="flex justify-center">
<p>
<a
className="text-primary-800 hover:text-primary-500"
href="/api/auth/signin"
onClick={(event) => {
event.preventDefault();
signIn();
}}>
Sign in
</a>{' '}
to join discussion
</p>
</div>
);
}
// Signed in. Return Add review button
return (
<Button
display="block"
label="Add your review"
variant="tertiary"
onClick={() => setShowCommentsForm(true)}
/>
);
}

@ -0,0 +1,24 @@
import { useState } from 'react';
import CommentsForm from './CommentsForm';
import CommentsList from './CommentsList';
type ICommentsSectionProps = {
resumeId: string;
};
export default function CommentsSection({ resumeId }: ICommentsSectionProps) {
const [showCommentsForm, setShowCommentsForm] = useState(false);
return showCommentsForm ? (
<CommentsForm
resumeId={resumeId}
setShowCommentsForm={setShowCommentsForm}
/>
) : (
<CommentsList
resumeId={resumeId}
setShowCommentsForm={setShowCommentsForm}
/>
);
}

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

@ -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 (
<div className="flex w-full flex-row space-x-2 p-1 align-top">
{comment.user.image ? (
<img
alt={comment.user.name ?? 'Reviewer'}
className="mt-1 h-8 w-8 rounded-full"
src={comment.user.image!}
/>
) : (
<FaceSmileIcon className="h-8 w-8 rounded-full" />
)}
<div className="flex w-full flex-col space-y-1">
{/* Name and creation time */}
<div className="flex flex-row justify-between">
<div className="font-medium">
{comment.user.name ?? 'Reviewer ABC'}
</div>
<div className="text-xs text-gray-600">
{comment.createdAt.toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
})}
</div>
</div>
{/* Description */}
<div className="text-sm">{comment.description}</div>
{/* Upvote and edit */}
<div className="flex flex-row space-x-1 pt-1 align-middle">
{/* TODO: Implement upvote */}
<ArrowUpCircleIcon className="h-4 w-4 fill-gray-400" />
<div className="text-xs">{comment.numVotes}</div>
<ArrowDownCircleIcon className="h-4 w-4 fill-gray-400" />
{/* TODO: Implement edit */}
{isCommentOwner ? (
<div className="text-primary-800 hover:text-primary-400 px-1 text-xs">
Edit
</div>
) : null}
</div>
</div>
</div>
);
}

@ -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 <div> to allow customisation of owner comments
return isCommentOwner ? (
<div className="border-primary-300 float-right w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
{children}
</div>
) : (
<div className="border-primary-300 float-left w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
{children}
</div>
);
}

@ -0,0 +1,24 @@
import { ResumesSection } from '@prisma/client';
export const COMMENTS_SECTIONS = [
{
label: 'General',
value: ResumesSection.GENERAL,
},
{
label: 'Education',
value: ResumesSection.EDUCATION,
},
{
label: 'Experience',
value: ResumesSection.EXPERIENCE,
},
{
label: 'Projects',
value: ResumesSection.PROJECTS,
},
{
label: 'Skills',
value: ResumesSection.SKILLS,
},
];

@ -9,8 +9,8 @@ export default function ProfilePage() {
}
return (
<main className="flex-1 overflow-y-auto p-6 space-y-6">
<h1 className="font-bold text-4xl">Profile</h1>
<main className="flex-1 space-y-6 overflow-y-auto p-6">
<h1 className="text-4xl font-bold">Profile</h1>
{session?.user?.image && (
<img
alt={session?.user?.email ?? session?.user?.name ?? ''}

@ -0,0 +1,147 @@
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 = (
<Error statusCode={404} title="Requested resume does not exist." />
);
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 && <Spinner display="block" label="" size="lg" />}
{detailsQuery.isFetched && detailsQuery.data && (
<main className="flex-1 p-4">
<div className="flex flex-row md:space-x-8">
<h1 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
{detailsQuery.data.title}
</h1>
<button
className="isolate inline-flex max-h-10 items-center space-x-4 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:z-10 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
disabled={session?.user === null}
id="star-button"
type="button"
onClick={onStarButtonClick}>
<span className="relative inline-flex">
<StarIcon
aria-hidden="true"
className={clsx(
detailsQuery.data?.stars.length
? 'text-orange-400'
: 'text-gray-400',
'-ml-1 mr-2 h-5 w-5',
)}
id="star-icon"
/>
Star
</span>
<span className="relative -ml-px inline-flex">
{detailsQuery.data._count.stars}
</span>
</button>
</div>
<div className="flex flex-col pt-1 sm:mt-0 sm:flex-row sm:flex-wrap sm:space-x-8">
<div className="mt-2 flex items-center text-sm text-gray-500">
<BriefcaseIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{detailsQuery.data.role}
</div>
<div className="flex items-center pt-2 text-sm text-gray-500">
<MapPinIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{detailsQuery.data.location}
</div>
<div className="flex items-center pt-2 text-sm text-gray-500">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{detailsQuery.data.experience}
</div>
<div className="flex items-center pt-2 text-sm text-gray-500">
<CalendarIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{`Uploaded ${formatDistanceToNow(
new Date(detailsQuery.data.createdAt),
{ addSuffix: true },
)} by ${detailsQuery.data.user.name}`}
</div>
</div>
{detailsQuery.data.additionalInfo && (
<div className="flex items-center pt-2 text-sm text-gray-500">
<InformationCircleIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{detailsQuery.data.additionalInfo}
</div>
)}
<div className="flex h-full w-full flex-row py-4">
<div className="w-1/2">
<ResumePdf url={detailsQuery.data.url} />
</div>
<div className="mx-8 w-1/2">
<CommentsSection resumeId={resumeId as string} />
</div>
</div>
</main>
)}
</>
);
}

@ -1,11 +1,244 @@
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 (
<main className="flex-1 overflow-y-auto">
<div className="flex h-full items-center justify-center">
<main className="h-full flex-1 overflow-y-auto">
<div className="ml-4 py-4">
<ResumeReviewsTitle />
</div>
<div className="mt-4 flex items-start">
<div className="w-screen sm:px-4 md:px-8">
<div className="grid grid-cols-12">
<div className="col-span-2 self-end">
<h1 className="mb-4 tracking-tight text-gray-900">Filters</h1>
</div>
<div className="col-span-10">
<div className="border-grey-200 grid grid-cols-12 items-center gap-4 border-b pb-2">
<div className="col-span-7">
<Tabs
label="Resume Browse Tabs"
tabs={[
{
label: 'All Resumes',
value: 'all',
},
{
label: 'Starred Resumes',
value: 'starred',
},
{
label: 'My Resumes',
value: 'my',
},
]}
value={tabsValue}
onChange={setTabsValue}
/>
</div>
<div className="col-span-3 self-end">
<form>
<TextInput
label=""
placeholder="Search Resumes"
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
type="text"
value={searchValue}
onChange={setSearchValue}
/>
</form>
</div>
<div className="col-span-1 justify-self-center">
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="group inline-flex justify-center text-sm font-medium text-gray-700 hover:text-gray-900">
Sort
<ChevronDownIcon
aria-hidden="true"
className="-mr-1 ml-1 h-5 w-5 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
/>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items className="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-white shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{SORT_OPTIONS.map((option) => (
<Menu.Item key={option.name}>
{({ active }) => (
<a
className={clsx(
option.current
? 'font-medium text-gray-900'
: 'text-gray-500',
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm',
)}
href={option.href}>
{option.name}
</a>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
<div className="col-span-1">
<button
className="rounded-md bg-indigo-500 py-1 px-3 text-sm text-white"
type="button">
New
</button>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-12">
<div className="col-span-2">
<div className="w-100 pt-4 sm:pr-0 md:pr-4">
<form>
<h3 className="sr-only">Categories</h3>
<ul
className="flex flex-wrap justify-start gap-4 pb-6 text-sm font-medium text-gray-900"
role="list">
{TOP_HITS.map((category) => (
<li key={category.name}>
{/* TODO: Replace onClick with filtering function */}
<FilterPill
title={category.name}
onClick={() => true}
/>
</li>
))}
</ul>
{filters.map((section) => (
<Disclosure
key={section.id}
as="div"
className="border-b border-gray-200 py-6">
{({ open }) => (
<>
<h3 className="-my-3 flow-root">
<Disclosure.Button className="flex w-full items-center justify-between py-3 text-sm text-gray-400 hover:text-gray-500">
<span className="font-medium text-gray-900">
{section.name}
</span>
<span className="ml-6 flex items-center">
{open ? (
<MinusIcon
aria-hidden="true"
className="h-5 w-5"
/>
) : (
<PlusIcon
aria-hidden="true"
className="h-5 w-5"
/>
)}
</span>
</Disclosure.Button>
</h3>
<Disclosure.Panel className="pt-6">
<div className="space-y-4">
{section.options.map((option, optionIdx) => (
<div
key={option.value}
className="flex items-center">
<input
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
defaultChecked={option.checked}
defaultValue={option.value}
id={`filter-${section.id}-${optionIdx}`}
name={`${section.id}[]`}
type="checkbox"
/>
<label
className="ml-3 text-sm text-gray-600"
htmlFor={`filter-${section.id}-${optionIdx}`}>
{option.label}
</label>
</div>
))}
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
))}
</form>
</div>
</div>
{resumesQuery.isLoading ? (
<div>Loading...</div>
) : (
<div className="col-span-10 pr-8">
<ul role="list">
{resumesQuery.data?.map((resumeObj) => (
<li key={resumeObj.id}>
<BrowseListItem href="#" resumeInfo={resumeObj} />
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</main>
);
}

@ -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. Im 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<File | null>();
const [invalidFileUploadError, setInvalidFileUploadError] = useState<
string | null
@ -81,10 +87,11 @@ export default function SubmitResumeForm() {
formState: { errors },
} = useForm<IFormInput>();
// TODO: Add Create resume mutation
const onSubmit: SubmitHandler<IFormInput> = (data) => {
alert(JSON.stringify(data));
onClickReset();
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
await resumeCreateMutation.mutate({
...data,
});
router.push('/resumes');
};
const onUploadFile = (event: React.ChangeEvent<HTMLInputElement>) => {
@ -196,10 +203,10 @@ export default function SubmitResumeForm() {
<div className="mb-4">
{/* TODO: Use TextInputArea instead */}
<TextInput
{...register('additionalInformation')}
{...register('additionalInfo')}
label="Additional Information"
placeholder={ADDITIONAL_INFO_PLACEHOLDER}
onChange={(val) => setValue('additionalInformation', val)}
onChange={(val) => setValue('additionalInfo', val)}
/>
</div>
<div className="mt-4 flex justify-end gap-4">

@ -2,6 +2,11 @@ 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';
import { resumesReviewsUserRouter } from './resumes-reviews-user-router';
import { todosRouter } from './todos';
import { todosUserRouter } from './todos-user-router';
@ -12,7 +17,12 @@ 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.', resumesRouter)
.merge('resumes.details.', resumesDetailsRouter)
.merge('resumes.resume.user.', resumesResumeUserRouter)
.merge('resumes.reviews.', resumeReviewsRouter)
.merge('resumes.reviews.user.', resumesReviewsUserRouter);
// Export type definition of API
export type AppRouter = typeof appRouter;

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

@ -0,0 +1,28 @@
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,
},
});
},
},
);

@ -0,0 +1,72 @@
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, 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
const comments = 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,
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;
});
},
});

@ -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<IResumeCommentInput> = [
{ 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,
});
},
},
);

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

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

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

@ -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<typeof Collapsible>;
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 (
<div className="divide-y divide-slate-200">
<div className="py-2">
<Collapsible label="What is your name?">
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.
</Collapsible>
</div>
<div className="py-2">
<Collapsible label="What is your age?">
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.
</Collapsible>
</div>
</div>
);
}

@ -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<typeof HorizontalDivider>;
export function Basic() {
return (
<div>
<p className="text-sm">
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.
</p>
<HorizontalDivider />
<p className="text-sm">
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.
</p>
</div>
);
}

@ -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<typeof Pagination>;
// eslint-disable-next-line @typescript-eslint/no-empty-function
function emptyFunction() {}
export function Basic({
current,
end,
start,
pagePadding,
}: Pick<
React.ComponentProps<typeof Pagination>,
'current' | 'end' | 'pagePadding' | 'start'
>) {
return (
<div className="space-y-4">
<Pagination
current={current}
end={end}
label="Pagination"
pagePadding={pagePadding}
start={start}
onSelect={emptyFunction}
/>
</div>
);
}
Basic.args = {
current: 3,
end: 10,
pagePadding: 1,
start: 1,
};
export function Interaction() {
const [currentPage, setCurrentPage] = useState(5);
return (
<div className="space-y-4">
<div>
<Pagination
current={currentPage}
end={10}
label="Pagination"
start={1}
onSelect={(page) => setCurrentPage(page)}
/>
</div>
</div>
);
}
export function PageRanges() {
return (
<div className="space-y-4">
<div>
<Pagination
current={5}
end={10}
label="Pagination"
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={1}
end={10}
label="Pagination"
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={2}
end={10}
label="Pagination"
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={9}
end={10}
label="Pagination"
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={10}
end={10}
label="Pagination"
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={1}
end={1}
label="Pagination"
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={1}
end={1}
label="Pagination"
pagePadding={2}
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={1}
end={2}
label="Pagination"
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={2}
end={2}
label="Pagination"
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={1}
end={3}
label="Pagination"
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={2}
end={3}
label="Pagination"
start={1}
onSelect={emptyFunction}
/>
</div>
</div>
);
}
export function PagePadding() {
return (
<div className="space-y-4">
<div>
<Pagination
current={5}
end={10}
label="Pagination"
pagePadding={2}
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={5}
end={20}
label="Pagination"
pagePadding={2}
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={10}
end={20}
label="Pagination"
pagePadding={2}
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={10}
end={20}
label="Pagination"
pagePadding={3}
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={5}
end={10}
label="Pagination"
pagePadding={3}
start={1}
onSelect={emptyFunction}
/>
</div>
<div>
<Pagination
current={1}
end={1}
label="Pagination"
pagePadding={2}
start={1}
onSelect={emptyFunction}
/>
</div>
</div>
);
}

@ -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<TextAreaResize> = [
'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<typeof TextArea>;
export const Basic = {
args: {
label: 'Comment',
placeholder: 'Type your comment here',
},
};
export function HiddenLabel() {
const [value, setValue] = useState('');
return (
<TextArea
isLabelHidden={true}
label="Name"
placeholder="John Doe"
value={value}
onChange={setValue}
/>
);
}
export function Disabled() {
return (
<TextArea
disabled={true}
label="Comment"
placeholder="You can't type here, it's disabled."
/>
);
}
export function Required() {
return (
<TextArea label="Required input" placeholder="John Doe" required={true} />
);
}
export function Error() {
const [value, setValue] = useState('1234');
return (
<TextArea
errorMessage={value.length < 6 ? 'Your comment is too short' : undefined}
label="Leave a reply"
value={value}
onChange={setValue}
/>
);
}
export function ReadOnly() {
return (
<TextArea
label="Leave a reply"
readOnly={true}
value="But you can't change this"
/>
);
}
export function Rows() {
return (
<div className="space-y-4">
<TextArea label="Reply" placeholder="Leave a reply" rows={4} />
<TextArea label="Reply" placeholder="Leave a reply" rows={10} />
</div>
);
}
export function Resize() {
return (
<div className="space-y-4">
<TextArea
label="Vertical resizing"
placeholder="Leave a reply"
resize="vertical"
/>
<TextArea
label="Horizontal resizing"
placeholder="Leave a reply"
resize="horizontal"
/>
<TextArea label="No resizing" placeholder="Leave a reply" resize="none" />
<TextArea
label="Both resizing"
placeholder="Leave a reply"
resize="both"
/>
</div>
);
}

@ -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: {
@ -30,6 +30,9 @@ export default {
placeholder: {
control: 'text',
},
required: {
control: 'boolean',
},
type: {
control: 'text',
},
@ -67,7 +70,8 @@ export function Email() {
<TextInput
label="Email"
placeholder="john.doe@email.com"
startIcon={EnvelopeIcon}
startAddOn={EnvelopeIcon}
startAddOnType="icon"
type="email"
value={value}
onChange={setValue}
@ -91,7 +95,8 @@ export function Icon() {
<TextInput
label="Account number"
placeholder="000-00-0000"
startIcon={QuestionMarkCircleIcon}
startAddOn={QuestionMarkCircleIcon}
startAddOnType="icon"
type="text"
value={value}
onChange={setValue}
@ -101,11 +106,54 @@ export function Icon() {
}
export function Disabled() {
return (
<div className="space-y-4">
<TextInput
disabled={true}
label="Disabled input"
placeholder="John Doe"
type="text"
/>
<TextInput
disabled={true}
endAddOn={
<Select
borderStyle="borderless"
isLabelHidden={true}
label="Currency"
options={[
{
label: 'USD',
value: 'USD',
},
{
label: 'SGD',
value: 'SGD',
},
{
label: 'EUR',
value: 'EUR',
},
]}
/>
}
endAddOnType="element"
label="Price"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="text"
/>
</div>
);
}
export function Required() {
return (
<TextInput
disabled={true}
label="Disabled input"
label="Required input"
placeholder="John Doe"
required={true}
type="text"
/>
);
@ -119,11 +167,98 @@ export function Error() {
errorMessage={
value.length < 6 ? 'Password must be at least 6 characters' : undefined
}
label="Email"
startIcon={KeyIcon}
label="Password"
startAddOn={KeyIcon}
startAddOnType="icon"
type="password"
value={value}
onChange={setValue}
/>
);
}
export function AddOns() {
return (
<div className="space-y-4">
<TextInput
label="Price"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="text"
/>
<TextInput
endAddOn="USD"
endAddOnType="label"
label="Price"
placeholder="0.00"
type="text"
/>
<TextInput
endAddOn="USD"
endAddOnType="label"
label="Price"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="text"
/>
<TextInput
label="Phone Number"
placeholder="+1 (123) 456-7890"
startAddOn={
<Select
borderStyle="borderless"
isLabelHidden={true}
label="country"
options={[
{
label: 'US',
value: 'US',
},
{
label: 'SG',
value: 'SG',
},
{
label: 'JP',
value: 'JP',
},
]}
/>
}
startAddOnType="element"
type="text"
/>
<TextInput
endAddOn={
<Select
borderStyle="borderless"
isLabelHidden={true}
label="Currency"
options={[
{
label: 'USD',
value: 'USD',
},
{
label: 'SGD',
value: 'SGD',
},
{
label: 'EUR',
value: 'EUR',
},
]}
/>
}
endAddOnType="element"
label="Price"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="text"
/>
</div>
);
}

@ -0,0 +1,33 @@
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;
defaultOpen?: boolean;
label: string;
}>;
export default function Collapsible({ children, defaultOpen, label }: Props) {
return (
<Disclosure defaultOpen={defaultOpen}>
{({ open }) => (
<>
<Disclosure.Button className="text-primary-900 hover:bg-primary-100 focus-visible:ring-primary-500 -mx-2.5 box-content flex w-full justify-between rounded-lg px-2.5 py-2 text-left text-sm font-medium focus:outline-none focus-visible:ring focus-visible:ring-opacity-75">
<ChevronDownIcon
className={clsx(
'text-primary-500 mr-1 -ml-1 h-5 w-5',
open && 'rotate-180 transform',
)}
/>
<span className="flex-1">{label}</span>
</Disclosure.Button>
<Disclosure.Panel className="pt-1 pb-2 text-sm text-gray-500">
{children}
</Disclosure.Panel>
</>
)}
</Disclosure>
);
}

@ -0,0 +1,14 @@
import clsx from 'clsx';
type Props = Readonly<{
className?: string;
}>;
export default function HorizontalDivider({ className }: Props) {
return (
<hr
aria-hidden={true}
className={clsx('my-2 h-0 border-t border-slate-200', className)}
/>
);
}

@ -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<HTMLElement>) => void;
pagePadding?: number;
start: number;
}>;
function PaginationPage({
isCurrent = false,
label,
onClick,
}: Readonly<{
isCurrent?: boolean;
label: number;
onClick: (event: React.MouseEvent<HTMLElement>) => void;
}>) {
return (
<button
aria-current={isCurrent}
className={clsx(
'focus:ring-primary-500 focus:border-primary-500 relative inline-flex items-center border px-4 py-2 text-sm font-medium focus:z-20 focus:outline-none focus:ring-1',
isCurrent
? 'border-primary-500 bg-primary-50 text-primary-600 z-10'
: 'border-slate-300 bg-white text-slate-500 hover:bg-slate-50',
)}
disabled={isCurrent}
type="button"
onClick={onClick}>
{label}
</button>
);
}
function PaginationEllipsis() {
return (
<span className="relative inline-flex items-center border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700">
...
</span>
);
}
export default function Pagination({
current,
end,
label,
onSelect,
pagePadding = 1,
start = 1,
}: Props) {
const pageNumberSet = new Set();
const pageNumberList: Array<number | string> = [];
const elements: Array<ReactElement> = [];
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(
<PaginationPage
isCurrent={current === page}
label={page}
onClick={(event) => {
onSelect(page, event);
}}
/>,
);
}
}
for (let i = start; i <= start + pagePadding; i++) {
addPage(i);
}
if (lastAddedPage < current - pagePadding) {
elements.push(<PaginationEllipsis />);
}
for (let i = current - pagePadding; i <= current + pagePadding; i++) {
addPage(i);
}
if (lastAddedPage < end - pagePadding) {
elements.push(<PaginationEllipsis />);
}
for (let i = end - pagePadding; i <= end; i++) {
addPage(i);
}
const isPrevButtonDisabled = current === start;
const isNextButtonDisabled = current === end;
return (
<nav
aria-label={label}
className="isolate inline-flex -space-x-px rounded-md shadow-sm">
<button
aria-label="Previous"
className={clsx(
'relative inline-flex items-center rounded-l-md border border-slate-300 px-2 py-2 text-sm font-medium focus:z-20',
isPrevButtonDisabled
? 'text-slate-300'
: 'focus:ring-primary-500 focus:border-primary-500 bg-white text-slate-500 hover:bg-slate-50 focus:outline-none focus:ring-1',
)}
disabled={isPrevButtonDisabled}
type="button"
onClick={(event) => {
onSelect(current - 1, event);
}}>
<ChevronLeftIcon aria-hidden="true" className="h-5 w-5" />
</button>
{elements}
<button
aria-label="Next"
className={clsx(
'relative inline-flex items-center rounded-r-md border border-slate-300 px-2 py-2 text-sm font-medium focus:z-20',
isNextButtonDisabled
? 'text-slate-300'
: 'focus:ring-primary-500 focus:border-primary-500 bg-white text-slate-500 hover:bg-slate-50 focus:outline-none focus:ring-1',
)}
disabled={isNextButtonDisabled}
type="button"
onClick={(event) => {
onSelect(current + 1, event);
}}>
<ChevronRightIcon aria-hidden="true" className="h-5 w-5" />
</button>
</nav>
);
}

@ -14,8 +14,10 @@ export type SelectItem<T> = Readonly<{
}>;
export type SelectDisplay = 'block' | 'inline';
export type SelectBorderStyle = 'bordered' | 'borderless';
type Props<T> = Readonly<{
borderStyle?: SelectBorderStyle;
defaultValue?: T;
display?: SelectDisplay;
isLabelHidden?: boolean;
@ -27,8 +29,14 @@ type Props<T> = Readonly<{
}> &
Readonly<Attributes>;
const borderClasses: Record<SelectBorderStyle, string> = {
bordered: 'border-slate-300',
borderless: 'border-transparent bg-transparent',
};
function Select<T>(
{
borderStyle = 'bordered',
defaultValue,
display,
disabled,
@ -45,20 +53,20 @@ function Select<T>(
return (
<div>
<label
className={clsx(
'mb-1 block text-sm font-medium text-slate-700',
isLabelHidden && 'sr-only',
)}
htmlFor={id ?? undefined}>
{label}
</label>
{!isLabelHidden && (
<label
className={clsx('mb-1 block text-sm font-medium text-slate-700')}
htmlFor={id ?? undefined}>
{label}
</label>
)}
<select
ref={ref}
aria-label={isLabelHidden ? label : undefined}
className={clsx(
display === 'block' && 'block w-full',
'focus:border-primary-500 focus:ring-primary-500 rounded-md border-slate-300 py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm',
'focus:border-primary-500 focus:ring-primary-500 rounded-md py-2 pl-3 pr-8 text-base focus:outline-none sm:text-sm',
borderClasses[borderStyle],
disabled && 'bg-slate-100',
)}
defaultValue={defaultValue != null ? String(defaultValue) : undefined}

@ -19,46 +19,39 @@ export default function Tabs<T>({ label, tabs, value, onChange }: Props<T>) {
return (
<div className="w-full">
<div role="tablist">
<div className="border-b border-slate-200">
<nav aria-label={label} className="-mb-px flex space-x-4">
{tabs.map((tab) => {
const isSelected = tab.value === value;
const commonProps = {
'aria-label': tab.label,
'aria-selected': isSelected,
children: tab.label,
className: clsx(
isSelected
? 'border-primary-500 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm',
),
onClick:
onChange != null ? () => onChange(tab.value) : undefined,
role: 'tab',
};
if (tab.href != null) {
// TODO: Allow passing in of Link component.
return (
<Link
key={String(tab.value)}
href={tab.href}
{...commonProps}
/>
);
}
<nav aria-label={label} className="flex space-x-2">
{tabs.map((tab) => {
const isSelected = tab.value === value;
const commonProps = {
'aria-label': tab.label,
'aria-selected': isSelected,
children: tab.label,
className: clsx(
isSelected
? 'bg-indigo-100 text-indigo-700'
: 'hover:bg-slate-100 text-slate-500 hover:text-slate-700',
'px-3 py-2 font-medium text-sm rounded-md',
),
onClick: onChange != null ? () => onChange(tab.value) : undefined,
role: 'tab',
};
if (tab.href != null) {
// TODO: Allow passing in of Link component.
return (
<button
<Link
key={String(tab.value)}
type="button"
href={tab.href}
{...commonProps}
/>
);
})}
</nav>
</div>
}
return (
<button key={String(tab.value)} type="button" {...commonProps} />
);
})}
</nav>
</div>
</div>
);

@ -0,0 +1,141 @@
import clsx from 'clsx';
import type {
ChangeEvent,
FocusEvent,
ForwardedRef,
TextareaHTMLAttributes,
} from 'react';
import React, { forwardRef, useId } from 'react';
type Attributes = Pick<
TextareaHTMLAttributes<HTMLTextAreaElement>,
| 'autoComplete'
| 'autoFocus'
| 'disabled'
| 'maxLength'
| 'minLength'
| 'name'
| 'onBlur'
| 'onFocus'
| 'placeholder'
| 'readOnly'
| 'required'
| 'rows'
>;
export type TextAreaResize = 'both' | 'horizontal' | 'none' | 'vertical';
type Props = Readonly<{
defaultValue?: string;
errorMessage?: React.ReactNode;
id?: string;
isLabelHidden?: boolean;
label: string;
onBlur?: (event: FocusEvent<HTMLTextAreaElement>) => void;
onChange?: (value: string, event: ChangeEvent<HTMLTextAreaElement>) => void;
resize?: TextAreaResize;
value?: string;
}> &
Readonly<Attributes>;
type State = 'error' | 'normal';
const stateClasses: Record<
State,
Readonly<{
textArea: string;
}>
> = {
error: {
textArea:
'border-danger-300 focus:ring-danger-500 focus:border-danger-500 text-danger-900 placeholder-danger-300',
},
normal: {
textArea:
'border-slate-300 focus:border-primary-500 focus:ring-primary-500 placeholder:text-slate-400',
},
};
const resizeClasses: Record<TextAreaResize, string> = {
both: 'resize',
horizontal: 'resize-x',
none: 'resize-none',
vertical: 'resize-y',
};
function TextArea(
{
defaultValue,
disabled,
errorMessage,
id: idParam,
isLabelHidden,
label,
resize = 'vertical',
required,
value,
onChange,
...props
}: Props,
ref: ForwardedRef<HTMLTextAreaElement>,
) {
const hasError = errorMessage != null;
const generatedId = useId();
const id = idParam ?? generatedId;
const errorId = useId();
const state: State = hasError ? 'error' : 'normal';
return (
<div>
<label
className={clsx(
isLabelHidden
? 'sr-only'
: 'mb-1 block text-sm font-medium text-gray-700',
)}
htmlFor={id}>
{label}
{required && (
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
)}
</label>
<div>
<textarea
ref={ref}
aria-describedby={hasError ? errorId : undefined}
aria-invalid={hasError ? true : undefined}
className={clsx(
'block w-full rounded-md sm:text-sm',
stateClasses[state].textArea,
disabled && 'bg-slate-100',
resizeClasses[resize],
)}
defaultValue={defaultValue}
disabled={disabled}
id={id}
name="comment"
required={required}
value={value != null ? value : undefined}
onChange={(event) => {
if (!onChange) {
return;
}
onChange(event.target.value, event);
}}
{...props}
/>
</div>
{errorMessage && (
<p className="text-danger-600 mt-2 text-sm" id={errorId}>
{errorMessage}
</p>
)}
</div>
);
}
export default forwardRef(TextArea);

@ -24,7 +24,43 @@ type Attributes = Pick<
| 'type'
>;
type Props = Readonly<{
type StartAddOnProps =
| Readonly<{
startAddOn: React.ComponentType<React.ComponentProps<'svg'>>;
startAddOnType: 'icon';
}>
| Readonly<{
startAddOn: React.ReactNode;
startAddOnType: 'element';
}>
| Readonly<{
startAddOn: string;
startAddOnType: 'label';
}>
| Readonly<{
startAddOn?: undefined;
startAddOnType?: undefined;
}>;
type EndAddOnProps =
| Readonly<{
endAddOn: React.ComponentType<React.ComponentProps<'svg'>>;
endAddOnType: 'icon';
}>
| Readonly<{
endAddOn: React.ReactNode;
endAddOnType: 'element';
}>
| Readonly<{
endAddOn: string;
endAddOnType: 'label';
}>
| Readonly<{
endAddOn?: undefined;
endAddOnType?: undefined;
}>;
type BaseProps = Readonly<{
defaultValue?: string;
endIcon?: React.ComponentType<React.ComponentProps<'svg'>>;
errorMessage?: React.ReactNode;
@ -33,30 +69,46 @@ type Props = Readonly<{
label: string;
onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
onChange?: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
startIcon?: React.ComponentType<React.ComponentProps<'svg'>>;
value?: string;
}> &
Readonly<Attributes>;
type Props = BaseProps & EndAddOnProps & StartAddOnProps;
type State = 'error' | 'normal';
const stateClasses: Record<State, string> = {
error:
'border-danger-300 text-danger-900 placeholder-danger-300 focus:outline-none focus:ring-danger-500 focus:border-danger-500',
normal:
'placeholder:text-slate-400 focus:ring-primary-500 focus:border-primary-500 border-slate-300',
const stateClasses: Record<
State,
Readonly<{
container: string;
input: string;
}>
> = {
error: {
container:
'border-danger-300 focus-within:outline-none focus-within:ring-danger-500 focus-within:border-danger-500',
input: 'text-danger-900 placeholder-danger-300',
},
normal: {
container:
'focus-within:ring-primary-500 focus-within:border-primary-500 border-slate-300',
input: 'placeholder:text-slate-400',
},
};
function TextInput(
{
defaultValue,
disabled,
endIcon: EndIcon,
endAddOn,
endAddOnType,
errorMessage,
id: idParam,
isLabelHidden = false,
label,
startIcon: StartIcon,
required,
startAddOn,
startAddOnType,
type = 'text',
value,
onChange,
@ -69,6 +121,7 @@ function TextInput(
const id = idParam ?? generatedId;
const errorId = useId();
const state: State = hasError ? 'error' : 'normal';
const { input: inputClass, container: containerClass } = stateClasses[state];
return (
<div>
@ -76,31 +129,63 @@ function TextInput(
className={clsx(
isLabelHidden
? 'sr-only'
: 'block text-sm font-medium text-slate-700',
: 'mb-1 block text-sm font-medium text-slate-700',
)}
htmlFor={id}>
{label}
</label>
<div className="relative mt-1">
{StartIcon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<StartIcon aria-hidden="true" className="h-5 w-5 text-slate-400" />
</div>
{required && (
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
)}
</label>
<div
className={clsx(
'flex w-full overflow-hidden rounded-md border focus-within:ring-1 sm:text-sm',
disabled && 'pointer-events-none select-none bg-slate-100',
containerClass,
)}>
{(() => {
if (startAddOnType == null) {
return;
}
switch (startAddOnType) {
case 'label':
return (
<div className="pointer-events-none flex items-center pl-3 text-slate-500">
{startAddOn}
</div>
);
case 'icon': {
const StartAddOn = startAddOn;
return (
<div className="pointer-events-none flex items-center pl-3">
<StartAddOn
aria-hidden="true"
className="h-5 w-5 text-slate-400"
/>
</div>
);
}
case 'element':
return startAddOn;
}
})()}
<input
ref={ref}
aria-describedby={hasError ? errorId : undefined}
aria-invalid={hasError ? true : undefined}
className={clsx(
'block w-full rounded-md sm:text-sm',
StartIcon && 'pl-10',
EndIcon && 'pr-10',
stateClasses[state],
disabled && 'bg-slate-100',
'flex-1 border-none focus:outline-none focus:ring-0 sm:text-sm',
inputClass,
disabled && 'bg-transparent',
)}
defaultValue={defaultValue}
disabled={disabled}
id={id}
required={required}
type={type}
value={value != null ? value : undefined}
onChange={(event) => {
@ -112,11 +197,33 @@ function TextInput(
}}
{...props}
/>
{EndIcon && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<EndIcon aria-hidden="true" className="h-5 w-5 text-slate-400" />
</div>
)}
{(() => {
if (endAddOnType == null) {
return;
}
switch (endAddOnType) {
case 'label':
return (
<div className="pointer-events-none flex items-center pr-3 text-slate-500">
{endAddOn}
</div>
);
case 'icon': {
const EndAddOn = endAddOn;
return (
<div className="pointer-events-none flex items-center pr-3">
<EndAddOn
aria-hidden="true"
className="h-5 w-5 text-slate-400"
/>
</div>
);
}
case 'element':
return endAddOn;
}
})()}
</div>
{errorMessage && (
<p className="text-danger-600 mt-2 text-sm" id={errorId}>

@ -7,12 +7,21 @@ 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';
// DropdownMenu
export * from './DropdownMenu/DropdownMenu';
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';
@ -25,6 +34,9 @@ export { default as Spinner } from './Spinner/Spinner';
// Tabs
export * from './Tabs/Tabs';
export { default as Tabs } from './Tabs/Tabs';
// TextArea
export * from './TextArea/TextArea';
export { default as TextArea } from './TextArea/TextArea';
// TextInput
export * from './TextInput/TextInput';
export { default as TextInput } from './TextInput/TextInput';

@ -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"
@ -6041,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==
@ -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"

Loading…
Cancel
Save