@nocobase/client-v2 2.1.8 → 2.1.10

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 (38) hide show
  1. package/es/components/form/JsonTextArea.d.ts +2 -1
  2. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  3. package/es/components/form/index.d.ts +1 -0
  4. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  5. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  6. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  7. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  8. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
  9. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  10. package/es/flow-compat/FieldValidation.d.ts +2 -1
  11. package/es/index.mjs +75 -49
  12. package/lib/index.js +81 -55
  13. package/package.json +7 -7
  14. package/src/components/form/JsonTextArea.tsx +21 -11
  15. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  16. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  17. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  18. package/src/components/form/index.tsx +1 -0
  19. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  20. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  21. package/src/flow/actions/linkageRules.tsx +75 -16
  22. package/src/flow/actions/validation.tsx +62 -30
  23. package/src/flow/components/FieldAssignRulesEditor.tsx +62 -12
  24. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  25. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  26. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -0
  27. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +30 -1
  28. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  29. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  30. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  31. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  32. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
  33. package/src/flow/models/blocks/form/value-runtime/rules.ts +443 -11
  34. package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
  35. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  36. package/src/flow/models/fields/InputFieldModel.tsx +14 -22
  37. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
  38. package/src/flow-compat/FieldValidation.tsx +122 -60
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { isObservable, reaction, toJS } from '@formily/reactive';
11
11
  import { FlowContext, FlowModel, isRunJSValue, normalizeRunJSValue, runjsWithSafeGlobals } from '@nocobase/flow-engine';
12
+ import { getValuesByPath } from '@nocobase/shared';
12
13
  import _ from 'lodash';
13
14
  import { dayjs } from '@nocobase/utils/client';
14
15
  import { evaluateCondition } from './conditions';
@@ -56,7 +57,31 @@ type ObservableBinding = {
56
57
  dispose: () => void;
57
58
  };
58
59
 
59
- type AssignMode = 'default' | 'assign';
60
+ type AssignMode = 'default' | 'assign' | 'override';
61
+
62
+ function normalizeAssignMode(mode: unknown): AssignMode {
63
+ if (mode === 'default') return 'default';
64
+ if (mode === 'override') return 'override';
65
+ return 'assign';
66
+ }
67
+
68
+ function getSourceByAssignMode(mode: AssignMode): ValueSource {
69
+ if (mode === 'default') return 'default';
70
+ if (mode === 'override') return 'override';
71
+ return 'system';
72
+ }
73
+
74
+ type LocalTemplateTokenStore = {
75
+ values: Map<string, unknown>;
76
+ protect: (value: unknown) => string;
77
+ };
78
+
79
+ type ToManyAggregateSourceInfo = {
80
+ subPath: string;
81
+ arrayPath: NamePath;
82
+ arrayValue: unknown;
83
+ lastWrite?: FormValueWriteMeta;
84
+ };
60
85
 
