diff --git a/packages/core/client-v2/src/components/form/TypedVariableInput.tsx b/packages/core/client-v2/src/components/form/TypedVariableInput.tsx
index a17820c494f..532631f1286 100644
--- a/packages/core/client-v2/src/components/form/TypedVariableInput.tsx
+++ b/packages/core/client-v2/src/components/form/TypedVariableInput.tsx
@@ -530,12 +530,14 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
);
const onClearVariable = useCallback(() => {
- if (nullable) {
- onChange?.(null);
+ const first = normalizedTypes[0];
+ if (first) {
+ onChange?.(defaultValueFor(first.type));
return;
}
- const first = normalizedTypes[0];
- if (first) onChange?.(defaultValueFor(first.type));
+ if (nullable) {
+ onChange?.(null);
+ }
}, [nullable, normalizedTypes, onChange]);
const constantTypeForRendering: TypedConstantType = useMemo(() => {
@@ -619,6 +621,42 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
if (constantTypeForRendering !== 'object') setJsonError(null);
}, [constantTypeForRendering]);
+ const variableValueClassName = useMemo(
+ () => css`
+ &:hover .clear-button,
+ &:focus-within .clear-button {
+ visibility: visible;
+ pointer-events: auto;
+ opacity: 1;
+ }
+
+ .clear-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: ${token.fontSizeIcon}px;
+ height: ${token.fontSizeIcon}px;
+ padding: 0;
+ color: ${token.colorTextQuaternary};
+ background: transparent;
+ cursor: pointer;
+ visibility: hidden;
+ pointer-events: none;
+ opacity: 0;
+ transition:
+ visibility ${token.motionDurationMid} ease,
+ color ${token.motionDurationMid} ease,
+ opacity ${token.motionDurationSlow} ease;
+
+ &:hover {
+ color: ${token.colorTextTertiary};
+ background: transparent;
+ }
+ }
+ `,
+ [token],
+ );
+
return (
{/* Default `Space.Compact` (align-items: stretch) so the switcher button
@@ -629,6 +667,7 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
{isVariable ? (
}
+ icon={
}
/>
) : null}
diff --git a/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx b/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx
index 334a2462079..5d4cae13f6d 100644
--- a/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx
+++ b/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx
@@ -161,10 +161,10 @@ describe('TypedVariableInput - variable rendering', () => {
});
});
- it('clears back to null when the close button is clicked (nullable=true)', async () => {
+ it('clears back to default-of-first-type when the close button is clicked (nullable=true)', async () => {
const ctx = createContextWithEnv();
const handleChange = vi.fn();
- renderWithCtx(
+ const { container } = renderWithCtx(
ctx,
{
onChange={handleChange}
/>,
);
- const clear = await screen.findByRole('button', { name: 'icon-close' });
- fireEvent.click(clear);
- expect(handleChange).toHaveBeenCalledWith(null);
+ const clear = container.querySelector('button.clear-button') as HTMLButtonElement | null;
+ expect(clear).not.toBeNull();
+ expect(clear).toHaveClass('clear-button');
+ fireEvent.click(clear as HTMLButtonElement);
+ expect(handleChange).toHaveBeenCalledWith(0);
});
it('clears back to default-of-first-type when nullable=false', async () => {
const ctx = createContextWithEnv();
const handleChange = vi.fn();
- renderWithCtx(
+ const { container } = renderWithCtx(
ctx,
{
onChange={handleChange}
/>,
);
- const clear = await screen.findByRole('button', { name: 'icon-close' });
- fireEvent.click(clear);
+ const clear = container.querySelector('button.clear-button') as HTMLButtonElement | null;
+ expect(clear).not.toBeNull();
+ fireEvent.click(clear as HTMLButtonElement);
expect(handleChange).toHaveBeenCalledWith(0);
});
});
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/NodeConfigDrawer.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/NodeConfigDrawer.tsx
index 2e9c3411af8..674a82d8a65 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/NodeConfigDrawer.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/NodeConfigDrawer.tsx
@@ -205,10 +205,10 @@ function NodeConfigForm({
// `justify-content: space-between` pushes the node-key tag to the drawer's far right (mirrors v1's flex
// title), the native close X sitting just left of the title.
- {data.title ?? typeTitle}
+ {data.title ?? typeTitle}
- {data.key}
+ {data.key}
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/__tests__/NodeConfigDrawer.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/__tests__/NodeConfigDrawer.test.tsx
index 780f37593e7..38bb8c80e17 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/__tests__/NodeConfigDrawer.test.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/__tests__/NodeConfigDrawer.test.tsx
@@ -96,4 +96,30 @@ describe('openNodeConfigDrawer', () => {
expect(await screen.findByTestId('legacy-workflow-collection')).toHaveTextContent('main.posts');
});
+
+ it('keeps the drawer title weight aligned with the v1 title layout', () => {
+ const drawer = vi.fn();
+
+ holder.ctx = {
+ api: {
+ resource: () => ({ update: vi.fn() }),
+ },
+ };
+
+ openNodeConfigDrawer({
+ ctx: { viewer: { drawer } },
+ data: { id: 9, key: 'node_9', title: 'Webhook response', type: 'update', config: {} },
+ instruction: {
+ title: 'Webhook response',
+ },
+ t: (key: string) => key,
+ workflow: { id: 1, config: {} },
+ });
+
+ const content = drawer.mock.calls[0][0].content as () => React.ReactElement;
+ renderWithApp(content());
+
+ expect(screen.getByText('Webhook response')).toHaveStyle({ fontWeight: 'bold' });
+ expect(screen.getByText('node_9')).toHaveStyle({ fontWeight: 'normal' });
+ });
});
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/Calculation.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/Calculation.tsx
index 611c5419dc4..474264f2a84 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/Calculation.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/Calculation.tsx
@@ -99,7 +99,12 @@ function useOperandMetaTree(): MetaTreeNode[] {
function Calculation({ calculator, operands = [], onChange }: any) {
const compile = useT();
- const metaTree = useOperandMetaTree();
+ // Keep the left/right operands on separate meta-tree instances. `TypedVariableInput`
+ // lazily resolves relation children by mutating its `metaTree` in place; sharing one
+ // tree between both sides can leave the other cascader stuck on a stale loading column
+ // when both operands walk the same workflow-variable branch.
+ const leftMetaTree = useOperandMetaTree();
+ const rightMetaTree = useOperandMetaTree();
const leftOperandOnChange = useCallback(
(v: unknown) => onChange({ calculator, operands: [v, operands[1]] }),
[calculator, onChange, operands],
@@ -127,7 +132,7 @@ function Calculation({ calculator, operands = [], onChange }: any) {
matches v1's single-row [operand · operator · operand] layout. */}
| null;
onChange?: (value: Record | null) => void;
+ /**
+ * Controls whether the filter row's right-hand value editor allows workflow variables.
+ * - `true`: render the RHS as a variable-aware input (constant or workflow variable)
+ * - `false`: render the RHS as a pure typed static input with no variable picker
+ */
+ rightAsVariable?: boolean;
}) {
const flowEngine = useFlowEngine();
const t = useT();
@@ -365,12 +372,17 @@ export function FilterDynamicComponent({
const Component = ({ value }: { value: VariableFilterItemValue }) => (
-
+
);
Component.displayName = 'WorkflowVariableFilterItem';
return Component;
- }, [filterModel, rightMetaTree]);
+ }, [filterModel, rightAsVariable, rightMetaTree]);
return ;
}
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/__tests__/AppendsSelect.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/__tests__/AppendsSelect.test.tsx
index f10605e3c66..a7c2705fbcb 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/__tests__/AppendsSelect.test.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/__tests__/AppendsSelect.test.tsx
@@ -13,7 +13,7 @@ import { render, screen } from '@testing-library/react';
import { AppendsSelect } from '../collection/AppendsSelect';
const treeSelectState = vi.hoisted(() => ({
- props: null as null | { treeData?: Array<{ title: string; value: string }> },
+ props: null as null | Record,
}));
vi.mock('antd', () => {
@@ -22,7 +22,9 @@ vi.mock('antd', () => {
return ;
};
TreeSelect.SHOW_PARENT = 'SHOW_PARENT';
- return { TreeSelect };
+ TreeSelect.SHOW_ALL = 'SHOW_ALL';
+ const Tag = ({ children }: any) => {children};
+ return { TreeSelect, Tag };
});
vi.mock('@nocobase/flow-engine', () => ({
@@ -41,6 +43,14 @@ vi.mock('@nocobase/flow-engine', () => ({
uiSchema: { title: '{{t("Created by")}}' },
},
},
+ {
+ options: {
+ name: 'role',
+ type: 'belongsTo',
+ target: 'roles',
+ uiSchema: { title: '{{t("Role")}}' },
+ },
+ },
],
}),
},
@@ -68,6 +78,42 @@ describe('AppendsSelect', () => {
title: 'Created by',
value: 'createdBy',
}),
+ expect.objectContaining({
+ title: 'Role',
+ value: 'role',
+ }),
]);
});
+
+ it('uses strict tree checking and full-path tag rendering to match v1 appends behavior', () => {
+ render();
+
+ expect(treeSelectState.props?.treeCheckStrictly).toBe(true);
+ expect(treeSelectState.props?.showCheckedStrategy).toBe('SHOW_ALL');
+ expect(treeSelectState.props?.value).toEqual([{ value: 'createdBy.role', label: 'createdBy.role' }]);
+
+ const tag = treeSelectState.props?.tagRender?.({
+ value: 'createdBy.role',
+ closable: true,
+ onClose: vi.fn(),
+ });
+ const { container } = render(tag);
+ expect(container).toHaveTextContent('Created by / Role');
+ });
+
+ it('adds parent paths when a child node is selected', () => {
+ const onChange = vi.fn();
+ render();
+
+ treeSelectState.props?.onChange?.([{ value: 'createdBy.role', label: 'createdBy.role' }]);
+ expect(onChange).toHaveBeenCalledWith(['createdBy.role', 'createdBy']);
+ });
+
+ it('removes descendants when a selected parent node is unselected', () => {
+ const onChange = vi.fn();
+ render();
+
+ treeSelectState.props?.onChange?.([{ value: 'createdBy.role', label: 'createdBy.role' }]);
+ expect(onChange).toHaveBeenCalledWith([]);
+ });
});
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/__tests__/Calculation.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/__tests__/Calculation.test.tsx
new file mode 100644
index 00000000000..f04fa2c0091
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/__tests__/Calculation.test.tsx
@@ -0,0 +1,66 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { CalculationConfig } from '../Calculation';
+
+const holder = vi.hoisted(() => ({
+ typedVariableInputProps: [] as Array<{ metaTree: unknown; value: unknown }>,
+}));
+
+vi.mock('../../locale', () => ({
+ NAMESPACE: 'workflow',
+ useT: () => (key: string) => key,
+ useWorkflowTranslation: () => ({ t: (key: string) => key }),
+}));
+
+vi.mock('@nocobase/client-v2', () => ({
+ TypedVariableInput: (props: any) => {
+ holder.typedVariableInputProps.push({
+ metaTree: props.metaTree,
+ value: props.value,
+ });
+ return ;
+ },
+}));
+
+vi.mock('@nocobase/evaluators/client', () => ({
+ evaluators: {
+ getEntities: () => [],
+ },
+}));
+
+describe('CalculationConfig', () => {
+ it('creates separate workflow variable trees for left and right operands', () => {
+ holder.typedVariableInputProps = [];
+ const useVariableHook = vi
+ .fn()
+ .mockImplementation(() => [{ name: '$context', title: 'Trigger variables', paths: ['$context'], type: '' }]);
+
+ render(
+ undefined}
+ />,
+ );
+
+ expect(screen.getAllByTestId('typed-variable-input')).toHaveLength(2);
+ expect(useVariableHook).toHaveBeenCalledTimes(2);
+ expect(holder.typedVariableInputProps).toHaveLength(2);
+ expect(holder.typedVariableInputProps[0].metaTree).not.toBe(holder.typedVariableInputProps[1].metaTree);
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/__tests__/FilterDynamicComponent.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/__tests__/FilterDynamicComponent.test.tsx
index 7a886c9250b..b8c5299238a 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/__tests__/FilterDynamicComponent.test.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/__tests__/FilterDynamicComponent.test.tsx
@@ -251,4 +251,19 @@ describe('FilterDynamicComponent', () => {
expect(onChange).toHaveBeenCalledWith({ $and: [{ title: { $eq: '{{$jobsMapByNodeKey.n1.body}}' } }] });
});
});
+
+ it('can disable right-side variable input for trigger-only filter usage', () => {
+ const { engine } = setupEngine();
+
+ render(
+
+ undefined} rightAsVariable={false} />
+ ,
+ );
+
+ fireEvent.click(screen.getByText('Add condition'));
+
+ expect(testState.variableFilterItems).toHaveLength(1);
+ expect(testState.variableFilterItems[0].rightAsVariable).toBe(false);
+ });
});
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/collection/AppendsSelect.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/collection/AppendsSelect.tsx
index 85dffe45ed9..bcd0096f577 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/collection/AppendsSelect.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/collection/AppendsSelect.tsx
@@ -8,7 +8,8 @@
*/
import { useFlowEngine } from '@nocobase/flow-engine';
-import { TreeSelect } from 'antd';
+import { useMemoizedFn } from 'ahooks';
+import { Tag, TreeSelect } from 'antd';
import React, { useMemo } from 'react';
import { useT } from '../../locale';
import {
@@ -19,7 +20,13 @@ import {
type CollectionTriggerField,
} from './utils';
-type AppendsTreeNode = { title: string; value: string; key: string; children?: AppendsTreeNode[] };
+type AppendsTreeNode = {
+ title: string;
+ value: string;
+ key: string;
+ fullTitle: string[];
+ children?: AppendsTreeNode[];
+};
type CollectionDataSourceManager = Parameters[0];
function buildAssociationTree(
@@ -28,6 +35,7 @@ function buildAssociationTree(
collectionValue?: string,
prefix = '',
depth = 2,
+ parentTitles: string[] = [],
): AppendsTreeNode[] {
const fields = getCollectionFields(dataSourceManager, collectionValue);
return fields
@@ -35,23 +43,38 @@ function buildAssociationTree(
.filter(isAssociationField)
.map((field: CollectionTriggerField & { name: string }) => {
const value = prefix ? `${prefix}.${field.name}` : field.name;
+ const title = field.uiSchema?.title ? compile(field.uiSchema.title) : field.name;
+ const fullTitle = [...parentTitles, title];
const [dataSourceKey] = parseCollectionName(collectionValue) as [string, string];
const targetCollection = field.target
? `${dataSourceKey && dataSourceKey !== 'main' ? `${dataSourceKey}:` : ''}${field.target}`
: undefined;
const children =
depth > 1 && targetCollection
- ? buildAssociationTree(dataSourceManager, compile, targetCollection, value, depth - 1)
+ ? buildAssociationTree(dataSourceManager, compile, targetCollection, value, depth - 1, fullTitle)
: [];
return {
- title: field.uiSchema?.title ? compile(field.uiSchema.title) : field.name,
+ title,
value,
key: value,
+ fullTitle,
children: children.length ? children : undefined,
};
});
}
+type TreeSelectValue = { value: string; label?: React.ReactNode };
+
+function flattenTree(treeData: AppendsTreeNode[]): Record {
+ return treeData.reduce>((result, node) => {
+ result[node.value] = node;
+ if (node.children?.length) {
+ Object.assign(result, flattenTree(node.children));
+ }
+ return result;
+ }, {});
+}
+
export function AppendsSelect({
collection,
value,
@@ -67,16 +90,62 @@ export function AppendsSelect({
() => buildAssociationTree(flowEngine.context.dataSourceManager, t, collection),
[flowEngine, t, collection],
);
+ const optionsMap = useMemo(() => flattenTree(treeData), [treeData]);
+ const treeValue = useMemo(
+ () => (value ?? []).map((item) => ({ value: item, label: item })).filter((item) => item.value in optionsMap),
+ [optionsMap, value],
+ );
+
+ const handleChange = useMemoizedFn((next: TreeSelectValue | TreeSelectValue[] | undefined) => {
+ const nextItems = Array.isArray(next) ? next : next ? [next] : [];
+ const nextValues = nextItems.map((item) => item.value).filter(Boolean) as string[];
+ const valueSet = new Set(nextValues);
+ const removedValue = (value ?? []).find((item) => !valueSet.has(item));
+
+ if (removedValue) {
+ const prefix = `${removedValue}.`;
+ Object.keys(optionsMap).forEach((key) => {
+ if (key.startsWith(prefix)) {
+ valueSet.delete(key);
+ }
+ });
+ } else {
+ nextValues.forEach((item) => {
+ const paths = item.split('.');
+ for (let i = 1; i <= paths.length; i++) {
+ valueSet.add(paths.slice(0, i).join('.'));
+ }
+ });
+ }
+
+ onChange?.(Array.from(valueSet));
+ });
+
+ const tagRender = useMemoizedFn(
+ (props: { value?: string; closable?: boolean; onClose?: (event?: React.MouseEvent) => void }) => {
+ const node = props.value ? optionsMap[props.value] : undefined;
+ if (!node) {
+ return null;
+ }
+ return (
+
+ {node.fullTitle.join(' / ')}
+
+ );
+ },
+ );
return (
);
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/collection/CollectionConfig.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/collection/CollectionConfig.tsx
index 196569dfe44..47dd3f711b4 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/collection/CollectionConfig.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/collection/CollectionConfig.tsx
@@ -97,7 +97,7 @@ export function CollectionTriggerConfig() {
{collection && !hasCollectionTriggerMode(mode, COLLECTION_TRIGGER_MODE.DELETED) ? (
-
+
) : null}
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/collection/__tests__/CollectionConfig.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/collection/__tests__/CollectionConfig.test.tsx
index 15adb7cac08..06459ac0f39 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/collection/__tests__/CollectionConfig.test.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/collection/__tests__/CollectionConfig.test.tsx
@@ -15,6 +15,9 @@ import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
import CollectionTriggerConfig from '../CollectionConfig';
const workflowState = vi.hoisted(() => ({ sync: true }));
+const conditionFieldState = vi.hoisted(() => ({
+ propsList: [] as any[],
+}));
vi.mock('../../../locale', () => ({
NAMESPACE: 'workflow',
@@ -28,7 +31,10 @@ vi.mock('../../../components/collection', () => ({
}));
vi.mock('../../../components/FilterDynamicComponent', () => ({
- ConditionField: () => ,
+ ConditionField: (props: any) => {
+ conditionFieldState.propsList.push(props);
+ return ;
+ },
}));
vi.mock('../../../canvas/contexts', () => ({
@@ -38,6 +44,7 @@ vi.mock('../../../canvas/contexts', () => ({
describe('CollectionTriggerConfig', () => {
it('consumes extracted shared collection components', () => {
workflowState.sync = true;
+ conditionFieldState.propsList = [];
const engine = new FlowEngine();
render(
@@ -52,6 +59,7 @@ describe('CollectionTriggerConfig', () => {
expect(screen.getByTestId('fields-select')).toBeInTheDocument();
expect(screen.getByTestId('condition-field')).toBeInTheDocument();
expect(screen.getByTestId('appends-select')).toBeInTheDocument();
+ expect(conditionFieldState.propsList.at(-1)?.rightAsVariable).toBe(false);
expect(
screen.getByText(
'Synchronous collection event workflows run within the trigger transaction by default. Related data operations automatically use this transaction.',