@nocobase/client-v2 2.2.0-beta.8 → 2.3.0-alpha.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 (164) hide show
  1. package/es/APIClient.d.ts +1 -3
  2. package/es/BaseApplication.d.ts +3 -0
  3. package/es/RouteRepository.d.ts +8 -0
  4. package/es/RouterManager.d.ts +15 -0
  5. package/es/authRedirect.d.ts +1 -0
  6. package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
  7. package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
  8. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  9. package/es/components/category-tabs/index.d.ts +9 -0
  10. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -1
  11. package/es/components/form/TypedVariableInput.d.ts +31 -5
  12. package/es/components/form/filter/CollectionFilter.d.ts +2 -0
  13. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  14. package/es/components/form/filter/CollectionFilterPanel.d.ts +2 -0
  15. package/es/components/form/filter/index.d.ts +2 -0
  16. package/es/components/form/filter/useFilterActionProps.d.ts +2 -0
  17. package/es/components/index.d.ts +1 -0
  18. package/es/entry-actions/EntryActionManager.d.ts +24 -0
  19. package/es/entry-actions/index.d.ts +9 -0
  20. package/es/flow/actions/index.d.ts +1 -1
  21. package/es/flow/actions/linkageRules.d.ts +2 -0
  22. package/es/flow/admin-shell/BaseLayoutModel.d.ts +6 -0
  23. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +7 -0
  24. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  25. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
  26. package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
  27. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  28. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
  29. package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
  30. package/es/flow/components/FieldAssignValueInput.d.ts +9 -0
  31. package/es/flow/components/RunJSValueEditor.d.ts +1 -0
  32. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  33. package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
  34. package/es/flow/index.d.ts +1 -0
  35. package/es/flow/models/base/ActionModelCore.d.ts +2 -0
  36. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  37. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  38. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  39. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
  40. package/es/flow/routeTransientInputArgs.d.ts +14 -0
  41. package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
  42. package/es/flow-compat/routeTypes.d.ts +1 -0
  43. package/es/index.d.ts +6 -0
  44. package/es/index.mjs +279 -158
  45. package/es/utils/getRouteRuntimeVersion.d.ts +23 -0
  46. package/es/utils/index.d.ts +1 -0
  47. package/es/utils/markdownSanitize.d.ts +11 -0
  48. package/lib/index.js +280 -159
  49. package/lib/locale/languageCodes.js +2 -1
  50. package/package.json +8 -7
  51. package/src/APIClient.ts +1 -10
  52. package/src/Application.tsx +4 -0
  53. package/src/BaseApplication.tsx +13 -6
  54. package/src/RouteRepository.ts +25 -0
  55. package/src/RouterManager.tsx +142 -1
  56. package/src/__tests__/RouteRepository.test.ts +23 -0
  57. package/src/__tests__/RouterManager.test.ts +125 -0
  58. package/src/__tests__/app.test.tsx +73 -0
  59. package/src/__tests__/authRedirect.test.ts +17 -0
  60. package/src/__tests__/browserChecker.test.ts +61 -0
  61. package/src/__tests__/getRouteRuntimeVersion.test.ts +68 -0
  62. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +28 -0
  63. package/src/authRedirect.ts +38 -0
  64. package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
  65. package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
  66. package/src/collection-manager/field-validation.ts +1 -1
  67. package/src/components/README.md +7 -1
  68. package/src/components/README.zh-CN.md +6 -1
  69. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  70. package/src/components/category-tabs/index.ts +10 -0
  71. package/src/components/form/JsonTextArea.tsx +4 -0
  72. package/src/components/form/ScanInput/CodeScanner.tsx +23 -6
  73. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +180 -3
  74. package/src/components/form/ScanInput/useCodeScanner.ts +170 -5
  75. package/src/components/form/TypedVariableInput.tsx +452 -101
  76. package/src/components/form/__tests__/TypedVariableInput.test.tsx +364 -12
  77. package/src/components/form/filter/CollectionFilter.tsx +4 -0
  78. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  79. package/src/components/form/filter/CollectionFilterPanel.tsx +4 -0
  80. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  81. package/src/components/form/filter/__tests__/useFilterActionProps.test.tsx +95 -0
  82. package/src/components/form/filter/index.ts +2 -0
  83. package/src/components/form/filter/useFilterActionProps.ts +37 -6
  84. package/src/components/index.ts +1 -0
  85. package/src/entry-actions/EntryActionManager.ts +76 -0
  86. package/src/entry-actions/index.ts +10 -0
  87. package/src/flow/FlowPage.tsx +38 -3
  88. package/src/flow/__tests__/FlowPage.test.tsx +201 -27
  89. package/src/flow/__tests__/FlowRoute.test.tsx +549 -4
  90. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +62 -0
  91. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  92. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  93. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +80 -20
  94. package/src/flow/actions/dataScopeFilter.ts +10 -1
  95. package/src/flow/actions/dateTimeFormat.tsx +2 -2
  96. package/src/flow/actions/index.ts +2 -0
  97. package/src/flow/actions/linkageRules.tsx +86 -11
  98. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  99. package/src/flow/actions/openView.tsx +21 -1
  100. package/src/flow/admin-shell/BaseLayoutModel.tsx +124 -0
  101. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +184 -42
  102. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +1016 -119
  103. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +41 -5
  104. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +177 -1
  105. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +20 -0
  106. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
  107. package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
  108. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +253 -6
  109. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +436 -2
  110. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +48 -0
  111. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  112. package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
  113. package/src/flow/common/Markdown/Display.tsx +14 -3
  114. package/src/flow/common/Markdown/Edit.tsx +19 -7
  115. package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
  116. package/src/flow/components/FieldAssignValueInput.tsx +91 -20
  117. package/src/flow/components/FlowRoute.tsx +134 -18
  118. package/src/flow/components/RunJSValueEditor.tsx +9 -1
  119. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +216 -0
  120. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  121. package/src/flow/components/filter/VariableFilterItem.tsx +158 -10
  122. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  123. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  124. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +70 -1
  125. package/src/flow/index.ts +1 -0
  126. package/src/flow/internal/components/Markdown/util.ts +2 -1
  127. package/src/flow/models/base/ActionModel.tsx +10 -8
  128. package/src/flow/models/base/ActionModelCore.tsx +5 -0
  129. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  130. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  131. package/src/flow/models/base/PageModel/PageTabModel.tsx +184 -3
  132. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
  133. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  134. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  135. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +572 -1
  136. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +1 -0
  137. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +27 -12
  138. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +36 -0
  139. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +34 -0
  140. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
  141. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
  142. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
  143. package/src/flow/models/blocks/table/TableBlockModel.tsx +1 -1
  144. package/src/flow/models/blocks/table/TableColumnModel.tsx +6 -2
  145. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +51 -0
  146. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +2 -1
  147. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +45 -13
  148. package/src/flow/models/fields/TextareaFieldModel.tsx +42 -19
  149. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  150. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +32 -1
  151. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  152. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +20 -10
  153. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +24 -12
  154. package/src/flow/models/topbar/TopbarActionModel.tsx +78 -3
  155. package/src/flow/routeTransientInputArgs.ts +27 -0
  156. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +42 -0
  157. package/src/flow-compat/fieldValidationConstants.ts +1 -1
  158. package/src/flow-compat/routeTypes.ts +1 -0
  159. package/src/index.ts +6 -0
  160. package/src/locale/languageCodes.ts +2 -1
  161. package/src/nocobase-buildin-plugin/index.tsx +19 -1
  162. package/src/utils/getRouteRuntimeVersion.ts +185 -0
  163. package/src/utils/index.tsx +1 -0
  164. package/src/utils/markdownSanitize.ts +88 -0
