@qoretechnologies/reqraft 0.10.57 → 0.10.58-pr.129.g2af0cf5

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 (131) hide show
  1. package/design/FORM_ENGINE_COMPACT.md +23 -5
  2. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  3. package/dist/components/form/engine/CompactToolbar.js +2 -2
  4. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  5. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  6. package/dist/components/form/engine/FormEngine.js +71 -62
  7. package/dist/components/form/engine/FormEngine.js.map +1 -1
  8. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  9. package/dist/components/form/engine/OptionFieldMessages.js +6 -3
  10. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  11. package/dist/components/form/engine/readFirst.d.ts +16 -20
  12. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  13. package/dist/components/form/engine/readFirst.js +16 -49
  14. package/dist/components/form/engine/readFirst.js.map +1 -1
  15. package/dist/components/form/engine/variants/VariantCalmTable.d.ts.map +1 -1
  16. package/dist/components/form/engine/variants/VariantCalmTable.js +1 -1
  17. package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -1
  18. package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -1
  19. package/dist/components/form/engine/variants/variantModel.js +8 -3
  20. package/dist/components/form/engine/variants/variantModel.js.map +1 -1
  21. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  22. package/dist/components/form/expressions/builder/index.js +22 -13
  23. package/dist/components/form/expressions/builder/index.js.map +1 -1
  24. package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
  25. package/dist/components/form/fields/array/ArrayAuto.js +8 -0
  26. package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
  27. package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
  28. package/dist/components/form/fields/array/ArrayAutoField.js +8 -0
  29. package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
  30. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  31. package/dist/components/form/fields/auto/AutoFormField.js +4 -1
  32. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  33. package/dist/components/form/fields/binary/Binary.d.ts.map +1 -1
  34. package/dist/components/form/fields/binary/Binary.js +10 -0
  35. package/dist/components/form/fields/binary/Binary.js.map +1 -1
  36. package/dist/components/form/fields/emittedValues.d.ts +38 -0
  37. package/dist/components/form/fields/emittedValues.d.ts.map +1 -0
  38. package/dist/components/form/fields/emittedValues.js +67 -0
  39. package/dist/components/form/fields/emittedValues.js.map +1 -0
  40. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  41. package/dist/components/form/fields/long-string/LongString.js +24 -8
  42. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  43. package/dist/components/form/fields/number/Number.d.ts.map +1 -1
  44. package/dist/components/form/fields/number/Number.js +9 -0
  45. package/dist/components/form/fields/number/Number.js.map +1 -1
  46. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  47. package/dist/components/form/fields/rich-text/RichText.js +10 -0
  48. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  49. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  50. package/dist/components/form/fields/template/TemplateField.js +3 -1
  51. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  52. package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
  53. package/dist/components/smartEditor/useLspSession.js +8 -0
  54. package/dist/components/smartEditor/useLspSession.js.map +1 -1
  55. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +21 -5
  56. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
  57. package/dist/components/supportTicket/InterfaceReferenceTags.js +24 -8
  58. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
  59. package/dist/components/supportTicket/ReferencePicker.d.ts +5 -4
  60. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
  61. package/dist/components/supportTicket/ReferencePicker.js +27 -2
  62. package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
  63. package/dist/components/supportTicket/meta.d.ts +16 -0
  64. package/dist/components/supportTicket/meta.d.ts.map +1 -1
  65. package/dist/components/supportTicket/meta.js +6 -1
  66. package/dist/components/supportTicket/meta.js.map +1 -1
  67. package/dist/components/supportTicket/useLoadedImages.d.ts +18 -0
  68. package/dist/components/supportTicket/useLoadedImages.d.ts.map +1 -0
  69. package/dist/components/supportTicket/useLoadedImages.js +72 -0
  70. package/dist/components/supportTicket/useLoadedImages.js.map +1 -0
  71. package/dist/components/ticketReferences/TicketReferences.d.ts +9 -5
  72. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
  73. package/dist/components/ticketReferences/TicketReferences.js +22 -9
  74. package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
  75. package/dist/components/ticketThread/TicketThread.d.ts +10 -6
  76. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  77. package/dist/components/ticketThread/TicketThread.js +2 -2
  78. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  79. package/dist/helpers/allowedValues.d.ts +71 -0
  80. package/dist/helpers/allowedValues.d.ts.map +1 -0
  81. package/dist/helpers/allowedValues.js +183 -0
  82. package/dist/helpers/allowedValues.js.map +1 -0
  83. package/dist/helpers/common.d.ts +7 -0
  84. package/dist/helpers/common.d.ts.map +1 -1
  85. package/dist/helpers/common.js +16 -1
  86. package/dist/helpers/common.js.map +1 -1
  87. package/dist/helpers/expressions.d.ts +13 -0
  88. package/dist/helpers/expressions.d.ts.map +1 -1
  89. package/dist/helpers/expressions.js +23 -1
  90. package/dist/helpers/expressions.js.map +1 -1
  91. package/dist/helpers/validations.d.ts.map +1 -1
  92. package/dist/helpers/validations.js +14 -0
  93. package/dist/helpers/validations.js.map +1 -1
  94. package/dist/utils/fetch.d.ts +4 -0
  95. package/dist/utils/fetch.d.ts.map +1 -1
  96. package/dist/utils/fetch.js +6 -1
  97. package/dist/utils/fetch.js.map +1 -1
  98. package/package.json +1 -1
  99. package/src/components/form/engine/CompactToolbar.tsx +8 -3
  100. package/src/components/form/engine/FormEngine.stories.tsx +206 -13
  101. package/src/components/form/engine/FormEngine.tsx +70 -57
  102. package/src/components/form/engine/OptionFieldMessages.tsx +7 -5
  103. package/src/components/form/engine/readFirst.ts +19 -50
  104. package/src/components/form/engine/variants/VariantCalmTable.tsx +3 -1
  105. package/src/components/form/engine/variants/variantModel.ts +10 -3
  106. package/src/components/form/expressions/builder/index.tsx +15 -2
  107. package/src/components/form/fields/array/ArrayAuto.tsx +10 -1
  108. package/src/components/form/fields/array/ArrayAutoField.tsx +10 -1
  109. package/src/components/form/fields/auto/AutoFormField.tsx +4 -1
  110. package/src/components/form/fields/binary/Binary.tsx +10 -0
  111. package/src/components/form/fields/emittedValues.ts +62 -0
  112. package/src/components/form/fields/long-string/LongString.tsx +28 -8
  113. package/src/components/form/fields/number/Number.tsx +9 -0
  114. package/src/components/form/fields/rich-text/RichText.tsx +10 -0
  115. package/src/components/form/fields/template/TemplateField.tsx +4 -1
  116. package/src/components/smartEditor/useLspSession.ts +8 -0
  117. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +86 -0
  118. package/src/components/supportTicket/InterfaceReferenceTags.tsx +44 -11
  119. package/src/components/supportTicket/ReferencePicker.stories.tsx +65 -0
  120. package/src/components/supportTicket/ReferencePicker.tsx +26 -5
  121. package/src/components/supportTicket/TicketReplyBox.stories.tsx +5 -2
  122. package/src/components/supportTicket/__fixtures__/kindMarks.ts +28 -0
  123. package/src/components/supportTicket/meta.ts +21 -0
  124. package/src/components/supportTicket/useLoadedImages.ts +73 -0
  125. package/src/components/ticketReferences/TicketReferences.tsx +27 -7
  126. package/src/components/ticketThread/TicketThread.tsx +11 -3
  127. package/src/helpers/allowedValues.ts +216 -0
  128. package/src/helpers/common.ts +14 -0
  129. package/src/helpers/expressions.ts +25 -0
  130. package/src/helpers/validations.ts +16 -0
  131. package/src/utils/fetch.ts +10 -1
