@cccsaurora/howler-ui 2.19.0-dev.1264 → 2.19.0-dev.1276
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/components/elements/display/HandlebarsMarkdown.js +36 -2
- package/components/elements/display/handlebars/helpers.d.ts +4 -0
- package/components/elements/display/handlebars/helpers.js +132 -33
- package/components/routes/cases/CaseViewer.test.js +26 -3
- package/components/routes/cases/hooks/useCase.js +4 -3
- package/components/routes/cases/hooks/useCase.test.js +33 -4
- package/package.json +1 -1
|
@@ -7,7 +7,17 @@ import { useTranslation } from 'react-i18next';
|
|
|
7
7
|
import Throttler from '@cccsaurora/howler-ui/utils/Throttler';
|
|
8
8
|
import { hashCode } from '@cccsaurora/howler-ui/utils/utils';
|
|
9
9
|
import Markdown, {} from '../display/Markdown';
|
|
10
|
-
import { useHelpers } from './handlebars/helpers';
|
|
10
|
+
import { HowlerHelperError, useHelpers } from './handlebars/helpers';
|
|
11
|
+
class HowlerHandlebarsRenderError extends Error {
|
|
12
|
+
helper;
|
|
13
|
+
hint;
|
|
14
|
+
constructor(message, helper, hint) {
|
|
15
|
+
super(Handlebars.escapeExpression(message));
|
|
16
|
+
this.name = 'HowlerHandlebarsRenderError';
|
|
17
|
+
this.helper = Handlebars.escapeExpression(helper);
|
|
18
|
+
this.hint = hint ? Handlebars.escapeExpression(hint) : undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
11
21
|
const THROTTLER = new Throttler(500);
|
|
12
22
|
const HandlebarsMarkdown = ({ md, object = {}, disableLinks = false }) => {
|
|
13
23
|
const { t } = useTranslation();
|
|
@@ -48,7 +58,23 @@ const HandlebarsMarkdown = ({ md, object = {}, disableLinks = false }) => {
|
|
|
48
58
|
}
|
|
49
59
|
return new Handlebars.SafeString(`\`${id}\``);
|
|
50
60
|
}
|
|
51
|
-
|
|
61
|
+
try {
|
|
62
|
+
const result = helper.callback(...args);
|
|
63
|
+
return result instanceof Promise
|
|
64
|
+
? result.catch(err => {
|
|
65
|
+
if (err instanceof HowlerHelperError) {
|
|
66
|
+
throw new HowlerHandlebarsRenderError(err.message, helper.keyword, helper.hint);
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
})
|
|
70
|
+
: result;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err instanceof HowlerHelperError) {
|
|
74
|
+
throw new HowlerHandlebarsRenderError(err.message, helper.keyword, helper.hint);
|
|
75
|
+
}
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
52
78
|
});
|
|
53
79
|
});
|
|
54
80
|
}, [handlebars, helpers, mdComponents]);
|
|
@@ -65,6 +91,14 @@ const HandlebarsMarkdown = ({ md, object = {}, disableLinks = false }) => {
|
|
|
65
91
|
setRendered(await compiled(object));
|
|
66
92
|
return;
|
|
67
93
|
}
|
|
94
|
+
if (err instanceof HowlerHandlebarsRenderError) {
|
|
95
|
+
setRendered(`
|
|
96
|
+
<h2 style="color: red">${t('markdown.error')}</h2>
|
|
97
|
+
<span style="color: red; font-weight: bold; font-family: monospace;">Invalid Usage [${err.helper}]: ${err.message}</span>
|
|
98
|
+
${err.hint ? `<br/><span style="color: gray; font-family: monospace;">${err.hint}</span>` : ''}
|
|
99
|
+
`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
68
102
|
// eslint-disable-next-line no-console
|
|
69
103
|
console.error(err);
|
|
70
104
|
setRendered(`
|
|
@@ -7,9 +7,13 @@ export interface HowlerHelper {
|
|
|
7
7
|
fr: string;
|
|
8
8
|
};
|
|
9
9
|
async?: boolean;
|
|
10
|
+
hint?: string;
|
|
10
11
|
callback?: Handlebars.HelperDelegate;
|
|
11
12
|
componentCallback?: (...args: any[]) => ReactElement | Promise<ReactElement>;
|
|
12
13
|
}
|
|
14
|
+
export declare class HowlerHelperError extends Error {
|
|
15
|
+
constructor(message: string);
|
|
16
|
+
}
|
|
13
17
|
export declare const useHelpers: (opts?: {
|
|
14
18
|
async: boolean;
|
|
15
19
|
components: boolean;
|
|
@@ -6,12 +6,18 @@ import HitCard from '@cccsaurora/howler-ui/components/elements/hit/HitCard';
|
|
|
6
6
|
import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
|
|
7
7
|
import { flatten } from 'flat';
|
|
8
8
|
import Handlebars from 'handlebars';
|
|
9
|
-
import { capitalize, get, groupBy, isObject } from 'lodash-es';
|
|
9
|
+
import { capitalize, get, groupBy, isNil, isObject } from 'lodash-es';
|
|
10
10
|
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
11
11
|
import { useMemo } from 'react';
|
|
12
12
|
import { usePluginStore } from 'react-pluggable';
|
|
13
13
|
import ActionButton from '../ActionButton';
|
|
14
14
|
import JSONViewer from '../json/JSONViewer';
|
|
15
|
+
export class HowlerHelperError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'HowlerHelperError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
15
21
|
const FETCH_RESULTS = {};
|
|
16
22
|
export const useHelpers = (opts = { async: true, components: true }) => {
|
|
17
23
|
const pluginStore = usePluginStore();
|
|
@@ -22,7 +28,15 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
22
28
|
en: 'Checks the equality of the string representation of the two arguments.',
|
|
23
29
|
fr: "Vérifie l'égalité de la représentation en chaîne de caractères des deux arguments."
|
|
24
30
|
},
|
|
25
|
-
callback: (
|
|
31
|
+
callback: (...args) => {
|
|
32
|
+
args.pop(); // remove options
|
|
33
|
+
const [arg1, arg2] = args;
|
|
34
|
+
if (isNil(arg1) || isNil(arg2)) {
|
|
35
|
+
throw new HowlerHelperError('Both arguments must be provided.');
|
|
36
|
+
}
|
|
37
|
+
return arg1.toString() === arg2.toString();
|
|
38
|
+
},
|
|
39
|
+
hint: 'Usage: {{equals arg1 arg2}}'
|
|
26
40
|
},
|
|
27
41
|
{
|
|
28
42
|
keyword: 'and',
|
|
@@ -30,7 +44,12 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
30
44
|
en: 'Runs the comparison `arg1 && arg2`, and returns the result.',
|
|
31
45
|
fr: 'Exécute la comparaison `arg1 && arg2`, et retourne le résultat.'
|
|
32
46
|
},
|
|
33
|
-
callback: (
|
|
47
|
+
callback: (...args) => {
|
|
48
|
+
args.pop(); // remove options
|
|
49
|
+
const [arg1, arg2] = args;
|
|
50
|
+
return arg1 && arg2;
|
|
51
|
+
},
|
|
52
|
+
hint: 'Usage: {{and arg1 arg2}}'
|
|
34
53
|
},
|
|
35
54
|
{
|
|
36
55
|
keyword: 'or',
|
|
@@ -38,7 +57,12 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
38
57
|
en: 'Runs the comparison `arg1 || arg2`, and returns the result.',
|
|
39
58
|
fr: 'Exécute la comparaison `arg1 || arg2`, et retourne le résultat.'
|
|
40
59
|
},
|
|
41
|
-
callback: (
|
|
60
|
+
callback: (...args) => {
|
|
61
|
+
args.pop(); // remove options
|
|
62
|
+
const [arg1, arg2] = args;
|
|
63
|
+
return arg1 || arg2;
|
|
64
|
+
},
|
|
65
|
+
hint: 'Usage: {{or arg1 arg2}}'
|
|
42
66
|
},
|
|
43
67
|
{
|
|
44
68
|
keyword: 'not',
|
|
@@ -46,7 +70,12 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
46
70
|
en: 'Runs the comparison `!arg`, and returns the result.',
|
|
47
71
|
fr: 'Exécute la comparaison `!arg`, et retourne le résultat.'
|
|
48
72
|
},
|
|
49
|
-
callback:
|
|
73
|
+
callback: (...args) => {
|
|
74
|
+
args.pop(); // remove options
|
|
75
|
+
const arg = args[0];
|
|
76
|
+
return !arg;
|
|
77
|
+
},
|
|
78
|
+
hint: 'Usage: {{not arg}}'
|
|
50
79
|
},
|
|
51
80
|
{
|
|
52
81
|
keyword: 'curly',
|
|
@@ -54,7 +83,12 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
54
83
|
en: 'Wraps the given argument in curly braces.',
|
|
55
84
|
fr: "Entoure l'argument donné d'accolades."
|
|
56
85
|
},
|
|
57
|
-
callback:
|
|
86
|
+
callback: (...args) => {
|
|
87
|
+
args.pop(); // remove options
|
|
88
|
+
const arg1 = args[0];
|
|
89
|
+
return new Handlebars.SafeString(`{{${arg1}}}`);
|
|
90
|
+
},
|
|
91
|
+
hint: 'Usage: {{curly arg}}'
|
|
58
92
|
},
|
|
59
93
|
{
|
|
60
94
|
keyword: 'join',
|
|
@@ -62,7 +96,12 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
62
96
|
en: 'Joins two string arguments with a given string `sep`, or the empty string as a default.',
|
|
63
97
|
fr: 'Joint deux arguments de chaîne avec une chaîne donnée `sep`, ou la chaîne vide par défaut.'
|
|
64
98
|
},
|
|
65
|
-
callback: (
|
|
99
|
+
callback: (...args) => {
|
|
100
|
+
const context = args.pop();
|
|
101
|
+
const [arg1, arg2] = args;
|
|
102
|
+
return [arg1?.toString() ?? '', arg2?.toString() ?? ''].join(context.hash?.sep ?? '');
|
|
103
|
+
},
|
|
104
|
+
hint: 'Usage: {{join arg1 arg2 sep=string}}'
|
|
66
105
|
},
|
|
67
106
|
{
|
|
68
107
|
keyword: 'upper',
|
|
@@ -70,7 +109,15 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
70
109
|
en: 'Returns the uppercase representation of a string argument.',
|
|
71
110
|
fr: "Retourne la représentation en majuscules d'un argument de chaîne."
|
|
72
111
|
},
|
|
73
|
-
callback: (
|
|
112
|
+
callback: (...args) => {
|
|
113
|
+
args.pop(); // remove options
|
|
114
|
+
const val = args[0];
|
|
115
|
+
if (isNil(val)) {
|
|
116
|
+
throw new HowlerHelperError('Upper expects a string argument');
|
|
117
|
+
}
|
|
118
|
+
return val.toString().toLocaleUpperCase();
|
|
119
|
+
},
|
|
120
|
+
hint: 'Usage: {{upper val}}'
|
|
74
121
|
},
|
|
75
122
|
{
|
|
76
123
|
keyword: 'lower',
|
|
@@ -78,7 +125,15 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
78
125
|
en: 'Returns the lowercase representation of a string argument.',
|
|
79
126
|
fr: "Retourne la représentation en minuscules d'un argument de chaîne."
|
|
80
127
|
},
|
|
81
|
-
callback: (
|
|
128
|
+
callback: (...args) => {
|
|
129
|
+
args.pop(); // remove options
|
|
130
|
+
const val = args[0];
|
|
131
|
+
if (isNil(val)) {
|
|
132
|
+
throw new HowlerHelperError('Lower expects a string argument');
|
|
133
|
+
}
|
|
134
|
+
return val.toString().toLocaleLowerCase();
|
|
135
|
+
},
|
|
136
|
+
hint: 'Usage: {{lower val}}'
|
|
82
137
|
},
|
|
83
138
|
{
|
|
84
139
|
keyword: 'fetch',
|
|
@@ -87,7 +142,9 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
87
142
|
fr: "Récupère l'URL fournie et retourne la clé donnée (aplatie) de l'objet JSON retourné. Notez que le résultat doit être du JSON !"
|
|
88
143
|
},
|
|
89
144
|
async: true,
|
|
90
|
-
callback: async (
|
|
145
|
+
callback: async (...args) => {
|
|
146
|
+
args.pop(); // remove options
|
|
147
|
+
const [url, key] = args;
|
|
91
148
|
try {
|
|
92
149
|
if (!FETCH_RESULTS[url]) {
|
|
93
150
|
FETCH_RESULTS[url] = fetch(url).then(res => res.json());
|
|
@@ -98,7 +155,8 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
98
155
|
catch (e) {
|
|
99
156
|
return '';
|
|
100
157
|
}
|
|
101
|
-
}
|
|
158
|
+
},
|
|
159
|
+
hint: 'Usage: {{fetch url key}}'
|
|
102
160
|
},
|
|
103
161
|
{
|
|
104
162
|
keyword: 'howler',
|
|
@@ -106,12 +164,15 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
106
164
|
en: 'Given a howler hit ID, this helper renders a hit card for that ID.',
|
|
107
165
|
fr: 'Étant donné un ID de résultat howler, cet assistant affiche une carte de résultat pour cet ID.'
|
|
108
166
|
},
|
|
109
|
-
componentCallback:
|
|
167
|
+
componentCallback: (...args) => {
|
|
168
|
+
args.pop(); // remove options
|
|
169
|
+
const id = args[0];
|
|
110
170
|
if (!id) {
|
|
111
171
|
return _jsx(AppListEmpty, {});
|
|
112
172
|
}
|
|
113
173
|
return _jsx(HitCard, { id: id, layout: HitLayout.NORMAL });
|
|
114
|
-
}
|
|
174
|
+
},
|
|
175
|
+
hint: 'Usage: {{howler hitId}}'
|
|
115
176
|
},
|
|
116
177
|
{
|
|
117
178
|
keyword: 'entries',
|
|
@@ -119,12 +180,15 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
119
180
|
en: 'Given a dict, return an array of {key, value} objects.',
|
|
120
181
|
fr: "Étant donné un dictionnaire, retourne un tableau d'objets {key, value}."
|
|
121
182
|
},
|
|
122
|
-
callback:
|
|
183
|
+
callback: (...args) => {
|
|
184
|
+
args.pop(); // remove options
|
|
185
|
+
const obj = args[0];
|
|
123
186
|
if (!isObject(obj)) {
|
|
124
187
|
return new Handlebars.SafeString('Invalid Object.');
|
|
125
188
|
}
|
|
126
189
|
return Object.entries(obj).map(([key, value]) => ({ key, value }));
|
|
127
|
-
}
|
|
190
|
+
},
|
|
191
|
+
hint: 'Usage: {{entries obj}}'
|
|
128
192
|
},
|
|
129
193
|
{
|
|
130
194
|
keyword: 'render_json',
|
|
@@ -132,12 +196,15 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
132
196
|
en: 'Given JSON data, this helper renders a JSON viewer component.',
|
|
133
197
|
fr: 'Étant donné des données JSON, cet assistant affiche un composant de visualisation JSON.'
|
|
134
198
|
},
|
|
135
|
-
componentCallback:
|
|
199
|
+
componentCallback: (...args) => {
|
|
200
|
+
args.pop(); // remove options
|
|
201
|
+
const data = args[0];
|
|
136
202
|
if (!data) {
|
|
137
203
|
return _jsx(AppListEmpty, {});
|
|
138
204
|
}
|
|
139
205
|
return _jsx(JSONViewer, { data: data });
|
|
140
|
-
}
|
|
206
|
+
},
|
|
207
|
+
hint: 'Usage: {{render_json obj}}'
|
|
141
208
|
},
|
|
142
209
|
{
|
|
143
210
|
keyword: 'to_json',
|
|
@@ -145,9 +212,12 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
145
212
|
en: 'Convert any object into a JSON string.',
|
|
146
213
|
fr: "Convertit n'importe quel objet en chaîne JSON."
|
|
147
214
|
},
|
|
148
|
-
callback:
|
|
215
|
+
callback: (...args) => {
|
|
216
|
+
args.pop(); // remove options
|
|
217
|
+
const obj = args[0];
|
|
149
218
|
return new Handlebars.SafeString(JSON.stringify(obj));
|
|
150
|
-
}
|
|
219
|
+
},
|
|
220
|
+
hint: 'Usage: {{to_json obj}}'
|
|
151
221
|
},
|
|
152
222
|
{
|
|
153
223
|
keyword: 'parse_json',
|
|
@@ -155,9 +225,20 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
155
225
|
en: 'Convert a JSON string into an object.',
|
|
156
226
|
fr: 'Convertit une chaîne JSON en objet.'
|
|
157
227
|
},
|
|
158
|
-
callback:
|
|
159
|
-
|
|
160
|
-
|
|
228
|
+
callback: (...args) => {
|
|
229
|
+
args.pop(); // remove options
|
|
230
|
+
const str = args[0];
|
|
231
|
+
if (isNil(str)) {
|
|
232
|
+
throw new HowlerHelperError('Parse JSON expects a string argument');
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
return JSON.parse(str);
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
throw new HowlerHelperError('Invalid JSON string');
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
hint: 'Usage: {{parse_json str}}'
|
|
161
242
|
},
|
|
162
243
|
{
|
|
163
244
|
keyword: 'get',
|
|
@@ -165,14 +246,17 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
165
246
|
en: 'Returns the given (flattened) key from the provided object.',
|
|
166
247
|
fr: "Retourne la clé donnée (aplatie) de l'objet fourni."
|
|
167
248
|
},
|
|
168
|
-
callback: (
|
|
249
|
+
callback: (...args) => {
|
|
250
|
+
args.pop(); // remove options
|
|
251
|
+
const [data, key] = args;
|
|
169
252
|
try {
|
|
170
253
|
return get(data, key);
|
|
171
254
|
}
|
|
172
255
|
catch (e) {
|
|
173
256
|
return '';
|
|
174
257
|
}
|
|
175
|
-
}
|
|
258
|
+
},
|
|
259
|
+
hint: 'Usage: {{get obj key}}'
|
|
176
260
|
},
|
|
177
261
|
{
|
|
178
262
|
keyword: 'includes',
|
|
@@ -180,9 +264,12 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
180
264
|
en: 'Checks if field is in string',
|
|
181
265
|
fr: 'Vérifie si le champ est dans la chaîne'
|
|
182
266
|
},
|
|
183
|
-
callback: (
|
|
267
|
+
callback: (...args) => {
|
|
268
|
+
args.pop(); // remove options
|
|
269
|
+
const [arg1, arg2] = args;
|
|
184
270
|
return !!arg2 && !!arg1?.includes(arg2);
|
|
185
|
-
}
|
|
271
|
+
},
|
|
272
|
+
hint: 'Usage: {{includes str substr}}'
|
|
186
273
|
},
|
|
187
274
|
{
|
|
188
275
|
keyword: 'table',
|
|
@@ -190,7 +277,9 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
190
277
|
en: 'Render a table in markdown given an array of cells',
|
|
191
278
|
fr: "Affiche un tableau en markdown à partir d'un tableau de cellules"
|
|
192
279
|
},
|
|
193
|
-
componentCallback: (
|
|
280
|
+
componentCallback: (...args) => {
|
|
281
|
+
args.pop(); // remove options
|
|
282
|
+
const cells = args[0];
|
|
194
283
|
const columns = Object.keys(groupBy(cells, 'column'));
|
|
195
284
|
const rows = groupBy(cells, 'row');
|
|
196
285
|
return (_jsx(Paper, { sx: { width: '95%', overflowX: 'auto', m: 1 }, children: _jsxs(Table, { children: [_jsx(TableHead, { children: _jsx(TableRow, { children: columns.map(col => (_jsx(TableCell, { sx: { maxWidth: '150px' }, children: col
|
|
@@ -202,7 +291,8 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
202
291
|
return _jsx(TableCell, { children: cell?.value ?? 'N/A' }, col + cell?.value);
|
|
203
292
|
}) }, rowId));
|
|
204
293
|
}) })] }) }));
|
|
205
|
-
}
|
|
294
|
+
},
|
|
295
|
+
hint: 'Usage: {{table cells}}'
|
|
206
296
|
},
|
|
207
297
|
{
|
|
208
298
|
keyword: 'action',
|
|
@@ -210,9 +300,12 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
210
300
|
en: 'Execute a howler action given a specific action ID (from the URL when viewing the action, i.e. yaIKVqiKhWpyCsWdqsE4D)',
|
|
211
301
|
fr: "Exécute une action howler à partir d'un ID d'action spécifique (de l'URL lors de la visualisation de l'action, par ex. yaIKVqiKhWpyCsWdqsE4D)"
|
|
212
302
|
},
|
|
213
|
-
componentCallback: (
|
|
303
|
+
componentCallback: (...args) => {
|
|
304
|
+
const context = args.pop(); // remove options
|
|
305
|
+
const [actionId, hitId] = args;
|
|
214
306
|
return _jsx(ActionButton, { actionId: actionId, hitId: hitId, ...(context.hash ?? {}) });
|
|
215
|
-
}
|
|
307
|
+
},
|
|
308
|
+
hint: 'Usage: {{action actionId hitId}}'
|
|
216
309
|
},
|
|
217
310
|
{
|
|
218
311
|
keyword: 'replace',
|
|
@@ -220,9 +313,15 @@ export const useHelpers = (opts = { async: true, components: true }) => {
|
|
|
220
313
|
en: '',
|
|
221
314
|
fr: ''
|
|
222
315
|
},
|
|
223
|
-
callback: (
|
|
224
|
-
|
|
225
|
-
|
|
316
|
+
callback: (...args) => {
|
|
317
|
+
args.pop(); // remove options
|
|
318
|
+
const [str, searchValue, replaceValue] = args;
|
|
319
|
+
if (isNil(str) || isNil(searchValue) || isNil(replaceValue)) {
|
|
320
|
+
throw new HowlerHelperError('Replace expects three arguments');
|
|
321
|
+
}
|
|
322
|
+
return str.toString().replaceAll(searchValue ?? '', replaceValue ?? '');
|
|
323
|
+
},
|
|
324
|
+
hint: 'Usage: {{replace str searchValue replaceValue}}'
|
|
226
325
|
},
|
|
227
326
|
...howlerPluginStore.plugins.flatMap(plugin => pluginStore.executeFunction(`${plugin}.helpers`))
|
|
228
327
|
].filter((entry) => (opts.async || !entry.async) && (opts.components || !entry.componentCallback)), [opts.async, opts.components, pluginStore]);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { render, waitFor } from '@testing-library/react';
|
|
2
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
3
3
|
import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
|
|
4
4
|
import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
|
|
5
5
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
@@ -40,10 +40,10 @@ vi.mock('api', () => ({
|
|
|
40
40
|
}
|
|
41
41
|
}));
|
|
42
42
|
vi.mock('./detail/CaseDetails', () => ({
|
|
43
|
-
default: () => _jsx("div", { id: "case-details" })
|
|
43
|
+
default: ({ case: _case }) => _jsx("div", { id: "case-details", children: _case?.title })
|
|
44
44
|
}));
|
|
45
45
|
vi.mock('./detail/CaseSidebar', () => ({
|
|
46
|
-
default: () => _jsx("div", { id: "case-sidebar" })
|
|
46
|
+
default: ({ case: _case }) => _jsx("div", { id: "case-sidebar", children: _case?.title })
|
|
47
47
|
}));
|
|
48
48
|
// ---------------------------------------------------------------------------
|
|
49
49
|
// Import after mocks
|
|
@@ -130,4 +130,27 @@ describe('CaseViewer', () => {
|
|
|
130
130
|
expect(mockFetchViewers).toHaveBeenCalledWith('case-1');
|
|
131
131
|
});
|
|
132
132
|
});
|
|
133
|
+
it('propagates socket case updates to the sidebar and details', async () => {
|
|
134
|
+
const initialCase = createMockCase({ case_id: 'case-1', title: 'Original title' });
|
|
135
|
+
const updatedCase = createMockCase({ case_id: 'case-1', title: 'Updated title' });
|
|
136
|
+
mockDispatchApi.mockResolvedValue(initialCase);
|
|
137
|
+
render(_jsx(CaseViewer, {}), { wrapper: createWrapper() });
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
expect(mockAddListener).toHaveBeenCalledOnce();
|
|
140
|
+
});
|
|
141
|
+
await waitFor(() => {
|
|
142
|
+
expect(screen.getByTestId('case-sidebar')).toHaveTextContent('Original title');
|
|
143
|
+
});
|
|
144
|
+
act(() => {
|
|
145
|
+
mockAddListener.mock.calls[0][1]({
|
|
146
|
+
type: 'cases',
|
|
147
|
+
case: updatedCase,
|
|
148
|
+
error: false,
|
|
149
|
+
message: '',
|
|
150
|
+
status: 200
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
expect(screen.getByTestId('case-sidebar')).toHaveTextContent('Updated title');
|
|
154
|
+
expect(screen.getByTestId('case-details')).toHaveTextContent('Updated title');
|
|
155
|
+
});
|
|
133
156
|
});
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import api from '@cccsaurora/howler-ui/api';
|
|
2
2
|
import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
|
|
3
3
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
4
|
-
import { useCallback, useContext, useEffect, useState } from 'react';
|
|
4
|
+
import { useCallback, useContext, useEffect, useId, useState } from 'react';
|
|
5
5
|
import { isCaseUpdate } from '@cccsaurora/howler-ui/utils/socketUtils';
|
|
6
6
|
const useCase = ({ caseId, case: providedCase }) => {
|
|
7
7
|
const { dispatchApi } = useMyApi();
|
|
8
8
|
const { addListener, removeListener } = useContext(SocketContext);
|
|
9
|
+
const listenerId = useId();
|
|
9
10
|
const [loading, setLoading] = useState(false);
|
|
10
11
|
const [missing, setMissing] = useState(false);
|
|
11
12
|
const [_case, setCase] = useState(providedCase);
|
|
@@ -27,7 +28,7 @@ const useCase = ({ caseId, case: providedCase }) => {
|
|
|
27
28
|
if (!activeCaseId) {
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
30
|
-
const listenerKey = `case-update-${activeCaseId}`;
|
|
31
|
+
const listenerKey = `case-update-${activeCaseId}-${listenerId}`;
|
|
31
32
|
addListener(listenerKey, data => {
|
|
32
33
|
if (isCaseUpdate(data) && data.case.case_id === activeCaseId) {
|
|
33
34
|
setCase(data.case);
|
|
@@ -36,7 +37,7 @@ const useCase = ({ caseId, case: providedCase }) => {
|
|
|
36
37
|
return () => {
|
|
37
38
|
removeListener(listenerKey);
|
|
38
39
|
};
|
|
39
|
-
}, [activeCaseId, addListener, removeListener]);
|
|
40
|
+
}, [activeCaseId, addListener, listenerId, removeListener]);
|
|
40
41
|
const update = useCallback(async (_updatedCase, publish = true) => {
|
|
41
42
|
if (!activeCaseId) {
|
|
42
43
|
return;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
1
|
+
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { createElement } from 'react';
|
|
2
3
|
import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
|
|
3
4
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
@@ -47,6 +48,12 @@ import useCase from './useCase';
|
|
|
47
48
|
const renderUseCaseHook = (args) => {
|
|
48
49
|
return renderHook(() => useCase(args));
|
|
49
50
|
};
|
|
51
|
+
const CaseConsumers = ({ case: providedCase }) => {
|
|
52
|
+
const sidebarCase = useCase({ case: providedCase });
|
|
53
|
+
const dashboardCase = useCase({ case: providedCase });
|
|
54
|
+
const detailsCase = useCase({ case: providedCase });
|
|
55
|
+
return createElement('div', null, createElement('span', { id: 'sidebar-case-title' }, sidebarCase.case.title), createElement('span', { id: 'dashboard-case-title' }, dashboardCase.case.title), createElement('span', { id: 'details-case-title' }, detailsCase.case.title));
|
|
56
|
+
};
|
|
50
57
|
// ---------------------------------------------------------------------------
|
|
51
58
|
// Setup
|
|
52
59
|
// ---------------------------------------------------------------------------
|
|
@@ -78,10 +85,10 @@ describe('useCase', () => {
|
|
|
78
85
|
});
|
|
79
86
|
});
|
|
80
87
|
describe('socket listener', () => {
|
|
81
|
-
it('registers a listener keyed by case ID', () => {
|
|
88
|
+
it('registers a listener keyed by case ID and hook instance', () => {
|
|
82
89
|
const mockCase = createMockCase({ case_id: 'c3' });
|
|
83
90
|
renderUseCaseHook({ case: mockCase });
|
|
84
|
-
expect(mockAddListener).toHaveBeenCalledWith(
|
|
91
|
+
expect(mockAddListener).toHaveBeenCalledWith(expect.stringMatching(/^case-update-c3-/), expect.any(Function));
|
|
85
92
|
});
|
|
86
93
|
it('updates state when a matching case update is received', () => {
|
|
87
94
|
const mockCase = createMockCase({ case_id: 'c4', title: 'Original' });
|
|
@@ -135,7 +142,29 @@ describe('useCase', () => {
|
|
|
135
142
|
const mockCase = createMockCase({ case_id: 'c7' });
|
|
136
143
|
const { unmount } = renderUseCaseHook({ case: mockCase });
|
|
137
144
|
unmount();
|
|
138
|
-
expect(mockRemoveListener).toHaveBeenCalledWith(
|
|
145
|
+
expect(mockRemoveListener).toHaveBeenCalledWith(expect.stringMatching(/^case-update-c7-/));
|
|
146
|
+
});
|
|
147
|
+
it('updates every concurrent case consumer for the same case', () => {
|
|
148
|
+
const mockCase = createMockCase({ case_id: 'c8', title: 'Original' });
|
|
149
|
+
render(createElement(CaseConsumers, { case: mockCase }));
|
|
150
|
+
const listenerKeys = mockAddListener.mock.calls.map(([key]) => key);
|
|
151
|
+
expect(listenerKeys).toHaveLength(3);
|
|
152
|
+
expect([...new Set(listenerKeys)]).toHaveLength(3);
|
|
153
|
+
const updatedCase = createMockCase({ case_id: 'c8', title: 'Updated via socket' });
|
|
154
|
+
act(() => {
|
|
155
|
+
mockAddListener.mock.calls.forEach(([, listener]) => {
|
|
156
|
+
listener({
|
|
157
|
+
type: 'cases',
|
|
158
|
+
case: updatedCase,
|
|
159
|
+
error: false,
|
|
160
|
+
message: '',
|
|
161
|
+
status: 200
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
expect(screen.getByTestId('sidebar-case-title')).toHaveTextContent('Updated via socket');
|
|
166
|
+
expect(screen.getByTestId('dashboard-case-title')).toHaveTextContent('Updated via socket');
|
|
167
|
+
expect(screen.getByTestId('details-case-title')).toHaveTextContent('Updated via socket');
|
|
139
168
|
});
|
|
140
169
|
});
|
|
141
170
|
});
|