@qoretechnologies/reqraft 0.10.16 → 0.10.17

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 (53) 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 +174 -110
  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/dist/hooks/useMediaQuery.d.ts +24 -0
  34. package/dist/hooks/useMediaQuery.d.ts.map +1 -0
  35. package/dist/hooks/useMediaQuery.js +56 -0
  36. package/dist/hooks/useMediaQuery.js.map +1 -0
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +7 -1
  40. package/dist/index.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/components/form/engine/CompactRow.tsx +139 -0
  43. package/src/components/form/engine/FormEngine.tsx +222 -70
  44. package/src/components/form/engine/FormEngineRemote.stories.tsx +329 -0
  45. package/src/components/form/engine/compactRowContext.ts +8 -0
  46. package/src/components/form/engine/compactRowStyles.ts +30 -0
  47. package/src/components/form/engine/optionActions.ts +40 -0
  48. package/src/components/form/engine/readFirst.ts +25 -6
  49. package/src/components/form/engine/rendererTypes.ts +55 -0
  50. package/src/components/form/fields/auto/AutoFormField.tsx +6 -1
  51. package/src/components/form/index.tsx +2 -0
  52. package/src/hooks/useMediaQuery.ts +61 -0
  53. package/src/index.tsx +7 -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,9 @@ 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';
43
+ import { useCanHover, useIsNarrowViewport } from '../../../hooks/useMediaQuery';
44
44
  import { cloneDeep, findKey, flatten, forEach, isEqual, isPlainObject, last } from 'lodash';
45
45
  import isArray from 'lodash/isArray';
46
46
  import map from 'lodash/map';
@@ -245,12 +245,16 @@ const StyledCommitDock = styled.div<{ $bg: string; $border: string }>`
245
245
  `;
246
246
 
247
247
  export const getType = (
248
- type: TQorusType | TQorusType[],
248
+ type?: TQorusType | TQorusType[],
249
249
  operators?: IOperatorsSchema,
250
250
  operator?: TOperatorValue
251
251
  ): TQorusType => {
252
252
  const finalType = getTypeFromOperator(operators, fixOperatorValue(operator)) || type;
253
- return (isArray(finalType) ? finalType[0] : finalType) as TQorusType;
253
+ const resolvedType = isArray(finalType) ? finalType[0] : finalType;
254
+
255
+ // A value can be received before its server-driven option schema. Keep the
256
+ // renderer type-safe during that transition without guessing a concrete type.
257
+ return (resolvedType || 'any') as TQorusType;
254
258
  };
255
259
 
