ui-soxo-bootstrap-core 2.6.61 → 2.6.62-dev.2
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/DEVELOPER_GUIDE.md +38 -9
- package/core/lib/components/consent/signature-pad.js +1 -1
- package/core/lib/components/global-header/global-header.js +22 -20
- package/core/lib/components/support-desk/components/my-tickets/my-tickets.js +343 -0
- package/core/lib/components/support-desk/components/raise-ticket/raise-ticket.js +116 -0
- package/core/lib/components/support-desk/support-desk.dom.test.js +20 -0
- package/core/lib/components/support-desk/support-desk.js +63 -0
- package/core/lib/components/support-desk/support-desk.scss +143 -0
- package/core/lib/components/support-desk/support-desk.utils.js +363 -0
- package/core/lib/components/support-desk/support-desk.utils.test.js +335 -0
- package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.js +4 -3
- package/package.json +1 -1
package/DEVELOPER_GUIDE.md
CHANGED
|
@@ -17,6 +17,7 @@ Incorrect versioning or incorrect tags will break the publish pipeline — follo
|
|
|
17
17
|
- Publishing via GitHub Release UI
|
|
18
18
|
- How GitHub Action Detects Release Type
|
|
19
19
|
- Summary Table
|
|
20
|
+
- CI/CD Authentication (Trusted Publishing)
|
|
20
21
|
- Common Mistakes & Fixes
|
|
21
22
|
|
|
22
23
|
---
|
|
@@ -255,17 +256,14 @@ npm publish --tag dev
|
|
|
255
256
|
|
|
256
257
|
# ⚙️ How GitHub Action Detects Release Type
|
|
257
258
|
|
|
258
|
-
|
|
259
|
+
The workflow reads the `version` field from `package.json` at publish time:
|
|
259
260
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
261
|
+
| Condition | Command | Result |
|
|
262
|
+
| ---------------------------- | ------------------------------------------------------- | ---------------------------------- |
|
|
263
|
+
| Version contains `-dev` | `npm publish --provenance --access public --tag dev` | Publishes to the `dev` dist-tag |
|
|
264
|
+
| Version has no `-dev` suffix | `npm publish --provenance --access public --tag latest` | Publishes to the `latest` dist-tag |
|
|
263
265
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
```
|
|
267
|
-
npm publish
|
|
268
|
-
```
|
|
266
|
+
The workflow also enforces that the GitHub release tag matches `v<version>` from `package.json` and fails the run immediately if they diverge — this prevents the most common publish failure described below.
|
|
269
267
|
|
|
270
268
|
---
|
|
271
269
|
|
|
@@ -283,6 +281,37 @@ npm publish
|
|
|
283
281
|
|
|
284
282
|
---
|
|
285
283
|
|
|
284
|
+
# 🔐 CI/CD Authentication (Trusted Publishing)
|
|
285
|
+
|
|
286
|
+
As of npm's 2025 policy changes, classic automation tokens (`NPM_TOKEN`) are deprecated. This repo now authenticates to npm via **OIDC Trusted Publishing** — GitHub Actions exchanges a short-lived OIDC token for a publish token at run time, so **no secret is stored in the repository**.
|
|
287
|
+
|
|
288
|
+
## What this means for developers
|
|
289
|
+
|
|
290
|
+
Nothing. You still follow the same flow: `npm version` → push tag → create GitHub Release. The auth happens transparently in CI.
|
|
291
|
+
|
|
292
|
+
## What this means for maintainers
|
|
293
|
+
|
|
294
|
+
The first-time setup on npmjs.com must be done once per package:
|
|
295
|
+
|
|
296
|
+
1. Log in to [npmjs.com](https://www.npmjs.com) → open the package (`ui-soxo-bootstrap-core`) → **Settings**.
|
|
297
|
+
2. Under **Trusted Publisher**, click **Add trusted publisher** and fill in:
|
|
298
|
+
- Publisher: **GitHub Actions**
|
|
299
|
+
- Organization or user: `soxo-tech`
|
|
300
|
+
- Repository: `bootstrap-core`
|
|
301
|
+
- Workflow filename: `npm-publish.yml`
|
|
302
|
+
- Environment name: *(leave blank)*
|
|
303
|
+
3. Save. Any old `NPM_TOKEN` repository secret can be removed.
|
|
304
|
+
|
|
305
|
+
## Runtime requirements
|
|
306
|
+
|
|
307
|
+
The workflow runs on Node 20 and upgrades npm to the latest CLI (`npm install -g npm@latest`) because OIDC trusted publishing requires **npm ≥ 11.5.1**. The `--provenance` flag attaches a verifiable build attestation to every published version, visible on the npmjs.com package page.
|
|
308
|
+
|
|
309
|
+
## If publish fails with `403 Forbidden` or `ENEEDAUTH`
|
|
310
|
+
|
|
311
|
+
The trusted publisher config on npmjs.com no longer matches the workflow. Check that org, repo, and workflow filename match exactly — including case.
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
286
315
|
# ⚠️ Common Mistakes & Fixes
|
|
287
316
|
|
|
288
317
|
| Mistake | Issue | Fix |
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
*
|
|
3
3
|
* Global header component
|
|
4
4
|
*/
|
|
5
|
-
import { useContext, useEffect, useRef, useState } from 'react';
|
|
5
|
+
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
6
|
import { motion, useAnimation } from 'framer-motion';
|
|
7
7
|
import { boxVariants } from './animations';
|
|
8
8
|
import { GlobalContext, GlobalProvider } from './../../Store';
|
|
@@ -21,6 +21,8 @@ import { useTranslation } from 'react-i18next';
|
|
|
21
21
|
import SettingsUtil from '../../../utils/settings.utils';
|
|
22
22
|
import SpotlightSearch from '../spotlight-search/spotlight-search.component';
|
|
23
23
|
import LicenseAlert from '../../../components/license-management/license-alert';
|
|
24
|
+
import SupportDesk from '../support-desk/support-desk';
|
|
25
|
+
import { resolveSupportConfig } from '../support-desk/support-desk.utils';
|
|
24
26
|
|
|
25
27
|
const { Title } = Typography;
|
|
26
28
|
function GlobalHeaderContent({ loading, appSettings, children, isConnected, history, modules = [], sidemenu = [], reload, meta = {},
|
|
@@ -32,6 +34,9 @@ function GlobalHeaderContent({ loading, appSettings, children, isConnected, hist
|
|
|
32
34
|
const { isMobile, user = { locations: [] }, kiosk, state, settings } = useContext(GlobalContext);
|
|
33
35
|
const [visible, setVisible] = useState(false);
|
|
34
36
|
const helpDeskSetting = settings?.HELPATR || {};
|
|
37
|
+
// Memoised: the drawer's effects depend on this object, so it must keep its identity across renders.
|
|
38
|
+
const supportConfig = useMemo(() => resolveSupportConfig(helpDeskSetting, user), [helpDeskSetting, user]);
|
|
39
|
+
const [supportOpen, setSupportOpen] = useState(false);
|
|
35
40
|
// Variable to handle toggling of menu
|
|
36
41
|
const [collapsed, setCollapsed] = useState(false);
|
|
37
42
|
// varibale handle branch switcher
|
|
@@ -203,23 +208,16 @@ function GlobalHeaderContent({ loading, appSettings, children, isConnected, hist
|
|
|
203
208
|
<Button onClick={reload} icon={<ReloadOutlined />} type="default" size={'small'}></Button>
|
|
204
209
|
{/* Reload Button Ends */}
|
|
205
210
|
{/* Help-desk-btn */}
|
|
206
|
-
{
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
{helpDeskSetting?.buttonText || 'Help'}
|
|
217
|
-
</Button>
|
|
218
|
-
</span>
|
|
219
|
-
</Tooltip>
|
|
220
|
-
);
|
|
221
|
-
})()
|
|
222
|
-
: null}
|
|
211
|
+
{/* Shown only when the support settings are complete, so the button never does nothing */}
|
|
212
|
+
{helpDeskSetting?.showSupportBtn && supportConfig.enabled ? (
|
|
213
|
+
<Tooltip title={helpDeskSetting?.toolTipText} overlayClassName="modern-tooltip">
|
|
214
|
+
<span>
|
|
215
|
+
<Button onClick={() => setSupportOpen(true)} type="default" size="small">
|
|
216
|
+
{helpDeskSetting?.buttonText || 'Help'}
|
|
217
|
+
</Button>
|
|
218
|
+
</span>
|
|
219
|
+
</Tooltip>
|
|
220
|
+
) : null}
|
|
223
221
|
{/** Switch Languages starts */}
|
|
224
222
|
{process.env.REACT_APP_ENABLE_LANGUAGE_SWITCHER ? <LanguageSwitcher /> : null}
|
|
225
223
|
{/** Switch Languages ends */}
|
|
@@ -244,7 +242,11 @@ function GlobalHeaderContent({ loading, appSettings, children, isConnected, hist
|
|
|
244
242
|
|
|
245
243
|
|
|
246
244
|
|
|
247
|
-
{
|
|
245
|
+
{supportConfig.enabled && supportOpen ? (
|
|
246
|
+
<SupportDesk open={supportOpen} onClose={() => setSupportOpen(false)} config={supportConfig} title={helpDeskSetting?.buttonText || 'Support'} />
|
|
247
|
+
) : null}
|
|
248
|
+
|
|
249
|
+
{licAlert && licenseData && (
|
|
248
250
|
<div
|
|
249
251
|
style={{
|
|
250
252
|
top: 0,
|
|
@@ -290,4 +292,4 @@ function ProfileAvatar() {
|
|
|
290
292
|
{/* {user.name} */}
|
|
291
293
|
</Link>
|
|
292
294
|
);
|
|
293
|
-
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Raise a Ticket" tab: the Helpdesk form in an iframe.
|
|
3
|
+
*
|
|
4
|
+
* The link is minted when the tab opens, so an idle session never holds an expired token.
|
|
5
|
+
*
|
|
6
|
+
* Frappe's session cookie is SameSite=Lax, so some browsers render the embedded form blank.
|
|
7
|
+
* If the form has not loaded after FORM_SLOW_MS we offer "Open in new tab", which opens a
|
|
8
|
+
* fresh link as a top level page.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
11
|
+
import { Alert, Button, Result, Spin, message } from 'antd';
|
|
12
|
+
import { ExportOutlined, ReloadOutlined } from '@ant-design/icons';
|
|
13
|
+
import { FORM_SLOW_MS, buildFormUrl, openInNewTab } from '../../support-desk.utils';
|
|
14
|
+
|
|
15
|
+
export default function RaiseTicket({ config }) {
|
|
16
|
+
const [url, setUrl] = useState('');
|
|
17
|
+
// minting -> loading (iframe requested) -> ready (iframe finished) | error
|
|
18
|
+
const [phase, setPhase] = useState('minting');
|
|
19
|
+
const [error, setError] = useState('');
|
|
20
|
+
const [slow, setSlow] = useState(false);
|
|
21
|
+
|
|
22
|
+
const requestRef = useRef(0);
|
|
23
|
+
|
|
24
|
+
const load = useCallback(async () => {
|
|
25
|
+
const requestId = requestRef.current + 1;
|
|
26
|
+
requestRef.current = requestId;
|
|
27
|
+
|
|
28
|
+
setPhase('minting');
|
|
29
|
+
setError('');
|
|
30
|
+
setSlow(false);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const next = await buildFormUrl(config);
|
|
34
|
+
if (requestRef.current !== requestId) return;
|
|
35
|
+
setUrl(next);
|
|
36
|
+
setPhase('loading');
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (requestRef.current !== requestId) return;
|
|
39
|
+
setUrl('');
|
|
40
|
+
setError(err?.message || 'Unable to open the Support form right now.');
|
|
41
|
+
setPhase('error');
|
|
42
|
+
}
|
|
43
|
+
}, [config]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
load();
|
|
47
|
+
// Ignore the result of a request that finishes after unmount.
|
|
48
|
+
return () => {
|
|
49
|
+
requestRef.current += 1;
|
|
50
|
+
};
|
|
51
|
+
}, [load]);
|
|
52
|
+
|
|
53
|
+
// Warn when the form is slow or blank.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (phase !== 'loading') return undefined;
|
|
56
|
+
const timer = setTimeout(() => setSlow(true), FORM_SLOW_MS);
|
|
57
|
+
return () => clearTimeout(timer);
|
|
58
|
+
}, [phase, url]);
|
|
59
|
+
|
|
60
|
+
const handleOpenInNewTab = () =>
|
|
61
|
+
openInNewTab(() => buildFormUrl(config)).catch((err) => message.error(err?.message || 'Unable to open the Support form right now.'));
|
|
62
|
+
|
|
63
|
+
if (phase === 'error') {
|
|
64
|
+
return (
|
|
65
|
+
<Result
|
|
66
|
+
status="warning"
|
|
67
|
+
title="We couldn't open the Support form"
|
|
68
|
+
subTitle={error}
|
|
69
|
+
extra={
|
|
70
|
+
<Button type="primary" icon={<ReloadOutlined />} onClick={load}>
|
|
71
|
+
Try again
|
|
72
|
+
</Button>
|
|
73
|
+
}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="support-raise">
|
|
80
|
+
<div className="support-raise__bar">
|
|
81
|
+
<span>Tell us about your issue and our support team will reply as soon as possible.</span>
|
|
82
|
+
<span>
|
|
83
|
+
<Button type="link" size="small" icon={<ReloadOutlined />} onClick={load} disabled={phase === 'minting'}>
|
|
84
|
+
Reload
|
|
85
|
+
</Button>
|
|
86
|
+
<Button type="link" size="small" icon={<ExportOutlined />} onClick={handleOpenInNewTab}>
|
|
87
|
+
Open in new tab
|
|
88
|
+
</Button>
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{slow && phase === 'loading' && (
|
|
93
|
+
<Alert
|
|
94
|
+
banner
|
|
95
|
+
type="warning"
|
|
96
|
+
showIcon
|
|
97
|
+
message="The form is taking longer than usual. If it stays blank, open it in a new tab."
|
|
98
|
+
action={
|
|
99
|
+
<Button size="small" icon={<ReloadOutlined />} onClick={load}>
|
|
100
|
+
Reload
|
|
101
|
+
</Button>
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
<div className="support-raise__frame">
|
|
107
|
+
{phase !== 'ready' && (
|
|
108
|
+
<div className="support-raise__loading">
|
|
109
|
+
<Spin tip="Preparing the Support form…" />
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
{url && <iframe title="Support" src={url} allow="clipboard-write" className="support-frame" onLoad={() => setPhase('ready')} />}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
});
|