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.
@@ -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
- });