61
86
  export type RuleEngineOptions = {
62
87
  getBlockModelUid: () => string;
@@ -74,6 +99,7 @@ export type RuleEngineOptions = {
74
99
  getFormValueAtPath: (namePath: NamePath) => any;
75
100
  setFormValues: (callerCtx: any, patch: Patch, options?: SetOptions) => Promise<void>;
76
101
  findExplicitHit: (pathKey: string) => string | null;
102
+ findUserEditedHit: (pathKey: string) => string | null;
77
103
  lastDefaultValueByPathKey: Map<string, any>;
78
104
  lastWriteMetaByPathKey: Map<string, FormValueWriteMeta>;
79
105
  observableBindings: Map<string, ObservableBinding>;
@@ -166,8 +192,8 @@ export class RuleEngine {
166
192
  if (!templateKey) continue;
167
193
  if (this.hasAnyNonBlockAssignRuleInstance(templateKey)) continue;
168
194
 
169
- const mode: AssignMode = template?.mode === 'default' ? 'default' : 'assign';
170
- const source: ValueSource = mode === 'default' ? 'default' : 'system';
195
+ const mode = normalizeAssignMode(template?.mode);
196
+ const source = getSourceByAssignMode(mode);
171
197
  const enabled = template?.enable !== false;
172
198
  const id = this.getAssignRuleBlockId(templateKey);
173
199
  if (this.rules.has(id)) continue;
@@ -503,8 +529,8 @@ export class RuleEngine {
503
529
  if (matchedTargetPaths.has(targetPath)) continue;
504
530
  if (!this.shouldCreateBlockLevelAssignRule(targetPath)) continue;
505
531
  for (const template of templates) {
506
- const mode: AssignMode = template?.mode === 'default' ? 'default' : 'assign';
507
- const source: ValueSource = mode === 'default' ? 'default' : 'system';
532
+ const mode = normalizeAssignMode(template?.mode);
533
+ const source = getSourceByAssignMode(mode);
508
534
  const enabled = template?.enable !== false;
509
535
  const id = `${prefix}${template.__key}:block`;
510
536
  if (this.rules.has(id)) this.removeRule(id);
@@ -867,8 +893,8 @@ export class RuleEngine {
867
893
  this.removeRowGridAssignRuleInstances(template.__key);
868
894
  }
869
895
 
870
- const mode: AssignMode = template?.mode === 'default' ? 'default' : 'assign';
871
- const source: ValueSource = mode === 'default' ? 'default' : 'system';
896
+ const mode = normalizeAssignMode(template?.mode);
897
+ const source = getSourceByAssignMode(mode);
872
898
  const enabled = template?.enable !== false;
873
899
  const id = this.getAssignRuleInstanceId(template.__key, model);
874
900
 
@@ -1200,13 +1226,52 @@ export class RuleEngine {
1200
1226
 
1201
1227
  this.commitRuleDeps(rule, state, collector);
1202
1228
 
1203
- const normalizedResolved = this.normalizeResolvedValueForTarget(baseCtx, resolved);
1204
- const normalizedResolvedForTarget = this.normalizeResolvedValueForAssociationTarget(
1229
+ const toManyAggregateSource = this.getToManyAggregateSourceInfo(baseCtx, rule.getValue());
1230
+ const unresolvedAggregate = this.normalizeUnresolvedToManyAggregateForScalarTarget(
1231
+ baseCtx,
1232
+ ensuredTargetNamePath,
1233
+ resolved,
1234
+ toManyAggregateSource,
1235
+ );
1236
+ if (unresolvedAggregate.skip) {
1237
+ return;
1238
+ }
1239
+ const resolvedForTarget = unresolvedAggregate.hasValue ? unresolvedAggregate.value : resolved;
1240
+
1241
+ const normalizedResolved = this.normalizeResolvedValueForTarget(baseCtx, resolvedForTarget);
1242
+ const normalizedResolvedForAssociationTarget = this.normalizeResolvedValueForAssociationTarget(
1205
1243
  baseCtx,
1206
1244
  ensuredTargetNamePath,
1207
1245
  normalizedResolved,
1208
1246
  );
1209
1247
 
1248
+ const normalizedResolvedForTarget = this.normalizeAggregateArrayForScalarTarget(
1249
+ baseCtx,
1250
+ ensuredTargetNamePath,
1251
+ normalizedResolvedForAssociationTarget,
1252
+ toManyAggregateSource,
1253
+ );
1254
+ if (normalizedResolvedForTarget === SKIP_RULE_VALUE) {
1255
+ return;
1256
+ }
1257
+
1258
+ if (
1259
+ this.shouldSkipEmptyArrayAggregateForScalarTarget(baseCtx, ensuredTargetNamePath, normalizedResolvedForTarget)
1260
+ ) {
1261
+ return;
1262
+ }
1263
+
1264
+ if (
1265
+ this.shouldSkipMultiValueAggregateForScalarTarget(
1266
+ baseCtx,
1267
+ ensuredTargetNamePath,
1268
+ normalizedResolvedForTarget,
1269
+ toManyAggregateSource,
1270
+ )
1271
+ ) {
1272
+ return;
1273
+ }
1274
+
1210
1275
  if (rule.source === 'default') {
1211
1276
  const shouldApply = this.checkDefaultRuleCanApply(
1212
1277
  normalizedResolvedForTarget,
@@ -1218,6 +1283,10 @@ export class RuleEngine {
1218
1283
  );
1219
1284
  if (!shouldApply) return;
1220
1285
  }
1286
+ if (rule.source === 'override' && this.options.findUserEditedHit(ensuredTargetKey)) {
1287
+ disposeBinding();
1288
+ return;
1289
+ }
1221
1290
 
1222
1291
  if (rule.source === 'default') {
1223
1292
  const lastWrite = this.lastRuleWriteByTargetKey.get(ensuredTargetKey);
@@ -1231,7 +1300,7 @@ export class RuleEngine {
1231
1300
  }
1232
1301
  }
1233
1302
 
1234
- if (rule.source === 'system') {
1303
+ if (rule.source === 'system' || rule.source === 'override') {
1235
1304
  const lastWrite = this.options.lastWriteMetaByPathKey.get(ensuredTargetKey);
1236
1305
  if (lastWrite?.source === 'linkage' && lastWrite.writeSeq >= scheduledAtWriteSeq) {
1237
1306
  disposeBinding();
@@ -1255,7 +1324,7 @@ export class RuleEngine {
1255
1324
  const initPatches = this.collectUpdateAssociationInitPatches(baseCtx, ensuredTargetNamePath);
1256
1325
  if (initPatches == null) return;
1257
1326
 
1258
- if (rule.source === 'system' || rule.source === 'default') {
1327
+ if (rule.source === 'system' || rule.source === 'default' || rule.source === 'override') {
1259
1328
  const modelForUi = baseCtx?.model;
1260
1329
  const fieldModelForUi = modelForUi?.subModels?.field;
1261
1330
  if (fieldModelForUi) {
@@ -1562,6 +1631,9 @@ export class RuleEngine {
1562
1631
  if (!this.shouldApplyDefaultRuleInCurrentState(baseCtx)) return false;
1563
1632
  if (this.options.findExplicitHit(targetKey)) return false;
1564
1633
  }
1634
+ if (rule.source === 'override') {
1635
+ if (this.options.findUserEditedHit(targetKey)) return false;
1636
+ }
1565
1637
  return true;
1566
1638
  }
1567
1639
 
@@ -1685,6 +1757,213 @@ export class RuleEngine {
1685
1757
  this.updateRuleDeps(rule, state, nextDeps);
1686
1758
  }
1687
1759
 
1760
+ private isScalarValueTarget(baseCtx: any, targetNamePath: NamePath): boolean {
1761
+ const targetField = this.resolveCollectionFieldByNamePath(baseCtx, targetNamePath);
1762
+ if (targetField?.isAssociationField?.() && isToManyAssociationField(targetField)) {
1763
+ return false;
1764
+ }
1765
+ if (this.isArrayValueCollectionField(targetField)) {
1766
+ return false;
1767
+ }
1768
+ if (targetField) {
1769
+ return true;
1770
+ }
1771
+
1772
+ const current = this.options.getFormValueAtPath(targetNamePath);
1773
+ return !Array.isArray(current);
1774
+ }
1775
+
1776
+ private shouldSkipEmptyArrayAggregateForScalarTarget(baseCtx: any, targetNamePath: NamePath, resolved: any): boolean {
1777
+ if (!Array.isArray(resolved) || resolved.length > 0) return false;
1778
+ return this.isScalarValueTarget(baseCtx, targetNamePath);
1779
+ }
1780
+
1781
+ private normalizeUnresolvedToManyAggregateForScalarTarget(
1782
+ baseCtx: any,
1783
+ targetNamePath: NamePath,
1784
+ resolved: any,
1785
+ aggregateInfo: ToManyAggregateSourceInfo | null,
1786
+ ): { skip: boolean; hasValue: boolean; value?: unknown } {
1787
+ if (typeof resolved !== 'undefined') return { skip: false, hasValue: false };
1788
+ if (!aggregateInfo) return { skip: false, hasValue: false };
1789
+ if (!this.isScalarValueTarget(baseCtx, targetNamePath)) return { skip: false, hasValue: false };
1790
+
1791
+ if (Array.isArray(aggregateInfo.arrayValue) && aggregateInfo.arrayValue.length === 0) {
1792
+ if (this.isUserClearedToManyAggregate(aggregateInfo)) {
1793
+ return {
1794
+ skip: false,
1795
+ hasValue: true,
1796
+ value: this.getClearedAggregateValueForScalarTarget(baseCtx, targetNamePath),
1797
+ };
1798
+ }
1799
+ return { skip: true, hasValue: false };
1800
+ }
1801
+
1802
+ if (Array.isArray(aggregateInfo.arrayValue) && aggregateInfo.arrayValue.length > 0) {
1803
+ return { skip: true, hasValue: false };
1804
+ }
1805
+
1806
+ return { skip: false, hasValue: false };
1807
+ }
1808
+
1809
+ private isArrayValueCollectionField(field: unknown): boolean {
1810
+ if (!field || typeof field !== 'object') return false;
1811
+ const collectionField = field as {
1812
+ interface?: unknown;
1813
+ multiple?: unknown;
1814
+ schema?: { type?: unknown };
1815
+ type?: unknown;
1816
+ uiSchema?: { type?: unknown };
1817
+ };
1818
+ if (collectionField.multiple === true) return true;
1819
+ if (collectionField.type === 'array') return true;
1820
+
1821
+ const uiSchemaType = collectionField.uiSchema?.type ?? collectionField.schema?.type;
1822
+ if (uiSchemaType === 'array') return true;
1823
+
1824
+ const fieldInterface = typeof collectionField.interface === 'string' ? collectionField.interface : '';
1825
+ return ['multipleSelect', 'checkboxGroup', 'json'].includes(fieldInterface);
1826
+ }
1827
+
1828
+ private stringifyAggregateItem(value: unknown): string {
1829
+ if (value && typeof value === 'object') {
1830
+ try {
1831
+ return JSON.stringify(value);
1832
+ } catch {
1833
+ return String(value);
1834
+ }
1835
+ }
1836
+ return String(value);
1837
+ }
1838
+
1839
+ private normalizeAggregateArrayForScalarTarget(
1840
+ baseCtx: any,
1841
+ targetNamePath: NamePath,
1842
+ resolved: any,
1843
+ aggregateInfo: ToManyAggregateSourceInfo | null,
1844
+ ): any {
1845
+ if (!Array.isArray(resolved)) return resolved;
1846
+ if (!this.isScalarValueTarget(baseCtx, targetNamePath)) return resolved;
1847
+ if (!aggregateInfo) return resolved;
1848
+
1849
+ const values = resolved.filter((item) => item !== null && typeof item !== 'undefined');
1850
+ if (!values.length) {
1851
+ if (this.isUserClearedToManyAggregate(aggregateInfo)) {
1852
+ return this.getClearedAggregateValueForScalarTarget(baseCtx, targetNamePath);
1853
+ }
1854
+ return SKIP_RULE_VALUE;
1855
+ }
1856
+ if (values.length === 1) return values[0];
1857
+ if (!this.isTextScalarTarget(baseCtx, targetNamePath)) return SKIP_RULE_VALUE;
1858
+ return values.map((item) => this.stringifyAggregateItem(item)).join(', ');
1859
+ }
1860
+
1861
+ private shouldSkipMultiValueAggregateForScalarTarget(
1862
+ baseCtx: any,
1863
+ targetNamePath: NamePath,
1864
+ resolved: any,
1865
+ aggregateInfo: ToManyAggregateSourceInfo | null,
1866
+ ): boolean {
1867
+ if (!aggregateInfo) return false;
1868
+ if (!Array.isArray(resolved) || resolved.length <= 1) return false;
1869
+ if (!this.isScalarValueTarget(baseCtx, targetNamePath)) return false;
1870
+ return !this.isTextScalarTarget(baseCtx, targetNamePath);
1871
+ }
1872
+
1873
+ private getClearedAggregateValueForScalarTarget(baseCtx: any, targetNamePath: NamePath): unknown {
1874
+ if (this.isTextScalarTarget(baseCtx, targetNamePath)) return '';
1875
+ return undefined;
1876
+ }
1877
+
1878
+ private isUserClearedToManyAggregate(aggregateInfo: ToManyAggregateSourceInfo): boolean {
1879
+ return aggregateInfo.lastWrite?.source === 'user';
1880
+ }
1881
+
1882
+ private isTextScalarTarget(baseCtx: any, targetNamePath: NamePath): boolean {
1883
+ const targetField = this.resolveCollectionFieldByNamePath(baseCtx, targetNamePath);
1884
+ if (!targetField) {
1885
+ const current = this.options.getFormValueAtPath(targetNamePath);
1886
+ return typeof current === 'undefined' || current == null || typeof current === 'string';
1887
+ }
1888
+ if (targetField?.isAssociationField?.()) return false;
1889
+ if (this.isArrayValueCollectionField(targetField)) return false;
1890
+
1891
+ const field = targetField as {
1892
+ interface?: unknown;
1893
+ schema?: { type?: unknown };
1894
+ type?: unknown;
1895
+ uiSchema?: { type?: unknown };
1896
+ };
1897
+ const schemaType = field.uiSchema?.type ?? field.schema?.type;
1898
+ if (field.type === 'string' || field.type === 'text' || schemaType === 'string') return true;
1899
+
1900
+ const fieldInterface = typeof field.interface === 'string' ? field.interface : '';
1901
+ return [
1902
+ 'input',
1903
+ 'textarea',
1904
+ 'email',
1905
+ 'phone',
1906
+ 'url',
1907
+ 'uuid',
1908
+ 'nanoid',
1909
+ 'markdown',
1910
+ 'richText',
1911
+ 'vditor',
1912
+ 'password',
1913
+ 'color',
1914
+ ].includes(fieldInterface);
1915
+ }
1916
+
1917
+ private getLatestWriteMetaForPath(namePath: NamePath): FormValueWriteMeta | undefined {
1918
+ const pathKey = namePathToPathKey(namePath);
1919
+ let latest = this.options.lastWriteMetaByPathKey.get(pathKey);
1920
+ for (const [key, meta] of this.options.lastWriteMetaByPathKey.entries()) {
1921
+ if (key !== pathKey && !key.startsWith(`${pathKey}.`) && !key.startsWith(`${pathKey}[`)) continue;
1922
+ if (!latest || meta.writeSeq > latest.writeSeq) {
1923
+ latest = meta;
1924
+ }
1925
+ }
1926
+ return latest;
1927
+ }
1928
+
1929
+ private getToManyAggregateSourceInfo(baseCtx: unknown, rawValue: unknown): ToManyAggregateSourceInfo | null {
1930
+ const subPath = this.extractSingleLocalFormValuesPath(rawValue);
1931
+ if (!subPath) return null;
1932
+
1933
+ const segs = parsePathString(subPath).filter((seg) => typeof seg !== 'object') as NamePath;
1934
+ if (segs.length < 2) return null;
1935
+
1936
+ let collection = this.getRootCollection() || this.getCollectionFromContext(baseCtx);
1937
+ if (!collection?.getField) return null;
1938
+
1939
+ const prefix: NamePath = [];
1940
+ for (let i = 0; i < segs.length - 1; i++) {
1941
+ const seg = segs[i];
1942
+ if (typeof seg !== 'string') return null;
1943
+
1944
+ const field = collection?.getField?.(seg);
1945
+ if (!field?.isAssociationField?.()) return null;
1946
+
1947
+ prefix.push(seg);
1948
+ if (isToManyAssociationField(field)) {
1949
+ const directValue = this.options.getFormValueAtPath(prefix);
1950
+ const snapshot = this.getLocalFormValuesSnapshot(baseCtx);
1951
+ const snapshotValue = snapshot && typeof snapshot === 'object' ? _.get(snapshot, prefix) : undefined;
1952
+ return {
1953
+ subPath,
1954
+ arrayPath: [...prefix],
1955
+ arrayValue: typeof directValue !== 'undefined' ? directValue : snapshotValue,
1956
+ lastWrite: this.getLatestWriteMetaForPath(prefix),
1957
+ };
1958
+ }
1959
+
1960
+ collection = field.targetCollection;
1961
+ if (!collection?.getField) return null;
1962
+ }
1963
+
1964
+ return null;
1965
+ }
1966
+
1688
1967
  /**
1689
1968
  * Check if default rule value can be applied.
1690
1969
  * Default value can overwrite when:
@@ -1741,6 +2020,16 @@ export class RuleEngine {
1741
2020
  ctx.defineProperty('formValues', { get: () => trackingFormValues, cache: false });
1742
2021
  }
1743
2022
 
2023
+ const delegatedResolveJsonTemplate =
2024
+ typeof ctx.resolveJsonTemplate === 'function' ? ctx.resolveJsonTemplate.bind(ctx) : undefined;
2025
+ ctx.defineMethod('resolveJsonTemplate', async (template: unknown) => {
2026
+ const tokenStore = this.createLocalTemplateTokenStore();
2027
+ const localResolved = this.resolveLocalFormValuesTemplates(baseCtx, template, collector, tokenStore);
2028
+ const nextTemplate = localResolved.matched ? localResolved.value : template;
2029
+ const resolved = delegatedResolveJsonTemplate ? await delegatedResolveJsonTemplate(nextTemplate) : nextTemplate;
2030
+ return this.restoreLocalTemplateTokens(resolved, tokenStore);
2031
+ });
2032
+
1744
2033
  // “当前项”链:用于多层级关系字段条件
1745
2034
  // 语义:ctx.item -> { index?, length?, __is_new__?, __is_stored__?, value, parentItem? },其中:
1746
2035
  // - index:仅当当前对象位于对多关联行内时存在(0-based)
@@ -1765,6 +2054,149 @@ export class RuleEngine {
1765
2054
  return ctx;
1766
2055
  }
1767
2056
 
2057
+ private getLocalFormValuesSnapshot(baseCtx: unknown): unknown {
2058
+ try {
2059
+ const snapshot = (baseCtx as { getFormValues?: () => unknown } | undefined)?.getFormValues?.();
2060
+ if (snapshot && typeof snapshot === 'object') {
2061
+ return snapshot;
2062
+ }
2063
+ } catch {
2064
+ // ignore
2065
+ }
2066
+
2067
+ const mirror = this.options.valuesMirror;
2068
+ return isObservable(mirror) ? toJS(mirror) : mirror;
2069
+ }
2070
+
2071
+ private resolveLocalFormValuesPath(baseCtx: unknown, subPath: string): unknown {
2072
+ const snapshot = this.getLocalFormValuesSnapshot(baseCtx);
2073
+ if (!snapshot || typeof snapshot !== 'object') return undefined;
2074
+ return getValuesByPath(snapshot as object, subPath);
2075
+ }
2076
+
2077
+ private recordLocalFormValuesDep(subPath: string, collector?: DepCollector) {
2078
+ const segs = parsePathString(subPath).filter((seg) => typeof seg !== 'object') as NamePath;
2079
+ if (!segs.length) {
2080
+ if (collector) collector.wildcard = true;
2081
+ return;
2082
+ }
2083
+ recordDep(segs, collector);
2084
+ }
2085
+
2086
+ private extractSingleLocalFormValuesPath(template: unknown): string | null {
2087
+ if (typeof template !== 'string') return null;
2088
+ const single = template.match(
2089
+ /^\s*\{\{\s*ctx\.formValues\.([a-zA-Z_$][a-zA-Z0-9_$-]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$-]*)*)\s*\}\}\s*$/,
2090
+ );
2091
+ return single?.[1] ?? null;
2092
+ }
2093
+
2094
+ private stringifyTemplateReplacement(value: unknown): string | undefined {
2095
+ if (typeof value === 'undefined') return undefined;
2096
+ return value && typeof value === 'object' ? JSON.stringify(value) : String(value);
2097
+ }
2098
+
2099
+ private createLocalTemplateTokenStore(): LocalTemplateTokenStore {
2100
+ const values = new Map<string, unknown>();
2101
+ let index = 0;
2102
+ return {
2103
+ values,
2104
+ protect(value: unknown) {
2105
+ const token = `__NOCObase_FORM_VALUE_LITERAL_${index}__`;
2106
+ index += 1;
2107
+ values.set(token, value);
2108
+ return token;
2109
+ },
2110
+ };
2111
+ }
2112
+
2113
+ private restoreLocalTemplateTokens(value: unknown, tokenStore: LocalTemplateTokenStore): unknown {
2114
+ if (!tokenStore.values.size) return value;
2115
+
2116
+ if (typeof value === 'string') {
2117
+ if (tokenStore.values.has(value)) {
2118
+ return tokenStore.values.get(value);
2119
+ }
2120
+
2121
+ let next = value;
2122
+ for (const [token, replacement] of tokenStore.values.entries()) {
2123
+ if (!next.includes(token)) continue;
2124
+ next = next.split(token).join(this.stringifyTemplateReplacement(replacement) ?? '');
2125
+ }
2126
+ return next;
2127
+ }
2128
+
2129
+ if (Array.isArray(value)) {
2130
+ return value.map((item) => this.restoreLocalTemplateTokens(item, tokenStore));
2131
+ }
2132
+
2133
+ if (_.isPlainObject(value)) {
2134
+ const restored: Record<string, unknown> = {};
2135
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
2136
+ restored[key] = this.restoreLocalTemplateTokens(item, tokenStore);
2137
+ }
2138
+ return restored;
2139
+ }
2140
+
2141
+ return value;
2142
+ }
2143
+
2144
+ private resolveLocalFormValuesTemplates(
2145
+ baseCtx: unknown,
2146
+ template: unknown,
2147
+ collector?: DepCollector,
2148
+ tokenStore?: LocalTemplateTokenStore,
2149
+ ): { matched: boolean; value: unknown } {
2150
+ if (typeof template === 'string') {
2151
+ const single = this.extractSingleLocalFormValuesPath(template);
2152
+ if (single) {
2153
+ this.recordLocalFormValuesDep(single, collector);
2154
+ const resolved = this.resolveLocalFormValuesPath(baseCtx, single);
2155
+ if (typeof resolved !== 'undefined') {
2156
+ return { matched: true, value: tokenStore ? tokenStore.protect(resolved) : resolved };
2157
+ }
2158
+ return { matched: false, value: template };
2159
+ }
2160
+
2161
+ let matched = false;
2162
+ const value = template.replace(
2163
+ /\{\{\s*ctx\.formValues\.([a-zA-Z_$][a-zA-Z0-9_$-]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$-]*)*)\s*\}\}/g,
2164
+ (fullMatch, subPath) => {
2165
+ this.recordLocalFormValuesDep(subPath, collector);
2166
+ const resolved = this.resolveLocalFormValuesPath(baseCtx, subPath);
2167
+ const replacement = this.stringifyTemplateReplacement(resolved);
2168
+ if (typeof replacement === 'undefined') return fullMatch;
2169
+ matched = true;
2170
+ return tokenStore ? tokenStore.protect(replacement) : replacement;
2171
+ },
2172
+ );
2173
+ return { matched, value };
2174
+ }
2175
+
2176
+ if (Array.isArray(template)) {
2177
+ let matched = false;
2178
+ const value = template.map((item) => {
2179
+ const resolved = this.resolveLocalFormValuesTemplates(baseCtx, item, collector, tokenStore);
2180
+ if (resolved.matched) matched = true;
2181
+ return resolved.value;
2182
+ });
2183
+ return matched ? { matched, value } : { matched: false, value: template };
2184
+ }
2185
+
2186
+ if (_.isPlainObject(template)) {
2187
+ let matched = false;
2188
+ const value: Record<string, unknown> = {};
2189
+ for (const [key, item] of Object.entries(template as Record<string, unknown>)) {
2190
+ const resolved = this.resolveLocalFormValuesTemplates(baseCtx, item, collector, tokenStore);
2191
+ if (resolved.matched) matched = true;
2192
+ value[key] = resolved.value;
2193
+ }
2194
+ return matched ? { matched, value } : { matched: false, value: template };
2195
+ }
2196
+
2197
+ return { matched: false, value: template };
2198
+ }
2199
+
1768
2200
  private buildItemChainValue(baseCtx: any, trackingFormValues: any, targetNamePath: NamePath | null) {
1769
2201
  const rootCollection = this.getRootCollection() || this.getCollectionFromContext(baseCtx);
1770
2202
  const buildNode = (value: any, index: number | undefined, length: number | undefined, parentItem: any) => {