Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9eab0c6
feat(app-shell): Studio item header keeps the item name readable
claude Nov 3, 2026
511875e
test(app-shell): pin the item header fold and the compact rail mark
claude Nov 3, 2026
9f0bf15
feat(app-shell): a second fold stage turns the item header's labelled…
claude Nov 3, 2026
08039a0
test(app-shell): pin the fold order, icon-only names and the folded t…
claude Nov 3, 2026
00f95d9
chore: describe the second fold stage in the changeset
claude Nov 3, 2026
9ee9fbd
test(app-shell): pass the ledger switch its busy prop in the fold pin
claude Nov 3, 2026
eaea6c6
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 3, 2026
205c453
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 3, 2026
b0949a5
test(e2e): the customize journey finds the override chip in the row o…
claude Nov 3, 2026
10d0e0a
feat(studio): the override mark opens a popover with what it override…
claude Nov 3, 2026
2aac813
test(studio): Restore is asked from the override mark; folded-menu, l…
claude Nov 3, 2026
81e34d4
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 3, 2026
5de32e8
fix(studio): the override popover stays open under the pointer, and R…
claude Nov 3, 2026
25aaa06
docs(changeset): the folded override popover stays open and Restore c…
claude Nov 3, 2026
9403cae
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 3, 2026
7680bea
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 3, 2026
42a3115
test(studio): the folded override mark keeps working with the header'…
claude Nov 3, 2026
01872ad
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 3, 2026
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
10 changes: 10 additions & 0 deletions .changeset/29692-override-chip-restore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/app-shell': patch
---

feat(app-shell): Studio's override mark opens a popover that names the customization and offers Restore package original

Clause-②: no
Metadata-protocol: none

In Studio, the mark on an item customized into the base's pair ("Overrides HotCRM") used to tell you nothing more, and you had to find "Restore package original" in the item's ⋯ menu. The mark is now a button. Pressing it opens a small popover that says which package the item overrides and which version of it the customization was taken from, with a "Restore package original" button. That button asks through the same confirmation dialog as before, and confirming sends the same two deletes at the pair: its draft, then its row. Customize keeps the header's primary slot, and Restore is never a header button of its own. The ⋯ menus of the Interfaces item, the flow and the All-metadata form editor no longer list Restore, so the mark is the one place to find it. On a narrow header, where the mark folds into the ⋯ menu, it opens the same popover there. Choosing the folded entry with the keyboard opens it too. The popover stays open while the pointer moves over it, and Restore there closes the ⋯ menu before the confirmation opens.
2 changes: 1 addition & 1 deletion content/docs/build-without-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Two properties make Studio safe to hand to non-developers:

## Setup — where the platform is administered

The **System Settings** app covers platform administration: user management and invitations, permission assignment, datasources (including federated external databases), diagnostics and audit logs, and the **Marketplace** — template apps such as HotCRM install with one click and appear alongside your own apps. Setup holds no metadata editor: its metadata pages open Studio, and an installed app is customized there. Open the app in Studio and every change lands in its paired customization package (`hotcrm.custom` for HotCRM), created on your first change and exported and deployed like any package, while what shipped stays the vendor's. **Customize** on a view, dashboard, report or flow saves your version as an override of the shipped one, marked with the app it overrides, and **Restore package original** removes it. **Add field** on a shipped object adds your own field, named with a `__c` suffix, and a shipped field's label, description, help text, placeholder and option labels can be changed, nothing else. A shipped flow's switch turns it off for this deployment, and **New similar** copies a flow or a permission set under a new name; a flow's copy starts switched off.
The **System Settings** app covers platform administration: user management and invitations, permission assignment, datasources (including federated external databases), diagnostics and audit logs, and the **Marketplace** — template apps such as HotCRM install with one click and appear alongside your own apps. Setup holds no metadata editor: its metadata pages open Studio, and an installed app is customized there. Open the app in Studio and every change lands in its paired customization package (`hotcrm.custom` for HotCRM), created on your first change and exported and deployed like any package, while what shipped stays the vendor's. **Customize** on a view, dashboard, report or flow saves your version as an override of the shipped one, marked with the app it overrides. Press that mark to see which version it was taken from, and **Restore package original** there removes it. **Add field** on a shipped object adds your own field, named with a `__c` suffix, and a shipped field's label, description, help text, placeholder and option labels can be changed, nothing else. A shipped flow's switch turns it off for this deployment, and **New similar** copies a flow or a permission set under a new name; a flow's copy starts switched off.

## When to reach for code instead