@@ -7,17 +7,365 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { describe, it, expect, vi, beforeEach } from 'vitest';
11
- import { FlowEngine, SingleRecordResource } from '@nocobase/flow-engine';
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
+ import { FlowEngine, FlowModel, SingleRecordResource } from '@nocobase/flow-engine';
12
12
  import { QuickEditFormModel } from '../QuickEditFormModel';
13
13
 
14
14
  describe('QuickEditFormModel - quick edit save triggers API (regression)', () => {
15
15
  let engine: FlowEngine;
16
+ const originalMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia');
16
17
 
17
18
  beforeEach(() => {
18
19
  engine = new FlowEngine();
19
20
  });
20
21
 
22
+ afterEach(() => {
23
+ if (originalMatchMediaDescriptor) {
24
+ Object.defineProperty(window, 'matchMedia', originalMatchMediaDescriptor);
25
+ return;
26
+ }
27
+ delete (window as unknown as { matchMedia?: Window['matchMedia'] }).matchMedia;
28
+ });
29
+
30
+ const mockMatchMedia = (matches: boolean) => {
31
+ const matchMediaMock = vi.fn((query: string) => {
32
+ return {
33
+ matches,
34
+ media: query,
35
+ onchange: null,
36
+ addListener: vi.fn(),
37
+ removeListener: vi.fn(),
38
+ addEventListener: vi.fn(),
39
+ removeEventListener: vi.fn(),
40
+ dispatchEvent: vi.fn(),
41
+ } as unknown as MediaQueryList;
42
+ });
43
+
44
+ Object.defineProperty(window, 'matchMedia', {
45
+ ...(originalMatchMediaDescriptor || { configurable: true, writable: true }),
46
+ value: matchMediaMock,
47
+ });
48
+ };
49
+
50
+ const addUsersCollection = () => {
51
+ const ds = engine.context.dataSourceManager.getDataSource('main');
52
+ ds.addCollection({
53
+ name: 'users',
54
+ fields: [{ name: 'name', type: 'string', interface: 'input', uiSchema: { title: 'Name' } }],
55
+ });
56
+ };
57
+
58
+ it('uses source field context when opening quick edit', async () => {
59
+ engine.registerModels({ QuickEditFormModel });
60
+ engine.context.defineProperty('pageActive', { value: { value: false } });
61
+ engine.context.defineProperty('viewer', { value: { open: vi.fn(async () => undefined) } });
62
+
63
+ const page = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'page' });
64
+ page.context.defineProperty('pageActive', { value: { value: true } });
65
+ const source = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'source-field', parentId: page.uid });
66
+
67
+ await QuickEditFormModel.open({
68
+ flowEngine: engine,
69
+ target: document.createElement('div'),
70
+ dataSourceKey: 'main',
71
+ collectionName: 'users',
72
+ fieldPath: 'name',
73
+ record: {},
74
+ sourceFieldModelUid: source.uid,
75
+ });
76
+
77
+ let quickEditModel: QuickEditFormModel | undefined;
78
+ engine.forEachModel((model) => {
79
+ if (model instanceof QuickEditFormModel) {
80
+ quickEditModel = model;
81
+ }
82
+ });
83
+
84
+ expect(quickEditModel?.context.pageActive.value).toBe(true);
85
+ });
86
+
87
+ it('opens quick edit in a mobile drawer when the source field layout is mobile', async () => {
88
+ engine.registerModels({ QuickEditFormModel });
89
+ addUsersCollection();
90
+ const engineOpen = vi.fn(async () => undefined);
91
+ const sourceOpen = vi.fn(async () => undefined);
92
+ engine.context.defineProperty('isMobileLayout', { value: false });
93
+ engine.context.defineProperty('viewer', { value: { open: engineOpen } });
94
+ const source = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'source-field' });
95
+ source.context.defineProperty('isMobileLayout', { value: true });
96
+ source.context.defineProperty('viewer', { value: { open: sourceOpen } });
97
+
98
+ await QuickEditFormModel.open({
99
+ flowEngine: engine,
100
+ target: document.createElement('div'),
101
+ dataSourceKey: 'main',
102
+ collectionName: 'users',
103
+ fieldPath: 'name',
104
+ record: {},
105
+ sourceFieldModelUid: source.uid,
106
+ });
107
+
108
+ expect(engineOpen).not.toHaveBeenCalled();
109
+ expect(sourceOpen).toHaveBeenCalledTimes(1);
110
+ expect(sourceOpen).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ type: 'drawer',
113
+ title: 'Name',
114
+ closable: true,
115
+ placement: 'bottom',
116
+ styles: {
117
+ body: expect.objectContaining({
118
+ maxHeight: '50vh',
119
+ }),
120
+ },
121
+ inputArgs: {
122
+ isMobileLayout: true,
123
+ },
124
+ }),
125
+ );
126
+ let quickEditModel: QuickEditFormModel | undefined;
127
+ engine.forEachModel((model) => {
128
+ if (model instanceof QuickEditFormModel) {
129
+ quickEditModel = model;
130
+ }
131
+ });
132
+ expect(quickEditModel?.context.isMobileLayout).toBe(true);
133
+ });
134
+
135
+ it('uses the source table column title for the mobile drawer header', async () => {
136
+ engine.registerModels({ QuickEditFormModel });
137
+ addUsersCollection();
138
+ const sourceOpen = vi.fn(async () => undefined);
139
+ engine.context.defineProperty('viewer', { value: { open: vi.fn(async () => undefined) } });
140
+ const column = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'table-column' });
141
+ column.setProps({ title: 'Custom marital status' });
142
+ const source = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'source-field', parentId: column.uid });
143
+ source.context.defineProperty('isMobileLayout', { value: true });
144
+ source.context.defineProperty('viewer', { value: { open: sourceOpen } });
145
+
146
+ await QuickEditFormModel.open({
147
+ flowEngine: engine,
148
+ target: document.createElement('div'),
149
+ dataSourceKey: 'main',
150
+ collectionName: 'users',
151
+ fieldPath: 'name',
152
+ record: {},
153
+ sourceFieldModelUid: source.uid,
154
+ });
155
+
156
+ expect(sourceOpen).toHaveBeenCalledWith(
157
+ expect.objectContaining({
158
+ type: 'drawer',
159
+ title: 'Custom marital status',
160
+ }),
161
+ );
162
+ });
163
+
164
+ it('uses the mobile drawer fallback on narrow viewports', async () => {
165
+ engine.registerModels({ QuickEditFormModel });
166
+ addUsersCollection();
167
+ mockMatchMedia(true);
168
+ const viewContext = {
169
+ defineProperty: vi.fn(),
170
+ };
171
+ const open = vi.fn(
172
+ async (config: { content: (view: { close: () => void }, context: typeof viewContext) => unknown }) => {
173
+ config.content({ close: vi.fn() }, viewContext);
174
+ },
175
+ );
176
+ engine.context.defineProperty('viewer', { value: { open } });
177
+
178
+ await QuickEditFormModel.open({
179
+ flowEngine: engine,
180
+ target: document.createElement('div'),
181
+ dataSourceKey: 'main',
182
+ collectionName: 'users',
183
+ fieldPath: 'name',
184
+ record: {},
185
+ });
186
+
187
+ expect(open).toHaveBeenCalledTimes(1);
188
+ expect(viewContext.defineProperty).toHaveBeenCalledWith('isMobileLayout', { value: true });
189
+ expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 768px)');
190
+ expect(open).toHaveBeenCalledWith(
191
+ expect.objectContaining({
192
+ type: 'drawer',
193
+ title: 'Name',
194
+ closable: true,
195
+ placement: 'bottom',
196
+ styles: {
197
+ body: expect.objectContaining({
198
+ maxHeight: '50vh',
199
+ }),
200
+ },
201
+ inputArgs: {
202
+ isMobileLayout: true,
203
+ },
204
+ }),
205
+ );
206
+ let quickEditModel: QuickEditFormModel | undefined;
207
+ engine.forEachModel((model) => {
208
+ if (model instanceof QuickEditFormModel) {
209
+ quickEditModel = model;
210
+ }
211
+ });
212
+ expect(quickEditModel?.context.isMobileLayout).toBe(true);
213
+ });
214
+
215
+ it('uses the engine mobile layout context without relying on matchMedia', async () => {
216
+ engine.registerModels({ QuickEditFormModel });
217
+ addUsersCollection();
218
+ mockMatchMedia(false);
219
+ const open = vi.fn(async () => undefined);
220
+ engine.context.defineProperty('isMobileLayout', { value: true });
221
+ engine.context.defineProperty('viewer', { value: { open } });
222
+
223
+ await QuickEditFormModel.open({
224
+ flowEngine: engine,
225
+ target: document.createElement('div'),
226
+ dataSourceKey: 'main',
227
+ collectionName: 'users',
228
+ fieldPath: 'name',
229
+ record: {},
230
+ });
231
+
232
+ expect(window.matchMedia).not.toHaveBeenCalled();
233
+ expect(open).toHaveBeenCalledTimes(1);
234
+ expect(open).toHaveBeenCalledWith(
235
+ expect.objectContaining({
236
+ type: 'drawer',
237
+ title: 'Name',
238
+ placement: 'bottom',
239
+ }),
240
+ );
241
+ });
242
+
243
+ it('keeps an explicit non-mobile layout context ahead of the narrow viewport fallback', async () => {
244
+ engine.registerModels({ QuickEditFormModel });
245
+ addUsersCollection();
246
+ mockMatchMedia(true);
247
+ const open = vi.fn(async () => undefined);
248
+ const target = document.createElement('div');
249
+ engine.context.defineProperty('isMobileLayout', { value: false });
250
+ engine.context.defineProperty('viewer', { value: { open } });
251
+
252
+ await QuickEditFormModel.open({
253
+ flowEngine: engine,
254
+ target,
255
+ dataSourceKey: 'main',
256
+ collectionName: 'users',
257
+ fieldPath: 'name',
258
+ record: {},
259
+ });
260
+
261
+ expect(window.matchMedia).not.toHaveBeenCalled();
262
+ expect(open).toHaveBeenCalledTimes(1);
263
+ expect(open).toHaveBeenCalledWith(
264
+ expect.objectContaining({
265
+ type: 'popover',
266
+ target,
267
+ placement: 'rightTop',
268
+ }),
269
+ );
270
+ });
271
+
272
+ it('keeps dynamic preventClose effective for mobile quick edit drawer containers', async () => {
273
+ engine.registerModels({ QuickEditFormModel });
274
+ addUsersCollection();
275
+ mockMatchMedia(true);
276
+ const originalBeforeClose = vi.fn(async () => true);
277
+ const drawerView = {
278
+ close: vi.fn(),
279
+ update: vi.fn(),
280
+ beforeClose: originalBeforeClose,
281
+ };
282
+ const open = vi.fn(async (config: { content: (view: typeof drawerView) => unknown }) => {
283
+ config.content(drawerView);
284
+ });
285
+ engine.context.defineProperty('viewer', { value: { open } });
286
+
287
+ await QuickEditFormModel.open({
288
+ flowEngine: engine,
289
+ target: document.createElement('div'),
290
+ dataSourceKey: 'main',
291
+ collectionName: 'users',
292
+ fieldPath: 'name',
293
+ record: {},
294
+ });
295
+
296
+ let quickEditModel: QuickEditFormModel | undefined;
297
+ engine.forEachModel((model) => {
298
+ if (model instanceof QuickEditFormModel) {
299
+ quickEditModel = model;
300
+ }
301
+ });
302
+
303
+ quickEditModel?.viewContainer.update?.({ preventClose: true });
304
+ expect(drawerView.update).toHaveBeenCalledWith({ preventClose: true });
305
+ await expect(drawerView.beforeClose?.({ force: false })).resolves.toBe(false);
306
+ expect(originalBeforeClose).not.toHaveBeenCalled();
307
+
308
+ quickEditModel?.viewContainer.update?.({ preventClose: false });
309
+ expect(drawerView.update).toHaveBeenCalledWith({ preventClose: false });
310
+ await expect(drawerView.beforeClose?.({ force: false })).resolves.toBe(true);
311
+ expect(originalBeforeClose).toHaveBeenCalledTimes(1);
312
+ });
313
+
314
+ it('keeps desktop quick edit in the existing right-top popover', async () => {
315
+ engine.registerModels({ QuickEditFormModel });
316
+ mockMatchMedia(false);
317
+ const open = vi.fn(async () => undefined);
318
+ const target = document.createElement('div');
319
+ engine.context.defineProperty('viewer', { value: { open } });
320
+
321
+ await QuickEditFormModel.open({
322
+ flowEngine: engine,
323
+ target,
324
+ dataSourceKey: 'main',
325
+ collectionName: 'users',
326
+ fieldPath: 'name',
327
+ record: {},
328
+ });
329
+
330
+ expect(open).toHaveBeenCalledTimes(1);
331
+ expect(open).toHaveBeenCalledWith(
332
+ expect.objectContaining({
333
+ type: 'popover',
334
+ target,
335
+ placement: 'rightTop',
336
+ styles: {
337
+ body: expect.objectContaining({
338
+ width: 420,
339
+ }),
340
+ },
341
+ }),
342
+ );
343
+ });
344
+
345
+ it('keeps mobile quick edit content naturally sized with half-window scroll bounds', () => {
346
+ engine.registerModels({ QuickEditFormModel });
347
+ const model = engine.createModel<QuickEditFormModel>({
348
+ use: QuickEditFormModel,
349
+ uid: 'quick-edit-style',
350
+ });
351
+ model.context.defineProperty('isMobileLayout', { value: true });
352
+
353
+ const result = QuickEditFormModel.prototype.render.call(model) as any;
354
+ const formBody = result.props.children[0];
355
+ const actions = result.props.children[1];
356
+
357
+ expect(formBody.props.style).toMatchObject({
358
+ maxHeight: 'calc(50vh - var(--nb-mobile-page-header-height, 40px) - 132px)',
359
+ overflowY: 'auto',
360
+ padding: '8px var(--nb-mobile-page-tabs-content-padding, 12px) 0',
361
+ });
362
+ expect(formBody.props.style.minHeight).toBe(0);
363
+ expect(actions.props.style).toMatchObject({
364
+ justifyContent: 'flex-end',
365
+ padding: '8px var(--nb-mobile-page-tabs-content-padding, 12px) calc(80px + env(safe-area-inset-bottom, 0px))',
366
+ });
367
+ });
368
+
21
369
  it('calls update with filterByTk and merges primary key from ctx.collection/record', async () => {
22
370
  // 1) 准备数据源与集合(含主键字段)
23
371
  const dsm = engine.context.dataSourceManager;
@@ -121,10 +121,11 @@ const Columns = observer<any>(({ record, model, index }) => {
121
121
  fork.context.defineProperty('recordIndex', {
122
122
  get: () => index,
123
123
  });
124
+ const rendererKey = `${fork.uid}:${fork.forkId}`;
124
125
  const renderer = (
125
126
  <FlowModelRenderer
126
127
  showFlowSettings={{ showBorder: false, toolbarPosition: 'above' }}
127
- key={fork.uid}
128
+ key={rendererKey}
128
129
  model={fork}
129
130
  inputArgs={record}
130
131
  fallback={<Skeleton.Button size="small" />}
@@ -310,7 +310,7 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
310
310
  transform: translateY(-50%);
311
311
  }
312
312
  &:hover {
313
- background: rgba(24, 144, 255, 0.1) !important;
313
+ box-shadow: inset 0 0 0 9999px rgba(24, 144, 255, 0.1);
314
314
  }
315
315
  &:hover .edit-icon {
316
316
  display: inline-flex;
@@ -373,14 +373,18 @@ TableColumnModel.registerFlow({
373
373
  currentProps: ctx.model.props,
374
374
  })
375
375
  : undefined;
376
+ const collectionFieldComponentProps = collectionField.getComponentProps();
376
377
  const componentProps =
377
378
  collectionField.isAssociationField() && titleField
378
379
  ? {
379
- ...collectionField.getComponentProps(),
380
+ ...collectionFieldComponentProps,
380
381
  ...targetCollectionField?.getComponentProps?.(),
381
382
  ...savedDateTimeDisplayProps,
382
383
  }
383
- : collectionField.getComponentProps();
384
+ : {
385
+ ...collectionFieldComponentProps,
386
+ ...savedDateTimeDisplayProps,
387
+ };
384
388
  ctx.model.setProps('title', collectionField.title);
385
389
  ctx.model.setProps('dataIndex', collectionField.name);
386
390
  // for quick edit
@@ -327,6 +327,57 @@ describe('TableColumnModel sorter settings', () => {
327
327
  );
328
328
  });
329
329
 
330
+ it('keeps saved ordinary datetime format when table column initializes again', async () => {
331
+ const engine = new FlowEngine();
332
+ const model = new TableColumnModel({
333
+ uid: 'table-column-saved-datetime-format',
334
+ flowEngine: engine,
335
+ } as any);
336
+ const initStep = model.getFlow('tableColumnSettings')?.steps?.init as any;
337
+ const setProps = vi.fn();
338
+
339
+ await initStep.handler({
340
+ model: {
341
+ context: {
342
+ collectionField: {
343
+ title: 'Datetime',
344
+ name: 'datetime',
345
+ isAssociationField: () => false,
346
+ getComponentProps: () => ({
347
+ dateFormat: 'YYYY-MM-DD',
348
+ showTime: false,
349
+ }),
350
+ },
351
+ },
352
+ props: {},
353
+ subModels: {
354
+ field: {
355
+ getStepParams: (flowKey, stepKey) =>
356
+ flowKey === 'datetimeSettings' && stepKey === 'dateFormat'
357
+ ? {
358
+ picker: 'date',
359
+ dateFormat: 'YYYY-MM-DD',
360
+ showTime: true,
361
+ timeFormat: 'HH:mm:ss',
362
+ }
363
+ : undefined,
364
+ },
365
+ },
366
+ applySubModelsBeforeRenderFlows: vi.fn(),
367
+ setProps,
368
+ },
369
+ });
370
+
371
+ expect(setProps).toHaveBeenCalledWith(
372
+ expect.objectContaining({
373
+ dateFormat: 'YYYY-MM-DD',
374
+ format: 'YYYY-MM-DD HH:mm:ss',
375
+ showTime: true,
376
+ timeFormat: 'HH:mm:ss',
377
+ }),
378
+ );
379
+ });
380
+
330
381
  it('does not update field component setting when title field refresh fails', async () => {
331
382
  const engine = new FlowEngine();
332
383
  const model = new TableColumnModel({ uid: 'table-column-title-field-component-failed', flowEngine: engine } as any);
@@ -102,7 +102,7 @@ const RenderCell = observer<any>((props) => {
102
102
  transform: translateY(-50%);
103
103
  }
104
104
  &:hover {
105
- background: rgba(24, 144, 255, 0.1) !important;
105
+ box-shadow: inset 0 0 0 9999px rgba(24, 144, 255, 0.1);
106
106
  }
107
107
  &:hover .edit-icon {
108
108
  display: inline-flex;
@@ -126,6 +126,7 @@ const RenderCell = observer<any>((props) => {
126
126
  fieldPath: dataIndex,
127
127
  record: record,
128
128
  fieldProps: { ...columnModel.props, ...columnModel.subModels.field.props },
129
+ sourceFieldModelUid: columnModel.subModels.field.uid,
129
130
  onOk: (values) => {
130
131
  record[dataIndex] = values[dataIndex];
131
132
  // 仅重渲染单元格
@@ -198,6 +198,48 @@ const useFieldPermissionMessage = (model, allowEdit) => {
198
198
  return messageValue;
199
199
  };
200
200
 
201
+ const recordSelectClassName = css`
202
+ min-width: 0;
203
+
204
+ .ant-select-selector {
205
+ min-width: 0;
206
+ overflow: hidden;
207
+ }
208
+
209
+ .ant-select-selection-search,
210
+ .ant-select-selection-item,
211
+ .ant-select-selection-placeholder,
212
+ .ant-select-selection-overflow,
213
+ .ant-select-selection-overflow-item {
214
+ min-width: 0;
215
+ max-width: 100%;
216
+ }
217
+
218
+ .ant-select-selection-item,
219
+ .ant-select-selection-item-content {
220
+ overflow: hidden;
221
+ text-overflow: ellipsis;
222
+ white-space: nowrap;
223
+ }
224
+ `;
225
+
226
+ const recordSelectLabelClassName = css`
227
+ display: block;
228
+ min-width: 0;
229
+ max-width: 100%;
230
+ overflow: hidden;
231
+ text-overflow: ellipsis;
232
+ white-space: nowrap;
233
+
234
+ * {
235
+ min-width: 0;
236
+ max-width: 100%;
237
+ overflow: hidden;
238
+ text-overflow: ellipsis;
239
+ white-space: nowrap !important;
240
+ }
241
+ `;
242
+
201
243
  const LazySelect = (props: Readonly<LazySelectProps>) => {
202
244
  const {
203
245
  fieldNames,
@@ -210,6 +252,7 @@ const LazySelect = (props: Readonly<LazySelectProps>) => {
210
252
  onChange,
211
253
  allowCreate = true,
212
254
  allowEdit = true,
255
+ className,
213
256
  ...others
214
257
  } = props;
215
258
  const model: any = useFlowModel();
@@ -317,6 +360,7 @@ const LazySelect = (props: Readonly<LazySelectProps>) => {
317
360
  <Select
318
361
  style={{ width: '100%' }}
319
362
  {...others}
363
+ className={[recordSelectClassName, className].filter(Boolean).join(' ')}
320
364
  allowClear
321
365
  showSearch
322
366
  maxTagCount="responsive"
@@ -409,19 +453,7 @@ const LazySelect = (props: Readonly<LazySelectProps>) => {
409
453
  }}
410
454
  popupMatchSelectWidth
411
455
  labelRender={(data) => {
412
- return (
413
- <div
414
- className={css`
415
- div {
416
- white-space: nowrap !important;
417
- overflow: hidden;
418
- text-overflow: ellipsis;
419
- }
420
- `}
421
- >
422
- {data.label}
423
- </div>
424
- );
456
+ return <div className={recordSelectLabelClassName}>{data.label}</div>;
425
457
  }}
426
458
  dropdownRender={(menu) => {
427
459
  const isFullMatch = realOptions.some((v) => v[normalizedFieldNames.label] === others.searchText);
@@ -9,56 +9,79 @@
9
9
 
10
10
  import { Input } from 'antd';
11
11
  import type { TextAreaProps } from 'antd/es/input';
12
- import type { TextAreaRef } from 'antd/es/input/TextArea';
13
- import React, { useEffect, useRef } from 'react';
12
+ import React, { useEffect, useRef, useState } from 'react';
14
13
  import { largeField, EditableItemModel } from '@nocobase/flow-engine';
15
14
  import { FieldModel } from '../base/FieldModel';
16
15
 
17
16
  function IMESafeTextArea(props: TextAreaProps) {
18
17
  const { value, onChange, onCompositionStart, onCompositionEnd, ...rest } = props;
19
- const textAreaRef = useRef<TextAreaRef>(null);
20
- const previousValueRef = useRef(value);
21
- const defaultValue = typeof value === 'bigint' ? String(value) : value;
18
+ const [textAreaValue, setTextAreaValue] = useState<TextAreaProps['value']>(() => normalizeTextAreaValue(value));
19
+ const textAreaValueRef = useRef<TextAreaProps['value']>(textAreaValue);
20
+ const pendingLocalValuesRef = useRef<TextAreaProps['value'][]>([]);
22
21
 
23
22
  useEffect(() => {
24
- if (Object.is(previousValueRef.current, value)) {
25
- return;
26
- }
27
- previousValueRef.current = value;
23
+ const nextValue = normalizeTextAreaValue(value);
24
+ const pendingValues = pendingLocalValuesRef.current;
25
+ const pendingIndex = pendingValues.findIndex((item) => Object.is(item, nextValue));
28
26
 
29
- const textArea = textAreaRef.current?.resizableTextArea?.textArea;
30
- if (textArea) {
31
- const nextValue = value == null ? '' : String(value);
32
- if (textArea.value !== nextValue) {
33
- textArea.value = nextValue;
27
+ if (pendingIndex >= 0) {
28
+ pendingValues.splice(0, pendingIndex + 1);
29
+ if (!Object.is(textAreaValueRef.current, nextValue)) {
30
+ return;
34
31
  }
35
32
  }
33
+
34
+ pendingValues.length = 0;
35
+ if (Object.is(textAreaValueRef.current, nextValue)) {
36
+ return;
37
+ }
38
+
39
+ textAreaValueRef.current = nextValue;
40
+ setTextAreaValue(nextValue);
36
41
  }, [value]);
37
42
 
38
43
  const getEventValue = (event: React.ChangeEvent<HTMLTextAreaElement> | React.CompositionEvent<HTMLTextAreaElement>) =>
39
44
  event.currentTarget.value;
40
45
 
46
+ const recordLocalValue = (
47
+ event: React.ChangeEvent<HTMLTextAreaElement> | React.CompositionEvent<HTMLTextAreaElement>,
48
+ ) => {
49
+ const nextValue = getEventValue(event);
50
+ const pendingValues = pendingLocalValuesRef.current;
51
+ pendingValues.push(nextValue);
52
+ if (pendingValues.length > 20) {
53
+ pendingValues.shift();
54
+ }
55
+ textAreaValueRef.current = nextValue;
56
+ setTextAreaValue(nextValue);
57
+ };
58
+
41
59
  return (
42
60
  <Input.TextArea
43
61
  {...rest}
44
- ref={textAreaRef}
45
- defaultValue={defaultValue}
62
+ value={textAreaValue}
46
63
  onChange={(event) => {
47
- previousValueRef.current = getEventValue(event);
64
+ recordLocalValue(event);
48
65
  onChange?.(event);
49
66
  }}
50
67
  onCompositionStart={(event) => {
51
- previousValueRef.current = getEventValue(event);
68
+ recordLocalValue(event);
52
69
  onCompositionStart?.(event);
53
70
  }}
54
71
  onCompositionEnd={(event) => {
55
- previousValueRef.current = getEventValue(event);
72
+ recordLocalValue(event);
56
73
  onCompositionEnd?.(event);
57
74
  }}
58
75
  />
59
76
  );
60
77
  }
61
78
 
79
+ function normalizeTextAreaValue(nextValue: unknown): TextAreaProps['value'] {
80
+ if (typeof nextValue === 'bigint') return String(nextValue);
81
+ if (nextValue == null) return '';
82
+ return nextValue as TextAreaProps['value'];
83
+ }
84
+
62
85
  @largeField()
63
86
  export class TextareaFieldModel extends FieldModel {
64
87
  render() {
@@ -48,12 +48,12 @@ export class VariableFieldFormModel extends FlowModel {
48
48
  <FormProvider form={this.form}>
49
49
  <FormLayout layout={'vertical'}>
50
50
  {this.mapSubModels('fields', (field) => {
51
- // 确保字段模型具备稳定的 id/name,便于依赖路径的组件(如公式字段)正确解析
51
+ // Avoid conflicting with the DOM nodeName property when rendering the temporary input.
52
52
  const init = field?.getStepParams?.('fieldSettings', 'init') || {};
53
53
  const fp = init?.fieldPath as string | undefined;
54
54
  if (fp) {
55
- const namePath = fp.includes('.') ? fp.split('.') : [fp];
56
- const toSet: any = {};
55
+ const namePath = (fp === 'nodeName' ? '__nb_variable_field_nodeName' : fp).split('.');
56
+ const toSet: Record<string, unknown> = {};
57
57
  if (!field?.props?.id) toSet.id = namePath;
58
58
  if (!field?.props?.name) toSet.name = namePath;
59
59
  if (Object.keys(toSet).length) field?.setProps?.(toSet);