@qoretechnologies/reqraft 0.10.41-pr.95.g294e5aa → 0.10.41

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 (137) hide show
  1. package/.claude/CLAUDE.md +12 -0
  2. package/.tasks/INDEX.md +1 -0
  3. package/.tasks/TEMPLATE_TOKEN_GRAMMAR.md +72 -0
  4. package/dist/components/codeSize/index.d.ts +37 -0
  5. package/dist/components/codeSize/index.d.ts.map +1 -0
  6. package/dist/components/codeSize/index.js +87 -0
  7. package/dist/components/codeSize/index.js.map +1 -0
  8. package/dist/components/form/engine/CompactRow.d.ts +20 -1
  9. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  10. package/dist/components/form/engine/CompactRow.js +284 -42
  11. package/dist/components/form/engine/CompactRow.js.map +1 -1
  12. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  13. package/dist/components/form/engine/CompactToolbar.js +72 -72
  14. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  15. package/dist/components/form/engine/FormEngine.d.ts +48 -1
  16. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  17. package/dist/components/form/engine/FormEngine.js +163 -75
  18. package/dist/components/form/engine/FormEngine.js.map +1 -1
  19. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  20. package/dist/components/form/engine/_structuredData/SchemaDataView.js +102 -87
  21. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  22. package/dist/components/form/engine/compactRowStyles.d.ts +2 -0
  23. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  24. package/dist/components/form/engine/compactRowStyles.js +22 -5
  25. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  26. package/dist/components/form/engine/compactToolbarContext.d.ts +8 -0
  27. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  28. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  29. package/dist/components/form/engine/readFirst.d.ts +57 -1
  30. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  31. package/dist/components/form/engine/readFirst.js +143 -4
  32. package/dist/components/form/engine/readFirst.js.map +1 -1
  33. package/dist/components/form/fields/Field.d.ts.map +1 -1
  34. package/dist/components/form/fields/Field.js +1 -1
  35. package/dist/components/form/fields/Field.js.map +1 -1
  36. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +12 -2
  37. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  38. package/dist/components/form/fields/allowed-values/AllowedValues.js +16 -6
  39. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  40. package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
  41. package/dist/components/form/fields/array/ArrayAuto.js +44 -26
  42. package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
  43. package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
  44. package/dist/components/form/fields/array/ArrayAutoField.js +42 -25
  45. package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
  46. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  47. package/dist/components/form/fields/auto/AutoFormField.js +32 -4
  48. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  49. package/dist/components/form/fields/long-string/LongString.d.ts +9 -1
  50. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  51. package/dist/components/form/fields/long-string/LongString.js +25 -4
  52. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  53. package/dist/components/form/fields/template/ExampleValueModal.d.ts +29 -0
  54. package/dist/components/form/fields/template/ExampleValueModal.d.ts.map +1 -0
  55. package/dist/components/form/fields/template/ExampleValueModal.js +122 -0
  56. package/dist/components/form/fields/template/ExampleValueModal.js.map +1 -0
  57. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  58. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -3
  59. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  60. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  61. package/dist/components/form/fields/template/TemplateField.js +35 -6
  62. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  63. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts +2 -0
  64. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts.map +1 -0
  65. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js +9 -0
  66. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js.map +1 -0
  67. package/dist/components/form/index.d.ts +1 -0
  68. package/dist/components/form/index.d.ts.map +1 -1
  69. package/dist/components/form/index.js +1 -0
  70. package/dist/components/form/index.js.map +1 -1
  71. package/dist/helpers/common.d.ts +8 -1
  72. package/dist/helpers/common.d.ts.map +1 -1
  73. package/dist/helpers/common.js +9 -1
  74. package/dist/helpers/common.js.map +1 -1
  75. package/dist/helpers/optionUiTypes.d.ts +30 -0
  76. package/dist/helpers/optionUiTypes.d.ts.map +1 -0
  77. package/dist/helpers/optionUiTypes.js +148 -0
  78. package/dist/helpers/optionUiTypes.js.map +1 -0
  79. package/dist/helpers/options.d.ts +19 -0
  80. package/dist/helpers/options.d.ts.map +1 -1
  81. package/dist/helpers/options.js +25 -1
  82. package/dist/helpers/options.js.map +1 -1
  83. package/dist/helpers/providerValue.d.ts +68 -0
  84. package/dist/helpers/providerValue.d.ts.map +1 -0
  85. package/dist/helpers/providerValue.js +50 -0
  86. package/dist/helpers/providerValue.js.map +1 -0
  87. package/dist/helpers/singleLineString.d.ts +37 -0
  88. package/dist/helpers/singleLineString.d.ts.map +1 -0
  89. package/dist/helpers/singleLineString.js +66 -0
  90. package/dist/helpers/singleLineString.js.map +1 -0
  91. package/dist/helpers/templates.d.ts +100 -0
  92. package/dist/helpers/templates.d.ts.map +1 -1
  93. package/dist/helpers/templates.js +237 -4
  94. package/dist/helpers/templates.js.map +1 -1
  95. package/dist/helpers/validations.d.ts +103 -2
  96. package/dist/helpers/validations.d.ts.map +1 -1
  97. package/dist/helpers/validations.js +858 -91
  98. package/dist/helpers/validations.js.map +1 -1
  99. package/dist/index.d.ts +2 -1
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +3 -1
  102. package/dist/index.js.map +1 -1
  103. package/package.json +1 -1
  104. package/src/components/codeSize/index.tsx +79 -0
  105. package/src/components/form/engine/CompactRow.tsx +407 -34
  106. package/src/components/form/engine/CompactToolbar.tsx +105 -98
  107. package/src/components/form/engine/FormEngine.stories.tsx +863 -12
  108. package/src/components/form/engine/FormEngine.tsx +183 -33
  109. package/src/components/form/engine/_structuredData/SchemaDataView.stories.tsx +110 -103
  110. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +132 -80
  111. package/src/components/form/engine/compactRowStyles.ts +44 -1
  112. package/src/components/form/engine/compactToolbarContext.ts +8 -0
  113. package/src/components/form/engine/readFirst.ts +163 -4
  114. package/src/components/form/fields/Field.tsx +3 -1
  115. package/src/components/form/fields/allowed-values/AllowedValues.tsx +24 -2
  116. package/src/components/form/fields/array/ArrayAuto.tsx +23 -4
  117. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +134 -0
  118. package/src/components/form/fields/array/ArrayAutoField.tsx +22 -4
  119. package/src/components/form/fields/auto/AutoFormField.tsx +31 -1
  120. package/src/components/form/fields/long-string/LongString.tsx +46 -5
  121. package/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx +5 -0
  122. package/src/components/form/fields/template/ExampleValueModal.stories.tsx +114 -0
  123. package/src/components/form/fields/template/ExampleValueModal.tsx +99 -0
  124. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +8 -4
  125. package/src/components/form/fields/template/TemplateField.stories.tsx +314 -1
  126. package/src/components/form/fields/template/TemplateField.tsx +47 -8
  127. package/src/components/form/fields/template/__fixtures__/longExampleValue.ts +7 -0
  128. package/src/components/form/index.tsx +1 -0
  129. package/src/helpers/common.ts +11 -1
  130. package/src/helpers/optionUiTypes.ts +153 -0
  131. package/src/helpers/options.ts +23 -0
  132. package/src/helpers/providerValue.ts +125 -0
  133. package/src/helpers/singleLineString.ts +61 -0
  134. package/src/helpers/templates.ts +272 -3
  135. package/src/helpers/validations.ts +1177 -109
  136. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +23 -2
  137. package/src/index.tsx +2 -0
