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
11 changes: 11 additions & 0 deletions .changeset/12690-discard-draft-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): "Discard draft" on the metadata editor names the item by its label, not its machine id (objectui#12690)

Clause-②: no

The confirm before a discard and the success toast after it used to name the item as `type/name` (`Draft discarded: object/uxscratch_widget`), even when the item has a label. Both now name the label that the item's loaded body carries (`Draft discarded: QA Widget`), in English and in Chinese. An item with no label still reads `type/name`, and the discard request is unchanged.

The two strings live in the metadata editor's own dictionary, which the package entry does not export. Each now takes one `{item}` placeholder where it took `{type}/{name}`. The runtime draft bar (the view and report config panels) shares the confirm string. It holds no label, so it passes `type/name` as `{item}` and its text is the same as before. No prop, export or language-pack key changes.
17 changes: 17 additions & 0 deletions packages/app-shell/src/views/RuntimeDraftBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,23 @@ describe('RuntimeDraftBar (ADR-0034)', () => {
await waitFor(() => expect(screen.getByTestId('runtime-draft-bar')).toBeTruthy());
});

// objectui#12690 — the confirm string this bar shares with the metadata edit
// page now takes one `{item}`; the bar holds no label, so it keeps naming
// the machine `type/name`, with no placeholder left unfilled.
it('the discard confirm names type/name, with every placeholder filled', async () => {
const confirmSpy = vi.fn(() => false);
vi.stubGlobal('confirm', confirmSpy);
const metadataClient = makeMetadataClient({ item: { columns: ['name'] } });

render(<RuntimeDraftBar type="view" name="my_view" metadataClient={metadataClient} />);

fireEvent.click(await screen.findByTestId('runtime-draft-discard'));

expect(confirmSpy).toHaveBeenCalledWith('Discard the pending draft for view/my_view?');
expect(metadataClient.reset).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});

it('stays inert until a name is known', async () => {
const metadataClient = makeMetadataClient({ item: { a: 1 } });

Expand Down
4 changes: 3 additions & 1 deletion packages/app-shell/src/views/RuntimeDraftBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ export function RuntimeDraftBar({
if (!name) return;
if (
typeof confirm === 'function' &&
!confirm(tFormat('engine.edit.discardDraftConfirm', locale, { type, name }))
// objectui#12690 — the shared confirm takes one `{item}`; this bar holds
// no label, so it names the machine `type/name` as it always has.
!confirm(tFormat('engine.edit.discardDraftConfirm', locale, { item: `${type}/${name}` }))
) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#12690 — "Discard draft" on the metadata edit page names the item by
* its label, not by its machine id.
*
* Measured on the card: discarding the draft of an object labelled "QA Widget"
* asked `…for object/uxscratch_widget?` and then toasted
* `Draft discarded: object/uxscratch_widget`. The confirm and the toast now
* name the label the item's loaded body carries, in both catalogs the editor
* ships; an item that carries no label still reads `type/name`, and the discard
* request itself is unchanged.
*
* The REAL page over a REAL `MetadataClient`, whose transport is an in-memory
* server answering as the framework does (the double
* `ResourceEditPage.discardFeedback-12303.test.tsx` uses). Only `sonner`,
* `window.confirm` and the editor's locale hook are replaced, so the two
* strings the page hands them are what is read.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { z } from 'zod';
import { ComponentRegistry } from '@object-ui/core';
import { DocSchema } from '@objectstack/spec/system';
import { MetadataClient } from '@object-ui/data-objectstack';

const toasts = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
vi.mock('sonner', () => ({ toast: toasts }));

const locale = vi.hoisted(() => ({ current: 'en-US' as 'en-US' | 'zh-CN' }));
vi.mock('./i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadataLocale: () => locale.current,
}));

const server = {
active: new Map<string, Record<string, unknown>>(),
drafts: new Map<string, Record<string, unknown>>(),
/** Every DELETE the page sent, as path + query. */
deletes: [] as 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;
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
if (!meta) return json(200, []);
const seg = meta[1].split('/').map(decodeURIComponent);
const key = `${seg[0]}/${seg[1]}`;
if (seg[0] === '_drafts') {
return json(200, { drafts: [...server.drafts.keys()].map((k) => ({ type: k.split('/')[0], name: k.split('/')[1], packageId: null })) });
}
if (seg.length === 1) return json(200, { items: [] });
if (seg.length === 3 && seg[2] === 'layers') {
const row = server.active.get(key);
return row
? json(200, { code: null, overlay: row, overlayScope: 'env', effective: row, editable: true, deletable: true, resettable: false, lock: 'none' })
: json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
}
if (seg.length === 3 && seg[2] === 'references') return json(200, []);
if (seg.length === 2 && method === 'DELETE') {
server.deletes.push(`${url.pathname}${url.search}`);
if (q.get('state') === 'draft') server.drafts.delete(key);
else server.active.delete(key);
return json(200, {});
}
if (seg.length === 2) {
const draftRead = q.get('state') === 'draft';
const row = (draftRead ? server.drafts : server.active).get(key);
return row
? json(200, { type: seg[0], name: seg[1], item: draftRead ? { ...row, _draft: true } : row })
: json(404, { error: { code: draftRead ? 'NO_DRAFT' : 'NOT_FOUND', message: 'absent' } });
}
return json(200, {});
}

const client = vi.hoisted(() => ({ current: null as unknown }));

vi.mock('./useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('./useMetadata')>();
return {
...mod,
useMetadataClient: () => client.current,
useMetadataTypes: () => ({
loading: false,
entries: [
{ type: 'doc', label: 'Documentation', domain: 'system', allowEnvironmentOverlay: false, allowRuntimeCreate: true, schema: DOC_JSON_SCHEMA },
],
}),
};
});

import { MetadataResourceEditPage } from './ResourceEditPage';
import { registerDefaultMetadataSchemas } from './default-schemas';
import { registerBuiltinPreviews } from './previews';
import { tFormat } from './i18n';

const DOC_JSON_SCHEMA = z.toJSONSchema(DocSchema as unknown as z.ZodType) as Record<string, unknown>;
client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });

