@nocobase/client-v2 2.1.0-alpha.45 → 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 (99) hide show
  1. package/es/authRedirect.d.ts +12 -0
  2. package/es/collection-manager/filter-operators/index.d.ts +1 -0
  3. package/es/components/form/TypedVariableInput.d.ts +75 -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/components/form/index.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  8. package/es/flow/components/ConditionBuilder.d.ts +1 -0
  9. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  10. package/es/flow/components/FieldAssignValueInput.d.ts +1 -0
  11. package/es/flow/components/fieldAssignOptions.d.ts +10 -0
  12. package/es/flow/components/filter/LinkageFilterItem.d.ts +1 -0
  13. package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
  14. package/es/flow/components/filter/metaTreeAssociationDepth.d.ts +13 -0
  15. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +26 -3
  16. package/es/flow/models/base/AssociationFieldGroupModel.d.ts +2 -33
  17. package/es/flow/models/blocks/assign-form/assignFieldValuesFlow.d.ts +8 -0
  18. package/es/flow/models/blocks/filter-manager/utils.d.ts +23 -1
  19. package/es/flow/models/blocks/table/TableColumnModel.d.ts +2 -0
  20. package/es/index.mjs +106 -106
  21. package/lib/index.js +106 -106
  22. package/package.json +7 -7
  23. package/src/__tests__/browserChecker.test.ts +103 -0
  24. package/src/__tests__/dataSourceRuntime.test.tsx +22 -1
  25. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +5 -0
  26. package/src/authRedirect.ts +43 -0
  27. package/src/collection-field-interface/CollectionFieldInterface.ts +14 -7
  28. package/src/collection-manager/filter-operators/index.ts +29 -2
  29. package/src/components/README.md +48 -0
  30. package/src/components/README.zh-CN.md +48 -0
  31. package/src/components/form/TypedVariableInput.tsx +441 -0
  32. package/src/components/form/__tests__/TypedVariableInput.test.tsx +152 -0
  33. package/src/components/form/filter/CollectionFilterItem.tsx +10 -2
  34. package/src/components/form/filter/FilterValueInput.tsx +27 -2
  35. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +39 -0
  36. package/src/components/form/index.tsx +1 -0
  37. package/src/flow/__tests__/FlowRoute.test.tsx +8 -1
  38. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +100 -0
  39. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +369 -2
  40. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +23 -1
  41. package/src/flow/actions/dataScope.tsx +8 -1
  42. package/src/flow/actions/linkageRules.tsx +61 -5
  43. package/src/flow/actions/linkageRulesRefresh.tsx +2 -3
  44. package/src/flow/actions/setTargetDataScope.tsx +1 -1
  45. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +15 -1
  46. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +75 -1
  47. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +11 -0
  48. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -4
  49. package/src/flow/common/Markdown/Edit.tsx +32 -4
  50. package/src/flow/components/ConditionBuilder.tsx +12 -2
  51. package/src/flow/components/DefaultValue.tsx +14 -8
  52. package/src/flow/components/FieldAssignRulesEditor.tsx +37 -46
  53. package/src/flow/components/FieldAssignValueInput.tsx +14 -14
  54. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +112 -1
  55. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +71 -1
  56. package/src/flow/components/code-editor/__tests__/runjsCompletions.test.ts +272 -27
  57. package/src/flow/components/code-editor/runjsCompletions.ts +490 -9
  58. package/src/flow/components/fieldAssignOptions.ts +23 -6
  59. package/src/flow/components/filter/LinkageFilterItem.tsx +26 -12
  60. package/src/flow/components/filter/VariableFilterItem.tsx +32 -13
  61. package/src/flow/components/filter/__tests__/fieldsToOptions.test.ts +48 -0
  62. package/src/flow/components/filter/__tests__/metaTreeAssociationDepth.test.ts +60 -0
  63. package/src/flow/components/filter/fieldsToOptions.ts +9 -4
  64. package/src/flow/components/filter/metaTreeAssociationDepth.ts +69 -0
  65. package/src/flow/internal/utils/operatorSchemaHelper.ts +112 -5
  66. package/src/flow/models/actions/FilterActionModel.tsx +15 -3
  67. package/src/flow/models/actions/__tests__/AssignFormRefill.test.ts +90 -0
  68. package/src/flow/models/base/AssociationFieldGroupModel.tsx +20 -3
  69. package/src/flow/models/base/GridModel.tsx +1 -1
  70. package/src/flow/models/base/PageModel/ChildPageModel.tsx +5 -1
  71. package/src/flow/models/base/PageModel/__tests__/ChildPageModel.test.ts +12 -0
  72. package/src/flow/models/base/__tests__/AssociationFieldGroupModel.test.ts +86 -0
  73. package/src/flow/models/base/__tests__/GridModel.render.test.tsx +53 -0
  74. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +12 -3
  75. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -4
  76. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +216 -0
  77. package/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx +142 -40
  78. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +14 -12
  79. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +6 -1
  80. package/src/flow/models/blocks/filter-manager/__tests__/getDefaultOperator.test.ts +35 -1
  81. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +148 -0
  82. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/defaultOperator.test.tsx +85 -0
  83. package/src/flow/models/blocks/filter-manager/flow-actions/connectFields.tsx +48 -6
  84. package/src/flow/models/blocks/filter-manager/flow-actions/customizeFilterRender.tsx +14 -44
  85. package/src/flow/models/blocks/filter-manager/flow-actions/defaultOperator.tsx +3 -5
  86. package/src/flow/models/blocks/filter-manager/flow-actions/operatorComponentProps.tsx +2 -12
  87. package/src/flow/models/blocks/filter-manager/utils.ts +143 -4
  88. package/src/flow/models/blocks/form/FormAssociationItemModel.tsx +9 -0
  89. package/src/flow/models/blocks/form/FormItemModel.tsx +9 -0
  90. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  91. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +27 -2
  92. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +29 -0
  93. package/src/flow/models/blocks/form/value-runtime/runtime.ts +5 -1
  94. package/src/flow/models/blocks/table/TableColumnModel.tsx +15 -5
  95. package/src/flow/models/blocks/table/TableCustomColumnModel.tsx +2 -2
  96. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +18 -0
  97. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +6 -7
  98. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -3
  99. 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-alpha.45",
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.45",
31
- "@nocobase/flow-engine": "2.1.0-alpha.45",
32
- "@nocobase/sdk": "2.1.0-alpha.45",
33
- "@nocobase/shared": "2.1.0-alpha.45",
34
- "@nocobase/utils": "2.1.0-alpha.45",
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": "e9e24987e12d0ad10a5db8815b1e1b7b447f1938"
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
  });
