@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.
Files changed (69) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  3. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  4. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  5. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  6. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  7. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  8. package/es/flow/models/blocks/form/FormBlockModel.d.ts +1 -0
  9. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  10. package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
  11. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  12. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  13. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  14. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  15. package/es/flow-compat/passwordUtils.d.ts +1 -1
  16. package/es/index.mjs +125 -116
  17. package/lib/index.js +162 -153
  18. package/package.json +7 -7
  19. package/src/APIClient.ts +92 -0
  20. package/src/Application.tsx +3 -1
  21. package/src/__tests__/APIClient.test.tsx +58 -0
  22. package/src/__tests__/settings-center.test.tsx +270 -6
  23. package/src/acl/ACLProvider.tsx +2 -0
  24. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  25. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  26. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  27. package/src/components/form/table/Table.tsx +81 -6
  28. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  29. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  30. package/src/flow/actions/linkageRules.tsx +8 -3
  31. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  32. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  33. package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
  34. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  35. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  36. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  37. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  38. package/src/flow/models/base/CollectionBlockModel.tsx +23 -1
  39. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +65 -9
  40. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  41. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  42. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  43. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  44. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  45. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  46. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  47. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  48. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  49. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  50. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  51. package/src/flow/models/blocks/form/FormBlockModel.tsx +43 -0
  52. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  53. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
  54. package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
  55. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  56. package/src/flow/models/blocks/table/utils.ts +7 -0
  57. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  58. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  59. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  60. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +121 -3
  61. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  62. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  63. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  64. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  65. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  66. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -0
  67. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  68. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +122 -14
  69. 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 { 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'] : {};
@@ -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
- await ctx.model.resource.refresh();
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
  },
@@ -7,9 +7,10 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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
- const ds = engine.dataSourceManager.getDataSource('main');
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: 'main',
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
- describe('CollectionBlockModel initial beforeRender refresh', () => {
92
- beforeEach(() => {
93
- vi.useFakeTimers();
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
- defaultCurrent={resource.getPage()}
158
- total={resource.getTotalPage()}
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}>{model.renderPagination()}</div>
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 getAssociationFilterTargetKey(field: any) {
63
- const filterTargetKey = field?.targetCollection?.filterTargetKey;
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 (Array.isArray(filterTargetKey)) {
66
- return filterTargetKey[0] || 'id';
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
  }