Skip to content

Commit d613fdc

Browse files
fix(app-shell): a position created from Setup is saved live and shows in the list (#26269)
Fixes #26192 Clause-②: no Metadata-protocol: none ## Premise (answered first, as the card asks) Setup → Positions → New carried **no** `packageId`. Positions register no `EditPage`, so New opens the generic `MetadataResourceEditPage`; its save sends `mode: 'draft'` and adds a package only under `?package=`, and Setup carries none. By the ruling's own condition ("a draft written with none is itself the defect"), the fix is the save path, not a drafts list. Measured red-first on `origin/main` `df8aac41f2` with the new test below: the create sent one PUT with `mode: "draft"` and `package: null`. ## What changes - `ui/packages/app-shell/src/services/builtinComponents.tsx`: the `position` resource registers `createLive: true`, the key the permission set already carries (objectui#7611). **Where the key lives:** the permission set's `createLive` sits in `anchors.ts` (`registerBuiltinAnchors`, beside its object anchor), but the position has no anchor entry there; its only registration is the `registerMetadataResource({ type: 'position', ... })` call in `builtinComponents.tsx`, so the key goes there. The registry merges per type, so either home would read the same at runtime; the position's single home keeps it in one place. - No change to `ResourceEditPage.tsx`: its existing rule already writes a `createLive` type live when no package binding is active, and as a package draft under `?package=`. - The server admits the live write: the spec's metadata-type registry row for `position` carries `allowRuntimeCreate: true`. Result: a position saved from Setup → Positions → New is written live (one PUT, no `mode`, no `package`, `If-None-Match: *`), shows in the Positions list right after Save, and its detail page reads it at once. Under `?package=` the create stays a package draft (ADR-0086 P0/P2). **Out of scope (stated per the claim):** later edits of an existing position in Setup still save as drafts, the generic editor's rule for every type. Changing that is a product choice and is not made here. ## Tests New `ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.positionCreateLive-26192.test.tsx`, after the liveCreate-7611 pattern: the REAL page over a REAL `MetadataClient`, the position's registration taken from the real `builtinComponents` module, the transport an in-memory server recording each PUT's `mode`, `package` and first-write pin. - outside a package: exactly one PUT `{ name: 'regional_lead', mode: null, packageId: null, ifNoneMatch: '*' }`, then the editor opens `/metadata/position/regional_lead`; - under `?package=com.acme.crm` (control): one PUT `{ mode: 'draft', packageId: 'com.acme.crm', ifNoneMatch: '*' }`. Readings (all under `scripts/pm/os-verify-lock.sh`, shared box): - Red first, test commit `739f7ba6` (no fix): `Tests 1 failed | 3 passed (4)`; the live case received `"mode": "draft"` against expected `null`. The 7611 permission control passed in the same run. - Green at head `41bbd17a`: the new test, `ResourceEditPage.liveCreate-7611`, `ResourceListPage.positionGrants-25333`, `ResourceEditPage.permissionSetsFeed-12126`: `Test Files 4 passed (4)`, `Tests 14 passed (14)`, VERDICT command-exit 0. - `@object-ui/app-shell` `type-check` (both `tsconfig.json` and `tsconfig.test.json`) at `41bbd17a`: VERDICT command-exit 0, 0 `error TS`. - NOT MEASURED, reason: lock queue-timeout twice behind a whole-package app-shell run held by another worktree. The wider set of 41 app-shell test files that load `builtinComponents` or name `position` is left to CI (the UI test jobs). ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `41bbd17a`: 35 derived commands, each run with its exit captured before any pipe: 34 exit 0. `pnpm check:type-check-debt` exit 3 = PREREQUISITE NOT MET (its closure build never got a turn on the verify lock), NOT MEASURED, left to CI `Lint & Repo Gates`. `--ran` reconciliation: 35 derived, 35 run, 0 UNRUN. `scripts/sdui-manifest.*` untouched (`check-sdui-manifest` exit 0). ## Acceptance notes - Main's Test UI may be red on the MetadataService ObjectSchema 48≠47 guards until #26231 lands; that red is not from this diff. This PR was authored by session `session_01SMps7wq7rBTk53e8KTSEup` (`domain:ui` seat 2 dispatch). --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 697a307 commit d613fdc

3 files changed

Lines changed: 170 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a position created from Setup → Positions → New is saved live and shows in the Positions list right after Save, as a permission set does
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **`@object-ui/app-shell`:** the `position` resource now registers `createLive: true`, the key the permission set already carries. Outside a package binding, "New" writes the position live (one PUT with no `mode` and no `package`, with the first-write pin `If-None-Match: *`), so it appears in the Positions list and its detail page reads it at once. Before, Setup carried no package, the create landed as a draft with no package, the list showed nothing and a read of the name answered not found until the admin published it by hand. Under a package binding (`?package=`) the create stays a package draft, as before.
11+
- Later edits of an existing position in Setup still save as drafts, the generic editor's rule for every type; that is unchanged here.
12+
13+
What to do: nothing.

‎ui/packages/app-shell/src/services/builtinComponents.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,14 @@ registerMetadataResource({
217217
type: 'position',
218218
domain: 'security',
219219
listInclude: 'grants',
220+
// objectstack#26192 — Setup → Positions → New creates the position LIVE
221+
// outside a package binding, as the permission set does (objectui#7611).
222+
// Setup carries no `?package=`, so the generic editor's draft save wrote a
223+
// draft with no package: the list (published items) never showed it and a
224+
// read of the name answered not found until a hand Publish. The server
225+
// admits a live position create (`allowRuntimeCreate`); under `?package=`
226+
// the create stays a package draft (ADR-0086).
227+
createLive: true,
220228
listColumns: [
221229
{ key: 'name', label: 'Name', width: '20%' },
222230
{ key: 'label', label: 'Label', width: '20%' },
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26192 — a position created from Setup → Positions → New is
5+
* created LIVE, as a permission set is (objectui#7611).
6+
*
7+
* Positions register no `EditPage`, so "New" opens the generic
8+
* `MetadataResourceEditPage`, whose save writes a draft. Setup carries no
9+
* package binding (`?package=`), so before this the create was a draft with no
10+
* package: the Positions list (published items) never showed it and a read of
11+
* the name answered not found until the admin published it by hand. The
12+
* server admits a live position create (`allowRuntimeCreate` on the `position`
13+
* row of the spec's metadata-type registry), and the permission set, authored
14+
* beside it, already creates live through the same `createLive` key.
15+
*
16+
* Pinned on the REAL page over a REAL `MetadataClient`, with the position's
17+
* registration taken from the real `builtinComponents` module (its only home),
18+
* its transport an in-memory server that records each PUT's `mode`, package
19+
* and first-write pin, as in `ResourceEditPage.liveCreate-7611.test.tsx`:
20+
*
21+
* - outside a package: ONE live PUT (no `mode`, no `package`) with the
22+
* first-write pin, and the editor opens the created position;
23+
* - under a package binding the create stays a package draft (control), the
24+
* ADR-0086 package door's draft semantics.
25+
*/
26+
27+
import '@testing-library/jest-dom/vitest';
28+
import * as React from 'react';
29+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
30+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
31+
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
32+
import { MetadataClient } from '@object-ui/data-objectstack';
33+
34+
const server = {
35+
puts: [] as Array<{ name: string; mode: string | null; packageId: string | null; ifNoneMatch: string | null }>,
36+
};
37+
38+
const json = (status: number, body: unknown) =>
39+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
40+
41+
async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
42+
const method = (init?.method ?? 'GET').toUpperCase();
43+
const url = new URL(String(input), 'http://console.test');
44+
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
45+
if (!meta) return json(200, []);
46+
const seg = meta[1].split('/').map(decodeURIComponent);
47+
if (seg[0] === '_drafts') return json(200, { drafts: [] });
48+
if (seg.length === 1) return json(200, { items: [] });
49+
if (method === 'PUT') {
50+
server.puts.push({
51+
name: seg[1]!,
52+
mode: url.searchParams.get('mode'),
53+
packageId: url.searchParams.get('package'),
54+
ifNoneMatch: new Headers(init?.headers).get('If-None-Match'),
55+
});
56+
return json(200, { success: true, version: 'hmac-sha256:v1', seq: 1, state: 'active', message: 'Saved' });
57+
}
58+
return json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
59+
}
60+
61+
const client = vi.hoisted(() => ({ current: null as unknown }));
62+
63+
const POSITION_SCHEMA = {
64+
type: 'object',
65+
required: ['name'],
66+
properties: {
67+
name: { type: 'string' },
68+
label: { type: 'string' },
69+
description: { type: 'string' },
70+
},
71+
};
72+
73+
vi.mock('./useMetadata', async (importOriginal) => {
74+
const mod = await importOriginal<typeof import('./useMetadata')>();
75+
return {
76+
...mod,
77+
useMetadataClient: () => client.current,
78+
useMetadataTypes: () => ({
79+
loading: false,
80+
entries: [
81+
{ type: 'position', label: 'Position', domain: 'security', allowEnvironmentOverlay: false, allowRuntimeCreate: true, schema: POSITION_SCHEMA },
82+
],
83+
}),
84+
};
85+
});
86+
87+
import { MetadataResourceEditPage } from './ResourceEditPage';
88+
// The position's registration — its only one — lives in the built-in
89+
// component module, not in `anchors.ts`.
90+
import '../../services/builtinComponents';
91+
92+
client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });
93+
94+
beforeEach(() => {
95+
server.puts = [];
96+
window.localStorage.setItem('metadata-admin:autosave', '0');
97+
});
98+
afterEach(() => {
99+
cleanup();
100+
window.localStorage.removeItem('metadata-admin:autosave');
101+
window.history.replaceState(null, '', '/');
102+
});
103+
104+
function Where() {
105+
return <div data-testid="where">{useLocation().pathname}</div>;
106+
}
107+
108+
/** Open the create form, name the position `regional_lead`, and press Save. */
109+
async function createPosition() {
110+
render(
111+
<MemoryRouter initialEntries={['/metadata/position/new']}>
112+
<Routes>
113+
<Route
114+
path="/metadata/position/new"
115+
element={
116+
<>
117+
<MetadataResourceEditPage type="position" name="" createMode />
118+
<Where />
119+
</>
120+
}
121+
/>
122+
<Route path="/metadata/position/:name" element={<Where />} />
123+
</Routes>
124+
</MemoryRouter>,
125+
);
126+
const name = await waitFor(() => {
127+
const el = document.getElementById('mdf-name') as HTMLInputElement | null;
128+
expect(el).not.toBeNull();
129+
return el!;
130+
}, { timeout: 8000 });
131+
fireEvent.change(name, { target: { value: 'regional_lead' } });
132+
await waitFor(() => expect((document.getElementById('mdf-name') as HTMLInputElement).value).toBe('regional_lead'));
133+
fireEvent.click(screen.getByRole('button', { name: /Save \(⌘S\)/ }));
134+
}
135+
136+
describe('a position is created live from Setup (objectstack#26192)', () => {
137+
it('outside a package: one live PUT with the first-write pin, then the editor opens', async () => {
138+
await createPosition();
139+
await waitFor(() => expect(screen.getByTestId('where')).toHaveTextContent('/metadata/position/regional_lead'), { timeout: 8000 });
140+
expect(server.puts).toEqual([{ name: 'regional_lead', mode: null, packageId: null, ifNoneMatch: '*' }]);
141+
});
142+
143+
it('under a package binding the create stays a package draft (control)', async () => {
144+
window.history.replaceState(null, '', '/?package=com.acme.crm');
145+
await createPosition();
146+
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 });
147+
expect(server.puts[0]).toEqual({ name: 'regional_lead', mode: 'draft', packageId: 'com.acme.crm', ifNoneMatch: '*' });
148+
});
149+
});

0 commit comments

Comments
 (0)