testing setup

This commit is contained in:
2025-08-29 14:19:38 +02:00
parent e74b30e04c
commit 869cc07fdd
46 changed files with 6710 additions and 1460 deletions

View File

@@ -15,10 +15,12 @@
"dev": "next dev --turbo", "dev": "next dev --turbo",
"preview": "next build && next start", "preview": "next build && next start",
"start": "next start", "start": "next start",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "vitest --coverage --typecheck"
}, },
"dependencies": { "dependencies": {
"@clerk/nextjs": "^6.27.1", "@clerk/nextjs": "^6.27.1",
"@electric-sql/pglite": "^0.3.7",
"@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.3", "@fortawesome/react-fontawesome": "^0.2.3",
@@ -64,7 +66,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"date-format": "^3.0.0", "date-format": "^3.0.0",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.44.5",
"drizzle-zod": "^0.7.1", "drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"glazejs": "^2.0.1", "glazejs": "^2.0.1",
@@ -75,7 +77,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"react": "^19.1.1", "react": "^19.1.1",
"react-day-picker": "8.10.1", "react-day-picker": "9.8.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-hook-form": "^7.61.1", "react-hook-form": "^7.61.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -93,15 +95,31 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@swc/jest": "^0.2.39",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@types/jest": "^30.0.0",
"@types/node": "^20.19.9", "@types/node": "^20.19.9",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^5.0.0",
"@vitest/coverage-v8": "^3.2.4",
"dotenv": "^17.2.1",
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"jest": "^30.0.5",
"jest-environment-jsdom": "^30.0.5",
"jsdom": "^26.1.0",
"next-router-mock": "^1.0.2",
"pg-mem": "^3.0.5",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"

6370
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { isAdmin } from "../actions" import { isAdmin } from "~/app/actions"
export default async function AdminWrap({children,}: Readonly<{ children: React.ReactNode }>) { export default async function AdminWrap({children,}: Readonly<{ children: React.ReactNode }>) {
if (await isAdmin()) { if (await isAdmin()) {

View File

@@ -42,7 +42,6 @@ export default function CalendarFormField<T extends FieldValues>(params: { contr
disabled={(date) => disabled={(date) =>
date > new Date() || date < new Date("1900-01-01") date > new Date() || date < new Date("1900-01-01")
} }
initialFocus
captionLayout="dropdown" captionLayout="dropdown"
/> />
</PopoverContent> </PopoverContent>

View File

@@ -0,0 +1,29 @@
import { ChevronsUpDown, Plus } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible";
type FormProps<T> = {
className?:string,
entity?:T
}
export default function CollapsibleForm<T>(params: {entityName:string, entity?:T, entityLabelIndex?:keyof T,form:React.ComponentType<FormProps<T>>}) {
return (
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant={"ghost"}>
{ params.entity ?
<>{params.entityLabelIndex ? params.entity[params.entityLabelIndex] : params.entityName} <ChevronsUpDown/></> : <> New {params.entityName} <Plus/></>
}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="autoAlpha">
{
params.entity ?
<params.form className="w-full" entity={params.entity}/> :
<params.form className="w-full"/>
}
</CollapsibleContent>
</Collapsible>
)
}

View File

@@ -0,0 +1,18 @@
import { DeleteIcon } from "lucide-react";
import type { UseTRPCMutationOptions, UseTRPCMutationResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs";
import { Button } from "~/components/ui/button";
export default function DeleteButton<TD, TE, TV, TC>(params: { mutation: UseTRPCMutationResult<TD, TE, TV, TC>, id?: string }) {
if (params.id) {
return (
<Button variant='destructive' onClick={() => params.mutation.mutate(({ id: params.id } as TV))}>
<DeleteIcon />
</Button>
)
}
return (
<Button variant='destructive' disabled>
<DeleteIcon />
</Button>
)
}

View File

@@ -0,0 +1,47 @@
import * as Card from '~/components/ui/card'
import { Form } from "~/components/ui/form";
import { DependentFormMessaage, SubmitButton, DeleteButton } from '~/app/_components/Form/Components';
import type { FieldValues, SubmitHandler, UseFormReturn } from 'react-hook-form';
import type { ReactNode } from 'react';
import DependentText from '../../DependentText';
import type { UseTRPCMutationResult } from 'node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs';
interface Error {
message: string
}
export default function FormScaffold<T extends FieldValues, TD, TE extends Error, TV, TC, TTD, TTE extends Error, TTV, TTC, TTTD, TTTE extends Error, TTTV, TTTC>(params: {
form: UseFormReturn<T>,
onSubmit: SubmitHandler<T>,
createMutation: UseTRPCMutationResult<TD, TE, TV, TC>,
updateMutation: UseTRPCMutationResult<TTD, TTE, TTV, TTC>,
deleteMutation: UseTRPCMutationResult<TTTD, TTTE, TTTV, TTTC>,
title: string,
children: ReactNode,
id?: string,
className?: string
}) {
const { form, onSubmit, createMutation, deleteMutation, updateMutation, title, id, className, children } = params
return (
<Card.Card className={className ? className : "w-5/6 lg:w-1/2"}>
<Card.CardHeader>
<Card.CardTitle>
<DependentText bool={id ? true : false} true={`Update ${title}`} false={`Create ${title}`} />
</Card.CardTitle>
</Card.CardHeader>
<Card.CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
{children}
<SubmitButton id={id} />
<div className='flex flex-row justify-between'>
<DependentFormMessaage falseStatus={createMutation.status} trueStatus={updateMutation.status} falseError={createMutation.error} trueError={updateMutation.error} bool={id ? true : false} />
<DeleteButton mutation={deleteMutation} id={id} />
</div>
</form>
</Form>
</Card.CardContent>
</Card.Card>
)
}

View File

@@ -0,0 +1,10 @@
import { Button } from "~/components/ui/button";
import DependentText from "../../DependentText";
export default function SubmitButton(params: {id?:string}) {
return (
<Button type="submit">
<DependentText bool={params.id?true:false} true='Update' false='Create' />
</Button>
)
}

View File

@@ -0,0 +1,5 @@
export { default as DependentFormMessaage } from './MutationFormMessage'
export { default as SubmitButton } from './SubmitButton'
export { default as DeleteButton } from './DeleteButton'
export { default as FormScaffold } from './FormScaffold'
export { default as CollapsibleForm } from './CollapsibleForm'

View File

@@ -0,0 +1,23 @@
import type { Control, FieldValues, Path } from "react-hook-form";
import { Checkbox } from "~/components/ui/checkbox";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
export default function BooleanFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string }) {
return (
<FormField
control={params.control}
name={params.name}
render={({ field }) => (
<FormItem>
<FormLabel>{params.label}</FormLabel>
<FormControl>
<Checkbox
checked={field.value ? field.value : false}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)
}

View File

@@ -0,0 +1,54 @@
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import type { Control, FieldValues, Path } from "react-hook-form";
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { cn } from "~/lib/utils";
export default function CalendarFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string }) {
return (
<FormField
control={params.control}
name={params.name}
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{params.label}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"w-[240px] pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
initialFocus
captionLayout="dropdown"
/>
</PopoverContent>
</Popover>
</FormItem>
)}
/>
)
}

View File

@@ -0,0 +1,25 @@
import MDEditor from "@uiw/react-md-editor";
import type { Control, FieldValues, Path } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) {
return (
<FormField
control={params.control}
name={params.name}
render={({ field }) => (
<FormItem>
<FormLabel>
Description
</FormLabel>
<FormControl>
<MDEditor
value={field.value ? field.value : ""}
onChange={field.onChange}
data-color-mode={params.dataColorMode}
/>
</FormControl>
</FormItem>
)}
/>
)
}

View File

@@ -0,0 +1,92 @@
import type { CheckedState } from "@radix-ui/react-checkbox";
import { ChevronDownIcon } from "lucide-react";
import { createContext,useContext, useState } from "react";
import { useFormContext, type Control, type ControllerRenderProps, type FieldValues, type Path } from "react-hook-form";
import type { Entries } from "type-fest";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
interface MultiBooleanFieldContextProps {
checkedValues:Record<string,boolean>;
setCheckedValue:(arg0:Record<string,boolean>) => void;
}
const MultiBooleanFieldContext = createContext<MultiBooleanFieldContextProps|undefined>(undefined)
function InnerMultiBooleanFormField(params: { options: string[], onChange: (arg0: string[]) => void }) {
const context = useContext(MultiBooleanFieldContext)
if (context === undefined) {
return (<></>)
}
const onCheckedItemChange = (key: string) => {
return function onCheckedChange(checked: CheckedState) {
console.log(key,checked)
let state = context.checkedValues;
if (checked === "indeterminate") {
console.log('checked was intermediate')
return
} else {
state[key] = checked
}
context.setCheckedValue(state)
let stateArr: string[] = []
for (key in state) {
if (state[key]) {
stateArr.push(key)
}
}
console.log('calling field on change with:', stateArr)
params.onChange(stateArr)
}
}
const checked = (key: string) => {
if (context.checkedValues[key] === undefined) {
return undefined
}
return context.checkedValues[key]
}
return (
<>
{
params.options.map((opt) => (
<FormItem key={opt}>
<div className="flex flex-row justify-between py-2 border-b-1">
<FormLabel>{opt}</FormLabel>
<Checkbox checked={checked(opt)} onCheckedChange={onCheckedItemChange(opt)} />
</div>
</FormItem>
))
}
</>
)
}
export default function MultiBooleanFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, options: string[], defaultValues?: string[] }) {
const [open,setOpen] = useState<boolean>(false)
const [checkedValues, setCheckedValues] = useState<Record<string, boolean>>(params.defaultValues == undefined ? {} : params.defaultValues.reduce((acc,current,index) => { acc[current] = true; return acc },{}))
return (
<FormField
control={params.control}
name={params.name}
render={({ field }) => (
<FormItem>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant={'ghost'}>
<FormLabel>{params.label}</FormLabel>
<ChevronDownIcon/>
</Button>
</PopoverTrigger>
<FormControl>
<PopoverContent>
<MultiBooleanFieldContext.Provider value={{checkedValues: checkedValues, setCheckedValue: setCheckedValues}}>
<InnerMultiBooleanFormField onChange={field.onChange} options={params.options} />
</MultiBooleanFieldContext.Provider>
</PopoverContent>
</FormControl>
</Popover>
</FormItem>
)}
/>
)
}

View File

@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import type { Control, FieldValues, Path } from "react-hook-form"; import type { Control, FieldValues, Path } from "react-hook-form";
import { FormField,FormControl, FormItem, FormLabel } from "~/components/ui/form"; import { FormField,FormControl, FormItem, FormLabel } from "~/components/ui/form";
import { Select, SelectContent, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Select, SelectContent, SelectTrigger, SelectValue } from "~/components/ui/select";
export default function SelectFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, defaultValue:string|undefined, placeholder:string|undefined, children: ReactNode}) { export default function SelectFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, defaultValue?:string|null, placeholder?:string|null, children: ReactNode}) {
return ( return (
<FormField <FormField
control={params.control} control={params.control}
@@ -12,10 +12,10 @@ export default function SelectFormField<T extends FieldValues>(params: { control
<FormLabel> <FormLabel>
{params.label} {params.label}
</FormLabel> </FormLabel>
<Select onValueChange={field.onChange} value={field.value == null ? undefined : field.value} defaultValue={params.defaultValue}> <Select onValueChange={field.onChange} value={field.value == null ? undefined : field.value} defaultValue={params.defaultValue ? params.defaultValue : field.value == null ? undefined : field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={params.placeholder} /> <SelectValue placeholder={params.placeholder ? undefined : params.placeholder} />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>

View File

@@ -9,7 +9,7 @@ export default function TexttInputFormField<T extends FieldValues>(params: { con
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel>{params.label}</FormLabel> <FormLabel>{params.label}</FormLabel>
<Input placeholder="release link" onChange={field.onChange} value={field.value == null ? undefined : field.value} /> <Input placeholder={params.name} onChange={field.onChange} value={field.value == null ? undefined : field.value} />
</FormItem> </FormItem>
)} )}
/> />

View File

@@ -0,0 +1,6 @@
export { default as BooleanFormField } from './BooleanFormField'
export { default as TextInputFormField } from './TextInputFormField'
export { default as MultiBooleanFormField } from './MultiBooleanFormField'
export { default as SelectFormField } from './SelectFormField'
export { default as MdeFormField } from './MdeFormField'
export { default as CalenderFormField } from './CalenderFormField'

View File

@@ -9,7 +9,7 @@ export default function MdeFormField<T extends FieldValues>(params: { control: C
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
Description {params.label}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<MDEditor <MDEditor

View File

@@ -18,6 +18,7 @@ export default async function AdminSideBar() {
<SimpleSidebarGroup lable="Projects"> <SimpleSidebarGroup lable="Projects">
<Link href={"/admin/project/create"}> Create Project </Link> <Link href={"/admin/project/create"}> Create Project </Link>
<Link href={"/admin/project/techStack/create"}> Create Stack </Link> <Link href={"/admin/project/techStack/create"}> Create Stack </Link>
<Link href={"/admin/project/list"}> Project List </Link>
</SimpleSidebarGroup> </SimpleSidebarGroup>
<SimpleSidebarGroup lable="Blog"> <SimpleSidebarGroup lable="Blog">
<Link href={"/"}> Some Blog Action </Link> <Link href={"/"}> Some Blog Action </Link>

View File

@@ -10,7 +10,7 @@ export default function Page() {
const {data} = trpc.category.select.useQuery({id: id}) const {data} = trpc.category.select.useQuery({id: id})
if (data !== undefined && data.length > 0) { if (data !== undefined && data.length > 0) {
return ( return (
<CreateUpdateCvCategoryForm category={data[0]}/> <CreateUpdateCvCategoryForm entity={data[0]}/>
) )
} }
return (<></>) return (<></>)

View File

@@ -1,27 +0,0 @@
import { ChevronsUpDown, Plus } from "lucide-react"
import { Button } from "~/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible";
import { type Element } from "~/lib/utils";
import CreateUpdateCvCategoryForm from "./CreateUpdateForm";
import type { RouterOutputs } from "~/server/routers/_app";
export default function CollapsibleCvCategoryForm(params:{category?:Element<RouterOutputs['category']['select']>}) {
return (
<Collapsible >
<CollapsibleTrigger asChild>
<Button variant={"ghost"}>
{ params.category ?
<>{params.category.name} <ChevronsUpDown/></> : <>New <Plus/></>
}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="autoAlpha">
{
params.category ?
<CreateUpdateCvCategoryForm className="w-full" category={params.category} /> :
<CreateUpdateCvCategoryForm className="w-full"/>
}
</CollapsibleContent>
</Collapsible>
)
}

View File

@@ -2,96 +2,53 @@
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { z } from "zod"; import { z } from "zod";
import { Form, FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectTrigger, SelectItem, SelectValue } from "~/components/ui/select";
import { trpc } from "~/app/_trpc/Client"; import { trpc } from "~/app/_trpc/Client";
import { Button } from "~/components/ui/button";
import * as Card from '~/components/ui/card'
import type { IterableElement } from 'type-fest' import type { IterableElement } from 'type-fest'
import { entitySchemas, makeOnSuccess } from "~/lib/utils"; import { entitySchemas, makeOnSuccess } from "~/lib/utils";
import type { RouterOutputs } from "~/server/routers/_app"; import type { RouterOutputs } from "~/server/routers/_app";
import DependentFormMessaage from '~/app/_components/MutationFormMessage'; import { CollapsibleForm, FormScaffold } from '~/app/_components/Form/Components';
import { useState } from 'react'; import { useState } from 'react';
import DependentText from '~/app/_components/DependentText'; import { SelectFormField, TextInputFormField } from '~/app/_components/Form/Fields';
export default function CreateUpdateCvCategoryForm(params:{className?:string,category?:IterableElement<RouterOutputs['category']['select']>,isUpdate?:boolean}) { import { usePathname, useRouter } from 'next/navigation';
import { SelectItem } from '~/components/ui/select';
export default function CreateUpdateCvCategoryForm(params: { className?: string, entity?: IterableElement<RouterOutputs['category']['select']> }) {
const schemas = entitySchemas('cvCategory') const schemas = entitySchemas('cvCategory')
const [isUpdate,setIsUpdate] = useState<boolean>(params.isUpdate ? params.isUpdate : (params.category ? true : false)) const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
const form = useForm<z.infer<typeof schemas.insert>>({ const form = useForm<z.infer<typeof schemas.insert>>({
resolver: zodResolver(schemas.insert), resolver: zodResolver(schemas.insert),
defaultValues: { defaultValues: {
id: params.category ? params.category.id : crypto.randomUUID(), id: params.entity ? params.entity.id : crypto.randomUUID(),
name: params.category ? params.category.name : "", name: params.entity ? params.entity.name : "",
layoutPosition: params.category ? params.category.layoutPosition : "col1" layoutPosition: params.entity ? params.entity.layoutPosition : "col1"
} }
}) })
const createMutation = trpc.category.insert.useMutation({onSuccess: makeOnSuccess('create',form,setIsUpdate)}) let path = usePathname()
const updateMutation = trpc.category.update.useMutation({onSuccess: makeOnSuccess('update',form)}) let router = useRouter()
const createMutation = trpc.category.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) })
const updateMutation = trpc.category.update.useMutation({ onSuccess: makeOnSuccess('update', form) })
const deleteMutation = trpc.category.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
function onSubmit(values: z.infer<typeof schemas.insert>) { function onSubmit(values: z.infer<typeof schemas.insert>) {
isUpdate ? id ?
updateMutation.mutate(values) : updateMutation.mutate(values) :
createMutation.mutate(values) createMutation.mutate(values)
} }
//TODO use SelectFormField and TextInputFormField
return ( return (
<Card.Card className={params.className ? params.className : "w-5/6 lg:w-1/2"}> <FormScaffold
<Card.CardHeader> form={form}
<Card.CardTitle> createMutation={createMutation}
<DependentText bool={isUpdate} true='Update Category' false='Create Category'/> updateMutation={updateMutation}
</Card.CardTitle> deleteMutation={deleteMutation}
</Card.CardHeader> onSubmit={onSubmit}
<Card.CardContent> title='Category'
<Form {...form}> id={id}
<form className={params.className}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
> >
<FormField <TextInputFormField control={form.control} name='name' label='Name' />
control={form.control} <SelectFormField control={form.control} name='layoutPosition' label='Layout Position' placeholder={form.getValues().layoutPosition == null ? undefined : form.getValues().layoutPosition}>
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Name
</FormLabel>
<FormControl>
<Input placeholder="name" onChange={field.onChange} value={field.value ? field.value : ""} />
</FormControl>
</FormItem>
)}
>
</FormField>
<FormField
control={form.control}
name="layoutPosition"
render={({ field }) => (
<FormItem>
<FormLabel>
Layout Position
</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} defaultValue={field.value == null ? undefined : field.value}>
<SelectTrigger>
<SelectValue placeholder={form.getValues().layoutPosition} />
</SelectTrigger>
<SelectContent>
{schemas.insert.shape.layoutPosition.unwrap().unwrap().options.map((o) => ( {schemas.insert.shape.layoutPosition.unwrap().unwrap().options.map((o) => (
<SelectItem key={o} value={o}> {o} </SelectItem> <SelectItem key={o} value={o}> {o} </SelectItem>
))} ))}
</SelectContent> </SelectFormField>
</Select> </FormScaffold>
</FormControl>
</FormItem>
)}
>
</FormField>
<Button type="submit">
<DependentText bool={isUpdate} true='Update' false='Create'/>
</Button>
<DependentFormMessaage falseStatus={createMutation.status} trueStatus={updateMutation.status} falseError={createMutation.error} trueError={updateMutation.error} bool={isUpdate}/>
</form>
</Form>
</Card.CardContent>
</Card.Card>
) )
} }

View File

@@ -5,12 +5,12 @@ import { useGSAP } from '@gsap/react'
import { Suspense, useRef } from "react"; import { Suspense, useRef } from "react";
import * as Card from '~/components/ui/card' import * as Card from '~/components/ui/card'
import { useGsapContext } from "~/app/_providers/GsapProvicer"; import { useGsapContext } from "~/app/_providers/GsapProvicer";
import CollapsibleCvEntryForm from "../../entry/_components/CollapsibleForm"; import { CollapsibleForm } from "~/app/_components/Form/Components";
import CollapsibleCvCategoryForm from "../_components/CollapsibleForm"; import CreateUpdateCvEntryForm from "../../entry/_components/CreateUpdateForm";
import CreateUpdateCvCategoryForm from "../_components/CreateUpdateForm"; import CreateUpdateCvCategoryForm from "../_components/CreateUpdateForm";
export default function CvPage() { export default function CvPage() {
const categories = trpc.category.select.useQuery({},{refetchInterval:1000}); const categories = trpc.category.select.useQuery({}, { refetchInterval: 1000 });
const entires = trpc.entry.select.useSuspenseQuery({},{refetchInterval:1000}) const entires = trpc.entry.select.useSuspenseQuery({}, { refetchInterval: 1000 })
const gsap = useGsapContext() const gsap = useGsapContext()
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
useGSAP(() => { useGSAP(() => {
@@ -34,31 +34,34 @@ export default function CvPage() {
</Link> </Link>
<Card.CardContent className="flex flex-row"> <Card.CardContent className="flex flex-row">
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
<CreateUpdateCvCategoryForm category={cat} className="w-full" /> <CreateUpdateCvCategoryForm entity={cat} className="w-full" />
<br /> <Card.Card className="w-full">
<span>Entries:</span> <Card.CardHeader>
<Card.CardTitle>
Entries:
</Card.CardTitle>
</Card.CardHeader>
<Card.CardContent className="w-full">
<Suspense fallback={(<></>)}> <Suspense fallback={(<></>)}>
<div className="w-full">
{ {
entires[0].filter((e) => {return e.categoryId == cat.id}).length > 0 ? ( entires[0].filter((e) => { return e.categoryId == cat.id }).length > 0 ? (
<> <>
{entires[0].filter((e) => {return e.categoryId == cat.id}).map((entry) => ( {entires[0].filter((e) => { return e.categoryId == cat.id }).map((entry) => (
<CollapsibleCvEntryForm key={entry.id} entry={entry}/> <CollapsibleForm entityName="Entry" form={CreateUpdateCvEntryForm} entity={entry} entityLabelIndex="title" />
))} ))}
</> </>
) : (<></>) ) : (<></>)
} }
</div>
</Suspense> </Suspense>
<div className="flex flex-col w-full"> <CollapsibleForm entityName="Entry" form={CreateUpdateCvEntryForm} />
<CollapsibleCvEntryForm categoryId={cat.id} /> </Card.CardContent>
</div> </Card.Card>
</div> </div>
</Card.CardContent> </Card.CardContent>
</Card.Card> </Card.Card>
) )
})} })}
<CollapsibleCvCategoryForm /> <CollapsibleForm entityName="Category" form={CreateUpdateCvCategoryForm} />
</> </>
} }
</div> </div>

