@nocobase/client-v2 2.1.0-alpha.37 → 2.1.0-alpha.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.0-alpha.37",
3
+ "version": "2.1.0-alpha.38",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -24,10 +24,10 @@
24
24
  "@formily/antd-v5": "1.2.3",
25
25
  "@formily/react": "^2.2.27",
26
26
  "@formily/shared": "^2.2.27",
27
- "@nocobase/evaluators": "2.1.0-alpha.37",
28
- "@nocobase/flow-engine": "2.1.0-alpha.37",
29
- "@nocobase/sdk": "2.1.0-alpha.37",
30
- "@nocobase/shared": "2.1.0-alpha.37",
27
+ "@nocobase/evaluators": "2.1.0-alpha.38",
28
+ "@nocobase/flow-engine": "2.1.0-alpha.38",
29
+ "@nocobase/sdk": "2.1.0-alpha.38",
30
+ "@nocobase/shared": "2.1.0-alpha.38",
31
31
  "ahooks": "^3.7.2",
32
32
  "antd": "5.24.2",
33
33
  "antd-style": "3.7.1",
@@ -41,5 +41,5 @@
41
41
  "react-i18next": "^11.15.1",
42
42
  "react-router-dom": "^6.30.1"
43
43
  },
44
- "gitHead": "8b45f4586ea5b386b376188cfc1012ec12e9bc8b"
44
+ "gitHead": "cc99815fc9ae0612d6db0db5ebbc87a774fff8ed"
45
45
  }