Expand Down
12 changes: 10 additions & 2 deletions ui/e2e/live/studio-customize-journey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,16 @@ test('a read-only base is customized, restored and extended through Studio, and
if (r.request().method() === 'DELETE' && r.url().includes(`/api/v1/meta/view/${VIEW}`)) deletes.push(`${r.status()} ${r.url()}`);
};
page.on('response', onResponse);
await page.getByTestId('if-item-more').click();
await page.getByTestId('studio-restore-original').click();
// objectstack#29692 — Restore is on the override chip's popover: the chip in
// the row, or in the item's ⋯ menu while the header is folded.
const rowChip = page.getByTestId('if-item-header').getByTestId('studio-override-chip');
if (await rowChip.isVisible()) {
await rowChip.click();
} else {
await page.getByTestId('if-item-more').click();
await page.getByTestId('studio-item-header-folded-chips').getByTestId('studio-override-chip').click();
}
await page.getByTestId('studio-override-popover').getByTestId('studio-override-restore').click();
await page.getByTestId('studio-restore-original-confirm').click();
await expect(page.getByTestId('studio-customize')).toBeVisible();
await expect.poll(() => deletes.length).toBe(2);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,15 @@ async function openMenu(): Promise<void> {
await screen.findByTestId('auto-item-new-similar');
}

/**
* objectstack#29692 — Restore is asked from the override mark's popover, and
* confirmed in the same dialog the flow's menu opened before.
*/
async function openRestorePopover(): Promise<HTMLElement> {
fireEvent.click(await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 }));
return screen.findByTestId('studio-override-popover');
}

/** The one switch drawn in the header is the ledger's: no definition switch beside it. */
async function expectLedgerSwitchAlone(): Promise<void> {
const ledger = await within(header()).findByTestId('auto-ledger-switch', undefined, { timeout: 8000 });
Expand Down Expand Up @@ -335,7 +344,13 @@ describe('Customize a read-only base’s flow into its pair (objectstack#28570)'
// The badge reads the declaration the pair's draft now serves.
const chip = await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 });
expect(chip).toHaveTextContent(tFormat('engine.studio.customize.overrides', 'en', { package: 'HotCRM' }));
expect(chip).toHaveAttribute('title', tFormat('engine.studio.customize.overridesTitle', 'en', { package: 'HotCRM', basedOn: '4.0.0' }));
// objectstack#29692 — pressed, the mark says the version it was taken from.
fireEvent.click(chip);
expect(await screen.findByTestId('studio-override-popover-facts')).toHaveTextContent(
tFormat('engine.studio.customize.overridesTitle', 'en', { package: 'HotCRM', basedOn: '4.0.0' }),
);
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
await waitFor(() => expect(screen.queryByTestId('studio-override-popover')).toBeNull());
await expectLedgerSwitchAlone();

