diff --git a/.changeset/11799-layers-draft-only.md b/.changeset/11799-layers-draft-only.md new file mode 100644 index 0000000000..da00b30a56 --- /dev/null +++ b/.changeset/11799-layers-draft-only.md @@ -0,0 +1,4 @@ +--- +--- + +No release: tests only. `@object-ui/app-shell` gains pins for the four `GET /meta/:type/:name/layers` reads objectui#11799 left open: the metadata edit page's first load, the embedded item editor's save read, the permission matrix's package-door save re-read, and the list-view panel's read of a view that exists nowhere yet. None of them can be skipped with what its editor holds. The framework keeps answering 404 for a name with no stored layer, its author included (objectstack-ai/objectstack#22397, answer (a)), and `MetadataClient.layered()` already resolves that 404 as an envelope with every layer null, so none of these reads raises an error for it. The pins hold that in place: a draft-only item opens with no error state, a published item still gets its layers, and a failed read (a 5xx) still shows its error. No executable code changed (objectui#11799). diff --git a/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.layersRead-11799.test.tsx b/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.layersRead-11799.test.tsx new file mode 100644 index 0000000000..52dfe7302b --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.layersRead-11799.test.tsx @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11799 — the embedded item editor's one `GET …/layers` read. + * + * `EmbeddedItemEditor` edits an item that lives inside a parent body + * (`object.fields.amount`). It reads the parent's layered view once, when the + * author saves, to splice the item back into the parent's current body. It + * holds only the item snapshot and the parent's name, so nothing in hand says + * whether the parent was ever published. The framework answers `/layers` with + * a 404 for a parent that exists only as a draft, and keeps that answer by + * design (objectstack-ai/objectstack#22397, answer (a)). + * + * The REAL editor over a REAL `MetadataClient`, whose transport is an + * in-memory server answering as the framework does. Requests are counted on + * it. + * + * Pinned: + * - opening an item of a draft-only parent sends no request at all, so it + * cannot show an error: the read belongs to the save, not to the open; + * - CONTROL: a published parent's save reads its layers once and splices the + * item into the effective body it got; + * - CONTROL: a failed `/layers` read (a 5xx) is the save's error, and nothing + * is PUT. + * + * Not covered, said here so it does not read as covered: what a SAVE does for + * a draft-only parent. `MetadataClient.layered()` resolves the 404 as an + * envelope with every layer null, so the read itself raises nothing; the save + * then splices the item into an empty body and PUTs the parent in publish + * mode. That PUT is outside objectui#11799 (its claim excludes it), and it is + * not pinned here, so this file does not hold that body in place. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +const FIELD_SCHEMA = { + type: 'object', + properties: { + label: { type: 'string', title: 'Label' }, + description: { type: 'string', title: 'Description' }, + }, +} satisfies Record; + +const server = { + active: new Map>(), + drafts: new Map>(), + requests: [] as Array<{ method: string; path: string; search: string; status: number }>, + puts: [] as Array<{ search: string; body: Record }>, + /** Rows whose `/layers` read fails with a 5xx. */ + failLayers: new Set(), +}; + +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 q = url.searchParams; + let status = 200; + let body: unknown = []; + const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/); + if (meta) { + const seg = meta[1].split('/').map(decodeURIComponent); + const key = `${seg[0]}/${seg[1]}`; + if (seg[0] === '_drafts') { + body = { drafts: [...server.drafts.keys()].map((k) => ({ type: k.split('/')[0], name: k.split('/')[1], packageId: null })) }; + } else if (seg.length === 3 && seg[2] === 'layers' && server.failLayers.has(key)) { + [status, body] = [500, { error: { code: 'INTERNAL_ERROR', message: 'metadata store unavailable' } }]; + } else if (seg.length === 3 && seg[2] === 'layers') { + const row = server.active.get(key); + if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row }; + else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }]; + } else if (seg.length === 2 && method === 'PUT') { + const item = JSON.parse(String(init?.body ?? '{}')) as Record; + server.puts.push({ search: url.search, body: item }); + if (q.get('mode') === 'draft') server.drafts.set(key, item); + else server.active.set(key, item); + body = { type: seg[0], name: seg[1], state: q.get('mode') === 'draft' ? 'draft' : 'active' }; + } else if (seg.length === 2) { + const draftRead = q.get('state') === 'draft'; + const row = (draftRead ? server.drafts : server.active).get(key); + if (row) body = { type: seg[0], name: seg[1], item: draftRead ? { ...row, _draft: true } : row }; + else [status, body] = [404, { error: { code: draftRead ? 'NO_DRAFT' : 'NOT_FOUND', message: 'absent' } }]; + } + } + server.requests.push({ method, path: url.pathname, search: url.search, status }); + return json(status, body); +} + +const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); + +vi.mock('./useMetadata', () => ({ + useMetadataClient: () => client, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'field', label: 'Field', allowOrgOverride: true, schema: FIELD_SCHEMA }], + }), +})); + +import { EmbeddedItemEditor } from './EmbeddedItemEditor'; + +const ORDER = { + name: 'sales_order', + label: 'Sales Order', + fields: { amount: { type: 'number', label: 'Amount' }, region: { type: 'text', label: 'Region' } }, +}; + +beforeEach(() => { + server.active.clear(); + server.drafts.clear(); + server.requests = []; + server.puts = []; + server.failLayers.clear(); +}); +afterEach(cleanup); + +async function settle() { + await act(async () => { + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); + }); +} + +function openAmountField() { + render( + , + ); +} + +const saveButton = () => screen.getByRole('button', { name: /save into object/i }); +const layersStatuses = () => + server.requests.filter((r) => r.method === 'GET' && r.path === '/api/v1/meta/object/sales_order/layers').map((r) => r.status); + +describe('EmbeddedItemEditor — the parent\'s /layers read belongs to the save (objectui#11799)', () => { + it('opening an item of a draft-only parent sends no request, and shows no error', async () => { + server.drafts.set('object/sales_order', ORDER); + openAmountField(); + await settle(); + + expect(screen.getByRole('textbox', { name: 'Label' })).toHaveValue('Amount'); + expect(server.requests).toEqual([]); + expect(screen.queryByText(/metadata store unavailable|Validation failed|absent/)).toBeNull(); + }); + + it('CONTROL: a published parent\'s save reads its layers once and splices the item into the body it got', async () => { + server.active.set('object/sales_order', ORDER); + openAmountField(); + fireEvent.change(screen.getByRole('textbox', { name: 'Label' }), { target: { value: 'Order amount' } }); + fireEvent.click(saveButton()); + + expect(await screen.findByText('Saved.')).toBeInTheDocument(); + expect(layersStatuses()).toEqual([200]); + expect(server.puts).toHaveLength(1); + expect(server.puts[0].body).toEqual({ + ...ORDER, + fields: { ...ORDER.fields, amount: { type: 'number', label: 'Order amount' } }, + }); + }); + + it('CONTROL: a failed /layers read (a 5xx) is the save\'s error, and nothing is PUT', async () => { + server.active.set('object/sales_order', ORDER); + server.failLayers.add('object/sales_order'); + openAmountField(); + fireEvent.click(saveButton()); + + expect(await screen.findByText('metadata store unavailable')).toBeInTheDocument(); + await waitFor(() => expect(saveButton()).not.toBeDisabled()); + expect(layersStatuses()).toEqual([500]); + expect(server.puts).toEqual([]); + expect(screen.queryByText('Saved.')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx index d5ea53fd2a..c3de3ec851 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx @@ -25,17 +25,23 @@ * - CONTROL: the published set list is read once, by the editor's own read * of it; the load sends no second one. * - * Not covered, said here so it does not read as covered: the package door's - * SAVE still re-reads `/layers` first, and gets the 404 for such a set. That - * read decides what other packages' rows a save must keep, and the draft in - * hand does not say the set was never published; the list read at open is not - * re-asked there. + * The package door's SAVE still re-reads `/layers` first, and gets the 404 for + * such a set. That read decides what other packages' rows a save must keep + * (objectui#9420: only a fresh read keeps that promise), and the draft in hand + * does not say the set was never published; the list read at open is not + * re-asked there. The framework keeps that 404 by design + * (objectstack-ai/objectstack#22397, answer (a)), and `MetadataClient.layered()` + * resolves it as an envelope with every layer null, so the save reads it as + * "never published": the draft is saved over its own slice, with no refusal + * and no error. Pinned in the second describe below, through the real client, + * with two controls: a published set's re-read keeps the other packages' rows, + * and a failed re-read (a 5xx) still refuses the save. */ import '@testing-library/jest-dom/vitest'; import * as React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, cleanup, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { MetadataClient } from '@object-ui/data-objectstack'; @@ -45,6 +51,10 @@ const server = { active: new Map>(), drafts: new Map>(), requests: [] as Array<{ method: string; path: string; search: string; status: number }>, + /** Bodies the editor PUT, with their query strings. */ + puts: [] as Array<{ search: string; body: Record }>, + /** Rows whose `/layers` read fails with a 5xx. */ + failLayers: new Set(), }; const json = (status: number, body: unknown) => @@ -66,10 +76,18 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise k.startsWith(`${seg[0]}/`)).map(([, v]) => v) }; + } else if (seg.length === 3 && seg[2] === 'layers' && server.failLayers.has(key)) { + [status, body] = [500, { error: { code: 'INTERNAL_ERROR', message: 'metadata store unavailable' } }]; } else if (seg.length === 3 && seg[2] === 'layers') { const row = server.active.get(key); if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row }; else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }]; + } else if (seg.length === 2 && method === 'PUT') { + const item = JSON.parse(String(init?.body ?? '{}')) as Record; + server.puts.push({ search: url.search, body: item }); + if (q.get('mode') === 'draft') server.drafts.set(key, item); + else server.active.set(key, item); + body = { type: seg[0], name: seg[1], state: q.get('mode') === 'draft' ? 'draft' : 'active' }; } else if (seg.length === 2) { const draftRead = q.get('state') === 'draft'; const row = (draftRead ? server.drafts : server.active).get(key); @@ -123,6 +141,8 @@ beforeEach(() => { server.active.clear(); server.drafts.clear(); server.requests = []; + server.puts = []; + server.failLayers.clear(); }); afterEach(cleanup); @@ -158,3 +178,63 @@ describe('PermissionMatrixEditPage — a never-published set is not asked for /l expect(granted('Create')).toHaveAttribute('aria-checked', 'true'); }); }); + +const layersStatuses = () => + server.requests.filter((r) => r.method === 'GET' && r.path === '/api/v1/meta/permission/sales_perms/layers').map((r) => r.status); + +/** Distinctive slice of the package door's refusal when its re-read fails (`perm.save.rereadFailed`). */ +const REFUSAL = /could not be re-read/; + +/** + * The package door has no Save button: an edit autosaves to the package draft + * after the shared autosave's pause (objectui#11787), so a wait on a save + * allows for that pause. + */ +const AUTOSAVE = { timeout: 4000 }; + +describe('PermissionMatrixEditPage — the package door\'s SAVE reads a /layers 404 as "never published" (objectui#11799)', () => { + it('a set "+ New" created: the save\'s one /layers answers 404, and the draft is saved over its own slice, with no refusal', async () => { + server.drafts.set('permission/sales_perms', set('Sales (draft)', true)); + await openSet(); + expect(layersStatuses()).toEqual([]); + + fireEvent.click(granted('Create')); + await waitFor(() => expect(server.puts).toHaveLength(1), AUTOSAVE); + + expect(layersStatuses()).toEqual([404]); + expect(server.puts[0].search).toBe(`?mode=draft&package=${PKG}`); + expect(server.puts[0].body.objects).toEqual({ a_account: { allowRead: true, allowCreate: true } }); + expect(screen.queryByText(REFUSAL)).toBeNull(); + }); + + it('CONTROL: a published set\'s save re-reads its layers and keeps the rows another package contributed', async () => { + server.active.set('permission/sales_perms', { + ...set('Sales', true), + objects: { a_account: { allowRead: true, allowCreate: false }, b_order: { allowRead: true } }, + }); + await openSet(); + expect(layersStatuses()).toEqual([200]); + + fireEvent.click(granted('Create')); + await waitFor(() => expect(server.puts).toHaveLength(1), AUTOSAVE); + + expect(layersStatuses()).toEqual([200, 200]); + expect(server.puts[0].body.objects).toEqual({ + a_account: { allowRead: true, allowCreate: true }, + b_order: { allowRead: true }, + }); + expect(screen.queryByText(REFUSAL)).toBeNull(); + }); + + it('CONTROL: a failed re-read (a 5xx) still refuses the save, for a "+ New" set too', async () => { + server.drafts.set('permission/sales_perms', set('Sales (draft)', true)); + server.failLayers.add('permission/sales_perms'); + await openSet(); + + fireEvent.click(granted('Create')); + expect(await screen.findByText(REFUSAL, undefined, AUTOSAVE)).toBeInTheDocument(); + + expect(layersStatuses()).toEqual([500]); + expect(server.puts).toEqual([]); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.layersUnsaved-11799.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.layersUnsaved-11799.test.tsx index 7768188d5c..f00b021cec 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.layersUnsaved-11799.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.layersUnsaved-11799.test.tsx @@ -18,11 +18,16 @@ * for `?state=draft` with no draft. Requests are counted on it. The `doc` * type's editor stands in for every type (the flow is the page's own). * - * Not covered, said here so it does not read as covered: the page's LOAD of a - * never-published item still asks `/layers` and gets its 404. Its answer feeds - * the lock banner, the overlay diff and the reset verdict even with a draft, - * and nothing the page holds before that read says the item was never saved - * (the draft read only says a draft is pending). + * The page's LOAD of a never-published item still asks `/layers` and gets its + * 404: nothing the page holds before that read says the item was never saved + * (the draft read, sent beside it, only says a draft is pending). The framework + * keeps that 404 for every caller by design (objectstack-ai/objectstack#22397, + * answer (a)), so the page reads it as "never published": `MetadataClient.layered()` + * resolves the 404 as an envelope with every layer null, and the lock banner, + * the Layers sheet and the reset verdict show their no-published-version form, + * with no error. Pinned in the second describe below, with two controls: a + * published item still gets its layers, and a failed `/layers` read (a 5xx) + * still shows the load error. */ import '@testing-library/jest-dom/vitest'; @@ -42,6 +47,10 @@ const server = { active: new Map>(), drafts: new Map>(), requests: [] as Array<{ method: string; path: string; search: string; status: number }>, + /** Protection fields a stored row's `/layers` answer carries beyond the defaults. */ + layerExtras: new Map>(), + /** Rows whose `/layers` read fails with a 5xx. */ + failLayers: new Set(), }; const json = (status: number, body: unknown) => @@ -61,9 +70,11 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise ({ type: k.split('/')[0], name: k.split('/')[1], packageId: null })) }; } else if (seg.length === 1) { body = { items: seg[0] === 'book' ? [BOOK] : [] }; + } else if (seg.length === 3 && seg[2] === 'layers' && server.failLayers.has(key)) { + [status, body] = [500, { error: { code: 'INTERNAL_ERROR', message: 'metadata store unavailable' } }]; } else if (seg.length === 3 && seg[2] === 'layers') { const row = server.active.get(key); - if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row, editable: true, deletable: true, resettable: false, lock: 'none' }; + if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row, editable: true, deletable: true, resettable: false, lock: 'none', ...server.layerExtras.get(key) }; else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }]; } else if (seg.length === 3 && seg[2] === 'references') { body = []; @@ -128,6 +139,8 @@ beforeEach(() => { server.active.clear(); server.drafts.clear(); server.requests = []; + server.layerExtras.clear(); + server.failLayers.clear(); window.localStorage.setItem('metadata-admin:autosave', '0'); Object.defineProperty(window, 'confirm', { configurable: true, writable: true, value: vi.fn(() => true) }); }); @@ -266,3 +279,67 @@ describe('MetadataResourceEditPage — no /layers re-read for an item known to h expect(await source()).toHaveValue('# Live'); }); }); + +/** The item's `/layers` reads, in order, as the server answered them. */ +const layersStatuses = (name: string) => + server.requests.filter((r) => r.method === 'GET' && r.path === `/api/v1/meta/doc/${name}/layers`).map((r) => r.status); + +/** Opens the Layers sheet and returns the Overlay tab, whose badge names the overlay layer. */ +async function overlayTab() { + fireEvent.click(screen.getByTitle('Layers')); + return screen.findByRole('tab', { name: /Overlay/ }); +} + +describe('MetadataResourceEditPage — the LOAD reads a /layers 404 as "never published", with no error (objectui#11799)', () => { + it('a draft-only item opens on its draft: one /layers, its 404, and no error, lock banner or reset/delete control', async () => { + server.drafts.set('doc/fresh_guide', doc('fresh_guide', '# Draft')); + renderEdit('fresh_guide'); + expect(await source()).toHaveValue('# Draft'); + await settle(); + + expect(layersStatuses('fresh_guide')).toEqual([404]); + expect(screen.queryByText(/Failed to load/)).toBeNull(); + // The draft is pending, and nothing says it is published, locked or resettable. + expect(screen.getByText(/Pending changes/)).toBeInTheDocument(); + expect(screen.queryByTestId('lock-banner-title')).toBeNull(); + expect(screen.queryByTestId('reset-or-delete-button')).toBeNull(); + // The Layers sheet: no overlay layer, and no code layer to diff against. + expect(await overlayTab()).toHaveTextContent('none'); + expect(screen.getByText(/No code-level artifact to compare against/)).toBeInTheDocument(); + }); + + it('CONTROL: a published item gets its layers, and the page uses them: the delete control and the overlay layer', async () => { + server.active.set('doc/live_guide', doc('live_guide', '# Live')); + renderEdit('live_guide'); + expect(await source()).toHaveValue('# Live'); + await settle(); + + expect(layersStatuses('live_guide')).toEqual([200]); + expect(screen.queryByText(/Failed to load/)).toBeNull(); + expect(screen.getByTestId('reset-or-delete-button')).toBeInTheDocument(); + expect(await overlayTab()).toHaveTextContent('env'); + }); + + it('CONTROL: a published item locked by its layers shows the lock banner from that answer', async () => { + server.active.set('doc/live_guide', doc('live_guide', '# Live')); + server.layerExtras.set('doc/live_guide', { lock: 'no-delete', deletable: false, lockReason: 'Shipped with the manual' }); + renderEdit('live_guide'); + expect(await source()).toHaveValue('# Live'); + await settle(); + + expect(layersStatuses('live_guide')).toEqual([200]); + expect(screen.getByTestId('lock-banner-title')).toHaveTextContent('This item is locked and cannot be deleted.'); + expect(screen.getByText('Shipped with the manual')).toBeInTheDocument(); + expect(screen.queryByTestId('reset-or-delete-button')).toBeNull(); + }); + + it('CONTROL: a failed /layers read (a 5xx) still shows the load error, even with a draft in hand', async () => { + server.drafts.set('doc/fresh_guide', doc('fresh_guide', '# Draft')); + server.failLayers.add('doc/fresh_guide'); + renderEdit('fresh_guide'); + + expect(await screen.findByText('Failed to load doc/fresh_guide: metadata store unavailable', undefined, { timeout: 8000 })).toBeInTheDocument(); + expect(layersStatuses('fresh_guide')).toEqual([500]); + expect(screen.queryByText(/Pending changes/)).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/ObjectListViewInspector.layersUnsaved-11799.test.tsx b/packages/app-shell/src/views/studio-design/ObjectListViewInspector.layersUnsaved-11799.test.tsx index 1da98b1d63..5a5e4bedb3 100644 --- a/packages/app-shell/src/views/studio-design/ObjectListViewInspector.layersUnsaved-11799.test.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectListViewInspector.layersUnsaved-11799.test.tsx @@ -21,13 +21,17 @@ * - CONTROL: a published view with no draft sends exactly one `/layers`, and * the buffer holds its published layer, as before. * - * Not covered, said here so it does not read as covered: a view that exists - * nowhere yet (no draft, no layer: the default list view of an object that has - * none) still asks `/layers` and gets its 404. Nothing the hook holds says the - * view was never saved: its draft read says only whether a draft is pending, - * and the object's `listViews` from the metadata cache is not read as that - * answer here (the 11823 pillar suite serves a default view its object - * definition does not list). + * A view that exists nowhere yet (no draft, no layer: the default list view of + * an object that has none) still asks `/layers` and gets its 404. Nothing the + * hook holds says the view was never saved: its draft read says only whether a + * draft is pending, and the object's `listViews` from the metadata cache is not + * read as that answer here (the 11823 pillar suite serves a default view its + * object definition does not list). The framework keeps that 404 by design + * (objectstack-ai/objectstack#22397, answer (a)), and `MetadataClient.layered()` + * resolves it as an envelope with every layer null, so the buffer reads it as + * "not created yet": no row, no failure, and the canvas shows the view the first + * edit will create. Pinned in the second describe below, with a control: a + * failed `/layers` read (a 5xx) is still the buffer's load failure. */ import '@testing-library/jest-dom/vitest'; @@ -64,6 +68,8 @@ const server = { active: new Map>(), drafts: new Map>(), requests: [] as Array<{ method: string; path: string; search: string; status: number }>, + /** Rows whose `/layers` read fails with a 5xx. */ + failLayers: new Set(), }; const json = (status: number, body: unknown) => @@ -80,6 +86,8 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise ({ type: k.split('/')[0], name: k.split('/')[1], packageId: PKG })) }; + } else if (seg.length === 3 && seg[2] === 'layers' && server.failLayers.has(`${seg[0]}/${seg[1]}`)) { + [status, body] = [500, { error: { code: 'INTERNAL_ERROR', message: 'metadata store unavailable' } }]; } else if (seg.length === 3 && seg[2] === 'layers') { const row = server.active.get(`${seg[0]}/${seg[1]}`); if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row }; @@ -149,6 +157,7 @@ beforeEach(() => { server.active.clear(); server.drafts.clear(); server.requests = []; + server.failLayers.clear(); }); afterEach(cleanup); @@ -176,3 +185,61 @@ describe('useObjectListViewDraft — an unpublished view is not asked for /layer expect(probe()).toEqual({ loadedFor: `listView:${VIEW}`, row: viewItem(['title']), hasDraft: false, failure: null }); }); }); + +/** What the buffer holds, plus the columns the canvas shows for it. */ +function CanvasProbe() { + const d = useObjectListViewDraft({ client, packageId: PKG, leaf: LEAF, publishNonce: 0 }); + const view = d.canvas?.view as { columns?: unknown } | undefined; + return ( +
+      {JSON.stringify({
+        loadedFor: d.loadedFor,
+        row: d.row,
+        hasDraft: d.hasDraft,
+        failure: d.failure ? { during: d.failure.during, error: String(d.failure.error) } : null,
+        canvasColumns: view?.columns ?? null,
+      })}
+    
+ ); +} + +async function openLeafWithCanvas() { + render( + + + , + ); + await act(async () => { + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); + }); + return JSON.parse(screen.getByTestId('canvas-probe').textContent ?? '{}') as Record; +} + +const layersStatuses = () => + server.requests.filter((r) => r.method === 'GET' && r.path === `/api/v1/meta/view/${VIEW}/layers`).map((r) => r.status); + +describe('useObjectListViewDraft — a view that exists nowhere reads its /layers 404 as "not created yet" (objectui#11799)', () => { + it('no draft and no layer: one /layers, its 404, no failure, and the canvas shows the view the first edit creates', async () => { + const seen = await openLeafWithCanvas(); + + expect(layersStatuses()).toEqual([404]); + expect(seen).toEqual({ + loadedFor: `listView:${VIEW}`, + row: null, + hasDraft: false, + failure: null, + // `defaultListColumnsFromObject` over the object's two fields. + canvasColumns: ['title', 'status'], + }); + }); + + it('CONTROL: a failed /layers read (a 5xx) is still the buffer\'s load failure', async () => { + server.failLayers.add(`view/${VIEW}`); + const seen = await openLeafWithCanvas(); + + expect(layersStatuses()).toEqual([500]); + expect(seen.loadedFor).toBe(''); + expect(seen.canvasColumns).toBeNull(); + expect(seen.failure).toEqual({ during: 'load', error: expect.stringContaining('metadata store unavailable') }); + }); +});