@nocobase/client-v2 2.3.0-beta.2 → 2.3.0-beta.21

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 (91) hide show
  1. package/es/RouteRepository.d.ts +6 -0
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
  3. package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
  4. package/es/flow/admin-shell/admin-layout/MobileTopbarActionsContext.d.ts +10 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  6. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
  7. package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
  8. package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
  9. package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
  10. package/es/flow/models/actions/LinkActionUtils.d.ts +1 -0
  11. package/es/flow/models/blocks/assign-form/assignFormValue.d.ts +29 -0
  12. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
  13. package/es/index.mjs +100 -100
  14. package/lib/index.js +102 -102
  15. package/package.json +9 -8
  16. package/src/BaseApplication.tsx +2 -0
  17. package/src/RouteRepository.ts +14 -0
  18. package/src/__tests__/RouteRepository.test.ts +51 -0
  19. package/src/__tests__/app.test.tsx +13 -0
  20. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  21. package/src/collection-manager/field-configure.ts +2 -2
  22. package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
  23. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
  24. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
  25. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  26. package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
  27. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  28. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  29. package/src/flow/admin-shell/admin-layout/MobileTopbarActionsContext.ts +13 -0
  30. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +13 -2
  31. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.mobile.test.tsx +205 -0
  32. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  33. package/src/flow/components/FieldAssignValueInput.tsx +4 -0
  34. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
  35. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
  36. package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
  37. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  38. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  39. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  40. package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
  41. package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
  42. package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
  43. package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
  44. package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
  45. package/src/flow/models/actions/LinkActionModel.tsx +11 -9
  46. package/src/flow/models/actions/LinkActionUtils.ts +4 -2
  47. package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
  48. package/src/flow/models/actions/__tests__/LinkActionModel.test.ts +14 -0
  49. package/src/flow/models/base/GridModel.tsx +0 -1
  50. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  51. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +40 -12
  52. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  53. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.multipleSelect.test.tsx +216 -0
  54. package/src/flow/models/blocks/assign-form/__tests__/assignFormValue.test.ts +41 -0
  55. package/src/flow/models/blocks/assign-form/assignFormValue.ts +64 -0
  56. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  57. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  58. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  59. package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
  60. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
  61. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
  62. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
  63. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  64. package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
  65. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
  66. package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
  67. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
  68. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +11 -2
  69. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
  70. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +6 -3
  71. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +13 -1
  72. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +47 -17
  73. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
  74. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.largeField.test.tsx +178 -0
  75. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
  76. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +26 -2
  77. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
  78. package/src/flow/models/fields/AssociationFieldModel/__tests__/associationSelect.search.test.ts +113 -0
  79. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  80. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  81. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  82. package/src/flow/models/fields/InputFieldModel.tsx +1 -1
  83. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  84. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  85. package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
  86. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +16 -0
  87. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
  88. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
  89. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -0
  90. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
  91. package/src/flow/models/topbar/UserCenterTopbarActionModel.tsx +8 -4
