Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/blocks/src/lib/app-kit/resource-kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,13 @@ export function ContactPage({ id }: { id: string }) {
name: 'defineResource(definition)',
type: 'ResourceDefinition<Row>',
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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -779,7 +779,7 @@ function OverflowAwareTableName({ name }: OverflowAwareTableNameProps) {

return (
<Tooltip>
<TooltipTrigger asChild>{triggerLabel}</TooltipTrigger>
<TooltipTrigger render={triggerLabel} />
<TooltipContent side='right' align='start'>
<span className='text-xs font-medium'>{name}</span>
</TooltipContent>
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/components/ai/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,11 @@ function MessageAction({
side = 'top',
...props
}: MessageActionProps) {
const trigger = React.isValidElement(children) ? children : undefined;
return (
<TooltipProvider>
<Tooltip {...props}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipTrigger render={trigger}>{trigger ? undefined : children}</TooltipTrigger>
<TooltipContent side={side} className={className}>
{tooltip}
</TooltipContent>
Expand Down
8 changes: 5 additions & 3 deletions packages/ui/src/components/ai/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Tooltip {...props}>
<TooltipTrigger
asChild
// Keep focus on the textarea when interacting with actions.
render={trigger}
// Keep the composer shell from refocusing the textarea.
onClick={(event) => event.stopPropagation()}
disabled={disabled}
>
{children}
{trigger ? undefined : children}
</TooltipTrigger>
<TooltipContent side={side} className={className}>
{tooltip}
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/ai/source.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ function Source({ href, title, description, favicon, domain, showTitle = false,

return (
<Tooltip>
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipTrigger render={chip} />
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-0.5">
<div className="font-medium">{title}</div>
Expand Down
17 changes: 11 additions & 6 deletions packages/ui/src/components/resource-kit/form-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())}`;
}
Expand All @@ -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':
Expand All @@ -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[],
Expand All @@ -59,9 +58,15 @@ export function initialFormValues(
linked?: Record<string, string[]>,
): 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;
}
Expand Down
28 changes: 16 additions & 12 deletions packages/ui/src/components/resource-kit/record-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? '…') : '';

Expand Down Expand Up @@ -101,24 +101,28 @@ export function RecordDetail({ resource: resourceRef, id, relatedLimit = 5, read
label={title || resource.singular}
title={breadcrumb}
actions={
readOnly || !row ? null : (
row ? (
<>
<Button size="sm" variant="outline" className="h-7" onClick={() => records.edit(row)} aria-label={`Edit ${title}`}>
<PencilIcon aria-hidden="true" />
<span className="hidden @md/view:inline">Edit</span>
</Button>
<Button size="sm" variant="destructive-outline" className="h-7" onClick={() => records.remove(row)} aria-label={`Delete ${title}`}>
<Trash2Icon aria-hidden="true" />
<span className="hidden @md/view:inline">Delete</span>
</Button>
{edit ? (
<Button size="sm" variant="outline" className="h-7" onClick={() => edit(row)} aria-label={`Edit ${title}`}>
<PencilIcon aria-hidden="true" />
<span className="hidden @md/view:inline">Edit</span>
</Button>
) : null}
{remove ? (
<Button size="sm" variant="destructive-outline" className="h-7" onClick={() => remove(row)} aria-label={`Delete ${title}`}>
<Trash2Icon aria-hidden="true" />
<span className="hidden @md/view:inline">Delete</span>
</Button>
) : null}
</>
)
) : null
}
maxWidth="max-w-4xl"
>
{body}
</ViewFrame>
{row ? records.dialogs : null}
{row ? dialogs : null}
</div>
);
}
Expand Down
10 changes: 6 additions & 4 deletions packages/ui/src/components/resource-kit/record-dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DialogState>(CLOSED);
const [deleting, setDeleting] = React.useState<DialogState>(CLOSED);
const dialogs = (
Expand All @@ -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,
};
}
10 changes: 5 additions & 5 deletions packages/ui/src/components/resource-kit/related-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 ? (
<Button size="xs" variant="outline" onClick={records.create} aria-label={`New ${inSentence(resource.singular)}`}>
<PlusIcon aria-hidden="true" />
<span className="hidden @md/view:inline">New {inSentence(resource.singular)}</span>
</Button>
)
) : null
}
/>
{list.data && total === 0 && !list.isError ? (
Expand All @@ -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}
Expand Down
16 changes: 8 additions & 8 deletions packages/ui/src/components/resource-kit/resource-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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 ? (
<Button size="sm" onClick={records.create}>
<PlusIcon aria-hidden="true" />
New {singular}
</Button>
)
) : undefined
}
/>
);
Expand Down Expand Up @@ -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')}
/>
);
Expand All @@ -134,12 +134,12 @@ export function ResourcePage({ resource: resourceRef, pageSize = 25, filters, re
</span>
}
actions={
readOnly ? null : (
records.create ? (
<Button size="sm" className="h-7" onClick={records.create} aria-label={`New ${singular}`}>
<PlusIcon aria-hidden="true" />
<span className="hidden @md/view:inline">New {singular}</span>
</Button>
)
) : null
}
className="gap-3 @3xl/view:py-5"
>
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/components/resource-kit/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -94,6 +96,8 @@ export type ResourceDefinition<Row extends Record<string, unknown> = Record<stri
/** Text fields the toolbar search covers. */
searchFields?: string[];
defaultSort?: { field: string; direction: 'asc' | 'desc' };
/** Writes shown by the built-in screens. Omit to show all three. */
operations?: { create: boolean; update: boolean; delete: boolean };
fields: Record<string, ResourceField>;
source: ResourceSource<Row>;
};
Expand Down
78 changes: 78 additions & 0 deletions packages/ui/test/resource-kit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<>
<ResourcePage resource="tasks" />
<RecordDetail resource="projects" id="p1" />
<ResourcePage resource="notes" filters={{ taskId: 'missing' }} />
</>,
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<HTMLButtonElement>('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(<EntityFormSheet resource="tasks" open onOpenChange={() => {}} 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(<EntityFormSheet resource="tasks" record={existing} open onOpenChange={() => {}} />, 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(<RecordDetail resource="projects" id="p1" />, resources);
Expand Down
Loading