@qoretechnologies/reqraft 0.10.19 → 0.10.20

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 (63) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +25 -11
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/form/engine/FormEngine.d.ts +6 -1
  5. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  6. package/dist/components/form/engine/FormEngine.js +38 -19
  7. package/dist/components/form/engine/FormEngine.js.map +1 -1
  8. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  9. package/dist/components/form/engine/compactRowStyles.js +1 -1
  10. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  11. package/dist/components/form/engine/readFirst.d.ts +6 -0
  12. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  13. package/dist/components/form/engine/readFirst.js +19 -3
  14. package/dist/components/form/engine/readFirst.js.map +1 -1
  15. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  16. package/dist/components/form/expressions/builder/index.js +13 -2
  17. package/dist/components/form/expressions/builder/index.js.map +1 -1
  18. package/dist/components/form/expressions/useExpressions.d.ts.map +1 -1
  19. package/dist/components/form/expressions/useExpressions.js +4 -1
  20. package/dist/components/form/expressions/useExpressions.js.map +1 -1
  21. package/dist/components/form/fields/Field.d.ts.map +1 -1
  22. package/dist/components/form/fields/Field.js +5 -3
  23. package/dist/components/form/fields/Field.js.map +1 -1
  24. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +2 -1
  25. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  26. package/dist/components/form/fields/allowed-values/AllowedValues.js +8 -26
  27. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  28. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  29. package/dist/components/form/fields/auto/AutoFormField.js +3 -3
  30. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  31. package/dist/components/form/fields/boolean/Boolean.js +1 -1
  32. package/dist/components/form/fields/boolean/Boolean.js.map +1 -1
  33. package/dist/components/form/fields/multi-select/MultiSelectFormField.d.ts.map +1 -1
  34. package/dist/components/form/fields/multi-select/MultiSelectFormField.js +5 -4
  35. package/dist/components/form/fields/multi-select/MultiSelectFormField.js.map +1 -1
  36. package/dist/components/form/fields/select/Select.d.ts.map +1 -1
  37. package/dist/components/form/fields/select/Select.js +29 -12
  38. package/dist/components/form/fields/select/Select.js.map +1 -1
  39. package/dist/components/form/fields/select/SelectCollection.d.ts +8 -2
  40. package/dist/components/form/fields/select/SelectCollection.d.ts.map +1 -1
  41. package/dist/components/form/fields/select/SelectCollection.js +52 -16
  42. package/dist/components/form/fields/select/SelectCollection.js.map +1 -1
  43. package/package.json +3 -3
  44. package/src/components/form/engine/CompactRow.tsx +46 -29
  45. package/src/components/form/engine/FormEngine.stories.tsx +99 -2
  46. package/src/components/form/engine/FormEngine.tsx +42 -10
  47. package/src/components/form/engine/compactRowStyles.ts +2 -1
  48. package/src/components/form/engine/readFirst.ts +25 -4
  49. package/src/components/form/expressions/ExpressionField.stories.tsx +10 -0
  50. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +26 -19
  51. package/src/components/form/expressions/builder/index.tsx +12 -1
  52. package/src/components/form/expressions/useExpressions.ts +4 -1
  53. package/src/components/form/fields/Field.tsx +10 -1
  54. package/src/components/form/fields/allowed-values/AllowedValues.tsx +13 -36
  55. package/src/components/form/fields/auto/AutoFormField.stories.tsx +1 -0
  56. package/src/components/form/fields/auto/AutoFormField.tsx +2 -0
  57. package/src/components/form/fields/boolean/Boolean.stories.tsx +34 -0
  58. package/src/components/form/fields/boolean/Boolean.tsx +1 -1
  59. package/src/components/form/fields/multi-select/MultiSelectFormField.tsx +14 -10
  60. package/src/components/form/fields/select/Select.stories.tsx +4 -0
  61. package/src/components/form/fields/select/Select.tsx +38 -12
  62. package/src/components/form/fields/select/SelectCollection.tsx +78 -17
  63. package/src/stores/currentUser/currentUser.stories.tsx +32 -0
@@ -59,6 +59,7 @@ import {
59
59
  getHashEntries,
60
60
  getReadFirstStatus,
61
61
  getValueType,
62
+ isFixedCompactAllowedValueOption,
62
63
  isOptionValueEmpty,
63
64
  optionHasImages,
64
65
  } from './readFirst';
