Skip to content

Commit aacaa64

Browse files
authored
Merge branch 'main' into claude/youthful-pasteur-be09ad
2 parents 12f5294 + 23871f1 commit aacaa64

25 files changed

Lines changed: 1643 additions & 72 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@object-ui/plugin-chatbot': patch
3+
---
4+
5+
**Read the agent catalog in the declared envelope too, before the server converts.**
6+
7+
`GET /api/v1/ai/agents` is served by two producers — the framework dispatcher's
8+
degraded fallback when no AI service is registered, and cloud's `service-ai` — and
9+
it is one of the last SDK-addressable routes still answering outside the platform's
10+
declared `{ success: true, data }` envelope (objectstack#4053). `useAgents` read
11+
only `{ agents }` and a bare array, so the day either producer converts, the parse
12+
would miss.
13+
14+
That miss is unusually dangerous on this particular route, which is why it is worth
15+
getting ahead of rather than fixing after. The catalog is not just data:
16+
`useAiSurfaceEnabled` gates the **entire AI surface** on `agents.length > 0`,
17+
because the route is access-filtered per caller and is therefore the only signal
18+
that is both edition- and user-aware (ADR-0068). An empty list is the correct
19+
answer for a seat-less user or a Community-Edition deployment with no `service-ai`
20+
— so a parse miss and the legitimate hidden state are **indistinguishable**: no
21+
error, no 403, no log, just the FAB, the top-bar link and the designer's "Ask AI"
22+
quietly gone for everyone.
23+
24+
`extractAgentList` now folds all four shapes to the same list — a bare array,
25+
`{ agents }`, `{ success: true, data: [...] }`, and `{ success: true, data:
26+
{ agents } }` — detecting the envelope the way `ObjectStackClient.unwrapResponse`
27+
does (a **boolean** `success`), so the two readers cannot disagree about what
28+
counts as one. Nine tests cover it; reverting to the previous two-shape read fails
29+
five of them.
30+
31+
No behaviour change against any server shipping today: the shapes that worked
32+
before still parse identically. This only removes the lockstep requirement, so the
33+
server side can convert on its own schedule.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@object-ui/core": minor
3+
"@object-ui/components": patch
4+
---
5+
6+
fix(sdui): a `kind:'html'` page can use lazily-registered blocks, and recovers when one registers late
7+
8+
objectui#2953 had a twin one tier over, unreported. The whitelist a
9+
`kind:'html'` page's source compiles against was built from `getAllTypes()` +
10+
`getConfig()` — both loaded-only — so any block registered via `registerLazy()`
11+
was rejected as *"not an allowed component"*.
12+
13+
The blast radius is worse than the react tier's. There, a missing block cost one
14+
identifier; here a compile diagnostic fails the **whole page**, so a single
15+
`<object-kanban>` replaced the entire page with `HTML page failed to compile (2)`.
16+
And it never recovered: `layoutElement` was memoised on `[schema, pageType]` with
17+
no registry signal, so the cached error panel outlived the plugin actually
18+
landing — permanently broken for the session.
19+
20+
`ComponentRegistry` gains three lazy-aware reads:
21+
22+
- `getKnownTypes()` — loaded registrations **plus** pending lazy stubs, deduped.
23+
The set a whitelist or manifest should be built from. `getAllTypes()` keeps its
24+
loaded-only meaning ("what can render right now") and now says so.
25+
- `getMeta(type, namespace?)` — metadata from the loaded registration, else from
26+
a pending stub. `getConfig()` stays loaded-only, since callers read
27+
`.component` off it.
28+
- `getVersion()` — monotonic counter of changes to the known set, bumped on
29+
register / unregister / registerLazy. A cache key that a type *count* cannot
30+
substitute for: one registration plus one unregistration leaves the count
31+
untouched while the set changed.
32+
33+
`getJsxManifest()` builds from those, and `PageRenderer` subscribes to the
34+
registry so a page that could not compile retries when the registry grows.
35+
36+
A stub carries no `inputs` yet, so its props surface as `unknown-prop` warnings
37+
rather than errors — the page compiles and renders, and the inner
38+
`SchemaRenderer` triggers the loader and swaps in the real block. Authoring-time
39+
prop validation is unaffected: `sdui.manifest.json` is generated with every
40+
plugin eagerly loaded, and asserts as much.

packages/components/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"tailwindcss": "^4.2.1"
9191
},
9292
"devDependencies": {
93+
"@objectstack/spec": "^17.0.0-rc.0",
9394
"@tailwindcss/postcss": "^4.3.3",
9495
"@types/react": "19.2.17",
9596
"@types/react-dom": "19.2.3",
@@ -100,7 +101,8 @@
100101
"tailwindcss": "^4.3.3",
101102
"typescript": "^6.0.3",
102103
"vite": "^8.1.5",
103-
"vite-plugin-dts": "^5.0.3"
104+
"vite-plugin-dts": "^5.0.3",
105+
"zod": "^4.4.3"
104106
},
105107
"keywords": [
106108
"objectui",
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Selection mode ↔ spec vocabulary parity + behavior (#2941).
11+
*
12+
* `SelectionConfigSchema.type` (`ui/view.zod.ts`) publishes three names:
13+
* `none | single | multiple`. The table used to treat `selectable` as a bare
14+
* truthy, so a view authored with `selection.type: 'single'` rendered the full
15+
* multi-select UX — per-row checkboxes that accumulate AND a select-all
16+
* header. The output looked right and wasn't: `single` was never
17+
* distinguished from `multiple`.
18+
*
19+
* Contract under test:
20+
* - parity: the table's declared mode set equals the spec enum, both ways;
21+
* - `single` offers no select-all and replaces the selection on each pick;
22+
* - `multiple` (and legacy `true`) keeps the accumulate + select-all UX.
23+
*/
24+
import { describe, it, expect, vi, beforeAll } from 'vitest';
25+
import { fireEvent } from '@testing-library/react';
26+
import '@testing-library/jest-dom';
27+
import { SelectionConfigSchema } from '@objectstack/spec/ui';
28+
import { renderComponent } from './test-utils';
29+
import { SUPPORTED_SELECTION_MODES } from '../renderers/complex/data-table';
30+
31+
beforeAll(async () => {
32+
await import('../renderers');
33+
}, 30000);
34+
35+
/** The spec's selection vocabulary, read through the `.default()` wrapper. */
36+
function specSelectionModes(): string[] {
37+
const typeSchema = (SelectionConfigSchema as unknown as { shape?: Record<string, unknown> })
38+
.shape?.type as { def?: { innerType?: { options?: readonly string[] } } } | undefined;
39+
const options = typeSchema?.def?.innerType?.options;
40+
return Array.isArray(options) ? [...options] : [];
41+
}
42+
43+
const baseSchema = {
44+
type: 'data-table' as const,
45+
columns: [{ header: 'Name', accessorKey: 'name' }],
46+
data: [
47+
{ id: '1', name: 'Alice' },
48+
{ id: '2', name: 'Bob' },
49+
{ id: '3', name: 'Carol' },
50+
],
51+
pagination: false,
52+
searchable: false,
53+
};
54+
55+
describe('data-table selection mode covers the spec selection vocabulary', () => {
56+
const specNames = specSelectionModes();
57+
58+
it('reads a non-empty enum from the spec', () => {
59+
expect(specNames, 'could not read SelectionConfigSchema.shape.type options from the spec').not.toEqual([]);
60+
});
61+
62+
it('implements every selection mode the spec accepts', () => {
63+
const unimplemented = specNames.filter((name) => !SUPPORTED_SELECTION_MODES.has(name));
64+
expect(
65+
unimplemented,
66+
'these pass schema validation but render an undistinguished selection UX — implement them in data-table',
67+
).toEqual([]);
68+
});
69+
70+
it('does not accept selection modes the spec rejects', () => {
71+
const extra = [...SUPPORTED_SELECTION_MODES].filter((name) => !specNames.includes(name));
72+
expect(
73+
extra,
74+
'these are renderer-local dialect — promote them into @objectstack/spec instead',
75+
).toEqual([]);
76+
});
77+
});
78+
79+
describe('data-table selection behavior per mode', () => {
80+
it("'single' renders no select-all header and replaces the selection on each pick", () => {
81+
const onSelectionChange = vi.fn();
82+
const { getAllByRole } = renderComponent({
83+
...baseSchema,
84+
selectable: 'single',
85+
onSelectionChange,
86+
} as any);
87+
88+
// 3 rows → exactly 3 checkboxes; a select-all header would make it 4.
89+
const checkboxes = getAllByRole('checkbox');
90+
expect(checkboxes).toHaveLength(3);
91+
92+
fireEvent.click(checkboxes[0]);
93+
expect(onSelectionChange).toHaveBeenLastCalledWith([expect.objectContaining({ name: 'Alice' })]);
94+
95+
// Picking Bob must REPLACE Alice, never accumulate to two rows.
96+
fireEvent.click(checkboxes[1]);
97+
expect(onSelectionChange).toHaveBeenLastCalledWith([expect.objectContaining({ name: 'Bob' })]);
98+
});
99+
100+
it("'single' allows deselecting the picked row", () => {
101+
const onSelectionChange = vi.fn();
102+
const { getAllByRole } = renderComponent({
103+
...baseSchema,
104+
selectable: 'single',
105+
onSelectionChange,
106+
} as any);
107+
108+
const checkboxes = getAllByRole('checkbox');
109+
fireEvent.click(checkboxes[0]);
110+
fireEvent.click(checkboxes[0]);
111+
expect(onSelectionChange).toHaveBeenLastCalledWith([]);
112+
});
113+
114+
it("'multiple' keeps the select-all header and accumulates picks", () => {
115+
const onSelectionChange = vi.fn();
116+
const { getAllByRole } = renderComponent({
117+
...baseSchema,
118+
selectable: 'multiple',
119+
onSelectionChange,
120+
} as any);
121+
122+
// 3 rows + the select-all header.
123+
const checkboxes = getAllByRole('checkbox');
124+
expect(checkboxes).toHaveLength(4);
125+
126+
// First checkbox is the header select-all; rows follow.
127+
fireEvent.click(checkboxes[1]);
128+
fireEvent.click(checkboxes[2]);
129+
expect(onSelectionChange).toHaveBeenLastCalledWith([
130+
expect.objectContaining({ name: 'Alice' }),
131+
expect.objectContaining({ name: 'Bob' }),
132+
]);
133+
});
134+
135+
it('legacy `selectable: true` still means multi-select', () => {
136+
const { getAllByRole } = renderComponent({
137+
...baseSchema,
138+
selectable: true,
139+
} as any);
140+
expect(getAllByRole('checkbox')).toHaveLength(4);
141+
});
142+
143+
it("'none' renders no selection column", () => {
144+
const { queryAllByRole } = renderComponent({
145+
...baseSchema,
146+
selectable: 'none',
147+
} as any);
148+
expect(queryAllByRole('checkbox')).toHaveLength(0);
149+
});
150+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* A `kind:'html'` page can use lazily-registered blocks (objectui#2953, html tier).
9+
*
10+
* Same root cause as the react tier, worse blast radius. The whitelist a page's
11+
* source is compiled against was built from `getAllTypes()` + `getConfig()` —
12+
* both loaded-only — so a block registered via `registerLazy()` was rejected as
13+
* "not an allowed component". And because a compile diagnostic fails the WHOLE
14+
* page rather than the one node, `<object-kanban>` anywhere in the source
15+
* replaced the entire page with an error panel. Load-order dependent, so the
16+
* same page worked or didn't depending on what else had rendered first.
17+
*
18+
* It also never recovered: `layoutElement` was memoised on `[schema, pageType]`
19+
* with no registry signal, so the cached error panel outlived the plugin
20+
* actually landing.
21+
*/
22+
23+
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
24+
import { render, waitFor } from '@testing-library/react';
25+
import React from 'react';
26+
import { ComponentRegistry } from '@object-ui/core';
27+
import { SchemaRenderer, AdapterCtx } from '@object-ui/react';
28+
29+
const adapter = { find: async () => [] } as any;
30+
31+
/** Registers a lazy `object-kanban` whose chunk lands only when you say so. */
32+
function lazyKanban() {
33+
let land!: () => void;
34+
const gate = new Promise<void>((resolve) => {
35+
land = resolve;
36+
});
37+
ComponentRegistry.registerLazy(
38+
'object-kanban',
39+
async () => {
40+
await gate;
41+
ComponentRegistry.register('object-kanban', () => <div data-testid="kanban-double" />, {
42+
namespace: 'plugin-kanban',
43+
});
44+
},
45+
{ namespace: 'plugin-kanban', category: 'view' },
46+
);
47+
return land;
48+
}
49+
50+
function renderHtmlPage(source: string) {
51+
return render(
52+
<AdapterCtx.Provider value={adapter}>
53+
<SchemaRenderer schema={{ type: 'home', kind: 'html', name: 'test_page', source }} />
54+
</AdapterCtx.Provider>,
55+
);
56+
}
57+
58+
beforeAll(async () => {
59+
// Registers PageRenderer for `type:'home'`, which dispatches kind:'html'.
60+
await import('../renderers');
61+
}, 30000);
62+
63+
afterEach(() => {
64+
ComponentRegistry.unregister('object-kanban', 'plugin-kanban');
65+
ComponentRegistry.unregister('object-kanban');
66+
});
67+
68+
describe('kind:\'html\' page with a lazily-registered block', () => {
69+
it('compiles instead of failing the whole page, and renders the block once loaded', async () => {
70+
const land = lazyKanban();
71+
72+
const { container, findByTestId } = renderHtmlPage(
73+
`<flex><object-kanban object="showcase_project" /></flex>`,
74+
);
75+
76+
// Before: 'HTML page failed to compile (2)' — `<object-kanban> is not an
77+
// allowed component` + `is not a known component` — and no <flex> either.
78+
expect(container.textContent).not.toContain('failed to compile');
79+
80+
land();
81+
expect(await findByTestId('kanban-double')).toBeTruthy();
82+
});
83+
84+
it('recovers a page that compiled before the block was registered', async () => {
85+
// Nothing knows `object-kanban` yet, so this page genuinely cannot compile.
86+
const { container } = renderHtmlPage(`<flex><object-kanban object="showcase_project" /></flex>`);
87+
expect(container.textContent).toContain('failed to compile');
88+
89+
// The plugin registers late — a legitimate case (a plugin installed at
90+
// runtime, or any registration that races first paint).
91+
ComponentRegistry.register('object-kanban', () => <div data-testid="kanban-double" />, {
92+
namespace: 'plugin-kanban',
93+
});
94+
95+
// Before: the memoised error panel outlived the registration, permanently.
96+
await waitFor(() => expect(container.querySelector('[data-testid=kanban-double]')).toBeTruthy());
97+
expect(container.textContent).not.toContain('failed to compile');
98+
});
99+
100+
it('still rejects a tag nothing has registered at all', async () => {
101+
const { container } = renderHtmlPage(`<flex><totally-not-a-block /></flex>`);
102+
103+
// The negative control. Admitting lazy stubs must not turn the whitelist
104+
// into "anything goes" — that whitelist is what keeps a parsed page from
105+
// rendering arbitrary tags.
106+
await waitFor(() => expect(container.textContent).toContain('failed to compile'));
107+
expect(container.textContent).toContain('totally-not-a-block');
108+
});
109+
});

0 commit comments

Comments
 (0)