Skip to content

Commit 7525045

Browse files
fix(plugin-detail,plugin-gantt,plugin-grid,plugin-form,app-shell): leg A of the write census reads the affordance-to-grant map (objectui#12082) (#12132)
Part of #12082 (leg A of the write-census backlog; the backlog freeze and leg B stay on the card) Clause-②: no `domain:ui` seat 2 dispatch, claim `6106206228`, implementation session `session_01TYgwmFK1q4KJ6Qq2WRLzsD` (subagent mode: the seat's id). The card stays open: this round converts leg A only, per the claim's sequencing call. ## What lands Ten `unmapped` entries of the write census (`WRITE_SITES` in `packages/plugin-form/src/affordanceGrantMap-12082.test.tsx`) become `mapped`, each read where its affordance is offered. **No row is added to `AFFORDANCE_GRANTS`**: every site reuses the row of the same verb on the same object, and the row comments now name the new affordances. So `@object-ui/core` is `patch` (comment-only source change). | Census entry (site) | Row | Read in | How a closed row withholds it | |---|---|---|---| | calendar, kanban, gantt `renderRecordOverlay` update (3) | `recordEdit` | `plugin-detail` `RecordDetailPanel` | no inline-edit session: `canEdit`, `inlineEdit` and the save bar all see no handler | | gantt `handleTaskUpdateDefault` (bar drag / resize / progress, in-place title) | `rowEdit` | `plugin-gantt` `ObjectGantt` | `onTaskUpdate` withheld from `GanttView` | | gantt `persistDependencies` (link create / delete) | `rowEdit` | `ObjectGantt` | `onDependencyCreate` / `onDependencyDelete` withheld | | gantt `confirmDelete` (row Delete) | `rowDelete` | `ObjectGantt` | `onTaskDelete` withheld | | grid `useBulkExecutor` per-row / undo / retry update (3) | `rowEdit` | `plugin-grid` `ObjectGrid` | a `bulkActionDefs` `update` def is filtered from the bar, as a `delete` def already was on `rowDelete`; no dialog, so no undo or retry | | `MasterDetailForm` `sendBatch` | `relatedNew` / `relatedRowDelete` on the child (+ the form rows for the header) | `MasterDetailForm` (+ `fieldWriteGate`) | the line grid's `allowAdd` / `allowDelete` | The riders triage placed with leg A: - **Line items, `childSchema`:** `LineItemsPanel` hands the child schema it already loads (for the save's sanitize) to `relatedNew` / `relatedRowDelete`, so the child's own policy (`managedBy`, `userActions`) is read, as `RelatedRecordActionsBridge` does. - **`ExcelImportBar`:** reads `listImport` of the picked object; Import is disabled when the row is closed, and picking another object asks again. `GanttView.tsx`, `useBulkExecutor.ts`, `resolveBulkActions.ts`, `BulkActionDialog.tsx` and the three overlay hosts are not edited: each affordance is already drawn from handler or def presence, so the gate sits where the handler is handed over. ## Measured on main first Each runtime pin was written against BASE source and is the defect direction when red. Recorded run (restore-trapped script, commit `9e3748a`): the seven source files checked out at BASE `29b99490` blobs (7/7 hashes equal on disk, each fix anchor counted 0), then the six runtime pins and the census: `Test Files 7 failed (7)` · `Tests 28 failed | 73 passed (101)`; restore proven: `7/7 blobs == HEAD (9e3748a), git diff HEAD empty`. | Pin | red on BASE | what was red | stayed green on BASE (controls) | |---|---|---|---| | `plugin-detail` `RecordDetailPanel.editGrant-12082` | 4 / 9 | create-only and read-only shapes (with an `editable: true` field entry), an operation set without `update`, `userActions.edit: false` | edit-only, full, fail-open, the plain read-only shape (see H6) | | `plugin-gantt` `ObjectGantt.writeGrant-12082` | 7 / 11 | every shape lacking update or delete, the operation set, the policy | edit-only update, full, fail-open | | `plugin-grid` `bulkUpdateGrant-12082` | 4 / 7 | create-only, read-only, the operation set, the policy | edit-only, full, fail-open | | `plugin-form` `MasterDetailForm.lineGrant-12082` | 6 / 11 | edit form: create-only (remove), edit-only, read-only; create form: edit-only, read-only (entry row present); the operation set | full, create-only create form, fail-open, the H4 measurement | | `plugin-form` `LineItemsPanel.lineGrant-12082` | 1 / 8 | the child-policy rider | the round-1 cases, the rider's control | | `app-shell` `ExcelImportBar.importGrant-12082` | 4 / 7 | edit-only, read-only, the operation set, the picked-object case | create-only, full, fail-open | | census `affordanceGrantMap-12082` | 2 / 48 | "every mapped site names rows … read by one of the files it names" (16 problems), the overlay one-seam pin | the rest | ### The PM's hypotheses, as measured - **H1 holds.** All three hosts reach the overlay's inline edit through `RecordDetailPanel`'s `onFieldSave`, and `recordEdit` is the row the record page reads for the same in-place edit. The grid and tree overlays hand no `onFieldSave` (read-only by design) and are unchanged. - **H6, overlay, partly already closed.** Since objectui#12117 each field asks the row's field question, so the plain read-only shape (no field entry, `allowEdit: false`) was already withheld on BASE (the CONTROL case is green on BASE). The site still reproduced through the three object-level layers the field question does not ask: a permission set naming a field `editable: true` under an object grant without `allowEdit`, an effective operation set without `update`, and an object policy closing edit. Those are what the `recordEdit` read closes. - **H2:** the gantt needs no internal prop: `GanttView` already draws every edit affordance (drag, resize, progress, in-place title, link handles, the Delete item, undo, auto-schedule) from handler presence. Reused `rowEdit` / `rowDelete` rather than adding gantt rows (Zone 3: reuse where verb and object match). - **H3:** the gate sits in `ObjectGrid` where `bulkActionDefs` are resolved, reading the `rowEditVerdict` already computed there. objectui#12119 had landed before this branch's base (`29b99490`), so both intents are in the file. Undo and retry are only reachable from the dialog of a def the bar drew, so they inherit the verdict. - **H4, measured.** A create form's batch is `buildMasterDetailBatch`: the parent create plus one create per line, never a delete. The MEASURED case adds two lines, removes one, saves, and reads `[sales_order:create, sales_order_line:create]` with the kept line's value. So remove asks `relatedRowDelete` on an edit form only; on a create form it stays open for a create-only caller. - **H5:** the census keys one site for two mounts, so a new `HOST_GATED` list holds every console mount of `ImportWizard` (a JSX element of that name, found by the same TypeScript walk) to the site's `readBy`, and the existing mapped-site check holds each named file to reading the row. Ablation A1 below. ### Ablation A1 (the mount census can fail) `scripts/ablation-replace.mjs` (objectstack) WRAP mode at `9e3748a`, prediction written first: drop `EXCEL_IMPORT_BAR` from the import wizard entry's `readBy` → the mount census alone goes red naming `ExcelImportBar.tsx`. Observed: anchor 1 → 0, blob moved; `Tests 1 failed | 47 passed (48)`, the one failure "packages/app-shell/src/console/ai/ExcelImportBar.tsx mounts ImportWizard but is not named by …"; restored `blob == HEAD (47a5b15)`, `git diff HEAD` empty. As predicted. ## Tests and gates Runtime pins + census + the re-judged `rowCrudEffectiveOps` fixture at merged head `5d006eb` (main `0df67f23` merged, see below): `Test Files 8 passed (8)` · `Tests 121 passed (121)`. At `9e3748a` (before the merge of main): dependency closure built (`turbo` `Tasks: 28 successful, 28 total`); `pnpm --filter @object-ui/PKG type-check` for `core`, `plugin-detail`, `plugin-gantt`, `plugin-grid`, `plugin-form`, `app-shell`: each echoed its own `type-check` script, exit 0, 0 `error TS` (each package's `tsconfig.test.json` includes `src/**/*.test.tsx`, so the new pins are compiled). eslint on the 15 touched `.ts`/`.tsx`: 0 errors, and every modified file's warning count is equal at BASE and head (JSON counts); the three new test files carry 2 `no-explicit-any` warnings each. At `5d006eb`, all exit 0: `check-changeset-presence` (15 source files of 6 released packages, 1 changeset), `check:changeset-claims`, `check:pending-changeset-literals`, `check-changeset-no-major`, `check-changeset-overwrite`, `check:new-line-citations` (0 new), `check:control-bytes`, `check:test-path-roots`, `check:vi-mock-specifiers` / `-inherit` / `-override-shape`, `check:phantom-deps`, `check:self-import`, `check:esm-specifiers`, `check:unreferenced-sources`, `check:handler-key-reads`, `check:i18n-keys`, `check:doc-fences`, `check:doc-types`, `check:doc-example-ids`. Final readings, from the dev's report (`6107630689`), amended into this body by the seat: - At `b85ebdf` (the final head, `main` `10713937` merged): the dependency closure built (`turbo` `Tasks: 29 successful, 29 total`); `type-check` for `core`, `plugin-detail`, `plugin-gantt`, `plugin-grid`, `plugin-form` and `app-shell`, each exit 0 with 0 `error TS`; `vitest` over `packages/plugin-grid/`, every leg-A pin, the census, `createFormGrant-12082`, `masterDetailDetailsMembers-8071` and the two repo-wide ratchets: `Test Files 211 passed (211)`, `Tests 2046 passed (2046)`. - At `5d006eb` (`main` `0df67f23` merged): `packages/core/`, `packages/plugin-detail/` and `packages/plugin-gantt/`: `Test Files 549 passed | 2 skipped (551)`, `Tests 7168 passed | 149 skipped`. `packages/plugin-form/`: 173 of 174 files passed; the 2 failures were `masterDetailDetailsMembers-8071`, re-judged in `cbbc04e` (see below) and green at `cbbc04e` and `b85ebdf`. `packages/app-shell/src/console/`, `scripts/__tests__/` and the column-identity ratchet: `Test Files 313 passed | 2 skipped (315)`. - Not re-run locally after the second merge of `main` (`10713937`, which touches `app-shell`, `i18n`, `types` and `plugin-grid`): the `core`, `plugin-detail`, `plugin-gantt`, `plugin-form` and `app-shell` console suites. `plugin-grid` was re-run; CI runs the rest. NOT MEASURED, with reasons: `check:doc-examples` / `check:doc-snippets` (prerequisite: every package built; the docs edits are prose only, no code block added or changed); `check:eager-closure` (needs a base and a head Console build, not run; the source delta is a few conditionals plus comments across seven modules, left to the Bundle Analysis workflow). ## Merge of main objectui#12089 (the objectui#7611 epic) landed while this round ran; `main` `0df67f23` is merged. It added two `unmapped` Setup entries (`PositionHoldersSection` add / remove, not the three the claim expected); both are kept byte-for-byte, beside the existing leg-B entries. The census on the merged tree: 13 `unmapped` entries left, all leg B (feed 5, Setup 4, dashboard designer 1, related list 3). A second merge, `main` `10713937`, followed: objectui#12123 also edits `ObjectGrid.tsx` and `content/docs/plugins/plugin-grid.mdx`. It merged clean, and both intents are kept. ## Files beyond the claim's surface - `packages/plugin-grid/src/__tests__/rowCrudEffectiveOps.test.tsx`: its "drops a bulkActionDefs entry whose operation is delete" case used an `update` def as the control that stays under a read-only operation set; that set now withholds the update def too, so the fixture's set became `['get', 'list', 'update']` (the control still stays, the delete still drops). - `packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx`: it pins the exact key set the block hands the grid. `allowAdd` and `allowDelete`, the map's verdicts, now join it, so the set grows from six keys to eight (`cbbc04e`). The authored `allowAdd: false` still reaches nothing. - `packages/core/README.md`, `packages/plugin-detail/README.md`, `content/docs/plugins/plugin-form.mdx`, `plugin-gantt.mdx`, `plugin-grid.mdx` (objectui AGENTS.md #2). ## Acceptance notes Noted, not filed (no measured reach at a public door, or a pre-existing limit): - The gantt's drag and the grid's bulk update read the row's object verdict only. Neither applies the row's per-record `userActions` predicates (`visibleWhen` / `disabledWhen`), and neither asks the field question of the fields it writes (dates, progress, the dependencies field, the patch keys). The calendar reschedule and the kanban move have the same two limits (round-1 note). An `editable: false` field is still refused only by the server. - `ExcelImportBar` reads `listImport` with the default bucket (it has not read the target's schema when it offers Import), and its target fields come from its own `normalizeFields`, not `importTargetFields`, so they do not ask the field question the list's Import asks. - `MasterDetailForm`'s line rows read the child's default bucket: a fully configured entry loads no child schema into render state (the sanitize cache is a ref by design, objectui#1581). - On an edit form, `allowDelete` is whole-grid, so a caller without the child delete grant cannot remove a just-added, unsaved line either (the same `GridField` limit round 1 noted for `LineItemsPanel`). - `HOST_GATED` is hand-written: a host-gated write component that is not listed there is not seen by the mount census. - A `value`-provider gantt that also names an `objectName` asks that object's grant, as the calendar and kanban rows do. ## Overlap with in-flight work - objectui#12089: landed and merged in (above). - objectui#12119: landed before this branch's base; `ObjectGrid.tsx` carries both. - objectui#12123: landed; merged in with `main` `10713937` (above). --- _Generated by [Claude Code](https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1071393 commit 7525045

22 files changed

Lines changed: 1146 additions & 68 deletions

‎.changeset/12082-census-leg-a.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/plugin-detail': patch
4+
'@object-ui/plugin-gantt': patch
5+
'@object-ui/plugin-grid': patch
6+
'@object-ui/plugin-form': patch
7+
'@object-ui/app-shell': patch
8+
---
9+
10+
More write affordances read the affordance-to-grant map, so a caller without the
11+
grant is no longer offered a write the server then refuses (objectui#12082, leg A
12+
of the write-census backlog). No row is added: each reuses the row of the same
13+
verb on the same object.
14+
15+
- **The record overlay's inline edit** — the overlay the calendar, the kanban and
16+
the gantt open on a record (`RecordDetailPanel`) — reads the `recordEdit` row,
17+
the row the record page reads for the same in-place edit: the object's policy,
18+
the effective API operation set and the caller's update grant. A closed row
19+
opens no inline-edit session, as an absent `onFieldSave` already did. Each
20+
field still asks its own field question, as since objectui#12103.
21+
- **The gantt's in-chart writes** read `rowEdit` (a bar's drag, resize and
22+
progress, its title edited in place, and its dependency links) and `rowDelete`
23+
(a row's Delete). A closed row withholds the handler the chart draws the
24+
affordance from, the way `readOnly` and `lockField` already do.
25+
- **A grid's bulk field update** (a `bulkActionDefs` entry with `operation:
26+
'update'`) reads `rowEdit`, as its bulk delete reads `rowDelete`: a closed row
27+
removes the button from the selection bar, and with it the dialog, its undo
28+
and its retry. `custom` operations are unchanged.
29+
- **A master-detail form's line grids** read `relatedNew` (Add, the entry row
30+
and Duplicate) and `relatedRowDelete` (Remove) on the CHILD object, as a
31+
line-items panel does. Remove asks the delete grant on an edit form only: a
32+
create form's lines are drafts, and a removed one is never a server delete.
33+
- **A line-items panel** now hands its child object's schema to those two rows,
34+
so the child's own policy (`managedBy`, `userActions`) is read too.
35+
- **The AI build panel's spreadsheet import** (`ExcelImportBar`) reads the
36+
`listImport` row of the object it imports into, as the list page's Import
37+
does: without it, Import is disabled for that object.
38+
39+
With no permission provider mounted every one of these reads open, as before.

‎content/docs/plugins/plugin-form.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,14 @@ Duplicate) only when the caller may create the CHILD object, and removing one
474474
only when they may delete it — the `relatedNew` and `relatedRowDelete` rows of the
475475
affordance-to-grant map in `@object-ui/core`, the same rows a related list reads
476476
for the same two writes. Its cells ask each column's field question, as the form
477-
layouts do. With no permission provider mounted both read open.
477+
layouts do. The child's own policy is read too: the panel hands the child
478+
object's schema to those two rows, so a child whose `userActions` close create or
479+
delete offers neither. With no permission provider mounted both read open.
480+
481+
A `MasterDetailForm`'s line grids read the same two rows on the child object.
482+
Remove asks the delete grant on an edit form only: a create form saves the parent
483+
and one create per line, so a line removed before that first save is a draft,
484+
never a server delete, and stays removable for a caller who may only create.
478485

479486
## Examples
480487

‎content/docs/plugins/plugin-gantt.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ The `@object-ui/plugin-gantt` plugin provides Gantt chart visualization for Obje
3636
`dataSource.update()` automatically (optimistic local update + revert on
3737
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
3838
lower-level `onTaskUpdate` hook when embedding `<GanttView>` directly.
39+
- **Writes follow the caller's grant**: a bar's drag, resize and progress, its
40+
title edited in place and its dependency links are offered only when the
41+
`rowEdit` row of the affordance-to-grant map (`@object-ui/core`) allows them —
42+
the object's policy, the effective API operation set and the caller's
43+
**update** grant — and a row's Delete only when `rowDelete` does. The record
44+
drawer's inline edit reads the record page's `recordEdit` row. With no
45+
permission provider mounted they read open, as `readOnly` and `lockField`
46+
still close them.
3947

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

‎content/docs/plugins/plugin-grid.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,15 @@ target from that field, so a lookup param renders the record picker and the
608608
confirm step shows the picked record's label. A param whose field is not in
609609
the object metadata is refused, and the run is blocked.
610610

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

613622
`onRowClick`, `onRowSelect`, `onCellChange`, `onRowSave`, `onBatchSave`,

‎packages/app-shell/src/console/ai/ExcelImportBar.tsx‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
* mapping, dry-run and the real server-side import. So the magic-flow trial
1313
* goes from "one sentence → app with demo data" to "app with MY data" — the
1414
* retention hinge for a spreadsheet refugee.
15+
*
16+
* Import is the `listImport` row of the affordance-to-grant map (objectui#12082),
17+
* the row the console list's Import reads for the same wizard: it is offered for
18+
* the picked object only when the caller's create grant and the server's
19+
* effective operation set allow it, and picking another object asks again.
1520
*/
1621

1722
import { useEffect, useMemo, useState } from 'react';
@@ -27,6 +32,8 @@ import {
2732
import { createAuthenticatedFetch } from '@object-ui/auth';
2833
import { toast } from 'sonner';
2934
import { useObjectTranslation } from '@object-ui/i18n';
35+
import { resolveAffordance } from '@object-ui/core';
36+
import { usePermissions } from '@object-ui/permissions';
3037
import { ImportWizard } from '@object-ui/plugin-grid';
3138

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

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

164182
async function openWizard() {
165-
if (!selected) return;
183+
if (!selected || !importGranted) return;
166184
setLoadingFields(true);
167185
try {
168186
const schema = await dataSource.getObjectSchema(selected);
@@ -215,7 +233,7 @@ export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }:
215233
onPick={setSelected}
216234
disabled={!objects || objects.length === 0}
217235
/>
218-
<Button size="sm" onClick={openWizard} disabled={!selected || loadingFields}>
236+
<Button size="sm" onClick={openWizard} disabled={!selected || loadingFields || !importGranted}>
219237
{loadingFields ? t('excelImport.opening') : t('excelImport.importAction')}
220238
</Button>
221239
<Button size="sm" variant="ghost" onClick={onDone}>
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#12082 — the AI build panel's spreadsheet import reads the
5+
* `listImport` row of the affordance-to-grant map, asked of the object it
6+
* imports into.
7+
*
8+
* `ExcelImportBar` is the second mount of `ImportWizard`: the console list's
9+
* Import is the first, and it has read the `listImport` row since the map
10+
* landed. The bar read no grant at all, so a caller who may not create records
11+
* of the picked object could press Import, map the sheet and run it, and every
12+
* row was refused by the server. The bar now offers Import for the picked
13+
* object only when its row allows it — the caller's create grant and the
14+
* effective operation set — and picking another object asks again.
15+
*
16+
* Measured through the REAL `MePermissionsProvider`; the wizard is a probe that
17+
* records the object it was opened for.
18+
*/
19+
20+
import '@testing-library/jest-dom/vitest';
21+
import * as React from 'react';
22+
import { describe, it, expect, vi, beforeEach } from 'vitest';
23+
import { render, cleanup, fireEvent, waitFor, screen, within, act } from '@testing-library/react';
24+
import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';
25+
26+
const h = vi.hoisted(() => ({
27+
/** The objects the bar lists as import targets. */
28+
objects: [
29+
{ name: 'crm_account', label: 'Account' },
30+
{ name: 'crm_deal', label: 'Deal' },
31+
],
32+
/** The props each mounted ImportWizard received. */
33+
wizard: [] as Array<Record<string, unknown>>,
34+
}));
35+
36+
vi.mock('@object-ui/auth', async (importOriginal) => ({
37+
...(await importOriginal<Record<string, unknown>>()),
38+
createAuthenticatedFetch: () => async () =>
39+
new Response(JSON.stringify({ data: { items: h.objects } }), {
40+
status: 200,
41+
headers: { 'content-type': 'application/json' },
42+
}),
43+
}));
44+
vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({
45+
...(await importOriginal<Record<string, unknown>>()),
46+
ImportWizard: (props: { objectName: string }) => {
47+
h.wizard.push({ objectName: props.objectName });
48+
return <div data-testid="import-wizard" />;
49+
},
50+
}));
51+
vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) }));
52+
53+
import { ExcelImportBar } from '../ExcelImportBar';
54+
55+
const FILE = new File(['a,b\n1,2'], 'deals.xlsx');
56+
/** What `t()` answers with no i18n provider: the key itself. */
57+
const IMPORT = 'excelImport.importAction';
58+
59+
const SHAPES: Array<{ shape: string; bits: Record<string, boolean>; create: boolean }> = [
60+
{ shape: 'create-only', bits: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, create: true },
61+
{ shape: 'edit-only', bits: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false }, create: false },
62+
{ shape: 'read-only', bits: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, create: false },
63+
{ shape: 'full', bits: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, create: true },
64+
];
65+
const FULL = SHAPES[3].bits;
66+
67+
const envelope = (objects: Record<string, Record<string, unknown>>): MePermissionsResponse => ({
68+
authenticated: true,
69+
userId: 'u-pin',
70+
tenantId: null,
71+
roles: ['member'],
72+
permissionSets: ['member'],
73+
objects: objects as never,
74+
fields: {},
75+
});
76+
77+
const settle = () => act(() => new Promise<void>((resolve) => setTimeout(resolve, 50)));
78+
79+
function mount(perms: MePermissionsResponse | null) {
80+
const dataSource = {
81+
getObjectSchema: vi.fn(async (_name: string) => ({ fields: { title: { type: 'text', label: 'Title' } } })),
82+
};
83+
const bar = <ExcelImportBar file={FILE} dataSource={dataSource} onDone={vi.fn()} />;
84+
render(perms ? <MePermissionsProvider initialPermissions={perms}>{bar}</MePermissionsProvider> : bar);
85+
return { dataSource };
86+
}
87+
88+
const importButton = () => screen.getByRole('button', { name: IMPORT });
89+
90+
/** Press Import; report whether the wizard opened, and for which object. */
91+
async function pressImport(dataSource: { getObjectSchema: ReturnType<typeof vi.fn> }): Promise<string | null> {
92+
fireEvent.click(importButton());
93+
await settle();
94+
if (!screen.queryByTestId('import-wizard')) {
95+
expect(dataSource.getObjectSchema).not.toHaveBeenCalled();
96+
return null;
97+
}
98+
return String(h.wizard[h.wizard.length - 1].objectName);
99+
}
100+
101+
beforeEach(() => {
102+
cleanup();
103+
h.wizard.length = 0;
104+
});
105+
106+
describe.each(SHAPES)('the spreadsheet import bar under a $shape grant on its target (objectui#12082)', ({ bits, create }) => {
107+
it('Import is offered exactly when the create grant allows it', async () => {
108+
const { dataSource } = mount(envelope({ crm_account: bits, crm_deal: bits }));
109+
await settle();
110+
expect(importButton()).toHaveTextContent(IMPORT);
111+
expect(importButton().hasAttribute('disabled'), 'disabled').toBe(!create);
112+
expect(await pressImport(dataSource)).toBe(create ? 'crm_account' : null);
113+
});
114+
});
115+
116+
describe("the spreadsheet import bar: the map's other layers and fail-open (objectui#12082)", () => {
117+
it('fail-open: with no permission provider Import is offered, as before', async () => {
118+
const { dataSource } = mount(null);
119+
await settle();
120+
expect(importButton()).not.toBeDisabled();
121+
expect(await pressImport(dataSource)).toBe('crm_account');
122+
});
123+
124+
it('an effective operation set without import withholds Import under the full grant', async () => {
125+
const { dataSource } = mount(envelope({ crm_account: { ...FULL, apiOperations: ['get', 'list', 'create', 'update'] } }));
126+
await settle();
127+
expect(importButton()).toBeDisabled();
128+
expect(await pressImport(dataSource)).toBeNull();
129+
});
130+
131+
it('the verdict is asked of the PICKED object: a closed target, then an open one', async () => {
132+
const { dataSource } = mount(envelope({ crm_account: SHAPES[2].bits, crm_deal: FULL }));
133+
await settle();
134+
expect(importButton()).toBeDisabled();
135+
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
136+
const listbox = await screen.findByRole('listbox');
137+
fireEvent.click(within(listbox).getAllByRole('option').find((o) => o.textContent === 'Deal')!);
138+
await waitFor(() => expect(importButton()).not.toBeDisabled());
139+
expect(await pressImport(dataSource)).toBe('crm_deal');
140+
});
141+
});

‎packages/core/README.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,10 +224,15 @@ An affordance added later gets a row here and reads it through these functions;
224224
it does not spell its own `can(object, verb)`. That includes an affordance that
225225
reads NO grant at all — the calendar's quick-create and drag-to-reschedule
226226
(`calendarQuickCreate`, `calendarReschedule`) and the kanban card move
227-
(`kanbanCardMove`) were such affordances until they gained their rows. The map's
227+
(`kanbanCardMove`) were such affordances until they gained their rows. An
228+
affordance that writes with the same verb on the same object reuses that row:
229+
the record overlay's inline edit reads `recordEdit`, a gantt's bar edits and a
230+
grid's bulk field update read `rowEdit`, a gantt row's Delete reads `rowDelete`,
231+
and the AI build panel's spreadsheet import reads `listImport`. The map's
228232
enumeration pin in `@object-ui/plugin-form` (`affordanceGrantMap-12082.test.tsx`)
229233
also counts every write call site in the console tree and holds each to the row
230-
it sits behind, or to the reason it has none.
234+
it sits behind, or to the reason it has none — and, for a component whose write
235+
its host gates (the import wizard), holds every mount to reading the row.
231236

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

‎packages/core/src/utils/affordanceGrants.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ export const AFFORDANCE_GRANTS = {
133133
/**
134134
* The record page's Edit — the header CTA and the record body's in-place
135135
* editing (the details body and the highlights strip), each of whose fields
136-
* asks the update question (objectui#12103).
136+
* asks the update question (objectui#12103) — and the record overlay's
137+
* inline edit, the same body in the overlay the calendar, kanban and gantt
138+
* open (`RecordDetailPanel` in `@object-ui/plugin-detail`).
137139
*/
138140
recordEdit: { crud: 'edit', grant: 'update', field: 'write' },
139141
/** The record page's Delete. */
@@ -143,31 +145,42 @@ export const AFFORDANCE_GRANTS = {
143145
* "+", and the `object-view` node's toolbar button (`@object-ui/plugin-view`).
144146
*/
145147
listNew: { crud: 'create', grant: 'create' },
146-
/** An object list's Import, and the import wizard's writable target fields. */
148+
/**
149+
* An object list's Import, and the import wizard's writable target fields —
150+
* and the AI build panel's spreadsheet import (`ExcelImportBar` in
151+
* `@object-ui/app-shell`), the wizard's second mount, asked of the object it
152+
* imports into.
153+
*/
147154
listImport: { crud: 'import', grant: 'create', field: 'create' },
148155
/** The import wizard's template download — its endpoint answers 403 without the create grant. */
149156
importTemplate: { crud: null, grant: 'create' },
150157
/** A list's inline (in-cell) editing, each of whose cells asks the update question (objectui#12103). */
151158
listInlineEdit: { crud: 'edit', grant: 'update', field: 'write' },
152159
/** A list's bulk Delete. */
153160
listBulkDelete: { crud: 'delete', grant: 'delete' },
154-
/** A grid row's Edit. */
161+
/**
162+
* A grid row's Edit — and the writes that update the same rows in place: a
163+
* grid's bulk field update (a `bulkActionDefs` `update` operation, with its
164+
* undo and retry), and a gantt task's in-chart edits (a bar's drag, resize
165+
* and progress, its title edited in place, and its dependency links).
166+
*/
155167
rowEdit: { crud: 'edit', grant: 'update' },
156-
/** A grid row's Delete. */
168+
/** A grid row's Delete — and a gantt row's Delete. */
157169
rowDelete: { crud: 'delete', grant: 'delete' },
158170
/** A grid's inline add-record row. */
159171
gridAddRow: { crud: 'create', grant: 'create' },
160172
/**
161173
* A related list's "+ New", asked of the CHILD object — and a line-items
162-
* panel's add-a-line (its Add, entry row and Duplicate), which creates a child
163-
* under the same parent.
174+
* panel's or a master-detail form's add-a-line (its Add, entry row and
175+
* Duplicate), which creates a child under the same parent.
164176
*/
165177
relatedNew: { crud: 'create', grant: 'create' },
166178
/** A related list row's Edit, asked of the child object. */
167179
relatedRowEdit: { crud: 'edit', grant: 'update' },
168180
/**
169181
* A related list row's Delete, asked of the child object — and a line-items
170-
* panel's remove-a-line, which deletes that child on Save.
182+
* panel's or a master-detail EDIT form's remove-a-line, which deletes that
183+
* child on Save.
171184
*/
172185
relatedRowDelete: { crud: 'delete', grant: 'delete' },
173186
/** A lookup picker's "Create new", asked of the TARGET object. */

‎packages/plugin-detail/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,15 @@ delete action.
363363
delete action — which is what lets a row locked through a renderer's `lockField`
364364
open read-only without the panel knowing what a lock is.
365365

366+
**…and a handler is a wiring, not a grant.** The inline edit is the `recordEdit`
367+
row of the affordance-to-grant map in `@object-ui/core` (objectui#12082), the row
368+
the record page reads for the same in-place edit: the object's policy (read off
369+
the `objectSchema` you pass), the effective API operation set and the caller's
370+
**update** grant. When it is closed the panel opens no inline-edit session, as if
371+
`onFieldSave` were absent; each field still asks its own field question. The
372+
delete action is `DetailView`'s own `recordDelete` read. With no permission
373+
provider mounted both read open.
374+
366375
`RECORD_OVERLAY_DEFAULT_WIDTH` is exported alongside it and is the single code
367376
home of the default overlay width repo-wide. ⛔ Do not re-spell the literal at a
368377
call site.

0 commit comments

Comments
 (0)