Skip to content

Commit

Permalink
wlth-98: thematic category
Browse files Browse the repository at this point in the history
minor ui improvements
saving input word to the db
  • Loading branch information
konstrybakov committed Sep 6, 2024
1 parent 338e6d6 commit f6c6f23
Show file tree
Hide file tree
Showing 29 changed files with 303 additions and 61 deletions.
17 changes: 15 additions & 2 deletions app/(app)/(add-word)/components/add-word-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,20 @@ import { Form } from '@/components/ui/form'
import { Button } from '@/components/ui/button'
import { CornerDownLeftIcon } from 'lucide-react'
import { AddWordFormSchema } from '../schemas'
import type { AddWordFormValues } from '../types'
import type { AddWordFormValues, Category } from '../types'

import { AdditionalFields } from './additional-fields'
import { WordInput } from './word-input'
import { categoriesAtom } from '@/app/(app)/(add-word)/state'
import { useSetAtom } from 'jotai'
import { useEffect } from 'react'

export const AddWordForm = () => {
type AddWordFormProps = {
categories: Category[]
}

export const AddWordForm = ({ categories }: AddWordFormProps) => {
const setCategories = useSetAtom(categoriesAtom)
const form = useForm<AddWordFormValues>({
resolver: zodResolver(AddWordFormSchema),
defaultValues: {
Expand All @@ -28,6 +36,11 @@ export const AddWordForm = () => {
},
})

// TODO: evaluate if this is needed
useEffect(() => {
setCategories(categories)
}, [categories, setCategories])

const onSubmit = (values: AddWordFormValues) => {
console.log(values)
}
Expand Down
5 changes: 2 additions & 3 deletions app/(app)/(add-word)/components/additional-fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@prisma/client'
import { DescriptionField } from './description-field'
import { CategorySelect } from './category-select'
import { ThematicCategorySelect } from '@/app/(app)/(add-word)/components/thematic-category-select'

type AdditionalFieldsProps = {
form: UseFormReturn<AddWordFormValues>
Expand All @@ -35,27 +36,25 @@ export const AdditionalFields = ({ form }: AdditionalFieldsProps) => (
</CollapsibleTrigger>
<CollapsibleContent className="space-y-8">
<div className="space-y-8">
<ThematicCategorySelect form={form} />
<DescriptionField form={form} />
<div className="grid grid-cols-3 gap-4">
<CategorySelect
form={form}
name="difficultyCategory"
label="Difficulty"
placeholder="Select a difficulty"
options={Object.values(DifficultyCategory)}
/>
<CategorySelect
form={form}
name="frequencyCategory"
label="Frequency"
placeholder="Select a frequency"
options={Object.values(FrequencyCategory)}
/>
<CategorySelect
form={form}
name="registerCategory"
label="Register"
placeholder="Select a register"
options={Object.values(RegisterCategory)}
/>
</div>
Expand Down
4 changes: 1 addition & 3 deletions app/(app)/(add-word)/components/category-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,13 @@ type CategorySelectProps = {
form: UseFormReturn<AddWordFormValues>
name: 'difficultyCategory' | 'frequencyCategory' | 'registerCategory'
label: string
placeholder: string
options: string[]
}

export const CategorySelect = ({
form,
name,
label,
placeholder,
options,
}: CategorySelectProps) => (
<FormField
Expand All @@ -39,7 +37,7 @@ export const CategorySelect = ({
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
Expand Down
179 changes: 179 additions & 0 deletions app/(app)/(add-word)/components/thematic-category-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { titleCase } from 'title-case'
import { categoriesAtom } from '@/app/(app)/(add-word)/state'
import type { AddWordFormValues, Category } from '@/app/(app)/(add-word)/types'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { FormField, FormItem, FormLabel } from '@/components/ui/form'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { useAtom } from 'jotai'
import { CheckIcon, CirclePlusIcon, PlusIcon } from 'lucide-react'
import { useState } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import { Badge } from '@/components/ui/badge'
import { useCommandState } from 'cmdk'

type ThematicCategorySelectProps = {
form: UseFormReturn<AddWordFormValues>
}

const EmptyState = ({
onAdd,
}: {
onAdd: (name: string) => void
}) => {
const search = useCommandState(state => state.search)

return (
<div
onClick={() => onAdd(search)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
onAdd(search)
}
}}
className="relative flex gap-2 cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent"
>
<PlusIcon className="size-3" /> Add new category
</div>
)
}

export const ThematicCategorySelect = ({
form,
}: ThematicCategorySelectProps) => {
const [categories, setCategories] = useAtom(categoriesAtom)
const [open, setOpen] = useState(false)

const removeTCategory = (
event:
| React.MouseEvent<HTMLDivElement>
| React.KeyboardEvent<HTMLDivElement>,
category: Category,
) => {
event.stopPropagation()

const newCategories = form
?.getValues('category')
?.filter(c => c.id !== category.id)

form.setValue('category', newCategories)
}

const addNewCategory = (name: string) => {
setCategories([
...categories,
{
id: performance.now(),
name,
create: true,
},
])
}

// TODO: combobox on desktop, drawer on mobile
return (
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>
Thematic category{' '}
<span className="text-xs text-muted-foreground">(max 2)</span>
</FormLabel>
<Popover open={open} onOpenChange={setOpen}>
<div className="flex items-center justify-between gap-2 p-1 pl-3 border rounded-md">
{field.value?.map(category => (
<Badge
className="cursor-pointer"
onClick={e => removeTCategory(e, category)}
key={category.id}
tabIndex={0}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
removeTCategory(e, category)
}
}}
>
{titleCase(category.name)}
</Badge>
))}
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
size="sm"
className="justify-between ml-auto"
>
<CirclePlusIcon className="size-4" />
</Button>
</PopoverTrigger>
</div>
<PopoverContent className="w-[200px] p-0" align="end">
<Command>
<CommandInput
placeholder="Search category..."
className="h-9"
/>
<CommandList>
<CommandEmpty className="p-1">
<EmptyState onAdd={addNewCategory} />
</CommandEmpty>
<CommandGroup>
{categories.map(category => (
<CommandItem
className="flex items-center gap-2"
value={category.name}
key={category.id}
disabled={
field.value?.length === 2 &&
!field.value.some(c => c.id === category.id)
}
onSelect={() => {
let newCategories = [...(field.value || [])]

if (newCategories.some(c => c.id === category.id)) {
newCategories = newCategories.filter(
c => c.id !== category.id,
)
} else {
newCategories = [...newCategories, category]
}

form.setValue('category', newCategories)
}}
>
<CheckIcon
className={cn(
'size-3',
field.value?.some(c => c.id === category.id)
? 'opacity-100'
: 'opacity-0',
)}
/>
{titleCase(category.name)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</FormItem>
)}
/>
)
}
6 changes: 5 additions & 1 deletion app/(app)/(add-word)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { auth } from '@/app/(auth)/auth'
import { AddWordForm } from './components/add-word-form'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db/client'
import { CategoriesArgs } from '@/app/(app)/(add-word)/query-args'

export default async function AddWordPage() {
const session = await auth()
Expand All @@ -11,5 +13,7 @@ export default async function AddWordPage() {
redirect('/signin')
}

return <AddWordForm />
const categories = await prisma.category.findMany(CategoriesArgs)

return <AddWordForm categories={categories} />
}
12 changes: 12 additions & 0 deletions app/(app)/(add-word)/query-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Prisma } from '@prisma/client'

export const CategoriesArgs = {
select: {
id: true,
name: true,
},
distinct: 'name',
orderBy: {
name: 'asc',
},
} satisfies Prisma.CategoryFindManyArgs
50 changes: 20 additions & 30 deletions app/(app)/(add-word)/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,27 @@
import { z } from 'zod'
import { WordSchema } from '@/prisma/zod/word'
import { z, type ZodType } from 'zod'
import {
DifficultyCategory,
FrequencyCategory,
RegisterCategory,
} from '@prisma/client'
import type { Category } from '@/app/(app)/(add-word)/types'

export const AddWordFormSchema = WordSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
userId: true,
export const AddWordFormSchema = z.object({
word: z.string().min(1, 'Word must be at least 1 character long'),
translation: z
.string()
.min(1, 'Translation must be at least 1 character long'),
description: z.string().optional(),
difficultyCategory: z.nativeEnum(DifficultyCategory).optional(),
frequencyCategory: z.nativeEnum(FrequencyCategory).optional(),
registerCategory: z.nativeEnum(RegisterCategory).optional(),
category: z
.array(
z.object({
id: z.number(),
name: z.string(),
create: z.boolean().optional(),
}) satisfies ZodType<Category>,
)
.optional(),
})
.extend({
description: z.string().optional(),
difficultyCategory: z.nativeEnum(DifficultyCategory).optional(),
frequencyCategory: z.nativeEnum(FrequencyCategory).optional(),
registerCategory: z.nativeEnum(RegisterCategory).optional(),
})
.superRefine((data, ctx) => {
if (data.word.length < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Word must be at least 1 character long',
path: ['word'],
})
}

if (data.translation.length < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Translation must be at least 1 character long',
path: ['translation'],
})
}
})
4 changes: 4 additions & 0 deletions app/(app)/(add-word)/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { CategoryWithCreate } from '@/app/(app)/(add-word)/types'
import { atom } from 'jotai'

export const categoriesAtom = atom<CategoryWithCreate[]>([])
6 changes: 6 additions & 0 deletions app/(app)/(add-word)/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { z } from 'zod'
import type { AddWordFormSchema } from './schemas'
import type { CategoriesArgs } from '@/app/(app)/(add-word)/query-args'
import type { Prisma } from '@prisma/client'

export type AddWordFormValues = z.infer<typeof AddWordFormSchema>
export type Category = Prisma.CategoryGetPayload<typeof CategoriesArgs>
export type CategoryWithCreate = Category & {
create?: true
}
Loading

0 comments on commit f6c6f23

Please sign in to comment.