registerDefaultMetadataSchemas();
registerBuiltinPreviews();

const STUB_NS = 'discard-draft-label-12690';
function StubMarkdown({ schema }: { schema: { content?: string } }) {
return <div data-testid="markdown-render">{schema.content}</div>;
}
beforeAll(() => {
ComponentRegistry.register('markdown', StubMarkdown as never, { namespace: STUB_NS });
});
afterAll(() => {
ComponentRegistry.unregister('markdown', STUB_NS);
});

const confirmSpy = vi.fn((_message?: string) => true);
beforeEach(() => {
server.active.clear();
server.drafts.clear();
server.deletes = [];
locale.current = 'en-US';
for (const fn of Object.values(toasts)) fn.mockClear();
confirmSpy.mockReset();
confirmSpy.mockReturnValue(true);
window.localStorage.setItem('metadata-admin:autosave', '0');
Object.defineProperty(window, 'confirm', { configurable: true, writable: true, value: confirmSpy });
});
afterEach(() => {
cleanup();
window.localStorage.removeItem('metadata-admin:autosave');
});

async function settle() {
await act(async () => {
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
});
}

const source = () => screen.findByRole('textbox', { name: /Markdown source|Markdown 源码|Markdown/ }, { timeout: 8000 });

/** Opens `doc/NAME` holding a draft; `label` undefined means the body carries none. */
async function openDrafted(name: string, label: string | undefined) {
const body = (content: string) => ({ name, ...(label === undefined ? {} : { label }), content, group: 'reference' });
server.active.set(`doc/${name}`, body('# Live'));
server.drafts.set(`doc/${name}`, body('# Live, drafted'));
render(
<MemoryRouter initialEntries={[`/metadata/doc/${name}`]}>
<Routes>
<Route path="/metadata/doc/:name" element={<MetadataResourceEditPage type="doc" name={name} />} />
</Routes>
</MemoryRouter>,
);
expect(await source()).toHaveValue('# Live, drafted');
await settle();
}

