@nocobase/client-v2 2.1.0-beta.43 → 2.1.0-beta.45

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 (91) hide show
  1. package/es/BaseApplication.d.ts +9 -0
  2. package/es/authRedirect.d.ts +12 -0
  3. package/es/collection-manager/filter-operators/index.d.ts +1 -0
  4. package/es/components/form/filter/CollectionFilterItem.d.ts +5 -1
  5. package/es/components/form/filter/FilterValueInput.d.ts +5 -0
  6. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +11 -0
  8. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  9. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +3 -2
  10. package/es/flow/components/ConditionBuilder.d.ts +1 -0
  11. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  12. package/es/flow/components/FieldAssignValueInput.d.ts +1 -0
  13. package/es/flow/components/fieldAssignOptions.d.ts +10 -0
  14. package/es/flow/components/filter/LinkageFilterItem.d.ts +1 -0
  15. package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
  16. package/es/flow/components/filter/metaTreeAssociationDepth.d.ts +13 -0
  17. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +26 -3
  18. package/es/flow/models/base/AssociationFieldGroupModel.d.ts +2 -33
  19. package/es/flow/models/blocks/filter-manager/utils.d.ts +23 -1
  20. package/es/flow/models/blocks/table/TableColumnModel.d.ts +2 -0
  21. package/es/index.mjs +179 -101
  22. package/lib/index.js +187 -109
  23. package/package.json +7 -7
  24. package/src/BaseApplication.tsx +13 -0
  25. package/src/__tests__/browserChecker.test.ts +103 -0
  26. package/src/__tests__/dataSourceRuntime.test.tsx +22 -1
  27. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +5 -0
  28. package/src/authRedirect.ts +43 -0
  29. package/src/collection-field-interface/CollectionFieldInterface.ts +14 -7
  30. package/src/collection-manager/filter-operators/index.ts +29 -2
  31. package/src/components/form/filter/CollectionFilterItem.tsx +10 -2
  32. package/src/components/form/filter/FilterValueInput.tsx +27 -2
  33. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +39 -0
  34. package/src/flow/__tests__/FlowRoute.test.tsx +8 -1
  35. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +369 -2
  36. package/src/flow/actions/dataScope.tsx +8 -1
  37. package/src/flow/actions/linkageRules.tsx +56 -1
  38. package/src/flow/actions/setTargetDataScope.tsx +1 -1
  39. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +31 -3
  40. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +15 -1
  41. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +139 -0
  42. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +75 -1
  43. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  44. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +11 -0
  45. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -4
  46. package/src/flow/admin-shell/admin-layout/useApplications.tsx +34 -1
  47. package/src/flow/common/Markdown/Edit.tsx +32 -4
  48. package/src/flow/components/ConditionBuilder.tsx +12 -2
  49. package/src/flow/components/DefaultValue.tsx +14 -8
  50. package/src/flow/components/FieldAssignRulesEditor.tsx +37 -46
  51. package/src/flow/components/FieldAssignValueInput.tsx +14 -14
  52. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +112 -1
  53. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +71 -1
  54. package/src/flow/components/fieldAssignOptions.ts +23 -6
  55. package/src/flow/components/filter/LinkageFilterItem.tsx +26 -12
  56. package/src/flow/components/filter/VariableFilterItem.tsx +32 -13
  57. package/src/flow/components/filter/__tests__/fieldsToOptions.test.ts +48 -0
  58. package/src/flow/components/filter/__tests__/metaTreeAssociationDepth.test.ts +60 -0
  59. package/src/flow/components/filter/fieldsToOptions.ts +9 -4
  60. package/src/flow/components/filter/metaTreeAssociationDepth.ts +69 -0
  61. package/src/flow/internal/utils/operatorSchemaHelper.ts +112 -5
  62. package/src/flow/models/actions/FilterActionModel.tsx +15 -3
  63. package/src/flow/models/base/AssociationFieldGroupModel.tsx +20 -3
  64. package/src/flow/models/base/GridModel.tsx +1 -1
  65. package/src/flow/models/base/PageModel/ChildPageModel.tsx +5 -1
  66. package/src/flow/models/base/PageModel/__tests__/ChildPageModel.test.ts +12 -0
  67. package/src/flow/models/base/__tests__/AssociationFieldGroupModel.test.ts +86 -0
  68. package/src/flow/models/base/__tests__/GridModel.render.test.tsx +53 -0
  69. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +11 -0
  70. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +14 -12
  71. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +6 -1
  72. package/src/flow/models/blocks/filter-manager/__tests__/getDefaultOperator.test.ts +35 -1
  73. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +148 -0
  74. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/defaultOperator.test.tsx +85 -0
  75. package/src/flow/models/blocks/filter-manager/flow-actions/connectFields.tsx +48 -6
  76. package/src/flow/models/blocks/filter-manager/flow-actions/customizeFilterRender.tsx +14 -44
  77. package/src/flow/models/blocks/filter-manager/flow-actions/defaultOperator.tsx +3 -5
  78. package/src/flow/models/blocks/filter-manager/flow-actions/operatorComponentProps.tsx +2 -12
  79. package/src/flow/models/blocks/filter-manager/utils.ts +143 -4
  80. package/src/flow/models/blocks/form/FormAssociationItemModel.tsx +9 -0
  81. package/src/flow/models/blocks/form/FormItemModel.tsx +9 -0
  82. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  83. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +27 -2
  84. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +29 -0
  85. package/src/flow/models/blocks/form/value-runtime/runtime.ts +5 -1
  86. package/src/flow/models/blocks/table/TableColumnModel.tsx +15 -5
  87. package/src/flow/models/blocks/table/TableCustomColumnModel.tsx +2 -2
  88. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +18 -0
  89. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +6 -7
  90. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -3
  91. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +70 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.0-beta.43",