256
260
  const getTypeFromOperator = (
@@ -267,6 +271,38 @@ export const fixOperatorValue = (operator: TOperatorValue): (string | null | und
267
271
  return isArray(operator) ? operator : [operator];
268
272
  };
269
273
 
274
+ /**
275
+ * The renderer-only predicate. Defaults to reqraft's built-ins; a FormEngine
276
+ * instance passes its own (built-ins + the `rendererOnlyUiTypes` prop) so a
277
+ * consumer's injected editors are recognised too. See `./rendererTypes`.
278
+ */
279
+ type TRendererOnlyCheck = (type?: TQorusType | TQorusType[]) => boolean;
280
+
281
+ const getOptionSchemaStorageType = (
282
+ option?: TQorusFormFieldSchema,
283
+ isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
284
+ ): TQorusType =>
285
+ ((isRendererOnly(option?.ui_type)
286
+ ? option?.type || option?.ui_type
287
+ : option?.ui_type || option?.type) || 'any') as TQorusType;
288
+
289
+ const getOptionFieldStorageType = (
290
+ optionName: string,
291
+ fieldType: TQorusType | TQorusType[] | undefined,
292
+ schema?: IQorusFormSchema,
293
+ operators?: IOperatorsSchema,
294
+ operatorData?: TOperatorValue,
295
+ isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
296
+ ): TQorusType => {
297
+ const schemaOption = schema?.[optionName];
298
+ const storedType =
299
+ fieldType && !(fieldType === schemaOption?.ui_type && isRendererOnly(fieldType))
300
+ ? fieldType
301
+ : getOptionSchemaStorageType(schemaOption, isRendererOnly);
302
+
303
+ return getType(storedType as TQorusType, operators, operatorData);
304
+ };
305
+
270
306
  export const hasRequiredOptions = (options: IQorusFormSchema = {}) => {
271
307
  return !!findKey(options, (option) => option.required);
272
308
  };
@@ -278,11 +314,19 @@ export const OptionsContext = createContext<{
278
314
 
279
315
  export const fixOptions = (
280
316
  value: TQorusForm | TQorusFlatForm = {},
281
- options: IQorusFormSchema,
282
- operators?: IOperatorsSchema
317
+ options?: IQorusFormSchema,
318
+ operators?: IOperatorsSchema,
319
+ isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
283
320
  ): TQorusForm => {
284
321
  const fixedValue = cloneDeep(value);
285
322
 
323
+ // Server-driven schemas can arrive after the persisted value. Preserve that
324
+ // value verbatim until its schema is available instead of manufacturing
325
+ // partially typed form fields that can crash the following render.
326
+ if (!size(options)) {
327
+ return fixedValue as TQorusForm;
328
+ }
329
+
286
330
  forEach(options, (option, name) => {
287
331
  if (
288
332
  option.value ||
@@ -292,7 +336,7 @@ export const fixOptions = (
292
336
  ) {
293
337
  let obj: IQorusFormField;
294
338
  const type = getType(
295
- (option.ui_type || option.type) as TQorusType,
339
+ getOptionSchemaStorageType(option, isRendererOnly),
296
340
  operators,
297
341
  (fixedValue[name] as IQorusFormField)?.op
298
342
  );
@@ -328,20 +372,35 @@ export const fixOptions = (
328
372
  let newOption = option as IQorusFormField;
329
373
 
330
374
  if (!isPlainObject(newOption) || !(newOption as IQorusFormField)?.type) {
375
+ const isUntypedEnvelope = isPlainObject(newOption) && 'value' in newOption;
376
+ const untypedFieldValue =
377
+ isUntypedEnvelope ? (newOption as IQorusFormField).value : newOption;
378
+ // Spread the original envelope first so every key it carried survives —
379
+ // rebuilding from just {type, value} silently dropped `op`, `is_expression`
380
+ // and anything else a field needs to render, which emptied the card.
331
381
  const fixedOption: IQorusFormField = {
382
+ ...(isPlainObject(newOption) ? (newOption as IQorusFormField) : {}),
332
383
  type: getType(
333
- (options?.[optionName]?.ui_type || options?.[optionName]?.type) as TQorusType,
384
+ getOptionSchemaStorageType(options?.[optionName], isRendererOnly),
334
385
  operators,
335
386
  (newOption as IQorusFormField)?.op
336
387
  ),
337
- value: newOption,
388
+ value: untypedFieldValue,
338
389
  };
339
390
 
340
- if ((newOption as IQorusFormField)?.is_expression) {
341
- fixedOption.is_expression = true;
342
- }
343
-
344
391
  newOption = fixedOption;
392
+ } else if (
393
+ newOption.type === options?.[optionName]?.ui_type &&
394
+ isRendererOnly(newOption.type)
395
+ ) {
396
+ newOption = {
397
+ ...newOption,
398
+ type: getType(
399
+ getOptionSchemaStorageType(options?.[optionName], isRendererOnly),
400
+ operators,
401
+ newOption.op
402
+ ),
403
+ };
345
404
  }
346
405
 
347
406
  if (
@@ -411,7 +470,7 @@ export const flattenOptions = (options: TQorusForm): TQorusFlatForm => {
411
470
  };
412
471
 
413
472
  export const getTypeAndCanBeNull = (
414
- type: TQorusType | TQorusType[],
473
+ type?: TQorusType | TQorusType[],
415
474
  allowed_values?: any[],
416
475
  operatorData?: TOperatorValue,
417
476
  operators?: IOperatorsSchema
@@ -546,19 +605,36 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
546
605
  * option's name/schema/value (the context the IDE's `AiAssistanceAction`
547
606
  * captures); the consumer injects the button, reqraft stays AI-free.
548
607
  */
549
- optionActions?:
550
- | IReqorePanelAction[]
551
- | ((context: {
552
- name: string;
553
- schema: IQorusFormSchema[string];
554
- value?: TOption;
555
- }) => IReqorePanelAction[]);
608
+ optionActions?: TOptionActions;
609
+ /**
610
+ * Whether per-option injected actions collapse into the row's overflow menu
611
+ * instead of rendering as inline buttons.
612
+ *
613
+ * `'auto'` (default) collapses when the device cannot hover or the viewport is
614
+ * narrow — on a phone a hover-gated action would otherwise be unreachable, and
615
+ * a row has no width for a button strip. `'always'` / `'never'` force it, which
616
+ * consumers can use for a known-mobile surface and stories use to capture the
617
+ * collapsed state deterministically.
618
+ *
619
+ * Actions beyond `MAX_INLINE_OPTION_ACTIONS` always overflow into the menu, so
620
+ * a consumer injecting many actions can never blow out the row.
621
+ */
622
+ optionActionsCollapse?: 'auto' | 'always' | 'never';
556
623
  /**
557
624
  * SEAM (reqraft): consumer-injected field editors for types reqraft doesn't
558
625
  * ship (IDE domain fields). Keyed by field `type`/`ui_type`; forwarded through
559
626
  * `TemplateField` to the `AutoFormField` override seam.
560
627
  */
561
628
  componentOverrides?: Record<string, React.FC<any>>;
629
+ /**
630
+ * The `ui_type` names among `componentOverrides` that select a bespoke editor
631
+ * but store their value as the schema's plainer `type` (a `cron` editor stores
632
+ * a string). Declaring them here keeps the field's stored `type` correct —
633
+ * without it the renderer name is written into the value and validation and
634
+ * round-tripping both break. Merged with reqraft's own built-in list, so a
635
+ * consumer's new editor no longer needs a reqraft release to be handled.
636
+ */
637
+ rendererOnlyUiTypes?: string[];
562
638
 
563
639
  /**
564
640
  * Bag of values forwarded from an outer FormEngine scope, used as a
@@ -644,12 +720,27 @@ export const FormEngine = ({
644
720
  optionsLoader,
645
721
  onValidityChange,
646
722
  optionActions,
723
+ optionActionsCollapse = 'auto',
647
724
  componentOverrides,
725
+ rendererOnlyUiTypes,
648
726
  inheritedFromParent,
649
727
  autoFocusFirstRequired,
650
728
  initialExpandedOptions,
651
729
  ...rest
652
730
  }: IFormEngineProps) => {
731
+ // Built-ins + whatever the consumer declared for its own injected editors.
732
+ const isRendererOnly = useMemo(
733
+ () => createRendererOnlyUiTypeCheck(rendererOnlyUiTypes),
734
+ [JSON.stringify(rendererOnlyUiTypes)]
735
+ );
736
+ // Resolved once here rather than per row — every CompactRow reads the answer
737
+ // off the context instead of opening its own matchMedia subscription.
738
+ const canHover = useCanHover();
739
+ const isNarrowViewport = useIsNarrowViewport();
740
+ const collapseOptionActions =
741
+ optionActionsCollapse === 'always' ? true
742
+ : optionActionsCollapse === 'never' ? false
743
+ : !canHover || isNarrowViewport;
653
744
  const [options, setOptions] = useState<IQorusFormSchema | undefined>(rest?.options || undefined);
654
745
  // optionsLoader lifecycle: loading feeds the skeleton gate, error the banner.
655
746
  const [optionsLoading, setOptionsLoading] = useState<boolean>(!!optionsLoader && !rest?.options);
@@ -785,7 +876,7 @@ export const FormEngine = ({
785
876
  fields: TQorusForm | TQorusFlatForm;
786
877
  meta?: IOptionsOnChangeMeta;
787
878
  }>(() => ({
788
- fields: fixOptions(value, options || {}),
879
+ fields: fixOptions(value, options || {}, undefined, isRendererOnly),
789
880
  meta: undefined,
790
881
  }));
791
882
  const originalValue = useRef<any>();
@@ -878,7 +969,7 @@ export const FormEngine = ({
878
969
  setOptions({});
879
970
  return;
880
971
  }
881
- setLocalValue({ fields: fixOptions(value, data.data), meta: undefined });
972
+ setLocalValue({ fields: fixOptions(value, data.data, undefined, isRendererOnly), meta: undefined });
882
973
  if (!operatorsUrl) {
883
974
  setLoading(false);
884
975
  }
@@ -923,7 +1014,7 @@ export const FormEngine = ({
923
1014
  }
924
1015
  setOptions(data.data);
925
1016
  onOptionsLoaded?.(data.data);
926
- setLocalValue({ fields: fixOptions({}, data.data), meta: undefined });
1017
+ setLocalValue({ fields: fixOptions({}, data.data, undefined, isRendererOnly), meta: undefined });
927
1018
  })();
928
1019
  }
929
1020
  }, [url, customUrl]);
@@ -947,7 +1038,7 @@ export const FormEngine = ({
947
1038
  }, [operatorsUrl]);
948
1039
 
949
1040
  useUpdateEffect(() => {
950
- const fixedValue = fixOptions(value, options || {});
1041
+ const fixedValue = fixOptions(value, options || {}, undefined, isRendererOnly);
951
1042
 
952
1043
  // When the value we're receiving is the one we just emitted AND fixOptions has nothing to
953
1044
  // meaningfully add or change, skip the update. This breaks the controlled-component loop for
@@ -971,19 +1062,21 @@ export const FormEngine = ({
971
1062
  }
972
1063
 
973
1064
  setLocalValue?.({ fields: fixedValue, meta: undefined });
974
- }, [JSON.stringify(options), JSON.stringify(value)]);
1065
+ }, [JSON.stringify(options), JSON.stringify(value), isRendererOnly]);
975
1066
 
976
1067
  const handleValueChange = useCallback(
977
1068
  (optionName: string, val?: any, _type?: string, isFunction?: boolean) => {
978
1069
  setLocalValue(({ fields = {} }) => {
979
- const schemaType = (options?.[optionName]?.ui_type ||
980
- options?.[optionName]?.type) as TQorusType;
1070
+ const schemaType = getOptionSchemaStorageType(options?.[optionName], isRendererOnly);
981
1071
  const isAnyLike = schemaType === 'any' || schemaType === 'auto';
982
1072
  // For any/auto schema types, preserve the user's chosen type stored in the field
983
1073
  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);
1074
+ isAnyLike && (fields[optionName] as IQorusFormField)?.type
1075
+ ? ((fields[optionName] as IQorusFormField).type as TQorusType)
1076
+ : (fields[optionName] as IQorusFormField)?.type === options?.[optionName]?.ui_type &&
1077
+ isRendererOnly((fields[optionName] as IQorusFormField)?.type)
1078
+ ? schemaType
1079
+ : schemaType || ((fields[optionName] as IQorusFormField)?.type as TQorusType);
987
1080
  const type =
988
1081
  _type ||
989
1082
  getTypeAndCanBeNull(resolvedSchemaType, options?.[optionName]?.allowed_values).type;
@@ -1066,6 +1159,7 @@ export const FormEngine = ({
1066
1159
  [
1067
1160
  onSingleOptionsChange,
1068
1161
  onDependableOptionChange,
1162
+ isRendererOnly,
1069
1163
  JSON.stringify(options),
1070
1164
  JSON.stringify(operators),
1071
1165
  ]
@@ -1186,8 +1280,7 @@ export const FormEngine = ({
1186
1280
  optionName as string,
1187
1281
  getDefaultValue(options?.[optionName as string]),
1188
1282
  getTypeAndCanBeNull(
1189
- (options?.[optionName as string]?.ui_type ||
1190
- options?.[optionName as string]?.type) as TQorusType,
1283
+ getOptionSchemaStorageType(options?.[optionName as string], isRendererOnly),
1191
1284
  options?.[optionName as string]?.allowed_values
1192
1285
  ).type
1193
1286
  );
@@ -1215,36 +1308,37 @@ export const FormEngine = ({
1215
1308
  return newValue;
1216
1309
  }
1217
1310
 
1218
- const schemaType = getType(
1311
+ const rendererType = getType(
1219
1312
  (options[optionName].ui_type || options[optionName].type) as TQorusType,
1220
1313
  operators,
1221
1314
  (option as IQorusFormField)?.op
1222
1315
  );
1316
+ const storageType = getOptionFieldStorageType(
1317
+ optionName,
1318
+ (option as IQorusFormField)?.type,
1319
+ options,
1320
+ operators,
1321
+ (option as IQorusFormField)?.op,
1322
+ isRendererOnly
1323
+ );
1223
1324
 
1224
1325
  if (!isPlainObject(option)) {
1225
1326
  return {
1226
1327
  ...newValue,
1227
1328
  [optionName]: {
1228
- type: schemaType,
1329
+ type: storageType || rendererType,
1229
1330
  value: option,
1230
1331
  },
1231
1332
  };
1232
1333
  }
1233
1334
 
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
1335
  return {
1243
1336
  ...newValue,
1244
- [optionName]: { ...(option as IQorusFormField), type: effectiveType },
1337
+ [optionName]: { ...(option as IQorusFormField), type: storageType || rendererType },
1245
1338
  };
1246
1339
  }, {});
1247
1340
  }, [
1341
+ isRendererOnly,
1248
1342
  JSON.stringify(fixedValue),
1249
1343
  JSON.stringify(options),
1250
1344
  unavailableOptionsCount.current,
@@ -1341,10 +1435,14 @@ export const FormEngine = ({
1341
1435
  const fields: IFormFieldValidityData[] = reduce(
1342
1436
  availableOptions,
1343
1437
  (result, option, optionName) => {
1344
- const type =
1345
- (option as IQorusFormField).type ||
1346
- (options?.[optionName]?.ui_type as TQorusType) ||
1347
- (options?.[optionName]?.type as TQorusType);
1438
+ const type = getOptionFieldStorageType(
1439
+ optionName,
1440
+ (option as IQorusFormField).type,
1441
+ options,
1442
+ operators,
1443
+ (option as IQorusFormField).op,
1444
+ isRendererOnly
1445
+ );
1348
1446
  const optionValue = (option as IQorusFormField).value;
1349
1447
 
1350
1448
  const isRequired = options?.[optionName]?.required;
@@ -1386,8 +1484,10 @@ export const FormEngine = ({
1386
1484
  invalidFields,
1387
1485
  };
1388
1486
  }, [
1487
+ isRendererOnly,
1389
1488
  JSON.stringify(availableOptions),
1390
1489
  JSON.stringify(options),
1490
+ JSON.stringify(operators),
1391
1491
  JSON.stringify(localValue.fields),
1392
1492
  ]);
1393
1493
 
@@ -1406,8 +1506,14 @@ export const FormEngine = ({
1406
1506
  showInvalidOptionsOnly &&
1407
1507
  isOptionValid(
1408
1508
  optionName,
1409
- (options?.[optionName]?.ui_type as TQorusType) ||
1410
- (options?.[optionName]?.type as TQorusType),
1509
+ getOptionFieldStorageType(
1510
+ optionName,
1511
+ (option as IQorusFormField).type,
1512
+ options,
1513
+ operators,
1514
+ (option as IQorusFormField).op,
1515
+ isRendererOnly
1516
+ ),
1411
1517
  (option as IQorusFormField).value
1412
1518
  )
1413
1519
  ) {
@@ -1417,7 +1523,13 @@ export const FormEngine = ({
1417
1523
  },
1418
1524
  {}
1419
1525
  );
1420
- }, [showInvalidOptionsOnly, JSON.stringify(availableOptions)]);
1526
+ }, [
1527
+ showInvalidOptionsOnly,
1528
+ isRendererOnly,
1529
+ JSON.stringify(availableOptions),
1530
+ JSON.stringify(options),
1531
+ JSON.stringify(operators),
1532
+ ]);
1421
1533
 
1422
1534
  // Read-first STATUS / BOX for one option — lifted to component scope so the
1423
1535
  // status boxes (renderCompact) and the header's "needs attention" count share
@@ -1437,7 +1549,14 @@ export const FormEngine = ({
1437
1549
  (name: string, hidden = false): TReadFirstStatus => {
1438
1550
  if (hidden) return 'optional';
1439
1551
  const schema = options?.[name];
1440
- const type = (schema?.ui_type || schema?.type) as TQorusType;
1552
+ const type = getOptionFieldStorageType(
1553
+ name,
1554
+ (availableOptions as TQorusForm)?.[name]?.type,
1555
+ options,
1556
+ operators,
1557
+ (availableOptions as TQorusForm)?.[name]?.op,
1558
+ isRendererOnly
1559
+ );
1441
1560
  const value = (availableOptions as TQorusForm)?.[name]?.value;
1442
1561
  const empty = isOptionValueEmpty(value);
1443
1562
  const reqGroups = (schema?.required_groups as string[] | undefined) || [];
@@ -1459,8 +1578,10 @@ export const FormEngine = ({
1459
1578
  });
1460
1579
  },
1461
1580
  [
1581
+ isRendererOnly,
1462
1582
  JSON.stringify(options),
1463
1583
  JSON.stringify(availableOptions),
1584
+ JSON.stringify(operators),
1464
1585
  isOptionValid,
1465
1586
  requiredGroupsInfo,
1466
1587
  schemaMsgIntent,
@@ -1726,10 +1847,10 @@ export const FormEngine = ({
1726
1847
  next[optionName] = value as IQorusFormField;
1727
1848
  }
1728
1849
  });
1729
- return { fields: fixOptions(next, options || {}, operators), meta: undefined };
1850
+ return { fields: fixOptions(next, options || {}, operators, isRendererOnly), meta: undefined };
1730
1851
  });
1731
1852
  setRequiredOnly(false);
1732
- }, [JSON.stringify(options), JSON.stringify(operators)]);
1853
+ }, [JSON.stringify(options), JSON.stringify(operators), isRendererOnly]);
1733
1854
 
1734
1855
  const getCustomMenuTemplateItems = useCallback<(optionName: string) => TCustomTemplateItems>(
1735
1856
  (optionName) => {
@@ -1759,6 +1880,25 @@ export const FormEngine = ({
1759
1880
  suppressSchemaMessages?: boolean
1760
1881
  ) => {
1761
1882
  const operatorParts = fixOperatorValue(other.op);
1883
+ // The RENDERER type for this row — which editor mounts. Distinct from the
1884
+ // storage type on the value envelope: a `richtext` field stores a string
1885
+ // but must render the richtext editor, so `ui_type` wins here.
1886
+ //
1887
+ // The exception is an any-like `ui_type`. Those schemas say
1888
+ // `ui_type: 'any'` precisely so the user can pick a concrete type, and
1889
+ // that pick lives on the field's stored `type` — letting 'any' win would
1890
+ // pin the row to the untyped editor forever.
1891
+ //
1892
+ // The trailing fallbacks keep the row rendering when a server-driven
1893
+ // schema has not arrived yet (`type` undefined) instead of crashing.
1894
+ const schemaUiType = options?.[optionName]?.ui_type as TQorusType;
1895
+ const uiTypeIsAnyLike = schemaUiType === 'any' || schemaUiType === 'auto';
1896
+ const resolvedType =
1897
+ (schemaUiType && !uiTypeIsAnyLike ? schemaUiType : undefined) ||
1898
+ type ||
1899
+ schemaUiType ||
1900
+ (options?.[optionName]?.type as TQorusType) ||
1901
+ 'any';
1762
1902
  return (
1763
1903
  <>
1764
1904
  {(() => {
@@ -1900,15 +2040,18 @@ export const FormEngine = ({
1900
2040
  // (DPQL text mode); opt out per-form via `templateFieldProps`.
1901
2041
  allowTextExpressions
1902
2042
  allowCustomValues={
1903
- options?.[optionName]?.supports_custom_values !== false && type !== 'any'
2043
+ options?.[optionName]?.supports_custom_values !== false && resolvedType !== 'any'
1904
2044
  }
1905
2045
  templates={templates.value}
1906
2046
  {...getTypeAndCanBeNull(
1907
- type as TQorusType,
2047
+ // The RENDERER type picks the editor — the storage type lives on
2048
+ // the value envelope. Passing storage here rendered a `richtext`
2049
+ // field as a plain string input.
2050
+ resolvedType,
1908
2051
  options?.[optionName]?.allowed_values,
1909
2052
  other.op
1910
2053
  )}
1911
- ui_type={type}
2054
+ ui_type={resolvedType}
1912
2055
  name={optionName}
1913
2056
  uniqueName={`${uniqueName ? `${uniqueName}.` : `${name ? `${name}.` : ''}`}${optionName}`}
1914
2057
  onChange={
@@ -1949,7 +2092,7 @@ export const FormEngine = ({
1949
2092
  schema={options || {}}
1950
2093
  allOptions={availableOptions}
1951
2094
  name={optionName}
1952
- option={{ type, ...other }}
2095
+ option={{ type: resolvedType, ...other }}
1953
2096
  getType={getTypeForOption}
1954
2097
  />
1955
2098
  {operators && size(operators) && size(other.op) ?
@@ -1964,7 +2107,7 @@ export const FormEngine = ({
1964
2107
  intent='info'
1965
2108
  label={
1966
2109
  other.value ?
1967
- type === 'richtext' ?
2110
+ resolvedType === 'richtext' ?
1968
2111
  richtextToString(other.value)
1969
2112
  : JSON.stringify(other.value)
1970
2113
  : ''
@@ -2057,6 +2200,8 @@ export const FormEngine = ({
2057
2200
  getTypeForOption,
2058
2201
  isOptionValid,
2059
2202
  confirmAction,
2203
+ optionActions,
2204
+ collapseOptionActions,
2060
2205
  renderOption,
2061
2206
  theme,
2062
2207
  cText,
@@ -2102,6 +2247,8 @@ export const FormEngine = ({
2102
2247
  getTypeForOption,
2103
2248
  isOptionValid,
2104
2249
  confirmAction,
2250
+ optionActions,
2251
+ collapseOptionActions,
2105
2252
  renderOption,
2106
2253
  theme,
2107
2254
  cText,
@@ -2238,7 +2385,14 @@ export const FormEngine = ({
2238
2385
  const isFieldInvalid = (name: string) =>
2239
2386
  !isOptionValid(
2240
2387
  name,
2241
- (options?.[name]?.ui_type || options?.[name]?.type) as TQorusType,
2388
+ getOptionFieldStorageType(
2389
+ name,
2390
+ (shownOptions as TQorusForm)[name]?.type,
2391
+ options,
2392
+ operators,
2393
+ (shownOptions as TQorusForm)[name]?.op,
2394
+ isRendererOnly
2395
+ ),
2242
2396
  (shownOptions as TQorusForm)[name]?.value
2243
2397
  );
2244
2398
  const comparator = (a: { name: string }, b: { name: string }): number => {
@@ -2347,7 +2501,7 @@ export const FormEngine = ({
2347
2501
  optionField={
2348
2502
  entry.hidden ?
2349
2503
  ({
2350
- type: (options?.[entry.name]?.ui_type || options?.[entry.name]?.type) as TQorusType,
2504
+ type: getOptionSchemaStorageType(options?.[entry.name], isRendererOnly),
2351
2505
  value: undefined,
2352
2506
  } as IQorusFormField)
2353
2507
  : ((shownOptions as TQorusForm)[entry.name] as IQorusFormField)
@@ -2808,13 +2962,11 @@ export const FormEngine = ({
2808
2962
  // option's schema as context). The consumer (the IDE) injects
2809
2963
  // it; same factory pattern as the ExpressionBuilder's
2810
2964
  // `extraActions`.
2811
- ...(typeof optionActions === 'function' ?
2812
- optionActions({
2813
- name: optionName,
2814
- schema: options[optionName],
2815
- value: availableOptions?.[optionName] as TOption,
2816
- })
2817
- : (optionActions ?? [])),
2965
+ ...resolveOptionActions(optionActions, {
2966
+ name: optionName,
2967
+ schema: options[optionName],
2968
+ value: availableOptions?.[optionName] as TOption,
2969
+ }),
2818
2970
  {
2819
2971
  size: 'tiny',
2820
2972
  icon: 'FullscreenLine',