diff --git a/.changeset/26192-position-create-live.md b/.changeset/26192-position-create-live.md new file mode 100644 index 00000000000..273dcdef33c --- /dev/null +++ b/.changeset/26192-position-create-live.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + +- **`@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. +- Later edits of an existing position in Setup still save as drafts, the generic editor's rule for every type; that is unchanged here. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/services/builtinComponents.tsx b/ui/packages/app-shell/src/services/builtinComponents.tsx index 02b727def09..09467e23752 100644 --- a/ui/packages/app-shell/src/services/builtinComponents.tsx +++ b/ui/packages/app-shell/src/services/builtinComponents.tsx @@ -217,6 +217,14 @@ registerMetadataResource({ type: 'position', domain: 'security', listInclude: 'grants', + // objectstack#26192 — Setup → Positions → New creates the position LIVE + // outside a package binding, as the permission set does (objectui#7611). + // Setup carries no `?package=`, so the generic editor's draft save wrote a + // draft with no package: the list (published items) never showed it and a + // read of the name answered not found until a hand Publish. The server + // admits a live position create (`allowRuntimeCreate`); under `?package=` + // the create stays a package draft (ADR-0086). + createLive: true, listColumns: [ { key: 'name', label: 'Name', width: '20%' }, { key: 'label', label: 'Label', width: '20%' }, diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.positionCreateLive-26192.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.positionCreateLive-26192.test.tsx new file mode 100644 index 00000000000..78c4c5b1a7e --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.positionCreateLive-26192.test.tsx @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26192 — a position created from Setup → Positions → New is + * created LIVE, as a permission set is (objectui#7611). + * + * Positions register no `EditPage`, so "New" opens the generic + * `MetadataResourceEditPage`, whose save writes a draft. Setup carries no + * package binding (`?package=`), so before this the create was a draft with no + * package: the Positions list (published items) never showed it and a read of + * the name answered not found until the admin published it by hand. The + * server admits a live position create (`allowRuntimeCreate` on the `position` + * row of the spec's metadata-type registry), and the permission set, authored + * beside it, already creates live through the same `createLive` key. + * + * Pinned on the REAL page over a REAL `MetadataClient`, with the position's + * registration taken from the real `builtinComponents` module (its only home), + * its transport an in-memory server that records each PUT's `mode`, package + * and first-write pin, as in `ResourceEditPage.liveCreate-7611.test.tsx`: + * + * - outside a package: ONE live PUT (no `mode`, no `package`) with the + * first-write pin, and the editor opens the created position; + * - under a package binding the create stays a package draft (control), the + * ADR-0086 package door's draft semantics. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +const server = { + puts: [] as Array<{ name: string; mode: string | null; packageId: string | null; ifNoneMatch: string | null }>, +}; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +async function serve(input: RequestInfo | URL, init?: RequestInit): Promise { + const method = (init?.method ?? 'GET').toUpperCase(); + const url = new URL(String(input), 'http://console.test'); + const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/); + if (!meta) return json(200, []); + const seg = meta[1].split('/').map(decodeURIComponent); + if (seg[0] === '_drafts') return json(200, { drafts: [] }); + if (seg.length === 1) return json(200, { items: [] }); + if (method === 'PUT') { + server.puts.push({ + name: seg[1]!, + mode: url.searchParams.get('mode'), + packageId: url.searchParams.get('package'), + ifNoneMatch: new Headers(init?.headers).get('If-None-Match'), + }); + return json(200, { success: true, version: 'hmac-sha256:v1', seq: 1, state: 'active', message: 'Saved' }); + } + return json(404, { error: { code: 'NOT_FOUND', message: 'absent' } }); +} + +const client = vi.hoisted(() => ({ current: null as unknown })); + +const POSITION_SCHEMA = { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + label: { type: 'string' }, + description: { type: 'string' }, + }, +}; + +vi.mock('./useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => client.current, + useMetadataTypes: () => ({ + loading: false, + entries: [ + { type: 'position', label: 'Position', domain: 'security', allowEnvironmentOverlay: false, allowRuntimeCreate: true, schema: POSITION_SCHEMA }, + ], + }), + }; +}); + +import { MetadataResourceEditPage } from './ResourceEditPage'; +// The position's registration — its only one — lives in the built-in +// component module, not in `anchors.ts`. +import '../../services/builtinComponents'; + +client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); + +beforeEach(() => { + server.puts = []; + window.localStorage.setItem('metadata-admin:autosave', '0'); +}); +afterEach(() => { + cleanup(); + window.localStorage.removeItem('metadata-admin:autosave'); + window.history.replaceState(null, '', '/'); +}); + +function Where() { + return
{useLocation().pathname}
; +} + +/** Open the create form, name the position `regional_lead`, and press Save. */ +async function createPosition() { + render( + + + + + + + } + /> + } /> + + , + ); + const name = await waitFor(() => { + const el = document.getElementById('mdf-name') as HTMLInputElement | null; + expect(el).not.toBeNull(); + return el!; + }, { timeout: 8000 }); + fireEvent.change(name, { target: { value: 'regional_lead' } }); + await waitFor(() => expect((document.getElementById('mdf-name') as HTMLInputElement).value).toBe('regional_lead')); + fireEvent.click(screen.getByRole('button', { name: /Save \(⌘S\)/ })); +} + +describe('a position is created live from Setup (objectstack#26192)', () => { + it('outside a package: one live PUT with the first-write pin, then the editor opens', async () => { + await createPosition(); + await waitFor(() => expect(screen.getByTestId('where')).toHaveTextContent('/metadata/position/regional_lead'), { timeout: 8000 }); + expect(server.puts).toEqual([{ name: 'regional_lead', mode: null, packageId: null, ifNoneMatch: '*' }]); + }); + + it('under a package binding the create stays a package draft (control)', async () => { + window.history.replaceState(null, '', '/?package=com.acme.crm'); + await createPosition(); + await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 }); + expect(server.puts[0]).toEqual({ name: 'regional_lead', mode: 'draft', packageId: 'com.acme.crm', ifNoneMatch: '*' }); + }); +});