// A later edit asks the door no more, and is pinned to the version it was answered.
Expand All @@ -358,10 +373,14 @@ describe('Customize a read-only base’s flow into its pair (objectstack#28570)'
await expectLedgerSwitchAlone();

// The layered envelope says `deletable: false`; the declared row decides.
const popover = await openRestorePopover();
expect(within(popover).getByTestId('studio-override-restore')).toBeEnabled();
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
await waitFor(() => expect(screen.queryByTestId('studio-override-popover')).toBeNull());
// Delete flow stays a writable package's own; Restore is the mark's, not the menu's (objectstack#29692).
await openMenu();
expect(screen.getByTestId('studio-restore-original')).not.toHaveAttribute('data-disabled');
// Delete flow stays a writable package's own.
expect(screen.queryByTestId('auto-item-delete')).toBeNull();
expect(screen.queryByTestId('studio-restore-original')).toBeNull();

// Its editors are open: an edit is saved into the pair, keeping the declaration.
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
Expand All @@ -379,10 +398,16 @@ describe('Customize a read-only base’s flow into its pair (objectstack#28570)'
mount({ listedPair: PAIR });
await openFlow(/^Notify owner and manager v2$/);

await openMenu();
fireEvent.click(screen.getByTestId('studio-restore-original'));
// objectstack#29692 — the dialog names the flow as its rail row does.
const railText = within(screen.getByTestId('studio-rail-automations'))
.getByRole('button', { name: /^Notify owner/ })
// The row's label, without the switch reading the row prints after it.
.querySelector('.flex-1')
?.textContent?.trim();
expect(railText).toBe('Notify owner');
fireEvent.click(within(await openRestorePopover()).getByTestId('studio-override-restore'));
const dialog = await screen.findByTestId('studio-restore-original-dialog');
const vars = { item: 'Notify owner', package: 'HotCRM' };
const vars = { item: railText!, package: 'HotCRM' };
expect(dialog).toHaveTextContent(tFormat('engine.studio.customize.restoreConfirm', 'en', vars));
expect(dialog).toHaveTextContent(tFormat('engine.studio.customize.restoreFlowBody', 'en', vars));
expect(dialog).toHaveTextContent(t('engine.studio.customize.restoreDraft', 'en'));
Expand Down
16 changes: 5 additions & 11 deletions ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
* `type/name` (objectui#13333). The pillar sends the delete itself. Reset
* overlay is not offered: it arrives with the overlay editing of the
* Studio / Setup boundary record's phase 2 (#28045).
* - **Restore package original** (objectstack#28570, ADR-0146 D2), a
* read-only base's flow that its pair overrides. The pillar decides it from
* the served declaration and holds the confirmation, outside this menu.
*
* Restore package original is not here: it is on the flow's override mark,
* where the customization is named (objectstack#29692).
*/

import * as React from 'react';
Expand Down Expand Up @@ -56,7 +56,6 @@ import { t, tFormat, translateMetadataType } from '../metadata-admin/i18n.js';
import { envelopeData, envelopeRefused } from '../setup/packagedFlows.js';
import { apiBase } from '../../utils/apiBase.js';
import { STUDIO_HEADER_ICON_BUTTON } from './studio-tokens.js';
import { StudioRestoreOriginalItem } from './StudioCustomizeControls.js';

/** What the clone door saved: the copy's name, the package it names, and its sentence. */
export interface FlowCopied {
Expand Down Expand Up @@ -129,17 +128,15 @@ interface NewSimilarDraft {
/**
* `label` is the flow's display label; `disabled` holds the menu's actions
* while a save is in flight. `onDelete` draws Delete flow (a writable
* package's flow); `newSimilar` draws New similar; `restore` draws Restore
* package original (a flow the base's pair overrides).
* package's flow); `newSimilar` draws New similar.
*/
export function FlowItemMoreMenu({ name, label, locale, disabled, onDelete, newSimilar, restore }: {
export function FlowItemMoreMenu({ name, label, locale, disabled, onDelete, newSimilar }: {
name: string;
label: string;
locale: string;
disabled?: boolean;
onDelete?: () => void;
newSimilar?: FlowNewSimilarProps;
restore?: { disabled?: boolean; onSelect: () => void };
}): React.ReactElement {
const [confirming, setConfirming] = React.useState(false);
const [copy, setCopy] = React.useState<NewSimilarDraft | null>(null);
Expand Down Expand Up @@ -218,9 +215,6 @@ export function FlowItemMoreMenu({ name, label, locale, disabled, onDelete, newS
{t('engine.studio.auto.deleteFlow', locale)}
</DropdownMenuItem>
)}
{restore && (
<StudioRestoreOriginalItem disabled={disabled || restore.disabled} locale={locale} onSelect={restore.onSelect} />
)}
</DropdownMenuContent>
</DropdownMenu>
{onDelete && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,20 @@ async function customize(): Promise<void> {
await waitFor(() => expect(within(header()).queryByTestId('studio-customize')).toBeNull());
}

/**
* objectstack#29692 — Restore is asked from the override mark's popover, and
* confirmed in the same dialog the item menu opened before.
*/
async function openRestoreDialog(): Promise<HTMLElement> {
fireEvent.click(await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 }));
const popover = await screen.findByTestId('studio-override-popover');
await waitFor(() => expect(within(popover).getByTestId('studio-override-restore')).toBeEnabled(), { timeout: 8000 });
fireEvent.click(within(popover).getByTestId('studio-override-restore'));
return screen.findByTestId('studio-restore-original-dialog');
}

async function restore(): Promise<void> {
fireEvent.pointerDown(await screen.findByTestId('if-item-more', undefined, { timeout: 8000 }), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByTestId('studio-restore-original'));
const dialog = await screen.findByTestId('studio-restore-original-dialog');
fireEvent.click(within(dialog).getByTestId('studio-restore-original-confirm'));
fireEvent.click(within(await openRestoreDialog()).getByTestId('studio-restore-original-confirm'));
}

describe('Customize a read-only base’s dashboard into its pair (objectstack#28562)', () => {
Expand Down Expand Up @@ -351,7 +360,13 @@ describe('Customize a read-only base’s dashboard into its pair (objectstack#28
// The badge reads the declaration the pair's draft now serves.
const chip = await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 });
expect(chip).toHaveTextContent(tFormat('engine.studio.customize.overrides', 'en', { package: 'HotCRM' }));
expect(chip).toHaveAttribute('title', tFormat('engine.studio.customize.overridesTitle', 'en', { package: 'HotCRM', basedOn: '4.0.0' }));
// objectstack#29692 — pressed, the mark says the version it was taken from.
fireEvent.click(chip);
expect(await screen.findByTestId('studio-override-popover-facts')).toHaveTextContent(
tFormat('engine.studio.customize.overridesTitle', 'en', { package: 'HotCRM', basedOn: '4.0.0' }),
);
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
await waitFor(() => expect(screen.queryByTestId('studio-override-popover')).toBeNull());

// A later edit of the same item asks the door no more.
patchBoard();
Expand Down Expand Up @@ -480,13 +495,37 @@ describe('Restore package original (objectstack#28562)', () => {
overrides: { package: BASE, basedOn: '4.0.0' },
});
mount({ listedPair: PAIR });
await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 });
fireEvent.pointerDown(screen.getByTestId('if-item-more'), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByTestId('studio-restore-original'));
fireEvent.click(within(await screen.findByTestId('studio-restore-original-dialog')).getByTestId('studio-restore-original-cancel'));
fireEvent.click(within(await openRestoreDialog()).getByTestId('studio-restore-original-cancel'));
await sleep(300);
expect(srv.resets).toEqual([]);
});

it('objectstack#29692 — one door: the item’s ⋯ menu offers no Restore, and the dialog names the item as its rail row does', async () => {
srv.pairExists = true;
srv.pairActive.set('dashboard/pipeline', {
name: 'pipeline',
label: 'Pipeline (ours)',
widgets: [],
overrides: { package: BASE, basedOn: '4.0.0' },
});
mount({ listedPair: PAIR });
await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 });
fireEvent.pointerDown(screen.getByTestId('if-item-more'), { button: 0, ctrlKey: false });
await screen.findByTestId('if-item-view-definition');
expect(screen.queryByTestId('studio-restore-original')).toBeNull();
fireEvent.keyDown(screen.getByTestId('if-item-view-definition'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByTestId('if-item-view-definition')).toBeNull());

const railRow = within(screen.getByTestId('studio-rail-interfaces')).getByRole('button', { name: /Pipeline/ });
// The row's label, without the kind word the row prints after it.
const railText = railRow.querySelector('.flex-1')?.textContent?.trim() ?? '';
expect(railText).toBe('Pipeline');
const dialog = await openRestoreDialog();
expect(within(dialog).getByRole('heading')).toHaveTextContent(
tFormat('engine.studio.customize.restoreConfirm', 'en', { package: 'HotCRM', item: railText }),
);
fireEvent.click(within(dialog).getByTestId('studio-restore-original-cancel'));
});
});

/** The calendar setting `key` of the list-view panel. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,12 @@ async function customize(): Promise<void> {
await waitFor(() => expect(screen.queryByTestId('studio-customize')).toBeNull());
}

/** objectstack#29692 — Restore is asked from the override mark's popover. */
async function restore(): Promise<void> {
fireEvent.pointerDown(await screen.findByTestId('studio-bundle-editor-more'), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByTestId('studio-restore-original'));
fireEvent.click(await screen.findByTestId('studio-override-chip', undefined, { timeout: 8000 }));
const popover = await screen.findByTestId('studio-override-popover');
await waitFor(() => expect(within(popover).getByTestId('studio-override-restore')).toBeEnabled(), { timeout: 8000 });
fireEvent.click(within(popover).getByTestId('studio-override-restore'));
const dialog = await screen.findByTestId('studio-restore-original-dialog');
fireEvent.click(within(dialog).getByTestId('studio-restore-original-confirm'));
}
Expand Down Expand Up @@ -371,6 +374,12 @@ describe('Customize a read-only base’s email template, one locale (objectstack
mount({ type: 'email_template', name: 'welcome', member: 'zh-CN', listedPair: PAIR });
expect(await field('Subject')).toHaveValue('欢迎回来');
expect(await screen.findByTestId('studio-override-chip')).toBeInTheDocument();
// objectstack#29692 — one door: the editor's ⋯ menu offers no Restore.
fireEvent.pointerDown(screen.getByTestId('studio-bundle-editor-more'), { button: 0, ctrlKey: false });
await screen.findByTestId('studio-bundle-editor-history');
expect(screen.queryByTestId('studio-restore-original')).toBeNull();
fireEvent.keyDown(screen.getByTestId('studio-bundle-editor-history'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByTestId('studio-bundle-editor-history')).toBeNull());

await restore();
await waitFor(() => expect(srv.resets).toHaveLength(2));
Expand Down
Loading
Loading