View File

@@ -9,7 +9,7 @@ export default function Page() {
const {data} = trpc.entry.select.useQuery({id: id}) const {data} = trpc.entry.select.useQuery({id: id})
if (data !== undefined && data.length > 0) { if (data !== undefined && data.length > 0) {
return ( return (
<CreateUpdateCvEntryForm entry={data[0]}/> <CreateUpdateCvEntryForm entity={data[0]}/>
) )
} }
return (<></>) return (<></>)

View File

@@ -1,26 +0,0 @@
import { ChevronsUpDown, Plus } from "lucide-react"
import { Button } from "~/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible";
import CreateUpdateCvEntryForm from "./CreateUpdateForm";
import type { IterableElement } from "type-fest";
import type { RouterOutputs } from "~/server/routers/_app";
export default function CollapsibleCvEntryForm(params:{entry?:IterableElement<RouterOutputs['entry']['select']>, categoryId?: string}) {
return (
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant={"ghost"}>
{ params.entry ?
<>{params.entry.title} <ChevronsUpDown/></> : <>New <Plus/></>
}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="autoAlpha">
{
params.entry ?
<CreateUpdateCvEntryForm className="w-full" entry={params.entry}/> :
<CreateUpdateCvEntryForm className="w-full"/>
}
</CollapsibleContent>
</Collapsible>
)
}

View File

@@ -1,81 +1,56 @@
'use client' 'use client'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useForm, type UseFormReturn } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { format } from 'date-fns'
import { z } from "zod"; import { z } from "zod";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; import { SelectItem } from "~/components/ui/select";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectTrigger, SelectItem, SelectValue } from "~/components/ui/select";
import { trpc } from "~/app/_trpc/Client"; import { trpc } from "~/app/_trpc/Client";
import { Button } from "~/components/ui/button"; import { entitySchemas, ft, makeOnSuccess, tt } from "~/lib/utils";
import * as Card from '~/components/ui/card' import { useRelationShip } from '~/lib/hooks';
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { CalendarIcon } from "lucide-react";
import { Calendar } from "~/components/ui/calendar";
import { cn, entitySchemas, ft, makeOnSuccess, tt, type DatesAreString, type Pretty } from "~/lib/utils";
import { Checkbox } from "~/components/ui/checkbox"
import MDEditor from '@uiw/react-md-editor'
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import type { Entries, IterableElement } from 'type-fest'; import type { IterableElement } from 'type-fest';
import type { RouterOutputs } from '~/server/routers/_app'; import type { RouterOutputs } from '~/server/routers/_app';
import DependentText from '~/app/_components/DependentText'; import { FormScaffold } from '~/app/_components/Form/Components';
import DependentFormMessaage from '~/app/_components/MutationFormMessage'; import { useState } from 'react';
import { useEffect, useState } from 'react'; import { SelectFormField, TextInputFormField, MdeFormField, CalenderFormField, BooleanFormField } from '~/app/_components/Form/Fields'
import SelectFormField from '~/app/_components/SelectFormField'; import { usePathname, useRouter } from 'next/navigation';
import TexttInputFormField from '~/app/_components/TextInputFormField'; export default function CreateUpdateCvEntryForm(params: { className?: string, entity?: IterableElement<RouterOutputs['entry']['select']>, isUpdate?: boolean }) {
import MdeFormField from '~/app/_components/MdeFormField'; const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
import CalendarFormField from '~/app/_components/CalenderFormField';
export default function CreateUpdateCvEntryForm(params: { className?: string, entry?: IterableElement<RouterOutputs['entry']['select']>, isUpdate?: boolean }) {
const [isUpdate, setIsUpdate] = useState<boolean>(params.isUpdate ? params.isUpdate : (params.entry ? true : false))
const [categoryId,setCategoryId] = useState<string|undefined>()
const [categoryName,setCategoryName] = useState<string|undefined>()
const { theme } = useTheme() const { theme } = useTheme()
const schemas = entitySchemas('cvEntry') const schemas = entitySchemas('cvEntry')
const {data:categories,isSuccess: categoriesSuccess} = trpc.category.select.useQuery({}) const {data: categories,id:categoryId,name:categoryName,success:categoriesSuccess} = useRelationShip(trpc.category.select.useQuery({}),'name',id)
useEffect(() => {
if (isUpdate) {
return
}
setCategoryId(categories?.at(0)?.id)
if (categories !== undefined && categories[0]?.name !== null) {
setCategoryName(categories[0]?.name)
}
},[categoriesSuccess])
let defaultValues = { let defaultValues = {
id: params.entry ? params.entry.id : crypto.randomUUID(), id: params.entity ? params.entity.id : crypto.randomUUID(),
title: params.entry ? params.entry.title : "", title: params.entity ? params.entity.title : "",
description: params.entry ? params.entry.description : "", description: params.entity ? params.entity.description : "",
categoryId: params.entry ? params.entry.categoryId : categoriesSuccess ? categories.at(0)?.id : "", categoryId: params.entity ? params.entity.categoryId : categoriesSuccess ? categories?.at(0)?.id : "",
fromTime: params.entry ? ft(params.entry).fromTime : new Date(), fromTime: params.entity ? ft(params.entity).fromTime : new Date(),
toTime: params.entry ? tt(params.entry).toTime : new Date(), toTime: params.entity ? tt(params.entity).toTime : new Date(),
hideDates: params.entry ? params.entry.hideDates : false, hideDates: params.entity ? params.entity.hideDates : false,
}; };
const form = useForm<z.infer<typeof schemas.insert>>({ const form = useForm<z.infer<typeof schemas.insert>>({
resolver: zodResolver(schemas.insert), resolver: zodResolver(schemas.insert),
defaultValues: defaultValues, defaultValues: defaultValues,
// values: params.entry ? ut(ct(tt(ff(params.entry)))) : defaultValues
}) })
const createMutation = trpc.entry.insert.useMutation({onSuccess: makeOnSuccess('create',form,setIsUpdate)}) let path = usePathname()
const updateMutation = trpc.entry.update.useMutation({onSuccess: makeOnSuccess('update',form)}) let router = useRouter()
const createMutation = trpc.entry.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) })
const updateMutation = trpc.entry.update.useMutation({ onSuccess: makeOnSuccess('update', form) })
const deleteMutation = trpc.entry.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
function onSubmit(values: z.infer<typeof schemas.insert>) { function onSubmit(values: z.infer<typeof schemas.insert>) {
isUpdate ? id ?
updateMutation.mutate(values) : updateMutation.mutate(values) :
createMutation.mutate(values) createMutation.mutate(values)
} }
//TODO use SelectFormField and TextInputFormField
return ( return (
<Card.Card className={params.className ? params.className : "w-5/6 lg:w-1/2"}> <FormScaffold
<Card.CardHeader> form={form}
<Card.CardTitle> createMutation={createMutation}
<DependentText bool={isUpdate} true='Update Entry' false='Create Entry' /> updateMutation={updateMutation}
</Card.CardTitle> deleteMutation={deleteMutation}
</Card.CardHeader> onSubmit={onSubmit}
<Card.CardContent> title='Entry'
<Form {...form}> id={id}
<form className={params.className}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
> >
<SelectFormField control={form.control} name='categoryId' label='Category' defaultValue={categoryId} placeholder={categoryName}> <SelectFormField control={form.control} name='categoryId' label='Category' defaultValue={categoryId} placeholder={categoryName}>
{ {
@@ -85,35 +60,14 @@ export default function CreateUpdateCvEntryForm(params: { className?: string, en
return (<SelectItem key={c.id} value={c.id}> {c.name} </SelectItem>) return (<SelectItem key={c.id} value={c.id}> {c.name} </SelectItem>)
})} })}
</> : </> :
<SelectItem key="abc" value="abcd"/> <SelectItem key="abc" value="abcd" />
} }
</SelectFormField> </SelectFormField>
<TexttInputFormField control={form.control} name='title' label='Title'/> <TextInputFormField control={form.control} name='title' label='Title' />
<MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ? (theme as "dark" | "light") : "dark"}/> <MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ? (theme as "dark" | "light") : "dark"} />
<CalendarFormField control={form.control} name='fromTime' label='From Date'/> <CalenderFormField control={form.control} name='fromTime' label='From Date' />
<CalendarFormField control={form.control} name='toTime' label='To Date'/> <CalenderFormField control={form.control} name='toTime' label='To Date' />
<FormField <BooleanFormField control={form.control} name='hideDates' label='Hide Dates' />
control={form.control} </FormScaffold>
name="hideDates"
render={({ field }) => (
<FormItem>
<FormLabel>Hide dates</FormLabel>
<FormControl>
<Checkbox
checked={field.value ? field.value : false}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button type="submit">
<DependentText bool={isUpdate} true='Update' false='Create' />
</Button>
<DependentFormMessaage falseStatus={createMutation.status} trueStatus={updateMutation.status} falseError={createMutation.error} trueError={updateMutation.error} bool={isUpdate} />
</form>
</Form>
</Card.CardContent>
</Card.Card>
) )
} }

