From 59094b625de856527aa071f1f840d7415c35dc6b Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Fri, 6 Nov 2026 20:30:58 +0700 Subject: [PATCH 1/2] fix(registry): preserve tooltip trigger elements in Base UI installs --- .../components/schemas/schema-builder-sidebar.tsx | 2 +- packages/ui/src/components/ai/message.tsx | 3 ++- packages/ui/src/components/ai/prompt-input.tsx | 8 +++++--- packages/ui/src/components/ai/source.tsx | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/schema-builder/src/schema/schema-builder-tables/components/schemas/schema-builder-sidebar.tsx b/packages/schema-builder/src/schema/schema-builder-tables/components/schemas/schema-builder-sidebar.tsx index 8ff2abdc..cb3976b8 100644 --- a/packages/schema-builder/src/schema/schema-builder-tables/components/schemas/schema-builder-sidebar.tsx +++ b/packages/schema-builder/src/schema/schema-builder-tables/components/schemas/schema-builder-sidebar.tsx @@ -779,7 +779,7 @@ function OverflowAwareTableName({ name }: OverflowAwareTableNameProps) { return ( - {triggerLabel} + {name} diff --git a/packages/ui/src/components/ai/message.tsx b/packages/ui/src/components/ai/message.tsx index 9765737e..f9ea503f 100644 --- a/packages/ui/src/components/ai/message.tsx +++ b/packages/ui/src/components/ai/message.tsx @@ -119,10 +119,11 @@ function MessageAction({ side = 'top', ...props }: MessageActionProps) { + const trigger = React.isValidElement(children) ? children : undefined; return ( - {children} + {trigger ? undefined : children} {tooltip} diff --git a/packages/ui/src/components/ai/prompt-input.tsx b/packages/ui/src/components/ai/prompt-input.tsx index c026c4b0..25766c10 100644 --- a/packages/ui/src/components/ai/prompt-input.tsx +++ b/packages/ui/src/components/ai/prompt-input.tsx @@ -206,15 +206,17 @@ function PromptInputAction({ ...props }: PromptInputActionProps) { const { disabled } = usePromptInput(); + // Explicit render keeps the action intact when shadcn installs this for Base UI. + const trigger = React.isValidElement(children) ? children : undefined; return ( event.stopPropagation()} disabled={disabled} > - {children} + {trigger ? undefined : children} {tooltip} diff --git a/packages/ui/src/components/ai/source.tsx b/packages/ui/src/components/ai/source.tsx index 45af0738..d6fb7fc7 100644 --- a/packages/ui/src/components/ai/source.tsx +++ b/packages/ui/src/components/ai/source.tsx @@ -65,7 +65,7 @@ function Source({ href, title, description, favicon, domain, showTitle = false, return ( - {chip} +
{title}
From 5e49160fc0b171387e0040151788480b41a5d64e Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Fri, 6 Nov 2026 20:37:19 +0700 Subject: [PATCH 2/2] feat(resource-kit): honor write capabilities and create date defaults --- apps/blocks/src/lib/app-kit/resource-kit.ts | 8 +- .../components/resource-kit/form-values.ts | 17 ++-- .../components/resource-kit/record-detail.tsx | 28 ++++--- .../resource-kit/record-dialogs.tsx | 10 ++- .../components/resource-kit/related-list.tsx | 10 +-- .../components/resource-kit/resource-page.tsx | 16 ++-- .../ui/src/components/resource-kit/types.ts | 4 + packages/ui/test/resource-kit.test.tsx | 78 +++++++++++++++++++ 8 files changed, 135 insertions(+), 36 deletions(-) diff --git a/apps/blocks/src/lib/app-kit/resource-kit.ts b/apps/blocks/src/lib/app-kit/resource-kit.ts index b83054ce..b4dcf136 100644 --- a/apps/blocks/src/lib/app-kit/resource-kit.ts +++ b/apps/blocks/src/lib/app-kit/resource-kit.ts @@ -134,7 +134,13 @@ export function ContactPage({ id }: { id: string }) { name: 'defineResource(definition)', type: 'ResourceDefinition', behavior: - 'Names a table (name, label, singular, icon, primaryKey, titleField), its list columns, form and detail fields, search fields, default sort, field definitions, and source.', + 'Names a table, its fields, and its source. Set operations to { create, update, delete } to show only supported writes in the built-in list, detail, and related views. Omitting it keeps all three actions available.', + }, + { + name: 'ResourceField.createDefault', + type: "'now'", + behavior: + 'Prefills a date or datetime control from the reader’s local clock when a create form opens. An explicit form default takes precedence, and edit forms read the stored value.', }, { name: 'ResourceSource', diff --git a/packages/ui/src/components/resource-kit/form-values.ts b/packages/ui/src/components/resource-kit/form-values.ts index f9f26bee..8f36d336 100644 --- a/packages/ui/src/components/resource-kit/form-values.ts +++ b/packages/ui/src/components/resource-kit/form-values.ts @@ -20,9 +20,8 @@ export function formFieldsFor(resource: AnyResource, mode: FormMode): ResourceFi const pad = (value: number) => String(value).padStart(2, '0'); -/** An ISO timestamp as a `datetime-local` value in the reader's time zone. */ -function toLocalDateTime(value: string): string { - const date = new Date(value); +/** A date as a `datetime-local` value in the reader's time zone. */ +function toLocalDateTime(date: Date): string { if (Number.isNaN(date.getTime())) return ''; return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } @@ -38,7 +37,7 @@ function toFormValue(field: ResourceField, value: unknown): FormValue { case 'date': return /^\d{4}-\d{2}-\d{2}/.exec(String(value))?.[0] ?? ''; case 'datetime': - return toLocalDateTime(String(value)); + return toLocalDateTime(new Date(String(value))); case 'time': return String(value).slice(0, 5); case 'json': @@ -50,7 +49,7 @@ function toFormValue(field: ResourceField, value: unknown): FormValue { /** * The form's starting values: the record's (when editing), then any defaults - * (e.g. the parent of a related list), then empty controls. + * (e.g. the parent of a related list), then create defaults or empty controls. */ export function initialFormValues( fields: readonly ResourceField[], @@ -59,9 +58,15 @@ export function initialFormValues( linked?: Record, ): FormValues { const values: FormValues = {}; + let localNow: string | undefined; for (const field of fields) { const source = linked && isManyRelation(field) && field.name in linked ? linked[field.name] : record?.[field.name] ?? defaults?.[field.name]; - values[field.name] = toFormValue(field, source); + if (!record && source === undefined && field.createDefault === 'now' && (field.input === 'date' || field.input === 'datetime')) { + localNow ??= toLocalDateTime(new Date()); + values[field.name] = field.input === 'date' ? localNow.slice(0, 10) : localNow; + } else { + values[field.name] = toFormValue(field, source); + } } return values; } diff --git a/packages/ui/src/components/resource-kit/record-detail.tsx b/packages/ui/src/components/resource-kit/record-detail.tsx index 27b8a4eb..ea721283 100644 --- a/packages/ui/src/components/resource-kit/record-detail.tsx +++ b/packages/ui/src/components/resource-kit/record-detail.tsx @@ -41,7 +41,7 @@ export function RecordDetail({ resource: resourceRef, id, relatedLimit = 5, read const record = useResourceRecord(resource, id); const related = React.useMemo(() => hasManyRelations(resource, resources), [resource, resources]); const row = record.data ?? null; - const records = useRecordDialogs(resource, { record: row, onDeleted: () => router.navigate(router.listHref(resource.name)) }); + const { edit, remove, dialogs } = useRecordDialogs(resource, { readOnly, record: row, onDeleted: () => router.navigate(router.listHref(resource.name)) }); const titleOf = useRowTitles(resource, row ? [row] : []); const title = row ? (titleOf(row) ?? '…') : ''; @@ -101,24 +101,28 @@ export function RecordDetail({ resource: resourceRef, id, relatedLimit = 5, read label={title || resource.singular} title={breadcrumb} actions={ - readOnly || !row ? null : ( + row ? ( <> - - + {edit ? ( + + ) : null} + {remove ? ( + + ) : null} - ) + ) : null } maxWidth="max-w-4xl" > {body} - {row ? records.dialogs : null} + {row ? dialogs : null}
); } diff --git a/packages/ui/src/components/resource-kit/record-dialogs.tsx b/packages/ui/src/components/resource-kit/record-dialogs.tsx index 2d90a84f..123597f2 100644 --- a/packages/ui/src/components/resource-kit/record-dialogs.tsx +++ b/packages/ui/src/components/resource-kit/record-dialogs.tsx @@ -14,13 +14,14 @@ const CLOSED: DialogState = { open: false, record: null }; type RecordDialogsOptions = { /** Starting values for a new record, e.g. the parent of a related list. */ defaultValues?: ResourceRow; + readOnly?: boolean; onDeleted?: (id: string) => void; /** The record a detail view shows: the dialogs follow its live row instead of the row they opened with. */ record?: ResourceRow | null; }; /** The create and edit sheet and the delete confirmation of one resource, with the state that opens them. */ -export function useRecordDialogs(resource: AnyResource, { defaultValues, onDeleted, record }: RecordDialogsOptions = {}) { +export function useRecordDialogs(resource: AnyResource, { defaultValues, onDeleted, readOnly, record }: RecordDialogsOptions = {}) { const [sheet, setSheet] = React.useState(CLOSED); const [deleting, setDeleting] = React.useState(CLOSED); const dialogs = ( @@ -41,10 +42,11 @@ export function useRecordDialogs(resource: AnyResource, { defaultValues, onDelet /> ); + const { operations } = resource; return { - create: () => setSheet({ open: true, record: null }), - edit: (row: ResourceRow) => setSheet({ open: true, record: row }), - remove: (row: ResourceRow) => setDeleting({ open: true, record: row }), + create: !readOnly && operations?.create !== false ? () => setSheet({ open: true, record: null }) : undefined, + edit: !readOnly && operations?.update !== false ? (row: ResourceRow) => setSheet({ open: true, record: row }) : undefined, + remove: !readOnly && operations?.delete !== false ? (row: ResourceRow) => setDeleting({ open: true, record: row }) : undefined, dialogs, }; } diff --git a/packages/ui/src/components/resource-kit/related-list.tsx b/packages/ui/src/components/resource-kit/related-list.tsx index 59ed8c9b..9c168c8d 100644 --- a/packages/ui/src/components/resource-kit/related-list.tsx +++ b/packages/ui/src/components/resource-kit/related-list.tsx @@ -31,7 +31,7 @@ export function RelatedList({ parent, parentId, relation, limit, readOnly }: Rel const { resource, field } = relation; const { locale } = useResourceKit(); const icon = useResourceIcon(resource); - const records = useRecordDialogs(resource, { defaultValues: { [field.name]: parentId } }); + const records = useRecordDialogs(resource, { readOnly, defaultValues: { [field.name]: parentId } }); const query: ListQuery = { sort: resource.defaultSort, offset: 0, limit, filters: { [field.name]: parentId } }; const list = useResourceList(resource, query); const failure = queryFailure(list); @@ -55,12 +55,12 @@ export function RelatedList({ parent, parentId, relation, limit, readOnly }: Rel } description={list.data && total > limit ? `Showing the first ${formatCount(limit, locale)}.` : undefined} actions={ - readOnly ? null : ( + records.create ? ( - ) + ) : null } /> {list.data && total === 0 && !list.isError ? ( @@ -73,8 +73,8 @@ export function RelatedList({ parent, parentId, relation, limit, readOnly }: Rel loading={list.isPending && !list.failureReason} error={failure ? `Couldn’t load ${inSentence(resource.label)}: ${errorMessage(failure)}` : undefined} skeletonRows={Math.min(limit, 3)} - onEdit={readOnly ? undefined : records.edit} - onDelete={readOnly ? undefined : records.remove} + onEdit={records.edit} + onDelete={records.remove} /> )} {records.dialogs} diff --git a/packages/ui/src/components/resource-kit/resource-page.tsx b/packages/ui/src/components/resource-kit/resource-page.tsx index 666e8f58..b4b105a9 100644 --- a/packages/ui/src/components/resource-kit/resource-page.tsx +++ b/packages/ui/src/components/resource-kit/resource-page.tsx @@ -44,7 +44,7 @@ export function ResourcePage({ resource: resourceRef, pageSize = 25, filters, re const debounced = useDebounce(search.trim(), 250); const [sort, setSort] = React.useState(resource.defaultSort); const [offset, setOffset] = React.useState(0); - const records = useRecordDialogs(resource); + const records = useRecordDialogs(resource, { readOnly }); // A new search or sort starts from the first page, adjusted during render rather than in an effect. const pageKey = `${debounced}\u0000${sort?.field}\u0000${sort?.direction}`; @@ -80,14 +80,14 @@ export function ResourcePage({ resource: resourceRef, pageSize = 25, filters, re className="rounded-xl bg-card shadow-card" icon={icon} title={`No ${noun} yet`} - description={readOnly ? undefined : `Create the first ${singular} to see it here.`} + description={records.create ? `Create the first ${singular} to see it here.` : undefined} action={ - readOnly ? undefined : ( + records.create ? ( - ) + ) : undefined } /> ); @@ -115,8 +115,8 @@ export function ResourcePage({ resource: resourceRef, pageSize = 25, filters, re skeletonRows={Math.min(pageSize, 8)} sort={sort} onSortChange={setSort} - onEdit={readOnly ? undefined : records.edit} - onDelete={readOnly ? undefined : records.remove} + onEdit={records.edit} + onDelete={records.remove} className={cn(list.isPlaceholderData && 'opacity-70 transition-opacity')} /> ); @@ -134,12 +134,12 @@ export function ResourcePage({ resource: resourceRef, pageSize = 25, filters, re } actions={ - readOnly ? null : ( + records.create ? ( - ) + ) : null } className="gap-3 @3xl/view:py-5" > diff --git a/packages/ui/src/components/resource-kit/types.ts b/packages/ui/src/components/resource-kit/types.ts index 3e15e9a0..b4440e00 100644 --- a/packages/ui/src/components/resource-kit/types.ts +++ b/packages/ui/src/components/resource-kit/types.ts @@ -33,6 +33,8 @@ export type ResourceField = { /** List and detail display; defaults from `input`. */ format?: FieldFormat; required?: boolean; + /** Prefill a new date or datetime field from the local clock. Explicit form defaults take precedence. */ + createDefault?: 'now'; /** Shown, never written (e.g. createdAt). */ readOnly?: boolean; /** Never shown (e.g. ownerId). */ @@ -94,6 +96,8 @@ export type ResourceDefinition = Record; source: ResourceSource; }; diff --git a/packages/ui/test/resource-kit.test.tsx b/packages/ui/test/resource-kit.test.tsx index 278bafcb..57cfa661 100644 --- a/packages/ui/test/resource-kit.test.tsx +++ b/packages/ui/test/resource-kit.test.tsx @@ -234,6 +234,84 @@ async function submit() { } describe('Resource Kit', () => { + it('shows only supported writes on lists, details, and related lists', async () => { + const { resources } = workspace(); + const limited = resources.map((resource) => { + if (resource.name === 'tasks') return { ...resource, operations: { create: true, update: false, delete: false } }; + if (resource.name === 'projects') return { ...resource, operations: { create: false, update: true, delete: false } }; + if (resource.name === 'notes') return { ...resource, operations: { create: false, update: false, delete: false } }; + return resource; + }); + await render( + <> + + + + , + limited, + ); + await settle(); + + const hasButton = (name: string) => [...document.querySelectorAll('button')].some((element) => (element.getAttribute('aria-label') ?? text(element)) === name); + expect([...document.querySelectorAll('button[aria-label="New task"]')]).toHaveLength(2); + expect(hasButton('Edit Launch')).toBe(true); + expect(hasButton('Delete Launch')).toBe(false); + expect(text(document.body)).toContain('No notes yet'); + expect(hasButton('New note')).toBe(false); + + const rowActions = [...document.querySelectorAll('button[aria-label="Actions for Write the brief"]')]; + expect(rowActions).toHaveLength(2); + for (const actions of rowActions) { + await click(actions); + expect([...document.querySelectorAll('[role="menuitem"]')].map(text)).toEqual(['Open']); + await act(async () => { + document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + await settle(); + } + }); + + it('prefills local dates only when creating and preserves explicit starting values', async () => { + const localNow = new Date(2026, 10, 6, 0, 5); + vi.setSystemTime(localNow); + try { + const { resources, tasks } = workspace(); + const dated = resources.map((resource) => resource.name === 'tasks' ? { + ...resource, + formFields: [...resource.formFields, 'happenedAt', 'dueOn', 'scheduledFor'], + fields: { + ...resource.fields, + happenedAt: { name: 'happenedAt', label: 'Happened at', input: 'datetime' as const, required: true, createDefault: 'now' as const }, + dueOn: { name: 'dueOn', label: 'Due', input: 'date' as const, required: true, createDefault: 'now' as const }, + scheduledFor: { name: 'scheduledFor', label: 'Scheduled for', input: 'date' as const, required: true, createDefault: 'now' as const }, + }, + } : resource); + await render( {}} defaultValues={{ projectId: 'p1', scheduledFor: '2026-12-01' }} />, dated); + await settle(); + + expect(field('Happened at').value).toBe('2026-11-06T00:05'); + expect(field('Due').value).toBe('2026-11-06'); + expect(field('Scheduled for').value).toBe('2026-12-01'); + await type(field('Title'), 'Kickoff'); + await submit(); + expect(tasks.create).toHaveBeenCalledWith(expect.objectContaining({ + happenedAt: localNow.toISOString(), + dueOn: '2026-11-06', + scheduledFor: '2026-12-01', + })); + + await act(async () => root!.unmount()); + container!.remove(); + const existing = await tasks.get('t1'); + await render( {}} />, dated); + await settle(); + expect(field('Happened at').value).toBe(''); + expect(field('Due').value).toBe(''); + } finally { + vi.useRealTimers(); + } + }); + it('creates a related record from a detail view with the parent filled in, mapped values, and a refreshed list', async () => { const { resources, tasks } = workspace(); await render(, resources);