@qoretechnologies/reqraft 0.10.10 → 0.10.13-pr.74.gccefa54

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 (155) 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 +17 -0
  16. package/dist/components/imageLightbox/ImageLightbox.d.ts.map +1 -0
  17. package/dist/components/imageLightbox/ImageLightbox.js +129 -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/supportTicket/AttachmentChips.d.ts +10 -0
  24. package/dist/components/supportTicket/AttachmentChips.d.ts.map +1 -0
  25. package/dist/components/supportTicket/AttachmentChips.js +119 -0
  26. package/dist/components/supportTicket/AttachmentChips.js.map +1 -0
  27. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +35 -0
  28. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -0
  29. package/dist/components/supportTicket/InterfaceReferenceTags.js +31 -0
  30. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -0
  31. package/dist/components/supportTicket/ReferencePicker.d.ts +41 -0
  32. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -0
  33. package/dist/components/supportTicket/ReferencePicker.js +86 -0
  34. package/dist/components/supportTicket/ReferencePicker.js.map +1 -0
  35. package/dist/components/supportTicket/TicketHeader.d.ts +86 -0
  36. package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -0
  37. package/dist/components/supportTicket/TicketHeader.js +149 -0
  38. package/dist/components/supportTicket/TicketHeader.js.map +1 -0
  39. package/dist/components/supportTicket/TicketMetaTags.d.ts +32 -0
  40. package/dist/components/supportTicket/TicketMetaTags.d.ts.map +1 -0
  41. package/dist/components/supportTicket/TicketMetaTags.js +21 -0
  42. package/dist/components/supportTicket/TicketMetaTags.js.map +1 -0
  43. package/dist/components/supportTicket/TicketReplyBox.d.ts +63 -0
  44. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -0
  45. package/dist/components/supportTicket/TicketReplyBox.js +115 -0
  46. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -0
  47. package/dist/components/supportTicket/fileToAttachment.d.ts +9 -0
  48. package/dist/components/supportTicket/fileToAttachment.d.ts.map +1 -0
  49. package/dist/components/supportTicket/fileToAttachment.js +28 -0
  50. package/dist/components/supportTicket/fileToAttachment.js.map +1 -0
  51. package/dist/components/supportTicket/index.d.ts +9 -0
  52. package/dist/components/supportTicket/index.d.ts.map +1 -0
  53. package/dist/components/supportTicket/index.js +25 -0
  54. package/dist/components/supportTicket/index.js.map +1 -0
  55. package/dist/components/supportTicket/meta.d.ts +63 -0
  56. package/dist/components/supportTicket/meta.d.ts.map +1 -0
  57. package/dist/components/supportTicket/meta.js +79 -0
  58. package/dist/components/supportTicket/meta.js.map +1 -0
  59. package/dist/components/ticketReferences/TicketReferences.d.ts +52 -0
  60. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -0
  61. package/dist/components/ticketReferences/TicketReferences.js +453 -0
  62. package/dist/components/ticketReferences/TicketReferences.js.map +1 -0
  63. package/dist/components/ticketReferences/index.d.ts +3 -0
  64. package/dist/components/ticketReferences/index.d.ts.map +1 -0
  65. package/dist/components/ticketReferences/index.js +6 -0
  66. package/dist/components/ticketReferences/index.js.map +1 -0
  67. package/dist/components/ticketThread/TicketThread.d.ts +52 -0
  68. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -0
  69. package/dist/components/ticketThread/TicketThread.js +248 -0
  70. package/dist/components/ticketThread/TicketThread.js.map +1 -0
  71. package/dist/components/ticketThread/index.d.ts +3 -0
  72. package/dist/components/ticketThread/index.d.ts.map +1 -0
  73. package/dist/components/ticketThread/index.js +7 -0
  74. package/dist/components/ticketThread/index.js.map +1 -0
  75. package/dist/helpers/common.d.ts +5 -0
  76. package/dist/helpers/common.d.ts.map +1 -1
  77. package/dist/helpers/common.js +20 -1
  78. package/dist/helpers/common.js.map +1 -1
  79. package/dist/helpers/scrollbar.d.ts +2 -0
  80. package/dist/helpers/scrollbar.d.ts.map +1 -0
  81. package/dist/helpers/scrollbar.js +25 -0
  82. package/dist/helpers/scrollbar.js.map +1 -0
  83. package/dist/index.d.ts +5 -0
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +7 -1
  86. package/dist/index.js.map +1 -1
  87. package/package.json +3 -3
  88. package/src/components/composer/Composer.stories.tsx +238 -0
  89. package/src/components/composer/Composer.tsx +545 -0
  90. package/src/components/composer/helpers.ts +74 -0
  91. package/src/components/composer/index.ts +2 -0
  92. package/src/components/dpqlEditor/DpqlEditor.stories.tsx +74 -2
  93. package/src/components/form/engine/FormEngine.stories.tsx +654 -32
  94. package/src/components/form/engine/FormEngineRemote.stories.tsx +48 -0
  95. package/src/components/form/engine/_structuredData/StructuredDataView.stories.tsx +8 -0
  96. package/src/components/form/engine/variants/FormEngineVariants.stories.tsx +40 -0
  97. package/src/components/form/expressions/ExpressionField.stories.tsx +74 -2
  98. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +187 -0
  99. package/src/components/form/fields/Field.stories.tsx +40 -0
  100. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +64 -0
  101. package/src/components/form/fields/auto/AutoFormField.stories.tsx +136 -0
  102. package/src/components/form/fields/binary/Binary.stories.tsx +32 -0
  103. package/src/components/form/fields/boolean/Boolean.stories.tsx +24 -0
  104. package/src/components/form/fields/byte-size/ByteSize.stories.tsx +24 -0
  105. package/src/components/form/fields/color/Color.stories.tsx +30 -0
  106. package/src/components/form/fields/cron/Cron.stories.tsx +8 -0
  107. package/src/components/form/fields/date/Date.stories.tsx +22 -0
  108. package/src/components/form/fields/file/File.stories.tsx +48 -0
  109. package/src/components/form/fields/long-string/LongString.stories.tsx +24 -0
  110. package/src/components/form/fields/markdown/Markdown.stories.tsx +8 -0
  111. package/src/components/form/fields/number/Number.stories.tsx +31 -0
  112. package/src/components/form/fields/object/Object.stories.tsx +88 -0
  113. package/src/components/form/fields/radio-group/RadioGroup.stories.tsx +24 -0
  114. package/src/components/form/fields/rich-text/RichText.stories.tsx +23 -0
  115. package/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx +72 -0
  116. package/src/components/form/fields/select/Select.stories.tsx +104 -0
  117. package/src/components/form/fields/string/String.stories.tsx +16 -0
  118. package/src/components/form/fields/template/TemplateField.stories.tsx +160 -0
  119. package/src/components/form/fields/url/Url.stories.tsx +24 -0
  120. package/src/components/imageLightbox/ImageLightbox.stories.tsx +156 -0
  121. package/src/components/imageLightbox/ImageLightbox.tsx +169 -0
  122. package/src/components/imageLightbox/index.ts +1 -0
  123. package/src/components/log/Log.stories.tsx +96 -0
  124. package/src/components/menu/Menu.stories.tsx +54 -0
  125. package/src/components/qonsoleSmartInput/QonsoleSmartInput.stories.tsx +14 -0
  126. package/src/components/smartEditor/SmartEditor.stories.tsx +32 -0
  127. package/src/components/supportTicket/AttachmentChips.stories.tsx +122 -0
  128. package/src/components/supportTicket/AttachmentChips.tsx +142 -0
  129. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +134 -0
  130. package/src/components/supportTicket/InterfaceReferenceTags.tsx +85 -0
  131. package/src/components/supportTicket/ReferencePicker.stories.tsx +296 -0
  132. package/src/components/supportTicket/ReferencePicker.tsx +237 -0
  133. package/src/components/supportTicket/TicketConversation.stories.tsx +122 -0
  134. package/src/components/supportTicket/TicketHeader.stories.tsx +183 -0
  135. package/src/components/supportTicket/TicketHeader.tsx +247 -0
  136. package/src/components/supportTicket/TicketMetaTags.stories.tsx +103 -0
  137. package/src/components/supportTicket/TicketMetaTags.tsx +85 -0
  138. package/src/components/supportTicket/TicketReplyBox.stories.tsx +355 -0
  139. package/src/components/supportTicket/TicketReplyBox.tsx +146 -0
  140. package/src/components/supportTicket/fileToAttachment.ts +24 -0
  141. package/src/components/supportTicket/index.ts +8 -0
  142. package/src/components/supportTicket/meta.ts +145 -0
  143. package/src/components/ticketReferences/TicketReferences.stories.tsx +301 -0
  144. package/src/components/ticketReferences/TicketReferences.tsx +899 -0
  145. package/src/components/ticketReferences/index.ts +2 -0
  146. package/src/components/ticketThread/TicketThread.stories.tsx +751 -0
  147. package/src/components/ticketThread/TicketThread.tsx +650 -0
  148. package/src/components/ticketThread/index.ts +6 -0
  149. package/src/helpers/common.ts +19 -0
  150. package/src/helpers/scrollbar.ts +32 -0
  151. package/src/hooks/useFetch/useFetch.stories.tsx +30 -0
  152. package/src/hooks/useStorage/useStorage.stories.tsx +30 -0
  153. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +127 -1
  154. package/src/index.tsx +5 -0
  155. package/src/stores/currentUser/currentUser.stories.tsx +17 -0
