Skip to content

Commit b9e4fde

Browse files
committed
feat: add @hanzo/ui/dash — shared dashboard UI (layout, data table, forms, CRUD)
1 parent 5c1cf7a commit b9e4fde

File tree

10 files changed

+1542
-0
lines changed

10 files changed

+1542
-0
lines changed

pkg/ui/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,17 @@
278278
"import": "./dist/drawer.mjs",
279279
"require": "./dist/drawer.js"
280280
},
281+
"./dash": {
282+
"types": "./dist/dash/index.d.ts",
283+
"import": "./dist/dash/index.mjs",
284+
"require": "./dist/dash/index.js"
285+
},
286+
"./dash/tokens.css": "./src/dash/tokens.css",
287+
"./dash/*": {
288+
"types": "./dist/dash/*.d.ts",
289+
"import": "./dist/dash/*.mjs",
290+
"require": "./dist/dash/*.js"
291+
},
281292
"./billing": {
282293
"types": "./dist/billing/index.d.ts",
283294
"import": "./dist/billing/index.mjs",

pkg/ui/src/dash/dash-crud.tsx

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import { cn } from '../../utils'
5+
import { DashDataTable, type DashColumn, type DashDataTableProps } from './dash-data-table'
6+
import { DashForm, type DashFieldDef } from './dash-form'
7+
8+
/* ------------------------------------------------------------------ */
9+
/* Types */
10+
/* ------------------------------------------------------------------ */
11+
12+
export interface DashCrudProps<T> {
13+
/** Resource name (e.g. "User", "API Key") used in UI labels */
14+
resourceName: string
15+
/** Plural resource name (defaults to resourceName + "s") */
16+
resourceNamePlural?: string
17+
/** Column definitions for the list table */
18+
columns: DashColumn<T>[]
19+
/** Row data */
20+
data: T[]
21+
/** Unique key extractor per row */
22+
rowKey: (row: T) => string
23+
/** Field definitions for create/edit form */
24+
fields: DashFieldDef[]
25+
/** Convert a row to form values for editing */
26+
rowToValues?: (row: T) => Record<string, unknown>
27+
/** Called when creating a new record */
28+
onCreate?: (values: Record<string, unknown>) => Promise<void>
29+
/** Called when updating an existing record */
30+
onUpdate?: (key: string, values: Record<string, unknown>) => Promise<void>
31+
/** Called when deleting a record */
32+
onDelete?: (key: string) => Promise<void>
33+
/** Whether create is available */
34+
canCreate?: boolean
35+
/** Whether edit is available */
36+
canEdit?: boolean
37+
/** Whether delete is available */
38+
canDelete?: boolean
39+
/** Pass-through DataTable props */
40+
tableProps?: Partial<DashDataTableProps<T>>
41+
/** Loading state */
42+
loading?: boolean
43+
className?: string
44+
}
45+
46+
type View = 'list' | 'create' | 'edit'
47+
48+
/* ------------------------------------------------------------------ */
49+
/* Component */
50+
/* ------------------------------------------------------------------ */
51+
52+
export function DashCrud<T>({
53+
resourceName,
54+
resourceNamePlural,
55+
columns,
56+
data,
57+
rowKey,
58+
fields,
59+
rowToValues,
60+
onCreate,
61+
onUpdate,
62+
onDelete,
63+
canCreate = true,
64+
canEdit = true,
65+
canDelete = true,
66+
tableProps,
67+
loading = false,
68+
className,
69+
}: DashCrudProps<T>) {
70+
const plural = resourceNamePlural ?? `${resourceName}s`
71+
const [view, setView] = React.useState<View>('list')
72+
const [editingKey, setEditingKey] = React.useState<string | null>(null)
73+
const [editValues, setEditValues] = React.useState<Record<string, unknown>>({})
74+
const [deleteKey, setDeleteKey] = React.useState<string | null>(null)
75+
const [submitting, setSubmitting] = React.useState(false)
76+
77+
/* ------ edit ------ */
78+
function handleEdit(row: T) {
79+
const key = rowKey(row)
80+
setEditingKey(key)
81+
setEditValues(rowToValues ? rowToValues(row) : (row as Record<string, unknown>))
82+
setView('edit')
83+
}
84+
85+
/* ------ delete confirm ------ */
86+
function handleDeleteConfirm(row: T) {
87+
setDeleteKey(rowKey(row))
88+
}
89+
90+
async function confirmDelete() {
91+
if (!deleteKey || !onDelete) return
92+
setSubmitting(true)
93+
try {
94+
await onDelete(deleteKey)
95+
} finally {
96+
setSubmitting(false)
97+
setDeleteKey(null)
98+
}
99+
}
100+
101+
/* ------ actions column ------ */
102+
function actionsRenderer(row: T) {
103+
return (
104+
<div className="flex items-center justify-end gap-1">
105+
{canEdit && onUpdate && (
106+
<button
107+
type="button"
108+
onClick={() => handleEdit(row)}
109+
className="rounded-[var(--dash-radius-sm)] px-2 py-1 text-xs text-[var(--dash-text-muted)] hover:bg-[var(--dash-surface-hover)] hover:text-[var(--dash-text)] transition-colors"
110+
>
111+
Edit
112+
</button>
113+
)}
114+
{canDelete && onDelete && (
115+
<button
116+
type="button"
117+
onClick={() => handleDeleteConfirm(row)}
118+
className="rounded-[var(--dash-radius-sm)] px-2 py-1 text-xs text-[var(--dash-error)] hover:bg-[var(--dash-error-dim)] transition-colors"
119+
>
120+
Delete
121+
</button>
122+
)}
123+
</div>
124+
)
125+
}
126+
127+
/* ------ list view ------ */
128+
if (view === 'list') {
129+
return (
130+
<div className={cn('space-y-4', className)}>
131+
<div className="flex items-center justify-between">
132+
<h2 className="text-xl font-semibold text-[var(--dash-text)]">{plural}</h2>
133+
{canCreate && onCreate && (
134+
<button
135+
type="button"
136+
onClick={() => {
137+
setEditingKey(null)
138+
setEditValues({})
139+
setView('create')
140+
}}
141+
className={cn(
142+
'rounded-[var(--dash-radius)] px-3 py-1.5 text-sm font-medium transition-colors',
143+
'bg-[var(--dash-primary)] text-[var(--dash-primary-text)]',
144+
'hover:bg-[var(--dash-primary-hover)]',
145+
)}
146+
>
147+
Create {resourceName}
148+
</button>
149+
)}
150+
</div>
151+
152+
<DashDataTable
153+
columns={columns}
154+
data={data}
155+
rowKey={rowKey}
156+
actions={(canEdit || canDelete) ? actionsRenderer : undefined}
157+
loading={loading}
158+
searchable
159+
emptyMessage={`No ${plural.toLowerCase()} found`}
160+
{...tableProps}
161+
/>
162+
163+
{/* Delete confirmation dialog */}
164+
{deleteKey !== null && (
165+
<DeleteDialog
166+
resourceName={resourceName}
167+
loading={submitting}
168+
onConfirm={confirmDelete}
169+
onCancel={() => setDeleteKey(null)}
170+
/>
171+
)}
172+
</div>
173+
)
174+
}
175+
176+
/* ------ create / edit view ------ */
177+
return (
178+
<div className={cn('space-y-4', className)}>
179+
<button
180+
type="button"
181+
onClick={() => setView('list')}
182+
className="inline-flex items-center gap-1 text-sm text-[var(--dash-text-muted)] hover:text-[var(--dash-text)] transition-colors"
183+
>
184+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
185+
<path d="M10 4L6 8l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
186+
</svg>
187+
Back to {plural}
188+
</button>
189+
190+
<DashForm
191+
title={view === 'create' ? `Create ${resourceName}` : `Edit ${resourceName}`}
192+
fields={fields}
193+
values={view === 'edit' ? editValues : undefined}
194+
loading={submitting}
195+
submitLabel={view === 'create' ? 'Create' : 'Save changes'}
196+
onCancel={() => setView('list')}
197+
onSubmit={async (vals) => {
198+
setSubmitting(true)
199+
try {
200+
if (view === 'create' && onCreate) {
201+
await onCreate(vals)
202+
} else if (view === 'edit' && editingKey && onUpdate) {
203+
await onUpdate(editingKey, vals)
204+
}
205+
setView('list')
206+
} finally {
207+
setSubmitting(false)
208+
}
209+
}}
210+
/>
211+
</div>
212+
)
213+
}
214+
215+
/* ------------------------------------------------------------------ */
216+
/* DeleteDialog (internal) */
217+
/* ------------------------------------------------------------------ */
218+
219+
function DeleteDialog({
220+
resourceName,
221+
loading,
222+
onConfirm,
223+
onCancel,
224+
}: {
225+
resourceName: string
226+
loading: boolean
227+
onConfirm: () => void
228+
onCancel: () => void
229+
}) {
230+
return (
231+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
232+
<div className="w-full max-w-sm rounded-[var(--dash-radius-lg)] border border-[var(--dash-border)] bg-[var(--dash-surface)] p-6 shadow-lg">
233+
<h3 className="text-lg font-semibold text-[var(--dash-text)]">Delete {resourceName}?</h3>
234+
<p className="mt-2 text-sm text-[var(--dash-text-muted)]">
235+
This action cannot be undone. The {resourceName.toLowerCase()} will be permanently removed.
236+
</p>
237+
<div className="mt-6 flex items-center justify-end gap-3">
238+
<button
239+
type="button"
240+
onClick={onCancel}
241+
disabled={loading}
242+
className={cn(
243+
'rounded-[var(--dash-radius)] px-4 py-2 text-sm font-medium transition-colors',
244+
'border border-[var(--dash-border)] text-[var(--dash-text-muted)]',
245+
'hover:bg-[var(--dash-surface-hover)] hover:text-[var(--dash-text)]',
246+
)}
247+
>
248+
Cancel
249+
</button>
250+
<button
251+
type="button"
252+
onClick={onConfirm}
253+
disabled={loading}
254+
className={cn(
255+
'rounded-[var(--dash-radius)] px-4 py-2 text-sm font-medium transition-colors',
256+
'bg-[var(--dash-error)] text-white',
257+
'hover:bg-red-600',
258+
'disabled:opacity-50 disabled:cursor-not-allowed',
259+
)}
260+
>
261+
{loading ? 'Deleting...' : 'Delete'}
262+
</button>
263+
</div>
264+
</div>
265+
</div>
266+
)
267+
}

0 commit comments

Comments
 (0)