@@ -2153,6 +2153,114 @@ export const CompactWithholdsFieldsWithUnmetDependencies: Story = {
2153
2153
  },
2154
2154
  };
2155
2155
 
2156
+ /** A check whose kind decides which of the rest apply — the guided check's shape. */
2157
+ const lockedRequiredCheckOptions = {
2158
+ kind: {
2159
+ type: 'string',
2160
+ ui_type: 'string',
2161
+ display_name: 'Kind',
2162
+ short_desc: 'What the check compares',
2163
+ preselected: true,
2164
+ allowed_values: [
2165
+ { value: { type: 'string', value: 'equals' }, display_name: 'Equals' },
2166
+ { value: { type: 'string', value: 'between' }, display_name: 'Between' },
2167
+ { value: { type: 'string', value: 'exists' }, display_name: 'Exists' },
2168
+ ],
2169
+ },
2170
+ expected: {
2171
+ type: 'string',
2172
+ ui_type: 'string',
2173
+ display_name: 'Expected value',
2174
+ short_desc: 'Applies once a kind is picked',
2175
+ required: true,
2176
+ depends_on: ['kind'],
2177
+ },
2178
+ min: {
2179
+ type: 'int',
2180
+ ui_type: 'int',
2181
+ display_name: 'Minimum',
2182
+ short_desc: 'Applies to Between alone',
2183
+ required: true,
2184
+ depends_on: ['kind=between'],
2185
+ },
2186
+ max: {
2187
+ type: 'int',
2188
+ ui_type: 'int',
2189
+ display_name: 'Maximum',
2190
+ short_desc: 'Applies to Between alone',
2191
+ required: true,
2192
+ depends_on: ['kind=between'],
2193
+ },
2194
+ } as IOptionsSchema;
2195
+
2196
+ export const CompactLockedRequiredNeedsNoAttention: Story = {
2197
+ parameters: {
2198
+ docs: {
2199
+ description: {
2200
+ story:
2201
+ 'Renders a check whose Expected value, Minimum and Maximum are required but locked until a kind is picked. A locked field is not listed under "Needs attention": the author can do nothing with it yet. Each stays on the form, locked, among the optional fields, saying what unlocks it. With no kind picked, nothing needs attention and the header reads Ready: the locked fields do not apply yet.',
2202
+ },
2203
+ },
2204
+ },
2205
+ args: {
2206
+ compact: true,
2207
+ minColumnWidth: '300px',
2208
+ options: lockedRequiredCheckOptions,
2209
+ value: {} as IOptions,
2210
+ },
2211
+ play: async () => {
2212
+ await _testsWaitForText('Expected value');
2213
+ await waitFor(
2214
+ () =>
2215
+ expect(
2216
+ document.querySelector(
2217
+ '.readfirst-row[data-field="expected"] .options-readfirst-lock-deps'
2218
+ )
2219
+ ).toBeTruthy(),
2220
+ { timeout: 10000 }
2221
+ );
2222
+ await expect(document.querySelector('[data-field="min"]')).toBeTruthy();
2223
+ await expect(document.querySelector('[data-field="max"]')).toBeTruthy();
2224
+ await _testsWaitForTextToNotExist('Needs attention');
2225
+ // Nothing the author can fill in is missing, so the form is Ready — the
2226
+ // locked fields do not apply until a kind is picked.
2227
+ await _testsWaitForText('Ready');
2228
+ await _testsWaitForTextToNotExist('Incomplete');
2229
+ },
2230
+ };
2231
+
2232
+ export const CompactUnlockedRequiredNeedsAttention: Story = {
2233
+ parameters: {
2234
+ docs: {
2235
+ description: {
2236
+ story:
2237
+ 'Renders the same check with the kind set to Between. Every field that kind unlocks is required and empty, so Expected value, Minimum and Maximum are all listed under "Needs attention" and the header reads Incomplete (never Draft: nothing has been changed).',
2238
+ },
2239
+ },
2240
+ },
2241
+ args: {
2242
+ compact: true,
2243
+ minColumnWidth: '300px',
2244
+ options: lockedRequiredCheckOptions,
2245
+ value: { kind: { type: 'string', value: 'between' } } as IOptions,
2246
+ },
2247
+ play: async () => {
2248
+ await _testsWaitForText('Needs attention');
2249
+ await _testsWaitForText('Incomplete');
2250
+ await _testsWaitForTextToNotExist('Draft');
2251
+ const attention = [...document.querySelectorAll('.options-readfirst-group')].find((group) =>
2252
+ (group.textContent || '').startsWith('Needs attention')
2253
+ );
2254
+ await expect(attention).toBeTruthy();
2255
+ for (const field of ['expected', 'min', 'max']) {
2256
+ await waitFor(
2257
+ () => expect(attention!.querySelector(`[data-field="${field}"]`)).toBeTruthy(),
2258
+ { timeout: 10000 }
2259
+ );
2260
+ }
2261
+ },
2262
+ };
2263
+
2156
2264
  export const CompactConditionalMessage: Story = {
2157
2265
  parameters: {
2158
2266
  docs: {
@@ -2616,6 +2724,63 @@ const _expandOptionalBox = async () => {
2616
2724
  }
2617
2725
  };
2618
2726
 
2727
+ /**
2728
+ * Put one row of the Optional box on screen: open the box, and its "N more
2729
+ * optional fields" fold if the row is behind it. A required field locked by an
2730
+ * unmet dependency waits here rather than under "Needs attention", and a box
2731
+ * that shows its preselected rows folds the rest.
2732
+ */
2733
+ const _revealOptionalRow = async (field: string): Promise<{ refold: () => Promise<void> }> => {
2734
+ await _expandOptionalBox();
2735
+ const row = () => document.querySelector(`.readfirst-row[data-field="${field}"]`);
2736
+ const opensFold = !row();
2737
+ if (opensFold) {
2738
+ await _testsClickButton({ selector: '.options-readfirst-more' });
2739
+ }
2740
+ await waitFor(() => expect(row()).toBeTruthy(), { timeout: 10000 });
2741
+ return {
2742
+ /** Close the fold again if this opened it, so the form is laid out as before. */
2743
+ refold: async () => {
2744
+ if (opensFold) {
2745
+ await fireEvent.click(document.querySelector('.options-readfirst-more') as HTMLElement);
2746
+ await waitFor(() => expect(row()).toBeNull(), { timeout: 10000 });
2747
+ }
2748
+ },
2749
+ };
2750
+ };
2751
+
2752
+ /** Every scrolled element in the page, with its scroll offsets. */
2753
+ const _scrollPositions = () =>
2754
+ new Map(
2755
+ [document.scrollingElement, ...Array.from(document.querySelectorAll('*'))]
2756
+ .filter((element): element is Element => !!element)
2757
+ .map((element) => [element, [element.scrollTop, element.scrollLeft] as const])
2758
+ );
2759
+
2760
+ /**
2761
+ * Run `check` and leave the page scrolled exactly as it was. Opening a box or a
2762
+ * fold to reach a row moves focus and scrolls the frame, and a story whose
2763
+ * snapshot is its reference frame must not end somewhere else.
2764
+ */
2765
+ const _withoutScrolling = async (check: () => Promise<void>) => {
2766
+ const before = _scrollPositions();
2767
+ await check();
2768
+ const elements = [document.scrollingElement, ...Array.from(document.querySelectorAll('*'))];
2769
+ for (const element of elements) {
2770
+ if (!element) {
2771
+ continue;
2772
+ }
2773
+ const [top, left] = before.get(element) ?? [0, 0];
2774
+ if (element.scrollTop !== top || element.scrollLeft !== left) {
2775
+ element.scrollTop = top;
2776
+ element.scrollLeft = left;
2777
+ }
2778
+ }
2779
+ (document.activeElement as HTMLElement | null)?.blur?.();
2780
+ const top = before.get(document.scrollingElement as Element)?.[0] ?? 0;
2781
+ await waitFor(() => expect(document.scrollingElement?.scrollTop ?? 0).toBe(top));
2782
+ };
2783
+
2619
2784
  // CompactSchema plus one optional (non-preselected) field, to exercise the
2620
2785
  // "Fields" menu add / select-all / reset actions.
2621
2786
  const CompactFieldsMenuSchema: Record<string, TCompactField> = {
@@ -2785,9 +2950,10 @@ export const CompactReadOnly: Story = {
2785
2950
  },
2786
2951
  play: async () => {
2787
2952
  await _testsWaitForText('order-fulfilment');
2788
- // Nothing that reports progress: no meter, no Draft/Ready, no boxes.
2953
+ // Nothing that reports progress: no meter, no Incomplete/Ready, no boxes.
2789
2954
  expect(document.querySelector('.options-readfirst-completion')).toBeNull();
2790
2955
  expect(document.querySelector('.options-readfirst-group')).toBeNull();
2956
+ await _testsWaitForTextToNotExist('Incomplete');
2791
2957
  await _testsWaitForTextToNotExist('Draft');
2792
2958
  await _testsWaitForTextToNotExist('Needs attention');
2793
2959
  await _testsWaitForTextToNotExist('Optional');
@@ -2924,9 +3090,10 @@ export const CompactBasic: Story = {
2924
3090
  value: basicFormValue,
2925
3091
  },
2926
3092
  play: async () => {
2927
- // Unresolved required/invalid fields → the header shows the Draft badge
2928
- // (the IDE restyled-hero convention).
2929
- await _testsWaitForText('Draft');
3093
+ // Unresolved required/invalid fields → the header says Incomplete. Never
3094
+ // "Draft": nobody has changed this form, and Draft means unsaved changes.
3095
+ await _testsWaitForText('Incomplete');
3096
+ await _testsWaitForTextToNotExist('Draft');
2930
3097
  // Several asserted/clicked fields (Disabled option, …) are empty optionals in
2931
3098
  // the collapsed Optional box — open it so they're on screen.
2932
3099
  await _expandOptionalBox();
@@ -2955,7 +3122,9 @@ export const CompactBasic: Story = {
2955
3122
  ).toBeFalsy();
2956
3123
 
2957
3124
  // Dependency locks are navigable: the lock's popover lists the blockers
2958
- // with their state; clicking one scrolls to + flashes it.
3125
+ // with their state; clicking one scrolls to + flashes it. The locked row is
3126
+ // required, but locked it needs no attention, so it waits in Optional.
3127
+ const lockedRow = await _revealOptionalRow('optionWithShortDescription');
2959
3128
  const depLock = document.querySelector(
2960
3129
  '.readfirst-row[data-field="optionWithShortDescription"] .options-readfirst-lock-deps'
2961
3130
  ) as HTMLElement;
@@ -2984,6 +3153,10 @@ export const CompactBasic: Story = {
2984
3153
  { timeout: 10000 }
2985
3154
  );
2986
3155
  await fireEvent.click(depLock); // close the popover
3156
+ // Fold the Optional box's extra rows back, so the rest of the play (and the
3157
+ // captured frame) sees the form laid out as before; once unlocked, the row
3158
+ // moves to Needs attention and is on screen again.
3159
+ await lockedRow.refold();
2987
3160
 
2988
3161
  // Fulfilling the dependency UNLOCKS the dependent row and flashes it.
2989
3162
  await _testsClickText('basicOption');
@@ -3612,7 +3785,7 @@ export const CompactSensitive: Story = {
3612
3785
  };
3613
3786
 
3614
3787
  // `rules: ['valid_identifier']` flows from the schema into validation: a bad
3615
- // identifier marks the form invalid (banner + Draft badge).
3788
+ // identifier marks the form invalid (banner + Incomplete status).
3616
3789
  export const CompactValidIdentifierRule: Story = {
3617
3790
  parameters: {
3618
3791
  docs: {
@@ -3641,7 +3814,7 @@ export const CompactValidIdentifierRule: Story = {
3641
3814
  await _testsWaitForText('1-bad-identifier');
3642
3815
  // The rules-driven validation marks the form as needing attention — the
3643
3816
  // dedicated "Needs attention" box (and the header link) signal it.
3644
- await _testsWaitForText('Draft');
3817
+ await _testsWaitForText('Incomplete');
3645
3818
  await _testsWaitForText('Needs attention');
3646
3819
  },
3647
3820
  };
@@ -5801,12 +5974,12 @@ export const CompactRequiredGroups: Story = {
5801
5974
  value: {} as IOptions,
5802
5975
  },
5803
5976
  play: async () => {
5804
- // All three members show the required placeholder + the Draft badge. The two
5977
+ // All three members show the required placeholder + the Incomplete status. The two
5805
5978
  // contiguous members (byHost/byFile in Connection) cluster into a rail, which
5806
5979
  // carries the grouping in place of a chip; only the lone member (byUrl in
5807
5980
  // General) keeps a "One of" chip — so exactly one chip, not three.
5808
5981
  await _testsWaitForTextsCount('—', undefined, 3);
5809
- await _testsWaitForText('Draft');
5982
+ await _testsWaitForText('Incomplete');
5810
5983
  await _testsWaitForTextsCount('One of', undefined, 1);
5811
5984
 
5812
5985
  // The chip is a ReqoreDropdown listing the siblings; selecting one flashes
@@ -5861,7 +6034,7 @@ export const CompactRequiredGroups: Story = {
5861
6034
  await _testsClickButton({ selector: '.options-readfirst-done' });
5862
6035
  await _testsWaitForText('https://example.com');
5863
6036
 
5864
- // One fulfilled member satisfies the group → the badge flips to Ready and the
6037
+ // One fulfilled member satisfies the group → the status flips to Ready and the
5865
6038
  // Once satisfied: the filled member keeps a "Covers" chip; the empty siblings
5866
6039
  // show their "Covered by 'By URL'" note INLINE (not a chip), and no "One of"
5867
6040
  // remains. So exactly one required-group chip stays (the coverer's).
@@ -6468,10 +6641,30 @@ export const CompactShowcase: Story = {
6468
6641
  await expect(document.querySelectorAll('.options-readfirst-info-panel').length).toBeGreaterThan(
6469
6642
  0
6470
6643
  );
6471
- // The unmet-dependency hint surfaces on the dependent field's row.
6472
- await _testsWaitForText(
6473
- 'This field is disabled because some dependencies are not fulfilled: "basicOption"'
6644
+ // The unmet-dependency hint surfaces on the dependent field's row — locked,
6645
+ // so in the Optional box rather than under "Needs attention". Not under
6646
+ // Needs attention is a DOM fact; reaching the row means opening the
6647
+ // Optional box's fold, which scrolls, so the frame is put back afterwards:
6648
+ // this story's snapshot is the form's top.
6649
+ const attentionBox = Array.from(document.querySelectorAll('.options-readfirst-group')).find(
6650
+ (group) => (group.textContent || '').startsWith('Needs attention')
6474
6651
  );
6652
+ await expect(
6653
+ attentionBox?.querySelector('[data-field="optionWithShortDescription"]')
6654
+ ).toBeFalsy();
6655
+ await _withoutScrolling(async () => {
6656
+ const lockedRow = await _revealOptionalRow('optionWithShortDescription');
6657
+ const optionalBox = Array.from(document.querySelectorAll('.options-readfirst-group')).find(
6658
+ (group) => (group.textContent || '').startsWith('Optional')
6659
+ );
6660
+ await expect(
6661
+ optionalBox?.querySelector('[data-field="optionWithShortDescription"]')
6662
+ ).toBeTruthy();
6663
+ await _testsWaitForText(
6664
+ 'This field is disabled because some dependencies are not fulfilled: "basicOption"'
6665
+ );
6666
+ await lockedRow.refold();
6667
+ });
6475
6668
  // ONE info mechanism: no subtitle lines, no badge — just the panel + ⓘ.
6476
6669
  await expect(document.querySelectorAll('.options-readfirst-subtitle')).toHaveLength(0);
6477
6670
  await expect(document.querySelectorAll('.options-readfirst-info-badge')).toHaveLength(0);
@@ -76,7 +76,6 @@ import {
76
76
  ITemplateFieldProps,
77
77
  TCustomTemplateItems,
78
78
  TemplateField,
79
- isValueTemplate,
80
79
  } from '../fields/template/TemplateField';
81
80
  import { CompactRow } from './CompactRow';
82
81
  import { FormFieldsSkeleton } from './FormFieldsSkeleton';
@@ -115,7 +114,6 @@ import { OptionsHelpDialog } from './OptionsHelpDialog';
115
114
  import { firstDeclaredType, isUntypedOptionType } from '../../../helpers/optionUiTypes';
116
115
  import {
117
116
  TReadFirstStatus,
118
- findAllowedValueOption,
119
117
  getFirstAttentionOptionName,
120
118
  getOptionGroup,
121
119
  getOptionGroupLabel,
@@ -522,24 +520,15 @@ export const fixOptions = (
522
520
  (fixedValue[name] as IQorusFormField)?.value === undefined
523
521
  ) {
524
522
  obj = option.default_value as IQorusFormField;
525
- } else if (
526
- option.default_view === 'expression' &&
527
- (fixedValue[name] as IQorusFormField)?.value === undefined
528
- ) {
529
- // A field the schema declares as opening in expression mode starts as an
530
- // EMPTY expression, not as its raw default. `isDefaultFunction` already
531
- // makes the renderer open that way, so without this the data disagreed
532
- // with what the operator was looking at: an expression editor whose
533
- // value was still the plain default, and no `is_expression` on the field
534
- // until the first edit.
535
- obj = {
536
- type,
537
- value: {
538
- args: [],
539
- },
540
- is_expression: true,
541
- };
542
523
  } else {
524
+ // A field declared with `default_view: 'expression'` is NOT seeded with
525
+ // an empty expression here. This runs on every load, so a seed written
526
+ // here changed a saved object merely by opening it: an existing Qog
527
+ // state gained `{args: []}` the moment its panel opened, and the editor
528
+ // autosaved a draft nobody made. The renderer already opens such a
529
+ // field in expression mode from the schema (`isDefaultFunction`), the
530
+ // first edit stores `is_expression`, and a host that creates a NEW
531
+ // object seeds the expression itself, at creation.
543
532
  obj = {
544
533
  type,
545
534
  value:
@@ -596,34 +585,15 @@ export const fixOptions = (
596
585
  };
597
586
  }
598
587
 
599
- // A value that is not one of the declared choices is dropped. What counts
600
- // as "one of the choices" is `findAllowedValueOption` — the same predicate
601
- // the read-first row uses to LABEL a value — because a value the row can
602
- // name is by definition a value the form must keep. Inlining a narrower
603
- // test here (envelope and `name`, but not a bare `value`) silently erased
604
- // every value declared the bare way: the collapsed row still showed its
605
- // display name while the editor showed "—" and the value never reached
606
- // the submitted data.
607
- // `allowed_values: []` is NOT "no value is permitted" — it is the server
608
- // saying it has no reference values to offer right now (the app-action
609
- // catalogue ships exactly that, with an
610
- // `option_reference_values_unavailable` message attached). An empty array
611
- // is truthy, so testing the array itself made every such option erase the
612
- // operator's stored value on the first render, before the
613
- // connection-refreshed schema arrived to say otherwise — and the emptied
614
- // form then autosaved over the draft, so the value was gone for good.
615
- // Only an option that actually declares choices can have a value that is
616
- // not one of them.
617
- if (
618
- newOption.value !== undefined &&
619
- options?.[optionName]?.allowed_values?.length &&
620
- !findAllowedValueOption(newOption.value, options?.[optionName]) &&
621
- !isValueTemplate(newOption.value) &&
622
- !options?.[optionName]?.multiselect &&
623
- !options?.[optionName]?.allowed_values_creatable
624
- ) {
625
- newOption.value = undefined;
626
- }
588
+ // A value that is not one of the declared choices is KEPT. It used to be
589
+ // dropped here, on load, and the dropped value was three times a real
590
+ // one — a bare-declared choice, a document naming a choice, a value whose
591
+ // choices had not arrived yet — that the emptied form then autosaved, so
592
+ // it was gone for good. Whether a value is one of the choices is now the
593
+ // validator's question (`getUnlistedChoiceReason`, asked through
594
+ // `validateFieldWithResult`): such a field is invalid, sits under "Needs
595
+ // attention" saying which value is not a choice, and the form reports
596
+ // itself invalid so the host blocks submitting it.
627
597
 
628
598
  if (
629
599
  newOption.value &&
@@ -1869,8 +1839,13 @@ const FormEngineImpl = ({
1869
1839
  ) ?
1870
1840
  schemaType
1871
1841
  : schemaType || ((fields[optionName] as IQorusFormField)?.type as TQorusType);
1842
+ // A field reports back the type it was drawn as, which for a renderer-only editor (markdown, cron,
1843
+ // dpql, ...) is the editor's name rather than the type the value is stored as. Storing that name
1844
+ // sent a description as `{type: "markdown"}`, which the server decodes by type and so parsed as
1845
+ // YAML. A type picked for an untyped field is never renderer-only, so it still wins.
1846
+ const emittedType = _type && !isRendererOnly(_type as TQorusType) ? _type : undefined;
1872
1847
  const type =
1873
- _type ||
1848
+ emittedType ||
1874
1849
  getTypeAndCanBeNull(resolvedSchemaType, options?.[optionName]?.allowed_values).type;
1875
1850
 
1876
1851
  if (!(fields as TQorusForm)[optionName]) {
@@ -1969,7 +1944,15 @@ const FormEngineImpl = ({
1969
1944
 
1970
1945
  onSingleOptionsChange?.(optionName, updatedValue[optionName]);
1971
1946
 
1972
- if (compact && !readOnly && shouldAutoCollapseCompactOption(options?.[optionName], val)) {
1947
+ if (
1948
+ compact &&
1949
+ !readOnly &&
1950
+ shouldAutoCollapseCompactOption(
1951
+ options?.[optionName],
1952
+ val,
1953
+ !!(updatedValue[optionName] as { is_expression?: boolean } | undefined)?.is_expression
1954
+ )
1955
+ ) {
1973
1956
  setExpandedOptions((prev) => prev.filter((name) => name !== optionName));
1974
1957
  }
1975
1958
 
@@ -2324,10 +2307,13 @@ const FormEngineImpl = ({
2324
2307
 
2325
2308
  const isOptionValid = useCallback(
2326
2309
  (optionName: string, type: TQorusType, optionValue: any) => {
2310
+ const isEmpty = optionValue === undefined || optionValue === '';
2311
+ // An empty field locked by an unmet `depends_on` does not apply, so it is
2312
+ // not missing — the same rule the read-first status follows.
2327
2313
  if (
2328
- !options?.[optionName]?.required &&
2329
- !options?.[optionName]?.required_groups &&
2330
- (optionValue === undefined || optionValue === '')
2314
+ isEmpty &&
2315
+ ((!options?.[optionName]?.required && !options?.[optionName]?.required_groups) ||
2316
+ dependencyLockedNames.includes(optionName))
2331
2317
  ) {
2332
2318
  return true;
2333
2319
  }
@@ -2342,7 +2328,12 @@ const FormEngineImpl = ({
2342
2328
  isFunction: (availableOptions?.[optionName] as { is_expression?: boolean })?.is_expression,
2343
2329
  } as any);
2344
2330
  },
2345
- [JSON.stringify(options), JSON.stringify(availableOptions), JSON.stringify(localValue.fields)]
2331
+ [
2332
+ JSON.stringify(options),
2333
+ JSON.stringify(availableOptions),
2334
+ JSON.stringify(localValue.fields),
2335
+ dependencyLockedNames,
2336
+ ]
2346
2337
  );
2347
2338
 
2348
2339
  const getValidityData = useCallback((): IFormValidityData => {
@@ -2366,7 +2357,17 @@ const FormEngineImpl = ({
2366
2357
 
2367
2358
  let validation: IValidationResult;
2368
2359
 
2369
- if (!isRequired && !hasRequiredGroups && isEmpty) {
2360
+ /* A required field locked by an unmet `depends_on` does not apply
2361
+ while it is locked: it is not in "Needs attention", and an empty one
2362
+ does not make the form incomplete either. Otherwise a check whose
2363
+ kind is Equals could never be saved, because Minimum — required for
2364
+ Between alone — is empty; and a form whose only gaps are fields
2365
+ nobody can fill in would say Incomplete with nothing to act on. A
2366
+ locked field that still HOLDS a value is validated as before. */
2367
+ if (
2368
+ isEmpty &&
2369
+ ((!isRequired && !hasRequiredGroups) || dependencyLockedNames.includes(optionName))
2370
+ ) {
2370
2371
  validation = { isValid: true, reasons: [] };
2371
2372
  } else {
2372
2373
  validation = validateFieldWithResult(getType(type), optionValue, {
@@ -2404,6 +2405,7 @@ const FormEngineImpl = ({
2404
2405
  JSON.stringify(options),
2405
2406
  JSON.stringify(operators),
2406
2407
  JSON.stringify(localValue.fields),
2408
+ dependencyLockedNames,
2407
2409
  ]);
2408
2410
 
2409
2411
  const validityData = useMemo(() => getValidityData(), [getValidityData]);
@@ -2487,7 +2489,14 @@ const FormEngineImpl = ({
2487
2489
  // sub-form it summarises said 0/3 set.
2488
2490
  const empty = isOptionValueEmpty(value) || isUnsetSchemaHash(value, schema);
2489
2491
  const reqGroups = (schema?.required_groups as string[] | undefined) || [];
2490
- const required = !!(schema?.required || reqGroups.length);
2492
+ // Required only while it applies. A field locked by an unmet `depends_on`
2493
+ // renders disabled with what unlocks it; nothing the author can do to it
2494
+ // is a next step — the next step is the field it depends on, which carries
2495
+ // its own status. Counting it here put a guided check's locked Path /
2496
+ // Minimum / Maximum under "Needs attention" before its kind was even
2497
+ // picked. The moment the dependency holds, it is required again.
2498
+ const required =
2499
+ !dependencyLockedNames.includes(name) && !!(schema?.required || reqGroups.length);
2491
2500
  const covered =
2492
2501
  empty &&
2493
2502
  reqGroups.some((g) => {
@@ -2512,11 +2521,15 @@ const FormEngineImpl = ({
2512
2521
  isOptionValid,
2513
2522
  requiredGroupsInfo,
2514
2523
  schemaMsgIntent,
2524
+ dependencyLockedNames,
2515
2525
  ]
2516
2526
  );
2517
2527
  const getOptionBucket = useCallback(
2518
2528
  (name: string, hidden = false): 'attention' | 'set' | 'optional' => {
2519
- if (!hidden) {
2529
+ // A locked one-of member cannot be the one that satisfies its group, so it
2530
+ // does not travel with the group into "Needs attention"; its own status
2531
+ // (not required while locked) decides its box.
2532
+ if (!hidden && !dependencyLockedNames.includes(name)) {
2520
2533
  const reqGroups = (options?.[name]?.required_groups as string[] | undefined) || [];
2521
2534
  if (reqGroups.length) {
2522
2535
  return reqGroups.some((g) => !requiredGroupsInfo.satisfiedBy[g]) ? 'attention' : 'set';
@@ -2524,7 +2537,7 @@ const FormEngineImpl = ({
2524
2537
  }
2525
2538
  return getReadFirstBucket(getOptionStatus(name, hidden));
2526
2539
  },
2527
- [JSON.stringify(options), requiredGroupsInfo, getOptionStatus]
2540
+ [JSON.stringify(options), requiredGroupsInfo, getOptionStatus, dependencyLockedNames]
2528
2541
  );
2529
2542
  // How many fields are in the "Needs attention" box — drives the header link.
2530
2543
  const readFirstAttentionCount = useMemo(
@@ -358,6 +358,9 @@ export const getOptionFieldMessages = ({
358
358
  }: IOptionFieldMessagesProps): IReqoreTagProps[] => {
359
359
  const optionSchema = schema[name];
360
360
  const result: IReqoreTagProps[] = [];
361
+ const locked =
362
+ !!optionSchema?.depends_on &&
363
+ !hasAllDependenciesFullfilled(optionSchema.depends_on, allOptions, schema);
361
364
 
362
365
  if (option.value || option.value === false || option.value === 0 || option.value === null) {
363
366
  /* A concrete type the VALUE carries beats an untyped schema.
@@ -392,7 +395,9 @@ export const getOptionFieldMessages = ({
392
395
  });
393
396
  }
394
397
  } else {
395
- if (optionSchema?.required && !untouched) {
398
+ // A locked field is not asked for: the dependency message below says what
399
+ // unlocks it, and until then there is nothing to fill in.
400
+ if (optionSchema?.required && !untouched && !locked) {
396
401
  result.push({ label: 'This field is required', intent: 'danger' });
397
402
  }
398
403
 
@@ -413,10 +418,7 @@ export const getOptionFieldMessages = ({
413
418
  }
414
419
  }
415
420
 
416
- if (
417
- optionSchema?.depends_on &&
418
- !hasAllDependenciesFullfilled(optionSchema.depends_on, allOptions, schema)
419
- ) {
421
+ if (locked) {
420
422
  const dependsOn = describeDependencies(optionSchema.depends_on, schema);
421
423
 
422
424
  result.push({
@@ -11,6 +11,7 @@ import { IExpressionValue } from '../expressions/types';
11
11
  import yaml from 'js-yaml';
12
12
  import { formatTimeoutValue, richtextToString } from '../../../helpers/common';
13
13
  import { firstDeclaredType } from '../../../helpers/optionUiTypes';
14
+ import { findAllowedValueOption } from '../../../helpers/allowedValues';
14
15
 
15
16
  /** "N noun" with naive pluralisation ("1 item", "2 items"). */
16
17
  const pluralize = (count: number, noun: string): string =>
@@ -244,65 +245,33 @@ export const isCompactBooleanOption = (schema: TQorusFormFieldSchema | undefined
244
245
  return type === 'bool' || type === 'boolean';
245
246
  };
246
247
 
248
+ /**
249
+ * Whether a change completes a compact row, so its editor can close.
250
+ *
251
+ * Only a VALUE does: Yes or No, or one of a fixed list. An expression is
252
+ * built one step at a time - an operation, then each of its arguments - and
253
+ * every step is a change; closing the editor on the first one left a bool
254
+ * field's condition at `null > null`, with no way to fill in its arguments
255
+ * short of opening the row again for each of them.
256
+ *
257
+ * @param isExpression the row now holds an expression (`is_expression`)
258
+ */
247
259
  export const shouldAutoCollapseCompactOption = (
248
260
  schema: TQorusFormFieldSchema | undefined,
249
- value: unknown
261
+ value: unknown,
262
+ isExpression?: boolean
250
263
  ): boolean =>
264
+ !isExpression &&
251
265
  !isOptionValueEmpty(value) &&
252
266
  (isCompactBooleanOption(schema) || isFixedCompactAllowedValueOption(schema));
253
267
 
254
268
  /**
255
269
  * Prefer the matching allowed_values entry's display_name (fallback `name`)
256
- * over the raw stored value.
257
- *
258
- * A LIST carries its options under `element_allowed_values` — they constrain
259
- * each element, not the list itself — so a multi-select that was missing here
260
- * printed what it stores rather than what you picked: `orders, batch` for
261
- * fields whose picker reads "Orders, Batch". The gap shows worst exactly where
262
- * allowed values earn their keep, since the stored form is often not readable
263
- * at all (a permission code, `PO_REQUIRE_TYPES`, an app-specific id).
264
- *
265
- * Exported (as `findAllowedValueOption`) because the form engine's value
266
- * validation has to answer the SAME question — "is this stored value one of the
267
- * declared choices?" — and answering it differently loses data. An
268
- * `allowed_values` entry is written three ways: an envelope (`{value: {type,
269
- * value}}`), a bare value (`{value: 'default'}`) or a named entry
270
- * (`{name: 'default'}`). This has always accepted all three; the engine's
271
- * clearing guard accepted only the first and the third, so a schema using the
272
- * bare form had its value ERASED on load while this function went on rendering
273
- * the display name for it — the row read "Default RBAC" collapsed and "—" when
274
- * opened, and the value was gone from the submitted data.
270
+ * over the raw stored value. The predicate lives in `helpers/allowedValues`,
271
+ * where the validator uses it too, so a value the row can name is exactly a
272
+ * value the form accepts.
275
273
  */
276
- export const findAllowedValueOption = (
277
- value: unknown,
278
- schema?: TQorusFormFieldSchema
279
- ): any | undefined => {
280
- const s = schema as
281
- { allowed_values?: any[]; element_allowed_values?: any[]; items?: any[] } | undefined;
282
- const options =
283
- (s?.allowed_values?.length && s.allowed_values) ||
284
- (s?.element_allowed_values?.length && s.element_allowed_values) ||
285
- s?.items;
286
- if (!options?.length) {
287
- return undefined;
288
- }
289
- // A stored element can be the bare value or the typed `{type, value}`
290
- // envelope the form engine round-trips; match either against either.
291
- const stored =
292
- value && typeof value === 'object' && 'value' in (value as Record<string, unknown>) ?
293
- (value as Record<string, unknown>).value
294
- : value;
295
-
296
- return options.find(
297
- (option) =>
298
- option?.value?.value === value ||
299
- option?.value === value ||
300
- option?.name === value ||
301
- option?.value?.value === stored ||
302
- option?.value === stored ||
303
- option?.name === stored
304
- );
305
- };
274
+ export { findAllowedValueOption };
306
275
 
307
276
  const getAllowedValueLabel = (
308
277
  value: unknown,
@@ -112,7 +112,9 @@ const Meter = ({ form }: { form: TVariantForm }) => {
112
112
  color: (s.attention ? c.warning : c.success) as never,
113
113
  }}
114
114
  >
115
- {s.attention ? 'Draft' : 'Ready'}
115
+ {/* What the form lacks, not whether it has unsaved changes:
116
+ "Draft" is the batched mode's per-row chip alone. */}
117
+ {s.attention ? 'Incomplete' : 'Ready'}
116
118
  </ReqoreP>
117
119
  <span>
118
120
  {s.set} of {s.total} set