@@ -681,6 +681,7 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
681
681
  AdminLayoutMenuItemModel.registerFlow({
682
682
  key: 'menuCreation',
683
683
  title: 'Add menu item',
684
+ manual: true,
684
685
  steps: {
685
686
  basic: {
686
687
  title: 'Add menu item',
@@ -697,6 +698,7 @@ AdminLayoutMenuItemModel.registerFlow({
697
698
  AdminLayoutMenuItemModel.registerFlow({
698
699
  key: 'menuSettings',
699
700
  title: 'Menu settings',
701
+ manual: true,
700
702
  steps: {
701
703
  edit: {
702
704
  title: 'Edit',
@@ -14,6 +14,7 @@ import { render, waitFor } from '@testing-library/react';
14
14
  import { App, ConfigProvider } from 'antd';
15
15
  import { useCodeRunner } from '../hooks/useCodeRunner';
16
16
  import {
17
+ FlowContext,
17
18
  FlowEngine,
18
19
  FlowModel,
19
20
  FlowEngineProvider,
@@ -21,6 +22,7 @@ import {
21
22
  ElementProxy,
22
23
  createSafeWindow,
23
24
  createSafeDocument,
25
+ createViewScopedEngine,
24
26
  } from '@nocobase/flow-engine';
25
27
  import { JSEditableFieldModel } from '../../../models/fields/JSEditableFieldModel';
26
28
 
@@ -31,6 +33,20 @@ class DummyJsAutoModel extends FlowModel {
31
33
  }
32
34
  }
33
35
 
36
+ function registerRunJsPreviewFlow(model: FlowModel) {
37
+ model.registerFlow('jsSettings', {
38
+ steps: {
39
+ runJs: {
40
+ useRawParams: true,
41
+ async handler(ctx) {
42
+ const code = ctx?.inputArgs?.preview?.code || '';
43
+ return ctx.runjs(code, undefined, { preprocessTemplates: true });
44
+ },
45
+ },
46
+ },
47
+ });
48
+ }
49
+
34
50
  describe('useCodeRunner (beforeRender)', () => {
35
51
  it('logs success and captures console output', async () => {
36
52
  const engine = new FlowEngine();
@@ -192,6 +208,71 @@ describe('useCodeRunner (beforeRender)', () => {
192
208
  });
193
209
  });
194
210
 
211
+ it('keeps popup context when a top scoped engine has another model with the same uid', async () => {
212
+ const engine = new FlowEngine();
213
+ engine.registerModels({ DummyJsAutoModel });
214
+ const model = engine.createModel<DummyJsAutoModel>({ use: 'DummyJsAutoModel', uid: 'same-popup-uid' });
215
+ model.context.defineProperty('popup', {
216
+ value: {
217
+ uid: 'popup-view',
218
+ record: { username: 'alice' },
219
+ },
220
+ });
221
+ registerRunJsPreviewFlow(model);
222
+
223
+ const scopedEngine = createViewScopedEngine(engine);
224
+ const topModel = scopedEngine.createModel<DummyJsAutoModel>({ use: 'DummyJsAutoModel', uid: 'same-popup-uid' });
225
+ registerRunJsPreviewFlow(topModel);
226
+
227
+ const { result } = renderHook(() => useCodeRunner(model.context, 'v1'));
228
+ let runResult: any;
229
+ await act(async () => {
230
+ runResult = await result.current.run(`
231
+ const currentUsername = await ctx.getVar('ctx.popup.record.username');
232
+ console.log(currentUsername);
233
+ return currentUsername;
234
+ `);
235
+ });
236
+
237
+ expect(runResult?.success).toBe(true);
238
+ expect(runResult?.value).toBe('alice');
239
+ expect(result.current.logs.some((l) => l.level === 'log' && l.msg.includes('alice'))).toBe(true);
240
+ });
241
+
242
+ it('runs direct event-flow previews against the popup-bound model context when the settings view has no popup', async () => {
243
+ const engine = new FlowEngine();
244
+ engine.registerModels({ DummyJsAutoModel });
245
+ const model = engine.createModel<DummyJsAutoModel>({ use: 'DummyJsAutoModel', uid: 'direct-popup-uid' });
246
+ model.context.defineProperty('popup', {
247
+ value: {
248
+ uid: 'popup-view',
249
+ record: { username: 'alice' },
250
+ },
251
+ });
252
+
253
+ const scopedEngine = createViewScopedEngine(engine);
254
+ scopedEngine.createModel<DummyJsAutoModel>({ use: 'DummyJsAutoModel', uid: 'direct-popup-uid' });
255
+
256
+ const settingsCtx = new FlowContext();
257
+ settingsCtx.defineProperty('engine', { value: scopedEngine });
258
+ settingsCtx.defineProperty('popup', { value: undefined });
259
+ settingsCtx.addDelegate(model.context);
260
+
261
+ const { result } = renderHook(() => useCodeRunner(settingsCtx as any, 'v1'));
262
+ let runResult: any;
263
+ await act(async () => {
264
+ runResult = await result.current.run(`
265
+ const currentUsername = await ctx.getVar('ctx.popup.record.username');
266
+ console.log(currentUsername);
267
+ return currentUsername;
268
+ `);
269
+ });
270
+
271
+ expect(runResult?.success).toBe(true);
272
+ expect(runResult?.value).toBe('alice');
273
+ expect(result.current.logs.some((l) => l.level === 'log' && l.msg.includes('alice'))).toBe(true);
274
+ });
275
+
195
276
  it('compiles JSX in preview and renders antd Input without syntax error', async () => {
196
277
  const engine = new FlowEngine();
197
278
  engine.registerModels({ DummyJsAutoModel });
@@ -112,6 +112,31 @@ function createLoggerWrapperFactory(append: (level: RunLog['level'], args: any[]
112
112
  return wrap;
113
113
  }
114
114
 
115
+ function hasPopupViewMarkers(view: any): boolean {
116
+ const inputArgs = view?.inputArgs || {};
117
+ const openerUids = inputArgs?.openerUids;
118
+ const viewStack = view?.navigation?.viewStack;
119
+
120
+ return (Array.isArray(openerUids) && openerUids.length > 0) || (Array.isArray(viewStack) && viewStack.length >= 2);
121
+ }
122
+
123
+ async function hasPreviewPopupContext(ctx: any): Promise<boolean> {
124
+ if (!ctx) return false;
125
+
126
+ try {
127
+ const popup = await ctx.popup;
128
+ if (popup) return true;
129
+ } catch (_) {
130
+ // ignore unavailable popup getters
131
+ }
132
+
133
+ try {
134
+ return hasPopupViewMarkers(await ctx.view);
135
+ } catch (_) {
136
+ return false;
137
+ }
138
+ }
139
+
115
140
  export function useCodeRunner(hostCtx: FlowModelContext, version = 'v1') {
116
141
  const [logs, setLogs] = useState<RunLog[]>([]);
117
142
  const [running, setRunning] = useState(false);
@@ -131,7 +156,14 @@ export function useCodeRunner(hostCtx: FlowModelContext, version = 'v1') {
131
156
  const model = hostCtx?.model;
132
157
  if (!model) throw new Error('No model in FlowContext');
133
158
  const engine = hostCtx.engine;
134
- const runtimeModel = engine.getModel(model.uid, true) || model;
159
+ const globalRuntimeModel = engine.getModel(model.uid, true) || model;
160
+ const [hostHasPopupContext, modelHasPopupContext] = await Promise.all([
161
+ hasPreviewPopupContext(hostCtx),
162
+ hasPreviewPopupContext(model.context),
163
+ ]);
164
+ const shouldPreservePopupModel = hostHasPopupContext || modelHasPopupContext;
165
+ const runtimeModel = shouldPreservePopupModel ? model : globalRuntimeModel;
166
+ const directRunCtx = hostHasPopupContext ? hostCtx : modelHasPopupContext ? model.context : hostCtx;
135
167
 
136
168
  const nativeConsole: Record<RunLog['level'], (...args: any[]) => void> = {
137
169
  log: (...args) => console.log(...args),
@@ -255,7 +287,7 @@ export function useCodeRunner(hostCtx: FlowModelContext, version = 'v1') {
255
287
  if (!flow) {
256
288
  // 无可用流程(典型场景:联动规则里的 RunJS 预览),直接在当前上下文执行代码
257
289
  const navigator = createSafeNavigator();
258
- await hostCtx.runjs(
290
+ await directRunCtx.runjs(
259
291
  code,
260
292
  { window: createSafeWindow({ navigator }), document: createSafeDocument(), navigator },
261
293
  { version },
@@ -69,6 +69,106 @@ function resolveScriptCode(codeParam?: string) {
69
69
  return typeof raw === 'string' ? raw.trim() : '';
70
70
  }
71
71
 
72
+ type NamePathPart = string | number;
73
+
74
+ function toNamePath(input: unknown): NamePathPart[] | null {
75
+ if (Array.isArray(input)) {
76
+ return input.filter((item): item is NamePathPart => typeof item === 'string' || typeof item === 'number');
77
+ }
78
+ if (typeof input === 'number') {
79
+ return [input];
80
+ }
81
+ if (typeof input === 'string') {
82
+ return input
83
+ .split('.')
84
+ .map((item) => item.trim())
85
+ .filter(Boolean);
86
+ }
87
+ return null;
88
+ }
89
+
90
+ function startsWithNamePath(namePath: NamePathPart[], prefix: NamePathPart[]) {
91
+ return prefix.length <= namePath.length && prefix.every((item, index) => String(namePath[index]) === String(item));
92
+ }
93
+
94
+ function getFieldSettingsNamePath(model: any): NamePathPart[] | null {
95
+ const init =
96
+ model?.getStepParams?.('fieldSettings', 'init') || model?.parent?.getStepParams?.('fieldSettings', 'init');
97
+ const fieldPath = toNamePath(init?.fieldPath);
98
+ const associationPath = toNamePath(init?.associationPathName);
99
+
100
+ if (!fieldPath?.length) {
101
+ return null;
102
+ }
103
+
104
+ if (!associationPath?.length || startsWithNamePath(fieldPath, associationPath)) {
105
+ return fieldPath;
106
+ }
107
+
108
+ return [...associationPath, ...fieldPath];
109
+ }
110
+
111
+ function applyFieldIndex(namePath: NamePathPart[] | null, fieldIndex: unknown): NamePathPart[] | null {
112
+ if (!namePath?.length) {
113
+ return null;
114
+ }
115
+ if (namePath.some((item) => typeof item === 'number') || !Array.isArray(fieldIndex) || fieldIndex.length === 0) {
116
+ return namePath;
117
+ }
118
+
119
+ const indexQueues = new Map<string, number[]>();
120
+ for (const item of fieldIndex) {
121
+ if (typeof item !== 'string') continue;
122
+ const [fieldName, indexStr] = item.split(':');
123
+ const index = Number(indexStr);
124
+ if (!fieldName || !Number.isFinite(index)) continue;
125
+ const queue = indexQueues.get(fieldName) || [];
126
+ queue.push(index);
127
+ indexQueues.set(fieldName, queue);
128
+ }
129
+
130
+ if (!indexQueues.size) {
131
+ return namePath;
132
+ }
133
+
134
+ const result: NamePathPart[] = [];
135
+ for (const item of namePath) {
136
+ result.push(item);
137
+ const queue = indexQueues.get(String(item));
138
+ if (queue?.length) {
139
+ result.push(queue.shift() as number);
140
+ }
141
+ }
142
+ return result;
143
+ }
144
+
145
+ function resolveEffectiveNamePath(ctx: any): NamePathPart[] | null {
146
+ const namePath =
147
+ getFieldSettingsNamePath(ctx.model) || toNamePath(ctx.fieldPathArray) || toNamePath(ctx.model?.props?.name);
148
+ return applyFieldIndex(namePath, ctx.fieldIndex);
149
+ }
150
+
151
+ function setFormValue(form: any, namePath: NamePathPart[], value: any) {
152
+ if (typeof form?.setFieldValue === 'function') {
153
+ form.setFieldValue(namePath, value);
154
+ return;
155
+ }
156
+
157
+ if (typeof form?.setFieldsValue === 'function') {
158
+ const patch: any = {};
159
+ let cursor = patch;
160
+ namePath.forEach((item, index) => {
161
+ if (index === namePath.length - 1) {
162
+ cursor[item] = value;
163
+ return;
164
+ }
165
+ cursor[item] = typeof namePath[index + 1] === 'number' ? [] : {};
166
+ cursor = cursor[item];
167
+ });
168
+ form.setFieldsValue(patch);
169
+ }
170
+ }
171
+
72
172
  const JSFormRuntime: React.FC<{
73
173
  model: JSEditableFieldModel;
74
174
  value?: any;
@@ -274,9 +374,9 @@ JSEditableFieldModel.registerFlow({
274
374
  cache: false,
275
375
  });
276
376
  ctx.defineMethod('getValue', () => {
277
- const name = ctx.model.props?.name;
278
- if (name !== undefined && name !== null) {
279
- const fv = ctx.form?.getFieldValue?.(name);
377
+ const namePath = resolveEffectiveNamePath(ctx);
378
+ if (namePath?.length) {
379
+ const fv = ctx.form?.getFieldValue?.(namePath);
280
380
  return fv !== undefined ? fv : ctx.model.props?.value;
281
381
  }
282
382
  return ctx.model.props?.value;
@@ -284,15 +384,15 @@ JSEditableFieldModel.registerFlow({
284
384
  ctx.defineMethod('setValue', (v) => {
285
385
  try {
286
386
  ctx.model.setProps('value', v);
287
- const name = ctx.model.props?.name;
288
- if (name !== undefined && name !== null) {
289
- ctx.form?.setFieldValue?.(name, v);
387
+ const namePath = resolveEffectiveNamePath(ctx);
388
+ if (namePath?.length) {
389
+ setFormValue(ctx.form, namePath, v);
290
390
  }
291
391
  } catch (_) {
292
392
  // ignore
293
393
  }
294
394
  });
295
- ctx.defineProperty('namePath', { get: () => ctx.model.props?.name, cache: false });
395
+ ctx.defineProperty('namePath', { get: () => resolveEffectiveNamePath(ctx), cache: false });
296
396
  ctx.defineProperty('disabled', { get: () => !!ctx.model.props?.disabled, cache: false });
297
397
  ctx.defineProperty('readOnly', {
298
398
  get: () => isReadOnlyMode(ctx.model),
@@ -11,6 +11,7 @@ import React from 'react';
11
11
  import { act, render, screen, waitFor } from '@testing-library/react';
12
12
  import { describe, expect, it, vi } from 'vitest';
13
13
  import { FlowEngine, FlowEngineProvider, FlowModel, FlowModelRenderer } from '@nocobase/flow-engine';
14
+ import { get as lodashGet, set as lodashSet } from 'lodash';
14
15
  import { JSEditableFieldModel } from '../JSEditableFieldModel';
15
16
 
16
17
  function createField(props?: Record<string, any>, code = '') {
@@ -73,6 +74,21 @@ const React = ctx.React;
73
74
  ctx.render(<span data-testid="js-readonly-state">{String(ctx.readOnly)}</span>);
74
75
  `;
75
76
 
77
+ const SET_VALUE_AND_RENDER_NAME_PATH_CODE = `
78
+ const React = ctx.React;
79
+ ctx.setValue?.('44');
80
+ ctx.render(<span data-testid="js-name-path">{JSON.stringify(ctx.namePath)}</span>);
81
+ `;
82
+
83
+ function createFormStub(initialValues: any = {}) {
84
+ const store = JSON.parse(JSON.stringify(initialValues));
85
+ return {
86
+ getFieldValue: (namePath: any) => lodashGet(store, namePath),
87
+ setFieldValue: (namePath: any, value: any) => lodashSet(store, namePath, value),
88
+ getFieldsValue: () => store,
89
+ };
90
+ }
91
+
76
92
  class ParentModel extends FlowModel<any> {
77
93
  render() {
78
94
  return <FlowModelRenderer model={this.subModels.field} />;
@@ -83,6 +99,11 @@ function renderParentFieldWithFlowRenderer(
83
99
  fieldProps?: Record<string, any>,
84
100
  parentProps?: Record<string, any>,
85
101
  code = EDITABLE_CODE,
102
+ options?: {
103
+ fieldIndex?: string[];
104
+ fieldStepParams?: Record<string, any>;
105
+ form?: any;
106
+ },
86
107
  ) {
87
108
  const engine = new FlowEngine();
88
109
  engine.registerModels({ JSEditableFieldModel, ParentModel });
@@ -96,6 +117,7 @@ function renderParentFieldWithFlowRenderer(
96
117
  uid: 'js-field-with-parent',
97
118
  props: fieldProps,
98
119
  stepParams: {
120
+ ...(options?.fieldStepParams || {}),
99
121
  jsSettings: {
100
122
  runJs: {
101
123
  code,
@@ -106,6 +128,13 @@ function renderParentFieldWithFlowRenderer(
106
128
  },
107
129
  });
108
130
 
131
+ if (options?.form) {
132
+ parent.context.defineProperty('form', { value: options.form });
133
+ }
134
+ if (options?.fieldIndex) {
135
+ parent.subModels.field.context.defineProperty('fieldIndex', { value: options.fieldIndex });
136
+ }
137
+
109
138
  render(
110
139
  <FlowEngineProvider engine={engine}>
111
140
  <FlowModelRenderer model={parent} />
@@ -207,4 +236,72 @@ describe('JSEditableFieldModel', () => {
207
236
  applyFlowSpy.mockRestore();
208
237
  }
209
238
  });
239
+
240
+ it('writes top-level form values through the effective name path', async () => {
241
+ const form = createFormStub({});
242
+
243
+ renderParentFieldWithFlowRenderer({ name: 'staffname' }, undefined, SET_VALUE_AND_RENDER_NAME_PATH_CODE, {
244
+ form,
245
+ fieldStepParams: {
246
+ fieldSettings: {
247
+ init: {
248
+ fieldPath: 'staffname',
249
+ },
250
+ },
251
+ },
252
+ });
253
+
254
+ await waitFor(() => {
255
+ expect(form.getFieldValue(['staffname'])).toBe('44');
256
+ expect(screen.getByTestId('js-name-path')).toHaveTextContent(JSON.stringify(['staffname']));
257
+ });
258
+ });
259
+
260
+ it('writes subform list values under the association path instead of the form root', async () => {
261
+ const form = createFormStub({ org_o2m: [{}] });
262
+
263
+ renderParentFieldWithFlowRenderer({ name: 'orgname' }, undefined, SET_VALUE_AND_RENDER_NAME_PATH_CODE, {
264
+ form,
265
+ fieldIndex: ['org_o2m:0'],
266
+ fieldStepParams: {
267
+ fieldSettings: {
268
+ init: {
269
+ fieldPath: 'orgname',
270
+ associationPathName: 'org_o2m',
271
+ },
272
+ },
273
+ },
274
+ });
275
+
276
+ await waitFor(() => {
277
+ expect(form.getFieldValue(['org_o2m', 0, 'orgname'])).toBe('44');
278
+ expect(form.getFieldValue(['orgname'])).toBeUndefined();
279
+ expect(screen.getByTestId('js-name-path')).toHaveTextContent(JSON.stringify(['org_o2m', 0, 'orgname']));
280
+ });
281
+ });
282
+
283
+ it('writes nested subform list values with the full field index chain', async () => {
284
+ const form = createFormStub({ users: [{ roles: [{}, {}] }] });
285
+
286
+ renderParentFieldWithFlowRenderer({ name: 'roleName' }, undefined, SET_VALUE_AND_RENDER_NAME_PATH_CODE, {
287
+ form,
288
+ fieldIndex: ['users:0', 'roles:1'],
289
+ fieldStepParams: {
290
+ fieldSettings: {
291
+ init: {
292
+ fieldPath: 'roleName',
293
+ associationPathName: 'users.roles',
294
+ },
295
+ },
296
+ },
297
+ });
298
+
299
+ await waitFor(() => {
300
+ expect(form.getFieldValue(['users', 0, 'roles', 1, 'roleName'])).toBe('44');
301
+ expect(form.getFieldValue(['roleName'])).toBeUndefined();
302
+ expect(screen.getByTestId('js-name-path')).toHaveTextContent(
303
+ JSON.stringify(['users', 0, 'roles', 1, 'roleName']),
304
+ );
305
+ });
306
+ });
210
307
  });