@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.
- package/es/APIClient.d.ts +12 -0
- package/es/acl/aclCheckReadiness.d.ts +11 -0
- package/es/authRedirect.d.ts +6 -2
- package/es/flow/actions/linkageRules.d.ts +24 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
- package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
- package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
- package/es/flow/components/field-value-variable/index.d.ts +11 -0
- package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
- package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
- package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
- package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
- package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
- package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
- package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
- package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
- package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
- package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
- package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
- package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
- package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
- package/es/flow/models/blocks/table/utils.d.ts +1 -0
- package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
- package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
- package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.d.ts +2 -1
- package/es/index.mjs +140 -140
- package/es/nocobase-buildin-plugin/index.d.ts +1 -0
- package/es/settings-app/SettingsGroupNav.d.ts +3 -2
- package/es/settings-app/SettingsSearch.d.ts +1 -1
- package/es/ui-operation/index.d.ts +15 -0
- package/es/ui-operation/ui-operation-codec.d.ts +10 -0
- package/lib/index.js +145 -145
- package/package.json +7 -7
- package/src/APIClient.ts +92 -0
- package/src/Application.tsx +3 -1
- package/src/__tests__/APIClient.test.tsx +58 -0
- package/src/__tests__/app.test.tsx +122 -3
- package/src/__tests__/authRedirect.test.ts +17 -0
- package/src/__tests__/browserChecker.test.ts +81 -33
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
- package/src/__tests__/settings-center.test.tsx +312 -11
- package/src/__tests__/settings-layout-root.test.tsx +346 -6
- package/src/__tests__/settings-runtime-paths.test.ts +1 -1
- package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
- package/src/__tests__/settings-shell.test.tsx +62 -1
- package/src/acl/ACLProvider.tsx +34 -3
- package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
- package/src/acl/aclCheckReadiness.ts +44 -0
- package/src/authRedirect.ts +13 -5
- package/src/components/AppComponents.tsx +16 -7
- package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
- package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
- package/src/components/form/table/Table.tsx +81 -6
- package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
- package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
- package/src/flow/actions/linkageRules.tsx +33 -10
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
- package/src/flow/components/FieldAssignValueInput.tsx +38 -621
- package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
- package/src/flow/components/field-value-variable/dateValue.ts +223 -0
- package/src/flow/components/field-value-variable/index.ts +12 -0
- package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
- package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
- package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
- package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
- package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
- package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
- package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
- package/src/flow/models/base/CollectionBlockModel.tsx +32 -1
- package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
- package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
- package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
- package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
- package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
- package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
- package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
- package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
- package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
- package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
- package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
- package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
- package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
- package/src/flow/models/blocks/table/utils.ts +7 -0
- package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
- package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
- package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
- package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
- package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
- package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
- package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
- package/src/index.ts +2 -0
- package/src/layout-manager/LayoutContentRoute.tsx +2 -1
- package/src/layout-manager/LayoutRoute.tsx +2 -1
- package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
- package/src/nocobase-buildin-plugin/index.tsx +14 -1
- package/src/settings-app/SettingsGroupNav.tsx +11 -11
- package/src/settings-app/SettingsSearch.tsx +115 -10
- package/src/settings-app/SettingsShell.tsx +23 -6
- package/src/settings-app/runtimePaths.ts +0 -1
- package/src/settings-app/settingsTheme.ts +82 -14
- package/src/settings-center/AdminSettingsLayout.tsx +132 -107
- package/src/settings-center/__tests__/groups.test.ts +25 -0
- package/src/settings-center/groups.ts +1 -1
- package/src/ui-operation/index.ts +33 -0
- 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 {
|
|
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
|
-
|
|
52
|
+
isDataSourceLoadFailed,
|
|
53
|
+
isDataSourceUnavailable: Boolean(dataSourceKey && (!dataSource || isDataSourceLoadFailed)),
|
|
51
54
|
};
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
function
|
|
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 =
|
|
57
|
-
? t
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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=
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
76
|
+
render(
|
|
75
77
|
<FlowModelProvider model={model}>
|
|
76
78
|
<BlockDeletePlaceholder />
|
|
77
79
|
</FlowModelProvider>,
|
|
78
80
|
);
|
|
79
81
|
|
|
80
82
|
expect(
|
|
81
|
-
screen.
|
|
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:
|
|
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
|
-
|
|
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
|
|
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).
|
|
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
|
});
|