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
12 changes: 12 additions & 0 deletions .changeset/11785-author-refusal-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/app-shell': patch
'@object-ui/data-objectstack': patch
---

Studio shows a refused save as a sentence the author can act on, with the technical detail behind a "Details" disclosure (objectui#11785).

- **Studio's save strips.** The Data, Automations and Interfaces pages used to print the refusal verbatim: the write guard's developer prose, or the server's raw issue paths such as `nodes.2.config.title — …`. Each strip now shows one sentence that names the input the refusal is about, in the terms the editor shows. On the Data page that is the field's label. On the Automations page it is the step's label and the inspector input, so a refusal at `nodes.2.config.title` reads "Check Title on the step “Notify approver” — …". On the Interfaces page it is the navigation item and its input. A "Show me" button opens that field, step or navigation item. The raw text stays under a closed "Details" disclosure, unchanged. A path Studio cannot place on the open page keeps its line there, and the sentence says the draft was refused.
- **A Picklist with no options.** Switching a field to Picklist before it has an option now says "Changes not saved: the field “Status” needs at least one option…", instead of the guard's message.
- **The object write guard's message names no code.** `assertObjectMetadataWritable` no longer prints the door's class name, a package name or tracker ids. Its message is the same at every door, so the other surfaces that show it as their banner read the field, its type and the remedy. Its signature, error type and exports are unchanged.

`formatMetadataError` is unchanged, so every other surface that calls it prints what it printed before. Nothing is added to either package entry: no export, prop, type member or language-pack key. The new sentences are rows in the metadata-admin designer's own string tables (en and zh).
31 changes: 31 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2742,6 +2742,23 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.publish': 'Publish',
'engine.studio.loading': 'Loading…',
'engine.studio.loadFailed': 'Failed to load',
// objectui#11785 — a refused save, in the author's words. The strip shows one
// of these sentences; the raw refusal (field paths, codes) stays under
// `engine.studio.refusal.details`, and `show` opens the input it names.
'engine.studio.refusal.choiceWithoutOptions':
'Changes not saved: the field “{field}” needs at least one option. Add an option, or change the field to a type that is not a choice.',
'engine.studio.refusal.relationshipWithoutTarget':
'Changes not saved: the field “{field}” does not say which object it links to. Pick the object, or change the field to a type that is not a relationship.',
'engine.studio.refusal.issue': 'Changes not saved. Check {where} — {problem}',
'engine.studio.refusal.more': '({count} more under Details)',
'engine.studio.refusal.unlocated': 'Changes not saved: the server refused this draft. Details say what to change.',
'engine.studio.refusal.field': 'the field “{field}”',
'engine.studio.refusal.step': 'the step “{step}”',
'engine.studio.refusal.stepInput': '{input} on the step “{step}”',
'engine.studio.refusal.navItem': 'the navigation item “{item}”',
'engine.studio.refusal.navItemInput': '{input} on the navigation item “{item}”',
'engine.studio.refusal.details': 'Details',
'engine.studio.refusal.show': 'Show me',
'engine.studio.unpublishedDraft': 'Unpublished draft',
'engine.studio.unpublished': 'Unpublished',
'engine.studio.new': 'New',
Expand Down Expand Up @@ -5684,6 +5701,20 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.publish': '发布',
'engine.studio.loading': '加载中…',
'engine.studio.loadFailed': '加载失败',
'engine.studio.refusal.choiceWithoutOptions':
'更改未保存:字段「{field}」至少需要一个选项。请添加选项,或将该字段改为非选项类型。',
'engine.studio.refusal.relationshipWithoutTarget':
'更改未保存:字段「{field}」未指定要关联的对象。请选择对象,或将该字段改为非关联类型。',
'engine.studio.refusal.issue': '更改未保存。请检查{where} —— {problem}',
'engine.studio.refusal.more': '(另有 {count} 处,见详情)',
'engine.studio.refusal.unlocated': '更改未保存:服务端拒绝了此草稿。详情中说明了需要修改的内容。',
'engine.studio.refusal.field': '字段「{field}」',
'engine.studio.refusal.step': '步骤「{step}」',
'engine.studio.refusal.stepInput': '步骤「{step}」的「{input}」',
'engine.studio.refusal.navItem': '导航项「{item}」',
'engine.studio.refusal.navItemInput': '导航项「{item}」的「{input}」',
'engine.studio.refusal.details': '详情',
'engine.studio.refusal.show': '定位',
'engine.studio.unpublishedDraft': '未发布草稿',
'engine.studio.unpublished': '未发布',
'engine.studio.new': '新建',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11785 — on the Studio Automations page, a server refusal names the
* step and the input it is about, instead of printing a raw issue path.
*
* The card's measured case: a 422 on `nodes.2.config.title` printed
* "nodes.2.config.title — …" as the banner. The strip now says which step
* (node 2, by its label) and which input (its Title, by the inspector's own
* label), offers "Show me" to open that step, and keeps the raw line under a
* closed "Details" disclosure. A path it cannot place stays under Details.
*
* The canvas and inspector are the real registered `FlowPreview` and
* `FlowInspector`; the client is a server double whose `save` refuses, driven
* through the header switch, which saves at once (as in
* `StudioDesignSurface.automationsReadOnly-11124.test.tsx`).
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const PKG = 'com.acme.app';

const FLOW = {
name: 'approval',
label: 'Approval',
type: 'autolaunched',
status: 'active',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'route', type: 'decision', label: 'Route' },
{ id: 'tell', type: 'notify', label: 'Notify approver', config: { recipients: ['owner'] } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'route' },
{ id: 'e2', source: 'route', target: 'tell' },
{ id: 'e3', source: 'tell', target: 'end' },
],
};

