Skip to content
Open
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
35 changes: 35 additions & 0 deletions .changeset/6499-inactive-values-retained.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@object-ui/app-shell': minor
---

metadata-admin inspectors: name the "inactive values retained" state instead of
rendering it as live configuration (objectui#6499).

`showWhen` gates rendering only, and `isFieldVisible` additionally re-shows any
field that already holds a stored value — deliberately, "so existing config is
never hidden". The consequence on screen: an author who enabled a controller,
filled its dependent fields, then switched the controller back off keeps seeing
those fields as ordinary, live-looking controls. The stored config and the
switch beside it disagree, and nothing said which one was in effect.

Per the maintainer ruling of 2026-08-27 (Option C), the values are KEPT and the
state is made explicit. Pruning on save was rejected: it silently discards
config an author entered, and inverts the very rule that stops config from
vanishing unseen.

- New `inactiveRetainedKind(field, node, fields)` in `flow-node-config.ts` — a
pure read that reports a field rendered ONLY because the stored-value re-show
rule fired. It distinguishes `'controller-off'` (a real toggle the author can
switch back on) from `'no-controller'` (the `__legacy__` render-only keys,
where no such toggle exists and saying otherwise would be a fresh lie).
- `FlowNodeConfigField` renders the notice beside the affected control, with a
"Clear value" action so the author can discard the residue **deliberately**.
Read-only inspectors show the notice without the action.
- Coverage is every `showWhen` group in the inspectors, pinned mechanically:
all 33 gated fields across the descriptor tables, plus the two runtime
producers (an engine-published `configSchema` and a connector input schema)
that mint groups no source file contains.

Render-layer only: no save-path change, no data deletion, and `isFieldVisible`'s
stored-value re-show rule is unchanged — clearing is an ordinary author-initiated
field commit, the same write as emptying the control by hand.
9 changes: 9 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,12 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.flowNode.config': 'Config (JSON)',
'engine.inspector.flowNode.advanced': 'Advanced (JSON)',
'engine.inspector.flowNode.advancedHint': 'Optional custom keys not covered by the form above — most flows don\u2019t need this.',
// objectui#6499 — a dependent field whose controller is off still renders,
// because a stored value is never hidden. These name that state instead of
// letting it read as live configuration.
'engine.inspector.flowNode.inactiveRetained': 'Kept, not in effect — its controlling field is off. The value stays saved until you clear it.',
'engine.inspector.flowNode.inactiveRetainedOrphan': 'Kept, not in effect — nothing activates this field. The value stays saved so it is not lost silently.',
'engine.inspector.flowNode.inactiveRetainedClear': 'Clear value',
'engine.inspector.flowNode.noConfig': 'No configuration needed for this node type.',
'engine.inspector.flowNode.nestedIdHint': 'A node inside a container region keeps its id here — rename it in the container’s Advanced JSON.',
'engine.inspector.flowNode.kv.add': 'Add entry',
Expand Down Expand Up @@ -2211,6 +2217,9 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.flowNode.config': '配置(JSON)',
'engine.inspector.flowNode.advanced': '高级(JSON)',
'engine.inspector.flowNode.advancedHint': '上方表单未覆盖的可选自定义键 —— 大多数流程无需填写。',
'engine.inspector.flowNode.inactiveRetained': '已保留,未生效 —— 控制字段处于关闭。该值仍然存储,直到你清除它。',
'engine.inspector.flowNode.inactiveRetainedOrphan': '已保留,未生效 —— 没有任何开关能启用此字段。该值仍然存储,以免静默丢失。',
'engine.inspector.flowNode.inactiveRetainedClear': '清除值',
'engine.inspector.flowNode.noConfig': '此节点类型无需配置。',
'engine.inspector.flowNode.nestedIdHint': '容器区域内的节点 ID 在此只读 —— 请在容器的高级 JSON 中重命名。',
'engine.inspector.flowNode.kv.add': '添加条目',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
*/

import * as React from 'react';
import type { FlowConfigField } from './flow-node-config.js';
import type { FlowConfigField, InactiveRetainedKind } from './flow-node-config.js';
import { t } from '../i18n.js';
import {
InspectorNumberField,
InspectorSelectField,
InspectorCheckboxField,
} from './_shared.js';
import { Label } from '@object-ui/components';
import { Button, Label } from '@object-ui/components';
import { FlowKeyValueField } from './FlowKeyValueField.js';
import { FlowStringListField } from './FlowStringListField.js';
import { FlowObjectListField } from './FlowObjectListField.js';
Expand Down Expand Up @@ -65,9 +65,26 @@ export interface FlowNodeConfigFieldProps {
* the value.
*/
triggerScope?: TriggerScope;
/**
* objectui#6499 — set when this field is on screen ONLY because it holds a
* stored value its `showWhen` controller does not currently admit. Supplied
* by the host inspector, which owns the node and the sibling field set (this
* component sees neither), and computed by `inactiveRetainedKind`.
*
* `undefined`/`null` is the normal case and renders exactly as before, so a
* caller that does not pass it is unaffected.
*/
inactiveRetained?: InactiveRetainedKind | null;
/**
* Clears the retained value — an ordinary field commit of `undefined`, the
* same write the author would make by emptying the control by hand. The
* ruling keeps hidden values by default; this is how the author discards one
* DELIBERATELY. Omit to render the notice without the button.
*/
onClearInactive?: () => void;
}

export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, context, scopeGroups, approvalScopeGroups, triggerScope }: FlowNodeConfigFieldProps) {
export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, context, scopeGroups, approvalScopeGroups, triggerScope, inactiveRetained, onClearInactive }: FlowNodeConfigFieldProps) {
const refMode: 'expression' | 'template' =
field.refMode ?? (field.kind === 'expression' ? 'expression' : 'template');
// objectui#6226 — the row-based condition builder, on the fields that opted in
Expand Down Expand Up @@ -281,6 +298,44 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,
return (
<div className="space-y-1">
{control}
{/*
objectui#6499 — the "inactive values retained" affordance. Rendered
ABOVE the expression/scope notes and independently of them: those judge
the value's CONTENT, this one says the value is not in effect at all,
and an author who cannot see the second will misread the first.
Deliberately not a `disabled` control — the value is still editable, it
just is not live, and greying it out would hide the very text the
ruling asks the author to be able to read and act on.
*/}
{inactiveRetained && (
<div
className="flex items-start gap-2 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5"
role="note"
data-testid="inactive-retained"
data-inactive-retained={inactiveRetained}
>
<p className="flex-1 text-[11px] leading-snug text-amber-700 dark:text-amber-400">
{t(
inactiveRetained === 'no-controller'
? 'engine.inspector.flowNode.inactiveRetainedOrphan'
: 'engine.inspector.flowNode.inactiveRetained',
locale,
)}
</p>
{onClearInactive && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 shrink-0 px-2 text-[11px] text-amber-700 dark:text-amber-400"
onClick={onClearInactive}
disabled={disabled}
>
{t('engine.inspector.flowNode.inactiveRetainedClear', locale)}
</Button>
)}
</div>
)}
{exprIssue && (
<p className="text-[11px] leading-snug text-destructive" role="alert">
{exprIssue.message}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The rendered "inactive values retained" affordance (objectui#6499, Option C).
*
* The predicate is pinned in `flow-node-config.inactiveRetained.test.ts`; this
* file pins what the AUTHOR sees, because the whole ruling is about what is
* visible on screen. A hidden-but-stored dependent value keeps rendering — the
* ruling forbids deleting it — so the only thing that can tell the author it is
* inert is this notice beside it.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';

vi.mock('../previews/useFlowNodePalette', () => ({
useActionConfigSchemas: () => ({}),
useFlowNodePalette: () => [],
}));
vi.mock('../previews/useObjectFields', () => ({
useObjectFields: () => ({ fields: [], loading: false, error: null }),
}));

import { FlowNodeInspector } from './FlowNodeInspector';
import type { MetadataSelection } from '../preview-registry';

afterEach(cleanup);

function draftWith(config: Record<string, unknown>, type = 'approval') {
return { nodes: [{ id: 'gate', type, label: 'Gate', config }], edges: [] };
}

function renderInspector(draft: Record<string, unknown>, readOnly = false) {
const onPatch = vi.fn();
const utils = render(
<FlowNodeInspector
type="flow"
name="renewal"
draft={draft}
selection={{ kind: 'node', id: 'gate' } as MetadataSelection}
onPatch={onPatch}
onClearSelection={vi.fn()}
readOnly={readOnly}
locale="en-US"
/>,
);
return { onPatch, ...utils };
}

const notices = () => screen.queryAllByTestId('inactive-retained');

describe('the affordance appears exactly when a value is hidden-but-stored', () => {
it('appears for the escalation residue the card measured', () => {
// enable → fill → toggle back off → save. The stored payload the UI has
// been manufacturing: `{ enabled: false, timeoutHours: 24 }`.
renderInspector(draftWith({ escalation: { enabled: false, timeoutHours: 24 } }));
const found = notices();
expect(found).toHaveLength(1);
expect(found[0].textContent).toMatch(/kept, not in effect/i);
expect(found[0].textContent).toMatch(/controlling field is off/i);
expect(found[0].getAttribute('data-inactive-retained')).toBe('controller-off');
// The value itself is still on screen and still stored — nothing pruned.
expect(screen.getByDisplayValue('24')).toBeInTheDocument();
});

it('does NOT appear when the controller is on — same stored value', () => {
renderInspector(draftWith({ escalation: { enabled: true, timeoutHours: 24 } }));
expect(notices()).toHaveLength(0);
expect(screen.getByDisplayValue('24')).toBeInTheDocument(); // control: the field IS rendered
});

it('does NOT appear when the controller is off and nothing is stored', () => {
renderInspector(draftWith({ escalation: { enabled: false } }));
expect(notices()).toHaveLength(0);
expect(screen.queryByDisplayValue('24')).not.toBeInTheDocument();
});

it('does NOT appear on a node with no gated fields at all', () => {
renderInspector(draftWith({ objectName: 'contract', outputVariable: 'r' }, 'create_record'));
expect(notices()).toHaveLength(0);
});

it('appears once per retained dependent, not once for the group', () => {
renderInspector(draftWith({ escalation: { enabled: false, timeoutHours: 24, escalateTo: 'sre-lead' } }));
expect(notices()).toHaveLength(2);
});

it('uses the no-controller wording for a `__legacy__` render-only key', () => {
renderInspector({ nodes: [{ id: 'gate', type: 'decision', label: 'Gate', config: { condition: 'amount > 10000' } }], edges: [] });
const found = notices();
expect(found).toHaveLength(1);
expect(found[0].getAttribute('data-inactive-retained')).toBe('no-controller');
expect(found[0].textContent).toMatch(/nothing activates this field/i);
// and NOT the controller wording — there is no toggle to point at
expect(found[0].textContent).not.toMatch(/controlling field is off/i);
});
});

describe('clearing is deliberate, author-initiated, and goes through the ordinary field commit', () => {
it('offers a clear button that removes the retained key', () => {
const { onPatch } = renderInspector(draftWith({ escalation: { enabled: false, timeoutHours: 24 } }));
const clear = within(notices()[0]).getByRole('button', { name: /clear value/i });
fireEvent.click(clear);
const patched = onPatch.mock.calls.at(-1)![0] as any;
// The retained key is gone; the controller the author actually set stays.
expect(patched.nodes[0].config.escalation).toEqual({ enabled: false });
});

it('does not clear anything until the author clicks — rendering is a read', () => {
const { onPatch } = renderInspector(draftWith({ escalation: { enabled: false, timeoutHours: 24 } }));
expect(onPatch).not.toHaveBeenCalled();
});

it('offers no clear button in a read-only inspector', () => {
renderInspector(draftWith({ escalation: { enabled: false, timeoutHours: 24 } }), true);
expect(notices()).toHaveLength(1); // the author can still SEE the state
expect(within(notices()[0]).queryByRole('button', { name: /clear value/i })).toBeNull();
});
});

describe('assertions that must NOT move', () => {
it('an ordinary live field renders with no notice and no clear button', () => {
renderInspector(draftWith({ escalation: { enabled: true, timeoutHours: 24 } }));
expect(screen.queryByRole('button', { name: /clear value/i })).toBeNull();
expect(notices()).toHaveLength(0);
});

it('the retained value survives a re-render — the affordance never prunes on its own', () => {
const draft = draftWith({ escalation: { enabled: false, timeoutHours: 24 } });
const { onPatch, rerender } = renderInspector(draft);
rerender(
<FlowNodeInspector
type="flow"
name="renewal"
draft={draft}
selection={{ kind: 'node', id: 'gate' } as MetadataSelection}
onPatch={onPatch}
onClearSelection={vi.fn()}
readOnly={false}
locale="en-US"
/>,
);
expect(onPatch).not.toHaveBeenCalled();
expect((draft as any).nodes[0].config.escalation).toEqual({ enabled: false, timeoutHours: 24 });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
mergeServerFlowFields,
localizeFlowFields,
isFieldVisible,
inactiveRetainedKind,
getFieldValue,
configKeyOf,
FLOW_NODE_TYPE_OPTIONS,
Expand Down Expand Up @@ -454,6 +455,13 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
scopeGroups={scopeGroups}
approvalScopeGroups={approvalExpressionGroups}
triggerScope={triggerScope}
// objectui#6499 — a gated field that survived the filter above ONLY
// because it holds a stored value is inert config wearing a live
// control's clothes. Name it, and offer the deliberate clear.
// Computed from `field` (not `effField`): the read is by `path`,
// which the nested-branch rewrite above does not touch.
inactiveRetained={inactiveRetainedKind(field, node, fields)}
onClearInactive={readOnly ? undefined : () => setField(field, undefined)}
/>
);
})}
Expand Down
Loading
Loading