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
21 changes: 21 additions & 0 deletions .changeset/11168-action-group-type-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@object-ui/types': minor
---

**Breaking (types only):** the `ActionGroup` interface is removed from
`@object-ui/types` (objectui#11168, slice 2). Nothing in this repository
imported it.

It was not the type of the `action:group` block, and it disagreed with that
block's `@objectstack/spec` row on six counts:

- it required `name`, which the row refuses and which `action:group` no
longer publishes;
- it required `label` and `actions`, which the row makes optional;
- it typed `visible` as a string only;
- it lacked `location`, `variant` and `size`.

To type an `action:group` node, use `ActionGroupBlockSchema` from
`@object-ui/types/zod` (the spec row by reference), or `ActionGroupProps` from
`@objectstack/spec/ui` for its `properties` bag. Released as `minor`, per this
repository's version-alignment rule for the fixed package group.
36 changes: 36 additions & 0 deletions .changeset/11168-element-family-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@object-ui/components': minor
---

`element:definition-list`, `element:repeater` and `action:button` publish
their inputs as the `@objectstack/spec` 17.5.0 rows declare them and as their
renderers read them (objectui#11168, slice 2). Each key was decided by
measuring it through `SchemaRenderer`, per the objectui#11111 ruling: declare
what the renderer honours, refuse or retire what it does not.

Narrowed (breaking for an author who wrote them, which the spec already
refuses):

- `element:definition-list.columns` takes the NUMBERS `1` and `2`, not the
strings `'1'` and `'2'`. The renderer compares the number, so `columns: '2'`
drew a single column. The page validator used to refuse `columns: 2`, the
value the designer writes, and pass the string. It now accepts the number
and reports the string as an invalid enum value. Write `columns: 2`.
- `element:repeater.filter` and `.sort` are published as lists of objects
(`[{ field, operator, value }]` and `[{ field, order }]`), the only member
kind the spec accepts.

Widened:

- `element:definition-list.items` is no longer required. An absent `items`
renders the "No details" state, and the spec row does not require it, but
the page validator reported `missing-required-prop` for it.
- `action:button.size` publishes `default`, `sm`, `md`, `lg` and `icon`, the
five sizes the spec row declares. `default` and `icon` were refused by the
page validator although the renderer draws both. `md` renders as `default`.

`element:repeater.fields` no longer describes a `label` member: the list has
no header row, so a label was never printed, and the spec refuses it. Write a
bare field name or `{ field }`.

Nothing changes at render time for a value the spec accepts.
65 changes: 41 additions & 24 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,8 +760,8 @@ const OBJECTUI_11111_LEDGER_CAPS = {
unjudgedBlocks: 4, // objectui#11168: object-gantt, object-map, object-timeline, object-tree
offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name
unpublishedKeys: 3, // objectui#11168: 3 (action:button endpoint + undoable, action:icon endpoint); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing)
refusedArms: 2, // objectui#11168: element:definition-list, object-form
memberPins: 4, // objectui#11168: element:definition-list, element:repeater ×3
refusedArms: 1, // objectui#11168: object-form (slice 2 narrowed element:definition-list.columns)
memberPins: 0, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3
} as const;

/**
Expand Down Expand Up @@ -1985,11 +1985,13 @@ const OFF_SPEC_ARM_EXEMPTIONS: Record<string, string> = {
* list of objects (`type: 'array'`, `of: 'object'`, the shape both renderers
* read), and `action:group.size` publishes the Button primitive's four sizes
* without the refused `md`.
*
* objectui#11168 slice 2 took `element:definition-list.columns:enum` the same
* way: the enum's members are now the NUMBERS `1` and `2`, which the spec row
* declares and the renderer compares (`props.columns === 2`); the strings it
* declared before drew one column. The pin is
* `packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx`.
*/
'element:definition-list.columns:enum': OWED_TO(
'objectui#11168',
'A REFUSED ARM: `element:definition-list.columns` declares the members `1` and `2`, and the contract refuses them.',
),
'object-form.layout:enum': OWED_TO(
'objectui#11168',
'A REFUSED ARM: `object-form.layout` declares the members `inline` and `grid`, which the 17.5.0 contract refuses.',
Expand Down Expand Up @@ -2243,6 +2245,8 @@ const OFF_SPEC_MEMBER_EXEMPTIONS: Record<string, string> = {};
const MULTI_KIND_MEMBER_CONTRACTS: Record<string, string> = {
'record:highlights.fields':
'The member contract is a union — a field NAME or an inline field object — so no single coarse arm describes it and declaring both would advertise a member shape only the per-block pin next to the renderer can vouch for (packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts). objectui#8067 leaves it undeclared on purpose.',
'element:repeater.fields':
'The member contract is a union — a bare field NAME or `{ field }` — so no single coarse arm describes it; what the renderer prints for each arm is pinned next to it (packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx). objectui#11168 slice 2 leaves it undeclared on purpose.',
};

// ── the MEMBER-PIN direction (objectui#8068) ─────────────────────────────────
Expand Down Expand Up @@ -2594,6 +2598,22 @@ const MEMBER_PINS: Record<string, MemberPin> = {
file: 'packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx',
pins: 'The `object` arm is the CEL predicate envelope `{ dialect: \'cel\', source }`, mounted through the real `SchemaRenderer` with `data` bound: an envelope that holds renders the trigger and its twin that fails hides the whole menu, with the boolean and bare-CEL arms carrying the same holds/fails pair (objectui#11168 slice 1).',
},
'element:definition-list.items': {
file: 'packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx',
pins: 'Members are `{ term, description }` items, read through the real `SchemaRenderer`, one `<dt>`/`<dd>` row each in list order: `term` is the row label, `description` is shown as-is (a string or number verbatim, an object as JSON, an omitted one as an em dash). The `label` / `value` spelling is read by nothing (a blank term and an em dash beside a control row carrying the same text under the read members), and the spec row refuses it by name. The key itself is optional, as the renderer reads it: absent renders "No details", and the page validator no longer reports it missing (objectui#11168 slice 2).',
},
'element:repeater.fields': {
file: 'packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx',
pins: 'Members are a bare field name or `{ field }`, read through the real `SchemaRenderer` over a recording adapter: each prints that field\'s value of the record, in list order after the title, and a field the record lacks prints an em dash. A `label` member is never printed (the list has no header row) while its `field` still is, and the spec row refuses it; the key declares no `of` because its member contract is that union (objectui#11168 slice 2).',
},
'element:repeater.filter': {
file: 'packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx',
pins: 'Members are `ViewFilterRule` objects `{ field, operator, value }`, asserted on EVERY query the adapter is handed: they reach `$filter` member for member, with a `{current_user_id}` / `{current_org_id}` token in `value` resolved first and the other members unchanged; a token-free list arrives unchanged and an absent key sends no `$filter`. The lowering to the server\'s filter AST is the adapter\'s own contract, pinned in `@object-ui/data-objectstack` (objectui#11168 slice 2).',
},
'element:repeater.sort': {
file: 'packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx',
pins: 'Members are `SortItem` objects `{ field, order }`, asserted on EVERY query the adapter is handed: they reach `$orderby` unchanged and in list order, and an absent key sends no `$orderby`. The renderer reads no member itself (`data-list.sortKey-10664.test.tsx` pins that the read is keyed on the list\'s content); serializing it is the adapter\'s own contract (objectui#11168 slice 2).',
},
'element:button.action': {
file: 'packages/components/src/__tests__/elementButtonActionMembers-8071.test.tsx',
pins: 'The inline ActionDef\'s member set as a WHITELIST, driven through the real renderer and asserted on what the ActionRunner is handed: the forwarded keys are pinned as a SORTED SET, so a member silently added to or dropped from `ElementButtonRenderer`\'s explicit forward list is red in either direction — the defect shape objectstack#6837 (`bodyExtra`) and objectstack#6938 (`bodyShape`) were both filed for, an authored key that validates, publishes and then evaporates one hop before the runner. Three off-list keys are authored alongside and asserted ABSENT: `bodyShape` (deliberately not inline vocabulary — `action-bodyShape-forward.test.tsx` argues the boundary in prose, this pins it as behaviour) plus `icon` and `variant`, the block\'s own sibling props misplaced one level in. `actionType` OUTRANKS `type` with the losing spelling\'s handler asserted un-run, an ARRAY `params` is re-routed to `actionParams` while an OBJECT `params` stays `params` (objectstack#5777 direction A) with each arm controlling the other, the navigation members reach the navigation handler, and an omitted `action` dispatches nothing at all — the non-vacuity control for every row. LIMIT: `confirmText` is on the whitelist but unexercised, because the runner awaits confirmation before the dispatch this file observes. New file (objectui#8071 slice 7).',
Expand Down Expand Up @@ -3199,22 +3219,11 @@ const MEMBER_PIN_EXEMPTIONS: Record<string, string> = {

// ⚠️ 17.5.0 BOOKINGS — objectui#11111 decision 3 = B (record 5902351047).
// The array/object-armed inputs of the blocks 17.5.0 newly judges, owed a
// member pin by objectui#11168. Counted into `MEMBER_PIN_EXEMPTION_CEILING`
// and capped at the entries listed; that card's pins strike them.
// `action:group.actions` and `action:menu.actions` stood here until
// objectui#11168 slice 1 registered their pins in `MEMBER_PINS`.
...owedEntries(
'element:definition-list',
['items'],
'objectui#11168',
'A MEMBER PIN OWED: `element:definition-list.items` is array/object-armed on a block 17.5.0 newly judges, and no pin states its members.',
),
...owedEntries(
'element:repeater',
['fields', 'filter', 'sort'],
'objectui#11168',
'A MEMBER PIN OWED: this `element:repeater` input is array/object-armed on a block 17.5.0 newly judges, and no pin states its members.',
),
// member pin by objectui#11168, stood here. All six are pinned now, so no
// booking is left: `action:group.actions` and `action:menu.actions` by
// slice 1, and `element:definition-list.items` and `element:repeater`'s
// `fields`, `filter` and `sort` by slice 2, each registered in
// `MEMBER_PINS`. Header kept as a landmark for a future grep.
};

/**
Expand Down Expand Up @@ -4020,8 +4029,16 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS: string[] = [];
* the objectui#11111 bookings above landed between the card and this change, so
* the four `element:*` units owed to objectui#11168 are what remain, and they
* move only with that card.
*
* ## 4 -> 0 (objectui#11168 slice 2, the `element:*` pair) — the ruling paid off
*
* The slice registered member pins for `element:definition-list.items` and
* `element:repeater`'s `fields`, `filter` and `sort`, measured through the
* real renderer, and deleted their four booked entries. Nothing else stood in
* the list, so the ceiling follows it to 0 and this direction now runs with no
* exemption at all. A new array/object-armed key is answered with a pin.
*/
const MEMBER_PIN_EXEMPTION_CEILING = 4;
const MEMBER_PIN_EXEMPTION_CEILING = 0;

/**
* Every test file a member pin can live in, as LAZY `?raw` loaders.
Expand Down Expand Up @@ -5598,6 +5615,6 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide)
reasons.filter((reason) => reason.startsWith(`${OWED_PREFIX}${owner}.`)).length,
]),
),
).toEqual({ 'objectui#11168': 13, 'objectui#8652': 0, 'objectui#8649': 0 });
).toEqual({ 'objectui#11168': 8, 'objectui#8652': 0, 'objectui#8649': 0 });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
* `target` and never `endpoint`) and `undoable` on `action:button` (the
* runner's update path offers Undo only with a host row stash this block never
* writes).
*
* Slice 2 added the `size` rows at the end: `action:button` publishes the five
* sizes its spec row declares. The renderer hands `default`, `sm`, `lg` and
* `icon` to the Button primitive as-is and maps `md` to `default`; `default`
* and `icon` were unpublished before, so the page validator refused them.
*/

import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
Expand All @@ -54,6 +59,9 @@ import {
useAction,
} from '@object-ui/react';
import { ComponentPropsMap } from '@objectstack/spec/ui';
import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser';
import type { SchemaElement } from '@object-ui/sdui-parser';
import { buttonVariants } from '../../../ui/button';
// Module-scope side-effect imports: the registry must hold both renderers when
// `SchemaRenderer` resolves the type, and the light `dom` project does not load
// the components graph. Module scope, not a `beforeAll`, per AGENTS.md 测试纪律.
Expand Down Expand Up @@ -527,3 +535,58 @@ describe('action:button — `recordIdField`', () => {
expect(JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)).recordId).toBe('ID-1');
});
});

