Skip to content

Commit 968925c

Browse files
fix(studio): the publish review names the flow steps that changed instead of "~ nodes" (#27432)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ836VkJMbRirYcP4FDCFmjrE)_ Part of #27356 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:发布前的变更审阅里,流程草稿不再只写「其他变更:~ nodes」,而是逐个列出新增/修改/删除的步骤(先写步骤名,旁边是 id),修改的步骤写出改了哪个属性(如 `config.fields`)。 - 为什么改:HotCRM 18.2.0 走查(hotcrm#2320 Studio S6)发现发布对话框只说「Also changed: ~ nodes」,看不出改了哪一步。 - 风险与回滚:只在类型为 flow 且两侧步骤都有 id 时逐步对比;仅调整顺序或步骤无 id 时仍显示 `nodes` 行,不会把有变化的草稿说成「无变化」;回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/preview/DraftChangesPanel.tsx`(`diffFlowNodes`、`computeChangeDetail` 的 `type` 参数、`EntryDetail` 的节点行)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/preview/__tests__/DraftChangesPanel.flowNodeRows-27356.test.tsx`,预期 7 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes Row 17 of #27356 (hotcrm#2320, Studio report finding S6): "The publish dialog says "Also changed: ~ nodes"." Premise measured first, as the order asked: the drill-in (`EntryDetail`) already reads BOTH full bodies, the published item and the draft, and every flow node carries its `id` and `label`. So the names are on the client; no server door is needed. `computeChangeDetail` compared every top-level key other than `fields` whole, and a flow keeps all its steps in `nodes`, so any step edit read `~ nodes`. In `ui/packages/app-shell/src/preview/DraftChangesPanel.tsx`: - `computeChangeDetail(published, draft, type?)` takes the entry's type (the drill-in passes `entry.type`). For a `flow`, `diffFlowNodes` matches the two `nodes` lists by `id` and returns `nodes: { added, changed, removed }` on `EntryChangeDetail`. Each row is `{ id, label? }`; a changed node lists the properties that differ, `config` one level down (`config.fields`), since nearly every inspector edit lands there. Rows sort by id, as field rows sort by name. - When that yields at least one node row, `nodes` leaves the "Also changed:" key list. `edges` and every other key stay there. - The drill-in renders the node rows after the field rows in `FieldRow`'s own shape: mark, label, the id in monospace beside it, and `· config.fields` for a changed node. `FieldRow` takes a `testId` (default `draft-field-row`, unchanged); node rows are `draft-node-row`. - `isEmptyChangeDetail` counts node rows. Guards, each pinned: - A reorder alone changes no node, so `nodes` stays a key row and the draft never reads "No differences detected". - A `nodes` list that is not keyed by a unique string `id` keeps the old whole-key comparison (`nodes: null`). - Any type other than `flow` is compared exactly as before. ## Why "~ nodes" tells the author that something in the flow changed, which they knew. The summary is the last look before publishing, and it should say which step and what about it, the way the same panel already names an object's fields. ## Tests `ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.flowNodeRows-27356.test.tsx` (7 tests): - `computeChangeDetail` on a flow: the added node (no label, id only), the changed node with `config.fields`, the removed node by label; `nodes` is not a key row and `edges` is. - A changed top-level node property (`label`) is named as itself. - A NEW flow adds every node. - CONTROL: a reorder alone keeps `nodes` as a key row. - CONTROL: a list not keyed by `id` gives `nodes: null` and keeps the key row. - CONTROL: the same bodies typed `page` compare `nodes` whole. - The panel: expanding a flow draft shows `+ wait_a_day`, `~ Create follow-up task create_task · config.fields`, `− Notify owner notify_owner`, the id in monospace, no "~ nodes", and "Also changed:" with `~ edges`. <details><summary>Ablation (one: the flow branch never taken)</summary> `node scripts/ablation-replace.mjs --anchor " if (type === 'flow') {" --replacement " if (type === 'not-a-flow') {"` on `DraftChangesPanel.tsx` (anchor 1 → 0, blob `0583bc280d81` → `9f20adc71adb`): 5 failed / 2 passed. The two that stay green are the not-keyed and not-a-flow controls, which assert the old behaviour. Restored: blob after restore = blob at HEAD = `0583bc280d81`, `git diff HEAD` empty. </details> ## Acceptance notes - A changed node lists property paths (`config.fields`), not the field names inside the map (`subject`, `due_date`). One level down covers the inspector's edits without restating each node type's config shape here. - Node rows sort by id, not by canvas position: the drill-in has no layout order to read, and field rows sort by name the same way. - `edges` still reads `~ edges`. Edge ids are machine ids with no label, so per-edge rows would not read better; the node rows already say which steps were added or removed. - `computeChangeDetail` stays one function for every type; the flow branch is opt-in by `type`, so no caller that passes two arguments changes. ## Verification All on head `751c7d1b` (merge base `b10fd655`), after `pnpm install --frozen-lockfile` and a fresh `@object-ui/app-shell^...` closure build, under the shared verification lock. - New file: 7 / 7 passed. Every test file under `ui/` that names `DraftChangesPanel` (72 files, the new one included): 436 / 436 passed. - `pnpm --filter @object-ui/app-shell run type-check` (both `tsc` programs): exit 0. - `pnpm --dir ui run check:new-line-citations`: 0 new citations, exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: the same 34 families as rows 21 and 16, all 34 exit 0 (the `ui scripts/` family: 169 files passed, 2 skipped); `--ran`: 34 run, 0 NOT-MEASURED, 0 UNRUN. Tier: no path-derived mandate; 290 changed lines. Repo-wide lint, the full ui test battery and the e2e jobs are CI's. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWuU6sVfBYiy4dY4PA)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b74793c commit 968925c

3 files changed

Lines changed: 283 additions & 7 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(studio): the publish review names the flow steps that changed instead of "Also changed: ~ nodes"
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** Expanding a flow draft in the publish review compared every top-level key other than `fields` whole. A flow keeps all of its steps in one `nodes` list, so any step edit read "Also changed: ~ nodes".
12+
- **Now.** A flow's steps are compared one by one, matched by `id`: a row per step added, changed or removed, led by the step's label with its id beside it, and a changed step names the properties that differ (`config` one level down, as `config.fields`). `edges` and the flow's other keys stay under "Also changed:".
13+
- **Unchanged.** A reorder alone, or a step list not keyed by `id`, keeps the `nodes` row, so a draft that differs never reads as unchanged. Other item types are compared as before.

‎ui/packages/app-shell/src/preview/DraftChangesPanel.tsx‎

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,18 @@ export interface EntryChangeDetail {
314314
*/
315315
labels: ReadonlyMap<string, string>;
316316
} | null;
317+
/**
318+
* Node-level diff of a flow's `nodes` (objectstack#27356): each node is
319+
* matched by its `id`, named by its `label` when it has one, and a changed
320+
* node lists the properties that differ (`config` one level down, as
321+
* `config.KEY`). Present only for a flow whose two bodies key every node by
322+
* a string `id`; otherwise `nodes` stays a top-level key row.
323+
*/
324+
nodes: {
325+
added: FlowNodeRow[];
326+
changed: Array<FlowNodeRow & { keys: string[] }>;
327+
removed: FlowNodeRow[];
328+
} | null;
317329
/**
318330
* Top-level keys (other than `fields`) whose values differ, split the way the
319331
* `fields` diff is (objectui#11807): `added` has no published value, `removed`
@@ -347,11 +359,89 @@ function valueEqual(a: unknown, b: unknown): boolean {
347359
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
348360
}
349361

350-
/** True when the detail lists nothing: no field row, no key row and no generated row. */
362+
/** One flow node in the drill-in: its `id`, and its `label` when it declares one. */
363+
export interface FlowNodeRow {
364+
id: string;
365+
label?: string;
366+
}
367+
368+
/** True when the detail lists nothing: no field row, no node row, no key row and no generated row. */
351369
function isEmptyChangeDetail(detail: EntryChangeDetail): boolean {
352-
const { fields, keys, generated } = detail;
370+
const { fields, nodes, keys, generated } = detail;
353371
const fieldRows = fields ? fields.added.length + fields.changed.length + fields.removed.length : 0;
354-
return fieldRows + keys.added.length + keys.changed.length + keys.removed.length + generated.length === 0;
372+
const nodeRows = nodes ? nodes.added.length + nodes.changed.length + nodes.removed.length : 0;
373+
return fieldRows + nodeRows + keys.added.length + keys.changed.length + keys.removed.length + generated.length === 0;
374+
}
375+
376+
function isPlainBody(value: unknown): value is Record<string, unknown> {
377+
return typeof value === 'object' && value !== null && !Array.isArray(value);
378+
}
379+
380+
/**
381+
* A flow's `nodes` by `id`, or `null` when the value is not a list of nodes
382+
* each keyed by a string `id` (the drill-in then compares `nodes` whole). An
383+
* absent list is an empty one: a new flow adds every node.
384+
*/
385+
function flowNodesById(value: unknown): Map<string, Record<string, unknown>> | null {
386+
if (value == null) return new Map();
387+
if (!Array.isArray(value)) return null;
388+
const out = new Map<string, Record<string, unknown>>();
389+
for (const node of value) {
390+
if (!isPlainBody(node) || typeof node.id !== 'string' || node.id === '' || out.has(node.id)) return null;
391+
out.set(node.id, node);
392+
}
393+
return out;
394+
}
395+
396+
function flowNodeRow(node: Record<string, unknown>): FlowNodeRow {
397+
const label = typeof node.label === 'string' ? node.label.trim() : '';
398+
return label === '' ? { id: node.id as string } : { id: node.id as string, label };
399+
}
400+
401+
/**
402+
* The properties that differ between two versions of one node: its top-level
403+
* keys, with `config` read one level down (`config.fields`), since nearly
404+
* every edit in the flow inspector lands there.
405+
*/
406+
function changedNodeKeys(before: Record<string, unknown>, after: Record<string, unknown>): string[] {
407+
const out: string[] = [];
408+
for (const k of [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()) {
409+
if (valueEqual(before[k], after[k])) continue;
410+
const b = before[k];
411+
const a = after[k];
412+
if (k === 'config' && (b == null || isPlainBody(b)) && (a == null || isPlainBody(a))) {
413+
const bc = (b ?? {}) as Record<string, unknown>;
414+
const ac = (a ?? {}) as Record<string, unknown>;
415+
for (const ck of [...new Set([...Object.keys(bc), ...Object.keys(ac)])].sort()) {
416+
if (!valueEqual(bc[ck], ac[ck])) out.push(`config.${ck}`);
417+
}
418+
continue;
419+
}
420+
out.push(k);
421+
}
422+
return out;
423+
}
424+
425+
/**
426+
* objectstack#27356 — the node rows of a flow's publish summary, or `null`
427+
* when either side's `nodes` is not a list keyed by `id`. Nodes sort by id,
428+
* as field rows sort by name.
429+
*/
430+
function diffFlowNodes(before: unknown, after: unknown): EntryChangeDetail['nodes'] {
431+
const pub = flowNodesById(before);
432+
const cur = flowNodesById(after);
433+
if (!pub || !cur) return null;
434+
const byId = (x: FlowNodeRow, y: FlowNodeRow) => x.id.localeCompare(y.id);
435+
const added: FlowNodeRow[] = [];
436+
const changed: Array<FlowNodeRow & { keys: string[] }> = [];
437+
const removed: FlowNodeRow[] = [];
438+
for (const [id, node] of cur) {
439+
const was = pub.get(id);
440+
if (!was) added.push(flowNodeRow(node));
441+
else if (!valueEqual(was, node)) changed.push({ ...flowNodeRow(node), keys: changedNodeKeys(was, node) });
442+
}
443+
for (const [id, node] of pub) if (!cur.has(id)) removed.push(flowNodeRow(node));
444+
return { added: added.sort(byId), changed: changed.sort(byId), removed: removed.sort(byId) };
355445
}
356446

357447
/**
@@ -460,6 +550,7 @@ function fieldLabelOf(def: Record<string, unknown> | undefined): string | undefi
460550
export function computeChangeDetail(
461551
published: Record<string, unknown> | null,
462552
draft: Record<string, unknown> | null,
553+
type?: string,
463554
): EntryChangeDetail {
464555
const pub = withoutProtectionEnvelope(published ?? {});
465556
const cur = withoutProtectionEnvelope(draft ?? {});
@@ -506,6 +597,13 @@ export function computeChangeDetail(
506597
const isNew = published === null;
507598
const names = new Set([...Object.keys(pub), ...Object.keys(cur)]);
508599
names.delete('fields');
600+
// objectstack#27356 — a flow names the nodes that changed, not "~ nodes".
601+
// A reorder alone changes no node, so it keeps the key row.
602+
let nodes: EntryChangeDetail['nodes'] = null;
603+
if (type === 'flow') {
604+
nodes = diffFlowNodes(pub.nodes, cur.nodes);
605+
if (nodes && nodes.added.length + nodes.changed.length + nodes.removed.length > 0) names.delete('nodes');
606+
}
509607
const keys: EntryChangeDetail['keys'] = { added: [], changed: [], removed: [] };
510608
const generatedKeys: EntryChangeDetail['generated'] = [];
511609
for (const k of [...names].sort()) {
@@ -521,7 +619,7 @@ export function computeChangeDetail(
521619
}
522620
keys[kind].push(k);
523621
}
524-
return { fields, keys, generated: [...generatedFields, ...generatedKeys], isNew };
622+
return { fields, nodes, keys, generated: [...generatedFields, ...generatedKeys], isNew };
525623
}
526624

527625
/**
@@ -649,16 +747,18 @@ function FieldRow({
649747
name,
650748
label,
651749
children,
750+
testId = 'draft-field-row',
652751
}: {
653752
kind: keyof typeof FIELD_ROW_MARK;
654753
name: string;
655754
label: string | undefined;
656755
children?: ReactNode;
756+
testId?: string;
657757
}) {
658758
const lead = label !== undefined && label !== name ? label : undefined;
659759
return (
660760
<p
661-
data-testid="draft-field-row"
761+
data-testid={testId}
662762
className={`${lead === undefined ? 'font-mono ' : ''}text-xs ${FIELD_ROW_TONE[kind]}`}
663763
>
664764
{FIELD_ROW_MARK[kind]}{' '}
@@ -758,7 +858,7 @@ function EntryDetail({
758858
]);
759859
if (cancelled) return;
760860
const draft = draftRead?.body ?? null;
761-
const computed = computeChangeDetail(published, draft);
861+
const computed = computeChangeDetail(published, draft, entry.type);
762862
setAgents(draftAgentSources(draftRead?.changes));
763863
setDetail(computed);
764864
// Only an object's added fields can be backfilled (objectui#12929).
@@ -812,7 +912,7 @@ function EntryDetail({
812912
);
813913
}
814914

815-
const { fields, keys, generated, isNew } = detail;
915+
const { fields, nodes, keys, generated, isNew } = detail;
816916
const hasKeyRows = keys.added.length > 0 || keys.changed.length > 0 || keys.removed.length > 0;
817917
// objectstack#27176 — which AI tools drafted part of this item, one line each.
818918
const agentLines = agents.map((source) => (
@@ -887,6 +987,18 @@ function EntryDetail({
887987
{fields?.removed.map((name) => (
888988
<FieldRow key={`-${name}`} kind="removed" name={name} label={fields.labels.get(name)} />
889989
))}
990+
{/* objectstack#27356 — a flow's nodes, each by its label, in the field rows' shape. */}
991+
{nodes?.added.map((n) => (
992+
<FieldRow key={`n+${n.id}`} kind="added" name={n.id} label={n.label} testId="draft-node-row" />
993+
))}
994+
{nodes?.changed.map((n) => (
995+
<FieldRow key={`n~${n.id}`} kind="changed" name={n.id} label={n.label} testId="draft-node-row">
996+
{n.keys.length > 0 && <span className="text-muted-foreground"> · {n.keys.join(', ')}</span>}
997+
</FieldRow>
998+
))}
999+
{nodes?.removed.map((n) => (
1000+
<FieldRow key={`n-${n.id}`} kind="removed" name={n.id} label={n.label} testId="draft-node-row" />
1001+
))}
8901002
{hasKeyRows && (
8911003
<p className="text-xs text-muted-foreground">
8921004
{isNew
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27356 (hotcrm#2320 Studio S6) — publishing a flow edit said
5+
* "Also changed: ~ nodes". The drill-in compared every top-level key other
6+
* than `fields` whole, and a flow keeps all of its steps in one `nodes` list,
7+
* so any step edit read as the one word `nodes`.
8+
*
9+
* Both bodies are already read for the drill-in, and each node carries its
10+
* `id` and `label`, so a flow's `nodes` is now diffed per node, matched by
11+
* `id`: a row per node added, changed or removed, led by its label, and a
12+
* changed node names the properties that differ (`config` one level down).
13+
*
14+
* Controls: a reorder alone (no node differs) keeps the `nodes` key row, so a
15+
* real difference is never summarised as none; a list that is not keyed by
16+
* `id` keeps the key row; a non-flow item with a `nodes` key keeps it too;
17+
* and `edges` stays a key row.
18+
*/
19+
20+
import '@testing-library/jest-dom/vitest';
21+
import { afterEach, describe, expect, it, vi } from 'vitest';
22+
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
23+
24+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
25+
...(await importOriginal<Record<string, unknown>>()),
26+
useObjectTranslation: () => ({
27+
t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k,
28+
language: 'en',
29+
}),
30+
}));
31+
32+
import { DraftChangesPanel, computeChangeDetail } from '../DraftChangesPanel';
33+
34+
const START = { id: 'start', type: 'start', label: 'Start' };
35+
const END = { id: 'end', type: 'end', label: 'End' };
36+
const CREATE_TASK = {
37+
id: 'create_task',
38+
type: 'create_record',
39+
label: 'Create follow-up task',
40+
config: { objectName: 'task', fields: { subject: 'Call back' } },
41+
};
42+
const NOTIFY = { id: 'notify_owner', type: 'notify', label: 'Notify owner', config: { title: 'Heads up' } };
43+
44+
const PUBLISHED = {
45+
name: 'case_follow_up',
46+
label: 'Case follow-up',
47+
type: 'autolaunched',
48+
nodes: [START, CREATE_TASK, NOTIFY, END],
49+
edges: [{ id: 'e1', source: 'start', target: 'create_task' }],
50+
};
51+
const DRAFT = {
52+
...PUBLISHED,
53+
nodes: [
54+
START,
55+
{ ...CREATE_TASK, config: { ...CREATE_TASK.config, fields: { subject: 'Call back', due_date: 'today' } } },
56+
{ id: 'wait_a_day', type: 'wait', config: { duration: 'P1D' } },
57+
END,
58+
],
59+
edges: [{ id: 'e1', source: 'start', target: 'create_task' }, { id: 'e2', source: 'create_task', target: 'wait_a_day' }],
60+
};
61+
62+
afterEach(() => {
63+
cleanup();
64+
vi.unstubAllGlobals();
65+
});
66+
67+
describe("computeChangeDetail names a flow's nodes (objectstack#27356)", () => {
68+
it('lists the node added, the node changed with the property, and the node removed; `nodes` is no key row', () => {
69+
const d = computeChangeDetail(PUBLISHED, DRAFT, 'flow');
70+
expect(d.nodes).toEqual({
71+
added: [{ id: 'wait_a_day' }],
72+
changed: [{ id: 'create_task', label: 'Create follow-up task', keys: ['config.fields'] }],
73+
removed: [{ id: 'notify_owner', label: 'Notify owner' }],
74+
});
75+
expect(d.keys.changed).toEqual(['edges']);
76+
});
77+
78+
it('a changed top-level node property is named as itself', () => {
79+
const draft = { ...PUBLISHED, nodes: [START, { ...CREATE_TASK, label: 'Create task' }, NOTIFY, END] };
80+
expect(computeChangeDetail(PUBLISHED, draft, 'flow').nodes?.changed).toEqual([
81+
{ id: 'create_task', label: 'Create task', keys: ['label'] },
82+
]);
83+
});
84+
85+
it('a NEW flow adds every node', () => {
86+
expect(computeChangeDetail(null, PUBLISHED, 'flow').nodes?.added.map((n) => n.id)).toEqual([
87+
'create_task',
88+
'end',
89+
'notify_owner',
90+
'start',
91+
]);
92+
});
93+
94+
it('CONTROL: a reorder alone keeps the `nodes` key row, so the draft never reads unchanged', () => {
95+
const d = computeChangeDetail(PUBLISHED, { ...PUBLISHED, nodes: [END, NOTIFY, CREATE_TASK, START] }, 'flow');
96+
expect(d.nodes).toEqual({ added: [], changed: [], removed: [] });
97+
expect(d.keys.changed).toEqual(['nodes']);
98+
});
99+
100+
it('CONTROL: a node list not keyed by `id` keeps the `nodes` key row', () => {
101+
const d = computeChangeDetail(PUBLISHED, { ...PUBLISHED, nodes: [START, { type: 'end' }] }, 'flow');
102+
expect(d.nodes).toBeNull();
103+
expect(d.keys.changed).toContain('nodes');
104+
});
105+
106+
it('CONTROL: an item that is not a flow compares `nodes` whole', () => {
107+
const d = computeChangeDetail(PUBLISHED, DRAFT, 'page');
108+
expect(d.nodes).toBeNull();
109+
expect(d.keys.changed).toEqual(['edges', 'nodes']);
110+
});
111+
});
112+
113+
/* ─────────────── the panel ─────────────── */
114+
115+
function stubServer() {
116+
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
117+
vi.stubGlobal(
118+
'fetch',
119+
vi.fn(async (input: RequestInfo | URL) => {
120+
const url = String(input);
121+
if (url.includes('/_drafts')) {
122+
return ok({ drafts: [{ type: 'flow', name: 'case_follow_up', packageId: 'com.x', label: 'Case follow-up' }] });
123+
}
124+
if (/\/meta\/flow(\?|$)/.test(url)) return ok([{ name: 'case_follow_up' }]);
125+
if (/\/meta\/flow\/case_follow_up\b/.test(url)) return ok(url.includes('state=draft') ? { item: DRAFT } : PUBLISHED);
126+
return { ok: false, status: 404, json: async () => ({}) };
127+
}) as unknown as typeof fetch,
128+
);
129+
}
130+
131+
describe('the publish drill-in of a flow edit (objectstack#27356)', () => {
132+
it('names each node by its label and the property changed, and never prints "~ nodes"', async () => {
133+
stubServer();
134+
render(<DraftChangesPanel open onOpenChange={() => {}} packageId="com.x" />);
135+
const toggle = (await screen.findByText('Case follow-up')).closest('[data-testid="draft-entry-toggle"]') as HTMLElement;
136+
fireEvent.click(toggle);
137+
const detail = await screen.findByTestId('draft-entry-detail');
138+
139+
const rows = await within(detail).findAllByTestId('draft-node-row');
140+
expect(rows.map((r) => r.textContent)).toEqual([
141+
'+ wait_a_day',
142+
'~ Create follow-up task create_task · config.fields',
143+
'− Notify owner notify_owner',
144+
]);
145+
expect(within(rows[1]!).getByText('create_task')).toHaveClass('font-mono');
146+
expect(detail.textContent).not.toContain('~ nodes');
147+
// `edges` is still a key row under "Also changed:".
148+
expect(within(detail).getByText('Also changed:')).toBeInTheDocument();
149+
expect(within(detail).getByText('~ edges')).toBeInTheDocument();
150+
});
151+
});

0 commit comments

Comments
 (0)