@qoretechnologies/reqraft 0.10.39-pr.99.gdbafa34 → 0.10.39

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 (110) hide show
  1. package/.claude/CLAUDE.md +12 -0
  2. package/dist/components/codeSize/index.d.ts +37 -0
  3. package/dist/components/codeSize/index.d.ts.map +1 -0
  4. package/dist/components/codeSize/index.js +87 -0
  5. package/dist/components/codeSize/index.js.map +1 -0
  6. package/dist/components/form/engine/CompactRow.d.ts +20 -1
  7. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  8. package/dist/components/form/engine/CompactRow.js +365 -30
  9. package/dist/components/form/engine/CompactRow.js.map +1 -1
  10. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  11. package/dist/components/form/engine/CompactToolbar.js +6 -8
  12. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  13. package/dist/components/form/engine/FormEngine.d.ts +24 -45
  14. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  15. package/dist/components/form/engine/FormEngine.js +211 -84
  16. package/dist/components/form/engine/FormEngine.js.map +1 -1
  17. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts +37 -0
  18. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -0
  19. package/dist/components/form/engine/_structuredData/SchemaDataView.js +335 -0
  20. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -0
  21. package/dist/components/form/engine/compactRowStyles.d.ts +13 -0
  22. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  23. package/dist/components/form/engine/compactRowStyles.js +30 -5
  24. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  25. package/dist/components/form/engine/compactToolbarContext.d.ts +0 -8
  26. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  27. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  28. package/dist/components/form/engine/readFirst.d.ts +73 -1
  29. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  30. package/dist/components/form/engine/readFirst.js +175 -5
  31. package/dist/components/form/engine/readFirst.js.map +1 -1
  32. package/dist/components/form/fields/Field.d.ts.map +1 -1
  33. package/dist/components/form/fields/Field.js +1 -1
  34. package/dist/components/form/fields/Field.js.map +1 -1
  35. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +12 -2
  36. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  37. package/dist/components/form/fields/allowed-values/AllowedValues.js +16 -6
  38. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  39. package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
  40. package/dist/components/form/fields/array/ArrayAuto.js +51 -21
  41. package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
  42. package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
  43. package/dist/components/form/fields/array/ArrayAutoField.js +42 -25
  44. package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
  45. package/dist/components/form/fields/auto/AutoFormField.d.ts +4 -0
  46. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  47. package/dist/components/form/fields/auto/AutoFormField.js +29 -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/TemplateField.d.ts.map +1 -1
  54. package/dist/components/form/fields/template/TemplateField.js +12 -2
  55. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  56. package/dist/helpers/common.d.ts +8 -1
  57. package/dist/helpers/common.d.ts.map +1 -1
  58. package/dist/helpers/common.js +9 -1
  59. package/dist/helpers/common.js.map +1 -1
  60. package/dist/helpers/optionUiTypes.d.ts +30 -0
  61. package/dist/helpers/optionUiTypes.d.ts.map +1 -0
  62. package/dist/helpers/optionUiTypes.js +148 -0
  63. package/dist/helpers/optionUiTypes.js.map +1 -0
  64. package/dist/helpers/options.d.ts +19 -0
  65. package/dist/helpers/options.d.ts.map +1 -1
  66. package/dist/helpers/options.js +25 -1
  67. package/dist/helpers/options.js.map +1 -1
  68. package/dist/helpers/providerValue.d.ts +68 -0
  69. package/dist/helpers/providerValue.d.ts.map +1 -0
  70. package/dist/helpers/providerValue.js +50 -0
  71. package/dist/helpers/providerValue.js.map +1 -0
  72. package/dist/helpers/singleLineString.d.ts +37 -0
  73. package/dist/helpers/singleLineString.d.ts.map +1 -0
  74. package/dist/helpers/singleLineString.js +66 -0
  75. package/dist/helpers/singleLineString.js.map +1 -0
  76. package/dist/helpers/validations.d.ts +85 -2
  77. package/dist/helpers/validations.d.ts.map +1 -1
  78. package/dist/helpers/validations.js +802 -76
  79. package/dist/helpers/validations.js.map +1 -1
  80. package/dist/index.d.ts +1 -0
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +1 -0
  83. package/dist/index.js.map +1 -1
  84. package/package.json +1 -1
  85. package/src/components/codeSize/index.tsx +79 -0
  86. package/src/components/form/engine/CompactRow.tsx +450 -31
  87. package/src/components/form/engine/CompactToolbar.tsx +16 -21
  88. package/src/components/form/engine/FormEngine.stories.tsx +804 -17
  89. package/src/components/form/engine/FormEngine.tsx +207 -105
  90. package/src/components/form/engine/_structuredData/SchemaDataView.stories.tsx +233 -0
  91. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +630 -0
  92. package/src/components/form/engine/compactRowStyles.ts +44 -2
  93. package/src/components/form/engine/compactToolbarContext.ts +0 -8
  94. package/src/components/form/engine/readFirst.ts +201 -5
  95. package/src/components/form/fields/Field.tsx +3 -1
  96. package/src/components/form/fields/allowed-values/AllowedValues.tsx +24 -2
  97. package/src/components/form/fields/array/ArrayAuto.tsx +35 -4
  98. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +134 -0
  99. package/src/components/form/fields/array/ArrayAutoField.tsx +22 -4
  100. package/src/components/form/fields/auto/AutoFormField.tsx +30 -1
  101. package/src/components/form/fields/long-string/LongString.tsx +46 -5
  102. package/src/components/form/fields/template/TemplateField.tsx +16 -1
  103. package/src/helpers/common.ts +11 -1
  104. package/src/helpers/optionUiTypes.ts +153 -0
  105. package/src/helpers/options.ts +23 -0
  106. package/src/helpers/providerValue.ts +125 -0
  107. package/src/helpers/singleLineString.ts +61 -0
  108. package/src/helpers/validations.ts +1105 -80
  109. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +23 -2
  110. package/src/index.tsx +1 -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: {
@@ -1164,7 +1474,7 @@ export const NestedOptionInheritsRenderPropFromAncestorCompact: Story = {
1164
1474
  docs: {
1165
1475
  description: {
1166
1476
  story:
1167
- "Renders the NestedOptionInheritsRenderPropFromAncestor schema with compact=true — the compact renderer summarises the list-of-hash rows as 'init, run' rather than [object Object].",
1477
+ "Renders the NestedOptionInheritsRenderPropFromAncestor schema with compact=true — the compact renderer previews the list-of-hash rows through their arg_schema, naming each method, rather than printing [object Object].",
1168
1478
  },
1169
1479
  },
1170
1480
  },
@@ -1229,11 +1539,13 @@ export const NestedOptionInheritsRenderPropFromAncestorCompact: Story = {
1229
1539
  await waitFor(() => expect(canvas.getAllByText('Methods').length).toBeGreaterThan(0), {
1230
1540
  timeout: 5000,
1231
1541
  });
1232
- // The list-of-hashes value summarises by the items' names — never a raw
1233
- // "[object Object]" (regression: it used to stringify each hash envelope).
1234
- await expect(
1235
- await canvas.findByText('init, run', undefined, { timeout: 5000 })
1236
- ).toBeInTheDocument();
1542
+ // The list-of-hashes value names its items — never a raw "[object Object]"
1543
+ // (regression: it used to stringify each hash envelope). The names now come
1544
+ // from the schema preview rather than a joined summary line above it, so the
1545
+ // assertion moved with them; what must never come back is the stringified
1546
+ // envelope.
1547
+ await expect(await canvas.findByText('init', undefined, { timeout: 5000 })).toBeInTheDocument();
1548
+ await expect(await canvas.findByText('run')).toBeInTheDocument();
1237
1549
  await expect(canvasElement.textContent ?? '').not.toContain('[object Object]');
1238
1550
  },
1239
1551
  };