@@ -0,0 +1,61 @@
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 React from 'react';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { createForm } from '@formily/core';
13
+ import { Field, FormProvider } from '@formily/react';
14
+ import { render, screen } from '@nocobase/test/client';
15
+ import userEvent from '@testing-library/user-event';
16
+ import { FlowEngine, FlowEngineProvider, FlowModel, FlowModelProvider } from '@nocobase/flow-engine';
17
+ import { FieldComponentProps } from '../FieldComponentProps';
18
+
19
+ class HostModel extends FlowModel {
20
+ render() {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ describe.each(['RadioGroupFieldModel', 'CheckboxGroupFieldModel', 'SelectFieldModel'])('%s options', (fieldModel) => {
26
+ it('keeps focus while editing option values and preserves remaining rows after removal', async () => {
27
+ const form = createForm();
28
+ const engine = new FlowEngine();
29
+ engine.registerModels({ HostModel });
30
+ const model = engine.createModel<HostModel>({ use: 'HostModel' });
31
+ render(
32
+ <FlowEngineProvider engine={engine}>
33
+ <FlowModelProvider model={model}>
34
+ <FormProvider form={form}>
35
+ <Field name="props" component={[FieldComponentProps, { fieldModel, source: [] }]} />
36
+ </FormProvider>
37
+ </FlowModelProvider>
38
+ </FlowEngineProvider>,
39
+ );
40
+
41
+ await userEvent.click(screen.getByRole('button', { name: 'plus Add' }));
42
+ const valueInput = screen.getByPlaceholderText('Option value');
43
+ await userEvent.type(valueInput, 'abcdef');
44
+ expect(screen.getByPlaceholderText('Option value')).toHaveValue('abcdef');
45
+ expect(valueInput).toHaveFocus();
46
+
47
+ await userEvent.keyboard('{Backspace}{Backspace}xy');
48
+ expect(valueInput).toHaveValue('abcdxy');
49
+ expect(valueInput).toHaveFocus();
50
+ await userEvent.type(screen.getByPlaceholderText('Option label'), 'First');
51
+ await userEvent.click(screen.getByRole('button', { name: 'plus Add' }));
52
+ await userEvent.type(screen.getAllByPlaceholderText('Option label')[1], 'Second');
53
+ await userEvent.type(screen.getAllByPlaceholderText('Option value')[1], 'second');
54
+ await userEvent.click(screen.getAllByRole('button', { name: 'close' })[0]);
55
+ expect(screen.getByPlaceholderText('Option label')).toHaveValue('Second');
56
+ expect(screen.getByPlaceholderText('Option value')).toHaveValue('second');
57
+ await userEvent.type(screen.getByPlaceholderText('Option value'), '2');
58
+ expect(screen.getByPlaceholderText('Option value')).toHaveFocus();
59
+ expect(form.values.props.options).toEqual([{ label: 'Second', value: 'second2' }]);
60
+ });
61
+ });
@@ -399,20 +399,41 @@ export class FormBlockModel<
399
399
  if (Array.isArray(topValue) && topValue.length === 0) return false;
400
400
 
401
401
  // 本地优先:支持对多关系的 dot 聚合路径(例如 assignees.name)。
402
- // lodash.get 对数组聚合路径会返回 undefined(如 _.get({ assignees:[{name:'A'}] }, 'assignees.name')),
403
- // 因而这里先用 getValuesByPath 做一次前端可解析性检查,命中则直接前端解析。
402
+ // 关联字段只有在本地值包含目标标题字段时才算完整;标量外键或仅含主键的轻量对象仍需服务端补全。
404
403
  const formValuesSnapshot = runtime.getFormValuesSnapshot();
404
+ let shouldResolveAssociationValueOnServer = false;
405
405
  if (formValuesSnapshot && typeof formValuesSnapshot === 'object') {
406
- const localResolved = getValuesByPath(formValuesSnapshot as Record<string, any>, subPath);
406
+ const localResolved = getValuesByPath(formValuesSnapshot as Record<string, unknown>, subPath);
407
407
  if (typeof localResolved !== 'undefined') {
408
- return false;
408
+ const fieldPath = subPath
409
+ .replace(/\[\d+\]/g, '')
410
+ .split('.')
411
+ .filter((segment) => !/^\d+$/.test(segment))
412
+ .join('.');
413
+ const resolvedField = this.collection?.getFieldByPath?.(fieldPath);
414
+ const titleFieldName = resolvedField?.targetCollectionTitleFieldName;
415
+ const isLoadedAssociationRecord = (value: unknown) => {
416
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
417
+ if (!titleFieldName) return true;
418
+ return typeof (value as Record<string, unknown>)[titleFieldName] !== 'undefined';
419
+ };
420
+ const isAssociationValueLoaded =
421
+ localResolved === null ||
422
+ (Array.isArray(localResolved)
423
+ ? localResolved.length === 0 ||
424
+ localResolved.every((value) => value === null || isLoadedAssociationRecord(value))
425
+ : isLoadedAssociationRecord(localResolved));
426
+ if (!resolvedField?.isAssociationField?.() || isAssociationValueLoaded) {
427
+ return false;
428
+ }
429
+ shouldResolveAssociationValueOnServer = true;
409
430
  }
410
431
  }
411
432
 
412
433
  // 已配置字段:仅关联字段的子路径按需服务端补全(保持现有语义)
413
434
  const assocResolver = createAssociationSubpathResolver(
414
435
  () => this.collection,
415
- () => runtime.getFormValuesSnapshot(),
436
+ shouldResolveAssociationValueOnServer ? undefined : () => runtime.getFormValuesSnapshot(),
416
437
  );
417
438
  return assocResolver(subPath);
418
439
  }
@@ -23,6 +23,7 @@ import {
23
23
  } from '@nocobase/flow-engine';
24
24
  import { Button, Form, Skeleton, Space } from 'antd';
25
25
  import React from 'react';
26
+ import { findDataScopeStep, getQuickEditDataScopeFilter } from '../../../internal/utils/quickEditDataScope';
26
27
  import { FieldModel } from '../../base/FieldModel';
27
28
  import { FormComponent } from './FormBlockModel';
28
29
  import { FormItemModel } from './FormItemModel';
@@ -36,6 +37,14 @@ const QUICK_EDIT_MOBILE_CONTENT_PADDING = '8px var(--nb-mobile-page-tabs-content
36
37
  const QUICK_EDIT_MOBILE_ACTIONS_PADDING =
37
38
  '8px var(--nb-mobile-page-tabs-content-padding, 12px) calc(80px + env(safe-area-inset-bottom, 0px))';
38
39
  const QUICK_EDIT_MOBILE_MEDIA_QUERY = '(max-width: 768px)';
40
+ // Dropdown-style editors open upwards inside the desktop popover so the option list does not cover the Submit and
41
+ // Cancel buttons rendered right below the field. antd still flips the list downwards when there is no room above.
42
+ const QUICK_EDIT_DROPDOWN_UP_MODELS = [
43
+ 'SelectFieldModel',
44
+ 'RecordSelectFieldModel',
45
+ 'CascadeSelectFieldModel',
46
+ 'CascadeSelectListFieldModel',
47
+ ];
39
48
 
40
49
  type QuickEditViewBeforeClosePayload = {
41
50
  result?: unknown;
@@ -159,6 +168,7 @@ export class QuickEditFormModel extends FlowModel {
159
168
  __onSubmitSuccess;
160
169
  _fieldProps: any;
161
170
  _onOk: any;
171
+ _dataScopeFilter: Record<string, any> | undefined;
162
172
 
163
173
  get form() {
164
174
  return this.context.form;
@@ -212,6 +222,7 @@ export class QuickEditFormModel extends FlowModel {
212
222
  },
213
223
  sourceFieldModel ? { delegate: sourceFieldModel.context } : undefined,
214
224
  ) as QuickEditFormModel;
225
+ model._dataScopeFilter = getQuickEditDataScopeFilter(sourceFieldModel);
215
226
 
216
227
  const bodyStyles = {
217
228
  maxHeight: QUICK_EDIT_POPOVER_MAX_HEIGHT,
@@ -410,6 +421,13 @@ QuickEditFormModel.registerFlow({
410
421
  resource.setDataSourceKey(dataSourceKey);
411
422
  resource.setResourceName(collectionName);
412
423
  ctx.model.resource = resource;
424
+ // Seed the edited row before dispatching the field model's flows. An association data scope resolves
425
+ // `ctx.record` while the field renders, and so does the foreign key filter that one-to-one and one-to-many
426
+ // editors build, so the record has to be in place first.
427
+ if (ctx.inputArgs.filterByTk || ctx.inputArgs.record) {
428
+ resource.setFilterByTk(ctx.inputArgs.filterByTk);
429
+ resource.setData(ctx.inputArgs.record);
430
+ }
413
431
  const collectionField = ctx.model.collection.getField(fieldPath) as CollectionField;
414
432
  if (collectionField) {
415
433
  const binding = FormItemModel.getDefaultBindingByField(ctx, collectionField);
@@ -417,24 +435,42 @@ QuickEditFormModel.registerFlow({
417
435
  return;
418
436
  }
419
437
  const use = binding.modelName;
438
+ const stepParams: Record<string, Record<string, Record<string, unknown>>> = {
439
+ fieldSettings: {
440
+ init: {
441
+ dataSourceKey,
442
+ collectionName,
443
+ fieldPath,
444
+ },
445
+ },
446
+ };
447
+ const dataScopeFilter = ctx.model._dataScopeFilter;
448
+ if (dataScopeFilter) {
449
+ const dataScopeStep = findDataScopeStep(ctx.engine.getModelClass(use));
450
+ if (dataScopeStep) {
451
+ stepParams[dataScopeStep.flowKey] = {
452
+ ...stepParams[dataScopeStep.flowKey],
453
+ [dataScopeStep.stepKey]: { filter: dataScopeFilter },
454
+ };
455
+ }
456
+ }
420
457
  const fieldModel = ctx.model.addSubModel<FieldModel>('fields', {
421
458
  use,
422
459
  props:
423
460
  typeof binding.defaultProps === 'function'
424
461
  ? binding.defaultProps(ctx, collectionField)
425
462
  : binding.defaultProps,
426
- stepParams: {
427
- fieldSettings: {
428
- init: {
429
- dataSourceKey,
430
- collectionName,
431
- fieldPath,
432
- },
433
- },
434
- },
463
+ stepParams,
435
464
  });
436
465
  fieldModel.setProps(getQuickEditFieldProps(collectionField, ctx.model._fieldProps));
437
466
  fieldModel.setProps({ sourceFieldModelUid: ctx.inputArgs.sourceFieldModelUid });
467
+ if (
468
+ !ctx.model.context.isMobileLayout &&
469
+ QUICK_EDIT_DROPDOWN_UP_MODELS.includes(use) &&
470
+ fieldModel.props.placement == null
471
+ ) {
472
+ fieldModel.setProps({ placement: 'topLeft' });
473
+ }
438
474
  ctx.model.context.defineProperty('collectionField', {
439
475
  get: () => collectionField,
440
476
  });
@@ -442,8 +478,6 @@ QuickEditFormModel.registerFlow({
442
478
  await fieldModel.dispatchEvent('beforeRender');
443
479
  }
444
480
  if (ctx.inputArgs.filterByTk || ctx.inputArgs.record) {
445
- resource.setFilterByTk(ctx.inputArgs.filterByTk);
446
- resource.setData(ctx.inputArgs.record);
447
481
  ctx.model.form?.setFieldsValue(resource.getData());
448
482
  }
449
483
  },
@@ -77,6 +77,7 @@ async function setupFormModel() {
77
77
  ds.addCollection({
78
78
  name: 'levels',
79
79
  filterTargetKey: 'id',
80
+ titleField: 'name',
80
81
  fields: [
81
82
  { name: 'id', type: 'integer', interface: 'number' },
82
83
  { name: 'name', type: 'string', interface: 'text' },
@@ -549,6 +550,150 @@ describe('FormBlockModel (form/formValues injection & server resolve anchors)',
549
550
  expect(api.request).toHaveBeenCalledTimes(1);
550
551
  });
551
552
 
553
+ it('resolves a configured nested association from the server when the local value is only its key', async () => {
554
+ const model = await setupFormModel();
555
+ const api = {
556
+ request: vi.fn(async (config: any) => {
557
+ const batch = config?.data?.values?.batch || [];
558
+ const item = batch[0] || {};
559
+ expect(item.template).toEqual({ level: '{{ ctx.formValues.customer.level }}' });
560
+ expect(Object.keys(item.contextParams || {})).toEqual(['formValues.customer']);
561
+ expect(item.contextParams['formValues.customer']).toMatchObject({
562
+ collection: 'customers',
563
+ filterByTk: 9,
564
+ });
565
+ return {
566
+ data: {
567
+ data: {
568
+ results: [{ id: item.id, data: { level: { id: 'level-1', name: 'Level 1' } } }],
569
+ },
570
+ },
571
+ } as any;
572
+ }),
573
+ } as any;
574
+ (model.flowEngine.context as any).defineProperty('api', { value: api });
575
+
576
+ function HookCaller() {
577
+ model.useHooksBeforeRender();
578
+ return null;
579
+ }
580
+ render(React.createElement(HookCaller));
581
+
582
+ const mem: Record<string, any> = {};
583
+ const fakeForm = {
584
+ setFieldsValue: (values: Record<string, any>) => Object.assign(mem, values),
585
+ getFieldsValue: () => ({ ...mem }),
586
+ getFieldValue: (namePath: any) => getByPath(mem, namePath),
587
+ setFieldValue: (key: string, value: any) => (mem[key] = value),
588
+ };
589
+ (model.context as any).defineProperty('form', { value: fakeForm });
590
+ fakeForm.setFieldsValue({ customer: { id: 9, level: 'level-1' } });
591
+ mockFormGridEnabledFields(model, ['customer']);
592
+
593
+ const output = await (model.context as any).resolveJsonTemplate({
594
+ level: '{{ ctx.formValues.customer.level }}',
595
+ });
596
+
597
+ expect(api.request).toHaveBeenCalledTimes(1);
598
+ expect(output).toEqual({ level: { id: 'level-1', name: 'Level 1' } });
599
+ });
600
+
601
+ it('resolves a configured nested association when its local record does not include the title field', async () => {
602
+ const model = await setupFormModel();
603
+ const api = {
604
+ request: vi.fn(async (config: any) => {
605
+ const item = config?.data?.values?.batch?.[0] || {};
606
+ return {
607
+ data: {
608
+ data: {
609
+ results: [{ id: item.id, data: { level: { id: 'level-1', name: 'Level 1' } } }],
610
+ },
611
+ },
612
+ } as any;
613
+ }),
614
+ } as any;
615
+ (model.flowEngine.context as any).defineProperty('api', { value: api });
616
+
617
+ function HookCaller() {
618
+ model.useHooksBeforeRender();
619
+ return null;
620
+ }
621
+ render(React.createElement(HookCaller));
622
+
623
+ const mem: Record<string, any> = {};
624
+ const fakeForm = {
625
+ setFieldsValue: (values: Record<string, any>) => Object.assign(mem, values),
626
+ getFieldsValue: () => ({ ...mem }),
627
+ getFieldValue: (namePath: any) => getByPath(mem, namePath),
628
+ setFieldValue: (key: string, value: any) => (mem[key] = value),
629
+ };
630
+ (model.context as any).defineProperty('form', { value: fakeForm });
631
+ fakeForm.setFieldsValue({ customer: { id: 9, level: { id: 'level-1' } } });
632
+ mockFormGridEnabledFields(model, ['customer']);
633
+
634
+ const output = await (model.context as any).resolveJsonTemplate({
635
+ level: '{{ ctx.formValues.customer.level }}',
636
+ });
637
+
638
+ expect(api.request).toHaveBeenCalledTimes(1);
639
+ expect(output).toEqual({ level: { id: 'level-1', name: 'Level 1' } });
640
+ });
641
+
642
+ it('uses a configured nested association locally when its record is already loaded', async () => {
643
+ const model = await setupFormModel();
644
+ const api = { request: vi.fn(async () => ({ data: {} }) as any) } as any;
645
+ (model.flowEngine.context as any).defineProperty('api', { value: api });
646
+
647
+ function HookCaller() {
648
+ model.useHooksBeforeRender();
649
+ return null;
650
+ }
651
+ render(React.createElement(HookCaller));
652
+
653
+ const mem: Record<string, any> = {};
654
+ const fakeForm = {
655
+ setFieldsValue: (values: Record<string, any>) => Object.assign(mem, values),
656
+ getFieldsValue: () => ({ ...mem }),
657
+ getFieldValue: (namePath: any) => getByPath(mem, namePath),
658
+ setFieldValue: (key: string, value: any) => (mem[key] = value),
659
+ };
660
+ (model.context as any).defineProperty('form', { value: fakeForm });
661
+ const level = { id: 'level-1', name: 'Level 1' };
662
+ fakeForm.setFieldsValue({ customer: { id: 9, level } });
663
+ mockFormGridEnabledFields(model, ['customer']);
664
+
665
+ const output = await (model.context as any).resolveJsonTemplate({
666
+ level: '{{ ctx.formValues.customer.level }}',
667
+ });
668
+
669
+ expect(output).toEqual({ level });
670
+ expect(api.request).not.toHaveBeenCalled();
671
+ });
672
+
673
+ it('keeps indexed paths local when the nested association record is already loaded', async () => {
674
+ const model = await setupFormModel();
675
+
676
+ function HookCaller() {
677
+ model.useHooksBeforeRender();
678
+ return null;
679
+ }
680
+ render(React.createElement(HookCaller));
681
+
682
+ const mem: Record<string, any> = {};
683
+ const fakeForm = {
684
+ setFieldsValue: (values: Record<string, any>) => Object.assign(mem, values),
685
+ getFieldsValue: () => ({ ...mem }),
686
+ getFieldValue: (namePath: any) => getByPath(mem, namePath),
687
+ setFieldValue: (key: string, value: any) => (mem[key] = value),
688
+ };
689
+ (model.context as any).defineProperty('form', { value: fakeForm });
690
+ fakeForm.setFieldsValue({ assignees: [{ id: 3, org: { id: 1, name: 'Org 1' } }] });
691
+ mockFormGridEnabledFields(model, ['assignees']);
692
+
693
+ const options = (model.context as any).getPropertyOptions('formValues');
694
+ expect(options.resolveOnServer('assignees[0].org.name')).toBe(false);
695
+ });
696
+
552
697
  it('configured toMany dot aggregation path uses local value and skips server', async () => {
553
698
  const model = await setupFormModel();
554
699
 
@@ -9,7 +9,11 @@
9
9
 
10
10
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
11
  import { FlowEngine, FlowModel, SingleRecordResource } from '@nocobase/flow-engine';
12
+ import '../../../index';
12
13
  import { QuickEditFormModel } from '../QuickEditFormModel';
14
+ import { dataScope } from '../../../../actions/dataScope';
15
+ import { RecordSelectFieldModel } from '../../../fields/AssociationFieldModel/RecordSelectFieldModel';
16
+ import { SelectFieldModel } from '../../../fields/SelectFieldModel';
13
17
 
14
18
  describe('QuickEditFormModel - quick edit save triggers API (regression)', () => {
15
19
  let engine: FlowEngine;
@@ -55,6 +59,54 @@ describe('QuickEditFormModel - quick edit save triggers API (regression)', () =>
55
59
  });
56
60
  };
57
61
 
62
+ const addMemberCollections = () => {
63
+ const ds = engine.context.dataSourceManager.getDataSource('main');
64
+ ds.addCollection({
65
+ name: 'departments',
66
+ filterTargetKey: 'id',
67
+ titleField: 'name',
68
+ fields: [
69
+ { name: 'id', type: 'integer', interface: 'number' },
70
+ { name: 'name', type: 'string', interface: 'input', uiSchema: { title: 'Name' } },
71
+ { name: 'level', type: 'string', interface: 'input', uiSchema: { title: 'Level' } },
72
+ ],
73
+ });
74
+ ds.addCollection({
75
+ name: 'members',
76
+ filterTargetKey: 'id',
77
+ fields: [
78
+ { name: 'id', type: 'integer', interface: 'number' },
79
+ { name: 'level', type: 'string', interface: 'input', uiSchema: { title: 'Level' } },
80
+ {
81
+ name: 'department',
82
+ type: 'belongsTo',
83
+ interface: 'm2o',
84
+ target: 'departments',
85
+ targetKey: 'id',
86
+ uiSchema: { title: 'Department' },
87
+ },
88
+ ],
89
+ });
90
+ };
91
+
92
+ const openQuickEditForAssociation = async (dataScopeFilter?: Record<string, unknown>) => {
93
+ engine.registerModels({ QuickEditFormModel, RecordSelectFieldModel });
94
+ engine.registerActions({ dataScope });
95
+ addMemberCollections();
96
+ const model = engine.createModel<QuickEditFormModel>({
97
+ use: QuickEditFormModel,
98
+ uid: `qe-association-${dataScopeFilter ? 'scoped' : 'plain'}`,
99
+ stepParams: {
100
+ quickEditFormSettings: {
101
+ init: { dataSourceKey: 'main', collectionName: 'members', fieldPath: 'department' },
102
+ },
103
+ },
104
+ });
105
+ model._dataScopeFilter = dataScopeFilter;
106
+ await model.applyFlow('quickEditFormSettings', { filterByTk: 7, record: { id: 7, level: 'senior' } });
107
+ return model;
108
+ };
109
+
58
110
  it('uses source field context when opening quick edit', async () => {
59
111
  engine.registerModels({ QuickEditFormModel });
60
112
  engine.context.defineProperty('pageActive', { value: { value: false } });
@@ -414,4 +466,72 @@ describe('QuickEditFormModel - quick edit save triggers API (regression)', () =>
414
466
 
415
467
  expect(api.request).toHaveBeenCalledTimes(1);
416
468
  });
469
+
470
+ it('opens dropdown editors upwards in the desktop popover so they do not cover the actions', async () => {
471
+ engine.registerModels({ QuickEditFormModel, SelectFieldModel });
472
+ const ds = engine.context.dataSourceManager.getDataSource('main');
473
+ ds.addCollection({
474
+ name: 'users',
475
+ fields: [
476
+ {
477
+ name: 'tags',
478
+ type: 'array',
479
+ interface: 'multipleSelect',
480
+ uiSchema: { title: 'Tags', enum: [{ label: 'A', value: 'a' }] },
481
+ },
482
+ ],
483
+ });
484
+
485
+ const desktopModel = engine.createModel<QuickEditFormModel>({
486
+ use: QuickEditFormModel,
487
+ uid: 'qe-desktop',
488
+ stepParams: {
489
+ quickEditFormSettings: {
490
+ init: { dataSourceKey: 'main', collectionName: 'users', fieldPath: 'tags' },
491
+ },
492
+ },
493
+ });
494
+ await desktopModel.applyFlow('quickEditFormSettings', { record: {} });
495
+ const desktopField = desktopModel.subModels.fields[0];
496
+ expect(desktopField.use).toBe('SelectFieldModel');
497
+ expect(desktopField.props.placement).toBe('topLeft');
498
+
499
+ const mobileModel = engine.createModel<QuickEditFormModel>({
500
+ use: QuickEditFormModel,
501
+ uid: 'qe-mobile',
502
+ stepParams: {
503
+ quickEditFormSettings: {
504
+ init: { dataSourceKey: 'main', collectionName: 'users', fieldPath: 'tags' },
505
+ },
506
+ },
507
+ });
508
+ mobileModel.context.defineProperty('isMobileLayout', { value: true });
509
+ await mobileModel.applyFlow('quickEditFormSettings', { record: {} });
510
+ expect(mobileModel.subModels.fields[0].props.placement).toBeUndefined();
511
+ });
512
+
513
+ it('hands the column data scope to the association editor built for the popover', async () => {
514
+ const filter = { logic: '$and', items: [{ path: 'level', operator: 'eq', value: '{{ctx.record.level}}' }] };
515
+
516
+ const model = await openQuickEditForAssociation(filter);
517
+
518
+ const fieldModel = model.subModels.fields[0];
519
+ expect(fieldModel.use).toBe('RecordSelectFieldModel');
520
+ expect(fieldModel.getStepParams('selectSettings', 'dataScope')).toEqual({ filter });
521
+ });
522
+
523
+ it('leaves the association editor unscoped when the column has no data scope', async () => {
524
+ const model = await openQuickEditForAssociation();
525
+
526
+ expect(model.subModels.fields[0].getStepParams('selectSettings', 'dataScope')).toBeUndefined();
527
+ });
528
+
529
+ it('seeds the edited row before the editor flows run so record variables resolve', async () => {
530
+ const filter = { logic: '$and', items: [{ path: 'level', operator: 'eq', value: '{{ctx.record.level}}' }] };
531
+
532
+ const model = await openQuickEditForAssociation(filter);
533
+
534
+ expect(model.resource.getData()).toMatchObject({ id: 7, level: 'senior' });
535
+ expect(JSON.stringify(model.subModels.fields[0].resource.getFilter() ?? null)).toContain('senior');
536
+ });
417
537
  });