@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
@@ -0,0 +1,441 @@
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 { CloseCircleFilled } from '@ant-design/icons';
11
+ import {
12
+ buildContextSelectorItems,
13
+ useFlowContext,
14
+ type ContextSelectorItem,
15
+ type MetaTreeNode,
16
+ } from '@nocobase/flow-engine';
17
+ import { Button, Cascader, DatePicker, Input, InputNumber, Select, Space, Tag, theme, type CascaderProps } from 'antd';
18
+ import dayjs from 'dayjs';
19
+ import React, { useCallback, useMemo } from 'react';
20
+ import {
21
+ makeFormatVariablePath,
22
+ makeParseVariablePath,
23
+ useFilteredMetaTree,
24
+ type VariableDelimiters,
25
+ } from './VariableInput';
26
+
27
+ /**
28
+ * Constant types this input can edit. Subset of v1 `Variable.Input`
29
+ * `useTypedConstant` — drops `'object'` (no v2 JSON editor yet) and `'null'`
30
+ * (handled by the dedicated `nullable` prop).
31
+ */
32
+ export type TypedConstantType = 'string' | 'number' | 'boolean' | 'date';
33
+
34
+ /**
35
+ * One allowed constant type. Either a bare type name (`'number'`) or a
36
+ * `[type, editorProps]` pair (`['number', { min: 1, max: 65535 }]`) where
37
+ * `editorProps` is forwarded to the antd editor for that type — same
38
+ * shape as v1 `useTypedConstant`.
39
+ */
40
+ export type TypedConstantSpec = TypedConstantType | [TypedConstantType, Record<string, unknown>];
41
+
42
+ export interface TypedVariableInputProps {
43
+ /**
44
+ * Stored value. A `{{ ... }}` string is treated as a variable reference;
45
+ * anything else is a constant of the inferred type.
46
+ */
47
+ value?: unknown;
48
+ onChange?: (next: unknown) => void;
49
+ /**
50
+ * Allowed constant types. The `Constant` switcher entry always exposes a
51
+ * typed submenu (matching v1 `Variable.Input`) so users can see what type
52
+ * the constant is, even when only one type is permitted. Default: all four
53
+ * supported types.
54
+ */
55
+ types?: TypedConstantSpec[];
56
+ /**
57
+ * Restrict the variable picker to specific top-level meta-tree namespaces
58
+ * (e.g. `['$env']`). When omitted, every registered top-level property is
59
+ * exposed — matching `VariableInput`'s default behaviour.
60
+ */
61
+ namespaces?: string[];
62
+ /** Additional leaves appended to the picker after the namespace-filtered nodes. */
63
+ extraNodes?: MetaTreeNode[];
64
+ /**
65
+ * When true (default), the switcher exposes a `Null` option that resets the
66
+ * value to `null`. When false, the value is constrained to one of the
67
+ * allowed types or a variable reference.
68
+ */
69
+ nullable?: boolean;
70
+ /** Variable-token delimiters. Default `['{{', '}}']` — see `VariableInput`. */
71
+ delimiters?: VariableDelimiters;
72
+ disabled?: boolean;
73
+ placeholder?: string;
74
+ style?: React.CSSProperties;
75
+ className?: string;
76
+ }
77
+
78
+ const DEFAULT_TYPES: TypedConstantSpec[] = ['string', 'number', 'boolean', 'date'];
79
+
80
+ const TYPE_LABEL_KEYS: Record<TypedConstantType, string> = {
81
+ string: 'String',
82
+ number: 'Number',
83
+ boolean: 'Boolean',
84
+ date: 'Date',
85
+ };
86
+
87
+ type NormalizedType = { type: TypedConstantType; props: Record<string, unknown> };
88
+
89
+ function normalizeTypes(types: TypedConstantSpec[]): NormalizedType[] {
90
+ return types.map((spec) =>
91
+ Array.isArray(spec) ? { type: spec[0], props: spec[1] ?? {} } : { type: spec, props: {} },
92
+ );
93
+ }
94
+
95
+ function defaultValueFor(type: TypedConstantType): unknown {
96
+ switch (type) {
97
+ case 'string':
98
+ return '';
99
+ case 'number':
100
+ return 0;
101
+ case 'boolean':
102
+ return false;
103
+ case 'date': {
104
+ const now = new Date();
105
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
106
+ }
107
+ default:
108
+ return null;
109
+ }
110
+ }
111
+
112
+ type DetectedMode = { mode: 'null' | 'variable' | TypedConstantType; variablePath?: string[] };
113
+
114
+ function detectMode(value: unknown, parseVariablePath: (v?: string) => string[] | undefined): DetectedMode {
115
+ if (value == null) return { mode: 'null' };
116
+ if (typeof value === 'string') {
117
+ const path = parseVariablePath(value);
118
+ if (path && path.length > 0) return { mode: 'variable', variablePath: path };
119
+ return { mode: 'string' };
120
+ }
121
+ if (typeof value === 'number') return { mode: 'number' };
122
+ if (typeof value === 'boolean') return { mode: 'boolean' };
123
+ if (value instanceof Date) return { mode: 'date' };
124
+ return { mode: 'string' };
125
+ }
126
+
127
+ const NULL_KEY = '__null__';
128
+ const CONST_KEY = '__const__';
129
+
130
+ interface SwitcherOption {
131
+ value: string;
132
+ label: React.ReactNode;
133
+ isLeaf?: boolean;
134
+ children?: SwitcherOption[];
135
+ meta?: MetaTreeNode;
136
+ paths?: string[];
137
+ disabled?: boolean;
138
+ }
139
+
140
+ function fromContextItem(item: ContextSelectorItem): SwitcherOption {
141
+ return {
142
+ value: item.value,
143
+ label: item.label,
144
+ isLeaf: item.isLeaf,
145
+ meta: item.meta,
146
+ paths: item.paths,
147
+ disabled: item.disabled,
148
+ children: item.children?.map(fromContextItem),
149
+ };
150
+ }
151
+
152
+ function resolveVariableLabels(path: string[], metaTree: MetaTreeNode[]): string[] {
153
+ const labels: string[] = [];
154
+ let nodes: MetaTreeNode[] | undefined = metaTree;
155
+ for (const segment of path) {
156
+ if (!nodes) break;
157
+ const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
158
+ if (!matched) {
159
+ labels.push(segment);
160
+ nodes = undefined;
161
+ continue;
162
+ }
163
+ labels.push(typeof matched.title === 'string' && matched.title ? matched.title : matched.name);
164
+ nodes = Array.isArray(matched.children) ? matched.children : undefined;
165
+ }
166
+ return labels;
167
+ }
168
+
169
+ function renderConstantEditor(
170
+ type: TypedConstantType,
171
+ value: unknown,
172
+ onChange: ((next: unknown) => void) | undefined,
173
+ options: {
174
+ typedProps: Record<string, unknown>;
175
+ disabled?: boolean;
176
+ placeholder?: string;
177
+ t: (text: string) => string;
178
+ },
179
+ ): React.ReactNode {
180
+ const { typedProps, disabled, placeholder, t } = options;
181
+ switch (type) {
182
+ case 'string':
183
+ return (
184
+ <Input
185
+ value={typeof value === 'string' ? value : ''}
186
+ onChange={(ev) => onChange?.(ev.target.value)}
187
+ disabled={disabled}
188
+ placeholder={placeholder}
189
+ {...typedProps}
190
+ />
191
+ );
192
+ case 'number':
193
+ return (
194
+ <InputNumber
195
+ value={typeof value === 'number' ? value : null}
196
+ onChange={(next) => onChange?.(next)}
197
+ disabled={disabled}
198
+ placeholder={placeholder}
199
+ style={{ width: '100%' }}
200
+ {...typedProps}
201
+ />
202
+ );
203
+ case 'boolean':
204
+ return (
205
+ <Select
206
+ value={typeof value === 'boolean' ? value : undefined}
207
+ onChange={(next) => onChange?.(next)}
208
+ disabled={disabled}
209
+ placeholder={placeholder ?? t('Select')}
210
+ options={[
211
+ { value: true, label: t('True') },
212
+ { value: false, label: t('False') },
213
+ ]}
214
+ style={{ width: '100%' }}
215
+ {...typedProps}
216
+ />
217
+ );
218
+ case 'date': {
219
+ const parsed = value instanceof Date ? dayjs(value) : null;
220
+ return (
221
+ <DatePicker
222
+ value={parsed}
223
+ onChange={(next) => onChange?.(next ? next.toDate() : null)}
224
+ disabled={disabled}
225
+ showTime
226
+ allowClear={false}
227
+ style={{ width: '100%' }}
228
+ {...typedProps}
229
+ />
230
+ );
231
+ }
232
+ default:
233
+ return null;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Constant-or-variable input. Port of v1 `Variable.Input` typed-constant
239
+ * Cascader pattern (the `[Null | Constant<type> | Variable<…namespaces>]`
240
+ * switcher). Use this when a field can accept either a typed literal
241
+ * (number, boolean, date, string) or a variable reference like
242
+ * `{{ $env.SMTP_PORT }}` — see `plugin-notification-email`'s port / secure
243
+ * fields for the canonical example.
244
+ *
245
+ * Pure literal fields should keep using the antd primitive
246
+ * (`InputNumber`, `Select`, `DatePicker`). Pure variable fields should keep
247
+ * using `EnvVariableInput` / `VariableInput`. This component carries the
248
+ * Cascader switcher overhead, so reach for it only when the field
249
+ * genuinely accepts both shapes.
250
+ */
251
+ export function TypedVariableInput(props: TypedVariableInputProps) {
252
+ const {
253
+ value,
254
+ onChange,
255
+ types = DEFAULT_TYPES,
256
+ namespaces,
257
+ extraNodes,
258
+ nullable = true,
259
+ delimiters,
260
+ disabled,
261
+ placeholder,
262
+ style,
263
+ className,
264
+ } = props;
265
+ const ctx = useFlowContext();
266
+ const t = useCallback((text: string): string => (typeof ctx?.t === 'function' ? ctx.t(text) : text), [ctx]);
267
+ const { token } = theme.useToken();
268
+
269
+ const metaTree = useFilteredMetaTree({ namespaces, extraNodes });
270
+
271
+ const parseVariablePath = useMemo(() => makeParseVariablePath(delimiters), [delimiters]);
272
+ const formatVariablePath = useMemo(() => makeFormatVariablePath(delimiters), [delimiters]);
273
+
274
+ const normalizedTypes = useMemo(() => normalizeTypes(types), [types]);
275
+ const detected = useMemo(() => detectMode(value, parseVariablePath), [value, parseVariablePath]);
276
+ const variableItems = useMemo(() => buildContextSelectorItems(metaTree).map(fromContextItem), [metaTree]);
277
+
278
+ const switcherOptions = useMemo<SwitcherOption[]>(() => {
279
+ const items: SwitcherOption[] = [];
280
+ if (nullable) {
281
+ items.push({ value: NULL_KEY, label: t('Null'), isLeaf: true });
282
+ }
283
+ // Always render Constant with a typed submenu — even when only one type is
284
+ // allowed. Matches v1 `Variable.Input`, where clicking 常量 reveals 数字 /
285
+ // 逻辑值 / etc. so the user can see what type the constant actually is.
286
+ if (normalizedTypes.length > 0) {
287
+ items.push({
288
+ value: CONST_KEY,
289
+ label: t('Constant'),
290
+ children: normalizedTypes.map(({ type }) => ({
291
+ value: type,
292
+ label: t(TYPE_LABEL_KEYS[type]),
293
+ isLeaf: true,
294
+ })),
295
+ });
296
+ }
297
+ items.push(...variableItems);
298
+ return items;
299
+ }, [nullable, normalizedTypes, variableItems, t]);
300
+
301
+ const onSwitcherChange = useCallback<NonNullable<CascaderProps<SwitcherOption>['onChange']>>(
302
+ (path, selectedOptions) => {
303
+ const head = path?.[0];
304
+ if (head === NULL_KEY) {
305
+ onChange?.(null);
306
+ return;
307
+ }
308
+ if (head === CONST_KEY) {
309
+ // Cascader always emits the second segment now that we render typed
310
+ // children even for single-type configs. Fall back to the first
311
+ // allowed type so picking only the parent still does something
312
+ // sensible (`changeOnSelect` lets this fire).
313
+ const targetType = (path[1] as TypedConstantType | undefined) ?? normalizedTypes[0]?.type;
314
+ if (!targetType) return;
315
+ if (detected.mode === targetType) return;
316
+ onChange?.(defaultValueFor(targetType));
317
+ return;
318
+ }
319
+ const leaf = selectedOptions?.[selectedOptions.length - 1] as SwitcherOption | undefined;
320
+ const meta = leaf?.meta;
321
+ if (!meta) return;
322
+ const formatted = formatVariablePath(meta);
323
+ if (formatted != null) onChange?.(formatted);
324
+ },
325
+ [onChange, normalizedTypes, detected.mode, formatVariablePath],
326
+ );
327
+
328
+ const onClearVariable = useCallback(() => {
329
+ if (nullable) {
330
+ onChange?.(null);
331
+ return;
332
+ }
333
+ const first = normalizedTypes[0];
334
+ if (first) onChange?.(defaultValueFor(first.type));
335
+ }, [nullable, normalizedTypes, onChange]);
336
+
337
+ const constantTypeForRendering: TypedConstantType = useMemo(() => {
338
+ const m = detected.mode;
339
+ if (m === 'string' || m === 'number' || m === 'boolean' || m === 'date') return m;
340
+ return normalizedTypes[0]?.type ?? 'string';
341
+ }, [detected.mode, normalizedTypes]);
342
+
343
+ const typedProps = useMemo(
344
+ () => normalizedTypes.find((nt) => nt.type === constantTypeForRendering)?.props ?? {},
345
+ [normalizedTypes, constantTypeForRendering],
346
+ );
347
+
348
+ const isVariable = detected.mode === 'variable';
349
+ const isNull = detected.mode === 'null';
350
+
351
+ const variableLabels = useMemo(
352
+ () => (isVariable && detected.variablePath ? resolveVariableLabels(detected.variablePath, metaTree) : []),
353
+ [isVariable, detected.variablePath, metaTree],
354
+ );
355
+
356
+ return (
357
+ <Space.Compact style={{ display: 'flex', width: '100%', ...style }} className={className}>
358
+ <div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
359
+ {isVariable ? (
360
+ <div
361
+ role="button"
362
+ aria-label="variable-tag"
363
+ style={{
364
+ display: 'flex',
365
+ alignItems: 'center',
366
+ gap: token.marginXXS,
367
+ padding: `0 ${token.paddingSM}px`,
368
+ minHeight: token.controlHeight,
369
+ border: `1px solid ${token.colorBorder}`,
370
+ borderRadius: token.borderRadius,
371
+ borderTopRightRadius: 0,
372
+ borderBottomRightRadius: 0,
373
+ background: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
374
+ overflow: 'hidden',
375
+ }}
376
+ >
377
+ <Tag
378
+ color="blue"
379
+ style={{
380
+ marginInlineEnd: 0,
381
+ maxWidth: '100%',
382
+ overflow: 'hidden',
383
+ textOverflow: 'ellipsis',
384
+ whiteSpace: 'nowrap',
385
+ }}
386
+ >
387
+ {variableLabels.map((label, index) => (
388
+ <React.Fragment key={`${label}-${index}`}>
389
+ {index ? ' / ' : ''}
390
+ {label}
391
+ </React.Fragment>
392
+ ))}
393
+ </Tag>
394
+ {!disabled ? (
395
+ <Button
396
+ type="text"
397
+ size="small"
398
+ aria-label="icon-close"
399
+ onClick={onClearVariable}
400
+ icon={<CloseCircleFilled style={{ color: token.colorTextTertiary }} />}
401
+ />
402
+ ) : null}
403
+ </div>
404
+ ) : isNull ? (
405
+ // v1 used the `placeholder` slot (not `value`) so the antd default
406
+ // placeholder colour applies — keeps the field looking visibly
407
+ // empty/inactive rather than holding a real text value.
408
+ <Input placeholder={`<${t('Null')}>`} readOnly disabled={disabled} style={{ width: '100%' }} />
409
+ ) : (
410
+ renderConstantEditor(constantTypeForRendering, value, onChange, {
411
+ typedProps,
412
+ disabled,
413
+ placeholder,
414
+ t,
415
+ })
416
+ )}
417
+ </div>
418
+ <Cascader<SwitcherOption>
419
+ options={switcherOptions}
420
+ onChange={onSwitcherChange}
421
+ disabled={disabled}
422
+ changeOnSelect
423
+ >
424
+ <Button
425
+ aria-label="variable-switcher"
426
+ disabled={disabled}
427
+ type={isVariable ? 'primary' : 'default'}
428
+ style={{
429
+ flexShrink: 0,
430
+ fontStyle: 'italic',
431
+ fontFamily: '"New York", "Times New Roman", Times, serif',
432
+ }}
433
+ >
434
+ x
435
+ </Button>
436
+ </Cascader>
437
+ </Space.Compact>
438
+ );
439
+ }
440
+
441
+ export default TypedVariableInput;
@@ -0,0 +1,152 @@
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 { FlowContext, FlowContextProvider } from '@nocobase/flow-engine';
11
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
12
+ import React from 'react';
13
+ import { describe, expect, it, vi } from 'vitest';
14
+ import { TypedVariableInput } from '../TypedVariableInput';
15
+
16
+ function createContextWithEnv() {
17
+ const ctx = new FlowContext();
18
+ (ctx as any).t = (key: string) => key;
19
+
20
+ ctx.defineProperty('$env', {
21
+ value: { SMTP_PORT: 465, SECURE_FLAG: true },
22
+ meta: {
23
+ title: 'Env',
24
+ type: 'object',
25
+ properties: {
26
+ SMTP_PORT: { title: 'SMTP Port', type: 'number' },
27
+ SECURE_FLAG: { title: 'Secure flag', type: 'boolean' },
28
+ },
29
+ },
30
+ });
31
+
32
+ return ctx;
33
+ }
34
+
35
+ function renderWithCtx(ctx: FlowContext, node: React.ReactNode) {
36
+ return render(<FlowContextProvider context={ctx}>{node}</FlowContextProvider>);
37
+ }
38
+
39
+ describe('TypedVariableInput - constant rendering', () => {
40
+ it('renders an InputNumber for a numeric value when types=[number]', async () => {
41
+ const ctx = createContextWithEnv();
42
+ renderWithCtx(
43
+ ctx,
44
+ <TypedVariableInput
45
+ value={465}
46
+ types={[['number', { min: 1, max: 65535 }]]}
47
+ namespaces={['$env']}
48
+ onChange={() => undefined}
49
+ />,
50
+ );
51
+ const numberInput = await screen.findByDisplayValue('465');
52
+ expect(numberInput).toBeInTheDocument();
53
+ expect(numberInput.getAttribute('role')).toBe('spinbutton');
54
+ });
55
+
56
+ it('renders a boolean Select when value is true and types=[boolean]', async () => {
57
+ const ctx = createContextWithEnv();
58
+ renderWithCtx(
59
+ ctx,
60
+ <TypedVariableInput value={true} types={['boolean']} namespaces={['$env']} onChange={() => undefined} />,
61
+ );
62
+ await waitFor(() => {
63
+ // antd Select renders the chosen item label via a selection-item span
64
+ const item = screen.getByText('True');
65
+ expect(item).toBeInTheDocument();
66
+ });
67
+ });
68
+
69
+ it('renders the Null placeholder when value=null and nullable=true', async () => {
70
+ const ctx = createContextWithEnv();
71
+ renderWithCtx(
72
+ ctx,
73
+ <TypedVariableInput value={null} types={['number']} namespaces={['$env']} nullable onChange={() => undefined} />,
74
+ );
75
+ // Uses placeholder slot (not value) so antd's grey placeholder colour
76
+ // applies — see comment in TypedVariableInput.tsx.
77
+ const nullInput = await screen.findByPlaceholderText('<Null>');
78
+ expect(nullInput).toBeInTheDocument();
79
+ expect(nullInput.getAttribute('readonly')).not.toBeNull();
80
+ });
81
+ });
82
+
83
+ describe('TypedVariableInput - variable rendering', () => {
84
+ it('renders a labelled pill when value is a {{ $env.X }} expression', async () => {
85
+ const ctx = createContextWithEnv();
86
+ renderWithCtx(
87
+ ctx,
88
+ <TypedVariableInput
89
+ value="{{$env.SMTP_PORT}}"
90
+ types={['number']}
91
+ namespaces={['$env']}
92
+ onChange={() => undefined}
93
+ />,
94
+ );
95
+ await waitFor(() => {
96
+ const tag = screen.getByRole('button', { name: 'variable-tag' });
97
+ expect(tag.textContent).toContain('Env');
98
+ expect(tag.textContent).toContain('SMTP Port');
99
+ });
100
+ });
101
+
102
+ it('clears back to null when the close button is clicked (nullable=true)', async () => {
103
+ const ctx = createContextWithEnv();
104
+ const handleChange = vi.fn();
105
+ renderWithCtx(
106
+ ctx,
107
+ <TypedVariableInput
108
+ value="{{$env.SMTP_PORT}}"
109
+ types={['number']}
110
+ namespaces={['$env']}
111
+ nullable
112
+ onChange={handleChange}
113
+ />,
114
+ );
115
+ const clear = await screen.findByRole('button', { name: 'icon-close' });
116
+ fireEvent.click(clear);
117
+ expect(handleChange).toHaveBeenCalledWith(null);
118
+ });
119
+
120
+ it('clears back to default-of-first-type when nullable=false', async () => {
121
+ const ctx = createContextWithEnv();
122
+ const handleChange = vi.fn();
123
+ renderWithCtx(
124
+ ctx,
125
+ <TypedVariableInput
126
+ value="{{$env.SMTP_PORT}}"
127
+ types={['number']}
128
+ namespaces={['$env']}
129
+ nullable={false}
130
+ onChange={handleChange}
131
+ />,
132
+ );
133
+ const clear = await screen.findByRole('button', { name: 'icon-close' });
134
+ fireEvent.click(clear);
135
+ expect(handleChange).toHaveBeenCalledWith(0);
136
+ });
137
+ });
138
+
139
+ describe('TypedVariableInput - editor onChange propagation', () => {
140
+ it('forwards numeric edits via onChange', async () => {
141
+ const ctx = createContextWithEnv();
142
+ const handleChange = vi.fn();
143
+ renderWithCtx(ctx, <TypedVariableInput value={465} types={['number']} onChange={handleChange} />);
144
+ const numberInput = await screen.findByDisplayValue('465');
145
+ fireEvent.change(numberInput, { target: { value: '587' } });
146
+ // antd InputNumber may emit on blur; force blur to flush
147
+ fireEvent.blur(numberInput);
148
+ expect(handleChange).toHaveBeenCalled();
149
+ const lastCall = handleChange.mock.calls[handleChange.mock.calls.length - 1][0];
150
+ expect(lastCall).toBe(587);
151
+ });
152
+ });
@@ -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} />