ui-soxo-bootstrap-core 2.6.63-dev.0 → 2.6.63-dev.1
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/core/lib/components/global-header/global-header.js +191 -151
- package/core/lib/components/support-desk/components/{raise-ticket/raise-ticket.js → support-frame/support-frame.js} +42 -19
- package/core/lib/components/support-desk/support-desk.js +8 -8
- package/core/lib/components/support-desk/support-desk.scss +0 -72
- package/core/lib/components/support-desk/support-desk.utils.js +35 -315
- package/core/lib/components/support-desk/support-desk.utils.test.js +51 -308
- package/package.json +1 -1
- package/core/lib/components/support-desk/components/my-tickets/my-tickets.js +0 -343
- package/core/lib/components/support-desk/support-desk.dom.test.js +0 -20
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* "My Tickets" tab. Only the logged in user's tickets are returned: the encrypted token
|
|
3
|
-
* carries their email and the Helpdesk filters on it. Nothing is filtered on the client.
|
|
4
|
-
*
|
|
5
|
-
* If the browser blocks the API call (CORS) the Helpdesk's own page is shown in an iframe.
|
|
6
|
-
*/
|
|
7
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
8
|
-
import { Button, DatePicker, Empty, Input, Result, Select, Table, Tag, Typography, message } from 'antd';
|
|
9
|
-
import { ClearOutlined, ExportOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
|
10
|
-
import {
|
|
11
|
-
PAGE_SIZE,
|
|
12
|
-
PRIORITY_COLORS,
|
|
13
|
-
STATUS_COLORS,
|
|
14
|
-
buildTicketsPageUrl,
|
|
15
|
-
fetchTickets,
|
|
16
|
-
formatDate,
|
|
17
|
-
formatDateTime,
|
|
18
|
-
normalize,
|
|
19
|
-
openInNewTab,
|
|
20
|
-
toPlainText,
|
|
21
|
-
} from '../../support-desk.utils';
|
|
22
|
-
|
|
23
|
-
const { Text } = Typography;
|
|
24
|
-
|
|
25
|
-
// ==============================|| STATE ||============================== //
|
|
26
|
-
|
|
27
|
-
const SEARCH_DEBOUNCE_MS = 350;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* @param {object} config result of resolveSupportConfig
|
|
31
|
-
* @param {boolean} active load only while the tab is visible
|
|
32
|
-
*/
|
|
33
|
-
function useTickets(config, active) {
|
|
34
|
-
const [search, setSearch] = useState('');
|
|
35
|
-
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
36
|
-
const [status, setStatusState] = useState();
|
|
37
|
-
const [priority, setPriorityState] = useState();
|
|
38
|
-
const [range, setRangeState] = useState(null);
|
|
39
|
-
const [page, setPage] = useState(1);
|
|
40
|
-
|
|
41
|
-
const [tickets, setTickets] = useState([]);
|
|
42
|
-
const [total, setTotal] = useState(0);
|
|
43
|
-
const [options, setOptions] = useState({ statuses: [], priorities: [] });
|
|
44
|
-
|
|
45
|
-
const [loading, setLoading] = useState(false);
|
|
46
|
-
const [error, setError] = useState('');
|
|
47
|
-
const [fallbackUrl, setFallbackUrl] = useState('');
|
|
48
|
-
|
|
49
|
-
// The newest request wins; older ones are cancelled.
|
|
50
|
-
const controllerRef = useRef(null);
|
|
51
|
-
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
if (search === debouncedSearch) return undefined;
|
|
54
|
-
|
|
55
|
-
const timer = setTimeout(() => {
|
|
56
|
-
setDebouncedSearch(search);
|
|
57
|
-
setPage(1);
|
|
58
|
-
}, SEARCH_DEBOUNCE_MS);
|
|
59
|
-
return () => clearTimeout(timer);
|
|
60
|
-
}, [search, debouncedSearch]);
|
|
61
|
-
|
|
62
|
-
const load = useCallback(async () => {
|
|
63
|
-
if (controllerRef.current) controllerRef.current.abort();
|
|
64
|
-
const controller = new AbortController();
|
|
65
|
-
controllerRef.current = controller;
|
|
66
|
-
|
|
67
|
-
setLoading(true);
|
|
68
|
-
setError('');
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
const result = await fetchTickets(
|
|
72
|
-
config,
|
|
73
|
-
{
|
|
74
|
-
page,
|
|
75
|
-
page_size: PAGE_SIZE,
|
|
76
|
-
search: debouncedSearch,
|
|
77
|
-
status,
|
|
78
|
-
priority,
|
|
79
|
-
date_from: range?.[0]?.format('YYYY-MM-DD'),
|
|
80
|
-
date_to: range?.[1]?.format('YYYY-MM-DD'),
|
|
81
|
-
},
|
|
82
|
-
controller.signal
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
setTickets(result.tickets);
|
|
86
|
-
setTotal(result.total);
|
|
87
|
-
// Keep the last known options so the dropdowns do not empty when a filter returns no rows.
|
|
88
|
-
setOptions((prev) => ({
|
|
89
|
-
statuses: result.statuses.length ? result.statuses : prev.statuses,
|
|
90
|
-
priorities: result.priorities.length ? result.priorities : prev.priorities,
|
|
91
|
-
}));
|
|
92
|
-
} catch (err) {
|
|
93
|
-
if (err.code === 'aborted') return;
|
|
94
|
-
|
|
95
|
-
// Blocked by CORS: show the Helpdesk's own page instead of a dead tab.
|
|
96
|
-
if (err.code === 'blocked') {
|
|
97
|
-
try {
|
|
98
|
-
setFallbackUrl(await buildTicketsPageUrl(config));
|
|
99
|
-
return;
|
|
100
|
-
} catch (e) {
|
|
101
|
-
/* fall through to the error state */
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
setError(err.message || 'Please try again in a moment.');
|
|
106
|
-
setTickets([]);
|
|
107
|
-
setTotal(0);
|
|
108
|
-
} finally {
|
|
109
|
-
if (controllerRef.current === controller) setLoading(false);
|
|
110
|
-
}
|
|
111
|
-
}, [config, page, debouncedSearch, status, priority, range]);
|
|
112
|
-
|
|
113
|
-
useEffect(() => {
|
|
114
|
-
if (active) load();
|
|
115
|
-
}, [active, load]);
|
|
116
|
-
|
|
117
|
-
// Cancel anything in flight when the drawer closes.
|
|
118
|
-
useEffect(
|
|
119
|
-
() => () => {
|
|
120
|
-
if (controllerRef.current) controllerRef.current.abort();
|
|
121
|
-
controllerRef.current = null;
|
|
122
|
-
},
|
|
123
|
-
[]
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
const withFirstPage = (setter) => (value) => {
|
|
127
|
-
setter(value);
|
|
128
|
-
setPage(1);
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
const hasFilters = Boolean(search || status || priority || range);
|
|
132
|
-
|
|
133
|
-
const clearFilters = () => {
|
|
134
|
-
setSearch('');
|
|
135
|
-
setDebouncedSearch('');
|
|
136
|
-
setStatusState(undefined);
|
|
137
|
-
setPriorityState(undefined);
|
|
138
|
-
setRangeState(null);
|
|
139
|
-
setPage(1);
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
return {
|
|
143
|
-
filters: { search, status, priority, range },
|
|
144
|
-
setSearch,
|
|
145
|
-
setStatus: withFirstPage(setStatusState),
|
|
146
|
-
setPriority: withFirstPage(setPriorityState),
|
|
147
|
-
setRange: withFirstPage(setRangeState),
|
|
148
|
-
clearFilters,
|
|
149
|
-
hasFilters,
|
|
150
|
-
options,
|
|
151
|
-
page,
|
|
152
|
-
setPage,
|
|
153
|
-
tickets,
|
|
154
|
-
total,
|
|
155
|
-
loading,
|
|
156
|
-
error,
|
|
157
|
-
fallbackUrl,
|
|
158
|
-
reload: load,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// ==============================|| FILTERS ||============================== //
|
|
163
|
-
|
|
164
|
-
function TicketFilters({ tickets }) {
|
|
165
|
-
const { filters, options, hasFilters, setSearch, setStatus, setPriority, setRange, clearFilters } = tickets;
|
|
166
|
-
|
|
167
|
-
return (
|
|
168
|
-
<div className="support-filters">
|
|
169
|
-
<Input
|
|
170
|
-
allowClear
|
|
171
|
-
className="support-filters__search"
|
|
172
|
-
prefix={<SearchOutlined />}
|
|
173
|
-
placeholder="Search by ticket ID or subject"
|
|
174
|
-
value={filters.search}
|
|
175
|
-
onChange={(event) => setSearch(event.target.value)}
|
|
176
|
-
/>
|
|
177
|
-
<Select allowClear placeholder="Status" value={filters.status} onChange={setStatus} className="support-filters__select">
|
|
178
|
-
{options.statuses.map((item) => (
|
|
179
|
-
<Select.Option key={item} value={item}>
|
|
180
|
-
{item}
|
|
181
|
-
</Select.Option>
|
|
182
|
-
))}
|
|
183
|
-
</Select>
|
|
184
|
-
<Select allowClear placeholder="Priority" value={filters.priority} onChange={setPriority} className="support-filters__select">
|
|
185
|
-
{options.priorities.map((item) => (
|
|
186
|
-
<Select.Option key={item} value={item}>
|
|
187
|
-
{item}
|
|
188
|
-
</Select.Option>
|
|
189
|
-
))}
|
|
190
|
-
</Select>
|
|
191
|
-
<DatePicker.RangePicker value={filters.range} onChange={setRange} className="support-filters__dates" />
|
|
192
|
-
<div className="support-filters__actions">
|
|
193
|
-
{hasFilters && (
|
|
194
|
-
<Button type="link" icon={<ClearOutlined />} onClick={clearFilters}>
|
|
195
|
-
Clear
|
|
196
|
-
</Button>
|
|
197
|
-
)}
|
|
198
|
-
</div>
|
|
199
|
-
</div>
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ==============================|| TABLE ||============================== //
|
|
204
|
-
|
|
205
|
-
const columns = [
|
|
206
|
-
{
|
|
207
|
-
title: 'Ticket',
|
|
208
|
-
dataIndex: 'name',
|
|
209
|
-
width: 110,
|
|
210
|
-
render: (value) => <Text strong>{value}</Text>,
|
|
211
|
-
},
|
|
212
|
-
{
|
|
213
|
-
title: 'Subject',
|
|
214
|
-
dataIndex: 'subject',
|
|
215
|
-
render: (value, row) => {
|
|
216
|
-
const description = toPlainText(row.description);
|
|
217
|
-
return (
|
|
218
|
-
<div className="support-subject">
|
|
219
|
-
<div className="support-subject__title" title={value}>
|
|
220
|
-
{value || '-'}
|
|
221
|
-
</div>
|
|
222
|
-
{description && (
|
|
223
|
-
<div className="support-subject__text" title={description}>
|
|
224
|
-
{description}
|
|
225
|
-
</div>
|
|
226
|
-
)}
|
|
227
|
-
</div>
|
|
228
|
-
);
|
|
229
|
-
},
|
|
230
|
-
},
|
|
231
|
-
{
|
|
232
|
-
title: 'Status',
|
|
233
|
-
dataIndex: 'status',
|
|
234
|
-
width: 160,
|
|
235
|
-
render: (value) => <Tag color={STATUS_COLORS[normalize(value)] || 'default'}>{value || '-'}</Tag>,
|
|
236
|
-
},
|
|
237
|
-
{
|
|
238
|
-
title: 'Priority',
|
|
239
|
-
dataIndex: 'priority',
|
|
240
|
-
width: 100,
|
|
241
|
-
render: (value) => <Tag color={PRIORITY_COLORS[normalize(value)] || 'default'}>{value || '-'}</Tag>,
|
|
242
|
-
},
|
|
243
|
-
{
|
|
244
|
-
title: 'Created',
|
|
245
|
-
dataIndex: 'creation',
|
|
246
|
-
width: 120,
|
|
247
|
-
render: (value) => <span title={formatDateTime(value)}>{formatDate(value)}</span>,
|
|
248
|
-
},
|
|
249
|
-
];
|
|
250
|
-
|
|
251
|
-
function TicketTable({ tickets, onRaiseTicket }) {
|
|
252
|
-
const { tickets: rows, total, page, setPage, loading, error, hasFilters, clearFilters, reload } = tickets;
|
|
253
|
-
|
|
254
|
-
if (error) {
|
|
255
|
-
return (
|
|
256
|
-
<Result
|
|
257
|
-
status="warning"
|
|
258
|
-
title="We couldn't load your tickets"
|
|
259
|
-
subTitle={error}
|
|
260
|
-
extra={
|
|
261
|
-
<Button type="primary" icon={<ReloadOutlined />} onClick={reload}>
|
|
262
|
-
Try again
|
|
263
|
-
</Button>
|
|
264
|
-
}
|
|
265
|
-
/>
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const empty = hasFilters ? (
|
|
270
|
-
<Empty description="No tickets match your filters">
|
|
271
|
-
<Button onClick={clearFilters}>Clear filters</Button>
|
|
272
|
-
</Empty>
|
|
273
|
-
) : (
|
|
274
|
-
<Empty description="You haven't raised any tickets yet">
|
|
275
|
-
<Button type="primary" icon={<PlusOutlined />} onClick={onRaiseTicket}>
|
|
276
|
-
Raise a ticket
|
|
277
|
-
</Button>
|
|
278
|
-
</Empty>
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
return (
|
|
282
|
-
<Table
|
|
283
|
-
size="middle"
|
|
284
|
-
rowKey="name"
|
|
285
|
-
columns={columns}
|
|
286
|
-
dataSource={rows}
|
|
287
|
-
loading={loading}
|
|
288
|
-
scroll={{ x: 640 }}
|
|
289
|
-
locale={{ emptyText: loading ? <div style={{ height: 120 }} /> : empty }}
|
|
290
|
-
pagination={{
|
|
291
|
-
current: page,
|
|
292
|
-
pageSize: PAGE_SIZE,
|
|
293
|
-
total,
|
|
294
|
-
showSizeChanger: false,
|
|
295
|
-
hideOnSinglePage: true,
|
|
296
|
-
showTotal: (count, [from, to]) => `${from}-${to} of ${count} tickets`,
|
|
297
|
-
onChange: setPage,
|
|
298
|
-
}}
|
|
299
|
-
/>
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ==============================|| MY TICKETS ||============================== //
|
|
304
|
-
|
|
305
|
-
export default function MyTickets({ config, active, onRaiseTicket }) {
|
|
306
|
-
const tickets = useTickets(config, active);
|
|
307
|
-
|
|
308
|
-
const handleOpenInNewTab = () =>
|
|
309
|
-
openInNewTab(() => buildTicketsPageUrl(config)).catch((err) => message.error(err?.message || 'Unable to open your tickets right now.'));
|
|
310
|
-
|
|
311
|
-
// Reload also works in fallback mode: it makes a new link, so the embedded page reloads.
|
|
312
|
-
const toolbar = (
|
|
313
|
-
<div className="support-toolbar">
|
|
314
|
-
<Button type="link" size="small" icon={<ReloadOutlined />} onClick={tickets.reload} disabled={tickets.loading}>
|
|
315
|
-
Reload
|
|
316
|
-
</Button>
|
|
317
|
-
<Button type="link" size="small" icon={<ExportOutlined />} onClick={handleOpenInNewTab}>
|
|
318
|
-
Open in new tab
|
|
319
|
-
</Button>
|
|
320
|
-
</div>
|
|
321
|
-
);
|
|
322
|
-
|
|
323
|
-
if (tickets.fallbackUrl) {
|
|
324
|
-
return (
|
|
325
|
-
<div className="support-tickets">
|
|
326
|
-
{toolbar}
|
|
327
|
-
<div className="support-tickets__frame">
|
|
328
|
-
<iframe title="My support tickets" src={tickets.fallbackUrl} className="support-frame" />
|
|
329
|
-
</div>
|
|
330
|
-
</div>
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
return (
|
|
335
|
-
<div className="support-tickets">
|
|
336
|
-
{toolbar}
|
|
337
|
-
<TicketFilters tickets={tickets} />
|
|
338
|
-
<div className="support-tickets__body">
|
|
339
|
-
<TicketTable tickets={tickets} onRaiseTicket={onRaiseTicket} />
|
|
340
|
-
</div>
|
|
341
|
-
</div>
|
|
342
|
-
);
|
|
343
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @jest-environment jsdom
|
|
3
|
-
*/
|
|
4
|
-
import { toPlainText } from './support-desk.utils';
|
|
5
|
-
|
|
6
|
-
describe('toPlainText', () => {
|
|
7
|
-
it('strips markup and collapses spaces', () => {
|
|
8
|
-
expect(toPlainText('<p>Hello <b>world</b></p>\n<p>again</p>')).toBe('Hello world again');
|
|
9
|
-
expect(toPlainText('')).toBe('');
|
|
10
|
-
expect(toPlainText(null)).toBe('');
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('does not load or run anything in the markup', async () => {
|
|
14
|
-
window.__pwned = false;
|
|
15
|
-
toPlainText('<img src="x" onerror="window.__pwned = true"><script>window.__pwned = true</script>');
|
|
16
|
-
|
|
17
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
18
|
-
expect(window.__pwned).toBe(false);
|
|
19
|
-
});
|
|
20
|
-
});
|