Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/12609-packaged-flow-reason-chip.md
Original file line number Diff line number Diff line change
@@ -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.
97 changes: 68 additions & 29 deletions packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand All @@ -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';
Expand Down Expand Up @@ -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<HTMLElement> {
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' }];
Expand All @@ -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 () => {
Expand All @@ -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();
});
});
83 changes: 71 additions & 12 deletions packages/app-shell/src/views/setup/PackagedAutomationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -73,7 +75,7 @@
*/

import * as React from 'react';
import { RefreshCw } from 'lucide-react';
import { Info, RefreshCw } from 'lucide-react';
import {
Badge,
Button,
Expand All @@ -88,6 +90,9 @@ import {
EmptyTitle,
Input,
Label,
Popover,
PopoverContent,
PopoverTrigger,
Skeleton,
Switch,
Table,
Expand Down Expand Up @@ -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 (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
data-testid="packaged-flow-reason-chip"
className="ml-2 inline-flex items-center gap-1 rounded-md border border-border px-1.5 align-middle text-xs font-normal leading-4 text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Info className="h-3 w-3 shrink-0" aria-hidden="true" />
{label}
</button>
</PopoverTrigger>
<PopoverContent align="start" aria-label={label} className="w-96 max-w-[90vw]">
<p
data-testid="packaged-flow-reason"
className="whitespace-normal break-words text-sm text-muted-foreground"
>
{reason}
</p>
</PopoverContent>
</Popover>
);
}

/* -------------------------------------------------------------------------- */
/* Page */
/* -------------------------------------------------------------------------- */
Expand Down Expand Up @@ -422,16 +481,16 @@ export function PackagedAutomationPage() {
return (
<TableRow key={row.name} data-testid={`packaged-flow-${row.name}`}>
<TableCell className="align-top">
<div className="font-medium">{row.label}</div>
<div className="font-medium">
{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 && <ReasonDetails reason={row.reason} />}
</div>
<code className="font-mono text-xs text-muted-foreground">{row.name}</code>
{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).
<p className="mt-2 max-w-prose text-sm text-muted-foreground">{row.reason}</p>
)}
{refusal && (
// VERBATIM server refusal, next to the control that
// caused it. `role="alert"` so it is announced.
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4524,6 +4524,7 @@ const ar = {
toggleFailed: "تعذّر تغيير التفعيل.",
cloneFailedHttp: "تعذّر استنساخ هذا التدفق (HTTP {{status}}).",
cloneFailed: "تعذّر استنساخ هذا التدفق.",
reasonDetails: "التفاصيل",
actionsSubtitle: "إجراءات تأتي مع الحزم المثبّتة. أوقف أحدها في هذا النشر فيتوقف عن العمل في كل مكان يُعرض فيه. ويبقى بإمكانك إنشاء إجراء خاص بك إلى جانبه في Studio.",
colAction: "الإجراء",
colObject: "الكائن",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4145,6 +4145,7 @@ const ja = {
toggleFailed: "有効状態を変更できませんでした。",
cloneFailedHttp: "このフローをクローンできませんでした (HTTP {{status}})。",
cloneFailed: "このフローをクローンできませんでした。",
reasonDetails: "詳細",
actionsSubtitle: "インストール済みパッケージが提供するアクションです。このデプロイでオフにすると、提供されているすべての場所で実行されなくなります。自分のアクションを別途 Studio で作成することは引き続き可能です。",
colAction: "アクション",
colObject: "オブジェクト",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4142,6 +4142,7 @@ const ko = {
toggleFailed: "활성화 상태를 변경하지 못했습니다.",
cloneFailedHttp: "이 플로우를 복제하지 못했습니다 (HTTP {{status}}).",
cloneFailed: "이 플로우를 복제하지 못했습니다.",
reasonDetails: "세부 정보",
actionsSubtitle: "설치된 패키지가 제공하는 액션입니다. 이 배포에서 끄면 액션이 제공되는 모든 곳에서 실행되지 않습니다. Studio에서 직접 액션을 따로 작성하는 것은 계속 가능합니다.",
colAction: "액션",
colObject: "오브젝트",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4344,6 +4344,7 @@ const ru = {
toggleFailed: "Не удалось изменить активацию.",
cloneFailedHttp: "Не удалось клонировать этот поток (HTTP {{status}}).",
cloneFailed: "Не удалось клонировать этот поток.",
reasonDetails: "Подробности",
actionsSubtitle: "Действия, поставляемые установленными пакетами. Отключите действие в этой установке — и оно перестанет выполняться везде, где предлагается. Создать рядом собственное действие по-прежнему можно в Studio.",
colAction: "Действие",
colObject: "Объект",
Expand Down
Loading
Loading