View File

@@ -1,27 +0,0 @@
import { ChevronsUpDown, Plus } from "lucide-react"
import { Button } from "~/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible";
import { type Element } from "~/lib/utils";
import CreateUpdateProjectForm from "./CreateUpdateProjectForm";
import type { RouterOutputs } from "~/server/routers/_app";
export default function CollapsibleForm(params:{project?:Element<RouterOutputs['project']['select']>}) {
return (
<Collapsible >
<CollapsibleTrigger asChild>
<Button variant={"ghost"}>
{ params.project ?
<>{params.project.title} <ChevronsUpDown/></> : <>New <Plus/></>
}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="autoAlpha">
{
params.project ?
<CreateUpdateProjectForm className="w-full" project={params.project}/> :
<CreateUpdateProjectForm className="w-full"/>
}
</CollapsibleContent>
</Collapsible>
)
}

View File

@@ -0,0 +1,15 @@
'use server'
import { expect, test } from 'vitest'
import { render, screen } from '@testing-library/react'
import CreateUpdateProjectForm from './CreateUpdateProjectForm'
import TrpcProvider from '~/../test/trpc/vitest.trpcProvider.mock'
test('CreateUpdateProjectForm', () => {
render(
<TrpcProvider>
<CreateUpdateProjectForm/>
</TrpcProvider>
)
let options = screen.getAllByRole('combobox')
console.log(options)
})

