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
16 changes: 16 additions & 0 deletions .changeset/27835-publish-panel-names-changes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/app-shell': patch
---

feat(studio): the Pending changes panel names each change in words — a property by its form label, each validation rule and field group by its label, a view by the settings that changed

Clause-②: no
Metadata-protocol: none

In the Pending changes panel (Studio's publish review and the console's draft preview), an item's drill-in used to list what changed as raw keys: `+ label + name + sharingModel` for a new object, `+ config + isDefault + viewKind` for a new view, `+ fieldGroups + validations` for an object edit, with no word for which rule moved.

- **Properties by their label.** Each property reads as the label its item type's authoring form gives it, the same row the inspector shows: `Sharing Model`, `Field Groups`, `Validations`. The machine key follows only where the label does not already spell it. A property the form has no row for still reads as its key. A changed field's properties read the same way (`· Label, Required`).
- **Rules and groups by name.** An object's `validations` and `fieldGroups` list each entry publishing adds, changes or removes under their row, led by the entry's label with its name beside it, the way a flow already names its nodes. A changed field group names what differs by its declared sub-row labels.
- **Views by their settings.** A view is compared inside its body, so a view edit names `Columns` or `Filter` instead of one `config` key, and a new view lists the settings it includes.

Flow node rows are unchanged. The labels are the spec's English form labels in every locale, as most of the inspector shows them.
247 changes: 190 additions & 57 deletions ui/packages/app-shell/src/preview/DraftChangesPanel.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ describe('two members of one email_template are two rows (objectui#12271)', () =
const detail = screen.getByTestId('draft-entry-detail');
// zh-CN is new: its draft adds `body` and `subject` against no published body.
expect(detail).toHaveTextContent('+ body');
expect(detail).toHaveTextContent('+ subject');
expect(detail).toHaveTextContent('+ Subject');
expect(urls).toContain('/api/v1/meta/email_template/welcome?state=draft&package=com.x&member=zh-CN');
// The canonical body was never read for this row.
expect(urls).not.toContain('/api/v1/meta/email_template/welcome?state=draft&package=com.x');
Expand All @@ -166,7 +166,7 @@ describe('two members of one email_template are two rows (objectui#12271)', () =
fireEvent.click(memberRow('en-US'));

await waitFor(() => expect(screen.getByTestId('draft-entry-detail')).toBeInTheDocument());
expect(screen.getByTestId('draft-entry-detail')).toHaveTextContent('~ subject');
expect(screen.getByTestId('draft-entry-detail')).toHaveTextContent('~ Subject');
expect(screen.getByTestId('draft-entry-detail')).not.toHaveTextContent('body');
expect(urls).toContain('/api/v1/meta/email_template/welcome?state=draft&package=com.x&member=en-US');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ describe('the drill-in of an UPDATE (objectui#13125)', () => {
const detail = await expandRow('Ticket');
const changed = fieldRow(detail, '~ Subject');
expect(within(changed).getByText('subject')).toHaveClass('font-mono');
expect(changed).toHaveTextContent('· label');
expect(changed).toHaveTextContent('· Label');
const removed = fieldRow(detail, '− Legacy Code');
expect(within(removed).getByText('legacy_code')).toHaveClass('font-mono');
expect(detail.textContent ?? '').not.toContain('__search');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,9 @@ describe('the publish drill-in of a flow edit (objectstack#27356)', () => {
]);
expect(within(rows[1]!).getByText('create_task')).toHaveClass('font-mono');
expect(detail.textContent).not.toContain('~ nodes');
// `edges` is still a key row under "Also changed:".
// `edges` is still a key row under "Also changed:", by its flow form label
// (objectstack#27835).
expect(within(detail).getByText('Also changed:')).toBeInTheDocument();
expect(within(detail).getByText('~ edges')).toBeInTheDocument();
expect(within(detail).getByTestId('draft-key-row')).toHaveTextContent('~ Edges');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ describe("the card's repro in the sheet (objectstack#25890)", () => {
const detail = await expandRepairTicket();
expect(within(detail).getByText('Includes:')).toBeInTheDocument();
expect(within(detail).queryByText('Also changed:')).toBeNull();
expect(within(detail).getByText('+ sharingModel')).toBeInTheDocument();
expect(within(detail).getByText('+ Sharing Model')).toBeInTheDocument();
});

it('opens the group to the five system columns, label first, and the derived nameField', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#27835 — the publish panel listed what a change touches as raw
* keys: "+ label + name + sharingModel" for a new object, "+ config + isDefault
* + viewKind" for a new view, "+ fieldGroups + validations" for an object edit,
* with no word for which rule moved.
*
* Now a property reads as the label its item type's form gives it (the spec's
* `METADATA_FORM_REGISTRY`, the form the inspector renders), its key beside it
* only where the label does not already spell it; a property the form has no
* row for keeps its key. A view is compared one level down, inside `config`,
* where its inspector edits it. A list of named entries (`validations` by
* `name`, `fieldGroups` by `key`) names each entry it adds, changes or removes,
* by its label, as a flow names its nodes.
*
* Controls: a list of plain strings stays one key row; a flow's lists are still
* only its nodes; a property no form row names reads as its key.
*/

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

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k,
language: 'en',
}),
}));

