From ad380ce46dbbc2512e6b0fe9d651d461324075e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 09:49:13 +0000 Subject: [PATCH] fix(app-shell): packaged flow rows keep their height; the platform reason opens from a neutral chip (objectui#12609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime's `reason` sentence for an enabled-but-unarmed flow rendered as a paragraph inside the Setup › Packaged automation table row, growing each such row to several lines. It now opens, verbatim and muted, from a popover behind a "Details" chip on the flow name's line. The chip's label and look follow only whether a reason was sent, never what it says: the contract renders the field and never parses it, so nothing here names a cause. One i18n key, `packagedAutomation.reasonDetails`, in all ten packs. Claude-Session: https://claude.ai/code/session_011K9g23W67dy2zwnyzaoB6o Co-authored-by: Claude --- .changeset/12609-packaged-flow-reason-chip.md | 12 +++ .../setup/PackagedAutomationPage.test.tsx | 97 +++++++++++++------ .../views/setup/PackagedAutomationPage.tsx | 83 +++++++++++++--- packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 6 ++ packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + 13 files changed, 166 insertions(+), 41 deletions(-) create mode 100644 .changeset/12609-packaged-flow-reason-chip.md diff --git a/.changeset/12609-packaged-flow-reason-chip.md b/.changeset/12609-packaged-flow-reason-chip.md new file mode 100644 index 0000000000..c68e75c3aa --- /dev/null +++ b/.changeset/12609-packaged-flow-reason-chip.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +Setup › Packaged automation keeps a flow row to the table's height when the platform says why the flow is not armed (objectui#12609). + +The runtime's `reason` sentence for an enabled flow that is not armed used to render as a paragraph under the flow's name, so every such row grew to several lines and the table stopped scanning as a table. The sentence now opens from a small "Details" chip on the name's line, in a popover that shows it whole and unedited, in muted text. A row without a reason shows no chip and renders as before. + +The chip does not say what the reason is. Its label is the same on every row that carries a reason, whatever the sentence says, because the contract says the field is rendered and never parsed, and a deployment-policy row and a binding failure arrive in the same shape. The activation switch still shows the flow's `enabled` field, as before. + +**Clause-②: yes (widening)**: `@object-ui/i18n`'s ten locale packs gain one key, `packagedAutomation.reasonDetails`. No export, prop or other key changes on either package. diff --git a/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx b/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx index 26a7cb35f2..b321640a61 100644 --- a/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx +++ b/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx @@ -3,7 +3,7 @@ /** * Setup › Packaged automation (ADR-0126 §7.4) — behaviour. * - * Five properties, in the order the page exercises them: + * Six properties, in the order the page exercises them: * * 1. **scope** — the list is packaged flows only. The rule itself lives in * `packagedFlows.ts` and is measured directly in `packagedFlows.test.ts`; @@ -29,6 +29,9 @@ * platform's sentence for why the flow is not armed. It is shown VERBATIM * and muted, never as an alert, whether it names the deployment policy or * a real binding failure; a row without one renders exactly as before. + * Since objectui#12609 the sentence is not in the row at all: it opens + * from a neutral chip on the name's line, the same chip whatever the + * sentence says, so the row keeps the table's height. */ import '@testing-library/jest-dom/vitest'; @@ -469,54 +472,85 @@ describe('the platform reason for an unbound flow', () => { return cell; } + /** The neutral reason chip of ONE named row, or `null` when it has none. */ + function chipIn(flowName: string): HTMLElement | null { + return within(screen.getByTestId(`packaged-flow-${flowName}`)).queryByRole('button', { + name: 'Details', + }); + } + + /** Open a row's chip and return the disclosure it opened. */ + async function openReason(flowName: string): Promise { + const chip = chipIn(flowName); + if (!chip) throw new Error(`no reason chip on ${flowName}`); + fireEvent.click(chip); + return screen.findByRole('dialog', { name: 'Details' }); + } + + /** Add one unarmed packaged flow, carrying `reason`, to the fixture. */ + function withReasonRow(name: string, label: string, triggerType: string, reason: string) { + fixture.runtime = [...fixture.runtime, { name, enabled: true, bound: false, triggerType, reason }]; + fixture.meta = [...fixture.meta, packagedItem(name, label)]; + } + /** - * The one assertion every reason row owes: the platform's sentence is in its - * row, whole and unedited, in muted text — and nothing in the row is an alert - * or destructive-styled. + * What every reason row owes (objectui#9217, objectui#12609). The ROW holds + * no paragraph and none of the sentence, so it keeps the table's height. Its + * chip opens a disclosure holding the platform's sentence and nothing else: + * whole, unedited and muted. Nothing in the row or the disclosure is an + * alert or destructive-styled. */ - function expectMutedVerbatim(flowName: string, sentence: string) { + async function expectBehindChipVerbatim(flowName: string, sentence: string) { const row = screen.getByTestId(`packaged-flow-${flowName}`); - const shown = within(row).getByText(sentence); + expect(row.querySelectorAll('p')).toHaveLength(0); + expect(row.textContent).not.toContain(sentence); + expect(within(row).queryAllByRole('alert')).toHaveLength(0); + expect(row.querySelectorAll('.text-destructive')).toHaveLength(0); + + const disclosure = await openReason(flowName); + expect(disclosure.textContent).toBe(sentence); + const shown = within(disclosure).getByText(sentence); expect(shown.textContent).toBe(sentence); expect(shown).toHaveClass('text-muted-foreground'); expect(shown).not.toHaveClass('text-destructive'); - expect(within(row).queryAllByRole('alert')).toHaveLength(0); - expect(row.querySelectorAll('.text-destructive')).toHaveLength(0); + expect(within(disclosure).queryAllByRole('alert')).toHaveLength(0); } - it('shows a deployment-policy row with the platform sentence, muted, and not as a binding failure', async () => { - fixture.runtime = [ - ...fixture.runtime, - { name: 'pkg_digest', enabled: true, bound: false, triggerType: 'schedule', reason: POLICY_REASON }, - ]; - fixture.meta = [...fixture.meta, packagedItem('pkg_digest', 'Daily digest')]; + it('keeps a deployment-policy row to its name line; the sentence opens from the chip, whole and muted', async () => { + withReasonRow('pkg_digest', 'Daily digest', 'schedule', POLICY_REASON); await renderPage(); - expectMutedVerbatim('pkg_digest', POLICY_REASON); + await expectBehindChipVerbatim('pkg_digest', POLICY_REASON); // The page adds no failure wording of its own around the policy sentence. expect(screen.getByTestId('packaged-flow-pkg_digest').textContent).not.toMatch(/binding failed/i); // The flow's activation is still shown as the engine reports it. expect(screen.getByRole('switch', { name: 'Activation for Daily digest' })).toBeChecked(); }); - it('shows a real binding failure in the platform words too, muted (acceptance ②)', async () => { - fixture.runtime = [ - ...fixture.runtime, - { - name: 'pkg_sync', - enabled: true, - bound: false, - triggerType: 'record_change', - reason: BINDING_FAILED_REASON, - }, - ]; - fixture.meta = [...fixture.meta, packagedItem('pkg_sync', 'Sync accounts')]; + it('puts a real binding failure behind the same chip, in the platform words too (acceptance ②)', async () => { + withReasonRow('pkg_sync', 'Sync accounts', 'record_change', BINDING_FAILED_REASON); + await renderPage(); + + await expectBehindChipVerbatim('pkg_sync', BINDING_FAILED_REASON); + }); + + it('draws one chip that does not classify: a policy row and a binding failure show the same one', async () => { + withReasonRow('pkg_digest', 'Daily digest', 'schedule', POLICY_REASON); + withReasonRow('pkg_sync', 'Sync accounts', 'record_change', BINDING_FAILED_REASON); await renderPage(); - expectMutedVerbatim('pkg_sync', BINDING_FAILED_REASON); + const policy = chipIn('pkg_digest'); + const failure = chipIn('pkg_sync'); + expect(policy).not.toBeNull(); + expect(failure).not.toBeNull(); + // The chip's words and look follow "this row has a reason", never what the + // reason says: the two rows' chips are indistinguishable. + expect(policy!.textContent).toBe('Details'); + expect(failure!.textContent).toBe(policy!.textContent); + expect(failure!.className).toBe(policy!.className); }); - it('renders a row without a reason exactly as before, older backends included', async () => { + it('renders a row without a reason exactly as before, with no chip, older backends included', async () => { // `pkg_notify` (bound) and `pkg_escalate` (disabled) come from the default // fixture and carry no reason; `pkg_legacy` is an older backend's bare row. fixture.runtime = [...fixture.runtime, { name: 'pkg_legacy' }]; @@ -526,6 +560,9 @@ describe('the platform reason for an unbound flow', () => { expect(flowCell('pkg_notify').textContent).toBe('Notify ownerpkg_notify'); expect(flowCell('pkg_escalate').textContent).toBe('Escalate casepkg_escalate'); expect(flowCell('pkg_legacy').textContent).toBe('Legacy flowpkg_legacy'); + for (const name of ['pkg_notify', 'pkg_escalate', 'pkg_legacy']) { + expect(chipIn(name)).toBeNull(); + } }); it('ignores a reason that is not a string', async () => { @@ -543,5 +580,7 @@ describe('the platform reason for an unbound flow', () => { expect(flowCell('pkg_numeric').textContent).toBe('Numeric reasonpkg_numeric'); expect(flowCell('pkg_object').textContent).toBe('Object reasonpkg_object'); + expect(chipIn('pkg_numeric')).toBeNull(); + expect(chipIn('pkg_object')).toBeNull(); }); }); diff --git a/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx b/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx index 3c9713a98d..09f1c5c3b1 100644 --- a/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx +++ b/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx @@ -37,8 +37,10 @@ * `_status` carries both: §7.4 scopes it to what the activation ledger knows. * The Studio rail is where a flow's binding is diagnosed. * The one exception is an unbound row's `reason`: the platform's own sentence - * for why the flow is not armed, shown verbatim and muted under its name - * (objectui#9217) — never parsed, never styled as an error. + * for why the flow is not armed (objectui#9217), shown verbatim and muted in a + * disclosure behind a neutral chip after the flow's name (objectui#12609), so + * a row that carries one keeps the table's row height — never parsed, never + * styled as an error. `ReasonDetails` below carries the chip's rules. * * ## Server refusals reach the operator VERBATIM * @@ -73,7 +75,7 @@ */ import * as React from 'react'; -import { RefreshCw } from 'lucide-react'; +import { Info, RefreshCw } from 'lucide-react'; import { Badge, Button, @@ -88,6 +90,9 @@ import { EmptyTitle, Input, Label, + Popover, + PopoverContent, + PopoverTrigger, Skeleton, Switch, Table, @@ -170,6 +175,60 @@ interface CloneDraft { busy: boolean; } +/* -------------------------------------------------------------------------- */ +/* The reason disclosure */ +/* -------------------------------------------------------------------------- */ + +/** + * The chip an unarmed flow row's `reason` opens from (objectui#12609). + * + * The sentence used to render as a paragraph under the flow's name, which grew + * every row that carried one to several lines and stopped the table scanning + * as a table. It now sits behind this chip, inline on the name's line, so a + * row with a reason is as tall as a row without one. + * + * ⛔ The chip does NOT classify. Its label is the same generic word on every + * row that carries a reason, and whether it renders follows only "the runtime + * row sent a non-empty string `reason`" — never what the sentence says. The + * contract's own words on the field are "Consumers RENDER it; ⛔ do not parse + * it", and a deployment-policy row and a real binding failure arrive in the + * same `enabled: true, bound: false` shape. A chip that named which of the two + * a row is needs a structured field beside `reason` (objectstack#23842); until + * the wire carries one, nothing here names a cause. + * + * The disclosure shows the sentence verbatim and whole — not truncated, not + * reworded, not restyled by what it says — muted, and not as an alert. A + * popover rather than a tooltip: the sentence runs to several lines and can + * name an operator switch the admin needs to copy, so it opens on click, tap + * or keyboard and stays open to be read and selected. + */ +function ReasonDetails({ reason }: { reason: string }) { + const { t } = useObjectTranslation(); + const label = t('packagedAutomation.reasonDetails', { defaultValue: 'Details' }); + return ( + + + + + +

+ {reason} +

+
+
+ ); +} + /* -------------------------------------------------------------------------- */ /* Page */ /* -------------------------------------------------------------------------- */ @@ -422,16 +481,16 @@ export function PackagedAutomationPage() { return ( -
{row.label}
+
+ {row.label} + {/* The PLATFORM's sentence for why the engine has not + armed this flow (objectui#9217), behind a chip on the + name's own line so the row keeps its height + (objectui#12609). Present only when a reason was + sent; the chip reads the same whatever it says. */} + {row.reason && } +
{row.name} - {row.reason && ( - // The PLATFORM's sentence for why the engine has not - // armed this flow, verbatim. Muted and NOT an alert: a - // deployment-policy refusal names its operator switch in - // its own words, a binding failure says so in its own, - // and neither is re-styled or read here (objectui#9217). -

{row.reason}

- )} {refusal && ( // VERBATIM server refusal, next to the control that // caused it. `role="alert"` so it is announced. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 794ac2ed10..1fb10d2dba 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -4524,6 +4524,7 @@ const ar = { toggleFailed: "تعذّر تغيير التفعيل.", cloneFailedHttp: "تعذّر استنساخ هذا التدفق (HTTP {{status}}).", cloneFailed: "تعذّر استنساخ هذا التدفق.", + reasonDetails: "التفاصيل", actionsSubtitle: "إجراءات تأتي مع الحزم المثبّتة. أوقف أحدها في هذا النشر فيتوقف عن العمل في كل مكان يُعرض فيه. ويبقى بإمكانك إنشاء إجراء خاص بك إلى جانبه في Studio.", colAction: "الإجراء", colObject: "الكائن", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 3956dac602..8682f22b15 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -4144,6 +4144,7 @@ const de = { toggleFailed: "Aktivierung konnte nicht geändert werden.", cloneFailedHttp: "Dieser Flow konnte nicht geklont werden (HTTP {{status}}).", cloneFailed: "Dieser Flow konnte nicht geklont werden.", + reasonDetails: "Details", actionsSubtitle: "Aktionen aus installierten Paketen. Schalten Sie eine für diese Installation ab, und sie läuft überall dort nicht mehr, wo sie angeboten wird. Eine eigene Aktion daneben zu erstellen, bleibt Ihnen im Studio offen.", colAction: "Aktion", colObject: "Objekt", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index cd8052d7f3..c92511fd7a 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -4856,6 +4856,12 @@ const en = { toggleFailed: 'Could not change activation.', cloneFailedHttp: 'Could not clone this flow (HTTP {{status}}).', cloneFailed: 'Could not clone this flow.', + // The chip an unarmed flow row's platform sentence opens from + // (objectui#12609). ⛔ Generic on purpose: the same word on every row that + // carries a sentence, whatever the sentence says. The sentence itself is + // the server's and is never kept in a pack; a label naming its cause would + // be the console classifying a field the contract says not to parse. + reasonDetails: 'Details', // ── Packaged ACTIONS section (ADR-0126 §8 item 2, ruling 3) ──────────── // ⛔ No clone key here on purpose: the action-clone half is unchartered, diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 949e421657..8c8ab19382 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -4236,6 +4236,7 @@ const es = { toggleFailed: "No se pudo cambiar la activación.", cloneFailedHttp: "No se pudo clonar este flujo (HTTP {{status}}).", cloneFailed: "No se pudo clonar este flujo.", + reasonDetails: "Detalles", actionsSubtitle: "Acciones incluidas en los paquetes instalados. Desactiva una en esta implementación y dejará de ejecutarse en todos los lugares donde se ofrece. Crear tu propia acción junto a ella sigue estando disponible en Studio.", colAction: "Acción", colObject: "Objeto", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 080f300eb6..bb500f1442 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -4233,6 +4233,7 @@ const fr = { toggleFailed: "Impossible de modifier l'activation.", cloneFailedHttp: "Impossible de cloner ce flux (HTTP {{status}}).", cloneFailed: "Impossible de cloner ce flux.", + reasonDetails: "Détails", actionsSubtitle: "Actions fournies par les packages installés. Désactivez-en une pour ce déploiement et elle cesse de s'exécuter partout où elle est proposée. Vous pouvez toujours créer votre propre action à côté, dans Studio.", colAction: "Action", colObject: "Objet", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index e668abb214..ed77c28c7d 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -4145,6 +4145,7 @@ const ja = { toggleFailed: "有効状態を変更できませんでした。", cloneFailedHttp: "このフローをクローンできませんでした (HTTP {{status}})。", cloneFailed: "このフローをクローンできませんでした。", + reasonDetails: "詳細", actionsSubtitle: "インストール済みパッケージが提供するアクションです。このデプロイでオフにすると、提供されているすべての場所で実行されなくなります。自分のアクションを別途 Studio で作成することは引き続き可能です。", colAction: "アクション", colObject: "オブジェクト", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 7706bc5691..d29f1af1f8 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -4142,6 +4142,7 @@ const ko = { toggleFailed: "활성화 상태를 변경하지 못했습니다.", cloneFailedHttp: "이 플로우를 복제하지 못했습니다 (HTTP {{status}}).", cloneFailed: "이 플로우를 복제하지 못했습니다.", + reasonDetails: "세부 정보", actionsSubtitle: "설치된 패키지가 제공하는 액션입니다. 이 배포에서 끄면 액션이 제공되는 모든 곳에서 실행되지 않습니다. Studio에서 직접 액션을 따로 작성하는 것은 계속 가능합니다.", colAction: "액션", colObject: "오브젝트", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 066f10ab96..7c52708ced 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -4231,6 +4231,7 @@ const pt = { toggleFailed: "Não foi possível alterar a ativação.", cloneFailedHttp: "Não foi possível clonar este fluxo (HTTP {{status}}).", cloneFailed: "Não foi possível clonar este fluxo.", + reasonDetails: "Detalhes", actionsSubtitle: "Ações fornecidas pelos pacotes instalados. Desligue uma nesta implantação e ela deixa de ser executada em todos os lugares onde é oferecida. Criar sua própria ação ao lado dela continua disponível no Studio.", colAction: "Ação", colObject: "Objeto", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 69512e0bcf..6badb12ff5 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -4344,6 +4344,7 @@ const ru = { toggleFailed: "Не удалось изменить активацию.", cloneFailedHttp: "Не удалось клонировать этот поток (HTTP {{status}}).", cloneFailed: "Не удалось клонировать этот поток.", + reasonDetails: "Подробности", actionsSubtitle: "Действия, поставляемые установленными пакетами. Отключите действие в этой установке — и оно перестанет выполняться везде, где предлагается. Создать рядом собственное действие по-прежнему можно в Studio.", colAction: "Действие", colObject: "Объект", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 43391a79d2..bc182a385b 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -4203,6 +4203,7 @@ const zh = { toggleFailed: '无法更改启用状态。', cloneFailedHttp: '无法克隆此流程(HTTP {{status}})。', cloneFailed: '无法克隆此流程。', + reasonDetails: '详情', actionsSubtitle: '由已安装软件包提供的动作。在本部署中关闭后,它在所有提供它的位置都不再运行。你仍然可以在 Studio 中另行编写自己的动作。', colAction: '动作', colObject: '对象',