View File

@@ -2,99 +2,76 @@
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { z } from "zod"; import { z } from "zod";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; import { SelectItem } from "~/components/ui/select";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectTrigger, SelectItem, SelectValue } from "~/components/ui/select";
import { trpc } from "~/app/_trpc/Client"; import { trpc } from "~/app/_trpc/Client";
import { Button } from "~/components/ui/button"; import type { IterableElement } from 'type-fest'
import * as Card from '~/components/ui/card'
import type { Entries, IterableElement } from 'type-fest'
import { entitySchemas, makeOnSuccess } from "~/lib/utils"; import { entitySchemas, makeOnSuccess } from "~/lib/utils";
import { Suspense, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { RouterOutputs } from '~/server/routers/_app'; import type { RouterOutputs } from '~/server/routers/_app';
import TexttInputFormField from '~/app/_components/TextInputFormField'; import { SelectFormField, TextInputFormField } from '~/app/_components/Form/Fields'
import SelectFormField from '~/app/_components/SelectFormField'; import { FormScaffold } from '~/app/_components/Form/Components';
import DependentFormMessaage from '~/app/_components/MutationFormMessage'; import { usePathname, useRouter } from 'next/navigation';
import DependentText from '~/app/_components/DependentText'; import { makeUseRelationShipWithNameIndex } from '~/lib/hooks';
export default function CreateUpdateProjectForm(params: { className?: string, project?: IterableElement<RouterOutputs['project']['select']>, isUpdate?: boolean }) { export default function CreateUpdateProjectForm(params: { className?: string, entity?: IterableElement<RouterOutputs['project']['select']> }) {
const [isUpdate, setIsUpdate] = useState<boolean>(params.isUpdate ? params.isUpdate : (params.project ? true : false)) const [id, setId] = useState<string|undefined>(params.entity ? params.entity.id : undefined)
const [stackId, setstackId] = useState<string | undefined>()
const [stackName, setstackName] = useState<string | undefined>()
const schemas = entitySchemas('project') const schemas = entitySchemas('project')
const { data: stacks, isSuccess: stacksSuccess } = trpc.techStack.select.useQuery({}) console.log('using trpc')
useEffect(() => { const { data:stacks,id:stackId, name:stackName,success:stacksSuccess, error:stackError } = makeUseRelationShipWithNameIndex('stackItems')(trpc.techStack.select.useQuery({}),id,(items) => {return items ? items.join('-') : ""})
if (isUpdate) { console.log(stackError)
return
}
setstackId(stacks?.at(0)?.id)
if (stacks !== undefined) {
setstackName(stacks.at(0)?.stackItems ? stacks.at(0)?.stackItems?.join("-") : "")
}
}, [stacksSuccess])
const form = useForm<z.infer<typeof schemas.insert>>({ const form = useForm<z.infer<typeof schemas.insert>>({
resolver: zodResolver(schemas.insert), resolver: zodResolver(schemas.insert),
defaultValues: { defaultValues: {
id: params.project ? params.project.id : crypto.randomUUID(), id: id ? id : crypto.randomUUID(),
stackId: params.project ? params.project.stackId : stacksSuccess ? stacks?.at(0)?.id : "", stackId: params.entity ? params.entity.stackId : stacksSuccess ? stacks?.at(0)?.id : "",
releaseStatus: params.project ? params.project.releaseStatus : "unreleased", releaseStatus: params.entity ? params.entity.releaseStatus : "unreleased",
releaseLink: params.project ? params.project.releaseLink : "", releaseLink: params.entity ? params.entity.releaseLink : "",
sourceType: params.project ? params.project.sourceType : "open", sourceType: params.entity ? params.entity.sourceType : "open",
sourceLink: params.project ? params.project.sourceLink : "" sourceLink: params.entity ? params.entity.sourceLink : ""
} }
}) })
const createMutation = trpc.project.insert.useMutation({ let path = usePathname()
onSuccess: makeOnSuccess('create',form,setIsUpdate) let router = useRouter()
}) const createMutation = trpc.project.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) })
const updateMutation = trpc.project.update.useMutation({ const updateMutation = trpc.project.update.useMutation({ onSuccess: makeOnSuccess('update', form) })
onSuccess: makeOnSuccess('update',form) const deleteMutation = trpc.project.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
})
function onSubmit(values: z.infer<typeof schemas.insert>) { function onSubmit(values: z.infer<typeof schemas.insert>) {
params.project ? id ?
updateMutation.mutate(values) : updateMutation.mutate(values) :
createMutation.mutate(values) createMutation.mutate(values)
} }
return ( return (
<Card.Card className={params.className ? params.className : "w-5/6 lg:w-1/2"}> <FormScaffold
<Card.CardHeader> form={form}
<Card.CardTitle> createMutation={createMutation}
<DependentText bool={isUpdate} true='Update Project' false='Create Project'/> updateMutation={updateMutation}
</Card.CardTitle> deleteMutation={deleteMutation}
</Card.CardHeader> onSubmit={onSubmit}
<Card.CardContent> title='Project'
<Form {...form}> id={id}
<form className={params.className}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
> >
<SelectFormField control={form.control} name='stackId' label='Stack' defaultValue={stackId} placeholder={stackName} > <SelectFormField control={form.control} name='stackId' label='Stack' defaultValue={stackId} placeholder={stackName} >
{ {
stacksSuccess ? stacksSuccess ?
<> <>
{stacks.map((stack) => { {stacks?.map((stack) => {
return (<SelectItem key={stack.id} value={stack.id}> {stack.stackItems ? stack.stackItems.join("-") : "Empty Stack"} </SelectItem>) return (<SelectItem key={stack.id} value={stack.id}> {stack.stackItems ? stack.stackItems.join("-") : "Empty Stack"} </SelectItem>)
})} })}
</> : </> :
<SelectItem key='placeholder' value="placeholder" /> <SelectItem key='placeholder' value="placeholder" />
} }
</SelectFormField> </SelectFormField>
<TexttInputFormField control={form.control} name='title' label='Title' /> <TextInputFormField control={form.control} name='title' label='Title' />
<SelectFormField control={form.control} name='sourceType' label='Source Type' defaultValue={'open'} placeholder='open' > <SelectFormField control={form.control} name='sourceType' label='Source Type' defaultValue={'open'} placeholder='open' >
<SelectItem value="open"> open </SelectItem> <SelectItem value="open"> open </SelectItem>
<SelectItem value="closed"> closed </SelectItem> <SelectItem value="closed"> closed </SelectItem>
</SelectFormField> </SelectFormField>
<TexttInputFormField control={form.control} label='Source Link' name='sourceLink' /> <TextInputFormField control={form.control} label='Source Link' name='sourceLink' />
<SelectFormField control={form.control} name='releaseStatus' label='Release Status' defaultValue={'unreleased'} placeholder='unreleased' > <SelectFormField control={form.control} name='releaseStatus' label='Release Status' defaultValue={'unreleased'} placeholder='unreleased' >
<SelectItem value="released"> released </SelectItem> <SelectItem value="released"> released </SelectItem>
<SelectItem value="unreleased"> unreleased </SelectItem> <SelectItem value="unreleased"> unreleased </SelectItem>
</SelectFormField> </SelectFormField>
<TexttInputFormField control={form.control} label='Release Link' name='releaseLink' /> <TextInputFormField control={form.control} label='Release Link' name='releaseLink' />
<Button type="submit"> </FormScaffold>
<DependentText bool={isUpdate} true='Update' false='Create'/>
</Button>
<DependentFormMessaage falseStatus={createMutation.status} trueStatus={updateMutation.status} falseError={createMutation.error} trueError={updateMutation.error} bool={isUpdate}/>
</form>
</Form>
</Card.CardContent>
</Card.Card>
) )
} }

