@qoretechnologies/reqraft 0.10.46-pr.95.g68dab3c → 0.10.46

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 (147) hide show
  1. package/.claude/CLAUDE.md +12 -0
  2. package/.tasks/INDEX.md +1 -0
  3. package/.tasks/TEMPLATE_TOKEN_GRAMMAR.md +72 -0
  4. package/dist/components/composer/Composer.d.ts.map +1 -1
  5. package/dist/components/composer/Composer.js +5 -1
  6. package/dist/components/composer/Composer.js.map +1 -1
  7. package/dist/components/form/engine/CompactRow.d.ts +14 -0
  8. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  9. package/dist/components/form/engine/CompactRow.js +199 -41
  10. package/dist/components/form/engine/CompactRow.js.map +1 -1
  11. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  12. package/dist/components/form/engine/CompactToolbar.js +72 -72
  13. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  14. package/dist/components/form/engine/FormEngine.d.ts +65 -1
  15. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  16. package/dist/components/form/engine/FormEngine.js +276 -108
  17. package/dist/components/form/engine/FormEngine.js.map +1 -1
  18. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  19. package/dist/components/form/engine/_structuredData/SchemaDataView.js +55 -13
  20. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  21. package/dist/components/form/engine/_structuredData/structuredData.d.ts +15 -0
  22. package/dist/components/form/engine/_structuredData/structuredData.d.ts.map +1 -1
  23. package/dist/components/form/engine/_structuredData/structuredData.js +22 -1
  24. package/dist/components/form/engine/_structuredData/structuredData.js.map +1 -1
  25. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  26. package/dist/components/form/engine/compactRowStyles.js +15 -3
  27. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  28. package/dist/components/form/engine/compactToolbarContext.d.ts +8 -0
  29. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  30. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  31. package/dist/components/form/engine/readFirst.d.ts +24 -0
  32. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  33. package/dist/components/form/engine/readFirst.js +84 -6
  34. package/dist/components/form/engine/readFirst.js.map +1 -1
  35. package/dist/components/form/fields/Field.d.ts.map +1 -1
  36. package/dist/components/form/fields/Field.js +1 -1
  37. package/dist/components/form/fields/Field.js.map +1 -1
  38. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +12 -2
  39. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  40. package/dist/components/form/fields/allowed-values/AllowedValues.js +16 -6
  41. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  42. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  43. package/dist/components/form/fields/auto/AutoFormField.js +64 -14
  44. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  45. package/dist/components/form/fields/long-string/LongString.d.ts +9 -1
  46. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  47. package/dist/components/form/fields/long-string/LongString.js +25 -4
  48. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  49. package/dist/components/form/fields/template/ExampleValueModal.d.ts +29 -0
  50. package/dist/components/form/fields/template/ExampleValueModal.d.ts.map +1 -0
  51. package/dist/components/form/fields/template/ExampleValueModal.js +122 -0
  52. package/dist/components/form/fields/template/ExampleValueModal.js.map +1 -0
  53. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  54. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -3
  55. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  56. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  57. package/dist/components/form/fields/template/TemplateField.js +81 -27
  58. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  59. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts +2 -0
  60. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts.map +1 -0
  61. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js +9 -0
  62. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js.map +1 -0
  63. package/dist/components/form/index.d.ts +1 -0
  64. package/dist/components/form/index.d.ts.map +1 -1
  65. package/dist/components/form/index.js +1 -0
  66. package/dist/components/form/index.js.map +1 -1
  67. package/dist/components/searchFilterBar/SearchFilterBar.d.ts +12 -1
  68. package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -1
  69. package/dist/components/searchFilterBar/SearchFilterBar.js +2 -2
  70. package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -1
  71. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  72. package/dist/components/ticketThread/TicketThread.js +1 -1
  73. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  74. package/dist/helpers/common.d.ts +8 -1
  75. package/dist/helpers/common.d.ts.map +1 -1
  76. package/dist/helpers/common.js +9 -1
  77. package/dist/helpers/common.js.map +1 -1
  78. package/dist/helpers/expressions.d.ts.map +1 -1
  79. package/dist/helpers/expressions.js +15 -2
  80. package/dist/helpers/expressions.js.map +1 -1
  81. package/dist/helpers/optionUiTypes.d.ts +30 -0
  82. package/dist/helpers/optionUiTypes.d.ts.map +1 -0
  83. package/dist/helpers/optionUiTypes.js +156 -0
  84. package/dist/helpers/optionUiTypes.js.map +1 -0
  85. package/dist/helpers/options.d.ts +19 -0
  86. package/dist/helpers/options.d.ts.map +1 -1
  87. package/dist/helpers/options.js +25 -1
  88. package/dist/helpers/options.js.map +1 -1
  89. package/dist/helpers/providerValue.d.ts +68 -0
  90. package/dist/helpers/providerValue.d.ts.map +1 -0
  91. package/dist/helpers/providerValue.js +50 -0
  92. package/dist/helpers/providerValue.js.map +1 -0
  93. package/dist/helpers/singleLineString.d.ts +37 -0
  94. package/dist/helpers/singleLineString.d.ts.map +1 -0
  95. package/dist/helpers/singleLineString.js +66 -0
  96. package/dist/helpers/singleLineString.js.map +1 -0
  97. package/dist/helpers/templates.d.ts +100 -0
  98. package/dist/helpers/templates.d.ts.map +1 -1
  99. package/dist/helpers/templates.js +237 -4
  100. package/dist/helpers/templates.js.map +1 -1
  101. package/dist/helpers/validations.d.ts +89 -2
  102. package/dist/helpers/validations.d.ts.map +1 -1
  103. package/dist/helpers/validations.js +801 -89
  104. package/dist/helpers/validations.js.map +1 -1
  105. package/dist/index.d.ts +1 -1
  106. package/dist/index.d.ts.map +1 -1
  107. package/dist/index.js +2 -1
  108. package/dist/index.js.map +1 -1
  109. package/package.json +1 -1
  110. package/src/components/composer/Composer.tsx +7 -3
  111. package/src/components/form/engine/CompactRow.tsx +279 -33
  112. package/src/components/form/engine/CompactToolbar.tsx +105 -98
  113. package/src/components/form/engine/FormEngine.stories.tsx +995 -12
  114. package/src/components/form/engine/FormEngine.tsx +377 -94
  115. package/src/components/form/engine/_structuredData/SchemaDataView.stories.tsx +104 -0
  116. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +158 -101
  117. package/src/components/form/engine/_structuredData/structuredData.ts +20 -0
  118. package/src/components/form/engine/compactRowStyles.ts +55 -1
  119. package/src/components/form/engine/compactToolbarContext.ts +8 -0
  120. package/src/components/form/engine/readFirst.ts +125 -35
  121. package/src/components/form/fields/Field.tsx +3 -1
  122. package/src/components/form/fields/allowed-values/AllowedValues.tsx +24 -2
  123. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +81 -0
  124. package/src/components/form/fields/auto/AutoFormField.stories.tsx +47 -1
  125. package/src/components/form/fields/auto/AutoFormField.tsx +73 -11
  126. package/src/components/form/fields/long-string/LongString.tsx +46 -5
  127. package/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx +5 -0
  128. package/src/components/form/fields/template/ExampleValueModal.stories.tsx +114 -0
  129. package/src/components/form/fields/template/ExampleValueModal.tsx +99 -0
  130. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +8 -4
  131. package/src/components/form/fields/template/TemplateField.stories.tsx +389 -1
  132. package/src/components/form/fields/template/TemplateField.tsx +100 -35
  133. package/src/components/form/fields/template/__fixtures__/longExampleValue.ts +7 -0
  134. package/src/components/form/index.tsx +1 -0
  135. package/src/components/searchFilterBar/SearchFilterBar.stories.tsx +62 -0
  136. package/src/components/searchFilterBar/SearchFilterBar.tsx +13 -0
  137. package/src/components/ticketThread/TicketThread.tsx +7 -0
  138. package/src/helpers/common.ts +11 -1
  139. package/src/helpers/expressions.ts +16 -2
  140. package/src/helpers/optionUiTypes.ts +161 -0
  141. package/src/helpers/options.ts +23 -0
  142. package/src/helpers/providerValue.ts +125 -0
  143. package/src/helpers/singleLineString.ts +61 -0
  144. package/src/helpers/templates.ts +272 -3
  145. package/src/helpers/validations.ts +1118 -107
  146. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +23 -2
  147. package/src/index.tsx +1 -0
@@ -10,8 +10,11 @@ import isNaN from 'lodash/isNaN';
10
10
  import isNumber from 'lodash/isNumber';
11
11
  import isObject from 'lodash/isPlainObject';
12
12
  import size from 'lodash/size';