3
+ "version": "2.1.0-beta.45",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "2.1.0-beta.43",
31
- "@nocobase/flow-engine": "2.1.0-beta.43",
32
- "@nocobase/sdk": "2.1.0-beta.43",
33
- "@nocobase/shared": "2.1.0-beta.43",
34
- "@nocobase/utils": "2.1.0-beta.43",
30
+ "@nocobase/evaluators": "2.1.0-beta.45",
31
+ "@nocobase/flow-engine": "2.1.0-beta.45",
32
+ "@nocobase/sdk": "2.1.0-beta.45",
33
+ "@nocobase/shared": "2.1.0-beta.45",
34
+ "@nocobase/utils": "2.1.0-beta.45",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -45,5 +45,5 @@
45
45
  "react-i18next": "^11.15.1",
46
46
  "react-router-dom": "^6.30.1"
47
47
  },
48
- "gitHead": "6d7750e2373bf2451d246de88cc1f62491685e18"
48
+ "gitHead": "42587115fc34c3eb01ef2b2549f1c998e5708318"
49
49
  }
@@ -25,6 +25,7 @@ import { I18nextProvider } from 'react-i18next';
25
25
  import { ErrorBoundary } from 'react-error-boundary';
26
26
  import { Link, NavLink, Navigate } from 'react-router-dom';
27
27
  import { isValidElementType } from 'react-is';
28
+ import type { AppListProps } from '@ant-design/pro-layout/es/components/AppsLogoComponents/types';
28
29
  import AntdAppProvider from './theme/AntdAppProvider';
29
30
  import { GlobalThemeProvider } from './theme';
30
31
  import { AIManager } from './ai';
@@ -55,6 +56,7 @@ declare global {
55
56
  }
56
57
 
57
58
  type AnyComponent = RenderableComponentType<any>;
