diff --git a/.changeset/29692-override-chip-restore.md b/.changeset/29692-override-chip-restore.md new file mode 100644 index 00000000000..d1da170ca66 --- /dev/null +++ b/.changeset/29692-override-chip-restore.md @@ -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. diff --git a/content/docs/build-without-code.mdx b/content/docs/build-without-code.mdx index 94558478846..efab5103592 100644 --- a/content/docs/build-without-code.mdx +++ b/content/docs/build-without-code.mdx @@ -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 diff --git a/ui/e2e/live/studio-customize-journey.spec.ts b/ui/e2e/live/studio-customize-journey.spec.ts index f1cfceb4e81..3b1eba7bc0b 100644 --- a/ui/e2e/live/studio-customize-journey.spec.ts +++ b/ui/e2e/live/studio-customize-journey.spec.ts @@ -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); diff --git a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx index ca2a67fc000..56355245561 100644 --- a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx @@ -286,6 +286,15 @@ async function openMenu(): Promise { 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 { + 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 { const ledger = await within(header()).findByTestId('auto-ledger-switch', undefined, { timeout: 8000 }); @@ -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. @@ -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' }); @@ -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')); diff --git a/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx b/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx index 99a1b4beef2..ed89f6d6c09 100644 --- a/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx +++ b/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx @@ -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'; @@ -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 { @@ -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(null); @@ -218,9 +215,6 @@ export function FlowItemMoreMenu({ name, label, locale, disabled, onDelete, newS {t('engine.studio.auto.deleteFlow', locale)} )} - {restore && ( - - )} {onDelete && ( diff --git a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx index 9e00cc6da7d..7540b44b656 100644 --- a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx @@ -307,11 +307,20 @@ async function customize(): Promise { 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 { + 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 { - 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)', () => { @@ -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(); @@ -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. */ diff --git a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx index cd94b5347a8..189fbf2ec9c 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx @@ -253,9 +253,12 @@ async function customize(): Promise { await waitFor(() => expect(screen.queryByTestId('studio-customize')).toBeNull()); } +/** objectstack#29692 — Restore is asked from the override mark's popover. */ async function restore(): Promise { - 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')); } @@ -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)); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx index 7e92dfb4d42..e0d004151c7 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx @@ -58,7 +58,6 @@ import { StudioCustomizeButton, StudioOverrideChip, StudioRestoreOriginalDialog, - StudioRestoreOriginalItem, overrideDraftBody, useServedOverride, type CustomizeBlock, @@ -375,7 +374,14 @@ export function StudioBundleItemEditor({ )} {customizing && served.declaration && ( - + setRestoreOpen(true) : undefined} + restoreDisabled={restoring || saving || (customization?.pairId == null && pairRef.current === null)} + /> )} {status && ( @@ -418,13 +424,6 @@ export function StudioBundleItemEditor({