diff --git a/.changeset/user-actions-three-keys.md b/.changeset/user-actions-three-keys.md new file mode 100644 index 0000000000..8c3d6b7b56 --- /dev/null +++ b/.changeset/user-actions-three-keys.md @@ -0,0 +1,5 @@ +--- +'@objectstack/spec': minor +--- + +`UserActionsConfigSchema` adopts `group`, `hideFields` and `rowColor` (ruled Option A on objectui#5435): the three toolbar affordances ListView already honours become authorable in a spec-valid document, so the runtime fold of legacy `showGroup`/`showHideFields`/`showColor` flags now passes the save gate instead of being rejected by name. All three are booleans; the defaults copy the renderer's reads — `group` defaults on, `hideFields`/`rowColor` default off. Accept-set widening only: no existing document changes meaning, and the object-level `userActions` block (create/import/edit/delete/exportCsv) still rejects all view-vocabulary keys by name. diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index b28011f79e..d485c7bda3 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -736,8 +736,11 @@ User action toggles for the view toolbar | **filter** | `boolean` | optional (default: `true`) | Allow users to filter records | | **refresh** | `boolean` | optional (default: `true`) | Allow users to reload the view data from the backend without a full page reload | | **rowHeight** | `boolean` | optional (default: `true`) | Allow users to toggle row height/density | +| **group** | `boolean` | optional (default: `true`) | Allow users to change record grouping from the toolbar. Toggle only — the grouping itself is configured in the view-level `grouping` block. | | **addRecordForm** | `boolean` | optional (default: `false`) | Add records through a form instead of inline | | **editInline** | `boolean` | optional (default: `false`) | Allow users to edit records inline — click a cell to edit it with the field's type-aware widget (the same control the form uses). Off by default: the list is read-only unless the author opts in. | +| **hideFields** | `boolean` | optional (default: `false`) | Allow users to hide/show fields from the toolbar (the affordance behind the view-level `hiddenFields` list). Boolean toggle — distinct from the record-details component's `hideFields`, which is an array of field names to omit. Off by default: column hiding is opt-in. | +| **rowColor** | `boolean` | optional (default: `false`) | Allow users to configure row colouring from the toolbar. Boolean toggle — the colour rules themselves live in the view-level `rowColor` block. Off by default: row colouring is opt-in. | | **buttons** | `string[]` | optional | Custom action button IDs to show in the toolbar | diff --git a/packages/spec/authorable-defaults/ui.json b/packages/spec/authorable-defaults/ui.json index d24f6db559..bf6b528ec8 100644 --- a/packages/spec/authorable-defaults/ui.json +++ b/packages/spec/authorable-defaults/ui.json @@ -109,7 +109,10 @@ "ui/UserActionsConfig:addRecordForm = false", "ui/UserActionsConfig:editInline = false", "ui/UserActionsConfig:filter = true", + "ui/UserActionsConfig:group = true", + "ui/UserActionsConfig:hideFields = false", "ui/UserActionsConfig:refresh = true", + "ui/UserActionsConfig:rowColor = false", "ui/UserActionsConfig:rowHeight = true", "ui/UserActionsConfig:search = true", "ui/UserActionsConfig:sort = true", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 75623770a1..b804473bed 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1126,7 +1126,10 @@ "ui/UserActionsConfig:buttons", "ui/UserActionsConfig:editInline", "ui/UserActionsConfig:filter", + "ui/UserActionsConfig:group", + "ui/UserActionsConfig:hideFields", "ui/UserActionsConfig:refresh", + "ui/UserActionsConfig:rowColor", "ui/UserActionsConfig:rowHeight", "ui/UserActionsConfig:search", "ui/UserActionsConfig:sort", diff --git a/packages/spec/src/data/object-strictness-batch20.test.ts b/packages/spec/src/data/object-strictness-batch20.test.ts index d588273a36..81c42dbe2f 100644 --- a/packages/spec/src/data/object-strictness-batch20.test.ts +++ b/packages/spec/src/data/object-strictness-batch20.test.ts @@ -55,6 +55,7 @@ import { } from './object.zod'; import { resolveInjectedSystemColumns } from './injected-system-columns'; import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { UserActionsConfigSchema } from '../ui/view.zod'; /** Reject `value` through `schema` and return its issues as a searchable string. */ function reject( @@ -409,13 +410,32 @@ describe('#4001 批 20 — curation is anchored to the sibling contract that mak it('`userActions.sort` names the VIEW block it belongs to — same key NAME, disjoint vocabulary', () => { // `ui/view.zod.ts`'s UserActionsConfigSchema declares sort/search/filter/ - // refresh/rowHeight/addRecordForm/editInline/buttons; the object block - // declares create/import/edit/delete/exportCsv. Nothing overlaps, which - // is exactly why an author who learned one writes it on the other. + // refresh/rowHeight/group/addRecordForm/editInline/hideFields/rowColor/ + // buttons (group/hideFields/rowColor adopted at #11195, ruled A on + // objectui#5435); the object block declares create/import/edit/delete/ + // exportCsv. Nothing overlaps, which is exactly why an author who + // learned one writes it on the other. const msg = rejectOnObject({ userActions: { sort: false } }); expect(msg).toContain('VIEW'); }); + it('the two `userActions` vocabularies stay disjoint — re-checked mechanically, not assumed (#11195)', () => { + // The enumeration above is prose and can drift; this reads the VIEW + // block's real shape. The object block's five verbs are transcribed — + // they are pinned individually across this file — and the overlap is + // asserted empty so the NEXT adoption re-runs this check for free. + const viewKeys = Object.keys(UserActionsConfigSchema.shape); + const objectVerbs = ['create', 'import', 'edit', 'delete', 'exportCsv']; + for (const k of ['group', 'hideFields', 'rowColor']) expect(viewKeys).toContain(k); + expect(viewKeys.filter((k) => objectVerbs.includes(k))).toEqual([]); + // …and the object block still REFUSES the three adopted view keys by + // name — the operative half of "disjoint": the wrong-layer write stays + // a loud rejection, never a silent second meaning. + for (const k of ['group', 'hideFields', 'rowColor']) { + expect(rejectOnObject({ userActions: { [k]: true } })).toContain(k); + } + }); + it('`userActions.clone` points at the `enable` capability block, which really does declare it', () => { const msg = rejectOnObject({ userActions: { clone: false } }); expect(msg).toContain('enable'); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 32a506c357..fbbdec604d 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1724,9 +1724,10 @@ const ObjectSchemaBase = strictObject( guidance: { // Wrong-LAYER, and the trap is that the key name is right somewhere else. // `ui/view.zod.ts` declares its own `userActions` with a completely - // disjoint vocabulary (sort/search/filter/refresh/rowHeight/ - // addRecordForm/editInline/buttons), so an author who learned that block - // writes these here and gets a shape that has never heard of them. + // disjoint vocabulary (sort/search/filter/refresh/rowHeight/group/ + // addRecordForm/editInline/hideFields/rowColor/buttons — the last three + // adopted at #11195), so an author who learned that block writes these + // here and gets a shape that has never heard of them. sort: '`sort` is a VIEW `userActions` key, not an object one — the two blocks share a ' + 'name and nothing else. The object block governs CRUD affordances ' + diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 7bbd25be34..f960e24215 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2496,25 +2496,60 @@ describe('UserActionsConfigSchema', () => { expect(config.filter).toBe(true); expect(config.refresh).toBe(true); expect(config.rowHeight).toBe(true); + expect(config.group).toBe(true); expect(config.addRecordForm).toBe(false); expect(config.editInline).toBe(false); + expect(config.hideFields).toBe(false); + expect(config.rowColor).toBe(false); expect(config.buttons).toBeUndefined(); }); + // The split is copied from the renderer's reads, not chosen by the spec + // (ruled A on objectui#5435): ListView.tsx computes `showGroup` as + // `ua?.group !== false` (absent ⇒ shown) but `showHideFields` / `showColor` + // as `ua?.hideFields === true` / `ua?.rowColor === true` (absent ⇒ hidden). + // Normalising the three to one default would silently flip a toolbar + // affordance on every view that never wrote the key. + it('pins the group-ON / hideFields-OFF / rowColor-OFF defaults asymmetry (objectui#5435)', () => { + const config = UserActionsConfigSchema.parse({}); + expect(config.group).toBe(true); + expect(config.hideFields).toBe(false); + expect(config.rowColor).toBe(false); + }); + + // objectui's normalize-list-view fold maps legacy `showGroup` / + // `showHideFields` / `showColor` onto exactly these keys, so this document is + // the fold's own OUTPUT — before adoption the save gate rejected it by name. + it('accepts the legacy-fold output through ListViewSchema (acceptance criterion, objectstack#11195)', () => { + const result = ListViewSchema.safeParse({ + name: 'my_view', + label: 'My View', + columns: [{ field: 'name' }], + userActions: { group: false, hideFields: true, rowColor: true }, + }); + expect(result.success).toBe(true); + }); + it('should accept full configuration', () => { const config = UserActionsConfigSchema.parse({ sort: false, search: true, filter: false, rowHeight: true, + group: false, addRecordForm: true, editInline: true, + hideFields: true, + rowColor: true, buttons: ['btn_export', 'btn_archive'], }); expect(config.sort).toBe(false); expect(config.filter).toBe(false); + expect(config.group).toBe(false); expect(config.addRecordForm).toBe(true); expect(config.editInline).toBe(true); + expect(config.hideFields).toBe(true); + expect(config.rowColor).toBe(true); expect(config.buttons).toEqual(['btn_export', 'btn_archive']); }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 43705aa375..1d8a8cd659 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -848,6 +848,22 @@ export const VisualizationTypeSchema = lazySchema(() => z.enum([ * Controls which interactive actions are available to users in the view toolbar. * Each boolean toggles the corresponding toolbar element on/off. * + * `group` / `hideFields` / `rowColor` adopted from objectui's legacy `show*` + * fold (`SHOW_FLAG_TO_USER_ACTION` in normalize-list-view.ts; ruled Option A on + * objectui#5435): the ListView toolbar already honoured all three, but no + * spec-valid document could declare them, so the fold's own output failed the + * save gate. The defaults asymmetry is LOAD-BEARING, copied from the renderer's + * reads, not chosen here: `group` defaults ON (`ua?.group !== false`) like the + * other core toolbar controls, while `hideFields` / `rowColor` default OFF + * (`=== true`) — column hiding and row colouring are opt-in affordances. + * + * Name-collision note (same key NAME, different shape, on OTHER surfaces — + * deliberate, each toggle is named after the config it gates): these three are + * booleans HERE, while `rowColor` on the list view itself is a + * {@link RowColorConfigSchema} (the colour rules this toggle exposes to users) + * and `hideFields` on the record-details component is a `string[]` of field + * names to omit. The describes below say which side of each pair this is. + * * @see Airtable Interface → "User actions" panel */ export const UserActionsConfigSchema = lazySchema(() => strictObject({ @@ -859,8 +875,11 @@ export const UserActionsConfigSchema = lazySchema(() => strictObject({ filter: z.boolean().default(true).describe('Allow users to filter records'), refresh: z.boolean().default(true).describe('Allow users to reload the view data from the backend without a full page reload'), rowHeight: z.boolean().default(true).describe('Allow users to toggle row height/density'), + group: z.boolean().default(true).describe('Allow users to change record grouping from the toolbar. Toggle only — the grouping itself is configured in the view-level `grouping` block.'), addRecordForm: z.boolean().default(false).describe('Add records through a form instead of inline'), editInline: z.boolean().default(false).describe('Allow users to edit records inline — click a cell to edit it with the field\'s type-aware widget (the same control the form uses). Off by default: the list is read-only unless the author opts in.'), + hideFields: z.boolean().default(false).describe('Allow users to hide/show fields from the toolbar (the affordance behind the view-level `hiddenFields` list). Boolean toggle — distinct from the record-details component\'s `hideFields`, which is an array of field names to omit. Off by default: column hiding is opt-in.'), + rowColor: z.boolean().default(false).describe('Allow users to configure row colouring from the toolbar. Boolean toggle — the colour rules themselves live in the view-level `rowColor` block. Off by default: row colouring is opt-in.'), buttons: z.array(z.string()).optional().describe('Custom action button IDs to show in the toolbar'), }).describe('User action toggles for the view toolbar'));