@nocobase/flow-engine 2.2.0-beta.9 → 2.2.1

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.
Files changed (83) hide show
  1. package/lib/acl/Acl.d.ts +2 -1
  2. package/lib/acl/Acl.js +28 -0
  3. package/lib/components/FlowContextSelector.js +62 -13
  4. package/lib/components/FormItem.js +11 -7
  5. package/lib/components/MobilePopup.js +39 -10
  6. package/lib/components/MobilePopup.style.js +11 -1
  7. package/lib/components/subModel/LazyDropdown.js +62 -33
  8. package/lib/components/variables/VariableHybridInput.d.ts +9 -0
  9. package/lib/components/variables/VariableHybridInput.js +146 -17
  10. package/lib/components/variables/VariableInput.js +19 -7
  11. package/lib/components/variables/VariableTag.js +48 -36
  12. package/lib/components/variables/types.d.ts +21 -0
  13. package/lib/flowContext.d.ts +12 -1
  14. package/lib/flowContext.js +47 -6
  15. package/lib/flowEngine.js +6 -0
  16. package/lib/flowI18n.js +3 -3
  17. package/lib/locale/en-US.json +2 -0
  18. package/lib/locale/index.d.ts +4 -0
  19. package/lib/locale/zh-CN.json +2 -0
  20. package/lib/resources/flowResource.js +1 -0
  21. package/lib/types.d.ts +3 -1
  22. package/lib/types.js +1 -0
  23. package/lib/utils/associationObjectVariable.d.ts +10 -0
  24. package/lib/utils/associationObjectVariable.js +10 -7
  25. package/lib/utils/dateVariable.d.ts +22 -0
  26. package/lib/utils/dateVariable.js +123 -16
  27. package/lib/utils/dirtyAwareApiClient.d.ts +1 -0
  28. package/lib/utils/dirtyAwareApiClient.js +280 -13
  29. package/lib/utils/index.d.ts +3 -3
  30. package/lib/utils/index.js +8 -0
  31. package/lib/utils/loadedPageCache.d.ts +1 -0
  32. package/lib/utils/loadedPageCache.js +6 -0
  33. package/lib/utils/params-resolvers.d.ts +3 -0
  34. package/lib/utils/params-resolvers.js +10 -0
  35. package/lib/utils/variablesParams.js +5 -0
  36. package/lib/views/createViewMeta.d.ts +1 -0
  37. package/lib/views/createViewMeta.js +53 -22
  38. package/package.json +4 -4
  39. package/src/__tests__/createViewMeta.popup.test.ts +84 -1
  40. package/src/__tests__/flowContext.test.ts +8 -0
  41. package/src/__tests__/flowI18n.test.ts +11 -0
  42. package/src/__tests__/objectVariable.test.ts +6 -1
  43. package/src/__tests__/runjsFormSubmit.test.ts +138 -0
  44. package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
  45. package/src/acl/Acl.tsx +36 -1
  46. package/src/acl/__tests__/Acl.test.tsx +70 -0
  47. package/src/components/FlowContextSelector.tsx +73 -12
  48. package/src/components/FormItem.tsx +12 -7
  49. package/src/components/MobilePopup.style.ts +12 -1
  50. package/src/components/MobilePopup.tsx +42 -10
  51. package/src/components/__tests__/FormItem.test.tsx +17 -2
  52. package/src/components/__tests__/MobilePopup.test.tsx +150 -0
  53. package/src/components/subModel/LazyDropdown.tsx +71 -38
  54. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
  55. package/src/components/subModel/__tests__/LazyDropdown.test.tsx +202 -0
  56. package/src/components/variables/VariableHybridInput.tsx +185 -14
  57. package/src/components/variables/VariableInput.tsx +32 -7
  58. package/src/components/variables/VariableTag.tsx +51 -37
  59. package/src/components/variables/__tests__/FlowContextSelector.test.tsx +95 -3
  60. package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
  61. package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
  62. package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
  63. package/src/components/variables/types.ts +21 -0
  64. package/src/flowContext.ts +79 -6
  65. package/src/flowEngine.ts +6 -0
  66. package/src/flowI18n.ts +8 -3
  67. package/src/locale/__tests__/index.test.ts +21 -0
  68. package/src/locale/en-US.json +2 -0
  69. package/src/locale/zh-CN.json +2 -0
  70. package/src/resources/__tests__/flowResource.test.ts +3 -0
  71. package/src/resources/flowResource.ts +1 -0
  72. package/src/types.ts +2 -0
  73. package/src/utils/__tests__/dateVariable.test.ts +57 -4
  74. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +321 -0
  75. package/src/utils/__tests__/variablesParams.test.ts +28 -1
  76. package/src/utils/associationObjectVariable.ts +9 -6
  77. package/src/utils/dateVariable.ts +145 -18
  78. package/src/utils/dirtyAwareApiClient.ts +348 -13
  79. package/src/utils/index.ts +17 -2
  80. package/src/utils/loadedPageCache.ts +7 -0
  81. package/src/utils/params-resolvers.ts +12 -0
  82. package/src/utils/variablesParams.ts +10 -0
  83. package/src/views/createViewMeta.ts +52 -18