const MISSING = 'Invalid input: expected string, received undefined';

const server = vi.hoisted(() => ({
/** The issues the next draft save is refused with. */
issues: [] as Array<{ path: string; message: string }>,
}));

const mockClient = vi.hoisted(() => ({
list: vi.fn(async () => [{ name: 'approval', label: 'Approval' }]),
listDrafts: vi.fn(async () => []),
listTypes: vi.fn(async () => ({ entries: [] })),
get: vi.fn(async () => null),
references: vi.fn(async () => []),
layered: vi.fn(async () => ({
code: null,
overlay: null,
overlayScope: null,
effective: JSON.parse(JSON.stringify(FLOW)),
editable: true,
deletable: true,
resettable: false,
lock: 'none',
})),
getDraft: vi.fn(async (type: string, name: string) => {
throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 });
}),
save: vi.fn(async () => {
// The client's parsed `MetadataError`: a headline, a status, a code and
// the structured issues the server returned.
throw Object.assign(new Error(`flow/approval failed spec validation (${server.issues.length})`), {
status: 422,
code: 'INVALID_METADATA',
issues: server.issues,
});
}),
publish: vi.fn(async () => ({ success: true })),
reset: vi.fn(async () => ({})),
}));

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => dataSource };
});

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

import { AutomationsPillar } from './StudioDesignSurface';
import { createEmptyDataSource } from './__tests__/emptyDataSource';
import { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { registerMetadataInspector } from '../metadata-admin/inspector-registry';
import { FlowPreview } from '../metadata-admin/previews/FlowPreview';
import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector';
import { t, tFormat } from '../metadata-admin/i18n';

const dataSource = createEmptyDataSource();

// The pillar's `/automation/_status` probe and the inspector's catalog reads go
// through the global `fetch`; "absent" keeps each on its documented fallback.
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
);

registerMetadataPreview('flow', FlowPreview);
registerMetadataInspector('flow', FlowInspector);

beforeEach(() => {
server.issues = [];
for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear();
});

afterEach(cleanup);