@@ -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'> = {},
@@ -169,6 +169,54 @@ Key props:
169
169
 
170
170
  Under the hood `VariableInput` wraps `VariableHybridInput` (inline pills), `VariableTextArea` wraps `TextAreaWithContextSelector` (textarea + variable button). Both share the same MetaTree.
171
171
 
172
+ #### TypedVariableInput
173
+
174
+ Typed-constant + variable hybrid input. Ported from v1 `Variable.Input`'s `useTypedConstant` pattern: an italic `x` button on the right triggers a Cascader switcher `[Null | Constant<types> | Variable<…namespaces>]`; the left side renders the matching editor (`Input` / `InputNumber` / `Select(True/False)` / `DatePicker`) or a pill carrying the variable path.
175
+
176
+ Reach for this when a field **accepts both** a typed literal **and** a variable reference. The canonical example is `plugin-notification-email`'s SMTP `port` and `secure` fields: users can type a numeric port / boolean flag, or pass `{{ $env.SMTP_PORT }}` to read from environment variables.
177
+
178
+ ```tsx
179
+ import { TypedVariableInput } from '@nocobase/client-v2';
180
+
181
+ // Port — numeric constant + $env variable
182
+ <Form.Item name={['options', 'port']} label={t('Port')} initialValue={465}>
183
+ <TypedVariableInput
184
+ types={[['number', { min: 1, max: 65535, step: 1 }]]}
185
+ namespaces={['$env']}
186
+ />
187
+ </Form.Item>
188
+
189
+ // Secure mode — boolean constant + $env variable
190
+ <Form.Item name={['options', 'secure']} label={t('Secure')} initialValue={true}>
191
+ <TypedVariableInput types={['boolean']} namespaces={['$env']} />
192
+ </Form.Item>
193
+ ```
194
+
195
+ Key props:
196
+
197
+ - `types`: allowed constant types. Shape mirrors v1 `useTypedConstant` — pass bare type names (`['number', 'boolean']`) or `[type, editorProps]` tuples (`[['number', { min, max, step }]]`) to forward props to the underlying antd editor. Defaults to `['string', 'number', 'boolean', 'date']`. **Even when only one type is allowed, the `Constant` entry still expands into a typed submenu** (Number / Boolean / Date / String) — matches v1 so users can see what type the constant is
198
+ - `namespaces`: restrict the variable picker to specific top-level namespaces (e.g. `['$env']`). Omit to expose every namespace registered on `flowEngine.context`
199
+ - `extraNodes`: static leaves appended after the namespace-filtered nodes
200
+ - `nullable`: whether to expose the `Null` switcher entry. Default `true`. Combined with `Form.Item.rules={[{ required: true }]}`, the user can explicitly clear the field but submission is still blocked by validation — mirrors v1's "Null + required" pairing
201
+ - `delimiters`: variable-token delimiters, default `['{{', '}}']` — same as `VariableInput`
202
+ - `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`: standard controlled-input props
203
+
204
+ Value shape:
205
+
206
+ - Constant: stored as the native type (`number` / `boolean` / `Date` / `string`)
207
+ - Variable: a string like `'{{ $env.SMTP_PORT }}'`
208
+ - Null: `null`
209
+
210
+ When **not** to use it:
211
+
212
+ - **Pure literal fields** (users will never pass a variable) → use the antd primitive directly (`InputNumber` / `Select` / `DatePicker` / `Input`) and skip the Cascader column overhead
213
+ - **Pure variable fields** (users will never pass a literal) → use `EnvVariableInput` (`$env`-only, with optional password masking) or `VariableInput` (general-purpose)
214
+
215
+ Capabilities skipped (present in v1, not yet ported to v2):
216
+
217
+ - `object` constant type (JSON editor) — v2 has no inline "JSON editor + Cascader switcher" yet; add when there's a concrete caller
218
+ - Async `loadChildren` cascading — most MetaTree namespaces are already eagerly resolved by `useFilteredMetaTree`, so this hasn't been needed
219
+
172
220
  #### FileSizeInput
