ui-soxo-bootstrap-core 2.6.62-dev.4 → 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/index.js +13 -0
- 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/core/lib/elements/basic/table/column-filter/column-filter.js +299 -0
- package/core/lib/elements/basic/table/column-filter/column-filter.scss +48 -0
- package/core/lib/elements/basic/table/column-filter/column-filter.test.js +148 -0
- package/core/lib/elements/basic/table/readme.md +50 -1
- package/core/lib/elements/basic/table/table.js +59 -4
- package/core/lib/elements/basic/table/table.test.js +80 -0
- 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,335 +1,78 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*/
|
|
4
|
-
import { createDecipheriv, createHmac, randomBytes, webcrypto } from 'crypto';
|
|
1
|
+
import ApiUtils from '../../utils/api/api.utils';
|
|
2
|
+
import { GENERATE_TOKEN_URL, MODES, getSupportUrl, openInNewTab, resolveSupportConfig } from './support-desk.utils';
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
global.window = { crypto: webcrypto };
|
|
4
|
+
jest.mock('../../utils/api/api.utils', () => ({ __esModule: true, default: { post: jest.fn() } }));
|
|
8
5
|
|
|
9
|
-
|
|
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
|
-
}
|
|
6
|
+
const config = { enabled: true };
|
|
55
7
|
|
|
56
8
|
describe('resolveSupportConfig', () => {
|
|
57
|
-
it('is enabled when
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
expect(
|
|
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('');
|
|
9
|
+
it('is enabled only when showSupportBtn is true', () => {
|
|
10
|
+
expect(resolveSupportConfig({ showSupportBtn: true }).enabled).toBe(true);
|
|
11
|
+
expect(resolveSupportConfig({ showSupportBtn: false }).enabled).toBe(false);
|
|
12
|
+
expect(resolveSupportConfig(undefined).enabled).toBe(false);
|
|
94
13
|
});
|
|
95
14
|
|
|
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
15
|
});
|
|
155
16
|
|
|
156
|
-
describe('
|
|
157
|
-
|
|
17
|
+
describe('getSupportUrl', () => {
|
|
18
|
+
beforeEach(() => ApiUtils.post.mockReset());
|
|
158
19
|
|
|
159
|
-
it('
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
expect(
|
|
163
|
-
|
|
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'],
|
|
20
|
+
it('posts only the mode and returns the url', async () => {
|
|
21
|
+
ApiUtils.post.mockResolvedValue({ success: true, data: { secureUrl: 'https://h.app/raise-ticket/new?d=x' } });
|
|
22
|
+
await expect(getSupportUrl(config, MODES.RAISE)).resolves.toBe('https://h.app/raise-ticket/new?d=x');
|
|
23
|
+
expect(ApiUtils.post).toHaveBeenCalledWith({
|
|
24
|
+
url: GENERATE_TOKEN_URL,
|
|
25
|
+
formBody: { mode: 'helpdesk' },
|
|
26
|
+
hideError: true,
|
|
221
27
|
});
|
|
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
28
|
});
|
|
250
29
|
|
|
251
|
-
it('
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
30
|
+
it('uses the tickets mode for My Tickets', async () => {
|
|
31
|
+
ApiUtils.post.mockResolvedValue({ data: { secureUrl: 'https://h.app/my-tickets?d=x' } });
|
|
32
|
+
await getSupportUrl(config, MODES.MINE);
|
|
33
|
+
expect(ApiUtils.post.mock.calls[0][0].formBody.mode).toBe('tickets');
|
|
255
34
|
});
|
|
256
35
|
|
|
257
|
-
it('
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
await
|
|
36
|
+
it('asks for a new url on every call', async () => {
|
|
37
|
+
ApiUtils.post.mockResolvedValue({ data: { secureUrl: 'https://h.app/x?d=1' } });
|
|
38
|
+
await getSupportUrl(config, MODES.RAISE);
|
|
39
|
+
await getSupportUrl(config, MODES.RAISE);
|
|
40
|
+
expect(ApiUtils.post).toHaveBeenCalledTimes(2);
|
|
261
41
|
});
|
|
262
42
|
|
|
263
|
-
it('
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
43
|
+
it('rejects a missing or non-https url', async () => {
|
|
44
|
+
ApiUtils.post.mockResolvedValue({ data: {} });
|
|
45
|
+
await expect(getSupportUrl(config, MODES.RAISE)).rejects.toThrow();
|
|
46
|
+
ApiUtils.post.mockResolvedValue({ data: { secureUrl: 'http://h.app/x' } });
|
|
47
|
+
await expect(getSupportUrl(config, MODES.RAISE)).rejects.toThrow();
|
|
267
48
|
});
|
|
268
49
|
|
|
269
|
-
it('
|
|
270
|
-
|
|
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' });
|
|
50
|
+
it('shows the server message when the request fails', async () => {
|
|
51
|
+
ApiUtils.post.mockRejectedValue({ message: 'Branch not found' });
|
|
52
|
+
await expect(getSupportUrl(config, MODES.RAISE)).rejects.toThrow('Branch not found');
|
|
301
53
|
});
|
|
54
|
+
});
|
|
302
55
|
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
);
|
|
56
|
+
describe('openInNewTab', () => {
|
|
57
|
+
afterEach(() => jest.restoreAllMocks());
|
|
315
58
|
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
59
|
+
it('opens the tab first, then points it at the link', async () => {
|
|
60
|
+
const popup = { close: jest.fn(), location: {} };
|
|
61
|
+
jest.spyOn(window, 'open').mockReturnValue(popup);
|
|
62
|
+
await openInNewTab(async () => 'https://h.app/x');
|
|
63
|
+
expect(popup.location.href).toBe('https://h.app/x');
|
|
64
|
+
expect(popup.opener).toBeNull();
|
|
321
65
|
});
|
|
322
|
-
});
|
|
323
66
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
expect(
|
|
67
|
+
it('closes the tab when the link fails', async () => {
|
|
68
|
+
const popup = { close: jest.fn(), location: {} };
|
|
69
|
+
jest.spyOn(window, 'open').mockReturnValue(popup);
|
|
70
|
+
await expect(openInNewTab(async () => { throw new Error('x'); })).rejects.toThrow('x');
|
|
71
|
+
expect(popup.close).toHaveBeenCalled();
|
|
328
72
|
});
|
|
329
73
|
|
|
330
|
-
it('
|
|
331
|
-
|
|
332
|
-
expect(
|
|
333
|
-
expect(formatDate('not a date')).toBe('not a date');
|
|
74
|
+
it('reports a blocked popup', async () => {
|
|
75
|
+
jest.spyOn(window, 'open').mockReturnValue(null);
|
|
76
|
+
await expect(openInNewTab(async () => 'https://h.app/x')).rejects.toThrow('blocked');
|
|
334
77
|
});
|
|
335
78
|
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Excel style column filter for tables
|
|
3
|
+
*
|
|
4
|
+
* Each filterable column gets a dropdown listing the distinct values of that column,
|
|
5
|
+
* where one or more values can be picked (with Select All / Clear All). Filters of
|
|
6
|
+
* multiple columns can be active at the same time, a record is shown only when it
|
|
7
|
+
* matches the filter of every column.
|
|
8
|
+
*
|
|
9
|
+
* The values are the text shown in the cell, read from the column render (or the
|
|
10
|
+
* exportDefinition when the cell is a component, ex: status dropdowns), else the
|
|
11
|
+
* dataIndex value of the record.
|
|
12
|
+
*
|
|
13
|
+
* A column is left out with `filterable: false`.
|
|
14
|
+
*/
|
|
15
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
import { Checkbox, Input, Empty } from 'antd';
|
|
18
|
+
|
|
19
|
+
import { FilterFilled } from '@ant-design/icons';
|
|
20
|
+
|
|
21
|
+
import { useTranslation } from 'react-i18next';
|
|
22
|
+
|
|
23
|
+
import Button from '../../button/button';
|
|
24
|
+
|
|
25
|
+
import './column-filter.scss';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Text of a rendered cell, ex: <Tag>Male</Tag> gives Male
|
|
29
|
+
*
|
|
30
|
+
* @param {*} node
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function nodeToText(node) {
|
|
34
|
+
if (node === null || node === undefined || typeof node === 'boolean') return '';
|
|
35
|
+
|
|
36
|
+
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
|
37
|
+
|
|
38
|
+
if (Array.isArray(node)) return node.map(nodeToText).join('');
|
|
39
|
+
|
|
40
|
+
if (React.isValidElement(node)) return nodeToText(node.props.children);
|
|
41
|
+
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Value of the dataIndex in the record, dataIndex can be a path array
|
|
47
|
+
*
|
|
48
|
+
* @param {Object} record
|
|
49
|
+
* @param {string|Array} dataIndex
|
|
50
|
+
* @returns {*}
|
|
51
|
+
*/
|
|
52
|
+
function getDataIndexValue(record, dataIndex) {
|
|
53
|
+
const path = Array.isArray(dataIndex) ? dataIndex : [dataIndex];
|
|
54
|
+
|
|
55
|
+
return path.reduce((value, key) => (value === null || value === undefined ? value : value[key]), record);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Key the filter of the column is stored against
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} column
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function getColumnFilterKey(column) {
|
|
65
|
+
if (column.key !== undefined && column.key !== null) return String(column.key);
|
|
66
|
+
|
|
67
|
+
if (column.dataIndex !== undefined && column.dataIndex !== null) return [].concat(column.dataIndex).join('.');
|
|
68
|
+
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Value of the column for the record, used to list and match the filter values
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} column
|
|
76
|
+
* @param {Object} record
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function getColumnFilterValue(column, record) {
|
|
80
|
+
let text = '';
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const value = column.dataIndex !== undefined ? getDataIndexValue(record, column.dataIndex) : record;
|
|
84
|
+
|
|
85
|
+
text = column.render ? nodeToText(column.render(value, record, 0)) : nodeToText(value);
|
|
86
|
+
|
|
87
|
+
// Cells rendered as a component (ex: status change) have no text, use the export value
|
|
88
|
+
if (!text && column.exportDefinition) text = nodeToText(column.exportDefinition(record));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
text = '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return String(text).replace(/\s+/g, ' ').trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether the column can be filtered
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} column
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
export function isFilterableColumn(column) {
|
|
103
|
+
if (column.filterable === false) return false;
|
|
104
|
+
|
|
105
|
+
return !!getColumnFilterKey(column) && !!(column.render || column.exportDefinition || column.dataIndex !== undefined);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Records matching the filter of every column
|
|
110
|
+
*
|
|
111
|
+
* @param {Array} records
|
|
112
|
+
* @param {Array} columns
|
|
113
|
+
* @param {Object} filters - Selected values against the column key, ex: { gender: ['Male'] }
|
|
114
|
+
* @param {string} exceptKey - Key of the column to leave out (to list the values of that column)
|
|
115
|
+
* @returns {Array}
|
|
116
|
+
*/
|
|
117
|
+
export function applyColumnFilters(records = [], columns = [], filters = {}, exceptKey) {
|
|
118
|
+
const active = columns.filter((column) => {
|
|
119
|
+
const key = getColumnFilterKey(column);
|
|
120
|
+
|
|
121
|
+
return key !== exceptKey && isFilterableColumn(column) && Array.isArray(filters[key]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!active.length) return records;
|
|
125
|
+
|
|
126
|
+
return records.filter((record) => active.every((column) => filters[getColumnFilterKey(column)].includes(getColumnFilterValue(column, record))));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether any column filter is applied
|
|
131
|
+
*
|
|
132
|
+
* @param {Object} filters
|
|
133
|
+
* @returns {boolean}
|
|
134
|
+
*/
|
|
135
|
+
export function hasColumnFilters(filters = {}) {
|
|
136
|
+
return Object.values(filters || {}).some((values) => Array.isArray(values));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Distinct values of the column in the records, sorted
|
|
141
|
+
*
|
|
142
|
+
* @param {Object} column
|
|
143
|
+
* @param {Array} records
|
|
144
|
+
* @returns {Array<string>}
|
|
145
|
+
*/
|
|
146
|
+
export function getColumnValues(column, records = []) {
|
|
147
|
+
const values = new Set(records.map((record) => getColumnFilterValue(column, record)));
|
|
148
|
+
|
|
149
|
+
return [...values].sort((a, b) => {
|
|
150
|
+
// Blanks are listed at the end
|
|
151
|
+
if (!a) return 1;
|
|
152
|
+
if (!b) return -1;
|
|
153
|
+
|
|
154
|
+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Dropdown with the values of the column
|
|
160
|
+
*
|
|
161
|
+
* @param {Object} column - Column being filtered
|
|
162
|
+
* @param {Array} records - Records to list the values from
|
|
163
|
+
* @param {Array} applied - Values applied now, undefined when the column is not filtered
|
|
164
|
+
* @param {Function} onApply - Called with the selected values, undefined to remove the filter
|
|
165
|
+
* @param {Function} close - Closes the dropdown
|
|
166
|
+
* @param {boolean} visible - Whether the dropdown is open
|
|
167
|
+
*/
|
|
168
|
+
export function ColumnFilterDropdown({ column, records, applied, onApply, close, visible }) {
|
|
169
|
+
const { t } = useTranslation();
|
|
170
|
+
|
|
171
|
+
const [search, setSearch] = useState('');
|
|
172
|
+
|
|
173
|
+
const [selected, setSelected] = useState([]);
|
|
174
|
+
|
|
175
|
+
const options = useMemo(() => (visible ? getColumnValues(column, records) : []), [visible, column, records]);
|
|
176
|
+
|
|
177
|
+
// Every time the dropdown opens start from the applied filter, all values are selected when there is no filter
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (visible) {
|
|
180
|
+
setSearch('');
|
|
181
|
+
setSelected(applied ? applied : options);
|
|
182
|
+
}
|
|
183
|
+
}, [visible]);
|
|
184
|
+
|
|
185
|
+
const visibleOptions = options.filter((value) => (value || t('(Blanks)')).toLowerCase().includes(search.trim().toLowerCase()));
|
|
186
|
+
|
|
187
|
+
const selectedVisible = visibleOptions.filter((value) => selected.includes(value));
|
|
188
|
+
|
|
189
|
+
const allSelected = visibleOptions.length > 0 && selectedVisible.length === visibleOptions.length;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Select / unselect all the listed values
|
|
193
|
+
*/
|
|
194
|
+
function onSelectAll(event) {
|
|
195
|
+
if (event.target.checked) {
|
|
196
|
+
setSelected([...new Set([...selected, ...visibleOptions])]);
|
|
197
|
+
} else {
|
|
198
|
+
setSelected(selected.filter((value) => !visibleOptions.includes(value)));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function onToggle(value, checked) {
|
|
203
|
+
setSelected(checked ? [...selected, value] : selected.filter((ele) => ele !== value));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Apply the selected values, selecting every value removes the filter of the column
|
|
208
|
+
*/
|
|
209
|
+
function onOk() {
|
|
210
|
+
const isEveryValue = options.every((value) => selected.includes(value));
|
|
211
|
+
|
|
212
|
+
onApply(isEveryValue ? undefined : options.filter((value) => selected.includes(value)));
|
|
213
|
+
|
|
214
|
+
// close() instead of confirm(), confirm makes antd reset the pagination to the first page
|
|
215
|
+
if (close) close();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div className="column-filter-dropdown" onKeyDown={(event) => event.stopPropagation()}>
|
|
220
|
+
<Input
|
|
221
|
+
size="small"
|
|
222
|
+
allowClear
|
|
223
|
+
placeholder={t('Search')}
|
|
224
|
+
value={search}
|
|
225
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
226
|
+
className="column-filter-search"
|
|
227
|
+
/>
|
|
228
|
+
|
|
229
|
+
{visibleOptions.length ? (
|
|
230
|
+
<>
|
|
231
|
+
<Checkbox
|
|
232
|
+
className="column-filter-select-all"
|
|
233
|
+
checked={allSelected}
|
|
234
|
+
indeterminate={selectedVisible.length > 0 && !allSelected}
|
|
235
|
+
onChange={onSelectAll}
|
|
236
|
+
>
|
|
237
|
+
{t('Select All')}
|
|
238
|
+
</Checkbox>
|
|
239
|
+
|
|
240
|
+
<div className="column-filter-options">
|
|
241
|
+
{visibleOptions.map((value) => (
|
|
242
|
+
<Checkbox key={value || '__blank__'} checked={selected.includes(value)} onChange={(event) => onToggle(value, event.target.checked)}>
|
|
243
|
+
{value || t('(Blanks)')}
|
|
244
|
+
</Checkbox>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
</>
|
|
248
|
+
) : (
|
|
249
|
+
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('No values')} />
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
<div className="column-filter-actions">
|
|
253
|
+
<Button size="small" type="secondary" onClick={() => setSelected([])}>
|
|
254
|
+
{t('Clear All')}
|
|
255
|
+
</Button>
|
|
256
|
+
|
|
257
|
+
<Button size="small" type="primary" disabled={!selected.length} onClick={onOk}>
|
|
258
|
+
{t('OK')}
|
|
259
|
+
</Button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Add the Excel style filter to the columns
|
|
267
|
+
*
|
|
268
|
+
* @param {Array} columns
|
|
269
|
+
* @param {Array} records - Records the values are listed from
|
|
270
|
+
* @param {Object} filters - Applied filters against the column key
|
|
271
|
+
* @param {Function} onChange - Called with the column key and the selected values (undefined to remove)
|
|
272
|
+
* @returns {Array}
|
|
273
|
+
*/
|
|
274
|
+
export function withColumnFilters(columns = [], records = [], filters = {}, onChange) {
|
|
275
|
+
return columns.map((column) => {
|
|
276
|
+
if (!isFilterableColumn(column)) return column;
|
|
277
|
+
|
|
278
|
+
const key = getColumnFilterKey(column);
|
|
279
|
+
|
|
280
|
+
const applied = filters[key];
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
...column,
|
|
284
|
+
filteredValue: applied || null,
|
|
285
|
+
filterIcon: () => <FilterFilled className={`column-filter-icon ${applied ? 'column-filter-icon--active' : ''}`} />,
|
|
286
|
+
filterDropdown: ({ close, visible }) => (
|
|
287
|
+
<ColumnFilterDropdown
|
|
288
|
+
column={column}
|
|
289
|
+
// Values are listed from the records matching the filters of the other columns
|
|
290
|
+
records={applyColumnFilters(records, columns, filters, key)}
|
|
291
|
+
applied={applied}
|
|
292
|
+
visible={visible}
|
|
293
|
+
close={close}
|
|
294
|
+
onApply={(values) => onChange(key, values)}
|
|
295
|
+
/>
|
|
296
|
+
),
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
}
|