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: 13 additions & 6 deletions .changeset/10746-joined-report-clears-binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,16 @@ unbound report's switch stays the one-key patch it always was. `runtimeFilter` a
`drilldown` are kept: the joined branch reads both. Switching between two non-joined
types keeps the binding; switching away from `joined` restores nothing — the author
re-binds. `blocks` is never touched. The clear holds for the patch and for the save that
follows it. The metadata-admin editor rebuilds its draft as the served draft spread over
`layered.effective` (on load, after each save and after publish), and `effective` is the
published layer, so a report whose PUBLISHED version was bound gets those keys back in the
draft after the first draft save until it is published, and a report already saved `joined`
with stale keys is not repaired on load. Both are the host's draft-over-baseline merge,
objectui#10765.
follows it. At this change the metadata-admin editor rebuilds its draft as the served draft
spread over `layered.effective` (on load, after each save and after publish), and
`effective` is the published layer, so a report whose PUBLISHED version was bound gets those
keys back in the draft after the first draft save until it is published, and a report already
saved `joined` with stale keys is not repaired on load. Both are the host's
draft-over-baseline merge, objectui#10765.

⚠️ **Dated note, 2026-09-27 — that merge has since been removed — objectui#10765.** Later in
this same release the metadata-admin editor takes a served draft as-is instead of spreading
it over the published layer, so the clear holds across the refresh after the save and through
publish. A report already saved `joined` with stale keys is still not repaired on load; the
author deletes them from the JSON tab. The rest of this entry is kept as the reading of this
change; the objectui#10765 entry states what the editors now do.
25 changes: 25 additions & 0 deletions .changeset/10765-draft-merge-deletion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): the metadata editors take a served draft as-is instead of spreading it over the published layer (objectui#10765)

The metadata-admin editor (`ResourceEditPage`) rebuilt its draft as the served draft spread
over `layered.effective` — on load, after every draft save and after publish — and the Studio
design surface did the same when it opened an app, a surface, an object or a flow, as did the
package OWD overview when it read and re-saved an object's draft. `effective` is the PUBLISHED
layer, and a spread cannot express deletion: a key the author cleared in the draft (the report
inspector's joined clear of `dataset` / `values` / `rows` / `columns` / `chart` / `order`, an OWD
model set back to unset, any key deleted from the JSON tab) was absent from the first draft PUT,
came back into the editor from the published layer on the refresh after that save, invisibly,
and the next save or publish sent it again.

The spread existed for a partial draft overlay that never reaches these editors: the server
(`metadata-protocol`) stores a `?mode=draft` body raw and its `?state=draft` read returns that
row raw, and every draft writer sends a whole document. So each site now takes the served draft
as-is when there is one and falls back to `effective` (then `code`) only when no draft exists;
the inherited `type` the spread was protecting is already inside the draft. `config.toDraft`
still normalises whatever is taken, now on the post-publish refresh as well. This removes the
reach limit the objectui#10746 entry states — a bound published report getting its cleared
keys back after the first draft save; a report already saved `joined` with stale keys is still
not repaired on load, and the author deletes them from the JSON tab.

Large diffs are not rendered by default.

44 changes: 24 additions & 20 deletions packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1044,16 +1044,16 @@ function MetadataResourceEditPageImpl({
// Prefer the pending draft as the editing baseline — the
// operator is mid-flight on this item and should see their
// own in-progress state, not the last published version.
// A pending draft overlay can carry only the edited fields, so using
// it wholesale would drop inherited fields that were never touched —
// notably `type`, which section-level `visibleOn` predicates depend on
// (ADR-0047 hides Data Context / Layout when `data.type == 'list'`).
// Merge the draft over the effective baseline so those fields survive;
// the draft still wins for anything it does carry.
// A served draft is the WHOLE document (objectui#10765): the server
// stores a `?mode=draft` body raw and its `?state=draft` read returns
// that row raw, and every writer of a draft sends a full document —
// so `type` and every other inherited field are already inside it.
// ⛔ Never spread the draft over `effective`: a spread cannot express
// deletion, so a key the author cleared came back from the PUBLISHED
// layer on every reload and the next save sent it again. The
// baseline is only for an item that has no pending draft at all.
const baseline = (lay.effective ?? lay.code ?? {}) as Record<string, unknown>;
const rawInitial: Record<string, unknown> = draftReal
? { ...baseline, ...(draftReal as Record<string, unknown>) }
: baseline;
const rawInitial: Record<string, unknown> = draftReal ?? baseline;
// Normalise the wire shape into the editor's draft shape (e.g.
// `view` unwraps an expanded ViewItem's `config` into a
// `{ list | form }` family key). No-op for types without a hook.
Expand Down Expand Up @@ -1513,12 +1513,14 @@ function MetadataResourceEditPageImpl({
setLayered(lay);
const draftReal = extractDraftBody(draftResp);
setHasDraft(!!draftReal);
// Merge the draft over the effective baseline (see the load effect):
// a partial draft overlay must not drop inherited fields like `type`.
// The served draft is the whole document the save just stored (see the
// load effect, objectui#10765): take it as-is. ⛔ Not spread over
// `effective` — that is the PUBLISHED layer, and a spread would bring a
// key this save deleted straight back into the editor, invisibly, for
// the next save to send. The baseline is only for a save that left no
// draft row to read back.
const freshBaseline = (lay.effective ?? itemToSave) as Record<string, unknown>;
const rawFresh: Record<string, unknown> = draftReal
? { ...freshBaseline, ...(draftReal as Record<string, unknown>) }
: freshBaseline;
const rawFresh: Record<string, unknown> = draftReal ?? freshBaseline;
// Re-normalise the refreshed wire shape so the editor keeps showing
// the canonical draft shape after a save (e.g. the backend re-expands
// a view into the ViewItem `config` wrapper).
Expand Down Expand Up @@ -1690,13 +1692,15 @@ function MetadataResourceEditPageImpl({
setLayered(lay);
const draftReal = extractDraftBody(draftResp);
setHasDraft(!!draftReal);
// Merge the draft over the effective baseline so a partial draft overlay
// doesn't drop inherited fields like `type` (section visibleOn depends
// on it — ADR-0047).
// After a publish the promoted body IS `effective` and the draft row is
// normally gone; a draft that does remain is a whole document and is
// taken as-is (objectui#10765). ⛔ Not spread over `effective`: the
// spread cannot express deletion and would resurrect a published key
// the draft had cleared.
const freshBaseline = (lay.effective ?? draft) as Record<string, unknown>;
const fresh: Record<string, unknown> = draftReal
? { ...freshBaseline, ...(draftReal as Record<string, unknown>) }
: freshBaseline;
const rawFresh: Record<string, unknown> = draftReal ?? freshBaseline;
// Same wire-to-editor normalisation the load and save refreshes apply.
const fresh = config.toDraft ? config.toDraft(rawFresh) : rawFresh;
setDraft(fresh);
draftSnapshotRef.current = fresh;
} catch (err: any) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#10765 — the package OWD overview reads and re-saves an object's
* pending draft AS-IS. It never spreads the draft over `layered.effective`.
*
* `effective` is the PUBLISHED layer and a spread cannot express deletion, so
* an OWD model the author had set back to unset in the draft (a real authored
* state — `PackageOwdOverviewPanel.test.tsx` pins that clearing a model DROPS
* the key) read as still set from the published row, and the next OWD save
* wrote that published value, plus every other key the draft had deleted,
* back into the object's draft. Same host-level cause as the metadata-admin
* editor's merge; the panel has two sites — the list load that derives each
* row's values and the save that splices the edit into the body — and both
* are pinned here, one assertion each, red on base.
*
* The client is a server double: `effective` is the active row, `getDraft`
* serves the stored draft row raw, `save` records the wire body.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

import { PackageOwdOverviewPanel } from './PackageOwdOverviewPanel';

/** The published object: both OWD models set, plus a description. */
const PUBLISHED = {
name: 'crm_contact',
label: 'Contact',
sharingModel: 'private',
externalSharingModel: 'private',
description: 'published description — the draft deleted this key',
};

/** The pending draft as the author left it: both models unset, description deleted. */
const SERVED_DRAFT = {
name: 'crm_contact',
label: 'Contact',
_diagnostics: { valid: true, errors: [] },
};

const saved: Array<{ name: string; body: Record<string, unknown>; opts: Record<string, unknown> | undefined }> = [];

function makeClient() {
return {
list: async (type: string) => (type === 'object' ? [{ name: PUBLISHED.name, label: PUBLISHED.label }] : []),
listDrafts: async () => [],
layered: async (_t: string, name: string) => ({
effective: name === PUBLISHED.name ? PUBLISHED : {},
code: null,
}),
getDraft: async (_t: string, name: string) =>
name === PUBLISHED.name ? { type: 'object', name, item: SERVED_DRAFT } : null,
save: async (_t: string, name: string, body: Record<string, unknown>, opts?: Record<string, unknown>) => {
saved.push({ name, body: JSON.parse(JSON.stringify(body)) as Record<string, unknown>, opts });
return body;
},
} as any;
}

beforeEach(() => {
saved.length = 0;
Element.prototype.scrollIntoView = vi.fn();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});

describe('PackageOwdOverviewPanel — the pending draft is read and re-saved as-is (objectui#10765)', () => {
it('LIST LOAD: a model the draft cleared reads as unset, not as the published value', async () => {
render(<PackageOwdOverviewPanel client={makeClient()} packageId="com.example.showcase" locale="en-US" />);
await screen.findByTestId('owd-row-crm_contact');

const internal = screen.getByTestId('owd-internal-crm_contact') as HTMLSelectElement;
// Red on base: the spread over `effective` showed `private` from the published row.
expect(internal.value).toBe('');
});

it('SAVE: an edit lands on the draft alone — the published model and description the draft deleted do not come back', async () => {
render(<PackageOwdOverviewPanel client={makeClient()} packageId="com.example.showcase" locale="en-US" />);
await screen.findByTestId('owd-row-crm_contact');

fireEvent.change(screen.getByTestId('owd-internal-crm_contact'), { target: { value: 'public_read' } });
fireEvent.click(screen.getByTestId('owd-save'));

await waitFor(() => expect(saved).toHaveLength(1));
const { name, body, opts } = saved[0]!;
expect(name).toBe('crm_contact');
expect(opts).toMatchObject({ mode: 'draft', packageId: 'com.example.showcase' });
expect(body.sharingModel).toBe('public_read');
// Red on base: both came back from `effective` through the spread.
expect(body).not.toHaveProperty('externalSharingModel');
expect(body).not.toHaveProperty('description');
// The draft's own keys travel; the read decorations do not (objectui#8181).
expect(body.label).toBe('Contact');
expect(body).not.toHaveProperty('_diagnostics');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ import { isExternalWider, deriveMasterObject } from './owd-sharing.js';
import { toast } from 'sonner';


/** A single object's loaded OWD baseline (already merged over any pending draft). */
/**
* A single object's loaded OWD values — read from its pending draft as-is when
* one exists, else from its layered baseline (objectui#10765).
*/
interface OwdRow {
name: string;
label: string;
Expand Down Expand Up @@ -143,8 +146,9 @@ export function PackageOwdOverviewPanel({

// The list row carries `sharingModel` but not reliably `fields` (needed
// for the master link), and never the pending-draft value — so read each
// object's merged body (layered ∪ its draft), exactly as the per-object
// Settings tab does, and derive every column from that one source.
// object's body (its pending draft as-is, else its layered baseline),
// exactly as the per-object Settings tab does, and derive every column
// from that one source.
const built = await Promise.all(
names.map(async (name): Promise<OwdRow> => {
const [layRaw, draftResp] = await Promise.all([
Expand All @@ -154,7 +158,11 @@ export function PackageOwdOverviewPanel({
const lay = (layRaw ?? {}) as { effective?: Record<string, unknown>; code?: Record<string, unknown> };
const baseline = (lay.effective ?? lay.code ?? {}) as Record<string, unknown>;
const draftBody = extractDraftBody(draftResp);
const body = draftBody ? { ...baseline, ...draftBody } : baseline;
// The pending draft is the whole document: read it as-is, and the
// published baseline only when no draft exists (objectui#10765). A
// spread over `effective` showed a model the draft had cleared as
// still set, from the published layer.
const body = draftBody ?? baseline;
const internal = typeof body.sharingModel === 'string' ? body.sharingModel : '';
return {
name,
Expand Down Expand Up @@ -233,7 +241,7 @@ export function PackageOwdOverviewPanel({
setSaveError(null);
try {
// Each changed row lands as that object's package-scoped DRAFT: read the
// fresh merged body, apply just the OWD pair (unset → drop the key), and
// fresh body (below), apply just the OWD pair (unset → drop the key), and
// save. Identical to the per-object Settings write, N times — publish
// then goes through the same package security-domain gate unchanged.
for (const s of changed) {
Expand All @@ -244,7 +252,11 @@ export function PackageOwdOverviewPanel({
const lay = (layRaw ?? {}) as { effective?: Record<string, unknown>; code?: Record<string, unknown> };
const baseline = (lay.effective ?? lay.code ?? {}) as Record<string, unknown>;
const draftBody = extractDraftBody(draftResp);
const next: Record<string, unknown> = { ...baseline, ...(draftBody ?? {}) };
// The pending draft is the whole document — the edit lands on it as-is,
// and on the published baseline only when no draft exists
// (objectui#10765): a spread over `effective` would put back every key
// the draft had deleted and save them again as this object's draft.
const next: Record<string, unknown> = { ...(draftBody ?? baseline) };
if (s.edit.internal) next.sharingModel = s.edit.internal;
else delete next.sharingModel;
if (s.edit.external) next.externalSharingModel = s.edit.external;
Expand Down
Loading
Loading