@nocobase/flow-engine 2.1.0-alpha.30 → 2.1.0-alpha.32

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.
@@ -0,0 +1,531 @@
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 { css, cx } from '@emotion/css';
11
+ import { Space, theme } from 'antd';
12
+ import React, { isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
+ import type { MetaTreeNode } from '../../flowContext';
14
+ import { useFlowContext } from '../../FlowContextProvider';
15
+ import { FlowContextSelector } from '../FlowContextSelector';
16
+ import { useResolvedMetaTree } from './useResolvedMetaTree';
17
+ import { formatPathToValue as defaultFormatPathToValue, parseValueToPath as defaultParseValueToPath } from './utils';
18
+
19
+ type RangeIndexes = [number, number, number, number];
20
+
21
+ const DEFAULT_VARIABLE_REGEXP = /\{\{\s*([^{}]+?)\s*\}\}/g;
22
+ const TAG_CLASS = 'nb-variable-tag';
23
+
24
+ export interface VariableHybridInputConverters {
25
+ formatPathToValue?: (item?: MetaTreeNode) => string | undefined;
26
+ parseValueToPath?: (value?: string) => string[] | undefined;
27
+ variableRegExp?: RegExp;
28
+ }
29
+
30
+ export interface VariableHybridInputProps {
31
+ value?: string;
32
+ onChange?: (value: string) => void;
33
+ disabled?: boolean;
34
+ placeholder?: string;
35
+ addonBefore?: React.ReactNode;
36
+ metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
37
+ converters?: VariableHybridInputConverters;
38
+ style?: React.CSSProperties;
39
+ className?: string;
40
+ }
41
+
42
+ function reactNodeToPlainText(node: React.ReactNode): string {
43
+ if (node == null || typeof node === 'boolean') return '';
44
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
45
+ if (Array.isArray(node)) return node.map(reactNodeToPlainText).join('');
46
+ if (isValidElement(node)) return reactNodeToPlainText(node.props.children);
47
+ return '';
48
+ }
49
+
50
+ function escapeHtml(value: string) {
51
+ return value
52
+ .replace(/&/g, '&amp;')
53
+ .replace(/</g, '&lt;')
54
+ .replace(/>/g, '&gt;')
55
+ .replace(/"/g, '&quot;')
56
+ .replace(/'/g, '&#039;');
57
+ }
58
+
59
+ function getDomValue(element: HTMLElement) {
60
+ const out: string[] = [];
61
+ for (const node of Array.from(element.childNodes)) {
62
+ if (node instanceof HTMLElement && node.dataset.variable) {
63
+ out.push(node.dataset.variable);
64
+ } else {
65
+ out.push(node.textContent || '');
66
+ }
67
+ }
68
+ return out.join('');
69
+ }
70
+
71
+ function createTagHTML(variable: string, label: string) {
72
+ return `<span class="${TAG_CLASS}" contenteditable="false" data-variable="${escapeHtml(
73
+ variable,
74
+ )}" title="${escapeHtml(label)}">${escapeHtml(label)}</span>`;
75
+ }
76
+
77
+ // Strip outer `{{ }}` and trim, so values like `{{ $env.x }}` and `{{$env.x}}`
78
+ // share the same lookup key.
79
+ function normalizeVariableKey(value: string): string {
80
+ return value
81
+ .replace(/^\{\{\s*/, '')
82
+ .replace(/\s*\}\}$/, '')
83
+ .trim();
84
+ }
85
+
86
+ function renderHTML(value: string, labelMap: Map<string, string>, regExp: RegExp) {
87
+ const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
88
+ return escapeHtml(value || '').replace(re, (matched) => {
89
+ const label = labelMap.get(normalizeVariableKey(matched)) || matched;
90
+ return createTagHTML(matched, label);
91
+ });
92
+ }
93
+
94
+ function buildLabelMap(
95
+ nodes: MetaTreeNode[] | undefined,
96
+ ctxT: (text: string) => string,
97
+ converters?: VariableHybridInputConverters,
98
+ ) {
99
+ const map = new Map<string, string>();
100
+
101
+ function walk(items: MetaTreeNode[] = [], parentTitles: string[] = []) {
102
+ for (const item of items) {
103
+ const titlePart = reactNodeToPlainText(item.title || item.name);
104
+ const titles = [...parentTitles, titlePart];
105
+ const value = converters?.formatPathToValue?.(item) ?? defaultFormatPathToValue(item);
106
+ if (value) {
107
+ map.set(normalizeVariableKey(value), titles.map(ctxT).join('/'));
108
+ }
109
+ if (Array.isArray(item.children)) {
110
+ walk(item.children as MetaTreeNode[], titles);
111
+ }
112
+ }
113
+ }
114
+
115
+ walk(nodes);
116
+ return map;
117
+ }
118
+
119
+ function pasteHTML(container: HTMLElement, html: string, indexes?: RangeIndexes) {
120
+ const selection = window.getSelection?.();
121
+ const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
122
+ if (!range) return;
123
+
124
+ if (indexes) {
125
+ const children = Array.from(container.childNodes);
126
+ if (indexes[0] === -1) {
127
+ if (indexes[1] && children[indexes[1] - 1]) {
128
+ range.setStartAfter(children[indexes[1] - 1]);
129
+ } else {
130
+ range.setStart(container, 0);
131
+ }
132
+ } else {
133
+ range.setStart(children[indexes[0]], indexes[1]);
134
+ }
135
+
136
+ if (indexes[2] === -1) {
137
+ if (indexes[3] && children[indexes[3] - 1]) {
138
+ range.setEndAfter(children[indexes[3] - 1]);
139
+ } else {
140
+ range.setEnd(container, 0);
141
+ }
142
+ } else {
143
+ range.setEnd(children[indexes[2]], indexes[3]);
144
+ }
145
+ }
146
+
147
+ const wrapper = document.createElement('div');
148
+ wrapper.innerHTML = html;
149
+ const fragment = document.createDocumentFragment();
150
+ let lastNode: ChildNode | null = null;
151
+ while (wrapper.firstChild) {
152
+ lastNode = fragment.appendChild(wrapper.firstChild);
153
+ }
154
+ range.deleteContents();
155
+ range.insertNode(fragment);
156
+
157
+ if (lastNode) {
158
+ const next = new Range();
159
+ next.setStartAfter(lastNode);
160
+ next.collapse(true);
161
+ selection?.removeAllRanges();
162
+ selection?.addRange(next);
163
+ }
164
+ }
165
+
166
+ function getSingleEndRange(nodes: ChildNode[], index: number, offset: number): [number, number] {
167
+ if (index === -1) {
168
+ let realIndex = offset;
169
+ let collapseFlag = false;
170
+ if (realIndex && nodes[realIndex - 1]?.nodeName === '#text' && nodes[realIndex]?.nodeName === '#text') {
171
+ collapseFlag = true;
172
+ }
173
+ let textOffset = 0;
174
+ for (let i = offset - 1; i >= 0; i -= 1) {
175
+ if (collapseFlag) {
176
+ if (nodes[i]?.nodeName === '#text') {
177
+ textOffset += nodes[i].textContent?.length || 0;
178
+ } else {
179
+ collapseFlag = false;
180
+ }
181
+ }
182
+ if (nodes[i]?.nodeName === '#text' && nodes[i + 1]?.nodeName === '#text') {
183
+ realIndex -= 1;
184
+ }
185
+ }
186
+ return textOffset ? [realIndex, textOffset] : [-1, realIndex];
187
+ }
188
+
189
+ let realIndex = 0;
190
+ let textOffset = 0;
191
+ for (let i = 0; i < index + 1; i += 1) {
192
+ if (nodes[i]?.nodeName === '#text') {
193
+ if (i !== index && nodes[i + 1] && nodes[i + 1]?.nodeName !== '#text') {
194
+ realIndex += 1;
195
+ }
196
+ textOffset += i === index ? offset : nodes[i].textContent?.length || 0;
197
+ } else {
198
+ realIndex += 1;
199
+ textOffset = 0;
200
+ }
201
+ }
202
+ return [realIndex, textOffset];
203
+ }
204
+
205
+ function getCurrentRange(element: HTMLElement): RangeIndexes {
206
+ const selection = window.getSelection?.();
207
+ const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
208
+ if (!range || !element.contains(range.commonAncestorContainer)) {
209
+ return [-1, 0, -1, 0];
210
+ }
211
+
212
+ const nodes = Array.from(element.childNodes);
213
+ if (!nodes.length) return [-1, 0, -1, 0];
214
+
215
+ const startIndex = range.startContainer === element ? -1 : nodes.indexOf(range.startContainer as HTMLElement);
216
+ const endIndex = range.endContainer === element ? -1 : nodes.indexOf(range.endContainer as HTMLElement);
217
+
218
+ return [
219
+ ...getSingleEndRange(nodes, startIndex, range.startOffset),
220
+ ...getSingleEndRange(nodes, endIndex, range.endOffset),
221
+ ];
222
+ }
223
+
224
+ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props) => {
225
+ const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, style } = props;
226
+ const { token } = theme.useToken();
227
+ const ctx = useFlowContext();
228
+ const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
229
+ const inputRef = useRef<HTMLDivElement>(null);
230
+ const [isComposing, setIsComposing] = useState(false);
231
+ const [changed, setChanged] = useState(false);
232
+ const [range, setRange] = useState<RangeIndexes>([-1, 0, -1, 0]);
233
+
234
+ const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : String(props.value);
235
+ const variableRegExp = converters?.variableRegExp ?? DEFAULT_VARIABLE_REGEXP;
236
+
237
+ const labelMap = useMemo(
238
+ () => buildLabelMap(resolvedMetaTree as MetaTreeNode[] | undefined, ctx.t, converters),
239
+ [resolvedMetaTree, ctx, converters],
240
+ );
241
+
242
+ const [html, setHtml] = useState(() => renderHTML(value, labelMap, variableRegExp));
243
+
244
+ const emitChange = useCallback(
245
+ (target: HTMLElement) => {
246
+ onChange?.(getDomValue(target).trim());
247
+ },
248
+ [onChange],
249
+ );
250
+
251
+ useEffect(() => {
252
+ setHtml(renderHTML(value, labelMap, variableRegExp));
253
+ if (!changed) {
254
+ setRange([-1, 0, -1, 0]);
255
+ }
256
+ // eslint-disable-next-line react-hooks/exhaustive-deps
257
+ }, [value, labelMap]);
258
+
259
+ // Restore caret position after html update
260
+ useEffect(() => {
261
+ const element = inputRef.current;
262
+ if (!element) return;
263
+ if (document.activeElement !== element) return;
264
+
265
+ const nextRange = new Range();
266
+ if (changed) {
267
+ if (range.join() === '-1,0,-1,0') return;
268
+ const selection = window.getSelection?.();
269
+ if (!selection) return;
270
+ try {
271
+ const children = Array.from(element.childNodes) as HTMLElement[];
272
+ if (children.length) {
273
+ if (range[0] === -1) {
274
+ if (range[1]) nextRange.setStartAfter(children[range[1] - 1]);
275
+ } else {
276
+ nextRange.setStart(children[range[0]], range[1]);
277
+ }
278
+ if (range[2] === -1) {
279
+ if (range[3]) nextRange.setEndAfter(children[range[3] - 1]);
280
+ } else {
281
+ nextRange.setEnd(children[range[2]], range[3]);
282
+ }
283
+ }
284
+ nextRange.collapse(true);
285
+ selection.removeAllRanges();
286
+ selection.addRange(nextRange);
287
+ } catch {
288
+ /* ignore */
289
+ }
290
+ } else {
291
+ const { lastChild } = element;
292
+ if (lastChild) {
293
+ nextRange.setStartAfter(lastChild);
294
+ nextRange.setEndAfter(lastChild);
295
+ const nodes = Array.from(element.childNodes);
296
+ const idx = nodes.indexOf(lastChild);
297
+ const startIndex = nextRange.startContainer === element ? -1 : idx;
298
+ const endIndex = nextRange.startContainer === element ? -1 : idx;
299
+ setRange([startIndex, nextRange.startOffset, endIndex, nextRange.endOffset]);
300
+ }
301
+ }
302
+ }, [changed, html, range]);
303
+
304
+ const insertVariable = useCallback(
305
+ (variable: string, meta?: MetaTreeNode) => {
306
+ const current = inputRef.current;
307
+ if (!current || !variable) return;
308
+
309
+ const label =
310
+ labelMap.get(normalizeVariableKey(variable)) ||
311
+ (meta
312
+ ? [...(meta.parentTitles || []), reactNodeToPlainText(meta.title || meta.name)].map(ctx.t).join('/')
313
+ : variable);
314
+
315
+ current.focus();
316
+ pasteHTML(current, createTagHTML(variable, label), range);
317
+ setChanged(true);
318
+ setRange(getCurrentRange(current));
319
+ emitChange(current);
320
+ },
321
+ [labelMap, range, emitChange, ctx],
322
+ );
323
+
324
+ const handleSelectorChange = useCallback(
325
+ (next: string, meta?: MetaTreeNode) => {
326
+ if (!next) return;
327
+ insertVariable(next, meta);
328
+ },
329
+ [insertVariable],
330
+ );
331
+
332
+ const handleInput = useCallback(
333
+ ({ currentTarget }: React.FormEvent<HTMLDivElement>) => {
334
+ if (isComposing) return;
335
+ setChanged(true);
336
+ setRange(getCurrentRange(currentTarget));
337
+ emitChange(currentTarget);
338
+ },
339
+ [emitChange, isComposing],
340
+ );
341
+
342
+ const handleBlur = useCallback(({ currentTarget }: React.FocusEvent<HTMLDivElement>) => {
343
+ setRange(getCurrentRange(currentTarget));
344
+ }, []);
345
+
346
+ const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
347
+ if (event.key === 'Enter') {
348
+ event.preventDefault();
349
+ }
350
+ }, []);
351
+
352
+ const handlePaste = useCallback(
353
+ (event: React.ClipboardEvent<HTMLDivElement>) => {
354
+ event.preventDefault();
355
+ // Paste as plain text only; variable tags must be inserted via the picker.
356
+ const text = event.clipboardData.getData('text/plain').replace(/\n/g, ' ');
357
+ if (!text) return;
358
+ setChanged(true);
359
+ pasteHTML(event.currentTarget, escapeHtml(text));
360
+ setRange(getCurrentRange(event.currentTarget));
361
+ emitChange(event.currentTarget);
362
+ },
363
+ [emitChange],
364
+ );
365
+
366
+ const handleCompositionStart = useCallback(() => setIsComposing(true), []);
367
+ const handleCompositionEnd = useCallback(
368
+ ({ currentTarget }: React.CompositionEvent<HTMLDivElement>) => {
369
+ setIsComposing(false);
370
+ setChanged(true);
371
+ setRange(getCurrentRange(currentTarget));
372
+ emitChange(currentTarget);
373
+ },
374
+ [emitChange],
375
+ );
376
+
377
+ const wrapperClassName = useMemo(
378
+ () => css`
379
+ display: flex;
380
+ width: 100%;
381
+ min-width: 0;
382
+
383
+ &.ant-space-compact {
384
+ display: flex;
385
+ }
386
+
387
+ /* The trigger button from FlowContextSelector sits at the right end.
388
+ Flatten its left corners so it shares the border with the editor. */
389
+ > .ant-btn {
390
+ flex-shrink: 0;
391
+ border-top-left-radius: 0;
392
+ border-bottom-left-radius: 0;
393
+ margin-left: -${token.lineWidth}px;
394
+ }
395
+
396
+ > .ant-btn:hover,
397
+ > .ant-btn:focus {
398
+ z-index: 2;
399
+ }
400
+ `,
401
+ [token.lineWidth],
402
+ );
403
+
404
+ const addonClassName = useMemo(
405
+ () => css`
406
+ display: inline-flex;
407
+ align-items: center;
408
+ padding: 0 ${token.paddingSM}px;
409
+ background: ${token.colorFillTertiary};
410
+ border: ${token.lineWidth}px ${token.lineType} ${token.colorBorder};
411
+ border-right: 0;
412
+ border-radius: ${token.borderRadius}px 0 0 ${token.borderRadius}px;
413
+ color: ${token.colorText};
414
+ font-size: ${token.fontSize}px;
415
+ line-height: 1;
416
+ white-space: nowrap;
417
+ `,
418
+ [token],
419
+ );
420
+
421
+ const editorClassName = useMemo(() => {
422
+ const verticalPad = Math.max(
423
+ 0,
424
+ (token.controlHeight - Math.round(token.lineHeight * token.fontSize)) / 2 - token.lineWidth,
425
+ );
426
+ return css`
427
+ flex: 1 1 auto;
428
+ min-width: 0;
429
+ min-height: ${token.controlHeight}px;
430
+ padding: ${verticalPad}px ${token.paddingSM}px;
431
+ overflow: hidden;
432
+ white-space: pre-wrap;
433
+ word-break: break-word;
434
+ line-height: ${token.lineHeight};
435
+ font-size: ${token.fontSize}px;
436
+ color: ${token.colorText};
437
+ background: ${token.colorBgContainer};
438
+ border: ${token.lineWidth}px ${token.lineType} ${token.colorBorder};
439
+ /* Right corners are always flat because the X picker button is glued to the right side. */
440
+ border-radius: ${addonBefore ? '0' : `${token.borderRadius}px 0 0 ${token.borderRadius}px`};
441
+ cursor: text;
442
+ transition: all ${token.motionDurationMid};
443
+ outline: none;
444
+
445
+ &:hover {
446
+ border-color: ${token.colorPrimaryHover};
447
+ z-index: 1;
448
+ }
449
+
450
+ &:focus,
451
+ &:focus-visible {
452
+ border-color: ${token.colorPrimary};
453
+ box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.controlOutline};
454
+ z-index: 1;
455
+ }
456
+
457
+ &[data-placeholder]:empty::before {
458
+ content: attr(data-placeholder);
459
+ color: ${token.colorTextPlaceholder};
460
+ pointer-events: none;
461
+ }
462
+
463
+ .${TAG_CLASS} {
464
+ /* inline lets long tag content wrap naturally across lines, mirroring v1. */
465
+ display: inline;
466
+ margin: 0 ${token.marginXXS}px;
467
+ padding: ${token.paddingXXS}px ${token.paddingXS}px;
468
+ font-size: ${token.fontSizeSM}px;
469
+ line-height: ${token.lineHeightSM};
470
+ color: ${token.colorPrimaryText};
471
+ background: ${token.colorPrimaryBg};
472
+ border: ${token.lineWidth}px ${token.lineType} ${token.colorPrimaryBorder};
473
+ border-radius: ${token.borderRadiusSM}px;
474
+ vertical-align: baseline;
475
+ user-select: none;
476
+ cursor: default;
477
+ }
478
+
479
+ &.is-disabled {
480
+ background: ${token.colorBgContainerDisabled};
481
+ color: ${token.colorTextDisabled};
482
+ border-color: ${token.colorBorder};
483
+ cursor: not-allowed;
484
+
485
+ &:hover {
486
+ border-color: ${token.colorBorder};
487
+ }
488
+
489
+ .${TAG_CLASS} {
490
+ color: ${token.colorTextDisabled};
491
+ background: ${token.colorFillTertiary};
492
+ border-color: ${token.colorBorder};
493
+ }
494
+ }
495
+ `;
496
+ }, [token, addonBefore]);
497
+
498
+ return (
499
+ <>
500
+ <Space.Compact className={cx('nb-variable-hybrid-input', wrapperClassName, className)} style={style}>
501
+ {addonBefore != null && <span className={addonClassName}>{addonBefore}</span>}
502
+ <div
503
+ ref={inputRef}
504
+ role="textbox"
505
+ aria-label="textbox"
506
+ className={cx(editorClassName, {
507
+ 'is-disabled': disabled,
508
+ })}
509
+ contentEditable={!disabled}
510
+ data-placeholder={placeholder}
511
+ onInput={handleInput}
512
+ onBlur={handleBlur}
513
+ onKeyDown={handleKeyDown}
514
+ onPaste={handlePaste}
515
+ onCompositionStart={handleCompositionStart}
516
+ onCompositionEnd={handleCompositionEnd}
517
+ dangerouslySetInnerHTML={{ __html: html }}
518
+ />
519
+ <FlowContextSelector
520
+ metaTree={metaTree}
521
+ disabled={disabled}
522
+ parseValueToPath={converters?.parseValueToPath ?? defaultParseValueToPath}
523
+ formatPathToValue={(item) => converters?.formatPathToValue?.(item) || defaultFormatPathToValue(item)}
524
+ onChange={handleSelectorChange}
525
+ />
526
+ </Space.Compact>
527
+ </>
528
+ );
529
+ };
530
+
531
+ export const VariableHybridInput = React.memo(VariableHybridInputComponent);
@@ -8,6 +8,8 @@
8
8
  */