View File

@@ -0,0 +1,60 @@
'use client'
import * as Card from '~/components/ui/card'
import { trpc } from "~/app/_trpc/Client"
import CreateUpdateProjectForm from '../_components/CreateUpdateProjectForm'
import CreateUpdateStackForm from '../techStack/_components/CreateUpdateForm'
import { CollapsibleForm } from '~/app/_components/Form/Components'
import { Suspense } from 'react'
export default function ProjectList() {
const projects = trpc.project.select.useQuery({}, { refetchInterval: 1000 })
const techStacks = trpc.techStack.select.useSuspenseQuery({}, { refetchInterval: 1000 })
return (
<div className="w-5/6 lg:w-1/2 flex flex-col gap-3">
{
projects.data == undefined ?
<></> :
<>
{projects.data.map((project) => {
return (
<Card.Card className="gsapan" key={project.id}>
<Card.CardHeader>
<Card.CardTitle>
Project: {project.title}
</Card.CardTitle>
</Card.CardHeader>
<Card.CardContent className="flex flex-row">
<div className="flex flex-col w-full">
<CreateUpdateProjectForm entity={project} className="w-full" />
<Card.Card className="w-full">
<Card.CardHeader>
<Card.CardTitle>
TechStack:
</Card.CardTitle>
</Card.CardHeader>
<Card.CardContent className="w-full">
<Suspense fallback={(<></>)}>
{
techStacks[0].filter((e) => { return e.id == project.stackId }).length > 0 ? (
<>
{techStacks[0].filter((e) => { return e.id == project.stackId }).map((stack) => (
<CollapsibleForm entityName="Stack" form={CreateUpdateStackForm} entity={stack} entityLabelIndex="stackItems"/>
))}
</>
) : (<></>)
}
</Suspense>
<CollapsibleForm entityName="Stack" form={CreateUpdateStackForm} />
</Card.CardContent>
</Card.Card>
</div>
</Card.CardContent>
</Card.Card>
)
})}
<CollapsibleForm entityName="Project" form={CreateUpdateProjectForm} />
</>
}
</div>
)
}

