@qoretechnologies/reqraft 0.10.15 → 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 (54) 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 +57 -11
  5. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  6. package/dist/components/form/engine/FormEngine.js +199 -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 +2 -2
  42. package/src/components/form/engine/CompactRow.tsx +139 -0
  43. package/src/components/form/engine/FormEngine.stories.tsx +73 -0
  44. package/src/components/form/engine/FormEngine.tsx +270 -70
  45. package/src/components/form/engine/FormEngineRemote.stories.tsx +329 -0
  46. package/src/components/form/engine/compactRowContext.ts +8 -0
  47. package/src/components/form/engine/compactRowStyles.ts +30 -0
  48. package/src/components/form/engine/optionActions.ts +40 -0
  49. package/src/components/form/engine/readFirst.ts +25 -6
  50. package/src/components/form/engine/rendererTypes.ts +55 -0
  51. package/src/components/form/fields/auto/AutoFormField.tsx +6 -1
  52. package/src/components/form/index.tsx +2 -0
  53. package/src/hooks/useMediaQuery.ts +61 -0
  54. 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
@@ -586,6 +662,27 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
586
662
  * classic (non-compact) mode. Default: off.
587
663
  */
588
664
  autoFocusFirstRequired?: boolean;
665
+
666
+ /**
667
+ * Names of read-first rows to open on mount, in addition to whatever the
668
+ * user opens afterwards.
669
+ *
670
+ * Which rows are expanded is otherwise private to the engine, which is
671
+ * fine while expansion is only ever a click. It stops being fine when the
672
+ * expanded row is part of an address: a consumer whose field renders its
673
+ * own routable surface (a table whose rows open panes with their own URLs)
674
+ * can restore the pane from the URL, but not the row that has to be open
675
+ * for the pane to exist at all — so a pasted or reloaded link lands on a
676
+ * collapsed form.
677
+ *
678
+ * Applied once, on the first render where the schema has rows, so an
679
+ * async-loaded schema is covered. It never re-expands a row the user has
680
+ * since collapsed, and it does not participate in `expandMode: 'single'`
681
+ * accordion collapsing — the caller is naming a starting point, not
682
+ * driving the state. Only a remount re-arms it. No-op in classic
683
+ * (non-compact) mode.
684
+ */
685
+ initialExpandedOptions?: string[];
589
686
  }
590
687
 
591
688
  // Option types rendered full-width (IDE Options parity, commit 8e6b7781).
@@ -623,11 +720,27 @@ export const FormEngine = ({
623
720
  optionsLoader,
624
721
  onValidityChange,
625
722
  optionActions,
723
+ optionActionsCollapse = 'auto',
626
724
  componentOverrides,
725
+ rendererOnlyUiTypes,
627
726
  inheritedFromParent,
628
727
  autoFocusFirstRequired,
728
+ initialExpandedOptions,
629
729
  ...rest
630
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;
631
744
  const [options, setOptions] = useState<IQorusFormSchema | undefined>(rest?.options || undefined);
632
745
  // optionsLoader lifecycle: loading feeds the skeleton gate, error the banner.
633
746
  const [optionsLoading, setOptionsLoading] = useState<boolean>(!!optionsLoader && !rest?.options);
@@ -763,7 +876,7 @@ export const FormEngine = ({
763
876
  fields: TQorusForm | TQorusFlatForm;
764
877
  meta?: IOptionsOnChangeMeta;
765
878
  }>(() => ({
766
- fields: fixOptions(value, options || {}),
879
+ fields: fixOptions(value, options || {}, undefined, isRendererOnly),
767
880
  meta: undefined,
768
881
  }));
769
882
  const originalValue = useRef<any>();
@@ -856,7 +969,7 @@ export const FormEngine = ({
856
969
  setOptions({});
857
970
  return;
858
971
  }
859
- setLocalValue({ fields: fixOptions(value, data.data), meta: undefined });
972
+ setLocalValue({ fields: fixOptions(value, data.data, undefined, isRendererOnly), meta: undefined });
860
973
  if (!operatorsUrl) {
861
974
  setLoading(false);
862
975
  }
@@ -901,7 +1014,7 @@ export const FormEngine = ({
901
1014
  }
902
1015
  setOptions(data.data);
903
1016
  onOptionsLoaded?.(data.data);
904
- setLocalValue({ fields: fixOptions({}, data.data), meta: undefined });
1017
+ setLocalValue({ fields: fixOptions({}, data.data, undefined, isRendererOnly), meta: undefined });
905
1018
  })();
906
1019
  }
907
1020
  }, [url, customUrl]);
