refactor(plugin-workflow): migrate output node to v2 and align result display (#9813)

This commit is contained in:
PiEgg
2026-06-17 17:09:55 +08:00
committed by GitHub
parent 28b9ec4117
commit 42b56b8bf4
15 changed files with 320 additions and 54 deletions
+2 -1
View File
@@ -77,4 +77,5 @@ openspec/
.windsurf
.zencoder
/skills
/skills-lock.json
/skills-lock.json
paseo.json
@@ -0,0 +1,26 @@
/**
* 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 { TypedVariableInput, type TypedConstantSpec, type TypedVariableInputProps } from '@nocobase/client-v2';
import { useWorkflowVariableOptions, type UseWorkflowVariableOptions } from './useWorkflowVariableOptions';
export const WORKFLOW_TYPED_CONSTANT_TYPES: TypedConstantSpec[] = ['string', 'number', 'boolean', 'date', 'object'];
export type WorkflowTypedVariableInputProps = Omit<
TypedVariableInputProps,
'extraNodes' | 'metaTree' | 'namespaces'
> & {
variableOptions?: UseWorkflowVariableOptions;
};
export function WorkflowTypedVariableInput({ variableOptions, ...rest }: WorkflowTypedVariableInputProps) {
const metaTree = useWorkflowVariableOptions(variableOptions);
return <TypedVariableInput {...rest} metaTree={metaTree} />;
}
@@ -31,10 +31,9 @@ import React, { createContext, useCallback, useContext } from 'react';
import { Trans } from 'react-i18next';
import { useT, useWorkflowTranslation, NAMESPACE } from '../locale';
import { useWorkflowVariableOptions } from '../canvas/useWorkflowVariableOptions';
import { WORKFLOW_TYPED_CONSTANT_TYPES } from '../canvas/WorkflowTypedVariableInput';
// Constant types a calculation operand accepts. v1 uses bare `useTypedConstant` (= all types), whose constant submenu
// includes JSON — so include `object`.
const OPERAND_TYPES: TypedConstantSpec[] = ['string', 'number', 'boolean', 'date', 'object'];
const OPERAND_TYPES: TypedConstantSpec[] = WORKFLOW_TYPED_CONSTANT_TYPES;
// v1 relied on a global FormItem `.auto-width` rule to shrink the operator Select to its content; v2 has no such global
// rule, so scope it locally (same pattern as the core `FileSizeInput`). Without this the antd Select defaults to
@@ -17,6 +17,7 @@ import { useFlowContext } from '../canvas/contexts';
import { formatTime } from './workflowCanvas';
import { JobStatusTag } from './jobStatus';
import useStyles from '../canvas/style';
import { formatResultForDisplay } from './formatResultForDisplay';
function JobResult({ jobId }: { jobId: string | number }) {
const ctx = useFlowEngineContext();
@@ -43,7 +44,7 @@ function JobResult({ jobId }: { jobId: string | number }) {
</div>
<div style={{ fontWeight: 500, marginBottom: 8 }}>{t('Node result')}:</div>
<Input.TextArea
value={JSON.stringify(data?.result ?? null, null, 2)}
value={formatResultForDisplay(data?.result)}
disabled
autoSize={{ minRows: 4, maxRows: 20 }}
className={styles.nodeJobResultClass}
@@ -34,6 +34,7 @@ import { parse } from '@nocobase/utils/client';
import { useT } from '../locale';
import { CurrentWorkflowContext, NodeContext, useCurrentWorkflowContext } from '../canvas/contexts';
import { WorkflowVariableInput } from '../canvas/WorkflowVariableInput';
import { formatResultForDisplay } from './formatResultForDisplay';
// Replacement values accept any literal, matching v1's
// `useTypedConstant={['string','number','boolean','date','object']}`.
@@ -185,7 +186,7 @@ function TestRunDialog({ data }: { data: any }) {
/>
) : null}
<Input.TextArea
value={result == null ? '' : JSON.stringify(result.result ?? null, null, 2)}
value={result == null ? '' : formatResultForDisplay(result.result)}
readOnly
autoSize={{ minRows: 5, maxRows: 20 }}
style={{ whiteSpace: 'pre', cursor: 'text', fontFamily: 'monospace', fontSize: '80%' }}
@@ -0,0 +1,84 @@
/**
* 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 { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { JobResultModal } from '../JobResultModal';
vi.mock('@nocobase/flow-engine', () => ({
useFlowContext: () => ({
api: {
resource: () => ({
get: vi.fn(),
}),
},
}),
}));
vi.mock('ahooks', () => ({
useRequest: () => ({
loading: false,
data: {
status: 1,
updatedAt: '2026-06-17T07:27:07.888Z',
result: '1234',
log: null,
},
}),
}));
vi.mock('../../locale', () => ({
useT: () => (key: string) => key,
}));
vi.mock('../../canvas/useWorkflowInstruction', () => ({
useInstruction: () => ({
title: 'Output',
}),
}));
vi.mock('../../canvas/contexts', () => ({
useFlowContext: () => ({
viewJob: {
id: 11,
node: {
type: 'output',
title: '流程输出',
},
},
setViewJob: vi.fn(),
}),
}));
vi.mock('../jobStatus', () => ({
JobStatusTag: () => <div>Resolved</div>,
}));
vi.mock('../workflowCanvas', () => ({
formatTime: () => '2026-06-17 15:27:07',
}));
vi.mock('../../canvas/style', () => ({
default: () => ({
styles: {
nodeTitleClass: 'node-title',
nodeJobResultClass: 'node-job-result',
},
}),
}));
describe('JobResultModal', () => {
it('matches the v1 Input.JSON display semantics for numeric-looking string results', () => {
render(<JobResultModal />);
expect(screen.getByRole('textbox')).toHaveValue('1234');
expect(screen.getByRole('textbox')).not.toHaveValue('"1234"');
});
});
@@ -97,6 +97,7 @@ describe('TestRunButton', () => {
expect(screen.getByTestId('workflow-variable-pill')).toHaveTextContent('resolved-pill');
expect(screen.getByTestId('typed-variable-input')).toBeInTheDocument();
expect(screen.getByRole('textbox')).toHaveValue('');
expect(holder.typedVariableInputProps).toEqual([
{
value: undefined,
@@ -0,0 +1,34 @@
/**
* 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 { describe, expect, it } from 'vitest';
import { formatResultForDisplay } from '../formatResultForDisplay';
describe('formatResultForDisplay', () => {
it('matches v1 Input.JSON behavior for a numeric-looking string result', () => {
expect(formatResultForDisplay('1234')).toBe('1234');
});
it('keeps raw JSON-looking string text unchanged', () => {
expect(formatResultForDisplay('{"a":1}')).toBe('{"a":1}');
});
it('quotes a plain non-JSON string', () => {
expect(formatResultForDisplay('hello')).toBe('"hello"');
});
it('pretty-prints objects', () => {
expect(formatResultForDisplay({ a: 1 })).toBe('{\n "a": 1\n}');
});
it('returns empty string for nullish values', () => {
expect(formatResultForDisplay(null)).toBe('');
expect(formatResultForDisplay(undefined)).toBe('');
});
});
@@ -0,0 +1,33 @@
/**
* 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.
*/
/**
* Match v1 `Input.JSON` display semantics for workflow result viewers.
*
* - `null`/`undefined` → empty string
* - string that itself is valid JSON text (`1234`, `true`, `{"a":1}`) → show raw string
* - other strings (`hello`) → show JSON-quoted string
* - non-strings → pretty JSON
*/
export function formatResultForDisplay(value: unknown, space = 2): string {
if (value == null) {
return '';
}
if (typeof value === 'string') {
try {
JSON.parse(value);
return value;
} catch {
return JSON.stringify(value, null, space);
}
}
return JSON.stringify(value, null, space);
}
@@ -16,6 +16,8 @@ export { Instruction } from './canvas/Instruction';
export type { LoaderOf, NodeAvailableContext, TempAssociationSource } from './canvas/Instruction';
export { WorkflowVariableInput } from './canvas/WorkflowVariableInput';
export type { WorkflowVariableInputProps } from './canvas/WorkflowVariableInput';
export { WorkflowTypedVariableInput, WORKFLOW_TYPED_CONSTANT_TYPES } from './canvas/WorkflowTypedVariableInput';
export type { WorkflowTypedVariableInputProps } from './canvas/WorkflowTypedVariableInput';
export { useWorkflowVariableOptions } from './canvas/useWorkflowVariableOptions';
export { Trigger } from './triggers';
export type { LoaderOf as TriggerLoaderOf, TriggerTempAssociationSource } from './triggers';
@@ -0,0 +1,53 @@
/**
* 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 { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Form } from 'antd';
vi.mock('../../locale', () => ({
NAMESPACE: 'workflow',
useT: () => (key: string) => key,
}));
vi.mock('../../canvas/WorkflowTypedVariableInput', () => ({
WORKFLOW_TYPED_CONSTANT_TYPES: ['string', 'number', 'boolean', 'date', 'object'],
WorkflowTypedVariableInput: (props: any) => (
<div
data-testid="workflow-typed-variable-input"
data-value={props.value ?? ''}
data-placeholder={props.placeholder ?? ''}
data-nullable={String(props.nullable)}
data-default-to-first={String(props.defaultToFirstConstantTypeWhenUndefined)}
data-types={JSON.stringify(props.types)}
/>
),
}));
import { OutputFieldset } from '../components/output';
describe('OutputFieldset', () => {
it('binds the field to config.value and preserves the v1 typed-variable configuration', () => {
render(
<Form initialValues={{ value: 'top-level-value', config: { value: 'nested-config-value' } }}>
<OutputFieldset />
</Form>,
);
expect(screen.getByText('Output value')).toBeInTheDocument();
const input = screen.getByTestId('workflow-typed-variable-input');
expect(input).toHaveAttribute('data-value', 'nested-config-value');
expect(input).toHaveAttribute('data-placeholder', 'Input workflow result');
expect(input).toHaveAttribute('data-nullable', 'false');
expect(input).toHaveAttribute('data-default-to-first', 'true');
expect(input).toHaveAttribute('data-types', '["string","number","boolean","date","object"]');
});
});
@@ -0,0 +1,31 @@
/**
* 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 { describe, expect, it } from 'vitest';
import V2OutputInstruction from '../output';
import V1OutputInstruction from '../../../client/nodes/output';
describe('OutputInstruction', () => {
it('keeps the v1 node as a thin compatibility entry over the v2 implementation', () => {
const instruction = new V1OutputInstruction();
expect(instruction).toBeInstanceOf(V2OutputInstruction);
expect(typeof instruction.FieldsetLoader).toBe('function');
});
it('preserves the v1 metadata in the v2 instruction', () => {
const instruction = new V2OutputInstruction();
expect(instruction.type).toBe('output');
expect(instruction.group).toBe('control');
expect(instruction.description).toBe(
'{{t("Set output data of this workflow. When this one is executed as a subflow, the output could be used as variables in downstream nodes of super workflow. You can also use this node in an AI employee workflow, to define what to output. If this node is added multiple times, the value of the last executed node prevails.", { ns: "workflow" })}}',
);
});
});
@@ -0,0 +1,28 @@
/**
* 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 { Form } from 'antd';
import { useT } from '../../locale';
import { WorkflowTypedVariableInput, WORKFLOW_TYPED_CONSTANT_TYPES } from '../../canvas/WorkflowTypedVariableInput';
export function OutputFieldset() {
const t = useT();
return (
<Form.Item name={['config', 'value']} label={t('Output value')}>
<WorkflowTypedVariableInput
types={WORKFLOW_TYPED_CONSTANT_TYPES}
nullable={false}
defaultToFirstConstantTypeWhenUndefined
placeholder={t('Input workflow result')}
/>
</Form.Item>
);
}
@@ -11,6 +11,7 @@ import React from 'react';
import { ProfileOutlined } from '@ant-design/icons';
import { Instruction } from '../canvas/Instruction';
import { NAMESPACE } from '../locale';
import { BaseTypeSets } from '../canvas/collectionFieldOptions';
const t = (key: string) => `{{t("${key}", { ns: "${NAMESPACE}" })}}`;
@@ -18,5 +19,22 @@ export default class extends Instruction {
type = 'output';
title = t('Output');
group = 'control';
description = t(
'Set output data of this workflow. When this one is executed as a subflow, the output could be used as variables in downstream nodes of super workflow. You can also use this node in an AI employee workflow, to define what to output. If this node is added multiple times, the value of the last executed node prevails.',
);
icon = (<ProfileOutlined />);
FieldsetLoader = () => import('./components/output').then((m) => ({ default: m.OutputFieldset }));
useVariables({ key, title }, { types }) {
if (
types &&
!types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type)))
) {
return null;
}
return {
value: key,
label: title,
};
}
}
@@ -7,52 +7,6 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { ArrayItems } from '@formily/antd-v5';
import { ProfileOutlined } from '@ant-design/icons';
import V2OutputInstruction from '../../client-v2/nodes/output';
import { Instruction } from '.';
import { BaseTypeSets, WorkflowVariableInput } from '../variable';
import { NAMESPACE } from '../locale';
export default class extends Instruction {
title = `{{t("Output", { ns: "${NAMESPACE}" })}}`;
type = 'output';
group = 'control';
description = `{{t("Set output data of this workflow. When this one is executed as a subflow, the output could be used as variables in downstream nodes of super workflow. You can also use this node in an AI employee workflow, to define what to output. If this node is added multiple times, the value of the last executed node prevails.", { ns: "${NAMESPACE}" })}}`;
icon = (<ProfileOutlined />);
fieldset = {
value: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'WorkflowVariableInput',
'x-component-props': {
changeOnSelect: true,
useTypedConstant: true,
nullable: false,
autoSize: {
minRows: 10,
},
placeholder: `{{t("Input workflow result", { ns: "${NAMESPACE}" })}}`,
},
title: `{{t('Output value', { ns: "${NAMESPACE}" })}}`,
},
};
scope = {};
components = {
ArrayItems,
WorkflowVariableInput,
};
useVariables({ key, title }, { types }) {
if (
types &&
!types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type)))
) {
return null;
}
return {
value: key,
label: title,
};
}
}
export default class extends V2OutputInstruction {}