@qoretechnologies/reqraft 0.10.8 → 0.10.9-pr.74.g452d4d3
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/dist/components/form/engine/CompactRow.d.ts.map +1 -1
- package/dist/components/form/engine/CompactRow.js +1 -22
- package/dist/components/form/engine/CompactRow.js.map +1 -1
- package/dist/components/form/engine/compactRowStyles.d.ts +0 -1
- package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
- package/dist/components/form/engine/compactRowStyles.js +4 -19
- package/dist/components/form/engine/compactRowStyles.js.map +1 -1
- package/dist/components/supportTicket/AttachmentChips.d.ts +7 -0
- package/dist/components/supportTicket/AttachmentChips.d.ts.map +1 -0
- package/dist/components/supportTicket/AttachmentChips.js +26 -0
- package/dist/components/supportTicket/AttachmentChips.js.map +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +25 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.js +28 -0
- package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -0
- package/dist/components/supportTicket/TicketMetaTags.d.ts +24 -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 +59 -0
- package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -0
- package/dist/components/supportTicket/TicketReplyBox.js +169 -0
- package/dist/components/supportTicket/TicketReplyBox.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 +7 -0
- package/dist/components/supportTicket/index.d.ts.map +1 -0
- package/dist/components/supportTicket/index.js +23 -0
- package/dist/components/supportTicket/index.js.map +1 -0
- package/dist/components/supportTicket/meta.d.ts +55 -0
- package/dist/components/supportTicket/meta.d.ts.map +1 -0
- package/dist/components/supportTicket/meta.js +77 -0
- package/dist/components/supportTicket/meta.js.map +1 -0
- package/dist/components/ticketThread/TicketThread.d.ts +36 -0
- package/dist/components/ticketThread/TicketThread.d.ts.map +1 -0
- package/dist/components/ticketThread/TicketThread.js +137 -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 +6 -0
- package/dist/components/ticketThread/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/form/engine/CompactRow.tsx +1 -57
- package/src/components/form/engine/FormEngine.stories.tsx +0 -67
- package/src/components/form/engine/compactRowStyles.ts +0 -21
- package/src/components/supportTicket/AttachmentChips.tsx +50 -0
- package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +102 -0
- package/src/components/supportTicket/InterfaceReferenceTags.tsx +62 -0
- package/src/components/supportTicket/TicketMetaTags.stories.tsx +71 -0
- package/src/components/supportTicket/TicketMetaTags.tsx +58 -0
- package/src/components/supportTicket/TicketReplyBox.stories.tsx +111 -0
- package/src/components/supportTicket/TicketReplyBox.tsx +251 -0
- package/src/components/supportTicket/fileToAttachment.ts +24 -0
- package/src/components/supportTicket/index.ts +6 -0
- package/src/components/supportTicket/meta.ts +135 -0
- package/src/components/ticketThread/TicketThread.stories.tsx +192 -0
- package/src/components/ticketThread/TicketThread.tsx +331 -0
- package/src/components/ticketThread/index.ts +6 -0
- package/src/index.tsx +2 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { expect, within } from 'storybook/test';
|
|
3
|
+
import { StoryMeta } from '../../types';
|
|
4
|
+
import { TTicketCategory, TTicketPriority, TTicketStatus } from './meta';
|
|
5
|
+
import { TicketMetaTags } from './TicketMetaTags';
|
|
6
|
+
|
|
7
|
+
const ticket = (
|
|
8
|
+
status: TTicketStatus,
|
|
9
|
+
priority: TTicketPriority,
|
|
10
|
+
category: TTicketCategory
|
|
11
|
+
): { status: TTicketStatus; priority: TTicketPriority; category: TTicketCategory } => ({
|
|
12
|
+
status,
|
|
13
|
+
priority,
|
|
14
|
+
category,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
component: TicketMetaTags,
|
|
19
|
+
title: 'Components/TicketMetaTags',
|
|
20
|
+
args: {
|
|
21
|
+
ticket: ticket('open', 'high', 'bug'),
|
|
22
|
+
},
|
|
23
|
+
} as StoryMeta<typeof TicketMetaTags>;
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof meta>;
|
|
27
|
+
|
|
28
|
+
/** The status / priority / category badge row — each value's label, intent and
|
|
29
|
+
* icon come from the shared `supportTicket/meta` vocabularies. */
|
|
30
|
+
export const Default: Story = {
|
|
31
|
+
async play({ canvasElement }) {
|
|
32
|
+
const canvas = within(canvasElement);
|
|
33
|
+
await expect(canvas.getByText('Open')).toBeInTheDocument();
|
|
34
|
+
await expect(canvas.getByText('High')).toBeInTheDocument();
|
|
35
|
+
await expect(canvas.getByText('Bug')).toBeInTheDocument();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The `awaiting_customer` status is the one value whose label is audience-specific.
|
|
41
|
+
* With no `viewerRole` (the staff / neutral default) it reads "Awaiting customer".
|
|
42
|
+
*/
|
|
43
|
+
export const StaffAwaitingLabel: Story = {
|
|
44
|
+
args: { ticket: ticket('awaiting_customer', 'normal', 'question') },
|
|
45
|
+
async play({ canvasElement }) {
|
|
46
|
+
const canvas = within(canvasElement);
|
|
47
|
+
await expect(canvas.getByText('Awaiting customer')).toBeInTheDocument();
|
|
48
|
+
await expect(canvas.queryByText('Awaiting your reply')).not.toBeInTheDocument();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The customer viewpoint re-labels the same status as "Awaiting your reply". */
|
|
53
|
+
export const CustomerAwaitingLabel: Story = {
|
|
54
|
+
args: { viewerRole: 'customer', ticket: ticket('awaiting_customer', 'normal', 'question') },
|
|
55
|
+
async play({ canvasElement }) {
|
|
56
|
+
const canvas = within(canvasElement);
|
|
57
|
+
await expect(canvas.getByText('Awaiting your reply')).toBeInTheDocument();
|
|
58
|
+
await expect(canvas.queryByText('Awaiting customer')).not.toBeInTheDocument();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** `hideCategory` drops the category tag — for dense list rows. */
|
|
63
|
+
export const HideCategory: Story = {
|
|
64
|
+
args: { hideCategory: true },
|
|
65
|
+
async play({ canvasElement }) {
|
|
66
|
+
const canvas = within(canvasElement);
|
|
67
|
+
await expect(canvas.getByText('Open')).toBeInTheDocument();
|
|
68
|
+
await expect(canvas.getByText('High')).toBeInTheDocument();
|
|
69
|
+
await expect(canvas.queryByText('Bug')).not.toBeInTheDocument();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ReqoreControlGroup, ReqoreTag } from '@qoretechnologies/reqore';
|
|
2
|
+
import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes';
|
|
3
|
+
import {
|
|
4
|
+
TICKET_CATEGORY_META,
|
|
5
|
+
TICKET_PRIORITY_META,
|
|
6
|
+
TICKET_STATUS_META,
|
|
7
|
+
ticketStatusLabel,
|
|
8
|
+
TTicketCategory,
|
|
9
|
+
TTicketPriority,
|
|
10
|
+
TTicketStatus,
|
|
11
|
+
TTicketViewerRole,
|
|
12
|
+
} from './meta';
|
|
13
|
+
|
|
14
|
+
export interface ITicketMetaTagsProps {
|
|
15
|
+
/** The three enum values to badge — a full ticket satisfies this structurally. */
|
|
16
|
+
ticket: {
|
|
17
|
+
status: TTicketStatus;
|
|
18
|
+
priority: TTicketPriority;
|
|
19
|
+
category: TTicketCategory;
|
|
20
|
+
};
|
|
21
|
+
/** Picks audience-specific labels (e.g. "Awaiting your reply" vs "Awaiting
|
|
22
|
+
* customer"). Omit for the staff / neutral wording. */
|
|
23
|
+
viewerRole?: TTicketViewerRole;
|
|
24
|
+
size?: TSizes;
|
|
25
|
+
/** Hide the category tag (e.g. in a dense list row). */
|
|
26
|
+
hideCategory?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The status / priority / category badge row for a ticket — the single place the
|
|
31
|
+
* three enum vocabularies (`../supportTicket/meta`) turn into Reqore tags, so the
|
|
32
|
+
* customer list, the staff queue, the detail header and the Qonsole card all read
|
|
33
|
+
* identically. Each tag's label, intent and icon come from the shared meta maps.
|
|
34
|
+
*/
|
|
35
|
+
export const TicketMetaTags = ({
|
|
36
|
+
ticket,
|
|
37
|
+
viewerRole,
|
|
38
|
+
size = 'small',
|
|
39
|
+
hideCategory,
|
|
40
|
+
}: ITicketMetaTagsProps) => {
|
|
41
|
+
const status = TICKET_STATUS_META[ticket.status];
|
|
42
|
+
const priority = TICKET_PRIORITY_META[ticket.priority];
|
|
43
|
+
const category = TICKET_CATEGORY_META[ticket.category];
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<ReqoreControlGroup size={size} wrap>
|
|
47
|
+
<ReqoreTag
|
|
48
|
+
icon={status.icon}
|
|
49
|
+
intent={status.intent}
|
|
50
|
+
label={ticketStatusLabel(ticket.status, viewerRole)}
|
|
51
|
+
size={size}
|
|
52
|
+
effect={{ weight: 'bold' }}
|
|
53
|
+
/>
|
|
54
|
+
<ReqoreTag icon={priority.icon} intent={priority.intent} label={priority.label} size={size} />
|
|
55
|
+
{!hideCategory && <ReqoreTag icon={category.icon} label={category.label} size={size} />}
|
|
56
|
+
</ReqoreControlGroup>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { ReqoreButton } from '@qoretechnologies/reqore';
|
|
2
|
+
import { StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { expect, fn, screen, userEvent, waitFor, within } from 'storybook/test';
|
|
4
|
+
import { StoryMeta } from '../../types';
|
|
5
|
+
import { InterfaceReferenceTags } from './InterfaceReferenceTags';
|
|
6
|
+
import { TicketReplyBox } from './TicketReplyBox';
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
component: TicketReplyBox,
|
|
10
|
+
title: 'Components/TicketReplyBox',
|
|
11
|
+
args: {
|
|
12
|
+
onSend: fn(),
|
|
13
|
+
},
|
|
14
|
+
} as StoryMeta<typeof TicketReplyBox>;
|
|
15
|
+
|
|
16
|
+
export default meta;
|
|
17
|
+
type Story = StoryObj<typeof meta>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The customer composer: a textarea, an attach button (no extra menu → a plain
|
|
21
|
+
* button, not a dropdown), and Send. No internal-note toggle. Typing enables Send;
|
|
22
|
+
* clicking it calls `onSend` with the body and `internal: false`.
|
|
23
|
+
*/
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
async play({ canvasElement, args }) {
|
|
26
|
+
const canvas = within(canvasElement);
|
|
27
|
+
const textarea = canvasElement.querySelector('textarea');
|
|
28
|
+
await expect(textarea).not.toBeNull();
|
|
29
|
+
await expect(canvas.queryByText('Internal note')).not.toBeInTheDocument();
|
|
30
|
+
|
|
31
|
+
await userEvent.type(textarea!, 'Here is my reply');
|
|
32
|
+
await userEvent.click(canvas.getByText('Send reply'));
|
|
33
|
+
await waitFor(() =>
|
|
34
|
+
expect(args.onSend).toHaveBeenCalledWith(
|
|
35
|
+
'Here is my reply',
|
|
36
|
+
expect.objectContaining({ internal: false })
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The staff composer (`allowInternalNote`): no toggle — the send button splits, so
|
|
44
|
+
* the primary action posts a public reply and the caret opens "Add internal note",
|
|
45
|
+
* which sends with `internal: true`.
|
|
46
|
+
*/
|
|
47
|
+
export const StaffWithInternalNote: Story = {
|
|
48
|
+
args: { allowInternalNote: true },
|
|
49
|
+
async play({ canvasElement, args }) {
|
|
50
|
+
const canvas = within(canvasElement);
|
|
51
|
+
// no toggle any more; the primary action is a plain "Send reply"
|
|
52
|
+
await expect(canvas.queryByText('Internal note')).not.toBeInTheDocument();
|
|
53
|
+
await expect(canvas.getByText('Send reply')).toBeInTheDocument();
|
|
54
|
+
|
|
55
|
+
const textarea = canvasElement.querySelector('textarea');
|
|
56
|
+
await userEvent.type(textarea!, 'internal context');
|
|
57
|
+
|
|
58
|
+
// "Add internal note" lives behind the split-send caret
|
|
59
|
+
const caret = canvasElement.querySelector('.ticket-send-more');
|
|
60
|
+
await expect(caret).not.toBeNull();
|
|
61
|
+
await userEvent.click(caret as Element);
|
|
62
|
+
await userEvent.click(await screen.findByText('Add internal note'));
|
|
63
|
+
|
|
64
|
+
await waitFor(() =>
|
|
65
|
+
expect(args.onSend).toHaveBeenCalledWith(
|
|
66
|
+
'internal context',
|
|
67
|
+
expect.objectContaining({ internal: true })
|
|
68
|
+
)
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** A closed ticket replaces the editor with a muted notice. */
|
|
74
|
+
export const Closed: Story = {
|
|
75
|
+
args: { disabled: true },
|
|
76
|
+
async play({ canvasElement }) {
|
|
77
|
+
await expect(within(canvasElement).getByText(/ticket is closed/i)).toBeInTheDocument();
|
|
78
|
+
await expect(canvasElement.querySelector('textarea')).toBeNull();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The composer with app-injected affordances — how the qorus-ide customer view
|
|
84
|
+
* dresses it: `attachMenuItems` adds screenshot/capture to the attach dropdown,
|
|
85
|
+
* `footerActions` adds the reference-interfaces toggle, and `belowInput` hosts the
|
|
86
|
+
* reference display/picker (here the shared `InterfaceReferenceTags` stands in).
|
|
87
|
+
* The extra attach items receive `addFiles` so they can stage what they produce.
|
|
88
|
+
*/
|
|
89
|
+
export const WithSlots: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
attachMenuItems: () => [
|
|
92
|
+
{ label: 'Take screenshot', icon: 'CameraAiLine', onClick: fn() },
|
|
93
|
+
{ label: 'Capture area', icon: 'CropLine', onClick: fn() },
|
|
94
|
+
],
|
|
95
|
+
footerActions: (
|
|
96
|
+
<ReqoreButton icon='LinksLine' minimal flat badge={1} tooltip='Reference interfaces' />
|
|
97
|
+
),
|
|
98
|
+
belowInput: (
|
|
99
|
+
<InterfaceReferenceTags
|
|
100
|
+
label='References'
|
|
101
|
+
references={[{ interface_kind: 'workflow', interface_name: 'order-sync:1.2' }]}
|
|
102
|
+
/>
|
|
103
|
+
),
|
|
104
|
+
},
|
|
105
|
+
async play({ canvasElement }) {
|
|
106
|
+
const canvas = within(canvasElement);
|
|
107
|
+
// the below-input slot (reference chips) and the footer toggle both render
|
|
108
|
+
await expect(canvas.getByText('References')).toBeInTheDocument();
|
|
109
|
+
await expect(canvas.getByText('order-sync:1.2')).toBeInTheDocument();
|
|
110
|
+
},
|
|
111
|
+
};
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ReqoreButton,
|
|
3
|
+
ReqoreControlGroup,
|
|
4
|
+
ReqoreDropdown,
|
|
5
|
+
ReqoreMessage,
|
|
6
|
+
ReqoreTextarea,
|
|
7
|
+
} from '@qoretechnologies/reqore';
|
|
8
|
+
import { ComponentProps, ReactNode, useRef, useState } from 'react';
|
|
9
|
+
import { AttachmentChips } from './AttachmentChips';
|
|
10
|
+
import { fileToAttachment as defaultFileToAttachment } from './fileToAttachment';
|
|
11
|
+
import { IAttachmentUpload } from './meta';
|
|
12
|
+
|
|
13
|
+
/** The exact `items` shape ReqoreDropdown accepts — no import-path guessing. */
|
|
14
|
+
type TAttachMenuItems = NonNullable<ComponentProps<typeof ReqoreDropdown>['items']>;
|
|
15
|
+
|
|
16
|
+
export interface ITicketReplyBoxProps {
|
|
17
|
+
/** Send the composed message. `internal` is only ever true when
|
|
18
|
+
* `allowInternalNote` is set (the staff surface). */
|
|
19
|
+
onSend: (
|
|
20
|
+
body: string,
|
|
21
|
+
options: { internal: boolean; attachments?: IAttachmentUpload[] }
|
|
22
|
+
) => void | Promise<void>;
|
|
23
|
+
/** File → base64 upload conversion; defaults to the shared `fileToAttachment`. */
|
|
24
|
+
fileToAttachment?: (file: File) => Promise<IAttachmentUpload>;
|
|
25
|
+
/** the send round-trip is in flight */
|
|
26
|
+
sending?: boolean;
|
|
27
|
+
/** the ticket can't be replied to (e.g. closed) — renders a notice instead */
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
disabledReason?: string;
|
|
30
|
+
/** placeholder text. (A function is called with `false` — there is no live
|
|
31
|
+
* internal-note mode any more; the mode is chosen at send time.) */
|
|
32
|
+
placeholder?: string | ((internal: boolean) => string);
|
|
33
|
+
/** send labels; a function is called with `false` for the primary "Send reply"
|
|
34
|
+
* and `true` for the "Add internal note" split-menu item. Defaults to
|
|
35
|
+
* "Send reply" / "Add internal note". */
|
|
36
|
+
sendLabel?: string | ((internal: boolean) => string);
|
|
37
|
+
/** Staff surface: split the send button so it also offers "Add internal note"
|
|
38
|
+
* (customer composers omit it and just send a reply). */
|
|
39
|
+
allowInternalNote?: boolean;
|
|
40
|
+
/** Extra attach-menu entries beyond "Upload files" — e.g. the IDE's screenshot /
|
|
41
|
+
* region capture, which need IDE-only capabilities. Receives the composer's
|
|
42
|
+
* `addFiles` so an item can stage the file(s) it produces. With any entry the
|
|
43
|
+
* attach control becomes a dropdown; otherwise it's a plain button. */
|
|
44
|
+
attachMenuItems?: (addFiles: (files: File[]) => void) => TAttachMenuItems;
|
|
45
|
+
/** Extra footer controls left of the send button — e.g. the IDE's
|
|
46
|
+
* reference-interfaces toggle. */
|
|
47
|
+
footerActions?: ReactNode;
|
|
48
|
+
/** Rendered below the textarea — e.g. the IDE's interface-reference picker. */
|
|
49
|
+
belowInput?: ReactNode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Image files pasted from the clipboard (a screenshot, a copied image) — the
|
|
53
|
+
* generic bits, so pasting to attach works on every composer without an IDE dep. */
|
|
54
|
+
const clipboardImages = (event: React.ClipboardEvent): File[] => {
|
|
55
|
+
const images: File[] = [];
|
|
56
|
+
for (const item of Array.from(event.clipboardData?.items ?? [])) {
|
|
57
|
+
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
58
|
+
const file = item.getAsFile();
|
|
59
|
+
if (file) {
|
|
60
|
+
images.push(file);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return images;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The shared ticket reply composer core: a markdown textarea, staged file chips
|
|
69
|
+
* (with image thumbnails), an attach control, and a send button — presentational,
|
|
70
|
+
* with the parent owning the send round-trip and busy/disabled state. On the staff
|
|
71
|
+
* surface (`allowInternalNote`) the send button splits: the primary posts a public
|
|
72
|
+
* reply and a caret offers "Add internal note", so the mode is chosen at send time
|
|
73
|
+
* instead of held in a toggle that's easy to misread. A closed ticket renders a
|
|
74
|
+
* muted notice.
|
|
75
|
+
*
|
|
76
|
+
* App-specific affordances are injected through slots rather than baked in, so
|
|
77
|
+
* the IDE-only bits (screenshot / region capture, clipboard paste, the
|
|
78
|
+
* interface-reference picker — all of which need IDE helpers or the customer's
|
|
79
|
+
* instance) stay in qorus-ide: `attachMenuItems` adds to the attach menu,
|
|
80
|
+
* `belowInput` hosts the reference picker, `footerActions` adds footer buttons,
|
|
81
|
+
* and `onPaste` handles clipboard images.
|
|
82
|
+
*/
|
|
83
|
+
export const TicketReplyBox = ({
|
|
84
|
+
onSend,
|
|
85
|
+
fileToAttachment = defaultFileToAttachment,
|
|
86
|
+
sending,
|
|
87
|
+
disabled,
|
|
88
|
+
disabledReason = 'This ticket is closed. Open a new ticket to continue the conversation.',
|
|
89
|
+
placeholder = 'Write a reply…',
|
|
90
|
+
sendLabel,
|
|
91
|
+
allowInternalNote,
|
|
92
|
+
attachMenuItems,
|
|
93
|
+
footerActions,
|
|
94
|
+
belowInput,
|
|
95
|
+
}: ITicketReplyBoxProps) => {
|
|
96
|
+
const [body, setBody] = useState('');
|
|
97
|
+
const [files, setFiles] = useState<File[]>([]);
|
|
98
|
+
const fileInput = useRef<HTMLInputElement>(null);
|
|
99
|
+
|
|
100
|
+
if (disabled) {
|
|
101
|
+
return (
|
|
102
|
+
<ReqoreMessage intent='muted' icon='LockLine' size='small' flat>
|
|
103
|
+
{disabledReason}
|
|
104
|
+
</ReqoreMessage>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const canSend = !!body.trim() && !sending;
|
|
109
|
+
const resolvedPlaceholder =
|
|
110
|
+
typeof placeholder === 'function' ? placeholder(false) : placeholder;
|
|
111
|
+
// Label for a given send mode: `false` = public reply, `true` = internal note.
|
|
112
|
+
const labelFor = (note: boolean): string =>
|
|
113
|
+
sendLabel !== undefined
|
|
114
|
+
? typeof sendLabel === 'function'
|
|
115
|
+
? sendLabel(note)
|
|
116
|
+
: sendLabel
|
|
117
|
+
: note
|
|
118
|
+
? 'Add internal note'
|
|
119
|
+
: 'Send reply';
|
|
120
|
+
|
|
121
|
+
const addFiles = (incoming: File[]) => {
|
|
122
|
+
if (incoming.length) {
|
|
123
|
+
setFiles((prev) => [...prev, ...incoming]);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const handlePaste = (event: React.ClipboardEvent) => {
|
|
128
|
+
const images = clipboardImages(event);
|
|
129
|
+
if (images.length) {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
addFiles(images);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const extraAttachItems = attachMenuItems ? attachMenuItems(addFiles) : [];
|
|
136
|
+
|
|
137
|
+
const handleSend = async (internal: boolean) => {
|
|
138
|
+
if (!body.trim()) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const attachments = files.length
|
|
142
|
+
? await Promise.all(files.map(fileToAttachment))
|
|
143
|
+
: undefined;
|
|
144
|
+
await onSend(body.trim(), { internal, attachments });
|
|
145
|
+
// only reached when onSend resolves — a thrown error keeps the draft + files
|
|
146
|
+
setBody('');
|
|
147
|
+
setFiles([]);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<ReqoreControlGroup vertical fluid gapSize='small' onPaste={handlePaste}>
|
|
152
|
+
{/* Input + controls on one line: the textarea grows to fill, the controls
|
|
153
|
+
sit at its right and stay bottom-aligned as it scales with content. */}
|
|
154
|
+
<ReqoreControlGroup fluid verticalAlign='flex-end' gapSize='small'>
|
|
155
|
+
<ReqoreTextarea
|
|
156
|
+
value={body}
|
|
157
|
+
scaleWithContent
|
|
158
|
+
fluid
|
|
159
|
+
placeholder={resolvedPlaceholder}
|
|
160
|
+
disabled={sending}
|
|
161
|
+
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setBody(e.target.value)}
|
|
162
|
+
/>
|
|
163
|
+
{extraAttachItems.length ? (
|
|
164
|
+
<ReqoreDropdown
|
|
165
|
+
icon='AttachmentLine'
|
|
166
|
+
minimal
|
|
167
|
+
flat
|
|
168
|
+
disabled={sending}
|
|
169
|
+
tooltip='Attach'
|
|
170
|
+
items={[
|
|
171
|
+
{ label: 'Upload files', icon: 'Upload2Line', onClick: () => fileInput.current?.click() },
|
|
172
|
+
...extraAttachItems,
|
|
173
|
+
]}
|
|
174
|
+
/>
|
|
175
|
+
) : (
|
|
176
|
+
<ReqoreButton
|
|
177
|
+
icon='AttachmentLine'
|
|
178
|
+
minimal
|
|
179
|
+
flat
|
|
180
|
+
disabled={sending}
|
|
181
|
+
tooltip='Attach files'
|
|
182
|
+
onClick={() => fileInput.current?.click()}
|
|
183
|
+
/>
|
|
184
|
+
)}
|
|
185
|
+
{footerActions}
|
|
186
|
+
{allowInternalNote ? (
|
|
187
|
+
<ReqoreControlGroup stack fixed>
|
|
188
|
+
<ReqoreButton
|
|
189
|
+
icon='SendPlane2Line'
|
|
190
|
+
intent='info'
|
|
191
|
+
disabled={!canSend}
|
|
192
|
+
loading={sending}
|
|
193
|
+
onClick={() => handleSend(false)}
|
|
194
|
+
>
|
|
195
|
+
{labelFor(false)}
|
|
196
|
+
</ReqoreButton>
|
|
197
|
+
<ReqoreDropdown
|
|
198
|
+
className='ticket-send-more'
|
|
199
|
+
icon='ArrowDownSLine'
|
|
200
|
+
intent='info'
|
|
201
|
+
disabled={!canSend}
|
|
202
|
+
tooltip='More send options'
|
|
203
|
+
items={[
|
|
204
|
+
{
|
|
205
|
+
label: labelFor(false),
|
|
206
|
+
icon: 'SendPlane2Line',
|
|
207
|
+
onClick: () => handleSend(false),
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
label: labelFor(true),
|
|
211
|
+
icon: 'StickyNoteLine',
|
|
212
|
+
intent: 'warning',
|
|
213
|
+
onClick: () => handleSend(true),
|
|
214
|
+
},
|
|
215
|
+
]}
|
|
216
|
+
/>
|
|
217
|
+
</ReqoreControlGroup>
|
|
218
|
+
) : (
|
|
219
|
+
<ReqoreButton
|
|
220
|
+
icon='SendPlane2Line'
|
|
221
|
+
intent='info'
|
|
222
|
+
disabled={!canSend}
|
|
223
|
+
loading={sending}
|
|
224
|
+
onClick={() => handleSend(false)}
|
|
225
|
+
fixed
|
|
226
|
+
>
|
|
227
|
+
{labelFor(false)}
|
|
228
|
+
</ReqoreButton>
|
|
229
|
+
)}
|
|
230
|
+
</ReqoreControlGroup>
|
|
231
|
+
<AttachmentChips
|
|
232
|
+
files={files}
|
|
233
|
+
onRemove={(index) => setFiles((prev) => prev.filter((_, j) => j !== index))}
|
|
234
|
+
disabled={sending}
|
|
235
|
+
/>
|
|
236
|
+
{belowInput}
|
|
237
|
+
<input
|
|
238
|
+
ref={fileInput}
|
|
239
|
+
type='file'
|
|
240
|
+
multiple
|
|
241
|
+
style={{ display: 'none' }}
|
|
242
|
+
onChange={(e) => {
|
|
243
|
+
if (e.target.files) {
|
|
244
|
+
addFiles(Array.from(e.target.files));
|
|
245
|
+
}
|
|
246
|
+
e.target.value = '';
|
|
247
|
+
}}
|
|
248
|
+
/>
|
|
249
|
+
</ReqoreControlGroup>
|
|
250
|
+
);
|
|
251
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { IAttachmentUpload } from './meta';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read a picked `File` into the inline base64 upload shape the support API expects
|
|
5
|
+
* (the SaaS plane accepts attachments as base64 on the create/reply payload, with
|
|
6
|
+
* no separate multipart upload). Shared by every composer so the customer and
|
|
7
|
+
* staff surfaces encode files identically.
|
|
8
|
+
*/
|
|
9
|
+
export const fileToAttachment = (file: File): Promise<IAttachmentUpload> =>
|
|
10
|
+
new Promise((resolve, reject) => {
|
|
11
|
+
const reader = new FileReader();
|
|
12
|
+
reader.onerror = () => reject(new Error(`Could not read ${file.name}`));
|
|
13
|
+
reader.onload = () => {
|
|
14
|
+
// reader.result is a data URL: "data:<type>;base64,<content>"
|
|
15
|
+
const result = String(reader.result);
|
|
16
|
+
const content = result.slice(result.indexOf(',') + 1);
|
|
17
|
+
resolve({
|
|
18
|
+
filename: file.name,
|
|
19
|
+
content_type: file.type || 'application/octet-stream',
|
|
20
|
+
content,
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
reader.readAsDataURL(file);
|
|
24
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
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 / 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
|
+
}
|
|
114
|
+
|
|
115
|
+
// Built-in per-kind icons for referenced interfaces, kept in step with the
|
|
116
|
+
// qorus-ide interface vocabulary. Used when a consumer doesn't pass its own
|
|
117
|
+
// `resolveInterfaceIcon`, so the staff view and the Qonsole renderer show the
|
|
118
|
+
// same chips as the customer view without each re-declaring the mapping.
|
|
119
|
+
const DEFAULT_INTERFACE_ICONS: Record<string, IReqoreIconName> = {
|
|
120
|
+
workflow: 'GitBranchLine',
|
|
121
|
+
service: 'ServerLine',
|
|
122
|
+
job: 'CalendarLine',
|
|
123
|
+
fsm: 'DashboardLine',
|
|
124
|
+
connection: 'Plug2Line',
|
|
125
|
+
mapper: 'MindMap',
|
|
126
|
+
class: 'CodeSLine',
|
|
127
|
+
'value-map': 'BringToFront',
|
|
128
|
+
'ai-collection': 'FileMarkedLine',
|
|
129
|
+
'ai-endpoint': 'RobotLine',
|
|
130
|
+
'ai-guardrail': 'ShieldLine',
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Default per-kind icon for a referenced interface, with a generic fallback. */
|
|
134
|
+
export const defaultInterfaceIcon = (kind: string): IReqoreIconName =>
|
|
135
|
+
DEFAULT_INTERFACE_ICONS[kind] ?? 'CodeBoxLine';
|