@@ -33,6 +33,13 @@ __export(createViewMeta_exports, {
33
33
  });
34
34
  module.exports = __toCommonJS(createViewMeta_exports);
35
35
  var import_variablesParams = require("../utils/variablesParams");
36
+ function buildPopupSourceRecordRef(ref) {
37
+ if ((ref == null ? void 0 : ref.sourceId) == null || ref.sourceId === "" || typeof ref.associationName !== "string") return void 0;
38
+ const collection = ref.associationName.split(".")[0];
39
+ if (!collection) return void 0;
40
+ return { collection, dataSourceKey: ref.dataSourceKey || "main", filterByTk: ref.sourceId };
41
+ }
42
+ __name(buildPopupSourceRecordRef, "buildPopupSourceRecordRef");
36
43
  function isDefined(value) {
37
44
  return value !== void 0 && value !== null;
38
45
  }
@@ -306,9 +313,11 @@ function createPopupMeta(ctx, anchorView) {
306
313
  let level = 1;
307
314
  let parentRef = await getParentRecordRef(level, c);
308
315
  while (parentRef) {
309
- if (!cur.parent) cur.parent = {};
310
- cur.parent.record = parentRef;
311
- cur = cur.parent;
316
+ const parent = { record: parentRef };
317
+ const sourceRecord = buildPopupSourceRecordRef(parentRef);
318
+ if (sourceRecord) parent.sourceRecord = sourceRecord;
319
+ cur.parent = parent;
320
+ cur = parent;
312
321
  level += 1;
313
322
  parentRef = await getParentRecordRef(level, c);
314
323
  }
@@ -317,19 +326,12 @@ function createPopupMeta(ctx, anchorView) {
317
326
  (_b = (_a = c.logger) == null ? void 0 : _a.debug) == null ? void 0 : _b.call(_a, { err }, "[FlowEngine] buildVariablesParams: build parent-chain failed");
318
327
  }
319
328
  try {
320
- const srcId = inputArgs == null ? void 0 : inputArgs.sourceId;
321
- const assoc = inputArgs == null ? void 0 : inputArgs.associationName;
322
- const dsKey = (inputArgs == null ? void 0 : inputArgs.dataSourceKey) || "main";
323
- if (srcId != null && srcId !== "" && assoc && typeof assoc === "string") {
324
- const parentCollectionName = String(assoc).split(".")[0];
325
- if (parentCollectionName) {
326
- params.sourceRecord = {
327
- collection: parentCollectionName,
328
- dataSourceKey: dsKey,
329
- filterByTk: srcId
330
- };
331
- }
332
- }
329
+ const sourceRecord = buildPopupSourceRecordRef({
330
+ sourceId: inputArgs == null ? void 0 : inputArgs.sourceId,
331
+ associationName: inputArgs == null ? void 0 : inputArgs.associationName,
332
+ dataSourceKey: inputArgs == null ? void 0 : inputArgs.dataSourceKey
333
+ });
334
+ if (sourceRecord) params.sourceRecord = sourceRecord;
333
335
  } catch (err) {
334
336
  (_d = (_c = c.logger) == null ? void 0 : _c.debug) == null ? void 0 : _d.call(_c, { err }, "[FlowEngine] buildVariablesParams: infer sourceRecord failed");
335
337
  }
@@ -426,10 +428,11 @@ function createPopupMeta(ctx, anchorView) {
426
428
  }
427
429
  __name(createPopupMeta, "createPopupMeta");
428
430
  async function buildPopupRuntime(ctx, view) {
429
- var _a;
431
+ var _a, _b, _c;
430
432
  const stack = getViewStack(view);
431
433
  const currentIndex = getAnchoredViewStackIndex(view, stack);
432
- const openerUids = (_a = view == null ? void 0 : view.inputArgs) == null ? void 0 : _a.openerUids;
434
+ const sourceRecord = (_b = (_a = view == null ? void 0 : view.inputArgs) == null ? void 0 : _a.parentItem) == null ? void 0 : _b.value;
435
+ const openerUids = (_c = view == null ? void 0 : view.inputArgs) == null ? void 0 : _c.openerUids;
433
436
  const hasOpener = Array.isArray(openerUids) && openerUids.length > 0;
434
437
  const hasStackPopup = currentIndex >= 1;
435
438
  const isPopup = hasStackPopup || hasOpener;
@@ -446,16 +449,17 @@ async function buildPopupRuntime(ctx, view) {
446
449
  associationName: args.associationName,
447
450
  filterByTk: args.filterByTk,
448
451
  sourceId: args.sourceId
449
- }
452
+ },
453
+ ...typeof sourceRecord !== "undefined" ? { sourceRecord } : {}
450
454
  };
