@nocobase/client-v2 2.1.0-beta.46 → 2.1.0-beta.48

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 (44) hide show
  1. package/es/components/form/ScanInput/CodeScanner.d.ts +18 -0
  2. package/es/components/form/ScanInput/ScanBox.d.ts +12 -0
  3. package/es/components/form/ScanInput/ScanInput.d.ts +18 -0
  4. package/es/components/form/ScanInput/index.d.ts +11 -0
  5. package/es/components/form/ScanInput/types.d.ts +10 -0
  6. package/es/components/form/ScanInput/useCodeScanner.d.ts +31 -0
  7. package/es/components/form/filter/CollectionFilter.d.ts +2 -0
  8. package/es/components/form/filter/CollectionFilterPanel.d.ts +38 -0
  9. package/es/components/form/filter/index.d.ts +2 -0
  10. package/es/components/form/filter/useFilterActionProps.d.ts +3 -0
  11. package/es/components/form/index.d.ts +1 -0
  12. package/es/flow/internal/utils/enumOptionsUtils.d.ts +2 -0
  13. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +14 -0
  14. package/es/flow/models/fields/DisplayJSONFieldModel.d.ts +2 -1
  15. package/es/index.mjs +101 -61
  16. package/lib/index.js +112 -72
  17. package/package.json +8 -7
  18. package/src/components/form/DrawerFormLayout.tsx +1 -1
  19. package/src/components/form/ScanInput/CodeScanner.tsx +219 -0
  20. package/src/components/form/ScanInput/ScanBox.tsx +30 -0
  21. package/src/components/form/ScanInput/ScanInput.tsx +150 -0
  22. package/src/components/form/ScanInput/__tests__/ScanInput.test.tsx +119 -0
  23. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +123 -0
  24. package/src/components/form/ScanInput/index.ts +12 -0
  25. package/src/components/form/ScanInput/types.ts +12 -0
  26. package/src/components/form/ScanInput/useCodeScanner.ts +136 -0
  27. package/src/components/form/filter/CollectionFilter.tsx +4 -0
  28. package/src/components/form/filter/CollectionFilterPanel.tsx +97 -0
  29. package/src/components/form/filter/__tests__/compileFilterGroup.test.ts +76 -1
  30. package/src/components/form/filter/index.ts +2 -0
  31. package/src/components/form/filter/useFilterActionProps.ts +66 -2
  32. package/src/components/form/index.tsx +1 -0
  33. package/src/flow/actions/__tests__/actionLinkageRules.race.repro.test.ts +45 -0
  34. package/src/flow/actions/linkageRules.tsx +18 -6
  35. package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -9
  36. package/src/flow/internal/utils/enumOptionsUtils.ts +11 -3
  37. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +111 -26
  38. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +61 -0
  39. package/src/flow/models/fields/DisplayEnumFieldModel.tsx +2 -1
  40. package/src/flow/models/fields/DisplayJSONFieldModel.tsx +15 -4
  41. package/src/flow/models/fields/InputFieldModel.tsx +56 -1
  42. package/src/flow/models/fields/SelectFieldModel.tsx +3 -3
  43. package/src/flow/models/fields/__tests__/DisplayJSONFieldModel.test.tsx +36 -0
  44. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +116 -0
