@nocobase/client-v2 2.2.0-beta.17 → 2.2.0-beta.18
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/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
- package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -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 +1 -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.mjs +125 -116
- package/lib/index.js +162 -153
- 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__/settings-center.test.tsx +270 -6
- package/src/acl/ACLProvider.tsx +2 -0
- package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
- 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.subFormSetFieldProps.test.ts +72 -0
- package/src/flow/actions/linkageRules.tsx +8 -3
- 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/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/base/CollectionBlockModel.tsx +23 -1
- package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +65 -9
- 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 +43 -0
- package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
- 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__/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/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 +121 -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 +5 -0
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +122 -14
- package/src/settings-center/AdminSettingsLayout.tsx +23 -2
|
@@ -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'] : {};
|
|
@@ -16,10 +16,13 @@ import {
|
|
|
16
16
|
tExpr,
|
|
17
17
|
FlowModelContext,
|
|
18
18
|
MultiRecordResource,
|
|
19
|
+
ResourceError,
|
|
19
20
|
SingleRecordResource,
|
|
20
21
|
} from '@nocobase/flow-engine';
|
|
21
22
|
import type { FlowEngine } from '@nocobase/flow-engine';
|
|
22
23
|
import _ from 'lodash';
|
|
24
|
+
import React from 'react';
|
|
25
|
+
import { BlockResourceErrorPlaceholder } from '../../components/placeholders/BlockPlaceholder';
|
|
23
26
|
import { createDefaultCollectionBlockTitle } from '../../utils/blockUtils';
|
|
24
27
|
import { areCapabilitiesSupported, getBlockCapabilityNamesFromModelClass } from '../../utils/actionCapability';
|
|
25
28
|
import { FilterManager } from '../blocks/filter-manager/FilterManager';
|
|
@@ -548,6 +551,18 @@ export class CollectionBlockModel<T = DefaultStructure> extends DataBlockModel<T
|
|
|
548
551
|
return this.resource.refresh();
|
|
549
552
|
}
|
|
550
553
|
|
|
554
|
+
isExternalDataSource() {
|
|
555
|
+
const dataSourceKey = this.getResourceSettingsInitParams()?.dataSourceKey;
|
|
556
|
+
return Boolean(dataSourceKey && dataSourceKey !== 'main');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
render() {
|
|
560
|
+
if (this.isExternalDataSource() && this.collection && this.resource?.getError()) {
|
|
561
|
+
return <BlockResourceErrorPlaceholder />;
|
|
562
|
+
}
|
|
563
|
+
return super.render();
|
|
564
|
+
}
|
|
565
|
+
|
|
551
566
|
protected defaultBlockTitle() {
|
|
552
567
|
const blockLabel = this.translate(this.constructor['meta']?.label || this.constructor.name);
|
|
553
568
|
const params = this.getResourceSettingsInitParams();
|
|
@@ -706,7 +721,14 @@ CollectionBlockModel.registerFlow({
|
|
|
706
721
|
if (ctx.model.isManualRefresh) {
|
|
707
722
|
ctx.model.resource.loading = false;
|
|
708
723
|
} else {
|
|
709
|
-
|
|
724
|
+
try {
|
|
725
|
+
await resource.refresh();
|
|
726
|
+
} catch (error) {
|
|
727
|
+
if (blockModel.isExternalDataSource() && error instanceof ResourceError && resource.getError() === error) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
710
732
|
}
|
|
711
733
|
},
|
|
712
734
|
},
|
package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { afterEach,
|
|
11
|
-
import { FlowEngine, MultiRecordResource } from '@nocobase/flow-engine';
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { FlowEngine, FlowRuntimeContext, MultiRecordResource, ResourceError } from '@nocobase/flow-engine';
|
|
12
12
|
import '../../../index';
|
|
13
|
+
import { BlockResourceErrorPlaceholder } from '../../../components/placeholders/BlockPlaceholder';
|
|
13
14
|
import { CollectionBlockModel } from '../CollectionBlockModel';
|
|
14
15
|
import { dataLoadingMode } from '../../../actions/dataLoadingMode';
|
|
15
16
|
|
|
@@ -19,13 +20,16 @@ class InitialBeforeRenderTestBlockModel extends CollectionBlockModel {
|
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
function setupModel() {
|
|
23
|
+
function setupModel(dataSourceKey = 'main') {
|
|
23
24
|
const engine = new FlowEngine();
|
|
24
25
|
engine.context.defineProperty('location', { value: { search: '' } as any });
|
|
25
26
|
engine.registerActions({ dataLoadingMode });
|
|
26
27
|
engine.registerModels({ InitialBeforeRenderTestBlockModel });
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
if (!engine.dataSourceManager.getDataSource(dataSourceKey)) {
|
|
30
|
+
engine.dataSourceManager.addDataSource({ key: dataSourceKey });
|
|
31
|
+
}
|
|
32
|
+
const ds = engine.dataSourceManager.getDataSource(dataSourceKey);
|
|
29
33
|
ds.addCollection({
|
|
30
34
|
name: 'users',
|
|
31
35
|
filterTargetKey: 'id',
|
|
@@ -41,7 +45,7 @@ function setupModel() {
|
|
|
41
45
|
stepParams: {
|
|
42
46
|
resourceSettings: {
|
|
43
47
|
init: {
|
|
44
|
-
dataSourceKey
|
|
48
|
+
dataSourceKey,
|
|
45
49
|
collectionName: 'users',
|
|
46
50
|
},
|
|
47
51
|
},
|
|
@@ -88,16 +92,21 @@ function setupModelWithManualMode() {
|
|
|
88
92
|
return { model, resource: model.resource as MultiRecordResource };
|
|
89
93
|
}
|
|
90
94
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
async function runRefreshStep(model: InitialBeforeRenderTestBlockModel) {
|
|
96
|
+
const handler = model.getFlow('refreshSettings')?.getStep('refresh')?.serialize().handler;
|
|
97
|
+
if (!handler) {
|
|
98
|
+
throw new Error('refreshSettings.refresh handler is not registered');
|
|
99
|
+
}
|
|
100
|
+
await handler(new FlowRuntimeContext(model, 'refreshSettings'), {});
|
|
101
|
+
}
|
|
95
102
|
|
|
103
|
+
describe('CollectionBlockModel initial beforeRender refresh', () => {
|
|
96
104
|
afterEach(() => {
|
|
97
105
|
vi.useRealTimers();
|
|
98
106
|
});
|
|
99
107
|
|
|
100
108
|
it('only performs one actual data request on first beforeRender', async () => {
|
|
109
|
+
vi.useFakeTimers();
|
|
101
110
|
const { model, resource } = setupModel();
|
|
102
111
|
const runActionSpy = vi.spyOn(resource, 'runAction').mockResolvedValue({
|
|
103
112
|
data: [{ id: 1, name: 'Alice' }],
|
|
@@ -112,7 +121,54 @@ describe('CollectionBlockModel initial beforeRender refresh', () => {
|
|
|
112
121
|
expect(runActionSpy).toHaveBeenCalledTimes(1);
|
|
113
122
|
});
|
|
114
123
|
|
|
124
|
+
it('keeps resource request failures in the block error state', async () => {
|
|
125
|
+
const { model, resource } = setupModel('external');
|
|
126
|
+
const resourceError = new ResourceError({
|
|
127
|
+
response: {
|
|
128
|
+
data: {
|
|
129
|
+
errors: [{ code: 'DATA_SOURCE_UNAVAILABLE', message: 'Data source unavailable' }],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
vi.spyOn(resource, 'refresh').mockImplementation(async () => {
|
|
134
|
+
resource.setError(resourceError);
|
|
135
|
+
throw resourceError;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await runRefreshStep(model);
|
|
139
|
+
|
|
140
|
+
expect(resource.getError()).toBe(resourceError);
|
|
141
|
+
expect(CollectionBlockModel.prototype.render.call(model)).toMatchObject({
|
|
142
|
+
type: BlockResourceErrorPlaceholder,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('continues to throw resource request failures from the main data source', async () => {
|
|
147
|
+
const { model, resource } = setupModel();
|
|
148
|
+
const resourceError = new ResourceError({
|
|
149
|
+
response: {
|
|
150
|
+
data: {
|
|
151
|
+
errors: [{ code: 'UNKNOWN_ERROR', message: 'Main data source failed' }],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
vi.spyOn(resource, 'refresh').mockImplementation(async () => {
|
|
156
|
+
resource.setError(resourceError);
|
|
157
|
+
throw resourceError;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await expect(runRefreshStep(model)).rejects.toBe(resourceError);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('continues to throw non-resource errors from beforeRender', async () => {
|
|
164
|
+
const { model, resource } = setupModel('external');
|
|
165
|
+
vi.spyOn(resource, 'refresh').mockRejectedValue(new Error('beforeRender bug'));
|
|
166
|
+
|
|
167
|
+
await expect(runRefreshStep(model)).rejects.toThrow('beforeRender bug');
|
|
168
|
+
});
|
|
169
|
+
|
|
115
170
|
it('respects saved manual mode and skips initial request when filters are empty', async () => {
|
|
171
|
+
vi.useFakeTimers();
|
|
116
172
|
const { model, resource } = setupModelWithManualMode();
|
|
117
173
|
const runActionSpy = vi.spyOn(resource, 'runAction').mockResolvedValue({
|
|
118
174
|
data: [{ id: 1, name: 'Alice' }],
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
SingleRecordResource,
|
|
21
21
|
createCurrentRecordMetaFactory,
|
|
22
22
|
createRecordResolveOnServerWithLocal,
|
|
23
|
+
observer,
|
|
23
24
|
tExpr,
|
|
24
25
|
} from '@nocobase/flow-engine';
|
|
25
26
|
import { Pagination, Space } from 'antd';
|
|
@@ -154,8 +155,8 @@ export class DetailsBlockModel extends CollectionBlockModel<{
|
|
|
154
155
|
simple
|
|
155
156
|
pageSize={1}
|
|
156
157
|
showSizeChanger={false}
|
|
157
|
-
|
|
158
|
-
total={resource.
|
|
158
|
+
current={resource.getPage()}
|
|
159
|
+
total={resource.getCount()}
|
|
159
160
|
onChange={this.handlePageChange}
|
|
160
161
|
style={{ display: 'inline-block' }}
|
|
161
162
|
/>
|
|
@@ -226,6 +227,8 @@ export class DetailsBlockModel extends CollectionBlockModel<{
|
|
|
226
227
|
}
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
export const DetailsPagination = observer(({ model }: { model: DetailsBlockModel }) => model.renderPagination());
|
|
231
|
+
|
|
229
232
|
const DetailsBlockContent = ({
|
|
230
233
|
model,
|
|
231
234
|
gridModel,
|
|
@@ -297,7 +300,9 @@ const DetailsBlockContent = ({
|
|
|
297
300
|
model={gridModel}
|
|
298
301
|
showFlowSettings={false}
|
|
299
302
|
/>
|
|
300
|
-
<div ref={paginationRef}>
|
|
303
|
+
<div ref={paginationRef}>
|
|
304
|
+
<DetailsPagination model={model} />
|
|
305
|
+
</div>
|
|
301
306
|
</div>
|
|
302
307
|
</FormComponent>
|
|
303
308
|
);
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { act, render, screen } from '@testing-library/react';
|
|
10
11
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
12
|
import { FlowEngine, MultiRecordResource, SingleRecordResource } from '@nocobase/flow-engine';
|
|
13
|
+
import React from 'react';
|
|
12
14
|
import '../../../../index';
|
|
13
|
-
import { DetailsBlockModel } from '../DetailsBlockModel';
|
|
15
|
+
import { DetailsBlockModel, DetailsPagination } from '../DetailsBlockModel';
|
|
14
16
|
import { DetailsGridModel } from '../DetailsGridModel';
|
|
15
17
|
|
|
16
18
|
function setupDetailsBlockModel(options?: { filterByTk?: string | number }) {
|
|
@@ -93,6 +95,35 @@ describe('DetailsBlockModel initial pagination refresh', () => {
|
|
|
93
95
|
expect(dispatchSpy.mock.calls.filter(([eventName]) => eventName === 'paginationChange')).toHaveLength(1);
|
|
94
96
|
});
|
|
95
97
|
|
|
98
|
+
it('renders pagination from the current count and page after filtering', () => {
|
|
99
|
+
const { model, resource } = setupDetailsBlockModel();
|
|
100
|
+
resource.setData([{ id: 3, name: 'CC', title: 'filtered' }] as any);
|
|
101
|
+
resource.setMeta({ count: 7, page: 3, pageSize: 1, totalPage: 20 });
|
|
102
|
+
|
|
103
|
+
const paginationContainer = model.renderPagination() as any;
|
|
104
|
+
const pagination = paginationContainer.props.children;
|
|
105
|
+
|
|
106
|
+
expect(pagination.props.total).toBe(7);
|
|
107
|
+
expect(pagination.props.current).toBe(3);
|
|
108
|
+
expect(pagination.props.defaultCurrent).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('updates the rendered pagination when filtering changes only resource metadata', () => {
|
|
112
|
+
const { model, resource } = setupDetailsBlockModel();
|
|
113
|
+
resource.setData([{ id: 1, name: 'AA', title: 'foo' }] as any);
|
|
114
|
+
resource.setMeta({ count: 7, page: 1, pageSize: 1, totalPage: 7 });
|
|
115
|
+
|
|
116
|
+
render(React.createElement(DetailsPagination, { model }));
|
|
117
|
+
|
|
118
|
+
expect(screen.getByRole('list')).toBeInTheDocument();
|
|
119
|
+
|
|
120
|
+
act(() => {
|
|
121
|
+
resource.setMeta({ count: 1, page: 1, pageSize: 1, totalPage: 1 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(screen.queryByRole('list')).not.toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
|
|
96
127
|
it('uses a real single-record details model and does not emit root paginationChange on initial render', async () => {
|
|
97
128
|
const { model, resource } = setupDetailsBlockModel({ filterByTk: 1 });
|
|
98
129
|
expect(resource).toBeInstanceOf(SingleRecordResource);
|
|
@@ -59,11 +59,42 @@ export class FilterFormGridModel extends GridModel {
|
|
|
59
59
|
|
|
60
60
|
readonly loading = observable.ref(false);
|
|
61
61
|
|
|
62
|
-
private
|
|
63
|
-
const
|
|
62
|
+
private isRecordSelectFilterField(subModel: FilterFormItemModel) {
|
|
63
|
+
const fieldModel = subModel.subModels?.field;
|
|
64
|
+
if (!fieldModel) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const RecordSelectModel = this.flowEngine.getModelClass('FilterFormRecordSelectFieldModel');
|
|
69
|
+
if (!RecordSelectModel) {
|
|
70
|
+
return fieldModel.constructor?.name === 'FilterFormRecordSelectFieldModel';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return fieldModel instanceof RecordSelectModel;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private getAssociationFilterTargetKey(field: any, subModel: FilterFormItemModel) {
|
|
77
|
+
const normalizeKey = (key: any) => {
|
|
78
|
+
if (Array.isArray(key)) {
|
|
79
|
+
return key[0];
|
|
80
|
+
}
|
|
81
|
+
return key;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const fieldModelValueKey = normalizeKey(subModel.subModels?.field?.props?.fieldNames?.value);
|
|
85
|
+
if (fieldModelValueKey) {
|
|
86
|
+
return fieldModelValueKey;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const filterTargetKey = normalizeKey(field?.targetCollection?.filterTargetKey);
|
|
90
|
+
const fieldTargetKey = normalizeKey(field?.targetKey);
|
|
91
|
+
|
|
92
|
+
if (filterTargetKey && filterTargetKey !== 'id') {
|
|
93
|
+
return filterTargetKey;
|
|
94
|
+
}
|
|
64
95
|
|
|
65
|
-
if (
|
|
66
|
-
return
|
|
96
|
+
if (!this.isRecordSelectFilterField(subModel) && fieldTargetKey) {
|
|
97
|
+
return fieldTargetKey;
|
|
67
98
|
}
|
|
68
99
|
|
|
69
100
|
return filterTargetKey || 'id';
|
|
@@ -370,7 +401,7 @@ export class FilterFormGridModel extends GridModel {
|
|
|
370
401
|
if (field?.target) {
|
|
371
402
|
return {
|
|
372
403
|
targetId: model.uid,
|
|
373
|
-
filterPaths: [`${fieldPath}.${this.getAssociationFilterTargetKey(field)}`],
|
|
404
|
+
filterPaths: [`${fieldPath}.${this.getAssociationFilterTargetKey(field, subModel)}`],
|
|
374
405
|
};
|
|
375
406
|
}
|
|
376
407
|
}
|