Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 1ff5f45

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-10286-7759-groups-cd
2 parents cb0cca0 + 8b1f066 commit 1ff5f45

60 files changed

Lines changed: 1929 additions & 1519 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@object-ui/types': minor
3+
'@object-ui/components': minor
4+
---
5+
6+
**BREAKING: the unimplemented async export-job path is removed from `@object-ui/types` and `@object-ui/components`**
7+
8+
What left:
9+
10+
- `@object-ui/components`: the `useExportJob` hook with its `UseExportJobOptions`
11+
and `UseExportJobReturn` types, and the `ExportProgressDialog` component with
12+
its `ExportProgressDialogProps` type.
13+
- `@object-ui/types` (the root entry and the `./data` subpath): the optional
14+
`DataSource` members `createExportJob`, `getExportJobProgress`,
15+
`cancelExportJob` and `getExportJobDownloadUrl`, and the types
16+
`ExportJobStatus`, `ExportJobFormat`, `CreateExportJobRequest`,
17+
`CreateExportJobResult` and `ExportJobProgressInfo`. None of them had a zod
18+
mirror.
19+
20+
Why: ruling letter A on objectstack#17158 decided to retire the export-job API
21+
contract family from `@objectstack/spec`, because nothing serves it and
22+
`IExportService` has no provider. The ruling sequences the objectui side first:
23+
this change retires objectui's consumer side, rather than keeping it alive with
24+
local copies of the spec's types (objectui#10247). The spec-side retirement
25+
comes after this change; until it lands, `@objectstack/spec` still exports the
26+
export-job contracts.
27+
28+
No `DataSource` in this repository implemented any of the four methods. That
29+
was measured at `d7de5348` over every class that implements `DataSource` and
30+
every test double typed as one under `packages/`, `apps/` and `examples/`, with
31+
the synchronous `exportDownload` member as the control that did match. So
32+
`useExportJob` could only ever report `isSupported: false` here, and no runtime
33+
behaviour in this repository changes.
34+
35+
⚠️ Implementers and importers outside this repository are NOT MEASURED. Code
36+
that imports any name above from `@object-ui/types` or `@object-ui/components`
37+
no longer compiles, and a host `DataSource` that implements the four methods is
38+
no longer called by anything in objectui. There is no replacement API: nothing
39+
serves the spec's export-job contracts and `IExportService` has no provider;
40+
objectstack#17158 (ruling A) retires them from `@objectstack/spec` after this
41+
lands.
42+
43+
The synchronous export path is unchanged: `DataSource.exportDownload` and its
44+
`ExportDownloadRequest` (csv / json / xlsx), and the grid's client-side
45+
fallback.
46+
47+
Marked `minor`, not `major`, per the version-alignment convention in AGENTS.md;
48+
the break is real and is stated here.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@object-ui/react': minor
3+
---
4+
5+
feat(react): an action's `params` values are templates, evaluated where `properties` are
6+
7+
Every string leaf of a node's `params` bag - the node-level `params` and
8+
`properties.params` alike (and the legacy `props.params`, which follows its
9+
canonical bag), at any depth, inside nested objects and arrays - is now
10+
template-evaluated by `SchemaRenderer`, with the same evaluator and the same
11+
scope that already evaluates `properties`. A metadata-authored button on a
12+
record page can therefore name the record it sits on:
13+
14+
```json
15+
{ "type": "action:button", "label": "Edit", "actionType": "navigate_edit",
16+
"params": { "objectName": "account", "recordId": "${record.id}" } }
17+
```
18+
19+
Before this, both spellings of `params.recordId` reached the action handler as
20+
the raw `${record.id}` text: the node-level bag was never evaluated, and
21+
`properties.params` was one value of a shallow loop, so the template inside it
22+
was never visited.
23+
24+
Behaviour change, stated plainly: a `params` string containing `${...}` is now
25+
evaluated at render time instead of being passed through verbatim. It reaches
26+
only values whose templates never worked - no authored `params` bag in this
27+
repository carries one. A template that still cannot be evaluated (an unbound
28+
root such as `${nope.id}`) keeps its source text exactly as before, and the
29+
development-build unevaluated-expression diagnostic now reports it by its path
30+
(`params.recordId`, `properties.params.target.id`), so a wrong template stays
31+
loud.
32+
33+
What is not walked: an ARRAY `params` (the `ActionParam[]` definition list the
34+
params dialog renders) is left as authored; keys are never evaluated, only
35+
values; non-plain objects (`Date`, `Map`, class instances) and functions pass
36+
through by identity; a cycle in a host-built bag ends the walk. Every other
37+
nested config value keeps the shallow reading it had.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@object-ui/sdui-parser': minor
3+
'@object-ui/components': minor
4+
'@object-ui/layout': minor
5+
'@object-ui/types': minor
6+
---
7+
8+
fix(sdui-parser,components,layout,types): containment is the declared `children` slot, not `isContainer` (objectui#9910)
9+
10+
`validateTree`'s `not-a-container` diagnostic now reads exactly one declaration:
11+
an input named `children` in the component's registration `inputs`. Ten
12+
registrations already carried such an input — `slot`-typed on `tooltip` and the
13+
four `page:*` containers, `array`-typed on the five page kinds — and the
14+
predicate reads the NAME, so both count. `isContainer` is no longer
15+
consulted and is not a fallback — new exports `acceptsChildren` and
16+
`CHILD_LIST_KEY` in `@object-ui/sdui-parser` spell the predicate, and the
17+
retired `body` dialect follows the same reading.
18+
19+
Why: the flag was hand-kept and drifted from the renderers four times; after
20+
objectui#6771 converged a dozen `schema.body` readers onto `children`, and with
21+
objectui#6804 ruling the flag OFF for that population (it means layout
22+
containment, and declaring it deletes a public tag from every react page's JSX
23+
scope), the diagnostic landed FALSE on the one key `button`, `badge`, `alert`
24+
and the `sidebar-*` parts render. The maintainer ruled declare-and-pin
25+
(2026-09-24).
26+
27+
What is declared: every renderer under `@object-ui/components` and
28+
`@object-ui/layout` that puts `schema.children` on the page — 65 registrations
29+
measured bare plus `sidebar` and `sidebar-menu-button` in their provider
30+
context — now declares the `children` slot; the 34 flow/inline HTML tags, the
31+
seven sectioning tags, the layout primitives, `button` / `badge` / `alert` /
32+
`toggle` (label and description fallbacks), `span` (read ahead of `value`),
33+
`div`, `form`, `scroll-area`, the ten `sidebar-*` parts and `page-header`. Nine public blocks
34+
gain the slot in `sdui.manifest.json`; a `slot` input emits no JSX attribute, so
35+
the generated `.d.ts` is unchanged. `isContainer` is kept everywhere it was
36+
declared and re-described on every published face (`ComponentMeta`,
37+
`RuntimeWidgetManifest`, `ComponentMetaSchema`, the manifest types) as LAYOUT
38+
containment — the react-page scope builder keeps reading it unchanged.
39+
40+
The runtime containment census is re-pointed from the flag to the input in both
41+
directions (declared ⇔ actually renders, with named context probes for the
42+
provider-scoped and portal renderers), `scripts/container-declaration-baseline.json`
43+
is at zero, and a registration that is a layout container but renders no
44+
authored list (`page:tabs`, `page:accordion`, `responsive-grid`) now draws the
45+
TRUE `not-a-container` the flag used to silence.