@@ -95,7 +96,7 @@ const COMPACT_COMPLEX_TYPES = new Set([
95
96
 
96
97
  // Card editors that hold a SINGLE value through one text-bearing input — their
97
98
  // built-in ReqoreInput clear duplicates the card's own "Clear value" action, so
98
- // we suppress it (the card is the single source). Structural/multi-input
99
+ // we suppress it (the card trash action is the single source). Structural/multi-input
99
100
  // editors (hash, list, schema…) and polymorphic ones (auto/any) are NOT listed:
100
101
  // their per-sub-field clears are distinct affordances and must stay, while the
101
102
  // card ✕ clears the whole value. (Operators force a card on a scalar value, so
@@ -520,7 +521,11 @@ export const CompactRow = memo(
520
521
  />
521
522
  </span>
522
523
  : null;
523
- const editType = ((schema?.ui_type as string) || (schema?.type as string)) ?? '';
524
+ const fixedAllowedValueOption = isFixedCompactAllowedValueOption(schema);
525
+ const editType =
526
+ (fixedAllowedValueOption
527
+ ? (schema?.type as string) || (schema?.ui_type as string)
528
+ : (schema?.ui_type as string) || (schema?.type as string)) ?? '';
524
529
  // Scalars edit in place inside the row; complex fields (tall or nested
525
530
  // editors) still open the expanded card below.
526
531
  const inlineEditable =
@@ -573,8 +578,9 @@ export const CompactRow = memo(
573
578
  // — distinct from the read-row "Remove field" (which deletes an optional
574
579
  // field entirely). Once cleared, `changed` flips on and `hasValue` off, so
575
580
  // this button gives way to `revertButton` in the same slot — the morph the
576
- // edit row's actions are designed around. Neutral (not danger): it's a
577
- // reversible step, undone by Revert or by re-typing.
581
+ // edit row's actions are designed around. Clearing is destructive even
582
+ // though it can be reverted before saving, so both the trigger and its
583
+ // confirmation use danger intent.
578
584
  //
579
585
  // Only editors with NO built-in clear of their own get this button —
580
586
  // toggles and fixed-choice pickers. The text/number/date inputs already
@@ -587,6 +593,17 @@ export const CompactRow = memo(
587
593
  // CREATABLE one still renders the raw input (with its own ✕), so it keeps
588
594
  // its clear — matching the dispatch in AutoFormField/TemplateField.
589
595
  (!!size(schema?.allowed_values) && !schema?.allowed_values_creatable);
596
+ const confirmClearValue = (event?: React.MouseEvent) => {
597
+ event?.stopPropagation();
598
+ confirmAction({
599
+ title: 'Clear value',
600
+ description: `Clear the current value of "${label}"?`,
601
+ confirmLabel: 'Clear value',
602
+ confirmButtonIntent: 'danger',
603
+ intent: 'danger',
604
+ onConfirm: () => handleValueChange(optionName, undefined),
605
+ });
606
+ };
590
607
  const clearValueButton =
591
608
  hasValue && !readOnly && editorLacksOwnClear ?
592
609
  <ReqoreButton
@@ -594,12 +611,11 @@ export const CompactRow = memo(
594
611
  size='small'
595
612
  flat
596
613
  minimal
597
- icon='CloseLine'
614
+ icon='DeleteBinLine'
615
+ intent='danger'
616
+ aria-label='Clear value'
598
617
  tooltip='Clear value'
599
- onClick={(e: React.MouseEvent) => {
600
- e.stopPropagation();
601
- handleValueChange(optionName, undefined);
602
- }}
618
+ onClick={confirmClearValue}
603
619
  />
604
620
  : null;
605
621
  // Batched commit: a changed row is a draft until Save — mark it with the
@@ -619,6 +635,21 @@ export const CompactRow = memo(
619
635
  effect={{ uppercase: true, spaced: 1 }}
620
636
  />
621
637
  : null;
638
+ const isPassiveCloseAction = fixedAllowedValueOption || readOnly;
639
+ const closeExpandedFieldButton = (
640
+ <ReqoreButton
641
+ className='options-readfirst-done'
642
+ size='small'
643
+ intent={!isPassiveCloseAction ? 'success' : undefined}
644
+ flat={isPassiveCloseAction}
645
+ minimal={isPassiveCloseAction}
646
+ fixed
647
+ icon={isPassiveCloseAction ? 'CloseLine' : 'CheckLine'}
648
+ aria-label={isPassiveCloseAction ? 'Close field' : 'Done'}
649
+ tooltip={isPassiveCloseAction ? 'Close field' : 'Done'}
650
+ onClick={() => toggleExpandedOption(optionName)}
651
+ />
652
+ );
622
653
 
623
654
  // Info tiers: Tier 1 (danger/warning + dependency hints) must be visible
624
655
  // without interaction; Tier 2 (info/success, default notes) sits behind ⓘ.
@@ -860,15 +891,7 @@ export const CompactRow = memo(
860
891
  renderInjectedOptionAction(action, index)
861
892
  )}
862
893
  {moreMenu}
863
- <ReqoreButton
864
- className='options-readfirst-done'
865
- size='small'
866
- intent='success'
867
- fixed
868
- icon='CheckLine'
869
- tooltip='Done'
870
- onClick={collapse}
871
- />
894
+ {closeExpandedFieldButton}
872
895
  </StyledRowActions>
873
896
  {/* Cluster node is rendered LAST (it's absolutely positioned, so DOM
874
897
  order doesn't move it) — keeping it out of the leading cells lets
@@ -1021,25 +1044,19 @@ export const CompactRow = memo(
1021
1044
  {hasValue && !readOnly ?
1022
1045
  <ReqoreButton
1023
1046
  size='small'
1024
- icon='CloseLine'
1047
+ icon='DeleteBinLine'
1025
1048
  minimal
1026
1049
  flat
1027
1050
  fixed
1051
+ intent='danger'
1028
1052
  className='options-readfirst-clear'
1053
+ aria-label='Clear value'
1029
1054
  tooltip='Clear value'
1030
- onClick={() => handleValueChange(optionName, undefined)}
1055
+ onClick={confirmClearValue}
1031
1056
  />
1032
1057
  : null}
1033
1058
  {moreMenu}
1034
- <ReqoreButton
1035
- size='small'
1036
- icon={readOnly ? 'CloseLine' : 'CheckLine'}
1037
- intent='success'
1038
- fixed
1039
- className='options-readfirst-done'
1040
- tooltip={readOnly ? 'Close' : 'Done'}
1041
- onClick={() => toggleExpandedOption(optionName)}
1042
- />
1059
+ {closeExpandedFieldButton}
1043
1060
  </ReqoreControlGroup>
1044
1061
  </div>
1045
1062
  {/* Same fullscreen focused-editing affordance as the classic cards —
@@ -5,6 +5,7 @@ import { Meta, StoryObj } from '@storybook/react-vite';
5
5
  import { ChangeEvent, useState } from 'react';
6
6
  import { expect, fireEvent, fn, userEvent, waitFor, within } from 'storybook/test';
7
7
  import { validateField } from '../../../helpers/validations';
8
+ import { defaultQorusTypes } from '../../../hooks/useQorusTypes';
8
9
  import {
9
10
  _testsChangeRichText,
10
11
  _testsChangeStringField,
@@ -90,6 +91,29 @@ const meta: Meta<typeof FormEngine> = {
90
91
  chromatic: {
91
92
  viewports: [2560],
92
93
  },
94
+ // `useQorusTypes` resolves the type catalogue as `size(data) ? data : defaultQorusTypes`,
95
+ // so a reachable, authenticated instance *replaces* the built-in list rather than
96
+ // supplementing it. These stories never opt into live data (no `live: true`), but the
97
+ // request fires anyway — which made `Option With Any Type` pass locally (401 → built-in
98
+ // list, so "Boolean" exists) and fail in CI, where the token is valid and the server's
99
+ // list decides the labels. Pin the catalogue so the type names the plays click are ours.
100
+ mockData: [
101
+ {
102
+ // `query()` builds `${instance}api/latest/${url}`, and the hook's url is
103
+ // `/system/qorus-type-info` — hence the doubled slash. Both spellings are listed
104
+ // so the mock keeps matching if that leading slash is ever dropped.
105
+ url: 'https://hq.qoretechnologies.com:8092/api/latest//system/qorus-type-info',
106
+ method: 'GET',
107
+ status: 200,
108
+ response: defaultQorusTypes,
109
+ },
110
+ {
111
+ url: 'https://hq.qoretechnologies.com:8092/api/latest/system/qorus-type-info',
112
+ method: 'GET',
113
+ status: 200,
114
+ response: defaultQorusTypes,
115
+ },
116
+ ],
93
117
  },
94
118
  render: ({ value, onChange, ...rest }: IFormEngineProps) => {
95
119
  const [val, setValue] = useState(value);
@@ -622,7 +646,7 @@ export const OptionWithAnyType: Story = {
622
646
  // and switch it to a specific custom type (Boolean).
623
647
  await _testsOpenTemplateMenu(4);
624
648
  await _testsClickButton({ label: 'Set Custom Value' });
625
- await _testsClickButton({ label: 'True or False' });
649
+ await _testsClickButton({ label: 'Boolean' });
626
650
  },
627
651
  };
628
652
 
@@ -3361,6 +3385,57 @@ const langImg = (color: string, letter: string): string =>
3361
3385
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="${color}"/><text x="16" y="23" font-size="20" fill="white" text-anchor="middle" font-family="sans-serif">${letter}</text></svg>`
3362
3386
  )}`;
3363
3387
 
3388
+ const assertFixedChoiceCanCloseWithoutClearing = async (field: string, expectedLabel: string) => {
3389
+ const cardSelector = `.options-readfirst-card[data-field="${field}"]`;
3390
+ const rowSelector = `.readfirst-row[data-field="${field}"]`;
3391
+
3392
+ let card: HTMLElement | null = null;
3393
+ await waitFor(
3394
+ () => {
3395
+ card = document.querySelector(cardSelector);
3396
+ expect(card).toBeTruthy();
3397
+ },
3398
+ { timeout: 10000 }
3399
+ );
3400
+
3401
+ const clearButton = card!.querySelector(
3402
+ '.options-readfirst-clear[aria-label="Clear value"]'
3403
+ ) as HTMLElement;
3404
+ const closeButton = card!.querySelector(
3405
+ '.options-readfirst-done[aria-label="Close field"]'
3406
+ ) as HTMLElement;
3407
+ await expect(clearButton).toBeInTheDocument();
3408
+ await expect(closeButton).toBeInTheDocument();
3409
+ // Clear is destructive and visually distinct from the adjacent passive
3410
+ // actions. It must not mutate the value until the Reqore confirmation is
3411
+ // accepted; cancelling keeps both the value and expanded editor intact.
3412
+ await expect(getComputedStyle(clearButton).color).not.toBe(getComputedStyle(closeButton).color);
3413
+ await fireEvent.click(clearButton);
3414
+ await waitFor(() => expect(document.querySelector('.reqore-confirmation-modal')).toBeTruthy(), {
3415
+ timeout: 10000,
3416
+ });
3417
+ await expect(document.querySelector('.reqore-confirmation-modal')?.textContent).toContain(
3418
+ 'Clear value'
3419
+ );
3420
+ await _testsClickButton({ label: 'Cancel' });
3421
+ await waitFor(() => expect(document.querySelector('.reqore-confirmation-modal')).toBeNull());
3422
+ await expect(document.querySelector(cardSelector)).toBeTruthy();
3423
+
3424
+ await fireEvent.click(closeButton);
3425
+ await waitFor(() => expect(document.querySelector(cardSelector)).toBeNull(), {
3426
+ timeout: 10000,
3427
+ });
3428
+
3429
+ const row = document.querySelector(rowSelector) as HTMLElement;
3430
+ expect(row?.textContent).toContain(expectedLabel);
3431
+
3432
+ // Re-open so the story's visual snapshot still covers the expanded fixed-choice card.
3433
+ await fireEvent.click(row);
3434
+ await waitFor(() => expect(document.querySelector(cardSelector)).toBeTruthy(), {
3435
+ timeout: 10000,
3436
+ });
3437
+ };
3438
+
3364
3439
  /**
3365
3440
  * Enum field with per-value images (the IDE `language` field shape: `type:
3366
3441
  * 'enum'` + `items: [{ value, title, image }]`). Read-first shows the selected
@@ -3426,6 +3501,7 @@ export const CompactEnumWithImages: Story = {
3426
3501
  },
3427
3502
  { timeout: 10000 }
3428
3503
  );
3504
+ await assertFixedChoiceCanCloseWithoutClearing('lang', 'Qore');
3429
3505
  },
3430
3506
  };
3431
3507
 
@@ -3502,6 +3578,7 @@ export const CompactEnumRichtextValue: Story = {
3502
3578
  },
3503
3579
  { timeout: 10000 }
3504
3580
  );
3581
+ await assertFixedChoiceCanCloseWithoutClearing('lang', 'Qore');
3505
3582
  },
3506
3583
  };
3507
3584
 
@@ -4163,8 +4240,28 @@ export const CompactFieldTypesEditing: Story = {
4163
4240
  editRow('enabled').querySelector('.options-readfirst-revert')
4164
4241
  ).not.toBeInTheDocument();
4165
4242
 
4166
- // Clear it the value empties, so Clear is replaced by Revert in place.
4243
+ // Clear is destructive: cancelling its Reqore confirmation leaves the
4244
+ // value untouched, while confirming empties it and swaps Clear for Revert.
4245
+ await fireEvent.click(editRow('enabled').querySelector('.options-readfirst-clear')!);
4246
+ await waitFor(() => expect(document.querySelector('.reqore-confirmation-modal')).toBeTruthy(), {
4247
+ timeout: 10000,
4248
+ });
4249
+ await _testsClickButton({ label: 'Cancel' });
4250
+ await waitFor(() => expect(document.querySelector('.reqore-confirmation-modal')).toBeNull());
4251
+ await expect(editRow('enabled').querySelector('.options-readfirst-clear')).toBeInTheDocument();
4252
+
4167
4253
  await fireEvent.click(editRow('enabled').querySelector('.options-readfirst-clear')!);
4254
+ let confirmationModal: HTMLElement | null = null;
4255
+ await waitFor(
4256
+ () => {
4257
+ confirmationModal = document.querySelector('.reqore-confirmation-modal');
4258
+ expect(confirmationModal).toBeTruthy();
4259
+ },
4260
+ { timeout: 10000 }
4261
+ );
4262
+ await userEvent.click(
4263
+ within(confirmationModal!).getByRole('button', { name: 'Clear value' })
4264
+ );
4168
4265
  await waitFor(() => {
4169
4266
  expect(editRow('enabled').querySelector('.options-readfirst-clear')).not.toBeInTheDocument();
4170
4267
  expect(editRow('enabled').querySelector('.options-readfirst-revert')).toBeInTheDocument();
@@ -101,7 +101,8 @@ import {
101
101
  getReadFirstCompletion,
102
102
  getReadFirstStatus,
103
103
  isOptionValueEmpty,
104
- shouldAutoCollapseCompactAllowedValueOption,
104
+ isFixedCompactAllowedValueOption,
105
+ shouldAutoCollapseCompactOption,
105
106
  } from './readFirst';
106
107
 
107
108
  // Re-export types for consumers
@@ -282,7 +283,9 @@ const getOptionSchemaStorageType = (
282
283
  option?: TQorusFormFieldSchema,
283
284
  isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
284
285
  ): TQorusType =>
285
- ((isRendererOnly(option?.ui_type)
286
+ ((isFixedCompactAllowedValueOption(option)
287
+ ? option?.type || option?.ui_type
288
+ : isRendererOnly(option?.ui_type)
286
289
  ? option?.type || option?.ui_type
287
290
  : option?.ui_type || option?.type) || 'any') as TQorusType;
288
291
 
@@ -556,6 +559,11 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
556
559
  /** Opt-in: fetch global templates from `system/getContextData` for this context. */
557
560
  interfaceContext?: string;
558
561
  templateFieldProps?: Partial<ITemplateFieldProps>;
562
+ /**
563
+ * Force single-value selectors to use inline dropdowns even when their
564
+ * allowed values carry descriptions.
565
+ */
566
+ forceDropdown?: boolean;
559
567
  showTypeToggle?: boolean;
560
568
  /**
561
569
  * Compact (read-first) mode: each option renders as a row with its formatted
@@ -707,6 +715,7 @@ export const FormEngine = ({
707
715
  allowTemplates = true,
708
716
  interfaceContext,
709
717
  templateFieldProps,
718
+ forceDropdown,
710
719
  showTypeToggle = true,
711
720
  compact,
712
721
  compactFlush = false,
@@ -836,9 +845,12 @@ export const FormEngine = ({
836
845
  // same tick targets the stale (pre-move) layout, so the page doesn't budge.
837
846
  // A rAF lets the new position settle first.
838
847
  requestAnimationFrame(() => {
839
- document
840
- .querySelector(`.readfirst-row[data-field="${optionNames[0]}"]`)
841
- ?.scrollIntoView({ block: 'center', behavior: 'smooth' });
848
+ const target = document.querySelector<HTMLElement>(
849
+ `.readfirst-row[data-field="${optionNames[0]}"]`
850
+ );
851
+ if (typeof target?.scrollIntoView === 'function') {
852
+ target.scrollIntoView({ block: 'center', behavior: 'smooth' });
853
+ }
842
854
  });
843
855
  }
844
856
  setFlashedOptions(optionNames);
@@ -1161,7 +1173,7 @@ export const FormEngine = ({
1161
1173
  if (
1162
1174
  compact &&
1163
1175
  !readOnly &&
1164
- shouldAutoCollapseCompactAllowedValueOption(options?.[optionName], val)
1176
+ shouldAutoCollapseCompactOption(options?.[optionName], val)
1165
1177
  ) {
1166
1178
  setExpandedOptions((prev) => prev.filter((name) => name !== optionName));
1167
1179
  }
@@ -1909,14 +1921,23 @@ export const FormEngine = ({
1909
1921
  //
1910
1922
  // The trailing fallbacks keep the row rendering when a server-driven
1911
1923
  // schema has not arrived yet (`type` undefined) instead of crashing.
1912
- const schemaUiType = options?.[optionName]?.ui_type as TQorusType;
1924
+ const optionSchema = options?.[optionName];
1925
+ const schemaUiType = optionSchema?.ui_type as TQorusType;
1913
1926
  const uiTypeIsAnyLike = schemaUiType === 'any' || schemaUiType === 'auto';
1914
1927
  const resolvedType =
1915
- (schemaUiType && !uiTypeIsAnyLike ? schemaUiType : undefined) ||
1928
+ (isFixedCompactAllowedValueOption(optionSchema)
1929
+ ? getOptionSchemaStorageType(optionSchema, isRendererOnly)
1930
+ : schemaUiType && !uiTypeIsAnyLike
1931
+ ? schemaUiType
1932
+ : undefined) ||
1916
1933
  type ||
1917
1934
  schemaUiType ||
1918
- (options?.[optionName]?.type as TQorusType) ||
1935
+ (optionSchema?.type as TQorusType) ||
1919
1936
  'any';
1937
+ const effectiveForceDropdown =
1938
+ forceDropdown ??
1939
+ (templateFieldProps as { forceDropdown?: boolean } | undefined)?.forceDropdown;
1940
+
1920
1941
  return (
1921
1942
  <>
1922
1943
  {(() => {
@@ -2057,8 +2078,16 @@ export const FormEngine = ({
2057
2078
  // reqraft: form-level expression fields get the Visual/Text shell
2058
2079
  // (DPQL text mode); opt out per-form via `templateFieldProps`.
2059
2080
  allowTextExpressions
2081
+ // A fixed allowed-value field still needs its selector even when
2082
+ // arbitrary custom values are forbidden. TemplateField uses this
2083
+ // flag to decide whether to mount AutoFormField at all; treating
2084
+ // `supports_custom_values: false` as "no input" left dependent
2085
+ // delivery fields (Discord server/channel, for example) as empty
2086
+ // control groups. The allowed-values renderer itself enforces the
2087
+ // fixed catalogue and never exposes free-form entry.
2060
2088
  allowCustomValues={
2061
- options?.[optionName]?.supports_custom_values !== false && resolvedType !== 'any'
2089
+ isFixedCompactAllowedValueOption(optionSchema) ||
2090
+ (options?.[optionName]?.supports_custom_values !== false && resolvedType !== 'any')
2062
2091
  }
2063
2092
  templates={templates.value}
2064
2093
  {...getTypeAndCanBeNull(
@@ -2088,6 +2117,7 @@ export const FormEngine = ({
2088
2117
  default_value={getDefaultValue(options?.[optionName])}
2089
2118
  isDefaultTemplate={options?.[optionName]?.default_view === 'template'}
2090
2119
  allowed_values={options?.[optionName]?.allowed_values}
2120
+ allowed_values_creatable={options?.[optionName]?.allowed_values_creatable}
2091
2121
  disabled={
2092
2122
  options?.[optionName]?.disabled ||
2093
2123
  readOnly ||
@@ -2105,6 +2135,7 @@ export const FormEngine = ({
2105
2135
  : undefined
2106
2136
  }
2107
2137
  {...templateFieldProps}
2138
+ forceDropdown={effectiveForceDropdown}
2108
2139
  />
2109
2140
  <OptionFieldMessages
2110
2141
  schema={options || {}}
@@ -2148,6 +2179,7 @@ export const FormEngine = ({
2148
2179
  uniqueName,
2149
2180
  componentOverrides,
2150
2181
  templateFieldProps,
2182
+ forceDropdown,
2151
2183
  availableOptions,
2152
2184
  fixedValue,
2153
2185
  // Depend on the specific `rest` values used, not the whole `rest` object
@@ -220,7 +220,8 @@ export const StyledEditCard = styled.div<{ $bg: string; $border: string }>`
220
220
 
221
221
  /* Single-value editors (operators, long-string, markdown, richtext, byte-size)
222
222
  carry the card's own "Clear value" action, so suppress the value input's
223
- built-in ReqoreInput clear — ReqoreInput has no prop to hide it, and two ✕
223
+ built-in ReqoreInput clear — ReqoreInput has no prop to hide it, and a
224
+ separate input ✕ plus the row-level trash action
224
225
  read as a duplicate. Multi-input cards (hash/list/…) are not marked single,
225
226
  so their per-sub-field clears stay. */
226
227
  &.options-readfirst-card-single .reqore-clear-input-button {
@@ -87,11 +87,10 @@ export const isOptionValueEmpty = (value: unknown): boolean =>
87
87
  value === '' ||
88
88
  (Array.isArray(value) && value.length === 0);
89
89
 
90
- export const shouldAutoCollapseCompactAllowedValueOption = (
91
- schema: TQorusFormFieldSchema | undefined,
92
- value: unknown
90
+ export const isFixedCompactAllowedValueOption = (
91
+ schema: TQorusFormFieldSchema | undefined
93
92
  ): boolean => {
94
- if (!schema || isOptionValueEmpty(value)) {
93
+ if (!schema) {
95
94
  return false;
96
95
  }
97
96
 
@@ -108,6 +107,28 @@ export const shouldAutoCollapseCompactAllowedValueOption = (
108
107
  );
109
108
  };
110
109
 
110
+ export const shouldAutoCollapseCompactAllowedValueOption = (
111
+ schema: TQorusFormFieldSchema | undefined,
112
+ value: unknown
113
+ ): boolean => !isOptionValueEmpty(value) && isFixedCompactAllowedValueOption(schema);
114
+
115
+ /** Boolean fields have three meaningful states: unset, Yes, and No. Once the
116
+ * user chooses either boolean value in compact mode, the choice is complete
117
+ * and the inline editor can close without a separate confirmation. */
118
+ export const isCompactBooleanOption = (
119
+ schema: TQorusFormFieldSchema | undefined
120
+ ): boolean => {
121
+ const type = (schema?.ui_type || schema?.type)?.toLowerCase();
122
+ return type === 'bool' || type === 'boolean';
123
+ };
124
+
125
+ export const shouldAutoCollapseCompactOption = (
126
+ schema: TQorusFormFieldSchema | undefined,
127
+ value: unknown
128
+ ): boolean =>
129
+ !isOptionValueEmpty(value) &&
130
+ (isCompactBooleanOption(schema) || isFixedCompactAllowedValueOption(schema));
131
+
111
132
  /** Prefer the matching allowed_values entry's display_name (fallback `name`)
112
133
  * over the raw stored value. */
113
134
  const findAllowedOption = (value: unknown, schema?: TQorusFormFieldSchema): any | undefined => {
@@ -23,6 +23,16 @@ const SAMPLE: IExpression = {
23
23
  const meta = {
24
24
  component: ExpressionField,
25
25
  title: 'Components/Form/Expressions/ExpressionField',
26
+ parameters: {
27
+ mockData: [
28
+ {
29
+ url: 'https://hq.qoretechnologies.com:8092/api/latest/system?action=expressions&context=ui',
30
+ method: 'GET',
31
+ status: 200,
32
+ response: mockExpressions,
33
+ },
34
+ ],
35
+ },
26
36
  args: {
27
37
  onChange: fn(),
28
38
  expressions: mockExpressions,
@@ -16,7 +16,8 @@ import { ExpressionBuilder } from './index';
16
16
  // --- inlined test helpers (equivalents of the IDE `../Tests/utils`) ---------
17
17
 
18
18
  /** Count regular expression cards (`.expression`). */
19
- const expressionCount = () => document.querySelectorAll('.expression').length;
19
+ const expressionCount = (scope: ParentNode = document) =>
20
+ scope.querySelectorAll('.expression').length;
20
21
 
21
22
  /** Click a ReqoreButton by visible label (anywhere in the document). */
22
23
  const clickButtonByLabel = async (label: string) => {
@@ -26,14 +27,14 @@ const clickButtonByLabel = async (label: string) => {
26
27
  };
27
28
 
28
29
  /** Click a button by CSS selector. */
29
- const clickSelector = async (selector: string, nth = 0) => {
30
- const els = document.querySelectorAll(selector);
30
+ const clickSelector = async (selector: string, nth = 0, scope: ParentNode = document) => {
31
+ const els = scope.querySelectorAll(selector);
31
32
  await fireEvent.click(els[nth]);
32
33
  };
33
34
 
34
35
  /** Wait for some text to appear in the document. */
35
- const waitForText = async (text: string | RegExp) => {
36
- const body = within(document.body);
36
+ const waitForText = async (text: string | RegExp, scope: HTMLElement = document.body) => {
37
+ const body = within(scope);
37
38
  await waitFor(() => expect(body.queryAllByText(text)[0]).toBeTruthy(), { timeout: 10000 });
38
39
  };
39
40
 
@@ -44,15 +45,19 @@ const waitForText = async (text: string | RegExp) => {
44
45
  * toolkit `Select` opens a `SelectFieldCollection` modal (not an inline
45
46
  * dropdown) — the item is a collection button with the display name as text.
46
47
  */
47
- const selectOperation = async (currentLabel: string, itemLabel: string) => {
48
- const body = within(document.body);
48
+ const selectOperation = async (
49
+ currentLabel: string,
50
+ itemLabel: string,
51
+ ownerDocument: Document = document
52
+ ) => {
53
+ const body = within(ownerDocument.body);
49
54
  // Open the picker (placeholder when empty, or the current operation name).
50
55
  const trigger = await body.findByText(currentLabel, undefined, { timeout: 10000 });
51
56
  await fireEvent.click(trigger);
52
57
  // Click the item inside the now-open collection modal.
53
58
  const item = await waitFor(
54
59
  () => {
55
- const el = within(document.body).queryAllByText(itemLabel)[0];
60
+ const el = within(ownerDocument.body).queryAllByText(itemLabel)[0];
56
61
  if (!el) throw new Error(`collection item "${itemLabel}" not ready`);
57
62
  return el;
58
63
  },
@@ -121,8 +126,8 @@ export const Default: Story = {
121
126
  },
122
127
  },
123
128
  },
124
- play: async () => {
125
- await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 });
129
+ play: async ({ canvasElement }) => {
130
+ await waitFor(() => expect(expressionCount(canvasElement)).toBe(1), { timeout: 10000 });
126
131
  },
127
132
  };
128
133
 
@@ -630,19 +635,19 @@ export const ExpressionCanBeWrapped: Story = {
630
635
  },
631
636
  },
632
637
  },
633
- play: async () => {
638
+ play: async ({ canvasElement }) => {
634
639
  // Seeded base op + its operand render.
635
- await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 });
636
- await waitForText('Value To Convert');
640
+ await waitFor(() => expect(expressionCount(canvasElement)).toBe(1), { timeout: 10000 });
641
+ await waitForText('Value To Convert', canvasElement);
637
642
 
638
643
  // Hover the card to reveal the floating "Wrap" picker, then wrap it in
639
644
  // `Join List Elements` — the wrapped op becomes its first operand and the
640
645
  // outer card shows "List To Join". The card count goes 1 → 2.
641
- await userEvent.hover(document.querySelectorAll('.expression')[0]);
646
+ await userEvent.hover(canvasElement.querySelectorAll('.expression')[0]);
642
647
  await sleep(500);
643
- await selectOperation('Wrap', 'Join List Elements');
644
- await waitForText('List To Join');
645
- await waitFor(() => expect(expressionCount()).toBe(2), { timeout: 10000 });
648
+ await selectOperation('Wrap', 'Join List Elements', canvasElement.ownerDocument);
649
+ await waitForText('List To Join', canvasElement);
650
+ await waitFor(() => expect(expressionCount(canvasElement)).toBe(2), { timeout: 10000 });
646
651
  },
647
652
  };
648
653
 
@@ -662,11 +667,13 @@ export const ExpressionCanBeUnwrapped: Story = {
662
667
  await ExpressionCanBeWrapped.play!(args);
663
668
 
664
669
  // Unwrap the outer op (keep the child) — the `.expression-unwrap` action.
665
- await userEvent.hover(document.querySelectorAll('.expression')[0]);
670
+ await userEvent.hover(args.canvasElement.querySelectorAll('.expression')[0]);
666
671
  await sleep(500);
672
+ // The card's actions are ReQore *floating* actions, so they are portalled out
673
+ // of the canvas — this one lookup stays document-scoped on purpose.
667
674
  await clickSelector('.expression-unwrap');
668
675
 
669
- await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 });
676
+ await waitFor(() => expect(expressionCount(args.canvasElement)).toBe(1), { timeout: 10000 });
670
677
  },
671
678
  };
672
679
 
@@ -651,7 +651,10 @@ export const Expression = ({
651
651
  return value.value?.args?.some((arg) => arg?.types_mismatch);
652
652
  }, [JSON.stringify(value)]);
653
653
 
654
- if (expressions.loading || types.loading) {
654
+ // The type hook is seeded with the complete built-in catalogue and refreshes
655
+ // it in the background. Do not hide an otherwise usable expression behind
656
+ // a skeleton while that optional refresh is in flight.
657
+ if (expressions.loading || (types.loading && !size(types.value))) {
655
658
  return <ReqorePanelSkeleton size='small' />;
656
659
  }
657
660
 
@@ -687,6 +690,11 @@ export const Expression = ({
687
690
  fluid={false}
688
691
  fixed={true}
689
692
  flat
693
+ // The operation catalogue is long and every entry carries a description,
694
+ // so it belongs in the searchable collection modal rather than an inline
695
+ // dropdown. `SelectFormField` defaults `forceDropdown` to `true`, which
696
+ // would otherwise collapse this picker into the dropdown presentation.
697
+ forceDropdown={false}
690
698
  minimal
691
699
  items={defaultItems}
692
700
  onChange={handleOperatorChange}
@@ -732,6 +740,9 @@ export const Expression = ({
732
740
  transparent: false,
733
741
  fixed: true,
734
742
  minimal: false,
743
+ // Same catalogue as the operation selector above — keep the collection
744
+ // modal rather than the `forceDropdown` default's inline dropdown.
745
+ forceDropdown: false,
735
746
  items: defaultItems,
736
747
  hideItemCount: true,
737
748
  onChange: handleWrapExpressionClick,
@@ -84,7 +84,10 @@ export const useExpressions = (
84
84
 
85
85
  return {
86
86
  expressions,
87
- loading: system.loading || extra.loading,
87
+ // `extra` is deliberately not loaded without an expressions URL. The
88
+ // generic fetch hook keeps an unstarted request in its initial loading
89
+ // state, so only include it when that request is actually enabled.
90
+ loading: system.loading || (!!expressionsUrl && extra.loading),
88
91
  error: errorOverride ?? system.error ?? extra.error,
89
92
  reload: () => {
90
93
  system.load();