mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-21 05:44:51 +08:00
refactor(runjs): narrow workspace boundaries
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 "$@"' \
|
||||
|
||||
@@ -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<unknown> }).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<unknown> }).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');
|
||||
});
|
||||
});
|
||||
-13
@@ -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: [] });
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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<string, unknown>) => null),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/RunJSValueEditor', () => ({
|
||||
RunJSValueEditor: (props: Record<string, unknown>) => {
|
||||
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<LinkageRunJSComponentProps>;
|
||||
|
||||
render(
|
||||
<FlowSettingsContextProvider value={model.context}>
|
||||
<Component
|
||||
value={{ script: 'return ctx.formValues.amount;' }}
|
||||
onChange={onChange}
|
||||
linkageRuleIndex={2}
|
||||
linkageActionIndex={3}
|
||||
/>
|
||||
</FlowSettingsContextProvider>,
|
||||
);
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
+11
-50
@@ -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<string, unknown> = {};
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
<Form.Item
|
||||
label={t('Variable identifier')}
|
||||
name="key"
|
||||
rules={[
|
||||
{ required: true, message: t('Please enter variable identifier') },
|
||||
{
|
||||
validator: async (_, nextKey) => {
|
||||
if (!isSafeCustomVariableKey(nextKey)) {
|
||||
throw new Error(t('Please enter variable identifier'));
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
rules={[{ required: true, message: t('Please enter variable identifier') }]}
|
||||
>
|
||||
<Input placeholder={t('Please enter variable identifier')} />
|
||||
</Form.Item>
|
||||
@@ -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'));
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<RunJSValueEditor
|
||||
t={t}
|
||||
scene="eventFlow"
|
||||
height="240px"
|
||||
containerStyle={{ width: '100%' }}
|
||||
sourceLabel={`${t('Custom variable')} / ${t('RunJS')}`}
|
||||
surfaceStyle="value"
|
||||
/>
|
||||
<RunJSValueEditor t={t} scene="eventFlow" height="240px" containerStyle={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Form>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<FieldAssignRulesEditor
|
||||
t={t}
|
||||
@@ -1008,7 +1001,6 @@ const FieldAssignRulesActionComponent: React.FC<
|
||||
value={normalized}
|
||||
onChange={handleChange}
|
||||
fixedMode={fixedMode}
|
||||
getValueInputProps={getValueInputProps}
|
||||
isTitleFieldCandidate={isTitleFieldCandidate}
|
||||
onSyncAssociationTitleField={onSyncAssociationTitleField}
|
||||
enableDateVariableAsConstant
|
||||
@@ -1028,44 +1020,6 @@ const SetFieldsDefaultValueComponent: React.FC<ArrayFieldComponentProps> = (prop
|
||||
return <FieldAssignRulesActionComponent {...props} legacy={LEGACY_DEFAULT_RULE} fixedMode="default" />;
|
||||
};
|
||||
|
||||
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<ArrayFieldComponentProps> = (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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<RunJSValueEditor
|
||||
t={t}
|
||||
value={runJSValue}
|
||||
onChange={handleChange}
|
||||
height="200px"
|
||||
scene="linkage"
|
||||
sourceLabel={`${t('Linkage rule')} / ${t('Execute JavaScript')}`}
|
||||
surfaceStyle="action"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{/* <div
|
||||
style={{
|
||||
backgroundColor: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#666', fontSize: '12px', lineHeight: '1.5' }}>
|
||||
预留一个位置,用于显示一些提示信息
|
||||
</div>
|
||||
</div> */}
|
||||
<div>
|
||||
<CodeEditor
|
||||
value={value.script}
|
||||
onChange={handleScriptChange}
|
||||
height="200px"
|
||||
enableLinter={true}
|
||||
scene="linkage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
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<s
|
||||
if (typeof script === 'string' && script.length) {
|
||||
_.set(action, ['params', 'value', 'script'], mask(script));
|
||||
}
|
||||
const valueCode = _.get(action, ['params', 'value', 'code']);
|
||||
if (typeof valueCode === 'string' && valueCode.length) {
|
||||
_.set(action, ['params', 'value', 'code'], mask(valueCode));
|
||||
}
|
||||
const code = _.get(action, ['params', 'code']);
|
||||
if (typeof code === 'string' && code.length) {
|
||||
_.set(action, ['params', 'code'], mask(code));
|
||||
@@ -1529,10 +1509,6 @@ function protectLinkageRunJsScripts(params: { value?: LinkageRule[] } & Record<s
|
||||
if (typeof script === 'string' && tokenToScript.has(script)) {
|
||||
_.set(action, ['params', 'value', 'script'], tokenToScript.get(script));
|
||||
}
|
||||
const valueCode = _.get(action, ['params', 'value', 'code']);
|
||||
if (typeof valueCode === 'string' && tokenToScript.has(valueCode)) {
|
||||
_.set(action, ['params', 'value', 'code'], tokenToScript.get(valueCode));
|
||||
}
|
||||
const code = _.get(action, ['params', 'code']);
|
||||
if (typeof code === 'string' && tokenToScript.has(code)) {
|
||||
_.set(action, ['params', 'code'], tokenToScript.get(code));
|
||||
@@ -1638,31 +1614,6 @@ const LinkageRulesUI = observer(
|
||||
return supportedActions.map((actionName: string) => ctx.getAction(actionName));
|
||||
};
|
||||
|
||||
const withLinkageSourceProps = <T,>(
|
||||
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<string, unknown>;
|
||||
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(
|
||||
</div>
|
||||
<div>
|
||||
{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<string, any[]>();
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<MetaTreeNode[]>);
|
||||
model: FieldModel;
|
||||
flags?: Record<string, any>;
|
||||
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<any> = (inputProps) => (
|
||||
<RunJSValueEditor
|
||||
t={flowContext.t}
|
||||
value={inputProps?.value}
|
||||
onChange={inputProps?.onChange}
|
||||
sourceLabel={sourceLabel}
|
||||
surfaceStyle="value"
|
||||
/>
|
||||
<RunJSValueEditor t={flowContext.t} value={inputProps?.value} onChange={inputProps?.onChange} />
|
||||
);
|
||||
return C;
|
||||
}, [flowContext, sourceLabel]);
|
||||
}, [flowContext]);
|
||||
const mergedMetaTree = useMemo<() => Promise<MetaTreeNode[]>>(() => {
|
||||
return async () => {
|
||||
let base: MetaTreeNode[] = [];
|
||||
|
||||
@@ -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<Props> = ({
|
||||
operatorMetaList,
|
||||
preferFormItemFieldModel,
|
||||
associationFieldNamesOverride,
|
||||
allowRunJS = true,
|
||||
maxAssociationFieldDepth = 2,
|
||||
disabled = false,
|
||||
variableConverters,
|
||||
@@ -1025,6 +1025,18 @@ export const FieldAssignValueInput: React.FC<Props> = ({
|
||||
return N;
|
||||
}, [flowCtx]);
|
||||
|
||||
const RunJSComponent = React.useMemo(() => {
|
||||
const C: React.FC<any> = (inputProps) => (
|
||||
<RunJSValueEditor
|
||||
t={flowCtx.t}
|
||||
value={inputProps?.value}
|
||||
onChange={inputProps?.onChange}
|
||||
disabled={inputProps?.disabled}
|
||||
/>
|
||||
);
|
||||
return C;
|
||||
}, [flowCtx]);
|
||||
|
||||
const baseMetaTree = React.useMemo<() => Promise<MetaTreeNode[]>>(() => {
|
||||
return async () => {
|
||||
const base = (await flowCtx.getPropertyMetaTree?.()) || [];
|
||||
@@ -1047,10 +1059,12 @@ export const FieldAssignValueInput: React.FC<Props> = ({
|
||||
baseMetaTree={baseMetaTree}
|
||||
constantComponent={ConstantValueEditor}
|
||||
nullComponent={NullComponent}
|
||||
runJSComponent={RunJSComponent}
|
||||
isDateLikeField={isDateLikeField}
|
||||
dateComponentProps={dateVariableComponentProps}
|
||||
style={{ width: '100%' }}
|
||||
clearValue={''}
|
||||
allowRunJS={allowRunJS}
|
||||
disabled={disabled}
|
||||
converters={variableConverters}
|
||||
/>
|
||||
|
||||
@@ -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<RunJSValueEditorProps> = (props) => {
|
||||
@@ -43,25 +32,27 @@ export const RunJSValueEditor: React.FC<RunJSValueEditorProps> = (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 (
|
||||
<RunJSEditorField
|
||||
t={t}
|
||||
value={value}
|
||||
onChange={(nextValue) => {
|
||||
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}
|
||||
/>
|
||||
<div style={containerStyle}>
|
||||
<CodeEditor
|
||||
value={current.code}
|
||||
onChange={(code) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onChange?.({ ...current, code });
|
||||
}}
|
||||
version={current.version}
|
||||
height={height}
|
||||
readonly={disabled}
|
||||
enableLinter
|
||||
placeholder={placeholderText}
|
||||
scene={scene}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string, any> = { ...initial };
|
||||
|
||||
+21
-1
@@ -126,12 +126,32 @@ describe('FieldAssignValueInput context', () => {
|
||||
getPropertyMetaTree: vi.fn(async () => []),
|
||||
});
|
||||
|
||||
render(<FieldAssignValueInput targetPath="status" value="" onChange={vi.fn()} />);
|
||||
const onChange = vi.fn();
|
||||
const view = render(<FieldAssignValueInput targetPath="status" value="" onChange={onChange} />);
|
||||
|
||||
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(<FieldAssignValueInput targetPath="status" value="" onChange={onChange} allowRunJS={false} />);
|
||||
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 () => {
|
||||
|
||||
+5
-5
@@ -67,7 +67,7 @@ export type FieldValueVariableInputProps = Omit<
|
||||
baseMetaTree: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
|
||||
constantComponent: ValueEditorComponent;
|
||||
nullComponent: ValueEditorComponent;
|
||||
runJSComponent?: ValueEditorComponent;
|
||||
runJSComponent: ValueEditorComponent;
|
||||
isDateLikeField: boolean;
|
||||
dateComponentProps: DateVariableComponentProps;
|
||||
allowRunJS?: boolean;
|
||||
@@ -238,7 +238,7 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
||||
selectable: false,
|
||||
children: dateChildren,
|
||||
},
|
||||
...(allowRunJS && RunJSComponent
|
||||
...(allowRunJS
|
||||
? [
|
||||
{
|
||||
title: tExpr('RunJS'),
|
||||
@@ -295,7 +295,7 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
||||
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<FieldValueVariableInputProps> = (
|
||||
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)
|
||||
|
||||
+33
-6
@@ -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',
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+22
-37
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+2
-50
@@ -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<AssignedValues | null> {
|
||||
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<AssignedValues> {
|
||||
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<unknown> {
|
||||
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<T extends Record<string, unknown>>(
|
||||
values: T,
|
||||
assignedValues?: AssignedValues | null,
|
||||
|
||||
-46
@@ -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<JSBlockModel>({
|
||||
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(<span data-testid="settings-js-block">{ctx.settings.message}:{ctx.settings.pageSize}:{String(ctx.settings.enabled)}</span>);',
|
||||
|
||||
+60
-127
@@ -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(
|
||||
<SettingsAutoForm
|
||||
schema={{
|
||||
type: 'object',
|
||||
properties: {
|
||||
plan: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
}}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
onChange.mockClear();
|
||||
|
||||
rerender(
|
||||
<SettingsAutoForm
|
||||
schema={{
|
||||
type: 'object',
|
||||
properties: {
|
||||
plan: {
|
||||
type: 'string',
|
||||
enum: ['basic'],
|
||||
},
|
||||
},
|
||||
}}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SettingsAutoForm
|
||||
<SettingsSingleField
|
||||
fieldName="settings"
|
||||
disabled
|
||||
schema={{
|
||||
fieldSchema={{
|
||||
type: 'object',
|
||||
properties: {
|
||||
plan: {
|
||||
@@ -412,8 +354,9 @@ describe('SettingsAutoForm', () => {
|
||||
|
||||
const { container } = render(
|
||||
<ApplicationContext.Provider value={app as never}>
|
||||
<SettingsAutoForm
|
||||
schema={{
|
||||
<SettingsSingleField
|
||||
fieldName="settings"
|
||||
fieldSchema={{
|
||||
type: 'object',
|
||||
properties: {
|
||||
collection: {
|
||||
@@ -493,7 +436,7 @@ describe('SettingsAutoForm', () => {
|
||||
|
||||
const { container } = render(
|
||||
<ApplicationContext.Provider value={app as never}>
|
||||
<SettingsAutoForm schema={schema} value={{ displayField: 'products.name' }} />
|
||||
<SettingsSingleField fieldName="settings" fieldSchema={schema} value={{ displayField: 'products.name' }} />
|
||||
</ApplicationContext.Provider>,
|
||||
);
|
||||
|
||||
@@ -556,7 +499,11 @@ describe('SettingsAutoForm', () => {
|
||||
|
||||
const { container } = render(
|
||||
<ApplicationContext.Provider value={app as never}>
|
||||
<SettingsAutoForm schema={schema} value={{ advanced: { collection: 'products', displayField: 'name' } }} />
|
||||
<SettingsSingleField
|
||||
fieldName="settings"
|
||||
fieldSchema={schema}
|
||||
value={{ advanced: { collection: 'products', displayField: 'name' } }}
|
||||
/>
|
||||
</ApplicationContext.Provider>,
|
||||
);
|
||||
|
||||
@@ -623,7 +570,11 @@ describe('SettingsAutoForm', () => {
|
||||
|
||||
const { container } = render(
|
||||
<ApplicationContext.Provider value={app as never}>
|
||||
<SettingsAutoForm schema={schema} value={{ collection: 'products', advanced: { displayField: 'name' } }} />
|
||||
<SettingsSingleField
|
||||
fieldName="settings"
|
||||
fieldSchema={schema}
|
||||
value={{ collection: 'products', advanced: { displayField: 'name' } }}
|
||||
/>
|
||||
</ApplicationContext.Provider>,
|
||||
);
|
||||
|
||||
@@ -695,8 +646,9 @@ describe('SettingsAutoForm', () => {
|
||||
|
||||
const { container } = render(
|
||||
<ApplicationContext.Provider value={app as never}>
|
||||
<SettingsAutoForm
|
||||
schema={schema}
|
||||
<SettingsSingleField
|
||||
fieldName="settings"
|
||||
fieldSchema={schema}
|
||||
value={{ advanced: { collection: 'products', filters: { displayField: 'name' } } }}
|
||||
/>
|
||||
</ApplicationContext.Provider>,
|
||||
@@ -785,7 +737,11 @@ describe('SettingsAutoForm', () => {
|
||||
|
||||
const { container } = render(
|
||||
<ApplicationContext.Provider value={app as never}>
|
||||
<SettingsAutoForm schema={schema} value={{ advanced: { dataSource: 'main', filters: {} } }} />
|
||||
<SettingsSingleField
|
||||
fieldName="settings"
|
||||
fieldSchema={schema}
|
||||
value={{ advanced: { dataSource: 'main', filters: {} } }}
|
||||
/>
|
||||
</ApplicationContext.Provider>,
|
||||
);
|
||||
|
||||
@@ -821,8 +777,9 @@ describe('SettingsAutoForm', () => {
|
||||
};
|
||||
const { container } = render(
|
||||
<ApplicationContext.Provider value={app as never}>
|
||||
<SettingsAutoForm
|
||||
schema={{
|
||||
<SettingsSingleField
|
||||
fieldName="settings"
|
||||
fieldSchema={{
|
||||
type: 'object',
|
||||
properties: {
|
||||
visibleForRole: {
|
||||
@@ -844,26 +801,20 @@ describe('SettingsAutoForm', () => {
|
||||
it('validates supported string formats', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<SettingsAutoForm
|
||||
schema={{
|
||||
type: 'object',
|
||||
properties: {
|
||||
contact: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
},
|
||||
},
|
||||
<SettingsSingleField
|
||||
fieldName="contact"
|
||||
fieldSchema={{
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
}}
|
||||
value={{ contact: 'not-an-email' }}
|
||||
value="not-an-email"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SettingsAutoForm
|
||||
schema={{
|
||||
type: 'object',
|
||||
required: ['title'],
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
<SettingsSingleField
|
||||
fieldName="title"
|
||||
required
|
||||
fieldSchema={{
|
||||
type: 'string',
|
||||
}}
|
||||
value={{ title: '' }}
|
||||
value=""
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SettingsAutoForm
|
||||
schema={{
|
||||
type: 'object',
|
||||
required: ['title'],
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
<SettingsSingleField
|
||||
fieldName="title"
|
||||
required
|
||||
fieldSchema={{
|
||||
type: 'string',
|
||||
}}
|
||||
value={{ title: null }}
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SettingsAutoForm
|
||||
schema={{
|
||||
type: 'object',
|
||||
properties: {
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 2,
|
||||
},
|
||||
},
|
||||
<SettingsSingleField
|
||||
fieldName="tags"
|
||||
fieldSchema={{
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 2,
|
||||
},
|
||||
}}
|
||||
value={{ tags: ['a'] }}
|
||||
value={['a']}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
{
|
||||
tags: ['a'],
|
||||
},
|
||||
['a'],
|
||||
expect.objectContaining({
|
||||
errors: [
|
||||
expect.objectContaining({
|
||||
|
||||
-69
@@ -69,13 +69,6 @@ export type SettingsValidationResult = {
|
||||
errors: SettingsValidationError[];
|
||||
};
|
||||
|
||||
export interface SettingsAutoFormProps {
|
||||
schema?: Record<string, unknown> | null;
|
||||
value?: Record<string, unknown> | null;
|
||||
onChange?: (value: Record<string, unknown>, 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<SettingsAutoFormProps> = ({ 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<string>();
|
||||
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 <Alert type="info" showIcon message={t('No settings')} />;
|
||||
}
|
||||
|
||||
const handleChange = (path: string[], nextValue: unknown) => {
|
||||
const next = updateAtPath(current, path, nextValue);
|
||||
onChange?.(next, normalizeSettingsForSchema(rootSchema, next));
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
{validation.errors.length > 0 ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Settings validation failed')}
|
||||
description={validationErrors.join('\n')}
|
||||
/>
|
||||
) : null}
|
||||
{Object.entries(rootSchema.properties).map(([key, childSchema]) => (
|
||||
<SettingsField
|
||||
key={key}
|
||||
path={[key]}
|
||||
rootValue={current}
|
||||
scopeValues={[current]}
|
||||
schema={childSchema}
|
||||
value={getOwnValue(current, key)}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsSingleField: React.FC<SettingsSingleFieldProps> = ({
|
||||
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;
|
||||
|
||||
+12
-5
@@ -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) => <RunJSStudioEditorEntry {...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],
|
||||
};
|
||||
}
|
||||
|
||||
+56
-19
@@ -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<RunJSSourceLocator, { kind: 'flowModel.step' }> };
|
||||
};
|
||||
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<RunJSSourceLocator, { kind: 'flowModel.flowRegistry.runjs' }> };
|
||||
};
|
||||
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',
|
||||
|
||||
+81
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user