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 +

+ +
+
+ +
+
+ \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 a6d00a8..5e89743 100644 --- a/registry.json +++ b/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/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(); + }); + }); +}); + diff --git a/registry/new-york/blocks/crud/form.tsx b/registry/new-york/blocks/crud/form.tsx new file mode 100644 index 0000000..b960fd2 --- /dev/null +++ b/registry/new-york/blocks/crud/form.tsx @@ -0,0 +1,15 @@ +"use client" + +type CrudFormProps = { + children: React.ReactNode +} & React.FormHTMLAttributes + +const CrudForm = ({ children, ...props }: CrudFormProps) => { + return ( +
+ {children} +
+ ) +} + +export default CrudForm diff --git a/registry/new-york/blocks/crud/index.tsx b/registry/new-york/blocks/crud/index.tsx new file mode 100644 index 0000000..b606dbd --- /dev/null +++ b/registry/new-york/blocks/crud/index.tsx @@ -0,0 +1,136 @@ +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 + setDialogOpen: (v: boolean) => void +} + +const CrudContext = createContext(undefined) + +export function useCrud() { + const ctx = useContext(CrudContext) + if (!ctx) throw new Error("useCrud must be used within ") + return ctx +} + +export type CrudFormType = { + method: 'create' | 'update' + data: TData +} + +export type CrudProps = { + name: string + 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>>] + defaultData?: TData +} + +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 }) + } + const onCrudDelete = (data: TData) => { + setDeleteRow(data) + setDeleteDialogOpen(true) + } + 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 (formType.method === 'update') { + onEdit(formType.data, e) + } else { + onCreate(formType.data, e) + } + setSubmitting(false) + setDialogOpen(false) + }, 1000) + } + return ( + +
+
+ +
+ + {Content} +
+ + +
+ + {formType.method === 'update' ? "Edit" : "Add"} {name} + + +
+ {Form} +
+
+ + + +
+
+
+ + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the {name.toLowerCase()} record. + + + + Cancel + deleteRow && onDelete?.(deleteRow)} + className="bg-red-600 hover:bg-red-700" + > + Delete + + + + +
+ ) +} + +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 new file mode 100644 index 0000000..8b48d57 --- /dev/null +++ b/registry/new-york/blocks/crud/table.tsx @@ -0,0 +1,137 @@ +"use client" + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, +} from "@tanstack/react-table" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/registry/new-york/ui/table" +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "@/registry/new-york/ui/dropdown-menu"; +import { Button } from "@/registry/new-york/ui/button" +import { MoreHorizontal } from "lucide-react"; + +export type CrudTableProps = { + columns: ColumnDef[] + data: TData[] + nextPageToken?: string + onLoadMore?: (token?: string) => void + onEdit?: (row: TData) => void + onDelete?: (row: TData) => void +} + +export default function CrudTable({ + columns, + data, + nextPageToken, + onLoadMore, + onEdit, + onDelete, +}: CrudTableProps) { + const actionColumnIndex = columns.findIndex((column) => column.id === "actions") + if (actionColumnIndex === -1) { + columns.push({ + id: "actions", + cell: ({ row }) => ( + + + + + + + Actions + {onEdit && onEdit(row.original)}>Edit} + {onDelete && onDelete(row.original)}>Delete} + + + + ), + }) + } + + 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/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, +} 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, +}