diff --git a/.changeset/27464-interfaces-rename-held-guard.md b/.changeset/27464-interfaces-rename-held-guard.md new file mode 100644 index 00000000000..e688db34d57 --- /dev/null +++ b/.changeset/27464-interfaces-rename-held-guard.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(studio): a navigation rename typed in the Interfaces nav editor but not yet committed now asks before a reload or a tab close + +Clause-②: no +Metadata-protocol: none + +A rename in the nav editor commits on blur or Enter. Before either, the typed label lived only in the input, so a reload or a tab close dropped it with no prompt, while every other held Studio edit asked. `AppNavCanvas` gains an optional `onRenamePendingChange` report, and the Interfaces pillar folds it into the same held-edit report the other pillars use, so the surface's one `beforeunload` guard covers it and Publish names it as an edit not finished yet. Escape, or typing the authored label back, lets the guard go. diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.tsx index bc8f786cbcd..e882a071d08 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.tsx @@ -301,6 +301,14 @@ export interface AppNavCanvasProps { onPatch?: (patch: Record) => void; selection: { kind: string; id: string } | null; onSelectionChange?: (sel: { kind: string; id: string; label?: string } | null) => void; + /** + * objectstack#27464 — the label of the entry whose rename is typed but not + * yet committed (the input differs from the authored label), or `null`. A + * rename commits on blur or Enter, so a reload or a tab close before either + * drops it; a host reports it as an edit held unsent. Optional: a host with + * no departure guard leaves it out. + */ + onRenamePendingChange?: (label: string | null) => void; } export function AppNavCanvas({ @@ -309,6 +317,7 @@ export function AppNavCanvas({ onPatch, selection, onSelectionChange, + onRenamePendingChange, }: AppNavCanvasProps) { const locale = useMetadataLocale(); const targetLabel = useNavTargetLabel(); @@ -487,6 +496,7 @@ export function AppNavCanvas({ canEdit={!!onPatch} onClick={(p, lbl) => onSelectionChange?.({ kind: 'nav', id: p, label: lbl })} onRename={(lbl) => renameItem(i, lbl)} + onRenamePendingChange={onRenamePendingChange} onRemove={() => removeItem(i)} onDragStart={() => setDragIndex(i)} onDragEnd={() => setDragIndex(null)} @@ -514,6 +524,7 @@ function NavCardTree({ canEdit, onClick, onRename, + onRenamePendingChange, onRemove, onDragStart, onDragEnd, @@ -529,6 +540,7 @@ function NavCardTree({ canEdit: boolean; onClick: (path: string, label: string) => void; onRename: (label: string) => void; + onRenamePendingChange?: (label: string | null) => void; onRemove: () => void; onDragStart: () => void; onDragEnd: () => void; @@ -547,6 +559,7 @@ function NavCardTree({ canEdit={canEdit && depth === 0} onClick={() => onClick(path, navLabel(item, index, locale, targetLabel))} onRename={onRename} + onRenamePendingChange={onRenamePendingChange} onRemove={onRemove} onDragStart={onDragStart} onDragEnd={onDragEnd} @@ -590,6 +603,7 @@ function NavCard({ canEdit, onClick, onRename, + onRenamePendingChange, onRemove, onDragStart, onDragEnd, @@ -605,6 +619,7 @@ function NavCard({ canEdit: boolean; onClick: () => void; onRename: (label: string) => void; + onRenamePendingChange?: (label: string | null) => void; onRemove: () => void; onDragStart: () => void; onDragEnd: () => void; @@ -625,6 +640,27 @@ function NavCard({ if (!editing) setDraft(authored); }, [authored, editing]); + // objectstack#27464 — a typed rename that blur or Enter has not committed + // yet is reported, keyed on the label string (AGENTS.md #10). Only a card + // that has reported takes it back, so the other cards' mounts never clear it. + const pendingRename = editing && draft.trim() !== '' && draft.trim() !== authored ? label : null; + const reportRef = React.useRef(onRenamePendingChange); + React.useLayoutEffect(() => { + reportRef.current = onRenamePendingChange; + }); + const reportedRef = React.useRef(false); + React.useEffect(() => { + if (pendingRename === null && !reportedRef.current) return; + reportedRef.current = pendingRename !== null; + reportRef.current?.(pendingRename); + }, [pendingRename]); + React.useEffect( + () => () => { + if (reportedRef.current) reportRef.current?.(null); + }, + [], + ); + return (
{dropPos === 'before' && ( diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navRenameHeld-27464.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navRenameHeld-27464.test.tsx new file mode 100644 index 00000000000..aba45c59c80 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navRenameHeld-27464.test.tsx @@ -0,0 +1,136 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27464 — a navigation rename typed into the Interfaces nav editor + * but not committed yet (it commits on blur or Enter) is unsaved work: a reload + * or a tab close asks first, as it does for every other held Studio edit. + * + * The canvas reports the pending rename and the pillar folds it into the one + * held-edit report the surface already reads, so its one `beforeunload` guard + * covers it. A bare `AppNavCanvas` (the `AppPreview` caller) renames as before. + */ + +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 } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const PKG = 'com.acme.app'; +const NAV = [{ id: 'nav_qa', type: 'page', label: 'QA Dash', pageName: 'home' }]; +const HOME = { name: 'home', label: 'Home', type: 'app', regions: [{ name: 'main', components: [] }] }; + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async (type: string) => (type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [])), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(), + getDraft: vi.fn(async () => { + throw Object.assign(new Error('No pending draft'), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ loading: false, error: null, entries: [] }) }; +}); +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => [{ id: PKG, name: 'Acme', writable: true, namespace: 'acme' }]) }; +}); +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas'; + +const noop = () => {}; +window.matchMedia = ((media: string) => ({ matches: false, media, addEventListener: noop, removeEventListener: noop })) as unknown as typeof window.matchMedia; +(globalThis as { ResizeObserver?: unknown }).ResizeObserver = + (globalThis as { ResizeObserver?: unknown }).ResizeObserver ?? class { observe() {} unobserve() {} disconnect() {} }; + +beforeEach(() => { + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); + mockClient.layered.mockImplementation(async (type: string) => { + const effective = type === 'app' ? { name: 'acme_app', label: 'Acme', navigation: NAV } : type === 'page' ? HOME : null; + return { code: null, overlay: null, overlayScope: null, effective, editable: true }; + }); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => [] })) as unknown as typeof fetch); +}); +afterEach(() => (cleanup(), vi.unstubAllGlobals())); + +/** Whether a reload / tab close would be asked about. */ +function reloadAsks(): boolean { + const e = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(e); + return e.defaultPrevented; +} + +/** Opens nav editing and starts a rename of "QA Dash"; returns its input. */ +async function startRename(): Promise { + render( + + + } /> + + , + ); + await screen.findByRole('button', { name: /QA Dash/ }, { timeout: 8000 }); + fireEvent.click(screen.getByTitle(/^Edit navigation/)); + await screen.findByRole('button', { name: /Add nav item/ }, { timeout: 8000 }); + const card = screen.getAllByRole('button').find((b) => b.getAttribute('aria-pressed') !== null && b.textContent?.startsWith('QA Dash'))!; + fireEvent.doubleClick(card.querySelector('span')!); + return (await screen.findByDisplayValue('QA Dash')) as HTMLInputElement; +} + +describe('an uncommitted nav rename raises the held-edit guard (objectstack#27464)', () => { + it('typing a rename asks on reload; Escape reverts it and the guard lets go', async () => { + const input = await startRename(); + expect(reloadAsks()).toBe(false); + fireEvent.change(input, { target: { value: 'QA Dash Lost' } }); + await waitFor(() => expect(reloadAsks()).toBe(true)); + fireEvent.keyDown(input, { key: 'Escape' }); + await waitFor(() => expect(reloadAsks()).toBe(false)); + expect(mockClient.save).not.toHaveBeenCalled(); + }); + + it('typing the authored label back is no rename, and the guard lets go', async () => { + const input = await startRename(); + fireEvent.change(input, { target: { value: 'QA Dash Lost' } }); + await waitFor(() => expect(reloadAsks()).toBe(true)); + fireEvent.change(input, { target: { value: 'QA Dash' } }); + await waitFor(() => expect(reloadAsks()).toBe(false)); + }); + + it('committing hands the rename to the nav autosave; once it lands nothing is held', async () => { + const input = await startRename(); + fireEvent.change(input, { target: { value: 'QA Dash Lost' } }); + await waitFor(() => expect(reloadAsks()).toBe(true)); + fireEvent.blur(input); + await waitFor(() => expect(mockClient.save.mock.calls.some(([type]) => type === 'app')).toBe(true), { timeout: 8000 }); + const sent = mockClient.save.mock.calls.find(([type]) => type === 'app')![2] as { navigation: Array<{ label: unknown }> }; + expect(sent.navigation[0].label).toBe('QA Dash Lost'); + await waitFor(() => expect(reloadAsks()).toBe(false), { timeout: 8000 }); + }); +}); + +describe('a canvas with no report (the AppPreview caller) renames as before', () => { + it('commits on blur and needs no report', () => { + const onPatch = vi.fn(); + render(); + fireEvent.doubleClick(screen.getByText('QA Dash')); + const input = screen.getByDisplayValue('QA Dash'); + fireEvent.change(input, { target: { value: 'Renamed' } }); + fireEvent.blur(input); + expect(onPatch).toHaveBeenCalledWith({ navigation: [expect.objectContaining({ label: 'Renamed' })] }); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index f8db0991e7d..9840e7a867c 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -4847,7 +4847,13 @@ export function InterfacesPillar({ }); // Only the open leaf's own buffer is held (objectui#11272). const leafHeld = draftLoaded ? leafIncomplete : null; - useHeldEditReport(onHeldEditChange, leafHeld?.clause ?? null); + // objectstack#27464 — a nav rename typed but not committed (blur / Enter) + // lives only in the canvas input, so it is held as the leaf's edit is: the + // same report, so the surface's one guard (beforeunload included) asks. + const [navRenamePending, setNavRenamePending] = React.useState(null); + const navRenameClause = + navRenamePending === null ? null : tFormat('engine.studio.held.navRename', locale, { item: navRenamePending }); + useHeldEditReport(onHeldEditChange, leafHeld?.clause ?? navRenameClause); // objectui#12725 — the header's draft line reads the badge's ledger. const leafDraftStatus = useStudioItemDraftStatus( current ? { type: current.type, name: current.name } : null, @@ -6116,6 +6122,7 @@ export function InterfacesPillar({ onPatch={onNavPatch} selection={navSel} onSelectionChange={(s) => setNavSel(s ? { kind: s.kind, id: s.id } : null)} + onRenamePendingChange={setNavRenamePending} /> ) : navTree.length === 0 ? (

diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 12780e87cb7..9f6c5a33970 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3012,6 +3012,8 @@ const STUDIO_EN: Record = { // objectui#11910 — a dashboard widget held until it is bound; `input` is the // widget inspector's own label for it (Dataset, Values (measures)). 'engine.studio.held.widgetNeedsInput': '{input} on the widget “{widget}” needs a value', + // objectstack#27464 — a navigation rename typed but not committed yet. + 'engine.studio.held.navRename': 'the rename of the navigation item “{item}” is not finished', // objectstack#27485 — a field CEL expression the inspector judged broken, held after it closes. 'engine.studio.held.celInvalid': 'an expression on the field “{field}” has an error to fix', 'engine.studio.held.line': 'Not saved yet: {clause}. Your changes are kept here and saved once it is filled in.', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 9ad6d303447..09261b3ffad 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2681,6 +2681,8 @@ const STUDIO_ZH: Record = { 'engine.studio.held.needsRuleField': '规则「{rule}」需要选择一个字段', 'engine.studio.held.needsRuleMessage': '规则「{rule}」需要一条错误消息', 'engine.studio.held.widgetNeedsInput': '组件「{widget}」的「{input}」需要填写', + // objectstack#27464 — a navigation rename typed but not committed yet. + 'engine.studio.held.navRename': '导航项「{item}」的重命名尚未完成', 'engine.studio.held.celInvalid': '字段「{field}」的表达式有错误需要修正', 'engine.studio.held.line': '尚未保存:{clause}。更改已保留在此处,补全后会自动保存。', 'engine.studio.held.publish': '未发布:{clause},该更改尚未保存。请先补全,再发布。',