451
455
  }
452
456
  const buildNode = /* @__PURE__ */ __name(async (idx) => {
453
- var _a2, _b, _c, _d, _e, _f;
457
+ var _a2, _b2, _c2, _d, _e, _f;
454
458
  if (idx < 0 || !((_a2 = stack[idx]) == null ? void 0 : _a2.viewUid)) return void 0;
455
459
  const viewUid = stack[idx].viewUid;
456
- let model = (_b = ctx.engine) == null ? void 0 : _b.getModel(viewUid, true);
460
+ let model = (_b2 = ctx.engine) == null ? void 0 : _b2.getModel(viewUid, true);
457
461
  if (!model) {
458
- model = await ((_c = ctx.engine) == null ? void 0 : _c.loadModel({ uid: viewUid }));
462
+ model = await ((_c2 = ctx.engine) == null ? void 0 : _c2.loadModel({ uid: viewUid }));
459
463
  }
460
464
  const p = ((_d = model == null ? void 0 : model.getStepParams) == null ? void 0 : _d.call(model, "popupSettings", "openView")) || {};
461
465
  const collectionName = p == null ? void 0 : p.collectionName;
@@ -475,16 +479,43 @@ async function buildPopupRuntime(ctx, view) {
475
479
  return node;
476
480
  }, "buildNode");
477
481
  const currentNode = await buildNode(currentIndex);
482
+ if (currentNode && typeof sourceRecord !== "undefined") {
483
+ currentNode.sourceRecord = sourceRecord;
484
+ }
478
485
  return currentNode;
479
486
  }
480
487
  __name(buildPopupRuntime, "buildPopupRuntime");