59
+ type AppListLoader = (app: BaseApplication<any>) => Promise<AppListProps> | AppListProps;
58
60
  type AuthTokenPayload = {
59
61
  token: string;
60
62
  authenticator: string | null;
@@ -155,8 +157,10 @@ export abstract class BaseApplication<
155
157
  private wsAuthorized = false;
156
158
  apps: {
157
159
  Component?: AnyComponent | null;
160
+ loadAppList?: AppListLoader | null;
158
161
  } = {
159
162
  Component: null,
163
+ loadAppList: null,
160
164
  };
161
165
 
162
166
  get pm(): TPluginManager {
@@ -234,6 +238,7 @@ export abstract class BaseApplication<
234
238
  maintained: observable.ref,
235
239
  maintaining: observable.ref,
236
240
  error: observable.ref,
241
+ apps: observable,
237
242
  });
238
243
  }
239
244
 
@@ -533,6 +538,14 @@ export abstract class BaseApplication<
533
538
  });
534
539
  }
535
540
 
541
+ setAppsComponent({ Component }: { Component: AnyComponent }) {
542
+ this.apps.Component = Component;
543
+ }
544
+
545
+ setAppsProvider({ loadAppList }: { loadAppList: AppListLoader }) {
546
+ this.apps.loadAppList = loadAppList;
547
+ }
548
+
536
549
  protected getRootFallback() {
537
550
  return this.renderComponent('AppSpin');
538
551
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { readFileSync } from 'node:fs';
11
+ import path from 'node:path';
12
+ import vm from 'node:vm';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ type BrowserCheckerCase = {
16
+ pathname: string;
17
+ publicPath: string;
18
+ expectedRedirect?: string;
19
+ };
20
+
21
+ const testDir = path.dirname(fileURLToPath(import.meta.url));
22
+ const browserCheckerCases = [
23
+ {
24
+ label: 'v2 public browser-checker',
25
+ scriptPath: path.resolve(testDir, '../../../app/client-v2/public/browser-checker.js'),
26
+ },
27
+ {
28
+ label: 'v1 public browser-checker',
29
+ scriptPath: path.resolve(testDir, '../../../app/client/public/browser-checker.js'),
30
+ },
31
+ ] as const;
32
+
33
+ function executeBrowserChecker(scriptPath: string, input: BrowserCheckerCase) {
34
+ const replace = vi.fn();
35
+ const consoleMock = {
36
+ debug: vi.fn(),
37
+ log: vi.fn(),
38
+ };
39
+ const context = {
40
+ showLog: false,
41
+ window: {
42
+ __nocobase_public_path__: input.publicPath,
43
+ location: {
44
+ origin: 'http://c.local.nocobase.com',
45
+ pathname: input.pathname,
46
+ search: '',
47
+ hash: '',
48
+ replace,
49
+ },
50
+ console: consoleMock,
51
+ outerWidth: 1280,
52
+ outerHeight: 720,
53
+ devicePixelRatio: 1,
54
+ onresize: undefined,
55
+ },
56
+ document: {
57
+ documentElement: {
58
+ className: '',
59
+ clientWidth: 1280,
60
+ clientHeight: 720,
61
+ },
62
+ },
63
+ navigator: {
64
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 Chrome/126.0 Safari/537.36',
65
+ },
66
+ console: consoleMock,
67
+ };
68
+
69
+ vm.runInNewContext(readFileSync(scriptPath, 'utf8'), context, {
70
+ filename: scriptPath,
71
+ });
72
+
73
+ return replace;
74
+ }
75
+
76
+ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
77
+ it('normalizes a relative public path before redirecting to the trailing-slash entry', () => {
78
+ const replace = executeBrowserChecker(scriptPath, {
79
+ pathname: '/nocobase/v',
80
+ publicPath: 'nocobase/v/',
81
+ });
82
+
83
+ expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/');
84
+ });
85
+
86
+ it('prefixes outside paths with a root-relative basename instead of duplicating a relative segment', () => {
87
+ const replace = executeBrowserChecker(scriptPath, {
88
+ pathname: '/foo',
89
+ publicPath: 'nocobase/v/',
90
+ });
91
+
92
+ expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/foo');
93
+ });
94
+
95
+ it('does not redirect when the current path is already under the normalized basename', () => {
96
+ const replace = executeBrowserChecker(scriptPath, {
97
+ pathname: '/nocobase/v/',
98
+ publicPath: 'nocobase/v/',
99
+ });
100
+
101
+ expect(replace).not.toHaveBeenCalled();
102
+ });
103
+ });
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { createMockClient } from '@nocobase/client-v2';
10
+ import { CollectionFieldInterface, createMockClient } from '@nocobase/client-v2';
11
11
  import { describe, expect, it } from 'vitest';