173
221
 
174
222
  A byte-valued size input paired with a unit selector (Byte / KB / MB / GB). The persisted value is always in bytes; the displayed number is derived from the picked unit.
@@ -169,6 +169,54 @@ import { VariableInput, VariableTextArea } from '@nocobase/client-v2';
169
169
 
170
170
  底层共用 `VariableHybridInput`(`VariableInput`)和 `TextAreaWithContextSelector`(`VariableTextArea`),用同一套 MetaTree 数据。
171
171
 
172
+ #### TypedVariableInput
173
+
174
+ 类型化常量 + 变量混合输入器。移植 v1 `Variable.Input` 的 `useTypedConstant` 形态:右侧斜体 `x` 按钮触发 Cascader 切换 `[空值 | 常量<types> | 变量和密钥<…namespaces>]`,左侧根据当前模式渲染对应编辑器(`Input` / `InputNumber` / `Select(True/False)` / `DatePicker`)或一颗带变量路径的 pill。
175
+
176
+ 用于字段**同时接受**字面量**和**变量引用的场景。最典型的就是 `plugin-notification-email` 的 SMTP `port` 和 `secure`:可以填具体数字 / 布尔值,也可以填 `{{ $env.SMTP_PORT }}` 走环境变量。
177
+
178
+ ```tsx
179
+ import { TypedVariableInput } from '@nocobase/client-v2';
180
+
181
+ // 端口:数字常量 + $env 变量
182
+ <Form.Item name={['options', 'port']} label={t('端口')} initialValue={465}>
183
+ <TypedVariableInput
184
+ types={[['number', { min: 1, max: 65535, step: 1 }]]}
185
+ namespaces={['$env']}
186
+ />
187
+ </Form.Item>
188
+
189
+ // 安全模式:布尔常量 + $env 变量
190
+ <Form.Item name={['options', 'secure']} label={t('安全模式')} initialValue={true}>
191
+ <TypedVariableInput types={['boolean']} namespaces={['$env']} />
192
+ </Form.Item>
193
+ ```
194
+
195
+ 主要属性:
196
+
197
+ - `types`:允许的常量类型。形态对齐 v1 `useTypedConstant`,可以传裸类型名 `['number', 'boolean']`,也可以传 `[type, editorProps]` 元组 `[['number', { min, max, step }]]` 把 props 透传给底层 antd 编辑器。默认 `['string', 'number', 'boolean', 'date']`。**即使只允许一种类型,「常量」入口也会展开二级菜单**(数字 / 逻辑值 / 日期 / 字符串)——跟 v1 一致,让用户能直观看到当前常量是什么类型
198
+ - `namespaces`:限定变量 picker 可选的顶层命名空间(如 `['$env']`)。不传就用 `flowEngine.context` 里所有已注册命名空间
199
+ - `extraNodes`:在命名空间过滤后追加几条静态变量节点
200
+ - `nullable`:是否暴露「空值」入口,默认 `true`。配合 `Form.Item.rules={[{ required: true }]}` 可以让用户能手动清空、但提交时会被校验拦截——跟 v1 的「空值 + required」组合一致
201
+ - `delimiters`:变量 token 开闭分隔符,默认 `['{{', '}}']`,跟 `VariableInput` 一致
202
+ - `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`:标准受控字段属性
203
+
204
+ 值的形态:
205
+
206
+ - 常量:原生类型直接存(`number` / `boolean` / `Date` / `string`)
207
+ - 变量:字符串 `'{{ $env.SMTP_PORT }}'`
208
+ - 空值:`null`
209
+
210
+ 什么时候**不该用**:
211
+
212
+ - **纯字面量字段**(用户不会想填变量)→ 直接用 antd `InputNumber` / `Select` / `DatePicker` / `Input`,省掉 Cascader 那一格的视觉开销
213
+ - **纯变量字段**(用户不会想填字面量)→ 用 `EnvVariableInput`(`$env` 专用,带 password mask)或 `VariableInput`(更通用)
214
+
215
+ 跳过的能力(v1 有但 v2 还没补):
216
+
217
+ - `object` 类型(JSON 编辑器)——v2 还没对应的「内联 JSON 编辑器 + Cascader 切换」组件,等真有需求再补
218
+ - 异步 `loadChildren` 分支——大多数命名空间的 MetaTree 已经由 `useFilteredMetaTree` 提前展平,没遇到刚需
219
+
172
220
  #### FileSizeInput
173
221
 
174
222
  文件大小输入器。值统一存字节数,UI 上配一个单位选择器(Byte / KB / MB / GB)。