@@ -0,0 +1,296 @@
1
+ import { ReqoreButton, ReqoreUIProvider } from '@qoretechnologies/reqore';
2
+ import { StoryObj } from '@storybook/react-vite';
3
+ import { 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 } from './ReferencePicker';
9
+ import { TicketReplyBox } from './TicketReplyBox';
10
+
11
+ /**
12
+ * The two-pane reference browser (master-detail: kinds on the left, that kind's
13
+ * interfaces on the right) with a single search bar that scopes both panes. Rendered
14
+ * **on top** of the composer and toggled by its "Reference interfaces" action.
15
+ */
16
+ const meta = {
17
+ component: ReferencePicker,
18
+ title: 'Components/ReferencePicker',
19
+ } as StoryMeta<typeof ReferencePicker>;
20
+ export default meta;
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ const ACCENT = { main: 'custom1' } as const;
24
+
25
+ const KINDS = [
26
+ 'workflow',
27
+ 'service',
28
+ 'job',
29
+ 'fsm',
30
+ 'connection',
31
+ 'mapper',
32
+ 'class',
33
+ 'value-map',
34
+ 'qog',
35
+ 'ai-collection',
36
+ 'ai-endpoint',
37
+ 'ai-guardrail',
38
+ ];
39
+
40
+ /* Stands in for the host's fetch: in the IDE these come off the customer's live
41
+ * instance one kind at a time. */
42
+ const CATALOGUE: Record<string, string[]> = {
43
+ workflow: ['order-sync:1.2', 'invoice-export:2.0', 'partner-recon:1.1'],
44
+ service: ['notifier', 'audit-logger', 'partner-gateway'],
45
+ job: ['nightly-recon', 'cleanup-temp', 'sla-report'],
46
+ fsm: ['onboarding', 'escalation'],
47
+ connection: ['sftp-partner', 'pgsql-omq', 'salesforce-prod'],
48
+ mapper: ['csv-to-order', 'order-to-invoice'],
49
+ class: ['OrderUtils', 'PartnerApi'],
50
+ 'value-map': ['country-codes', 'currency'],
51
+ qog: ['telegram-intake', 'stripe-billing'],
52
+ 'ai-collection': ['kb-support'],
53
+ 'ai-endpoint': ['gpt-router'],
54
+ 'ai-guardrail': ['pii-filter'],
55
+ };
56
+
57
+ const darkTheme = (Story: () => JSX.Element) => (
58
+ <ReqoreUIProvider theme={{ main: '#121212', intents: { custom1: '#762f7e' } }}>
59
+ <div style={{ padding: 20, background: '#121212' }}>
60
+ <Story />
61
+ </div>
62
+ </ReqoreUIProvider>
63
+ );
64
+
65
+ /** Drives the picker the way a host does: owns the selected kind, resolves that
66
+ * kind's interfaces, and accumulates what gets picked. */
67
+ const Hosted = ({ withComposer }: { withComposer?: boolean }) => {
68
+ const [kind, setKind] = useState<string>('workflow');
69
+ const [picked, setPicked] = useState<IInterfaceReference[]>([]);
70
+ const [open, setOpen] = useState<boolean>(true);
71
+
72
+ const picker = (
73
+ <ReferencePicker
74
+ kinds={KINDS}
75
+ kind={kind}
76
+ onKindChange={setKind}
77
+ items={CATALOGUE[kind] ?? []}
78
+ picked={picked}
79
+ onAdd={(reference) => setPicked((current) => [...current, reference])}
80
+ />
81
+ );
82
+
83
+ if (!withComposer) {
84
+ return picker;
85
+ }
86
+
87
+ return (
88
+ <div style={{ maxWidth: 780 }}>
89
+ {open ? <div style={{ marginBottom: 10 }}>{picker}</div> : null}
90
+ <TicketReplyBox
91
+ onSend={fn()}
92
+ placeholder='Write a reply…'
93
+ aboveInput={
94
+ picked.length ? (
95
+ <InterfaceReferenceTags size='tiny' customTheme={ACCENT} references={picked} />
96
+ ) : undefined
97
+ }
98
+ footerActions={
99
+ <ReqoreButton
100
+ className='references-toggle'
101
+ icon='LinksLine'
102
+ minimal
103
+ flat
104
+ badge={picked.length || undefined}
105
+ active={open}
106
+ customTheme={ACCENT}
107
+ tooltip={open ? 'Hide references' : 'Reference interfaces'}
108
+ onClick={() => setOpen((shown) => !shown)}
109
+ />
110
+ }
111
+ />
112
+ </div>
113
+ );
114
+ };
115
+
116
+ /*
117
+ * Queried by role, not by text: ReqoreButton renders its label in a pair of spans (an
118
+ * active one and an aria-hidden inactive one) to cross-fade, so getByText matches twice
119
+ * for every button. The accessible name collapses that pair back to one.
120
+ */
121
+ const option = (canvas: ReturnType<typeof within>, name: string) =>
122
+ canvas.getByRole('button', { name });
123
+ const missingOption = (canvas: ReturnType<typeof within>, name: string) =>
124
+ canvas.queryByRole('button', { name });
125
+
126
+ /** Picking an interface adds it as a reference — and it stops being offered, so the
127
+ * same thing can't be referenced twice. */
128
+ export const Default: Story = {
129
+ parameters: {
130
+ docs: {
131
+ description: {
132
+ story:
133
+ "Renders the two-pane reference browser and, on picking an interface, adds it as a reference and drops it from the pane so the same thing can't be referenced twice.",
134
+ },
135
+ },
136
+ },
137
+ decorators: [darkTheme],
138
+ render: () => <Hosted />,
139
+ async play({ canvasElement }) {
140
+ const canvas = within(canvasElement);
141
+ await userEvent.click(await canvas.findByRole('button', { name: 'order-sync:1.2' }));
142
+ // it moves out of the pane once picked
143
+ await waitFor(() => expect(missingOption(canvas, 'order-sync:1.2')).not.toBeInTheDocument());
144
+ // its siblings are untouched
145
+ await expect(option(canvas, 'invoice-export:2.0')).toBeInTheDocument();
146
+ },
147
+ };
148
+
149
+ /** Selecting a kind on the left swaps the right pane to that kind's interfaces. */
150
+ export const SwitchesKind: Story = {
151
+ parameters: {
152
+ docs: {
153
+ description: {
154
+ story:
155
+ "Renders the picker and shows that selecting a kind on the left swaps the right pane to that kind's interfaces.",
156
+ },
157
+ },
158
+ },
159
+ decorators: [darkTheme],
160
+ render: () => <Hosted />,
161
+ async play({ canvasElement }) {
162
+ const canvas = within(canvasElement);
163
+ await expect(option(canvas, 'order-sync:1.2')).toBeInTheDocument();
164
+ await userEvent.click(option(canvas, 'connection'));
165
+ await waitFor(() => expect(option(canvas, 'sftp-partner')).toBeInTheDocument());
166
+ await expect(missingOption(canvas, 'order-sync:1.2')).not.toBeInTheDocument();
167
+ },
168
+ };
169
+
170
+ /** One search scopes both panes: the kind list and the selected kind's interfaces. */
171
+ export const SearchScopesBothPanes: Story = {
172
+ parameters: {
173
+ docs: {
174
+ description: {
175
+ story:
176
+ "Renders the picker and shows its single search box scoping both panes at once — filtering the selected kind's interfaces and the kind list together.",
177
+ },
178
+ },
179
+ },
180
+ decorators: [darkTheme],
181
+ render: () => <Hosted />,
182
+ async play({ canvasElement }) {
183
+ const canvas = within(canvasElement);
184
+ const search = canvasElement.querySelector(
185
+ 'input[placeholder*="Search kinds"]'
186
+ ) as HTMLInputElement;
187
+
188
+ await userEvent.type(search, 'sync');
189
+ // right pane: only the matching interface survives
190
+ await waitFor(() =>
191
+ expect(missingOption(canvas, 'invoice-export:2.0')).not.toBeInTheDocument()
192
+ );
193
+ await expect(option(canvas, 'order-sync:1.2')).toBeInTheDocument();
194
+ // left pane: no kind is called "sync", so the kind list empties out
195
+ await expect(canvas.getByText('No kinds')).toBeInTheDocument();
196
+
197
+ // a kind name matches: that kind stays, and every other kind drops away
198
+ await userEvent.clear(search);
199
+ await userEvent.type(search, 'connect');
200
+ await waitFor(() => expect(option(canvas, 'connection')).toBeInTheDocument());
201
+ await expect(missingOption(canvas, 'workflow')).not.toBeInTheDocument();
202
+ },
203
+ };
204
+
205
+ /** The right pane says it's still fetching rather than claiming the kind is empty. */
206
+ export const Loading: Story = {
207
+ parameters: {
208
+ docs: {
209
+ description: {
210
+ story:
211
+ "Renders the loading state, where the right pane reports it is still fetching ('Loading workflows…') rather than claiming the kind is empty.",
212
+ },
213
+ },
214
+ },
215
+ decorators: [darkTheme],
216
+ render: () => (
217
+ <ReferencePicker
218
+ kinds={KINDS}
219
+ kind='workflow'
220
+ onKindChange={fn()}
221
+ items={[]}
222
+ loading
223
+ onAdd={fn()}
224
+ />
225
+ ),
226
+ async play({ canvasElement }) {
227
+ const canvas = within(canvasElement);
228
+ await expect(canvas.getByText('Loading workflows…')).toBeInTheDocument();
229
+ await expect(canvas.queryByText('No matching workflow')).not.toBeInTheDocument();
230
+ },
231
+ };
232
+
233
+ /**
234
+ * The host's fetch failed. The pane says so and offers a retry — an empty list here
235
+ * would read as "this instance has no connections", which is a different and much more
236
+ * alarming claim than "we couldn't ask". The specific failure is the tooltip.
237
+ */
238
+ export const LoadFailed: Story = {
239
+ parameters: {
240
+ docs: {
241
+ description: {
242
+ story:
243
+ "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'.",
244
+ },
245
+ },
246
+ },
247
+ decorators: [darkTheme],
248
+ render: () => (
249
+ <ReferencePicker
250
+ kinds={KINDS}
251
+ kind='connection'
252
+ onKindChange={fn()}
253
+ items={[]}
254
+ error='Could not reach the Qorus instance'
255
+ onRetry={fn()}
256
+ onAdd={fn()}
257
+ />
258
+ ),
259
+ async play({ canvasElement }) {
260
+ const canvas = within(canvasElement);
261
+ const retry = await canvas.findByRole('button', { name: /Couldn't load connections/ });
262
+ await expect(retry).toBeInTheDocument();
263
+ // the failure replaces the list rather than sitting alongside an empty one
264
+ await expect(canvas.queryByText('No matching connection')).not.toBeInTheDocument();
265
+ },
266
+ };
267
+
268
+ /** In place: above the composer, toggled by the composer's own action, with the picked
269
+ * references shown as chips over the input. */
270
+ export const AboveTheComposer: Story = {
271
+ parameters: {
272
+ docs: {
273
+ description: {
274
+ story:
275
+ "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.",
276
+ },
277
+ },
278
+ },
279
+ decorators: [darkTheme],
280
+ render: () => <Hosted withComposer />,
281
+ async play({ canvasElement }) {
282
+ const canvas = within(canvasElement);
283
+ await userEvent.click(await canvas.findByRole('button', { name: 'order-sync:1.2' }));
284
+ // the pick leaves the pane and surfaces as a chip on the composer
285
+ await waitFor(() => expect(missingOption(canvas, 'order-sync:1.2')).not.toBeInTheDocument());
286
+ await expect(canvas.getAllByText('order-sync:1.2').length).toBeGreaterThan(0);
287
+
288
+ const toggle = canvasElement.querySelector('.references-toggle') as HTMLElement;
289
+ await userEvent.click(toggle);
290
+ // the picker closes; the chip stays, because the reference is still attached
291
+ await waitFor(() =>
292
+ expect(missingOption(canvas, 'invoice-export:2.0')).not.toBeInTheDocument()
293
+ );
294
+ await expect(canvas.getAllByText('order-sync:1.2').length).toBeGreaterThan(0);
295
+ },
296
+ };
@@ -0,0 +1,237 @@
1
+ import { ReqoreInput, ReqoreMenu, ReqoreMenuItem, ReqoreSpinner } from '@qoretechnologies/reqore';
2
+ import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
3
+ import { ReactNode, useMemo, useState } from 'react';
4
+ import styled from 'styled-components';
5
+ import { thinScrollbar } from '../../helpers/scrollbar';
6
+ import { defaultInterfaceIcon, IInterfaceReference } from './meta';
7
+
8
+ /*
9
+ * The two-pane interface browser: kinds down the left, the selected kind's interfaces
10
+ * on the right, and one search field that scopes both. Sits above a ticket composer,
11
+ * toggled by the composer's "Reference interfaces" action.
12
+ *
13
+ * The host owns the data. Which kinds exist and what lives under one are questions only
14
+ * the surrounding app can answer — the IDE lists them off the customer's live instance,
15
+ * a story hands over fixtures — so this component takes `kinds` and the selected kind's
16
+ * `items` and does the rest: selection, search, and turning a click into a reference.
17
+ */
18
+
19
+ /** How many interfaces the right pane shows before it scrolls. */
20
+ const PANE_MAX_HEIGHT = 180;
21
+ const KIND_PANE_WIDTH = 150;
22
+
23
+ /*
24
+ * A scroll pane with the thin, unobtrusive scrollbar the Qonsole surfaces use
25
+ * (transparent track, translucent rounded thumb) rather than the raw browser one.
26
+ * webkit-only, like those surfaces: setting `scrollbar-width` as well would let
27
+ * Chromium override this custom 6px with its own, wider, "thin".
28
+ */
29
+ const Pane = styled.div<{ $right?: boolean }>`
30
+ ${({ $right }) =>
31
+ $right
32
+ ? 'flex: 1; min-width: 0;'
33
+ : `width: ${KIND_PANE_WIDTH}px; flex: none; border-right: 1px solid rgba(123, 104, 238, 0.25);`}
34
+ max-height: ${PANE_MAX_HEIGHT}px;
35
+ overflow-y: auto;
36
+ padding: 4px;
37
+
38
+ ${thinScrollbar}
39
+ `;
40
+
41
+ const Frame = styled.div`
42
+ border: 1px solid rgba(123, 104, 238, 0.25);
43
+ border-radius: 8px;
44
+ overflow: hidden;
45
+ `;
46
+
47
+ const Panes = styled.div`
48
+ display: flex;
49
+ `;
50
+
51
+ /*
52
+ * An underline search field. The input itself is borderless (`flat transparent`) and
53
+ * this wrapper carries the single bottom border, so the search reads as a line rather
54
+ * than a full grey box — including on focus, where Reqore's default is a 2px outline
55
+ * around the whole control. Suppressing that outline and brightening the underline
56
+ * instead keeps a focus cue without the box. The selector is scoped under this wrapper,
57
+ * so it outranks Reqore's without `!important`.
58
+ */
59
+ const Search = styled.div<{ $accent: string }>`
60
+ padding: 6px 4px 4px;
61
+ border-bottom: 1px solid ${({ $accent }) => `${$accent}80`};
62
+ transition: border-color 0.15s ease-out;
63
+
64
+ .reqore-control-wrapper:focus-within {
65
+ outline: none;
66
+ }
67
+ &:focus-within {
68
+ border-bottom-color: ${({ $accent }) => $accent};
69
+ }
70
+ `;
71
+
72
+ const Note = styled.div`
73
+ opacity: 0.4;
74
+ padding: 8px 6px;
75
+ font-size: 12px;
76
+ `;
77
+
78
+ const DEFAULT_ACCENT = '#762f7e';
79
+
80
+ export interface IReferencePickerProps {
81
+ /** the interface kinds listed down the left pane */
82
+ kinds: string[];
83
+ /** the kind whose interfaces `items` holds */
84
+ kind: string;
85
+ onKindChange: (kind: string) => void;
86
+ /**
87
+ * The selected kind's interfaces, by name. Fetching is the host's job — it knows
88
+ * where they come from and how to cache them.
89
+ */
90
+ items: string[];
91
+ /** `items` is still being fetched, so the right pane shows a spinner rather than
92
+ * claiming the kind is empty */
93
+ loading?: boolean;
94
+ /**
95
+ * The host's fetch for this kind failed. Shown in place of the list, because an
96
+ * empty pane would otherwise read as "this instance has no workflows" — which is a
97
+ * different and much more alarming statement than "we couldn't ask".
98
+ */
99
+ error?: string;
100
+ /** Retry the failed fetch. The error state is pressable when given. */
101
+ onRetry?: () => void;
102
+ /** Already-picked references. Anything of the current kind that is already picked is
103
+ * dropped from the right pane, so a reference can't be added twice. */
104
+ picked?: IInterfaceReference[];
105
+ onAdd: (reference: IInterfaceReference) => void;
106
+ /** the whole picker is inert (e.g. a reply is in flight) */
107
+ disabled?: boolean;
108
+ /** Per-kind icon. Consumers with their own icon vocabulary (the IDE) pass one; the
109
+ * built-in per-kind default is used when omitted. */
110
+ resolveInterfaceIcon?: (kind: string) => IReqoreIconName;
111
+ /** the underline / selected-kind colour. Defaults to the support surfaces' violet. */
112
+ accent?: string;
113
+ /** shown above the panes — the composer's picked-reference chips, typically */
114
+ header?: ReactNode;
115
+ }
116
+
117
+ export const ReferencePicker = ({
118
+ kinds,
119
+ kind,
120
+ onKindChange,
121
+ items,
122
+ loading,
123
+ error,
124
+ onRetry,
125
+ picked,
126
+ onAdd,
127
+ disabled,
128
+ resolveInterfaceIcon = defaultInterfaceIcon,
129
+ accent = DEFAULT_ACCENT,
130
+ header,
131
+ }: IReferencePickerProps) => {
132
+ const [query, setQuery] = useState<string>('');
133
+ const needle = query.trim().toLowerCase();
134
+
135
+ const visibleKinds = useMemo(
136
+ () => (needle ? kinds.filter((k) => k.toLowerCase().includes(needle)) : kinds),
137
+ [kinds, needle]
138
+ );
139
+
140
+ /*
141
+ * The right pane drops what's already been picked and then applies the search. When
142
+ * the KIND itself matches the search ("workflow"), its interfaces all stay — you
143
+ * searched for the kind, so you want to see what's in it.
144
+ */
145
+ const visibleItems = useMemo(() => {
146
+ const already = new Set(
147
+ (picked ?? [])
148
+ .filter((reference) => reference.interface_kind === kind)
149
+ .map((reference) => reference.interface_name)
150
+ );
151
+ const kindMatches = kind.toLowerCase().includes(needle);
152
+ return items.filter(
153
+ (name) =>
154
+ !already.has(name) && (!needle || kindMatches || name.toLowerCase().includes(needle))
155
+ );
156
+ }, [items, picked, kind, needle]);
157
+
158
+ return (
159
+ <Frame>
160
+ {header}
161
+ <Search $accent={accent}>
162
+ <ReqoreInput
163
+ value={query}
164
+ onChange={(event) => setQuery((event.target as HTMLInputElement).value)}
165
+ placeholder='Search kinds and interfaces…'
166
+ icon='SearchLine'
167
+ minimal
168
+ flat
169
+ transparent
170
+ fluid
171
+ disabled={disabled}
172
+ onClearClick={query ? () => setQuery('') : undefined}
173
+ />
174
+ </Search>
175
+ <Panes>
176
+ {/* Both panes are `ReqoreMenu`s of `ReqoreMenuItem`s — a vertical selectable
177
+ list is exactly what Menu is, so the selection (`selected`), the left-icon +
178
+ right-icon, and the item chrome come from reqore rather than hand-styled
179
+ buttons. The Pane wrapper still owns the scroll (fixed/flex width +
180
+ thinScrollbar); the menus render transparent inside it. */}
181
+ <Pane>
182
+ {visibleKinds.length ? (
183
+ <ReqoreMenu transparent flat padded={false} width='100%' size='small'>
184
+ {visibleKinds.map((option) => (
185
+ <ReqoreMenuItem
186
+ key={option}
187
+ icon={resolveInterfaceIcon(option)}
188
+ label={option}
189
+ selected={option === kind}
190
+ disabled={disabled}
191
+ onClick={() => onKindChange(option)}
192
+ />
193
+ ))}
194
+ </ReqoreMenu>
195
+ ) : (
196
+ <Note>No kinds</Note>
197
+ )}
198
+ </Pane>
199
+ <Pane $right>
200
+ {error ? (
201
+ <ReqoreMenu transparent flat padded={false} width='100%' size='small'>
202
+ <ReqoreMenuItem
203
+ icon='ErrorWarningLine'
204
+ rightIcon={onRetry ? 'RefreshLine' : undefined}
205
+ intent='danger'
206
+ label={`Couldn't load ${kind}s`}
207
+ /* the specific failure is the tooltip — the pane states the outcome */
208
+ tooltip={error}
209
+ onClick={onRetry}
210
+ />
211
+ </ReqoreMenu>
212
+ ) : loading ? (
213
+ <ReqoreSpinner iconColor='info' size='small' centered>
214
+ Loading {kind}s…
215
+ </ReqoreSpinner>
216
+ ) : visibleItems.length ? (
217
+ <ReqoreMenu transparent flat padded={false} width='100%' size='small'>
218
+ {visibleItems.map((name) => (
219
+ <ReqoreMenuItem
220
+ key={name}
221
+ icon={resolveInterfaceIcon(kind)}
222
+ rightIcon='AddLine'
223
+ label={name}
224
+ disabled={disabled}
225
+ tooltip={`Reference ${kind} ${name}`}
226
+ onClick={() => onAdd({ interface_kind: kind, interface_name: name })}
227
+ />
228
+ ))}
229
+ </ReqoreMenu>
230
+ ) : (
231
+ <Note>No matching {kind}</Note>
232
+ )}
233
+ </Pane>
234
+ </Panes>
235
+ </Frame>
236
+ );
237
+ };
@@ -0,0 +1,122 @@
1
+ import { ReqoreUIProvider } from '@qoretechnologies/reqore';
2
+ import { StoryObj } from '@storybook/react-vite';
3
+ import { expect, fn } from 'storybook/test';
4
+ import { ReactNode } from 'react';
5
+ import { StoryMeta } from '../../types';
6
+ import { ITicketThreadMessage, TicketThread } from '../ticketThread/TicketThread';
7
+ import { InterfaceReferenceTags } from './InterfaceReferenceTags';
8
+ import { TicketReplyBox } from './TicketReplyBox';
9
+
10
+ /**
11
+ * The full ticket surface — the shared `<TicketThread>` conversation above the
12
+ * `<TicketReplyBox>` composer, the way a ticket detail (customer) or the staff
13
+ * drawer stacks them. Shown under the Qorus theme so the `custom1` accent renders.
14
+ */
15
+ const meta = {
16
+ component: TicketReplyBox,
17
+ title: 'Components/TicketConversation',
18
+ args: { onSend: fn() },
19
+ } as StoryMeta<typeof TicketReplyBox>;
20
+
21
+ export default meta;
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ const CUSTOMER: ITicketThreadMessage = {
25
+ message_id: 'm1',
26
+ author_type: 'customer',
27
+ author_id: 'ops@acme.io',
28
+ body: 'Since upgrading to 3.2 our nightly SFTP transfer to the partner endpoint times out after about 30 seconds. It ran fine on 3.1 with the same connection config.',
29
+ created: '2026-07-16T09:12:00Z',
30
+ referenced_interfaces: [{ interface_kind: 'connection', interface_name: 'sftp-partner' }],
31
+ };
32
+ const STAFF: ITicketThreadMessage = {
33
+ message_id: 'm2',
34
+ author_type: 'staff',
35
+ author_id: 'nick',
36
+ body: "Thanks for the detailed report — we've reproduced the timeout and a fix is in review. In the meantime, can you confirm the datasource pool size configured for the sftp-partner connection?",
37
+ created: '2026-07-16T10:03:00Z',
38
+ };
39
+ const INTERNAL: ITicketThreadMessage = {
40
+ message_id: 'm3',
41
+ author_type: 'staff',
42
+ author_id: 'nick',
43
+ internal: true,
44
+ body: 'Root cause: the connection-pool default silently dropped from 4 to 1 in the 3.2 migration. Patch is in review against develop.',
45
+ created: '2026-07-16T10:05:00Z',
46
+ };
47
+
48
+ const themed = (node: ReactNode) => (
49
+ <ReqoreUIProvider theme={{ main: '#121212', intents: { custom1: '#762f7e' } }}>
50
+ <div style={{ padding: 20, background: '#121212', maxWidth: 780 }}>{node}</div>
51
+ </ReqoreUIProvider>
52
+ );
53
+
54
+ const refs = (
55
+ <InterfaceReferenceTags
56
+ size='tiny'
57
+ customTheme={{ main: 'custom1' }}
58
+ references={[{ interface_kind: 'connection', interface_name: 'sftp-partner' }]}
59
+ />
60
+ );
61
+
62
+ /**
63
+ * Staff view — the thread with `viewerRole='staff'` (internal notes show, amber)
64
+ * above the staff composer with the split send.
65
+ */
66
+ export const StaffConversation: Story = {
67
+ parameters: {
68
+ docs: {
69
+ description: {
70
+ story:
71
+ "Renders the staff view — the thread with viewerRole='staff' (internal notes visible, amber) stacked above the staff composer with its split send.",
72
+ },
73
+ },
74
+ },
75
+ render: () =>
76
+ themed(
77
+ <>
78
+ <TicketThread viewerRole='staff' messages={[CUSTOMER, STAFF, INTERNAL]} />
79
+ <div style={{ marginTop: 16 }}>
80
+ <TicketReplyBox allowInternalNote onSend={fn()} aboveInput={refs} />
81
+ </div>
82
+ </>
83
+ ),
84
+ async play({ canvasElement }) {
85
+ await expect(canvasElement.textContent).toContain('reproduced the timeout');
86
+ // the internal note is visible to staff
87
+ await expect(canvasElement.textContent).toContain('Root cause');
88
+ // the composer (rich editor) renders below the thread
89
+ await expect(canvasElement.querySelector('[contenteditable="true"]')).not.toBeNull();
90
+ },
91
+ };
92
+
93
+ /**
94
+ * Customer view — the same thread as the customer sees it (`viewerRole='customer'`,
95
+ * staff shown as "Qorus Support", no internal notes) above their reply composer.
96
+ */
97
+ export const CustomerConversation: Story = {
98
+ parameters: {
99
+ docs: {
100
+ description: {
101
+ story:
102
+ "Renders the customer view — the same thread with viewerRole='customer' (staff shown as 'Qorus Support', no internal notes) stacked above the customer's reply composer.",
103
+ },
104
+ },
105
+ },
106
+ render: () =>
107
+ themed(
108
+ <>
109
+ <TicketThread viewerRole='customer' messages={[CUSTOMER, STAFF]} />
110
+ <div style={{ marginTop: 16 }}>
111
+ <TicketReplyBox onSend={fn()} aboveInput={refs} />
112
+ </div>
113
+ </>
114
+ ),
115
+ async play({ canvasElement }) {
116
+ await expect(canvasElement.textContent).toContain('reproduced the timeout');
117
+ // the internal note is not in the customer's messages
118
+ await expect(canvasElement.textContent).not.toContain('Root cause');
119
+ // the composer (rich editor) renders below the thread
120
+ await expect(canvasElement.querySelector('[contenteditable="true"]')).not.toBeNull();
121
+ },
122
+ };