import { DraftChangesPanel, computeChangeDetail } from '../DraftChangesPanel';
import { formKeyLabel } from '../changeKeyLabels';

const HOURS_RULE = {
name: 'hours_over_40',
label: 'Hours over 40',
type: 'script',
condition: 'hours > 40',
message: 'A week holds at most 40 hours',
};
const DISCOUNT_RULE = { name: 'max_discount', type: 'script', condition: 'discount > 0.4', message: 'Too much' };
const OLD_RULE = { name: 'legacy_check', label: 'Legacy check', type: 'script', condition: 'false', message: 'x' };

const PUBLISHED_OBJECT = {
name: 'work_order',
label: 'Work Order',
fields: { hours: { type: 'number', label: 'Hours' } },
validations: [DISCOUNT_RULE, OLD_RULE],
fieldGroups: [{ key: 'basics', label: 'Basics' }],
highlightFields: ['hours'],
};
const DRAFT_OBJECT = {
...PUBLISHED_OBJECT,
validations: [{ ...DISCOUNT_RULE, condition: 'discount > 0.5' }, HOURS_RULE],
fieldGroups: [{ key: 'basics', label: 'Basics', collapse: 'collapsed' }],
highlightFields: ['hours', 'name'],
};

const NEW_VIEW = {
name: 'work_order_list',
object: 'work_order',
viewKind: 'list',
label: 'Work Order list',
isDefault: true,
config: { type: 'grid', columns: ['name', 'hours'], data: { provider: 'object', object: 'work_order' } },
};

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe('a property reads as its label on the item type form (objectstack#27835)', () => {
it('names an object property, a field property and a view body property by the form row', () => {
expect(formKeyLabel('object', 'sharingModel')).toBe('Sharing Model');
expect(formKeyLabel('object', 'validations')).toBe('Validations');
expect(formKeyLabel('field', 'required')).toBe('Required');
expect(formKeyLabel('view', 'config.columns')).toBe('Columns');
expect(formKeyLabel('flow', 'edges')).toBe('Edges');
});

it('a declared sub-row label wins over the prettified key', () => {
// `fieldGroups` declares its sub-rows; `relatedList` is labelled "Related List".
expect(formKeyLabel('object', 'fieldGroups.relatedList')).toBe('Related List');
expect(formKeyLabel('object', 'fieldGroups.collapse')).toBe('Collapse');
});

it('CONTROL: a property no form row names has no label', () => {
expect(formKeyLabel('view', 'viewKind')).toBeUndefined();
expect(formKeyLabel('object', 'no_such_key')).toBeUndefined();
expect(formKeyLabel('no_such_type', 'label')).toBeUndefined();
});
});

