diff --git a/docker/nocobase/Dockerfile b/docker/nocobase/Dockerfile index 8236e9f3066..ee3ffe8c032 100644 --- a/docker/nocobase/Dockerfile +++ b/docker/nocobase/Dockerfile @@ -46,9 +46,7 @@ RUN set -eux; \ /etc/apt/sources.list.d/nginx.list \ /tmp/nginx_signing.key \ /usr/share/keyrings/nginx-archive-keyring.gpg \ - /var/lib/apt/lists/* \ - /usr/share/doc/* \ - /usr/share/man/*; \ + /var/lib/apt/lists/*; \ git --version WORKDIR /app/nocobase diff --git a/docker/nocobase/Dockerfile-full b/docker/nocobase/Dockerfile-full index 0d0265a8b93..e5b065b402c 100644 --- a/docker/nocobase/Dockerfile-full +++ b/docker/nocobase/Dockerfile-full @@ -172,9 +172,7 @@ RUN --mount=from=assets,source=/tmp/libreoffice.tar.gz,target=/tmp/libreoffice.t /tmp/libreoffice-debs.list \ /tmp/LibreOffice*_Linux_*_deb \ /usr/share/keyrings/nginx-archive-keyring.gpg \ - /var/lib/apt/lists/* \ - /usr/share/doc/* \ - /usr/share/man/* && \ + /var/lib/apt/lists/* && \ printf '%s\n' \ '#!/bin/sh' \ 'exec /opt/libreoffice24.8/program/oosplash "$@"' \ diff --git a/packages/core/client-v2/src/flow/actions/__tests__/customVariable.runjs.test.ts b/packages/core/client-v2/src/flow/actions/__tests__/customVariable.runjs.test.ts deleted file mode 100644 index 10ee68add2c..00000000000 --- a/packages/core/client-v2/src/flow/actions/__tests__/customVariable.runjs.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * 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 { FlowContext } from '@nocobase/flow-engine'; -import { describe, expect, it, vi } from 'vitest'; - -import { customVariable } from '../customVariable'; - -describe('customVariable RunJS values', () => { - it('executes regular inline RunJS values', async () => { - const ctx = new FlowContext(); - ctx.defineProperty('flowKey', { value: 'eventSettings' }); - ctx.defineProperty('currentStep', { value: { key: 'customVariable' } }); - ctx.defineProperty('model', { value: { uid: 'form_block_1', use: 'FormBlockModel', context: ctx } }); - const runjs = vi.fn(async (code: string) => ({ - success: true, - value: code.includes('ctx.formValues.amount') ? 42 : undefined, - })); - ctx.defineMethod('runjs', runjs); - - await customVariable.handler(ctx, { - variables: [ - { - key: 'total', - title: 'Total', - type: 'runjs', - runjs: { code: 'return ctx.formValues.amount;', version: 'v2' }, - }, - ], - }); - - // Resolves to the {success, value} envelope, matching released behavior for saved expressions that read `.value` - await expect((ctx as unknown as { total: Promise }).total).resolves.toEqual({ success: true, value: 42 }); - expect(runjs).toHaveBeenCalledWith('return ctx.formValues.amount;', undefined, { version: 'v2' }); - }); - - it('treats empty RunJS code as unconfigured', async () => { - const ctx = new FlowContext(); - ctx.defineProperty('model', { value: { context: ctx } }); - const runjs = vi.fn(); - ctx.defineMethod('runjs', runjs); - - await customVariable.handler(ctx, { - variables: [{ key: 'total', title: 'Total', type: 'runjs', runjs: { code: '', version: 'v2' } }], - }); - - await expect((ctx as unknown as { total: Promise }).total).resolves.toBeUndefined(); - expect(runjs).not.toHaveBeenCalled(); - }); - - it('ignores unsafe variable identifiers', async () => { - const ctx = new FlowContext(); - const defineProperty = vi.fn(); - ctx.defineProperty('model', { - value: { - uid: 'form_block_1', - use: 'FormBlockModel', - context: { defineProperty }, - }, - }); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - - await customVariable.handler(ctx, { - variables: [ - { - key: '__proto__', - title: 'Unsafe', - type: 'runjs', - runjs: { code: 'return 1;', version: 'v2' }, - }, - ], - }); - - expect(defineProperty).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalledWith('[customVariable] Ignored an unsafe variable identifier'); - }); -}); diff --git a/packages/core/client-v2/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx b/packages/core/client-v2/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx index 9ad2583efa9..17f01b9beb2 100644 --- a/packages/core/client-v2/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx +++ b/packages/core/client-v2/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx @@ -62,7 +62,6 @@ function createModel(legacyFlowKey: string, fields = [{ fieldPath: 'description' engine.translate = vi.fn((key: string) => key) as any; const model = new FlowModel({ uid: `model-${legacyFlowKey}`, flowEngine: engine }) as any; model.subModels.grid = { - uid: `grid-${legacyFlowKey}`, subModels: { items: fields.map((field) => createLegacyField(field.fieldPath, field.value, legacyFlowKey)), }, @@ -129,18 +128,6 @@ describe('Field values legacy default migration', () => { ]); }); - it('always uses the inline editor for RunJS field values', async () => { - const model = createModel('editItemSettings'); - const rule = { key: 'rule-1', targetPath: 'title', value: { code: 'return "title";', version: 'v2' } }; - renderAction(formAssignRules, model, [rule]); - - await waitFor(() => { - expect(mockState.editorProps.length).toBeGreaterThan(0); - }); - - expect(mockState.editorProps.at(-1)?.getValueInputProps(rule, 0)?.sourceLocator).toBeUndefined(); - }); - it('does not re-import form legacy defaults after an empty form-level value is persisted', async () => { const model = createModel('editItemSettings'); model.setStepParams('formModelSettings', 'assignRules', { value: [] }); diff --git a/packages/core/client-v2/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts b/packages/core/client-v2/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts index 04d3a1be03e..c1507213636 100644 --- a/packages/core/client-v2/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +++ b/packages/core/client-v2/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts @@ -7,13 +7,9 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { FlowContext } from '@nocobase/flow-engine'; -import { setupRunJSTestHosts } from '@nocobase/test/client-v2'; import { describe, expect, it, vi } from 'vitest'; import { linkageAssignField, setFieldsDefaultValue, subFormLinkageAssignField } from '../linkageRules'; -setupRunJSTestHosts(); - describe('linkage assign actions - legacy params', () => { it('linkageAssignField should apply legacy object params at runtime', () => { const fieldModel: any = { uid: 'f1', fieldPath: 'a.b' }; diff --git a/packages/core/client-v2/src/flow/actions/__tests__/linkageRules.linkageRunjs.test.tsx b/packages/core/client-v2/src/flow/actions/__tests__/linkageRules.linkageRunjs.test.tsx deleted file mode 100644 index b801cef43ca..00000000000 --- a/packages/core/client-v2/src/flow/actions/__tests__/linkageRules.linkageRunjs.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/** - * 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 } from '@testing-library/react'; -import { - FlowContext, - FlowEngine, - FlowModel, - FlowSettingsContextProvider, - type RunJSValue, -} from '@nocobase/flow-engine'; -import { setupRunJSTestHosts } from '@nocobase/test/client-v2'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - runJSValueEditor: vi.fn((_props: Record) => null), -})); - -vi.mock('../../components/RunJSValueEditor', () => ({ - RunJSValueEditor: (props: Record) => { - mocks.runJSValueEditor(props); - return null; - }, -})); - -import { linkageRunjs } from '../linkageRules'; - -setupRunJSTestHosts(); - -type LinkageRunJSComponentProps = { - value?: unknown; - onChange?: (value: unknown) => void; - linkageRuleIndex?: number; - linkageActionIndex?: number; -}; - -describe('linkageRunjs', () => { - afterEach(() => { - mocks.runJSValueEditor.mockClear(); - vi.restoreAllMocks(); - }); - - it('normalizes legacy script values and keeps the editor inline-only', () => { - const engine = new FlowEngine(); - const model = new FlowModel({ uid: 'form-block-runjs', flowEngine: engine }); - model.context.defineProperty('flowKey', { value: 'eventSettings' }); - model.context.defineProperty('currentStep', { value: { key: 'linkageRules' } }); - const onChange = vi.fn(); - const Component = linkageRunjs.uiSchema?.value?.['x-component'] as React.ComponentType; - - render( - - - , - ); - - const editorProps = mocks.runJSValueEditor.mock.calls.at(-1)?.[0]; - // Legacy `{ script }` values must normalize to v1 so `{{ ctx.* }}` preprocessing keeps running, matching how - // origin/next executed them (`await ctx.runjs(script)` with no version → v1 default). - expect(editorProps?.value).toEqual({ - code: 'return ctx.formValues.amount;', - version: 'v1', - }); - expect(editorProps?.sourceLocator).toBeUndefined(); - - const nextValue: RunJSValue = { - code: 'return 8;', - version: 'v2', - }; - (editorProps?.onChange as ((value: RunJSValue) => void) | undefined)?.(nextValue); - - expect(onChange).toHaveBeenCalledWith(nextValue); - }); - - it('executes legacy script values through the RunJSValue runtime', async () => { - const runjs = vi.fn(async () => ({ success: true, value: 7 })); - const ctx = new FlowContext(); - ctx.defineProperty('model', { value: { uid: 'form-block-runjs', use: 'FormBlockModel' } }); - ctx.defineMethod('runjs', runjs); - - await linkageRunjs.handler(ctx, { - value: { script: 'return 7;' }, - }); - - expect(runjs).toHaveBeenCalledWith('return 7;', undefined, { version: 'v1' }); - }); -}); diff --git a/packages/core/client-v2/src/flow/actions/__tests__/linkageRules.runjsTemplateResolution.test.ts b/packages/core/client-v2/src/flow/actions/__tests__/linkageRules.runjsTemplateResolution.test.ts index f7565363f3c..c981d8a4547 100644 --- a/packages/core/client-v2/src/flow/actions/__tests__/linkageRules.runjsTemplateResolution.test.ts +++ b/packages/core/client-v2/src/flow/actions/__tests__/linkageRules.runjsTemplateResolution.test.ts @@ -7,11 +7,10 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { FlowContext } from '@nocobase/flow-engine'; import { describe, it, expect, vi } from 'vitest'; import { blockLinkageRules } from '../linkageRules'; -function fakeResolveJsonTemplate(input: unknown): unknown { +function fakeResolveJsonTemplate(input: any): any { if (typeof input === 'string') { return input .replaceAll('{{ctx.user.id}}', '123') @@ -21,30 +20,24 @@ function fakeResolveJsonTemplate(input: unknown): unknown { } if (Array.isArray(input)) return input.map((v) => fakeResolveJsonTemplate(v)); if (input && typeof input === 'object') { - const out: Record = {}; + const out: any = Array.isArray(input) ? [] : {}; for (const [k, v] of Object.entries(input)) out[k] = fakeResolveJsonTemplate(v); return out; } return input; } -function createTestContext(linkageRunjsHandler: (...args: unknown[]) => unknown) { - const ctx = new FlowContext(); - const resolveJsonTemplate = vi.fn(async (value: unknown) => fakeResolveJsonTemplate(value)); - ctx.defineProperty('app', { value: { jsonLogic: { apply: () => true } } }); - ctx.defineProperty('model', { value: { __allModels: [] } }); - ctx.defineProperty('t', { value: (text: string) => text }); - ctx.defineMethod('getAction', (name: string) => - name === 'linkageRunjs' ? { handler: linkageRunjsHandler } : undefined, - ); - ctx.defineMethod('resolveJsonTemplate', resolveJsonTemplate); - return { ctx, resolveJsonTemplate }; -} - describe('linkageRules: RunJS script templates resolved at execution time', () => { it('resolves non-script templates but preserves linkageRunjs script as raw', async () => { const linkageRunjsHandler = vi.fn(); - const { ctx, resolveJsonTemplate } = createTestContext(linkageRunjsHandler); + + const ctx: any = { + app: { jsonLogic: { apply: () => true } }, + model: { __allModels: [] }, + t: (s: string) => s, + getAction: (name: string) => (name === 'linkageRunjs' ? { handler: linkageRunjsHandler } : undefined), + resolveJsonTemplate: vi.fn(async (v: any) => fakeResolveJsonTemplate(v)), + }; await blockLinkageRules.handler(ctx, { value: [ @@ -66,7 +59,7 @@ describe('linkageRules: RunJS script templates resolved at execution time', () = ], }); - expect(resolveJsonTemplate).toHaveBeenCalledTimes(1); + expect(ctx.resolveJsonTemplate).toHaveBeenCalledTimes(1); expect(linkageRunjsHandler).toHaveBeenCalledTimes(1); const passedParams = linkageRunjsHandler.mock.calls[0]?.[1]; @@ -75,36 +68,4 @@ describe('linkageRules: RunJS script templates resolved at execution time', () = expect(passedParams.value.script).toContain('{{ctx.user.id}}'); expect(passedParams.value.script).toContain('{{ctx.user.name}}'); }); - - it('preserves RunJSValue code while resolving its settings', async () => { - const linkageRunjsHandler = vi.fn(); - const { ctx } = createTestContext(linkageRunjsHandler); - - await blockLinkageRules.handler(ctx, { - value: [ - { - key: 'r1', - title: 'r1', - enable: true, - condition: { logic: '$and', items: [] }, - actions: [ - { - name: 'linkageRunjs', - params: { - value: { - code: 'return "{{ctx.user.id}}/{{ctx.user.name}}";', - version: 'v2', - settings: { greeting: 'hello {{ctx.user.name}}' }, - }, - }, - }, - ], - }, - ], - }); - - const passedParams = linkageRunjsHandler.mock.calls[0]?.[1]; - expect(passedParams.value.code).toBe('return "{{ctx.user.id}}/{{ctx.user.name}}";'); - expect(passedParams.value.settings).toEqual({ greeting: 'hello Alice' }); - }); }); diff --git a/packages/core/client-v2/src/flow/actions/customVariable.tsx b/packages/core/client-v2/src/flow/actions/customVariable.tsx index c76e1b565c7..01b4e63d8e4 100644 --- a/packages/core/client-v2/src/flow/actions/customVariable.tsx +++ b/packages/core/client-v2/src/flow/actions/customVariable.tsx @@ -38,17 +38,11 @@ export const customVariable = defineAction({ async handler(ctx, params) { const { variables = [] } = params; - variables.forEach((variable, variableIndex) => { - if (!isSafeCustomVariableKey(variable.key)) { - console.warn('[customVariable] Ignored an unsafe variable identifier'); - return; - } + variables.forEach((variable) => { if (variable.type === 'runjs') { const getFunction = async () => { - const runJs = normalizeRunJSValue(variable.runjs); - if (!runJs.code.trim()) return undefined; - // Keep resolving to the {success, value} envelope for compatibility with saved expressions that read `.value` - return ctx.runjs(runJs.code, undefined, { version: runJs.version }); + const { code, version } = normalizeRunJSValue(variable.runjs); + return ctx.runjs(code, undefined, { version }); }; const metaFunction = () => ({ title: variable.title, @@ -102,12 +96,6 @@ export const customVariable = defineAction({ }, }); -const UNSAFE_CUSTOM_VARIABLE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); - -function isSafeCustomVariableKey(value: unknown): value is string { - return typeof value === 'string' && Boolean(value.trim()) && !UNSAFE_CUSTOM_VARIABLE_KEYS.has(value); -} - type FlowVariableType = 'formValue' | 'runjs'; interface FormValueVariable { @@ -373,16 +361,7 @@ function VariableEditor(props: VariableEditorProps) { { - if (!isSafeCustomVariableKey(nextKey)) { - throw new Error(t('Please enter variable identifier')); - } - }, - }, - ]} + rules={[{ required: true, message: t('Please enter variable identifier') }]} > @@ -402,22 +381,14 @@ function VariableEditor(props: VariableEditorProps) { rules={[ { validator: async (_, value) => { - const normalized = isRunJSValue(value) ? normalizeRunJSValue(value) : undefined; - if (!normalized?.code.trim()) { + if (!isRunJSValue(value) || !normalizeRunJSValue(value).code.trim()) { throw new Error(t('Please enter JavaScript code')); } }, }, ]} > - + ) : null} diff --git a/packages/core/client-v2/src/flow/actions/filterFormDefaultValues.tsx b/packages/core/client-v2/src/flow/actions/filterFormDefaultValues.tsx index 1d928a50d7d..fc3a12d3880 100644 --- a/packages/core/client-v2/src/flow/actions/filterFormDefaultValues.tsx +++ b/packages/core/client-v2/src/flow/actions/filterFormDefaultValues.tsx @@ -25,9 +25,9 @@ import { operators } from '../../flow-compat'; const FilterFormDefaultValuesUI = observer( (props: { value?: FieldAssignRuleItem[]; onChange?: (value: FieldAssignRuleItem[]) => void }) => { - const { value: propValue, onChange } = props; const ctx = useFlowContext(); - const t = ctx.model.translate.bind(ctx.model); + const { onChange, value: propsValue } = props; + const t = React.useMemo(() => ctx.model.translate.bind(ctx.model), [ctx.model]); const { isTitleFieldCandidate, onSyncAssociationTitleField } = useAssociationTitleFieldSync(t); const canEdit = typeof onChange === 'function'; @@ -74,15 +74,15 @@ const FilterFormDefaultValuesUI = observer( }, []); const normalizedValue = React.useMemo(() => { - return Array.isArray(propValue) ? propValue : []; - }, [propValue]); + return Array.isArray(propsValue) ? propsValue : []; + }, [propsValue]); const legacyAwareValue = React.useMemo(() => { if (hasPersistedValue) { return normalizedValue; } - return mergeAssignRulesWithLegacyDefaults(propValue, legacyDefaults); - }, [hasPersistedValue, legacyDefaults, normalizedValue, propValue]); + return mergeAssignRulesWithLegacyDefaults(propsValue, legacyDefaults); + }, [hasPersistedValue, legacyDefaults, normalizedValue, propsValue]); const value = React.useMemo(() => { if (!canEdit || !hasInitializedMerge) { diff --git a/packages/core/client-v2/src/flow/actions/formAssignRules.tsx b/packages/core/client-v2/src/flow/actions/formAssignRules.tsx index 7d3b6e1f4e8..3014f0002cb 100644 --- a/packages/core/client-v2/src/flow/actions/formAssignRules.tsx +++ b/packages/core/client-v2/src/flow/actions/formAssignRules.tsx @@ -23,9 +23,9 @@ import { hasPersistedAssignRulesValue } from '../models/blocks/shared/legacyDefa const FormAssignRulesUI = observer( (props: { value?: FieldAssignRuleItem[]; onChange?: (value: FieldAssignRuleItem[]) => void }) => { - const { value: propValue, onChange } = props; const ctx = useFlowContext(); - const t = ctx.model.translate.bind(ctx.model); + const { onChange, value: propsValue } = props; + const t = React.useMemo(() => ctx.model.translate.bind(ctx.model), [ctx.model]); const { isTitleFieldCandidate, onSyncAssociationTitleField } = useAssociationTitleFieldSync(t); const canEdit = typeof onChange === 'function'; @@ -52,16 +52,16 @@ const FormAssignRulesUI = observer( }, []); const normalizedValue = React.useMemo(() => { - const base = Array.isArray(propValue) ? propValue : []; + const base = Array.isArray(propsValue) ? propsValue : []; return base; - }, [propValue]); + }, [propsValue]); const legacyAwareValue = React.useMemo(() => { if (hasPersistedValue) { return normalizedValue; } - return mergeAssignRulesWithLegacyDefaults(propValue, legacyDefaults); - }, [hasPersistedValue, legacyDefaults, normalizedValue, propValue]); + return mergeAssignRulesWithLegacyDefaults(propsValue, legacyDefaults); + }, [hasPersistedValue, legacyDefaults, normalizedValue, propsValue]); const value = React.useMemo(() => { if (!canEdit || !hasInitializedMerge) { @@ -79,8 +79,6 @@ const FormAssignRulesUI = observer( [canEdit, markInitialized, onChange], ); - const getValueInputProps = React.useCallback(() => ({}), []); - // 仅在首次打开时,把合并结果写回到当前 step 表单状态,后续不再自动合并(以免重复添加)。 React.useEffect(() => { if (hasInitializedMergeRef.current) return; @@ -105,7 +103,6 @@ const FormAssignRulesUI = observer( value={value} onChange={handleChange} showValueEditorWhenNoField - getValueInputProps={getValueInputProps} isTitleFieldCandidate={isTitleFieldCandidate} onSyncAssociationTitleField={onSyncAssociationTitleField} enableDateVariableAsConstant diff --git a/packages/core/client-v2/src/flow/actions/linkageRules.tsx b/packages/core/client-v2/src/flow/actions/linkageRules.tsx index ba8f21caa63..6e3fb9d9e1b 100644 --- a/packages/core/client-v2/src/flow/actions/linkageRules.tsx +++ b/packages/core/client-v2/src/flow/actions/linkageRules.tsx @@ -19,7 +19,6 @@ import { observer, isRunJSValue, normalizeRunJSValue, - type RunJSValue, } from '@nocobase/flow-engine'; import { evaluateConditions, FilterGroupType, removeInvalidFilterItems } from '@nocobase/utils/client'; import React from 'react'; @@ -35,8 +34,7 @@ import { import { uid } from '@formily/shared'; import { FilterGroup } from '../components/filter/FilterGroup'; import { LinkageFilterItem } from '../components/filter'; -import { RunJSValueEditor } from '../components/RunJSValueEditor'; -import { evaluateInlineRunJSValue } from '../components/runjs-source'; +import { CodeEditor } from '../components/code-editor'; import { FieldAssignRulesEditor } from '../components/FieldAssignRulesEditor'; import type { AssignMode, FieldAssignRuleItem } from '../components/FieldAssignRulesEditor'; import { collectFieldAssignCascaderOptions } from '../components/fieldAssignOptions'; @@ -494,17 +492,18 @@ function createLegacyTargetPathResolver(ctx: FlowContext) { const SKIP_RUNJS_ASSIGN_VALUE = Symbol('SKIP_RUNJS_ASSIGN_VALUE'); -async function resolveLinkageAssignRuntimeValue(ctx: FlowContext, rawValue: unknown) { +async function resolveLinkageAssignRuntimeValue(ctx: FlowContext, rawValue: any) { if (!isRunJSValue(rawValue)) { return rawValue; } - const runJs = normalizeRunJSValue(rawValue); - if (!runJs.code.trim()) { - return SKIP_RUNJS_ASSIGN_VALUE; - } try { - return await evaluateInlineRunJSValue({ ctx, runJs }); + const { code, version } = normalizeRunJSValue(rawValue); + const ret = await ctx.runjs(code, undefined, { version }); + if (!ret?.success) { + return SKIP_RUNJS_ASSIGN_VALUE; + } + return ret.value; } catch (error) { console.warn('[linkageRules] Failed to evaluate RunJS assign value', error); return SKIP_RUNJS_ASSIGN_VALUE; @@ -945,10 +944,6 @@ export const linkageSetDetailsFieldProps = defineAction({ type ArrayFieldComponentProps = { value?: unknown; onChange?: (value: unknown) => void; - linkageRuleKey?: string | number; - linkageRuleIndex?: number; - linkageActionKey?: string | number; - linkageActionIndex?: number; }; const LEGACY_ASSIGN_RULE = { mode: 'assign', valueKey: 'assignValue' } as const; @@ -998,8 +993,6 @@ const FieldAssignRulesActionComponent: React.FC< [onChange], ); - const getValueInputProps = React.useCallback(() => ({}), []); - return ( = (prop return ; }; -function normalizeLinkageRunJSValue(value: unknown): RunJSValue | undefined { - if (isRunJSValue(value)) { - return normalizeRunJSValue(value); - } - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined; - } - const script = (value as { script?: unknown }).script; - // Legacy `{ script }` values were authored before the v2 runtime and rely on `{{ ctx.* }}` template - // preprocessing, which only runs under v1. Default them to v1 (matching normalizeRunJSValue and every sibling - // linkage path) so previously-saved scripts keep working; forcing v2 here silently broke them. - return typeof script === 'string' ? { code: script, version: 'v1' } : undefined; -} - -const LinkageRunJSValueComponent: React.FC = (props) => { - const { value, onChange } = props; - const ctx = useFlowContext(); - const t = React.useCallback((key: string) => ctx.model.translate(key), [ctx.model]); - const runJSValue = React.useMemo(() => normalizeLinkageRunJSValue(value) || { code: '', version: 'v2' }, [value]); - const handleChange = React.useCallback((next: RunJSValue) => onChange?.(next), [onChange]); - - return ( -
-
- -
-
- ); -}; - export const linkageAssignField = defineAction({ name: 'linkageAssignField', title: tExpr('Field assignment'), @@ -1458,29 +1412,59 @@ export const linkageRunjs = defineAction({ uiSchema: { value: { type: 'object', - 'x-component': LinkageRunJSValueComponent, + 'x-component': (props) => { + const { value = { script: '' }, onChange } = props; + const handleScriptChange = (script: string) => { + onChange({ + ...value, + script, + }); + }; + + return ( +
+ {/*
+
+ 预留一个位置,用于显示一些提示信息 +
+
*/} +
+ +
+
+ ); + }, }, }, handler: async (ctx, { value }) => { - const runJs = normalizeLinkageRunJSValue(value); - if (!runJs?.code.trim()) { + // 执行 JS 脚本处理逻辑 + const { script } = value || {}; + + if (!script || typeof script !== 'string') { return; } try { - await evaluateInlineRunJSValue({ ctx, runJs }); + await ctx.runjs(script); } catch (error) { - console.error('[linkageRules] RunJS execution failed', error); - const translate = - typeof ctx.t === 'function' - ? ctx.t.bind(ctx) - : typeof ctx.model?.translate === 'function' - ? ctx.model.translate.bind(ctx.model) - : undefined; - const messageText = translate?.('RunJS execution failed'); - const message = ctx.message || ctx.app?.message; - if (messageText) { - message?.error?.(messageText); + console.error('Script execution error:', error); + // 可以选择显示错误信息给用户 + if (ctx.app?.message) { + const msg = error instanceof Error ? error.message : String(error); + ctx.app.message.error(`Script execution error: ${msg}`); } } }, @@ -1506,10 +1490,6 @@ function protectLinkageRunJsScripts(params: { value?: LinkageRule[] } & Record ctx.getAction(actionName)); }; - const withLinkageSourceProps = ( - uiSchema: T, - rule: LinkageRule, - ruleIndex: number, - action: LinkageRule['actions'][number], - actionIndex: number, - ): T => { - if (!uiSchema || typeof uiSchema !== 'object' || Array.isArray(uiSchema)) return uiSchema; - const schema = uiSchema as T & { value?: unknown }; - if (!schema.value || typeof schema.value !== 'object' || Array.isArray(schema.value)) return uiSchema; - const next = _.cloneDeep(schema); - const valueSchema = next.value as Record; - const componentProps = valueSchema['x-component-props']; - valueSchema['x-component-props'] = { - ...(componentProps && typeof componentProps === 'object' && !Array.isArray(componentProps) - ? componentProps - : {}), - linkageRuleKey: rule.key || ruleIndex, - linkageRuleIndex: ruleIndex, - linkageActionKey: action.key || actionIndex, - linkageActionIndex: actionIndex, - }; - return next as T; - }; - // 添加动作 const handleAddAction = (ruleIndex: number, actionName: string) => { const newAction = { @@ -1863,7 +1814,7 @@ const LinkageRulesUI = observer(
{flowEngine.flowSettings.renderStepForm({ - uiSchema: withLinkageSourceProps(actionDef.uiSchema, rule, index, action, actionIndex), + uiSchema: actionDef.uiSchema, initialValues: action.params, flowEngine, onFormValuesChange: (form: any) => handleActionValueChange(index, actionIndex, form.values), @@ -2282,16 +2233,13 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => { }; // 1. 运行所有的联动规则 - for (const [ruleIndex, rule] of linkageRules.entries()) { - if (!rule.enable) { - continue; - } + for (const rule of linkageRules.filter((r) => r.enable)) { const { condition: conditions, actions } = rule; const matched = evaluateConditions(removeInvalidFilterItems(conditions), evaluator); if (!matched) continue; - for (const [actionIndex, action] of actions.entries()) { + for (const action of actions) { const setProps = (model: FlowModel & { __originalProps?: any; __shouldReset?: boolean }, props: any) => { const normalizedProps = props && typeof props === 'object' && Object.prototype.hasOwnProperty.call(props, 'value') @@ -2346,20 +2294,7 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => { }; // TODO: 需要改成 runAction 的写法。但 runAction 是异步的,用在这里会不符合预期。后面需要解决这个问题 - const previousOwnerPath = (ctx as { __linkageRunJSOwnerPath?: unknown }).__linkageRunJSOwnerPath; - (ctx as { __linkageRunJSOwnerPath?: { ruleIndex: number; actionIndex: number } }).__linkageRunJSOwnerPath = { - ruleIndex: Number((rule as { __linkageRuleIndex?: unknown }).__linkageRuleIndex ?? ruleIndex), - actionIndex: Number((action as { __linkageActionIndex?: unknown }).__linkageActionIndex ?? actionIndex), - }; - try { - await ctx.getAction(action.name)?.handler(ctx, { ...action.params, setProps, addFormValuePatch }); - } finally { - if (typeof previousOwnerPath === 'undefined') { - delete (ctx as { __linkageRunJSOwnerPath?: unknown }).__linkageRunJSOwnerPath; - } else { - (ctx as { __linkageRunJSOwnerPath?: unknown }).__linkageRunJSOwnerPath = previousOwnerPath; - } - } + await ctx.getAction(action.name)?.handler(ctx, { ...action.params, setProps, addFormValuePatch }); } } @@ -2495,7 +2430,7 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => { } catch (error) { console.warn('[linkageRules] Failed to set form values via setFormValues', { flowKey: ctx.flowKey, - modelUid: ctx.model?.uid, + modelUid: (ctx.model as any)?.uid, setter: 'ctx', patchCount: allPatches.length, patches: allPatches.slice(0, 10).map((p) => ({ path: p.path, value: previewValueForLog(p.value) })), @@ -2512,7 +2447,7 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => { } catch (error) { console.warn('[linkageRules] Failed to set form values via setFormValues', { flowKey: ctx.flowKey, - modelUid: ctx.model?.uid, + modelUid: (ctx.model as any)?.uid, setter: 'blockModel', patchCount: allPatches.length, patches: allPatches.slice(0, 10).map((p) => ({ path: p.path, value: previewValueForLog(p.value) })), @@ -2757,25 +2692,23 @@ export const fieldLinkageRules = defineAction({ rowRulesByKey.set(rowKey, arr); }; - for (const [ruleIndex, rule] of rawRules.entries()) { + for (const rule of rawRules) { if (!rule || typeof rule !== 'object') continue; const baseRule = { key: (rule as any).key, title: (rule as any).title, enable: (rule as any).enable, condition: (rule as any).condition, - __linkageRuleIndex: ruleIndex, }; const actions = Array.isArray((rule as any).actions) ? ((rule as any).actions as any[]) : []; const blockActions: any[] = []; const rowActionsByKey = new Map(); - for (const [actionIndex, action] of actions.entries()) { + for (const action of actions) { const actionName = (action as any)?.name; const actionParams = (action as any)?.params; const rawValue = actionParams?.value; - const actionWithIndex = { ...action, __linkageActionIndex: actionIndex }; const splitAssignAction = (legacy: { mode: 'default' | 'assign'; @@ -2807,7 +2740,7 @@ export const fieldLinkageRules = defineAction({ const blockAction = blockItems.length > 0 ? { - ...actionWithIndex, + ...action, params: { ...actionParams, value: blockItems }, } : null; @@ -2816,7 +2749,7 @@ export const fieldLinkageRules = defineAction({ for (const [rowScopeKey, rowItems] of rowItemsByKey.entries()) { if (!rowItems.length) continue; rowActions.set(rowScopeKey, { - ...actionWithIndex, + ...action, params: { ...actionParams, value: rowItems }, }); } @@ -2847,7 +2780,7 @@ export const fieldLinkageRules = defineAction({ } // other actions: run at block scope only - blockActions.push(actionWithIndex); + blockActions.push(action); } if (blockActions.length) { @@ -3023,21 +2956,20 @@ export const fieldLinkageRules = defineAction({ // 如果当前未找到任何 row fork,但存在需要 row 上下文的赋值规则,延迟一帧再跑一次(解决 add 新行时 fork 尚未创建的问题) if (!hasAnyRowFork) { - const retryModel = ctx.model as FlowModel & { - __pendingLinkageRowScopedRetry__?: boolean; - disposed?: boolean; - }; - if (!retryModel?.__pendingLinkageRowScopedRetry__) { + const flagKey = '__pendingLinkageRowScopedRetry__'; + const anyModel = ctx.model as any; + if (!anyModel?.[flagKey]) { console.warn('[linkageRules] Row-scoped linkage assignment deferred (row forks not ready), will retry', { flowKey: ctx.flowKey, - modelUid: ctx.model?.uid, + modelUid: (ctx.model as any)?.uid, rowKeys: Array.from(rowParamsByKey.keys()), }); - retryModel.__pendingLinkageRowScopedRetry__ = true; + anyModel[flagKey] = true; setTimeout(() => { - retryModel.__pendingLinkageRowScopedRetry__ = false; - if (retryModel.disposed) return; - runRowScoped().catch((error) => { + anyModel[flagKey] = false; + const base = ctx.model as any; + if (!base || base.disposed) return; + void runRowScoped().catch((error) => { console.warn('[linkageRules] Failed to retry row-scoped linkage rules', error); }); }, 0); diff --git a/packages/core/client-v2/src/flow/components/DefaultValue.tsx b/packages/core/client-v2/src/flow/components/DefaultValue.tsx index 9eef8a9d807..8d655975b53 100644 --- a/packages/core/client-v2/src/flow/components/DefaultValue.tsx +++ b/packages/core/client-v2/src/flow/components/DefaultValue.tsx @@ -17,6 +17,7 @@ import { isVariableExpression, parseValueToPath, isRunJSValue, + normalizeRunJSValue, useFlowContext, extractPropertyPath, FlowModel, @@ -32,7 +33,6 @@ import { ensureOptionsFromUiSchemaEnumIfAbsent } from '../internal/utils/enumOpt import { pickOperatorStyle as pickStyle, resolveOperatorComponent } from '../internal/utils/operatorSchemaHelper'; import { RunJSValueEditor } from './RunJSValueEditor'; import { buildDynamicNamePath } from '../models/blocks/form/dynamicNamePath'; -import { evaluateInlineRunJSValue } from './runjs-source'; interface Props { value: any; @@ -40,7 +40,6 @@ interface Props { metaTree: MetaTreeNode[] | (() => Promise); model: FieldModel; flags?: Record; - sourceLabel?: string; } const snapshotOptions = (input: any): any[] | undefined => { @@ -175,7 +174,7 @@ function createTempFieldClass(Base: any) { } export const DefaultValue = connect((props: Props) => { - const { value, onChange, metaTree: propMetaTree, flags: componentFlags, sourceLabel, ...restProps } = props; + const { value, onChange, metaTree: propMetaTree, flags: componentFlags, ...restProps } = props; const flowContext = useFlowContext(); const { model } = flowContext; // no side-effects to original form until confirmed @@ -239,8 +238,9 @@ export const DefaultValue = connect((props: Props) => { // RunJS default: execute and use the computed result for preview/backfill if (isRunJSValue(out)) { try { - if (!out.code.trim()) return undefined; - out = await evaluateInlineRunJSValue({ ctx: model?.context, runJs: out }); + const { code, version } = normalizeRunJSValue(out); + const ret = await model?.context?.runjs(code, undefined, { version }); + out = ret?.success ? ret.value : undefined; } catch { out = undefined; } @@ -657,16 +657,10 @@ export const DefaultValue = connect((props: Props) => { const RunJSComponent = useMemo(() => { const C: React.FC = (inputProps) => ( - + ); return C; - }, [flowContext, sourceLabel]); + }, [flowContext]); const mergedMetaTree = useMemo<() => Promise>(() => { return async () => { let base: MetaTreeNode[] = []; diff --git a/packages/core/client-v2/src/flow/components/FieldAssignValueInput.tsx b/packages/core/client-v2/src/flow/components/FieldAssignValueInput.tsx index d8459bb896f..ed77a148872 100644 --- a/packages/core/client-v2/src/flow/components/FieldAssignValueInput.tsx +++ b/packages/core/client-v2/src/flow/components/FieldAssignValueInput.tsx @@ -27,6 +27,7 @@ import { getFormItemPreferredFieldPath, isToManyAssociationField, } from '../internal/utils/modelUtils'; +import { RunJSValueEditor } from './RunJSValueEditor'; import { pickOperatorStyle as pickStyle, resolveOperatorComponent } from '../internal/utils/operatorSchemaHelper'; import { InputFieldModel } from '../models/fields/InputFieldModel'; import { normalizeFilterValueByOperator } from '../models/blocks/filter-form/valueNormalization'; @@ -52,7 +53,7 @@ interface Props { value: any; onChange: (value: any) => void; placeholder?: string; - /** 额外变量树(置于 Constant/Null 与 base metaTree 之间) */ + /** 额外变量树(置于 Constant/Null/RunJS 与 base metaTree 之间) */ extraMetaTree?: MetaTreeNode[]; /** 可选:当前字段的筛选操作符,用于在默认值/赋值编辑器中按 operator schema 适配输入组件 */ operator?: string; @@ -69,9 +70,7 @@ interface Props { * @deprecated Date 已作为独立一级变量提供,此参数仅为调用兼容保留。 */ enableDateVariableAsConstant?: boolean; - /** - * @deprecated RunJS is no longer offered by this input. This parameter is retained for caller compatibility. - */ + /** 是否允许在变量选择器中使用 RunJS。默认 true,保持历史行为。 */ allowRunJS?: boolean; maxAssociationFieldDepth?: number; disabled?: boolean; @@ -512,6 +511,7 @@ export const FieldAssignValueInput: React.FC = ({ operatorMetaList, preferFormItemFieldModel, associationFieldNamesOverride, + allowRunJS = true, maxAssociationFieldDepth = 2, disabled = false, variableConverters, @@ -1025,6 +1025,18 @@ export const FieldAssignValueInput: React.FC = ({ return N; }, [flowCtx]); + const RunJSComponent = React.useMemo(() => { + const C: React.FC = (inputProps) => ( + + ); + return C; + }, [flowCtx]); + const baseMetaTree = React.useMemo<() => Promise>(() => { return async () => { const base = (await flowCtx.getPropertyMetaTree?.()) || []; @@ -1047,10 +1059,12 @@ export const FieldAssignValueInput: React.FC = ({ baseMetaTree={baseMetaTree} constantComponent={ConstantValueEditor} nullComponent={NullComponent} + runJSComponent={RunJSComponent} isDateLikeField={isDateLikeField} dateComponentProps={dateVariableComponentProps} style={{ width: '100%' }} clearValue={''} + allowRunJS={allowRunJS} disabled={disabled} converters={variableConverters} /> diff --git a/packages/core/client-v2/src/flow/components/RunJSValueEditor.tsx b/packages/core/client-v2/src/flow/components/RunJSValueEditor.tsx index ff7f0b965a7..3260d358d70 100644 --- a/packages/core/client-v2/src/flow/components/RunJSValueEditor.tsx +++ b/packages/core/client-v2/src/flow/components/RunJSValueEditor.tsx @@ -8,13 +8,8 @@ */ import React from 'react'; -import type { RunJSValue } from '@nocobase/flow-engine'; -import { - RunJSEditorField, - type EmbeddedRunJSEditorController, - type RunJSSourceLocator, - type RunJSSurfaceStyle, -} from './runjs-studio'; +import { isRunJSValue, normalizeRunJSValue, type RunJSValue } from '@nocobase/flow-engine'; +import { CodeEditor } from './code-editor'; export interface RunJSValueEditorProps { t?: (key: string) => string; @@ -23,13 +18,7 @@ export interface RunJSValueEditorProps { disabled?: boolean; height?: string; scene?: string; - locator?: RunJSSourceLocator; - sourceLocator?: RunJSSourceLocator; - sourceLabel?: string; - surfaceStyle?: RunJSSurfaceStyle; containerStyle?: React.CSSProperties; - editorChrome?: 'standalone' | 'embedded'; - onEmbeddedEditorControllerChange?: (controller: EmbeddedRunJSEditorController | null) => void; } export const RunJSValueEditor: React.FC = (props) => { @@ -43,25 +32,27 @@ export const RunJSValueEditor: React.FC = (props) => { containerStyle = { flex: 1, minWidth: 0 }, } = props; + const current: RunJSValue = isRunJSValue(value) ? normalizeRunJSValue(value) : { code: '', version: 'v2' }; + const tip = t?.('Use return to output value') ?? 'Use return to output value'; + const placeholderText = `// ${tip}`; + return ( - { - if (typeof nextValue !== 'string') { - onChange?.(nextValue); - } - }} - height={height} - scene={scene} - locator={props.locator} - sourceLocator={props.sourceLocator} - sourceLabel={props.sourceLabel} - surfaceStyle={props.surfaceStyle} - containerStyle={containerStyle} - disabled={disabled} - editorChrome={props.editorChrome} - onEmbeddedEditorControllerChange={props.onEmbeddedEditorControllerChange} - /> +
+ { + if (disabled) { + return; + } + onChange?.({ ...current, code }); + }} + version={current.version} + height={height} + readonly={disabled} + enableLinter + placeholder={placeholderText} + scene={scene} + /> +
); }; diff --git a/packages/core/client-v2/src/flow/components/__tests__/DefaultValue.test.tsx b/packages/core/client-v2/src/flow/components/__tests__/DefaultValue.test.tsx index 506cf9ccc74..76795d6a833 100644 --- a/packages/core/client-v2/src/flow/components/__tests__/DefaultValue.test.tsx +++ b/packages/core/client-v2/src/flow/components/__tests__/DefaultValue.test.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { act, render, screen, userEvent, waitFor, sleep } from '@nocobase/test/client'; -import { setupRunJSTestHosts } from '@nocobase/test/client-v2'; import { FlowEngine, FlowEngineProvider, FlowModel, FlowModelProvider, FlowModelRenderer } from '@nocobase/flow-engine'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import { ConfigProvider, App } from 'antd'; @@ -23,8 +22,6 @@ import { RecordSelectFieldModel } from '../../models/fields/AssociationFieldMode import { SelectFieldModel } from '../../models/fields/SelectFieldModel'; import { RichTextFieldModel } from '../../models/fields/RichTextFieldModel'; -setupRunJSTestHosts(); - // 简易 Form stub(非 Formily 分支),用于验证写回逻辑 function createFormStub(initial: any = {}) { const state: Record = { ...initial }; diff --git a/packages/core/client-v2/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx b/packages/core/client-v2/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx index 3f723928daf..26b870ca392 100644 --- a/packages/core/client-v2/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +++ b/packages/core/client-v2/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx @@ -126,12 +126,32 @@ describe('FieldAssignValueInput context', () => { getPropertyMetaTree: vi.fn(async () => []), }); - render(); + const onChange = vi.fn(); + const view = render(); await waitFor(() => { expect(engine.createModel).toHaveBeenCalled(); }); expect(engine.createModel).toHaveBeenCalledWith(expect.any(Object), { delegate: sourceContext }); + + const enabledProps = mockVariableInput.mock.calls.at(-1)?.[0]; + const enabledTree = + typeof enabledProps.metaTree === 'function' ? await enabledProps.metaTree() : enabledProps.metaTree; + expect(enabledTree.map((node: { name?: string }) => node.name)).toContain('runjs'); + + const initialRunJSValue = enabledProps.converters.resolveValueFromPath({ paths: ['runjs'] }); + expect(initialRunJSValue).toEqual({ code: '', version: 'v2' }); + expect(initialRunJSValue).not.toHaveProperty('sourceRef'); + const savedRunJSValue = { code: 'return 42;', version: 'v2' }; + enabledProps.onChange(savedRunJSValue); + expect(onChange).toHaveBeenCalledWith(savedRunJSValue); + expect(onChange.mock.calls.at(-1)?.[0]).not.toHaveProperty('sourceRef'); + + view.rerender(); + const disabledProps = mockVariableInput.mock.calls.at(-1)?.[0]; + const disabledTree = + typeof disabledProps.metaTree === 'function' ? await disabledProps.metaTree() : disabledProps.metaTree; + expect(disabledTree.map((node: { name?: string }) => node.name)).not.toContain('runjs'); }); it('uses the flow model context when a configured item model has no context', async () => { diff --git a/packages/core/client-v2/src/flow/components/field-value-variable/FieldValueVariableInput.tsx b/packages/core/client-v2/src/flow/components/field-value-variable/FieldValueVariableInput.tsx index f1e53cbe12f..7698da1ae13 100644 --- a/packages/core/client-v2/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +++ b/packages/core/client-v2/src/flow/components/field-value-variable/FieldValueVariableInput.tsx @@ -67,7 +67,7 @@ export type FieldValueVariableInputProps = Omit< baseMetaTree: MetaTreeNode[] | (() => MetaTreeNode[] | Promise); constantComponent: ValueEditorComponent; nullComponent: ValueEditorComponent; - runJSComponent?: ValueEditorComponent; + runJSComponent: ValueEditorComponent; isDateLikeField: boolean; dateComponentProps: DateVariableComponentProps; allowRunJS?: boolean; @@ -238,7 +238,7 @@ export const FieldValueVariableInput: React.FC = ( selectable: false, children: dateChildren, }, - ...(allowRunJS && RunJSComponent + ...(allowRunJS ? [ { title: tExpr('RunJS'), @@ -295,7 +295,7 @@ export const FieldValueVariableInput: React.FC = ( if (firstPath === 'constant') return ConstantComponent; if (firstPath === 'null') return NullComponent; if (firstPath === 'date') return DateEditor; - if (allowRunJS && RunJSComponent && firstPath === 'runjs') return RunJSComponent; + if (allowRunJS && firstPath === 'runjs') return RunJSComponent; return null; }, resolveValueFromPath: (item) => { @@ -307,14 +307,14 @@ export const FieldValueVariableInput: React.FC = ( if (firstPath === 'date') { return createInitialDateConfig(item.paths[1], isDateLikeField, dateComponentProps); } - if (allowRunJS && RunJSComponent && firstPath === 'runjs') return { code: '', version: 'v2' }; + if (allowRunJS && firstPath === 'runjs') return { code: '', version: 'v2' }; return undefined; }, resolvePathFromValue: (currentValue) => { const external = converters?.resolvePathFromValue?.(currentValue); if (external !== undefined) return external; if (currentValue === null) return ['null']; - if (allowRunJS && RunJSComponent && isRunJSValue(currentValue)) return ['runjs']; + if (allowRunJS && isRunJSValue(currentValue)) return ['runjs']; if (isDateVariableEditConfig(currentValue)) return ['date', getDateNodeName(currentValue)]; return typeof currentValue === 'string' && isVariableExpression(currentValue) ? parseValueToPath(currentValue) diff --git a/packages/core/client-v2/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx b/packages/core/client-v2/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx index b96eba28445..ad08d8deedb 100644 --- a/packages/core/client-v2/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +++ b/packages/core/client-v2/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx @@ -50,7 +50,7 @@ function renderInput(options?: { value?: unknown; isDateLikeField?: boolean; dateComponentProps?: DateVariableComponentProps; - includeRunJS?: boolean; + allowRunJS?: boolean; }) { const onChange = vi.fn(); render( @@ -60,9 +60,10 @@ function renderInput(options?: { baseMetaTree={[{ name: 'currentUser', title: 'Current user', type: 'object', paths: ['currentUser'] }]} constantComponent={ConstantComponent} nullComponent={NullComponent} - runJSComponent={options?.includeRunJS === false ? undefined : RunJSComponent} + runJSComponent={RunJSComponent} isDateLikeField={options?.isDateLikeField ?? false} dateComponentProps={options?.dateComponentProps ?? DEFAULT_DATE_VARIABLE_COMPONENT_PROPS} + allowRunJS={options?.allowRunJS} />, ); return onChange; @@ -111,13 +112,39 @@ describe('FieldValueVariableInput', () => { expect(tree[4].name).toBe('currentUser'); }); - it('omits RunJS when the host does not provide an editor', async () => { + it('edits RunJS as a single-file code and version value', async () => { const runJSValue = { code: 'return 1;', version: 'v2' }; - renderInput({ value: runJSValue, includeRunJS: false }); + const onChange = renderInput({ value: runJSValue }); + + expect(mocks.variableInputProps?.converters?.resolvePathFromValue?.(runJSValue)).toEqual(['runjs']); + expect( + mocks.variableInputProps?.converters?.resolveValueFromPath?.({ + name: 'runjs', + title: 'RunJS', + type: 'object', + paths: ['runjs'], + }), + ).toEqual({ code: '', version: 'v2' }); + expect( + mocks.variableInputProps?.converters?.renderInputComponent?.({ + name: 'runjs', + title: 'RunJS', + type: 'object', + paths: ['runjs'], + }), + ).toBe(RunJSComponent); + + const edited = { code: 'return 2;', version: 'v2' }; + mocks.variableInputProps?.onChange?.(edited); + expect(onChange).toHaveBeenCalledWith(edited); + expect(onChange.mock.calls.at(-1)?.[0]).not.toHaveProperty('sourceRef'); + }); + + it('hides RunJS when explicitly disabled', async () => { + renderInput({ allowRunJS: false }); const tree = await resolveMetaTree(); - expect(tree.map((node) => node.name)).toEqual(['constant', 'null', 'date', 'currentUser']); - expect(mocks.variableInputProps?.converters?.resolvePathFromValue?.(runJSValue)).toEqual(['constant']); + expect(tree.map((node) => node.name)).not.toContain('runjs'); expect( mocks.variableInputProps?.converters?.resolveValueFromPath?.({ name: 'runjs', diff --git a/packages/core/client-v2/src/flow/models/__tests__/RunJSFlowModelSurfaces.test.ts b/packages/core/client-v2/src/flow/models/__tests__/RunJSFlowModelSurfaces.test.ts index 79ba7b87206..575dfaab746 100644 --- a/packages/core/client-v2/src/flow/models/__tests__/RunJSFlowModelSurfaces.test.ts +++ b/packages/core/client-v2/src/flow/models/__tests__/RunJSFlowModelSurfaces.test.ts @@ -202,6 +202,10 @@ const surfaces: SurfaceSpec[] = [ }, ]; +const settingsHosts = surfaces.filter((surface) => + ['JSBlockModel', 'JSFieldModel', 'JSItemModel', 'JSActionModel'].includes(surface.name), +); + function getRunJsCodeSchema(spec: SurfaceSpec): CodeSchema { const flow = spec.modelClass.globalFlowRegistry.getFlow(spec.flowKey); const step = flow?.getStep('runJs'); @@ -398,14 +402,13 @@ describe('RunJS FlowModel surfaces', () => { }); }); - it('runs the complete JS Template settings host contract once', async () => { - const spec = surfaces.find((surface) => surface.name === 'JSBlockModel') as SurfaceSpec; + it.each(settingsHosts)('$name runs the complete JS Template settings host contract', async (spec) => { const model = createSurfaceModel(spec); const sourceBinding = { type: 'js-template-entry', - projectId: 'jtp_settings_contract', - templateId: 'jtt_settings_contract', - kind: 'js-block', + projectId: `jtp_settings_contract_${spec.jsTemplateKind}`, + templateId: `jtt_settings_contract_${spec.jsTemplateKind}`, + kind: spec.jsTemplateKind, }; await assertJsTemplateSettingsHostContract({ @@ -415,7 +418,7 @@ describe('RunJS FlowModel surfaces', () => { sourceBinding, nextSourceBinding: { ...sourceBinding, - templateId: 'jtt_settings_contract_next', + templateId: `jtt_settings_contract_${spec.jsTemplateKind}_next`, }, }); }); diff --git a/packages/core/client-v2/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx b/packages/core/client-v2/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx index e0c0b1eeab6..eb622a45273 100644 --- a/packages/core/client-v2/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +++ b/packages/core/client-v2/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx @@ -293,8 +293,6 @@ export class AssignFormItemModel extends FormItemModel { value={inputProps?.value} onChange={inputProps?.onChange} containerStyle={{ width: '100%' }} - sourceLabel={`${this.context.t('Assign field')} / ${this.context.t('RunJS')}`} - surfaceStyle="value" /> ); }; diff --git a/packages/core/client-v2/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.runjs.test.ts b/packages/core/client-v2/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.runjs.test.ts index 6f8ff2c724e..860d45d77bc 100644 --- a/packages/core/client-v2/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.runjs.test.ts +++ b/packages/core/client-v2/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.runjs.test.ts @@ -8,21 +8,33 @@ */ import { describe, expect, it, vi } from 'vitest'; -import { FlowContext } from '@nocobase/flow-engine'; -import { setupRunJSTestHosts } from '@nocobase/test/client-v2'; import { resolveAssignFieldValues } from '../assignFieldValuesFlow'; -setupRunJSTestHosts(); - describe('assignFieldValuesFlow RunJS values', () => { + it('assigns successful RunJS results', async () => { + const runjs = vi.fn(async () => ({ success: true, value: 42 })); + const ctx = { message: undefined, runjs }; + + await expect( + resolveAssignFieldValues(ctx, { + amount: { + code: 'return 42;', + version: 'v2', + }, + }), + ).resolves.toEqual({ amount: 42 }); + + expect(runjs).toHaveBeenCalledWith('return 42;', undefined, { version: 'v2' }); + }); + it('shows an error and aborts assignment when RunJS fails', async () => { - const ctx: any = new FlowContext(); - ctx.defineProperty('model', { value: { uid: 'assign_action_1', use: 'UpdateRecordActionModel' } }); - ctx.defineMethod('runjs', async () => ({ success: false, error: new Error('boom') })); const message = { error: vi.fn() }; - ctx.defineProperty('message', { value: message }); - ctx.defineProperty('t', { value: (messageText: string) => messageText }); + const ctx = { + message, + runjs: async () => ({ success: false, error: new Error('boom') }), + t: (messageText: string) => messageText, + }; await expect( resolveAssignFieldValues( @@ -31,7 +43,6 @@ describe('assignFieldValuesFlow RunJS values', () => { amountText: { code: 'throw new Error("boom")', version: 'v2', - settings: { currency: 'USD' }, }, }, 'UpdateRecordAction', @@ -42,11 +53,7 @@ describe('assignFieldValuesFlow RunJS values', () => { }); it('skips assignment fields when RunJS returns undefined', async () => { - const ctx: any = new FlowContext(); - ctx.defineProperty('model', { value: { uid: 'assign_action_1', use: 'UpdateRecordActionModel' } }); - ctx.defineMethod('runjs', async function (_code: string) { - return { success: true, value: undefined }; - }); + const ctx = { message: undefined, runjs: async () => ({ success: true, value: undefined }) }; await expect( resolveAssignFieldValues(ctx, { @@ -64,26 +71,4 @@ describe('assignFieldValuesFlow RunJS values', () => { preserved: 'ok', }); }); - - it('preserves nested JSON constants that look like RunJSValue objects', async () => { - const ctx: any = new FlowContext(); - ctx.defineProperty('model', { value: { uid: 'assign_action_1', use: 'UpdateRecordActionModel' } }); - const runjs = vi.fn(); - ctx.defineMethod('runjs', runjs); - - await expect( - resolveAssignFieldValues(ctx, { - metadata: { - nested: { code: 'literal' }, - list: [{ code: 'item-literal' }], - }, - }), - ).resolves.toEqual({ - metadata: { - nested: { code: 'literal' }, - list: [{ code: 'item-literal' }], - }, - }); - expect(runjs).not.toHaveBeenCalled(); - }); }); diff --git a/packages/core/client-v2/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx b/packages/core/client-v2/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx index b1cadd40a1a..81e0639820f 100644 --- a/packages/core/client-v2/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx +++ b/packages/core/client-v2/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx @@ -9,8 +9,7 @@ import { FlowModelRenderer, - isRunJSValue, - normalizeRunJSValue, + resolveRunJSObjectValues, tExpr, type FlowModelContext, useFlowEngine, @@ -20,7 +19,6 @@ import React, { useEffect } from 'react'; import { CollectionActionModel } from '../../base/CollectionActionModel'; import { RecordActionModel } from '../../base/RecordActionModel'; import { AssignFormModel } from './AssignFormModel'; -import { evaluateInlineRunJSValue } from '../../../components/runjs-source'; export const ASSIGN_FIELD_VALUES_STEP_KEY = 'assignFieldValues'; @@ -55,8 +53,6 @@ type AssignFieldValuesStepOptions = { clearRecordContext?: boolean; }; -const SKIP_ASSIGN_VALUE = Symbol('SKIP_ASSIGN_VALUE'); - function getContextCollection(ctx: AssignFieldValuesContext | undefined): AssignFieldValuesCollection | undefined { const collection = ctx?.collection; return collection && typeof collection === 'object' ? collection : undefined; @@ -166,13 +162,12 @@ export async function resolveAssignFieldValues( ctx: { message?: { error?: (message: string) => void }; t?: (message: string) => string; - model?: { uid?: string; use?: string }; }, rawAssignedValues: unknown, logName = 'AssignFieldValues', ): Promise { try { - return await resolveAssignRunJSObjectValues(ctx, rawAssignedValues); + return await resolveRunJSObjectValues(ctx, rawAssignedValues); } catch (error) { console.error(`[${logName}] RunJS execution failed`, error); ctx.message?.error?.(ctx.t?.('RunJS execution failed') || 'RunJS execution failed'); @@ -180,49 +175,6 @@ export async function resolveAssignFieldValues( } } -async function resolveAssignRunJSObjectValues( - ctx: { - model?: { uid?: string; use?: string }; - }, - rawAssignedValues: unknown, -): Promise { - if (!rawAssignedValues || typeof rawAssignedValues !== 'object' || Array.isArray(rawAssignedValues)) { - return {}; - } - - const output: AssignedValues = {}; - for (const [key, value] of Object.entries(rawAssignedValues)) { - if (typeof value === 'undefined') { - continue; - } - - const resolved = await resolveAssignRunJSValue(ctx, value); - if (resolved !== SKIP_ASSIGN_VALUE) { - output[key] = resolved; - } - } - - return output; -} - -async function resolveAssignRunJSValue( - ctx: { - model?: { uid?: string; use?: string }; - }, - value: unknown, -): Promise { - if (isRunJSValue(value)) { - const normalized = normalizeRunJSValue(value); - if (!normalized.code.trim()) { - return SKIP_ASSIGN_VALUE; - } - const evaluated = await evaluateInlineRunJSValue({ ctx, runJs: normalized }); - return typeof evaluated === 'undefined' ? SKIP_ASSIGN_VALUE : evaluated; - } - - return value; -} - export function mergeAssignFieldValues>( values: T, assignedValues?: AssignedValues | null, diff --git a/packages/core/client-v2/src/flow/models/blocks/js-block/__tests__/JSBlockJsTemplateSource.test.tsx b/packages/core/client-v2/src/flow/models/blocks/js-block/__tests__/JSBlockJsTemplateSource.test.tsx index d7279d56468..c32791228f3 100644 --- a/packages/core/client-v2/src/flow/models/blocks/js-block/__tests__/JSBlockJsTemplateSource.test.tsx +++ b/packages/core/client-v2/src/flow/models/blocks/js-block/__tests__/JSBlockJsTemplateSource.test.tsx @@ -657,52 +657,6 @@ describe('JSBlockModel JS Template source', () => { expect(() => optionsStep?.beforeParamsSave?.(settingsContext, { value: { limit: 10 } })).not.toThrow(); }); - it('keeps step identity stable when the schema changes for the same entry', async () => { - let descriptor: RunJSSourceSettingsDescriptor = SETTINGS_DESCRIPTOR; - RunJSSourceResolverRegistry.registerResolver({ - sourceMode: 'js-template', - getSettingsDescriptor: vi.fn(async () => descriptor), - resolve: () => ({ - code: 'ctx.render("sales");', - }), - }); - - const engine = new FlowEngine(); - engine.registerModels({ JSBlockModel }); - const model = engine.createModel({ - use: 'JSBlockModel', - uid: 'js-block-runtime-settings-schema-change', - stepParams: { - jsSettings: { - runJs: { - sourceMode: 'js-template', - sourceBinding: SOURCE_BINDING, - }, - }, - }, - }); - - const oldSteps = await model.getRuntimeFlowSettingSteps('jsSettings'); - descriptor = { - ...SETTINGS_DESCRIPTOR, - settingsSchemaHash: 'schema_next', - schema: { - type: 'object', - properties: { - message: { - type: 'string', - title: 'Updated message', - }, - }, - }, - }; - - const nextSteps = await model.getRuntimeFlowSettingSteps('jsSettings'); - - expect(Object.values(nextSteps || {}).map((step) => step.title)).toEqual(['Updated message']); - expect(Object.keys(nextSteps || {})[0]).toBe(Object.keys(oldSteps || {})[0]); - }); - it('passes runtime settings step values to the JS Template resolver', async () => { const resolve = vi.fn((input) => ({ code: 'ctx.render({ctx.settings.message}:{ctx.settings.pageSize}:{String(ctx.settings.enabled)});', diff --git a/packages/plugins/@nocobase/plugin-js-template/src/client-v2/__tests__/settings-auto-form.integration.test.tsx b/packages/plugins/@nocobase/plugin-js-template/src/client-v2/__tests__/settings-auto-form.integration.test.tsx index 91153d654a7..107b96fb6c6 100644 --- a/packages/plugins/@nocobase/plugin-js-template/src/client-v2/__tests__/settings-auto-form.integration.test.tsx +++ b/packages/plugins/@nocobase/plugin-js-template/src/client-v2/__tests__/settings-auto-form.integration.test.tsx @@ -16,7 +16,6 @@ import { describe, expect, it, vi } from 'vitest'; import { normalizeSettingsForSchema, serializeDatePickerValue, - SettingsAutoForm, SettingsSingleField, } from '../components/SettingsAutoForm'; @@ -31,7 +30,7 @@ vi.mock('@nocobase/client-v2', async (importOriginal) => ({ ApplicationContext: (await import('react')).createContext(null), })); -describe('SettingsAutoForm', () => { +describe('SettingsSingleField', () => { it('uses the complete candidate root for object draft visibility without rendering the object title twice', async () => { const onChange = vi.fn(); const schema = { @@ -291,64 +290,6 @@ describe('SettingsAutoForm', () => { }); }); - it('reports validation changes when the selected entry schema changes without changing settings', async () => { - const onChange = vi.fn(); - const value = { - plan: 'pro', - }; - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(onChange).toHaveBeenCalled(); - }); - onChange.mockClear(); - - rerender( - , - ); - - await waitFor(() => { - expect(onChange).toHaveBeenCalledWith( - { - plan: 'pro', - }, - expect.objectContaining({ - errors: [ - expect.objectContaining({ - label: 'plan', - message: 'Must be one of the allowed values', - }), - ], - }), - ); - }); - }); - it('serializes date values as YYYY-MM-DD and date-time values as ISO timestamps', () => { const value = dayjs('2026-07-05T01:30:00.000Z'); @@ -356,11 +297,12 @@ describe('SettingsAutoForm', () => { expect(serializeDatePickerValue({ format: 'date-time' }, value)).toBe('2026-07-05T01:30:00.000Z'); }); - it('disables radio group fields when the form is disabled', () => { + it('disables radio group fields when the field is disabled', () => { const { container } = render( - { const { container } = render( - { const { container } = render( - + , ); @@ -556,7 +499,11 @@ describe('SettingsAutoForm', () => { const { container } = render( - + , ); @@ -623,7 +570,11 @@ describe('SettingsAutoForm', () => { const { container } = render( - + , ); @@ -695,8 +646,9 @@ describe('SettingsAutoForm', () => { const { container } = render( - , @@ -785,7 +737,11 @@ describe('SettingsAutoForm', () => { const { container } = render( - + , ); @@ -821,8 +777,9 @@ describe('SettingsAutoForm', () => { }; const { container } = render( - { it('validates supported string formats', async () => { const onChange = vi.fn(); render( - , ); await waitFor(() => { expect(onChange).toHaveBeenCalledWith( - { - contact: 'not-an-email', - }, + 'not-an-email', expect.objectContaining({ errors: [ expect.objectContaining({ @@ -879,26 +830,20 @@ describe('SettingsAutoForm', () => { it('treats required empty strings as present values like runtime validation', async () => { const onChange = vi.fn(); render( - , ); await waitFor(() => { expect(onChange).toHaveBeenCalledWith( - { - title: '', - }, + '', expect.objectContaining({ errors: [], }), @@ -909,26 +854,20 @@ describe('SettingsAutoForm', () => { it('validates required null values against their schema type like runtime validation', async () => { const onChange = vi.fn(); render( - , ); await waitFor(() => { expect(onChange).toHaveBeenCalledWith( - { - title: null, - }, + null, expect.objectContaining({ errors: [ expect.objectContaining({ @@ -944,29 +883,23 @@ describe('SettingsAutoForm', () => { it('validates array items against the item schema', async () => { const onChange = vi.fn(); render( - , ); await waitFor(() => { expect(onChange).toHaveBeenCalledWith( - { - tags: ['a'], - }, + ['a'], expect.objectContaining({ errors: [ expect.objectContaining({ diff --git a/packages/plugins/@nocobase/plugin-js-template/src/client-v2/components/SettingsAutoForm.tsx b/packages/plugins/@nocobase/plugin-js-template/src/client-v2/components/SettingsAutoForm.tsx index 71224c5250d..40f7f335d37 100644 --- a/packages/plugins/@nocobase/plugin-js-template/src/client-v2/components/SettingsAutoForm.tsx +++ b/packages/plugins/@nocobase/plugin-js-template/src/client-v2/components/SettingsAutoForm.tsx @@ -69,13 +69,6 @@ export type SettingsValidationResult = { errors: SettingsValidationError[]; }; -export interface SettingsAutoFormProps { - schema?: Record | null; - value?: Record | null; - onChange?: (value: Record, validation: SettingsValidationResult) => void; - disabled?: boolean; -} - export interface SettingsSingleFieldProps { fieldName?: string; fieldPath?: string[] | string; @@ -336,66 +329,6 @@ export function serializeDatePickerValue(schema: JsonSchema, value: Dayjs | null return schema.format === 'date' ? value.format('YYYY-MM-DD') : value.toISOString(); } -export const SettingsAutoForm: React.FC = ({ schema, value, onChange, disabled }) => { - const { t } = useTranslation(NAMESPACE); - const rootSchema = React.useMemo(() => asSchema(schema), [schema]); - const current = React.useMemo(() => normalizeSettingsForSchema(rootSchema, value).value, [rootSchema, value]); - const validation = React.useMemo(() => normalizeSettingsForSchema(rootSchema, current), [rootSchema, current]); - const lastReportedRef = React.useRef(); - const validationErrors = React.useMemo( - () => formatSettingsValidationErrors(validation.errors, t), - [t, validation.errors], - ); - - React.useEffect(() => { - const normalizedValue = isRecord(value) ? value : {}; - const valueChanged = JSON.stringify(current) !== JSON.stringify(normalizedValue); - const reportKey = JSON.stringify({ - value: current, - errors: validation.errors, - }); - if (!valueChanged && lastReportedRef.current === reportKey) { - return; - } - lastReportedRef.current = reportKey; - onChange?.(current, validation); - }, [current, onChange, validation, value]); - - if (!rootSchema.properties || Object.keys(rootSchema.properties).length === 0) { - return ; - } - - const handleChange = (path: string[], nextValue: unknown) => { - const next = updateAtPath(current, path, nextValue); - onChange?.(next, normalizeSettingsForSchema(rootSchema, next)); - }; - - return ( - - {validation.errors.length > 0 ? ( - - ) : null} - {Object.entries(rootSchema.properties).map(([key, childSchema]) => ( - - ))} - - ); -}; - export const SettingsSingleField: React.FC = ({ fieldName = 'value', fieldPath, @@ -1110,5 +1043,3 @@ function getFields(collection: SettingsCollection): SettingsCollectionField[] { function toNonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value : undefined; } - -export default SettingsAutoForm; diff --git a/packages/plugins/@nocobase/plugin-js-template/src/client-v2/runjs-studio/RunJSStudioProvider.tsx b/packages/plugins/@nocobase/plugin-js-template/src/client-v2/runjs-studio/RunJSStudioProvider.tsx index bbcf0abb34c..972aa4a48c3 100644 --- a/packages/plugins/@nocobase/plugin-js-template/src/client-v2/runjs-studio/RunJSStudioProvider.tsx +++ b/packages/plugins/@nocobase/plugin-js-template/src/client-v2/runjs-studio/RunJSStudioProvider.tsx @@ -15,7 +15,10 @@ import { useRunJSStudioController } from './useRunJSStudioController'; export const runJSStudioProvider: RunJSEditorProvider = { key: '@nocobase/runjs/workspace/runjs-studio', - canHandle: (props) => (props.sourceLocator ?? props.locator)?.kind === 'flowModel.step', + canHandle: (props) => { + const kind = (props.sourceLocator ?? props.locator)?.kind; + return kind === 'flowModel.step' || kind === 'flowModel.flowRegistry.runjs'; + }, renderEditor: (props) => , }; @@ -30,12 +33,16 @@ function cloneRunJSSourceLocator(locator: RunJSEditorProviderRenderProps['locato if (!locator) { return undefined; } - if (locator.kind !== 'flowModel.step') { - return undefined; + if (locator.kind === 'flowModel.step') { + return { + ...locator, + paramPath: [...locator.paramPath], + versionPath: locator.versionPath ? [...locator.versionPath] : undefined, + }; } + return { ...locator, - paramPath: [...locator.paramPath], - versionPath: locator.versionPath ? [...locator.versionPath] : undefined, + sourcePath: [...locator.sourcePath], }; } diff --git a/packages/plugins/@nocobase/plugin-js-template/src/client-v2/runjs-studio/__tests__/RunJSStudioProvider.test.tsx b/packages/plugins/@nocobase/plugin-js-template/src/client-v2/runjs-studio/__tests__/RunJSStudioProvider.test.tsx index 5a06460a2cb..4de927b7bed 100644 --- a/packages/plugins/@nocobase/plugin-js-template/src/client-v2/runjs-studio/__tests__/RunJSStudioProvider.test.tsx +++ b/packages/plugins/@nocobase/plugin-js-template/src/client-v2/runjs-studio/__tests__/RunJSStudioProvider.test.tsx @@ -168,6 +168,14 @@ const locator = { paramPath: ['code'], } satisfies RunJSSourceLocator; +const registryLocator = { + kind: 'flowModel.flowRegistry.runjs', + modelUid: 'fm_1', + flowKey: 'eventFlow', + stepKey: 'runjs', + sourcePath: ['defaultParams', 'code'], +} satisfies RunJSSourceLocator; + const repository = { id: 'repo-1', repoId: 'repo-1', @@ -510,40 +518,69 @@ describe('runJSStudioProvider', () => { expect(screen.getByText('Modified · +2 · -1')).toBeTruthy(); }); - it('handles only flow model step locators and prefers sourceLocator', () => { + it('handles flow model step and registry locators and prefers sourceLocator', () => { expect(runJSStudioProvider.canHandle?.({ value: { code: '', version: 'v2' }, locator })).toBe(true); - - const nonStepLocators = [ - { - kind: 'flowModel.flowRegistry.runjs' as const, - modelUid: 'fm_1', - flowKey: 'eventFlow', - stepKey: 'runjs', - sourcePath: ['params', 'code'], - }, - ]; - for (const nonStepLocator of nonStepLocators) { - expect(runJSStudioProvider.canHandle?.({ value: { code: '', version: 'v2' }, locator: nonStepLocator })).toBe( - false, - ); - } + expect(runJSStudioProvider.canHandle?.({ value: { code: '', version: 'v2' }, locator: registryLocator })).toBe( + true, + ); expect( runJSStudioProvider.canHandle?.({ value: { code: '', version: 'v2' }, locator, - sourceLocator: nonStepLocators[0], + sourceLocator: registryLocator, }), - ).toBe(false); + ).toBe(true); expect( runJSStudioProvider.canHandle?.({ value: { code: '', version: 'v2' }, - locator: nonStepLocators[0], + locator: registryLocator, sourceLocator: locator, }), ).toBe(true); }); + it('clones the locator-specific path arrays before opening the workspace', async () => { + const stepLocator = { + ...locator, + versionPath: ['version'], + } satisfies RunJSSourceLocator; + const stepView = renderEditor(vi.fn(), { sourceLocator: stepLocator }); + + await waitFor(() => { + expect(mocks.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'runJSSources:open', + data: expect.objectContaining({ locator: expect.objectContaining({ kind: 'flowModel.step' }) }), + }), + ); + }); + const stepRequest = mocks.request.mock.calls.find(([request]) => request.url === 'runJSSources:open')?.[0] as { + data: { locator: Extract }; + }; + expect(stepRequest.data.locator).toEqual(stepLocator); + expect(stepRequest.data.locator.paramPath).not.toBe(stepLocator.paramPath); + expect(stepRequest.data.locator.versionPath).not.toBe(stepLocator.versionPath); + + stepView.unmount(); + mocks.request.mockClear(); + renderEditor(vi.fn(), { sourceLocator: registryLocator }); + + await waitFor(() => { + expect(mocks.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'runJSSources:open', + data: expect.objectContaining({ locator: expect.objectContaining({ kind: 'flowModel.flowRegistry.runjs' }) }), + }), + ); + }); + const registryRequest = mocks.request.mock.calls.find(([request]) => request.url === 'runJSSources:open')?.[0] as { + data: { locator: Extract }; + }; + expect(registryRequest.data.locator).toEqual(registryLocator); + expect(registryRequest.data.locator.sourcePath).not.toBe(registryLocator.sourcePath); + }); + it('passes host source metadata to shared toolbar contributions', async () => { const unregister = runJSStudioToolbarRegistry.register({ key: 'test-source-metadata', diff --git a/packages/plugins/@nocobase/plugin-js-template/src/server/__tests__/flow-engine-runjs-source-integration.test.ts b/packages/plugins/@nocobase/plugin-js-template/src/server/__tests__/flow-engine-runjs-source-integration.test.ts index c7999590b6f..4311dd19ce1 100644 --- a/packages/plugins/@nocobase/plugin-js-template/src/server/__tests__/flow-engine-runjs-source-integration.test.ts +++ b/packages/plugins/@nocobase/plugin-js-template/src/server/__tests__/flow-engine-runjs-source-integration.test.ts @@ -113,6 +113,87 @@ describe('JS Template Flow Engine RunJS source integration', () => { }); }); + it('opens and saves a multi-file Dynamic Flow source back to the Flow Registry', async () => { + const repository = app.db.getCollection('flowModels').repository as FlowModelRepository; + await repository.insertModel({ + uid: 'dynamic-flow-runjs-source', + title: 'Dynamic Flow RunJS source', + use: 'FormModel', + flowRegistry: { + eventFlow: { + on: 'submit', + steps: { + runjs: { + use: 'runjs', + defaultParams: { + code: 'return "before";', + }, + }, + }, + }, + }, + }); + const user = await app.db.getRepository('users').findOne(); + const agent = await app.agent().login(user); + const locator: RunJSSourceLocator = { + kind: 'flowModel.flowRegistry.runjs', + modelUid: 'dynamic-flow-runjs-source', + flowKey: 'eventFlow', + stepKey: 'runjs', + sourcePath: ['defaultParams', 'code'], + }; + + const opened = await agent.resource('runJSSources').open({ values: { locator } }); + + expect(opened.status).toBe(200); + expect(opened.body.data).toMatchObject({ + locator, + locatorKind: 'flowModel.flowRegistry.runjs', + legacy: { + code: 'return "before";', + version: 'v2', + }, + }); + + const saved = await agent.resource('runJSSources').save({ + values: { + locator, + repoId: opened.body.data.repository.repoId, + baseCommitId: opened.body.data.repository.headCommitId, + baseOwnerFingerprint: opened.body.data.ownerFingerprint, + message: 'Update Dynamic Flow source', + entryPath: 'src/main.ts', + files: [ + { + path: 'src/main.ts', + operation: 'upsert', + content: 'import { result } from "./result";\nreturn result;', + language: 'typescript', + }, + { + path: 'src/result.ts', + operation: 'upsert', + content: 'export const result = "after from helper";', + language: 'typescript', + }, + ], + }, + }); + + expect(saved.status).toBe(200); + const model = await repository.findModelById('dynamic-flow-runjs-source'); + expect(model.flowRegistry.eventFlow.steps.runjs.defaultParams.code).toContain('after from helper'); + expect(model.flowRegistry.eventFlow.steps.runjs.defaultParams).not.toHaveProperty('sourceRef'); + + const reopened = await agent.resource('runJSSources').open({ values: { locator } }); + expect(reopened.body.data.files).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'src/main.ts', content: expect.stringContaining('return result') }), + expect.objectContaining({ path: 'src/result.ts', content: expect.stringContaining('after from helper') }), + ]), + ); + }); + it('bootstraps a complete ordinary workspace in the Host transaction without creating a JS Template repo', async () => { const repository = app.db.getCollection('flowModels').repository as FlowModelRepository; await repository.insertModel({