From a512197f610782a92cf40929f603180db2eb9ecb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:55:10 +0000 Subject: [PATCH] fix(app-shell): a locked metadata item's designer canvas is read-only ResourceEditPage handed the registered preview an onPatch even when the page cannot write the item, so the object canvas offered add field and section move/remove on a locked item, with no Save to keep the edit. Pass no onPatch when the page is read-only, as Studio does for a read-only package. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JQwxbajUhBue1th6Leba9E --- .changeset/26888-locked-object-canvas.md | 14 ++ ...sourceEditPage.lockedCanvas-26888.test.tsx | 138 ++++++++++++++++++ .../views/metadata-admin/ResourceEditPage.tsx | 13 +- 3 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 .changeset/26888-locked-object-canvas.md create mode 100644 ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.lockedCanvas-26888.test.tsx diff --git a/.changeset/26888-locked-object-canvas.md b/.changeset/26888-locked-object-canvas.md new file mode 100644 index 00000000000..b8e74d66b48 --- /dev/null +++ b/.changeset/26888-locked-object-canvas.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): a locked metadata item's designer canvas is read-only + +Clause-②: no +Metadata-protocol: none + +In Setup › All Metadata Types, a locked item (for example an object installed by a package, under the banner "This item is locked and cannot be edited or deleted.") still had a live designer canvas. On an object you could add a field, add a field to a section, move a section or remove one: the change showed in the canvas with an "Unsaved" chip, but the page offers no Save on a locked item, so the work was dropped without a word when you left. + +What changes: when the page cannot write the item (it is locked, or you lack the capability to author metadata), the designer canvas gets no edit callback, so it renders read-only for every designer type, the way the Studio canvas already does for a read-only package. The Description box was already disabled on a locked item. Writable items are unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.lockedCanvas-26888.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.lockedCanvas-26888.test.tsx new file mode 100644 index 00000000000..c4726c3da68 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.lockedCanvas-26888.test.tsx @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26888 — a locked item's designer canvas is read-only. + * + * The page learns the lock from the layered read (`editable: false`) and keeps + * Save, the form and the inspectors read-only on it, but it handed the + * registered preview an `onPatch` unconditionally. The object canvas derives + * its read-only state from `onPatch` alone, so on a locked object it offered + * Add field, Add field to this section, Move section and Remove section: the + * edit landed in the draft, the inspector said "Unsaved", and no Save existed + * to keep it. The fix passes no `onPatch` when the page cannot write the item, + * the preview contract Studio already follows for a read-only package + * (objectui#11136). + * + * The unlocked case is the control: the same object, writable, still gets + * every control, so a red locked case is the defect and not a harness that + * never mounted the canvas. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const OBJECT = { + name: 'crm_account', + label: 'Account', + fields: { + account_name: { type: 'text', label: 'Account Name', group: 'basics' }, + website: { type: 'url', label: 'Website', group: 'basics' }, + notes: { type: 'textarea', label: 'Notes', group: 'details' }, + }, + fieldGroups: [ + { key: 'basics', label: 'Basics' }, + { key: 'details', label: 'Details' }, + ], +}; + +const layered = vi.hoisted(() => ({ editable: true })); + +const client = vi.hoisted(() => ({ + list: async () => [], + listDrafts: async () => [], + get: async () => null, + getDraft: async () => null, + references: async () => [], + layered: async () => ({ + code: OBJECT, + overlay: null, + overlayScope: null, + effective: OBJECT, + lock: layered.editable ? 'none' : 'full', + provenance: 'package', + packageId: 'app.objectstack.hotcrm', + editable: layered.editable, + deletable: layered.editable, + }), +})); + +vi.mock('./useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => client, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [ + { + type: 'object', + name: 'object', + label: 'Object', + allowEnvironmentOverlay: true, + allowRuntimeCreate: true, + schema: { type: 'object', properties: { name: { type: 'string' }, label: { type: 'string' } } }, + }, + ], + }), + }; +}); + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +import { MetadataResourceEditPage } from './ResourceEditPage'; +import './register-builtins'; +// The built-in designers (the Object canvas among them), which the package +// entry registers from a chunk it loads with a dynamic `import()`: called at +// module scope so they are registered before the first render. +import { registerBuiltinDesigners } from './register-builtin-designers'; +registerBuiltinDesigners(); + +afterEach(() => { + cleanup(); + layered.editable = true; +}); + +async function openObject(editable: boolean) { + layered.editable = editable; + render( + + + , + ); + // The canvas is up once its first section header shows. + expect(await screen.findByText('Basics', {}, { timeout: 8000 })).toBeInTheDocument(); +} + +const CANVAS_WRITES = [ + 'Add field to this section', + 'Move section up', + 'Move section down', + 'Remove section', +]; + +describe('Setup object designer — a locked item gets a read-only canvas (objectstack#26888)', () => { + it('control: a writable object offers the canvas add / move / remove controls', async () => { + await openObject(true); + for (const name of CANVAS_WRITES) { + expect(screen.queryAllByRole('button', { name }).length).toBeGreaterThan(0); + } + expect(screen.getByRole('button', { name: 'Add field' })).toBeInTheDocument(); + }); + + it('a locked object offers none of them, and nothing reads "Unsaved"', async () => { + await openObject(false); + expect(await screen.findByTestId('lock-banner-title')).toBeInTheDocument(); + for (const name of CANVAS_WRITES) { + expect(screen.queryAllByRole('button', { name })).toEqual([]); + } + expect(screen.queryByRole('button', { name: 'Add field' })).toBeNull(); + expect(screen.queryByText('Unsaved')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index e0f7e261594..6cd1fb36269 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -3080,8 +3080,17 @@ function MetadataResourceEditPageImpl({ onSelectionChange={setSelection} locale={locale} diagnostics={previewDiagnostics} - onPatch={(patch) => - handleDraftChange((d) => ({ ...(d as Record), ...patch })) + // objectstack#26888 — an item this page cannot write (a + // locked package item, a caller without the capability) + // gets no `onPatch`: per the preview contract the canvas + // is then read-only, as Studio does for a read-only + // package (objectui#11136). A live canvas here took + // edits the page offers no Save for, and dropped them. + onPatch={ + readOnly + ? undefined + : (patch) => + handleDraftChange((d) => ({ ...(d as Record), ...patch })) } />