13
- import { getAddress, getProtocol, splitByteSize } from './common';
14
- import { getOptionsFromRequiredGroups } from './options';
13
+ import uniqWith from 'lodash/uniqWith';
14
+ import { fixOperatorValue, getAddress, getProtocol, splitByteSize } from './common';
15
+ import { isOptionInterfaceUiType } from './optionUiTypes';
16
+ import { getListElementValue, getOptionsFromRequiredGroups } from './options';
17
+ import { IProviderType, TVariableActionValue, maybeBuildOptionProvider } from './providerValue';
15
18
  import { getTemplateKey, getTemplateValue, isValueTemplate } from './templates';
16
19
 
17
20
  /** The five cron fields, in order, as a schedule hash may name them. */
@@ -74,17 +77,67 @@ interface IFieldValidationProps {
74
77
  validation_regex?: string;
75
78
  required_groups?: string[];
76
79
  optionSchema?: IQorusFormSchema;
77
- options?: TQorusForm;
80
+ /** The sibling option VALUES, for required-group and dependency checks.
81
+ * Widened because a schema entry is also passed straight in as `field`, and
82
+ * a schema entry carries an `options` of its own — the field's UI options
83
+ * bag (`{ file?: DropzoneOptions }`) — under the same name. */
84
+ options?: TQorusForm | Record<string, any>;
78
85
  isFunction?: boolean;
79
86
  arg_schema?: IQorusFormSchema;
80
87
  ui_element_type?: string;
81
88
  element_type?: string;
82
89
  allowed_values?: any[];
90
+ element_allowed_values?: any[];
83
91
  disabled?: boolean;
84
92
  metadata?: Record<string, any>;
93
+ /** The expression catalogue, when a caller holds one already — see
94
+ * {@link setExpressionCatalogueReader} for where it comes from otherwise. */
95
+ expressions?: any[];
96
+ /** The two key names an `array-of-pairs` row is made of. */
97
+ fields?: string[];
98
+ /** Set for a provider chosen as an FSM variable, which must support an action. */
99
+ isVariable?: boolean;
100
+ /** The declaration a `var-action` is resolved against. Partial because it
101
+ * carries only the provider half — the action supplies the variable half,
102
+ * and the two are merged before the provider validator sees them. */
103
+ variableData?: { value?: Partial<TVariableActionValue> };
85
104
  [key: string]: any;
86
105
  }
87
106
 
