@nocobase/client-v2 2.3.0-alpha.1 → 2.3.0-beta.11

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 (173) hide show
  1. package/es/APIClient.d.ts +12 -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/actions/linkageRules.d.ts +25 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +6 -30
  6. package/es/flow/components/FlowRoute.d.ts +2 -2
  7. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  8. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +32 -0
  9. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  10. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  11. package/es/flow/components/filter/VariableFilterItem.d.ts +7 -0
  12. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  13. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  14. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  15. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  16. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  17. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  18. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  19. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  20. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  21. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  22. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  23. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  24. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  25. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  26. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  27. package/es/flow/models/blocks/table/TableColumnModel.d.ts +12 -0
  28. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  29. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  30. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  31. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  32. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  33. package/es/flow-compat/passwordUtils.d.ts +1 -1
  34. package/es/index.d.ts +1 -0
  35. package/es/index.mjs +148 -145
  36. package/es/ui-operation/index.d.ts +15 -0
  37. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  38. package/lib/index.js +170 -167
  39. package/lib/locale/languageCodes.js +1 -0
  40. package/package.json +10 -8
  41. package/src/APIClient.ts +92 -0
  42. package/src/Application.tsx +3 -1
  43. package/src/__tests__/APIClient.test.tsx +58 -0
  44. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
  45. package/src/__tests__/settings-center.test.tsx +270 -6
  46. package/src/acl/ACLProvider.tsx +2 -0
  47. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  48. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  49. package/src/collection-manager/field-configure.ts +2 -2
  50. package/src/components/form/ScanInput/CodeScanner.tsx +64 -35
  51. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +107 -0
  52. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +294 -24
  53. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  54. package/src/components/form/ScanInput/useCodeScanner.ts +281 -23
  55. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  56. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  57. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  58. package/src/components/form/table/Table.tsx +81 -6
  59. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  60. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  61. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  62. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  63. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  64. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  65. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  66. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  67. package/src/flow/actions/linkageRules.tsx +59 -16
  68. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  69. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  70. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  71. package/src/flow/common/Markdown/style.ts +4 -0
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/FieldAssignValueInput.tsx +42 -621
  74. package/src/flow/components/FlowRoute.tsx +71 -29
  75. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  76. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +335 -0
  77. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +389 -0
  78. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  79. package/src/flow/components/field-value-variable/index.ts +12 -0
  80. package/src/flow/components/filter/VariableFilterItem.tsx +23 -6
  81. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  82. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  83. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  84. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  85. package/src/flow/index.ts +2 -0
  86. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  87. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  88. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  89. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  90. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  91. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  92. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  93. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  94. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  95. package/src/flow/models/base/CollectionBlockModel.tsx +38 -3
  96. package/src/flow/models/base/GridModel.tsx +0 -1
  97. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  98. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  99. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  100. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  101. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  102. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  103. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  104. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +36 -50
  105. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  106. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  107. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  108. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  109. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  110. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  111. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  112. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  113. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  114. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  115. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  116. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  117. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  118. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  119. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  120. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  121. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  122. package/src/flow/models/blocks/form/FormBlockModel.tsx +73 -5
  123. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  124. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +153 -2
  125. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  126. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  127. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  128. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  129. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  130. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  131. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  132. package/src/flow/models/blocks/table/TableBlockModel.tsx +28 -2
  133. package/src/flow/models/blocks/table/TableColumnModel.tsx +48 -9
  134. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  135. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  136. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  137. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  138. package/src/flow/models/blocks/table/utils.ts +7 -0
  139. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  140. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +12 -3
  141. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  142. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  143. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +29 -3
  144. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  145. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +5 -1
  146. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +191 -3
  147. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  148. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  149. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  150. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  151. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  152. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  153. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  154. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  155. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  156. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  157. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  158. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  159. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  160. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  161. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  162. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  163. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  164. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
  165. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  166. package/src/index.ts +1 -0
  167. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  168. package/src/layout-manager/LayoutRoute.tsx +2 -1
  169. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  170. package/src/locale/languageCodes.ts +1 -0
  171. package/src/settings-center/AdminSettingsLayout.tsx +23 -2
  172. package/src/ui-operation/index.ts +33 -0
  173. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -17,14 +17,16 @@ import {
17
17
  observer,
18
18
  } from '@nocobase/flow-engine';