@@ -925,7 +1038,7 @@ export const FormEngine = ({
925
1038
  }, [operatorsUrl]);
926
1039
 
927
1040
  useUpdateEffect(() => {
928
- const fixedValue = fixOptions(value, options || {});
1041
+ const fixedValue = fixOptions(value, options || {}, undefined, isRendererOnly);
929
1042
 
930
1043
  // When the value we're receiving is the one we just emitted AND fixOptions has nothing to
931
1044
  // meaningfully add or change, skip the update. This breaks the controlled-component loop for
@@ -949,19 +1062,21 @@ export const FormEngine = ({
949
1062
  }
950
1063
 
951
1064
  setLocalValue?.({ fields: fixedValue, meta: undefined });
952
- }, [JSON.stringify(options), JSON.stringify(value)]);
1065
+ }, [JSON.stringify(options), JSON.stringify(value), isRendererOnly]);
953
1066
 
954
1067
  const handleValueChange = useCallback(
955
1068
  (optionName: string, val?: any, _type?: string, isFunction?: boolean) => {
956
1069
  setLocalValue(({ fields = {} }) => {
957
- const schemaType = (options?.[optionName]?.ui_type ||
958
- options?.[optionName]?.type) as TQorusType;
1070
+ const schemaType = getOptionSchemaStorageType(options?.[optionName], isRendererOnly);
959
1071
  const isAnyLike = schemaType === 'any' || schemaType === 'auto';
960
1072
  // For any/auto schema types, preserve the user's chosen type stored in the field
961
1073
  const resolvedSchemaType =
962
- isAnyLike && (fields[optionName] as IQorusFormField)?.type ?
963
- ((fields[optionName] as IQorusFormField).type as TQorusType)
964
- : 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);
965
1080
  const type =
966
1081
  _type ||
967
1082
  getTypeAndCanBeNull(resolvedSchemaType, options?.[optionName]?.allowed_values).type;
@@ -1044,6 +1159,7 @@ export const FormEngine = ({
1044
1159
  [
1045
1160
  onSingleOptionsChange,
1046
1161
  onDependableOptionChange,
1162
+ isRendererOnly,
1047
1163
  JSON.stringify(options),
1048
1164
  JSON.stringify(operators),
1049
1165
  ]
@@ -1164,8 +1280,7 @@ export const FormEngine = ({
1164
1280
  optionName as string,
1165
1281
  getDefaultValue(options?.[optionName as string]),
1166
1282
  getTypeAndCanBeNull(
1167
- (options?.[optionName as string]?.ui_type ||
1168
- options?.[optionName as string]?.type) as TQorusType,
1283
+ getOptionSchemaStorageType(options?.[optionName as string], isRendererOnly),
1169
1284
  options?.[optionName as string]?.allowed_values
1170
1285
  ).type
1171
1286
  );
@@ -1193,36 +1308,37 @@ export const FormEngine = ({
1193
1308
  return newValue;
1194
1309
  }
1195
1310
 
1196
- const schemaType = getType(
1311
+ const rendererType = getType(
1197
1312
  (options[optionName].ui_type || options[optionName].type) as TQorusType,
1198
1313
  operators,
1199
1314
  (option as IQorusFormField)?.op
1200
1315
  );
1316
+ const storageType = getOptionFieldStorageType(
1317
+ optionName,
1318
+ (option as IQorusFormField)?.type,
1319
+ options,
1320
+ operators,
1321
+ (option as IQorusFormField)?.op,
1322
+ isRendererOnly
1323
+ );
1201
1324
 
1202
1325
  if (!isPlainObject(option)) {
1203
1326
  return {
1204
1327
  ...newValue,
1205
1328
  [optionName]: {
1206
- type: schemaType,
1329
+ type: storageType || rendererType,
1207
1330
  value: option,
1208
1331
  },
1209
1332
  };
1210
1333
  }
1211
1334
 
1212
- // any/auto: preserve the user-picked type; otherwise normalize to the
1213
- // schema type (ui_type wins) so rendering and validation agree.
1214
- const isAnyLike = schemaType === 'any' || schemaType === 'auto';
1215
- const effectiveType =
1216
- isAnyLike && (option as IQorusFormField)?.type ?
1217
- (option as IQorusFormField).type
1218
- : schemaType;
1219
-
1220
1335
  return {
1221
1336
  ...newValue,
1222
- [optionName]: { ...(option as IQorusFormField), type: effectiveType },
1337
+ [optionName]: { ...(option as IQorusFormField), type: storageType || rendererType },
1223
1338
  };
1224
1339
  }, {});
1225
1340
  }, [
1341
+ isRendererOnly,
1226
1342
  JSON.stringify(fixedValue),
1227
1343
  JSON.stringify(options),
1228
1344
  unavailableOptionsCount.current,
@@ -1319,10 +1435,14 @@ export const FormEngine = ({
1319
1435
  const fields: IFormFieldValidityData[] = reduce(
1320
1436
  availableOptions,
1321
1437
  (result, option, optionName) => {
1322
- const type =
1323
- (option as IQorusFormField).type ||
1324
- (options?.[optionName]?.ui_type as TQorusType) ||
1325
- (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
+ );
1326
1446
  const optionValue = (option as IQorusFormField).value;
1327
1447
 
1328
1448
  const isRequired = options?.[optionName]?.required;
@@ -1364,8 +1484,10 @@ export const FormEngine = ({
1364
1484
  invalidFields,
1365
1485
  };
1366
1486
  }, [
1487
+ isRendererOnly,
1367
1488
  JSON.stringify(availableOptions),
1368
1489
  JSON.stringify(options),
1490
+ JSON.stringify(operators),
1369
1491
  JSON.stringify(localValue.fields),
1370
1492
  ]);
1371
1493
 
@@ -1384,8 +1506,14 @@ export const FormEngine = ({
1384
1506
  showInvalidOptionsOnly &&
1385
1507
  isOptionValid(
1386
1508
  optionName,
1387
- (options?.[optionName]?.ui_type as TQorusType) ||
1388
- (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
+ ),
1389
1517
  (option as IQorusFormField).value
1390
1518
  )
1391
1519
  ) {
@@ -1395,7 +1523,13 @@ export const FormEngine = ({
1395
1523
  },
1396
1524
  {}
1397
1525
  );
1398
- }, [showInvalidOptionsOnly, JSON.stringify(availableOptions)]);
1526
+ }, [
1527
+ showInvalidOptionsOnly,
1528
+ isRendererOnly,
1529
+ JSON.stringify(availableOptions),
1530
+ JSON.stringify(options),
1531
+ JSON.stringify(operators),
1532
+ ]);
1399
1533
 
1400
1534
  // Read-first STATUS / BOX for one option — lifted to component scope so the
1401
1535
  // status boxes (renderCompact) and the header's "needs attention" count share
@@ -1415,7 +1549,14 @@ export const FormEngine = ({
1415
1549
  (name: string, hidden = false): TReadFirstStatus => {
1416
1550
  if (hidden) return 'optional';
1417
1551
  const schema = options?.[name];
1418
- 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
+ );
1419
1560
  const value = (availableOptions as TQorusForm)?.[name]?.value;
1420
1561
  const empty = isOptionValueEmpty(value);
1421
1562
  const reqGroups = (schema?.required_groups as string[] | undefined) || [];
@@ -1437,8 +1578,10 @@ export const FormEngine = ({
1437
1578
  });
1438
1579
  },
1439
1580
  [
1581
+ isRendererOnly,
1440
1582
  JSON.stringify(options),
1441
1583
  JSON.stringify(availableOptions),
1584
+ JSON.stringify(operators),
1442
1585
  isOptionValid,
1443
1586
  requiredGroupsInfo,
1444
1587
  schemaMsgIntent,
@@ -1541,6 +1684,32 @@ export const FormEngine = ({
1541
1684
  [expandMode]
1542
1685
  );
1543
1686
 
1687
+ // --- Caller-named starting rows (opt-in) ----------------------------------
1688
+ // `initialExpandedOptions` lets a consumer name the rows that must already
1689
+ // be open when the form first paints — the case where the expanded row is
1690
+ // part of an address rather than a click (see the prop's docs).
1691
+ //
1692
+ // One-shot for the same reason autofocus is: it fires on the first render
1693
+ // that actually has rows (so an async-loaded schema is covered) and then
1694
+ // never again, so a row the user collapses afterwards stays collapsed and a
1695
+ // later schema reload does not reopen it.
1696
+ const hasAppliedInitialExpansionRef = useRef(false);
1697
+ useEffect(() => {
1698
+ if (!initialExpandedOptions?.length || !compact || hasAppliedInitialExpansionRef.current) {
1699
+ return;
1700
+ }
1701
+ const names = Object.keys(availableOptions);
1702
+ if (!names.length) {
1703
+ return;
1704
+ }
1705
+ hasAppliedInitialExpansionRef.current = true;
1706
+ const toExpand = initialExpandedOptions.filter((name) => names.includes(name));
1707
+ if (!toExpand.length) {
1708
+ return;
1709
+ }
1710
+ setExpandedOptions((prev) => [...prev, ...toExpand.filter((name) => !prev.includes(name))]);
1711
+ }, [initialExpandedOptions, compact, availableOptions]);
1712
+
1544
1713
  // --- First-attention-field autofocus (opt-in) -----------------------------
1545
1714
  // With `autoFocusFirstRequired`, drop the user straight into the first field
1546
1715
  // they must fix. We reuse the engine's own ordering (`availableOptions`), the
@@ -1678,10 +1847,10 @@ export const FormEngine = ({
1678
1847
  next[optionName] = value as IQorusFormField;
1679
1848
  }
1680
1849
  });
1681
- return { fields: fixOptions(next, options || {}, operators), meta: undefined };
1850
+ return { fields: fixOptions(next, options || {}, operators, isRendererOnly), meta: undefined };
1682
1851
  });
1683
1852
  setRequiredOnly(false);
1684
- }, [JSON.stringify(options), JSON.stringify(operators)]);
1853
+ }, [JSON.stringify(options), JSON.stringify(operators), isRendererOnly]);
1685
1854
 
1686
1855
  const getCustomMenuTemplateItems = useCallback<(optionName: string) => TCustomTemplateItems>(
1687
1856
  (optionName) => {
@@ -1711,6 +1880,25 @@ export const FormEngine = ({
1711
1880
  suppressSchemaMessages?: boolean
1712
1881
  ) => {
1713
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';
1714
1902
  return (
1715
1903
  <>
1716
1904
  {(() => {
@@ -1852,15 +2040,18 @@ export const FormEngine = ({
1852
2040
  // (DPQL text mode); opt out per-form via `templateFieldProps`.
1853
2041
  allowTextExpressions
1854
2042
  allowCustomValues={
1855
- options?.[optionName]?.supports_custom_values !== false && type !== 'any'
2043
+ options?.[optionName]?.supports_custom_values !== false && resolvedType !== 'any'
1856
2044
  }
1857
2045
  templates={templates.value}
1858
2046
  {...getTypeAndCanBeNull(
1859
- 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,
1860
2051
  options?.[optionName]?.allowed_values,
1861
2052
  other.op
1862
2053
  )}
1863
- ui_type={type}
2054
+ ui_type={resolvedType}
1864
2055
  name={optionName}
1865
2056
  uniqueName={`${uniqueName ? `${uniqueName}.` : `${name ? `${name}.` : ''}`}${optionName}`}
1866
2057
  onChange={
@@ -1901,7 +2092,7 @@ export const FormEngine = ({
1901
2092
  schema={options || {}}
1902
2093
  allOptions={availableOptions}
1903
2094
  name={optionName}
1904
- option={{ type, ...other }}
2095
+ option={{ type: resolvedType, ...other }}
1905
2096
  getType={getTypeForOption}
1906
2097
  />
1907
2098
  {operators && size(operators) && size(other.op) ?
@@ -1916,7 +2107,7 @@ export const FormEngine = ({
1916
2107
  intent='info'
1917
2108
  label={
1918
2109
  other.value ?
1919
- type === 'richtext' ?
2110
+ resolvedType === 'richtext' ?
1920
2111
  richtextToString(other.value)
1921
2112
  : JSON.stringify(other.value)
1922
2113
  : ''
@@ -2009,6 +2200,8 @@ export const FormEngine = ({
2009
2200
  getTypeForOption,
2010
2201
  isOptionValid,
2011
2202
  confirmAction,
2203
+ optionActions,
2204
+ collapseOptionActions,
2012
2205
  renderOption,
2013
2206
  theme,
2014
2207
  cText,
@@ -2054,6 +2247,8 @@ export const FormEngine = ({
2054
2247
  getTypeForOption,
2055
2248
  isOptionValid,
2056
2249
  confirmAction,
2250
+ optionActions,
2251
+ collapseOptionActions,
2057
2252
  renderOption,
2058
2253
  theme,
2059
2254
  cText,
@@ -2190,7 +2385,14 @@ export const FormEngine = ({
2190
2385
  const isFieldInvalid = (name: string) =>
2191
2386
  !isOptionValid(
2192
2387
  name,
2193
- (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
+ ),
2194
2396
  (shownOptions as TQorusForm)[name]?.value
2195
2397
  );
2196
2398
  const comparator = (a: { name: string }, b: { name: string }): number => {
@@ -2299,7 +2501,7 @@ export const FormEngine = ({
2299
2501
  optionField={
2300
2502
  entry.hidden ?
2301
2503
  ({
2302
- type: (options?.[entry.name]?.ui_type || options?.[entry.name]?.type) as TQorusType,
2504
+ type: getOptionSchemaStorageType(options?.[entry.name], isRendererOnly),
2303
2505
  value: undefined,
2304
2506
  } as IQorusFormField)
2305
2507
  : ((shownOptions as TQorusForm)[entry.name] as IQorusFormField)
@@ -2760,13 +2962,11 @@ export const FormEngine = ({
2760
2962
  // option's schema as context). The consumer (the IDE) injects
2761
2963
  // it; same factory pattern as the ExpressionBuilder's
2762
2964
  // `extraActions`.
2763
- ...(typeof optionActions === 'function' ?
2764
- optionActions({
2765
- name: optionName,
2766
- schema: options[optionName],
2767
- value: availableOptions?.[optionName] as TOption,
2768
- })
2769
- : (optionActions ?? [])),
2965
+ ...resolveOptionActions(optionActions, {
2966
+ name: optionName,
2967
+ schema: options[optionName],
2968
+ value: availableOptions?.[optionName] as TOption,
2969
+ }),
2770
2970
  {
2771
2971
  size: 'tiny',
2772
2972
  icon: 'FullscreenLine',