‎content/docs/guide/component-registry.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ ComponentRegistry.register('my-component', MyComponent, {
135135

136136
This metadata is used by the Visual Designer to provide better editing experience.
137137

138+
`inputs` is also the authoring contract the SDUI parser validates against. A
139+
component that renders `schema.children` declares the slot as
140+
`{ name: 'children', type: 'slot' }`; a child list authored under a component
141+
whose `inputs` carry no such entry draws the `not-a-container` diagnostic.
142+
`isContainer` is a separate fact — *layout* containment, which keeps a block out
143+
of a react page's JSX scope — and does not stand in for the slot.
144+
138145
### Lazy Loading
139146

140147
Register components that load on demand:

‎content/docs/guide/react-pages.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,14 @@ every container (`if (!tag || cfg.isContainer) continue;`), so `<flex>`, `<grid>
143143
with real HTML, which React is better at than a schema-children renderer — styled
144144
inline, not with Tailwind: `<div style={{ display: 'flex', gap: 16 }}>`.
145145

146+
`isContainer` means exactly this: *layout containment*, the reason a block is
147+
kept out of the JSX scope. It does not mean "accepts children". Whether a block
148+
renders an authored child list is declared on its registration as
149+
`{ name: 'children', type: 'slot' }` in `inputs`, and that declaration is what
150+
the html tier's `not-a-container` diagnostic reads — so `<Button>` and `<Badge>`
151+
stay injected here (they are not layout) while still accepting `children` on the
152+
html tier.
153+
146154
### Styling — page source is metadata, not build input
147155

148156
**Do not author Tailwind utility classes in page source** — on either tier. A

‎content/docs/guide/record-edit-modes.md‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,10 @@ it. Arguments go in a top-level `params` object:
7878
}
7979
```
8080

81-
`navigate_edit` additionally needs the record to open. `params` reaches
82-
the handler verbatim: template expressions such as `${record.id}` are not
83-
evaluated inside `params`, and `action:button` does not inject the
84-
surrounding row, so a declared `navigate_edit` button carries a literal
85-
`recordId`:
81+
`navigate_edit` additionally needs the record to open. `params` values
82+
are templates: every string inside `params`, at any depth, is evaluated
83+
the same way `properties` values are, so a button on a record page names
84+
the record it sits on with `${record.id}`:
8685

8786
```jsonc
8887
{
@@ -92,15 +91,21 @@ surrounding row, so a declared `navigate_edit` button carries a literal
9291
"actionType": "navigate_edit",
9392
"params": {
9493
"objectName": "account",
95-
"recordId": "0015e000abcd"
94+
"recordId": "${record.id}"
9695
}
9796
}
9897
```
9998

100-
For a per-row **Edit** that follows the record under the cursor, use the
101-
list or detail view's built-in **Edit** entry point instead: under
102-
`editMode: "page"` it already routes to the same URL (see *Migrating an
103-
existing object* below).
99+
`record` is the record the page is bound to. A template that cannot be
100+
evaluated (on a page with no bound record, or with a misspelled root
101+
such as `${recrod.id}`) reaches the handler as its raw `${…}` text, and
102+
the development console reports it under `params.recordId`, so a wrong
103+
template stays visible. A misspelled field on a bound record
104+
(`${record.idd}`) is not an error: it resolves to nothing.
105+
106+
For a per-row **Edit** in a list, use the list view's built-in **Edit**
107+
entry point: under `editMode: "page"` it already routes to the same URL
108+
(see *Migrating an existing object* below).
104109

105110
When invoked from inside an `ObjectView`, the action context already
106111
carries the active `objectName`, so `params` may be omitted entirely:

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ type MinimalDataSource = Pick<
288288
>;
289289

290290
// And that those six SUFFICE. Every other member of `DataSource` — `searchAll`,
291-
// `bulk`, `getView`, `aggregate`, the import/export job family — is optional, so
291+
// `bulk`, `getView`, `aggregate`, `exportDownload`, the import-job family — is optional, so
292292
// a value with just the six is assignable to the whole interface. This line goes
293293
// red the moment a seventh member becomes required.
294294
declare const minimal: MinimalDataSource;

‎packages/components/src/__tests__/displayLocale-9909.test.tsx‎

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@
77
*/
88

99
/**
10-
* The export dialog's record counts and the debug panel's event times read the
11-
* DECLARED session locale, never the machine's (objectui#9909).
10+
* The debug panel's event times read the DECLARED session locale, never the
11+
* machine's (objectui#9909).
1212
*
1313
* Each surface renders the same state twice — under a declared `de-DE` tenant
1414
* locale and a declared `en` one, the UI language `en` on both — and must read
1515
* differently: a literal expectation would measure the runner, on which a
1616
* broken surface and a repaired one print the same bytes. The runtime tripwire
1717
* then checks the argument every locale-taking call received.
18+
*
19+
* The export-progress dialog's record-count surface left this table with the
20+
* component itself: objectui#10247 retired the unimplemented async export path.
1821
*/
1922

2023
import { describe, it, expect, afterEach } from 'vitest';
@@ -23,9 +26,7 @@ import * as React from 'react';
2326
import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
2427
import { DebugCollector } from '@object-ui/core';
2528
import { isMachineLocale, recordLocaleArguments } from '@object-ui/test-support';
26-
import { ExportProgressDialog } from '../custom/export-progress-dialog';
2729
import { DebugPanel } from '../debug/DebugPanel';
28-
import type { UseExportJobReturn } from '../hooks/use-export-job';
2930

3031
afterEach(() => {
3132
cleanup();
@@ -48,18 +49,6 @@ function textUnder(locale: string, node: React.ReactNode, reveal?: () => void):
4849
return text.replace(/\s+/g, ' ').trim();
4950
}
5051

51-
const job: UseExportJobReturn = {
52-
isRunning: true,
53-
progress: { jobId: 'j1', status: 'processing', processedRecords: 12345, totalRecords: 67890 },
54-
error: null,
55-
isSupported: true,
56-
start: async () => 'j1',
57-
cancel: async () => {},
58-
getDownloadUrl: async () => null,
59-
download: async () => true,
60-
reset: () => {},
61-
};
62-
6352
/** 15:30:45 UTC — the suite runs in UTC; `de` is 24-hour, `en` is 12-hour. */
6453
const EVENT_AT = Date.UTC(2020, 2, 4, 15, 30, 45);
6554

@@ -83,12 +72,6 @@ interface Surface {
8372
}
8473

8574
const SURFACES: Surface[] = [
86-
{
87-
name: 'ExportProgressDialog — processed / total record counts',
88-
node: () => <ExportProgressDialog open onOpenChange={() => {}} job={job} />,
89-
de: /12\.345 \/ 67\.890 records/,
90-
en: /12,345 \/ 67,890 records/,
91-
},
9275
{
9376
name: 'DebugPanel — Events tab timestamp',
9477
node: () => {
@@ -101,7 +84,7 @@ const SURFACES: Surface[] = [
10184
},
10285
];
10386

104-
describe('component number and time faces follow the declared session locale (objectui#9909)', () => {
87+
describe('component time faces follow the declared session locale (objectui#9909)', () => {
10588
it.each(SURFACES)('$name — says the de-DE face under a de-DE session', ({ node, reveal, de }) => {
10689
const text = textUnder('de-DE', node(), reveal);
10790
expect(text, `got: ${text}`).toMatch(de);

‎packages/components/src/__tests__/layout-containers-declare-containment.test.tsx‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,21 @@
3333
* stay green if the containment check were removed, mis-keyed, or fed a manifest
3434
* the tag is missing from — the three ways this fact dies without anyone
3535
* noticing. The reachability controls below exist for the same reason.
36+
*
37+
* RE-POINTED BY objectui#9910 (2026-09-24). What `validateTree` reads is no
38+
* longer the flag but the declared `children` slot input (`acceptsChildren`);
39+
* the four declare both. The flag keeps its OTHER consumer — the react-page
40+
* JSX scope skips containers — which is what the second describe still pins,
41+
* and the childless control moved from `badge` (which now declares the slot it
42+
* renders as a label fallback) to `tabs` (renders `items[].content`, never
43+
* `schema.children`).
3644
*/
3745

3846
import { describe, it, expect } from 'vitest';
3947
import { render, waitFor } from '@testing-library/react';
4048
import { ComponentRegistry } from '@object-ui/core';
4149
import { SchemaRenderer, AdapterCtx } from '@object-ui/react';
42-
import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser';
50+
import { CHILD_LIST_KEY, manifestFromConfigs, validateTree } from '@object-ui/sdui-parser';
4351
import type { Diagnostic, SchemaElement } from '@object-ui/sdui-parser';
4452

4553
// Module scope, not a hook: this import IS the registration (AGENTS.md
@@ -91,27 +99,39 @@ describe('the ui layout primitives accept children without warning (objectui#674
9199
expect(diagnostics.filter((d) => d.code === CONTAINMENT)).toEqual([]);
92100
});
93101

102+
it.each(LAYOUT_CONTAINERS)('`%s` declares the `children` slot the tier reads (objectui#9910)', (type) => {
103+
// The declaration behind the silence above, asserted by name so the
104+
// absence of a diagnostic cannot pass on a fallback: `validateTree` reads
105+
// this input and nothing else.
106+
const inputs = ComponentRegistry.getMeta(type)?.inputs ?? [];
107+
expect(inputs.find((i) => i.name === CHILD_LIST_KEY)?.type).toBe('slot');
108+
});
109+
94110
it('still reports `not-a-container` for a component that genuinely takes none', () => {
95111
// The control that keeps every assertion above meaningful. Without it the
96112
// suite would stay green if the containment check were deleted outright, or
97-
// if `isContainer` were defaulted on — either of which turns this file into
98-
// a measurement of nothing. `badge` is a leaf: it renders its own label and
99-
// never reads `schema.children`, so children under it ARE an authoring
100-
// mistake the author must still hear about.
113+
// if every registration declared the slot — either of which turns this file
114+
// into a measurement of nothing. `tabs` renders `items[].content` and never
115+
// reads `schema.children`, so children under it ARE an authoring mistake the
116+
// author must still hear about. (`badge` held this role until objectui#9910,
117+
// when it declared the slot it renders as its label fallback.)
101118
//
102-
// If `badge` ever legitimately becomes a container, this goes red — move the
119+
// If `tabs` ever legitimately reads `children`, this goes red — move the
103120
// control to another childless registration rather than deleting it.
104-
const codes = diagnose(withChildren('badge')).map((d) => d.code);
121+
const codes = diagnose(withChildren('tabs')).map((d) => d.code);
105122
expect(codes).toContain(CONTAINMENT);
123+
expect((ComponentRegistry.getMeta('tabs')?.inputs ?? []).some((i) => i.name === CHILD_LIST_KEY)).toBe(false);
106124
});
107125
});
108126

109127
describe('the declaration reaches the consumers that read it (objectui#6740)', () => {
110128
it.each(LAYOUT_CONTAINERS)('`%s` reports as a container on the public tier', (type) => {
111-
// The second consumer, and the reason it is pinned rather than left
112-
// implicit: `renderers/layout/react-page.tsx:77` builds the JSX scope of
129+
// The flag's consumer, and the reason it is pinned rather than left
130+
// implicit: `renderers/layout/react-page.tsx` builds the JSX scope of
113131
// every `kind:'react'` page with `if (!tag || cfg.isContainer) continue;`,
114132
// reading THIS predicate off `getPublicConfigs()` — not off `getMeta()`.
133+
// Since objectui#9910 this is the flag's ONE meaning — layout containment —
134+
// so this block is about the react-page scope, not about `validateTree`.
115135
// While `flex` omitted the flag it was the one layout primitive of the five
116136
// still injected there, contradicting
117137
// `content/docs/guide/react-pages.md` ("Layout containers are deliberately

‎packages/components/src/__tests__/page-container-authorable-keys.test.tsx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,13 @@ describe('the thin containers publish the `children` slot they render (objectui#
9393
expect(inputsOf(type)[0].type).toBe('slot');
9494
});
9595

96-
it.each(THIN_CONTAINERS)('%s is still a container', (type) => {
97-
// The two halves are independent and both required: `isContainer` is what
98-
// `sdui-parser/src/validate.ts` reads for its `not-a-container` diagnostic,
99-
// and `inputs` is what the designer reads to offer the slot. Publishing one
100-
// without the other is how objectui#3900 happened on `page:header`.
96+
it.each(THIN_CONTAINERS)('%s is still a layout container', (type) => {
97+
// Two independent facts, and since objectui#9910 the `children` slot in
98+
// `inputs` carries BOTH the designer's offer and the containment the tier
99+
// reads (`sdui-parser`'s `not-a-container` reads only that input). The
100+
// flag is the other fact — LAYOUT containment (objectui#6804): the
101+
// react-page JSX scope skips these three and the public layout ledger
102+
// lists them — and it stays declared because they are layout regions.
101103
expect(ComponentRegistry.getConfig(type)?.isContainer).toBe(true);
102104
});
103105

0 commit comments

Comments
 (0)