@nocobase/client-v2 3.0.0-alpha.1 → 3.0.0-alpha.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 (133) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +6 -2
  4. package/es/flow/actions/linkageRules.d.ts +24 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
  6. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  7. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
  8. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  9. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  10. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  11. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  12. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  13. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  14. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  15. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  16. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  17. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  18. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  19. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  20. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  21. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
  23. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  24. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  25. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  26. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  27. package/es/flow-compat/passwordUtils.d.ts +1 -1
  28. package/es/index.d.ts +2 -1
  29. package/es/index.mjs +140 -140
  30. package/es/nocobase-buildin-plugin/index.d.ts +1 -0
  31. package/es/settings-app/SettingsGroupNav.d.ts +3 -2
  32. package/es/settings-app/SettingsSearch.d.ts +1 -1
  33. package/es/ui-operation/index.d.ts +15 -0
  34. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  35. package/lib/index.js +145 -145
  36. package/package.json +7 -7
  37. package/src/APIClient.ts +92 -0
  38. package/src/Application.tsx +3 -1
  39. package/src/__tests__/APIClient.test.tsx +58 -0
  40. package/src/__tests__/app.test.tsx +122 -3
  41. package/src/__tests__/authRedirect.test.ts +17 -0
  42. package/src/__tests__/browserChecker.test.ts +81 -33
  43. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
  44. package/src/__tests__/settings-center.test.tsx +312 -11
  45. package/src/__tests__/settings-layout-root.test.tsx +346 -6
  46. package/src/__tests__/settings-runtime-paths.test.ts +1 -1
  47. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  48. package/src/__tests__/settings-shell.test.tsx +62 -1
  49. package/src/acl/ACLProvider.tsx +34 -3
  50. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  51. package/src/acl/aclCheckReadiness.ts +44 -0
  52. package/src/authRedirect.ts +13 -5
  53. package/src/components/AppComponents.tsx +16 -7
  54. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  55. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  56. package/src/components/form/table/Table.tsx +81 -6
  57. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  58. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  59. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  60. package/src/flow/actions/linkageRules.tsx +33 -10
  61. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  62. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  63. package/src/flow/components/FieldAssignValueInput.tsx +38 -621
  64. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  65. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  66. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  67. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  68. package/src/flow/components/field-value-variable/index.ts +12 -0
  69. package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
  70. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  71. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  72. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  73. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  74. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  75. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  76. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  77. package/src/flow/models/base/CollectionBlockModel.tsx +32 -1
  78. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  79. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  80. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  81. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  82. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  83. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  84. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  85. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  86. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  87. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  88. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  89. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  90. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  91. package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
  92. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  93. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  94. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  95. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  96. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  97. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  98. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  99. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
  100. package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
  101. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  102. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  103. package/src/flow/models/blocks/table/utils.ts +7 -0
  104. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  105. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  106. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  107. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  108. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  109. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  110. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
  111. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  112. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  113. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  114. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  115. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  116. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  117. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  118. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  119. package/src/index.ts +2 -0
  120. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  121. package/src/layout-manager/LayoutRoute.tsx +2 -1
  122. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  123. package/src/nocobase-buildin-plugin/index.tsx +14 -1
  124. package/src/settings-app/SettingsGroupNav.tsx +11 -11
  125. package/src/settings-app/SettingsSearch.tsx +115 -10
  126. package/src/settings-app/SettingsShell.tsx +23 -6
  127. package/src/settings-app/runtimePaths.ts +0 -1
  128. package/src/settings-app/settingsTheme.ts +82 -14
  129. package/src/settings-center/AdminSettingsLayout.tsx +132 -107
  130. package/src/settings-center/__tests__/groups.test.ts +25 -0
  131. package/src/settings-center/groups.ts +1 -1
  132. package/src/ui-operation/index.ts +33 -0
  133. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -47,6 +47,10 @@ vi.mock('@nocobase/flow-engine', async () => {
47
47
  return { ...actual, VariableInput: MockVariableInput };
48
48
  });
49
49
 
50
+ vi.mock('../../../models/blocks/filter-form/fields/date-time/components/DateFilterDynamicComponent', () => ({
51
+ DateFilterDynamicComponent: () => <div data-testid="date-filter-dynamic-component" />,
52
+ }));
53
+
50
54
  const getRenderedSelectTexts = (root: ParentNode = document.body) =>
