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
19 changes: 19 additions & 0 deletions .changeset/10210-overlay-marker-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/data-objectstack': patch
---

Views that an earlier "Edit view config → Save" made read-only recover on read, and keep
their edits (objectui#10210).

A view row now counts as a personalization overlay only when it carries the `_isOverride`
marker. The view list used to treat a flat row carrying `viewKind: 'list'` as an overlay
too, a shape guess kept for toolbar rows written before the marker existed. A config save
on a code-defined view stored exactly that shape, so the view dropped out of the saved
views and its tab turned read-only, for good once the draft was published. With the guess
retired, such a row reads back as the saved view it is: its tab is editable again and shows
what the save stored. Nothing is rewritten at rest; the next read is enough.

⚠️ This exposes one class of row: overlay rows written before the marker
(objectui#4227, closed 2026-08-15) and never touched since. They have the same shape, so
they now also read as plain rows, and their frozen label, columns and filter copy covers
the code definition again. No deployment is named as holding one.
14 changes: 6 additions & 8 deletions .changeset/10210-view-config-save-envelope.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ Saving a view's config no longer turns the view read-only (objectui#10210).

"Edit view config → Save" on a code-defined view used to store the flat view the
panel edits. The platform then copies `viewKind: 'list'` onto that row from the
code definition it shadows, and a flat row carrying `viewKind` is the shape the list
reader treats as a personalization overlay: the view dropped out of the saved views,
code definition it shadows, and a flat row carrying `viewKind` was the shape the list
reader treated as a personalization overlay: the view dropped out of the saved views,
every mutating entry vanished from its tab menu except "Manage all views…", and
publishing the draft made that permanent.

Expand All @@ -24,9 +24,7 @@ saves it back to the same view. Before, a stored envelope lost the view's identi
the way into the panel and the next Save wrote nothing. A change saved before this
release is still stored flat and still resumes.

⚠️ Views that an earlier save already made read-only are **not** repaired by this
release: their stored rows cannot be told apart, by shape, from older toolbar
personalization rows, so they keep reading back as read-only and their tab menu
still offers no way back. Deleting such a view's stored customization through the
metadata API resets it to its code definition and drops the edits that save made; an
in-product repair is a separate decision.
Views that an earlier save already made read-only are repaired on read, edits kept,
by the `@object-ui/data-objectstack` change for objectui#10210: the list reader no
longer treats that shape as an overlay unless the row carries the `_isOverride`
marker.
8 changes: 5 additions & 3 deletions .changeset/view-overlay-write-patch-only-5233.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ an explicitly runtime-only overlay key (objectstack#9933, released in
`@objectstack/spec` 17.1.0) — before that a `columnState`-only patch was refused
`422 INVALID_METADATA`, and the fat copy was the only thing supplying a
recognized key. The read half (`narrowPersonalizationOverlay`) shipped earlier
and stays: rows written before this land are still tolerated on read, and because
the write replaces the whole document, the next toolbar toggle also strips such a
row at rest. No migration.
and stays: rows written before this land are still tolerated on read when they
carry the overlay marker (a row older than the marker is not narrowed — see the
`@object-ui/data-objectstack` change for objectui#10210), and because the write
replaces the whole document, the next toolbar toggle also strips such a row at
rest. No migration.
20 changes: 17 additions & 3 deletions packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,14 @@ describe('objectui#5233 — rows written BEFORE this fix (the disposition, pinne
* frozen body behaves correctly on the very next page load, without its
* user having to touch that view again and without an operator running
* anything.
*
* One exception is ruled (objectui#10210, ruling B, comment 5824008636):
* a row written before the `_isOverride` marker existed carries no
* marker, and the marker is now the only thing that makes a row an
* overlay. The shape guess that used to narrow it too is retired — an
* "Edit view config → Save" wrote the same shape and the guess turned the
* user's own view read-only. The case that pinned the old narrowing is
* rewritten below to pin the exposure the ruling accepts, not deleted.
*/
it('a row stored in the old shape stops shadowing the source on the NEXT READ — no write, no migration', async () => {
const { meta, rows } = makeMetaStore();
Expand All @@ -460,13 +468,17 @@ describe('objectui#5233 — rows written BEFORE this fix (the disposition, pinne
expect(meta.saveItem).not.toHaveBeenCalled();
});

it('a PRE-MARKER legacy row is narrowed by the same predicate listViews() excludes it by', async () => {
it('a PRE-MARKER legacy row is no longer narrowed: its frozen copy covers the source again (objectui#10210 ruling B, the accepted exposure)', async () => {
const { meta, rows } = makeMetaStore();
const ds = makeAdapter(meta);

// Written before `_isOverride` existed (objectui#4227): flat body, and
// a `viewKind` only the platform's registry-backed identity heal can
// have put there.
// have put there. This case used to assert that the retired shape
// guess narrowed it, so the admin's edit won. Under ruling B the row is
// a plain row: the ruling names exactly this — an overlay written
// before the marker and never touched since, whose frozen label,
// columns and filter copy covers the code definition again.
rows.set(`view::${VIEW_ID}`, {
...SOURCE_VIEW_AT_WRITE_TIME,
object: OBJECT_NAME,
Expand All @@ -476,7 +488,9 @@ describe('objectui#5233 — rows written BEFORE this fix (the disposition, pinne

const tab = await tabAfterAdminEdit(ds);

expect(tab.filter).toEqual(SOURCE_VIEW_AFTER_ADMIN_EDIT.filter);
expect(tab.filter).toEqual(SOURCE_VIEW_AT_WRITE_TIME.filter);
expect(tab.columns).toEqual(SOURCE_VIEW_AT_WRITE_TIME.columns);
expect(tab.label).toBe(SOURCE_VIEW_AT_WRITE_TIME.label);
expect(tab.sort).toEqual([{ field: 'created_at', order: 'desc' }]);
});
});
Expand Down
59 changes: 49 additions & 10 deletions packages/app-shell/src/views/ObjectView.overrideMasquerade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,17 @@
* companion fixture that DOES include one, run through the REAL production
* pipeline: the adapter's `listViews()` (the actual fix) feeding
* `buildViewTabs` / `isSavedViewId` (the actual consumers), exactly as
* `ObjectView`'s own effect normalizes them (mirrored from
* ObjectView.tsx:967-978).
* `ObjectView`'s own effect normalizes them (mirrored from the `normalized`
* map in `ObjectView.tsx`'s `listViews(objectName, { previewDrafts })` effect).
*
* objectui#10210, ruling B (comment 5824008636): only the `_isOverride` marker
* makes a row an overlay. This file used to pin a second, shape-based layer as
* well — an unmarked flat row with a server-backfilled `viewKind` excluded and
* kept read-only. That guess is retired: "Edit view config → Save" wrote the
* same shape before PR #10332, and the guess turned the user's own view
* read-only for good once published. The case that pinned it is rewritten
* below, through the same real pipeline, to pin the ruled behaviour — not
* deleted.
*/

import { describe, it, expect, vi } from 'vitest';
Expand All @@ -43,7 +52,7 @@ const DEFINED_VIEWS = {

const fallbackTab = () => ({ id: 'all', label: 'All records', type: 'grid', columns: [] });

/** `ObjectView.tsx`'s own `savedViews` normalization (ObjectView.tsx:967-978), verbatim. */
/** `ObjectView.tsx`'s own `savedViews` normalization (the `normalized` map in its `listViews` effect), verbatim. */
function normalizeSavedViews(rows: any[]) {
return rows.map((sv: any) => ({
...sv,
Expand Down Expand Up @@ -81,13 +90,6 @@ describe('a system view stays readonly even with a personalization row (objectui
rowHeight: 40, _isOverride: true,
},
],
[
'legacy unmarked row (viewKind backfilled server-side, pre-marker writes)',
{
name: 'crm_lead.default', object: 'crm_lead', viewKind: 'list',
label: 'All Leads', type: 'grid', rowHeight: 40,
},
],
])('%s: excluded from savedViews, tab stays readonly, guard refuses', async (_label, overrideRow) => {
const ds = makeAdapterWithItems([overrideRow]);

Expand All @@ -113,6 +115,43 @@ describe('a system view stays readonly even with a personalization row (objectui
expect(isMutable(savedViews, 'crm_lead.default')).toBe(false);
});

it('an UNMARKED flat row with a backfilled viewKind is a saved view: the tab is editable and shows its edits (objectui#10210 ruling B)', async () => {
// This row was the second `it.each` case above, asserting the retired
// shape guess: excluded, tab read-only, guard refusing. Rewritten, not
// deleted. The fixture is now the row a pre-PR-#10332 config save left at
// rest (the flat panel draft, `viewKind`/`object` inherited server-side),
// which is the same shape as a pre-marker toolbar overlay; under ruling B
// both read as the plain row they are stored as.
const flatConfigSave = {
label: 'All Leads EDITED', type: 'grid', columns: ['name', 'status'],
name: 'crm_lead.default', isDefault: false, id: 'crm_lead.default',
viewKind: 'list', object: 'crm_lead',
};
const ds = makeAdapterWithItems([flatConfigSave]);

const rawSavedViews = await ds.listViews(OBJECT_NAME);
expect(rawSavedViews.map((v: any) => v.name)).toEqual(['crm_lead.default']);

const savedViews = normalizeSavedViews(rawSavedViews);
const tabs = buildViewTabs({
definedViews: DEFINED_VIEWS,
primary: undefined,
primaryId: undefined,
savedViews,
viewOverrides: {},
fallbackTab,
});

expect(tabs.map((t) => t.id)).toEqual(['crm_lead.default']);
// The edits the save stored are what the tab shows.
expect(tabs[0].label).toBe('All Leads EDITED');
expect(tabs[0].columns).toEqual(['name', 'status']);
// Render-time gate (`isSystem = !saved`, `readonly: isSystem`) lifts, and
// the predicate all five mutating handlers short-circuit on now admits it.
expect(isReadonlyTab(savedViews, 'crm_lead.default')).toBe(false);
expect(isMutable(savedViews, 'crm_lead.default')).toBe(true);
});

it('positive control: a genuinely created saved view stays fully manageable, even reusing a system-view-shaped label', async () => {
// A real save (createView / the ADR-0034 seam) is always a nested
// ViewItem record — the shape `listViews()` must keep letting through.
Expand Down
Loading
Loading