9
9
 
10
10
  export { VariableInput } from './VariableInput';
11
+ export { VariableHybridInput } from './VariableHybridInput';
12
+ export type { VariableHybridInputProps, VariableHybridInputConverters } from './VariableHybridInput';
11
13
  export { SlateVariableEditor } from './SlateVariableEditor';
12
14
  export { VariableTag } from './VariableTag';
13
15
  export { InlineVariableTag } from './InlineVariableTag';
@@ -7,8 +7,8 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { describe, expect, it } from 'vitest';
11
- import { DataSource, DataSourceManager, isFieldInterfaceMatch } from '../index';
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import { DataSource, DataSourceManager, getCollectionFieldInterface, isFieldInterfaceMatch } from '../index';
12
12
  import { FlowEngine } from '../../flowEngine';
13
13
 
14
14
  describe('Collection/Field helpers', () => {
@@ -55,4 +55,43 @@ describe('Collection/Field helpers', () => {
55
55
  const field = posts.getFieldByPath('category.name');
56
56
  expect(field?.name).toBe('name');
57
57
  });
58
+
59
+ it('resolves collection field interfaces from the first available manager', () => {
60
+ const first = { collectionFieldInterfaceManager: { getFieldInterface: vi.fn((name) => ({ name })) } };
61
+ const second = { collectionFieldInterfaceManager: { getFieldInterface: vi.fn((name) => ({ name })) } };
62
+
63
+ expect(getCollectionFieldInterface('input', {}, first, second)).toEqual({ name: 'input' });
64
+ expect(first.collectionFieldInterfaceManager.getFieldInterface).toHaveBeenCalledWith('input');
65
+ expect(second.collectionFieldInterfaceManager.getFieldInterface).not.toHaveBeenCalled();
66
+ expect(getCollectionFieldInterface(undefined, first)).toBeUndefined();
67
+ expect(getCollectionFieldInterface('input', {})).toBeUndefined();
68
+ });
69
+
70
+ it('uses collection field interface resolver from getInterfaceOptions', () => {
71
+ const { ds, m } = setup();
72
+ const ctx = m.flowEngine.context;
73
+ const getOwnerFieldInterface = vi.fn((name: string) => ({ name, source: 'owner' }));
74
+ const getLegacyFieldInterface = vi.fn((name: string) => ({ name, source: 'legacy' }));
75
+
76
+ ctx.defineProperty('app', {
77
+ value: {
78
+ dataSourceManager: {
79
+ collectionFieldInterfaceManager: {
80
+ getFieldInterface: getLegacyFieldInterface,
81
+ },
82
+ },
83
+ },
84
+ });
85
+ ds.addCollection({
86
+ name: 'posts',
87
+ fields: [{ name: 'title', type: 'string', interface: 'input' }],
88
+ });
89
+
90
+ const field = ds.getCollection('posts')!.getField('title')!;
91
+ expect(field.getInterfaceOptions()).toEqual({ name: 'input', source: 'legacy' });
92
+
93
+ m.setCollectionFieldInterfaceManager({ getFieldInterface: getOwnerFieldInterface });
94
+ expect(field.getInterfaceOptions()).toEqual({ name: 'input', source: 'owner' });
95
+ expect(getLegacyFieldInterface).toHaveBeenCalledTimes(1);
96
+ });
58
97
  });
