@nocobase/client-v2 2.1.0-alpha.46 → 2.1.0-alpha.47

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 (30) hide show
  1. package/es/collection-manager/filter-operators/index.d.ts +1 -0
  2. package/es/components/form/filter/CollectionFilterItem.d.ts +5 -1
  3. package/es/components/form/filter/FilterValueInput.d.ts +5 -0
  4. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +26 -3
  5. package/es/flow/models/blocks/filter-manager/utils.d.ts +23 -1
  6. package/es/index.mjs +77 -77
  7. package/lib/index.js +82 -82
  8. package/package.json +7 -7
  9. package/src/__tests__/browserChecker.test.ts +103 -0
  10. package/src/__tests__/dataSourceRuntime.test.tsx +22 -1
  11. package/src/collection-field-interface/CollectionFieldInterface.ts +14 -7
  12. package/src/collection-manager/filter-operators/index.ts +29 -2
  13. package/src/components/form/filter/CollectionFilterItem.tsx +10 -2
  14. package/src/components/form/filter/FilterValueInput.tsx +27 -2
  15. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +39 -0
  16. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +369 -2
  17. package/src/flow/actions/linkageRules.tsx +53 -0
  18. package/src/flow/components/DefaultValue.tsx +14 -8
  19. package/src/flow/components/FieldAssignValueInput.tsx +6 -10
  20. package/src/flow/components/filter/LinkageFilterItem.tsx +4 -3
  21. package/src/flow/components/filter/VariableFilterItem.tsx +5 -4
  22. package/src/flow/internal/utils/operatorSchemaHelper.ts +112 -5
  23. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +13 -11
  24. package/src/flow/models/blocks/filter-manager/__tests__/getDefaultOperator.test.ts +35 -1
  25. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +148 -0
  26. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/defaultOperator.test.tsx +85 -0
  27. package/src/flow/models/blocks/filter-manager/flow-actions/customizeFilterRender.tsx +14 -44
  28. package/src/flow/models/blocks/filter-manager/flow-actions/defaultOperator.tsx +3 -5
  29. package/src/flow/models/blocks/filter-manager/flow-actions/operatorComponentProps.tsx +2 -12
  30. package/src/flow/models/blocks/filter-manager/utils.ts +143 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.0-alpha.46",
3
+ "version": "2.1.0-alpha.47",
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-alpha.46",
31
- "@nocobase/flow-engine": "2.1.0-alpha.46",
32
- "@nocobase/sdk": "2.1.0-alpha.46",
33
- "@nocobase/shared": "2.1.0-alpha.46",
34
- "@nocobase/utils": "2.1.0-alpha.46",
30
+ "@nocobase/evaluators": "2.1.0-alpha.47",
31
+ "@nocobase/flow-engine": "2.1.0-alpha.47",
32
+ "@nocobase/sdk": "2.1.0-alpha.47",
33
+ "@nocobase/shared": "2.1.0-alpha.47",
34
+ "@nocobase/utils": "2.1.0-alpha.47",
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": "42b269944cdd1908d7a848c0af4936fe94c03bb7"
48
+ "gitHead": "66196b57f9043ea0ac6ebdafbc732bfb98af1396"
49
49
  }
@@ -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
  });
@@ -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