@@ -1290,12 +1602,16 @@ export const CompactNestedListRowsStayCompact: Story = {
1290
1602
  } as unknown as IOptionsSchema,
1291
1603
  },
1292
1604
  play: async ({ canvasElement }) => {
1293
- // Collapsed, the row summarises by the items' names.
1294
- await _testsWaitForText('init, run');
1605
+ // Collapsed, the row previews its items through their schema — one numbered
1606
+ // entry per method, each naming itself.
1607
+ // The method name heads its item now, so that is what identifies the row.
1608
+ await _testsWaitForText('init');
1609
+ await _testsWaitForText('run');
1295
1610
 
1296
1611
  // Open the Methods row — this mounts ArrayAuto and, with it, one
1297
- // arg_schema sub-form per row.
1298
- await _testsClickText('init, run');
1612
+ // arg_schema sub-form per row. Clicked by the field's NAME: the joined
1613
+ // summary that used to sit on this row is gone, replaced by the preview.
1614
+ await _testsClickText('Methods');
1299
1615
 
1300
1616
  await waitFor(
1301
1617
  () => expect(canvasElement.querySelectorAll('.array-auto-item')).toHaveLength(2),
@@ -1347,8 +1663,8 @@ export const CompactNestedListRowsStayCompactMobile: Story = {
1347
1663
  ),
1348
1664
  ],
1349
1665
  play: async ({ canvasElement }) => {
1350
- await _testsWaitForText('init, run');
1351
- await _testsClickText('init, run');
1666
+ await _testsWaitForText('init');
1667
+ await _testsClickText('Methods');
1352
1668
 
1353
1669
  await waitFor(
1354
1670
  () => expect(canvasElement.querySelectorAll('.array-auto-item')).toHaveLength(2),
@@ -4131,6 +4447,29 @@ export const CompactFieldTypes: Story = {
4131
4447
  secure: { type: 'bool', display_name: 'Secure' },
4132
4448
  },
4133
4449
  },
4450
+ // The list counterpart of `connectionInfo`: a list of hashes whose fields
4451
+ // are declared. This is the shape the schema preview was built for, and the
4452
+ // catalogue was missing it — it covered the described HASH but not the
4453
+ // described LIST, which is the case that was actually reported.
4454
+ routes: {
4455
+ type: 'list',
4456
+ ui_type: 'list',
4457
+ element_type: 'hash',
4458
+ display_name: 'List of hashes (arg_schema)',
4459
+ group: 'structured',
4460
+ arg_schema: {
4461
+ method: {
4462
+ type: 'string',
4463
+ display_name: 'Method',
4464
+ allowed_values: [
4465
+ { value: 'get', display_name: 'GET' },
4466
+ { value: 'post', display_name: 'POST' },
4467
+ ],
4468
+ },
4469
+ path: { type: 'string', display_name: 'Path' },
4470
+ handler: { type: 'string', ui_type: 'code-editor', display_name: 'Handler' },
4471
+ },
4472
+ },
4134
4473
  freeConfig: {
4135
4474
  type: 'free-hash',
4136
4475
  ui_type: 'free-hash',
@@ -4339,6 +4678,22 @@ export const CompactFieldTypes: Story = {
4339
4678
  secure: { type: 'bool', value: true },
4340
4679
  },
4341
4680
  },
4681
+ routes: {
4682
+ type: 'list',
4683
+ value: [
4684
+ // One item complete, one with the optional field unset — the empty
4685
+ // state stays exercised rather than being filled in everywhere.
4686
+ {
4687
+ type: 'hash',
4688
+ value: {
4689
+ method: 'get',
4690
+ path: '/orders',
4691
+ handler: 'sub get_orders() {\n return orders.all();\n}',
4692
+ },
4693
+ },
4694
+ { type: 'hash', value: { method: 'post', path: '/orders' } },
4695
+ ],
4696
+ },
4342
4697
  freeConfig: { type: 'free-hash', value: '%YAML 1.2\n---\nretries: 3\ntimeout: 30\n' },
4343
4698
  bigConfig: {
4344
4699
  type: 'hash',
@@ -4422,7 +4777,23 @@ export const CompactFieldTypes: Story = {
4422
4777
  await _testsWaitForText('#0000FF'); // colour → uppercase hex
4423
4778
  await _testsWaitForText('rgba(255, 0, 0, 0.5)'); // colour with alpha → rgba()
4424
4779
  await _testsWaitForText('config.txt'); // file → filename
4425
- await _testsWaitForText('3 fields'); // structured hash → field count summary
4780
+ // A hash the schema DESCRIBES needs no count: its preview names every field,
4781
+ // so "3 fields" above "Host / Port / Secure" would be the same fact twice.
4782
+ // A hash it does not describe keeps the count — the untyped tree beneath it
4783
+ // says nothing about what the value means, so the count is the only line
4784
+ // that does. Both halves asserted here: the distinction IS the behaviour.
4785
+ await _testsWaitForText('2 fields'); // undescribed hash → field count summary
4786
+ await _testsWaitForText('Host'); // described hash → its fields, by name
4787
+ await _testsWaitForTextToNotExist('3 fields');
4788
+
4789
+ // Described LIST of hashes: numbered items, labels by display_name, and the
4790
+ // allowed value read back as its label rather than the stored `get`.
4791
+ await _testsWaitForText('Path');
4792
+ await _testsWaitForText('/orders');
4793
+ await _testsWaitForText('GET');
4794
+ // A code sub-field renders AS code, not as a flattened mono line.
4795
+ await _testsWaitForText('Handler');
4796
+ await _testsWaitForText(/sub get_orders/);
4426
4797
  await _testsWaitForText('order-to-invoice'); // interface reference → raw value
4427
4798
 
4428
4799
  // Field stack (merged from dpql): byte-size shows its value string; the
@@ -4599,7 +4970,12 @@ export const CompactFieldTypesEditing: Story = {
4599
4970
  await userEvent.click(within(confirmationModal!).getByRole('button', { name: 'Clear value' }));
4600
4971
  await waitFor(() => {
4601
4972
  expect(editRow('enabled').querySelector('.options-readfirst-clear')).not.toBeInTheDocument();
4602
- 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();
4603
4979
  });
4604
4980
  },
4605
4981
  };
@@ -5895,6 +6271,173 @@ export const CompactReadFirstRichtextTemplateBaseline: Story = {
5895
6271
  },
5896
6272
  };
5897
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
+
5898
6441
  /**
5899
6442
  * The other half of the markdown contract: with NO host renderer there is no
5900
6443
  * inset at all. The row is not left empty though — its one line still carries
@@ -5933,6 +6476,250 @@ export const CompactRowMarkdownWithoutRenderer: Story = {
5933
6476
  },
5934
6477
  };
5935
6478
 
6479
+ /**
6480
+ * The auth-profile scheme sub-schema, which is where all three of the list-row
6481
+ * affordances below were reported. One required choice with named allowed
6482
+ * values, one field that belongs to a single choice.
6483
+ */
6484
+ const AuthSchemeArgSchema = {
6485
+ type: {
6486
+ type: 'string',
6487
+ ui_type: 'string',
6488
+ display_name: 'Scheme Type',
6489
+ short_desc: 'Authentication scheme type',
6490
+ required: true,
6491
+ allowed_values: [
6492
+ { value: 'default', display_name: 'Default RBAC' },
6493
+ { value: 'cookie', display_name: 'Cookie' },
6494
+ { value: 'oauth2', display_name: 'OAuth2' },
6495
+ ],
6496
+ },
6497
+ cookie_name: {
6498
+ type: 'string',
6499
+ ui_type: 'string',
6500
+ display_name: 'Session Cookie Name',
6501
+ short_desc: 'Applies to the Cookie scheme alone',
6502
+ depends_on: ['type=cookie'],
6503
+ },
6504
+ } as unknown as IOptionsSchema;
6505
+
6506
+ const AuthSchemeOptions = {
6507
+ schemes: {
6508
+ type: 'list',
6509
+ ui_type: 'list',
6510
+ element_type: 'hash',
6511
+ display_name: 'Authentication Schemes',
6512
+ short_desc: 'Schemes tried in order, first match wins',
6513
+ required: true,
6514
+ arg_schema: AuthSchemeArgSchema,
6515
+ },
6516
+ } as unknown as IOptionsSchema;
6517
+
6518
+ /**
6519
+ * A list-of-hash row reads back in the words the form asked for the value.
6520
+ *
6521
+ * Reported on an auth profile: the row summarised as "2 items" and its preview
6522
+ * printed a data tree — "Object · 1 field" over `type: default`. Both are
6523
+ * exactly what is stored, and neither is a string the author has ever seen: the
6524
+ * form calls that key "Scheme Type" and that value "Default RBAC". A row meant
6525
+ * to confirm a choice showed a value nobody had chosen, in a shape nobody had
6526
+ * asked about.
6527
+ *
6528
+ * A generic data view has to announce what it found, because inference is all it
6529
+ * has. A field with an `arg_schema` needs none of that — the shape, the names and
6530
+ * the choices are known before the value arrives — so the preview renders
6531
+ * THROUGH the schema (`SchemaDataView`) and the summary resolves through the
6532
+ * same one. The row, the preview and the editor cannot describe a value three
6533
+ * ways.
6534
+ */
6535
+ export const CompactListOfHashReadsInSchemaWords: Story = {
6536
+ parameters: {
6537
+ docs: {
6538
+ description: {
6539
+ story:
6540
+ 'A list-of-hash option whose sub-schema names its fields and values. The collapsed row summarises by those names ("Default RBAC, Cookie") and the preview renders as labelled schema rows — "Scheme Type · Default RBAC" per numbered item — instead of an untyped data tree over the stored `type: default`.',
6541
+ },
6542
+ },
6543
+ },
6544
+ args: {
6545
+ compact: true,
6546
+ minColumnWidth: '300px',
6547
+ options: AuthSchemeOptions,
6548
+ value: {
6549
+ schemes: {
6550
+ type: 'list',
6551
+ value: [
6552
+ { type: 'hash', value: { type: 'default' } },
6553
+ { type: 'hash', value: { type: 'cookie', cookie_name: 'qorus-session' } },
6554
+ ],
6555
+ },
6556
+ },
6557
+ },
6558
+ play: async ({ canvasElement }) => {
6559
+ // The preview IS the value here — the row prints no summary line above it.
6560
+ // A joined "Default RBAC, Cookie" directly over a preview that names both
6561
+ // items is the same facts twice, the lossy version first.
6562
+ const preview = await waitFor(() => {
6563
+ const element = canvasElement.querySelector('.schema-data-view');
6564
+ expect(element).toBeTruthy();
6565
+ return element!;
6566
+ });
6567
+ const text = preview.textContent ?? '';
6568
+ // Each item is headed by its first value, so the list reads by name; the
6569
+ // remaining fields keep their labels.
6570
+ const titles = [...preview.querySelectorAll('.schema-view-item-title')].map((element) =>
6571
+ (element.textContent ?? '').trim()
6572
+ );
6573
+ expect(titles).toEqual(['Default RBAC', 'Cookie']);
6574
+ expect(text).toContain('Session Cookie Name');
6575
+
6576
+ // Nothing announces the container's shape — that is the tell of a renderer
6577
+ // guessing at data it has not been told about.
6578
+ expect(text).not.toMatch(/Object\b/);
6579
+ expect(text).not.toMatch(/\d+ fields?\b/);
6580
+
6581
+ // The joined summary is gone from the row, and it is the ROW that has to be
6582
+ // checked: the same words still exist inside the preview, so asserting on
6583
+ // the whole canvas would pass whether or not the line was removed.
6584
+ const valueLine = canvasElement.querySelector(
6585
+ '[data-field="schemes"] .options-readfirst-valuetext'
6586
+ );
6587
+ expect(valueLine?.textContent ?? '').not.toBe('Default RBAC, Cookie');
6588
+
6589
+ // A literal the author typed is set in mono; a chosen label is not.
6590
+ const mono = [...preview.querySelectorAll('.schema-view-data')].map((el) =>
6591
+ (el.textContent ?? '').trim()
6592
+ );
6593
+ expect(mono).toContain('qorus-session');
6594
+ expect(mono).not.toContain('Default RBAC');
6595
+
6596
+ // The stored spellings are what the author never chose, so they must not be
6597
+ // in the preview at all. Substring checks cannot say this — "Scheme Type"
6598
+ // contains "type" and "Default RBAC" contains "default" — so the assertion
6599
+ // is on whole leaf elements: no chip or cell reads exactly `type`,
6600
+ // `cookie_name` or `default`. A substring assertion here passed against a
6601
+ // preview that was still rendering the raw pair.
6602
+ const leafTexts = [...preview.querySelectorAll('*')]
6603
+ .filter((element) => element.children.length === 0)
6604
+ .map((element) => (element.textContent ?? '').trim());
6605
+ expect(leafTexts).toContain('Default RBAC');
6606
+ for (const stored of ['type', 'cookie_name', 'default']) {
6607
+ expect(leafTexts).not.toContain(stored);
6608
+ }
6609
+ expect(canvasElement.textContent ?? '').not.toContain('[object Object]');
6610
+ },
6611
+ };
6612
+
6613
+ /**
6614
+ * Adding a list item opens the field the item cannot be saved without.
6615
+ *
6616
+ * `+ Add new item for "Authentication Scheme"` added a row whose one required
6617
+ * field sat collapsed, so the author had to find it and click it before the
6618
+ * form could be completed — a second click to reach the only thing the first
6619
+ * click could have meant.
6620
+ *
6621
+ * The engine already had `autoFocusFirstRequired`, and it could not fire here:
6622
+ * it waits for focus to be free so it never steals a caret, and a form mounted
6623
+ * BY a click never sees free focus — the button that mounted it still holds it.
6624
+ * Opening a row and moving the caret are separate decisions, so they are now
6625
+ * separate flags; this one only opens.
6626
+ */
6627
+ export const CompactAddedListRowOpensItsRequiredField: Story = {
6628
+ parameters: {
6629
+ docs: {
6630
+ description: {
6631
+ story:
6632
+ 'Clicks "Add new item" on a list-of-hash option and shows the new row with its required "Scheme Type" field already open. Rows that were already in the value stay collapsed — only the row just added is opened.',
6633
+ },
6634
+ },
6635
+ },
6636
+ args: {
6637
+ compact: true,
6638
+ minColumnWidth: '300px',
6639
+ options: AuthSchemeOptions,
6640
+ value: {
6641
+ schemes: {
6642
+ type: 'list',
6643
+ value: [{ type: 'hash', value: { type: 'default' } }],
6644
+ },
6645
+ },
6646
+ },
6647
+ play: async ({ canvasElement }) => {
6648
+ // Open the list itself, which mounts the rows and the Add button. Clicked by
6649
+ // FIELD, not by its text: the row no longer prints a summary line, and the
6650
+ // words that remain live inside the preview.
6651
+ await _testsClickText('Authentication Schemes');
6652
+ await waitFor(() => expect(canvasElement.querySelectorAll('.array-auto-item')).toHaveLength(1));
6653
+
6654
+ // The row that was already there is collapsed — this is the state the new
6655
+ // row must NOT be confused with.
6656
+ const isOpen = (element: Element | null | undefined) =>
6657
+ !!element &&
6658
+ (element.classList.contains('readfirst-row-editing') ||
6659
+ element.classList.contains('options-readfirst-card'));
6660
+ const typeRows = () => canvasElement.querySelectorAll('[data-field="type"]');
6661
+ expect([...typeRows()].some(isOpen)).toBe(false);
6662
+
6663
+ await _testsClickButton({ label: 'Add new item for "Authentication Schemes"' });
6664
+
6665
+ await waitFor(() => expect(canvasElement.querySelectorAll('.array-auto-item')).toHaveLength(2));
6666
+ // Exactly one open required field: the one belonging to the row just added.
6667
+ await waitFor(() => expect([...typeRows()].filter(isOpen)).toHaveLength(1));
6668
+ },
6669
+ };
6670
+
6671
+ /**
6672
+ * The same schema-worded row at a phone-class width.
6673
+ *
6674
+ * The narrow branch is what makes this worth its own story: below 480px the row
6675
+ * stacks the value under the label, so a summary and a preview that were both
6676
+ * rewritten to be READ (rather than decoded) have to survive the stacking
6677
+ * without wrapping into an unreadable column. `compactNarrow` comes from
6678
+ * `useMeasure` on the form's own wrapper, not from a media query, so a narrow
6679
+ * container is the honest way to reach the branch here — the viewport-parameter
6680
+ * rule applies to media-query components, which this is not.
6681
+ */
6682
+ export const CompactListOfHashReadsInSchemaWordsMobile: Story = {
6683
+ parameters: {
6684
+ docs: {
6685
+ description: {
6686
+ story:
6687
+ 'The schema-worded list-of-hash row at a ~360px width: the summary and the renamed preview stack under the field name and stay legible in one column.',
6688
+ },
6689
+ },
6690
+ },
6691
+ args: CompactListOfHashReadsInSchemaWords.args,
6692
+ decorators: [
6693
+ (StoryComponent: React.ComponentType) => (
6694
+ <div style={{ maxWidth: 360, margin: '0 auto', border: '1px dashed #ffffff22' }}>
6695
+ <StoryComponent />
6696
+ </div>
6697
+ ),
6698
+ ],
6699
+ play: async ({ canvasElement }) => {
6700
+ await _testsWaitForText('Default RBAC');
6701
+
6702
+ // The narrow branch is actually engaged — otherwise this is the desktop
6703
+ // story with a border round it, and it would pass while proving nothing.
6704
+ await waitFor(() =>
6705
+ expect(canvasElement.querySelector('.readfirst-narrow')).toBeTruthy()
6706
+ );
6707
+
6708
+ const preview = await waitFor(() => {
6709
+ const element = canvasElement.querySelector('.schema-data-view');
6710
+ expect(element).toBeTruthy();
6711
+ return element!;
6712
+ });
6713
+ const leafTexts = [...preview.querySelectorAll('*')]
6714
+ .filter((element) => element.children.length === 0)
6715
+ .map((element) => (element.textContent ?? '').trim());
6716
+ expect(leafTexts).toContain('Session Cookie Name');
6717
+ for (const stored of ['type', 'cookie_name', 'default']) {
6718
+ expect(leafTexts).not.toContain(stored);
6719
+ }
6720
+ },
6721
+ };
6722
+
5936
6723
  /**
5937
6724
  * The read-first summary of an EXPRESSION: it renders to one line of text, and
5938
6725
  * a template reference inside it used to print as its own raw token — the same