diff --git a/.changeset/12690-discard-draft-label.md b/.changeset/12690-discard-draft-label.md new file mode 100644 index 0000000000..0ae1045117 --- /dev/null +++ b/.changeset/12690-discard-draft-label.md @@ -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. diff --git a/packages/app-shell/src/views/RuntimeDraftBar.test.tsx b/packages/app-shell/src/views/RuntimeDraftBar.test.tsx index a4f33f524d..6adbe4bdd7 100644 --- a/packages/app-shell/src/views/RuntimeDraftBar.test.tsx +++ b/packages/app-shell/src/views/RuntimeDraftBar.test.tsx @@ -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(); + + 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 } }); diff --git a/packages/app-shell/src/views/RuntimeDraftBar.tsx b/packages/app-shell/src/views/RuntimeDraftBar.tsx index 405b57c2b7..ea21a053e5 100644 --- a/packages/app-shell/src/views/RuntimeDraftBar.tsx +++ b/packages/app-shell/src/views/RuntimeDraftBar.tsx @@ -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; } diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardDraftLabel-12690.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardDraftLabel-12690.test.tsx new file mode 100644 index 0000000000..b5fc0e13ac --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardDraftLabel-12690.test.tsx @@ -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>()), + useMetadataLocale: () => locale.current, +})); + +const server = { + active: new Map>(), + drafts: new Map>(), + /** 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 { + 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(); + 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; +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
{schema.content}
; +} +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( + + + } /> + + , + ); + 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']); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardFeedback-12303.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardFeedback-12303.test.tsx index 593b622f55..c8fef7a6b2 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardFeedback-12303.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardFeedback-12303.test.tsx @@ -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 @@ -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 () => { diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 4e0d816db9..1c6efaabe8 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -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); @@ -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 { diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 98aef7b583..eac013ca29 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -268,8 +268,8 @@ const ENGINE_STRINGS_EN: Record = { '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.', @@ -3815,8 +3815,8 @@ const ENGINE_STRINGS_ZH: Record = { '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}?该历史内容将被写回作为当前已发布的覆盖层。',