From 8d9ea5dcde159a622b7d599eccbdc16aed2f97f2 Mon Sep 17 00:00:00 2001 From: "Andreas Petersen (Guldberg)" Date: Fri, 20 Oct 2023 16:13:18 +0200 Subject: [PATCH] Add edit functionality (#574) * Add edit functionality * Remove old add recipe form * Remove meta data from recipe edit page * Fix code smells * Fix all type errors and formatted files --- package.json | 3 + pnpm-lock.yaml | 35 +++- prisma/schema.prisma | 12 +- prisma/seed.ts | 2 - .../recipe/[recipeId]/edit/loading.tsx | 5 + .../recipe/[recipeId]/edit/not-found.tsx | 15 ++ .../dashboard/recipe/[recipeId]/edit/page.tsx | 48 +++++ .../dashboard/recipe/[recipeId]/page.tsx | 2 +- .../dashboard/recipes/new/page.tsx | 2 +- src/app/actions.ts | 12 +- .../data-table/data-table-column-header.tsx | 39 ++-- .../data-table/data-table-faceted-filter.tsx | 51 +++-- .../data-table/data-table-loading.tsx | 18 +- .../data-table/data-table-pagination.tsx | 31 ++- .../data-table/data-table-toolbar.tsx | 59 +++--- .../data-table/data-table-view-options.tsx | 23 ++- src/components/data-table/data-table.tsx | 138 ++++++------- src/components/date-range-picker.tsx | 87 ++++---- src/components/forms/recipe/AddRecipeForm.tsx | 43 ++++ .../RecipeForm.tsx} | 185 +++++++++--------- .../forms/recipe/UpdateRecipeForm.tsx | 59 ++++++ src/components/recipeView.tsx | 28 ++- src/trpc/recipe/recipeRouter.ts | 69 ++++--- src/trpc/recipe/recipeUtil.ts | 34 +++- tsconfig.json | 3 +- 25 files changed, 624 insertions(+), 379 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/recipe/[recipeId]/edit/loading.tsx create mode 100644 src/app/(dashboard)/dashboard/recipe/[recipeId]/edit/not-found.tsx create mode 100644 src/app/(dashboard)/dashboard/recipe/[recipeId]/edit/page.tsx create mode 100644 src/components/forms/recipe/AddRecipeForm.tsx rename src/components/forms/{add-recipe-form.tsx => recipe/RecipeForm.tsx} (65%) create mode 100644 src/components/forms/recipe/UpdateRecipeForm.tsx diff --git a/package.json b/package.json index bfa6cd78..f639f9de 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "contentlayer": "^0.3.4", "date-fns": "^2.30.0", "langchain": "^0.0.153", + "lodash": "^4.17.21", "lucide-react": "^0.279.0", "next": "13.5.2", "next-contentlayer": "^0.3.4", @@ -102,6 +103,7 @@ "simplebar-react": "^3.2.4", "sonner": "^1.0.3", "stripe": "^13.7.0", + "superjson": "^2.0.0", "tailwind-merge": "^1.14.0", "tailwindcss": "3.3.3", "tailwindcss-animate": "^1.0.7", @@ -117,6 +119,7 @@ "@faker-js/faker": "^8.1.0", "@ianvs/prettier-plugin-sort-imports": "^4.1.0", "@types/eslint": "^8.44.3", + "@types/lodash": "^4.14.200", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", "eslint": "8.51.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3cec9dd..2d71a74e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,9 @@ dependencies: langchain: specifier: ^0.0.153 version: 0.0.153(@pinecone-database/pinecone@1.1.0)(@planetscale/database@1.11.0)(pdf-parse@1.1.1) + lodash: + specifier: ^4.17.21 + version: 4.17.21 lucide-react: specifier: ^0.279.0 version: 0.279.0(react@18.2.0) @@ -233,6 +236,9 @@ dependencies: stripe: specifier: ^13.7.0 version: 13.9.0 + superjson: + specifier: ^2.0.0 + version: 2.0.0 tailwind-merge: specifier: ^1.14.0 version: 1.14.0 @@ -265,6 +271,9 @@ devDependencies: '@types/eslint': specifier: ^8.44.3 version: 8.44.3 + '@types/lodash': + specifier: ^4.14.200 + version: 4.14.200 '@typescript-eslint/eslint-plugin': specifier: ^6.7.4 version: 6.7.4(@typescript-eslint/parser@6.7.4)(eslint@8.51.0)(typescript@5.2.2) @@ -3322,12 +3331,11 @@ packages: /@types/lodash-es@4.17.9: resolution: {integrity: sha512-ZTcmhiI3NNU7dEvWLZJkzG6ao49zOIjEgIE0RgV7wbPxU0f2xT3VSAHw2gmst8swH6V0YkLRGp4qPlX/6I90MQ==} dependencies: - '@types/lodash': 4.14.199 + '@types/lodash': 4.14.200 dev: false - /@types/lodash@4.14.199: - resolution: {integrity: sha512-Vrjz5N5Ia4SEzWWgIVwnHNEnb1UE1XMkvY5DGXrAeOGE9imk0hgTHh5GyDjLDJi9OTCn9oo9dXH1uToK1VRfrg==} - dev: false + /@types/lodash@4.14.200: + resolution: {integrity: sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==} /@types/mdast@3.0.13: resolution: {integrity: sha512-HjiGiWedR0DVFkeNljpa6Lv4/IZU1+30VY5d747K7lBudFc3R0Ibr6yJ9lN3BE28VnZyDfLF/VB1Ql1ZIbKrmg==} @@ -4362,6 +4370,13 @@ packages: engines: {node: '>= 0.6'} dev: false + /copy-anything@3.0.5: + resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} + engines: {node: '>=12.13'} + dependencies: + is-what: 4.1.15 + dev: false + /core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} dev: false @@ -6031,6 +6046,11 @@ packages: get-intrinsic: 1.2.1 dev: true + /is-what@4.1.15: + resolution: {integrity: sha512-uKua1wfy3Yt+YqsD6mTUEa2zSi3G1oPlqTflgaPJ7z63vUGN5pxFpnQfeSLMFnJDEsdvOtkp1rUWkYjB4YfhgA==} + engines: {node: '>=12.13'} + dev: false + /isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} dev: true @@ -9116,6 +9136,13 @@ packages: pirates: 4.0.6 ts-interface-checker: 0.1.13 + /superjson@2.0.0: + resolution: {integrity: sha512-W3n+NJ7TFjaLle8ihIIvsr/bbuKpnxeatsyjmhy7iSkom+/cshaHziCQAWXrHGWJVQSQFDOuES6C3nSEvcbrQg==} + engines: {node: '>=16'} + dependencies: + copy-anything: 3.0.5 + dev: false + /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 421509f8..4a90852c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,12 +15,12 @@ datasource db { model Recipe { id String @id @default(cuid()) title String - description String? @db.LongText + description String @db.LongText isPublic Boolean @default(false) userId String - timeInKitchen Int? - waitingTime Int? - numberOfPeople Int? + timeInKitchen Int + waitingTime Int + numberOfPeople Int ratings RecipeRating[] ingredients IngredientsOnRecipes[] steps Step[] @@ -40,7 +40,7 @@ model RecipeRating { model Ingredient { id String @id @default(cuid()) name String - unit String? + unit String userId String recipes IngredientsOnRecipes[] } @@ -52,12 +52,10 @@ model IngredientsOnRecipes { recipeId String quantity Float assignedAt DateTime @default(now()) - assignedBy String? @@id([ingredientId, recipeId]) @@index([ingredientId]) @@index([recipeId]) - @@index([assignedBy]) } model Step { diff --git a/prisma/seed.ts b/prisma/seed.ts index c01758b3..e9db2ccf 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -23,7 +23,6 @@ async function main() { }, }, quantity: 10, - assignedBy: '1', }, { ingredient: { @@ -34,7 +33,6 @@ async function main() { }, }, quantity: 5, - assignedBy: '1', }, ], }, diff --git a/src/app/(dashboard)/dashboard/recipe/[recipeId]/edit/loading.tsx b/src/app/(dashboard)/dashboard/recipe/[recipeId]/edit/loading.tsx new file mode 100644 index 00000000..91bf8960 --- /dev/null +++ b/src/app/(dashboard)/dashboard/recipe/[recipeId]/edit/loading.tsx @@ -0,0 +1,5 @@ +import { RecipeSkeleton } from '@/components/skeleton/RecipeSkeleton'; + +export default function RecipeLoading() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/recipe/[recipeId]/edit/not-found.tsx b/src/app/(dashboard)/dashboard/recipe/[recipeId]/edit/not-found.tsx new file mode 100644 index 00000000..ccd7f8af --- /dev/null +++ b/src/app/(dashboard)/dashboard/recipe/[recipeId]/edit/not-found.tsx @@ -0,0 +1,15 @@ +import { ErrorCard } from '@/components/cards/error-card'; +import { Shell } from '@/components/shells/shell'; + +export default function RecipeNotFound() { + return ( + + + + ); +} diff --git a/src/app/(dashboard)/dashboard/recipe/[recipeId]/edit/page.tsx b/src/app/(dashboard)/dashboard/recipe/[recipeId]/edit/page.tsx new file mode 100644 index 00000000..f02bab7c --- /dev/null +++ b/src/app/(dashboard)/dashboard/recipe/[recipeId]/edit/page.tsx @@ -0,0 +1,48 @@ +import { serverClient } from '@/app/_trpc/serverClient'; +import { EditRecipeForm } from '@/components/forms/recipe/UpdateRecipeForm'; +import { Breadcrumbs } from '@/components/pagers/breadcrumbs'; +import { Shell } from '@/components/shells/shell'; +import { currentUser } from '@clerk/nextjs/server'; +import { notFound } from 'next/navigation'; + +interface ProductPageProps { + params: { + recipeId: string; + }; +} + +export default async function RecipeEditPage({ + params, +}: Readonly) { + if (!params.recipeId) { + notFound(); + } + + const user = await currentUser(); + + const recipe = await serverClient.recipe + .getRecipe({ id: params.recipeId }) + .catch(() => null); + + if (!recipe || !user || recipe.userId !== user.id) { + notFound(); + } + + return ( + + + + + ); +} diff --git a/src/app/(dashboard)/dashboard/recipe/[recipeId]/page.tsx b/src/app/(dashboard)/dashboard/recipe/[recipeId]/page.tsx index 5be336d7..8ebff56c 100644 --- a/src/app/(dashboard)/dashboard/recipe/[recipeId]/page.tsx +++ b/src/app/(dashboard)/dashboard/recipe/[recipeId]/page.tsx @@ -31,7 +31,7 @@ export async function generateMetadata({ params }: ProductPageProps) { }; } -export default async function ProductPage({ +export default async function RecipeViewPage({ params, }: Readonly) { if (!params.recipeId) { diff --git a/src/app/(dashboard)/dashboard/recipes/new/page.tsx b/src/app/(dashboard)/dashboard/recipes/new/page.tsx index 9ac91efb..99837d7b 100644 --- a/src/app/(dashboard)/dashboard/recipes/new/page.tsx +++ b/src/app/(dashboard)/dashboard/recipes/new/page.tsx @@ -1,4 +1,4 @@ -import { AddRecipeForm } from '@/components/forms/add-recipe-form'; +import { AddRecipeForm } from '@/components/forms/recipe/AddRecipeForm'; import { PageHeader, PageHeaderDescription, diff --git a/src/app/actions.ts b/src/app/actions.ts index 4b69637e..1512c361 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -9,11 +9,19 @@ export async function createRecipeRevalidate() { revalidatePath('/dashboard/recipes'); } +export async function updateRecipeRevalidate(id: string) { + revalidatePath('/recipes'); + revalidatePath(`/recipe/${id}`); + revalidatePath(`/dashboard/recipes`); + revalidatePath(`/dashboard/recipe/${id}`); + revalidatePath(`/dashboard/recipe/${id}/edit`); +} + export async function deleteRecipeRevalidate(id: string) { revalidatePath('/recipes'); - revalidatePath(`/recipes/${id}`); + revalidatePath(`/recipe/${id}`); revalidatePath(`/dashboard/recipes`); - revalidatePath(`/dashboard/recipes/${id}`); + revalidatePath(`/dashboard/recipe/${id}`); } // For development diff --git a/src/components/data-table/data-table-column-header.tsx b/src/components/data-table/data-table-column-header.tsx index 10fd3aff..39e424d4 100644 --- a/src/components/data-table/data-table-column-header.tsx +++ b/src/components/data-table/data-table-column-header.tsx @@ -1,25 +1,24 @@ -import { - ArrowDownIcon, - ArrowUpIcon, - CaretSortIcon, - EyeNoneIcon, -} from "@radix-ui/react-icons" -import { type Column } from "@tanstack/react-table" - -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" +import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; +import { + ArrowDownIcon, + ArrowUpIcon, + CaretSortIcon, + EyeNoneIcon, +} from '@radix-ui/react-icons'; +import { type Column } from '@tanstack/react-table'; interface DataTableColumnHeaderProps extends React.HTMLAttributes { - column: Column - title: string + column: Column; + title: string; } export function DataTableColumnHeader({ @@ -28,18 +27,18 @@ export function DataTableColumnHeader({ className, }: DataTableColumnHeaderProps) { if (!column.getCanSort()) { - return
{title}
+ return
{title}
; } return ( -
+
- ) + ); } diff --git a/src/components/data-table/data-table-faceted-filter.tsx b/src/components/data-table/data-table-faceted-filter.tsx index fe62098d..8f3eae99 100644 --- a/src/components/data-table/data-table-faceted-filter.tsx +++ b/src/components/data-table/data-table-faceted-filter.tsx @@ -1,11 +1,5 @@ -import * as React from "react" -import { type Option } from "@/types" -import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons" -import { type Column } from "@tanstack/react-table" - -import { cn } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { Command, CommandEmpty, @@ -14,18 +8,23 @@ import { CommandItem, CommandList, CommandSeparator, -} from "@/components/ui/command" +} from '@/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger, -} from "@/components/ui/popover" -import { Separator } from "@/components/ui/separator" +} from '@/components/ui/popover'; +import { Separator } from '@/components/ui/separator'; +import { cn } from '@/lib/utils'; +import { type Option } from '@/types'; +import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'; +import { type Column } from '@tanstack/react-table'; +import * as React from 'react'; interface DataTableFacetedFilter { - column?: Column - title?: string - options: Option[] + column?: Column; + title?: string; + options: Option[]; } export function DataTableFacetedFilter({ @@ -33,7 +32,7 @@ export function DataTableFacetedFilter({ title, options, }: DataTableFacetedFilter) { - const selectedValues = new Set(column?.getFilterValue() as string[]) + const selectedValues = new Set(column?.getFilterValue() as string[]); return ( @@ -88,31 +87,31 @@ export function DataTableFacetedFilter({ No results found. {options.map((option) => { - const isSelected = selectedValues.has(option.value) + const isSelected = selectedValues.has(option.value); return ( { if (isSelected) { - selectedValues.delete(option.value) + selectedValues.delete(option.value); } else { - selectedValues.add(option.value) + selectedValues.add(option.value); } - const filterValues = Array.from(selectedValues) + const filterValues = Array.from(selectedValues); column?.setFilterValue( filterValues.length ? filterValues : undefined - ) + ); }} >
-
{option.icon && ( ({ )} {option.label}
- ) + ); })}
{selectedValues.size > 0 && ( @@ -142,5 +141,5 @@ export function DataTableFacetedFilter({
- ) + ); } diff --git a/src/components/data-table/data-table-loading.tsx b/src/components/data-table/data-table-loading.tsx index 593456d5..b5888c29 100644 --- a/src/components/data-table/data-table-loading.tsx +++ b/src/components/data-table/data-table-loading.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from "@/components/ui/skeleton" +import { Skeleton } from '@/components/ui/skeleton'; import { Table, TableBody, @@ -6,15 +6,15 @@ import { TableHead, TableHeader, TableRow, -} from "@/components/ui/table" +} from '@/components/ui/table'; interface DataTableLoadingProps { - columnCount: number - rowCount?: number - isNewRowCreatable?: boolean - isRowsDeletable?: boolean - searchableFieldCount?: number - filterableFieldCount?: number + columnCount: number; + rowCount?: number; + isNewRowCreatable?: boolean; + isRowsDeletable?: boolean; + searchableFieldCount?: number; + filterableFieldCount?: number; } export function DataTableLoading({ @@ -96,5 +96,5 @@ export function DataTableLoading({
- ) + ); } diff --git a/src/components/data-table/data-table-pagination.tsx b/src/components/data-table/data-table-pagination.tsx index 62f4e40e..2dec240e 100644 --- a/src/components/data-table/data-table-pagination.tsx +++ b/src/components/data-table/data-table-pagination.tsx @@ -1,23 +1,22 @@ -import { - ChevronLeftIcon, - ChevronRightIcon, - DoubleArrowLeftIcon, - DoubleArrowRightIcon, -} from "@radix-ui/react-icons" -import { type Table } from "@tanstack/react-table" - -import { Button } from "@/components/ui/button" +import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" +} from '@/components/ui/select'; +import { + ChevronLeftIcon, + ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from '@radix-ui/react-icons'; +import { type Table } from '@tanstack/react-table'; interface DataTablePaginationProps { - table: Table - pageSizeOptions?: number[] + table: Table; + pageSizeOptions?: number[]; } export function DataTablePagination({ @@ -27,7 +26,7 @@ export function DataTablePagination({ return (
- {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredSelectedRowModel().rows.length} of{' '} {table.getFilteredRowModel().rows.length} row(s) selected.
@@ -36,7 +35,7 @@ export function DataTablePagination({
- Page {table.getState().pagination.pageIndex + 1} of{" "} + Page {table.getState().pagination.pageIndex + 1} of{' '} {table.getPageCount()}
@@ -99,5 +98,5 @@ export function DataTablePagination({
- ) + ); } diff --git a/src/components/data-table/data-table-toolbar.tsx b/src/components/data-table/data-table-toolbar.tsx index dbe73635..e32fc4a3 100644 --- a/src/components/data-table/data-table-toolbar.tsx +++ b/src/components/data-table/data-table-toolbar.tsx @@ -1,26 +1,25 @@ -"use client" +'use client'; -import * as React from "react" -import Link from "next/link" +import { DataTableFacetedFilter } from '@/components/data-table/data-table-faceted-filter'; +import { DataTableViewOptions } from '@/components/data-table/data-table-view-options'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; import type { DataTableFilterableColumn, DataTableSearchableColumn, -} from "@/types" -import { Cross2Icon, PlusCircledIcon, TrashIcon } from "@radix-ui/react-icons" -import type { Table } from "@tanstack/react-table" - -import { cn } from "@/lib/utils" -import { Button, buttonVariants } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { DataTableFacetedFilter } from "@/components/data-table/data-table-faceted-filter" -import { DataTableViewOptions } from "@/components/data-table/data-table-view-options" +} from '@/types'; +import { Cross2Icon, PlusCircledIcon, TrashIcon } from '@radix-ui/react-icons'; +import type { Table } from '@tanstack/react-table'; +import Link from 'next/link'; +import * as React from 'react'; interface DataTableToolbarProps { - table: Table - filterableColumns?: DataTableFilterableColumn[] - searchableColumns?: DataTableSearchableColumn[] - newRowLink?: string - deleteRowsAction?: React.MouseEventHandler + table: Table; + filterableColumns?: DataTableFilterableColumn[]; + searchableColumns?: DataTableSearchableColumn[]; + newRowLink?: string; + deleteRowsAction?: React.MouseEventHandler; } export function DataTableToolbar({ @@ -30,8 +29,8 @@ export function DataTableToolbar({ newRowLink, deleteRowsAction, }: DataTableToolbarProps) { - const isFiltered = table.getState().columnFilters.length > 0 - const [isPending, startTransition] = React.useTransition() + const isFiltered = table.getState().columnFilters.length > 0; + const [isPending, startTransition] = React.useTransition(); return (
@@ -39,14 +38,14 @@ export function DataTableToolbar({ {searchableColumns.length > 0 && searchableColumns.map( (column) => - table.getColumn(column.id ? String(column.id) : "") && ( + table.getColumn(column.id ? String(column.id) : '') && ( table @@ -60,10 +59,10 @@ export function DataTableToolbar({ {filterableColumns.length > 0 && filterableColumns.map( (column) => - table.getColumn(column.id ? String(column.id) : "") && ( + table.getColumn(column.id ? String(column.id) : '') && ( @@ -90,9 +89,9 @@ export function DataTableToolbar({ className="h-8" onClick={(event) => { startTransition(() => { - table.toggleAllPageRowsSelected(false) - deleteRowsAction(event) - }) + table.toggleAllPageRowsSelected(false); + deleteRowsAction(event); + }); }} disabled={isPending} > @@ -104,9 +103,9 @@ export function DataTableToolbar({
@@ -118,5 +117,5 @@ export function DataTableToolbar({
- ) + ); } diff --git a/src/components/data-table/data-table-view-options.tsx b/src/components/data-table/data-table-view-options.tsx index b39fa76e..a64ebd8c 100644 --- a/src/components/data-table/data-table-view-options.tsx +++ b/src/components/data-table/data-table-view-options.tsx @@ -1,21 +1,20 @@ -"use client" +'use client'; -import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu" -import { MixerHorizontalIcon } from "@radix-ui/react-icons" -import { type Table } from "@tanstack/react-table" - -import { toSentenceCase } from "@/lib/utils" -import { Button } from "@/components/ui/button" +import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, -} from "@/components/ui/dropdown-menu" +} from '@/components/ui/dropdown-menu'; +import { toSentenceCase } from '@/lib/utils'; +import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'; +import { MixerHorizontalIcon } from '@radix-ui/react-icons'; +import { type Table } from '@tanstack/react-table'; interface DataTableViewOptionsProps { - table: Table + table: Table; } export function DataTableViewOptions({ @@ -41,7 +40,7 @@ export function DataTableViewOptions({ .getAllColumns() .filter( (column) => - typeof column.accessorFn !== "undefined" && column.getCanHide() + typeof column.accessorFn !== 'undefined' && column.getCanHide() ) .map((column) => { return ( @@ -53,9 +52,9 @@ export function DataTableViewOptions({ > {toSentenceCase(column.id)} - ) + ); })} - ) + ); } diff --git a/src/components/data-table/data-table.tsx b/src/components/data-table/data-table.tsx index b8b873c4..3f3f0016 100644 --- a/src/components/data-table/data-table.tsx +++ b/src/components/data-table/data-table.tsx @@ -1,9 +1,18 @@ -import * as React from "react" -import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { DataTablePagination } from '@/components/data-table/data-table-pagination'; +import { DataTableToolbar } from '@/components/data-table/data-table-toolbar'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useDebounce } from '@/hooks/use-debounce'; import type { DataTableFilterableColumn, DataTableSearchableColumn, -} from "@/types" +} from '@/types'; import { flexRender, getCoreRowModel, @@ -18,28 +27,18 @@ import { type PaginationState, type SortingState, type VisibilityState, -} from "@tanstack/react-table" - -import { useDebounce } from "@/hooks/use-debounce" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { DataTablePagination } from "@/components/data-table/data-table-pagination" -import { DataTableToolbar } from "@/components/data-table/data-table-toolbar" +} from '@tanstack/react-table'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import * as React from 'react'; interface DataTableProps { - columns: ColumnDef[] - data: TData[] - pageCount: number - filterableColumns?: DataTableFilterableColumn[] - searchableColumns?: DataTableSearchableColumn[] - newRowLink?: string - deleteRowsAction?: React.MouseEventHandler + columns: ColumnDef[]; + data: TData[]; + pageCount: number; + filterableColumns?: DataTableFilterableColumn[]; + searchableColumns?: DataTableSearchableColumn[]; + newRowLink?: string; + deleteRowsAction?: React.MouseEventHandler; } export function DataTable({ @@ -51,48 +50,48 @@ export function DataTable({ newRowLink, deleteRowsAction, }: DataTableProps) { - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); // Search params - const page = searchParams?.get("page") ?? "1" - const per_page = searchParams?.get("per_page") ?? "10" - const sort = searchParams?.get("sort") - const [column, order] = sort?.split(".") ?? [] + const page = searchParams?.get('page') ?? '1'; + const per_page = searchParams?.get('per_page') ?? '10'; + const sort = searchParams?.get('sort'); + const [column, order] = sort?.split('.') ?? []; // Create query string const createQueryString = React.useCallback( (params: Record) => { - const newSearchParams = new URLSearchParams(searchParams?.toString()) + const newSearchParams = new URLSearchParams(searchParams?.toString()); for (const [key, value] of Object.entries(params)) { if (value === null) { - newSearchParams.delete(key) + newSearchParams.delete(key); } else { - newSearchParams.set(key, String(value)) + newSearchParams.set(key, String(value)); } } - return newSearchParams.toString() + return newSearchParams.toString(); }, [searchParams] - ) + ); // Table states - const [rowSelection, setRowSelection] = React.useState({}) + const [rowSelection, setRowSelection] = React.useState({}); const [columnVisibility, setColumnVisibility] = - React.useState({}) + React.useState({}); const [columnFilters, setColumnFilters] = React.useState( [] - ) + ); // Handle server-side pagination const [{ pageIndex, pageSize }, setPagination] = React.useState({ pageIndex: Number(page) - 1, pageSize: Number(per_page), - }) + }); const pagination = React.useMemo( () => ({ @@ -100,14 +99,14 @@ export function DataTable({ pageSize, }), [pageIndex, pageSize] - ) + ); React.useEffect(() => { setPagination({ pageIndex: Number(page) - 1, pageSize: Number(per_page), - }) - }, [page, per_page]) + }); + }, [page, per_page]); React.useEffect(() => { router.push( @@ -118,63 +117,64 @@ export function DataTable({ { scroll: false, } - ) + ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pageIndex, pageSize]) + }, [pageIndex, pageSize]); // Handle server-side sorting const [sorting, setSorting] = React.useState([ { - id: column ?? "", - desc: order === "desc", + id: column ?? '', + desc: order === 'desc', }, - ]) + ]); React.useEffect(() => { router.push( `${pathname}?${createQueryString({ page, sort: sorting[0]?.id - ? `${sorting[0]?.id}.${sorting[0]?.desc ? "desc" : "asc"}` + ? `${sorting[0]?.id}.${sorting[0]?.desc ? 'desc' : 'asc'}` : null, })}`, { scroll: false, } - ) + ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sorting]) + }, [sorting]); // Handle server-side filtering const debouncedSearchableColumnFilters = JSON.parse( useDebounce( JSON.stringify( columnFilters.filter((filter) => { - return searchableColumns.find((column) => column.id === filter.id) + return searchableColumns.find((column) => column.id === filter.id); }) ), 500 ) - ) as ColumnFiltersState + ) as ColumnFiltersState; const filterableColumnFilters = columnFilters.filter((filter) => { - return filterableColumns.find((column) => column.id === filter.id) - }) + return filterableColumns.find((column) => column.id === filter.id); + }); React.useEffect(() => { + if (!searchParams) return; for (const column of debouncedSearchableColumnFilters) { - if (typeof column.value === "string") { + if (typeof column.value === 'string') { router.push( `${pathname}?${createQueryString({ page: 1, - [column.id]: typeof column.value === "string" ? column.value : null, + [column.id]: typeof column.value === 'string' ? column.value : null, })}`, { scroll: false, } - ) + ); } } @@ -191,24 +191,26 @@ export function DataTable({ { scroll: false, } - ) + ); } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSearchableColumnFilters]) + }, [debouncedSearchableColumnFilters]); React.useEffect(() => { + if (!searchParams) return; + for (const column of filterableColumnFilters) { - if (typeof column.value === "object" && Array.isArray(column.value)) { + if (typeof column.value === 'object' && Array.isArray(column.value)) { router.push( `${pathname}?${createQueryString({ page: 1, - [column.id]: column.value.join("."), + [column.id]: column.value.join('.'), })}`, { scroll: false, } - ) + ); } } @@ -225,11 +227,11 @@ export function DataTable({ { scroll: false, } - ) + ); } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filterableColumnFilters]) + }, [filterableColumnFilters]); const table = useReactTable({ data, @@ -257,7 +259,7 @@ export function DataTable({ manualPagination: true, manualSorting: true, manualFiltering: true, - }) + }); return (
@@ -283,7 +285,7 @@ export function DataTable({ header.getContext() )} - ) + ); })} ))} @@ -293,7 +295,7 @@ export function DataTable({ table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( @@ -320,5 +322,5 @@ export function DataTable({
- ) + ); } diff --git a/src/components/date-range-picker.tsx b/src/components/date-range-picker.tsx index 7f0ed715..1b238ee1 100644 --- a/src/components/date-range-picker.tsx +++ b/src/components/date-range-picker.tsx @@ -1,107 +1,106 @@ -"use client" +'use client'; -import * as React from "react" -import { usePathname, useRouter, useSearchParams } from "next/navigation" -import { CalendarIcon } from "@radix-ui/react-icons" -import { addDays, format } from "date-fns" -import type { DateRange } from "react-day-picker" - -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Calendar } from "@/components/ui/calendar" +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; import { Popover, PopoverContent, PopoverTrigger, -} from "@/components/ui/popover" +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { CalendarIcon } from '@radix-ui/react-icons'; +import { addDays, format } from 'date-fns'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import * as React from 'react'; +import type { DateRange } from 'react-day-picker'; interface DateRangePickerProps extends React.HTMLAttributes { - dateRange?: DateRange - dayCount?: number - align?: "center" | "start" | "end" + dateRange?: DateRange; + dayCount?: number; + align?: 'center' | 'start' | 'end'; } export function DateRangePicker({ dateRange, dayCount, - align = "start", + align = 'start', className, ...props }: DateRangePickerProps) { - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const [from, to] = React.useMemo(() => { - let fromDay: Date | undefined - let toDay: Date | undefined + let fromDay: Date | undefined; + let toDay: Date | undefined; if (dateRange) { - fromDay = dateRange.from - toDay = dateRange.to + fromDay = dateRange.from; + toDay = dateRange.to; } else if (dayCount) { - toDay = new Date() - fromDay = addDays(toDay, -dayCount) + toDay = new Date(); + fromDay = addDays(toDay, -dayCount); } - return [fromDay, toDay] - }, [dateRange, dayCount]) + return [fromDay, toDay]; + }, [dateRange, dayCount]); - const [date, setDate] = React.useState({ from, to }) + const [date, setDate] = React.useState({ from, to }); // Create query string const createQueryString = React.useCallback( (params: Record) => { - const newSearchParams = new URLSearchParams(searchParams?.toString()) + const newSearchParams = new URLSearchParams(searchParams?.toString()); for (const [key, value] of Object.entries(params)) { if (value === null) { - newSearchParams.delete(key) + newSearchParams.delete(key); } else { - newSearchParams.set(key, String(value)) + newSearchParams.set(key, String(value)); } } - return newSearchParams.toString() + return newSearchParams.toString(); }, [searchParams] - ) + ); // Update query string React.useEffect(() => { router.push( `${pathname}?${createQueryString({ - from: date?.from ? format(date.from, "yyyy-MM-dd") : null, - to: date?.to ? format(date.to, "yyyy-MM-dd") : null, + from: date?.from ? format(date.from, 'yyyy-MM-dd') : null, + to: date?.to ? format(date.to, 'yyyy-MM-dd') : null, })}`, { scroll: false, } - ) + ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [date?.from, date?.to]) + }, [date?.from, date?.to]); return ( -
+
- ) + ); } diff --git a/src/components/forms/recipe/AddRecipeForm.tsx b/src/components/forms/recipe/AddRecipeForm.tsx new file mode 100644 index 00000000..e80e909d --- /dev/null +++ b/src/components/forms/recipe/AddRecipeForm.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { trpc } from '@/app/_trpc/client'; +import { createRecipeRevalidate } from '@/app/actions'; +import { catchError } from '@/lib/utils'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { RecipeForm, type RecipeFormInput } from './RecipeForm'; + +export function AddRecipeForm() { + const router = useRouter(); + + const createRecipe = trpc.recipe.createRecipe.useMutation(); + + async function handleSubmit(data: RecipeFormInput) { + try { + await createRecipe.mutateAsync(data); + + toast.success('Recipe added successfully.'); + await createRecipeRevalidate(); + router.push('/dashboard/recipes'); + router.refresh(); // Workaround for the inconsistency of cache revalidation + } catch (err) { + catchError(err); + } + } + + return ( + + ); +} diff --git a/src/components/forms/add-recipe-form.tsx b/src/components/forms/recipe/RecipeForm.tsx similarity index 65% rename from src/components/forms/add-recipe-form.tsx rename to src/components/forms/recipe/RecipeForm.tsx index abee8830..63f1871c 100644 --- a/src/components/forms/add-recipe-form.tsx +++ b/src/components/forms/recipe/RecipeForm.tsx @@ -1,7 +1,5 @@ 'use client'; -import { trpc } from '@/app/_trpc/client'; -import { createRecipeRevalidate } from '@/app/actions'; import { Icons } from '@/components/icons'; import { Button } from '@/components/ui/button'; import { @@ -12,52 +10,44 @@ import { FormItem, FormLabel, FormMessage, + UncontrolledFormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; import { Textarea } from '@/components/ui/textarea'; import { catchError } from '@/lib/utils'; import { createRecipeInput } from '@/trpc/recipe/recipeRouter'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useRouter } from 'next/navigation'; import * as React from 'react'; import { useForm } from 'react-hook-form'; -import { toast } from 'sonner'; import type { z } from 'zod'; -import { Separator } from '../ui/separator'; -type Inputs = z.infer; +export type RecipeFormInput = z.infer; -export function AddRecipeForm() { - const router = useRouter(); - const [isPending, startTransition] = React.useTransition(); +interface RecipeFormProps { + initialData: RecipeFormInput; + onSubmit: (data: RecipeFormInput) => Promise; +} - const createRecipe = trpc.recipe.createRecipe.useMutation(); +export function RecipeForm({ + initialData, + onSubmit, +}: Readonly) { + const [isPending, startTransition] = React.useTransition(); // react-hook-form - const form = useForm({ + const form = useForm({ resolver: zodResolver(createRecipeInput), - defaultValues: { - title: '', - description: '', - ingredients: [{ name: '', quantity: '1', unit: 'g' }], - steps: [{ step: '' }], - timeInKitchen: '15', - waitingTime: '30', - numberOfPeople: '2', - }, + defaultValues: initialData, mode: 'onTouched', }); - function onSubmit(data: Inputs) { + function handleSubmit(data: RecipeFormInput) { startTransition(async () => { try { - await createRecipe.mutateAsync(data); + await onSubmit(data); form.reset(); - toast.success('Recipe added successfully.'); - await createRecipeRevalidate(); - router.push('/dashboard/recipes'); - router.refresh(); // Workaround for the inconsistency of cache revalidation } catch (err) { catchError(err); } @@ -68,7 +58,7 @@ export function AddRecipeForm() {
void form.handleSubmit(onSubmit)(...args)} + onSubmit={(...args) => void form.handleSubmit(handleSubmit)(...args)} >

Time

- ( - - Time In Kitchen - - - - - - )} - /> - ( - - Waiting Time - - - - - - )} - /> - ( - - Number Of People - - - - - - )} - /> + + Time In Kitchen + + + + + + + Waiting Time + + + + + + + Number Of People + + + + +

Ingredients

@@ -156,7 +155,7 @@ export function AddRecipeForm() { > ( Ingredient Name @@ -170,26 +169,28 @@ export function AddRecipeForm() { )} /> + + Amount + + + + + ( - - Amount - - - - - - )} - /> - ( Unit @@ -205,7 +206,7 @@ export function AddRecipeForm() { onClick={(event) => { event.preventDefault(); remove(index); - form.setFocus(`ingredients.${index - 1}.name`); + form.setFocus(`ingredients.${index - 1}.ingredient.name`); }} disabled={fields.length === 1} > @@ -217,7 +218,7 @@ export function AddRecipeForm() { className="mx-auto w-fit bg-cyan-600/90 hover:bg-cyan-600" onClick={(event) => { event.preventDefault(); - append({ name: '', quantity: '1', unit: 'g' }); + append({ ingredient: { name: '', unit: 'g' }, quantity: 1 }); }} >
diff --git a/src/trpc/recipe/recipeRouter.ts b/src/trpc/recipe/recipeRouter.ts index ff00a8e8..4e41a527 100644 --- a/src/trpc/recipe/recipeRouter.ts +++ b/src/trpc/recipe/recipeRouter.ts @@ -7,13 +7,11 @@ import { computeIngredientsToCreateOrConnect } from './recipeUtil'; export const ingredientsArrayForRecipe = z.array( z.object({ - name: z.string().min(1).max(50), - quantity: z - .string() - .min(1) - .regex(/^\d+$/, 'Must be a whole number') - .regex(/(?!\.)/, 'Must be a whole number'), - unit: z.string().min(1).max(50), + ingredient: z.object({ + name: z.string().min(1).max(50), + unit: z.string().min(1).max(50), + }), + quantity: z.number().min(1), }) ); @@ -21,25 +19,13 @@ const createAndUpdateRecipeInput = z.object({ title: z.string().min(3).max(50), description: z.string(), isPublic: z.boolean().default(false), - timeInKitchen: z - .string() - .min(1) - .regex(/^\d+$/, 'Must be a whole number') - .regex(/(?!\.)/, 'Must be a whole number'), - waitingTime: z - .string() - .min(1) - .regex(/^\d+$/, 'Must be a whole number') - .regex(/(?!\.)/, 'Must be a whole number'), - numberOfPeople: z - .string() - .min(1) - .regex(/^\d+$/, 'Must be a whole number') - .regex(/(?!\.)/, 'Must be a whole number'), + timeInKitchen: z.number().min(1).int(), + waitingTime: z.number().min(1).int(), + numberOfPeople: z.number().min(1).int(), ingredients: ingredientsArrayForRecipe, steps: z.array( z.object({ - step: z.string().min(1), + content: z.string().min(1), }) ), }); @@ -67,9 +53,23 @@ export const recipeRouter = router({ }, }); + const ingredientsAlreadyOnRecipe = await db.ingredientsOnRecipes.findMany( + { + where: { + ingredient: { + userId, + }, + }, + include: { + ingredient: true, + }, + } + ); + const { createIngredients, connectIngredients } = computeIngredientsToCreateOrConnect( existingIngredients, + ingredientsAlreadyOnRecipe, input.ingredients, userId ); @@ -90,7 +90,7 @@ export const recipeRouter = router({ }, steps: { create: input.steps.map((step) => ({ - content: step.step, + content: step.content, })), }, }, @@ -137,8 +137,9 @@ export const recipeRouter = router({ }, include: { ingredients: { - include: { + select: { ingredient: true, + quantity: true, }, }, steps: true, @@ -202,9 +203,21 @@ export const recipeRouter = router({ }, }); + const ingredientsAlreadyOnRecipe = await db.ingredientsOnRecipes.findMany( + { + where: { + recipeId: input.id, + }, + include: { + ingredient: true, + }, + } + ); + const { createIngredients, connectIngredients } = computeIngredientsToCreateOrConnect( existingIngredients, + ingredientsAlreadyOnRecipe, input.ingredients, userId ); @@ -218,6 +231,9 @@ export const recipeRouter = router({ data: { title: input.title, description: input.description, + timeInKitchen: input.timeInKitchen, + waitingTime: input.waitingTime, + numberOfPeople: input.numberOfPeople, isPublic: input.isPublic, ingredients: { deleteMany: { @@ -230,7 +246,7 @@ export const recipeRouter = router({ steps: { deleteMany: {}, create: input.steps.map((step) => ({ - content: step.step, + content: step.content, })), }, }, @@ -312,7 +328,6 @@ export const recipeRouter = router({ }, }, quantity: faker.number.int({ min: 1, max: 10 }), - assignedBy: userId, })), }, steps: { diff --git a/src/trpc/recipe/recipeUtil.ts b/src/trpc/recipe/recipeUtil.ts index 5db0cf60..b7d3ba2e 100644 --- a/src/trpc/recipe/recipeUtil.ts +++ b/src/trpc/recipe/recipeUtil.ts @@ -1,25 +1,40 @@ -import { type Ingredient } from '@prisma/client'; +import { type Ingredient, type IngredientsOnRecipes } from '@prisma/client'; import { TRPCError } from '@trpc/server'; import { z, type z as zodType } from 'zod'; import { type ingredientsArrayForRecipe } from './recipeRouter'; export function computeIngredientsToCreateOrConnect( - ingredients: Ingredient[], + existingIngredients: Ingredient[], + ingredientsAlreadyOnRecipe: { ingredient: Ingredient }[] & + IngredientsOnRecipes[], ingredientsInput: zodType.infer, userId: string ) { - const ingredientNames = ingredients.map((ingredient) => ingredient.name); + const existingIngredientNames = existingIngredients.map( + (ingredient) => ingredient.name + ); + + const ingredientsAlreadyOnRecipeNames = ingredientsAlreadyOnRecipe.map( + (ingredient) => ingredient.ingredient.name + ); const ingredientsToCreate = ingredientsInput.filter( - (ingredient) => !ingredientNames.includes(ingredient.name) + (ingredient) => + !existingIngredientNames.includes(ingredient.ingredient.name) ); // ingredientsToConnect should also have the quantity from the ingredientsInput array const ingredientsToConnect = ingredientsInput - .filter((ingredient) => ingredientNames.includes(ingredient.name)) + .filter((ingredient) => + existingIngredientNames.includes(ingredient.ingredient.name) + ) + .filter( + (ingredient) => + !ingredientsAlreadyOnRecipeNames.includes(ingredient.ingredient.name) + ) .map((ingredient) => { - const ingredientToConnect = ingredients.find( - (dbIngredient) => dbIngredient.name === ingredient.name + const ingredientToConnect = existingIngredients.find( + (dbIngredient) => dbIngredient.name === ingredient.ingredient.name ); if (!ingredientToConnect) throw new TRPCError({ code: 'NOT_FOUND' }); @@ -34,9 +49,8 @@ export function computeIngredientsToCreateOrConnect( quantity: z.coerce.number().parse(ingredient.quantity), ingredient: { create: { - name: ingredient.name, - // if unit is not provided, don't add it to the database as it is optional - ...(ingredient.unit ? { unit: ingredient.unit } : {}), + name: ingredient.ingredient.name, + unit: ingredient.ingredient.unit, userId, }, }, diff --git a/tsconfig.json b/tsconfig.json index 9b9a0bf7..e508cb32 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,7 +36,8 @@ { "name": "next" } - ] + ], + "noErrorTruncation": true }, "include": [ ".eslintrc.js",