describe('computeChangeDetail names the entries of a named list (objectstack#27835)', () => {
it('lists the rule added, the rule changed with its property, and the rule removed', () => {
const d = computeChangeDetail(PUBLISHED_OBJECT, DRAFT_OBJECT, 'object');
expect(d.keys.changed).toEqual(['fieldGroups', 'highlightFields', 'validations']);
expect(d.entries.get('validations')).toEqual({
added: [{ id: 'hours_over_40', label: 'Hours over 40' }],
changed: [{ id: 'max_discount', keys: ['condition'] }],
removed: [{ id: 'legacy_check', label: 'Legacy check' }],
});
expect(d.entries.get('fieldGroups')).toEqual({
added: [],
changed: [{ id: 'basics', label: 'Basics', keys: ['collapse'] }],
removed: [],
});
});

it('CONTROL: a list of plain strings stays one key row with no entries', () => {
const d = computeChangeDetail(PUBLISHED_OBJECT, DRAFT_OBJECT, 'object');
expect(d.entries.has('highlightFields')).toBe(false);
});

it('a NEW object adds every rule', () => {
const d = computeChangeDetail(null, DRAFT_OBJECT, 'object');
expect(d.entries.get('validations')?.added.map((e) => e.id)).toEqual(['hours_over_40', 'max_discount']);
});

it('CONTROL: a flow names only its nodes, never another list', () => {
const before = { name: 'f', nodes: [], edges: [{ id: 'e1', name: 'e1' }] };
const after = { name: 'f', nodes: [], edges: [{ id: 'e1', name: 'e1' }, { id: 'e2', name: 'e2' }] };
expect(computeChangeDetail(before, after, 'flow').entries.size).toBe(0);
});
});

describe('computeChangeDetail compares a view inside its body (objectstack#27835)', () => {
it('a NEW view lists its body properties, not one `config` key', () => {
const d = computeChangeDetail(null, NEW_VIEW, 'view');
expect(d.keys.added).toEqual([
'config.columns',
'config.data',
'config.type',
'isDefault',
'label',
'name',
'object',
'viewKind',
]);
});

it('a view edit names the body property that changed', () => {
const draft = { ...NEW_VIEW, config: { ...NEW_VIEW.config, columns: ['name'] } };
expect(computeChangeDetail(NEW_VIEW, draft, 'view').keys).toEqual({
added: [],
changed: ['config.columns'],
removed: [],
});
});

it('CONTROL: an item that is not a view compares `config` whole', () => {
const draft = { ...NEW_VIEW, config: { ...NEW_VIEW.config, columns: ['name'] } };
expect(computeChangeDetail(NEW_VIEW, draft, 'page').keys.changed).toEqual(['config']);
});
});

/* ─────────────── the panel ─────────────── */

function stubServer(type: string, name: string, label: string, published: unknown, draft: unknown) {
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/_drafts')) return ok({ drafts: [{ type, name, packageId: 'com.x', label }] });
if (new RegExp(`/meta/${type}s?(\\?|$)`).test(url)) return ok(published ? [{ name }] : []);
if (new RegExp(`/meta/${type}s?/${name}\\b`).test(url)) {
if (url.includes('state=draft')) return ok({ item: draft });
return published ? ok(published) : { ok: false, status: 404, json: async () => ({}) };
}
return { ok: false, status: 404, json: async () => ({}) };
}) as unknown as typeof fetch,
);
}

async function openDetail(label: string): Promise<HTMLElement> {
render(<DraftChangesPanel open onOpenChange={() => {}} packageId="com.x" />);
const toggle = (await screen.findByText(label)).closest('[data-testid="draft-entry-toggle"]') as HTMLElement;
fireEvent.click(toggle);
return screen.findByTestId('draft-entry-detail');
}