@@ -0,0 +1,12 @@
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
+ export * from './CodeScanner';
11
+ export * from './ScanInput';
12
+ export * from './types';
@@ -0,0 +1,12 @@
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 type { Html5QrcodeSupportedFormats } from 'html5-qrcode';
11
+
12
+ export type CodeFormatsToSupport = Html5QrcodeSupportedFormats[];
@@ -0,0 +1,136 @@
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 { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
11
+ import { useCallback, useEffect, useState } from 'react';
12
+ import type { CodeFormatsToSupport } from './types';
13
+
14
+ type ScannerSize = {
15
+ width: number;
16
+ height: number;
17
+ };
18
+
19
+ type UseCodeScannerOptions = {
20
+ enabled: boolean;
21
+ elementId: string;
22
+ formatsToSupport?: CodeFormatsToSupport;
23
+ scanBoxSize?: ScannerSize;
24
+ onScannerSizeChanged?: (size: ScannerSize) => void;
25
+ onScanSuccess: (text: string) => void;
26
+ onScanFailure?: () => void;
27
+ };
28
+
29
+ export const DEFAULT_CODE_FORMATS: CodeFormatsToSupport = [
30
+ Html5QrcodeSupportedFormats.QR_CODE,
31
+ Html5QrcodeSupportedFormats.CODE_128,
32
+ Html5QrcodeSupportedFormats.CODE_39,
33
+ Html5QrcodeSupportedFormats.CODE_93,
34
+ Html5QrcodeSupportedFormats.CODABAR,
35
+ Html5QrcodeSupportedFormats.EAN_13,
36
+ Html5QrcodeSupportedFormats.EAN_8,
37
+ Html5QrcodeSupportedFormats.ITF,
38
+ Html5QrcodeSupportedFormats.UPC_A,
39
+ Html5QrcodeSupportedFormats.UPC_E,
40
+ Html5QrcodeSupportedFormats.DATA_MATRIX,
41
+ Html5QrcodeSupportedFormats.PDF_417,
42
+ ];
43
+
44
+ export function getCodeScanBoxSize(width: number, height: number) {
45
+ return {
46
+ width: Math.floor(Math.min(width * 0.82, 520)),
47
+ height: Math.floor(Math.min(height * 0.32, 240)),
48
+ };
49
+ }
50
+
51
+ async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } = {}) {
52
+ if (!scanner) {
53
+ return;
54
+ }
55
+
56
+ const state = scanner.getState();
57
+ if ([Html5QrcodeScannerState.SCANNING, Html5QrcodeScannerState.PAUSED].includes(state)) {
58
+ await scanner.stop();
59
+ }
60
+ if (options.clear) {
61
+ scanner.clear();
62
+ }
63
+ }
64
+
65
+ export function useCodeScanner({
66
+ enabled,
67
+ elementId,
68
+ formatsToSupport,
69
+ scanBoxSize,
70
+ onScannerSizeChanged,
71
+ onScanSuccess,
72
+ onScanFailure,
73
+ }: UseCodeScannerOptions) {
74
+ const [scanner, setScanner] = useState<Html5Qrcode>();
75
+
76
+ const startScanCamera = useCallback(
77
+ async (scannerInstance: Html5Qrcode) => {
78
+ await scannerInstance.start(
79
+ { facingMode: 'environment' },
80
+ {
81
+ fps: 10,
82
+ qrbox(width, height) {
83
+ onScannerSizeChanged?.({ width, height });
84
+ return scanBoxSize ?? getCodeScanBoxSize(width, height);
85
+ },
86
+ },
87
+ (decodedText) => {
88
+ onScanSuccess(decodedText);
89
+ },
90
+ undefined,
91
+ );
92
+ },
93
+ [onScanSuccess, onScannerSizeChanged, scanBoxSize],
94
+ );
95
+
96
+ const startScanFile = useCallback(
97
+ async (file: File) => {
98
+ if (!scanner) {
99
+ return;
100
+ }
101
+
102
+ await stopScanner(scanner);
103
+ try {
104
+ const result = await scanner.scanFileV2(file, false);
105
+ onScanSuccess(result.decodedText);
106
+ } catch (error) {
107
+ onScanFailure?.();
108
+ await startScanCamera(scanner);
109
+ }
110
+ },
111
+ [onScanFailure, onScanSuccess, scanner, startScanCamera],
112
+ );
113
+
114
+ useEffect(() => {
115
+ if (!enabled) {
116
+ return;
117
+ }
118
+
119
+ const scannerInstance = new Html5Qrcode(elementId, {
120
+ formatsToSupport: formatsToSupport?.length ? formatsToSupport : DEFAULT_CODE_FORMATS,
121
+ verbose: false,
122
+ });
123
+ setScanner(scannerInstance);
124
+ startScanCamera(scannerInstance).catch(() => {
125
+ onScanFailure?.();
126
+ });
127
+
128
+ return () => {
129
+ stopScanner(scannerInstance, { clear: true }).catch(() => undefined);
130
+ };
131
+ }, [elementId, enabled, formatsToSupport, onScanFailure, startScanCamera]);
132
+
133
+ return {
134
+ startScanFile,
135
+ };
136
+ }
@@ -19,6 +19,8 @@ const identity = (s: string) => s;
19
19
  export interface CollectionFilterProps {
20
20
  /** Collection whose fields drive the filter row's field picker. */
21
21
  collection: Collection | undefined;
22
+ /** Previously compiled filter param used to seed the editable filter group. */
23
+ initialValue?: CompiledFilter;
22
24
  /** Called on Submit or Reset with the compiled NocoBase filter param (`undefined` when cleared). */
23
25
  onChange: (filter: CompiledFilter) => void;
24
26
  /** Translator. Defaults to identity. */
@@ -55,6 +57,7 @@ export interface CollectionFilterProps {
55
57
  export const CollectionFilter: FC<CollectionFilterProps> = (props) => {
56
58
  const {
57
59
  collection,
60
+ initialValue,
58
61
  onChange,
59
62
  t = identity,
60
63
  filterableFieldNames,
@@ -71,6 +74,7 @@ export const CollectionFilter: FC<CollectionFilterProps> = (props) => {
71
74
 
72
75
  const filterAction = useFilterActionProps({
73
76
  collection,
77
+ initialValue,
74
78
  filterableFieldNames,
75
79
  nonfilterableFieldNames,
76
80
  noIgnore,
@@ -0,0 +1,97 @@
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 type { Collection } from '@nocobase/flow-engine';
11
+ import { Empty } from 'antd';
12
+ import React, { forwardRef, useImperativeHandle } from 'react';
13
+ import { FilterGroup } from '../../../flow/components/filter';
14
+ import {
15
+ compileFilterGroup,
16
+ type CompiledFilter,
17
+ type FilterApplyAction,
18
+ useFilterActionProps,
19
+ } from './useFilterActionProps';
20
+
21
+ const identity = (s: string) => s;
22
+
23
+ export interface CollectionFilterPanelRef {
24
+ getFilter: () => CompiledFilter;
25
+ reset: () => void;
26
+ }
27
+
28
+ export interface CollectionFilterPanelProps {
29
+ /** Collection whose fields drive the filter row's field picker. */
30
+ collection: Collection | undefined;
31
+ /** Previously compiled filter param used to seed the editable filter group. */
32
+ initialValue?: CompiledFilter;
33
+ /** Called when the condition group structure changes or `reset()` is invoked. */
34
+ onChange?: (filter: CompiledFilter) => void;
35
+ /** Translator. Defaults to identity. */
36
+ t?: (key: string, options?: Record<string, unknown>) => string;
37
+ /** Whitelist of root-level field names to expose. */
38
+ filterableFieldNames?: string[];
39
+ /**
40
+ * Blacklist of root-level field names to drop. Mirrors v1's `nonfilterable: [...]` on `Filter.Action`.
41
+ */
42
+ nonfilterableFieldNames?: string[];
43
+ /** Bypass the `filterableFieldNames` whitelist. */
44
+ noIgnore?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Inline collection filter editor for forms and drawers. Unlike `CollectionFilter`, this does not render a trigger button, popover, or nested `<form>`, so it can safely live inside an antd form and be submitted by the outer drawer action.
49
+ */
50
+ export const CollectionFilterPanel = forwardRef<CollectionFilterPanelRef, CollectionFilterPanelProps>((props, ref) => {
51
+ const {
52
+ collection,
53
+ initialValue,
54
+ onChange,
55
+ t = identity,
56
+ filterableFieldNames,
57
+ nonfilterableFieldNames,
58
+ noIgnore,
59
+ } = props;
60
+
61
+ const filterAction = useFilterActionProps({
62
+ collection,
63
+ initialValue,
64
+ filterableFieldNames,
65
+ nonfilterableFieldNames,
66
+ noIgnore,
67
+ t,
68
+ onApply: (filter: CompiledFilter, _action: FilterApplyAction) => {
69
+ onChange?.(filter);
70
+ },
71
+ });
72
+
73
+ useImperativeHandle(
74
+ ref,
75
+ () => ({
76
+ getFilter: () => compileFilterGroup(filterAction.value),
77
+ reset: filterAction.onReset,
78
+ }),
79
+ [filterAction.onReset, filterAction.value],
80
+ );
81
+
82
+ if (!collection || !filterAction.FilterItem) {
83
+ return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('No data')} />;
84
+ }
85
+
86
+ return (
87
+ <FilterGroup
88
+ value={filterAction.value}
89
+ FilterItem={filterAction.FilterItem}
90
+ onChange={() => onChange?.(compileFilterGroup(filterAction.value))}
91
+ />
92
+ );
93
+ });
94
+
95
+ CollectionFilterPanel.displayName = 'CollectionFilterPanel';
96
+
97
+ export default CollectionFilterPanel;
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, it } from 'vitest';
11
- import { compileFilterGroup } from '../useFilterActionProps';
11
+ import { compileFilterGroup, decompileFilterGroup } from '../useFilterActionProps';
12
12
 
13
13
  describe('compileFilterGroup', () => {
14
14
  it('returns undefined for an empty group so callers can drop the filter param', () => {
@@ -144,3 +144,78 @@ describe('compileFilterGroup', () => {
144
144
  });
145
145
  });
146
146
  });
147
+
148
+ describe('decompileFilterGroup', () => {
149
+ it('returns undefined for an empty compiled filter', () => {
150
+ expect(decompileFilterGroup(undefined)).toBeUndefined();
151
+ expect(decompileFilterGroup({})).toBeUndefined();
152
+ });
153
+
154
+ it('restores a compiled condition into an editable filter row', () => {
155
+ expect(decompileFilterGroup({ $and: [{ lockReason: { $includes: 'abuse' } }] })).toEqual({
156
+ logic: '$and',
157
+ items: [{ path: 'lockReason', operator: '$includes', value: 'abuse' }],
158
+ });
159
+ });
160
+
161
+ it('restores a direct condition object into an editable filter row', () => {
162
+ expect(decompileFilterGroup({ title: { $includes: 'test' } })).toEqual({
163
+ logic: '$and',
164
+ items: [{ path: 'title', operator: '$includes', value: 'test' }],
165
+ });
166
+ });
167
+
168
+ it('restores legacy direct field values as equality rows', () => {
169
+ expect(decompileFilterGroup({ createdById: '{{ ctx.state.currentUser.id }}' })).toEqual({
170
+ logic: '$and',
171
+ items: [{ path: 'createdById', operator: '$eq', value: '{{ ctx.state.currentUser.id }}' }],
172
+ });
173
+ });
174
+
175
+ it('restores multiple direct condition fields', () => {
176
+ expect(
177
+ decompileFilterGroup({
178
+ title: { $includes: 'test' },
179
+ status: 'published',
180
+ }),
181
+ ).toEqual({
182
+ logic: '$and',
183
+ items: [
184
+ { path: 'title', operator: '$includes', value: 'test' },
185
+ { path: 'status', operator: '$eq', value: 'published' },
186
+ ],
187
+ });
188
+ });
189
+
190
+ it('restores nested association paths', () => {
191
+ expect(decompileFilterGroup({ $and: [{ user: { createdBy: { password: { $includes: '123' } } } }] })).toEqual({
192
+ logic: '$and',
193
+ items: [{ path: 'user.createdBy.password', operator: '$includes', value: '123' }],
194
+ });
195
+ });
196
+
197
+ it('restores nested filter groups', () => {
198
+ expect(
199
+ decompileFilterGroup({
200
+ $and: [
201
+ { lockReason: { $eq: 'abuse' } },
202
+ {
203
+ $or: [{ lockedTs: { $dateAfter: '2026-01-01' } }, { lockedTs: { $dateBefore: '2026-12-31' } }],
204
+ },
205
+ ],
206
+ }),
207
+ ).toEqual({
208
+ logic: '$and',
209
+ items: [
210
+ { path: 'lockReason', operator: '$eq', value: 'abuse' },
211
+ {
212
+ logic: '$or',
213
+ items: [
214
+ { path: 'lockedTs', operator: '$dateAfter', value: '2026-01-01' },
215
+ { path: 'lockedTs', operator: '$dateBefore', value: '2026-12-31' },
216
+ ],
217
+ },
218
+ ],
219
+ });
220
+ });
221
+ });
@@ -10,4 +10,6 @@
10
10
  // Higher-level filter compositions for non-schema surfaces (settings pages, panels, side drawers). The low-level primitives — `FilterContainer`, `FilterGroup`, `FilterItem`, `fieldsToOptions`, `useFilterOptions` — live under `src/flow/components/filter/`; this layer composes them with a `Collection` binding and exposes the hook/component pair callers actually reach for. The dependency direction is form/filter → flow/components/filter only.
11
11
  export { CollectionFilter } from './CollectionFilter';
12
12
  export type { CollectionFilterProps } from './CollectionFilter';
13
+ export { CollectionFilterPanel } from './CollectionFilterPanel';
14
+ export type { CollectionFilterPanelProps, CollectionFilterPanelRef } from './CollectionFilterPanel';
13
15
  export type { CompiledFilter } from './useFilterActionProps';
@@ -68,6 +68,68 @@ const nestPath = (path: string, leaf: unknown): Record<string, unknown> => {
68
68
  return result as Record<string, unknown>;
69
69
  };
70
70
 
71
+ const decompileConditions = (value: unknown, path: string[] = []): CollectionFilterItemValue[] => {
72
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
73
+ return path.length ? [{ path: path.join('.'), operator: '$eq', value }] : [];
74
+ }
75
+
76
+ const entries = Object.entries(value as Record<string, unknown>);
77
+ const operatorEntries = entries.filter(([key]) => key.startsWith('$'));
78
+ if (operatorEntries.length) {
79
+ if (!path.length) {
80
+ return [];
81
+ }
82
+ return operatorEntries.map(([operator, operatorValue]) => ({
83
+ path: path.join('.'),
84
+ operator,
85
+ value: operatorValue,
86
+ }));
87
+ }
88
+
89
+ return entries.flatMap(([fieldName, nextValue]) => decompileConditions(nextValue, [...path, fieldName]));
90
+ };
91
+
92
+ const getGroupLogic = (record: Record<string, unknown>): FilterGroupValue['logic'] | undefined => {
93
+ if (Array.isArray(record.$or)) {
94
+ return '$or';
95
+ }
96
+ if (Array.isArray(record.$and)) {
97
+ return '$and';
98
+ }
99
+ return undefined;
100
+ };
101
+
102
+ const decompileFilterItem = (item: unknown): FilterGroupItem[] => {
103
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
104
+ return [];
105
+ }
106
+ const record = item as Record<string, unknown>;
107
+ if (getGroupLogic(record)) {
108
+ const group = decompileFilterGroup(record);
109
+ return group ? [group] : [];
110
+ }
111
+ return decompileConditions(record);
112
+ };
113
+
114
+ export function decompileFilterGroup(filter: CompiledFilter): FilterGroupValue | undefined {
115
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
116
+ return undefined;
117
+ }
118
+
119
+ const record = filter as Record<string, unknown>;
120
+ const logic = getGroupLogic(record);
121
+ if (!logic) {
122
+ const items = decompileConditions(record);
123
+ return items.length ? { logic: '$and', items } : undefined;
124
+ }
125
+ const sourceItems = record[logic] as unknown[];
126
+ const items = sourceItems
127
+ .flatMap((item) => decompileFilterItem(item))
128
+ .filter((item): item is FilterGroupItem => Boolean(item));
129
+
130
+ return items.length ? { logic, items } : undefined;
131
+ }
132
+
71
133
  /**
72
134
  * Compile a reactive filter group into the `{ $and: [{ path: { op: val } }] }` envelope accepted by NocoBase's resource `list` filter param. Returns `undefined` when the group is empty so callers can drop the param.
73
135
  *
@@ -100,6 +162,8 @@ export type FilterApplyAction = 'submit' | 'reset';
100
162
  export interface UseFilterActionPropsArgs extends UseFilterOptionsArgs {
101
163
  /** Collection whose fields populate the filter row's field picker. */
102
164
  collection: Collection | undefined;
165
+ /** Previously compiled filter param used to seed the editable filter group. */
166
+ initialValue?: CompiledFilter;
103
167
  /**
104
168
  * Called when the user submits or resets the filter popover. Receives the compiled filter param (`undefined` when cleared) and which footer button triggered the call. Typical implementation: `(filter, action) => { listRequest.run(filter); if (action === 'submit') closePopover(); }`.
105
169
  */
@@ -152,12 +216,12 @@ export interface UseFilterActionPropsResult {
152
216
  * ```
153
217
  */
154
218
  export function useFilterActionProps(args: UseFilterActionPropsArgs): UseFilterActionPropsResult {
155
- const { collection, onApply, filterableFieldNames, nonfilterableFieldNames, noIgnore, t } = args;
219
+ const { collection, initialValue, onApply, filterableFieldNames, nonfilterableFieldNames, noIgnore, t } = args;
156
220
 
157
221
  // Held in a ref so the group object identity is stable for the lifetime of the host component — `<FilterContent>` mutates this object directly (push/splice on `items`, swap `logic`), and a fresh observable on every render would reset that internal state.
158
222
  const valueRef = useRef<FilterGroupValue>();
159
223
  if (!valueRef.current) {
160
- valueRef.current = observable(createEmptyGroup()) as FilterGroupValue;
224
+ valueRef.current = observable(decompileFilterGroup(initialValue) || createEmptyGroup()) as FilterGroupValue;
161
225
  }
162
226
  const value = valueRef.current;
163
227
 
@@ -16,5 +16,6 @@ export * from './FileSizeInput';
16
16
  export * from './JsonTextArea';
17
17
  export * from './PasswordInput';
18
18
  export * from './RemoteSelect';
19
+ export * from './ScanInput';
19
20
  export * from './TypedVariableInput';
20
21
  export * from './VariableInput';
@@ -10,6 +10,9 @@
10
10
  import { describe, expect, it, vi } from 'vitest';
11
11
  import { actionLinkageRules } from '../linkageRules';
12
12
 
13
+ class ActionModel {}
14
+ class PopupSubTableEditActionModel extends ActionModel {}
15
+
13
16
  function createActionModel() {
14
17
  const model: any = {
15
18
  uid: 'edit-action',
@@ -196,4 +199,46 @@ describe('actionLinkageRules props patch isolation', () => {
196
199
 
197
200
  expect(model.hidden).toBe(true);
198
201
  });
202
+
203
+ it('does not sync row action hidden state to the popup subtable field path', async () => {
204
+ const model = createActionModel();
205
+ Object.defineProperty(model, 'constructor', {
206
+ value: PopupSubTableEditActionModel,
207
+ });
208
+ model.isFork = true;
209
+ model.context = {
210
+ blockModel: { uid: 'form-block' },
211
+ fieldPathArray: ['org_o2m'],
212
+ };
213
+ const fieldModel: any = {
214
+ uid: 'org-o2m-field',
215
+ hidden: false,
216
+ context: {
217
+ blockModel: { uid: 'form-block' },
218
+ fieldPathArray: ['org_o2m'],
219
+ },
220
+ };
221
+ const { ctx } = createRuntime(model, { pauseHiddenAction: false });
222
+ ctx.engine = {
223
+ forEachModel: (visitor: (m: any) => void) => {
224
+ visitor(model);
225
+ visitor(fieldModel);
226
+ },
227
+ };
228
+
229
+ await actionLinkageRules.handler(
230
+ ctx,
231
+ createLinkageParams([
232
+ {
233
+ name: 'linkageSetActionProps',
234
+ params: {
235
+ value: 'hidden',
236
+ },
237
+ },
238
+ ]),
239
+ );
240
+
241
+ expect(model.hidden).toBe(true);
242
+ expect(fieldModel.hidden).toBe(false);
243
+ });
199
244
  });
@@ -46,7 +46,7 @@ import { useAssociationTitleFieldSync } from '../components/useAssociationTitleF
46
46
  import _ from 'lodash';
47
47
  import { SubFormFieldModel, SubFormListFieldModel } from '../models';
48
48
  import { coerceForToOneField } from '../internal/utils/associationValueCoercion';
49
- import { enumToOptions } from '../internal/utils/enumOptionsUtils';
49
+ import { enumToOptions, translateOptionLabel } from '../internal/utils/enumOptionsUtils';
50
50
  import {
51
51
  findFormItemModelByFieldPath,
52
52
  getCollectionFromModel,
@@ -106,6 +106,18 @@ const getLinkageScopeDepthFromContext = (ctx: FlowContext): number => {
106
106
  return getLinkageScopeDepthFromModel((ctx as any)?.model);
107
107
  };
108
108
 
109
+ const isActionFlowModel = (model: any): boolean => {
110
+ if (!model || typeof model !== 'object') return false;
111
+
112
+ let proto = model.constructor?.prototype;
113
+ while (proto) {
114
+ if (proto.constructor?.name === 'ActionModel') return true;
115
+ proto = Object.getPrototypeOf(proto);
116
+ }
117
+
118
+ return false;
119
+ };
120
+
109
121
  // 获取表单中所有字段的 model 实例的通用函数
110
122
  const getFormFields = (ctx: any) => {
111
123
  try {
@@ -156,7 +168,7 @@ const getFieldModelOptions = (fieldModel: any, t: (s: string) => string) => {
156
168
  if (originalOptions) {
157
169
  return originalOptions.map((option: any) =>
158
170
  option && typeof option === 'object' && typeof option.label === 'string'
159
- ? { ...option, label: t(option.label) }
171
+ ? { ...option, label: translateOptionLabel(option.label, t) }
160
172
  : option,
161
173
  );
162
174
  }
@@ -2142,12 +2154,12 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
2142
2154
  model.setProps(_.omit(newProps, ['hiddenModel', 'value', 'hiddenText']));
2143
2155
  syncFieldOptionsToForks(model, patchProps);
2144
2156
  if (typeof model.setHidden === 'function') {
2145
- model.setHidden(!!newProps.hiddenModel);
2157
+ model.setHidden(nextHidden);
2146
2158
  } else {
2147
2159
  model.hidden = nextHidden;
2148
- if (prevHidden !== nextHidden) {
2149
- hiddenStatePatches.push({ model, hidden: nextHidden });
2150
- }
2160
+ }
2161
+ if (prevHidden !== nextHidden && !isActionFlowModel(model)) {
2162
+ hiddenStatePatches.push({ model, hidden: nextHidden });
2151
2163
  }
2152
2164
 
2153
2165
  if (newProps.required === true) {
@@ -24,27 +24,28 @@ function mockT(input: string) {
24
24
  }
25
25
 
26
26
  describe('enumOptions utils', () => {
27
- it('enumToOptions: converts primitive enum and translates labels', () => {
27
+ it('enumToOptions: converts primitive enum and translates explicit template labels', () => {
28
28
  const uiEnum = ['{{t("Yes")}}', '{{t("No")}}'];
29
- const options = enumToOptions(uiEnum as any, mockT)!;
30
- expect(options).toHaveLength(2);
31
- expect(options[0]).toEqual({ label: '是', value: '{{t("Yes")}}' });
32
- expect(options[1]).toEqual({ label: '否', value: '{{t("No")}}' });
29
+ const options = enumToOptions(uiEnum as any, mockT);
30
+ expect(options).toEqual([
31
+ { label: '是', value: '{{t("Yes")}}' },
32
+ { label: '否', value: '{{t("No")}}' },
33
+ ]);
33
34
  });
34
35
 
35
- it('enumToOptions: keeps object value and translates object label', () => {
36
+ it('enumToOptions: keeps object value and translates explicit template object label', () => {
36
37
  const uiEnum = [
37
38
  { label: '{{t("Yes")}}', value: true },
38
39
  { label: '{{t("No")}}', value: false },
39
40
  ];
40
- const options = enumToOptions(uiEnum as any, mockT)!;
41
+ const options = enumToOptions(uiEnum as any, mockT);
41
42
  expect(options).toEqual([
42
43
  { label: '是', value: true },
43
44
  { label: '否', value: false },
44
45
  ]);
45
46
  });
46
47
 
47
- it('translateOptions: maps given options labels through t()', () => {
48
+ it('translateOptions: maps only explicit template labels through t()', () => {
48
49
  const options = [
49
50
  { label: '{{t("Draft")}}', value: 'draft' },
50
51
  { label: 'No', value: false },
@@ -52,7 +53,7 @@ describe('enumOptions utils', () => {
52
53
  const out = translateOptions(options as any, mockT);
53
54
  expect(out).toEqual([
54
55
  { label: '草稿', value: 'draft' },
55
- { label: '', value: false },
56
+ { label: 'No', value: false },
56
57
  ]);
57
58
  });
58
59
 
@@ -33,13 +33,21 @@ function normalizeUiSchemaEnumToOptions(uiEnum: UiSchemaEnumItem[] = []): Option
33
33
  });
34
34
  }
35
35
 
36
- // Map option labels through t() (supports {{t('key')}} template and plain strings)
36
+ export function isTranslationTemplate(value: unknown): value is string {
37
+ return typeof value === 'string' && /\{\{\s*t\s*\(/.test(value);
38
+ }
39
+
40
+ export function translateOptionLabel(label: any, t: (s: string) => string): any {
41
+ return isTranslationTemplate(label) ? t(label) : label;
42
+ }
43
+
44
+ // Map option labels through t() only when labels explicitly use {{t(...)}} templates.
37
45
  export function translateOptions(options: Option[] = [], t: (s: string) => string): Option[] {
38
46
  if (!Array.isArray(options)) return [] as Option[];
39
- return options.map((opt) => (typeof opt?.label === 'string' ? { ...opt, label: t(opt.label) } : opt));
47
+ return options.map((opt) => ({ ...opt, label: translateOptionLabel(opt?.label, t) }));
40
48
  }
41
49
 
42
- // Build options from uiSchema.enum with translated labels
50
+ // Build options from uiSchema.enum with explicitly translated labels
43
51
  export function enumToOptions(uiEnum: UiSchemaEnumItem[] | undefined, t: (s: string) => string): Option[] | undefined {
44
52
  if (!uiEnum || !Array.isArray(uiEnum) || uiEnum.length === 0) return undefined;
45
53
  const normalized = normalizeUiSchemaEnumToOptions(uiEnum);