View File

@@ -1,27 +0,0 @@
import { ChevronsUpDown, Plus } from "lucide-react"
import { Button } from "~/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible";
import { type Element } from "~/lib/utils";
import CreateUpdateProjectForm from "./CreateForm";
import type { ProjectRouterOutputs } from "~/server/routers/project";
export default function CollapsibleForm(params:{project:Element<ProjectRouterOutputs['list']>|undefined}) {
return (
<Collapsible >
<CollapsibleTrigger asChild>
<Button variant={"ghost"}>
{ params.project ?
<>{params.project.title} <ChevronsUpDown/></> : <>New <Plus/></>
}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="autoAlpha">
{
params.project ?
<CreateUpdateProjectForm className="w-full" project={params.project}/> :
<CreateUpdateProjectForm className="w-full" project={params.project}/>
}
</CollapsibleContent>
</Collapsible>
)
}

View File

@@ -2,104 +2,52 @@
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { z } from "zod"; import { z } from "zod";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; import { Form } from "~/components/ui/form";
import { trpc } from "~/app/_trpc/Client"; import { trpc } from "~/app/_trpc/Client";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import * as Card from '~/components/ui/card' import * as Card from '~/components/ui/card'
import type { Entries, IterableElement } from 'type-fest' import type { IterableElement } from 'type-fest'
import { stackItemEnum } from "~/server/db/schema"; import { stackItemEnum } from "~/server/db/schema";
import { ToggleGroup, ToggleGroupItem } from "~/components/ui/toggle-group";
import type { RouterOutputs } from '~/server/routers/_app'; import type { RouterOutputs } from '~/server/routers/_app';
import { entitySchemas } from '~/lib/utils'; import { entitySchemas, makeOnSuccess } from '~/lib/utils';
import { useState } from 'react'; import { useState } from 'react';
import { MultiBooleanFormField } from "~/app/_components/Form/Fields"
import { DependentFormMessaage, FormScaffold } from '~/app/_components/Form/Components';
import DependentText from '~/app/_components/DependentText';
import { usePathname, useRouter } from 'next/navigation';
export default function CreateUpdateStackForm(params:{className?:string, techStack?:IterableElement<RouterOutputs['techStack']['select']>,isUpdate?: boolean }) { export default function CreateUpdateStackForm(params: { className?: string, entity?: IterableElement<RouterOutputs['techStack']['select']> }) {
const schemas = entitySchemas('techStack') const schemas = entitySchemas('techStack')
const form = useForm<z.infer<typeof schemas.insert>>({ const form = useForm<z.infer<typeof schemas.insert>>({
resolver: zodResolver(schemas.insert), resolver: zodResolver(schemas.insert),
defaultValues: { defaultValues: {
id: params.techStack ? params.techStack.id : crypto.randomUUID(), id: params.entity ? params.entity.id : crypto.randomUUID(),
stackItems: params.techStack ? params.techStack.stackItems : [], stackItems: params.entity ? params.entity.stackItems : [],
}
})
const [isUpdate, setIsUpdate] = useState<boolean>(params.isUpdate ? params.isUpdate : (params.techStack ? true : false))
const createMutation = trpc.techStack.insert.useMutation({
onSuccess: (data) => {
form.setValue("id", data[0] ? data[0].id : "");
setIsUpdate(true)
}
})
const updateMutation = trpc.techStack.update.useMutation({
onSuccess: (data) => {
if (data.length > 0 && data[0] !== undefined) {
let entries = Object.entries(data[0]) as Entries<typeof data[0]>
entries.forEach((entry) => {
form.setValue(entry[0], entry[1])
})
}
} }
}) })
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
let path = usePathname()
let router = useRouter()
const createMutation = trpc.techStack.insert.useMutation({ onSuccess: makeOnSuccess('create', form, setId) })
const updateMutation = trpc.techStack.update.useMutation({ onSuccess: makeOnSuccess('update', form) })
const deleteMutation = trpc.techStack.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
function onSubmit(values: z.infer<typeof schemas.insert>) { function onSubmit(values: z.infer<typeof schemas.insert>) {
params.techStack ? params.entity ?
updateMutation.mutate(values) : updateMutation.mutate(values) :
createMutation.mutate(values) createMutation.mutate(values)
} }
return ( return (
<Card.Card className={params.className ? params.className : "w-5/6 lg:w-1/2"}> <FormScaffold
<Card.CardHeader> form={form}
<Card.CardTitle> createMutation={createMutation}
{params.techStack ? "Update" : "Create"} Tech Stack updateMutation={updateMutation}
</Card.CardTitle> deleteMutation={deleteMutation}
</Card.CardHeader> onSubmit={onSubmit}
<Card.CardContent> title='Entry'
<Form {...form}> id={id}
<form className={params.className}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
> >
<FormField <MultiBooleanFormField control={form.control} name='stackItems' label='Stack Items' options={stackItemEnum.enumValues} defaultValues={params.entity?.stackItems} />
control={form.control} </FormScaffold>
name="stackItems"
render={({ field }) => (
<FormItem>
<FormLabel>
Stack Items
</FormLabel>
<FormControl>
<ToggleGroup
variant="outline"
type="multiple"
onValueChange={field.onChange}
className="flex flex-wrap"
>
{stackItemEnum.enumValues.map((v) => {
return (
<ToggleGroupItem className="w-fit" key={v} value={v}>
{v}
</ToggleGroupItem>
)
})}
</ToggleGroup>
</FormControl>
</FormItem>
)}
/>
<Button type="submit"> {params.techStack ? "Update" : "Create"} </Button>
<FormMessage className={updateMutation.status == "success" || createMutation.status == "success" ? "text-green-500" : "text-red-500"}>
{ params.techStack ?
(
<>{updateMutation.error ? updateMutation.error.message : updateMutation.status}</>
) :
(
<>{createMutation.error ? createMutation.error.message : createMutation.status}</>
)
}
</FormMessage>
</form>
</Form>
</Card.CardContent>
</Card.Card>
) )
} }

View File