@@ -80,6 +80,40 @@ describe('DataSource & Collection APIs', () => {
80
80
  ).toThrow(/circular/);
81
81
  });
82
82
 
83
+ it('translates validation messages from data-source-main in component rules', async () => {
84
+ const { m, engine } = makeManager();
85
+ engine.context.i18n = {
86
+ t: (key: string, options?: Record<string, any>) => {
87
+ if (key === 'string.length' && options?.ns === 'data-source-main') {
88
+ return `${options.label} 长度必须为 ${options.limit} 个字符`;
89
+ }
90
+ return key;
91
+ },
92
+ } as any;
93
+
94
+ const ds = new DataSource({ key: 'main' });
95
+ m.addDataSource(ds);
96
+ ds.addCollection({
97
+ name: 'posts',
98
+ fields: [
99
+ {
100
+ name: 'title',
101
+ type: 'string',
102
+ interface: 'text',
103
+ title: '单行文本',
104
+ validation: {
105
+ type: 'string',
106
+ rules: [{ name: 'length', args: { limit: 18 } }],
107
+ },
108
+ },
109
+ ],
110
+ });
111
+
112
+ const rules = ds.getCollection('posts')!.getField('title')!.getComponentProps().rules;
113
+
114
+ await expect(rules[0].validator({}, '123')).rejects.toBe('单行文本 长度必须为 18 个字符');
115
+ });
116
+
83
117
  it('ensureLoaded, reload and data source events work for main loader', async () => {
84
118
  const { m, engine } = makeManager();
85
119
  const loadedListener = vi.fn();
@@ -295,6 +295,29 @@ export class DataSourceManager {
295
295
  }
296
296
  }
297
297
 
298
+ export type CollectionFieldInterfaceDataSourceManager = Pick<DataSourceManager, 'collectionFieldInterfaceManager'>;
299
+
300
+ export function getCollectionFieldInterface(
301
+ interfaceName: string | undefined,
302
+ ...dataSourceManagers: Array<CollectionFieldInterfaceDataSourceManager | null | undefined>
303
+ ) {
304
+ if (!interfaceName) {
305
+ return undefined;
306
+ }
307
+
308
+ // TODO: Once legacy client is removed and all runtimes share the client-v2 flow-engine
309
+ // DataSourceManager, callers should only pass the flow-engine context DataSourceManager.
310
+ for (const dataSourceManager of dataSourceManagers) {
311
+ const collectionFieldInterfaceManager = dataSourceManager?.collectionFieldInterfaceManager;
312
+ const getFieldInterface = collectionFieldInterfaceManager?.getFieldInterface;
313
+ if (typeof getFieldInterface === 'function') {
314
+ return getFieldInterface.call(collectionFieldInterfaceManager, interfaceName);
315
+ }
316
+ }
317
+
318
+ return undefined;
319
+ }
320
+
298
321
  export class DataSource {
299
322
  dataSourceManager: DataSourceManager;
300
323
  collectionManager: CollectionManager;
@@ -1112,7 +1135,21 @@ export class CollectionField {
1112
1135
  });
1113
1136
 
1114
1137
  if (error) {
1115
- const message = error.details.map((d: any) => d.message.replace(/"value"/g, `"${label}"`)).join(', ');
1138
+ const message = error.details
1139
+ .map((d: any) => {
1140
+ const translated = this.flowEngine.translate(d.type, {
1141
+ ...d.context,
1142
+ ns: 'data-source-main',
1143
+ label,
1144
+ });
1145
+
1146
+ if (translated && translated !== d.type) {
1147
+ return translated;
1148
+ }
1149
+
1150
+ return d.message.replace(/"value"/g, `"${label}"`);
1151
+ })
1152
+ .join(', ');
1116
1153
  return Promise.reject(message);
1117
1154
  }
1118
1155
 
@@ -1135,8 +1172,13 @@ export class CollectionField {
1135
1172
  }
1136
1173
 
1137
1174
  getInterfaceOptions() {
1138
- const app = this.flowEngine.context.app;
1139
- return app.dataSourceManager.collectionFieldInterfaceManager.getFieldInterface(this.interface);
1175
+ const ctx = this.flowEngine.context;
1176
+ return getCollectionFieldInterface(
1177
+ this.interface,
1178
+ this.collection?.dataSource?.dataSourceManager,
1179
+ ctx.dataSourceManager,
1180
+ ctx.app?.dataSourceManager,
1181
+ );
1140
1182
  }
1141
1183
 
1142
1184
  getFilterOperators() {