12
12
 
13
13
  class InputInterface {
@@ -16,6 +16,12 @@ class InputInterface {
16
16
  titleUsable = true;
17
17
  }
18
18
 
19
+ class OperatorOnlyInterface extends CollectionFieldInterface {
20
+ name = 'operatorOnly';
21
+ group = 'basic';
22
+ filterable = {};
23
+ }
24
+
19
25
  describe('data source runtime', () => {
20
26
  it('shares one dataSourceManager instance between app and flow context', () => {
21
27
  const app = createMockClient();
@@ -31,4 +37,19 @@ describe('data source runtime', () => {
31
37
 
32
38
  expect(app.dataSourceManager.collectionFieldInterfaceManager.getFieldInterface('input')?.titleUsable).toBe(true);
33
39
  });
40
+
41
+ it('exposes operators registered on interfaces without predefined operator lists', () => {
42
+ const app = createMockClient();
43
+ const operator = {
44
+ label: 'plugin operator',
45
+ value: '$plugin',
46
+ schema: { 'x-component': 'PluginInput' },
47
+ };
48
+
49
+ app.addFieldInterfaces([OperatorOnlyInterface as any]);
50
+ app.addFieldInterfaceOperator('operatorOnly', operator);
51
+
52
+ const fieldInterface = app.dataSourceManager.collectionFieldInterfaceManager.getFieldInterface('operatorOnly');
53
+ expect(fieldInterface?.filterable?.operators).toEqual([operator]);
54
+ });
34
55
  });
@@ -26,6 +26,10 @@ describe('nocobase buildin plugin auth redirect', () => {
26
26
  const originalLocation = globalThis.window.location;
27
27
 
28
28
  beforeEach(() => {
29
+ // These fixtures mount the modern client under the `v2` segment; tell the
30
+ // runtime-prefix helper so v2-runtime detection matches (server injects it
31
+ // in production).
32
+ (globalThis.window as any).__nocobase_modern_client_prefix__ = 'v2';
29
33
  Object.defineProperty(globalThis.window, 'matchMedia', {
30
34
  configurable: true,
31
35
  value: vi.fn().mockImplementation((query: string) => ({
@@ -42,6 +46,7 @@ describe('nocobase buildin plugin auth redirect', () => {
42
46
  });
43
47
 
44
48
  afterEach(() => {
49
+ delete (globalThis.window as any).__nocobase_modern_client_prefix__;
45
50
  Object.defineProperty(globalThis.window, 'location', {
46
51
  configurable: true,
47
52
  value: originalLocation,
@@ -113,6 +113,49 @@ function getV2BasePath(app: AppLike) {
113
113
  return trimTrailingSlashes(getV2PublicPath(app)) || '/';
114
114
  }
115
115
 
116
+ type ModernClientWindow = {
117
+ __nocobase_modern_client_prefix__?: string;
118
+ __nocobase_public_path__?: string;
119
+ };
120
+
121
+ function getModernClientWindow(): ModernClientWindow | undefined {
122
+ return typeof window !== 'undefined' ? (window as unknown as ModernClientWindow) : undefined;
123
+ }
124
+
125
+ /**
126
+ * The runtime URL segment under which the modern (v2) client is served.
127
+ * Injected by the server as `window.__nocobase_modern_client_prefix__`. Falls
128
+ * back to the trailing segment of `window.__nocobase_public_path__`, then to
129
+ * the default `v`. Returns a bare segment (no slashes).
130
+ */
131
+ export function getModernClientPrefix(): string {
132
+ const win = getModernClientWindow();
133
+ const fromWindow = win?.__nocobase_modern_client_prefix__;
134
+ if (typeof fromWindow === 'string' && fromWindow.trim()) {
135
+ return trimLeadingSlashes(trimTrailingSlashes(fromWindow.trim()));
136
+ }
137
+ const publicPath = win?.__nocobase_public_path__;
138
+ if (typeof publicPath === 'string' && publicPath.trim()) {
139
+ const segments = trimTrailingSlashes(publicPath.trim()).split('/');
140
+ const last = segments[segments.length - 1];
141
+ if (last) {
142
+ return last;
143
+ }
144
+ }
145
+ return 'v';
146
+ }
147
+
148
+ /**
149
+ * Strip the trailing modern-client prefix segment from a public path,
150
+ * recovering the app root public path (e.g. `/nocobase/v/` -> `/nocobase/`).
151
+ */
152
+ export function stripModernClientPrefix(publicPath?: string): string {
153
+ const normalized = normalizePublicPath(publicPath);
154
+ const prefix = getModernClientPrefix();
155
+ const suffixPattern = new RegExp(`/${escapeRegExp(prefix)}/?$`);
156
+ return normalizePublicPath(normalized.replace(suffixPattern, '/'));
157
+ }
158
+
116
159
  export function getV2EffectiveBasePath(app: AppLike): string {
117
160
  const basename = app.router?.getBasename?.();
118
161
  if (basename) {
@@ -14,7 +14,6 @@ import type { ComponentType, ReactNode } from 'react';
14
14
 
15
15
  import {
16
16
  normalizeFilterableOperators,
17
- resolveFilterOperators,
18
17
  type FieldFilterable,
19
18
  type FieldFilterOperator,
20
19
  } from '../collection-manager/filter-operators';
@@ -181,14 +180,22 @@ export abstract class CollectionFieldInterface {
181
180
  }
182
181
 
183
182
  addOperator(operatorOption: FieldFilterOperator) {
184
- normalizeFilterableOperators(this.filterable);
185
- const operators = [...resolveFilterOperators(this.filterable?.operators)];
186
- set(this, 'filterable.operators', operators);
183
+ if (!this.filterable) {
184
+ set(this, 'filterable', {});
185
+ }
187
186
 
188
- if (operators.find((item) => item.value === operatorOption.value)) {
189
- return;
187
+ const operatorOverrides = Array.isArray(this.filterable.operatorOverrides)
188
+ ? [...this.filterable.operatorOverrides]
189
+ : [];
190
+
191
+ const existingIndex = operatorOverrides.findIndex((item) => item.value === operatorOption.value);
192
+ if (existingIndex !== -1) {
193
+ operatorOverrides[existingIndex] = operatorOption;
194
+ } else {
195
+ operatorOverrides.push(operatorOption);
190
196
  }
191
197
 
192
- operators.push(operatorOption);
198
+ set(this, 'filterable.operatorOverrides', operatorOverrides);
199
+ normalizeFilterableOperators(this.filterable);
193
200
  }
194
201
  }
@@ -23,6 +23,7 @@ export type FieldFilterOperatorList<TMeta = unknown> = FieldFilterOperator<TMeta
23
23
  export type FieldFilterable<TMeta = unknown> = {
24
24
  operators?: FieldFilterOperatorList<TMeta>;
25
25
  operatorGroup?: string;
26
+ operatorOverrides?: FieldFilterOperator<TMeta>[];
26
27
  children?: unknown[];
27
28
  nested?: boolean;
28
29
  [key: string]: unknown;
@@ -120,14 +121,20 @@ export function normalizeFilterableOperators<TMeta = unknown>(filterable?: Field
120
121
  return filterable;
121
122
  }
122
123
 
124
+ const operatorOverrides = Array.isArray(filterable.operatorOverrides) ? filterable.operatorOverrides : [];
125
+
123
126
  if (filterable.operatorGroup) {
124
- filterable.operators = resolveFilterOperators(filterable.operatorGroup);
127
+ filterable.operators = mergeOperatorOverrides(resolveFilterOperators(filterable.operatorGroup), operatorOverrides);
125
128
  }
126
129
 
127
130
  const rawOperators = (filterable as { operators?: unknown }).operators;
128
131
  if (typeof rawOperators === 'string') {
129
132
  filterable.operatorGroup = rawOperators;
130
- filterable.operators = resolveFilterOperators(rawOperators);
133
+ filterable.operators = mergeOperatorOverrides(resolveFilterOperators(rawOperators), operatorOverrides);
134
+ } else if (Array.isArray(rawOperators)) {
135
+ filterable.operators = mergeOperatorOverrides(rawOperators, operatorOverrides);
136
+ } else if (operatorOverrides.length > 0) {
137
+ filterable.operators = [...operatorOverrides];
131
138
  }
132
139
 
133
140
  if (Array.isArray(filterable.children)) {
@@ -141,6 +148,26 @@ export function normalizeFilterableOperators<TMeta = unknown>(filterable?: Field
141
148
  return filterable;
142
149
  }
143
150
 
151
+ function mergeOperatorOverrides<TMeta = unknown>(
152
+ operators: FieldFilterOperator<TMeta>[] = [],
153
+ operatorOverrides: FieldFilterOperator<TMeta>[] = [],
154
+ ) {
155
+ if (operatorOverrides.length === 0) {
156
+ return operators;
157
+ }
158
+
159
+ const result = [...operators];
160
+ operatorOverrides.forEach((operatorOverride) => {
161
+ const existingIndex = result.findIndex((item) => item.value === operatorOverride.value);
162
+ if (existingIndex === -1) {
163
+ result.push(operatorOverride);
164
+ } else {
165
+ result[existingIndex] = operatorOverride;
166
+ }
167
+ });
168
+ return result;
169
+ }
170
+
144
171
  export function createFilterable<TPreset extends FieldFilterOperatorGroupName>(
145
172
  preset: TPreset,
146
173
  options: Omit<FieldFilterable, 'operators'> = {},
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { css } from '@emotion/css';
11
- import { Collection, observer } from '@nocobase/flow-engine';
11
+ import { Collection, observer, useFlowEngine } from '@nocobase/flow-engine';
12
12
  import { Cascader, Select, Space } from 'antd';
13
13
  import React, { FC, useMemo } from 'react';
14
14
  import { FilterOption, useFilterOptions } from '../../../flow/components/filter/useFilterOptions';
@@ -56,6 +56,8 @@ export interface CollectionFilterItemProps {
56
56
  noIgnore?: boolean;
57
57
  /** Translator; defaults to identity so callers can omit it. */
58
58
  t?: (key: string) => string;
59
+ /** Optional v2 app registry used to resolve plugin-provided operator components. */
60
+ app?: { getComponent?: (name: string) => React.ComponentType<any> | undefined };
59
61
  }
60
62
 
61
63
  const identity = (s: string) => s;
@@ -94,6 +96,8 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
94
96
  (props) => {
95
97
  const { collection, filterableFieldNames, nonfilterableFieldNames, noIgnore = false, t = identity } = props;
96
98
  const { path: leftValue, operator, value: rightValue } = props.value;
99
+ const flowEngine = useFlowEngine({ throwError: false }) as any;
100
+ const app = props.app || flowEngine?.context?.app;
97
101
 
98
102
  const options = useFilterOptions(collection, { filterableFieldNames, nonfilterableFieldNames, noIgnore, t });
99
103
 
@@ -160,6 +164,7 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
160
164
  onChange={handleValueChange}
161
165
  placeholder={t('Enter value')}
162
166
  t={t}
167
+ app={app}
163
168
  />
164
169
  </Space>
165
170
  );
@@ -172,7 +177,10 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
172
177
  */
173
178
  export function createCollectionFilterItem(
174
179
  collection: Collection,
175
- bound?: Pick<CollectionFilterItemProps, 'filterableFieldNames' | 'nonfilterableFieldNames' | 'noIgnore' | 't'>,
180
+ bound?: Pick<
181
+ CollectionFilterItemProps,
182
+ 'filterableFieldNames' | 'nonfilterableFieldNames' | 'noIgnore' | 't' | 'app'
183
+ >,
176
184
  ) {
177
185
  const Component: FC<{ value: CollectionFilterItemValue }> = (props) => (
178
186
  <CollectionFilterItem {...bound} value={props.value} collection={collection} />
@@ -13,6 +13,10 @@ import type { FilterOperator, FilterOption } from '../../../flow/components/filt
13
13
  import { PasswordInput } from '../PasswordInput';
14
14
  import { DateFilterDynamicComponent } from './DateFilterDynamicComponent';
15
15
 
16
+ type ComponentRegistryApp = {
17
+ getComponent?: (name: string) => React.ComponentType<any> | undefined;
18
+ };
19
+
16
20
  export interface FilterValueInputProps {
17
21
  /** The currently selected leaf field option from the field picker. */
18
22
  field?: FilterOption;
@@ -26,17 +30,20 @@ export interface FilterValueInputProps {
26
30
  t?: (key: string) => string;
27
31
  /** Optional placeholder for the fallback `Input`. */
28
32
  placeholder?: string;
33
+ /** Optional v2 app registry used to resolve plugin-provided operator components. */
34
+ app?: ComponentRegistryApp;
29
35
  }
30
36
 
31
37
  const identity = (s: string) => s;
32
38
 
33
39
  type EffectiveSchema = {
34
- 'x-component'?: string;
40
+ 'x-component'?: string | React.ComponentType<any>;
35
41
  'x-component-props'?: Record<string, any>;
36
42
  enum?: Array<{ value: any; label: string }> | any[];
37
43
  };
38
44
 
39
45
  const baseStyle = { minWidth: 200 } as const;
46
+ const fallbackInputComponents = new Set(['Input', 'Input.URL', 'NanoIDInput']);
40
47
 
41
48
  /** Resolve operator-level schema → field uiSchema → fallback Input. */
42
49
  const resolveSchema = (field?: FilterOption, operator?: FilterOperator): EffectiveSchema => {
@@ -54,7 +61,7 @@ const resolveSchema = (field?: FilterOption, operator?: FilterOperator): Effecti
54
61
  * Interface-aware value renderer for filter rows. Returns `null` for `noValue` operators (`$empty`, `$notEmpty`). Otherwise dispatches the effective `x-component` (operator schema > field uiSchema > Input) to a small registry of antd controls.
55
62
  */
56
63
  export const FilterValueInput: React.FC<FilterValueInputProps> = (props) => {
57
- const { field, operator, value, onChange, t = identity, placeholder } = props;
64
+ const { field, operator, value, onChange, t = identity, placeholder, app } = props;
58
65
 
59
66
  if (operator?.noValue) {
60
67
  return null;
@@ -64,6 +71,12 @@ export const FilterValueInput: React.FC<FilterValueInputProps> = (props) => {
64
71
  const componentName = schema['x-component'];
65
72
  const componentProps = schema['x-component-props'] || {};
66
73
  const enumOptions = (schema as any).enum || (field?.schema as any)?.enum;
74
+ const customComponentProps = {
75
+ value,
76
+ onChange,
77
+ ...componentProps,
78
+ style: { ...baseStyle, ...(componentProps.style || {}) },
79
+ };
67
80
 
68
81
  switch (componentName) {
69
82
  case 'DateFilterDynamicComponent':
@@ -183,6 +196,18 @@ export const FilterValueInput: React.FC<FilterValueInputProps> = (props) => {
183
196
  case 'Input.URL':
184
197
  case 'NanoIDInput':
185
198
  default:
199
+ if (typeof componentName === 'function') {
200
+ const Component = componentName;
201
+ return <Component {...customComponentProps} />;
202
+ }
203
+
204
+ if (typeof componentName === 'string' && !fallbackInputComponents.has(componentName)) {
205
+ const Component = app?.getComponent?.(componentName);
206
+ if (Component) {
207
+ return <Component {...customComponentProps} />;
208
+ }
209
+ }
210
+
186
211
  return (
187
212
  <Input
188
213
  value={value}
@@ -222,6 +222,45 @@ describe('FilterValueInput schema precedence', () => {
222
222
  expect(document.querySelector('.ant-select')).not.toBeNull();
223
223
  });
224
224
 
225
+ it('operator schema can render an app-registered custom component', () => {
226
+ const onChange = vi.fn();
227
+ const CustomFilterInput = (props: any) => (
228
+ <button
229
+ type="button"
230
+ data-testid="custom-filter-input"
231
+ data-field-interface={props.fieldInterface}
232
+ data-value={JSON.stringify(props.value)}
233
+ onClick={() => props.onChange?.(['alpha', 'beta'])}
234
+ >
235
+ custom-filter-input
236
+ </button>
237
+ );
238
+
239
+ render(
240
+ <FilterValueInput
241
+ operator={opOf({
242
+ value: '$in',
243
+ schema: {
244
+ 'x-component': 'MultipleKeywordsInput',
245
+ 'x-component-props': { fieldInterface: 'input' },
246
+ },
247
+ })}
248
+ value={['foo', 'bar']}
249
+ onChange={onChange}
250
+ app={{
251
+ getComponent: (name) => (name === 'MultipleKeywordsInput' ? CustomFilterInput : undefined),
252
+ }}
253
+ />,
254
+ );
255
+
256
+ const input = screen.getByTestId('custom-filter-input');
257
+ expect(input.getAttribute('data-field-interface')).toBe('input');
258
+ expect(input.getAttribute('data-value')).toBe(JSON.stringify(['foo', 'bar']));
259
+
260
+ fireEvent.click(input);
261
+ expect(onChange).toHaveBeenCalledWith(['alpha', 'beta']);
262
+ });
263
+
225
264
  it('field uiSchema is used when operator has none', () => {
226
265
  const { container } = render(
227
266
  <FilterValueInput
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import React from 'react';
11
- import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12
12
  import { MemoryRouter, Route, Routes } from 'react-router-dom';
13
13
  import { render, screen, waitFor } from '@testing-library/react';
14
14
  import { FlowContextProvider, FlowEngine, FlowEngineProvider, type FlowModel } from '@nocobase/flow-engine';
@@ -32,6 +32,13 @@ describe('FlowRoute', () => {
32
32
  beforeEach(() => {
33
33
  vi.clearAllMocks();
34
34
  hookState.refresh = vi.fn();
35
+ // Fixtures mount the modern client under the `v2` segment; tell the
36
+ // runtime-prefix helper so v2-runtime detection matches.
37
+ (globalThis.window as any).__nocobase_modern_client_prefix__ = 'v2';
38
+ });
39
+
40
+ afterEach(() => {
41
+ delete (globalThis.window as any).__nocobase_modern_client_prefix__;
35
42
  });
36
43
 
37
44
  it('should bridge page lifecycle to admin-layout-model', async () => {