481
488
  function registerPopupVariable(ctx, view) {
482
489
  const POPUP_SERVER_PATH_RE = /^(?:record|sourceRecord)(?:\.|$)|^parent(?:\.parent)*(?:\.(?:record|sourceRecord))(?:\.|$)/;
490
+ const shouldResolveSourceRecordOnServer = /* @__PURE__ */ __name((path) => {
491
+ var _a, _b;
492
+ if (path !== "sourceRecord" && !path.startsWith("sourceRecord.")) return false;
493
+ const parentItem = (_a = view == null ? void 0 : view.inputArgs) == null ? void 0 : _a.parentItem;
494
+ if (typeof (parentItem == null ? void 0 : parentItem.value) === "undefined") return true;
495
+ const sourcePath = path === "sourceRecord" ? "" : path.slice("sourceRecord.".length);
496
+ if (!sourcePath) return false;
497
+ const parentItemResolver = (_b = view == null ? void 0 : view.inputArgs) == null ? void 0 : _b.parentItemResolver;
498
+ if (typeof parentItemResolver === "function") {
499
+ return parentItemResolver(`value.${sourcePath}`);
500
+ }
501
+ const segments = sourcePath.split(".").filter(Boolean);
502
+ let current = parentItem.value;
503
+ for (const segment of segments) {
504
+ if (current === null || typeof current !== "object" || !(segment in current)) {
505
+ return true;
506
+ }
507
+ current = current[segment];
508
+ }
509
+ return false;
510
+ }, "shouldResolveSourceRecordOnServer");
483
511
  ctx.defineProperty("popup", {
484
512
  get: /* @__PURE__ */ __name(async () => buildPopupRuntime(ctx, view), "get"),
485
513
  meta: createPopupMeta(ctx, view),
486
514
  resolveOnServer: /* @__PURE__ */ __name((p) => {
487
515
  try {
516
+ if (p === "sourceRecord" || p.startsWith("sourceRecord.")) {
517
+ return shouldResolveSourceRecordOnServer(p);
518
+ }
488
519
  return !!p && POPUP_SERVER_PATH_RE.test(p);
489
520
  } catch (_) {
490
521
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.2.0-beta.9",
3
+ "version": "2.2.1",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.2.0-beta.9",
12
- "@nocobase/shared": "2.2.0-beta.9",
11
+ "@nocobase/sdk": "2.2.1",
12
+ "@nocobase/shared": "2.2.1",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "60e3d7abbaa0c7cead76f71a4f3d5eedb6b8acdb"
40
+ "gitHead": "29eedb8aa0160d7574063422a0ce01824c195861"
41
41
  }
@@ -11,7 +11,7 @@ import { describe, it, expect, vi } from 'vitest';
11
11
  import { FlowContext } from '../flowContext';
12
12
  import { FlowEngine } from '../flowEngine';
13
13
  import type { FlowView } from '../views/FlowView';
14
- import { buildPopupRuntime, createPopupMeta } from '../views/createViewMeta';
14
+ import { buildPopupRuntime, createPopupMeta, registerPopupVariable } from '../views/createViewMeta';
15
15
 
16
16
  describe('createPopupMeta - popup variables', () => {
17
17
  function makeCtx() {
@@ -167,6 +167,7 @@ describe('createPopupMeta - popup variables', () => {
167
167
  it('buildPopupRuntime anchors current popup even when the navigation stack already has a child popup', async () => {
168
168
  const { engine, ctx } = makeCtx();
169
169
  const parentView = makeNestedPopupView('parent-popup-uid', 13);
170
+ parentView.inputArgs.parentItem = { value: { id: 13, phone: '9999' } };
170
171
  mockNestedPopupModels(engine);
171
172
 
172
173
  const popup = await buildPopupRuntime(ctx, parentView);
@@ -179,6 +180,7 @@ describe('createPopupMeta - popup variables', () => {
179
180
  filterByTk: 13,
180
181
  sourceId: 13,
181
182
  });
183
+ expect(popup?.sourceRecord).toEqual({ id: 13, phone: '9999' });
182
184
  });
183
185
 
184
186
  it('buildVariablesParams(record) keeps the parent view record when a child popup is open', async () => {
@@ -314,4 +316,85 @@ describe('createPopupMeta - popup variables', () => {
314
316
  const props = typeof meta.properties === 'function' ? await (meta.properties as any)() : meta.properties || {};
315
317
  expect(props.record).toBeUndefined();
316
318
  });
319
+
320
+ it('uses the current parent item value for popup sourceRecord fields', async () => {
321
+ const { ctx } = makeCtx();
322
+ const parentItemResolver = vi.fn(() => false);
323
+ const anchorView: FlowView = {
324
+ type: 'dialog',
325
+ inputArgs: {
326
+ openerUids: ['opener-uid-1'],
327
+ viewUid: 'popup-uid',
328
+ dataSourceKey: 'main',
329
+ collectionName: 'roles',
330
+ associationName: 'users.roles',
331
+ sourceId: 1,
332
+ parentItem: { value: { id: 1, phone: '9999' } },
333
+ parentItemResolver,
334
+ },
335
+ Header: null,
336
+ Footer: null,
337
+ close: () => void 0,
338
+ update: () => void 0,
339
+ } as any;
340
+
341
+ registerPopupVariable(ctx, anchorView);
342
+
343
+ await expect(ctx.popup).resolves.toMatchObject({
344
+ sourceRecord: { id: 1, phone: '9999' },
345
+ });
346
+ expect(ctx.getPropertyOptions('popup')?.resolveOnServer?.('sourceRecord.phone')).toBe(false);
347
+ expect(parentItemResolver).toHaveBeenCalledWith('value.phone');
348
+ });
349
+
350
+ it('resolves a popup sourceRecord field from the current parent item without a server request', async () => {
351
+ const engine = new FlowEngine();
352
+ const ctx = engine.context;
353
+ const anchorView: FlowView = {
354
+ type: 'dialog',
355
+ inputArgs: {
356
+ openerUids: ['opener-uid-1'],
357
+ viewUid: 'popup-uid',
358
+ dataSourceKey: 'main',
359
+ collectionName: 'roles',
360
+ associationName: 'users.roles',
361
+ sourceId: 1,
362
+ parentItem: { value: { id: 1, phone: '9999' } },
363
+ parentItemResolver: () => false,
364
+ },
365
+ Header: null,
366
+ Footer: null,
367
+ close: () => void 0,
368
+ update: () => void 0,
369
+ } as any;
370
+
371
+ registerPopupVariable(ctx, anchorView);
372
+
373
+ await expect(ctx.resolveJsonTemplate('{{ ctx.popup.sourceRecord.phone }}')).resolves.toBe('9999');
374
+ });
375
+
376
+ it('keeps server resolution for popup sourceRecord association subpaths', async () => {
377
+ const { ctx } = makeCtx();
378
+ const anchorView: FlowView = {
379
+ type: 'dialog',
380
+ inputArgs: {
381
+ openerUids: ['opener-uid-1'],
382
+ viewUid: 'popup-uid',
383
+ dataSourceKey: 'main',
384
+ collectionName: 'roles',
385
+ associationName: 'users.roles',
386
+ sourceId: 1,
387
+ parentItem: { value: { id: 1, departmentId: 2 } },
388
+ parentItemResolver: (path: string) => path === 'value.department.title',
389
+ },
390
+ Header: null,
391
+ Footer: null,
392
+ close: () => void 0,
393
+ update: () => void 0,
394
+ } as any;
395
+
396
+ registerPopupVariable(ctx, anchorView);
397
+
398
+ expect(ctx.getPropertyOptions('popup')?.resolveOnServer?.('sourceRecord.department.title')).toBe(true);
399
+ });
317
400
  });
@@ -16,6 +16,7 @@ import { RunJSContextRegistry } from '../runjs-context/registry';
16
16
  import { setupRunJSContexts } from '../runjs-context/setup';
17
17
  import { createViewScopedEngine } from '../ViewScopedFlowEngine';
18
18
  import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
19
+ import { serializeCtxDateExpressionConfig } from '../utils/dateVariable';
19
20
 
20
21
  describe('FlowContext properties and methods', () => {
21
22
  it('should return static property value', () => {
@@ -2068,10 +2069,16 @@ describe('getPropertyMetaTree with deep delegate meta', () => {
2068
2069
  describe('FlowContext resolveOnServer selective server resolution', () => {
2069
2070
  it('resolves ctx.date expressions on client context', async () => {
2070
2071
  const engine = new FlowEngine();
2072
+ const formattedToday = serializeCtxDateExpressionConfig({
2073
+ kind: 'preset',
2074
+ preset: 'today',
2075
+ format: 'YYYY/MM/DD',
2076
+ });
2071
2077
  const out = await (engine.context as any).resolveJsonTemplate({
2072
2078
  today: '{{ ctx.date.preset.today }}',
2073
2079
  next12: '{{ ctx.date.relative.next.day.n12 }}',
2074
2080
  now: '{{ ctx.date.preset.now }}',
2081
+ formattedToday,
2075
2082
  });
2076
2083
 
2077
2084
  expect(typeof out.today).toBe('string');
@@ -2080,6 +2087,7 @@ describe('FlowContext resolveOnServer selective server resolution', () => {
2080
2087
  expect(out.next12).toMatch(/^\d{4}-\d{2}-\d{2}$/);
2081
2088
  expect(typeof out.now).toBe('string');
2082
2089
  expect(out.now.length).toBeGreaterThan(0);
2090
+ expect(out.formattedToday).toMatch(/^\d{4}\/\d{2}\/\d{2}$/);
2083
2091
  });
2084
2092
 
2085
2093
  it('does not call server by default (no resolveOnServer set)', async () => {
@@ -17,6 +17,17 @@ describe('FlowI18n', () => {
17
17
  expect(i18n.translate("{{ t('Hello') }}")).toBe('你好');
18
18
  });
19
19
 
20
+ it('keeps embedded quotes of a different type inside the key', () => {
21
+ // A single-quoted key whose text contains double quotes (and vice versa) must not be truncated at the first inner
22
+ // quote.
23
+ const key = 'Unlike "Post-action event", it listens for data changes.';
24
+ const table: Record<string, string> = { [key]: '与“操作后事件”不同,它监听数据变动。' };
25
+ const i18n = new FlowI18n({ i18n: { t: (k: string) => table[k] ?? k } });
26
+
27
+ expect(i18n.translate(`{{t('${key}', { ns: "workflow" })}}`)).toBe(table[key]);
28
+ expect(i18n.translate(`{{t("It's here", { ns: "workflow" })}}`)).toBe("It's here");
29
+ });
30
+
20
31
  it('template compile ignores malformed options', () => {
21
32
  const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
22
33
  const i18n = new FlowI18n({ i18n: { t: (k: string) => k } });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, it, vi } from 'vitest';
11
+ import { generateFlowModelRdFromToken } from '@nocobase/utils/client';
11
12
  import { FlowContext } from '../flowContext';
12
13
  import { FlowEngine } from '../flowEngine';
13
14
  import {
@@ -122,7 +123,10 @@ describe('objectVariable utilities', () => {
122
123
 
123
124
  // Provide API stub to intercept variables:resolve
124
125
  const calls: any[] = [];
126
+ const payload = Buffer.from(JSON.stringify({ userId: 1, signInTime: 'contract-owner-test' })).toString('base64url');
127
+ const token = `test.${payload}.sig`;
125
128
  (ctx as any).api = {
129
+ auth: { token },
126
130
  request: vi.fn(async ({ url, data, method }) => {
127
131
  calls.push({ url, data, method });
128
132
  const batch = (data?.values?.batch as any[]) || [];
@@ -146,13 +150,14 @@ describe('objectVariable utilities', () => {
146
150
  });
147
151
 
148
152
  const template = { x: '{{ ctx.obj.author.name }}' } as any;
149
- await (ctx as any).resolveJsonTemplate(template);
153
+ await (ctx as any).resolveJsonTemplate(template, { contractModelUid: 'form-grid' });
150
154
 
151
155
  // Assert variables:resolve was called with proper flattened contextParams
152
156
  expect((ctx as any).api.request).toHaveBeenCalled();
153
157
  const call = calls.find((c) => c.url === 'variables:resolve');
154
158
  expect(call).toBeTruthy();
155
159
  const batch0 = call.data?.values?.batch?.[0];
160
+ expect(batch0?.contractRd).toBe(generateFlowModelRdFromToken('form-grid', token));
156
161
  expect(batch0?.contextParams).toBeTruthy();
157
162
  // Flattened key should be 'obj.author'
158
163
  const cp = batch0.contextParams as Record<string, any>;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import { APIClient } from '@nocobase/sdk';
12
+ import { FlowContext, FlowRunJSContext } from '../flowContext';
13
+ import { JSItemRunJSContext } from '../runjs-context/contexts/JSItemRunJSContext';
14
+
15
+ describe('FlowRunJSContext form submission', () => {
16
+ it('uses the form block RunJS submit capability without changing the native form', () => {
17
+ const nativeSubmit = vi.fn();
18
+ const getFieldsValue = vi.fn(() => ({ name: 'Alice' }));
19
+ const form = { submit: nativeSubmit, getFieldsValue };
20
+ const submitFromRunJs = vi.fn();
21
+ const delegate = new FlowContext();
22
+ delegate.defineProperty('form', { value: form });
23
+ delegate.defineProperty('blockModel', { value: { submitFromRunJs } });
24
+
25
+ const ctx = new JSItemRunJSContext(delegate);
26
+
27
+ expect(ctx.form.getFieldsValue()).toEqual({ name: 'Alice' });
28
+ ctx.form.submit();
29
+ expect(submitFromRunJs).toHaveBeenCalledOnce();
30
+ expect(nativeSubmit).not.toHaveBeenCalled();
31
+ expect(form.submit).toBe(nativeSubmit);
32
+ });
33
+
34
+ it('keeps the native submit method when the block has no RunJS submit capability', () => {
35
+ const nativeSubmit = vi.fn();
36
+ const form = { submit: nativeSubmit };
37
+ const delegate = new FlowContext();
38
+ delegate.defineProperty('form', { value: form });
39
+ delegate.defineProperty('blockModel', { value: {} });
40
+
41
+ const ctx = new FlowRunJSContext(delegate);
42
+
43
+ ctx.form.submit();
44
+ expect(nativeSubmit).toHaveBeenCalledOnce();
45
+ });
46
+
47
+ it('preserves nested form values and adds association paths to matching resource create calls', async () => {
48
+ const api = new APIClient();
49
+ const request = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { data: { id: 1 } } });
50
+ const values = { name: 'Alice', children: [{ name: 'Bob' }] };
51
+ const getFieldsValue = vi.fn(() => values);
52
+ const delegate = new FlowContext();
53
+ delegate.defineProperty('api', { value: api });
54
+ delegate.defineProperty('resource', {
55
+ value: {
56
+ getResourceName: () => 't1_user',
57
+ getDataSourceKey: () => 'main',
58
+ getUpdateAssociationValues: () => ['children'],
59
+ },
60
+ });
61
+ delegate.defineProperty('form', { value: { submit: vi.fn(), getFieldsValue } });
62
+ delegate.defineProperty('blockModel', { value: { submitFromRunJs: vi.fn() } });
63
+ const ctx = new FlowRunJSContext(delegate);
64
+
65
+ await ctx.api.resource('t1_user').create({ values: ctx.form.getFieldsValue(true) });
66
+
67
+ expect(getFieldsValue).toHaveBeenCalledWith(true);
68
+ expect(request).toHaveBeenCalledWith({
69
+ url: 't1_user:create',
70
+ method: 'post',
71
+ params: { updateAssociationValues: ['children'] },
72
+ data: values,
73
+ });
74
+ });
75
+
76
+ it('keeps explicit association params and unrelated resource calls unchanged', async () => {
77
+ const api = new APIClient();
78
+ const request = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { data: { id: 1 } } });
79
+ const delegate = new FlowContext();
80
+ delegate.defineProperty('api', { value: api });
81
+ delegate.defineProperty('resource', {
82
+ value: {
83
+ getResourceName: () => 't1_user',
84
+ getDataSourceKey: () => 'main',
85
+ getUpdateAssociationValues: () => ['children'],
86
+ },
87
+ });
88
+ delegate.defineProperty('form', { value: { submit: vi.fn() } });
89
+ delegate.defineProperty('blockModel', { value: { submitFromRunJs: vi.fn() } });
90
+ const ctx = new FlowRunJSContext(delegate);
91
+
92
+ await ctx.api.resource('t1_user').create({ values: {}, updateAssociationValues: [] });
93
+ await ctx.api.resource('t1_user').create({ values: {}, updateAssociationValues: null });
94
+ await ctx.api.resource('t1_user').create({ values: {}, updateAssociationValues: undefined });
95
+ await ctx.api.resource('posts').create({ values: { title: 'Post' } });
96
+ await ctx.api.resource('t1_user', undefined, { 'X-Data-Source': 'external' }).create({ values: { name: 'Bob' } });
97
+
98
+ expect(request.mock.calls.map(([config]) => config.params)).toEqual([
99
+ { updateAssociationValues: [] },
100
+ { updateAssociationValues: null },
101
+ { updateAssociationValues: undefined },
102
+ {},
103
+ {},
104
+ ]);
105
+ });
106
+
107
+ it('adds association paths only for the matching association source record', async () => {
108
+ const api = new APIClient();
109
+ const request = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { data: { id: 1 } } });
110
+ const delegate = new FlowContext();
111
+ delegate.defineProperty('api', { value: api });
112
+ delegate.defineProperty('resource', {
113
+ value: {
114
+ getResourceName: () => 'users.children',
115
+ getDataSourceKey: () => 'main',
116
+ getSourceId: () => 1,
117
+ getUpdateAssociationValues: () => ['toys'],
118
+ },
119
+ });
120
+ delegate.defineProperty('form', { value: { submit: vi.fn() } });
121
+ delegate.defineProperty('blockModel', { value: { submitFromRunJs: vi.fn() } });
122
+ const ctx = new FlowRunJSContext(delegate);
123
+
124
+ await ctx.api.resource('users.children', 1).create({ values: { name: 'same source' } });
125
+ await ctx.api.resource('users.children', 2).create({ values: { name: 'different source' } });
126
+
127
+ expect(request.mock.calls.map(([config]) => ({ url: config.url, params: config.params }))).toEqual([
128
+ {
129
+ url: 'users/1/children:create',
130
+ params: { updateAssociationValues: ['toys'] },
131
+ },
132
+ {
133
+ url: 'users/2/children:create',
134
+ params: {},
135
+ },
136
+ ]);
137
+ });
138
+ });
@@ -340,9 +340,9 @@ describe('ViewScopedFlowEngine', () => {
340
340
  const repository = new DirtyPageRepository();
341
341
  root.setModelRepository(repository);
342
342
 
343
- class ParentModel extends FlowModel {}
344
- class PageModel extends FlowModel {}
345
343
  class BlockModel extends FlowModel {}
344
+ class PageModel extends FlowModel<{ parent?: FlowModel; subModels: { items: BlockModel[] } }> {}
345
+ class ParentModel extends FlowModel<{ parent?: FlowModel; subModels: { page?: PageModel } }> {}
346
346
  root.registerModels({ ParentModel, PageModel, BlockModel });
347
347
 
348
348
  const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'popup-action' });
@@ -357,7 +357,10 @@ describe('ViewScopedFlowEngine', () => {
357
357
  items: [{ use: 'BlockModel', uid: 'stale-block' }],
358
358
  },
359
359
  });
360
- const staleBlock = stalePage.findSubModel('items' as any, (item) => item.uid === 'stale-block') as FlowModel;
360
+ const staleBlock = stalePage.findSubModel('items', (item) => item.uid === 'stale-block');
361
+ if (!staleBlock) {
362
+ throw new Error('Expected stale block to be loaded');
363
+ }
361
364
  parent.setSubModel('page', stalePage);
362
365
  oldScoped.unlinkFromStack();
363
366
 
@@ -372,7 +375,7 @@ describe('ViewScopedFlowEngine', () => {
372
375
  },
373
376
  };
374
377
 
375
- root.flowSettings.enable();
378
+ await root.flowSettings.enable();
376
379
  await staleBlock.saveStepParams();
377
380
  root.flowSettings.disable();
378
381
  repository.findOneCalls = 0;
@@ -388,8 +391,8 @@ describe('ViewScopedFlowEngine', () => {
388
391
 
389
392
  expect(repository.findOneCalls).toBe(1);
390
393
  expect(loaded).not.toBe(stalePage);
391
- expect((parent.subModels as any).page).toBe(loaded);
392
- expect(loaded?.mapSubModels('items' as any, (item) => item.uid)).toEqual(['fresh-block']);
394
+ expect(parent.subModels.page).toBe(loaded);
395
+ expect(loaded?.mapSubModels('items', (item) => item.uid)).toEqual(['fresh-block']);
393
396
 
394
397
  repository.findOneCalls = 0;
395
398
  const nextRuntimeScoped = createViewScopedEngine(root);
@@ -405,6 +408,69 @@ describe('ViewScopedFlowEngine', () => {
405
408
  expect(loadedAgain?.uid).toBe('popup-page');
406
409
  });
407
410
 
411
+ it('reloads a page after it was loaded in flow settings mode', async () => {
412
+ const root = new FlowEngine();
413
+ const repository = new DirtyPageRepository();
414
+ root.setModelRepository(repository);
415
+
416
+ class ParentModel extends FlowModel {}
417
+ class PageModel extends FlowModel {}
418
+ class BlockModel extends FlowModel {}
419
+ root.registerModels({ ParentModel, PageModel, BlockModel });
420
+
421
+ const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'settings-popup-action' });
422
+ repository.data = {
423
+ use: 'PageModel',
424
+ uid: 'settings-popup-page',
425
+ parentId: parent.uid,
426
+ subKey: 'page',
427
+ subType: 'object',
428
+ subModels: {
429
+ items: [{ use: 'BlockModel', uid: 'stale-settings-block' }],
430
+ },
431
+ };
432
+
433
+ await root.flowSettings.enable();
434
+ const designScoped = createViewScopedEngine(root);
435
+ const designLoaded = await designScoped.loadOrCreateModel<PageModel>({
436
+ async: true,
437
+ parentId: parent.uid,
438
+ subKey: 'page',
439
+ subType: 'object',
440
+ use: 'PageModel',
441
+ });
442
+ expect(repository.findOneCalls).toBe(1);
443
+ expect(designLoaded?.mapSubModels('items', (item) => item.uid)).toEqual(['stale-settings-block']);
444
+ designScoped.unlinkFromStack();
445
+
446
+ repository.data = {
447
+ use: 'PageModel',
448
+ uid: 'settings-popup-page',
449
+ parentId: parent.uid,
450
+ subKey: 'page',
451
+ subType: 'object',
452
+ subModels: {
453
+ items: [{ use: 'BlockModel', uid: 'fresh-settings-block' }],
454
+ },
455
+ };
456
+ root.flowSettings.disable();
457
+ repository.findOneCalls = 0;
458
+
459
+ const runtimeScoped = createViewScopedEngine(root);
460
+ const runtimeLoaded = await runtimeScoped.loadOrCreateModel<PageModel>({
461
+ async: true,
462
+ parentId: parent.uid,
463
+ subKey: 'page',
464
+ subType: 'object',
465
+ use: 'PageModel',
466
+ });
467
+
468
+ expect(repository.findOneCalls).toBe(1);
469
+ expect(runtimeLoaded).not.toBe(designLoaded);
470
+ expect(parent.subModels.page).toBe(runtimeLoaded);
471
+ expect(runtimeLoaded?.mapSubModels('items', (item) => item.uid)).toEqual(['fresh-settings-block']);
472
+ });
473
+
408
474
  it('does not bypass loaded page cache after a non-config save', async () => {
409
475
  const root = new FlowEngine();
410
476
  const repository = new DirtyPageRepository();
package/src/acl/Acl.tsx CHANGED
@@ -16,7 +16,7 @@ interface CheckOptions {
16
16
  actionName: string;
17
17
  fields?: string[];
18
18
  recordPkValue?: string | number;
19
- allowedActions: any[];
19
+ allowedActions?: Record<string, Array<string | number>>;
20
20
  }
21
21
 
22
22
  export class ACL {
@@ -149,6 +149,41 @@ export class ACL {
149
149
  return allowed;
150
150
  }
151
151
 
152
+ can(options: CheckOptions): boolean {
153
+ const { allowAll } = this.data;
154
+ if (allowAll) {
155
+ return true;
156
+ }
157
+
158
+ const { actionName, allowedActions, recordPkValue } = options;
159
+ const hasRecordPkValue = recordPkValue !== undefined && recordPkValue !== null;
160
+ const recordPermission =
161
+ hasRecordPkValue && allowedActions ? this.verifyScope(actionName, recordPkValue, allowedActions) : null;
162
+ if (hasRecordPkValue && allowedActions && recordPermission !== true) {
163
+ return false;
164
+ }
165
+
166
+ const params = this.parseAction(options);
167
+ if (!params) {
168
+ return false;
169
+ }
170
+ if (!_.isEmpty(params.filter) && recordPermission !== true) {
171
+ return false;
172
+ }
173
+ if (!options.fields?.length) {
174
+ return true;
175
+ }
176
+
177
+ const allowedFields: string[] = []
178
+ .concat(params.whitelist || [])
179
+ .concat(params.fields || [])
180
+ .concat(params.appends || []);
181
+ if (!allowedFields.length) {
182
+ return true;
183
+ }
184
+ return options.fields.every((field) => allowedFields.includes(field));
185
+ }
186
+
152
187
  async aclCheck(options: CheckOptions): Promise<boolean> {
153
188
  // await this.load();
154
189
  const { allowAll } = this.data;