ui-soxo-bootstrap-core 2.6.62-dev.1 → 2.6.62-dev.3

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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ import { createDecipheriv, createHmac, randomBytes, webcrypto } from 'crypto';
5
+
6
+ // The module reads Web Crypto from `window`. Provide it in the node environment.
7
+ global.window = { crypto: webcrypto };
8
+
9
+ function setOnline(value) {
10
+ Object.defineProperty(global, 'navigator', { value: { onLine: value }, configurable: true, writable: true });
11
+ }
12
+
13
+ const {
14
+ API_ERROR_MESSAGES,
15
+ TicketApiError,
16
+ buildFormUrl,
17
+ buildTicketsApiUrl,
18
+ buildTicketsPageUrl,
19
+ createToken,
20
+ fetchTickets,
21
+ formatDate,
22
+ normalize,
23
+ resolveSupportConfig,
24
+ } = require('./support-desk.utils');
25
+
26
+ const SECRET = randomBytes(64);
27
+
28
+ const SETTING = {
29
+ helpDeskLink: 'https://help.example.com/raise-ticket/new',
30
+ projectToken: 'PROJECT_TOKEN',
31
+ encryptedToken: SECRET.toString('base64'),
32
+ };
33
+
34
+ const USER = { email: 'jane@example.com' };
35
+
36
+ const b64 = (value) => Buffer.from(value, 'base64url');
37
+
38
+ /** Decrypts a compact JWE the same way the Helpdesk does, and checks its integrity tag. */
39
+ function decryptJwe(jwe, secret = SECRET) {
40
+ const [header, encryptedKey, iv, ciphertext, tag] = jwe.split('.');
41
+ expect(encryptedKey).toBe('');
42
+ expect(JSON.parse(b64(header).toString())).toEqual({ alg: 'dir', enc: 'A256CBC-HS512' });
43
+
44
+ const al = Buffer.alloc(8);
45
+ al.writeBigUInt64BE(BigInt(header.length * 8));
46
+ const expectedTag = createHmac('sha512', secret.subarray(0, 32))
47
+ .update(Buffer.concat([Buffer.from(header), b64(iv), b64(ciphertext), al]))
48
+ .digest()
49
+ .subarray(0, 32);
50
+ expect(expectedTag.equals(b64(tag))).toBe(true);
51
+
52
+ const decipher = createDecipheriv('aes-256-cbc', secret.subarray(32), b64(iv));
53
+ return JSON.parse(Buffer.concat([decipher.update(b64(ciphertext)), decipher.final()]).toString());
54
+ }
55
+
56
+ describe('resolveSupportConfig', () => {
57
+ it('is enabled when the link, project token and secret are set', () => {
58
+ const config = resolveSupportConfig(SETTING, USER);
59
+
60
+ expect(config.enabled).toBe(true);
61
+ expect(config.formUrl).toBe(SETTING.helpDeskLink);
62
+ expect(config.user).toBe('jane@example.com');
63
+ });
64
+
65
+ it.each(['helpDeskLink', 'projectToken', 'encryptedToken'])('is disabled when %s is missing', (key) => {
66
+ expect(resolveSupportConfig({ ...SETTING, [key]: undefined }, USER).enabled).toBe(false);
67
+ });
68
+
69
+ it('is disabled for an empty or missing setting', () => {
70
+ expect(resolveSupportConfig(undefined, USER).enabled).toBe(false);
71
+ expect(resolveSupportConfig(null, USER).enabled).toBe(false);
72
+ });
73
+
74
+ it('derives the ticket list and API host from the form link', () => {
75
+ const config = resolveSupportConfig(SETTING, USER);
76
+
77
+ expect(config.ticketsUrl).toBe('https://help.example.com/my-tickets');
78
+ expect(config.apiBase).toBe('https://help.example.com');
79
+ });
80
+
81
+ it('prefers myTicketsLink and apiUrl, and trims trailing slashes', () => {
82
+ const config = resolveSupportConfig(
83
+ { ...SETTING, myTicketsLink: 'https://other.example.com/tickets', apiUrl: 'https://api.example.com//' },
84
+ USER
85
+ );
86
+
87
+ expect(config.ticketsUrl).toBe('https://other.example.com/tickets');
88
+ expect(config.apiBase).toBe('https://api.example.com');
89
+ });
90
+
91
+ it('uses the username when the user has no email', () => {
92
+ expect(resolveSupportConfig(SETTING, { username: 'jane' }).user).toBe('jane');
93
+ expect(resolveSupportConfig(SETTING, {}).user).toBe('');
94
+ });
95
+
96
+ it('defaults the token lifetime and enforces the minimum', () => {
97
+ expect(resolveSupportConfig(SETTING, USER).ttlSeconds).toBe(300);
98
+ expect(resolveSupportConfig({ ...SETTING, tokenTtlSeconds: 'abc' }, USER).ttlSeconds).toBe(300);
99
+ expect(resolveSupportConfig({ ...SETTING, tokenTtlSeconds: 5 }, USER).ttlSeconds).toBe(30);
100
+ expect(resolveSupportConfig({ ...SETTING, tokenTtlSeconds: 60 }, USER).ttlSeconds).toBe(60);
101
+ });
102
+ });
103
+
104
+ describe('createToken', () => {
105
+ beforeEach(() => {
106
+ jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
107
+ });
108
+
109
+ afterEach(() => {
110
+ jest.restoreAllMocks();
111
+ });
112
+
113
+ it('encrypts the project token and user into a JWE the Helpdesk can read', async () => {
114
+ const payload = decryptJwe(await createToken(resolveSupportConfig(SETTING, USER)));
115
+
116
+ expect(payload.token).toBe('PROJECT_TOKEN');
117
+ expect(payload.user).toBe('jane@example.com');
118
+ });
119
+
120
+ it('adds clock skew leeway on both sides of the lifetime', async () => {
121
+ const payload = decryptJwe(await createToken(resolveSupportConfig({ ...SETTING, tokenTtlSeconds: 60 }, USER)));
122
+
123
+ expect(payload.iat).toBe(1_700_000_000 - 30);
124
+ expect(payload.exp).toBe(1_700_000_000 + 60 + 30);
125
+ });
126
+
127
+ it('makes a different token every time', async () => {
128
+ const config = resolveSupportConfig(SETTING, USER);
129
+
130
+ expect(await createToken(config)).not.toBe(await createToken(config));
131
+ });
132
+
133
+ it('rejects a secret that is not 64 bytes', async () => {
134
+ const config = resolveSupportConfig({ ...SETTING, encryptedToken: randomBytes(32).toString('base64') }, USER);
135
+
136
+ await expect(createToken(config)).rejects.toThrow('must decode to 64 bytes (got 32)');
137
+ });
138
+
139
+ it('rejects a secret that is not base64', async () => {
140
+ const config = resolveSupportConfig({ ...SETTING, encryptedToken: '***not base64***' }, USER);
141
+
142
+ await expect(createToken(config)).rejects.toThrow('not valid base64');
143
+ });
144
+
145
+ it('rejects when the user has no email or username', async () => {
146
+ await expect(createToken(resolveSupportConfig(SETTING, {}))).rejects.toThrow('no email');
147
+ });
148
+
149
+ it('rejects when the project token is missing', async () => {
150
+ const config = resolveSupportConfig({ ...SETTING, projectToken: '' }, USER);
151
+
152
+ await expect(createToken(config)).rejects.toThrow('not configured');
153
+ });
154
+ });
155
+
156
+ describe('link builders', () => {
157
+ const config = resolveSupportConfig(SETTING, USER);
158
+
159
+ it('adds the token to the form link', async () => {
160
+ const url = new URL(await buildFormUrl(config));
161
+
162
+ expect(`${url.origin}${url.pathname}`).toBe('https://help.example.com/raise-ticket/new');
163
+ expect(decryptJwe(url.searchParams.get('d')).user).toBe('jane@example.com');
164
+ });
165
+
166
+ it('uses & when the link already has a query', async () => {
167
+ const withQuery = resolveSupportConfig({ ...SETTING, helpDeskLink: 'https://help.example.com/new?x=1' }, USER);
168
+ const url = new URL(await buildFormUrl(withQuery));
169
+
170
+ expect(url.searchParams.get('x')).toBe('1');
171
+ expect(url.searchParams.get('d')).toBeTruthy();
172
+ });
173
+
174
+ it('adds the token to the ticket list page link', async () => {
175
+ const url = new URL(await buildTicketsPageUrl(config));
176
+
177
+ expect(url.pathname).toBe('/my-tickets');
178
+ expect(url.searchParams.get('d')).toBeTruthy();
179
+ });
180
+
181
+ it('builds the API link and skips empty params', async () => {
182
+ const url = new URL(await buildTicketsApiUrl(config, { page: 2, search: '', status: undefined, priority: 'High' }));
183
+
184
+ expect(url.pathname).toBe('/api/method/get_tickets_based_on_token');
185
+ expect(url.searchParams.get('page')).toBe('2');
186
+ expect(url.searchParams.get('priority')).toBe('High');
187
+ expect(url.searchParams.has('search')).toBe(false);
188
+ expect(url.searchParams.has('status')).toBe(false);
189
+ });
190
+
191
+ it('fails clearly when a link is not configured', async () => {
192
+ const noLinks = { ...config, formUrl: '', ticketsUrl: '', apiBase: '' };
193
+
194
+ await expect(buildFormUrl(noLinks)).rejects.toThrow('form link is not configured');
195
+ await expect(buildTicketsPageUrl(noLinks)).rejects.toThrow('list link is not configured');
196
+ await expect(buildTicketsApiUrl(noLinks)).rejects.toThrow('API is not configured');
197
+ });
198
+ });
199
+
200
+ describe('fetchTickets', () => {
201
+ const config = resolveSupportConfig(SETTING, USER);
202
+ const reply = (body, ok = true, status = 200) => ({ ok, status, json: async () => body });
203
+
204
+ beforeEach(() => setOnline(true));
205
+
206
+ afterEach(() => {
207
+ delete global.fetch;
208
+ jest.restoreAllMocks();
209
+ });
210
+
211
+ it('returns tickets from a Frappe style response', async () => {
212
+ global.fetch = jest.fn().mockResolvedValue(
213
+ reply({ message: { success: true, tickets: [{ name: 'HD-1' }], total_count: 7, statuses: ['Open'], priorities: ['High'] } })
214
+ );
215
+
216
+ expect(await fetchTickets(config, { page: 1 })).toEqual({
217
+ tickets: [{ name: 'HD-1' }],
218
+ total: 7,
219
+ statuses: ['Open'],
220
+ priorities: ['High'],
221
+ });
222
+ expect(global.fetch.mock.calls[0][1]).toMatchObject({ credentials: 'omit' });
223
+ });
224
+
225
+ it('accepts a response that is not wrapped in message', async () => {
226
+ global.fetch = jest.fn().mockResolvedValue(reply({ success: true }));
227
+
228
+ expect(await fetchTickets(config, {})).toEqual({ tickets: [], total: 0, statuses: [], priorities: [] });
229
+ });
230
+
231
+ it('retries once with a new token when the token has expired', async () => {
232
+ global.fetch = jest
233
+ .fn()
234
+ .mockResolvedValueOnce(reply({ message: { success: false, error: 'expired_token' } }))
235
+ .mockResolvedValueOnce(reply({ message: { success: true, tickets: [] } }));
236
+
237
+ await expect(fetchTickets(config, {})).resolves.toMatchObject({ tickets: [] });
238
+
239
+ expect(global.fetch).toHaveBeenCalledTimes(2);
240
+ const tokens = global.fetch.mock.calls.map(([url]) => new URL(url).searchParams.get('d'));
241
+ expect(tokens[0]).not.toBe(tokens[1]);
242
+ });
243
+
244
+ it('gives up after one retry', async () => {
245
+ global.fetch = jest.fn().mockResolvedValue(reply({ message: { success: false, error: 'expired_token' } }));
246
+
247
+ await expect(fetchTickets(config, {})).rejects.toThrow(API_ERROR_MESSAGES.expired_token);
248
+ expect(global.fetch).toHaveBeenCalledTimes(2);
249
+ });
250
+
251
+ it('turns a known API error into a readable message', async () => {
252
+ global.fetch = jest.fn().mockResolvedValue(reply({ message: { success: false, error: 'project_not_found' } }));
253
+
254
+ await expect(fetchTickets(config, {})).rejects.toThrow(API_ERROR_MESSAGES.project_not_found);
255
+ });
256
+
257
+ it('falls back to a generic message for an unknown API error', async () => {
258
+ global.fetch = jest.fn().mockResolvedValue(reply({ message: { success: false, error: 'something_new' } }));
259
+
260
+ await expect(fetchTickets(config, {})).rejects.toThrow(API_ERROR_MESSAGES.invalid_token);
261
+ });
262
+
263
+ it('reports an HTTP error status', async () => {
264
+ global.fetch = jest.fn().mockResolvedValue(reply({}, false, 500));
265
+
266
+ await expect(fetchTickets(config, {})).rejects.toMatchObject({ code: 'http', message: expect.stringContaining('500') });
267
+ });
268
+
269
+ it('reports a blocked request (CORS) so the page can fall back', async () => {
270
+ global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'));
271
+
272
+ await expect(fetchTickets(config, {})).rejects.toMatchObject({ code: 'blocked' });
273
+ });
274
+
275
+ it('reports offline before blocked', async () => {
276
+ setOnline(false);
277
+ global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'));
278
+
279
+ await expect(fetchTickets(config, {})).rejects.toMatchObject({ code: 'offline' });
280
+ });
281
+
282
+ it('reports a cancelled request as aborted', async () => {
283
+ const abort = new Error('aborted');
284
+ abort.name = 'AbortError';
285
+ global.fetch = jest.fn().mockRejectedValue(abort);
286
+
287
+ await expect(fetchTickets(config, {})).rejects.toMatchObject({ code: 'aborted' });
288
+ });
289
+
290
+ it('reports a slow Helpdesk as a timeout', async () => {
291
+ // Fire the 15 second request timer immediately.
292
+ const realSetTimeout = global.setTimeout;
293
+ jest.spyOn(global, 'setTimeout').mockImplementation((fn, ms, ...args) => (ms === 15000 ? (fn(), 0) : realSetTimeout(fn, ms, ...args)));
294
+ global.fetch = jest.fn((url, { signal }) => new Promise((resolve, reject) => {
295
+ const abort = () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
296
+ if (signal.aborted) abort();
297
+ signal.addEventListener('abort', abort);
298
+ }));
299
+
300
+ await expect(fetchTickets(config, {})).rejects.toMatchObject({ code: 'timeout' });
301
+ });
302
+
303
+ it('stops when the caller cancels', async () => {
304
+ const controller = new AbortController();
305
+ global.fetch = jest.fn(
306
+ (url, { signal }) =>
307
+ new Promise((resolve, reject) => {
308
+ signal.addEventListener('abort', () => {
309
+ const err = new Error('aborted');
310
+ err.name = 'AbortError';
311
+ reject(err);
312
+ });
313
+ })
314
+ );
315
+
316
+ const pending = fetchTickets(config, {}, controller.signal);
317
+ const assertion = expect(pending).rejects.toBeInstanceOf(TicketApiError);
318
+ await new Promise((resolve) => setTimeout(resolve, 20));
319
+ controller.abort();
320
+ await assertion;
321
+ });
322
+ });
323
+
324
+ describe('helpers', () => {
325
+ it('normalize makes a lowercase, dashed key', () => {
326
+ expect(normalize('Work In Progress')).toBe('work-in-progress');
327
+ expect(normalize(undefined)).toBe('');
328
+ });
329
+
330
+ it('formatDate formats the Helpdesk timestamp and tolerates bad input', () => {
331
+ expect(formatDate('2026-10-21 12:07:02')).toMatch(/21/);
332
+ expect(formatDate('')).toBe('-');
333
+ expect(formatDate('not a date')).toBe('not a date');
334
+ });
335
+ });
@@ -3,7 +3,16 @@ import { Table } from 'antd';
3
3
  import PropTypes from 'prop-types';