@@ -2,7 +2,7 @@ import { ReqoreInput } from '@qoretechnologies/reqore';
2
2
  import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes';
3
3
  import { IQorusFormSchema } from '@qoretechnologies/ts-toolkit';
4
4
  import { Meta, StoryObj } from '@storybook/react-vite';
5
- import { ChangeEvent, useState } from 'react';
5
+ import { ChangeEvent, useEffect, useState } from 'react';
6
6
  import { expect, fireEvent, fn, userEvent, waitFor, within } from 'storybook/test';
7
7
  import { validateField } from '../../../helpers/validations';
8
8
  import { defaultQorusTypes } from '../../../hooks/useQorusTypes';
@@ -786,6 +786,27 @@ const CodeEditorStandin = ({
786
786
  // (rendered by a host-injected code-editor) inherits `language` from
787
787
  // a sibling `lang` picker, so flipping the picker live-changes the
788
788
  // editor's syntax highlighting with no refetch.
789
+ /**
790
+ * The same stand-in without the "syntax: <language>" tag.
791
+ *
792
+ * That tag exists to prove an inherited `language` prop reaches the renderer,
793
+ * and the stories that assert it keep it. Where the row already shows a Language
794
+ * control directly above the editor, it is the same fact stated twice — which is
795
+ * what the review flagged on `CompactRowAbsorbsLanguage`.
796
+ */
797
+ const CodeEditorStandinNoSyntax = (props: Parameters<typeof CodeEditorStandin>[0]) => (
798
+ <div data-testid='code-editor-mock'>
799
+ <ReqoreInput
800
+ fluid
801
+ size={props.size}
802
+ icon='CodeLine'
803
+ placeholder='Source code (stand-in code-editor)'
804
+ value={typeof props.value === 'string' ? props.value : ''}
805
+ onChange={(event: ChangeEvent<HTMLInputElement>) => props.onChange?.(event.target.value)}
806
+ />
807
+ </div>
808
+ );
809
+
789
810
  export const OptionInheritsRenderPropFromSibling: Story = {
790
811
  parameters: {
791
812
  docs: {
@@ -912,19 +933,182 @@ export const OptionInheritsRenderPropFromSiblingCompact: Story = {
912
933
  // cell couldn't provide on its own. Locks the compact preview so a future
913
934
  // CompactRow refactor can't silently reduce a Qorus source-code field to an
914
935
  // ellipsised one-liner again.
936
+ // An open field used to have exactly one way out — the green Done check — so
937
+ // every exit committed, Escape included. This locks the Cancel affordance in
938
+ // place: it appears only once there is something to discard, and it puts the
939
+ // value back to what the field was opened with.
940
+ export const CompactRowCancelEdit: Story = {
941
+ parameters: {
942
+ docs: {
943
+ description: {
944
+ story:
945
+ 'Renders a compact row opened for editing with a changed value — the Cancel action appears beside Done, and clicking it puts the field back to the value it was opened with and closes it.',
946
+ },
947
+ },
948
+ },
949
+ args: {
950
+ compact: true,
951
+ minColumnWidth: '360px',
952
+ value: { cookie_name: { type: 'string', value: 'my-cookie' } },
953
+ options: {
954
+ cookie_name: {
955
+ type: 'string',
956
+ ui_type: 'string',
957
+ display_name: 'Cookie Name',
958
+ short_desc: 'Cookie name for cookie authentication',
959
+ },
960
+ },
961
+ },
962
+ play: async ({ canvasElement }) => {
963
+ // Open the field.
964
+ const row = canvasElement.querySelector<HTMLElement>('[data-field="cookie_name"]');
965
+ expect(row).toBeTruthy();
966
+ fireEvent.click(row!);
967
+ await waitFor(() =>
968
+ expect(canvasElement.querySelector('.readfirst-row-editing')).toBeTruthy()
969
+ );
970
+
971
+ // An untouched field offers no Cancel — it would do exactly what Done does.
972
+ expect(canvasElement.querySelector('.options-readfirst-cancel')).toBeNull();
973
+
974
+ const input = canvasElement.querySelector<HTMLInputElement>(
975
+ '[data-field="cookie_name"] input, [data-field="cookie_name"] textarea'
976
+ );
977
+ expect(input).toBeTruthy();
978
+ fireEvent.change(input!, { target: { value: 'changed-cookie' } });
979
+
980
+ // Cancel appears the moment the value differs from what was opened.
981
+ await waitFor(
982
+ () => expect(canvasElement.querySelector('.options-readfirst-cancel')).toBeTruthy(),
983
+ { timeout: 5000 }
984
+ );
985
+
986
+ fireEvent.click(canvasElement.querySelector<HTMLElement>('.options-readfirst-cancel')!);
987
+
988
+ // The row closes and the opened-with value is back.
989
+ await waitFor(() => expect(canvasElement.querySelector('.readfirst-row-editing')).toBeNull(), {
990
+ timeout: 5000,
991
+ });
992
+ await waitFor(
993
+ () =>
994
+ expect(
995
+ canvasElement.querySelector('[data-field="cookie_name"]')?.textContent ?? ''
996
+ ).toContain('my-cookie'),
997
+ { timeout: 5000 }
998
+ );
999
+ },
1000
+ };
1001
+
1002
+ // Language and Source code are one decision — "what is this code, and in what
1003
+ // language" — and the form used to ask it as two unrelated rows one above the
1004
+ // other. `absorb_fields` lets the editor take the language into its own
1005
+ // container so the pair reads as the single element it is.
1006
+ export const CompactRowAbsorbsLanguage: Story = {
1007
+ parameters: {
1008
+ docs: {
1009
+ description: {
1010
+ story:
1011
+ 'Renders a code-editor row that absorbs its Language sibling — the language control sits inside the Source Code container above the editor instead of on a row of its own, and the collapsed row summarises it as a chip beside the code size.',
1012
+ },
1013
+ },
1014
+ },
1015
+ args: {
1016
+ compact: true,
1017
+ minColumnWidth: '360px',
1018
+ componentOverrides: { 'code-editor': CodeEditorStandinNoSyntax },
1019
+ value: {
1020
+ language: { type: 'string', value: 'qore' },
1021
+ source: { type: 'string', value: '%new-style\nclass Example {\n}\n' },
1022
+ },
1023
+ options: {
1024
+ language: {
1025
+ type: 'string',
1026
+ ui_type: 'string',
1027
+ display_name: 'Language',
1028
+ allowed_values: [
1029
+ { display_name: 'Qore', value: { type: 'string', value: 'qore' } },
1030
+ { display_name: 'Python', value: { type: 'string', value: 'python' } },
1031
+ ],
1032
+ },
1033
+ source: {
1034
+ type: 'string',
1035
+ ui_type: 'code-editor',
1036
+ display_name: 'Source Code',
1037
+ inherit_props: { language: 'language' },
1038
+ absorb_fields: ['language'],
1039
+ },
1040
+ },
1041
+ },
1042
+ play: async ({ canvasElement }) => {
1043
+ // The absorbed field has no row of its own.
1044
+ await waitFor(() => expect(canvasElement.querySelector('[data-field="source"]')).toBeTruthy());
1045
+ expect(canvasElement.querySelector('[data-field="language"]')).toBeNull();
1046
+
1047
+ // Collapsed, the host still reports the absorbed value — otherwise the
1048
+ // language would vanish from the form whenever the editor is closed.
1049
+ const sourceRow = canvasElement.querySelector('[data-field="source"]');
1050
+ expect(sourceRow?.textContent ?? '').toContain('qore');
1051
+
1052
+ // Opening the host renders the language control inside its container.
1053
+ fireEvent.click(sourceRow as HTMLElement);
1054
+ await waitFor(() =>
1055
+ expect(canvasElement.querySelector('.readfirst-row-editing')).toBeTruthy()
1056
+ );
1057
+ await waitFor(() =>
1058
+ expect(canvasElement.querySelector('.options-readfirst-absorbed')).toBeTruthy()
1059
+ );
1060
+ // It is still identifiable as its own field, not a property of the editor.
1061
+ expect(canvasElement.querySelector('.options-readfirst-absorbed')?.textContent ?? '').toContain(
1062
+ 'Language'
1063
+ );
1064
+ },
1065
+ };
1066
+
1067
+ export const CompactRowCancelEditAffordance: Story = {
1068
+ ...CompactRowCancelEdit,
1069
+ parameters: {
1070
+ docs: {
1071
+ description: {
1072
+ story:
1073
+ 'Renders a compact row open for editing with a changed value, stopping while it is open — Cancel edit sits beside the green Done check, which is what an open field used to offer on its own.',
1074
+ },
1075
+ },
1076
+ },
1077
+ play: async ({ canvasElement }) => {
1078
+ const row = canvasElement.querySelector<HTMLElement>('[data-field="cookie_name"]');
1079
+ expect(row).toBeTruthy();
1080
+ fireEvent.click(row!);
1081
+ await waitFor(() =>
1082
+ expect(canvasElement.querySelector('.readfirst-row-editing')).toBeTruthy()
1083
+ );
1084
+
1085
+ const input = canvasElement.querySelector<HTMLInputElement>(
1086
+ '[data-field="cookie_name"] input, [data-field="cookie_name"] textarea'
1087
+ );
1088
+ fireEvent.change(input!, { target: { value: 'changed-cookie' } });
1089
+
1090
+ await waitFor(
1091
+ () => expect(canvasElement.querySelector('.options-readfirst-cancel')).toBeTruthy(),
1092
+ { timeout: 5000 }
1093
+ );
1094
+ // Both ways out are on screen together, which is the point of the story.
1095
+ expect(canvasElement.querySelector('.options-readfirst-done')).toBeTruthy();
1096
+ },
1097
+ };
1098
+
915
1099
  export const CompactRowCodeEditorPreview: Story = {
916
1100
  parameters: {
917
1101
  docs: {
918
1102
  description: {
919
1103
  story:
920
- 'Renders a compact-mode code-editor row over a multi-line Qore source value — the value cell replaces the truncated string with a lines/chars summary tag and a collapsible monospace preview mounts under the row.',
1104
+ 'Renders a compact-mode code-editor row over a multi-line Qore source value — the value cell replaces the truncated string with a monospaced size note under the field name and a collapsible monospace preview mounts under the row. The stand-in prints no "syntax:" line: this row carries a Language control directly above the editor, so it would be the same fact stated twice.',
921
1105
  },
922
1106
  },
923
1107
  },
924
1108
  args: {
925
1109
  compact: true,
926
1110
  minColumnWidth: '360px',
927
- componentOverrides: { 'code-editor': CodeEditorStandin },
1111
+ componentOverrides: { 'code-editor': CodeEditorStandinNoSyntax },
928
1112
  value: {
929
1113
  language: { type: 'string', value: 'qore' },
930
1114
  source: {
@@ -980,6 +1164,132 @@ export const CompactRowCodeEditorPreview: Story = {
980
1164
  },
981
1165
  };
982
1166
 
1167
+ export const CompactRowCodeEditorMessage: Story = {
1168
+ parameters: {
1169
+ docs: {
1170
+ description: {
1171
+ story:
1172
+ "A schema `messages` entry on a code-editor field. Every other field surfaces its messages on the collapsed row; a code field draws a preview in the same cell, and the message has to survive beside it — a diagnostic the reader only finds by opening the field is not reported. Qorus puts source-validation results here, so an invalid service is called out on the row rather than at save time.",
1173
+ },
1174
+ },
1175
+ },
1176
+ args: {
1177
+ ...CompactRowCodeEditorPreview.args,
1178
+ options: {
1179
+ ...(CompactRowCodeEditorPreview.args as any).options,
1180
+ source: {
1181
+ ...(CompactRowCodeEditorPreview.args as any).options.source,
1182
+ // The field ABSORBS its language sibling, which is how Qorus ships it:
1183
+ // the language control renders inside the Source Code container. That
1184
+ // is the shape the message has to survive.
1185
+ absorb_fields: ['language'],
1186
+ // Qorus groups its fields (INFO / SCALING), so the row renders inside a
1187
+ // group rather than in a flat list.
1188
+ group: 'info',
1189
+ messages: [
1190
+ {
1191
+ intent: 'danger',
1192
+ title: 'Line 4',
1193
+ content: "syntax error, unexpected '}'",
1194
+ },
1195
+ ],
1196
+ },
1197
+ },
1198
+ },
1199
+ play: async ({ canvasElement }) => {
1200
+ // The message renders on the collapsed row, beside the code preview rather
1201
+ // than instead of it.
1202
+ await waitFor(
1203
+ () => {
1204
+ const row = canvasElement.querySelector('[data-field="source"]');
1205
+ expect(row?.textContent ?? '').toContain('Line 4');
1206
+ expect(row?.textContent ?? '').toContain("syntax error, unexpected '}'");
1207
+ },
1208
+ { timeout: 5000 }
1209
+ );
1210
+
1211
+ const row = canvasElement.querySelector('[data-field="source"]');
1212
+ // ... and the preview it sits beside is still there.
1213
+ expect(row?.textContent ?? '').toContain('class ExampleJob inherits QorusJob');
1214
+ },
1215
+ };
1216
+
1217
+ /**
1218
+ * Adds a schema message to a field a moment AFTER the form has mounted, the way
1219
+ * a host that validates asynchronously does.
1220
+ */
1221
+ const LateMessageHarness = (args: any) => {
1222
+ const [options, setOptions] = useState(args.options);
1223
+
1224
+ useEffect(() => {
1225
+ const timer = setTimeout(
1226
+ () =>
1227
+ setOptions((current: any) => ({
1228
+ ...current,
1229
+ source: {
1230
+ ...current.source,
1231
+ messages: [
1232
+ { intent: 'danger', title: 'Line 4', content: "syntax error, unexpected '}'" },
1233
+ ],
1234
+ },
1235
+ })),
1236
+ 100
1237
+ );
1238
+ return () => clearTimeout(timer);
1239
+ }, []);
1240
+
1241
+ return <FormEngine {...args} options={options} />;
1242
+ };
1243
+
1244
+ export const CompactRowCodeEditorLateMessage: Story = {
1245
+ parameters: {
1246
+ docs: {
1247
+ description: {
1248
+ story:
1249
+ 'The same message, but added to the schema after the form has mounted — which is how a host that validates asynchronously delivers one. A message that only renders when it was present at mount is no use to a validator: the answer always arrives later than the field did.',
1250
+ },
1251
+ },
1252
+ },
1253
+ args: { ...CompactRowCodeEditorPreview.args },
1254
+ render: (args) => <LateMessageHarness {...args} />,
1255
+ play: async ({ canvasElement }) => {
1256
+ await waitFor(
1257
+ () => {
1258
+ const row = canvasElement.querySelector('[data-field="source"]');
1259
+ expect(row?.textContent ?? '').toContain('Line 4');
1260
+ },
1261
+ { timeout: 10000 }
1262
+ );
1263
+ },
1264
+ };
1265
+
1266
+ export const CompactRowMessageWhileEditing: Story = {
1267
+ parameters: {
1268
+ docs: {
1269
+ description: {
1270
+ story:
1271
+ "The same message, with the row OPEN. A schema message is guidance about the value, so the moment it matters most is while the value is being edited — and a validation diagnostic is useless anywhere else: it names a line the author can only fix with the editor in front of them. Qorus opens the Source Code row on arrival, so a message that only survives on the collapsed row was never seen at all.",
1272
+ },
1273
+ },
1274
+ },
1275
+ args: { ...CompactRowCodeEditorMessage.args },
1276
+ play: async ({ canvasElement }) => {
1277
+ // Open the row, the way an author does before fixing what the message says.
1278
+ await waitFor(() => {
1279
+ expect(canvasElement.querySelector('[data-field="source"]')).toBeTruthy();
1280
+ });
1281
+ await fireEvent.click(canvasElement.querySelector('[data-field="source"]') as HTMLElement);
1282
+
1283
+ await waitFor(() => {
1284
+ expect(canvasElement.querySelector('.readfirst-row-editing')).toBeTruthy();
1285
+ });
1286
+
1287
+ const editingRow = canvasElement.querySelector('.readfirst-row-editing') as HTMLElement;
1288
+ expect(editingRow?.textContent ?? '').toContain('Line 4');
1289
+ expect(editingRow?.textContent ?? '').toContain("syntax error, unexpected '}'");
1290
+ },
1291
+ };
1292
+
983
1293
  export const CompactRowMarkdownPreview: Story = {
984
1294
  parameters: {
985
1295
  docs: {
@@ -2868,9 +3178,10 @@ export const CompactMultiSelectEditing: Story = {
2868
3178
  },
2869
3179
  play: async () => {
2870
3180
  await _testsWaitForText('Audiences');
2871
- // Read row joins the selected items.
2872
- await _testsWaitForText('orders');
2873
- await _testsClickText('orders');
3181
+ // Read row joins the selected items, labelled the way the picker labels
3182
+ // them (`Orders`) rather than the way they are stored (`orders`).
3183
+ await _testsWaitForText('Orders');
3184
+ await _testsClickText('Orders');
2874
3185
  await waitFor(() => expect(document.querySelector('.options-readfirst-card')).toBeTruthy(), {
2875
3186
  timeout: 10000,
2876
3187
  });
@@ -3654,8 +3965,16 @@ export const CompactExpressions: Story = {
3654
3965
  play: async () => {
3655
3966
  const stop = startDpqlMockLsp();
3656
3967
  try {
3657
- // Read-first: the offline DPQL summary of the AST.
3658
- await _testsWaitForText('"$local:name" == "John"');
3968
+ // Read-first: the offline DPQL summary of the AST — the literals read as
3969
+ // the text they are, and the template reference reads as a chip rather
3970
+ // than as its raw `$local:name` token inside the string.
3971
+ await waitFor(() => {
3972
+ const chip = Array.from(document.querySelectorAll('.reqore-tag')).find((tag) =>
3973
+ tag.textContent?.includes('$local:name')
3974
+ );
3975
+ expect(chip, 'the reference in the summary renders as a chip').toBeTruthy();
3976
+ });
3977
+ await _testsWaitForText('" == "John"');
3659
3978
 
3660
3979
  // Drill in → the card hosts the ExpressionField (Visual builder).
3661
3980
  await fireEvent.click(
@@ -4451,7 +4770,8 @@ export const CompactFieldTypes: Story = {
4451
4770
  await _testsWaitForText('0 0 * * *'); // cron
4452
4771
  await _testsWaitForText('Python'); // select → allowed_value display label
4453
4772
  await _testsWaitForText('Medium'); // enum → allowed_value display label
4454
- await _testsWaitForText('orders, batch'); // multi-select joined
4773
+ // multi-select joined, labelled the way the picker labels them
4774
+ await _testsWaitForText('Orders, Batch');
4455
4775
  await _testsWaitForText('a, b'); // list joined
4456
4776
  await _testsWaitForText('x, y, z'); // YAML-serialized list summarised
4457
4777
  await _testsWaitForText('#0000FF'); // colour → uppercase hex
@@ -4481,8 +4801,15 @@ export const CompactFieldTypes: Story = {
4481
4801
  // than a raw hash key-count.
4482
4802
  await _testsWaitForText('512MiB');
4483
4803
  await _testsWaitForText(/example_customer_addresses/);
4484
- // Expression field → read-first shows the offline DPQL summary of the AST.
4485
- await _testsWaitForText('"$local:name" == "John"');
4804
+ // Expression field → read-first shows the offline DPQL summary of the AST,
4805
+ // with the template reference inside it chipped rather than printed raw.
4806
+ await waitFor(() => {
4807
+ const chip = Array.from(document.querySelectorAll('.reqore-tag')).find((tag) =>
4808
+ tag.textContent?.includes('$local:name')
4809
+ );
4810
+ expect(chip, 'the reference in the summary renders as a chip').toBeTruthy();
4811
+ });
4812
+ await _testsWaitForText('" == "John"');
4486
4813
  await expect(document.querySelectorAll('.options-readfirst-card')).toHaveLength(0);
4487
4814
 
4488
4815
  // A hash renders its read-first preview (the structured tree by default)
@@ -4643,7 +4970,12 @@ export const CompactFieldTypesEditing: Story = {
4643
4970
  await userEvent.click(within(confirmationModal!).getByRole('button', { name: 'Clear value' }));
4644
4971
  await waitFor(() => {
4645
4972
  expect(editRow('enabled').querySelector('.options-readfirst-clear')).not.toBeInTheDocument();
4646
- expect(editRow('enabled').querySelector('.options-readfirst-revert')).toBeInTheDocument();
4973
+ // Clear swaps for the undo affordance. In an OPEN field that is
4974
+ // "Cancel edit" — the field was opened holding its form-load value, so
4975
+ // undoing this edit and reverting the field are the same thing and only
4976
+ // one button is offered. The form-load revert reappears here only when
4977
+ // the field was already modified before it was opened.
4978
+ expect(editRow('enabled').querySelector('.options-readfirst-cancel')).toBeInTheDocument();
4647
4979
  });
4648
4980
  },
4649
4981
  };
@@ -5939,6 +6271,173 @@ export const CompactReadFirstRichtextTemplateBaseline: Story = {
5939
6271
  },
5940
6272
  };
5941
6273
 
6274
+ /**
6275
+ * The same summary, built from the value the server actually sends.
6276
+ *
6277
+ * `CompactReadFirstRichtextTemplateBaseline` above separates its chips with
6278
+ * SPACES, and that is why it kept passing while the row stayed broken: an alert
6279
+ * rule's Gmail message body is five `\n`-separated lines, and the newline is the
6280
+ * whole difference. The richtext branch is the only read-first value that opts
6281
+ * into `white-space: pre` — it has to, or the space between a word and the chip
6282
+ * beside it is dropped — and `pre` honours the newlines too. Every prose segment
6283
+ * after the first then rendered a blank first line, measured 30px against the
6284
+ * chips' 14px, and the wrapper's `align-items: center` put each word 12px below
6285
+ * the chip next to it. The row read as a staircase (supah, 2026-08-28).
6286
+ *
6287
+ * The row is a single line by construction; every other value type reaches that
6288
+ * through `nowrap`, which eats newlines for free. So the fix is to collapse them
6289
+ * here as well, and this story holds the two things that prove it: the prose
6290
+ * boxes are one line tall, not two, and they still share a centre with the chips.
6291
+ */
6292
+ const RichtextMultilineTemplateValue: IOptions = {
6293
+ body: {
6294
+ type: 'richtext',
6295
+ value: [
6296
+ {
6297
+ type: 'paragraph',
6298
+ children: [
6299
+ { text: 'Alert: ' },
6300
+ { type: 'tag', value: '$fsminput:alert_code', label: 'alert_code', children: [{ text: '' }] },
6301
+ { text: '\nSeverity: ' },
6302
+ { type: 'tag', value: '$fsminput:severity', label: 'severity', children: [{ text: '' }] },
6303
+ { text: '\nRule: ' },
6304
+ { type: 'tag', value: '$fsminput:rule_name', label: 'rule_name', children: [{ text: '' }] },
6305
+ ],
6306
+ },
6307
+ ],
6308
+ },
6309
+ } as unknown as IOptions;
6310
+
6311
+ export const CompactReadFirstRichtextMultilineTemplate: Story = {
6312
+ parameters: {
6313
+ docs: {
6314
+ description: {
6315
+ story:
6316
+ "A multi-line richtext value in the read-first row. The row is one line, so the value's newlines must collapse rather than break — and the row must not also hover the raw template source, which is what the chips are there to replace.",
6317
+ },
6318
+ },
6319
+ },
6320
+ args: {
6321
+ compact: true,
6322
+ minColumnWidth: '300px',
6323
+ options: RichtextTemplateSchema,
6324
+ value: RichtextMultilineTemplateValue,
6325
+ },
6326
+ play: async ({ canvasElement }) => {
6327
+ await _testsWaitForText('alert_code');
6328
+
6329
+ const proseSpans = Array.from(canvasElement.querySelectorAll('span')).filter(
6330
+ // the collapsed newline leaves the segment with a leading space
6331
+ (element) =>
6332
+ element.children.length === 0 && /^\s*(Alert|Severity|Rule):/.test(element.textContent || '')
6333
+ ) as HTMLElement[];
6334
+ await expect(proseSpans.length).toBe(3);
6335
+
6336
+ // No hard break survives into the summary: a segment that still carried its
6337
+ // newline would render a blank first line and measure two lines tall.
6338
+ for (const span of proseSpans) {
6339
+ await expect(span.textContent).not.toContain('\n');
6340
+ }
6341
+
6342
+ const chipEl = Array.from(canvasElement.querySelectorAll('span')).find(
6343
+ (element) => element.children.length === 0 && element.textContent?.trim() === 'alert_code'
6344
+ ) as HTMLElement;
6345
+ const chipHeight = chipEl.getBoundingClientRect().height;
6346
+
6347
+ // One line tall, like the chip beside it -- this is the assertion that fails
6348
+ // on the unfixed code, where the blank line doubled it.
6349
+ for (const span of proseSpans) {
6350
+ await expect(span.getBoundingClientRect().height).toBeLessThan(chipHeight * 1.6);
6351
+ }
6352
+
6353
+ // ...and they still sit on the chips' centre line, which is what the earlier
6354
+ // baseline fix bought and this must not give back.
6355
+ const chipRect = chipEl.getBoundingClientRect();
6356
+ for (const span of proseSpans) {
6357
+ const rect = span.getBoundingClientRect();
6358
+ const offset = Math.abs(rect.top + rect.height / 2 - (chipRect.top + chipRect.height / 2));
6359
+ await expect(offset).toBeLessThanOrEqual(1.5);
6360
+ }
6361
+
6362
+ // The row draws the value in full as chips, so it must not ALSO carry a
6363
+ // native tooltip of the raw source: hovering a chip fired that and the chip's
6364
+ // own popover together, and only the popover is visible in a screenshot.
6365
+ const valueCell = chipEl.closest('[title]');
6366
+ await expect(valueCell).toBeNull();
6367
+ },
6368
+ };
6369
+
6370
+ /**
6371
+ * A list of template-capable STRINGS, which is what an email `To` field is.
6372
+ *
6373
+ * Gmail's `to` is `type: "list"`, `element_type: "string"`,
6374
+ * `supports_templates: true`, so each element is edited with a rich-text editor
6375
+ * and its value arrives wrapped: `{type: "richtext", value: [{type:
6376
+ * "paragraph", …}]}`. The hash-list test unwrapped `item.value`, found the Slate
6377
+ * document — an object — and classed a list of addresses as a list of hashes, so
6378
+ * the row drew an expandable `type / children / text` tree instead of the
6379
+ * address (supah, 2026-08-29).
6380
+ *
6381
+ * A rich-text envelope is a string in a coat. The row reads it as one.
6382
+ */
6383
+ const RichtextListSchema: IOptionsSchema = {
6384
+ to: {
6385
+ type: 'list',
6386
+ element_type: 'string',
6387
+ supports_templates: true,
6388
+ display_name: 'To',
6389
+ required: true,
6390
+ } as never,
6391
+ };
6392
+
6393
+ const RichtextListValue: IOptions = {
6394
+ to: {
6395
+ type: 'list',
6396
+ value: [
6397
+ { type: 'richtext', value: [{ type: 'paragraph', children: [{ text: 'ops@example.com' }] }] },
6398
+ { type: 'richtext', value: [{ type: 'paragraph', children: [{ text: 'sre@example.com' }] }] },
6399
+ ],
6400
+ },
6401
+ } as unknown as IOptions;
6402
+
6403
+ export const CompactReadFirstRichtextStringList: Story = {
6404
+ parameters: {
6405
+ docs: {
6406
+ description: {
6407
+ story:
6408
+ 'A list of template-capable string elements reads as its strings, not as a structured-data tree: the rich-text envelope each element arrives in is a string in a coat.',
6409
+ },
6410
+ },
6411
+ },
6412
+ args: {
6413
+ compact: true,
6414
+ minColumnWidth: '300px',
6415
+ options: RichtextListSchema,
6416
+ value: RichtextListValue,
6417
+ },
6418
+ play: async ({ canvasElement }) => {
6419
+ // The row itself, not a global text search: the addresses have to be IN the
6420
+ // `to` row, which is the whole point.
6421
+ await waitFor(
6422
+ () => {
6423
+ const row = canvasElement.querySelector('[data-field="to"]');
6424
+ expect(row).toBeTruthy();
6425
+ // The addresses themselves, not a bare "2 items" count.
6426
+ expect(row?.textContent ?? '').toContain('ops@example.com');
6427
+ },
6428
+ { timeout: 10000 }
6429
+ );
6430
+
6431
+ const text = canvasElement.querySelector('[data-field="to"]')?.textContent ?? '';
6432
+ await expect(text).toContain('sre@example.com');
6433
+
6434
+ // ...and none of the document's internals leak into the row. These are the
6435
+ // labels the structured-data tree prints, and they are what the operator saw.
6436
+ await expect(text).not.toContain('paragraph');
6437
+ await expect(text).not.toContain('children');
6438
+ },
6439
+ };
6440
+
5942
6441
  /**
5943
6442
  * The other half of the markdown contract: with NO host renderer there is no
5944
6443
  * inset at all. The row is not left empty though — its one line still carries
@@ -6220,3 +6719,355 @@ export const CompactListOfHashReadsInSchemaWordsMobile: Story = {
6220
6719
  }
6221
6720
  },
6222
6721
  };
6722
+
6723
+ /**
6724
+ * The read-first summary of an EXPRESSION: it renders to one line of text, and
6725
+ * a template reference inside it used to print as its own raw token — the same
6726
+ * reference the editor shows as a named chip. This is the Save Reply row of the
6727
+ * Discord assistant template, whose value is
6728
+ * `trim($data:{dc_ai_reply.choices[0].message.content})`.
6729
+ */
6730
+ export const CompactExpressionTemplateChips: Story = {
6731
+ parameters: {
6732
+ docs: {
6733
+ description: {
6734
+ story:
6735
+ 'Renders a compact read-first row whose value is an expression wrapping a template reference — the reference renders as a named chip inside the summary text (trim("Choices[0].message.content")) rather than as its raw $data token.',
6736
+ },
6737
+ },
6738
+ chromatic: { disable: true },
6739
+ },
6740
+ render: () => (
6741
+ <FormEngine
6742
+ compact
6743
+ name='exprChips'
6744
+ stringTemplates={
6745
+ {
6746
+ items: [
6747
+ {
6748
+ label: 'Generate AI Reply',
6749
+ items: [
6750
+ {
6751
+ label: 'Choices',
6752
+ value: '$data:{3.choices}',
6753
+ metadata: { aliasValues: ['$data:{dc_ai_reply.choices}'] },
6754
+ },
6755
+ ],
6756
+ },
6757
+ ],
6758
+ } as any
6759
+ }
6760
+ options={
6761
+ {
6762
+ reply: {
6763
+ type: 'string',
6764
+ display_name: 'Value',
6765
+ desc: 'The new value for the variable',
6766
+ supports_expressions: true,
6767
+ required: true,
6768
+ },
6769
+ } as unknown as IQorusFormSchema
6770
+ }
6771
+ value={
6772
+ {
6773
+ reply: {
6774
+ type: 'string',
6775
+ is_expression: true,
6776
+ value: {
6777
+ exp: 'trim',
6778
+ args: [{ type: 'string', value: '$data:{dc_ai_reply.choices[0].message.content}' }],
6779
+ },
6780
+ },
6781
+ } as any
6782
+ }
6783
+ onChange={fn()}
6784
+ />
6785
+ ),
6786
+ play: async () => {
6787
+ // The summary keeps its literal text…
6788
+ await _testsWaitForText('trim("', undefined, 1);
6789
+ // …and the reference inside it is a named chip, not the raw token.
6790
+ await waitFor(() => {
6791
+ const chip = Array.from(document.querySelectorAll('.reqore-tag')).find((tag) =>
6792
+ tag.textContent?.includes('Choices[0].message.content')
6793
+ );
6794
+ expect(chip, 'the reference renders as a named chip in the summary').toBeTruthy();
6795
+ });
6796
+ const raw = Array.from(document.querySelectorAll('.readfirst-row')).filter((row) =>
6797
+ row.textContent?.includes('$data:{')
6798
+ );
6799
+ expect(raw, 'the row prints no raw token').toHaveLength(0);
6800
+ },
6801
+ };
6802
+
6803
+ /**
6804
+ * The same row on a surface with NO template catalogue — the Automation Hub
6805
+ * template preview, which never fetches one. There is no name to resolve the
6806
+ * reference to, so the chip carries the reference's own path: the wrapper still
6807
+ * reads as `trim("…")` text, and the reference stops reading as code.
6808
+ */
6809
+ export const CompactExpressionWithoutCatalogue: Story = {
6810
+ parameters: {
6811
+ docs: {
6812
+ description: {
6813
+ story:
6814
+ 'Renders a compact read-first row whose value is an expression wrapping a template reference, with no templates supplied — the reference still renders as a chip carrying its own path (dc_ai_reply.choices[0].message.content) rather than the raw $data token.',
6815
+ },
6816
+ },
6817
+ chromatic: { disable: true },
6818
+ },
6819
+ render: () => (
6820
+ <FormEngine
6821
+ compact
6822
+ name='exprNoCatalogue'
6823
+ options={
6824
+ {
6825
+ reply: {
6826
+ type: 'string',
6827
+ display_name: 'Value',
6828
+ desc: 'The new value for the variable',
6829
+ supports_expressions: true,
6830
+ required: true,
6831
+ },
6832
+ } as unknown as IQorusFormSchema
6833
+ }
6834
+ value={
6835
+ {
6836
+ reply: {
6837
+ type: 'string',
6838
+ is_expression: true,
6839
+ value: {
6840
+ exp: 'trim',
6841
+ args: [{ type: 'string', value: '$data:{dc_ai_reply.choices[0].message.content}' }],
6842
+ },
6843
+ },
6844
+ } as any
6845
+ }
6846
+ onChange={fn()}
6847
+ />
6848
+ ),
6849
+ play: async () => {
6850
+ await _testsWaitForText('trim("', undefined, 1);
6851
+ await waitFor(() => {
6852
+ const chip = Array.from(document.querySelectorAll('.reqore-tag')).find((tag) =>
6853
+ tag.textContent?.includes('dc_ai_reply.choices[0].message.content')
6854
+ );
6855
+ expect(chip, 'the reference chips even with no catalogue').toBeTruthy();
6856
+ });
6857
+ const raw = Array.from(document.querySelectorAll('.readfirst-row')).filter((row) =>
6858
+ row.textContent?.includes('$data:{')
6859
+ );
6860
+ expect(raw, 'the row prints no raw token').toHaveLength(0);
6861
+ },
6862
+ };
6863
+
6864
+ // --- PR #99: compactCollapsedGroups + compactToolbar --------------------------
6865
+
6866
+ /** Find a status box (`Needs attention` / `Set` / `Optional`) by its title.
6867
+ * A collapsed ReqorePanel unmounts its content, so "has no
6868
+ * `.reqore-panel-content`" IS the collapsed assertion. */
6869
+ const findStatusBox = (title: string) =>
6870
+ Array.from(document.querySelectorAll('.options-readfirst-group')).find((panel) =>
6871
+ panel.querySelector('.reqore-panel-title')?.textContent?.includes(title)
6872
+ );
6873
+
6874
+ const expectBoxCollapsed = async (title: string) => {
6875
+ await waitFor(
6876
+ () => {
6877
+ const box = findStatusBox(title);
6878
+ expect(box, `the "${title}" box renders`).toBeTruthy();
6879
+ expect(
6880
+ box!.querySelector('.reqore-panel-content'),
6881
+ `the "${title}" box is collapsed (content unmounted)`
6882
+ ).toBeFalsy();
6883
+ },
6884
+ { timeout: 10000 }
6885
+ );
6886
+ };
6887
+
6888
+ const expectBoxOpen = async (title: string) => {
6889
+ await waitFor(
6890
+ () => {
6891
+ const box = findStatusBox(title);
6892
+ expect(box, `the "${title}" box renders`).toBeTruthy();
6893
+ expect(
6894
+ box!.querySelector('.reqore-panel-content'),
6895
+ `the "${title}" box is open (content mounted)`
6896
+ ).toBeTruthy();
6897
+ },
6898
+ { timeout: 10000 }
6899
+ );
6900
+ };
6901
+
6902
+ /**
6903
+ * The template drawer's combination: a form that is one item in a longer list,
6904
+ * where the reader chooses WHICH form to open rather than reading this one.
6905
+ */
6906
+ export const CompactChosenCollapsedBoxes: Story = {
6907
+ parameters: {
6908
+ docs: {
6909
+ description: {
6910
+ story:
6911
+ 'Renders a compact form with `compactCollapsedGroups={[\'set\', \'optional\']}` — the Set and Optional boxes start collapsed while Needs attention stays open, and typing a search query forces every box open (a collapsed panel unmounts its content, so a match inside one would be unreachable); clearing the query collapses them again.',
6912
+ },
6913
+ },
6914
+ },
6915
+ args: {
6916
+ compact: true,
6917
+ minColumnWidth: '300px',
6918
+ options: CompactFieldsMenuSchema,
6919
+ value: CompactValue,
6920
+ groups: CompactGroups,
6921
+ compactCollapsedGroups: ['set', 'optional'],
6922
+ },
6923
+ play: async () => {
6924
+ // Needs attention is not in the collapse list — its row is on screen.
6925
+ await expectBoxOpen('Needs attention');
6926
+ await _testsWaitForText('Description');
6927
+
6928
+ // Set and Optional start collapsed: headers present, rows unmounted.
6929
+ await expectBoxCollapsed('Set');
6930
+ await expectBoxCollapsed('Optional');
6931
+ await _testsWaitForTextToNotExist('order-fulfilment');
6932
+
6933
+ // A search overrides the collapse list — the matching Set row surfaces…
6934
+ await _testsChangeStringField({
6935
+ selector: 'input[placeholder="Filter fields..."]',
6936
+ value: 'name',
6937
+ });
6938
+ await expectBoxOpen('Set');
6939
+ await _testsWaitForText('order-fulfilment');
6940
+
6941
+ // …and clearing the query hands control back to the collapse list.
6942
+ await _testsChangeStringField({
6943
+ selector: 'input[placeholder="Filter fields..."]',
6944
+ value: '',
6945
+ });
6946
+ await expectBoxCollapsed('Set');
6947
+ await _testsWaitForTextToNotExist('order-fulfilment');
6948
+ },
6949
+ };
6950
+
6951
+ export const CompactToolbarHidden: Story = {
6952
+ parameters: {
6953
+ docs: {
6954
+ description: {
6955
+ story:
6956
+ "Renders a compact form with `compactToolbar={false}` — the completion meter, search, field picker and help button are all gone, and the header carries only the consumer's own `compactPanelProps.actions`; the rows themselves are untouched.",
6957
+ },
6958
+ },
6959
+ },
6960
+ args: {
6961
+ ...Compact.args,
6962
+ compactToolbar: false,
6963
+ compactPanelProps: {
6964
+ label: 'Save Reply',
6965
+ actions: [{ label: 'Apply', icon: 'CheckLine', responsive: false }],
6966
+ },
6967
+ },
6968
+ play: async () => {
6969
+ // The rows and the consumer's own header action render…
6970
+ await _testsWaitForText('order-fulfilment');
6971
+ await _testsWaitForText('Apply');
6972
+
6973
+ // …and none of the engine's toolbar does.
6974
+ await waitFor(() => {
6975
+ expect(document.querySelector('.options-readfirst-completion')).toBeFalsy();
6976
+ expect(document.querySelector('input[placeholder="Filter fields..."]')).toBeFalsy();
6977
+ expect(document.querySelector('.options-readfirst-fields')).toBeFalsy();
6978
+ expect(document.querySelector('.options-readfirst-descriptions')).toBeFalsy();
6979
+ });
6980
+ },
6981
+ };
6982
+
6983
+ export const CompactToolbarTrimmed: Story = {
6984
+ parameters: {
6985
+ docs: {
6986
+ description: {
6987
+ story:
6988
+ 'Renders a compact form with `compactToolbar={{ completion: false, help: false }}` — the keys listed turn their parts off (no meter, no help button) while the unlisted ones keep their defaults (search and the field picker still render), proving the object form merges over all-on rather than replacing it.',
6989
+ },
6990
+ },
6991
+ },
6992
+ args: {
6993
+ ...Compact.args,
6994
+ options: CompactFieldsMenuSchema,
6995
+ compactToolbar: { completion: false, help: false },
6996
+ },
6997
+ play: async () => {
6998
+ await _testsWaitForText('order-fulfilment');
6999
+
7000
+ // Listed parts are off…
7001
+ await waitFor(() => {
7002
+ expect(document.querySelector('.options-readfirst-completion')).toBeFalsy();
7003
+ expect(document.querySelector('.options-readfirst-descriptions')).toBeFalsy();
7004
+ });
7005
+
7006
+ // …unlisted parts keep rendering.
7007
+ await waitFor(() => {
7008
+ expect(document.querySelector('input[placeholder="Filter fields..."]')).toBeTruthy();
7009
+ expect(document.querySelector('.options-readfirst-fields')).toBeTruthy();
7010
+ });
7011
+ },
7012
+ };
7013
+
7014
+ export const CompactCollapseListLeavesLoneOptionalOpen: Story = {
7015
+ parameters: {
7016
+ docs: {
7017
+ description: {
7018
+ story:
7019
+ "Renders an all-optional compact form with an explicit `compactCollapsedGroups={['optional']}` — the Optional box stays open anyway, because it is the whole form and collapsing it would render a titled card containing one collapsed strip and nothing else. The invariant outranks the consumer's collapse list.",
7020
+ },
7021
+ },
7022
+ },
7023
+ args: {
7024
+ compact: true,
7025
+ minColumnWidth: '300px',
7026
+ options: {
7027
+ alpha: { type: 'string', ui_type: 'string', display_name: 'Alpha' },
7028
+ beta: { type: 'string', ui_type: 'string', display_name: 'Beta' },
7029
+ } as unknown as IOptionsSchema,
7030
+ value: {} as IOptions,
7031
+ compactCollapsedGroups: ['optional'],
7032
+ },
7033
+ play: async () => {
7034
+ // The lone Optional box is open — its rows are on screen with no click.
7035
+ await expectBoxOpen('Optional');
7036
+ await _testsWaitForText('Alpha');
7037
+ await _testsWaitForText('Beta');
7038
+ },
7039
+ };
7040
+
7041
+ export const CompactNoSearchCollapseIsFinal: Story = {
7042
+ parameters: {
7043
+ docs: {
7044
+ description: {
7045
+ story:
7046
+ "Renders a compact form with `compactToolbar={{ search: false }}` and `compactCollapsedGroups={['set']}` — with the search gone there is no query to force a box open, so the collapse list fully determines what is open: Set stays collapsed, and Optional (not listed — the list replaces the default rather than adding to it) renders open.",
7047
+ },
7048
+ },
7049
+ },
7050
+ args: {
7051
+ compact: true,
7052
+ minColumnWidth: '300px',
7053
+ options: CompactFieldsMenuSchema,
7054
+ value: CompactValue,
7055
+ groups: CompactGroups,
7056
+ compactCollapsedGroups: ['set'],
7057
+ compactToolbar: { search: false },
7058
+ },
7059
+ play: async () => {
7060
+ await _testsWaitForText('Description');
7061
+
7062
+ // No search input — nothing can override the collapse list.
7063
+ await waitFor(() =>
7064
+ expect(document.querySelector('input[placeholder="Filter fields..."]')).toBeFalsy()
7065
+ );
7066
+
7067
+ // Set is collapsed for good; Optional is NOT in the list, so it is open —
7068
+ // the list replaces the `['optional']` default rather than extending it.
7069
+ await expectBoxCollapsed('Set');
7070
+ await expectBoxOpen('Optional');
7071
+ await _testsWaitForTextToNotExist('order-fulfilment');
7072
+ },
7073
+ };