// ── objectui#11168 slice 2 ──────────────────────────────────────────────────

/** The diagnostics the page validator raises for one node, over the manifest the registry publishes. */
const diagnoseCodes = (schema: Record<string, unknown>) =>
validateTree(
schema as unknown as SchemaElement,
manifestFromConfigs(
ComponentRegistry.getAllConfigs() as unknown as Parameters<typeof manifestFromConfigs>[0],
),
).diagnostics.map((diagnostic) => diagnostic.code);

/** The class list the Button primitive gives one size. */
const primitiveSizeClasses = (size: 'default' | 'sm' | 'lg' | 'icon') =>
buttonVariants({ variant: 'default', size }).split(' ');

describe('action:button — `size` publishes the five sizes the spec row declares (objectui#11168 slice 2)', () => {
const SIZES = ['default', 'sm', 'md', 'lg', 'icon'];

it('publishes exactly the sizes the installed spec row accepts, and the spec refuses one outside them', () => {
const size = ComponentRegistry.getConfig('action:button')?.inputs?.find((input) => input.name === 'size');
expect(size?.enum).toEqual(SIZES);
const row = (ComponentPropsMap as unknown as Record<string, { safeParse: (v: unknown) => { success: boolean } }>)[
'action:button'
];
for (const value of SIZES) expect(row.safeParse({ size: value }).success, `spec refuses size ${value}`).toBe(true);
expect(row.safeParse({ size: 'xl' }).success).toBe(false);
});

it.each([
['default', 'default'],
['sm', 'sm'],
['lg', 'lg'],
['icon', 'icon'],
// `md` is the one size the renderer MAPS rather than hands on: it draws
// the primitive's `default`.
['md', 'default'],
] as const)('`size: %s` draws the Button primitive\'s `%s` size', (authored, drawn) => {
mount(node('action:button', { size: authored }));
const classes = screen.getByRole('button').className.split(' ');
for (const cls of primitiveSizeClasses(drawn)) expect(classes, `${authored} lacks ${cls}`).toContain(cls);
});

it('CONTROL: the sizes are distinguishable, so the rows above cannot pass on one shared class list', () => {
const drawn = (['default', 'sm', 'lg', 'icon'] as const).map((size) => primitiveSizeClasses(size).join(' '));
expect(new Set(drawn).size).toBe(4);
});

it('the page validator accepts every published size and reports a size outside them', () => {
for (const size of SIZES) {
expect(diagnoseCodes({ type: 'action:button', size }), `validator refuses size ${size}`).not.toContain('invalid-enum');
}
expect(diagnoseCodes({ type: 'action:button', size: 'xl' })).toContain('invalid-enum');
});
});
10 changes: 9 additions & 1 deletion packages/components/src/renderers/action/action-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -426,10 +426,18 @@ ComponentRegistry.register('button', ActionButtonRenderer, {
type: 'enum',
enum: ['default', 'primary', 'secondary', 'destructive', 'outline', 'ghost'],
},
// objectui#11168 slice 2 — the five sizes the spec row declares, each
// measured through the real `SchemaRenderer`: `default`, `sm`, `lg` and
// `icon` reach the Button primitive as-is, and `md` renders as `default`
// (the mapping above). `default` and `icon` were unpublished, so the page
// validator refused two values the renderer honours. Pinned in
// `__tests__/action-button-icon-inputs-11168.test.tsx`.
{
name: 'size',
type: 'enum',
enum: ['sm', 'md', 'lg'],
enum: ['default', 'sm', 'md', 'lg', 'icon'],
description:
'Button size: the Button primitive\'s `default`, `sm`, `lg` or `icon`, plus `md`, which renders as `default` (default: `default`)',
},
{ name: 'className', type: 'string' },
{
Expand Down
Loading
Loading