/** Open the flow, then flip the header switch: a save that the server refuses. */
async function refusedSave(issues: Array<{ path: string; message: string }>): Promise<HTMLElement> {
server.issues = issues;
render(
<MemoryRouter initialEntries={[`/studio/${PKG}/automations`]}>
<AutomationsPillar packageId={PKG} />
</MemoryRouter>,
);
const toggle = await screen.findByRole('switch', undefined, { timeout: 8000 });
await waitFor(() => expect(toggle).not.toBeDisabled());
fireEvent.click(toggle);
await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1));
return screen.findByTestId('studio-refusal');
}

describe('Automations page — a server refusal names the step and its input (objectui#11785)', () => {
it('`nodes.2.config.title` names node 2\'s Title, with the raw path under Details', async () => {
const strip = await refusedSave([{ path: 'nodes.2.config.title', message: MISSING }]);

const message = within(strip).getByTestId('studio-refusal-message');
expect(message).toHaveTextContent(
tFormat('engine.studio.refusal.issue', 'en', {
where: tFormat('engine.studio.refusal.stepInput', 'en', { input: 'Title', step: 'Notify approver' }),
problem: t('engine.validation.expectedStringUndefined', 'en'),
}),
);
expect(message.textContent).not.toMatch(/nodes\.\d|config\.title/);

const details = within(strip).getByTestId('studio-refusal-detail') as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(details.querySelector('pre')).toHaveTextContent(`• nodes.2.config.title — ${MISSING}`);
});

it('"Show me" opens that step in the inspector', async () => {
const strip = await refusedSave([{ path: 'nodes.2.config.title', message: MISSING }]);
fireEvent.click(within(strip).getByRole('button', { name: t('engine.studio.refusal.show', 'en') }));

const rail = screen.getByRole('complementary');
const label = (await within(rail).findByLabelText('Label', undefined, { timeout: 8000 })) as HTMLInputElement;
expect(label.value).toBe('Notify approver');
expect(within(rail).getByLabelText('ID')).toHaveValue('tell');
});

it('a path it cannot place stays reachable under Details', async () => {
const strip = await refusedSave([{ path: 'variables.0.name', message: 'Invalid input' }]);

expect(within(strip).getByTestId('studio-refusal-message')).toHaveTextContent(
t('engine.studio.refusal.unlocated', 'en'),
);
expect(within(strip).queryByRole('button', { name: t('engine.studio.refusal.show', 'en') })).toBeNull();
expect(within(strip).getByTestId('studio-refusal-detail').querySelector('pre')).toHaveTextContent(
'• variables.0.name — Invalid input',
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11785 — on the Studio data page, a save the write guard holds shows
* the author a sentence, not the guard's developer text.
*
* The card's measured case: add a field, switch its type to Picklist, and the
* draft autosave is held because a choice field has no options. The strip used
* to print the guard's message verbatim (a class name and tracker ids). It now
* shows a sentence naming the field by its label, a "Show me" button that
* opens that field, and the raw text inside a closed "Details" disclosure.
*
* The client is a real `MetadataClient`, so the door that runs the guard is the
* production one; only the transport under it is a double, as in
* `DataPillar.choiceWithoutOptions-11253.test.tsx`.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, cleanup, act, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { MetadataClient } from '@object-ui/data-objectstack';

const objectDef = {
name: 'showcase_task',
label: 'Task',
fields: [{ name: 'title', label: 'Title', type: 'text' }],
};

const puts: unknown[] = [];

const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
puts.push(JSON.parse(String(init.body)));
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response('null', { status: 404, headers: { 'content-type': 'application/json' } });
});

const client = new MetadataClient({ baseUrl: 'http://test.local', fetch: fetchImpl as unknown as typeof fetch });
Object.assign(client, {
list: vi.fn(async () => [{ name: 'showcase_task', label: 'Task' }]),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async () => ({ effective: objectDef, code: objectDef })),
getDraft: vi.fn(async () => null),
});

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => client, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => dataSource };
});