19
19
  import { Table } from 'antd';
20
+ import type { TableProps } from 'antd';
20
21
  import classNames from 'classnames';
21
22
  import { DragEndEvent } from '@dnd-kit/core';
22
23
  import { css } from '@emotion/css';
23
- import { isEmpty } from 'lodash';
24
+ import { get, isEmpty, orderBy } from 'lodash';
24
25
  import React, { useEffect, useMemo, useState, useCallback } from 'react';
25
26
  import { useTranslation } from 'react-i18next';
26
27
  import { FieldModel } from '../../base';
27
28
  import { DetailsItemModel } from '../../blocks/details/DetailsItemModel';
29
+ import { FormAssociationItemModel } from '../../blocks/form/FormAssociationItemModel';
28
30
  import { adjustColumnOrder } from '../../blocks/table/utils';
29
31
 
30
32
  const HeaderWrapperComponent = React.memo((props) => {
@@ -65,11 +67,31 @@ const AddFieldColumn = ({ model }) => {
65
67
  };
66
68
 
67
69
  const DisplayTable = (props) => {
68
- const { pageSize, value, size, collection, baseColumns, enableIndexColumn = true, model } = props;
70
+ const { pageSize, value: rawValue, size, collection, baseColumns, enableIndexColumn = true, model } = props;
71
+ const isFormAssociation = model.parent instanceof FormAssociationItemModel;
69
72
  const [currentPage, setCurrentPage] = useState(1);
70
73
  const [currentPageSize, setCurrentPageSize] = useState(pageSize);
74
+ const [localSort, setLocalSort] = useState<{ field: string; order: 'asc' | 'desc' }>();
71
75
  const { t } = useTranslation();
72
76
 
77
+ const value = useMemo(() => {
78
+ if (!isFormAssociation || Array.isArray(rawValue)) return rawValue;
79
+ if (rawValue && Array.isArray(rawValue.rows)) return rawValue.rows;
80
+ return rawValue && typeof rawValue === 'object' ? [rawValue] : [];
81
+ }, [isFormAssociation, rawValue]);
82
+
83
+ const sortedValue = useMemo(
84
+ () =>
85
+ isFormAssociation && localSort
86
+ ? orderBy(value, [(record) => get(record, localSort.field)], [localSort.order])
87
+ : value,
88
+ [isFormAssociation, localSort, value],
89
+ );
90
+
91
+ useEffect(() => {
92
+ if (isFormAssociation) setCurrentPage(1);
93
+ }, [isFormAssociation, rawValue]);
94
+
73
95
  useEffect(() => {
74
96
  setCurrentPageSize(pageSize);
75
97
  }, [pageSize]);
@@ -89,7 +111,7 @@ const DisplayTable = (props) => {
89
111
  return t('Total {{count}} items', { count: total });
90
112
  },
91
113
  } as any;
92
- }, [currentPage, currentPageSize, value]);
114
+ }, [currentPage, currentPageSize, value, t]);
93
115
 
