@qoretechnologies/reqraft 0.10.16 → 0.10.18

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 (43) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +82 -3
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/form/engine/FormEngine.d.ts +37 -11
  5. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  6. package/dist/components/form/engine/FormEngine.js +187 -116
  7. package/dist/components/form/engine/FormEngine.js.map +1 -1
  8. package/dist/components/form/engine/compactRowContext.d.ts +8 -0
  9. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  10. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  11. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  12. package/dist/components/form/engine/compactRowStyles.js +1 -1
  13. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  14. package/dist/components/form/engine/optionActions.d.ts +28 -0
  15. package/dist/components/form/engine/optionActions.d.ts.map +1 -0
  16. package/dist/components/form/engine/optionActions.js +18 -0
  17. package/dist/components/form/engine/optionActions.js.map +1 -0
  18. package/dist/components/form/engine/readFirst.d.ts +9 -2
  19. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  20. package/dist/components/form/engine/readFirst.js +15 -3
  21. package/dist/components/form/engine/readFirst.js.map +1 -1
  22. package/dist/components/form/engine/rendererTypes.d.ts +35 -0
  23. package/dist/components/form/engine/rendererTypes.d.ts.map +1 -0
  24. package/dist/components/form/engine/rendererTypes.js +63 -0
  25. package/dist/components/form/engine/rendererTypes.js.map +1 -0
  26. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  27. package/dist/components/form/fields/auto/AutoFormField.js +6 -1
  28. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  29. package/dist/components/form/index.d.ts +2 -0
  30. package/dist/components/form/index.d.ts.map +1 -1
  31. package/dist/components/form/index.js +2 -0
  32. package/dist/components/form/index.js.map +1 -1
  33. package/package.json +3 -3
  34. package/src/components/form/engine/CompactRow.tsx +139 -0
  35. package/src/components/form/engine/FormEngine.tsx +229 -70
  36. package/src/components/form/engine/FormEngineRemote.stories.tsx +329 -0
  37. package/src/components/form/engine/compactRowContext.ts +8 -0
  38. package/src/components/form/engine/compactRowStyles.ts +30 -0
  39. package/src/components/form/engine/optionActions.ts +40 -0
  40. package/src/components/form/engine/readFirst.ts +25 -6
  41. package/src/components/form/engine/rendererTypes.ts +55 -0
  42. package/src/components/form/fields/auto/AutoFormField.tsx +6 -1
  43. package/src/components/form/index.tsx +2 -0
@@ -15,10 +15,7 @@ import {
15
15
  } from '@qoretechnologies/reqore';
16
16
  import { IReqoreCollectionProps } from '@qoretechnologies/reqore/dist/components/Collection';
17
17
  import { IReqoreCollectionItemProps } from '@qoretechnologies/reqore/dist/components/Collection/item';
18
- import {
19
- IReqorePanelAction,
20
- IReqorePanelProps,
21
- } from '@qoretechnologies/reqore/dist/components/Panel';
18
+ import { IReqorePanelProps } from '@qoretechnologies/reqore/dist/components/Panel';
22
19
  import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
23
20
  import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme';
