@qoretechnologies/reqraft 0.10.20 → 0.10.26

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 (139) hide show
  1. package/.claude/CLAUDE.md +11 -8
  2. package/dist/components/composer/Composer.d.ts +108 -0
  3. package/dist/components/composer/Composer.d.ts.map +1 -0
  4. package/dist/components/composer/Composer.js +238 -0
  5. package/dist/components/composer/Composer.js.map +1 -0
  6. package/dist/components/composer/helpers.d.ts +21 -0
  7. package/dist/components/composer/helpers.d.ts.map +1 -0
  8. package/dist/components/composer/helpers.js +75 -0
  9. package/dist/components/composer/helpers.js.map +1 -0
  10. package/dist/components/composer/index.d.ts +3 -0
  11. package/dist/components/composer/index.d.ts.map +1 -0
  12. package/dist/components/composer/index.js +19 -0
  13. package/dist/components/composer/index.js.map +1 -0
  14. package/dist/components/form/engine/FormEngine.d.ts +1 -1
  15. package/dist/components/imageLightbox/ImageLightbox.d.ts +21 -0
  16. package/dist/components/imageLightbox/ImageLightbox.d.ts.map +1 -0
  17. package/dist/components/imageLightbox/ImageLightbox.js +169 -0
  18. package/dist/components/imageLightbox/ImageLightbox.js.map +1 -0
  19. package/dist/components/imageLightbox/index.d.ts +2 -0
  20. package/dist/components/imageLightbox/index.d.ts.map +1 -0
  21. package/dist/components/imageLightbox/index.js +18 -0
  22. package/dist/components/imageLightbox/index.js.map +1 -0
  23. package/dist/components/searchFilterBar/SearchFilterBar.d.ts +33 -0
  24. package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -0
  25. package/dist/components/searchFilterBar/SearchFilterBar.js +76 -0
  26. package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -0
  27. package/dist/components/searchFilterBar/index.d.ts +2 -0
  28. package/dist/components/searchFilterBar/index.d.ts.map +1 -0
  29. package/dist/components/searchFilterBar/index.js +18 -0
  30. package/dist/components/searchFilterBar/index.js.map +1 -0
  31. package/dist/components/supportTicket/AttachmentChips.d.ts +10 -0
  32. package/dist/components/supportTicket/AttachmentChips.d.ts.map +1 -0
  33. package/dist/components/supportTicket/AttachmentChips.js +122 -0
  34. package/dist/components/supportTicket/AttachmentChips.js.map +1 -0
  35. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +43 -0
  36. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -0
  37. package/dist/components/supportTicket/InterfaceReferenceTags.js +31 -0
  38. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -0
  39. package/dist/components/supportTicket/ReferencePicker.d.ts +87 -0
  40. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -0
  41. package/dist/components/supportTicket/ReferencePicker.js +295 -0
  42. package/dist/components/supportTicket/ReferencePicker.js.map +1 -0
  43. package/dist/components/supportTicket/TicketHeader.d.ts +86 -0
  44. package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -0
  45. package/dist/components/supportTicket/TicketHeader.js +149 -0
  46. package/dist/components/supportTicket/TicketHeader.js.map +1 -0
  47. package/dist/components/supportTicket/TicketMetaTags.d.ts +32 -0
  48. package/dist/components/supportTicket/TicketMetaTags.d.ts.map +1 -0
  49. package/dist/components/supportTicket/TicketMetaTags.js +21 -0
  50. package/dist/components/supportTicket/TicketMetaTags.js.map +1 -0
  51. package/dist/components/supportTicket/TicketReplyBox.d.ts +63 -0
  52. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -0
  53. package/dist/components/supportTicket/TicketReplyBox.js +115 -0
  54. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -0
  55. package/dist/components/supportTicket/attachmentAccess.d.ts +37 -0
  56. package/dist/components/supportTicket/attachmentAccess.d.ts.map +1 -0
  57. package/dist/components/supportTicket/attachmentAccess.js +132 -0
  58. package/dist/components/supportTicket/attachmentAccess.js.map +1 -0
  59. package/dist/components/supportTicket/fileToAttachment.d.ts +9 -0
  60. package/dist/components/supportTicket/fileToAttachment.d.ts.map +1 -0
  61. package/dist/components/supportTicket/fileToAttachment.js +28 -0
  62. package/dist/components/supportTicket/fileToAttachment.js.map +1 -0
  63. package/dist/components/supportTicket/index.d.ts +11 -0
  64. package/dist/components/supportTicket/index.d.ts.map +1 -0
  65. package/dist/components/supportTicket/index.js +27 -0
  66. package/dist/components/supportTicket/index.js.map +1 -0
  67. package/dist/components/supportTicket/meta.d.ts +71 -0
  68. package/dist/components/supportTicket/meta.d.ts.map +1 -0
  69. package/dist/components/supportTicket/meta.js +115 -0
  70. package/dist/components/supportTicket/meta.js.map +1 -0
  71. package/dist/components/supportTicket/ticketModel.d.ts +102 -0
  72. package/dist/components/supportTicket/ticketModel.d.ts.map +1 -0
  73. package/dist/components/supportTicket/ticketModel.js +3 -0
  74. package/dist/components/supportTicket/ticketModel.js.map +1 -0
  75. package/dist/components/ticketReferences/TicketReferences.d.ts +52 -0
  76. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -0
  77. package/dist/components/ticketReferences/TicketReferences.js +492 -0
  78. package/dist/components/ticketReferences/TicketReferences.js.map +1 -0
  79. package/dist/components/ticketReferences/index.d.ts +3 -0
  80. package/dist/components/ticketReferences/index.d.ts.map +1 -0
  81. package/dist/components/ticketReferences/index.js +6 -0
  82. package/dist/components/ticketReferences/index.js.map +1 -0
  83. package/dist/components/ticketThread/TicketThread.d.ts +57 -0
  84. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -0
  85. package/dist/components/ticketThread/TicketThread.js +299 -0
  86. package/dist/components/ticketThread/TicketThread.js.map +1 -0
  87. package/dist/components/ticketThread/index.d.ts +3 -0
  88. package/dist/components/ticketThread/index.d.ts.map +1 -0
  89. package/dist/components/ticketThread/index.js +7 -0
  90. package/dist/components/ticketThread/index.js.map +1 -0
  91. package/dist/helpers/common.d.ts +5 -0
  92. package/dist/helpers/common.d.ts.map +1 -1
  93. package/dist/helpers/common.js +20 -1
  94. package/dist/helpers/common.js.map +1 -1
  95. package/dist/helpers/scrollbar.d.ts +2 -0
  96. package/dist/helpers/scrollbar.d.ts.map +1 -0
  97. package/dist/helpers/scrollbar.js +25 -0
  98. package/dist/helpers/scrollbar.js.map +1 -0
  99. package/dist/index.d.ts +8 -1
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +10 -1
  102. package/dist/index.js.map +1 -1
  103. package/package.json +1 -1
  104. package/src/components/composer/Composer.stories.tsx +238 -0
  105. package/src/components/composer/Composer.tsx +545 -0
  106. package/src/components/composer/helpers.ts +74 -0
  107. package/src/components/composer/index.ts +2 -0
  108. package/src/components/imageLightbox/ImageLightbox.stories.tsx +238 -0
  109. package/src/components/imageLightbox/ImageLightbox.tsx +236 -0
  110. package/src/components/imageLightbox/index.ts +1 -0
  111. package/src/components/searchFilterBar/SearchFilterBar.tsx +131 -0
  112. package/src/components/searchFilterBar/index.ts +1 -0
  113. package/src/components/supportTicket/AttachmentChips.stories.tsx +171 -0
  114. package/src/components/supportTicket/AttachmentChips.tsx +147 -0
  115. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +171 -0
  116. package/src/components/supportTicket/InterfaceReferenceTags.tsx +96 -0
  117. package/src/components/supportTicket/ReferencePicker.stories.tsx +519 -0
  118. package/src/components/supportTicket/ReferencePicker.tsx +563 -0
  119. package/src/components/supportTicket/TicketConversation.stories.tsx +121 -0
  120. package/src/components/supportTicket/TicketHeader.stories.tsx +183 -0
  121. package/src/components/supportTicket/TicketHeader.tsx +247 -0
  122. package/src/components/supportTicket/TicketMetaTags.stories.tsx +103 -0
  123. package/src/components/supportTicket/TicketMetaTags.tsx +85 -0
  124. package/src/components/supportTicket/TicketReplyBox.stories.tsx +351 -0
  125. package/src/components/supportTicket/TicketReplyBox.tsx +146 -0
  126. package/src/components/supportTicket/attachmentAccess.ts +97 -0
  127. package/src/components/supportTicket/fileToAttachment.ts +24 -0
  128. package/src/components/supportTicket/index.ts +10 -0
  129. package/src/components/supportTicket/meta.ts +185 -0
  130. package/src/components/supportTicket/ticketModel.ts +131 -0
  131. package/src/components/ticketReferences/TicketReferences.stories.tsx +315 -0
  132. package/src/components/ticketReferences/TicketReferences.tsx +886 -0
  133. package/src/components/ticketReferences/index.ts +2 -0
  134. package/src/components/ticketThread/TicketThread.stories.tsx +828 -0
  135. package/src/components/ticketThread/TicketThread.tsx +704 -0
  136. package/src/components/ticketThread/index.ts +6 -0
  137. package/src/helpers/common.ts +19 -0
  138. package/src/helpers/scrollbar.ts +32 -0
  139. package/src/index.tsx +8 -1