@@ -12,16 +12,16 @@ type CvCategoryProps = {
children?: React.ReactElement<Parameters<typeof CvEntry>> children?: React.ReactElement<Parameters<typeof CvEntry>>
} }
export default function CvCategory(props:CvCategoryProps) { export default function CvCategory(props:CvCategoryProps) {
const query = trpc.cv.category.get.useQuery({id: props.initialData? props.initialData.id : ""}); const query = trpc.category.select.useQuery({id: props.initialData? props.initialData.id : ""});
if (query.data !== undefined) { if (query.data !== undefined) {
return ( return (
<Card className={cn(props.layout == "row" ? "w-full" : "","gsapan")}> <Card className={cn(props.layout == "row" ? "w-full" : "","gsapan")}>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{query.data?.name} {query.data[0].name}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
{(query.data?.cvEntry.length ? query.data?.cvEntry.length : 0 ) > 0 ? {(query.data?.at(0)?.cvEntry.length ? query.data?.cvEntry.length : 0 ) > 0 ?
<CardContent className={cn(props.layout == "row" ? "flex flex-row flex-wrap justify-center lg:justify-between" : "flex flex-col","gap-[1rem]","overflow-scroll")}> <CardContent className={cn(props.layout == "row" ? "flex flex-row flex-wrap justify-center lg:justify-between" : "flex flex-col","gap-[1rem]","overflow-scroll")}>
{query.data?.cvEntry.map((entry) => ( {query.data?.cvEntry.map((entry) => (
<CvEntry className={props.layout == "row" ? "w-full lg:w-fit" : undefined} key={entry.id} initialData={entry}/> <CvEntry className={props.layout == "row" ? "w-full lg:w-fit" : undefined} key={entry.id} initialData={entry}/>

View File

@@ -1,75 +1,213 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react" import {
import { DayPicker } from "react-day-picker" ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
import { buttonVariants } from "~/components/ui/button" import { Button, buttonVariants } from "~/components/ui/button"
function Calendar({ function Calendar({
className, className,
classNames, classNames,
showOutsideDays = true, showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props ...props
}: React.ComponentProps<typeof DayPicker>) { }: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return ( return (
<DayPicker <DayPicker
showOutsideDays={showOutsideDays} showOutsideDays={showOutsideDays}
className={cn("p-3", className)} className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{ classNames={{
months: "flex flex-col sm:flex-row gap-2", root: cn("w-fit", defaultClassNames.root),
month: "flex flex-col gap-4", months: cn(
caption: "flex justify-center pt-1 relative items-center w-full", "flex gap-4 flex-col md:flex-row relative",
caption_label: "text-sm font-medium", defaultClassNames.months
nav: "flex items-center gap-1",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100"
), ),
nav_button_previous: "absolute left-1", month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav_button_next: "absolute right-1", nav: cn(
table: "w-full border-collapse space-x-1", "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
head_row: "flex", defaultClassNames.nav
head_cell: ),
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", button_previous: cn(
row: "flex w-full mt-2", buttonVariants({ variant: buttonVariant }),
cell: cn( "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md", defaultClassNames.button_previous
props.mode === "range" ),
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" button_next: cn(
: "[&:has([aria-selected])]:rounded-md" buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
), ),
day: cn( day: cn(
buttonVariants({ variant: "ghost" }), "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
"size-8 p-0 font-normal aria-selected:opacity-100" defaultClassNames.day
), ),
day_range_start: range_start: cn(
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground", "rounded-l-md bg-accent",
day_range_end: defaultClassNames.range_start
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground", ),
day_selected: range_middle: cn("rounded-none", defaultClassNames.range_middle),
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
day_today: "bg-accent text-accent-foreground", today: cn(
day_outside: "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
"day-outside text-muted-foreground aria-selected:text-muted-foreground", defaultClassNames.today
day_disabled: "text-muted-foreground opacity-50", ),
day_range_middle: outside: cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground", "text-muted-foreground aria-selected:text-muted-foreground",
day_hidden: "invisible", defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames, ...classNames,
}} }}
components={{ components={{
IconLeft: ({ className, ...props }) => ( Root: ({ className, rootRef, ...props }) => {
<ChevronLeft className={cn("size-4", className)} {...props} /> return (
), <div
IconRight: ({ className, ...props }) => ( data-slot="calendar"
<ChevronRight className={cn("size-4", className)} {...props} /> ref={rootRef}
), className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}} }}
{...props} {...props}
/> />
) )
} }
export { Calendar } function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -76,7 +76,10 @@ function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="drawer-header" data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props} {...props}
/> />
) )

58
src/lib/hooks.ts Normal file
View File

@@ -0,0 +1,58 @@
'use client'
import type { UseTRPCQueryResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs"
import { useEffect, useState } from "react"
function useRelationShipSuccess<T extends Record<string,any> & {id:string},K extends keyof T>(
relationShipData: T[] | undefined,
relationShipNameIndex: K,
setRelationShipId: (arg0: string | undefined) => void,
setRelationShipName: (arg0: string | undefined) => void,
realtionShipDataSuccess: boolean,
id: string|undefined,
nameCallBack?: (arg0:T[K]) => string,
){
useEffect(() => {
if (id === undefined) {
return
}
setRelationShipId(relationShipData?.at(0)?.id)
if (relationShipData !== undefined && relationShipData[0] !==undefined) {
if (relationShipData[0][relationShipNameIndex] !== null) {
if (nameCallBack !== undefined) {
setRelationShipName(nameCallBack(relationShipData[0][relationShipNameIndex]))
return
}
setRelationShipName(relationShipData[0][relationShipNameIndex])
}
}
}, [realtionShipDataSuccess])
}
export function useRelationShip<T extends Record<string,any> & {id: string},TE>(
queryResult: UseTRPCQueryResult<T[],TE>,
relationShipNameIndex: keyof T,
dependsOnId:string|undefined,
) {
const [id, setRelationShipId] = useState<string | undefined>()
const [name, setRealtionShipName] = useState<string | undefined>()
const { data: data, isSuccess: success } = queryResult
useRelationShipSuccess(data,relationShipNameIndex,setRelationShipId,setRealtionShipName,success,dependsOnId)
return {data,id,name,success}
}
export function makeUseRelationShipWithNameIndex<K extends string>(key:K) {
return function useRelationShip<T extends Record<K, any> & { id: string },TE>(
queryResult: UseTRPCQueryResult<T[],TE>,
dependsOnId:string|undefined,
nameCallBack: (arg0:T[K]) => string
) {
const [id, setRelationShipId] = useState<string | undefined>()
const [name, setRealtionShipName] = useState<string | undefined>()
const { data: data, isSuccess: success, error } = queryResult
useRelationShipSuccess(data,key,setRelationShipId,setRealtionShipName,success,dependsOnId,nameCallBack)
return {data,error,id,name,success}
}
}

View File

@@ -4,6 +4,7 @@ import { createSchemaFactory, createSelectSchema, type BuildSchema } from 'drizz
import * as schema from "~/server/db/schema"; import * as schema from "~/server/db/schema";
import { PgTable } from "drizzle-orm/pg-core"; import { PgTable } from "drizzle-orm/pg-core";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
@@ -15,19 +16,19 @@ export type SchemaKeys<S> = {
}[keyof S] }[keyof S]
export type Pretty<T> = { export type Pretty<T> = {
[K in keyof T]: T[K]; [K in keyof T]:
T[K] extends InstanceType<new (...args: any[]) => any> ? T[K]
: T[K] extends object ? Pretty<T[K]>
: T[K];
} & {} } & {}
const { createInsertSchema, createUpdateSchema } = createSchemaFactory({ const { createInsertSchema, createUpdateSchema } = createSchemaFactory({
coerce: { coerce: {
date: true date: true
} }
}) })
export type DatesAreString<T extends {[x:string]: any}> = {
[K in keyof T] : T[K] extends Date ? string : T[K]
}
// type X = { // type X = {
// foo: Date // foo: Date
// bar: number // bar: number
@@ -35,7 +36,8 @@ export type DatesAreString<T extends {[x:string]: any}> = {
// type Y = DatesAreString<X> // type Y = DatesAreString<X>
export function makeOnSuccess<T extends FieldValues>(uc: 'update' | 'create', form: UseFormReturn<T>, setIsUpdate?: (arg0: boolean) => void) {
export function makeOnSuccess<T extends FieldValues>(uc: 'update' | 'create' | 'delete', form: UseFormReturn<T>, setId?: (arg0?: undefined) => void, path?: string, router?: AppRouterInstance) {
switch (uc) { switch (uc) {
case 'update': case 'update':
return (data: T[]) => { return (data: T[]) => {
@@ -46,22 +48,32 @@ export function makeOnSuccess<T extends FieldValues>(uc: 'update' | 'create', fo
} }
} }
case 'create': case 'create':
if (setIsUpdate !== undefined) { if (setId !== undefined) {
return (data: T[]) => { return (data: T[]) => {
form.setValue(("id" as Path<T>), data[0] ? data[0].id : ""); form.setValue(("id" as Path<T>), data[0] ? data[0].id : "");
setIsUpdate(true) setId(data[0] ? data[0].id : undefined)
}
}
case 'delete':
if (path !== undefined && router !== undefined) {
return (_data: T[]) => {
if (path.includes('list')) {
router.refresh();
return;
}
router.back()
return
} }
} }
} }
} }
export function entitySchemas<T extends SchemaKeys<Schema>>(table: T) export function entitySchemas<T extends SchemaKeys<Schema>>(table: T)
// : { : {
// insert: Pretty<BuildSchema<'insert', Schema[T]['_']['columns'], undefined>> insert: Pretty<BuildSchema<'insert', Schema[T]['_']['columns'], undefined>>
// update: Pretty<BuildSchema<'update', Schema[T]['_']['columns'], undefined>> update: Pretty<BuildSchema<'update', Schema[T]['_']['columns'], undefined>>
// select: Pretty<BuildSchema<'select', Schema[T]['_']['columns'], undefined>> select: Pretty<BuildSchema<'select', Schema[T]['_']['columns'], undefined>>
// } } {
{
const insertSchema = createInsertSchema<Schema[T]>(schema[table]); const insertSchema = createInsertSchema<Schema[T]>(schema[table]);
const updateSchema = createUpdateSchema<Schema[T]>(schema[table]); const updateSchema = createUpdateSchema<Schema[T]>(schema[table]);
const selectSchema = createSelectSchema<Schema[T]>(schema[table]); const selectSchema = createSelectSchema<Schema[T]>(schema[table]);

View File

@@ -43,14 +43,13 @@ export function trpcCrudRouterFromDrizzleEntity<T extends SchemaKeys<Schema>>(ta
) )
} }
const { input } = opts; const { input } = opts;
console.log(input)
if (input === undefined) { if (input === undefined) {
throw new TRPCError({ message: "no update input", code: "BAD_REQUEST" }) throw new TRPCError({ message: "no update input", code: "BAD_REQUEST" })
} }
if (!isKeyOf('id',input) || !isKeyOf('id',schema[table])) { if (!isKeyOf('id', input) || !isKeyOf('id', schema[table])) {
throw new TRPCError({ message: "no id provided", code: "BAD_REQUEST" }) throw new TRPCError({ message: "no id provided", code: "BAD_REQUEST" })
} }
return await db.update(schema[table]).set(input).where(eq(schema[table]['id'],input['id'])).returning().execute(); return await db.update(schema[table]).set(input).where(eq(schema[table]['id'], input['id'])).returning().execute();
}), }),
insert: publicProcedure.input(schemas.insert).mutation(async (opts) => { insert: publicProcedure.input(schemas.insert).mutation(async (opts) => {
let admin = await isAdmin(); let admin = await isAdmin();

View File

@@ -0,0 +1,19 @@
import type { QueryProcedure } from '@trpc/server/unstable-core-do-not-import';
import type { Entries } from 'type-fest';
import { assertType, expect, test } from 'vitest'
import { trpcRouter } from '~/server/routers/_app';
const routerEntries = Object.entries(trpcRouter) as Entries<typeof trpcRouter>
routerEntries.forEach(([key, value]) => {
switch (key) {
case '_def': break;
case 'createCaller': break;
default:
test.concurrent(`default router ${key}`, async ({ annotate }) => {
expect(value.select).toBeInstanceOf(Function)
expect(value.update).toBeInstanceOf(Function)
expect(value.delete).toBeInstanceOf(Function)
expect(value.insert).toBeInstanceOf(Function)
})
}
})

View File

@@ -8,7 +8,6 @@ const { router : project } = trpcCrudRouterFromDrizzleEntity('project')
const { router : techStack } = trpcCrudRouterFromDrizzleEntity('techStack') const { router : techStack } = trpcCrudRouterFromDrizzleEntity('techStack')
const { router : category } = trpcCrudRouterFromDrizzleEntity('cvCategory') const { router : category } = trpcCrudRouterFromDrizzleEntity('cvCategory')
const { router : entry } = trpcCrudRouterFromDrizzleEntity('cvEntry') const { router : entry } = trpcCrudRouterFromDrizzleEntity('cvEntry')
const root = {}
export const trpcRouter = router({ export const trpcRouter = router({
project: project, project: project,
techStack: techStack, techStack: techStack,

View File

@@ -0,0 +1,11 @@
import { vi } from "vitest";
import { drizzle } from 'drizzle-orm/pglite';
import { PGlite } from "@electric-sql/pglite";
import * as schema from "~/server/db/schema"
const client = new PGlite()
const db = drizzle({client:client,schema:schema})
vi.mock("~/server/db", () => {
return {
db: db
}
})

View File

@@ -0,0 +1,9 @@
import { vi } from 'vitest'
vi.mock('~/app/actions', () => {
return {
isAdmin: vi.fn(() => {
console.log("_____idAdminMockCall_____")
return true
})
}
})

View File

@@ -0,0 +1,15 @@
import { vi } from "vitest"
vi.mock("next/navigation", () => {
return {
useRouter: vi.fn(() => {
return {
push: () => {},
replace: () => {},
prefetch: () => {},
}
}),
usePathname: vi.fn(() => '/'),
useSearchParams: vi.fn(() => new URLSearchParams()),
useServerInsertedHTML:vi.fn(() => {})
}
})

View File

@@ -0,0 +1,16 @@
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { trpc } from '~/app/_trpc/Client'
import { createTestTrpcClient } from './vitest.trpcProxyClient'
export default function TrpcProvider({ children }:{children: React.ReactNode}) {
const queryClient = new QueryClient()
const trpcClient = createTestTrpcClient()
console.log("using test provider")
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
)}

View File

@@ -0,0 +1,26 @@
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { trpcRouter, type TrpcRouter } from '~/server/routers/_app'
// This simulates the server without HTTP
function serverHandler(path: string, req: Request) {
return fetchRequestHandler({
endpoint: '/api/trpc',
router: trpcRouter,
req,
createContext: () => ({}),
})
}
export function createTestTrpcClient() {
return createTRPCProxyClient<TrpcRouter>({
links: [
httpBatchLink({
url: 'http://localhost/api/trpc',
fetch: (input, init) => {
return serverHandler(input as string, new Request(input as string, init))
},
}),
],
})
}

59
vitest.config.mts Normal file
View File

@@ -0,0 +1,59 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
import dotenv from 'dotenv'
import path from 'path'
const env = dotenv.config({path: './.env'})
console.log('dname', __dirname)
console.log(env.error)
// console.log(env.parsed)
export default defineConfig({
plugins: [tsconfigPaths(), react()],
resolve: {
alias: {
'~/app/_trpc/TrpcProvicer': path.resolve(__dirname,'./vitest.trpcProvider.mock')
}
},
optimizeDeps: {
include: [
'@tanstack/react-query-next-experimental',
],
},
ssr: {
noExternal: [
'@tanstack/react-query-next-experimental',
],
},
test: {
reporters: ['verbose'],
coverage: { provider: 'v8'},
projects: [
{
extends: true,
test: {
name: "server",
setupFiles: [
'./test/mocks/vitest.isAdmin.mock.ts',
'./test/mocks/vitest.nextnavigation.mock.ts',
'./test/mocks/vitest.drizzle.mock.ts'
],
include: ['./src/**/*.server.test.ts'],
env: env.parsed
}
},
{
extends: true,
test: {
name: "client",
setupFiles: [
'./test/mocks/vitest.isAdmin.mock.ts',
'./test/mocks/vitest.nextnavigation.mock.ts',
'./test/mocks/vitest.drizzle.mock.ts'
],
include: ['./src/**/*.client.test.ts','./src/**/*.client.test.tsx'],
environment: 'jsdom',
}
}
]
},
})