24
21
  import {
@@ -41,6 +38,8 @@ import {
41
38
  TQorusFormOperatorValue,
42
39
  TQorusType,
43
40
  } from '@qoretechnologies/ts-toolkit';
41
+ import { resolveOptionActions, TOptionActions } from './optionActions';
42
+ import { createRendererOnlyUiTypeCheck, isRendererOnlyUiType } from './rendererTypes';
44
43
  import { cloneDeep, findKey, flatten, forEach, isEqual, isPlainObject, last } from 'lodash';
45
44
  import isArray from 'lodash/isArray';
46
45
  import map from 'lodash/map';
@@ -245,12 +244,16 @@ const StyledCommitDock = styled.div<{ $bg: string; $border: string }>`
245
244
  `;
246
245
 
247
246
  export const getType = (
248
- type: TQorusType | TQorusType[],
247
+ type?: TQorusType | TQorusType[],
249
248
  operators?: IOperatorsSchema,
250
249
  operator?: TOperatorValue
251
250
  ): TQorusType => {
252
251
  const finalType = getTypeFromOperator(operators, fixOperatorValue(operator)) || type;
253
- return (isArray(finalType) ? finalType[0] : finalType) as TQorusType;
252
+ const resolvedType = isArray(finalType) ? finalType[0] : finalType;
253
+
254
+ // A value can be received before its server-driven option schema. Keep the
255
+ // renderer type-safe during that transition without guessing a concrete type.
256
+ return (resolvedType || 'any') as TQorusType;
254
257
  };
255
258
 
256
259
  const getTypeFromOperator = (
@@ -267,6 +270,38 @@ export const fixOperatorValue = (operator: TOperatorValue): (string | null | und
267
270
  return isArray(operator) ? operator : [operator];
268
271
  };
269
272
 
273
+ /**
274
+ * The renderer-only predicate. Defaults to reqraft's built-ins; a FormEngine
275
+ * instance passes its own (built-ins + the `rendererOnlyUiTypes` prop) so a
276
+ * consumer's injected editors are recognised too. See `./rendererTypes`.
277
+ */
278
+ type TRendererOnlyCheck = (type?: TQorusType | TQorusType[]) => boolean;
279
+
280
+ const getOptionSchemaStorageType = (
281
+ option?: TQorusFormFieldSchema,
282
+ isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
283
+ ): TQorusType =>
284
+ ((isRendererOnly(option?.ui_type)
285
+ ? option?.type || option?.ui_type
286
+ : option?.ui_type || option?.type) || 'any') as TQorusType;
287
+
288
+ const getOptionFieldStorageType = (
289
+ optionName: string,
290
+ fieldType: TQorusType | TQorusType[] | undefined,
291
+ schema?: IQorusFormSchema,
292
+ operators?: IOperatorsSchema,
293
+ operatorData?: TOperatorValue,
294
+ isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
295
+ ): TQorusType => {
296
+ const schemaOption = schema?.[optionName];
297
+ const storedType =
298
+ fieldType && !(fieldType === schemaOption?.ui_type && isRendererOnly(fieldType))
299
+ ? fieldType
300
+ : getOptionSchemaStorageType(schemaOption, isRendererOnly);
301
+
302
+ return getType(storedType as TQorusType, operators, operatorData);
303
+ };
304
+
270
305
  export const hasRequiredOptions = (options: IQorusFormSchema = {}) => {
271
306
  return !!findKey(options, (option) => option.required);
272
307
  };
@@ -278,11 +313,19 @@ export const OptionsContext = createContext<{
278
313
 
279
314
  export const fixOptions = (
280
315
  value: TQorusForm | TQorusFlatForm = {},
281
- options: IQorusFormSchema,
282
- operators?: IOperatorsSchema
316
+ options?: IQorusFormSchema,
317
+ operators?: IOperatorsSchema,
318
+ isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
283
319
  ): TQorusForm => {
284
320
  const fixedValue = cloneDeep(value);
285
321
 
322
+ // Server-driven schemas can arrive after the persisted value. Preserve that
323
+ // value verbatim until its schema is available instead of manufacturing
324
+ // partially typed form fields that can crash the following render.
325
+ if (!size(options)) {
326
+ return fixedValue as TQorusForm;
327
+ }
328
+
286
329
  forEach(options, (option, name) => {
287
330
  if (
288
331
  option.value ||
@@ -292,7 +335,7 @@ export const fixOptions = (
292
335
  ) {
293
336
  let obj: IQorusFormField;
294
337
  const type = getType(
295
- (option.ui_type || option.type) as TQorusType,
338
+ getOptionSchemaStorageType(option, isRendererOnly),
296
339
  operators,
297
340
  (fixedValue[name] as IQorusFormField)?.op
298
341
  );
@@ -328,20 +371,35 @@ export const fixOptions = (
328
371
  let newOption = option as IQorusFormField;
329
372
 
330
373
  if (!isPlainObject(newOption) || !(newOption as IQorusFormField)?.type) {
374
+ const isUntypedEnvelope = isPlainObject(newOption) && 'value' in newOption;
375
+ const untypedFieldValue =
376
+ isUntypedEnvelope ? (newOption as IQorusFormField).value : newOption;
377
+ // Spread the original envelope first so every key it carried survives —
378
+ // rebuilding from just {type, value} silently dropped `op`, `is_expression`
379
+ // and anything else a field needs to render, which emptied the card.
331
380
  const fixedOption: IQorusFormField = {
381
+ ...(isPlainObject(newOption) ? (newOption as IQorusFormField) : {}),
332
382
  type: getType(
333
- (options?.[optionName]?.ui_type || options?.[optionName]?.type) as TQorusType,
383
+ getOptionSchemaStorageType(options?.[optionName], isRendererOnly),
334
384
  operators,
335
385
  (newOption as IQorusFormField)?.op
336
386
  ),
337
- value: newOption,
387
+ value: untypedFieldValue,
338
388
  };
339
389
 
340
- if ((newOption as IQorusFormField)?.is_expression) {
341
- fixedOption.is_expression = true;
342
- }
343
-
344
390
  newOption = fixedOption;
391
+ } else if (
392
+ newOption.type === options?.[optionName]?.ui_type &&
393
+ isRendererOnly(newOption.type)
394
+ ) {
395
+ newOption = {
396
+ ...newOption,
397
+ type: getType(
398
+ getOptionSchemaStorageType(options?.[optionName], isRendererOnly),
399
+ operators,
400
+ newOption.op
401
+ ),
402
+ };
345
403
  }
346
404
 
347
405
  if (
@@ -411,7 +469,7 @@ export const flattenOptions = (options: TQorusForm): TQorusFlatForm => {
411
469
  };
412
470
 
413
471
  export const getTypeAndCanBeNull = (
414
- type: TQorusType | TQorusType[],
472
+ type?: TQorusType | TQorusType[],
415
473
  allowed_values?: any[],
416
474
  operatorData?: TOperatorValue,
417
475
  operators?: IOperatorsSchema
@@ -546,19 +604,36 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
546
604
  * option's name/schema/value (the context the IDE's `AiAssistanceAction`
547
605
  * captures); the consumer injects the button, reqraft stays AI-free.
548
606
  */
549
- optionActions?:
550
- | IReqorePanelAction[]
551
- | ((context: {
552
- name: string;
553
- schema: IQorusFormSchema[string];
554
- value?: TOption;
555
- }) => IReqorePanelAction[]);
607
+ optionActions?: TOptionActions;
608
+ /**
609
+ * Whether per-option injected actions collapse into the row's overflow menu
610
+ * instead of rendering as inline buttons.
611
+ *
612
+ * `'auto'` (default) collapses when the device cannot hover or the viewport is
613
+ * narrow — on a phone a hover-gated action would otherwise be unreachable, and
614
+ * a row has no width for a button strip. `'always'` / `'never'` force it, which
615
+ * consumers can use for a known-mobile surface and stories use to capture the
616
+ * collapsed state deterministically.
617
+ *
618
+ * Actions beyond `MAX_INLINE_OPTION_ACTIONS` always overflow into the menu, so
619
+ * a consumer injecting many actions can never blow out the row.
620
+ */
621
+ optionActionsCollapse?: 'auto' | 'always' | 'never';
556
622
  /**
557
623
  * SEAM (reqraft): consumer-injected field editors for types reqraft doesn't
558
624
  * ship (IDE domain fields). Keyed by field `type`/`ui_type`; forwarded through
559
625
  * `TemplateField` to the `AutoFormField` override seam.
560
626
  */
561
627
  componentOverrides?: Record<string, React.FC<any>>;
628
+ /**
629
+ * The `ui_type` names among `componentOverrides` that select a bespoke editor
630
+ * but store their value as the schema's plainer `type` (a `cron` editor stores
631
+ * a string). Declaring them here keeps the field's stored `type` correct —
632
+ * without it the renderer name is written into the value and validation and
633
+ * round-tripping both break. Merged with reqraft's own built-in list, so a
634
+ * consumer's new editor no longer needs a reqraft release to be handled.
635
+ */
636
+ rendererOnlyUiTypes?: string[];
562
637
 
563
638
  /**
564
639
  * Bag of values forwarded from an outer FormEngine scope, used as a
@@ -644,12 +719,35 @@ export const FormEngine = ({
644
719
  optionsLoader,
645
720
  onValidityChange,
646
721
  optionActions,
722
+ optionActionsCollapse = 'auto',
647
723
  componentOverrides,
724
+ rendererOnlyUiTypes,
648
725
  inheritedFromParent,
649
726
  autoFocusFirstRequired,
650
727
  initialExpandedOptions,
651
728
  ...rest
652
729
  }: IFormEngineProps) => {
730
+ // Built-ins + whatever the consumer declared for its own injected editors.
731
+ const isRendererOnly = useMemo(
732
+ () => createRendererOnlyUiTypeCheck(rendererOnlyUiTypes),
733
+ [JSON.stringify(rendererOnlyUiTypes)]
734
+ );
735
+ // Pointer CAPABILITY and viewport WIDTH are different questions and both
736
+ // matter here: a hover-gated action is unreachable without a hover, and a
737
+ // phone-width row has no space for a button strip either way. Both now come
738
+ // from Reqore's context (one subscription for the whole app) rather than a
739
+ // reqraft-local matchMedia hook.
740
+ //
741
+ // `isHoverCapable` defaults to `true` in Reqore, but fall back explicitly so
742
+ // an older Reqore — where the property does not exist — degrades to "this
743
+ // pointer hovers" (the pre-existing behaviour) instead of collapsing every
744
+ // action into a menu for everyone.
745
+ const isHoverCapable = useReqoreProperty('isHoverCapable') ?? true;
746
+ const isMobile = useReqoreProperty('isMobile');
747
+ const collapseOptionActions =
748
+ optionActionsCollapse === 'always' ? true
749
+ : optionActionsCollapse === 'never' ? false
750
+ : !isHoverCapable || !!isMobile;
653
751
  const [options, setOptions] = useState<IQorusFormSchema | undefined>(rest?.options || undefined);
654
752
  // optionsLoader lifecycle: loading feeds the skeleton gate, error the banner.
655
753
  const [optionsLoading, setOptionsLoading] = useState<boolean>(!!optionsLoader && !rest?.options);
@@ -785,7 +883,7 @@ export const FormEngine = ({
785
883
  fields: TQorusForm | TQorusFlatForm;
786
884
  meta?: IOptionsOnChangeMeta;
787
885
  }>(() => ({
788
- fields: fixOptions(value, options || {}),
886
+ fields: fixOptions(value, options || {}, undefined, isRendererOnly),
789
887
  meta: undefined,
790
888
  }));
791
889
  const originalValue = useRef<any>();
@@ -878,7 +976,7 @@ export const FormEngine = ({
878
976
  setOptions({});
879
977
  return;
880
978
  }
881
- setLocalValue({ fields: fixOptions(value, data.data), meta: undefined });
979
+ setLocalValue({ fields: fixOptions(value, data.data, undefined, isRendererOnly), meta: undefined });
882
980
  if (!operatorsUrl) {
883
981
  setLoading(false);
884
982
  }
@@ -923,7 +1021,7 @@ export const FormEngine = ({
923
1021
  }
924
1022
  setOptions(data.data);
925
1023
  onOptionsLoaded?.(data.data);
926
- setLocalValue({ fields: fixOptions({}, data.data), meta: undefined });
1024
+ setLocalValue({ fields: fixOptions({}, data.data, undefined, isRendererOnly), meta: undefined });
927
1025
  })();
928
1026
  }
929
1027
  }, [url, customUrl]);
@@ -947,7 +1045,7 @@ export const FormEngine = ({
947
1045
  }, [operatorsUrl]);
948
1046
 
949
1047
  useUpdateEffect(() => {
950
- const fixedValue = fixOptions(value, options || {});
1048
+ const fixedValue = fixOptions(value, options || {}, undefined, isRendererOnly);
951
1049
 
952
1050
  // When the value we're receiving is the one we just emitted AND fixOptions has nothing to
953
1051
  // meaningfully add or change, skip the update. This breaks the controlled-component loop for
@@ -971,19 +1069,21 @@ export const FormEngine = ({
971
1069
  }
972
1070
 
973
1071
  setLocalValue?.({ fields: fixedValue, meta: undefined });
974
- }, [JSON.stringify(options), JSON.stringify(value)]);
1072
+ }, [JSON.stringify(options), JSON.stringify(value), isRendererOnly]);
975
1073
 
976
1074
  const handleValueChange = useCallback(
977
1075
  (optionName: string, val?: any, _type?: string, isFunction?: boolean) => {
978
1076
  setLocalValue(({ fields = {} }) => {
979
- const schemaType = (options?.[optionName]?.ui_type ||
980
- options?.[optionName]?.type) as TQorusType;
1077
+ const schemaType = getOptionSchemaStorageType(options?.[optionName], isRendererOnly);
981
1078
  const isAnyLike = schemaType === 'any' || schemaType === 'auto';
982
1079
  // For any/auto schema types, preserve the user's chosen type stored in the field
983
1080
  const resolvedSchemaType =
984
- isAnyLike && (fields[optionName] as IQorusFormField)?.type ?
985
- ((fields[optionName] as IQorusFormField).type as TQorusType)
986
- : schemaType || ((fields[optionName] as IQorusFormField)?.type as TQorusType);
1081
+ isAnyLike && (fields[optionName] as IQorusFormField)?.type
1082
+ ? ((fields[optionName] as IQorusFormField).type as TQorusType)
1083
+ : (fields[optionName] as IQorusFormField)?.type === options?.[optionName]?.ui_type &&
1084
+ isRendererOnly((fields[optionName] as IQorusFormField)?.type)
1085
+ ? schemaType
1086
+ : schemaType || ((fields[optionName] as IQorusFormField)?.type as TQorusType);
987
1087
  const type =
988
1088
  _type ||
989
1089
  getTypeAndCanBeNull(resolvedSchemaType, options?.[optionName]?.allowed_values).type;
@@ -1066,6 +1166,7 @@ export const FormEngine = ({
1066
1166
  [
1067
1167
  onSingleOptionsChange,
1068
1168
  onDependableOptionChange,
1169
+ isRendererOnly,
1069
1170
  JSON.stringify(options),
1070
1171
  JSON.stringify(operators),
1071
1172
  ]
@@ -1186,8 +1287,7 @@ export const FormEngine = ({
1186
1287
  optionName as string,
1187
1288
  getDefaultValue(options?.[optionName as string]),
1188
1289
  getTypeAndCanBeNull(
1189
- (options?.[optionName as string]?.ui_type ||
1190
- options?.[optionName as string]?.type) as TQorusType,
1290
+ getOptionSchemaStorageType(options?.[optionName as string], isRendererOnly),
1191
1291
  options?.[optionName as string]?.allowed_values
1192
1292
  ).type
1193
1293
  );
@@ -1215,36 +1315,37 @@ export const FormEngine = ({
1215
1315
  return newValue;
1216
1316
  }
1217
1317
 
1218
- const schemaType = getType(
1318
+ const rendererType = getType(
1219
1319
  (options[optionName].ui_type || options[optionName].type) as TQorusType,
1220
1320
  operators,
1221
1321
  (option as IQorusFormField)?.op
1222
1322
  );
1323
+ const storageType = getOptionFieldStorageType(
1324
+ optionName,
1325
+ (option as IQorusFormField)?.type,
1326
+ options,
1327
+ operators,
1328
+ (option as IQorusFormField)?.op,
1329
+ isRendererOnly
1330
+ );
1223
1331
 
1224
1332
  if (!isPlainObject(option)) {
1225
1333
  return {
1226
1334
  ...newValue,
1227
1335
  [optionName]: {
1228
- type: schemaType,
1336
+ type: storageType || rendererType,
1229
1337
  value: option,
1230
1338
  },
1231
1339
  };
1232
1340
  }
1233
1341
 
1234
- // any/auto: preserve the user-picked type; otherwise normalize to the
1235
- // schema type (ui_type wins) so rendering and validation agree.
1236
- const isAnyLike = schemaType === 'any' || schemaType === 'auto';
1237
- const effectiveType =
1238
- isAnyLike && (option as IQorusFormField)?.type ?
1239
- (option as IQorusFormField).type
1240
- : schemaType;
1241
-
1242
1342
  return {
1243
1343
  ...newValue,
1244
- [optionName]: { ...(option as IQorusFormField), type: effectiveType },
1344
+ [optionName]: { ...(option as IQorusFormField), type: storageType || rendererType },
1245
1345
  };
1246
1346
  }, {});
1247
1347
  }, [
1348
+ isRendererOnly,
1248
1349
  JSON.stringify(fixedValue),
1249
1350
  JSON.stringify(options),
1250
1351
  unavailableOptionsCount.current,
@@ -1341,10 +1442,14 @@ export const FormEngine = ({
1341
1442
  const fields: IFormFieldValidityData[] = reduce(
1342
1443
  availableOptions,
1343
1444
  (result, option, optionName) => {
1344
- const type =
1345
- (option as IQorusFormField).type ||
1346
- (options?.[optionName]?.ui_type as TQorusType) ||
1347
- (options?.[optionName]?.type as TQorusType);
1445
+ const type = getOptionFieldStorageType(
1446
+ optionName,
1447
+ (option as IQorusFormField).type,
1448
+ options,
1449
+ operators,
1450
+ (option as IQorusFormField).op,
1451
+ isRendererOnly
1452
+ );
1348
1453
  const optionValue = (option as IQorusFormField).value;
1349
1454
 
1350
1455
  const isRequired = options?.[optionName]?.required;
@@ -1386,8 +1491,10 @@ export const FormEngine = ({
1386
1491
  invalidFields,
1387
1492
  };
1388
1493
  }, [
1494
+ isRendererOnly,
1389
1495
  JSON.stringify(availableOptions),
1390
1496
  JSON.stringify(options),
1497
+ JSON.stringify(operators),
1391
1498
  JSON.stringify(localValue.fields),
1392
1499
  ]);
1393
1500
 
@@ -1406,8 +1513,14 @@ export const FormEngine = ({
1406
1513
  showInvalidOptionsOnly &&
1407
1514
  isOptionValid(
1408
1515
  optionName,
1409
- (options?.[optionName]?.ui_type as TQorusType) ||
1410
- (options?.[optionName]?.type as TQorusType),
1516
+ getOptionFieldStorageType(
1517
+ optionName,
1518
+ (option as IQorusFormField).type,
1519
+ options,
1520
+ operators,
1521
+ (option as IQorusFormField).op,
1522
+ isRendererOnly
1523
+ ),
1411
1524
  (option as IQorusFormField).value
1412
1525
  )
1413
1526
  ) {
@@ -1417,7 +1530,13 @@ export const FormEngine = ({
1417
1530
  },
1418
1531
  {}
1419
1532
  );
1420
- }, [showInvalidOptionsOnly, JSON.stringify(availableOptions)]);
1533
+ }, [
1534
+ showInvalidOptionsOnly,
1535
+ isRendererOnly,
1536
+ JSON.stringify(availableOptions),
1537
+ JSON.stringify(options),
1538
+ JSON.stringify(operators),
1539
+ ]);
1421
1540
 
1422
1541
  // Read-first STATUS / BOX for one option — lifted to component scope so the
1423
1542
  // status boxes (renderCompact) and the header's "needs attention" count share
@@ -1437,7 +1556,14 @@ export const FormEngine = ({
1437
1556
  (name: string, hidden = false): TReadFirstStatus => {
1438
1557
  if (hidden) return 'optional';
1439
1558
  const schema = options?.[name];
1440
- const type = (schema?.ui_type || schema?.type) as TQorusType;
1559
+ const type = getOptionFieldStorageType(
1560
+ name,
1561
+ (availableOptions as TQorusForm)?.[name]?.type,
1562
+ options,
1563
+ operators,
1564
+ (availableOptions as TQorusForm)?.[name]?.op,
1565
+ isRendererOnly
1566
+ );
1441
1567
  const value = (availableOptions as TQorusForm)?.[name]?.value;
1442
1568
  const empty = isOptionValueEmpty(value);
1443
1569
  const reqGroups = (schema?.required_groups as string[] | undefined) || [];
@@ -1459,8 +1585,10 @@ export const FormEngine = ({
1459
1585
  });
1460
1586
  },
1461
1587
  [
1588
+ isRendererOnly,
1462
1589
  JSON.stringify(options),
1463
1590
  JSON.stringify(availableOptions),
1591
+ JSON.stringify(operators),
1464
1592
  isOptionValid,
1465
1593
  requiredGroupsInfo,
1466
1594
  schemaMsgIntent,
@@ -1726,10 +1854,10 @@ export const FormEngine = ({
1726
1854
  next[optionName] = value as IQorusFormField;
1727
1855
  }
1728
1856
  });
1729
- return { fields: fixOptions(next, options || {}, operators), meta: undefined };
1857
+ return { fields: fixOptions(next, options || {}, operators, isRendererOnly), meta: undefined };
1730
1858
  });
1731
1859
  setRequiredOnly(false);
1732
- }, [JSON.stringify(options), JSON.stringify(operators)]);
1860
+ }, [JSON.stringify(options), JSON.stringify(operators), isRendererOnly]);
1733
1861
 
1734
1862
  const getCustomMenuTemplateItems = useCallback<(optionName: string) => TCustomTemplateItems>(
1735
1863
  (optionName) => {
@@ -1759,6 +1887,25 @@ export const FormEngine = ({
1759
1887
  suppressSchemaMessages?: boolean
1760
1888
  ) => {
1761
1889
  const operatorParts = fixOperatorValue(other.op);
1890
+ // The RENDERER type for this row — which editor mounts. Distinct from the
1891
+ // storage type on the value envelope: a `richtext` field stores a string
1892
+ // but must render the richtext editor, so `ui_type` wins here.
1893
+ //
1894
+ // The exception is an any-like `ui_type`. Those schemas say
1895
+ // `ui_type: 'any'` precisely so the user can pick a concrete type, and
1896
+ // that pick lives on the field's stored `type` — letting 'any' win would
1897
+ // pin the row to the untyped editor forever.
1898
+ //
1899
+ // The trailing fallbacks keep the row rendering when a server-driven
1900
+ // schema has not arrived yet (`type` undefined) instead of crashing.
1901
+ const schemaUiType = options?.[optionName]?.ui_type as TQorusType;
1902
+ const uiTypeIsAnyLike = schemaUiType === 'any' || schemaUiType === 'auto';
1903
+ const resolvedType =
1904
+ (schemaUiType && !uiTypeIsAnyLike ? schemaUiType : undefined) ||
1905
+ type ||
1906
+ schemaUiType ||
1907
+ (options?.[optionName]?.type as TQorusType) ||
1908
+ 'any';
1762
1909
  return (
1763
1910
  <>
1764
1911
  {(() => {
@@ -1900,15 +2047,18 @@ export const FormEngine = ({
1900
2047
  // (DPQL text mode); opt out per-form via `templateFieldProps`.
1901
2048
  allowTextExpressions
1902
2049
  allowCustomValues={
1903
- options?.[optionName]?.supports_custom_values !== false && type !== 'any'
2050
+ options?.[optionName]?.supports_custom_values !== false && resolvedType !== 'any'
1904
2051
  }
1905
2052
  templates={templates.value}
1906
2053
  {...getTypeAndCanBeNull(
1907
- type as TQorusType,
2054
+ // The RENDERER type picks the editor — the storage type lives on
2055
+ // the value envelope. Passing storage here rendered a `richtext`
2056
+ // field as a plain string input.
2057
+ resolvedType,
1908
2058
  options?.[optionName]?.allowed_values,
1909
2059
  other.op
1910
2060
  )}
1911
- ui_type={type}
2061
+ ui_type={resolvedType}
1912
2062
  name={optionName}
1913
2063
  uniqueName={`${uniqueName ? `${uniqueName}.` : `${name ? `${name}.` : ''}`}${optionName}`}
1914
2064
  onChange={
@@ -1949,7 +2099,7 @@ export const FormEngine = ({
1949
2099
  schema={options || {}}
1950
2100
  allOptions={availableOptions}
1951
2101
  name={optionName}
1952
- option={{ type, ...other }}
2102
+ option={{ type: resolvedType, ...other }}
1953
2103
  getType={getTypeForOption}
1954
2104
  />
1955
2105
  {operators && size(operators) && size(other.op) ?
@@ -1964,7 +2114,7 @@ export const FormEngine = ({
1964
2114
  intent='info'
1965
2115
  label={
1966
2116
  other.value ?
1967
- type === 'richtext' ?
2117
+ resolvedType === 'richtext' ?
1968
2118
  richtextToString(other.value)
1969
2119
  : JSON.stringify(other.value)
1970
2120
  : ''
@@ -2057,6 +2207,8 @@ export const FormEngine = ({
2057
2207
  getTypeForOption,
2058
2208
  isOptionValid,
2059
2209
  confirmAction,
2210
+ optionActions,
2211
+ collapseOptionActions,
2060
2212
  renderOption,
2061
2213
  theme,
2062
2214
  cText,
@@ -2102,6 +2254,8 @@ export const FormEngine = ({
2102
2254
  getTypeForOption,
2103
2255
  isOptionValid,
2104
2256
  confirmAction,
2257
+ optionActions,
2258
+ collapseOptionActions,
2105
2259
  renderOption,
2106
2260
  theme,
2107
2261
  cText,
@@ -2238,7 +2392,14 @@ export const FormEngine = ({
2238
2392
  const isFieldInvalid = (name: string) =>
2239
2393
  !isOptionValid(
2240
2394
  name,
2241
- (options?.[name]?.ui_type || options?.[name]?.type) as TQorusType,
2395
+ getOptionFieldStorageType(
2396
+ name,
2397
+ (shownOptions as TQorusForm)[name]?.type,
2398
+ options,
2399
+ operators,
2400
+ (shownOptions as TQorusForm)[name]?.op,
2401
+ isRendererOnly
2402
+ ),
2242
2403
  (shownOptions as TQorusForm)[name]?.value
2243
2404
  );
2244
2405
  const comparator = (a: { name: string }, b: { name: string }): number => {
@@ -2347,7 +2508,7 @@ export const FormEngine = ({
2347
2508
  optionField={
2348
2509
  entry.hidden ?
2349
2510
  ({
2350
- type: (options?.[entry.name]?.ui_type || options?.[entry.name]?.type) as TQorusType,
2511
+ type: getOptionSchemaStorageType(options?.[entry.name], isRendererOnly),
2351
2512
  value: undefined,
2352
2513
  } as IQorusFormField)
2353
2514
  : ((shownOptions as TQorusForm)[entry.name] as IQorusFormField)
@@ -2808,13 +2969,11 @@ export const FormEngine = ({
2808
2969
  // option's schema as context). The consumer (the IDE) injects
2809
2970
  // it; same factory pattern as the ExpressionBuilder's
2810
2971
  // `extraActions`.
2811
- ...(typeof optionActions === 'function' ?
2812
- optionActions({
2813
- name: optionName,
2814
- schema: options[optionName],
2815
- value: availableOptions?.[optionName] as TOption,
2816
- })
2817
- : (optionActions ?? [])),
2972
+ ...resolveOptionActions(optionActions, {
2973
+ name: optionName,
2974
+ schema: options[optionName],
2975
+ value: availableOptions?.[optionName] as TOption,
2976
+ }),
2818
2977
  {
2819
2978
  size: 'tiny',
2820
2979
  icon: 'FullscreenLine',