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
14 changes: 14 additions & 0 deletions .changeset/26888-locked-object-canvas.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<typeof import('./useMetadata')>();
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(
<MemoryRouter initialEntries={['/metadata/object/crm_account']}>
<MetadataResourceEditPage type="object" name="crm_account" />
</MemoryRouter>,
);
// 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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3080,8 +3080,17 @@ function MetadataResourceEditPageImpl({
onSelectionChange={setSelection}
locale={locale}
diagnostics={previewDiagnostics}
onPatch={(patch) =>
handleDraftChange((d) => ({ ...(d as Record<string, unknown>), ...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<string, unknown>), ...patch }))
}
/>
</div>
Expand Down
Loading