@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.
- package/.claude/CLAUDE.md +11 -8
- package/dist/components/composer/Composer.d.ts +108 -0
- package/dist/components/composer/Composer.d.ts.map +1 -0
- package/dist/components/composer/Composer.js +238 -0
- package/dist/components/composer/Composer.js.map +1 -0
- package/dist/components/composer/helpers.d.ts +21 -0
- package/dist/components/composer/helpers.d.ts.map +1 -0
- package/dist/components/composer/helpers.js +75 -0
- package/dist/components/composer/helpers.js.map +1 -0
- package/dist/components/composer/index.d.ts +3 -0
- package/dist/components/composer/index.d.ts.map +1 -0
- package/dist/components/composer/index.js +19 -0
- package/dist/components/composer/index.js.map +1 -0
- package/dist/components/form/engine/FormEngine.d.ts +1 -1
- package/dist/components/imageLightbox/ImageLightbox.d.ts +21 -0
- package/dist/components/imageLightbox/ImageLightbox.d.ts.map +1 -0
- package/dist/components/imageLightbox/ImageLightbox.js +169 -0
- package/dist/components/imageLightbox/ImageLightbox.js.map +1 -0
- package/dist/components/imageLightbox/index.d.ts +2 -0
- package/dist/components/imageLightbox/index.d.ts.map +1 -0
- package/dist/components/imageLightbox/index.js +18 -0
- package/dist/components/imageLightbox/index.js.map +1 -0
- package/dist/components/searchFilterBar/SearchFilterBar.d.ts +33 -0
- package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -0
- package/dist/components/searchFilterBar/SearchFilterBar.js +76 -0
- package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -0
- package/dist/components/searchFilterBar/index.d.ts +2 -0
- package/dist/components/searchFilterBar/index.d.ts.map +1 -0
- package/dist/components/searchFilterBar/index.js +18 -0
- package/dist/components/searchFilterBar/index.js.map +1 -0
- package/dist/components/supportTicket/AttachmentChips.d.ts +10 -0
- package/dist/components/supportTicket/AttachmentChips.d.ts.map +1 -0
- package/dist/components/supportTicket/AttachmentChips.js +122 -0
- package/dist/components/supportTicket/AttachmentChips.js.map +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +43 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.js +31 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -0
- package/dist/components/supportTicket/ReferencePicker.d.ts +87 -0
- package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -0
- package/dist/components/supportTicket/ReferencePicker.js +295 -0
- package/dist/components/supportTicket/ReferencePicker.js.map +1 -0
- package/dist/components/supportTicket/TicketHeader.d.ts +86 -0
- package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -0
- package/dist/components/supportTicket/TicketHeader.js +149 -0
- package/dist/components/supportTicket/TicketHeader.js.map +1 -0
- package/dist/components/supportTicket/TicketMetaTags.d.ts +32 -0
- package/dist/components/supportTicket/TicketMetaTags.d.ts.map +1 -0
- package/dist/components/supportTicket/TicketMetaTags.js +21 -0
- package/dist/components/supportTicket/TicketMetaTags.js.map +1 -0
- package/dist/components/supportTicket/TicketReplyBox.d.ts +63 -0
- package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -0
- package/dist/components/supportTicket/TicketReplyBox.js +115 -0
- package/dist/components/supportTicket/TicketReplyBox.js.map +1 -0
- package/dist/components/supportTicket/attachmentAccess.d.ts +37 -0
- package/dist/components/supportTicket/attachmentAccess.d.ts.map +1 -0
- package/dist/components/supportTicket/attachmentAccess.js +132 -0
- package/dist/components/supportTicket/attachmentAccess.js.map +1 -0
- package/dist/components/supportTicket/fileToAttachment.d.ts +9 -0
- package/dist/components/supportTicket/fileToAttachment.d.ts.map +1 -0
- package/dist/components/supportTicket/fileToAttachment.js +28 -0
- package/dist/components/supportTicket/fileToAttachment.js.map +1 -0
- package/dist/components/supportTicket/index.d.ts +11 -0
- package/dist/components/supportTicket/index.d.ts.map +1 -0
- package/dist/components/supportTicket/index.js +27 -0
- package/dist/components/supportTicket/index.js.map +1 -0
- package/dist/components/supportTicket/meta.d.ts +71 -0
- package/dist/components/supportTicket/meta.d.ts.map +1 -0
- package/dist/components/supportTicket/meta.js +115 -0
- package/dist/components/supportTicket/meta.js.map +1 -0
- package/dist/components/supportTicket/ticketModel.d.ts +102 -0
- package/dist/components/supportTicket/ticketModel.d.ts.map +1 -0
- package/dist/components/supportTicket/ticketModel.js +3 -0
- package/dist/components/supportTicket/ticketModel.js.map +1 -0
- package/dist/components/ticketReferences/TicketReferences.d.ts +52 -0
- package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -0
- package/dist/components/ticketReferences/TicketReferences.js +492 -0
- package/dist/components/ticketReferences/TicketReferences.js.map +1 -0
- package/dist/components/ticketReferences/index.d.ts +3 -0
- package/dist/components/ticketReferences/index.d.ts.map +1 -0
- package/dist/components/ticketReferences/index.js +6 -0
- package/dist/components/ticketReferences/index.js.map +1 -0
- package/dist/components/ticketThread/TicketThread.d.ts +57 -0
- package/dist/components/ticketThread/TicketThread.d.ts.map +1 -0
- package/dist/components/ticketThread/TicketThread.js +299 -0
- package/dist/components/ticketThread/TicketThread.js.map +1 -0
- package/dist/components/ticketThread/index.d.ts +3 -0
- package/dist/components/ticketThread/index.d.ts.map +1 -0
- package/dist/components/ticketThread/index.js +7 -0
- package/dist/components/ticketThread/index.js.map +1 -0
- package/dist/helpers/common.d.ts +5 -0
- package/dist/helpers/common.d.ts.map +1 -1
- package/dist/helpers/common.js +20 -1
- package/dist/helpers/common.js.map +1 -1
- package/dist/helpers/scrollbar.d.ts +2 -0
- package/dist/helpers/scrollbar.d.ts.map +1 -0
- package/dist/helpers/scrollbar.js +25 -0
- package/dist/helpers/scrollbar.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/composer/Composer.stories.tsx +238 -0
- package/src/components/composer/Composer.tsx +545 -0
- package/src/components/composer/helpers.ts +74 -0
- package/src/components/composer/index.ts +2 -0
- package/src/components/imageLightbox/ImageLightbox.stories.tsx +238 -0
- package/src/components/imageLightbox/ImageLightbox.tsx +236 -0
- package/src/components/imageLightbox/index.ts +1 -0
- package/src/components/searchFilterBar/SearchFilterBar.tsx +131 -0
- package/src/components/searchFilterBar/index.ts +1 -0
- package/src/components/supportTicket/AttachmentChips.stories.tsx +171 -0
- package/src/components/supportTicket/AttachmentChips.tsx +147 -0
- package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +171 -0
- package/src/components/supportTicket/InterfaceReferenceTags.tsx +96 -0
- package/src/components/supportTicket/ReferencePicker.stories.tsx +519 -0
- package/src/components/supportTicket/ReferencePicker.tsx +563 -0
- package/src/components/supportTicket/TicketConversation.stories.tsx +121 -0
- package/src/components/supportTicket/TicketHeader.stories.tsx +183 -0
- package/src/components/supportTicket/TicketHeader.tsx +247 -0
- package/src/components/supportTicket/TicketMetaTags.stories.tsx +103 -0
- package/src/components/supportTicket/TicketMetaTags.tsx +85 -0
- package/src/components/supportTicket/TicketReplyBox.stories.tsx +351 -0
- package/src/components/supportTicket/TicketReplyBox.tsx +146 -0
- package/src/components/supportTicket/attachmentAccess.ts +97 -0
- package/src/components/supportTicket/fileToAttachment.ts +24 -0
- package/src/components/supportTicket/index.ts +10 -0
- package/src/components/supportTicket/meta.ts +185 -0
- package/src/components/supportTicket/ticketModel.ts +131 -0
- package/src/components/ticketReferences/TicketReferences.stories.tsx +315 -0
- package/src/components/ticketReferences/TicketReferences.tsx +886 -0
- package/src/components/ticketReferences/index.ts +2 -0
- package/src/components/ticketThread/TicketThread.stories.tsx +828 -0
- package/src/components/ticketThread/TicketThread.tsx +704 -0
- package/src/components/ticketThread/index.ts +6 -0
- package/src/helpers/common.ts +19 -0
- package/src/helpers/scrollbar.ts +32 -0
- package/src/index.tsx +8 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme';
|
|
2
|
+
import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The single source of truth for the Qorus support-ticket presentation
|
|
6
|
+
* vocabulary — how each status / priority / category / audit-action value
|
|
7
|
+
* renders (label + Reqore intent + icon), plus the interface-kind icon map and
|
|
8
|
+
* the small enum helpers. Shared by every Qorus surface that shows a ticket
|
|
9
|
+
* (the qorus-ide customer view, the admin-portal staff queue, the Qonsole
|
|
10
|
+
* renderer) so the chips read identically everywhere and can't drift apart.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type TTicketStatus = 'new' | 'open' | 'awaiting_customer' | 'resolved' | 'closed';
|
|
14
|
+
export type TTicketPriority = 'low' | 'normal' | 'high' | 'urgent';
|
|
15
|
+
export type TTicketCategory = 'bug' | 'question' | 'billing' | 'feature' | 'other';
|
|
16
|
+
export type TTicketAuditAction = 'replied' | 'noted' | 'status_changed' | 'assigned' | 'updated';
|
|
17
|
+
|
|
18
|
+
/** A message author. Staff identities are masked from the customer by the backend. */
|
|
19
|
+
export type TTicketMessageAuthor = 'customer' | 'staff' | 'system';
|
|
20
|
+
|
|
21
|
+
/** Which side "you" sit on — decides self-alignment and audience-specific labels. */
|
|
22
|
+
export type TTicketViewerRole = 'customer' | 'staff';
|
|
23
|
+
|
|
24
|
+
export interface ITicketEnumMeta {
|
|
25
|
+
label: string;
|
|
26
|
+
intent: TReqoreIntent;
|
|
27
|
+
icon: IReqoreIconName;
|
|
28
|
+
/** Audience-specific label for the customer view; falls back to `label` (the
|
|
29
|
+
* staff / neutral wording). Only a few values differ by audience. */
|
|
30
|
+
customerLabel?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const TICKET_STATUS_META: Record<TTicketStatus, ITicketEnumMeta> = {
|
|
34
|
+
new: { label: 'New', intent: 'info', icon: 'SparklingLine' },
|
|
35
|
+
open: { label: 'Open', intent: 'pending', icon: 'Chat3Line' },
|
|
36
|
+
awaiting_customer: {
|
|
37
|
+
label: 'Awaiting customer',
|
|
38
|
+
customerLabel: 'Awaiting your reply',
|
|
39
|
+
intent: 'warning',
|
|
40
|
+
icon: 'UserReceivedLine',
|
|
41
|
+
},
|
|
42
|
+
resolved: { label: 'Resolved', intent: 'success', icon: 'CheckLine' },
|
|
43
|
+
closed: { label: 'Closed', intent: 'muted', icon: 'CloseCircleLine' },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const TICKET_PRIORITY_META: Record<TTicketPriority, ITicketEnumMeta> = {
|
|
47
|
+
low: { label: 'Low', intent: 'muted', icon: 'ArrowDownLine' },
|
|
48
|
+
normal: { label: 'Normal', intent: 'info', icon: 'SubtractLine' },
|
|
49
|
+
high: { label: 'High', intent: 'warning', icon: 'ArrowUpLine' },
|
|
50
|
+
urgent: { label: 'Urgent', intent: 'danger', icon: 'AlarmWarningLine' },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const TICKET_CATEGORY_META: Record<TTicketCategory, ITicketEnumMeta> = {
|
|
54
|
+
bug: { label: 'Bug', intent: 'danger', icon: 'BugLine' },
|
|
55
|
+
question: { label: 'Question', intent: 'info', icon: 'QuestionLine' },
|
|
56
|
+
billing: { label: 'Billing', intent: 'warning', icon: 'BankCardLine' },
|
|
57
|
+
feature: { label: 'Feature request', intent: 'success', icon: 'LightbulbLine' },
|
|
58
|
+
other: { label: 'Other', intent: 'muted', icon: 'MoreLine' },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Staff-only audit trail vocabulary (used by the admin-portal staff surface).
|
|
62
|
+
* These are exactly the actions the backend writes (qorus-api
|
|
63
|
+
* `QorusSaasTicketStore::insertAudit`): a public reply, an internal note, a
|
|
64
|
+
* status change, an assignment, or `updated` (a priority/category change). */
|
|
65
|
+
export const TICKET_AUDIT_ACTION_META: Record<TTicketAuditAction, ITicketEnumMeta> = {
|
|
66
|
+
replied: { label: 'Replied', intent: 'success', icon: 'ReplyLine' },
|
|
67
|
+
noted: { label: 'Internal note', intent: 'info', icon: 'StickyNoteLine' },
|
|
68
|
+
status_changed: { label: 'Status changed', intent: 'warning', icon: 'ExchangeLine' },
|
|
69
|
+
assigned: { label: 'Assigned', intent: 'pending', icon: 'UserFollowLine' },
|
|
70
|
+
updated: { label: 'Updated', intent: 'muted', icon: 'Edit2Line' },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** The status label for a given viewer — the customer sees "Awaiting your reply"
|
|
74
|
+
* where staff see "Awaiting customer"; all other values are audience-neutral. */
|
|
75
|
+
export const ticketStatusLabel = (
|
|
76
|
+
status: TTicketStatus,
|
|
77
|
+
viewerRole?: TTicketViewerRole
|
|
78
|
+
): string => {
|
|
79
|
+
const meta = TICKET_STATUS_META[status];
|
|
80
|
+
return viewerRole === 'customer' && meta.customerLabel ? meta.customerLabel : meta.label;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** A reply may be sent while a ticket is in any state except `closed`. */
|
|
84
|
+
export const REPLYABLE_STATUSES: TTicketStatus[] = [
|
|
85
|
+
'new',
|
|
86
|
+
'open',
|
|
87
|
+
'awaiting_customer',
|
|
88
|
+
'resolved',
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/** Whether a ticket can still be replied to (closed ⇒ no). */
|
|
92
|
+
export const isTicketReplyable = (status: TTicketStatus): boolean => status !== 'closed';
|
|
93
|
+
|
|
94
|
+
/** A file to upload with a ticket or reply — raw bytes as base64, inline on the
|
|
95
|
+
* create/reply payload (the SaaS plane has no separate multipart upload). */
|
|
96
|
+
export interface IAttachmentUpload {
|
|
97
|
+
filename: string;
|
|
98
|
+
content_type: string;
|
|
99
|
+
/** base64-encoded file content */
|
|
100
|
+
content: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A reference to a Qorus interface on the subscriber's instance, stored as a
|
|
104
|
+
* `{kind, name}` snapshot (the interface itself lives on the instance, not the
|
|
105
|
+
* SaaS plane). Attaches to a ticket (at creation) or a message (in a reply). */
|
|
106
|
+
export interface IInterfaceReference {
|
|
107
|
+
/** interface type: workflow / service / job / fsm / qog / connection / ... */
|
|
108
|
+
interface_kind: string;
|
|
109
|
+
/** interface name (may include a version) */
|
|
110
|
+
interface_name: string;
|
|
111
|
+
/** server-assigned reference UUID (present on read, absent when submitting) */
|
|
112
|
+
reference_id?: string;
|
|
113
|
+
/** For an app-triggered interface (a qog): the triggering app's name. A viewer
|
|
114
|
+
* with the app catalogue (the customer's IDE) resolves it to the app's logo the
|
|
115
|
+
* same way the qogs list does — see `resolveInterfaceImage` on the tags. */
|
|
116
|
+
trigger_app?: string;
|
|
117
|
+
/** A pre-resolved logo/image (data-URI or URL) for the chip, e.g. the trigger
|
|
118
|
+
* app's logo. Travels with the reference so a viewer without the app catalogue
|
|
119
|
+
* (staff triage, the Qonsole card) still shows it. */
|
|
120
|
+
logo?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Built-in per-kind icons for referenced interfaces, kept in step with the
|
|
124
|
+
// qorus-ide interface vocabulary. Used when a consumer doesn't pass its own
|
|
125
|
+
// `resolveInterfaceIcon`, so the staff view and the Qonsole renderer show the
|
|
126
|
+
// same chips as the customer view without each re-declaring the mapping.
|
|
127
|
+
const DEFAULT_INTERFACE_ICONS: Record<string, IReqoreIconName> = {
|
|
128
|
+
workflow: 'GitBranchLine',
|
|
129
|
+
service: 'ServerLine',
|
|
130
|
+
job: 'CalendarLine',
|
|
131
|
+
fsm: 'DashboardLine',
|
|
132
|
+
// qog fallback glyph (used only when there's no trigger-app logo to show)
|
|
133
|
+
qog: 'FlowChart',
|
|
134
|
+
connection: 'Plug2Line',
|
|
135
|
+
mapper: 'MindMap',
|
|
136
|
+
class: 'CodeSLine',
|
|
137
|
+
'value-map': 'BringToFront',
|
|
138
|
+
'ai-collection': 'FileMarkedLine',
|
|
139
|
+
'ai-endpoint': 'RobotLine',
|
|
140
|
+
'ai-guardrail': 'ShieldLine',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Default per-kind icon for a referenced interface, with a generic fallback. */
|
|
144
|
+
export const defaultInterfaceIcon = (kind: string): IReqoreIconName =>
|
|
145
|
+
DEFAULT_INTERFACE_ICONS[kind] ?? 'CodeBoxLine';
|
|
146
|
+
|
|
147
|
+
// The reading name for each interface kind. The kind id (`value-map`, `ai-collection`)
|
|
148
|
+
// is a wire value — it's what a host fetches with and what gets stored on the reference
|
|
149
|
+
// — so it must never be what the user reads. These are plural because they name a
|
|
150
|
+
// category the user picks from, not a single interface. `fsm` maps to "Qogs" like every
|
|
151
|
+
// other user-facing surface; the id stays `fsm` wherever a host still uses it.
|
|
152
|
+
const DEFAULT_INTERFACE_KIND_LABELS: Record<string, string> = {
|
|
153
|
+
workflow: 'Workflows',
|
|
154
|
+
service: 'Services',
|
|
155
|
+
job: 'Jobs',
|
|
156
|
+
fsm: 'Qogs',
|
|
157
|
+
qog: 'Qogs',
|
|
158
|
+
connection: 'Connections',
|
|
159
|
+
mapper: 'Mappers',
|
|
160
|
+
class: 'Classes',
|
|
161
|
+
'value-map': 'Value maps',
|
|
162
|
+
'ai-collection': 'AI collections',
|
|
163
|
+
'ai-endpoint': 'AI endpoints',
|
|
164
|
+
'ai-guardrail': 'AI guardrails',
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Default reading name for an interface kind — "Value maps" for `value-map`. An
|
|
169
|
+
* unknown kind is humanised generically (separators to spaces, sentence case, a
|
|
170
|
+
* plural `s`) so a kind this map hasn't caught up with still reads as a word
|
|
171
|
+
* rather than an identifier. Consumers with their own vocabulary override the
|
|
172
|
+
* whole thing via the picker's `resolveKindLabel`.
|
|
173
|
+
*/
|
|
174
|
+
export const defaultInterfaceKindLabel = (kind: string): string => {
|
|
175
|
+
const known = DEFAULT_INTERFACE_KIND_LABELS[kind];
|
|
176
|
+
|
|
177
|
+
if (known) {
|
|
178
|
+
return known;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const words = kind.replace(/[-_]+/g, ' ').trim();
|
|
182
|
+
const sentenceCase = words.charAt(0).toUpperCase() + words.slice(1);
|
|
183
|
+
|
|
184
|
+
return sentenceCase.endsWith('s') ? sentenceCase : `${sentenceCase}s`;
|
|
185
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { IInterfaceReference, TTicketCategory, TTicketMessageAuthor, TTicketPriority, TTicketStatus } from './meta';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The support-ticket wire model, shared by every surface that speaks it.
|
|
5
|
+
*
|
|
6
|
+
* These shapes mirror the `SupportTicket` / `SupportMessage` schemas served by
|
|
7
|
+
* the SaaS plane — `/qorus-saas/support/*` for the customer (qorus-api
|
|
8
|
+
* `QorusSaasSupportRestClass`) and `/qorus-saas-admin/support/*` for staff
|
|
9
|
+
* (`QorusSaasAdminRestHandler`). Both planes serve the SAME ticket row; the
|
|
10
|
+
* staff plane simply projects more of it.
|
|
11
|
+
*
|
|
12
|
+
* They live here because they were previously declared twice — once in
|
|
13
|
+
* qorus-ide's `views/support/types.ts` and once in admin-portal's
|
|
14
|
+
* `types/supportTickets.ts` — and had already started to drift. The enum
|
|
15
|
+
* vocabularies (`meta.ts`) were shared from the start; the record shapes that
|
|
16
|
+
* carry them were not, which is the wrong way round: a field added to the wire
|
|
17
|
+
* schema had to be remembered in two repos, and nothing failed if it wasn't.
|
|
18
|
+
*
|
|
19
|
+
* The split below is deliberate. `ITicket` is what BOTH planes return;
|
|
20
|
+
* `IStaffTicket` is the extra projection only the admin plane serves.
|
|
21
|
+
* That keeps "customer must never see the assignee" a property of the type
|
|
22
|
+
* rather than a convention, while still giving one place to add a field.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Attachment metadata as embedded in a message (no content). */
|
|
26
|
+
export interface ITicketAttachment {
|
|
27
|
+
attachment_id: string;
|
|
28
|
+
filename: string;
|
|
29
|
+
content_type: string;
|
|
30
|
+
size_bytes: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Attachment metadata from the ticket-level list endpoint.
|
|
35
|
+
*
|
|
36
|
+
* Richer than the per-message copy: it carries the provenance the embedded
|
|
37
|
+
* metadata omits — who uploaded the file, when, and which reply it came from
|
|
38
|
+
* (null for a file attached when the ticket was filed). A staff uploader arrives
|
|
39
|
+
* already masked by the server, so this never exposes a real staff identity.
|
|
40
|
+
*/
|
|
41
|
+
export interface ITicketAttachmentDetail extends ITicketAttachment {
|
|
42
|
+
message_id?: string | null;
|
|
43
|
+
uploaded_by?: string;
|
|
44
|
+
uploader_type?: 'customer' | 'staff';
|
|
45
|
+
created?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One message in a ticket thread. */
|
|
49
|
+
export interface ITicketMessage {
|
|
50
|
+
message_id: string;
|
|
51
|
+
ticket_id: string;
|
|
52
|
+
author_type: TTicketMessageAuthor;
|
|
53
|
+
/** author identity; staff identities are masked from the customer */
|
|
54
|
+
author_id: string;
|
|
55
|
+
/** message body (markdown) */
|
|
56
|
+
body: string;
|
|
57
|
+
/** staff-only internal note — never present in the customer thread */
|
|
58
|
+
internal: boolean;
|
|
59
|
+
created: string;
|
|
60
|
+
/** files attached to this message */
|
|
61
|
+
attachments?: ITicketAttachment[];
|
|
62
|
+
/** interfaces referenced in this message, as `{kind, name}` snapshots */
|
|
63
|
+
referenced_interfaces?: IInterfaceReference[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One support ticket, in the projection BOTH planes serve.
|
|
68
|
+
*
|
|
69
|
+
* Staff-only fields are not optional-here-required-there; they live in
|
|
70
|
+
* `IStaffTicket` so a customer surface cannot read them by accident.
|
|
71
|
+
*/
|
|
72
|
+
export interface ITicket {
|
|
73
|
+
/** external ticket UUID, used in URLs */
|
|
74
|
+
ticket_id: string;
|
|
75
|
+
/** human-visible ticket number */
|
|
76
|
+
ticket_number: number;
|
|
77
|
+
subject: string;
|
|
78
|
+
/** initial description (markdown) */
|
|
79
|
+
description: string;
|
|
80
|
+
status: TTicketStatus;
|
|
81
|
+
priority: TTicketPriority;
|
|
82
|
+
category: TTicketCategory;
|
|
83
|
+
subscription_code: string;
|
|
84
|
+
/** account name (email) of the filer */
|
|
85
|
+
created_by: string;
|
|
86
|
+
/** parent ticket UUID, when this continues a closed ticket */
|
|
87
|
+
parent_ticket_id?: string;
|
|
88
|
+
/** first staff-reply timestamp */
|
|
89
|
+
first_response_at?: string;
|
|
90
|
+
resolved_at?: string;
|
|
91
|
+
closed_at?: string;
|
|
92
|
+
/** most-recent-message timestamp */
|
|
93
|
+
last_message_at: string;
|
|
94
|
+
created: string;
|
|
95
|
+
modified?: string;
|
|
96
|
+
/** interfaces this ticket references (ticket-level), as `{kind, name}` snapshots */
|
|
97
|
+
referenced_interfaces?: IInterfaceReference[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The extra projection the admin plane adds on top of {@link ITicket}. */
|
|
101
|
+
export interface IStaffTicket extends ITicket {
|
|
102
|
+
/** owner scope key (staff view) */
|
|
103
|
+
scope_key: string;
|
|
104
|
+
/** organization / account */
|
|
105
|
+
scope_type: string;
|
|
106
|
+
account_id: number;
|
|
107
|
+
groupid?: number;
|
|
108
|
+
/** the staff assignee */
|
|
109
|
+
assignee?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The offset pagination every support list endpoint returns. */
|
|
113
|
+
export interface ITicketPagedResult {
|
|
114
|
+
limit: number;
|
|
115
|
+
offset: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A ticket-list response. Generic over the ticket projection so the staff plane
|
|
120
|
+
* gets `IStaffTicket[]` without redeclaring the envelope; each plane extends this
|
|
121
|
+
* with the scope fields it actually serves.
|
|
122
|
+
*/
|
|
123
|
+
export interface ITicketListResult<T extends ITicket = ITicket> extends ITicketPagedResult {
|
|
124
|
+
tickets: T[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A message-list response for one ticket. */
|
|
128
|
+
export interface ITicketMessageListResult extends ITicketPagedResult {
|
|
129
|
+
ticket_id: string;
|
|
130
|
+
messages: ITicketMessage[];
|
|
131
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
|
|
3
|
+
|
|
4
|
+
import { StoryMeta } from '../../types';
|
|
5
|
+
import { IInterfaceReference } from '../supportTicket/meta';
|
|
6
|
+
import { ITicketReferenceAttachment, TicketReferences } from './TicketReferences';
|
|
7
|
+
|
|
8
|
+
const NOW = Date.parse('2026-06-30T12:00:00Z');
|
|
9
|
+
const ago = (mins: number) => new Date(NOW - mins * 60_000).toISOString();
|
|
10
|
+
|
|
11
|
+
// The stories stand in for the authed blob-download endpoint by handing back an
|
|
12
|
+
// inline SVG data-URI per image id, so thumbnails + the lightbox render offline.
|
|
13
|
+
const svgUrl = (label: string, color: string) =>
|
|
14
|
+
`data:image/svg+xml;utf8,${encodeURIComponent(
|
|
15
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="400">` +
|
|
16
|
+
`<rect width="640" height="400" fill="${color}"/>` +
|
|
17
|
+
`<text x="320" y="215" font-size="30" fill="#ffffff" text-anchor="middle" ` +
|
|
18
|
+
`font-family="sans-serif">${label}</text></svg>`
|
|
19
|
+
)}`;
|
|
20
|
+
|
|
21
|
+
const REFERENCES: IInterfaceReference[] = [
|
|
22
|
+
{ interface_kind: 'workflow', interface_name: 'order-sync:1.0', reference_id: 'r1' },
|
|
23
|
+
{ interface_kind: 'workflow', interface_name: 'invoice-sync:2.3', reference_id: 'r2' },
|
|
24
|
+
{ interface_kind: 'service', interface_name: 'datasource-pool:2.3', reference_id: 'r3' },
|
|
25
|
+
{ interface_kind: 'job', interface_name: 'nightly-reconcile', reference_id: 'r4' },
|
|
26
|
+
{ interface_kind: 'connection', interface_name: 'pg-main-ro', reference_id: 'r5' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Uploaders come already resolved from the endpoint: customer uploads carry the
|
|
30
|
+
// account, a staff reply's attachment reads "Qorus Support" on the customer plane.
|
|
31
|
+
const ATTACHMENTS: ITicketReferenceAttachment[] = [
|
|
32
|
+
{ attachment_id: 'img-1', filename: 'error-screenshot.png', content_type: 'image/png', size_bytes: 148_221, uploaded_by: 'acme-prod', uploader_type: 'customer', created: ago(180), message_id: 'm1' },
|
|
33
|
+
{ attachment_id: 'img-2', filename: 'pool-metrics.png', content_type: 'image/png', size_bytes: 96_540, uploaded_by: 'Qorus Support', uploader_type: 'staff', created: ago(120), message_id: 'm2' },
|
|
34
|
+
{ attachment_id: 'img-3', filename: 'stack-trace.png', content_type: 'image/png', size_bytes: 210_004, uploaded_by: 'acme-prod', uploader_type: 'customer', created: ago(60), message_id: 'm3' },
|
|
35
|
+
{ attachment_id: 'file-1', filename: 'nightly-trace.log', content_type: 'text/x-log', size_bytes: 219_136, uploaded_by: 'acme-prod', uploader_type: 'customer', created: ago(180), message_id: 'm1' },
|
|
36
|
+
{ attachment_id: 'file-2', filename: 'incident-report.pdf', content_type: 'application/pdf', size_bytes: 512_900, uploaded_by: 'Qorus Support', uploader_type: 'staff', created: ago(90), message_id: 'm2' },
|
|
37
|
+
{ attachment_id: 'file-3', filename: 'pool-config.yaml', content_type: 'application/yaml', size_bytes: 2_048, uploaded_by: 'acme-prod', uploader_type: 'customer', created: ago(90) },
|
|
38
|
+
{ attachment_id: 'file-4', filename: 'diagnostics.zip', content_type: 'application/zip', size_bytes: 3_400_000, uploaded_by: 'acme-prod', uploader_type: 'customer', created: ago(30), message_id: 'm3' },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const IMG_URLS: Record<string, string> = {
|
|
42
|
+
'img-1': svgUrl('error-screenshot', '#7b3fe4'),
|
|
43
|
+
'img-2': svgUrl('pool-metrics', '#2872a0'),
|
|
44
|
+
'img-3': svgUrl('stack-trace', '#b23c3c'),
|
|
45
|
+
};
|
|
46
|
+
const fetchAttachmentUrl = (id: string) => Promise.resolve(IMG_URLS[id] ?? svgUrl(id, '#555555'));
|
|
47
|
+
|
|
48
|
+
const meta = {
|
|
49
|
+
component: TicketReferences,
|
|
50
|
+
title: 'Components/TicketReferences',
|
|
51
|
+
parameters: { layout: 'padded' },
|
|
52
|
+
args: {
|
|
53
|
+
references: REFERENCES,
|
|
54
|
+
attachments: ATTACHMENTS,
|
|
55
|
+
viewerRole: 'staff',
|
|
56
|
+
onDownloadAttachment: fn(),
|
|
57
|
+
fetchAttachmentUrl,
|
|
58
|
+
},
|
|
59
|
+
} satisfies StoryMeta<typeof TicketReferences>;
|
|
60
|
+
|
|
61
|
+
export default meta;
|
|
62
|
+
type Story = StoryObj<typeof meta>;
|
|
63
|
+
|
|
64
|
+
/** A busy ticket: 5 grouped interface references, 3 screenshots, 4 files, and the
|
|
65
|
+
* search / type-filter / sort strip a ticket this full earns. */
|
|
66
|
+
export const Populated: Story = {
|
|
67
|
+
parameters: {
|
|
68
|
+
docs: {
|
|
69
|
+
description: {
|
|
70
|
+
story:
|
|
71
|
+
'Renders a busy ticket with five grouped interface references, three screenshots, and four files — full enough to surface the search box, type-filter, and sort strip.',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
play: async ({ canvasElement }) => {
|
|
76
|
+
const canvas = within(canvasElement);
|
|
77
|
+
await expect(canvas.getByText('Linked interfaces')).toBeInTheDocument();
|
|
78
|
+
await expect(canvas.getByText('Screenshots')).toBeInTheDocument();
|
|
79
|
+
await expect(canvas.getByText('Files')).toBeInTheDocument();
|
|
80
|
+
// references render as rows carrying their kind as metadata
|
|
81
|
+
await expect(canvasElement.textContent).toContain('workflow');
|
|
82
|
+
await expect(canvasElement.textContent).toContain('order-sync:1.0');
|
|
83
|
+
// a file row with provenance (staff view shows uploaders as stored)
|
|
84
|
+
await expect(canvas.getByText('incident-report.pdf')).toBeInTheDocument();
|
|
85
|
+
await expect(canvasElement.textContent).toContain('added by acme-prod');
|
|
86
|
+
// the control strip: search box + the type-filter and sort dropdowns
|
|
87
|
+
await expect(
|
|
88
|
+
canvas.getByPlaceholderText('Search files and references…')
|
|
89
|
+
).toBeInTheDocument();
|
|
90
|
+
await expect(canvas.getByText('All types')).toBeInTheDocument();
|
|
91
|
+
await expect(canvas.getByText('Newest first')).toBeInTheDocument();
|
|
92
|
+
// thumbnails resolve to images
|
|
93
|
+
await waitFor(() =>
|
|
94
|
+
expect(
|
|
95
|
+
canvasElement.querySelector('button[title="error-screenshot.png"] img')
|
|
96
|
+
).not.toBeNull()
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The control strip in action: search narrows to matches, a no-match query offers
|
|
102
|
+
* "Clear filters", and the type filter collapses the view to one family. */
|
|
103
|
+
export const Filtering: Story = {
|
|
104
|
+
parameters: {
|
|
105
|
+
docs: {
|
|
106
|
+
description: {
|
|
107
|
+
story:
|
|
108
|
+
"Renders the control strip in action — searching narrows to matching files and references, a no-match query offers 'Clear filters', and the type filter collapses the view to a single family.",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
play: async ({ canvasElement }) => {
|
|
113
|
+
const canvas = within(canvasElement);
|
|
114
|
+
const box = canvas.getByPlaceholderText('Search files and references…');
|
|
115
|
+
|
|
116
|
+
// search narrows across both files and screenshots by filename
|
|
117
|
+
await userEvent.type(box, 'pool');
|
|
118
|
+
await waitFor(() => {
|
|
119
|
+
// "pool-*" survive, "error-screenshot"/"nightly-trace" fall away
|
|
120
|
+
expect(canvas.getByText('pool-config.yaml')).toBeInTheDocument();
|
|
121
|
+
expect(canvasElement.querySelector('button[title="pool-metrics.png"]')).not.toBeNull();
|
|
122
|
+
expect(canvasElement.querySelector('button[title="error-screenshot.png"]')).toBeNull();
|
|
123
|
+
expect(canvas.queryByText('nightly-trace.log')).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// a query that matches nothing surfaces the empty state + a clear affordance
|
|
127
|
+
await userEvent.clear(box);
|
|
128
|
+
await userEvent.type(box, 'zzz-no-such-file');
|
|
129
|
+
await waitFor(() =>
|
|
130
|
+
expect(canvasElement.textContent).toContain('No matching references or files')
|
|
131
|
+
);
|
|
132
|
+
await userEvent.click(canvas.getByText('Clear filters'));
|
|
133
|
+
await waitFor(() => expect(canvas.getByText('Files')).toBeInTheDocument());
|
|
134
|
+
|
|
135
|
+
// the type filter collapses to a single family (Screenshots only)
|
|
136
|
+
await userEvent.click(canvas.getByText('All types'));
|
|
137
|
+
await userEvent.click(await within(document.body).findByText(/^Screenshots \(3\)$/));
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
expect(canvas.queryByText('Files')).toBeNull();
|
|
140
|
+
expect(canvas.queryByText('Linked interfaces')).toBeNull();
|
|
141
|
+
expect(canvasElement.querySelector('button[title="stack-trace.png"]')).not.toBeNull();
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Clicking a loaded screenshot opens the paging lightbox (portalled to body). */
|
|
147
|
+
export const LightboxOpens: Story = {
|
|
148
|
+
parameters: {
|
|
149
|
+
docs: {
|
|
150
|
+
description: {
|
|
151
|
+
story:
|
|
152
|
+
'Renders the references view and, when a loaded screenshot is clicked, opens the paging lightbox portalled to the body.',
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
play: async ({ canvasElement }) => {
|
|
157
|
+
const thumb = await waitFor(() => {
|
|
158
|
+
const el = canvasElement.querySelector<HTMLButtonElement>(
|
|
159
|
+
'button[title="error-screenshot.png"]'
|
|
160
|
+
);
|
|
161
|
+
if (!el || !el.querySelector('img')) {
|
|
162
|
+
throw new Error('thumbnail not ready');
|
|
163
|
+
}
|
|
164
|
+
return el;
|
|
165
|
+
});
|
|
166
|
+
await userEvent.click(thumb);
|
|
167
|
+
// error-screenshot is the oldest of the three, so under the default
|
|
168
|
+
// newest-first sort it sits last — the counter reads "3 / 3".
|
|
169
|
+
await waitFor(() => expect(document.body.textContent).toContain('3 / 3'));
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** The same ticket from the customer's side: their own uploads read "you", and a
|
|
174
|
+
* staff reply's attachment reads "Qorus Support" — never the raw staff identity. */
|
|
175
|
+
export const CustomerView: Story = {
|
|
176
|
+
parameters: {
|
|
177
|
+
docs: {
|
|
178
|
+
description: {
|
|
179
|
+
story:
|
|
180
|
+
"Renders the same ticket from the customer's side, where their own uploads read 'added by you' and a staff reply's attachment reads 'Qorus Support' rather than the raw staff identity.",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
args: { viewerRole: 'customer' },
|
|
185
|
+
play: async ({ canvasElement }) => {
|
|
186
|
+
await waitFor(() => expect(canvasElement.textContent).toContain('added by you'));
|
|
187
|
+
await expect(canvasElement.textContent).toContain('added by Qorus Support');
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** Without a fetcher, screenshots degrade to download tiles rather than thumbnails. */
|
|
192
|
+
export const NoPreview: Story = {
|
|
193
|
+
parameters: {
|
|
194
|
+
docs: {
|
|
195
|
+
description: {
|
|
196
|
+
story:
|
|
197
|
+
'Renders the references view with no fetchAttachmentUrl, so screenshots degrade to download tiles rather than thumbnails.',
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
args: { fetchAttachmentUrl: undefined },
|
|
202
|
+
play: async ({ canvasElement }) => {
|
|
203
|
+
// No fetcher ⇒ the tile body falls through to the filename fallback. The caption
|
|
204
|
+
// always shows the filename, so the discriminating checks are: no thumbnail image,
|
|
205
|
+
// and no forever-pending "…" in the tile body (the qlip-caught regression).
|
|
206
|
+
const tile = await waitFor(() => {
|
|
207
|
+
const el = canvasElement.querySelector<HTMLElement>('button[title="error-screenshot.png"]');
|
|
208
|
+
if (!el) {
|
|
209
|
+
throw new Error('screenshot tile not rendered');
|
|
210
|
+
}
|
|
211
|
+
return el;
|
|
212
|
+
});
|
|
213
|
+
await expect(tile.querySelector('img')).toBeNull();
|
|
214
|
+
await expect(tile.textContent).not.toContain('…');
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** Nothing attached or linked. */
|
|
219
|
+
export const Empty: Story = {
|
|
220
|
+
parameters: {
|
|
221
|
+
docs: {
|
|
222
|
+
description: {
|
|
223
|
+
story:
|
|
224
|
+
"Renders the empty state with nothing attached or linked, showing 'No references or files yet'.",
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
args: { references: [], attachments: [], messages: [] },
|
|
229
|
+
play: async ({ canvasElement }) => {
|
|
230
|
+
await expect(canvasElement.textContent).toContain('No references or files yet');
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* With `onShowInChat` wired, a row that came from a reply gains a "three dots" menu
|
|
236
|
+
* offering to reveal it in the conversation.
|
|
237
|
+
*
|
|
238
|
+
* A row filed against the ticket itself carries the same menu, but its reveal is
|
|
239
|
+
* disabled and labelled "Filed on the ticket" — there's no message to jump to. That
|
|
240
|
+
* distinction between a reply-borne row and a ticket-level one is the thing to see here.
|
|
241
|
+
*/
|
|
242
|
+
export const ShowInChat: Story = {
|
|
243
|
+
parameters: {
|
|
244
|
+
docs: {
|
|
245
|
+
description: {
|
|
246
|
+
story:
|
|
247
|
+
"Renders references with onShowInChat wired — a reply-borne row's menu offers an active 'Show in chat' that calls onShowInChat, while a ticket-level row carries the same menu with its reveal disabled ('Filed on the ticket').",
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
args: {
|
|
252
|
+
messages: [
|
|
253
|
+
{
|
|
254
|
+
message_id: 'm-42',
|
|
255
|
+
author_type: 'customer',
|
|
256
|
+
author_id: 'acme-prod',
|
|
257
|
+
body: 'Same failure on the reconcile job.',
|
|
258
|
+
created: ago(30),
|
|
259
|
+
referenced_interfaces: [{ interface_kind: 'job', interface_name: 'nightly-reconcile' }],
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
onShowInChat: fn(),
|
|
263
|
+
},
|
|
264
|
+
play: async ({ args, canvasElement }) => {
|
|
265
|
+
const canvas = within(canvasElement);
|
|
266
|
+
await expect(canvas.getByText('nightly-reconcile')).toBeInTheDocument();
|
|
267
|
+
|
|
268
|
+
const rowFor = (label: string) =>
|
|
269
|
+
Array.from(canvasElement.querySelectorAll<HTMLElement>('.reqore-entity-row')).find((row) =>
|
|
270
|
+
row.textContent?.includes(label)
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// the reply-borne reference offers the menu...
|
|
274
|
+
const fromReply = rowFor('nightly-reconcile');
|
|
275
|
+
const menuButton = fromReply?.querySelector<HTMLElement>(
|
|
276
|
+
'.reqore-entity-row-actions .reqore-button:last-child'
|
|
277
|
+
);
|
|
278
|
+
await userEvent.click(menuButton!);
|
|
279
|
+
const item = await waitFor(() => {
|
|
280
|
+
const found = Array.from(document.querySelectorAll<HTMLElement>('.reqore-menu-item')).find(
|
|
281
|
+
(el) => /Show in chat/.test(el.textContent || '')
|
|
282
|
+
);
|
|
283
|
+
if (!found) {
|
|
284
|
+
throw new Error('Show in chat not offered');
|
|
285
|
+
}
|
|
286
|
+
return found;
|
|
287
|
+
});
|
|
288
|
+
await userEvent.click(item);
|
|
289
|
+
await expect(args.onShowInChat).toHaveBeenCalledWith('m-42');
|
|
290
|
+
|
|
291
|
+
// ...and a ticket-level reference carries the SAME menu, so the rows don't look
|
|
292
|
+
// half-broken — but the reveal is disabled there, because it was filed on the
|
|
293
|
+
// ticket and has no message to jump to.
|
|
294
|
+
const ticketLevel = rowFor('order-sync:1.0');
|
|
295
|
+
const ticketMenu = ticketLevel?.querySelector<HTMLElement>(
|
|
296
|
+
'.reqore-entity-row-actions .reqore-button:last-child'
|
|
297
|
+
);
|
|
298
|
+
await expect(ticketMenu).toBeTruthy();
|
|
299
|
+
await userEvent.click(ticketMenu!);
|
|
300
|
+
const ticketEntry = await waitFor(() => {
|
|
301
|
+
const entry = Array.from(document.querySelectorAll<HTMLElement>('.reqore-menu-item')).find(
|
|
302
|
+
(el) => /Show in chat/.test(el.textContent || '')
|
|
303
|
+
);
|
|
304
|
+
if (!entry) {
|
|
305
|
+
throw new Error('the menu should still offer the entry, disabled');
|
|
306
|
+
}
|
|
307
|
+
return entry;
|
|
308
|
+
});
|
|
309
|
+
// it is genuinely unclickable, not merely styled as such — and the handler stays
|
|
310
|
+
// on its single call from the reply-borne row above
|
|
311
|
+
await expect(getComputedStyle(ticketEntry).pointerEvents).toBe('none');
|
|
312
|
+
await expect(ticketEntry.textContent).toContain('Filed on the ticket');
|
|
313
|
+
await expect(args.onShowInChat).toHaveBeenCalledTimes(1);
|
|
314
|
+
},
|
|
315
|
+
};
|