51
55
  Array.from(root.querySelectorAll('.ant-select-selection-item')).map((node) => (node.textContent || '').trim());
52
56
 
@@ -158,6 +162,127 @@ describe('VariableFilterItem', () => {
158
162
  expect(value.value).toBe('abc');
159
163
  });
160
164
 
165
+ it.each([
166
+ { operator: '$includes', xComponent: 'Input' },
167
+ { operator: '$eq', xComponent: 'Select' },
168
+ { operator: '$in', xComponent: 'Select' },
169
+ ])('uses a silent app registry lookup for the local $xComponent control', async ({ operator, xComponent }) => {
170
+ const value = observable({ path: '', operator: '', value: '' }) as VariableFilterItemValue;
171
+ const model = CreateModel();
172
+ const app = model.context.app as unknown as ReturnType<typeof createMockFlowApp>;
173
+ const getComponent = vi.spyOn(app, 'getComponent');
174
+
175
+ (globalThis as { __TEST_PATH__?: string }).__TEST_PATH__ = 'status';
176
+ (globalThis as { __TEST_META__?: MetaTreeNode }).__TEST_META__ = {
177
+ interface: 'input',
178
+ uiSchema: {
179
+ 'x-component': xComponent,
180
+ enum: [{ label: 'Draft', value: 'draft' }],
181
+ 'x-filter-operators': [
182
+ {
183
+ value: operator,
184
+ label: 'Operator',
185
+ selected: true,
186
+ schema: {
187
+ 'x-component': xComponent,
188
+ 'x-component-props': xComponent === 'Select' ? { mode: operator === '$in' ? 'tags' : null } : {},
189
+ },
190
+ },
191
+ ],
192
+ },
193
+ paths: ['collection', 'status'],
194
+ name: 'status',
195
+ title: 'Status',
196
+ type: 'string',
197
+ };
198
+
199
+ render(<VariableFilterItem value={value} model={model} rightAsVariable={false} />);
200
+ fireEvent.click(screen.getByTestId('variable-input'));
201
+
202
+ await waitFor(() => {
203
+ expect(value.operator).toBe(operator);
204
+ });
205
+ if (xComponent === 'Select' && operator === '$eq') {
206
+ expect(getComponent).not.toHaveBeenCalled();
207
+ } else {
208
+ expect(getComponent).toHaveBeenCalledWith(xComponent, false);
209
+ }
210
+ });
211
+
212
+ it('silently handles the DatePicker field schema before the date operator component takes over', async () => {
213
+ const value = observable({ path: '', operator: '', value: '' }) as VariableFilterItemValue;
214
+ const model = CreateModel();
215
+ const app = model.context.app as unknown as ReturnType<typeof createMockFlowApp>;
216
+ const getComponent = vi.spyOn(app, 'getComponent');
217
+
218
+ (globalThis as { __TEST_PATH__?: string }).__TEST_PATH__ = 'createdAt';
219
+ (globalThis as { __TEST_META__?: MetaTreeNode }).__TEST_META__ = {
220
+ interface: 'createdAt',
221
+ uiSchema: {
222
+ 'x-component': 'DatePicker',
223
+ 'x-filter-operators': [
224
+ {
225
+ value: '$dateOn',
226
+ label: 'Is',
227
+ selected: true,
228
+ schema: {
229
+ 'x-component': 'DateFilterDynamicComponent',
230
+ 'x-component-props': { isRange: false },
231
+ },
232
+ },
233
+ ],
234
+ },
235
+ paths: ['collection', 'createdAt'],
236
+ name: 'createdAt',
237
+ title: 'Created at',
238
+ type: 'date',
239
+ };
240
+
241
+ render(<VariableFilterItem value={value} model={model} rightAsVariable={false} />);
242
+ fireEvent.click(screen.getByTestId('variable-input'));
243
+
244
+ await waitFor(() => {
245
+ expect(value.operator).toBe('$dateOn');
246
+ });
247
+ expect(getComponent).toHaveBeenCalledWith('DatePicker', false);
248
+ expect(await screen.findByTestId('date-filter-dynamic-component')).toBeInTheDocument();
249
+ });
250
+
251
+ it('continues resolving plugin-provided keyword controls from the app registry', async () => {
252
+ const value = observable({ path: '', operator: '', value: '' }) as VariableFilterItemValue;
253
+ const model = CreateModel();
254
+ const app = model.context.app as unknown as ReturnType<typeof createMockFlowApp>;
255
+ const MultipleKeywordsInput: React.FC = () => <div data-testid="multiple-keywords-input" />;
256
+ app.addComponents({ MultipleKeywordsInput });
257
+ const getComponent = vi.spyOn(app, 'getComponent');
258
+
259
+ (globalThis as { __TEST_PATH__?: string }).__TEST_PATH__ = 'name';
260
+ (globalThis as { __TEST_META__?: MetaTreeNode }).__TEST_META__ = {
261
+ interface: 'input',
262
+ uiSchema: {
263
+ 'x-component': 'Input',
264
+ 'x-filter-operators': [
265
+ {
266
+ value: '$in',
267
+ label: 'Is any of',
268
+ selected: true,
269
+ schema: { 'x-component': 'MultipleKeywordsInput' },
270
+ },
271
+ ],
272
+ },
273
+ paths: ['collection', 'name'],
274
+ name: 'name',
275
+ title: 'Name',
276
+ type: 'string',
277
+ };
278
+
279
+ render(<VariableFilterItem value={value} model={model} rightAsVariable={false} />);
280
+ fireEvent.click(screen.getByTestId('variable-input'));
281
+
282
+ expect(await screen.findByTestId('multiple-keywords-input')).toBeTruthy();
283
+ expect(getComponent).toHaveBeenCalledWith('MultipleKeywordsInput');
284
+ });
285
+
161
286
  it('passes disabled through to the left selector, operator select, and right variable input', async () => {
162
287
  const value: VariableFilterItemValue = { path: '', operator: '', value: '' };
163
288
  const model = CreateModel();
@@ -8,12 +8,13 @@
8
8
  */
9
9
 
10
10
  import { useFlowModel } from '@nocobase/flow-engine';
11
- import { Result } from 'antd';
11
+ import { useMemoizedFn } from 'ahooks';
12
+ import { Button, Result } from 'antd';
12
13
  import _ from 'lodash';
13
14
  import React, { useMemo } from 'react';
14
15
  import { useTranslation } from 'react-i18next';
15
16
  import { BlockItemCard } from '../BlockItemCard';
16
- import { BlockModel } from '../../models/base/BlockModel';
17
+ import type { BlockModel } from '../../models/base/BlockModel';
17
18
 
18
19
  function getResourceSettingsInitParams(model: any) {
19
20
  if (typeof model?.getResourceSettingsInitParams === 'function') {
@@ -43,26 +44,46 @@ function getBlockResourceInfo(model: any, t: (key: string) => string) {
43
44
  const dataSourceName = dataSource ? t(dataSource.displayName || dataSource.key) : dataSourceKey;
44
45
  const collectionLabel = collectionName ? `${t(collectionName) || collectionName}` : '';
45
46
  const dataSourceLabel = dataSourceName ? `${t(dataSourceName)} > ` : '';
47
+ const isDataSourceLoadFailed = ['loading-failed', 'reloading-failed'].includes(dataSource?.status);
46
48
 
47
49
  return {
48
50
  dataSourceName,
49
51
  nameValue: `${dataSourceLabel}${collectionLabel}`,
50
- isDataSourceUnavailable: Boolean(dataSourceKey && !dataSource),
52
+ isDataSourceLoadFailed,
53
+ isDataSourceUnavailable: Boolean(dataSourceKey && (!dataSource || isDataSourceLoadFailed)),
51
54
  };
52
55
  }
53
56
 
54
- function DataSourceUnavailablePlaceholder({ dataSourceName }: { dataSourceName?: string }) {
57
+ function getTemporarilyUnavailableMessage(t: (key: string, options?: Record<string, string>) => string, name?: string) {
58
+ return name
59
+ ? t('The data source "{{name}}" is temporarily unavailable. Please try again later or contact an administrator.', {
60
+ name,
61
+ })
62
+ : t('The data source is temporarily unavailable. Please try again later or contact an administrator.');
63
+ }
64
+
65
+ function DataSourceUnavailablePlaceholder({
66
+ dataSourceName,
67
+ isLoadFailed,
68
+ }: {
69
+ dataSourceName?: string;
70
+ isLoadFailed?: boolean;
71
+ }) {
55
72
  const { t } = useTranslation();
56
- const subTitle = dataSourceName
57
- ? t(
58
- 'The data source "{{name}}" used by this block is disabled or unavailable. Enable the data source to display this block.',
59
- { name: dataSourceName },
60
- )
61
- : t('The data source used by this block is disabled or unavailable. Enable the data source to display this block.');
73
+ const subTitle = isLoadFailed
74
+ ? getTemporarilyUnavailableMessage(t, dataSourceName)
75
+ : dataSourceName
76
+ ? t(
77
+ 'The data source "{{name}}" used by this block is disabled or unavailable. Enable the data source to display this block.',
78
+ { name: dataSourceName },
79
+ )
80
+ : t(
81
+ 'The data source used by this block is disabled or unavailable. Enable the data source to display this block.',
82
+ );
62
83
 
63
84
  return (
64
- <BlockItemCard>
65
- <Result status="403" subTitle={subTitle}></Result>
85
+ <BlockItemCard role="alert" aria-live="polite">
86
+ <Result status={isLoadFailed ? 'warning' : '403'} subTitle={subTitle}></Result>
66
87
  </BlockItemCard>
67
88
  );
68
89
  }
@@ -70,7 +91,7 @@ function DataSourceUnavailablePlaceholder({ dataSourceName }: { dataSourceName?:
70
91
  export const BlockPlaceholder = () => {
71
92
  const { t } = useTranslation();
72
93
  const model: BlockModel = useFlowModel();
73
- const { dataSourceName, isDataSourceUnavailable, nameValue } = useMemo(() => {
94
+ const { dataSourceName, isDataSourceLoadFailed, isDataSourceUnavailable, nameValue } = useMemo(() => {
74
95
  return getBlockResourceInfo(model, t);
75
96
  }, [model, t]);
76
97
 
@@ -86,10 +107,7 @@ export const BlockPlaceholder = () => {
86
107
  }, [actionName, nameValue, t]);
87
108
 
88
109
  if (isDataSourceUnavailable) {
89
- if (!model.context.flowSettingsEnabled) {
90
- return null;
91
- }
92
- return <DataSourceUnavailablePlaceholder dataSourceName={dataSourceName} />;
110
+ return <DataSourceUnavailablePlaceholder dataSourceName={dataSourceName} isLoadFailed={isDataSourceLoadFailed} />;
93
111
  }
94
112
 
95
113
  return (
@@ -101,8 +119,8 @@ export const BlockPlaceholder = () => {
101
119
 
102
120
  export function BlockDeletePlaceholder() {
103
121
  const { t } = useTranslation();
104
- const model: any = useFlowModel();
105
- const { dataSourceName, isDataSourceUnavailable, nameValue } = useMemo(() => {
122
+ const model: BlockModel = useFlowModel();
123
+ const { dataSourceName, isDataSourceLoadFailed, isDataSourceUnavailable, nameValue } = useMemo(() => {
106
124
  return getBlockResourceInfo(model, t);
107
125
  }, [model, t]);
108
126
 
@@ -115,10 +133,7 @@ export function BlockDeletePlaceholder() {
115
133
  }, [nameValue, t]);
116
134
 
117
135
  if (isDataSourceUnavailable) {
118
- if (!model.context.flowSettingsEnabled) {
119
- return null;
120
- }
121
- return <DataSourceUnavailablePlaceholder dataSourceName={dataSourceName} />;
136
+ return <DataSourceUnavailablePlaceholder dataSourceName={dataSourceName} isLoadFailed={isDataSourceLoadFailed} />;
122
137
  }
123
138
 
124
139
  return (
@@ -127,3 +142,35 @@ export function BlockDeletePlaceholder() {
127
142
  </BlockItemCard>
128
143
  );
129
144
  }
145
+
146
+ type RefreshableBlockModel = BlockModel & {
147
+ refresh?: () => Promise<unknown> | unknown;
148
+ };
149
+
150
+ export function BlockResourceErrorPlaceholder() {
151
+ const { t } = useTranslation();
152
+ const model = useFlowModel() as RefreshableBlockModel;
153
+ const { dataSourceName } = useMemo(() => getBlockResourceInfo(model, t), [model, t]);
154
+ const handleRetry = useMemoizedFn(async () => {
155
+ try {
156
+ await model.refresh?.();
157
+ } catch {
158
+ // The resource retains the latest error and keeps this placeholder visible.
159
+ }
160
+ });
161
+
162
+ return (
163
+ <BlockItemCard ref={model.context.ref} {...model.decoratorProps} role="alert" aria-live="polite">
164
+ <Result
165
+ status="warning"
166
+ title={t('Data loading failed')}
167
+ subTitle={getTemporarilyUnavailableMessage(t, dataSourceName)}
168
+ extra={
169
+ <Button type="primary" onClick={handleRetry}>
170
+ {t('Try again')}
171
+ </Button>
172
+ }
173
+ />
174
+ </BlockItemCard>
175
+ );
176
+ }
@@ -8,9 +8,9 @@
8
8
  */
9
9
 
10
10
  import { FlowModelProvider } from '@nocobase/flow-engine';
11
- import { render, screen } from '@testing-library/react';
11
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
12
12
  import React from 'react';
13
- import { BlockDeletePlaceholder } from '../BlockPlaceholder';
13
+ import { BlockDeletePlaceholder, BlockResourceErrorPlaceholder } from '../BlockPlaceholder';
14
14
 
15
15
  vi.mock('react-i18next', () => ({
16
16
  useTranslation: () => ({
@@ -23,8 +23,9 @@ vi.mock('react-i18next', () => ({
23
23
  function createModel(options: {
24
24
  dataSourceKey?: string;
25
25
  collectionName?: string;
26
- dataSource?: { key: string; displayName?: string };
26
+ dataSource?: { key: string; displayName?: string; status?: string };
27
27
  flowSettingsEnabled?: boolean;
28
+ refresh?: () => Promise<unknown>;
28
29
  }) {
29
30
  const model: any = {
30
31
  context: {
@@ -38,6 +39,7 @@ function createModel(options: {
38
39
  dataSourceKey: options.dataSourceKey,
39
40
  collectionName: options.collectionName,
40
41
  }),
42
+ refresh: options.refresh,
41
43
  };
42
44
  model.context.blockModel = model;
43
45
  return model;
@@ -64,25 +66,47 @@ describe('BlockPlaceholder', () => {
64
66
  ).toBeInTheDocument();
65
67
  });
66
68
 
67
- it('should hide data source unavailable block outside configuration mode', () => {
69
+ it('should render data source unavailable placeholder outside configuration mode', () => {
68
70
  const model = createModel({
69
71
  dataSourceKey: 'external-mysql',
70
72
  collectionName: 'orders',
71
73
  flowSettingsEnabled: false,
72
74
  });
73
75
 
74
- const { container } = render(
76
+ render(
75
77
  <FlowModelProvider model={model}>
76
78
  <BlockDeletePlaceholder />
77
79
  </FlowModelProvider>,
78
80
  );
79
81
 
80
82
  expect(
81
- screen.queryByText(
83
+ screen.getByText(
82
84
  'The data source "external-mysql" used by this block is disabled or unavailable. Enable the data source to display this block.',
83
85
  ),
86
+ ).toBeInTheDocument();
87
+ });
88
+
89
+ it('should treat a failed data source as unavailable', () => {
90
+ const model = createModel({
91
+ dataSourceKey: 'external-mysql',
92
+ collectionName: 'orders',
93
+ dataSource: { key: 'external-mysql', displayName: 'External MySQL', status: 'loading-failed' },
94
+ });
95
+
96
+ render(
97
+ <FlowModelProvider model={model}>
98
+ <BlockDeletePlaceholder />
99
+ </FlowModelProvider>,
100
+ );
101
+
102
+ expect(
103
+ screen.getByText(
104
+ 'The data source "External MySQL" is temporarily unavailable. Please try again later or contact an administrator.',
105
+ ),
106
+ ).toBeInTheDocument();
107
+ expect(
108
+ screen.queryByText('The Collection "External MySQL > orders" may have been deleted. Please remove this Block.'),
84
109
  ).not.toBeInTheDocument();
85
- expect(container).toBeEmptyDOMElement();
86
110
  });
87
111
 
88
112
  it('should keep collection deleted placeholder when the data source is available', () => {
@@ -102,4 +126,30 @@ describe('BlockPlaceholder', () => {
102
126
  screen.getByText('The Collection "External MySQL > orders" may have been deleted. Please remove this Block.'),
103
127
  ).toBeInTheDocument();
104
128
  });
129
+
130
+ it('should render a retryable resource error placeholder', async () => {
131
+ const refresh = vi.fn().mockRejectedValue(new Error('still unavailable'));
132
+ const model = createModel({
133
+ dataSourceKey: 'external-mysql',
134
+ collectionName: 'orders',
135
+ dataSource: { key: 'external-mysql', displayName: 'External MySQL' },
136
+ refresh,
137
+ });
138
+
139
+ render(
140
+ <FlowModelProvider model={model}>
141
+ <BlockResourceErrorPlaceholder />
142
+ </FlowModelProvider>,
143
+ );
144
+
145
+ expect(screen.getByText('Data loading failed')).toBeInTheDocument();
146
+ expect(
147
+ screen.getByText(
148
+ 'The data source "External MySQL" is temporarily unavailable. Please try again later or contact an administrator.',
149
+ ),
150
+ ).toBeInTheDocument();
151
+
152
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
153
+ await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
154
+ });
105
155
  });
@@ -20,7 +20,7 @@ type OperatorMeta = {
20
20
  };
21
21
 
22
22
  type ComponentRegistry = {
23
- getComponent?: (name: string) => ComponentType<Record<string, unknown>> | undefined;
23
+ getComponent?: (name: string, isShowError?: boolean) => ComponentType<Record<string, unknown>> | undefined;
24
24
  };
25
25
 
26
26
  type OperatorComponentFieldModel = {
@@ -67,6 +67,7 @@ export function resolveOperatorComponent(
67
67
  app: ComponentRegistry | undefined,
68
68
  operator: string,
69
69
  operators?: OperatorMeta[],
70
+ isShowError = true,
70
71
  ) {
71
72
  if (!operators || !Array.isArray(operators)) return null;
72
73
  const op = operators.find((item) => item?.value === operator);
@@ -77,7 +78,7 @@ export function resolveOperatorComponent(
77
78
  if (xComp === 'DateFilterDynamicComponent') {
78
79
  Comp = DateFilterDynamicComponent as ComponentType<Record<string, unknown>>;
79
80
  } else {
80
- Comp = app?.getComponent?.(xComp);
81
+ Comp = isShowError ? app?.getComponent?.(xComp) : app?.getComponent?.(xComp, false);
81
82
  }
82
83
  if (!Comp) return null;
83
84
  const props = isRecord(schema?.['x-component-props']) ? schema['x-component-props'] : {};
@@ -22,6 +22,11 @@ import { applyUpdateRecordAction } from './UpdateRecordActionUtils';
22
22
  // import { RemoteFlowModelRenderer } from '../../FlowPage';
23
23
 
24
24
  const SETTINGS_FLOW_KEY = 'assignSettings';
25
+ const AFTER_SUCCESS_DEFAULT_PARAMS = {
26
+ successMessage: tExpr('Saved successfully'),
27
+ manualClose: false,
28
+ actionAfterSuccess: 'stay',
29
+ };
25
30
 
26
31
  function Info() {
27
32
  const ctx = useFlowSettingsContext();
@@ -101,6 +106,10 @@ UpdateRecordActionModel.registerFlow({
101
106
  tipComponent: Info,
102
107
  validateBeforeSave: true,
103
108
  }),
109
+ afterSuccess: {
110
+ use: 'afterSuccess',
111
+ defaultParams: AFTER_SUCCESS_DEFAULT_PARAMS,
112
+ },
104
113
  },
105
114
  });
106
115
 
@@ -113,7 +122,15 @@ UpdateRecordActionModel.registerFlow({
113
122
  return getAssignFieldValuesDefaultParams(ctx, SETTINGS_FLOW_KEY);
114
123
  },
115
124
  async handler(ctx, params) {
116
- await applyUpdateRecordAction(ctx, params, { settingsFlowKey: SETTINGS_FLOW_KEY });
125
+ const updated = await applyUpdateRecordAction(ctx, params, { settingsFlowKey: SETTINGS_FLOW_KEY });
126
+ if (!updated) {
127
+ return;
128
+ }
129
+ const savedAfterSuccess = ctx.model.getStepParams(SETTINGS_FLOW_KEY, 'afterSuccess') || {};
130
+ await ctx.runAction('afterSuccess', {
131
+ ...AFTER_SUCCESS_DEFAULT_PARAMS,
132
+ ...savedAfterSuccess,
133
+ });
117
134
  },
118
135
  },
119
136
  },
@@ -8,9 +8,15 @@
8
8
  */
9
9
 
10
10
  import { MultiRecordResource, SingleRecordResource } from '@nocobase/flow-engine';
11
+ import type { AxiosRequestConfig } from 'axios';
11
12
  import { dispatchEventDeep } from '../../utils';
12
13
  import { resolveAssignFieldValues } from '../blocks/assign-form/assignFieldValuesFlow';
13
14
 
15
+ type UpdateRecordActionParams = {
16
+ assignedValues?: unknown;
17
+ requestConfig?: AxiosRequestConfig;
18
+ };
19
+
14
20
  export async function refreshLinkageRulesAfterUpdate(ctx: any) {
15
21
  const blockModel = ctx?.blockModel || ctx?.model?.context?.blockModel || ctx?.model;
16
22
  const actionModel = ctx?.model;
@@ -48,11 +54,11 @@ export async function refreshLinkageRulesAfterUpdate(ctx: any) {
48
54
 
49
55
  export async function applyUpdateRecordAction(
50
56
  ctx: any,
51
- params: any,
57
+ params: UpdateRecordActionParams,
52
58
  options?: {
53
59
  settingsFlowKey?: string;
54
60
  },
55
- ) {
61
+ ): Promise<boolean> {
56
62
  const settingsFlowKey = options?.settingsFlowKey || 'assignSettings';
57
63
 
58
64
  // 统一接入二次确认:如果启用则弹窗;未配置时默认不启用
@@ -62,25 +68,31 @@ export async function applyUpdateRecordAction(
62
68
 
63
69
  const assignedValues = await resolveAssignFieldValues(ctx, params?.assignedValues, 'UpdateRecordAction');
64
70
  if (!assignedValues) {
65
- return;
71
+ return false;
66
72
  }
67
73
 
68
74
  if (!assignedValues || typeof assignedValues !== 'object' || !Object.keys(assignedValues).length) {
69
75
  ctx.message.warning(ctx.t('No assigned fields configured'));
70
- return;
76
+ return false;
71
77
  }
72
78
  const collection = ctx.collection?.name;
73
79
  const filterByTk = ctx.collection?.getFilterByTK?.(ctx.record);
74
80
  if (!collection || typeof filterByTk === 'undefined' || filterByTk === null) {
75
81
  ctx.message.error(ctx.t('Record is required to perform this action'));
76
- return;
82
+ return false;
77
83
  }
84
+ let updated = false;
78
85
  if (ctx.resource instanceof SingleRecordResource) {
79
86
  await ctx.resource.save(assignedValues, params.requestConfig);
87
+ updated = true;
80
88
  } else if (ctx.resource instanceof MultiRecordResource) {
81
89
  await ctx.resource.update(filterByTk, assignedValues, params.requestConfig);
90
+ updated = true;
91
+ }
92
+ if (!updated) {
93
+ return false;
82
94
  }
83
95
 
84
96
  await refreshLinkageRulesAfterUpdate(ctx);
85
- ctx.message.success(ctx.t('Saved successfully'));
97
+ return true;
86
98
  }
@@ -8,8 +8,9 @@
8
8
  */
9
9
 
10
10
  import { beforeEach, describe, expect, it, vi } from 'vitest';
11
- import { MultiRecordResource } from '@nocobase/flow-engine';
11
+ import { FlowEngine, MultiRecordResource } from '@nocobase/flow-engine';
12
12
  import { applyUpdateRecordAction } from '../UpdateRecordActionUtils';
13
+ import { UpdateRecordActionModel } from '../UpdateRecordActionModel';
13
14
  import { dispatchEventDeep } from '../../../utils';
14
15
 
15
16
  vi.mock('../../../utils', () => ({
@@ -21,7 +22,7 @@ describe('UpdateRecordActionModel apply action', () => {
21
22
  vi.clearAllMocks();
22
23
  });
23
24
 
24
- it('dispatches paginationChange for action and block after successful update', async () => {
25
+ it('dispatches paginationChange and returns success after updating the record', async () => {
25
26
  const resource: any = Object.create(MultiRecordResource.prototype);
26
27
  resource.update = vi.fn(async () => ({}));
27
28
  resource.refresh = vi.fn(async () => {});
@@ -52,12 +53,13 @@ describe('UpdateRecordActionModel apply action', () => {
52
53
  t: (value: string) => value,
53
54
  };
54
55
 
55
- await applyUpdateRecordAction(ctx, {
56
+ const updated = await applyUpdateRecordAction(ctx, {
56
57
  assignedValues: {
57
58
  marital_status: '已婚',
58
59
  },
59
60
  });
60
61
 
62
+ expect(updated).toBe(true);
61
63
  expect(resource.update).toHaveBeenCalledWith(1, { marital_status: '已婚' }, undefined);
62
64
  expect(resource.refresh).not.toHaveBeenCalled();
63
65
 
@@ -67,6 +69,76 @@ describe('UpdateRecordActionModel apply action', () => {
67
69
  expect(paginationCalls.length).toBeGreaterThan(0);
68
70
  expect(paginationCalls.some(([model]: [any]) => model === ctx.model)).toBe(true);
69
71
  expect(paginationCalls.some(([model]: [any]) => model === blockModel)).toBe(true);
70
- expect(ctx.message.success).toHaveBeenCalledWith('Saved successfully');
72
+ expect(ctx.message.success).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it('runs the configured after-success action only after a successful update', async () => {
76
+ const engine = new FlowEngine();
77
+ const action = new UpdateRecordActionModel({ uid: 'update-record-action', flowEngine: engine } as any);
78
+ action.setStepParams('assignSettings', 'confirm', { enable: false });
79
+ action.setStepParams('assignSettings', 'afterSuccess', {
80
+ successMessage: 'Record updated',
81
+ });
82
+
83
+ const resource: any = Object.create(MultiRecordResource.prototype);
84
+ resource.update = vi.fn(async () => ({}));
85
+ const blockModel: any = { uid: 'details-block' };
86
+ const runAction = vi.fn(async () => {});
87
+ const ctx: any = {
88
+ model: action,
89
+ blockModel,
90
+ runAction,
91
+ collection: {
92
+ name: 'users',
93
+ getFilterByTK: vi.fn(() => 1),
94
+ },
95
+ record: { id: 1 },
96
+ resource,
97
+ message: {
98
+ success: vi.fn(),
99
+ warning: vi.fn(),
100
+ error: vi.fn(),
101
+ },
102
+ t: (value: string) => value,
103
+ };
104
+ const handler = action.getFlow('apply')?.getStep('apply')?.serialize().handler;
105
+
106
+ await handler(ctx, { assignedValues: { status: 'active' } });
107
+
108
+ expect(runAction).toHaveBeenNthCalledWith(1, 'confirm', { enable: false });
109
+ expect(runAction).toHaveBeenNthCalledWith(2, 'afterSuccess', {
110
+ successMessage: 'Record updated',
111
+ manualClose: false,
112
+ actionAfterSuccess: 'stay',
113
+ });
114
+ });
115
+
116
+ it('does not run the after-success action when no fields are assigned', async () => {
117
+ const engine = new FlowEngine();
118
+ const action = new UpdateRecordActionModel({ uid: 'update-record-action-empty', flowEngine: engine } as any);
119
+ const runAction = vi.fn(async () => {});
120
+ const ctx: any = {
121
+ model: action,
122
+ runAction,
123
+ collection: {
124
+ name: 'users',
125
+ getFilterByTK: vi.fn(() => 1),
126
+ },
127
+ record: { id: 1 },
128
+ resource: Object.create(MultiRecordResource.prototype),
129
+ message: {
130
+ success: vi.fn(),
131
+ warning: vi.fn(),
132
+ error: vi.fn(),
133
+ },
134
+ t: (value: string) => value,
135
+ };
136
+ const handler = action.getFlow('apply')?.getStep('apply')?.serialize().handler;
137
+
138
+ await handler(ctx, { assignedValues: {} });
139
+
140
+ expect(runAction).toHaveBeenCalledTimes(1);
141
+ expect(runAction).toHaveBeenCalledWith('confirm', { enable: false });
142
+ expect(ctx.message.warning).toHaveBeenCalledWith('No assigned fields configured');
71
143
  });
72
144
  });