From ee123792620b79dfae0ba425f605aad61c8e147a Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 3 Dec 2025 15:11:53 +0530 Subject: [PATCH 1/9] feat: add new CRUD block with table and dropdown menu components --- app/page.tsx | 13 ++ components/crud-example.tsx | 49 +++++ package.json | 2 + pnpm-lock.yaml | 162 +++++++++++++++ registry.json | 41 +++- registry/new-york/blocks/crud/index.tsx | 21 ++ registry/new-york/blocks/crud/table.tsx | 142 +++++++++++++ registry/new-york/ui/dropdown-menu.tsx | 257 ++++++++++++++++++++++++ registry/new-york/ui/table.tsx | 116 +++++++++++ 9 files changed, 797 insertions(+), 6 deletions(-) create mode 100644 components/crud-example.tsx create mode 100644 registry/new-york/blocks/crud/index.tsx create mode 100644 registry/new-york/blocks/crud/table.tsx create mode 100644 registry/new-york/ui/dropdown-menu.tsx create mode 100644 registry/new-york/ui/table.tsx diff --git a/app/page.tsx b/app/page.tsx index 608be77..b0c6265 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,6 +6,7 @@ import { Github } from "lucide-react" import Link from "next/link" import { RelativeTime } from "@/registry/new-york/blocks/relative-time/relative-time" import { InputCopyable } from "@/registry/new-york/blocks/input-copyable/input-copyable" +import CrudExample from "@/components/crud-example" export default function Home() { const date = new Date() @@ -74,6 +75,18 @@ export default function Home() { +
+
+

+ A component that displays a CRUD interface for a given resource +

+ +
+
+ +
+
+ + + + + Actions + {onView && onView(row.original)}>View} + {onEdit && onEdit(row.original)}>Edit} + {onDelete && onDelete(row.original)}>Delete} + + + + ), + }) + } + + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + const LoadMoreButton = () => { + if (!nextPageToken) return + + const loadMore = () => onLoadMore && onLoadMore(nextPageToken) + + return ( +
+ +
+ ) + } + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ) +} \ No newline at end of file diff --git a/registry/new-york/ui/dropdown-menu.tsx b/registry/new-york/ui/dropdown-menu.tsx new file mode 100644 index 0000000..bbe6fb0 --- /dev/null +++ b/registry/new-york/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/registry/new-york/ui/table.tsx b/registry/new-york/ui/table.tsx new file mode 100644 index 0000000..51b74dd --- /dev/null +++ b/registry/new-york/ui/table.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} From 7586e77c00ee050d6d4ec52007260dae42ef25ed Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 3 Dec 2025 19:26:20 +0530 Subject: [PATCH 2/9] dev: Enhance CRUD block with add, view, edit drawer dialog and delete confirmation alert dialog. --- package.json | 1 + pnpm-lock.yaml | 30 ++++ registry.json | 3 +- registry/new-york/blocks/crud/index.tsx | 50 +++++- registry/new-york/blocks/crud/table.tsx | 2 +- .../blocks/drawer-dialog/drawer-dialog.tsx | 19 ++- registry/new-york/ui/alert-dialog.tsx | 157 ++++++++++++++++++ 7 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 registry/new-york/ui/alert-dialog.tsx diff --git a/package.json b/package.json index 82c247b..4c5a731 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "registry:build": "shadcn build -o ./public/r" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ad8606..4d9c054 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + '@radix-ui/react-alert-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -830,6 +833,19 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': 19.1.2 + '@types/react-dom': 19.1.2 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -4964,6 +4980,20 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.2)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.2 + '@types/react-dom': 19.1.2(@types/react@19.1.2) + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) diff --git a/registry.json b/registry.json index 5b9b14a..70c54c4 100644 --- a/registry.json +++ b/registry.json @@ -65,7 +65,8 @@ "description": "A component that displays a CRUD interface for a given resource", "registryDependencies": [ "dialog", - "drawer" + "drawer", + "@vinkas/drawer-dialog" ], "dependencies": [ "@tanstack/react-table" diff --git a/registry/new-york/blocks/crud/index.tsx b/registry/new-york/blocks/crud/index.tsx index 74eda57..b8ddf56 100644 --- a/registry/new-york/blocks/crud/index.tsx +++ b/registry/new-york/blocks/crud/index.tsx @@ -1,5 +1,8 @@ -import { createContext, useContext } from "react" +import { createContext, useContext, useState } from "react" import CrudTable, { CrudTableProps } from "./table" +import { DrawerDialog, DrawerDialogContent } from "../drawer-dialog/drawer-dialog" +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../ui/alert-dialog" +import { Button } from "../../ui/button" const CrudContext = createContext(undefined) @@ -11,9 +14,52 @@ export type CrudProps = { } & CrudTableProps export function Crud({ columns, data, onView, onEdit, onDelete }: CrudProps) { + const [dialogOpen, setDialogOpen] = useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [deleteRow, setDeleteRow] = useState(null) + const onCrudView = (row: TData) => { + onView?.(row) && setDialogOpen(true) + } + const onCrudEdit = (row: TData) => { + onEdit?.(row) && setDialogOpen(true) + } + const onCrudDelete = (row: TData) => { + if (!onDelete) return + setDeleteDialogOpen(true) + setDeleteRow(row) + } return ( - +
+
+ +
+ +
+ +
Drawer Content
+
+ + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the record. + + + + Cancel + deleteRow && onDelete?.(deleteRow)} + className="bg-red-600 hover:bg-red-700" + > + Delete + + + +
) } diff --git a/registry/new-york/blocks/crud/table.tsx b/registry/new-york/blocks/crud/table.tsx index 50a01a7..383d176 100644 --- a/registry/new-york/blocks/crud/table.tsx +++ b/registry/new-york/blocks/crud/table.tsx @@ -91,7 +91,7 @@ export default function CrudTable({ } return ( -
+
diff --git a/registry/new-york/blocks/drawer-dialog/drawer-dialog.tsx b/registry/new-york/blocks/drawer-dialog/drawer-dialog.tsx index 9b03fae..33a15c8 100644 --- a/registry/new-york/blocks/drawer-dialog/drawer-dialog.tsx +++ b/registry/new-york/blocks/drawer-dialog/drawer-dialog.tsx @@ -13,15 +13,15 @@ import { Drawer, DrawerClose, DrawerContent, - DrawerDescription, + DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, } from "@/registry/new-york/ui/drawer" -import { createContext, useContext, useState } from "react" +import { createContext, useContext } from "react" -const DrawerDialogContext = createContext<{ isDesktop: boolean; open: boolean; setOpen: (v: boolean) => void } | null>(null) +const DrawerDialogContext = createContext<{ isDesktop: boolean } | null>(null) export function useDrawerDialog() { const ctx = useContext(DrawerDialogContext) @@ -29,16 +29,21 @@ export function useDrawerDialog() { return ctx } +type DrawerDialogProps = { + open?: boolean + onOpenChange?: (v: boolean) => void + children: React.ReactNode +} -export function DrawerDialog({ children }: { children: React.ReactNode }) { - const [open, setOpen] = useState(false) +export function DrawerDialog({ children, open = false, onOpenChange }: DrawerDialogProps) { + onOpenChange = onOpenChange || (() => { }) const isDesktop = useMediaQuery("(min-width: 768px)") const Wrapper = isDesktop ? Dialog : Drawer return ( - - + + {children} diff --git a/registry/new-york/ui/alert-dialog.tsx b/registry/new-york/ui/alert-dialog.tsx new file mode 100644 index 0000000..cd12a17 --- /dev/null +++ b/registry/new-york/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "./button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} From 8d85446cd0df44797612e6c0a49e2375ac9af515 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 3 Dec 2025 21:07:16 +0530 Subject: [PATCH 3/9] dev: Add form and create/edit functionality to Crud component, and remove view action. --- components/crud-example.tsx | 10 +++- registry/new-york/blocks/crud/form.tsx | 15 +++++ registry/new-york/blocks/crud/index.tsx | 76 ++++++++++++++++++++----- registry/new-york/blocks/crud/table.tsx | 5 -- 4 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 registry/new-york/blocks/crud/form.tsx diff --git a/components/crud-example.tsx b/components/crud-example.tsx index 64e80a3..b34bc42 100644 --- a/components/crud-example.tsx +++ b/components/crud-example.tsx @@ -1,6 +1,6 @@ "use client" -import { Crud } from "@/registry/new-york/blocks/crud" +import { Crud, CrudForm } from "@/registry/new-york/blocks/crud" type Payment = { id: string @@ -27,7 +27,7 @@ export const payments: Payment[] = [ export default function CrudExample() { return ( - console.log(row)} onEdit={(row) => console.log(row)} onDelete={(row) => console.log(row)} /> + ]} onEdit={(row) => console.log(row)} onDelete={(row) => console.log(row)} onCreate={(e) => console.log(e)}> + +
Form Content
+
+
) } \ No newline at end of file diff --git a/registry/new-york/blocks/crud/form.tsx b/registry/new-york/blocks/crud/form.tsx new file mode 100644 index 0000000..7ad5bfd --- /dev/null +++ b/registry/new-york/blocks/crud/form.tsx @@ -0,0 +1,15 @@ +"use client" + +type CrudFormProps = { + children: React.ReactNode +} + +const CrudForm = ({ children }: CrudFormProps) => { + return ( +
+ {children} + + ) +} + +export default CrudForm diff --git a/registry/new-york/blocks/crud/index.tsx b/registry/new-york/blocks/crud/index.tsx index b8ddf56..4406ef8 100644 --- a/registry/new-york/blocks/crud/index.tsx +++ b/registry/new-york/blocks/crud/index.tsx @@ -1,8 +1,10 @@ -import { createContext, useContext, useState } from "react" +import React, { createContext, FormEvent, useContext, useState } from "react" import CrudTable, { CrudTableProps } from "./table" -import { DrawerDialog, DrawerDialogContent } from "../drawer-dialog/drawer-dialog" +import { DrawerDialog, DrawerDialogContent, DrawerDialogContentWrapper, DrawerDialogFooter, DrawerDialogHeader, DrawerDialogTitle } from "../drawer-dialog/drawer-dialog" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../ui/alert-dialog" import { Button } from "../../ui/button" +import CrudForm from "./form" +import { Plus } from "lucide-react" const CrudContext = createContext(undefined) @@ -11,42 +13,88 @@ export function useCrud() { } export type CrudProps = { + name: string + children: React.ReactNode + onCreate: (e: FormEvent) => void } & CrudTableProps -export function Crud({ columns, data, onView, onEdit, onDelete }: CrudProps) { +export function Crud({ name, children, columns, data, onCreate, onEdit, onDelete }: CrudProps) { const [dialogOpen, setDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteRow, setDeleteRow] = useState(null) - const onCrudView = (row: TData) => { - onView?.(row) && setDialogOpen(true) - } + const [editRow, setEditRow] = useState(null) + const [submitting, setSubmitting] = useState(false) + const onCrudEdit = (row: TData) => { - onEdit?.(row) && setDialogOpen(true) + onEdit?.(row) + setDialogOpen(true) + setEditRow(row) } const onCrudDelete = (row: TData) => { if (!onDelete) return setDeleteDialogOpen(true) setDeleteRow(row) } + const Form = React.Children.toArray(children).find((child) => { + if (React.isValidElement(child) && child.type === CrudForm) { + return child + } + }) + const Content = React.Children.toArray(children).filter((child) => { + if (React.isValidElement(child) && child.type !== CrudForm) { + return child + } + }) + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + setSubmitting(true) + setTimeout(() => { + if (editRow) { + onEdit?.(editRow) + } else { + onCreate(e) + } + setSubmitting(false) + setDialogOpen(false) + }, 1000) + } return (
-
- + + {Content}
-
Drawer Content
+ +
+ + {editRow ? "Edit" : "Add"} {name} + + +
+ {Form} +
+
+ + + + +
Are you absolutely sure? - This action cannot be undone. This will permanently delete the record. + This action cannot be undone. This will permanently delete the {name.toLowerCase()} record. @@ -60,8 +108,8 @@ export function Crud({ columns, data, onView, onEdit, onDelete }: -
+ ) } -export { CrudTable } \ No newline at end of file +export { CrudTable, CrudForm } \ No newline at end of file diff --git a/registry/new-york/blocks/crud/table.tsx b/registry/new-york/blocks/crud/table.tsx index 383d176..8b48d57 100644 --- a/registry/new-york/blocks/crud/table.tsx +++ b/registry/new-york/blocks/crud/table.tsx @@ -23,9 +23,7 @@ export type CrudTableProps = { columns: ColumnDef[] data: TData[] nextPageToken?: string - // eslint-disable-next-line no-unused-vars onLoadMore?: (token?: string) => void - onView?: (row: TData) => void onEdit?: (row: TData) => void onDelete?: (row: TData) => void } @@ -35,7 +33,6 @@ export default function CrudTable({ data, nextPageToken, onLoadMore, - onView, onEdit, onDelete, }: CrudTableProps) { @@ -54,7 +51,6 @@ export default function CrudTable({ Actions - {onView && onView(row.original)}>View} {onEdit && onEdit(row.original)}>Edit} {onDelete && onDelete(row.original)}>Delete} @@ -64,7 +60,6 @@ export default function CrudTable({ }) } - // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ data, columns, From 225bb2ca297b5db5a2248c322b2fe91a1d857168 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 3 Dec 2025 22:15:36 +0530 Subject: [PATCH 4/9] refactor: Extract Crud header component and type CrudContext with provider enforcement. --- registry/new-york/blocks/crud/index.tsx | 30 +++++++++++++++++-------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/registry/new-york/blocks/crud/index.tsx b/registry/new-york/blocks/crud/index.tsx index 4406ef8..6e82c7b 100644 --- a/registry/new-york/blocks/crud/index.tsx +++ b/registry/new-york/blocks/crud/index.tsx @@ -6,10 +6,17 @@ import { Button } from "../../ui/button" import CrudForm from "./form" import { Plus } from "lucide-react" -const CrudContext = createContext(undefined) +type CrudType = { + name: string + setDialogOpen: (v: boolean) => void +} + +const CrudContext = createContext(undefined) export function useCrud() { - return useContext(CrudContext) + const ctx = useContext(CrudContext) + if (!ctx) throw new Error("useCrud must be used within ") + return ctx } export type CrudProps = { @@ -59,14 +66,9 @@ export function Crud({ name, children, columns, data, onCreate, o }, 1000) } return ( - +
-
- -
+ {Content}
@@ -112,4 +114,14 @@ export function Crud({ name, children, columns, data, onCreate, o ) } +function CrudHeader() { + const { name, setDialogOpen } = useCrud() + return
+ +
+} + export { CrudTable, CrudForm } \ No newline at end of file From e88f6c24ea7e8b8956647f472b65ae99ca6a3e18 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Thu, 4 Dec 2025 14:06:45 +0530 Subject: [PATCH 5/9] dev: Implement state management for CRUD operations in CrudExample component, enabling dynamic data updates for create, edit, and delete actions. --- components/crud-example.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/components/crud-example.tsx b/components/crud-example.tsx index b34bc42..796340b 100644 --- a/components/crud-example.tsx +++ b/components/crud-example.tsx @@ -1,6 +1,7 @@ "use client" import { Crud, CrudForm } from "@/registry/new-york/blocks/crud" +import { useState } from "react" type Payment = { id: string @@ -22,12 +23,12 @@ export const payments: Payment[] = [ status: "processing", email: "example@gmail.com", }, - // ... ] export default function CrudExample() { + const [data, setData] = useState(payments) return ( - console.log(row)} onDelete={(row) => console.log(row)} onCreate={(e) => console.log(e)}> + ]} onEdit={(row) => { + setData(data.map((item) => item.id === row.id ? row : item)) + }} onDelete={(row) => { + setData(data.filter((item) => item.id !== row.id)) + }} onCreate={(e) => { + setData([...data, { + id: crypto.randomUUID(), + amount: 100, + status: "pending", + email: "m@example.com", + }]) + }}>
Form Content
From db3e693af9f17c40f7e170651831e93b05f1f65f Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Sat, 6 Dec 2025 16:10:09 +0530 Subject: [PATCH 6/9] dev: Introduce `formState` prop for managing CRUD form data and method, and enhance `CrudForm` to accept standard form attributes. --- components/crud-example.tsx | 77 +++++++++++++++---------- registry/new-york/blocks/crud/form.tsx | 6 +- registry/new-york/blocks/crud/index.tsx | 43 ++++++++------ 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/components/crud-example.tsx b/components/crud-example.tsx index 796340b..9ec123c 100644 --- a/components/crud-example.tsx +++ b/components/crud-example.tsx @@ -1,6 +1,7 @@ "use client" -import { Crud, CrudForm } from "@/registry/new-york/blocks/crud" +import { Crud, CrudForm, CrudFormType } from "@/registry/new-york/blocks/crud" +import { Input } from "@/registry/new-york/ui/input" import { useState } from "react" type Payment = { @@ -27,38 +28,52 @@ export const payments: Payment[] = [ export default function CrudExample() { const [data, setData] = useState(payments) + const formState = useState>({ + method: 'create', + data: { + id: "", + amount: 0, + status: "pending", + email: "", + } + }) + const [formType, setFormType] = formState + const formData = formType.data + const setFormData = (data: Payment) => setFormType({ ...formType, data }) return ( - { - setData(data.map((item) => item.id === row.id ? row : item)) - }} onDelete={(row) => { - setData(data.filter((item) => item.id !== row.id)) - }} onCreate={(e) => { - setData([...data, { - id: crypto.randomUUID(), - amount: 100, - status: "pending", - email: "m@example.com", - }]) - }}> + { + setData(data.map((item) => item.id === row.id ? row : item)) + }} onDelete={(row) => { + setData(data.filter((item) => item.id !== row.id)) + }} onCreate={(e) => { + setData([...data, formData]) + }}> -
Form Content
+ setFormData({ ...formData, amount: Number(e.target.value) })} /> + setFormData({ ...formData, email: e.target.value })} /> + setFormData({ ...formData, status: e.target.value as "pending" | "processing" | "success" | "failed" })} /> + setFormData({ ...formData, id: e.target.value })} />
) diff --git a/registry/new-york/blocks/crud/form.tsx b/registry/new-york/blocks/crud/form.tsx index 7ad5bfd..b960fd2 100644 --- a/registry/new-york/blocks/crud/form.tsx +++ b/registry/new-york/blocks/crud/form.tsx @@ -2,11 +2,11 @@ type CrudFormProps = { children: React.ReactNode -} +} & React.FormHTMLAttributes -const CrudForm = ({ children }: CrudFormProps) => { +const CrudForm = ({ children, ...props }: CrudFormProps) => { return ( -
+ {children} ) diff --git a/registry/new-york/blocks/crud/index.tsx b/registry/new-york/blocks/crud/index.tsx index 6e82c7b..71aab8d 100644 --- a/registry/new-york/blocks/crud/index.tsx +++ b/registry/new-york/blocks/crud/index.tsx @@ -1,10 +1,11 @@ -import React, { createContext, FormEvent, useContext, useState } from "react" -import CrudTable, { CrudTableProps } from "./table" +import React, { createContext, Dispatch, FormEvent, ReactNode, SetStateAction, useContext, useState } from "react" +import CrudTable from "./table" import { DrawerDialog, DrawerDialogContent, DrawerDialogContentWrapper, DrawerDialogFooter, DrawerDialogHeader, DrawerDialogTitle } from "../drawer-dialog/drawer-dialog" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../ui/alert-dialog" import { Button } from "../../ui/button" import CrudForm from "./form" import { Plus } from "lucide-react" +import { ColumnDef } from "@tanstack/react-table" type CrudType = { name: string @@ -19,28 +20,36 @@ export function useCrud() { return ctx } +export type CrudFormType = { + method: 'create' | 'update' + data: TData +} + export type CrudProps = { name: string - children: React.ReactNode - onCreate: (e: FormEvent) => void -} & CrudTableProps + children: ReactNode + onCreate: (data: TData, e: FormEvent) => void + onEdit: (data: TData, e: FormEvent) => void + onDelete: (data: TData) => void + columns: ColumnDef[] + data: TData[] + formState: [CrudFormType, Dispatch>>] +} -export function Crud({ name, children, columns, data, onCreate, onEdit, onDelete }: CrudProps) { +export function Crud({ name, formState, children, columns, data, onCreate, onEdit, onDelete }: CrudProps) { const [dialogOpen, setDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteRow, setDeleteRow] = useState(null) - const [editRow, setEditRow] = useState(null) + const [formType, setFormType] = formState const [submitting, setSubmitting] = useState(false) - const onCrudEdit = (row: TData) => { - onEdit?.(row) + const onCrudEdit = (data: TData) => { setDialogOpen(true) - setEditRow(row) + setFormType({ method: 'update', data }) } - const onCrudDelete = (row: TData) => { - if (!onDelete) return + const onCrudDelete = (data: TData) => { + setDeleteRow(data) setDeleteDialogOpen(true) - setDeleteRow(row) } const Form = React.Children.toArray(children).find((child) => { if (React.isValidElement(child) && child.type === CrudForm) { @@ -56,10 +65,10 @@ export function Crud({ name, children, columns, data, onCreate, o e.preventDefault() setSubmitting(true) setTimeout(() => { - if (editRow) { - onEdit?.(editRow) + if (formType.method === 'update') { + onEdit(formType.data, e) } else { - onCreate(e) + onCreate(formType.data, e) } setSubmitting(false) setDialogOpen(false) @@ -76,7 +85,7 @@ export function Crud({ name, children, columns, data, onCreate, o
- {editRow ? "Edit" : "Add"} {name} + {formType.method === 'update' ? "Edit" : "Add"} {name}
From b45998bb65c4bd3c212a898ae3358cc7122cdfaa Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Sat, 6 Dec 2025 16:34:25 +0530 Subject: [PATCH 7/9] dev: Add `defaultData` prop to `Crud` component for new item creation and refactor its add button. --- components/crud-example.tsx | 14 ++++++++------ registry/new-york/blocks/crud/index.tsx | 24 ++++++++++++------------ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/components/crud-example.tsx b/components/crud-example.tsx index 9ec123c..6a18f16 100644 --- a/components/crud-example.tsx +++ b/components/crud-example.tsx @@ -28,14 +28,15 @@ export const payments: Payment[] = [ export default function CrudExample() { const [data, setData] = useState(payments) + const defaultPayment = { + id: "", + amount: 0, + status: "pending", + email: "", + } as Payment const formState = useState>({ method: 'create', - data: { - id: "", - amount: 0, - status: "pending", - email: "", - } + data: defaultPayment, }) const [formType, setFormType] = formState const formData = formType.data @@ -43,6 +44,7 @@ export default function CrudExample() { return ( = { columns: ColumnDef[] data: TData[] formState: [CrudFormType, Dispatch>>] + defaultData?: TData } -export function Crud({ name, formState, children, columns, data, onCreate, onEdit, onDelete }: CrudProps) { +export function Crud({ name, formState, children, columns, data, onCreate, onEdit, onDelete, defaultData = {} as TData }: CrudProps) { const [dialogOpen, setDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteRow, setDeleteRow] = useState(null) const [formType, setFormType] = formState const [submitting, setSubmitting] = useState(false) + const onCrudCreate = () => { + setDialogOpen(true) + setFormType({ method: 'create', data: defaultData }) + } const onCrudEdit = (data: TData) => { setDialogOpen(true) setFormType({ method: 'update', data }) @@ -77,7 +82,12 @@ export function Crud({ name, formState, children, columns, data, return (
- +
+ +
{Content}
@@ -123,14 +133,4 @@ export function Crud({ name, formState, children, columns, data, ) } -function CrudHeader() { - const { name, setDialogOpen } = useCrud() - return
- -
-} - export { CrudTable, CrudForm } \ No newline at end of file From e3339e6d81704bd97acaa7c0bf24ff682fc7d8e3 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Sat, 6 Dec 2025 16:39:25 +0530 Subject: [PATCH 8/9] dev: add CRUD component and update registry definitions to include its block files. --- public/r/crud.json | 35 ++++++++++++++++++++++++ public/r/drawer-dialog.json | 2 +- public/r/registry.json | 53 ++++++++++++++++++++++++++++++++----- registry.json | 13 ++++++++- 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 public/r/crud.json diff --git a/public/r/crud.json b/public/r/crud.json new file mode 100644 index 0000000..6ef9fb0 --- /dev/null +++ b/public/r/crud.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "crud", + "type": "registry:component", + "title": "CRUD", + "description": "A component that displays a CRUD interface for a given resource", + "dependencies": [ + "@tanstack/react-table" + ], + "registryDependencies": [ + "dialog", + "drawer", + "@vinkas/drawer-dialog" + ], + "files": [ + { + "path": "registry/new-york/blocks/crud/index.tsx", + "content": "import React, { createContext, Dispatch, FormEvent, ReactNode, SetStateAction, useContext, useState } from \"react\"\nimport CrudTable from \"./table\"\nimport { DrawerDialog, DrawerDialogContent, DrawerDialogContentWrapper, DrawerDialogFooter, DrawerDialogHeader, DrawerDialogTitle } from \"../drawer-dialog/drawer-dialog\"\nimport { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from \"../../ui/alert-dialog\"\nimport { Button } from \"../../ui/button\"\nimport CrudForm from \"./form\"\nimport { Plus } from \"lucide-react\"\nimport { ColumnDef } from \"@tanstack/react-table\"\n\ntype CrudType = {\n name: string\n setDialogOpen: (v: boolean) => void\n}\n\nconst CrudContext = createContext(undefined)\n\nexport function useCrud() {\n const ctx = useContext(CrudContext)\n if (!ctx) throw new Error(\"useCrud must be used within \")\n return ctx\n}\n\nexport type CrudFormType = {\n method: 'create' | 'update'\n data: TData\n}\n\nexport type CrudProps = {\n name: string\n children: ReactNode\n onCreate: (data: TData, e: FormEvent) => void\n onEdit: (data: TData, e: FormEvent) => void\n onDelete: (data: TData) => void\n columns: ColumnDef[]\n data: TData[]\n formState: [CrudFormType, Dispatch>>]\n defaultData?: TData\n}\n\nexport function Crud({ name, formState, children, columns, data, onCreate, onEdit, onDelete, defaultData = {} as TData }: CrudProps) {\n const [dialogOpen, setDialogOpen] = useState(false)\n const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)\n const [deleteRow, setDeleteRow] = useState(null)\n const [formType, setFormType] = formState\n const [submitting, setSubmitting] = useState(false)\n\n const onCrudCreate = () => {\n setDialogOpen(true)\n setFormType({ method: 'create', data: defaultData })\n }\n const onCrudEdit = (data: TData) => {\n setDialogOpen(true)\n setFormType({ method: 'update', data })\n }\n const onCrudDelete = (data: TData) => {\n setDeleteRow(data)\n setDeleteDialogOpen(true)\n }\n const Form = React.Children.toArray(children).find((child) => {\n if (React.isValidElement(child) && child.type === CrudForm) {\n return child\n }\n })\n const Content = React.Children.toArray(children).filter((child) => {\n if (React.isValidElement(child) && child.type !== CrudForm) {\n return child\n }\n })\n const handleSubmit = (e: FormEvent) => {\n e.preventDefault()\n setSubmitting(true)\n setTimeout(() => {\n if (formType.method === 'update') {\n onEdit(formType.data, e)\n } else {\n onCreate(formType.data, e)\n }\n setSubmitting(false)\n setDialogOpen(false)\n }, 1000)\n }\n return (\n \n
\n
\n \n
\n \n {Content}\n
\n \n \n \n \n {formType.method === 'update' ? \"Edit\" : \"Add\"} {name}\n \n \n
\n {Form}\n
\n
\n \n \n \n \n
\n
\n \n \n \n Are you absolutely sure?\n \n This action cannot be undone. This will permanently delete the {name.toLowerCase()} record.\n \n \n \n Cancel\n deleteRow && onDelete?.(deleteRow)}\n className=\"bg-red-600 hover:bg-red-700\"\n >\n Delete\n \n \n \n \n
\n )\n}\n\nexport { CrudTable, CrudForm }", + "type": "registry:block", + "target": "registry/new-york/blocks/crud/index.tsx" + }, + { + "path": "registry/new-york/blocks/crud/table.tsx", + "content": "\"use client\"\n\nimport {\n ColumnDef,\n flexRender,\n getCoreRowModel,\n useReactTable,\n getPaginationRowModel,\n} from \"@tanstack/react-table\"\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"@/registry/new-york/ui/table\"\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from \"@/registry/new-york/ui/dropdown-menu\";\nimport { Button } from \"@/registry/new-york/ui/button\"\nimport { MoreHorizontal } from \"lucide-react\";\n\nexport type CrudTableProps = {\n columns: ColumnDef[]\n data: TData[]\n nextPageToken?: string\n onLoadMore?: (token?: string) => void\n onEdit?: (row: TData) => void\n onDelete?: (row: TData) => void\n}\n\nexport default function CrudTable({\n columns,\n data,\n nextPageToken,\n onLoadMore,\n onEdit,\n onDelete,\n}: CrudTableProps) {\n const actionColumnIndex = columns.findIndex((column) => column.id === \"actions\")\n if (actionColumnIndex === -1) {\n columns.push({\n id: \"actions\",\n cell: ({ row }) => (\n \n \n \n \n \n \n Actions\n {onEdit && onEdit(row.original)}>Edit}\n {onDelete && onDelete(row.original)}>Delete}\n \n \n \n ),\n })\n }\n\n const table = useReactTable({\n data,\n columns,\n getCoreRowModel: getCoreRowModel(),\n getPaginationRowModel: getPaginationRowModel(),\n })\n\n const LoadMoreButton = () => {\n if (!nextPageToken) return\n\n const loadMore = () => onLoadMore && onLoadMore(nextPageToken)\n\n return (\n
\n \n Load More\n \n
\n )\n }\n\n return (\n
\n
\n
\n \n {table.getHeaderGroups().map((headerGroup) => (\n \n {headerGroup.headers.map((header) => {\n return (\n \n {header.isPlaceholder\n ? null\n : flexRender(\n header.column.columnDef.header,\n header.getContext()\n )}\n \n )\n })}\n \n ))}\n \n \n {table.getRowModel().rows?.length ? (\n table.getRowModel().rows.map((row) => (\n \n {row.getVisibleCells().map((cell) => (\n \n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n \n ))}\n \n ))\n ) : (\n \n \n No results.\n \n \n )}\n \n
\n
\n \n
\n )\n}", + "type": "registry:block", + "target": "registry/new-york/blocks/crud/table.tsx" + }, + { + "path": "registry/new-york/blocks/crud/form.tsx", + "content": "\"use client\"\n\ntype CrudFormProps = {\n children: React.ReactNode\n} & React.FormHTMLAttributes\n\nconst CrudForm = ({ children, ...props }: CrudFormProps) => {\n return (\n
\n {children}\n
\n )\n}\n\nexport default CrudForm\n", + "type": "registry:block", + "target": "registry/new-york/blocks/crud/form.tsx" + } + ] +} \ No newline at end of file diff --git a/public/r/drawer-dialog.json b/public/r/drawer-dialog.json index 3bc9310..a23abbc 100644 --- a/public/r/drawer-dialog.json +++ b/public/r/drawer-dialog.json @@ -11,7 +11,7 @@ "files": [ { "path": "registry/new-york/blocks/drawer-dialog/drawer-dialog.tsx", - "content": "\"use client\"\n\nimport { useMediaQuery } from \"@/registry/new-york/blocks/drawer-dialog/hooks/use-media-query\"\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n DialogTrigger,\n} from \"@/registry/new-york/ui/dialog\"\nimport {\n Drawer,\n DrawerClose,\n DrawerContent,\n DrawerDescription, \n DrawerFooter,\n DrawerHeader,\n DrawerTitle,\n DrawerTrigger,\n} from \"@/registry/new-york/ui/drawer\"\nimport { createContext, useContext, useState } from \"react\"\n\nconst DrawerDialogContext = createContext<{ isDesktop: boolean; open: boolean; setOpen: (v: boolean) => void } | null>(null)\n\nexport function useDrawerDialog() {\n const ctx = useContext(DrawerDialogContext)\n if (!ctx) throw new Error(\"useDrawerDialog must be used within \")\n return ctx\n}\n\n\nexport function DrawerDialog({ children }: { children: React.ReactNode }) {\n const [open, setOpen] = useState(false)\n const isDesktop = useMediaQuery(\"(min-width: 768px)\")\n\n const Wrapper = isDesktop ? Dialog : Drawer\n\n return (\n \n \n {children}\n \n \n )\n}\n\nexport function DrawerDialogTrigger({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n const Trigger = isDesktop ? DialogTrigger : DrawerTrigger\n return {children}\n}\n\nexport function DrawerDialogContent({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n const Content = isDesktop ? DialogContent : DrawerContent\n return {children}\n}\n\nexport function DrawerDialogContentWrapper({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n const className = isDesktop ? \"\" : \"px-4\"\n return
{children}
\n}\n\nexport function DrawerDialogHeader({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n const Header = isDesktop ? DialogHeader : DrawerHeader\n return
{children}
\n}\n\nexport function DrawerDialogTitle({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n const Title = isDesktop ? DialogTitle : DrawerTitle\n return {children}\n}\n\nexport function DrawerDialogDescription({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n const Desc = isDesktop ? DialogDescription : DrawerDescription\n return {children}\n}\n\nexport function DrawerDialogFooter({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n if (isDesktop) return children\n const Footer = DrawerFooter\n return (\n
\n {children}\n \n Cancel\n \n
\n )\n}", + "content": "\"use client\"\n\nimport { useMediaQuery } from \"@/registry/new-york/blocks/drawer-dialog/hooks/use-media-query\"\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n DialogTrigger,\n} from \"@/registry/new-york/ui/dialog\"\nimport {\n Drawer,\n DrawerClose,\n DrawerContent,\n DrawerDescription,\n DrawerFooter,\n DrawerHeader,\n DrawerTitle,\n DrawerTrigger,\n} from \"@/registry/new-york/ui/drawer\"\nimport { createContext, useContext } from \"react\"\n\nconst DrawerDialogContext = createContext<{ isDesktop: boolean } | null>(null)\n\nexport function useDrawerDialog() {\n const ctx = useContext(DrawerDialogContext)\n if (!ctx) throw new Error(\"useDrawerDialog must be used within \")\n return ctx\n}\n\ntype DrawerDialogProps = {\n open?: boolean\n onOpenChange?: (v: boolean) => void\n children: React.ReactNode\n}\n\nexport function DrawerDialog({ children, open = false, onOpenChange }: DrawerDialogProps) {\n onOpenChange = onOpenChange || (() => { })\n const isDesktop = useMediaQuery(\"(min-width: 768px)\")\n\n const Wrapper = isDesktop ? Dialog : Drawer\n\n return (\n \n \n {children}\n \n \n )\n}\n\nexport function DrawerDialogTrigger({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n const Trigger = isDesktop ? DialogTrigger : DrawerTrigger\n return {children}\n}\n\nexport function DrawerDialogContent({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n const Content = isDesktop ? DialogContent : DrawerContent\n return {children}\n}\n\nexport function DrawerDialogContentWrapper({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n const className = isDesktop ? \"\" : \"px-4\"\n return
{children}
\n}\n\nexport function DrawerDialogHeader({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n const Header = isDesktop ? DialogHeader : DrawerHeader\n return
{children}
\n}\n\nexport function DrawerDialogTitle({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n const Title = isDesktop ? DialogTitle : DrawerTitle\n return {children}\n}\n\nexport function DrawerDialogDescription({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n const Desc = isDesktop ? DialogDescription : DrawerDescription\n return {children}\n}\n\nexport function DrawerDialogFooter({ children }: { children: React.ReactNode }) {\n const { isDesktop } = useDrawerDialog()\n if (isDesktop) return children\n const Footer = DrawerFooter\n return (\n
\n {children}\n \n Cancel\n \n
\n )\n}", "type": "registry:ui" }, { diff --git a/public/r/registry.json b/public/r/registry.json index a6d00a8..5e89743 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -8,7 +8,10 @@ "type": "registry:component", "title": "Drawer Dialog", "description": "A simple component that works as a dialog component in desktop and as a drawer component in other screens.", - "registryDependencies": ["dialog", "drawer"], + "registryDependencies": [ + "dialog", + "drawer" + ], "files": [ { "path": "registry/new-york/blocks/drawer-dialog/drawer-dialog.tsx", @@ -19,24 +22,31 @@ "type": "registry:hook" } ] - }, { + }, + { "name": "relative-time", "type": "registry:component", "title": "Relative Time", "description": "A component that displays current relative time to the given input", - "registryDependencies": ["tooltip"], + "registryDependencies": [ + "tooltip" + ], "files": [ { "path": "registry/new-york/blocks/relative-time/relative-time.tsx", "type": "registry:ui" } ] - }, { + }, + { "name": "input-copyable", "type": "registry:component", "title": "Input Copyable", "description": "An Input component that has copy button which copies the value to clipboard", - "registryDependencies": ["input-group", "tooltip"], + "registryDependencies": [ + "input-group", + "tooltip" + ], "files": [ { "path": "registry/new-york/blocks/input-copyable/input-copyable.tsx", @@ -47,6 +57,37 @@ "type": "registry:hook" } ] + }, + { + "name": "crud", + "type": "registry:component", + "title": "CRUD", + "description": "A component that displays a CRUD interface for a given resource", + "registryDependencies": [ + "dialog", + "drawer", + "@vinkas/drawer-dialog" + ], + "dependencies": [ + "@tanstack/react-table" + ], + "files": [ + { + "path": "registry/new-york/blocks/crud/index.tsx", + "target": "registry/new-york/blocks/crud/index.tsx", + "type": "registry:block" + }, + { + "path": "registry/new-york/blocks/crud/table.tsx", + "target": "registry/new-york/blocks/crud/table.tsx", + "type": "registry:block" + }, + { + "path": "registry/new-york/blocks/crud/form.tsx", + "target": "registry/new-york/blocks/crud/form.tsx", + "type": "registry:block" + } + ] } ] -} +} \ No newline at end of file diff --git a/registry.json b/registry.json index 70c54c4..5e89743 100644 --- a/registry.json +++ b/registry.json @@ -74,7 +74,18 @@ "files": [ { "path": "registry/new-york/blocks/crud/index.tsx", - "type": "registry:ui" + "target": "registry/new-york/blocks/crud/index.tsx", + "type": "registry:block" + }, + { + "path": "registry/new-york/blocks/crud/table.tsx", + "target": "registry/new-york/blocks/crud/table.tsx", + "type": "registry:block" + }, + { + "path": "registry/new-york/blocks/crud/form.tsx", + "target": "registry/new-york/blocks/crud/form.tsx", + "type": "registry:block" } ] } From b6885d12232b56e6e1a7b66d5b51f0d2cbad85c1 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Sat, 6 Dec 2025 16:48:58 +0530 Subject: [PATCH 9/9] test: Add comprehensive CRUD operations tests for the Crud component. --- registry/new-york/blocks/crud/crud.test.tsx | 202 ++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 registry/new-york/blocks/crud/crud.test.tsx diff --git a/registry/new-york/blocks/crud/crud.test.tsx b/registry/new-york/blocks/crud/crud.test.tsx new file mode 100644 index 0000000..57249e0 --- /dev/null +++ b/registry/new-york/blocks/crud/crud.test.tsx @@ -0,0 +1,202 @@ +import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { Crud, CrudForm, CrudFormType } from './index'; +import { useState } from 'react'; +import { useMediaQuery } from '../drawer-dialog/hooks/use-media-query'; + +// Mock useMediaQuery to force desktop view +jest.mock('../drawer-dialog/hooks/use-media-query'); +const mockUseMediaQuery = useMediaQuery as jest.Mock; + +type TestData = { + id: string; + name: string; +}; + +const defaultData: TestData = { id: '', name: '' }; + +const TestCrud = ({ + onCreate = jest.fn(), + onEdit = jest.fn(), + onDelete = jest.fn(), + initialData = [] as TestData[], +}: { + onCreate?: jest.Mock; + onEdit?: jest.Mock; + onDelete?: jest.Mock; + initialData?: TestData[]; +}) => { + const [data, setData] = useState(initialData); + const formState = useState>({ + method: 'create', + data: defaultData, + }); + const [formType, setFormType] = formState; + + // Sync form data handling for the "form input" simulation + const handleNameChange = (e: React.ChangeEvent) => { + setFormType({ ...formType, data: { ...formType.data, name: e.target.value } }); + }; + + return ( + { + onCreate(d, e); + setData([...data, { ...d, id: 'new-id' }]); // Simulate add + }} + onEdit={(d, e) => { + onEdit(d, e); + setData(data.map(item => item.id === d.id ? d : item)); + }} + onDelete={(d) => { + onDelete(d); + setData(data.filter(item => item.id !== d.id)); + }} + > + + + + + + ); +}; + +describe('Crud Component', () => { + beforeEach(() => { + mockUseMediaQuery.mockReturnValue(true); // Desktop + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('renders "Add Item" button and table', () => { + render(); + expect(screen.getByText('Add Item')).toBeInTheDocument(); + expect(screen.getByText('No results.')).toBeInTheDocument(); + }); + + it('opens add dialog and submits new item', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const onCreateStub = jest.fn(); + render(); + + // Open Add Dialog + await user.click(screen.getByText('Add Item')); + + expect(await screen.findByRole('heading', { name: 'Add Item' })).toBeInTheDocument(); // Dialog title + + // Fill form + const input = screen.getByLabelText('Name'); + await user.type(input, 'New Item'); + + // Submit + const saveButton = screen.getByText('Save changes'); + await user.click(saveButton); + + // Initial state: submitting + expect(screen.getByText('Saving...')).toBeInTheDocument(); + + // Fast-forward timer + act(() => { + jest.runAllTimers(); + }); + + await waitFor(() => { + expect(onCreateStub).toHaveBeenCalled(); + expect(screen.getByText('New Item')).toBeInTheDocument(); // In table + }); + }); + + it('opens edit dialog and updates item', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const initialData = [{ id: '1', name: 'Existing Item' }]; + const onEditStub = jest.fn(); + render(); + + // Open Actions menu + const actionsButton = screen.getByRole('button', { name: /open menu/i }); + await user.click(actionsButton); + + // Click Edit + const editButton = await screen.findByText('Edit'); + await user.click(editButton); + + // Check Dialog Title + expect(await screen.findByRole('heading', { name: 'Edit Item' })).toBeInTheDocument(); + + // Check input has value + const input = screen.getByLabelText('Name'); + expect(input).toHaveValue('Existing Item'); + + // Change value + await user.clear(input); + await user.type(input, 'Updated Item'); + + // Submit + const saveButton = screen.getByText('Save changes'); + await user.click(saveButton); + + act(() => { + jest.runAllTimers(); + }); + + await waitFor(() => { + expect(onEditStub).toHaveBeenCalled(); + expect(screen.queryByText('Existing Item')).not.toBeInTheDocument(); + expect(screen.getByText('Updated Item')).toBeInTheDocument(); + }); + }); + + it('opens delete confirmation and deletes item', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const initialData = [{ id: '1', name: 'To Delete' }]; + const onDeleteStub = jest.fn(); + render(); + + // Verify item is there + expect(screen.getByText('To Delete')).toBeInTheDocument(); + + // Open Actions menu + const actionsButton = screen.getByRole('button', { name: /open menu/i }); + await user.click(actionsButton); + + // Click Delete + const deleteOption = await screen.findByText('Delete'); + await user.click(deleteOption); + + // Verify Alert Dialog + expect(await screen.findByText('Are you absolutely sure?')).toBeInTheDocument(); + + // Click Confirm Delete + // The button usually has text "Delete" and specific class, let's find it. + // In index.tsx: Delete + // There might be multiple "Delete" texts (the menu item `Delete`), so scoped search or unique text is better. + // The dialog should be top layer. + const dialog = screen.getByRole('alertdialog'); + const deleteButton = within(dialog).getByText('Delete'); + + await user.click(deleteButton); + + await waitFor(() => { + expect(onDeleteStub).toHaveBeenCalled(); + expect(screen.queryByText('To Delete')).not.toBeInTheDocument(); + }); + }); +}); +