94
116
  const getColumns = () => {
95
117
  const cols = adjustColumnOrder(
@@ -119,8 +141,20 @@ const DisplayTable = (props) => {
119
141
  }
120
142
  return cols;
121
143
  };
122
- const handleChange = useCallback(
123
- async (pagination, filters, sorter) => {
144
+ const handleChange = useCallback<NonNullable<TableProps<Record<string, unknown>>['onChange']>>(
145
+ async (pagination, filters, sorters, extra) => {
146
+ const sorter = Array.isArray(sorters) ? sorters[0] : sorters;
147
+ if (isFormAssociation) {
148
+ if (extra.action !== 'sort') return;
149
+ const column = sorter?.column as { sortField?: string } | undefined;
150
+ const sortField = column?.sortField || sorter?.field;
151
+ const fullPath = Array.isArray(sortField) ? sortField.join('.') : String(sortField ?? '');
152
+ const prefix = `${model.context.fieldPath}.`;
153
+ const field = fullPath.startsWith(prefix) ? fullPath.slice(prefix.length) : fullPath;
154
+ setLocalSort(sorter?.order && field ? { field, order: sorter.order === 'ascend' ? 'asc' : 'desc' } : undefined);
155
+ setCurrentPage(1);
156
+ return;
157
+ }
124
158
  //支持列点击排序
125
159
  if (!isEmpty(sorter)) {
126
160
  const resource = model.context.blockModel.resource;
@@ -138,7 +172,7 @@ const DisplayTable = (props) => {
138
172
  await resource.refresh();
139
173
  }
140
174
  },
141
- [model],
175
+ [isFormAssociation, model],
142
176
  );
143
177
 
144
178
  return (
@@ -147,7 +181,7 @@ const DisplayTable = (props) => {
147
181
  size={size}
148
182
  rowKey={collection.filterTargetKey}
149
183
  scroll={{ x: 'max-content' }}
150
- dataSource={value}
184
+ dataSource={sortedValue}
151
185
  columns={getColumns()}
152
186
  pagination={pagination}
153
187
  onChange={handleChange}
@@ -269,3 +303,4 @@ DisplaySubTableFieldModel.define({
269
303
  });
270
304
 
271
305
  DetailsItemModel.bindModelToInterface('DisplaySubTableFieldModel', ['m2m', 'o2m', 'mbm']);
306
+ FormAssociationItemModel.bindModelToInterface('DisplaySubTableFieldModel', ['m2m', 'o2m', 'mbm']);
@@ -0,0 +1,351 @@
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 { Form, type FormInstance, type TableProps } from 'antd';
12
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
13
+ import { DisplayItemModel, FlowEngine, FlowEngineProvider, FlowModelProvider } from '@nocobase/flow-engine';
14
+ import { afterEach, describe, expect, it, vi } from 'vitest';
15
+ import {
16
+ DetailsItemModel,
17
+ DisplaySubTableFieldModel,
18
+ DisplayTextFieldModel,
19
+ FormAssociationItemModel,
20
+ FormItemModel,
21
+ TableColumnModel,
22
+ aclCheck,
23
+ displayFieldComponent,
24
+ fixed,
25
+ overflowMode,
26
+ titleField,
27
+ } from '../../../../index';
28
+ import { buildAssociationOptions } from '../../../../actions/displayFieldComponent';
29
+ import { rebuildFieldSubModel } from '../../../../internal/utils/rebuildFieldSubModel';
30
+
31
+ type Row = Record<string, unknown>;
32
+ type TableOptions = TableProps<Row>;
33
+
34
+ const { tableRender } = vi.hoisted(() => ({ tableRender: vi.fn() }));
35
+
36
+ vi.mock('react-i18next', async (importOriginal) => ({
37
+ ...(await importOriginal<typeof import('react-i18next')>()),
38
+ useTranslation: () => ({ t: (value: string) => value }),
39
+ }));
40
+
41
+ vi.mock('antd', async (importOriginal) => ({
42
+ ...(await importOriginal<typeof import('antd')>()),
43
+ Table: (props: TableOptions) => {
44
+ tableRender(props);
45
+ return <div data-testid="rows">{JSON.stringify(props.dataSource)}</div>;
46
+ },
47
+ }));
48
+
49
+ afterEach(() => {
50
+ cleanup();
51
+ vi.restoreAllMocks();
52
+ tableRender.mockClear();
53
+ });
54
+
55
+ function setup(parentUse = 'FormAssociationItemModel') {
56
+ const engine = new FlowEngine();
57
+ engine.registerActions({ aclCheck, displayFieldComponent, fixed, overflowMode, titleField });
58
+ engine.registerModels({
59
+ DetailsItemModel,
60
+ DisplaySubTableFieldModel,
61
+ DisplayTextFieldModel,
62
+ FormAssociationItemModel,
63
+ FormItemModel,
64
+ TableColumnModel,
65
+ });
66
+ const dataSource = engine.dataSourceManager.getDataSource('main');
67
+ dataSource.addCollection({
68
+ name: 'users',
69
+ filterTargetKey: 'id',
70
+ titleField: 'name',
71
+ fields: [
72
+ { name: 'id', type: 'integer', interface: 'integer' },
73
+ { name: 'name', type: 'string', interface: 'input' },
74
+ ],
75
+ });
76
+ dataSource.addCollection({
77
+ name: 'orgs',
78
+ fields: [
79
+ { name: 'staff', type: 'belongsToMany', interface: 'm2m', target: 'users' },
80
+ { name: 'reports', type: 'hasMany', interface: 'o2m', target: 'users' },
81
+ { name: 'members', type: 'belongsToArray', interface: 'mbm', target: 'users' },
82
+ { name: 'manager', type: 'belongsTo', interface: 'm2o', target: 'users' },
83
+ { name: 'name', type: 'string', interface: 'input' },
84
+ ],
85
+ });
86
+ dataSource.addCollection({
87
+ name: 'posts',
88
+ fields: [{ name: 'org', type: 'belongsTo', interface: 'm2o', target: 'orgs' }],
89
+ });
90
+ const parent = engine.createModel<FormAssociationItemModel>({
91
+ use: parentUse,
92
+ stepParams: {
93
+ fieldSettings: {
94
+ init: { dataSourceKey: 'main', collectionName: 'posts', fieldPath: 'org.staff' },
95
+ },
96
+ },
97
+ });
98
+ const resource = { getAppends: vi.fn(() => ['staff']), setAppends: vi.fn(), refresh: vi.fn() };
99
+ parent.context.defineProperty('blockModel', {
100
+ value: { collection: dataSource.getCollection('posts'), resource, addAppends: vi.fn() },
101
+ });
102
+ parent.context.defineProperty('flowSettingsEnabled', { value: false });
103
+ parent.context.defineProperty('aclCheck', { value: vi.fn().mockResolvedValue(true) });
104
+ const field = parent.setSubModel('field', { use: 'DisplaySubTableFieldModel', props: { pageSize: 10 } });
105
+ return { engine, parent, field: field as DisplaySubTableFieldModel, resource, dataSource };
106
+ }
107
+
108
+ function tableProps(): TableOptions {
109
+ return tableRender.mock.lastCall[0] as TableOptions;
110
+ }
111
+
112
+ describe('DisplaySubTableFieldModel in association display fields', () => {
113
+ it.each(['staff', 'reports', 'members'])('offers a subtable for the to-many field %s', (fieldName) => {
114
+ const { parent, dataSource } = setup();
115
+ const collectionField = dataSource.getCollection('orgs').getField(fieldName);
116
+ const bindings = FormAssociationItemModel.getBindingsByField(parent.context, collectionField);
117
+ expect(bindings.map((binding) => binding.modelName)).toContain('DisplaySubTableFieldModel');
118
+ expect(buildAssociationOptions(parent.context, FormAssociationItemModel)).toContainEqual({
119
+ label: expect.any(String),
120
+ value: 'DisplaySubTableFieldModel',
121
+ });
122
+ });
123
+
124
+ it('keeps the binding scoped and preserves the default title-field display', () => {
125
+ const { parent, dataSource } = setup();
126
+ const collection = dataSource.getCollection('orgs');
127
+ for (const itemModel of [DisplayItemModel, TableColumnModel, FormItemModel]) {
128
+ expect(itemModel.getBindingsByField(parent.context, collection.getField('staff'))).not.toEqual(
129
+ expect.arrayContaining([expect.objectContaining({ modelName: 'DisplaySubTableFieldModel' })]),
130
+ );
131
+ }
132
+ for (const fieldName of ['manager', 'name']) {
133
+ expect(FormAssociationItemModel.getBindingsByField(parent.context, collection.getField(fieldName))).not.toEqual(
134
+ expect.arrayContaining([expect.objectContaining({ modelName: 'DisplaySubTableFieldModel' })]),
135
+ );
136
+ }
137
+ expect(DetailsItemModel.getBindingsByField(parent.context, collection.getField('staff'))).toEqual(
138
+ expect.arrayContaining([expect.objectContaining({ modelName: 'DisplaySubTableFieldModel' })]),
139
+ );
140
+ expect(
141
+ FormAssociationItemModel.getDefaultBindingByField(parent.context, collection.getField('staff'), {
142
+ fallbackToTargetTitleField: true,
143
+ })?.modelName,
144
+ ).toBe('DisplayTextFieldModel');
145
+ });
146
+
147
+ it.each([undefined, null, [], { rows: [] }])('renders an empty table for %j', (value) => {
148
+ const { field } = setup();
149
+ field.setProps({ value });
150
+ render(field.render());
151
+ expect(tableProps().dataSource).toEqual([]);
152
+ });
153
+
154
+ it('updates rows and resets pagination when the association changes or is cleared', async () => {
155
+ const { field, resource } = setup();
156
+ field.setProps({ value: [{ id: 1, name: 'Alice' }] });
157
+ const view = render(field.render());
158
+ await act(async () => {
159
+ const pagination = tableProps().pagination;
160
+ if (pagination) pagination.onChange?.(3, 10);
161
+ });
162
+ expect(tableProps().pagination).toMatchObject({ current: 3 });
163
+ act(() => {
164
+ field.setProps({ value: { rows: [{ id: 2, name: 'Bob' }] } });
165
+ view.rerender(field.render());
166
+ });
167
+ expect(screen.getByTestId('rows').textContent).toContain('Bob');
168
+ expect(screen.getByTestId('rows').textContent).not.toContain('Alice');
169
+ expect(tableProps().pagination).toMatchObject({ current: 1 });
170
+ act(() => {
171
+ field.setProps({ value: null });
172
+ view.rerender(field.render());
173
+ });
174
+ expect(tableProps().dataSource).toEqual([]);
175
+ expect(resource.refresh).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it('sorts nested columns locally without mutating form values or refreshing the form resource', async () => {
179
+ const { field, resource } = setup();
180
+ const rows = [
181
+ { id: 1, name: 'Bob' },
182
+ { id: 2, name: 'Alice' },
183
+ ];
184
+ const original = structuredClone(rows);
185
+ field.setProps({ value: rows });
186
+ render(field.render());
187
+ await act(async () => {
188
+ await tableProps().onChange?.(
189
+ {},
190
+ {},
191
+ { field: 'org.staff.name', order: 'ascend' },
192
+ {
193
+ action: 'sort',
194
+ currentDataSource: rows,
195
+ },
196
+ );
197
+ });
198
+ expect(tableProps().dataSource).toEqual([rows[1], rows[0]]);
199
+ await act(async () => {
200
+ await tableProps().onChange?.(
201
+ {},
202
+ {},
203
+ { field: 'org.staff.name', order: 'descend' },
204
+ {
205
+ action: 'sort',
206
+ currentDataSource: rows,
207
+ },
208
+ );
209
+ });
210
+ expect(tableProps().dataSource).toEqual(rows);
211
+ await act(async () => {
212
+ await tableProps().onChange?.(
213
+ {},
214
+ {},
215
+ { field: 'org.staff.name' },
216
+ {
217
+ action: 'sort',
218
+ currentDataSource: rows,
219
+ },
220
+ );
221
+ });
222
+ expect(tableProps().dataSource).toEqual(rows);
223
+ expect(rows).toEqual(original);
224
+ expect(resource.setAppends).not.toHaveBeenCalled();
225
+ expect(resource.refresh).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it('retains resource-based sorting in details fields', async () => {
229
+ const { field, resource } = setup('DetailsItemModel');
230
+ field.setProps({ value: [{ id: 1 }] });
231
+ render(field.render());
232
+ await act(async () => {
233
+ await tableProps().onChange?.(
234
+ {},
235
+ {},
236
+ { field: 'name', order: 'ascend' },
237
+ {
238
+ action: 'sort',
239
+ currentDataSource: [],
240
+ },
241
+ );
242
+ });
243
+ expect(resource.setAppends).toHaveBeenCalledWith(['staff(sort=name)']);
244
+ expect(resource.refresh).toHaveBeenCalledOnce();
245
+ });
246
+
247
+ it('uses the configured sort field and keeps local ordering when paginating', async () => {
248
+ const { field, resource } = setup();
249
+ const rows = [{ id: 10 }, { id: 2 }];
250
+ field.setProps({ value: rows });
251
+ render(field.render());
252
+ const column: NonNullable<TableOptions['columns']>[number] & { sortField: string } = {
253
+ sortField: 'org.staff.id',
254
+ };
255
+ await act(async () => {
256
+ await tableProps().onChange?.(
257
+ {},
258
+ {},
259
+ { field: 'other', column, order: 'ascend' },
260
+ {
261
+ action: 'sort',
262
+ currentDataSource: rows,
263
+ },
264
+ );
265
+ });
266
+ expect(tableProps().dataSource).toEqual([rows[1], rows[0]]);
267
+ await act(async () => {
268
+ await tableProps().onChange?.(
269
+ { current: 2 },
270
+ {},
271
+ {},
272
+ {
273
+ action: 'paginate',
274
+ currentDataSource: rows,
275
+ },
276
+ );
277
+ });
278
+ expect(tableProps().dataSource).toEqual([rows[1], rows[0]]);
279
+ expect(resource.refresh).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it('follows form association changes without writing displayed records into the form', async () => {
283
+ vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
284
+ matches: false,
285
+ media: query,
286
+ onchange: null,
287
+ addListener: vi.fn(),
288
+ removeListener: vi.fn(),
289
+ addEventListener: vi.fn(),
290
+ removeEventListener: vi.fn(),
291
+ dispatchEvent: vi.fn(),
292
+ }));
293
+ const { engine, parent, resource } = setup();
294
+ let form: FormInstance;
295
+ const firstOrg = { id: 1, staff: [{ id: 1, name: 'Alice' }] };
296
+ const secondOrg = { id: 2, staff: [{ id: 2, name: 'Bob' }] };
297
+ function TestForm() {
298
+ const [formInstance] = Form.useForm();
299
+ form = formInstance;
300
+ parent.context.defineProperty('form', { value: formInstance });
301
+ parent.context.defineProperty('formValues', { get: () => formInstance.getFieldsValue(true), cache: false });
302
+ return (
303
+ <Form form={formInstance} initialValues={{ org: firstOrg, note: 'unsaved' }}>
304
+ <FlowModelProvider model={parent}>{parent.renderItem()}</FlowModelProvider>
305
+ </Form>
306
+ );
307
+ }
308
+ render(
309
+ <FlowEngineProvider engine={engine}>
310
+ <TestForm />
311
+ </FlowEngineProvider>,
312
+ );
313
+ await waitFor(() => expect(screen.getByTestId('rows').textContent).toContain('Alice'));
314
+ act(() => form.setFieldValue('org', secondOrg));
315
+ await waitFor(() => expect(screen.getByTestId('rows').textContent).toContain('Bob'));
316
+ expect(form.getFieldsValue(true)).toEqual({ org: secondOrg, note: 'unsaved' });
317
+ act(() => form.setFieldValue('org', null));
318
+ await waitFor(() => expect(tableProps().dataSource).toEqual([]));
319
+ expect(form.getFieldsValue(true)).toEqual({ org: null, note: 'unsaved' });
320
+ expect(resource.refresh).not.toHaveBeenCalled();
321
+ });
322
+
323
+ it('keeps nested column paths and column configuration when switching components', async () => {
324
+ const { parent, field } = setup();
325
+ vi.spyOn(parent, 'save').mockResolvedValue(undefined);
326
+ const column = field.addSubModel('columns', {
327
+ use: 'TableColumnModel',
328
+ stepParams: {
329
+ fieldSettings: {
330
+ init: { dataSourceKey: 'main', collectionName: 'posts', fieldPath: 'org.staff.name' },
331
+ },
332
+ },
333
+ subModels: { field: { use: 'DisplayTextFieldModel' } },
334
+ });
335
+ expect(field.context.prefixFieldPath).toBe('org.staff');
336
+ expect(field.collection.name).toBe('users');
337
+ const columnField = column.subModels.field as DisplayTextFieldModel;
338
+ const createFork = vi.spyOn(columnField, 'createFork');
339
+ (column as TableColumnModel).renderItem()(undefined, { id: 1, name: 'Alice' }, 0);
340
+ expect(createFork.mock.results[0].value.props.value).toBe('Alice');
341
+ expect(createFork.mock.results[0].value.context.record).toEqual({ id: 1, name: 'Alice' });
342
+ await rebuildFieldSubModel({ parentModel: parent, targetUse: 'DisplayTextFieldModel' });
343
+ await rebuildFieldSubModel({ parentModel: parent, targetUse: 'DisplaySubTableFieldModel' });
344
+ const rebuilt = parent.subModels.field as DisplaySubTableFieldModel;
345
+ expect(rebuilt.uid).toBe(field.uid);
346
+ expect(rebuilt.serialize().subModels.columns[0]).toMatchObject({
347
+ uid: column.uid,
348
+ stepParams: { fieldSettings: { init: { fieldPath: 'org.staff.name' } } },
349
+ });
350
+ });
351
+ });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { DisplayItemModel, tExpr } from '@nocobase/flow-engine';
11
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
11
12
  import React from 'react';
12
13
  import { DisplayMarkdown } from '../../internal/components/Markdown/DisplayMarkdown';
13
14
  import { DisplayTitleFieldModel } from './DisplayTitleFieldModel';
@@ -15,7 +16,7 @@ import { DisplayTitleFieldModel } from './DisplayTitleFieldModel';
15
16
  export class DisplayHtmlFieldModel extends DisplayTitleFieldModel {
16
17
  public renderComponent(value) {
17
18
  const { textOnly = true } = this.props;
18
- return <DisplayMarkdown {...this.props} textOnly={textOnly} value={value} />;
19
+ return <DisplayMarkdown {...this.props} sanitizeHtml={sanitizeRichTextHtml} textOnly={textOnly} value={value} />;
19
20
  }
20
21
  }
21
22
 
@@ -71,7 +71,7 @@ function formatBigNumberWithSeparator(value, format = '0.00', step = 1, formatSt
71
71
  let number = value;
72
72
 
73
73
  if (formatStyle) {
74
- number = new BigNumber(value).toString();
74
+ number = new BigNumber(value).toFixed();
75
75
  }
76
76
 
77
77
  let formattedNumber = '';
@@ -13,6 +13,22 @@ import JSON5 from 'json5';
13
13
  import React, { useState, useEffect, useRef } from 'react';
14
14
  import { FieldModel } from '../base/FieldModel';
15
15
 
16
+ const jsonValidationRuleMarker = '__jsonSyntaxValidation';
17
+
18
+ interface JsonValidationRule {
19
+ [jsonValidationRuleMarker]: true;
20
+ type: 'any';
21
+ validator?: (_rule: unknown, value: unknown) => Promise<void>;
22
+ }
23
+
24
+ function isRuleObject(rule: unknown): rule is Record<string, unknown> {
25
+ return typeof rule === 'object' && rule !== null;
26
+ }
27
+
28
+ function isJsonValidationRule(rule: unknown): rule is JsonValidationRule {
29
+ return isRuleObject(rule) && rule[jsonValidationRuleMarker] === true;
30
+ }
31
+
16
32
  const jsonCss = css`
17
33
  font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
18
34
  font-size: 14px;
@@ -40,7 +56,7 @@ export const JsonInput = observer((props: any) => {
40
56
  }
41
57
 
42
58
  internalChange.current = false;
43
- }, [value]);
59
+ }, [_JSON, space, value]);
44
60
 
45
61
  // 用户输入时
46
62
  const handleChange = (ev) => {
@@ -91,23 +107,30 @@ JsonFieldModel.registerFlow({
91
107
  key: 'jsonInitSetting',
92
108
  steps: {
93
109
  initValidation: {
94
- handler(ctx, params) {
95
- const rules = ctx.model.parent.props.rules || [];
110
+ handler(ctx) {
111
+ const currentRules = Array.isArray(ctx.model.parent.props.rules) ? ctx.model.parent.props.rules : [];
112
+ const rules = currentRules.filter((rule: unknown) => {
113
+ return (!isRuleObject(rule) || Object.keys(rule).length > 0) && !isJsonValidationRule(rule);
114
+ });
96
115
  // 添加 JSON 语法校验规则
97
116
  rules.push({
98
- validator: (_, value) => {
117
+ // validator 函数序列化后会被丢弃;保留 any 类型可避免持久化规则退化为默认字符串校验。
118
+ type: 'any',
119
+ [jsonValidationRuleMarker]: true,
120
+ validator: (_rule: unknown, value: unknown) => {
99
121
  // 允许空
100
122
  if (value === null || value === undefined || value === '') return Promise.resolve();
101
123
  // 如果已经是对象/数组,直接通过
102
124
  if (typeof value === 'object') return Promise.resolve();
103
125
  try {
104
- JSON.parse(value);
126
+ JSON.parse(String(value));
105
127
  return Promise.resolve();
106
- } catch (err) {
107
- return Promise.reject(ctx.t('Invalid JSON format') + ': ' + err.message);
128
+ } catch (error) {
129
+ const message = error instanceof Error ? error.message : String(error);
130
+ return Promise.reject(ctx.t('Invalid JSON format') + ': ' + message);
108
131
  }
109
132
  },
110
- });
133
+ } satisfies JsonValidationRule);
111
134
  ctx.model.parent.setProps({
112
135
  rules,
113
136
  });
@@ -20,7 +20,7 @@ function toSafeNumber(value) {
20
20
  return value;
21
21
  }
22
22
  if (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER) {
23
- return new BigNumber(value).toString();
23
+ return new BigNumber(value).toFixed();
24
24
  } else {
25
25
  return Number(value);
26
26
  }
@@ -0,0 +1,74 @@
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 { act, render } from '@nocobase/test/client';
11
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
12
+ import React from 'react';
13
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import { RichTextField } from '..';
15
+
16
+ const mockState = vi.hoisted(() => ({
17
+ onChange: undefined as ((value: string) => void) | undefined,
18
+ value: undefined as unknown,
19
+ }));
20
+
21
+ vi.mock('../../../../../flow-compat', async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import('../../../../../flow-compat')>();
23
+ return {
24
+ ...actual,
25
+ lazy: () => (props: { onChange?: (value: string) => void; value?: unknown }) => {
26
+ mockState.onChange = props.onChange;
27
+ mockState.value = props.value;
28
+ return null;
29
+ },
30
+ };
31
+ });
32
+
33
+ describe('RichTextField edit mode sanitization', () => {
34
+ beforeEach(() => {
35
+ mockState.onChange = undefined;
36
+ mockState.value = undefined;
37
+ });
38
+
39
+ it('sanitizes stored HTML before passing it to ReactQuill', () => {
40
+ const payload = '<p>safe</p><img src="https://example.com/image.png" onerror="alert(1)"><script>alert(1)</script>';
41
+
42
+ render(<RichTextField value={payload} onChange={vi.fn()} />);
43
+
44
+ expect(mockState.value).toBe(sanitizeRichTextHtml(payload));
45
+ });
46
+
47
+ it('preserves Delta values', () => {
48
+ const delta = { ops: [{ insert: 'safe\n' }] };
49
+
50
+ render(<RichTextField value={delta} onChange={vi.fn()} />);
51
+
52
+ expect(mockState.value).toBe(delta);
53
+ });
54
+
55
+ it('does not normalize active editor feedback', () => {
56
+ const editorValue = '<p>Hello<br>World</p>';
57
+ expect(sanitizeRichTextHtml(editorValue)).not.toBe(editorValue);
58
+
59
+ const { rerender } = render(<RichTextField value="" onChange={vi.fn()} />);
60
+ act(() => mockState.onChange?.(editorValue));
61
+ rerender(<RichTextField value={editorValue} onChange={vi.fn()} />);
62
+
63
+ expect(mockState.value).toBe(editorValue);
64
+ });
65
+
66
+ it('sanitizes subsequent external values', () => {
67
+ const payload = '<img src=x onerror="alert(1)">';
68
+ const { rerender } = render(<RichTextField value="" onChange={vi.fn()} />);
69
+
70
+ rerender(<RichTextField value={payload} onChange={vi.fn()} />);
71
+
72
+ expect(mockState.value).toBe(sanitizeRichTextHtml(payload));
73
+ });
74
+ });