describe('the publish drill-in names each change in words (objectstack#27835)', () => {
it('names each validation rule under its list, by its label', async () => {
stubServer('object', 'work_order', 'Work Order', PUBLISHED_OBJECT, DRAFT_OBJECT);
const detail = await openDetail('Work Order');

const keyRows = await within(detail).findAllByTestId('draft-key-row');
const validations = keyRows.find((r) => r.textContent?.startsWith('~ Validations'))!;
expect(validations).toBeDefined();
const entries = within(validations).getAllByTestId('draft-list-entry-row');
expect(entries.map((r) => r.textContent)).toEqual([
'+ Hours over 40 hours_over_40',
// `validations` is a JSON row with no declared sub-rows: the property keeps its key.
'~ max_discount · condition',
'− Legacy check legacy_check',
]);
expect(within(entries[0]!).getByText('hours_over_40')).toHaveClass('font-mono');
// A field group's changed property reads by its declared sub-row label.
const groups = keyRows.find((r) => r.textContent?.startsWith('~ Field Groups'))!;
expect(within(groups).getByTestId('draft-list-entry-row')).toHaveTextContent('~ Basics basics · Collapse');
expect(detail.textContent).not.toContain('~ validations');
});

it('heads a NEW view with its body properties by their labels, keeping a key no form row names', async () => {
stubServer('view', 'work_order_list', 'Work Order list', null, NEW_VIEW);
const detail = await openDetail('Work Order list');

const rows = (await within(detail).findAllByTestId('draft-key-row')).map((r) => r.textContent);
expect(rows).toContain('+ Columns');
expect(rows).toContain('+ Label');
expect(rows).toContain('+ viewKind');
expect(rows).not.toContain('+ config');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ describe('a draft equal to its published version is not listed as Update (object
expect(within(row('repairs_repair_ticket')).getByRole('img', { name: NO_CHANGE })).toBeInTheDocument();
expect(row('repairs_repair_ticket')).toHaveAttribute('title', NO_CHANGE);
// CONTROL: the drafted change keeps its mark, and its drill-in did load.
await waitFor(() => expect(screen.getByText('~ label')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('~ Label')).toBeInTheDocument());
expect(row('visit').textContent).toContain('Update');
expect(row('visit')).not.toHaveAttribute('title');
expect(within(row('visit')).queryByRole('img', { name: NO_CHANGE })).toBeNull();
Expand Down Expand Up @@ -211,7 +211,8 @@ describe('the drill-in lists top-level keys as added, changed and removed (objec
fireEvent.click(row('visit'));
const detail = await screen.findByTestId('draft-entry-detail');
const lines = [...detail.querySelectorAll('p')].map((p) => p.textContent);
expect(lines).toEqual(['+ status', 'Also changed:', '+ description', '~ label', '− icon']);
// objectstack#27835 — each key by its label on the object form.
expect(lines).toEqual(['+ status', 'Also changed:', '+ Description', '~ Label', '− Icon']);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,13 @@ async function openEntryDetail() {
fireEvent.click(await screen.findByTestId('draft-entry-toggle'));
}

/** The text of every key row the drill-in rendered, `+ name` / `~ name` / `− name`. */
/** The text of every field and key row the drill-in rendered, `+ name` / `~ name` / `− name`. */
function renderedRows(): string[] {
return [...screen.getByTestId('draft-entry-detail').querySelectorAll('p.font-mono')].map((p) =>
(p.textContent ?? '').trim(),
);
return [
...screen
.getByTestId('draft-entry-detail')
.querySelectorAll('[data-testid="draft-field-row"], [data-testid="draft-key-row"]'),
].map((row) => (row.textContent ?? '').trim());
}

describe('DraftChangesPanel — the protection envelope never lists as authored (objectui#12594)', () => {
Expand All @@ -108,7 +110,7 @@ describe('DraftChangesPanel — the protection envelope never lists as authored
await openEntryDetail();

// CONTROL: the authored key and the field are listed, so the diff ran.
await waitFor(() => expect(renderedRows()).toContain('+ label'));
await waitFor(() => expect(renderedRows()).toContain('+ Label'));
expect(renderedRows()).toContain('+ subject');

const text = screen.getByTestId('draft-entry-detail').textContent ?? '';
Expand All @@ -122,7 +124,7 @@ describe('DraftChangesPanel — the protection envelope never lists as authored
await openEntryDetail();

await waitFor(() => expect(renderedRows()).toContain('+ _unpublished'));
expect(renderedRows()).toContain('+ label');
expect(renderedRows()).toContain('+ Label');
const text = screen.getByTestId('draft-entry-detail').textContent ?? '';
for (const key of ENVELOPE_KEYS) expect(text).not.toContain(key);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe('DraftChangesPanel — read decorations never reach the review diff (ob

// The CONTROL first: the real authored change IS reported, so the diff ran
// and this harness reaches the changed-keys strip.
await waitFor(() => expect(detail.textContent).toContain('label'));
await waitFor(() => expect(detail.textContent).toContain('Label'));

// …and the framework's own keys are not sitting next to it. `_draft` is the
// deterministic one — the server stamps it on the draft read and never on
Expand Down
Loading
Loading