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
39 changes: 39 additions & 0 deletions .changeset/12082-census-leg-a.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@object-ui/core': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-gantt': patch
'@object-ui/plugin-grid': patch
'@object-ui/plugin-form': patch
'@object-ui/app-shell': patch
---

More write affordances read the affordance-to-grant map, so a caller without the
grant is no longer offered a write the server then refuses (objectui#12082, leg A
of the write-census backlog). No row is added: each reuses the row of the same
verb on the same object.

- **The record overlay's inline edit** — the overlay the calendar, the kanban and
the gantt open on a record (`RecordDetailPanel`) — reads the `recordEdit` row,
the row the record page reads for the same in-place edit: the object's policy,
the effective API operation set and the caller's update grant. A closed row
opens no inline-edit session, as an absent `onFieldSave` already did. Each
field still asks its own field question, as since objectui#12103.
- **The gantt's in-chart writes** read `rowEdit` (a bar's drag, resize and
progress, its title edited in place, and its dependency links) and `rowDelete`
(a row's Delete). A closed row withholds the handler the chart draws the
affordance from, the way `readOnly` and `lockField` already do.
- **A grid's bulk field update** (a `bulkActionDefs` entry with `operation:
'update'`) reads `rowEdit`, as its bulk delete reads `rowDelete`: a closed row
removes the button from the selection bar, and with it the dialog, its undo
and its retry. `custom` operations are unchanged.
- **A master-detail form's line grids** read `relatedNew` (Add, the entry row
and Duplicate) and `relatedRowDelete` (Remove) on the CHILD object, as a
line-items panel does. Remove asks the delete grant on an edit form only: a
create form's lines are drafts, and a removed one is never a server delete.
- **A line-items panel** now hands its child object's schema to those two rows,
so the child's own policy (`managedBy`, `userActions`) is read too.
- **The AI build panel's spreadsheet import** (`ExcelImportBar`) reads the
`listImport` row of the object it imports into, as the list page's Import
does: without it, Import is disabled for that object.

With no permission provider mounted every one of these reads open, as before.
9 changes: 8 additions & 1 deletion content/docs/plugins/plugin-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,14 @@ Duplicate) only when the caller may create the CHILD object, and removing one
only when they may delete it — the `relatedNew` and `relatedRowDelete` rows of the
affordance-to-grant map in `@object-ui/core`, the same rows a related list reads
for the same two writes. Its cells ask each column's field question, as the form
layouts do. With no permission provider mounted both read open.
layouts do. The child's own policy is read too: the panel hands the child
object's schema to those two rows, so a child whose `userActions` close create or
delete offers neither. With no permission provider mounted both read open.

A `MasterDetailForm`'s line grids read the same two rows on the child object.
Remove asks the delete grant on an edit form only: a create form saves the parent
and one create per line, so a line removed before that first save is a draft,
never a server delete, and stays removable for a caller who may only create.

## Examples

Expand Down
8 changes: 8 additions & 0 deletions content/docs/plugins/plugin-gantt.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ The `@object-ui/plugin-gantt` plugin provides Gantt chart visualization for Obje
`dataSource.update()` automatically (optimistic local update + revert on
failure). See the [package README](https://github.com/objectstack-ai/objectui/blob/main/packages/plugin-gantt/README.md#drag-and-drop-rescheduling) for details on the
lower-level `onTaskUpdate` hook when embedding `<GanttView>` directly.
- **Writes follow the caller's grant**: a bar's drag, resize and progress, its
title edited in place and its dependency links are offered only when the
`rowEdit` row of the affordance-to-grant map (`@object-ui/core`) allows them —
the object's policy, the effective API operation set and the caller's
**update** grant — and a row's Delete only when `rowDelete` does. The record
drawer's inline edit reads the record page's `recordEdit` row. With no
permission provider mounted they read open, as `readOnly` and `lockField`
still close them.

<PluginLoader plugins={['gantt']}>

Expand Down
9 changes: 9 additions & 0 deletions content/docs/plugins/plugin-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,15 @@ target from that field, so a lookup param renders the record picker and the
confirm step shows the picked record's label. A param whose field is not in
the object metadata is refused, and the run is blocked.

The selection bar offers a write only to a caller who may make it. A bulk Delete
is the `rowDelete` row of the affordance-to-grant map (`@object-ui/core`), and a
`bulkActionDefs` entry with `operation: 'update'` — a patch written to every
selected record — is the `rowEdit` row, the same rows the row menu's Delete and
Edit read: the object's policy, the effective API operation set and the
caller's **delete** or **update** grant. A closed row removes the button, and
with it the dialog, its undo and its retry. `custom` entries are unchanged.
With no permission provider mounted both read open.

### Row callbacks are component props

`onRowClick`, `onRowSelect`, `onCellChange`, `onRowSave`, `onBatchSave`,
Expand Down
22 changes: 20 additions & 2 deletions packages/app-shell/src/console/ai/ExcelImportBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
* mapping, dry-run and the real server-side import. So the magic-flow trial
* goes from "one sentence → app with demo data" to "app with MY data" — the
* retention hinge for a spreadsheet refugee.
*
* Import is the `listImport` row of the affordance-to-grant map (objectui#12082),
* the row the console list's Import reads for the same wizard: it is offered for
* the picked object only when the caller's create grant and the server's
* effective operation set allow it, and picking another object asks again.
*/

import { useEffect, useMemo, useState } from 'react';
Expand All @@ -27,6 +32,8 @@ import {
import { createAuthenticatedFetch } from '@object-ui/auth';
import { toast } from 'sonner';
import { useObjectTranslation } from '@object-ui/i18n';
import { resolveAffordance } from '@object-ui/core';
import { usePermissions } from '@object-ui/permissions';
import { ImportWizard } from '@object-ui/plugin-grid';

interface WizardField {
Expand Down Expand Up @@ -133,6 +140,17 @@ export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }:
const [loadingFields, setLoadingFields] = useState(false);
const [fields, setFields] = useState<WizardField[] | null>(null);
const [open, setOpen] = useState(false);
const perms = usePermissions();
// objectui#12082 — the `listImport` row, asked of the picked object. The bar
// has not read that object's schema when it offers Import (it reads it on the
// press), so the policy half reads the default bucket; the grant and the
// effective operation set are the server's own refusal. With no permission
// provider mounted both read open, as before.
const importGranted = resolveAffordance('listImport', {
objectSchema: null,
objectName: selected,
perms,
}).allowed;

// List the env's user objects so the user can target the import. Uses the
// uncached meta list endpoint; filters platform/system objects.
Expand Down Expand Up @@ -162,7 +180,7 @@ export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }:
}, [authFetch, defaultObjectName]); // eslint-disable-line react-hooks/exhaustive-deps

async function openWizard() {
if (!selected) return;
if (!selected || !importGranted) return;
setLoadingFields(true);
try {
const schema = await dataSource.getObjectSchema(selected);
Expand Down Expand Up @@ -215,7 +233,7 @@ export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }:
onPick={setSelected}
disabled={!objects || objects.length === 0}
/>
<Button size="sm" onClick={openWizard} disabled={!selected || loadingFields}>
<Button size="sm" onClick={openWizard} disabled={!selected || loadingFields || !importGranted}>
{loadingFields ? t('excelImport.opening') : t('excelImport.importAction')}
</Button>
<Button size="sm" variant="ghost" onClick={onDone}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#12082 — the AI build panel's spreadsheet import reads the
* `listImport` row of the affordance-to-grant map, asked of the object it
* imports into.
*
* `ExcelImportBar` is the second mount of `ImportWizard`: the console list's
* Import is the first, and it has read the `listImport` row since the map
* landed. The bar read no grant at all, so a caller who may not create records
* of the picked object could press Import, map the sheet and run it, and every
* row was refused by the server. The bar now offers Import for the picked
* object only when its row allows it — the caller's create grant and the
* effective operation set — and picking another object asks again.
*
* Measured through the REAL `MePermissionsProvider`; the wizard is a probe that
* records the object it was opened for.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, cleanup, fireEvent, waitFor, screen, within, act } from '@testing-library/react';
import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';

const h = vi.hoisted(() => ({
/** The objects the bar lists as import targets. */
objects: [
{ name: 'crm_account', label: 'Account' },
{ name: 'crm_deal', label: 'Deal' },
],
/** The props each mounted ImportWizard received. */
wizard: [] as Array<Record<string, unknown>>,
}));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createAuthenticatedFetch: () => async () =>
new Response(JSON.stringify({ data: { items: h.objects } }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
}));
vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
ImportWizard: (props: { objectName: string }) => {
h.wizard.push({ objectName: props.objectName });
return <div data-testid="import-wizard" />;
},
}));
vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) }));

import { ExcelImportBar } from '../ExcelImportBar';

const FILE = new File(['a,b\n1,2'], 'deals.xlsx');
/** What `t()` answers with no i18n provider: the key itself. */
const IMPORT = 'excelImport.importAction';

const SHAPES: Array<{ shape: string; bits: Record<string, boolean>; create: boolean }> = [
{ shape: 'create-only', bits: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, create: true },
{ shape: 'edit-only', bits: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false }, create: false },
{ shape: 'read-only', bits: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, create: false },
{ shape: 'full', bits: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, create: true },
];
const FULL = SHAPES[3].bits;

const envelope = (objects: Record<string, Record<string, unknown>>): MePermissionsResponse => ({
authenticated: true,
userId: 'u-pin',
tenantId: null,
roles: ['member'],
permissionSets: ['member'],
objects: objects as never,
fields: {},
});

const settle = () => act(() => new Promise<void>((resolve) => setTimeout(resolve, 50)));

function mount(perms: MePermissionsResponse | null) {
const dataSource = {
getObjectSchema: vi.fn(async (_name: string) => ({ fields: { title: { type: 'text', label: 'Title' } } })),
};
const bar = <ExcelImportBar file={FILE} dataSource={dataSource} onDone={vi.fn()} />;
render(perms ? <MePermissionsProvider initialPermissions={perms}>{bar}</MePermissionsProvider> : bar);
return { dataSource };
}

const importButton = () => screen.getByRole('button', { name: IMPORT });

/** Press Import; report whether the wizard opened, and for which object. */
async function pressImport(dataSource: { getObjectSchema: ReturnType<typeof vi.fn> }): Promise<string | null> {
fireEvent.click(importButton());
await settle();
if (!screen.queryByTestId('import-wizard')) {
expect(dataSource.getObjectSchema).not.toHaveBeenCalled();
return null;
}
return String(h.wizard[h.wizard.length - 1].objectName);
}

beforeEach(() => {
cleanup();
h.wizard.length = 0;
});

describe.each(SHAPES)('the spreadsheet import bar under a $shape grant on its target (objectui#12082)', ({ bits, create }) => {
it('Import is offered exactly when the create grant allows it', async () => {
const { dataSource } = mount(envelope({ crm_account: bits, crm_deal: bits }));
await settle();
expect(importButton()).toHaveTextContent(IMPORT);
expect(importButton().hasAttribute('disabled'), 'disabled').toBe(!create);
expect(await pressImport(dataSource)).toBe(create ? 'crm_account' : null);
});
});

describe("the spreadsheet import bar: the map's other layers and fail-open (objectui#12082)", () => {
it('fail-open: with no permission provider Import is offered, as before', async () => {
const { dataSource } = mount(null);
await settle();
expect(importButton()).not.toBeDisabled();
expect(await pressImport(dataSource)).toBe('crm_account');
});

it('an effective operation set without import withholds Import under the full grant', async () => {
const { dataSource } = mount(envelope({ crm_account: { ...FULL, apiOperations: ['get', 'list', 'create', 'update'] } }));
await settle();
expect(importButton()).toBeDisabled();
expect(await pressImport(dataSource)).toBeNull();
});

it('the verdict is asked of the PICKED object: a closed target, then an open one', async () => {
const { dataSource } = mount(envelope({ crm_account: SHAPES[2].bits, crm_deal: FULL }));
await settle();
expect(importButton()).toBeDisabled();
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
const listbox = await screen.findByRole('listbox');
fireEvent.click(within(listbox).getAllByRole('option').find((o) => o.textContent === 'Deal')!);
await waitFor(() => expect(importButton()).not.toBeDisabled());
expect(await pressImport(dataSource)).toBe('crm_deal');
});
});
9 changes: 7 additions & 2 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,15 @@ An affordance added later gets a row here and reads it through these functions;
it does not spell its own `can(object, verb)`. That includes an affordance that
reads NO grant at all — the calendar's quick-create and drag-to-reschedule
(`calendarQuickCreate`, `calendarReschedule`) and the kanban card move
(`kanbanCardMove`) were such affordances until they gained their rows. The map's
(`kanbanCardMove`) were such affordances until they gained their rows. An
affordance that writes with the same verb on the same object reuses that row:
the record overlay's inline edit reads `recordEdit`, a gantt's bar edits and a
grid's bulk field update read `rowEdit`, a gantt row's Delete reads `rowDelete`,
and the AI build panel's spreadsheet import reads `listImport`. The map's
enumeration pin in `@object-ui/plugin-form` (`affordanceGrantMap-12082.test.tsx`)
also counts every write call site in the console tree and holds each to the row
it sits behind, or to the reason it has none.
it sits behind, or to the reason it has none — and, for a component whose write
its host gates (the import wizard), holds every mount to reading the row.

### Undo snapshot for an update (`captureUpdateUndoData`)

Expand Down
27 changes: 20 additions & 7 deletions packages/core/src/utils/affordanceGrants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ export const AFFORDANCE_GRANTS = {
/**
* The record page's Edit — the header CTA and the record body's in-place
* editing (the details body and the highlights strip), each of whose fields
* asks the update question (objectui#12103).
* asks the update question (objectui#12103) — and the record overlay's
* inline edit, the same body in the overlay the calendar, kanban and gantt
* open (`RecordDetailPanel` in `@object-ui/plugin-detail`).
*/
recordEdit: { crud: 'edit', grant: 'update', field: 'write' },
/** The record page's Delete. */
Expand All @@ -143,31 +145,42 @@ export const AFFORDANCE_GRANTS = {
* "+", and the `object-view` node's toolbar button (`@object-ui/plugin-view`).
*/
listNew: { crud: 'create', grant: 'create' },
/** An object list's Import, and the import wizard's writable target fields. */
/**
* An object list's Import, and the import wizard's writable target fields —
* and the AI build panel's spreadsheet import (`ExcelImportBar` in
* `@object-ui/app-shell`), the wizard's second mount, asked of the object it
* imports into.
*/
listImport: { crud: 'import', grant: 'create', field: 'create' },
/** The import wizard's template download — its endpoint answers 403 without the create grant. */
importTemplate: { crud: null, grant: 'create' },
/** A list's inline (in-cell) editing, each of whose cells asks the update question (objectui#12103). */
listInlineEdit: { crud: 'edit', grant: 'update', field: 'write' },
/** A list's bulk Delete. */
listBulkDelete: { crud: 'delete', grant: 'delete' },
/** A grid row's Edit. */
/**
* A grid row's Edit — and the writes that update the same rows in place: a
* grid's bulk field update (a `bulkActionDefs` `update` operation, with its
* undo and retry), and a gantt task's in-chart edits (a bar's drag, resize
* and progress, its title edited in place, and its dependency links).
*/
rowEdit: { crud: 'edit', grant: 'update' },
/** A grid row's Delete. */
/** A grid row's Delete — and a gantt row's Delete. */
rowDelete: { crud: 'delete', grant: 'delete' },
/** A grid's inline add-record row. */
gridAddRow: { crud: 'create', grant: 'create' },
/**
* A related list's "+ New", asked of the CHILD object — and a line-items
* panel's add-a-line (its Add, entry row and Duplicate), which creates a child
* under the same parent.
* panel's or a master-detail form's add-a-line (its Add, entry row and
* Duplicate), which creates a child under the same parent.
*/
relatedNew: { crud: 'create', grant: 'create' },
/** A related list row's Edit, asked of the child object. */
relatedRowEdit: { crud: 'edit', grant: 'update' },
/**
* A related list row's Delete, asked of the child object — and a line-items
* panel's remove-a-line, which deletes that child on Save.
* panel's or a master-detail EDIT form's remove-a-line, which deletes that
* child on Save.
*/
relatedRowDelete: { crud: 'delete', grant: 'delete' },
/** A lookup picker's "Create new", asked of the TARGET object. */
Expand Down
9 changes: 9 additions & 0 deletions packages/plugin-detail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,15 @@ delete action.
delete action — which is what lets a row locked through a renderer's `lockField`
open read-only without the panel knowing what a lock is.

**…and a handler is a wiring, not a grant.** The inline edit is the `recordEdit`
row of the affordance-to-grant map in `@object-ui/core` (objectui#12082), the row
the record page reads for the same in-place edit: the object's policy (read off
the `objectSchema` you pass), the effective API operation set and the caller's
**update** grant. When it is closed the panel opens no inline-edit session, as if
`onFieldSave` were absent; each field still asks its own field question. The
delete action is `DetailView`'s own `recordDelete` read. With no permission
provider mounted both read open.

`RECORD_OVERLAY_DEFAULT_WIDTH` is exported alongside it and is the single code
home of the default overlay width repo-wide. ⛔ Do not re-spell the literal at a
call site.
Expand Down
Loading
Loading