Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/11799-layers-draft-only.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

const server = {
active: new Map<string, Record<string, unknown>>(),
drafts: new Map<string, Record<string, unknown>>(),
requests: [] as Array<{ method: string; path: string; search: string; status: number }>,
puts: [] as Array<{ search: string; body: Record<string, unknown> }>,
/** Rows whose `/layers` read fails with a 5xx. */
failLayers: new Set<string>(),
};

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<Response> {
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<string, unknown>;
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(
<EmbeddedItemEditor
parentType="object"
parentName="sales_order"
embeddedPath="fields"
itemName="amount"
editAs="field"
initialRaw={{ type: 'number', label: 'Amount' }}
/>,
);
}

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -45,6 +51,10 @@ const server = {
active: new Map<string, Record<string, unknown>>(),
drafts: new Map<string, Record<string, unknown>>(),
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<string, unknown> }>,
/** Rows whose `/layers` read fails with a 5xx. */
failLayers: new Set<string>(),
};

const json = (status: number, body: unknown) =>
Expand All @@ -66,10 +76,18 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Resp
body = { items: [{ name: 'a_account' }] };
} else if (seg.length === 1) {
body = { items: [...server.active.entries()].filter(([k]) => 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<string, unknown>;
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);
Expand Down Expand Up @@ -123,6 +141,8 @@ beforeEach(() => {
server.active.clear();
server.drafts.clear();
server.requests = [];
server.puts = [];
server.failLayers.clear();
});
afterEach(cleanup);

Expand Down Expand Up @@ -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([]);
});
});
Loading
Loading