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/27464-interfaces-rename-held-guard.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,14 @@ export interface AppNavCanvasProps {
onPatch?: (patch: Record<string, unknown>) => 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({
Expand All @@ -309,6 +317,7 @@ export function AppNavCanvas({
onPatch,
selection,
onSelectionChange,
onRenamePendingChange,
}: AppNavCanvasProps) {
const locale = useMetadataLocale();
const targetLabel = useNavTargetLabel();
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -514,6 +524,7 @@ function NavCardTree({
canEdit,
onClick,
onRename,
onRenamePendingChange,
onRemove,
onDragStart,
onDragEnd,
Expand All @@ -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;
Expand All @@ -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}
Expand Down Expand Up @@ -590,6 +603,7 @@ function NavCard({
canEdit,
onClick,
onRename,
onRenamePendingChange,
onRemove,
onDragStart,
onDragEnd,
Expand All @@ -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;
Expand All @@ -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 (
<div className={cn('relative', DEPTH_INDENT[Math.min(depth, DEPTH_INDENT.length - 1)])}>
{dropPos === 'before' && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ loading: false, error: null, entries: [] }) };
});
vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
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<typeof import('@object-ui/react')>();
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<HTMLInputElement> {
render(
<MemoryRouter initialEntries={[`/studio/${PKG}/interfaces`]}>
<Routes>
<Route path="/studio/:packageId/:tab" element={<StudioDesignSurface />} />
</Routes>
</MemoryRouter>,
);
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(<AppNavCanvas draft={{ navigation: NAV }} rootKey="navigation" onPatch={onPatch} selection={null} />);
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' })] });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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,
Expand Down Expand Up @@ -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 ? (
<p className="px-2 py-3 text-[11px] text-muted-foreground">
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3012,6 +3012,8 @@ const STUDIO_EN: Record<string, string> = {
// 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.',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2681,6 +2681,8 @@ const STUDIO_ZH: Record<string, string> = {
'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},该更改尚未保存。请先补全,再发布。',
Expand Down
Loading