import { DataPillar } from './StudioDesignSurface';
import { createEmptyDataSource } from './__tests__/emptyDataSource';
import { registerBuiltinInspectors } from '../metadata-admin/inspectors';
import { t, tFormat } from '../metadata-admin/i18n';

const dataSource = createEmptyDataSource();
registerBuiltinInspectors();

afterEach(() => {
cleanup();
puts.length = 0;
fetchImpl.mockClear();
});

/** The inspector control under a visible label. */
function controlUnder(label: string): HTMLElement {
const lab = screen.getByText(label, { selector: 'label' });
return lab.parentElement!.querySelector('input, [role="combobox"]') as HTMLElement;
}

describe('Studio data page — a held Picklist reads as a sentence (objectui#11785)', () => {
it('names the field by its label, shows no developer text, and keeps the raw text under Details', async () => {
render(
<MemoryRouter initialEntries={['/studio/com.example.showcase/data']}>
<DataPillar packageId="com.example.showcase" />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: 'Form' }));
fireEvent.click(await screen.findByTitle(/^Add a field/));
await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 });
const apiName = (controlUnder('API name') as HTMLInputElement).value;
const label = (controlUnder('Label') as HTMLInputElement).value;
expect(apiName, 'the new field has no API name: the harness is dead').not.toBe('');
expect(label, 'the new field has no label: the harness is dead').not.toBe('');
await userEvent.click(controlUnder('Type'));
await userEvent.click(await screen.findByRole('option', { name: /· Picklist$/ }));

// Past the autosave's 1.5 s debounce: the guard holds the save.
await act(async () => {
await new Promise((r) => setTimeout(r, 2300));
});
expect(puts, 'the guard must hold this save').toEqual([]);

const strip = await screen.findByTestId('studio-refusal');
const message = within(strip).getByTestId('studio-refusal-message');
expect(message).toHaveTextContent(
tFormat('engine.studio.refusal.choiceWithoutOptions', 'en', { field: label }),
);
expect(message.textContent).not.toMatch(/MetadataClient|objectstack#|objectui#|`/);
// The API name is the guard's word for the field; the author reads its label.
expect(label).not.toBe(apiName);
expect(message.textContent).not.toContain(apiName);

// The raw text is still there, closed until the author asks for it.
const details = within(strip).getByTestId('studio-refusal-detail') as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(within(details).getByText(t('engine.studio.refusal.details', 'en'))).toBeVisible();
const raw = details.querySelector('pre')!;
expect(raw).not.toBeVisible();
expect(raw).toHaveTextContent(new RegExp(`\`${apiName}\` is a \`select\` with no options`));
fireEvent.click(within(details).getByText(t('engine.studio.refusal.details', 'en')));
expect(details.open).toBe(true);
});

it('"Show me" opens the named field\'s inspector after it was closed', async () => {
render(
<MemoryRouter initialEntries={['/studio/com.example.showcase/data']}>
<DataPillar packageId="com.example.showcase" />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: 'Form' }));
fireEvent.click(await screen.findByTitle(/^Add a field/));
await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 });
const apiName = (controlUnder('API name') as HTMLInputElement).value;
await userEvent.click(controlUnder('Type'));
await userEvent.click(await screen.findByRole('option', { name: /· Picklist$/ }));
await act(async () => {
await new Promise((r) => setTimeout(r, 2300));
});
const strip = await screen.findByTestId('studio-refusal');

// The rail's own header close (the field inspector inside carries another).
const railHeader = screen.getByText(t('engine.studio.data.fieldProps', 'en')).closest('header') as HTMLElement;
fireEvent.click(within(railHeader).getByRole('button', { name: t('engine.studio.close', 'en') }));
expect(screen.queryByText('API name', { selector: 'label' })).toBeNull();

fireEvent.click(within(strip).getByRole('button', { name: t('engine.studio.refusal.show', 'en') }));
expect(await screen.findByText('API name', { selector: 'label' })).toBeInTheDocument();
expect((controlUnder('API name') as HTMLInputElement).value).toBe(apiName);
});
});
Loading
Loading