4
4
  import { GlobalContext } from '../../../Store';
5
5
 
6
- const TableComponent = ({ columns, dataSource, loading, fixed, scroll, summary, pagination, title, onChange }) => {
6
+ import './table.scss';
7
+
8
+ /**
9
+ * Table with the theme styling.
10
+ *
11
+ * `freezeHeader` keeps the header row fixed while the rows scroll vertically. It is opt-in,
12
+ * use it for long lists only. The header cells stay inside the same table as the rows
13
+ * (see table.scss), so the columns always line up with the header.
14
+ */
15
+ const TableComponent = ({ columns, dataSource, loading, fixed, scroll, summary, pagination, title, onChange, freezeHeader }) => {
7
16
  const { state } = useContext(GlobalContext);
8
17
 
9
18
  useEffect(() => {}, [state.theme.colors]);
@@ -60,6 +69,7 @@ const TableComponent = ({ columns, dataSource, loading, fixed, scroll, summary,
60
69
  <Table
61
70
  title={title ? () => title : undefined}
62
71
  scroll={scroll}
72
+ className={freezeHeader ? 'frozen-header-table' : undefined}
63
73
  dataSource={dataSource}
64
74
  loading={loading}
65
75
  bordered
@@ -86,10 +96,12 @@ TableComponent.propTypes = {
86
96
  columns: PropTypes.array.isRequired,
87
97
  data: PropTypes.array.isRequired,
88
98
  loading: PropTypes.bool,
99
+ freezeHeader: PropTypes.bool,
89
100
  };
90
101
 
91
102
  TableComponent.defaultProps = {
92
103
  loading: false,
104
+ freezeHeader: false,
93
105
  };
94
106
 
95
107
  export default TableComponent;
@@ -0,0 +1,37 @@
1
+ // Table with a frozen header (freezeHeader).
2
+ //
3
+ // Not the antd `scroll.y` : there the header is a separate table and takes its column
4
+ // widths from the body, so a header wider than its cells wraps or overlaps. Here the
5
+ // header stays in the same table as the rows, the content scrolls in both directions
6
+ // and the header cells stick to the top of it.
7
+ .frozen-header-table {
8
+ .ant-table-content {
9
+ // rows scroll under the header, the pagination below the table stays in view
10
+ max-height: max(400px, calc(100vh - 220px));
11
+ overflow: auto !important;
12
+ }
13
+
14
+ // antd draws the top line of a bordered table on the `table` element, which scrolls
15
+ // away with the rows. Draw it on the content wrapper instead, that one stays in place.
16
+ &.ant-table-wrapper .ant-table.ant-table-bordered>.ant-table-container>.ant-table-content {
17
+ border-top: 1px solid #f0f0f0;
18
+
19
+ >table {
20
+ border-top: 0;
21
+ }
22
+ }
23
+
24
+ .ant-table-thead>tr>th {
25
+ position: sticky;
26
+ top: 0;
27
+ z-index: 3;
28
+ }
29
+
30
+ // Header cells of the fixed columns stick to the top and to the side,
31
+ // above the header cells that scroll
32
+ .ant-table-thead>tr>th.ant-table-cell-fix-left,
33
+ .ant-table-thead>tr>th.ant-table-cell-fix-right {
34
+ top: 0;
35
+ z-index: 4;
36
+ }
37
+ }
@@ -292,7 +292,8 @@ function BranchAwareActionLink({ label, redirectLink, hasTargetBranchAccess }) {
292
292
  onClick={(e) => {
293
293
  e.preventDefault();
294
294
  Modal.confirm({
295
- content: 'This data is another branch. Switch to that branch to view details?',
295
+ title: 'Switch Branch?', // Adding a title makes it look more standard
296
+ content: 'This record belongs to another branch. Would you like to switch branches to view the details?',
296
297
  onOk: () => {
297
298
  if (hasTargetBranchAccess) {
298
299
  Location.navigate({ url: redirectLink });
@@ -300,8 +301,8 @@ function BranchAwareActionLink({ label, redirectLink, hasTargetBranchAccess }) {
300
301
  }
301
302
 
302
303
  Modal.error({
303
- title: 'Branch Switch Failed',
304
- content: "You don't have permission to view this branch's details.",
304
+ title: 'Access Denied',
305
+ content: 'This data belongs to another branch.Would you like to switch branches to view the details?',
305
306
  });
306
307
  },
307
308
  });
@@ -342,7 +342,7 @@
342
342
  -webkit-tap-highlight-color: transparent;
343
343
  }
344
344
 
345
- /* Thin connecting line drawn into the gap toward the next step.
345
+ /* Thin connecting "strip" drawn into the gap toward the next step.
346
346
  Width matches the strip's gap so it spans the space without overhanging. */
347
347
  .steps-breadcrumb-item:not(:last-child)::after {
348
348
  content: '';
@@ -350,9 +350,9 @@
350
350
  left: 100%;
351
351
  top: 50%;
352
352
  width: clamp(28px, 3vw, 52px);
353
- height: 1px;
354
- background: rgba(37, 58, 91, 0.2);
355
- transform: translateY(-50%);
353
+ height: 0px;
354
+ border-top: 0.5px solid #ffffff66;
355
+ transform: translateY(-50%) rotate(0deg);
356
356
  }
357
357
 
358
358
  .steps-breadcrumb-item:active {
@@ -414,11 +414,11 @@
414
414
  color: #253a5b;
415
415
  }
416
416
 
417
- /* COMPLETED — filled navy circle. */
417
+ /* COMPLETED — translucent white circle (was solid navy/blue). */
418
418
  .steps-breadcrumb-item.completed .steps-breadcrumb-index {
419
- background: #253a5b;
420
- border-color: #253a5b;
421
- color: #ffffff;
419
+ background: #ffffff80;
420
+ border-color: #ffffff80;
421
+ color: #253a5b;
422
422
  }
423
423
 
424
424
  /* Primary CTA (Start / Verify Profile / Next / Finish) — dark navy pill. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ui-soxo-bootstrap-core",
3
- "version": "2.6.62-dev.1",
3
+ "version": "2.6.62-dev.3",
4
4
  "description": "All the Core Components for you to start",
5
5
  "keywords": [
6
6
  "all in one"