Merge branch 'main' into next

This commit is contained in:
nocobase[bot]
2026-08-21 08:00:10 +00:00
3 changed files with 162 additions and 3 deletions
@@ -8,6 +8,7 @@
*/
import { describe, expect, it, vi } from 'vitest';
import { APIClient } from '@nocobase/sdk';
import { FlowContext, FlowRunJSContext } from '../flowContext';
import { JSItemRunJSContext } from '../runjs-context/contexts/JSItemRunJSContext';
@@ -42,4 +43,96 @@ describe('FlowRunJSContext form submission', () => {
ctx.form.submit();
expect(nativeSubmit).toHaveBeenCalledOnce();
});
it('preserves nested form values and adds association paths to matching resource create calls', async () => {
const api = new APIClient();
const request = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { data: { id: 1 } } });
const values = { name: 'Alice', children: [{ name: 'Bob' }] };
const getFieldsValue = vi.fn(() => values);
const delegate = new FlowContext();
delegate.defineProperty('api', { value: api });
delegate.defineProperty('resource', {
value: {
getResourceName: () => 't1_user',
getDataSourceKey: () => 'main',
getUpdateAssociationValues: () => ['children'],
},
});
delegate.defineProperty('form', { value: { submit: vi.fn(), getFieldsValue } });
delegate.defineProperty('blockModel', { value: { submitFromRunJs: vi.fn() } });
const ctx = new FlowRunJSContext(delegate);
await ctx.api.resource('t1_user').create({ values: ctx.form.getFieldsValue(true) });
expect(getFieldsValue).toHaveBeenCalledWith(true);
expect(request).toHaveBeenCalledWith({
url: 't1_user:create',
method: 'post',
params: { updateAssociationValues: ['children'] },
data: values,
});
});
it('keeps explicit association params and unrelated resource calls unchanged', async () => {
const api = new APIClient();
const request = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { data: { id: 1 } } });
const delegate = new FlowContext();
delegate.defineProperty('api', { value: api });
delegate.defineProperty('resource', {
value: {
getResourceName: () => 't1_user',
getDataSourceKey: () => 'main',
getUpdateAssociationValues: () => ['children'],
},
});
delegate.defineProperty('form', { value: { submit: vi.fn() } });
delegate.defineProperty('blockModel', { value: { submitFromRunJs: vi.fn() } });
const ctx = new FlowRunJSContext(delegate);
await ctx.api.resource('t1_user').create({ values: {}, updateAssociationValues: [] });
await ctx.api.resource('t1_user').create({ values: {}, updateAssociationValues: null });
await ctx.api.resource('t1_user').create({ values: {}, updateAssociationValues: undefined });
await ctx.api.resource('posts').create({ values: { title: 'Post' } });
await ctx.api.resource('t1_user', undefined, { 'X-Data-Source': 'external' }).create({ values: { name: 'Bob' } });
expect(request.mock.calls.map(([config]) => config.params)).toEqual([
{ updateAssociationValues: [] },
{ updateAssociationValues: null },
{ updateAssociationValues: undefined },
{},
{},
]);
});
it('adds association paths only for the matching association source record', async () => {
const api = new APIClient();
const request = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { data: { id: 1 } } });
const delegate = new FlowContext();
delegate.defineProperty('api', { value: api });
delegate.defineProperty('resource', {
value: {
getResourceName: () => 'users.children',
getDataSourceKey: () => 'main',
getSourceId: () => 1,
getUpdateAssociationValues: () => ['toys'],
},
});
delegate.defineProperty('form', { value: { submit: vi.fn() } });
delegate.defineProperty('blockModel', { value: { submitFromRunJs: vi.fn() } });
const ctx = new FlowRunJSContext(delegate);
await ctx.api.resource('users.children', 1).create({ values: { name: 'same source' } });
await ctx.api.resource('users.children', 2).create({ values: { name: 'different source' } });
expect(request.mock.calls.map(([config]) => ({ url: config.url, params: config.params }))).toEqual([
{
url: 'users/1/children:create',
params: { updateAssociationValues: ['toys'] },
},
{
url: 'users/2/children:create',
params: {},
},
]);
});
});
+44 -1
View File
@@ -53,7 +53,7 @@ import { FlowExitAllException } from './utils/exceptions';
import { buildFlowModelResolveDescriptor, enqueueVariablesResolve, JSONValue } from './utils/params-resolvers';
import type { RecordRef } from './utils/serverContextParams';
import { buildServerContextParams as _buildServerContextParams } from './utils/serverContextParams';
import { getDirtyAwareApiClient } from './utils/dirtyAwareApiClient';
import { getDirtyAwareApiClient, PREPARE_CONTEXT_RESOURCE_ACTION_PARAMS } from './utils/dirtyAwareApiClient';
import { inferRecordRef, inferViewRecordRef } from './utils/variablesParams';
import { FlowView, FlowViewer } from './views/FlowView';
import { RunJSContextRegistry, getModelClassName, type RunJSVersion } from './runjs-context/registry';
@@ -4608,6 +4608,49 @@ function __mergeRunJSDocMeta(base: any, patch: any): RunJSDocMeta {
return out as RunJSDocMeta;
}
export class FlowRunJSContext extends FlowContext {
[PREPARE_CONTEXT_RESOURCE_ACTION_PARAMS](
action: { actionName: string; dataSourceKey?: string; resourceName: string; resourceOf?: unknown },
params: Record<string, unknown> | undefined,
) {
if (
action.actionName.toLowerCase() !== 'create' ||
!params ||
Array.isArray(params) ||
Object.prototype.hasOwnProperty.call(params, 'updateAssociationValues') ||
!this.form ||
typeof this.blockModel?.submitFromRunJs !== 'function'
) {
return params;
}
const resource = this.resource;
const currentResourceName = resource?.getResourceName?.();
const currentDataSourceKey = resource?.getDataSourceKey?.() || 'main';
if (action.resourceName !== currentResourceName || (action.dataSourceKey || 'main') !== currentDataSourceKey) {
return params;
}
const currentSourceId = resource?.getSourceId?.();
if (
currentResourceName?.includes('.') &&
currentSourceId !== null &&
typeof currentSourceId !== 'undefined' &&
String(action.resourceOf ?? '') !== String(currentSourceId)
) {
return params;
}
const updateAssociationValues = resource?.getUpdateAssociationValues?.();
if (!Array.isArray(updateAssociationValues) || updateAssociationValues.length === 0) {
return params;
}
return {
...params,
updateAssociationValues: [...updateAssociationValues],
};
}
constructor(delegate: FlowContext) {
super();
this.addDelegate(delegate);
@@ -42,9 +42,19 @@ type ResourceRequestOptions = RequestOptions & {
type DirtyResourceAction = {
dataSourceKey?: string;
resourceName: string;
resourceOf?: unknown;
actionName: string;
};
export const PREPARE_CONTEXT_RESOURCE_ACTION_PARAMS = Symbol('prepareContextResourceActionParams');
type ContextResourceActionParamsPreparer = {
[PREPARE_CONTEXT_RESOURCE_ACTION_PARAMS]?: (
action: DirtyResourceAction,
params: ActionParams | undefined,
) => ActionParams | undefined;
};
type ApiUrlProvider = {
getApiUrl?: (pathname?: string) => string;
};
@@ -328,6 +338,7 @@ function resolveDirtyResourceActionFromResource(
return {
resourceName: normalizedResourceName,
resourceOf,
actionName: normalizedActionName,
};
}
@@ -442,6 +453,18 @@ function createDirtyAwareResource(
return async (...args: Parameters<ResourceActionFn>) => {
const actionOptions = isObjectRecord(args[1]) ? args[1] : undefined;
const dirtyResourceAction = resolveDirtyResourceActionFromResource(resourceName, resourceOf, prop, context);
const prepareParams = (context as ContextResourceActionParamsPreparer)[PREPARE_CONTEXT_RESOURCE_ACTION_PARAMS];
const actionParams =
dirtyResourceAction && typeof prepareParams === 'function'
? prepareParams.call(
context,
{
...dirtyResourceAction,
dataSourceKey: dirtyResourceAction.dataSourceKey || getDataSourceKeyFromHeaders(headers),
},
args[0],
)
: args[0];
const requestKey = getDirtyResourceActionDispatchKey(dirtyResourceAction, headers);
const resourceKey = getResourceDispatchKey(resourceName, resourceOf, headers);
const inheritedToken =
@@ -459,13 +482,13 @@ function createDirtyAwareResource(
const forwardedArgs: Parameters<ResourceActionFn> =
actionOptions || args[1] == null
? [
args[0],
actionParams,
{
...actionOptions,
[DIRTY_DISPATCH_TOKEN]: token,
},
]
: args;
: [actionParams, args[1]];
let actionResult: Promise<unknown>;
requestTokenStack.push({ key: requestKey, token });
try {