From 9a4f18b91485361b6d1c9c6d12b2f5d7d4ea952f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 20:56:54 +0000 Subject: [PATCH 1/5] feat(app-shell): customize a read-only base's flow into its pair in Studio On a read-only base, the Automations pillar offers Customize on a flow. The first saved edit asks the ensure door and writes the flow's draft into the pair under the same name, declaring the override. The ledger switch stays on the flow's name, no definition switch is drawn for the override, the header carries the override badge, and the flow menu offers Restore package original, decided from the served declaration. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRWZNXu1HAHuMowQXS --- .../views/studio-design/FlowItemMoreMenu.tsx | 13 +- .../studio-design/StudioCustomizeControls.tsx | 12 +- .../studio-design/StudioDesignSurface.tsx | 246 ++++++++++++++++-- ui/packages/i18n/src/namespaces/studio/en.ts | 1 + ui/packages/i18n/src/namespaces/studio/zh.ts | 1 + 5 files changed, 250 insertions(+), 23 deletions(-) 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 23c5dbecbb8..9bd5160d550 100644 --- a/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx +++ b/ui/packages/app-shell/src/views/studio-design/FlowItemMoreMenu.tsx @@ -20,6 +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. */ import * as React from 'react'; @@ -52,6 +55,7 @@ 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 { @@ -124,15 +128,17 @@ 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. + * package's flow); `newSimilar` draws New similar; `restore` draws Restore + * package original (a flow the base's pair overrides). */ -export function FlowItemMoreMenu({ name, label, locale, disabled, onDelete, newSimilar }: { +export function FlowItemMoreMenu({ name, label, locale, disabled, onDelete, newSimilar, restore }: { 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); @@ -209,6 +215,9 @@ 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/StudioCustomizeControls.tsx b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx index 49a3510b0c5..77f90a4464d 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx @@ -309,7 +309,8 @@ export function StudioRestoreOriginalItem({ /** * The confirmation of "Restore package original": it says what goes, the * customization, and its unpublished draft when one is pending. `onConfirm` - * sends both deletes; nothing is sent on Cancel. + * sends both deletes; nothing is sent on Cancel. objectstack#28570 — a flow's + * (`kind: 'flow'`) says the base's flow runs again, under the same switch. */ export function StudioRestoreOriginalDialog({ open, @@ -319,6 +320,7 @@ export function StudioRestoreOriginalDialog({ hasDraft, locale, onConfirm, + kind = 'item', }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -328,9 +330,15 @@ export function StudioRestoreOriginalDialog({ hasDraft: boolean; locale: string; onConfirm: () => void; + /** What is restored: an item served in the base's place, or a flow that runs in its place. */ + kind?: 'item' | 'flow'; }): React.ReactElement { const vars = { item: label, package: baseLabel }; - const body = tFormat('engine.studio.customize.restoreBody', locale, vars); + const body = tFormat( + kind === 'flow' ? 'engine.studio.customize.restoreFlowBody' : 'engine.studio.customize.restoreBody', + locale, + vars, + ); return ( diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 95f69a03d78..ff799334f01 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -9598,6 +9598,74 @@ export function AutomationsPillar({ // package-less scope, so the draft row stays as package-less as the flow it // edits. The client sends no `package=` for an absent id. const draftPackageId = packageId ?? undefined; + // objectstack#28570 (ADR-0146 D9 items 1–3, D2) — Customize a flow. On a + // read-only base its flow is edited in the base's paired customization + // package under the flow's own name, as a declared override of the base's + // flow, with N-17's control and ensure door. Its editors stay read-only + // until Customize is pressed, or while the server serves no override of + // it; nothing is written until an edit is saved. ⛔ The pair a write names + // is the door's answer; the pair a READ names is the surface's, read + // through this ref, so a pair the first save creates never reloads the + // buffer it saved. Whether the flow is overridden is the declared + // override's own row as served (`useServedOverride`: the pair's draft, else + // the layered read's `overlay`), ⛔ never the served envelope's + // `deletable`, which follows the base's seal. + const customizationRef = React.useRef(customization); + React.useLayoutEffect(() => { + customizationRef.current = customization; + }); + const flowCustomizing = packageReadOnly && railPackageId !== null && customization !== null; + const customizeBase = flowCustomizing && customization ? customization.base : null; + const customizePair = flowCustomizing && customization ? customization.pairId : null; + const customizeBaseLabel = customization?.baseLabel ?? railPackageId ?? ''; + const customizeWalled = flowCustomizing && customization?.walled === true; + const customizeBlock: CustomizeBlock = customizeWalled + ? 'walled' + : flowCustomizing && !customization?.basedOn + ? 'noVersion' + : null; + const flowCustomizable = flowCustomizing && !!current && !current.addedIn; + // The flows Customize was pressed on, by name: unlocked, nothing saved yet. + const [customizeAsked, setCustomizeAsked] = React.useState>(() => new Set()); + // The ensure door's answer for each flow's first saved edit: asked once per + // flow, and again only after a refusal. + const pairsByFlowRef = React.useRef(new Map>()); + const pairForFlow = React.useCallback((custom: StudioBaseCustomization, name: string): Promise => { + const held = pairsByFlowRef.current.get(name); + if (held) return held; + const asked = custom.ensure(); + pairsByFlowRef.current.set(name, asked); + asked.catch(() => { + if (pairsByFlowRef.current.get(name) === asked) pairsByFlowRef.current.delete(name); + }); + return asked; + }, []); + // Re-reads the served declaration: a flow's first save into the pair, a restore. + const [servedNonce, setServedNonce] = React.useState(0); + const flowServed = useServedOverride( + client, + flowCustomizable && current ? { type: 'flow', name: current.name } : null, + customizeBase, + customizePair, + `${publishNonce}:${servedNonce}`, + ); + const flowInPair = flowCustomizable && flowServed.declaration !== null; + const flowUnlocked = + flowCustomizable && + !customizeWalled && + (flowInPair || (customizeBlock === null && !!current && customizeAsked.has(current.name))); + // The open flow's editors: locked on a read-only package's flow unless it is customized. + const editLocked = readOnly && !flowUnlocked; + const flowInPairRef = React.useRef(flowInPair); + React.useLayoutEffect(() => { + flowInPairRef.current = flowInPair; + }); + // A restore in flight: no save is sent beside it. + const [restoring, setRestoring] = React.useState(false); + const [restoreOpen, setRestoreOpen] = React.useState(false); + // The package the open flow's draft read was asked at (`flow:NAME`): the + // address the pin it recorded describes. + const flowReadAtRef = React.useRef<{ key: string; at: string | undefined } | null>(null); // `?surface=flow:` capture + mirror — shared plumbing (see // useSurfaceDeepLink). No producer emits this link yet; honoring it keeps // the pillars uniform so a future "design this flow" bridge just works. @@ -9638,14 +9706,15 @@ export function AutomationsPillar({ [flowVerdict, locale], ); const flowVerdictSeq = React.useRef(0); + // objectstack#28570 — read at the package the draft was saved at (a customized flow's pair). const rereadFlowVerdict = React.useCallback( - (flowName: string) => { + (flowName: string, at: string | undefined) => { const seq = ++flowVerdictSeq.current; - void readDraftForSave(client, 'flow', flowName, draftPackageId).then((read) => { + void readDraftForSave(client, 'flow', flowName, at).then((read) => { if (seq === flowVerdictSeq.current) setFlowVerdict(draftEnvelopeDiagnostics(read.answer)); }); }, - [client, draftPackageId], + [client], ); // objectui#11785 — a load failure as it was (`plainRefusal`), a refused save // as an author sentence naming the step and its input, raw text behind Details. @@ -9923,8 +9992,11 @@ export function AutomationsPillar({ (async () => { try { // objectui#11773 — read at the address the flow saves write, so the - // version it serves pins the first of them. - const draftRead = await readDraftForSave>(client, 'flow', current.name, draftPackageId); + // version it serves pins the first of them. objectstack#28570 — a + // read-only base's flow: its pair, when one is listed. + const pair = flowCustomizing && !current.addedIn ? (customizationRef.current?.pairId ?? null) : null; + const readAt = pair ?? draftPackageId; + const draftRead = await readDraftForSave>(client, 'flow', current.name, readAt); if (cancelled) return; const draftBody = extractDraftBody(draftRead.answer); // objectui#11799 — the baseline is read only for a flow with no @@ -9946,7 +10018,8 @@ export function AutomationsPillar({ }), ); // objectui#11773 — the next save is pinned to what the draft read served. - recordFlowRead('flow', current.name, draftPackageId, draftRead.pin); + recordFlowRead('flow', current.name, readAt, draftRead.pin); + flowReadAtRef.current = { key: `flow:${current.name}`, at: readAt }; setHasDraft(!!draftBody); } catch (e) { if (!cancelled) setError(plainRefusal(e)); @@ -9964,7 +10037,7 @@ export function AutomationsPillar({ // `loading` it raised, as in InterfacesPillar's draft load. if (!settled) setLoading(false); }; - }, [client, current, draftPackageId, publishNonce, flowReloadNonce, recordFlowRead]); + }, [client, current, draftPackageId, publishNonce, flowReloadNonce, recordFlowRead, flowCustomizing]); // objectui#5813 — local dirty flag: auto-save arms only after a real edit. const [autoDirty, setAutoDirty] = React.useState(false); @@ -9980,12 +10053,49 @@ export function AutomationsPillar({ setSaving('draft'); setError(null); try { - const outcome = await saveFlowDraft('flow', current.name, draft, { mode: 'draft', packageId: draftPackageId, surface: 'studio' }); + // objectstack#28570 — a read-only base's flow is saved into the pair the + // ensure door answers, under its own name, declaring the override. A + // refused door is shown as it was sent, and nothing is saved. + const custom = readOnly ? customizationRef.current : null; + let target: { packageId: string | undefined; body: Record } = { packageId: draftPackageId, body: draft }; + let entering = false; + if (custom) { + let pair: string; + try { + pair = await pairForFlow(custom, current.name); + } catch (e) { + setError(plainRefusal(e)); + return; + } + const declared = overrideDraftBody(draft, custom.base, custom.basedOn); + if (declared === null) { + setError({ message: tFormat('engine.studio.customize.noVersion', locale, { package: custom.baseLabel }) }); + return; + } + // The buffer was read at another package (the base, before its pair + // was listed): the pair is taken to hold no draft of the flow, so a + // draft there is a conflict the author is asked about, never one this + // save overwrites. Its receipt pins the saves after it. + const key = `flow:${current.name}`; + if (!(flowReadAtRef.current?.key === key && flowReadAtRef.current.at === pair)) { + recordFlowRead('flow', current.name, pair, { ifNoneMatch: '*' }); + flowReadAtRef.current = { key, at: pair }; + } + entering = !flowInPairRef.current; + target = { packageId: pair, body: declared }; + } + const outcome = await saveFlowDraft('flow', current.name, target.body, { + mode: 'draft', + packageId: target.packageId, + surface: 'studio', + }); // objectui#11773 — the author chose the saved version; the load replaces the buffer. if (outcome === 'reloaded') return; + // The badge reads the declaration the server now serves. + if (entering) setServedNonce((n) => n + 1); setHasDraft(true); // objectstack#26858 — the stored draft carries its own verdict now. - rereadFlowVerdict(current.name); + rereadFlowVerdict(current.name, target.packageId); // objectui#11204 — clean only if nothing was edited while it was in flight. if (sent.unmoved()) setAutoDirty(false); onDraftSaved?.({ type: 'flow', name: current.name }); @@ -9995,7 +10105,7 @@ export function AutomationsPillar({ } finally { setSaving(false); } - }, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved, locale, rereadFlowVerdict]); + }, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved, locale, rereadFlowVerdict, readOnly, pairForFlow, recordFlowRead]); // objectui#11786 — an edit that leaves a step's required input out (a step // just added, before it is filled in) is held, not sent: the server would // refuse the flow with a 422 naming that input. The step's inspector hints the @@ -10010,7 +10120,7 @@ export function AutomationsPillar({ target: `flow:${current?.name ?? ''}`, loadedFor: draftFor, dirty: autoDirty, - blocked: !current || !isEditable || !!saving || readOnly || flowIncomplete !== null, + blocked: !current || !isEditable || !!saving || editLocked || restoring || flowIncomplete !== null, snapshot: draft, save: doSave, }); @@ -10055,7 +10165,7 @@ export function AutomationsPillar({ // objectui#11773 — a reload replaced the buffer the flip was taken on. if ((await saveFlowDraft('flow', flowName, nextDraft, { mode: 'draft', packageId: draftPackageId, surface: 'studio' })) === 'reloaded') return; setHasDraft(true); - rereadFlowVerdict(flowName); + rereadFlowVerdict(flowName, draftPackageId); onDraftSaved?.({ type: 'flow', name: flowName }); toast.success(next ? t('engine.studio.auto.enabledToast', locale) : t('engine.studio.auto.disabledToast', locale)); } catch (e) { @@ -10121,6 +10231,48 @@ export function AutomationsPillar({ onDraftSaved?.({ type: 'flow', name: flow.name }); toast.success(tFormat('engine.studio.auto.flowDeleted', locale, { item: flow.label || flow.name })); }, [client, current, packageId, hasDraft, onDraftSaved, locale]); + // objectstack#28570 (ADR-0146 D2) — "Restore package original" on a flow + // the pair overrides: the pair's draft of the flow, then its row, deleted + // at the pair. The pair is the door's answer this flow's saves named, else + // the surface's; never asked of the door again. The base's flow reads back + // and runs again under the same ledger row, and the server's refusal is + // shown as it is sent. + const restoreFlow = React.useCallback(async () => { + const custom = customizationRef.current; + if (!current || !custom) return; + const flow = current; + const held = pairsByFlowRef.current.get(flow.name); + const pair = (held ? await held.catch(() => null) : null) ?? custom.pairId; + if (!pair) return; + setRestoring(true); + setAutoDirty(false); + setError(null); + try { + await client.reset('flow', flow.name, { state: 'draft', packageId: pair }); + await client.reset('flow', flow.name, { packageId: pair }); + } catch (e) { + setError(plainRefusal(e)); + return; + } finally { + setRestoring(false); + } + pairsByFlowRef.current.delete(flow.name); + setCustomizeAsked((prev) => { + if (!prev.has(flow.name)) return prev; + const next = new Set(prev); + next.delete(flow.name); + return next; + }); + reloadFlowDraft(); + setServedNonce((n) => n + 1); + // The base's flow is armed again: the engine's rows are read again. + setStatusNonce((n) => n + 1); + emitMetadataRefresh(); + onDraftSaved?.({ type: 'flow', name: flow.name }); + toast.success( + tFormat('engine.studio.customize.restoreDone', locale, { item: flow.label || flow.name, package: custom.baseLabel }), + ); + }, [client, current, reloadFlowDraft, onDraftSaved, locale]); // objectstack#28567 (ADR-0146 D4) — "New similar" saved a copy. It opens // only where the door's answer says it landed: in this pillar after a // re-read when that is this package, else that package's Automations pillar @@ -10252,8 +10404,9 @@ export function AutomationsPillar({ onClearSelection: () => setSelection(null), onSelectionChange: setSelection, // objectui#11124 — the pillar's real flag, threaded exactly as - // the Data pillar threads it (objectui#2259). - readOnly, + // the Data pillar threads it (objectui#2259). objectstack#28570 — + // a customized flow's editors are open. + readOnly: editLocked, locale, })} @@ -10292,7 +10445,12 @@ export function AutomationsPillar({ locale={locale} onToggle={() => void toggleLedger()} /> - ) : current && flowLoaded + ) : current && flowLoaded && flowCustomizable && (flowInPair || flowUnlocked) + ? // objectstack#28570 (ADR-0146 D9 item 3) — a customized flow is + // switched by the ledger row alone; no definition switch is drawn for + // it, and Studio never writes its `status` to mean on or off. + null + : current && flowLoaded ? (() => { const defaultOff = t('engine.studio.auto.defaultOff', locale); // objectstack#27356 — a read-only package's switch cannot be flipped, @@ -10452,7 +10610,7 @@ export function AutomationsPillar({ // link there truncated the flow's label; it sits at the tab row's right // end, the slot objectui#12456 gave the Access note for the same reason. const autoReadOnlyLinkEl = - readOnly && packageId !== null ? ( + editLocked && packageId !== null ? ( ) : null; const flowTabsEl = @@ -10489,6 +10647,17 @@ export function AutomationsPillar({ return (
{flowConflictDialog} + {/* objectstack#28570 — Restore's confirmation, outside the menu that opens it. */} + void restoreFlow()} + />
{isMobile && railOpen && (
{t('engine.studio.auto.designersMissing', locale)} ) } - chips={current?.addedIn ? : undefined} + chips={ + current?.addedIn ? ( + + ) : flowCustomizable && flowServed.declaration ? ( + // objectstack#28570 — the override the server serves, never a computation. + + ) : undefined + } status={flowDraftStatus} actions={ <> @@ -10656,6 +10836,17 @@ export function AutomationsPillar({ {t('engine.studio.autoSaving', locale)} )} + {/* objectstack#28570 — Customize unlocks the flow's editors; nothing is saved until an edit. */} + {current && flowLoaded && flowCustomizable && !flowUnlocked && !flowInPair && ( + + setCustomizeAsked((prev) => (prev.has(current.name) ? prev : new Set(prev).add(current.name))) + } + /> + )} {flowSwitchEl} {/* objectstack#28239 — the item tools Setup's item page opens. */} {current && flowLoaded && ( @@ -10690,6 +10881,16 @@ export function AutomationsPillar({ locale={locale} disabled={!!saving} onDelete={readOnly ? undefined : () => void deleteOpenFlow()} + // objectstack#28570 — for a flow the pair overrides, read from the served declaration. + restore={ + flowInPair + ? { + disabled: + restoring || (customizePair === null && !pairsByFlowRef.current.has(current.name)), + onSelect: () => setRestoreOpen(true), + } + : undefined + } newSimilar={{ target: readOnly ? undefined : packageId, blocked: canAuthorFlows ? null : t('engine.edit.capabilityReadOnly', locale), @@ -10837,7 +11038,7 @@ export function AutomationsPillar({ // the preview contract the canvas is then read-only (no add, // insert, drag or delete — each a doomed write), while node and // edge selection still open the inspector read-only below. - onPatch: readOnly ? undefined : onPatch, + onPatch: editLocked ? undefined : onPatch, locale, // objectstack#27799 — the Problems list reads the verdict the inspectors read. diagnostics: flowPreviewDiagnostics, @@ -10895,7 +11096,14 @@ export function AutomationsPillar({ onClose={selection ? () => setSelection(null) : undefined} closeLabel={t('engine.studio.deselect', locale)} // objectui#12453 — a read-only package saves no draft: no footer. - footer={readOnly ? null : t('engine.studio.inspector.draftFooter', locale)} + // objectstack#28570 — a customized flow saves into the pair. + footer={ + editLocked + ? null + : readOnly + ? t('engine.studio.customize.draftFooter', locale) + : t('engine.studio.inspector.draftFooter', locale) + } // objectui#12454 — a selection's inspector brings its own padding // (the metadata inspector shell); only the empty sentence takes the aside's. bodyClassName={heading ? 'p-0' : undefined} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index e707b47672a..8c4de9bdf49 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3207,6 +3207,7 @@ const STUDIO_EN: Record = { 'engine.studio.customize.restore': 'Restore package original', 'engine.studio.customize.restoreConfirm': 'Restore {package}’s “{item}”?', 'engine.studio.customize.restoreBody': 'This deployment’s customization of “{item}” is deleted, and {package}’s version is served again.', + 'engine.studio.customize.restoreFlowBody': 'This deployment’s customization of “{item}” is deleted, and {package}’s flow runs again in its place, under the same on/off switch.', 'engine.studio.customize.restoreDraft': 'Its unpublished draft is discarded first.', 'engine.studio.customize.restoreDone': '“{item}” is back to {package}’s version', // objectstack#28572 — one surface for a read-only base and its customization package. diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 5c6e6610e62..e2eacc1902d 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2841,6 +2841,7 @@ const STUDIO_ZH: Record = { 'engine.studio.customize.restore': '恢复软件包原版', 'engine.studio.customize.restoreConfirm': '恢复 {package} 的「{item}」?', 'engine.studio.customize.restoreBody': '本部署对「{item}」的自定义会被删除,重新使用 {package} 的版本。', + 'engine.studio.customize.restoreFlowBody': '本部署对「{item}」的自定义会被删除,{package} 的流程重新替它运行,启用开关保持不变。', 'engine.studio.customize.restoreDraft': '会先丢弃它未发布的草稿。', 'engine.studio.customize.restoreDone': '「{item}」已恢复为 {package} 的版本', // objectstack#28572 — 只读基础包与其定制包合为一个界面。 From 47ed4fb8d8697279247e0bdc36edbb362888d62e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 20:59:57 +0000 Subject: [PATCH 2/5] test(app-shell): pin Studio's flow customization on a read-only base Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRWZNXu1HAHuMowQXS --- .changeset/28570-studio-customize-flow.md | 19 + ...mationsPillar.customizeFlow-28570.test.tsx | 447 ++++++++++++++++++ 2 files changed, 466 insertions(+) create mode 100644 .changeset/28570-studio-customize-flow.md create mode 100644 ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx diff --git a/.changeset/28570-studio-customize-flow.md b/.changeset/28570-studio-customize-flow.md new file mode 100644 index 00000000000..c44d93c197d --- /dev/null +++ b/.changeset/28570-studio-customize-flow.md @@ -0,0 +1,19 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(studio): customize a read-only base's flow into its pair under the flow's own name, with one ledger switch, the override badge and "Restore package original" + +Clause-②: no +Metadata-protocol: none + +On a read-only base, Studio's Automations pillar now customizes a flow the way the Interfaces pillar customizes a view, a dashboard or a report (ADR-0146 D9 items 1 to 3, D2, D4). + +- **Customize this flow.** The flow's header offers Customize, with the same control, the same ensure door (`POST /api/v1/packages/:id/ensure-customization`) and the same disabled reasons as the other customizable types. A walled tenant admin sees it disabled. Pressing it only unlocks the flow's editors. Nothing is written until an edit is saved. +- **The first saved edit** asks the ensure door once and writes the flow's draft into the package that answer names, under the flow's own name. The body is the served flow without its `_` keys, declaring `overrides: { package, basedOn }`. The designer then edits that draft. The rest of the package stays read-only. +- **One switch.** The open override keeps the activation-ledger switch on the flow's name. No definition switch is drawn for it, and Studio never writes the override's `status` to mean on or off. +- **The badge.** The header carries the override badge, read from the served declaration (the pair's draft, else the layered read's `overlay`). +- **Restore package original.** The flow's menu offers it for an override. Whether the flow is overridden is read from the declared override's own row, never from the served envelope's `deletable`, which follows the base's seal. The confirmation says the base's flow runs again under the same switch. Confirming deletes the pair's draft and then the pair's row, and the base's flow reads back. Delete flow stays on a writable package's own flow. +- **Refusals.** A refused save into the pair, and a refused publish of it, read as the server sent them. +- **New string.** `@object-ui/i18n`'s studio namespace gains `engine.studio.customize.restoreFlowBody` in `en` and `zh`. The other languages fall back to `en`, as the whole namespace does. 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 new file mode 100644 index 00000000000..b9f9a3ab19d --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx @@ -0,0 +1,447 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28570 (ADR-0146 D9 items 1–3, D2, D4) — on a read-only base, + * the Automations pillar customizes a flow into the base's paired + * customization package, under the flow's own name, as a declared override. + * + * Through the mounted pillar, its real autosave, the real header controls and + * the real ensure-door client (`ensureCustomizationPackage`) over a fetch stub + * that also serves the `/automation` doors. The metadata client is a server + * double holding the base's flow, the pair's row and the pair's draft, and + * recording every write. The pillar is handed what the Studio surface hands + * it (`StudioBaseCustomization`) by a host that takes the pair's id from the + * door's answer, as `InterfacesPillar.customize-28562`'s does. + * + * Pinned: + * - Customize is offered on the base's flow and writes nothing; the first + * saved edit asks the door once and writes the pair's draft under the + * flow's name, the served body without `_` keys, declaring the override; + * no ledger row is written and the base is untouched; + * - the open override keeps the ledger switch on the flow's name, and no + * definition switch is drawn; the badge reads the served declaration; + * - Restore is decided from the declared override's own row even though + * the served envelope says `deletable: false`; it deletes the pair's + * draft and row, and the base's flow reads back; + * - a walled tenant admin sees Customize disabled, and nothing is asked; + * - a refused save and a refused publish read as the server sent them; + * - CONTROLS: the base's flow with no override offers no Restore; a + * writable package's flow keeps Delete flow and its definition switch. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const BASE = 'com.example.hotcrm'; +const PAIR = 'com.example.hotcrm.custom'; +const FLOW_NAME = 'notify_owner'; + +/** The base's flow as its read serves it: the protection envelope rides along. */ +const BASE_FLOW: Record = { + name: FLOW_NAME, + label: 'Notify owner', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + _packageId: BASE, + _lock: 'full', +}; +const DECLARATION = { package: BASE, basedOn: '4.0.0' }; +const strip = (row: Record) => + Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith('_'))); + +// ── The published posture facts ─────────────────────────────────────────── + +let posture: string | undefined; +let operator: boolean | undefined; + +vi.mock('../../hooks/useTenancyPosture', async (importOriginal) => ({ + ...(await importOriginal>()), + useTenancyPosture: () => posture, +})); +vi.mock('@object-ui/auth', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useAuth: () => ({ + ...mod.useAuth(), + user: { id: 'u_caller', name: 'Caller', email: 'caller@example.com', isPlatformAdmin: operator }, + }), + }; +}); + +// ── The metadata server, doubled at the client ───────────────────────────── + +const srv = vi.hoisted(() => ({ + base: null as Record | null, + pairActive: null as Record | null, + pairDraft: null as Record | null, + pairExists: false, + ensure: [] as number[], + saveRefusal: null as null | { status: number; message: string }, + saves: [] as Array<{ name: string; body: Record; opts: Record }>, + resets: [] as Array<{ name: string; opts: Record }>, + version: 0, +})); + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async (type: string, opts: { packageId?: string } = {}) => + type === 'flow' && opts.packageId !== PAIR ? [{ name: FLOW_NAME, label: 'Notify owner' }] : [], + ), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + // N-11: the pair's row is served over the base's flow, in the base's slot, + // and the envelope keeps following the base's seal (`deletable: false`). + layered: vi.fn(async () => ({ + code: srv.base ? clone(srv.base) : null, + overlay: srv.pairActive ? clone({ ...srv.pairActive, _packageId: BASE }) : null, + overlayScope: srv.pairActive ? 'env' : null, + effective: clone(srv.pairActive ? { ...srv.pairActive, _packageId: BASE } : srv.base), + lock: 'full', + editable: false, + deletable: false, + resettable: false, + })), + getDraft: vi.fn(async (type: string, name: string, opts: { packageId?: string } = {}) => { + if (opts.packageId === PAIR && srv.pairDraft) { + return { type, name, item: clone(srv.pairDraft), version: `v${srv.version}` }; + } + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async (type: string, name: string, item: unknown, opts: Record = {}) => { + srv.saves.push({ name, body: clone(item) as Record, opts: clone(opts) }); + if (srv.saveRefusal) { + throw Object.assign(new Error(srv.saveRefusal.message), { status: srv.saveRefusal.status }); + } + if (opts.packageId !== PAIR) { + throw Object.assign(new Error(`Package '${String(opts.packageId)}' is read-only.`), { status: 403 }); + } + srv.pairDraft = clone(item) as Record; + srv.version += 1; + return { success: true, version: `v${srv.version}`, state: 'draft' }; + }), + reset: vi.fn(async (type: string, name: string, opts: Record = {}) => { + srv.resets.push({ name, opts: clone(opts) }); + if (opts.packageId === PAIR) { + if (opts.state === 'draft') srv.pairDraft = null; + else srv.pairActive = null; + } + return { success: true }; + }), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } })); + +import { AutomationsPillar, type StudioBaseCustomization } from './StudioDesignSurface'; +import { ensureCustomizationPackage } from './packages-io'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { publishRefusal } from './metadataError'; +import { t, tFormat } from '../metadata-admin/i18n'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; + +const dataSource = createEmptyDataSource(); + +/** The flow canvas, reduced to its label and one in-place edit. */ +function FlowCanvas({ draft, onPatch }: { draft: Record; onPatch?: (patch: Record) => void }) { + return ( +
+

{String(draft?.label ?? '')}

+ +
+ ); +} + +// ── The ensure door and the `/automation` doors, doubled at fetch ─────────── + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = new URL(String(input), 'http://localhost').pathname; + if (path.endsWith('/automation/_status')) { + return json({ success: true, data: { flows: [{ name: FLOW_NAME, enabled: true, bound: true }] } }); + } + if (path.endsWith('/toggle')) return json({ success: true, data: { name: FLOW_NAME, enabled: false } }); + if (path === `/api/v1/packages/${BASE}/ensure-customization` && init?.method === 'POST') { + const created = !srv.pairExists; + srv.pairExists = true; + srv.ensure.push(created ? 201 : 200); + return json( + { + success: true, + data: { basePackageId: BASE, packageId: PAIR, namespace: 'hotcrm_custom', dependencyRange: '^4', created, writable: true }, + }, + created ? 201 : 200, + ); + } + return json(null, 404); +}); +const calls = (suffix: string) => fetchMock.mock.calls.filter(([u]) => String(u).endsWith(suffix)); + +beforeAll(() => { + registerMetadataPreview('flow', FlowCanvas as never); +}); + +beforeEach(() => { + posture = 'single'; + operator = false; + srv.base = clone(BASE_FLOW); + srv.pairActive = null; + srv.pairDraft = null; + srv.pairExists = false; + srv.ensure.length = 0; + srv.saveRefusal = null; + srv.saves.length = 0; + srv.resets.length = 0; + srv.version = 0; + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); + fetchMock.mockClear(); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** What the Studio surface hands the pillar on a read-only base: the pair's id comes from the door's answer. */ +function Host({ + readOnly = true, + walled = false, + listedPair = null, +}: { + readOnly?: boolean; + walled?: boolean; + listedPair?: string | null; +}) { + const [pairId, setPairId] = React.useState(listedPair); + const ensurePackage = React.useCallback(async () => { + const answer = await ensureCustomizationPackage(BASE); + setPairId(answer.packageId); + return answer; + }, []); + const ensure = React.useCallback(async () => (await ensurePackage()).packageId, [ensurePackage]); + const customization = React.useMemo( + () => + readOnly + ? { base: BASE, baseLabel: 'HotCRM', basedOn: '4.0.0', walled, pairId, ensure, ensurePackage, marks: { status: 'off' } } + : null, + [readOnly, walled, pairId, ensure, ensurePackage], + ); + return ; +} + +function mount(props: React.ComponentProps = {}) { + return render( + + + , + ); +} + +const header = () => screen.getByTestId('auto-flow-header'); +const patchFlow = () => fireEvent.click(screen.getByRole('button', { name: 'patch the flow' })); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function openFlow(label = /^Notify owner/): Promise { + await waitFor(() => expect(screen.getByTestId('flow-label')).toHaveTextContent(label), { timeout: 8000 }); +} + +async function customize(): Promise { + fireEvent.click(await within(header()).findByTestId('studio-customize', undefined, { timeout: 8000 })); + await waitFor(() => expect(within(header()).queryByTestId('studio-customize')).toBeNull()); +} + +async function openMenu(): Promise { + fireEvent.pointerDown(await screen.findByTestId('auto-item-more', undefined, { timeout: 8000 }), { button: 0, ctrlKey: false }); + await screen.findByTestId('auto-item-new-similar'); +} + +/** 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 }); + expect(ledger).toHaveAttribute('aria-checked', 'true'); + expect(within(header()).getAllByRole('switch')).toEqual([ledger]); + expect(header()).not.toHaveTextContent(t('engine.studio.auto.enabled', 'en')); +} + +describe('Customize a read-only base’s flow into its pair (objectstack#28570)', () => { + it('Customize is offered and writes nothing; until it is pressed the canvas edits nothing', async () => { + mount(); + await openFlow(); + expect(within(header()).getByTestId('studio-customize')).toBeEnabled(); + + patchFlow(); + expect(screen.getByTestId('flow-label')).toHaveTextContent(/^Notify owner$/); + + await customize(); + await sleep(1500); + expect(srv.ensure).toEqual([]); + expect(srv.saves).toEqual([]); + expect(within(header()).queryByTestId('studio-override-chip')).toBeNull(); + // Unlocked, it is switched by the ledger row alone. + await expectLedgerSwitchAlone(); + }); + + it('the first saved edit asks the door once and writes the pair’s draft under the flow’s name, declaring the override; no ledger row is written', async () => { + mount(); + await openFlow(); + await customize(); + + patchFlow(); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + expect(srv.ensure).toEqual([201]); + const [saved] = srv.saves; + expect(saved!.name).toBe(FLOW_NAME); + expect(saved!.opts).toMatchObject({ mode: 'draft', packageId: PAIR, surface: 'studio', ifNoneMatch: '*' }); + expect(saved!.body).toStrictEqual({ ...strip(BASE_FLOW), label: 'Notify owner (ours)', overrides: DECLARATION }); + // The base's flow is untouched, and nothing reached the ledger door. + expect(srv.base).toStrictEqual(BASE_FLOW); + expect(calls('/toggle')).toHaveLength(0); + + // 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' })); + await expectLedgerSwitchAlone(); + + // A later edit asks the door no more, and is pinned to the version it was answered. + patchFlow(); + await waitFor(() => expect(srv.saves).toHaveLength(2), { timeout: 8000 }); + expect(srv.ensure).toEqual([201]); + expect(srv.saves[1]!.opts).toMatchObject({ packageId: PAIR, ifMatch: 'v1' }); + expect(srv.saves[1]!.body.overrides).toEqual(DECLARATION); + expect(calls('/toggle')).toHaveLength(0); + }); + + it('a published override keeps the ledger switch and draws no definition switch; Restore is read from its row, not from the envelope', async () => { + srv.pairActive = { ...strip(BASE_FLOW), label: 'Notify owner and manager', overrides: DECLARATION }; + srv.pairExists = true; + mount({ listedPair: PAIR }); + await openFlow(/^Notify owner and manager$/); + + expect(await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 })).toBeInTheDocument(); + expect(within(header()).queryByTestId('studio-customize')).toBeNull(); + await expectLedgerSwitchAlone(); + + // The layered envelope says `deletable: false`; the declared row decides. + 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(); + + // Its editors are open: an edit is saved into the pair, keeping the declaration. + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }); + patchFlow(); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + expect(srv.saves[0]!.opts.packageId).toBe(PAIR); + expect(srv.saves[0]!.body.overrides).toEqual(DECLARATION); + expect(srv.ensure).toEqual([200]); + }); + + it('Restore confirms that the base’s flow runs again, deletes the pair’s draft and row, and the base’s flow reads back', async () => { + srv.pairActive = { ...strip(BASE_FLOW), label: 'Notify owner and manager', overrides: DECLARATION }; + srv.pairDraft = { ...strip(BASE_FLOW), label: 'Notify owner and manager v2', overrides: DECLARATION }; + srv.pairExists = true; + mount({ listedPair: PAIR }); + await openFlow(/^Notify owner and manager v2$/); + + await openMenu(); + fireEvent.click(screen.getByTestId('studio-restore-original')); + const dialog = await screen.findByTestId('studio-restore-original-dialog'); + const vars = { item: 'Notify owner', 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')); + fireEvent.click(within(dialog).getByTestId('studio-restore-original-confirm')); + + await waitFor(() => expect(srv.resets).toHaveLength(2), { timeout: 8000 }); + expect(srv.resets).toEqual([ + { name: FLOW_NAME, opts: { state: 'draft', packageId: PAIR } }, + { name: FLOW_NAME, opts: { packageId: PAIR } }, + ]); + await openFlow(/^Notify owner$/); + await waitFor(() => expect(within(header()).queryByTestId('studio-override-chip')).toBeNull(), { timeout: 8000 }); + expect(await within(header()).findByTestId('studio-customize')).toBeEnabled(); + expect(srv.saves).toEqual([]); + expect(calls('/toggle')).toHaveLength(0); + }); + + it('a walled tenant admin sees Customize disabled, with the reason; nothing is asked or sent', async () => { + posture = 'group'; + operator = false; + mount({ walled: true }); + await openFlow(); + const button = within(header()).getByTestId('studio-customize'); + expect(button).toBeDisabled(); + expect(within(header()).getByTestId('studio-customize-blocked')).toHaveAttribute( + 'title', + t('engine.studio.customize.walled', 'en'), + ); + fireEvent.click(button); + patchFlow(); + await sleep(1500); + expect(screen.getByTestId('flow-label')).toHaveTextContent(/^Notify owner$/); + expect(srv.ensure).toEqual([]); + expect(srv.saves).toEqual([]); + }); + + it('a refused save into the pair is shown as the server sent it', async () => { + const sentence = "Package 'com.example.hotcrm.custom' cannot declare an override of 'com.example.hotcrm' for flow 'notify_owner'."; + srv.saveRefusal = { status: 403, message: sentence }; + mount(); + await openFlow(); + await customize(); + patchFlow(); + expect(await screen.findByTestId('studio-refusal-message', undefined, { timeout: 8000 })).toHaveTextContent(sentence); + expect(within(header()).queryByTestId('studio-override-chip')).toBeNull(); + }); + + it('a refused publish of the pair’s flow reads with the server’s sentence as sent', () => { + const sentence = "flow 'notify_owner' declares an override of 'com.example.hotcrm' at 3.0.0, but 4.0.0 is installed."; + const refusal = publishRefusal({ type: 'flow', name: FLOW_NAME, error: sentence }, 'flow', null, 'en'); + expect(refusal.message).toBe(tFormat('engine.studio.refusal.publishPlain', 'en', { reason: sentence })); + expect(refusal.message).toContain(sentence); + }); + + it('CONTROL: the base’s flow with no override offers no Restore', async () => { + mount({ listedPair: PAIR }); + await openFlow(); + await openMenu(); + expect(screen.queryByTestId('studio-restore-original')).toBeNull(); + expect(screen.queryByTestId('auto-item-delete')).toBeNull(); + }); + + it('CONTROL: a writable package’s flow keeps Delete flow and its definition switch, with no Customize', async () => { + mount({ readOnly: false }); + await openFlow(); + expect(within(header()).queryByTestId('studio-customize')).toBeNull(); + expect(within(header()).queryByTestId('auto-ledger-switch')).toBeNull(); + expect(within(header()).getByRole('switch')).toHaveTextContent(t('engine.studio.auto.enabled', 'en')); + await openMenu(); + expect(screen.getByTestId('auto-item-delete')).toBeInTheDocument(); + expect(screen.queryByTestId('studio-restore-original')).toBeNull(); + }); +}); From 8df87c687a329d64247bea7858d6f702d737ad82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 21:11:07 +0000 Subject: [PATCH 3/5] fix(app-shell): read no ref while rendering the flow menu's Restore Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRWZNXu1HAHuMowQXS --- .../app-shell/src/views/studio-design/StudioDesignSurface.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index ff799334f01..d002f5d2ae7 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -10885,8 +10885,7 @@ export function AutomationsPillar({ restore={ flowInPair ? { - disabled: - restoring || (customizePair === null && !pairsByFlowRef.current.has(current.name)), + disabled: restoring, onSelect: () => setRestoreOpen(true), } : undefined From 92ba27ebc5e8144ab8addde8674782658a00c746 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 21:16:24 +0000 Subject: [PATCH 4/5] test(app-shell): a customized flow with no ledger row draws no switch Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRWZNXu1HAHuMowQXS --- ...mationsPillar.customizeFlow-28570.test.tsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) 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 b9f9a3ab19d..a100667d7bc 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 @@ -89,6 +89,8 @@ const srv = vi.hoisted(() => ({ saves: [] as Array<{ name: string; body: Record; opts: Record }>, resets: [] as Array<{ name: string; opts: Record }>, version: 0, + /** Whether the engine's `_status` answer holds a row for the flow. */ + statusRow: true, })); const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; @@ -181,7 +183,7 @@ const json = (body: unknown, status = 200) => const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const path = new URL(String(input), 'http://localhost').pathname; if (path.endsWith('/automation/_status')) { - return json({ success: true, data: { flows: [{ name: FLOW_NAME, enabled: true, bound: true }] } }); + return json({ success: true, data: { flows: srv.statusRow ? [{ name: FLOW_NAME, enabled: true, bound: true }] : [] } }); } if (path.endsWith('/toggle')) return json({ success: true, data: { name: FLOW_NAME, enabled: false } }); if (path === `/api/v1/packages/${BASE}/ensure-customization` && init?.method === 'POST') { @@ -216,6 +218,7 @@ beforeEach(() => { srv.saves.length = 0; srv.resets.length = 0; srv.version = 0; + srv.statusRow = true; for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); fetchMock.mockClear(); vi.stubGlobal('fetch', fetchMock); @@ -389,6 +392,25 @@ describe('Customize a read-only base’s flow into its pair (objectstack#28570)' expect(calls('/toggle')).toHaveLength(0); }); + it('with no ledger row served, a customized flow draws no switch at all, where the base’s own flow keeps its locked one', async () => { + srv.statusRow = false; + mount(); + await openFlow(); + // CONTROL: the base's flow, not customized, keeps the read-only definition switch. + expect(within(header()).getByRole('switch')).toBeDisabled(); + expect(within(header()).queryByTestId('auto-ledger-switch')).toBeNull(); + + await customize(); + expect(within(header()).queryByRole('switch')).toBeNull(); + patchFlow(); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 }); + expect(within(header()).queryByRole('switch')).toBeNull(); + // Studio never writes the override's status to mean on or off. + expect(srv.saves[0]!.body.status).toBe(BASE_FLOW.status); + expect(calls('/toggle')).toHaveLength(0); + }); + it('a walled tenant admin sees Customize disabled, with the reason; nothing is asked or sent', async () => { posture = 'group'; operator = false; From 146e0b60969ce2b9f06c599ad7a520e507792862 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 21:46:18 +0000 Subject: [PATCH 5/5] test(app-shell): name the unused type parameters of the flow customization doubles Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRWZNXu1HAHuMowQXS --- .../AutomationsPillar.customizeFlow-28570.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a100667d7bc..2f6b8215e0c 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 @@ -121,7 +121,7 @@ const mockClient = vi.hoisted(() => ({ } throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); }), - save: vi.fn(async (type: string, name: string, item: unknown, opts: Record = {}) => { + save: vi.fn(async (_type: string, name: string, item: unknown, opts: Record = {}) => { srv.saves.push({ name, body: clone(item) as Record, opts: clone(opts) }); if (srv.saveRefusal) { throw Object.assign(new Error(srv.saveRefusal.message), { status: srv.saveRefusal.status }); @@ -133,7 +133,7 @@ const mockClient = vi.hoisted(() => ({ srv.version += 1; return { success: true, version: `v${srv.version}`, state: 'draft' }; }), - reset: vi.fn(async (type: string, name: string, opts: Record = {}) => { + reset: vi.fn(async (_type: string, name: string, opts: Record = {}) => { srv.resets.push({ name, opts: clone(opts) }); if (opts.packageId === PAIR) { if (opts.state === 'draft') srv.pairDraft = null;