async function discard(name: string) {
const label = locale.current === 'zh-CN' ? '丢弃草稿' : 'Discard draft';
fireEvent.click(screen.getAllByRole('button', { name: label })[0]!);
await waitFor(() => expect(server.drafts.has(`doc/${name}`)).toBe(false));
await settle();
}

describe('MetadataResourceEditPage — "Discard draft" names the item by its label (objectui#12690)', () => {
it('en: the confirm and the toast name the label, not doc/NAME', async () => {
await openDrafted('qa_widget', 'QA Widget');
await discard('qa_widget');

expect(confirmSpy).toHaveBeenCalledTimes(1);
const asked = confirmSpy.mock.calls[0]![0]!;
expect(asked).toBe(tFormat('engine.edit.discardDraftConfirm', 'en-US', { item: 'QA Widget' }));
expect(asked).toContain('QA Widget');
expect(asked).not.toContain('doc/qa_widget');

expect(toasts.success).toHaveBeenCalledTimes(1);
const said = toasts.success.mock.calls[0]![0] as string;
expect(said).toBe(tFormat('engine.edit.draftDiscarded', 'en-US', { item: 'QA Widget' }));
expect(said).toContain('QA Widget');
expect(said).not.toContain('doc/qa_widget');
});

it('zh-CN: the confirm and the toast name the label, not doc/NAME', async () => {
locale.current = 'zh-CN';
await openDrafted('qa_widget', 'QA Widget');
await discard('qa_widget');

const asked = confirmSpy.mock.calls[0]![0]!;
expect(asked).toBe(tFormat('engine.edit.discardDraftConfirm', 'zh-CN', { item: 'QA Widget' }));
expect(asked).toContain('QA Widget');
expect(asked).not.toContain('doc/qa_widget');

const said = toasts.success.mock.calls[0]![0] as string;
expect(said).toBe(tFormat('engine.edit.draftDiscarded', 'zh-CN', { item: 'QA Widget' }));
expect(said).toContain('QA Widget');
expect(said).not.toContain('doc/qa_widget');
});

it('CONTROL: an item with no label still reads type/name, and the discard request is unchanged', async () => {
await openDrafted('plain_note', undefined);
await discard('plain_note');

expect(confirmSpy.mock.calls[0]![0]).toBe('Discard the pending draft for doc/plain_note?');
expect(toasts.success).toHaveBeenCalledWith('Draft discarded: doc/plain_note');
// The same one DELETE of the draft row, at the same package-less address.
expect(server.deletes).toEqual(['/api/v1/meta/doc/plain_note?state=draft']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
*
* Measured on the card: the `DELETE …?state=draft` answered 200, the pending
* banner went away, and nothing on the page said so. The page now raises the
* console's shared success toast naming the item (`type/name`), once the
* discard and its re-read have landed. A refused discard raises none: it
* console's shared success toast naming the item, once the discard and its
* re-read have landed. objectui#12690 changed WHAT names it: the item's own
* label when it carries one (here the fixture's label is its name), the
* machine `type/name` only when it carries none; that rule is pinned in
* `ResourceEditPage.discardDraftLabel-12690.test.tsx`. A refused discard raises none: it
* keeps the page's error line, as before.
*
* The REAL page over a REAL `MetadataClient`, whose transport is an in-memory
Expand Down Expand Up @@ -173,10 +176,11 @@ describe('MetadataResourceEditPage — "Discard draft" says what it discarded (o
expect(await source()).toHaveValue('# Live');
expect(toasts.success).toHaveBeenCalledTimes(1);
expect(toasts.success).toHaveBeenCalledWith(
tFormat('engine.edit.draftDiscarded', 'en', { type: 'doc', name: 'live_guide' }),
tFormat('engine.edit.draftDiscarded', 'en', { item: 'live_guide' }),
);
// The words name the item, not just the act.
expect(toasts.success.mock.calls[0]![0]).toContain('doc/live_guide');
// The words name the item, not just the act. objectui#12690 — by its label
// (`doc()` labels the fixture with its name), no longer as `doc/live_guide`.
expect(toasts.success.mock.calls[0]![0]).toContain('live_guide');
});

it('CONTROL: a declined confirm sends no DELETE and says nothing', async () => {
Expand Down
10 changes: 8 additions & 2 deletions packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1925,7 +1925,13 @@ function MetadataResourceEditPageImpl({
// Discard the pending draft (`DELETE ?state=draft`). The published
// overlay is untouched; the editor reverts to showing the live body.
async function doDiscardDraft() {
if (!confirm(tFormat('engine.edit.discardDraftConfirm', locale, { type, name: name ?? '' }))) {
// objectui#12690 — the confirm and the toast name the item by the label
// its loaded body carries (the one the assistant context above reads),
// and by the machine `type/name` only when it carries none. Read once,
// before the discard swaps the body.
const ownLabel = typeof draft.label === 'string' ? draft.label.trim() : '';
const item = ownLabel || `${type}/${name ?? ''}`;
if (!confirm(tFormat('engine.edit.discardDraftConfirm', locale, { item }))) {
return;
}
setSaving(true);
Expand Down Expand Up @@ -1954,7 +1960,7 @@ function MetadataResourceEditPageImpl({
// the console's shared toast (`sonner`, the one Studio's publish uses).
// Only here, after the DELETE and the re-read landed: a refused discard
// goes to the `catch` below and keeps showing its error instead.
toast.success(tFormat('engine.edit.draftDiscarded', locale, { type, name: name ?? '' }));
toast.success(tFormat('engine.edit.draftDiscarded', locale, { item }));
} catch (err: any) {
setError(err?.message ?? String(err));
} finally {
Expand Down
8 changes: 4 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.edit.publish': 'Publish',
'engine.edit.publishBlockedDirty': 'Save first, then publish.',
'engine.edit.discardDraft': 'Discard draft',
'engine.edit.discardDraftConfirm': 'Discard the pending draft for {type}/{name}?',
'engine.edit.draftDiscarded': 'Draft discarded: {type}/{name}',
'engine.edit.discardDraftConfirm': 'Discard the pending draft for {item}?',
'engine.edit.draftDiscarded': 'Draft discarded: {item}',
'engine.edit.draftPending': 'Pending changes — save lives in a draft. Click Publish to release.',
'engine.edit.rollback': 'Rollback to this version',
'engine.edit.rollbackConfirm': 'Restore version {version}? This writes the historical body back as the current published overlay.',
Expand Down Expand Up @@ -3815,8 +3815,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.edit.publish': '发布',
'engine.edit.publishBlockedDirty': '请先保存再发布。',
'engine.edit.discardDraft': '丢弃草稿',
'engine.edit.discardDraftConfirm': '丢弃 {type}/{name} 的待发布草稿?',
'engine.edit.draftDiscarded': '已丢弃 {type}/{name} 的待发布草稿',
'engine.edit.discardDraftConfirm': '丢弃 {item} 的待发布草稿?',
'engine.edit.draftDiscarded': '已丢弃 {item} 的待发布草稿',
'engine.edit.draftPending': '存在未发布的草稿 — 保存仅写入草稿,点击"发布"使其生效。',
'engine.edit.rollback': '回滚到此版本',
'engine.edit.rollbackConfirm': '恢复版本 {version}?该历史内容将被写回作为当前已发布的覆盖层。',
Expand Down
Loading