@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.
- package/es/BaseApplication.d.ts +9 -0
- package/es/authRedirect.d.ts +12 -0
- package/es/collection-manager/filter-operators/index.d.ts +1 -0
- package/es/components/form/filter/CollectionFilterItem.d.ts +5 -1
- package/es/components/form/filter/FilterValueInput.d.ts +5 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
- package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +11 -0
- package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
- package/es/flow/admin-shell/admin-layout/useApplications.d.ts +3 -2
- package/es/flow/components/ConditionBuilder.d.ts +1 -0
- package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
- package/es/flow/components/FieldAssignValueInput.d.ts +1 -0
- package/es/flow/components/fieldAssignOptions.d.ts +10 -0
- package/es/flow/components/filter/LinkageFilterItem.d.ts +1 -0
- package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
- package/es/flow/components/filter/metaTreeAssociationDepth.d.ts +13 -0
- package/es/flow/internal/utils/operatorSchemaHelper.d.ts +26 -3
- package/es/flow/models/base/AssociationFieldGroupModel.d.ts +2 -33
- package/es/flow/models/blocks/filter-manager/utils.d.ts +23 -1
- package/es/flow/models/blocks/table/TableColumnModel.d.ts +2 -0
- package/es/index.mjs +179 -101
- package/lib/index.js +187 -109
- package/package.json +7 -7
- package/src/BaseApplication.tsx +13 -0
- package/src/__tests__/browserChecker.test.ts +103 -0
- package/src/__tests__/dataSourceRuntime.test.tsx +22 -1
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +5 -0
- package/src/authRedirect.ts +43 -0
- package/src/collection-field-interface/CollectionFieldInterface.ts +14 -7
- package/src/collection-manager/filter-operators/index.ts +29 -2
- package/src/components/form/filter/CollectionFilterItem.tsx +10 -2
- package/src/components/form/filter/FilterValueInput.tsx +27 -2
- package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +39 -0
- package/src/flow/__tests__/FlowRoute.test.tsx +8 -1
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +369 -2
- package/src/flow/actions/dataScope.tsx +8 -1
- package/src/flow/actions/linkageRules.tsx +56 -1
- package/src/flow/actions/setTargetDataScope.tsx +1 -1
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +31 -3
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +15 -1
- package/src/flow/admin-shell/admin-layout/AppListRender.tsx +139 -0
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +75 -1
- package/src/flow/admin-shell/admin-layout/index.ts +1 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +11 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -4
- package/src/flow/admin-shell/admin-layout/useApplications.tsx +34 -1
- package/src/flow/common/Markdown/Edit.tsx +32 -4
- package/src/flow/components/ConditionBuilder.tsx +12 -2
- package/src/flow/components/DefaultValue.tsx +14 -8
- package/src/flow/components/FieldAssignRulesEditor.tsx +37 -46
- package/src/flow/components/FieldAssignValueInput.tsx +14 -14
- package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +112 -1
- package/src/flow/components/__tests__/fieldAssignOptions.test.ts +71 -1
- package/src/flow/components/fieldAssignOptions.ts +23 -6
- package/src/flow/components/filter/LinkageFilterItem.tsx +26 -12
- package/src/flow/components/filter/VariableFilterItem.tsx +32 -13
- package/src/flow/components/filter/__tests__/fieldsToOptions.test.ts +48 -0
- package/src/flow/components/filter/__tests__/metaTreeAssociationDepth.test.ts +60 -0
- package/src/flow/components/filter/fieldsToOptions.ts +9 -4
- package/src/flow/components/filter/metaTreeAssociationDepth.ts +69 -0
- package/src/flow/internal/utils/operatorSchemaHelper.ts +112 -5
- package/src/flow/models/actions/FilterActionModel.tsx +15 -3
- package/src/flow/models/base/AssociationFieldGroupModel.tsx +20 -3
- package/src/flow/models/base/GridModel.tsx +1 -1
- package/src/flow/models/base/PageModel/ChildPageModel.tsx +5 -1
- package/src/flow/models/base/PageModel/__tests__/ChildPageModel.test.ts +12 -0
- package/src/flow/models/base/__tests__/AssociationFieldGroupModel.test.ts +86 -0
- package/src/flow/models/base/__tests__/GridModel.render.test.tsx +53 -0
- package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +11 -0
- package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +14 -12
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +6 -1
- package/src/flow/models/blocks/filter-manager/__tests__/getDefaultOperator.test.ts +35 -1
- package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +148 -0
- package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/defaultOperator.test.tsx +85 -0
- package/src/flow/models/blocks/filter-manager/flow-actions/connectFields.tsx +48 -6
- package/src/flow/models/blocks/filter-manager/flow-actions/customizeFilterRender.tsx +14 -44
- package/src/flow/models/blocks/filter-manager/flow-actions/defaultOperator.tsx +3 -5
- package/src/flow/models/blocks/filter-manager/flow-actions/operatorComponentProps.tsx +2 -12
- package/src/flow/models/blocks/filter-manager/utils.ts +143 -4
- package/src/flow/models/blocks/form/FormAssociationItemModel.tsx +9 -0
- package/src/flow/models/blocks/form/FormItemModel.tsx +9 -0
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
- package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +27 -2
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +29 -0
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +5 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +15 -5
- package/src/flow/models/blocks/table/TableCustomColumnModel.tsx +2 -2
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +18 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +6 -7
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -3
- 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.
|
|
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.
|
|
31
|
-
"@nocobase/flow-engine": "2.1.0-beta.
|
|
32
|
-
"@nocobase/sdk": "2.1.0-beta.
|
|
33
|
-
"@nocobase/shared": "2.1.0-beta.
|
|
34
|
-
"@nocobase/utils": "2.1.0-beta.
|
|
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": "
|
|
48
|
+
"gitHead": "42587115fc34c3eb01ef2b2549f1c998e5708318"
|
|
49
49
|
}
|
package/src/BaseApplication.tsx
CHANGED
|
@@ -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,
|
package/src/authRedirect.ts
CHANGED
|
@@ -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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
183
|
+
if (!this.filterable) {
|
|
184
|
+
set(this, 'filterable', {});
|
|
185
|
+
}
|
|
187
186
|
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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<
|
|
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 () => {
|