@@ -0,0 +1,519 @@
1
+ import { ReqoreButton, ReqoreUIProvider } from '@qoretechnologies/reqore';
2
+ import { StoryObj } from '@storybook/react-vite';
3
+ import { ComponentProps, useState } from 'react';
4
+ import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
5
+ import { StoryMeta } from '../../types';
6
+ import { InterfaceReferenceTags } from './InterfaceReferenceTags';
7
+ import { IInterfaceReference } from './meta';
8
+ import { ReferencePicker, TReferencePickerItem } from './ReferencePicker';
9
+ import { TicketReplyBox } from './TicketReplyBox';
10
+
11
+ /**
12
+ * The interface browser — kinds on one side, the selected kind's interfaces on the
13
+ * other, one search bar scoping both. Rendered **on top** of the composer and toggled
14
+ * by its "Reference interfaces" action. Search dims non-matching kinds rather than
15
+ * removing them, picked interfaces stay in the list as selected rows, and the two panes
16
+ * collapse into one column (with a modal on phones) when there isn't width for both.
17
+ */
18
+ const meta = {
19
+ component: ReferencePicker,
20
+ title: 'Components/ReferencePicker',
21
+ } as StoryMeta<typeof ReferencePicker>;
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ const ACCENT = { main: 'custom1' } as const;
26
+
27
+ /* The kind ids a host passes — wire values, not labels. `qog` is the customer-facing
28
+ * kind for what the instance still lists as `fsm`; hosts map that at the fetch
29
+ * boundary, so `fsm` never reaches the picker. */
30
+ const KINDS = [
31
+ 'workflow',
32
+ 'service',
33
+ 'job',
34
+ 'connection',
35
+ 'mapper',
36
+ 'class',
37
+ 'value-map',
38
+ 'qog',
39
+ 'ai-collection',
40
+ 'ai-endpoint',
41
+ 'ai-guardrail',
42
+ ];
43
+
44
+ /* Stands in for the host's fetch: in the IDE these come off the customer's live
45
+ * instance one kind at a time. Services carry the richer item shape — a description
46
+ * and a badge — with `partner-gateway` left as a bare string so the plain row and the
47
+ * mixed list both stay on screen. */
48
+ const CATALOGUE: Record<string, TReferencePickerItem[]> = {
49
+ workflow: ['order-sync:1.2', 'invoice-export:2.0', 'partner-recon:1.1'],
50
+ service: [
51
+ {
52
+ name: 'notifier',
53
+ description: 'Fans order events out to email and Slack',
54
+ badge: '3 running',
55
+ },
56
+ { name: 'audit-logger', description: 'Writes every state change to the audit table' },
57
+ 'partner-gateway',
58
+ ],
59
+ job: ['nightly-recon', 'cleanup-temp', 'sla-report'],
60
+ connection: ['sftp-partner', 'pgsql-omq', 'salesforce-prod'],
61
+ mapper: ['csv-to-order', 'order-to-invoice'],
62
+ class: ['OrderUtils', 'PartnerApi'],
63
+ 'value-map': ['country-codes', 'currency'],
64
+ qog: ['telegram-intake', 'stripe-billing', 'onboarding', 'escalation'],
65
+ 'ai-collection': ['kb-support'],
66
+ 'ai-endpoint': ['gpt-router'],
67
+ 'ai-guardrail': ['pii-filter'],
68
+ };
69
+
70
+ const darkTheme = (Story: () => JSX.Element) => (
71
+ <ReqoreUIProvider theme={{ main: '#121212', intents: { custom1: '#762f7e' } }}>
72
+ <div style={{ padding: 20, background: '#121212' }}>
73
+ <Story />
74
+ </div>
75
+ </ReqoreUIProvider>
76
+ );
77
+
78
+ /** Drives the picker the way a host does: owns the selected kind, resolves that
79
+ * kind's interfaces, and accumulates what gets picked. */
80
+ const Hosted = ({
81
+ withComposer,
82
+ initialKind = 'workflow',
83
+ ...pickerProps
84
+ }: { withComposer?: boolean; initialKind?: string } & Partial<
85
+ ComponentProps<typeof ReferencePicker>
86
+ >) => {
87
+ const [kind, setKind] = useState<string>(initialKind);
88
+ const [picked, setPicked] = useState<IInterfaceReference[]>([]);
89
+ const [open, setOpen] = useState<boolean>(true);
90
+
91
+ const isPicked = (reference: IInterfaceReference) =>
92
+ picked.some(
93
+ (existing) =>
94
+ existing.interface_kind === reference.interface_kind &&
95
+ existing.interface_name === reference.interface_name
96
+ );
97
+
98
+ const picker = (
99
+ <ReferencePicker
100
+ kinds={KINDS}
101
+ kind={kind}
102
+ onKindChange={setKind}
103
+ items={CATALOGUE[kind] ?? []}
104
+ picked={picked}
105
+ onAdd={(reference) =>
106
+ setPicked((current) => (isPicked(reference) ? current : [...current, reference]))
107
+ }
108
+ onRemove={(reference) =>
109
+ setPicked((current) =>
110
+ current.filter(
111
+ (existing) =>
112
+ !(
113
+ existing.interface_kind === reference.interface_kind &&
114
+ existing.interface_name === reference.interface_name
115
+ )
116
+ )
117
+ )
118
+ }
119
+ {...pickerProps}
120
+ />
121
+ );
122
+
123
+ if (!withComposer) {
124
+ return picker;
125
+ }
126
+
127
+ return (
128
+ <div style={{ maxWidth: 780 }}>
129
+ {open ? <div style={{ marginBottom: 10 }}>{picker}</div> : null}
130
+ <TicketReplyBox
131
+ onSend={fn()}
132
+ placeholder='Write a reply…'
133
+ aboveInput={
134
+ picked.length ? (
135
+ /* Composer chips are the references you're about to send, so they carry
136
+ their own remove — and read a size up from the thread's, where they're
137
+ a record rather than a control. */
138
+ <InterfaceReferenceTags
139
+ customTheme={ACCENT}
140
+ references={picked}
141
+ onRemove={(reference) =>
142
+ setPicked((current) =>
143
+ current.filter(
144
+ (existing) =>
145
+ !(
146
+ existing.interface_kind === reference.interface_kind &&
147
+ existing.interface_name === reference.interface_name
148
+ )
149
+ )
150
+ )
151
+ }
152
+ />
153
+ ) : undefined
154
+ }
155
+ footerActions={
156
+ <ReqoreButton
157
+ className='references-toggle'
158
+ icon='LinksLine'
159
+ minimal
160
+ flat
161
+ badge={picked.length || undefined}
162
+ active={open}
163
+ customTheme={ACCENT}
164
+ tooltip={open ? 'Hide references' : 'Reference interfaces'}
165
+ onClick={() => setOpen((shown) => !shown)}
166
+ />
167
+ }
168
+ />
169
+ </div>
170
+ );
171
+ };
172
+
173
+ /*
174
+ * Queried by role, not by text: ReqoreButton renders its label in a pair of spans (an
175
+ * active one and an aria-hidden inactive one) to cross-fade, so getByText matches twice
176
+ * for every button. The accessible name collapses that pair back to one.
177
+ */
178
+ const option = (canvas: ReturnType<typeof within>, name: string) =>
179
+ canvas.getByRole('button', { name });
180
+ const missingOption = (canvas: ReturnType<typeof within>, name: string) =>
181
+ canvas.queryByRole('button', { name });
182
+
183
+ /** Picking an interface adds it as a reference and leaves the row in place, selected. */
184
+ export const Default: Story = {
185
+ parameters: {
186
+ docs: {
187
+ description: {
188
+ story:
189
+ 'Renders the two-pane reference browser. Picking an interface adds it as a reference and leaves the row in the list, selected and ticked, rather than removing it.',
190
+ },
191
+ },
192
+ },
193
+ decorators: [darkTheme],
194
+ render: () => <Hosted />,
195
+ async play({ canvasElement }) {
196
+ const canvas = within(canvasElement);
197
+ await userEvent.click(await canvas.findByRole('button', { name: 'order-sync:1.2' }));
198
+ // it stays in the pane, now selected
199
+ await waitFor(() =>
200
+ expect(option(canvas, 'order-sync:1.2')).toHaveClass('reqore-reference-picker-picked')
201
+ );
202
+ // its siblings are untouched
203
+ await expect(option(canvas, 'invoice-export:2.0')).toBeInTheDocument();
204
+ },
205
+ };
206
+
207
+ /** A picked interface can be un-picked from the list it was picked in. */
208
+ export const DeselectsAPickedInterface: Story = {
209
+ parameters: {
210
+ docs: {
211
+ description: {
212
+ story:
213
+ 'Renders the browser and picks an interface, then clicks the same row again — the reference comes back off without the user having to click away from the picker.',
214
+ },
215
+ },
216
+ },
217
+ decorators: [darkTheme],
218
+ render: () => <Hosted />,
219
+ async play({ canvasElement }) {
220
+ const canvas = within(canvasElement);
221
+ const row = await canvas.findByRole('button', { name: 'invoice-export:2.0' });
222
+
223
+ await userEvent.click(row);
224
+ await waitFor(() => expect(row).toHaveClass('reqore-reference-picker-picked'));
225
+
226
+ await userEvent.click(row);
227
+ await waitFor(() => expect(row).not.toHaveClass('reqore-reference-picker-picked'));
228
+ },
229
+ };
230
+
231
+ /**
232
+ * Interfaces can arrive as bare names or as objects carrying a description and a
233
+ * badge — a name alone often isn't enough to tell two of them apart. A picked row
234
+ * tints rather than filling solid, so the ones still up for choosing stay the loudest
235
+ * things in the list.
236
+ */
237
+ export const WithDescriptionsAndBadges: Story = {
238
+ parameters: {
239
+ docs: {
240
+ description: {
241
+ story:
242
+ 'Renders a kind whose interfaces carry descriptions and badges alongside one that has neither. Picking a row tints it rather than filling it solid, keeping the unpicked rows the most prominent.',
243
+ },
244
+ },
245
+ },
246
+ decorators: [darkTheme],
247
+ render: () => <Hosted initialKind='service' />,
248
+ async play({ canvasElement }) {
249
+ const canvas = within(canvasElement);
250
+ await expect(
251
+ await canvas.findByText('Fans order events out to email and Slack')
252
+ ).toBeInTheDocument();
253
+ await expect(canvas.getByText('3 running')).toBeInTheDocument();
254
+
255
+ // a bare-string item in the same list renders as a plain row
256
+ await expect(option(canvas, 'partner-gateway')).toBeInTheDocument();
257
+
258
+ // picking keeps the description and badge in place
259
+ await userEvent.click(canvas.getByRole('button', { name: /audit-logger/ }));
260
+ await waitFor(() =>
261
+ expect(canvas.getByRole('button', { name: /audit-logger/ })).toHaveClass(
262
+ 'reqore-reference-picker-picked'
263
+ )
264
+ );
265
+ await expect(
266
+ canvas.getByText('Writes every state change to the audit table')
267
+ ).toBeInTheDocument();
268
+ },
269
+ };
270
+
271
+ /** A description is searchable text too, so the search reads it alongside the name. */
272
+ export const SearchMatchesDescriptions: Story = {
273
+ parameters: {
274
+ docs: {
275
+ description: {
276
+ story:
277
+ "Renders a kind with described interfaces and searches for a word that appears only in a description — the matching row survives even though its name doesn't contain the query.",
278
+ },
279
+ },
280
+ },
281
+ decorators: [darkTheme],
282
+ render: () => <Hosted initialKind='service' />,
283
+ async play({ canvasElement }) {
284
+ const canvas = within(canvasElement);
285
+ const search = canvasElement.querySelector(
286
+ 'input[placeholder*="Search types"]'
287
+ ) as HTMLInputElement;
288
+
289
+ // "Slack" is only in the notifier's description
290
+ await userEvent.type(search, 'slack');
291
+ await waitFor(() => expect(missingOption(canvas, 'partner-gateway')).not.toBeInTheDocument());
292
+ await expect(canvas.getByRole('button', { name: /notifier/ })).toBeInTheDocument();
293
+ },
294
+ };
295
+
296
+ /** Selecting a kind on the left swaps the right pane to that kind's interfaces. */
297
+ export const SwitchesKind: Story = {
298
+ parameters: {
299
+ docs: {
300
+ description: {
301
+ story:
302
+ "Renders the picker and shows that selecting a kind on the left swaps the right pane to that kind's interfaces.",
303
+ },
304
+ },
305
+ },
306
+ decorators: [darkTheme],
307
+ render: () => <Hosted />,
308
+ async play({ canvasElement }) {
309
+ const canvas = within(canvasElement);
310
+ await expect(option(canvas, 'order-sync:1.2')).toBeInTheDocument();
311
+ await userEvent.click(option(canvas, 'Connections'));
312
+ await waitFor(() => expect(option(canvas, 'sftp-partner')).toBeInTheDocument());
313
+ await expect(missingOption(canvas, 'order-sync:1.2')).not.toBeInTheDocument();
314
+ },
315
+ };
316
+
317
+ /**
318
+ * One search scopes both panes: non-matching kinds drop off the left so the matches
319
+ * are the whole list, and the selected kind stays on — dimmed — even when the query
320
+ * doesn't match it, so the interfaces on the right always have a visible owner.
321
+ */
322
+ export const SearchKeepsTheSelectedKind: Story = {
323
+ parameters: {
324
+ docs: {
325
+ description: {
326
+ story:
327
+ "Renders the picker and types a search. Non-matching kinds drop off the left pane, but the selected kind stays on as a dimmed row so the interfaces still listed on the right can be told apart from the kind they belong to.",
328
+ },
329
+ },
330
+ },
331
+ decorators: [darkTheme],
332
+ render: () => <Hosted />,
333
+ async play({ canvasElement }) {
334
+ const canvas = within(canvasElement);
335
+ const search = canvasElement.querySelector(
336
+ 'input[placeholder*="Search types"]'
337
+ ) as HTMLInputElement;
338
+
339
+ await userEvent.type(search, 'sync');
340
+ // right pane: only the matching interface survives
341
+ await waitFor(() =>
342
+ expect(missingOption(canvas, 'invoice-export:2.0')).not.toBeInTheDocument()
343
+ );
344
+ await expect(option(canvas, 'order-sync:1.2')).toBeInTheDocument();
345
+ // left pane: nothing is called "sync", so only the selected kind is left — dimmed,
346
+ // which is what tells you the surviving interface is a workflow
347
+ await expect(option(canvas, 'Workflows')).toHaveClass('reqore-reference-picker-dimmed');
348
+ await expect(missingOption(canvas, 'Services')).not.toBeInTheDocument();
349
+
350
+ // a kind name matches: it joins the list at full strength, next to the still-dimmed
351
+ // selected kind
352
+ await userEvent.clear(search);
353
+ await userEvent.type(search, 'connect');
354
+ await waitFor(() =>
355
+ expect(option(canvas, 'Connections')).not.toHaveClass(
356
+ 'reqore-reference-picker-dimmed'
357
+ )
358
+ );
359
+ await expect(option(canvas, 'Workflows')).toHaveClass('reqore-reference-picker-dimmed');
360
+ await expect(missingOption(canvas, 'Jobs')).not.toBeInTheDocument();
361
+ },
362
+ };
363
+
364
+ /**
365
+ * Narrow container: the panes collapse into one column and the selected kind's
366
+ * interfaces render directly under its row. The width measured is the picker's own,
367
+ * not the viewport's — this canvas is 320px wide on a desktop-sized screen, which is
368
+ * exactly the case a viewport media query gets wrong.
369
+ */
370
+ export const StacksInANarrowContainer: Story = {
371
+ parameters: {
372
+ docs: {
373
+ description: {
374
+ story:
375
+ "Renders the picker in a 320px-wide container on a full-size screen: it collapses to one column on its own measured width, with the selected kind's interfaces indented directly beneath its row instead of in a second pane.",
376
+ },
377
+ },
378
+ },
379
+ decorators: [darkTheme],
380
+ render: () => (
381
+ <div style={{ width: 320 }}>
382
+ <Hosted />
383
+ </div>
384
+ ),
385
+ async play({ canvasElement }) {
386
+ const canvas = within(canvasElement);
387
+ const services = option(canvas, 'Services');
388
+
389
+ /* Stacked is a claim about ORDER, not just presence: the workflows sit between
390
+ their own kind row and the next one. Side by side, every kind would come
391
+ first and the interfaces after — so the comparison tells the two apart
392
+ without reaching for a marker class. */
393
+ await waitFor(() => {
394
+ const workflow = option(canvas, 'order-sync:1.2');
395
+ expect(
396
+ workflow.compareDocumentPosition(services) & Node.DOCUMENT_POSITION_FOLLOWING
397
+ ).toBeTruthy();
398
+ });
399
+ await expect(option(canvas, 'Workflows')).toBeInTheDocument();
400
+ },
401
+ };
402
+
403
+ /** On a phone the picker lifts into a modal — inline it would own the whole screen. */
404
+ export const OnAPhone: Story = {
405
+ parameters: {
406
+ // capture this one at phone size; the modal portals to the viewport, so a
407
+ // narrow story wrapper wouldn't constrain it
408
+ qlip: { viewport: { width: 390, height: 844 } },
409
+ docs: {
410
+ description: {
411
+ story:
412
+ 'Renders the phone presentation: the picker lifts out of the composer into a full-width modal with a close action, stacked into a single column.',
413
+ },
414
+ },
415
+ },
416
+ decorators: [darkTheme],
417
+ render: () => <Hosted presentation='modal' layout='stacked' onClose={fn()} />,
418
+ async play() {
419
+ // the modal portals out of the story canvas, so query the document
420
+ const canvas = within(document.body);
421
+ await expect(await canvas.findByText('Reference interfaces')).toBeInTheDocument();
422
+ await expect(await canvas.findByRole('button', { name: 'order-sync:1.2' })).toBeInTheDocument();
423
+ },
424
+ };
425
+
426
+ /** The right pane says it's still fetching rather than claiming the kind is empty. */
427
+ export const Loading: Story = {
428
+ parameters: {
429
+ docs: {
430
+ description: {
431
+ story:
432
+ "Renders the loading state, where the right pane reports it is still fetching ('Loading workflows…') rather than claiming the kind is empty.",
433
+ },
434
+ },
435
+ },
436
+ decorators: [darkTheme],
437
+ render: () => (
438
+ <ReferencePicker
439
+ kinds={KINDS}
440
+ kind='workflow'
441
+ onKindChange={fn()}
442
+ items={[]}
443
+ loading
444
+ onAdd={fn()}
445
+ />
446
+ ),
447
+ async play({ canvasElement }) {
448
+ const canvas = within(canvasElement);
449
+ await expect(canvas.getByText('Loading workflows…')).toBeInTheDocument();
450
+ await expect(canvas.queryByText(/No workflows/)).not.toBeInTheDocument();
451
+ },
452
+ };
453
+
454
+ /**
455
+ * The host's fetch failed. The pane says so and offers a retry — an empty list here
456
+ * would read as "this instance has no connections", which is a different and much more
457
+ * alarming claim than "we couldn't ask". The specific failure is the tooltip.
458
+ */
459
+ export const LoadFailed: Story = {
460
+ parameters: {
461
+ docs: {
462
+ description: {
463
+ story:
464
+ "Renders the fetch-failure state, where the pane shows the error and a retry action instead of an empty list that would read as 'this instance has no connections'.",
465
+ },
466
+ },
467
+ },
468
+ decorators: [darkTheme],
469
+ render: () => (
470
+ <ReferencePicker
471
+ kinds={KINDS}
472
+ kind='connection'
473
+ onKindChange={fn()}
474
+ items={[]}
475
+ error='Could not reach the Qorus instance'
476
+ onRetry={fn()}
477
+ onAdd={fn()}
478
+ />
479
+ ),
480
+ async play({ canvasElement }) {
481
+ const canvas = within(canvasElement);
482
+ const retry = await canvas.findByRole('button', { name: /Couldn't load connections/ });
483
+ await expect(retry).toBeInTheDocument();
484
+ // the failure replaces the list rather than sitting alongside an empty one
485
+ await expect(canvas.queryByText(/No connections/)).not.toBeInTheDocument();
486
+ },
487
+ };
488
+
489
+ /** In place: above the composer, toggled by the composer's own action, with the picked
490
+ * references shown as chips over the input. */
491
+ export const AboveTheComposer: Story = {
492
+ parameters: {
493
+ docs: {
494
+ description: {
495
+ story:
496
+ "Renders the picker in place above the composer, toggled by the composer's own action, with picked references surfacing as chips over the input that persist when the picker is closed.",
497
+ },
498
+ },
499
+ },
500
+ decorators: [darkTheme],
501
+ render: () => <Hosted withComposer />,
502
+ async play({ canvasElement }) {
503
+ const canvas = within(canvasElement);
504
+ await userEvent.click(await canvas.findByRole('button', { name: 'order-sync:1.2' }));
505
+ // the pick stays selected in the pane and surfaces as a chip on the composer
506
+ await waitFor(() =>
507
+ expect(option(canvas, 'order-sync:1.2')).toHaveClass('reqore-reference-picker-picked')
508
+ );
509
+ await expect(canvas.getAllByText('order-sync:1.2').length).toBeGreaterThan(0);
510
+
511
+ const toggle = canvasElement.querySelector('.references-toggle') as HTMLElement;
512
+ await userEvent.click(toggle);
513
+ // the picker closes; the chip stays, because the reference is still attached
514
+ await waitFor(() =>
515
+ expect(missingOption(canvas, 'invoice-export:2.0')).not.toBeInTheDocument()
516
+ );
517
+ await expect(canvas.getAllByText('order-sync:1.2').length).toBeGreaterThan(0);
518
+ },
519
+ };