107
+ /**
108
+ * Where the expression catalogue comes from when a caller does not carry one.
109
+ *
110
+ * Every other rule here is a pure function of the value and its schema, but an
111
+ * expression can only be checked against the catalogue of operations the server
112
+ * publishes — which is application state, fetched once and held in a store. The
113
+ * library must not reach into a consumer's store, so the consumer hands the
114
+ * reader in instead: qorus-ide registers one that reads its expressions store.
115
+ *
116
+ * Unregistered, the catalogue is empty, and `case 'expression'` then validates
117
+ * only what an expression can be judged on by itself (see the ordering there).
118
+ */
119
+ type TExpressionCatalogueReader = () => any[] | undefined;
120
+
121
+ let expressionCatalogueReader: TExpressionCatalogueReader | undefined;
122
+
123
+ export const setExpressionCatalogueReader = (reader?: TExpressionCatalogueReader): void => {
124
+ expressionCatalogueReader = reader;
125
+ };
126
+
127
+ const readExpressionCatalogue = (field?: IFieldValidationProps): any[] =>
128
+ field?.expressions ?? expressionCatalogueReader?.() ?? [];
129
+
130
+ // permissive patterns for the semantic string formats — mirror the qore DataProvider
131
+ // QoreStringFormatDataType server backstop (email/uuid/hostname/ipv4/ipv6/phone)
132
+ const SEMANTIC_FORMAT_PATTERNS: Record<string, RegExp> = {
133
+ email: /^[^@\s]+@[^@\s]+$/,
134
+ uuid: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,
135
+ hostname: /^[A-Za-z0-9.-]+$/,
136
+ ipv4: /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
137
+ ipv6: /^([0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}$/,
138
+ phone: /^\+?[0-9 ().-]{3,}$/,
139
+ };
140
+
88
141
  const validResult = (): IValidationResult => ({ isValid: true, reasons: [] });
89
142
 
90
143
  const invalidResult = (
@@ -160,16 +213,6 @@ export const _validateField = (
160
213
  if (canBeNull && (value === null || value === undefined)) {
161
214
  return validResult();
162
215
  }
163
- // Expression values (`is_expression`) carry the AST `{ exp, args }` instead
164
- // of a typed literal — validate that an expression is chosen, not the
165
- // base type. (`isFunction` is set by FormEngine for expression options.)
166
- if (field?.isFunction) {
167
- const ast = (value as { value?: any })?.value ?? value;
168
- return resultFromBoolean(
169
- !!(ast as { exp?: string })?.exp,
170
- 'Expression operation is required'
171
- );
172
- }
173
216
  // Strip type parameters e.g. hash<string, int> → hash
174
217
  const pos: number = type.indexOf('<');
175
218
  if (pos > 0) {
@@ -182,17 +225,44 @@ export const _validateField = (
182
225
 
183
226
  return resultFromBoolean(hasKey && hasValue, 'Template must include key and value');
184
227
  }
228
+ // Expression values (`is_expression`) carry the AST `{ exp, args }` instead of
229
+ // a typed literal, so the declared type says nothing about them — validate
230
+ // them as the expression they are, arguments and all. (`isFunction` is set by
231
+ // FormEngine, and by the option recursion below, for expression options.)
232
+ if (field?.isFunction) {
233
+ // The AST is normally the value itself; a caller that still holds the
234
+ // option envelope hands over `{ value: ast }`, so accept both.
235
+ const ast = (value as { value?: any })?.value ?? value;
236
+
237
+ return validateFieldWithResult(
238
+ 'expression',
239
+ { value: ast },
240
+ omit(field, ['isFunction']),
241
+ canBeNull
242
+ );
243
+ }
185
244
 
186
245
  // Check if the field has required groups
187
246
  if (field?.required_groups) {
188
247
  if (
189
248
  !isValueDefined(type, value) &&
190
- validateOptionWithRequiredGroups(field.options, field.optionSchema, field.required_groups)
249
+ validateOptionWithRequiredGroups(
250
+ field.options as TQorusForm,
251
+ field.optionSchema,
252
+ field.required_groups
253
+ )
191
254
  ) {
192
255
  return validResult();
193
256
  }
194
257
  }
195
258
 
259
+ // An interface type names another Qorus object by its name, so a non-empty
260
+ // string is the whole contract. `connection` is the exception: it carries
261
+ // enablement and authentication state of its own, checked below.
262
+ if (isOptionInterfaceUiType(type) && type !== 'connection') {
263
+ return validateFieldWithResult('string', value, field);
264
+ }
265
+
196
266
  switch (type) {
197
267
  case 'richtext': {
198
268
  if (!value || !size(value)) {
@@ -245,33 +315,67 @@ export const _validateField = (
245
315
  'Connection string validation failed'
246
316
  );
247
317
  }
318
+ // semantic string formats — permissive patterns mirror the qore DataProvider
319
+ // QoreStringFormatDataType backstop; a server-supplied validation_regex, if
320
+ // present, is also enforced
321
+ case 'email':
322
+ case 'uuid':
323
+ case 'hostname':
324
+ case 'ipv4':
325
+ case 'ipv6':
326
+ case 'phone': {
327
+ if (value === undefined || value === null || value === '' || typeof value !== 'string') {
328
+ return invalidResult('Value must be a non-empty text');
329
+ }
330
+ if (field?.validation_regex && !value.match(field.validation_regex)) {
331
+ return invalidResult('Value does not match the required format');
332
+ }
333
+ return resultFromBoolean(
334
+ SEMANTIC_FORMAT_PATTERNS[type].test(value),
335
+ `Value is not a valid ${type}`
336
+ );
337
+ }
248
338
  case 'binary': {
249
339
  if (typeof value !== 'string') {
250
340
  return invalidResult('Binary value must be a string');
251
341
  }
252
342
 
253
- const hex = value.trim().replace(/^0x/i, '');
343
+ const trimmed = value.trim();
254
344
 
255
- if (hex.length === 0) {
345
+ if (trimmed.length === 0) {
256
346
  return invalidResult('Binary value is empty');
257
347
  }
258
348
 
259
- if (hex.length % 2 !== 0) {
260
- return invalidResult('Binary hex length must be even');
349
+ // the canonical wire form is base64 (matching the server decode); a "data:<mime>;base64,..." URL
350
+ // and legacy "0x"-prefixed hex are also accepted
351
+ if (/^data:[^;]+;base64,/.test(trimmed)) {
352
+ return validResult();
261
353
  }
262
354
 
263
- if (!/^[0-9a-fA-F]+$/.test(hex)) {
264
- return invalidResult('Binary value must contain only hex characters');
355
+ if (/^0x/i.test(trimmed)) {
356
+ const hex = trimmed.replace(/^0x/i, '');
357
+ return resultFromBoolean(
358
+ hex.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(hex),
359
+ 'Binary hex value is invalid'
360
+ );
265
361
  }
266
362
 
267
- return validResult();
363
+ return resultFromBoolean(
364
+ /^[A-Za-z0-9+/]+={0,2}$/.test(trimmed),
365
+ 'Binary value must be base64, a data: URL, or 0x-prefixed hex'
366
+ );
268
367
  }
269
368
  case 'string':
270
369
  case 'mapper':
271
370
  case 'workflow':
272
371
  case 'service':
273
372
  case 'job':
373
+ case 'softstring':
374
+ case 'select-string':
375
+ case 'file-string':
376
+ case 'file-as-string':
274
377
  case 'long-string':
378
+ case 'method-name':
275
379
  case 'code-editor':
276
380
  case 'data': {
277
381
  if (value === undefined || value === null || value === '' || typeof value !== 'string') {
@@ -347,6 +451,128 @@ export const _validateField = (
347
451
 
348
452
  return reasons.length ? invalidResult(reasons) : validResult();
349
453
  }
454
+ case 'collection-documents': {
455
+ // The value is an array of rows in mixed lifecycle states.
456
+ // A row is valid when it's either:
457
+ // 1. Server-managed: has `ai_documentid` (number) + `name`.
458
+ // 2. Client-managed: has `name` + `content` (base64) — i.e.
459
+ // a fresh upload that hasn't landed yet.
460
+ // Failed rows count as valid for form-level purposes (the
461
+ // user can Retry; the form shouldn't block save just because
462
+ // an upload errored). Empty list is valid here — the
463
+ // top-level required check (`field.required`) handles "must
464
+ // pick at least one" gating.
465
+ if (value === undefined || value === null) {
466
+ return validResult();
467
+ }
468
+ if (!Array.isArray(value)) {
469
+ return invalidResult('Value must be a list of documents');
470
+ }
471
+ const reasons: string[] = [];
472
+ value.forEach((row: any, idx: number) => {
473
+ if (!row || typeof row !== 'object') {
474
+ reasons.push(`Row ${idx + 1} is not an object`);
475
+ return;
476
+ }
477
+ if (typeof row.name !== 'string' || !row.name) {
478
+ reasons.push(`Row ${idx + 1} is missing a name`);
479
+ return;
480
+ }
481
+ const hasServerId = typeof row.ai_documentid === 'number';
482
+ const hasContent = typeof row.content === 'string' && !!row.content;
483
+ if (!hasServerId && !hasContent) {
484
+ reasons.push(
485
+ `Row ${idx + 1} ("${row.name}") has neither uploaded ` +
486
+ `content nor a server document id`
487
+ );
488
+ }
489
+ });
490
+ return reasons.length ? invalidResult(undefined, reasons) : validResult();
491
+ }
492
+ case 'array-of-pairs': {
493
+ let valid = true;
494
+ const reasons: string[] = [];
495
+ // Check if every pair has key & value
496
+ // assigned properly
497
+ if (!Array.isArray(value)) {
498
+ return invalidResult('Value must be a list');
499
+ }
500
+ if (
501
+ !value?.every(
502
+ (pair: { [key: string]: string }): boolean =>
503
+ pair[field.fields[0]] !== '' && pair[field.fields[1]] !== ''
504
+ )
505
+ ) {
506
+ valid = false;
507
+ reasons.push('All pairs must include both fields');
508
+ }
509
+ // Get a list of unique values
510
+ const uniqueValues: any[] = uniqWith(
511
+ value,
512
+ (cur, prev) => cur[field.fields[0]] === prev[field.fields[0]]
513
+ );
514
+ // Check if there are any duplicates
515
+ if (size(uniqueValues) !== size(value)) {
516
+ valid = false;
517
+ reasons.push('Pairs must use unique keys');
518
+ }
519
+
520
+ return valid ? validResult() : invalidResult(undefined, reasons);
521
+ }
522
+ case 'class-connectors': {
523
+ if (!Array.isArray(value) || value.length === 0) {
524
+ return invalidResult('At least one connector is required');
525
+ }
526
+ let valid = true;
527
+ const reasons: string[] = [];
528
+ // Check if every pair has name, input method and output method
529
+ // assigned properly
530
+ if (
531
+ !value?.every(
532
+ (pair: { [key: string]: string }): boolean =>
533
+ !!pair.name && pair.name !== '' && !!pair.method && pair.method !== ''
534
+ )
535
+ ) {
536
+ valid = false;
537
+ reasons.push('Each connector must have a name and method');
538
+ }
539
+ // Get a list of unique values
540
+ const uniqueValues: any[] = uniqWith(value, (cur, prev) => cur.name === prev.name);
541
+ // Check if there are any duplicates
542
+ if (size(uniqueValues) !== size(value)) {
543
+ valid = false;
544
+ reasons.push('Connector names must be unique');
545
+ }
546
+
547
+ return valid ? validResult() : invalidResult(undefined, reasons);
548
+ }
549
+ // Classes check
550
+ case 'class-array': {
551
+ if (!(Array.isArray(value) && value.length > 0)) {
552
+ return invalidResult('At least one class must be provided');
553
+ }
554
+ let valid = true;
555
+ const reasons: string[] = [];
556
+ // Check if the fields are not empty
557
+ if (
558
+ !value?.every((pair: { [key: string]: string }): boolean => !!pair.name && pair.name !== '')
559
+ ) {
560
+ valid = false;
561
+ reasons.push('Each class must have a name');
562
+ }
563
+ // Get a list of unique values
564
+ const uniqueValues: any[] = uniqWith(
565
+ value,
566
+ (cur, prev) => `${cur.prefix}${cur.name}` === `${prev.prefix}${prev.name}`
567
+ );
568
+ // Check if there are any duplicates
569
+ if (size(uniqueValues) !== size(value)) {
570
+ valid = false;
571
+ reasons.push('Class names must be unique');
572
+ }
573
+
574
+ return valid ? validResult() : invalidResult(undefined, reasons);
575
+ }
350
576
  case 'number': {
351
577
  return resultFromBoolean(
352
578
  !isNaN(value) && (getTypeFromValue(value) === 'float' || getTypeFromValue(value) === 'int'),
@@ -363,6 +589,7 @@ export const _validateField = (
363
589
  !isNaN(value) && (getTypeFromValue(value) === 'float' || getTypeFromValue(value) === 'int'),
364
590
  'Value must be a float'
365
591
  );
592
+ case 'select-array':
366
593
  case 'multi-select':
367
594
  case 'array':
368
595
  case 'file-tree':
@@ -453,7 +680,13 @@ export const _validateField = (
453
680
  for (let i = 0; i < parsedValue.length; i++) {
454
681
  const itemResult = validateFieldWithResult(
455
682
  (field.ui_element_type as string) || (field.element_type as string),
456
- parsedValue[i].value,
683
+ // An element arrives bare OR in a `{value, type}` envelope: the
684
+ // envelope is what the editor writes, the bare form is what storage
685
+ // holds. Reaching straight for `.value` failed every stored element
686
+ // as empty, so a reloaded alert rule reported its delivery action
687
+ // invalid while showing all six options set -- and threw outright on
688
+ // a null element.
689
+ getListElementValue(parsedValue[i]),
457
690
  field
458
691
  );
459
692
 
@@ -465,18 +698,370 @@ export const _validateField = (
465
698
 
466
699
  return validResult();
467
700
  }
468
- case 'auto':
469
- case 'any': {
470
- let yamlCorrect = true;
471
- let parsedData: any;
472
- try {
473
- parsedData = jsyaml.load(value);
474
- } catch (e) {
475
- yamlCorrect = false;
701
+ case 'list-of-hashes': {
702
+ const parsedValue: any = maybeParseYaml(value);
703
+
704
+ if (!parsedValue || !isArray(parsedValue)) {
705
+ return invalidResult('Value must be a list of hashes');
706
+ }
707
+
708
+ for (let i = 0; i < parsedValue.length; i++) {
709
+ const item = parsedValue[i];
710
+
711
+ if (!size(item)) {
712
+ return invalidResult(`Hash at index ${i} must not be empty`);
713
+ }
714
+
715
+ const itemResult = validateFieldWithResult('hash', item);
716
+
717
+ if (!itemResult.isValid) {
718
+ return withContext(itemResult, `Hash at index ${i} is invalid`);
719
+ }
720
+ }
721
+
722
+ return validResult();
723
+ }
724
+ case 'mapper-code': {
725
+ if (!value) {
726
+ return invalidResult('Mapper code is required');
727
+ }
728
+ // Split the value
729
+ const [code, method] = value.split('::');
730
+ // Both fields need to be strings & filled
731
+ const codeResult = validateFieldWithResult('string', code);
732
+
733
+ if (!codeResult.isValid) {
734
+ return withContext(codeResult, 'Mapper code is invalid');
735
+ }
736
+
737
+ const methodResult = validateFieldWithResult('string', method);
738
+
739
+ if (!methodResult.isValid) {
740
+ return withContext(methodResult, 'Mapper method is invalid');
741
+ }
742
+
743
+ return validResult();
744
+ }
745
+ case 'var-action': {
746
+ const varAction: TVariableActionValue = value;
747
+
748
+ if (
749
+ varAction?.var_type !== 'localvar' &&
750
+ varAction?.var_type !== 'globalvar' &&
751
+ varAction?.var_type !== 'autovar'
752
+ ) {
753
+ return invalidResult('Variable type is invalid');
754
+ }
755
+
756
+ const nameResult = validateFieldWithResult('string', varAction.var_name, {
757
+ has_to_have_value: true,
758
+ });
759
+
760
+ if (!nameResult.isValid) {
761
+ return withContext(nameResult, 'Variable name is invalid');
762
+ }
763
+
764
+ const actionResult = validateFieldWithResult('string', varAction.action_type, {
765
+ has_to_have_value: true,
766
+ });
767
+
768
+ if (!actionResult.isValid) {
769
+ return withContext(actionResult, 'Variable action type is invalid');
770
+ }
771
+
772
+ // If the action type is transaction, the transaction_action needs to be set
773
+ if (varAction.action_type === 'transaction') {
774
+ const transactionResult = validateFieldWithResult('string', varAction.transaction_action, {
775
+ has_to_have_value: true,
776
+ });
777
+
778
+ if (!transactionResult.isValid) {
779
+ return withContext(transactionResult, 'Transaction action is invalid');
780
+ }
781
+
782
+ return validResult();
783
+ }
784
+
785
+ // If the variable data is missing
786
+ if (!field?.variableData?.value) {
787
+ return invalidResult('Variable data is missing');
788
+ }
789
+
790
+ // Get the variable data
791
+ const variableData: TVariableActionValue = {
792
+ ...value,
793
+ ...field.variableData.value,
794
+ };
795
+
796
+ return withContext(
797
+ validateFieldWithResult(varAction.action_type, variableData, field),
798
+ 'Variable action is invalid'
799
+ );
800
+ }
801
+ case 'type-selector':
802
+ case 'data-provider':
803
+ case 'api-call':
804
+ case 'search-single':
805
+ case 'send-message':
806
+ case 'search':
807
+ case 'update':
808
+ case 'delete':
809
+ case 'create': {
810
+ const newValue: IProviderType | null = maybeBuildOptionProvider(value);
811
+
812
+ if (!newValue) {
813
+ return invalidResult('Provider value is invalid');
814
+ }
815
+
816
+ // Api call only supports requests / response
817
+ if (type === 'api-call' && !value.supports_request) {
818
+ return invalidResult('API call must support requests');
819
+ }
820
+
821
+ // If the provider is from FSM variables, it needs pass this
822
+ if (
823
+ field?.isVariable &&
824
+ !(
825
+ newValue.supports_read ||
826
+ newValue.supports_create ||
827
+ newValue.supports_update ||
828
+ newValue.supports_delete ||
829
+ newValue.supports_request ||
830
+ newValue.supports_messages ||
831
+ newValue.transaction_management
832
+ )
833
+ ) {
834
+ return invalidResult('Variable provider must support at least one action');
835
+ }
836
+
837
+ // Send message only supports messages
838
+ if (
839
+ type === 'send-message' &&
840
+ (!newValue.supports_messages || !newValue.message_id || !newValue.message)
841
+ ) {
842
+ return invalidResult('Send message provider must include message id and content');
843
+ }
844
+
845
+ if (newValue.message_id) {
846
+ const messageIdResult = validateFieldWithResult('string', newValue.message_id);
847
+
848
+ if (!messageIdResult.isValid) {
849
+ return withContext(messageIdResult, 'Message id is invalid');
850
+ }
851
+
852
+ if (!newValue.message) {
853
+ return invalidResult('Message content is missing');
854
+ }
855
+
856
+ const messageResult = validateFieldWithResult(
857
+ newValue.message.type,
858
+ newValue.message.value
859
+ );
860
+
861
+ if (!messageResult.isValid) {
862
+ return withContext(messageResult, 'Message content is invalid');
863
+ }
864
+ }
865
+
866
+ if (newValue.use_args) {
867
+ if (newValue.args?.type !== 'nothing') {
868
+ if (!newValue.args) {
869
+ return invalidResult('Arguments are missing');
870
+ }
871
+
872
+ const argsResult = validateFieldWithResult(
873
+ newValue.args.type === 'hash' ? 'system-options' : newValue.args.type,
874
+ newValue.args.value
875
+ );
876
+
877
+ if (!argsResult.isValid) {
878
+ return withContext(argsResult, 'Arguments are invalid');
879
+ }
880
+ }
881
+ }
882
+
883
+ if (
884
+ (type === 'search-single' || type === 'search') &&
885
+ size(newValue.search_args) !== 0 &&
886
+ !validateFieldWithResult('system-options-with-operators', newValue.search_args).isValid
887
+ ) {
888
+ return invalidResult('Search arguments are invalid');
889
+ }
890
+
891
+ const isUpdateOrCreate = type === 'update' || type === 'create';
892
+
893
+ if (isUpdateOrCreate) {
894
+ const areNormalArgsInvalid =
895
+ `${type}_args` in newValue &&
896
+ (size(newValue[`${type}_args`]) === 0 ||
897
+ !validateFieldWithResult('system-options', newValue[`${type}_args`]).isValid);
898
+
899
+ const areFreeFormArgsInvalid =
900
+ `${type}_args_freeform` in newValue &&
901
+ (size(newValue[`${type}_args_freeform`]) === 0 ||
902
+ !validateFieldWithResult('list-of-hashes', newValue[`${type}_args_freeform`]).isValid);
903
+
904
+ if (`${type}_args` in newValue && areNormalArgsInvalid && areFreeFormArgsInvalid) {
905
+ return invalidResult('Update/create arguments are invalid');
906
+ }
907
+
908
+ if (`${type}_args_freeform` in newValue && areFreeFormArgsInvalid && areNormalArgsInvalid) {
909
+ return invalidResult('Update/create arguments are invalid');
910
+ }
911
+ }
912
+
913
+ if (newValue?.type === 'factory') {
914
+ if (newValue.optionsChanged) {
915
+ return invalidResult('Factory options are not saved');
916
+ }
917
+
918
+ let options = true;
919
+
920
+ if (newValue.options) {
921
+ options = validateFieldWithResult('system-options', newValue.options).isValid;
922
+ }
923
+
924
+ // Type path and name are required
925
+ return resultFromBoolean(
926
+ !!(newValue.type && newValue.name && options),
927
+ 'Factory provider is incomplete'
928
+ );
929
+ }
930
+
931
+ if (newValue.record_requires_search_options && newValue.searchOptionsChanged) {
932
+ return invalidResult('Search options must be saved');
933
+ }
934
+
935
+ if (
936
+ newValue.search_options &&
937
+ !validateFieldWithResult('system-options', newValue.search_options).isValid
938
+ ) {
939
+ return invalidResult('Search options are invalid');
940
+ }
941
+
942
+ return resultFromBoolean(
943
+ !!(newValue.type && newValue.name),
944
+ 'Provider type and name required'
945
+ );
946
+ }
947
+ case 'context-selector': {
948
+ if (isString(value)) {
949
+ const cont: string[] = value.split(':');
950
+ const contextResult = validateFieldWithResult('string', cont[0]);
951
+
952
+ if (!contextResult.isValid) {
953
+ return withContext(contextResult, 'Context selector prefix is invalid');
954
+ }
955
+
956
+ const nameResult = validateFieldWithResult('string', cont[1]);
957
+
958
+ if (!nameResult.isValid) {
959
+ return withContext(nameResult, 'Context selector name is invalid');
960
+ }
961
+
962
+ return validResult();
963
+ }
964
+ return resultFromBoolean(
965
+ !!value?.iface_kind && !!value?.name,
966
+ 'Context selector requires iface_kind and name'
967
+ );
968
+ }
969
+ case 'service-event': {
970
+ const typeResult = validateFieldWithResult('type-selector', value);
971
+
972
+ if (!typeResult.isValid) {
973
+ return withContext(typeResult, 'Service event type is invalid');
974
+ }
975
+
976
+ if (
977
+ !size(value.handlers) ||
978
+ !every(
979
+ value.handlers,
980
+ (handler: any) => (handler.type === 'fsm' || handler.type === 'method') && handler.value
981
+ )
982
+ ) {
983
+ return invalidResult('Service event handlers are invalid');
984
+ }
985
+
986
+ return validResult();
987
+ }
988
+ case 'service-events': {
989
+ if (!isArray(value) || !size(value)) {
990
+ return invalidResult('At least one service event is required');
991
+ }
992
+
993
+ for (let i = 0; i < value.length; i++) {
994
+ const serviceEventResult = validateFieldWithResult('service-event', value[i]);
995
+
996
+ if (!serviceEventResult.isValid) {
997
+ return withContext(serviceEventResult, `Service event ${i} is invalid`);
998
+ }
999
+ }
1000
+
1001
+ return validResult();
1002
+ }
1003
+ case 'service-webhook': {
1004
+ if (
1005
+ !validateFieldWithResult('string', value?.name).isValid ||
1006
+ !validateFieldWithResult('string', value?.['rest-method']).isValid ||
1007
+ !validateFieldWithResult('string', value?.auth).isValid
1008
+ ) {
1009
+ return invalidResult('Webhook name, method and auth are required');
1010
+ }
1011
+
1012
+ if (value.handler) {
1013
+ if (value.handler.type !== 'fsm' && value.handler.type !== 'method') {
1014
+ return invalidResult('Webhook handler type is invalid');
1015
+ }
1016
+
1017
+ const handlerResult = validateFieldWithResult('string', value.handler.value);
1018
+
1019
+ if (!handlerResult.isValid) {
1020
+ return withContext(handlerResult, 'Webhook handler value is invalid');
1021
+ }
1022
+ }
1023
+
1024
+ return validResult();
1025
+ }
1026
+ case 'service-webhooks': {
1027
+ if (!isArray(value) || !size(value)) {
1028
+ return invalidResult('At least one webhook is required');
476
1029
  }
477
1030
 
478
- if (!yamlCorrect) {
479
- return invalidResult('Value is not valid YAML');
1031
+ for (let i = 0; i < value.length; i++) {
1032
+ const webhookResult = validateFieldWithResult('service-webhook', value[i]);
1033
+
1034
+ if (!webhookResult.isValid) {
1035
+ return withContext(webhookResult, `Service webhook ${i} is invalid`);
1036
+ }
1037
+ }
1038
+
1039
+ return validResult();
1040
+ }
1041
+ case 'auto':
1042
+ case 'any': {
1043
+ // Only a STRING has YAML in it. An `auto` field's value is usually
1044
+ // already the structure it describes -- a hash the form's hash editor
1045
+ // wrote, a list from a list editor -- and `jsyaml.load()` stringifies
1046
+ // whatever it is handed first: an object becomes `"[object Object]"`,
1047
+ // which YAML then reads as the flow sequence `["object Object"]`. So the
1048
+ // auto-detected type came back `list`, the value was validated as a list,
1049
+ // and every hash stored in an `auto` field was reported *"Value must be a
1050
+ // list"* -- an invalid marker on a value that is fine. A list fared no
1051
+ // better: `[1, 2]` stringified to `"1,2"` and detected as a string.
1052
+ //
1053
+ // Every other branch that accepts either shape already parses through
1054
+ // `maybeParseYaml`, which passes a non-string straight through; this one
1055
+ // reached for `jsyaml` directly. It cannot use `maybeParseYaml` as-is
1056
+ // because that reports a YAML error as an absent value, and "not valid
1057
+ // YAML" and "empty" are different answers here.
1058
+ let parsedData: any = value;
1059
+ if (isString(value)) {
1060
+ try {
1061
+ parsedData = jsyaml.load(value);
1062
+ } catch (e) {
1063
+ return invalidResult('Value is not valid YAML');
1064
+ }
480
1065
  }
481
1066
 
482
1067
  if (parsedData) {
@@ -488,7 +1073,187 @@ export const _validateField = (
488
1073
 
489
1074
  return invalidResult('Value is empty');
490
1075
  }
491
- case 'options': {
1076
+ case 'processor': {
1077
+ if (!value || !value['processor-input-type'] || !value['processor-output-type']) {
1078
+ return invalidResult('Processor input and output types are required');
1079
+ }
1080
+ // Validate the input and output types
1081
+ if (value?.['processor-input-type']) {
1082
+ const inputResult = validateFieldWithResult(
1083
+ 'type-selector',
1084
+ value?.['processor-input-type']
1085
+ );
1086
+
1087
+ if (!inputResult.isValid) {
1088
+ return withContext(inputResult, 'Processor input type is invalid');
1089
+ }
1090
+ }
1091
+
1092
+ if (value?.['processor-output-type']) {
1093
+ const outputResult = validateFieldWithResult(
1094
+ 'type-selector',
1095
+ value?.['processor-output-type']
1096
+ );
1097
+
1098
+ if (!outputResult.isValid) {
1099
+ return withContext(outputResult, 'Processor output type is invalid');
1100
+ }
1101
+ }
1102
+
1103
+ return validResult();
1104
+ }
1105
+ case 'processor-mappings': {
1106
+ // processor-mappings is an array of field mappings
1107
+ // Each mapping must have an outputPath (the target field being mapped)
1108
+ // and either an inputPath (source field) or options configured
1109
+ if (!isArray(value)) {
1110
+ return canBeNull ? validResult() : invalidResult('Processor mappings must be a list');
1111
+ }
1112
+
1113
+ for (let i = 0; i < value.length; i++) {
1114
+ const mapping = value[i];
1115
+
1116
+ if (!mapping || !isObject(mapping)) {
1117
+ return invalidResult(`Mapping at index ${i} is invalid`);
1118
+ }
1119
+
1120
+ // outputPath is always required - it identifies which output field is being mapped
1121
+ const outputPathResult = validateFieldWithResult('string', mapping.outputPath);
1122
+
1123
+ if (!outputPathResult.isValid) {
1124
+ return withContext(outputPathResult, `Mapping ${i} output path is invalid`);
1125
+ }
1126
+
1127
+ // A mapping must have either an inputPath or options (or both)
1128
+ const hasInputPath = mapping.inputPath && mapping.inputPath !== '';
1129
+ const hasOptions = mapping.options && size(mapping.options) > 0;
1130
+
1131
+ if (!hasInputPath && !hasOptions) {
1132
+ return invalidResult(
1133
+ `Mapping for "${mapping.outputPath}" must have an input path or options configured`
1134
+ );
1135
+ }
1136
+ }
1137
+
1138
+ return validResult();
1139
+ }
1140
+ case 'tool-catalog': {
1141
+ // tool-catalog is a flat string[] of selectors; each selector is one of:
1142
+ // "*" — all tools from every source
1143
+ // "system:*" — all Qorus system tools
1144
+ // "system:<tool>" — a specific system tool
1145
+ // "<connection>" — all tools from a connection
1146
+ // "<connection>/<tool>" — a specific tool from a connection
1147
+ if (!isArray(value)) {
1148
+ return canBeNull ? validResult() : invalidResult('Tool catalog must be a list');
1149
+ }
1150
+
1151
+ for (let i = 0; i < value.length; i++) {
1152
+ const selector = value[i];
1153
+ if (typeof selector !== 'string' || selector.length === 0) {
1154
+ return invalidResult(`Tool selector at index ${i} must be a non-empty string`);
1155
+ }
1156
+ }
1157
+
1158
+ return validResult();
1159
+ }
1160
+ case 'fsm-list': {
1161
+ if (!isArray(value)) {
1162
+ return invalidResult('FSM list must be an array');
1163
+ }
1164
+
1165
+ for (let i = 0; i < value.length; i++) {
1166
+ const itemResult = validateFieldWithResult('string', value[i]?.name);
1167
+
1168
+ if (!itemResult.isValid) {
1169
+ return withContext(itemResult, `FSM entry ${i} name is invalid`);
1170
+ }
1171
+ }
1172
+
1173
+ return validResult();
1174
+ }
1175
+ case 'api-manager': {
1176
+ if (!value) {
1177
+ return invalidResult('API manager value is missing');
1178
+ }
1179
+
1180
+ const factoryResult = validateFieldWithResult('string', value.factory);
1181
+
1182
+ if (!factoryResult.isValid) {
1183
+ return withContext(factoryResult, 'API manager factory is invalid');
1184
+ }
1185
+
1186
+ const endpointsResult = validateFieldWithResult('api-endpoints', value.endpoints);
1187
+
1188
+ if (!endpointsResult.isValid) {
1189
+ return withContext(endpointsResult, 'API manager endpoints are invalid');
1190
+ }
1191
+
1192
+ return validResult();
1193
+ }
1194
+ case 'api-endpoints': {
1195
+ if (!isArray(value) || !size(value)) {
1196
+ return invalidResult('At least one API endpoint is required');
1197
+ }
1198
+
1199
+ for (let i = 0; i < value.length; i++) {
1200
+ const endpointResult = validateFieldWithResult('string', value[i]?.value);
1201
+
1202
+ if (!endpointResult.isValid) {
1203
+ return withContext(endpointResult, `Endpoint ${i} value is invalid`);
1204
+ }
1205
+
1206
+ const authorizationResult = validateFieldWithResult(
1207
+ 'api-endpoint-authorization',
1208
+ value[i]?.authorization,
1209
+ undefined,
1210
+ true
1211
+ );
1212
+
1213
+ if (!authorizationResult.isValid) {
1214
+ return withContext(authorizationResult, `Endpoint ${i} authorization is invalid`);
1215
+ }
1216
+ }
1217
+
1218
+ return validResult();
1219
+ }
1220
+ case 'api-endpoint-authorization': {
1221
+ if (!value) {
1222
+ return canBeNull ? validResult() : invalidResult('Authorization override is missing');
1223
+ }
1224
+
1225
+ if (typeof value !== 'object' || isArray(value)) {
1226
+ return invalidResult('Authorization override must be an object');
1227
+ }
1228
+
1229
+ if (value.mode !== undefined && !['merge', 'replace'].includes(value.mode)) {
1230
+ return invalidResult('Authorization mode must be merge or replace');
1231
+ }
1232
+
1233
+ if (value.allow_anonymous !== undefined && typeof value.allow_anonymous !== 'boolean') {
1234
+ return invalidResult('Authorization allow_anonymous must be a boolean');
1235
+ }
1236
+
1237
+ if (value.roles !== undefined) {
1238
+ if (!isArray(value.roles)) {
1239
+ return invalidResult('Authorization roles must be a list');
1240
+ }
1241
+
1242
+ for (let i = 0; i < value.roles.length; i++) {
1243
+ const roleResult = validateFieldWithResult('string', value.roles[i]);
1244
+
1245
+ if (!roleResult.isValid) {
1246
+ return withContext(roleResult, `Authorization role ${i} is invalid`);
1247
+ }
1248
+ }
1249
+ }
1250
+
1251
+ return validResult();
1252
+ }
1253
+ case 'options':
1254
+ case 'pipeline-options':
1255
+ case 'mapper-options':
1256
+ case 'system-options': {
492
1257
  const getIsValid = (
493
1258
  options?: TQorusForm,
494
1259
  optionSchema: IQorusFormSchema = {}
@@ -501,47 +1266,51 @@ export const _validateField = (
501
1266
  return validResult();
502
1267
  }
503
1268
 
504
- if (optionSchema) {
505
- for (const [option, optionData] of Object.entries(optionSchema)) {
506
- if (
507
- optionData.required &&
508
- (!options?.[option] || options?.[option]?.value === undefined)
509
- ) {
510
- return invalidResult(`Option ${option} is required`);
511
- }
512
-
513
- if (
514
- !options?.[option]?.value &&
515
- optionData.required_groups &&
516
- !validateOptionWithRequiredGroups(options, optionSchema, optionData.required_groups)
517
- ) {
518
- return invalidResult(`Option ${option} requires a value`);
519
- }
520
- }
1269
+ // Check if all required options are resolved by the current values
1270
+ const [unresolvedRequiredOption] = getUnresolvedRequiredOptions(optionSchema, options);
1271
+
1272
+ if (unresolvedRequiredOption) {
1273
+ return unresolvedRequiredOption.validation
1274
+ ? withContext(
1275
+ unresolvedRequiredOption.validation,
1276
+ `Option ${unresolvedRequiredOption.name} is invalid`
1277
+ )
1278
+ : invalidResult(
1279
+ getUnresolvedRequiredOptionReason(unresolvedRequiredOption, optionSchema)
1280
+ );
521
1281
  }
522
1282
 
523
1283
  for (const option of Object.keys(options)) {
1284
+ const optionData = options[option];
1285
+ const optionType = ((typeof optionData === 'object' ? optionData?.type : undefined) ||
1286
+ (optionSchema?.[option] as TQorusFormFieldSchema)?.ui_type ||
1287
+ (optionSchema?.[option] as TQorusFormFieldSchema)?.type) as string;
1288
+ const optionValue =
1289
+ optionData && typeof optionData === 'object' ? optionData.value : optionData;
1290
+
524
1291
  if (
525
- optionSchema?.[option]?.depends_on &&
526
- !hasAllDependenciesFullfilled(optionSchema[option].depends_on, options, optionSchema)
1292
+ !(optionSchema?.[option] as TQorusFormFieldSchema)?.required &&
1293
+ !isValueDefined(optionType, optionValue)
527
1294
  ) {
528
- return invalidResult(`Option ${option} dependencies are not fulfilled`);
1295
+ continue;
529
1296
  }
530
1297
 
531
1298
  if (
532
- optionSchema?.[option]?.preselected &&
533
- !optionSchema?.[option]?.required &&
534
- !options[option]?.value
1299
+ (optionSchema?.[option] as TQorusFormFieldSchema)?.depends_on &&
1300
+ !hasAllDependenciesFullfilled(
1301
+ (optionSchema[option] as TQorusFormFieldSchema).depends_on,
1302
+ options,
1303
+ optionSchema
1304
+ )
535
1305
  ) {
536
- continue;
1306
+ return invalidResult(`Option ${option} dependencies are not fulfilled`);
537
1307
  }
538
1308
 
539
- const optionData = options[option];
540
1309
  const optionResult =
541
1310
  typeof optionData !== 'object'
542
1311
  ? validateFieldWithResult(getTypeFromValue(optionData), optionData)
543
1312
  : validateFieldWithResult(optionData.type, optionData.value, {
544
- ...omit(optionSchema?.[option] as any, []),
1313
+ ...(optionSchema?.[option] as any),
545
1314
  optionSchema,
546
1315
  options,
547
1316
  isFunction: optionData.is_expression,
@@ -569,9 +1338,55 @@ export const _validateField = (
569
1338
 
570
1339
  return getIsValid(value, field?.optionSchema);
571
1340
  }
1341
+ case 'system-options-with-operators': {
1342
+ const isValid = (val: TQorusForm): IValidationResult => {
1343
+ if (!val || size(val) === 0) {
1344
+ if (canBeNull) {
1345
+ return validResult();
1346
+ }
1347
+
1348
+ return invalidResult('Options with operators are required');
1349
+ }
1350
+
1351
+ for (const option of Object.keys(val)) {
1352
+ const optionData = val[option];
1353
+ const optionResult =
1354
+ typeof optionData !== 'object'
1355
+ ? validateFieldWithResult(getTypeFromValue(optionData), optionData)
1356
+ : validateFieldWithResult(optionData.type, optionData.value);
1357
+
1358
+ if (!optionResult.isValid) {
1359
+ return withContext(optionResult, `Option ${option} is invalid`);
1360
+ }
1361
+
1362
+ if (
1363
+ !optionData.op ||
1364
+ !fixOperatorValue(optionData.op).every(
1365
+ (operator) => validateFieldWithResult('string', operator).isValid
1366
+ )
1367
+ ) {
1368
+ return invalidResult(`Operators for option ${option} are invalid`);
1369
+ }
1370
+ }
1371
+
1372
+ return validResult();
1373
+ };
1374
+
1375
+ if (isArray(value)) {
1376
+ for (let i = 0; i < value.length; i++) {
1377
+ const optionResult = isValid(value[i]);
1378
+
1379
+ if (!optionResult.isValid) {
1380
+ return withContext(optionResult, `Option set ${i} is invalid`);
1381
+ }
1382
+ }
1383
+
1384
+ return validResult();
1385
+ }
1386
+
1387
+ return isValid(value);
1388
+ }
572
1389
  case 'byte-size': {
573
- // Ported from qorus-ide validations (`sizeUnit` instead of the IDE's
574
- // `size`, which would shadow the lodash import here).
575
1390
  if (typeof value !== 'string') return invalidResult('Byte size must be a string');
576
1391
 
577
1392
  const [bytes, sizeUnit] = splitByteSize(value);
@@ -601,7 +1416,8 @@ export const _validateField = (
601
1416
 
602
1417
  return validResult();
603
1418
  }
604
- case 'url': {
1419
+ case 'url':
1420
+ case 'uri': {
605
1421
  const protocolResult = validateFieldWithResult('string', getProtocol(value));
606
1422
 
607
1423
  if (!protocolResult.isValid) {
@@ -617,8 +1433,7 @@ export const _validateField = (
617
1433
  return validResult();
618
1434
  }
619
1435
  case 'schema-definition': {
620
- // No IDE counterpart (the editor validates inline there); this is the
621
- // minimal NEW_FIELD.md shape check so malformed definitions can't
1436
+ // The minimal NEW_FIELD.md shape check, so malformed definitions can't
622
1437
  // silently save through FormEngine.
623
1438
  if (!isObject(value)) {
624
1439
  return invalidResult('Schema definition must be an object');
@@ -629,65 +1444,102 @@ export const _validateField = (
629
1444
  return validResult();
630
1445
  }
631
1446
  case 'expression': {
632
- // Ported from qorus-ide validations `'expression'` case. The catalogue
633
- // is supplied via `field.expressions` (the ExpressionBuilder passes it),
634
- // not a global store as in the IDE.
635
1447
  const castedValue = value as {
636
1448
  value?: { exp?: string; args?: any[] };
637
1449
  is_expression?: boolean;
638
1450
  type?: string;
639
1451
  };
640
- const expressions: any[] = (field as { expressions?: any[] })?.expressions ?? [];
1452
+ const expressions = readExpressionCatalogue(field);
641
1453
 
642
- // Expressions not loaded yet — skip validation until they're available.
643
- if (!size(expressions)) {
644
- return validResult();
645
- }
1454
+ // Expressions validation is complicated and needs to follow the following rules
1455
+ // 1. If the expression is empty, it is invalid
1456
+ // 2. If the expression has a type, it means its a normal argument (sub expressions don't have type)
1457
+ // 3. If the expression has no type and `exp` is present, it is a sub expression and must be validated as such
1458
+ // 4. If there are no `args` we need to check if the expression actually has any required arguments or what is the `min_args` property
1459
+
1460
+ // 1. If the expression is empty
1461
+ //
1462
+ // Checked BEFORE the catalogue guard below, and deliberately: an
1463
+ // expression with no value, or with no operation chosen, is incomplete on
1464
+ // its own terms. Nothing in the catalogue can make it valid, so making
1465
+ // these two answers wait for a network fetch meant the same option
1466
+ // reported valid or invalid depending on whether that fetch had landed —
1467
+ // the field's own "show invalid only" filter listed a different set of
1468
+ // rows on a fast machine than on a slow one.
646
1469
  if (!castedValue) {
647
1470
  return invalidResult('Expression value is empty');
648
1471
  }
1472
+
649
1473
  if (!castedValue.value?.exp) {
650
1474
  return invalidResult('Expression operation is required');
651
1475
  }
652
- const expressionDefinition = expressions.find((expr) => expr.name === castedValue.value?.exp);
1476
+
1477
+ // Everything past this point needs the expression's definition, which
1478
+ // only the catalogue can supply. Until it loads there is nothing to check
1479
+ // against, so an expression that names an operation is left alone rather
1480
+ // than reported against a catalogue we do not have.
1481
+ if (!size(expressions)) {
1482
+ return validResult();
1483
+ }
1484
+
1485
+ // Get the expression definition from the catalogue
1486
+ const expressionDefinition = expressions.find(
1487
+ (expr: any) => expr.name === castedValue.value?.exp
1488
+ );
1489
+
653
1490
  if (!expressionDefinition) {
654
1491
  return invalidResult(`Expression definition ${String(castedValue.value?.exp)} not found`);
655
1492
  }
1493
+
1494
+ // If the expression has no required args, we can consider it valid
656
1495
  const hasRequiredArgs = expressionDefinition.args?.some((arg: any) => arg.required);
1496
+
657
1497
  if (!hasRequiredArgs && size(castedValue.value?.args || []) === 0) {
658
1498
  return validResult();
659
1499
  }
1500
+
1501
+ // If there are no args provided but the expression requires some (normal expressions require at least 1 arg
1502
+ // because we checked for required args earlier), or min_args is set
660
1503
  if (size(castedValue.value?.args || []) < (expressionDefinition.min_args || 1)) {
661
1504
  return invalidResult('Not enough arguments provided');
662
1505
  }
1506
+
1507
+ // Now we need to validate each argument, either as a normal value or as a sub-expression
663
1508
  const args = castedValue.value?.args ?? [];
1509
+
664
1510
  for (let index = 0; index < args.length; index++) {
665
1511
  const argValue: any = args[index];
666
1512
  const argDefinition = expressionDefinition.varargs
667
1513
  ? expressionDefinition.args[0]
668
1514
  : expressionDefinition.args[index];
1515
+
669
1516
  if (!argValue?.value && !argDefinition?.required) {
670
1517
  continue;
671
1518
  }
1519
+
672
1520
  if (argValue?.is_expression) {
673
1521
  const result = validateFieldWithResult('expression', argValue, {
674
1522
  expressions,
675
1523
  has_to_have_value: argDefinition?.required,
676
- } as any);
1524
+ });
1525
+
677
1526
  if (!result.isValid) {
678
1527
  return withContext(
679
1528
  result,
680
1529
  `Sub-expression for argument ${index + 1} ("${argDefinition?.display_name}") is invalid`
681
1530
  );
682
1531
  }
1532
+
683
1533
  continue;
684
1534
  }
1535
+
685
1536
  const result = validateFieldWithResult(argValue.type, argValue.value, {
686
1537
  expressions,
687
1538
  allowed_values: argDefinition?.allowed_values,
688
1539
  element_allowed_values: argDefinition?.element_allowed_values,
689
1540
  has_to_have_value: argDefinition?.required,
690
- } as any);
1541
+ });
1542
+
691
1543
  if (!result.isValid) {
692
1544
  return withContext(
693
1545
  result,
@@ -695,6 +1547,7 @@ export const _validateField = (
695
1547
  );
696
1548
  }
697
1549
  }
1550
+
698
1551
  return validResult();
699
1552
  }
700
1553
  case 'nothing':
@@ -704,7 +1557,6 @@ export const _validateField = (
704
1557
  }
705
1558
  };
706
1559
 
707
- // Memoized version
708
1560
  /**
709
1561
  * Read one arg out of a hash, whichever of the two legitimate shapes it is in.
710
1562
  *
@@ -724,6 +1576,7 @@ export const _validateField = (
724
1576
  export const readArgValue = (arg: any): any =>
725
1577
  isObject(arg) && 'value' in (arg as object) ? (arg as { value: unknown }).value : arg;
726
1578
 
1579
+ // Memoized version
727
1580
  export const validateFieldWithResult: (
728
1581
  type: string,
729
1582
  value?: any,
@@ -799,7 +1652,10 @@ export const getValueOrDefaultValue = (value: any, defaultValue: any, canBeNull?
799
1652
  return undefined;
800
1653
  };
801
1654
 
802
- export const getTypeFromValue = (value: any): string => {
1655
+ /** Deliberately un-annotated: the inferred literal union is what callers assign
1656
+ * into their own narrower type aliases, and widening it to `string` breaks
1657
+ * them. */
1658
+ export const getTypeFromValue = (value: any) => {
803
1659
  switch (true) {
804
1660
  case isNull(value):
805
1661
  case isUndefined(value):
@@ -832,7 +1688,8 @@ export const validateOptionWithRequiredGroups = (
832
1688
  const optionsInGroups = getOptionsFromRequiredGroups(schema, groups);
833
1689
  return optionsInGroups.some((option) =>
834
1690
  isValueDefined(
835
- (schema[option]?.ui_type as string) || (schema[option]?.type as string),
1691
+ ((schema[option] as TQorusFormFieldSchema)?.ui_type as string) ||
1692
+ ((schema[option] as TQorusFormFieldSchema)?.type as string),
836
1693
  options?.[option]?.value
837
1694
  )
838
1695
  );
@@ -841,6 +1698,179 @@ export const validateOptionWithRequiredGroups = (
841
1698
  return true;
842
1699
  };
843
1700
 
1701
+ /** Why a required option is not satisfied by the current values. */
1702
+ export type TUnresolvedRequiredOptionReason =
1703
+ /** No value at all — neither typed, nor defaulted, nor preselected. */
1704
+ | 'missing'
1705
+ /** The option's dependencies are not fulfilled, so its value cannot be final yet. */
1706
+ | 'dependency'
1707
+ /** A value is present but does not validate against the option's type / schema. */
1708
+ | 'invalid';
1709
+
1710
+ export interface IUnresolvedRequiredOption {
1711
+ /** The option's key in the schema. */
1712
+ name: string;
1713
+ reason: TUnresolvedRequiredOptionReason;
1714
+ /** The failing validation, present only for the `invalid` reason. */
1715
+ validation?: IValidationResult;
1716
+ }
1717
+
1718
+ export const getUnresolvedRequiredOptionReason = (
1719
+ { name, reason }: IUnresolvedRequiredOption,
1720
+ optionSchema?: IQorusFormSchema
1721
+ ): string => {
1722
+ switch (reason) {
1723
+ case 'dependency':
1724
+ return `Option ${name} dependencies are not fulfilled`;
1725
+ case 'invalid':
1726
+ return `Option ${name} is invalid`;
1727
+ default:
1728
+ return (optionSchema?.[name] as TQorusFormFieldSchema)?.required
1729
+ ? `Option ${name} is required`
1730
+ : `Option ${name} requires a value`;
1731
+ }
1732
+ };
1733
+
1734
+ /**
1735
+ * List every required option that the given values do not resolve.
1736
+ *
1737
+ * `required` on an option schema means "the effective configuration must carry
1738
+ * this option" — NOT "the user has to type it in". An option whose schema
1739
+ * default or preselected value already resolves it needs no user input at all,
1740
+ * which is why callers pass values that have been through `fixOptions()`
1741
+ * (defaults, preselected values and rich-text default envelopes applied)
1742
+ * rather than the raw values the server returned.
1743
+ *
1744
+ * This is the single owner of the "which required options still need input"
1745
+ * question: `validateField('options', …)` uses it for form validity, and
1746
+ * qorus-ide's `ConnectionManagementModal` uses it to decide whether a
1747
+ * connection can be created — and its OAuth2 flow started — without showing a
1748
+ * form at all.
1749
+ */
1750
+ export const getUnresolvedRequiredOptions = (
1751
+ optionSchema: IQorusFormSchema = {},
1752
+ options: TQorusForm = {}
1753
+ ): IUnresolvedRequiredOption[] => {
1754
+ const unresolved: IUnresolvedRequiredOption[] = [];
1755
+
1756
+ for (const [name, schemaEntry] of Object.entries(optionSchema ?? {})) {
1757
+ const optionData = schemaEntry as TQorusFormFieldSchema;
1758
+
1759
+ if (!optionData?.required && !size(optionData?.required_groups)) {
1760
+ continue;
1761
+ }
1762
+
1763
+ const optionField = options?.[name];
1764
+ const optionValue =
1765
+ optionField && typeof optionField === 'object' ? optionField.value : optionField;
1766
+ const optionType = ((typeof optionField === 'object' ? optionField?.type : undefined) ||
1767
+ optionData.ui_type ||
1768
+ optionData.type) as string;
1769
+
1770
+ // A dependent option cannot be considered resolved while its dependencies
1771
+ // are not: the schema it will be validated against — and with it any
1772
+ // default the server would send — is not final yet.
1773
+ if (
1774
+ optionData.depends_on &&
1775
+ !hasAllDependenciesFullfilled(optionData.depends_on, options, optionSchema)
1776
+ ) {
1777
+ unresolved.push({ name, reason: 'dependency' });
1778
+ continue;
1779
+ }
1780
+
1781
+ if (!isValueDefined(optionType, optionValue)) {
1782
+ // An option required only through a group is resolved by any sibling in
1783
+ // that group carrying a value.
1784
+ if (
1785
+ optionData.required ||
1786
+ !validateOptionWithRequiredGroups(options, optionSchema, optionData.required_groups)
1787
+ ) {
1788
+ unresolved.push({ name, reason: 'missing' });
1789
+ }
1790
+
1791
+ continue;
1792
+ }
1793
+
1794
+ const validation =
1795
+ typeof optionField !== 'object'
1796
+ ? validateFieldWithResult(getTypeFromValue(optionField), optionField)
1797
+ : validateFieldWithResult(optionField.type, optionField.value, {
1798
+ ...(optionData as any),
1799
+ optionSchema,
1800
+ options,
1801
+ isFunction: optionField.is_expression,
1802
+ });
1803
+
1804
+ if (!validation.isValid) {
1805
+ unresolved.push({ name, reason: 'invalid', validation });
1806
+ }
1807
+ }
1808
+
1809
+ return unresolved;
1810
+ };
1811
+
1812
+ /**
1813
+ * Split a dependency entry into the sibling it names and how its value is compared.
1814
+ *
1815
+ * Three forms: `name` (the sibling must simply have a value), `name=value` (it must
1816
+ * have exactly that value) and `name!=value` (it must have a value, and not that
1817
+ * one). The negative form is what lets a field say it applies to every variant of a
1818
+ * record but one — spelling that as an `any-of` list of every other variant is both
1819
+ * unreadable in the "Depends on" lock and silently wrong the moment a variant is
1820
+ * added.
1821
+ *
1822
+ * `!=` deliberately requires the sibling to be answered: "not X" is a statement about
1823
+ * an answer, so an unanswered sibling does not satisfy it and the field stays locked.
1824
+ */
1825
+ export const parseDependency = (
1826
+ dependency: string
1827
+ ): { name: string; op?: '=' | '!='; value?: string } => {
1828
+ const neqIdx = dependency.indexOf('!=');
1829
+ if (neqIdx !== -1) {
1830
+ return {
1831
+ name: dependency.substring(0, neqIdx),
1832
+ op: '!=',
1833
+ value: dependency.substring(neqIdx + 2),
1834
+ };
1835
+ }
1836
+
1837
+ const eqIdx = dependency.indexOf('=');
1838
+ if (eqIdx !== -1) {
1839
+ return {
1840
+ name: dependency.substring(0, eqIdx),
1841
+ op: '=',
1842
+ value: dependency.substring(eqIdx + 1),
1843
+ };
1844
+ }
1845
+
1846
+ return { name: dependency };
1847
+ };
1848
+
1849
+ /** Whether one dependency entry holds against the current form values. */
1850
+ const isDependencyFulfilled = (
1851
+ dependency: string,
1852
+ options: TQorusForm,
1853
+ optionsSchema?: IQorusFormSchema
1854
+ ): boolean => {
1855
+ const { name, op, value } = parseDependency(dependency);
1856
+
1857
+ if (op) {
1858
+ const optValue = options?.[name]?.value;
1859
+ if (optValue == null) {
1860
+ return false;
1861
+ }
1862
+ return op === '=' ? String(optValue) === value : String(optValue) !== value;
1863
+ }
1864
+
1865
+ return options?.[name]
1866
+ ? validateField(options[name].type, options[name].value, {
1867
+ ...(optionsSchema?.[name] as unknown as IFieldValidationProps),
1868
+ options,
1869
+ optionSchema: optionsSchema,
1870
+ })
1871
+ : true;
1872
+ };
1873
+
844
1874
  export const hasAllDependenciesFullfilled = (
845
1875
  dependencies: string[] | string[][],
846
1876
  options: TQorusForm,
@@ -851,38 +1881,19 @@ export const hasAllDependenciesFullfilled = (
851
1881
  }
852
1882
 
853
1883
  return dependencies.every((dependency: string | string[]) => {
1884
+ // A nested list is an ANY. It used to look each entry up as a whole form-field
1885
+ // name, so a `name=value` entry inside one found no field and returned `true`
1886
+ // unconditionally — the any-of group was satisfied by anything at all, while
1887
+ // CompactRow's lock rendered those same entries as real comparisons. Both halves
1888
+ // now go through one parser, so the grammar cannot mean two things.
854
1889
  if (isArray(dependency)) {
855
- return (dependency as string[]).some((dep) => {
856
- return options?.[dep]
857
- ? validateField(options[dep].type, options[dep].value, {
858
- ...(optionsSchema?.[dep] as unknown as IFieldValidationProps),
859
- options,
860
- optionSchema: optionsSchema,
861
- })
862
- : true;
863
- });
864
- }
865
-
866
- if (isString(dependency)) {
867
- const eqIdx = (dependency as string).indexOf('=');
868
- if (eqIdx !== -1) {
869
- const depName = (dependency as string).substring(0, eqIdx);
870
- const depValue = (dependency as string).substring(eqIdx + 1);
871
- const optValue = options?.[depName]?.value;
872
- return optValue != null && String(optValue) === depValue;
873
- }
1890
+ return (dependency as string[]).some((dep) =>
1891
+ isDependencyFulfilled(dep, options, optionsSchema)
1892
+ );
874
1893
  }
875
1894
 
876
- return options?.[dependency as string]
877
- ? validateField(
878
- options[dependency as string].type,
879
- options[dependency as string].value,
880
- {
881
- ...(optionsSchema?.[dependency as string] as unknown as IFieldValidationProps),
882
- options,
883
- optionSchema: optionsSchema,
884
- }
885
- )
1895
+ return isString(dependency)
1896
+ ? isDependencyFulfilled(dependency as string, options, optionsSchema)
886
1897
  : true;
887
1898
  });
888
1899
  };