@qoretechnologies/reqraft 0.10.18 → 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 +44 -18
  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 +7 -0
  12. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  13. package/dist/components/form/engine/readFirst.js +32 -1
  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 +51 -8
  47. package/src/components/form/engine/compactRowStyles.ts +2 -1
  48. package/src/components/form/engine/readFirst.ts +42 -0
  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,6 +101,8 @@ import {
101
101
  getReadFirstCompletion,
102
102
  getReadFirstStatus,
103
103
  isOptionValueEmpty,
104
+ isFixedCompactAllowedValueOption,
105
+ shouldAutoCollapseCompactOption,
104
106
  } from './readFirst';
105
107
 
106
108
  // Re-export types for consumers
@@ -281,7 +283,9 @@ const getOptionSchemaStorageType = (
281
283
  option?: TQorusFormFieldSchema,
282
284
  isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
283
285
  ): TQorusType =>
284
- ((isRendererOnly(option?.ui_type)
286
+ ((isFixedCompactAllowedValueOption(option)
287
+ ? option?.type || option?.ui_type
288
+ : isRendererOnly(option?.ui_type)
285
289
  ? option?.type || option?.ui_type
286
290
  : option?.ui_type || option?.type) || 'any') as TQorusType;
287
291
 
@@ -555,6 +559,11 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
555
559
  /** Opt-in: fetch global templates from `system/getContextData` for this context. */
556
560
  interfaceContext?: string;
557
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;
558
567
  showTypeToggle?: boolean;
559
568
  /**
560
569
  * Compact (read-first) mode: each option renders as a row with its formatted
@@ -706,6 +715,7 @@ export const FormEngine = ({
706
715
  allowTemplates = true,
707
716
  interfaceContext,
708
717
  templateFieldProps,
718
+ forceDropdown,
709
719
  showTypeToggle = true,
710
720
  compact,
711
721
  compactFlush = false,
@@ -835,9 +845,12 @@ export const FormEngine = ({
835
845
  // same tick targets the stale (pre-move) layout, so the page doesn't budge.
836
846
  // A rAF lets the new position settle first.
837
847
  requestAnimationFrame(() => {
838
- document
839
- .querySelector(`.readfirst-row[data-field="${optionNames[0]}"]`)
840
- ?.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
+ }
841
854
  });
842
855
  }
843
856
  setFlashedOptions(optionNames);
@@ -1157,6 +1170,14 @@ export const FormEngine = ({
1157
1170
 
1158
1171
  onSingleOptionsChange?.(optionName, updatedValue[optionName]);
1159
1172
 
1173
+ if (
1174
+ compact &&
1175
+ !readOnly &&
1176
+ shouldAutoCollapseCompactOption(options?.[optionName], val)
1177
+ ) {
1178
+ setExpandedOptions((prev) => prev.filter((name) => name !== optionName));
1179
+ }
1180
+
1160
1181
  return {
1161
1182
  fields: updatedValue,
1162
1183
  meta,
@@ -1167,6 +1188,8 @@ export const FormEngine = ({
1167
1188
  onSingleOptionsChange,
1168
1189
  onDependableOptionChange,
1169
1190
  isRendererOnly,
1191
+ compact,
1192
+ readOnly,
1170
1193
  JSON.stringify(options),
1171
1194
  JSON.stringify(operators),
1172
1195
  ]
@@ -1898,14 +1921,23 @@ export const FormEngine = ({
1898
1921
  //
1899
1922
  // The trailing fallbacks keep the row rendering when a server-driven
1900
1923
  // schema has not arrived yet (`type` undefined) instead of crashing.
1901
- const schemaUiType = options?.[optionName]?.ui_type as TQorusType;
1924
+ const optionSchema = options?.[optionName];
1925
+ const schemaUiType = optionSchema?.ui_type as TQorusType;
1902
1926
  const uiTypeIsAnyLike = schemaUiType === 'any' || schemaUiType === 'auto';
1903
1927
  const resolvedType =
1904
- (schemaUiType && !uiTypeIsAnyLike ? schemaUiType : undefined) ||
1928
+ (isFixedCompactAllowedValueOption(optionSchema)
1929
+ ? getOptionSchemaStorageType(optionSchema, isRendererOnly)
1930
+ : schemaUiType && !uiTypeIsAnyLike
1931
+ ? schemaUiType
1932
+ : undefined) ||
1905
1933
  type ||
1906
1934
  schemaUiType ||
1907
- (options?.[optionName]?.type as TQorusType) ||
1935
+ (optionSchema?.type as TQorusType) ||
1908
1936
  'any';
1937
+ const effectiveForceDropdown =
1938
+ forceDropdown ??
1939
+ (templateFieldProps as { forceDropdown?: boolean } | undefined)?.forceDropdown;
1940
+
1909
1941
  return (
1910
1942
  <>
1911
1943
  {(() => {
@@ -2046,8 +2078,16 @@ export const FormEngine = ({
2046
2078
  // reqraft: form-level expression fields get the Visual/Text shell
2047
2079
  // (DPQL text mode); opt out per-form via `templateFieldProps`.
2048
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.
2049
2088
  allowCustomValues={
2050
- options?.[optionName]?.supports_custom_values !== false && resolvedType !== 'any'
2089
+ isFixedCompactAllowedValueOption(optionSchema) ||
2090
+ (options?.[optionName]?.supports_custom_values !== false && resolvedType !== 'any')
2051
2091
  }
2052
2092
  templates={templates.value}
2053
2093
  {...getTypeAndCanBeNull(
@@ -2077,6 +2117,7 @@ export const FormEngine = ({
2077
2117
  default_value={getDefaultValue(options?.[optionName])}
2078
2118
  isDefaultTemplate={options?.[optionName]?.default_view === 'template'}
2079
2119
  allowed_values={options?.[optionName]?.allowed_values}
2120
+ allowed_values_creatable={options?.[optionName]?.allowed_values_creatable}
2080
2121
  disabled={
2081
2122
  options?.[optionName]?.disabled ||
2082
2123
  readOnly ||
@@ -2094,6 +2135,7 @@ export const FormEngine = ({
2094
2135
  : undefined
2095
2136
  }
2096
2137
  {...templateFieldProps}
2138
+ forceDropdown={effectiveForceDropdown}
2097
2139
  />
2098
2140
  <OptionFieldMessages
2099
2141
  schema={options || {}}
@@ -2137,6 +2179,7 @@ export const FormEngine = ({
2137
2179
  uniqueName,
2138
2180
  componentOverrides,
2139
2181
  templateFieldProps,
2182
+ forceDropdown,
2140
2183
  availableOptions,
2141
2184
  fixedValue,
2142
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,6 +87,48 @@ export const isOptionValueEmpty = (value: unknown): boolean =>
87
87
  value === '' ||
88
88
  (Array.isArray(value) && value.length === 0);
89
89
 
90
+ export const isFixedCompactAllowedValueOption = (
91
+ schema: TQorusFormFieldSchema | undefined
92
+ ): boolean => {
93
+ if (!schema) {
94
+ return false;
95
+ }
96
+
97
+ const selectableSchema = schema as TQorusFormFieldSchema & { items?: unknown[] };
98
+ const options = selectableSchema.allowed_values?.length
99
+ ? selectableSchema.allowed_values
100
+ : selectableSchema.items;
101
+
102
+ return !!(
103
+ options?.length &&
104
+ !schema.allowed_values_creatable &&
105
+ !schema.multiselect &&
106
+ !schema.arg_schema
107
+ );
108
+ };
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
+
90
132
  /** Prefer the matching allowed_values entry's display_name (fallback `name`)
91
133
  * over the raw stored value. */
92
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();