@qoretechnologies/reqraft 0.10.64 → 0.10.65-pr.148.gc6381e4
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.
- package/.claude/CLAUDE.md +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +13 -1
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
- package/dist/components/supportTicket/InterfaceReferenceTags.js +11 -4
- package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
- package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
- package/dist/components/supportTicket/ReferencePicker.js +1 -1
- package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
- package/dist/components/supportTicket/WorkspaceAccessStatus.d.ts +55 -0
- package/dist/components/supportTicket/WorkspaceAccessStatus.d.ts.map +1 -0
- package/dist/components/supportTicket/WorkspaceAccessStatus.js +79 -0
- package/dist/components/supportTicket/WorkspaceAccessStatus.js.map +1 -0
- package/dist/components/supportTicket/accessMeta.d.ts +50 -0
- package/dist/components/supportTicket/accessMeta.d.ts.map +1 -0
- package/dist/components/supportTicket/accessMeta.js +174 -0
- package/dist/components/supportTicket/accessMeta.js.map +1 -0
- package/dist/components/supportTicket/index.d.ts +2 -0
- package/dist/components/supportTicket/index.d.ts.map +1 -1
- package/dist/components/supportTicket/index.js +2 -0
- package/dist/components/supportTicket/index.js.map +1 -1
- package/dist/components/supportTicket/meta.d.ts +62 -2
- package/dist/components/supportTicket/meta.d.ts.map +1 -1
- package/dist/components/supportTicket/meta.js +116 -3
- package/dist/components/supportTicket/meta.js.map +1 -1
- package/dist/components/supportTicket/ticketModel.d.ts +10 -0
- package/dist/components/supportTicket/ticketModel.d.ts.map +1 -1
- package/dist/components/ticketReferences/TicketReferences.d.ts +10 -1
- package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
- package/dist/components/ticketReferences/TicketReferences.js +12 -8
- package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
- package/dist/components/ticketThread/TicketThread.d.ts +30 -1
- package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
- package/dist/components/ticketThread/TicketThread.js +51 -13
- package/dist/components/ticketThread/TicketThread.js.map +1 -1
- package/dist/components/ticketThread/index.d.ts +1 -1
- package/dist/components/ticketThread/index.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/providers/StorageProvider.d.ts +11 -0
- package/dist/providers/StorageProvider.d.ts.map +1 -1
- package/dist/providers/StorageProvider.js +21 -3
- package/dist/providers/StorageProvider.js.map +1 -1
- package/dist/stores/currentUser/currentUser.d.ts +42 -0
- package/dist/stores/currentUser/currentUser.d.ts.map +1 -1
- package/dist/stores/currentUser/currentUser.js.map +1 -1
- package/package.json +2 -2
- package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +56 -0
- package/src/components/supportTicket/InterfaceReferenceTags.tsx +27 -7
- package/src/components/supportTicket/ReferencePicker.tsx +4 -1
- package/src/components/supportTicket/WorkspaceAccessStatus.stories.tsx +105 -0
- package/src/components/supportTicket/WorkspaceAccessStatus.tsx +176 -0
- package/src/components/supportTicket/accessMeta.ts +210 -0
- package/src/components/supportTicket/index.ts +2 -0
- package/src/components/supportTicket/meta.ts +156 -4
- package/src/components/supportTicket/ticketModel.ts +10 -0
- package/src/components/ticketReferences/TicketReferences.stories.tsx +40 -0
- package/src/components/ticketReferences/TicketReferences.tsx +24 -10
- package/src/components/ticketThread/TicketThread.stories.tsx +103 -1
- package/src/components/ticketThread/TicketThread.tsx +131 -15
- package/src/components/ticketThread/index.ts +1 -0
- package/src/index.tsx +1 -0
- package/src/providers/StorageProvider.tsx +20 -3
- package/src/stores/currentUser/currentUser.tsx +43 -0
|
@@ -13,7 +13,39 @@ import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
|
|
|
13
13
|
export type TTicketStatus = 'new' | 'open' | 'awaiting_customer' | 'resolved' | 'closed';
|
|
14
14
|
export type TTicketPriority = 'low' | 'normal' | 'high' | 'urgent';
|
|
15
15
|
export type TTicketCategory = 'bug' | 'question' | 'billing' | 'feature' | 'other';
|
|
16
|
-
export type TTicketAuditAction =
|
|
16
|
+
export type TTicketAuditAction =
|
|
17
|
+
| 'replied'
|
|
18
|
+
| 'noted'
|
|
19
|
+
| 'status_changed'
|
|
20
|
+
| 'assigned'
|
|
21
|
+
| 'updated'
|
|
22
|
+
// staff reads (qorus#693 Layer 0): a ticket, its thread or its attachment list was opened,
|
|
23
|
+
// or a file was downloaded — coalesced server-side per staff member and window
|
|
24
|
+
| 'viewed'
|
|
25
|
+
| 'attachment_downloaded'
|
|
26
|
+
// delegated workspace access (qorus#693 Layers 1–3): the customer's grant lifecycle, a
|
|
27
|
+
// staff read of the workspace under it, and a read-only IDE session launched from it
|
|
28
|
+
| 'workspace_viewed'
|
|
29
|
+
| 'access_requested'
|
|
30
|
+
| 'access_granted'
|
|
31
|
+
| 'access_extended'
|
|
32
|
+
| 'access_revoked'
|
|
33
|
+
| 'access_expired'
|
|
34
|
+
| 'session_launched';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The `system_event` a system message in a ticket thread may carry (qorus#693): the
|
|
38
|
+
* delegated-access lifecycle, written by the control plane as a thread message so the
|
|
39
|
+
* customer and the staff member see the same record in the conversation. A thread
|
|
40
|
+
* renders a known event with its own mark; an unknown one falls back to a plain line.
|
|
41
|
+
*/
|
|
42
|
+
export type TTicketSystemEvent =
|
|
43
|
+
| 'support_access.requested'
|
|
44
|
+
| 'support_access.granted'
|
|
45
|
+
| 'support_access.extended'
|
|
46
|
+
| 'support_access.first_used'
|
|
47
|
+
| 'support_access.revoked'
|
|
48
|
+
| 'support_access.expired';
|
|
17
49
|
|
|
18
50
|
/** A message author. Staff identities are masked from the customer by the backend. */
|
|
19
51
|
export type TTicketMessageAuthor = 'customer' | 'staff' | 'system';
|
|
@@ -68,8 +100,72 @@ export const TICKET_AUDIT_ACTION_META: Record<TTicketAuditAction, ITicketEnumMet
|
|
|
68
100
|
status_changed: { label: 'Status changed', intent: 'warning', icon: 'ExchangeLine' },
|
|
69
101
|
assigned: { label: 'Assigned', intent: 'pending', icon: 'UserFollowLine' },
|
|
70
102
|
updated: { label: 'Updated', intent: 'muted', icon: 'Edit2Line' },
|
|
103
|
+
viewed: { label: 'Viewed', intent: 'muted', icon: 'EyeLine' },
|
|
104
|
+
attachment_downloaded: { label: 'Attachment downloaded', intent: 'muted', icon: 'Download2Line' },
|
|
105
|
+
workspace_viewed: { label: 'Workspace viewed', intent: 'info', icon: 'Search2Line' },
|
|
106
|
+
access_requested: { label: 'Access requested', intent: 'pending', icon: 'ShieldUserLine' },
|
|
107
|
+
access_granted: { label: 'Access granted', intent: 'success', icon: 'ShieldCheckLine' },
|
|
108
|
+
access_extended: { label: 'Access extended', intent: 'info', icon: 'TimerLine' },
|
|
109
|
+
access_revoked: { label: 'Access revoked', intent: 'danger', icon: 'ShieldCrossLine' },
|
|
110
|
+
access_expired: { label: 'Access expired', intent: 'muted', icon: 'HourglassLine' },
|
|
111
|
+
session_launched: { label: 'IDE session launched', intent: 'info', icon: 'ExternalLinkLine' },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** The mark for an audit action the server writes but this vocabulary has not caught up
|
|
115
|
+
* with: a muted, generic row that still names the action. It exists because the staff
|
|
116
|
+
* audit tab used to index `TICKET_AUDIT_ACTION_META` directly, and one unknown action
|
|
117
|
+
* took the whole tab down (qorus#693). */
|
|
118
|
+
export const UNKNOWN_TICKET_AUDIT_ACTION_META: ITicketEnumMeta = {
|
|
119
|
+
label: 'Activity',
|
|
120
|
+
intent: 'muted',
|
|
121
|
+
icon: 'HistoryLine',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** A wire value humanised for reading: `attachment_downloaded` → "Attachment downloaded". */
|
|
125
|
+
export const humaniseWireValue = (value: string): string => {
|
|
126
|
+
const words = value.replace(/[._-]+/g, ' ').trim();
|
|
127
|
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : value;
|
|
71
128
|
};
|
|
72
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The mark for an audit action, whatever the server sent. A known action gets its
|
|
132
|
+
* entry; an unknown one gets the muted generic entry with the action's own wire value
|
|
133
|
+
* humanised as the label, so a new server action renders as a readable row instead
|
|
134
|
+
* of failing the surface that lists it.
|
|
135
|
+
*/
|
|
136
|
+
export const ticketAuditActionMeta = (action: string): ITicketEnumMeta =>
|
|
137
|
+
(TICKET_AUDIT_ACTION_META as Record<string, ITicketEnumMeta>)[action] ?? {
|
|
138
|
+
...UNKNOWN_TICKET_AUDIT_ACTION_META,
|
|
139
|
+
label: humaniseWireValue(action),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** How each delegated-access thread event renders: the mark on its system line or card. */
|
|
143
|
+
export const TICKET_SYSTEM_EVENT_META: Record<TTicketSystemEvent, ITicketEnumMeta> = {
|
|
144
|
+
'support_access.requested': {
|
|
145
|
+
label: 'Workspace access requested',
|
|
146
|
+
intent: 'info',
|
|
147
|
+
icon: 'ShieldUserLine',
|
|
148
|
+
},
|
|
149
|
+
'support_access.granted': { label: 'Workspace access granted', intent: 'success', icon: 'ShieldCheckLine' },
|
|
150
|
+
'support_access.extended': { label: 'Workspace access extended', intent: 'info', icon: 'TimerLine' },
|
|
151
|
+
'support_access.first_used': { label: 'Workspace opened by support', intent: 'info', icon: 'EyeLine' },
|
|
152
|
+
'support_access.revoked': { label: 'Workspace access revoked', intent: 'danger', icon: 'ShieldCrossLine' },
|
|
153
|
+
'support_access.expired': { label: 'Workspace access expired', intent: 'muted', icon: 'HourglassLine' },
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** The generic mark for a system message with no event, or an event this vocabulary
|
|
157
|
+
* does not know: the plain informational line every system message used to be. */
|
|
158
|
+
export const DEFAULT_TICKET_SYSTEM_EVENT_META: ITicketEnumMeta = {
|
|
159
|
+
label: 'System',
|
|
160
|
+
intent: 'muted',
|
|
161
|
+
icon: 'InformationLine',
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** The mark for a system message's `system_event`, with the generic fallback. */
|
|
165
|
+
export const ticketSystemEventMeta = (event?: string): ITicketEnumMeta =>
|
|
166
|
+
(event && (TICKET_SYSTEM_EVENT_META as Record<string, ITicketEnumMeta>)[event]) ||
|
|
167
|
+
DEFAULT_TICKET_SYSTEM_EVENT_META;
|
|
168
|
+
|
|
73
169
|
/** The status label for a given viewer — the customer sees "Awaiting your reply"
|
|
74
170
|
* where staff see "Awaiting customer"; all other values are audience-neutral. */
|
|
75
171
|
export const ticketStatusLabel = (
|
|
@@ -104,12 +200,28 @@ export interface IAttachmentUpload {
|
|
|
104
200
|
* `{kind, name}` snapshot (the interface itself lives on the instance, not the
|
|
105
201
|
* SaaS plane). Attaches to a ticket (at creation) or a message (in a reply). */
|
|
106
202
|
export interface IInterfaceReference {
|
|
107
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* The interface's kind. On READ this is the normalized Qorus `TypeMap` key the server
|
|
205
|
+
* stores (`fsm`, never `qog`; `step`, never `workflow-step`), so two surfaces compare
|
|
206
|
+
* equal; a writer may still send the spelling it has, and the server normalizes it.
|
|
207
|
+
* Compare kinds through `normalizeInterfaceKind()` rather than by string.
|
|
208
|
+
*/
|
|
108
209
|
interface_kind: string;
|
|
109
210
|
/** interface name (may include a version) */
|
|
110
211
|
interface_name: string;
|
|
111
212
|
/** server-assigned reference UUID (present on read, absent when submitting) */
|
|
112
213
|
reference_id?: string;
|
|
214
|
+
/**
|
|
215
|
+
* The interface's id on the subscriber's instance at the time it was referenced, when
|
|
216
|
+
* the writer knew it (the IDE's picker does). A viewer that can reach the instance
|
|
217
|
+
* prefers it over the name, which may have been renamed or versioned since.
|
|
218
|
+
*/
|
|
219
|
+
interface_id?: number;
|
|
220
|
+
/** The group the interface lived in when referenced — set by the server on read. */
|
|
221
|
+
groupid?: number;
|
|
222
|
+
/** That group's portable namespace — set by the server on read; absent for the host's
|
|
223
|
+
* DEFAULT group. What a support reader needs to find the interface after a move. */
|
|
224
|
+
group_namespace?: number;
|
|
113
225
|
/** For an app-triggered interface (a qog): the triggering app's name. A viewer
|
|
114
226
|
* with the app catalogue (the customer's IDE) resolves it to the app's logo the
|
|
115
227
|
* same way the qogs list does — see `resolveInterfaceImage` on the tags. */
|
|
@@ -120,6 +232,47 @@ export interface IInterfaceReference {
|
|
|
120
232
|
logo?: string;
|
|
121
233
|
}
|
|
122
234
|
|
|
235
|
+
// Kind spellings a writer may still send, and the normalized `TypeMap` key the server
|
|
236
|
+
// stores for each. The IDE's menus and older references say `qog`; the engine's type
|
|
237
|
+
// is `fsm`. A `workflow-step` is a `step`.
|
|
238
|
+
const INTERFACE_KIND_ALIASES: Record<string, string> = {
|
|
239
|
+
qog: 'fsm',
|
|
240
|
+
'workflow-step': 'step',
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The normalized `TypeMap` key for a kind, however a writer spelled it: `qog` → `fsm`,
|
|
245
|
+
* `workflow-step` → `step`; anything else is returned trimmed and lower-cased. Every
|
|
246
|
+
* comparison of two references' kinds goes through this, so a reference written as a
|
|
247
|
+
* `qog` and one read back as an `fsm` are the same interface.
|
|
248
|
+
*/
|
|
249
|
+
export const normalizeInterfaceKind = (kind: string): string => {
|
|
250
|
+
const key = kind.trim().toLowerCase();
|
|
251
|
+
return INTERFACE_KIND_ALIASES[key] ?? key;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A reference's identity for keys and dedupe: its normalized kind and its name. Not the
|
|
256
|
+
* `reference_id` (absent when submitting) and not the `interface_id` (absent on older
|
|
257
|
+
* references), so the same interface cited twice is one key whatever each copy carries.
|
|
258
|
+
*/
|
|
259
|
+
export const interfaceReferenceKey = (reference: IInterfaceReference): string =>
|
|
260
|
+
`${normalizeInterfaceKind(reference.interface_kind)}:${reference.interface_name}`;
|
|
261
|
+
|
|
262
|
+
/** The references with duplicates dropped — first occurrence wins, order kept —
|
|
263
|
+
* where two references are the same interface by `interfaceReferenceKey()`. */
|
|
264
|
+
export const dedupeInterfaceReferences = <T extends IInterfaceReference>(references: T[]): T[] => {
|
|
265
|
+
const seen = new Set<string>();
|
|
266
|
+
return references.filter((reference) => {
|
|
267
|
+
const key = interfaceReferenceKey(reference);
|
|
268
|
+
if (seen.has(key)) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
seen.add(key);
|
|
272
|
+
return true;
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
|
|
123
276
|
// Built-in per-kind icons for referenced interfaces, kept in step with the
|
|
124
277
|
// qorus-ide interface vocabulary. Used when a consumer doesn't pass its own
|
|
125
278
|
// `resolveInterfaceIcon`, so the staff view and the Qonsole renderer show the
|
|
@@ -199,8 +352,7 @@ export const defaultInterfaceKindLabel = (kind: string): string => {
|
|
|
199
352
|
return known;
|
|
200
353
|
}
|
|
201
354
|
|
|
202
|
-
const
|
|
203
|
-
const sentenceCase = words.charAt(0).toUpperCase() + words.slice(1);
|
|
355
|
+
const sentenceCase = humaniseWireValue(kind);
|
|
204
356
|
|
|
205
357
|
return sentenceCase.endsWith('s') ? sentenceCase : `${sentenceCase}s`;
|
|
206
358
|
};
|
|
@@ -61,6 +61,16 @@ export interface ITicketMessage {
|
|
|
61
61
|
attachments?: ITicketAttachment[];
|
|
62
62
|
/** interfaces referenced in this message, as `{kind, name}` snapshots */
|
|
63
63
|
referenced_interfaces?: IInterfaceReference[];
|
|
64
|
+
/**
|
|
65
|
+
* For a `system` message: the event it records (qorus#693), e.g.
|
|
66
|
+
* `support_access.requested`. A thread renders a known event with its own mark, and the
|
|
67
|
+
* `support_access.requested` event as a card that can carry the customer's Grant action;
|
|
68
|
+
* absent on the plain system lines (status changes, assignments).
|
|
69
|
+
*/
|
|
70
|
+
system_event?: string;
|
|
71
|
+
/** For a `system` message: the event's structured payload (a grant id, an expiry, the
|
|
72
|
+
* requesting staff member's reason), as the server wrote it. */
|
|
73
|
+
system_detail?: Record<string, unknown>;
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
/**
|
|
@@ -313,3 +313,43 @@ export const ShowInChat: Story = {
|
|
|
313
313
|
await expect(args.onShowInChat).toHaveBeenCalledTimes(1);
|
|
314
314
|
},
|
|
315
315
|
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* A viewer that can open some references but not others: a refused row offers no Open
|
|
319
|
+
* action and says why in its metadata line, next to the kind — in the row, so it reads on
|
|
320
|
+
* a touch screen, where a tooltip never shows.
|
|
321
|
+
*/
|
|
322
|
+
export const MixedLinkability: Story = {
|
|
323
|
+
parameters: {
|
|
324
|
+
docs: {
|
|
325
|
+
description: {
|
|
326
|
+
story:
|
|
327
|
+
'Renders the linked-interface rows with two refusals: those rows have no Open action and carry the reason in their metadata line after the kind, visible without hover (qorus#693).',
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
args: {
|
|
332
|
+
attachments: [],
|
|
333
|
+
onInterfaceClick: fn(),
|
|
334
|
+
isInterfaceLinkable: (reference: IInterfaceReference) => reference.interface_kind !== 'workflow',
|
|
335
|
+
unlinkableReason: (reference: IInterfaceReference) =>
|
|
336
|
+
reference.interface_kind === 'workflow' ? 'moved to another group' : undefined,
|
|
337
|
+
},
|
|
338
|
+
play: async ({ canvasElement, args }) => {
|
|
339
|
+
const canvas = within(canvasElement);
|
|
340
|
+
await expect(canvas.getByText('Linked interfaces')).toBeInTheDocument();
|
|
341
|
+
// the reason is in the row's text, not only a tooltip
|
|
342
|
+
await expect(canvas.getAllByText('workflow · moved to another group')).toHaveLength(2);
|
|
343
|
+
// the refused rows have no Open action; the three others do
|
|
344
|
+
await waitFor(() =>
|
|
345
|
+
expect(canvasElement.querySelectorAll('.reqore-entity-row-actions button')).toHaveLength(3)
|
|
346
|
+
);
|
|
347
|
+
await userEvent.click(canvasElement.querySelectorAll('.reqore-entity-row-actions button')[0]);
|
|
348
|
+
await waitFor(() =>
|
|
349
|
+
expect(args.onInterfaceClick).toHaveBeenCalledWith(
|
|
350
|
+
expect.objectContaining({ interface_kind: 'connection' })
|
|
351
|
+
)
|
|
352
|
+
);
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
|
|
@@ -23,6 +23,7 @@ import { ImageLightbox } from '../imageLightbox';
|
|
|
23
23
|
import {
|
|
24
24
|
defaultInterfaceIcon,
|
|
25
25
|
IInterfaceReference,
|
|
26
|
+
interfaceReferenceKey,
|
|
26
27
|
toInterfaceKindMark,
|
|
27
28
|
TResolveInterfaceIcon,
|
|
28
29
|
TTicketViewerRole,
|
|
@@ -73,6 +74,15 @@ export interface ITicketReferencesProps {
|
|
|
73
74
|
onDownloadAttachment?: (attachmentId: string, filename: string) => void;
|
|
74
75
|
/** open a referenced interface; reference chips are static (informational) when omitted */
|
|
75
76
|
onInterfaceClick?: (reference: IInterfaceReference) => void;
|
|
77
|
+
/**
|
|
78
|
+
* Whether a reference can be opened right now; consulted only when `onInterfaceClick`
|
|
79
|
+
* is given. A refused row offers no Open action and carries `unlinkableReason` in its
|
|
80
|
+
* metadata line — in the row itself, so it reads on a touch screen too, where a
|
|
81
|
+
* tooltip would never show. See `InterfaceReferenceTags` for the cases.
|
|
82
|
+
*/
|
|
83
|
+
isInterfaceLinkable?: (reference: IInterfaceReference) => boolean;
|
|
84
|
+
/** Why a reference is not linkable, shown in the row's metadata after its kind. */
|
|
85
|
+
unlinkableReason?: (reference: IInterfaceReference) => string | undefined;
|
|
76
86
|
/** Reveal the message a reference or file came from, in the conversation.
|
|
77
87
|
* Omitted ⇒ the action is not offered. It is also omitted per-row for anything filed
|
|
78
88
|
* against the ticket itself rather than a reply, which has no message to reveal. */
|
|
@@ -197,7 +207,9 @@ export interface IResolvedInterfaceReference extends IInterfaceReference {
|
|
|
197
207
|
const dedupeRefs = (refs: IResolvedInterfaceReference[]): IResolvedInterfaceReference[] => {
|
|
198
208
|
const byKey = new Map<string, IResolvedInterfaceReference>();
|
|
199
209
|
for (const r of refs) {
|
|
200
|
-
|
|
210
|
+
// identity by normalized kind + name, so a `qog` cited in a reply and the `fsm` the
|
|
211
|
+
// server stored on the ticket are one row
|
|
212
|
+
const key = interfaceReferenceKey(r);
|
|
201
213
|
const kept = byKey.get(key);
|
|
202
214
|
if (!kept) {
|
|
203
215
|
byKey.set(key, r);
|
|
@@ -421,6 +433,8 @@ export const TicketReferences = ({
|
|
|
421
433
|
fetchAttachmentUrl,
|
|
422
434
|
onDownloadAttachment,
|
|
423
435
|
onInterfaceClick,
|
|
436
|
+
isInterfaceLinkable,
|
|
437
|
+
unlinkableReason,
|
|
424
438
|
onShowInChat,
|
|
425
439
|
resolveInterfaceIcon,
|
|
426
440
|
preferKindImage = false,
|
|
@@ -791,28 +805,28 @@ export const TicketReferences = ({
|
|
|
791
805
|
// the same vocabulary and precedence as the reference chips: the kind's
|
|
792
806
|
// font icon, and the chosen image once it has loaded
|
|
793
807
|
const { icon, image } = markOf(reference);
|
|
808
|
+
// a row the consumer refuses offers no Open, and says why in its metadata
|
|
809
|
+
// line rather than in a tooltip, so the reason reads on touch as well
|
|
810
|
+
const linkable = !!onInterfaceClick && (isInterfaceLinkable?.(reference) ?? true);
|
|
811
|
+
const reason =
|
|
812
|
+
onInterfaceClick && !linkable ? unlinkableReason?.(reference) : undefined;
|
|
794
813
|
return (
|
|
795
814
|
<ReqoreEntityRow
|
|
796
|
-
key={
|
|
797
|
-
reference.reference_id ??
|
|
798
|
-
`${reference.interface_kind}:${reference.interface_name}`
|
|
799
|
-
}
|
|
815
|
+
key={reference.reference_id ?? interfaceReferenceKey(reference)}
|
|
800
816
|
icon={icon}
|
|
801
817
|
iconImage={isImageLoaded(image) ? image : undefined}
|
|
802
818
|
label={reference.interface_name}
|
|
803
819
|
metadata={
|
|
804
820
|
<ReqoreTextEffect effect={META_EFFECT}>
|
|
805
|
-
{reference.interface_kind}
|
|
821
|
+
{reason ? `${reference.interface_kind} · ${reason}` : reference.interface_kind}
|
|
806
822
|
</ReqoreTextEffect>
|
|
807
823
|
}
|
|
808
824
|
flat
|
|
809
825
|
tooltip={
|
|
810
|
-
|
|
811
|
-
`Open ${reference.interface_kind} ${reference.interface_name}`
|
|
812
|
-
: undefined
|
|
826
|
+
linkable ? `Open ${reference.interface_kind} ${reference.interface_name}` : undefined
|
|
813
827
|
}
|
|
814
828
|
actions={rowActions(
|
|
815
|
-
|
|
829
|
+
linkable ?
|
|
816
830
|
{
|
|
817
831
|
icon: 'ArrowRightUpLine',
|
|
818
832
|
tooltip: 'Open',
|
|
@@ -5,7 +5,7 @@ import styled from 'styled-components';
|
|
|
5
5
|
import { thinScrollbar } from '../../helpers/scrollbar';
|
|
6
6
|
import { StoryMeta } from '../../types';
|
|
7
7
|
import type { IInterfaceReference, TTicketMessageAuthor } from '../supportTicket/meta';
|
|
8
|
-
import { ITicketThreadMessage, TicketThread } from './TicketThread';
|
|
8
|
+
import { ITicketSystemEventAction, ITicketThreadMessage, TicketThread } from './TicketThread';
|
|
9
9
|
|
|
10
10
|
/* The drawer body's scroll area — the thin Qorus scrollbar rather than the platform
|
|
11
11
|
* default, matching what the app's ThreadScroll should carry. */
|
|
@@ -827,3 +827,105 @@ export const Everything: Story = {
|
|
|
827
827
|
await expect(canvas.getByText('pgsql-omq')).toBeInTheDocument();
|
|
828
828
|
},
|
|
829
829
|
};
|
|
830
|
+
|
|
831
|
+
// The delegated-access lifecycle as the control plane writes it into the thread
|
|
832
|
+
// (qorus#693): a request from support, then the customer's answer and what followed.
|
|
833
|
+
const SUPPORT_ACCESS_THREAD: ITicketThreadMessage[] = [
|
|
834
|
+
{ message_id: 'o1', author_type: 'customer', author_id: 'acme', body: 'The nightly reconcile has been failing since Tuesday.', created: ago(400) },
|
|
835
|
+
{
|
|
836
|
+
message_id: 'sa1',
|
|
837
|
+
author_type: 'system',
|
|
838
|
+
author_id: 'system',
|
|
839
|
+
system_event: 'support_access.requested',
|
|
840
|
+
system_detail: { reason: 'To read the nightly-reconcile job and its last three runs.', suggested_hours: 24 },
|
|
841
|
+
body: 'Qorus Support asks to read this workspace for **24 hours** to look at `nightly-reconcile` and its last three runs. Nothing is changed; every read is recorded here.',
|
|
842
|
+
created: ago(300),
|
|
843
|
+
},
|
|
844
|
+
{ message_id: 'sa2', author_type: 'system', author_id: 'system', system_event: 'support_access.granted', body: 'Workspace access granted until **tomorrow 14:00**.', created: ago(280) },
|
|
845
|
+
{ message_id: 'sa3', author_type: 'system', author_id: 'system', system_event: 'support_access.first_used', body: 'Qorus Support opened the workspace.', created: ago(200) },
|
|
846
|
+
{ message_id: 'st1', author_type: 'staff', author_id: 'nick', body: 'Found it: the job runs with the pool default from 3.2. Fix coming in 3.2.1.', created: ago(150) },
|
|
847
|
+
{ message_id: 'sa4', author_type: 'system', author_id: 'system', system_event: 'support_access.extended', body: 'Workspace access extended until **Friday 14:00**.', created: ago(100) },
|
|
848
|
+
{ message_id: 'sa5', author_type: 'system', author_id: 'system', system_event: 'support_access.revoked', body: 'Workspace access revoked by you.', created: ago(40) },
|
|
849
|
+
{ message_id: 'sa6', author_type: 'system', author_id: 'system', system_event: 'support_access.expired', body: 'Workspace access expired.', created: ago(5) },
|
|
850
|
+
];
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* A support request for workspace access is the one system message that asks the reader
|
|
854
|
+
* something, so it renders as a card rather than a line — and the customer's surface can
|
|
855
|
+
* put its answer on it through `systemEventAction`. Every other access event is a line
|
|
856
|
+
* marked with its own icon.
|
|
857
|
+
*/
|
|
858
|
+
export const SupportAccessRequestCard: Story = {
|
|
859
|
+
parameters: {
|
|
860
|
+
docs: {
|
|
861
|
+
description: {
|
|
862
|
+
story:
|
|
863
|
+
'Renders the delegated-access events in a customer thread in the notification look: the support request as a card (frosted surface, icon tile, the Grant action supplied by the consumer under the text), and the granted, opened, extended, revoked and expired events as compact pills centred in the thread (qorus#693).',
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
},
|
|
867
|
+
args: {
|
|
868
|
+
viewerRole: 'customer',
|
|
869
|
+
messages: SUPPORT_ACCESS_THREAD,
|
|
870
|
+
systemEventAction: fn(
|
|
871
|
+
(message: ITicketThreadMessage): ITicketSystemEventAction | undefined =>
|
|
872
|
+
message.system_event === 'support_access.requested'
|
|
873
|
+
? { label: 'Grant access', icon: 'ShieldCheckLine', onClick: fn() }
|
|
874
|
+
: undefined
|
|
875
|
+
),
|
|
876
|
+
},
|
|
877
|
+
async play({ canvasElement }) {
|
|
878
|
+
const canvas = within(canvasElement);
|
|
879
|
+
await expect(canvas.getByText('Workspace access requested')).toBeInTheDocument();
|
|
880
|
+
await expect(canvasElement.querySelector('.reqore-ticket-system-card')).not.toBeNull();
|
|
881
|
+
const grant = canvas.getByText('Grant access');
|
|
882
|
+
await expect(grant).toBeInTheDocument();
|
|
883
|
+
await expect(canvas.getByText(/Workspace access granted until/)).toBeInTheDocument();
|
|
884
|
+
await expect(canvas.getByText(/Workspace access expired/)).toBeInTheDocument();
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
/** The staff side of the same thread: the request card renders with no action, because
|
|
889
|
+
* the answer is the customer's, and the lines read the same. */
|
|
890
|
+
export const SupportAccessRequestCardStaffView: Story = {
|
|
891
|
+
parameters: {
|
|
892
|
+
docs: {
|
|
893
|
+
description: {
|
|
894
|
+
story:
|
|
895
|
+
'Renders the same delegated-access thread for staff: the request card carries no action (only the customer can answer), the lines are unchanged, and the staff reply sits on the right.',
|
|
896
|
+
},
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
args: {
|
|
900
|
+
viewerRole: 'staff',
|
|
901
|
+
messages: SUPPORT_ACCESS_THREAD,
|
|
902
|
+
},
|
|
903
|
+
async play({ canvasElement }) {
|
|
904
|
+
const canvas = within(canvasElement);
|
|
905
|
+
await expect(canvas.getByText('Workspace access requested')).toBeInTheDocument();
|
|
906
|
+
await expect(canvas.queryByText('Grant access')).toBeNull();
|
|
907
|
+
await expect(canvasElement.querySelectorAll('.reqore-ticket-system-card-action')).toHaveLength(0);
|
|
908
|
+
},
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
/** The request card at phone width: the card and its action stay inside the screen. */
|
|
912
|
+
export const SupportAccessRequestCardMobile: Story = {
|
|
913
|
+
parameters: {
|
|
914
|
+
qlip: { viewport: { width: 390, height: 844 } },
|
|
915
|
+
docs: {
|
|
916
|
+
description: {
|
|
917
|
+
story:
|
|
918
|
+
'Renders the delegated-access thread at 390px: the request card and its Grant action fit the screen, and the event lines wrap rather than overflow.',
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
},
|
|
922
|
+
args: SupportAccessRequestCard.args,
|
|
923
|
+
async play({ canvasElement }) {
|
|
924
|
+
const canvas = within(canvasElement);
|
|
925
|
+
await expect(canvas.getByText('Grant access')).toBeInTheDocument();
|
|
926
|
+
const card = canvasElement.querySelector('.reqore-ticket-system-card') as HTMLElement;
|
|
927
|
+
await expect(card).not.toBeNull();
|
|
928
|
+
await waitFor(() => expect(card.getBoundingClientRect().right).toBeLessThanOrEqual(window.innerWidth));
|
|
929
|
+
},
|
|
930
|
+
};
|
|
931
|
+
|
|
@@ -5,11 +5,13 @@ import {
|
|
|
5
5
|
ReqoreControlGroup,
|
|
6
6
|
ReqoreEmptyState,
|
|
7
7
|
ReqoreIcon,
|
|
8
|
+
ReqoreNotification,
|
|
8
9
|
ReqoreP,
|
|
9
10
|
ReqoreSkeleton,
|
|
10
11
|
ReqoreTimeAgo,
|
|
11
12
|
useReqoreTheme,
|
|
12
13
|
} from '@qoretechnologies/reqore';
|
|
14
|
+
import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme';
|
|
13
15
|
import { getReadableColor } from '@qoretechnologies/reqore/dist/helpers/colors';
|
|
14
16
|
import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
|
|
15
17
|
import { rgba } from 'polished';
|
|
@@ -19,8 +21,11 @@ import styled from 'styled-components';
|
|
|
19
21
|
import { formatBytes, isImage } from '../../helpers/common';
|
|
20
22
|
import { ImageLightbox } from '../imageLightbox';
|
|
21
23
|
import {
|
|
24
|
+
dedupeInterfaceReferences,
|
|
22
25
|
IInterfaceReference,
|
|
23
26
|
InterfaceReferenceTags,
|
|
27
|
+
TICKET_SYSTEM_EVENT_META,
|
|
28
|
+
ticketSystemEventMeta,
|
|
24
29
|
TResolveInterfaceIcon,
|
|
25
30
|
TTicketMessageAuthor,
|
|
26
31
|
TTicketViewerRole,
|
|
@@ -69,6 +74,23 @@ export interface ITicketThreadMessage {
|
|
|
69
74
|
attachments?: ITicketThreadAttachment[];
|
|
70
75
|
/** Qorus interfaces this message references, as `{kind, name}` snapshots. */
|
|
71
76
|
referenced_interfaces?: IInterfaceReference[];
|
|
77
|
+
/** For a `system` message: the event it records (`support_access.requested`, …); see
|
|
78
|
+
* `ITicketMessage.system_event`. Decides the line's mark, and the card variant. */
|
|
79
|
+
system_event?: string;
|
|
80
|
+
/** For a `system` message: the event's structured payload, as the server wrote it. */
|
|
81
|
+
system_detail?: Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* An action a consumer attaches to a system event's card — the customer's Grant on a
|
|
86
|
+
* `support_access.requested` card. The thread renders it as the card's action; what it
|
|
87
|
+
* does (open the grant drawer, call the API) is the consumer's.
|
|
88
|
+
*/
|
|
89
|
+
export interface ITicketSystemEventAction {
|
|
90
|
+
label: string;
|
|
91
|
+
icon?: IReqoreIconName;
|
|
92
|
+
intent?: TReqoreIntent;
|
|
93
|
+
onClick: () => void;
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
export interface ITicketThreadProps {
|
|
@@ -97,6 +119,17 @@ export interface ITicketThreadProps {
|
|
|
97
119
|
/** open a referenced interface; reference chips are static when omitted (e.g. the staff
|
|
98
120
|
* view, which can't reach the customer's instance, leaves this out). */
|
|
99
121
|
onInterfaceClick?: (reference: IInterfaceReference) => void;
|
|
122
|
+
/** Whether a reference chip can be opened right now, and why not; passed through to
|
|
123
|
+
* every message's `InterfaceReferenceTags` — see the cases there. */
|
|
124
|
+
isInterfaceLinkable?: (reference: IInterfaceReference) => boolean;
|
|
125
|
+
unlinkableReason?: (reference: IInterfaceReference) => string | undefined;
|
|
126
|
+
/**
|
|
127
|
+
* The action to offer on a system event's card, if any — asked per system message, so a
|
|
128
|
+
* consumer answers for the events and states it handles (the IDE offers Grant on a
|
|
129
|
+
* `support_access.requested` card while the request is open; the staff portal offers
|
|
130
|
+
* nothing). Omitted ⇒ cards carry no action.
|
|
131
|
+
*/
|
|
132
|
+
systemEventAction?: (message: ITicketThreadMessage) => ITicketSystemEventAction | undefined;
|
|
100
133
|
/** The TICKET's own references (picked when filing). Echoed as chips on the opening
|
|
101
134
|
* message so the conversation carries them too — when they lived only in the header
|
|
102
135
|
* rail + References tab, readers looked for them in the thread and concluded the
|
|
@@ -530,6 +563,94 @@ const AttachmentList = ({
|
|
|
530
563
|
);
|
|
531
564
|
};
|
|
532
565
|
|
|
566
|
+
/** The anchor a "show in chat" scroll lands on; the notification itself takes no id. */
|
|
567
|
+
const StyledSystemCard = styled.div`
|
|
568
|
+
width: 100%;
|
|
569
|
+
`;
|
|
570
|
+
|
|
571
|
+
/** A one-line access event sits centred in the thread, as the plain system lines do. */
|
|
572
|
+
const StyledSystemPill = styled.div`
|
|
573
|
+
display: flex;
|
|
574
|
+
justify-content: center;
|
|
575
|
+
width: 100%;
|
|
576
|
+
`;
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* A system message. The request for workspace access renders as a card — it asks the
|
|
580
|
+
* reader something, and can carry the customer's answer — while every other event stays
|
|
581
|
+
* the centred muted line, marked with its event's icon when the event is known.
|
|
582
|
+
*
|
|
583
|
+
* The card wears the notification look (Nick, 2026-10-20): the same frosted surface,
|
|
584
|
+
* icon tile and action row as a toast, because a thread message that asks the reader to
|
|
585
|
+
* act is a notification that happens to live in the thread. It never times out and
|
|
586
|
+
* cannot be dismissed; the answer is the one action on it.
|
|
587
|
+
*/
|
|
588
|
+
const SystemMessage = ({
|
|
589
|
+
message,
|
|
590
|
+
action,
|
|
591
|
+
}: {
|
|
592
|
+
message: ITicketThreadMessage;
|
|
593
|
+
action?: ITicketSystemEventAction;
|
|
594
|
+
}) => {
|
|
595
|
+
const meta = ticketSystemEventMeta(message.system_event);
|
|
596
|
+
if (message.system_event === 'support_access.requested') {
|
|
597
|
+
return (
|
|
598
|
+
<StyledSystemCard id={ticketMessageDomId(message.message_id)} className='reqore-ticket-system-card'>
|
|
599
|
+
<ReqoreNotification
|
|
600
|
+
fluid
|
|
601
|
+
// in the page, not over it: no drop shadow, no cast light (reqore 0.78.8)
|
|
602
|
+
lift={false}
|
|
603
|
+
icon={meta.icon}
|
|
604
|
+
intent={meta.intent}
|
|
605
|
+
title={meta.label}
|
|
606
|
+
content={<MessageBody>{message.body}</MessageBody>}
|
|
607
|
+
actions={
|
|
608
|
+
action
|
|
609
|
+
? [
|
|
610
|
+
{
|
|
611
|
+
label: action.label,
|
|
612
|
+
icon: action.icon,
|
|
613
|
+
intent: action.intent ?? 'success',
|
|
614
|
+
className: 'reqore-ticket-system-card-action',
|
|
615
|
+
// a notification's actions are plain buttons that keep their label at
|
|
616
|
+
// every width (a phone is where the customer reads this); only a panel
|
|
617
|
+
// action folds to an icon, so nothing has to be asked for here
|
|
618
|
+
// the card stays: the thread, not a click, decides when it goes
|
|
619
|
+
closeOnClick: false,
|
|
620
|
+
onClick: action.onClick,
|
|
621
|
+
},
|
|
622
|
+
]
|
|
623
|
+
: undefined
|
|
624
|
+
}
|
|
625
|
+
/>
|
|
626
|
+
</StyledSystemCard>
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
// A delegated-access event the vocabulary knows is a compact pill in the same look as
|
|
630
|
+
// the request card, so the grant's story reads as one family: asked, granted, opened,
|
|
631
|
+
// extended, revoked, expired. Any other system line stays the plain centred line.
|
|
632
|
+
if (message.system_event && message.system_event in TICKET_SYSTEM_EVENT_META) {
|
|
633
|
+
return (
|
|
634
|
+
<StyledSystemPill id={ticketMessageDomId(message.message_id)} className='reqore-ticket-system-pill'>
|
|
635
|
+
<ReqoreNotification
|
|
636
|
+
compact
|
|
637
|
+
lift={false}
|
|
638
|
+
size='small'
|
|
639
|
+
icon={meta.icon}
|
|
640
|
+
intent={meta.intent}
|
|
641
|
+
content={<ReactMarkdown components={{ p: 'span' }}>{message.body}</ReactMarkdown>}
|
|
642
|
+
/>
|
|
643
|
+
</StyledSystemPill>
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
return (
|
|
647
|
+
<StyledSystemRow id={ticketMessageDomId(message.message_id)}>
|
|
648
|
+
<ReqoreIcon icon={meta.icon} size='tiny' />
|
|
649
|
+
<ReactMarkdown>{message.body}</ReactMarkdown>
|
|
650
|
+
</StyledSystemRow>
|
|
651
|
+
);
|
|
652
|
+
};
|
|
653
|
+
|
|
533
654
|
type TThreadSegment =
|
|
534
655
|
| { kind: 'system'; message: ITicketThreadMessage }
|
|
535
656
|
| { kind: 'bubbles'; messages: ITicketThreadMessage[] };
|
|
@@ -565,6 +686,9 @@ export const TicketThread = ({
|
|
|
565
686
|
resolveInterfaceIcon,
|
|
566
687
|
preferKindImage,
|
|
567
688
|
onInterfaceClick,
|
|
689
|
+
isInterfaceLinkable,
|
|
690
|
+
unlinkableReason,
|
|
691
|
+
systemEventAction,
|
|
568
692
|
ticketReferences,
|
|
569
693
|
}: ITicketThreadProps) => {
|
|
570
694
|
// Open scrolled to the newest message, and follow new arrivals down — a thread is read
|
|
@@ -608,15 +732,7 @@ export const TicketThread = ({
|
|
|
608
732
|
return undefined;
|
|
609
733
|
}
|
|
610
734
|
const opening = messages.find((m) => m.message_id === openingMessageId);
|
|
611
|
-
|
|
612
|
-
return [...ticketReferences, ...(opening?.referenced_interfaces ?? [])].filter((ref) => {
|
|
613
|
-
const key = `${ref.interface_kind}:${ref.interface_name}`;
|
|
614
|
-
if (seen.has(key)) {
|
|
615
|
-
return false;
|
|
616
|
-
}
|
|
617
|
-
seen.add(key);
|
|
618
|
-
return true;
|
|
619
|
-
});
|
|
735
|
+
return dedupeInterfaceReferences([...ticketReferences, ...(opening?.referenced_interfaces ?? [])]);
|
|
620
736
|
}, [ticketReferences, messages, openingMessageId]);
|
|
621
737
|
|
|
622
738
|
const refsFor = (message: ITicketThreadMessage): IInterfaceReference[] | undefined =>
|
|
@@ -645,13 +761,11 @@ export const TicketThread = ({
|
|
|
645
761
|
<StyledThread ref={threadRef}>
|
|
646
762
|
{segments.map((segment, index) =>
|
|
647
763
|
segment.kind === 'system' ? (
|
|
648
|
-
<
|
|
764
|
+
<SystemMessage
|
|
649
765
|
key={segment.message.message_id}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
<ReactMarkdown>{segment.message.body}</ReactMarkdown>
|
|
654
|
-
</StyledSystemRow>
|
|
766
|
+
message={segment.message}
|
|
767
|
+
action={systemEventAction?.(segment.message)}
|
|
768
|
+
/>
|
|
655
769
|
) : (
|
|
656
770
|
<ReqoreBubbleGroup key={`group-${index}`}>
|
|
657
771
|
{segment.messages.map((message) => (
|
|
@@ -703,6 +817,8 @@ export const TicketThread = ({
|
|
|
703
817
|
resolveInterfaceIcon={resolveInterfaceIcon}
|
|
704
818
|
preferKindImage={preferKindImage}
|
|
705
819
|
onInterfaceClick={onInterfaceClick}
|
|
820
|
+
isInterfaceLinkable={isInterfaceLinkable}
|
|
821
|
+
unlinkableReason={unlinkableReason}
|
|
706
822
|
/>
|
|
707
823
|
</div>
|
|
708
824
|
) : null}
|