@cccsaurora/howler-ui 2.16.0-dev.378 → 2.16.0-dev.381

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.
Files changed (57) hide show
  1. package/components/app/App.js +2 -0
  2. package/components/app/hooks/useMatchers.js +0 -4
  3. package/components/app/providers/FavouritesProvider.js +2 -1
  4. package/components/app/providers/FieldProvider.d.ts +2 -2
  5. package/components/app/providers/HitProvider.d.ts +3 -3
  6. package/components/app/providers/HitSearchProvider.d.ts +7 -8
  7. package/components/app/providers/HitSearchProvider.js +64 -39
  8. package/components/app/providers/HitSearchProvider.test.d.ts +1 -0
  9. package/components/app/providers/HitSearchProvider.test.js +505 -0
  10. package/components/app/providers/ParameterProvider.d.ts +13 -5
  11. package/components/app/providers/ParameterProvider.js +240 -84
  12. package/components/app/providers/ParameterProvider.test.d.ts +1 -0
  13. package/components/app/providers/ParameterProvider.test.js +1041 -0
  14. package/components/app/providers/ViewProvider.d.ts +3 -2
  15. package/components/app/providers/ViewProvider.js +21 -14
  16. package/components/app/providers/ViewProvider.test.js +19 -29
  17. package/components/elements/display/ChipPopper.d.ts +21 -0
  18. package/components/elements/display/ChipPopper.js +36 -0
  19. package/components/elements/display/ChipPopper.test.d.ts +1 -0
  20. package/components/elements/display/ChipPopper.test.js +309 -0
  21. package/components/elements/hit/HitActions.js +3 -3
  22. package/components/elements/hit/HitSummary.d.ts +0 -1
  23. package/components/elements/hit/HitSummary.js +11 -21
  24. package/components/elements/hit/aggregate/HitGraph.d.ts +1 -3
  25. package/components/elements/hit/aggregate/HitGraph.js +9 -15
  26. package/components/routes/dossiers/DossierCard.test.js +0 -2
  27. package/components/routes/dossiers/DossierEditor.test.js +27 -33
  28. package/components/routes/hits/search/HitBrowser.js +7 -48
  29. package/components/routes/hits/search/HitContextMenu.test.js +11 -29
  30. package/components/routes/hits/search/InformationPane.js +1 -1
  31. package/components/routes/hits/search/QuerySettings.js +30 -0
  32. package/components/routes/hits/search/QuerySettings.test.d.ts +1 -0
  33. package/components/routes/hits/search/QuerySettings.test.js +553 -0
  34. package/components/routes/hits/search/SearchPane.js +8 -10
  35. package/components/routes/hits/search/ViewLink.d.ts +4 -1
  36. package/components/routes/hits/search/ViewLink.js +37 -19
  37. package/components/routes/hits/search/ViewLink.test.js +349 -303
  38. package/components/routes/hits/search/grid/HitGrid.js +2 -6
  39. package/components/routes/hits/search/shared/HitFilter.d.ts +2 -0
  40. package/components/routes/hits/search/shared/HitFilter.js +31 -23
  41. package/components/routes/hits/search/shared/HitSort.js +16 -8
  42. package/components/routes/hits/search/shared/SearchSpan.js +19 -10
  43. package/components/routes/views/ViewComposer.js +7 -6
  44. package/components/routes/views/Views.js +2 -1
  45. package/locales/en/translation.json +6 -0
  46. package/locales/fr/translation.json +6 -0
  47. package/package.json +2 -2
  48. package/setupTests.js +4 -1
  49. package/tests/mocks.d.ts +18 -0
  50. package/tests/mocks.js +65 -0
  51. package/tests/server-handlers.js +10 -28
  52. package/utils/viewUtils.d.ts +2 -0
  53. package/utils/viewUtils.js +11 -0
  54. package/components/routes/hits/search/shared/QuerySettings.js +0 -22
  55. /package/components/routes/hits/search/{shared/QuerySettings.d.ts → QuerySettings.d.ts} +0 -0
  56. /package/components/routes/hits/search/{CustomSort.d.ts → shared/CustomSort.d.ts} +0 -0
  57. /package/components/routes/hits/search/{CustomSort.js → shared/CustomSort.js} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { isEmpty, isNull, isUndefined, omitBy, pickBy } from 'lodash-es';
2
+ import { isEmpty, isEqual, isUndefined, omitBy, uniq } from 'lodash-es';
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useLocation, useParams, useSearchParams } from 'react-router-dom';
5
5
  import { createContext, useContextSelector } from 'use-context-selector';
@@ -11,6 +11,17 @@ const DEFAULT_VALUES = {
11
11
  sort: 'event.created desc',
12
12
  span: 'date.range.1.month'
13
13
  };
14
+ /**
15
+ * Mapping of URL parameter keys to internal state keys
16
+ * Note: 'filters' is handled separately due to multi-value support
17
+ */
18
+ const PARAM_MAPPINGS = [
19
+ ['query', 'query'],
20
+ ['sort', 'sort'],
21
+ ['span', 'span'],
22
+ ['start_date', 'startDate'],
23
+ ['end_date', 'endDate']
24
+ ];
14
25
  const WRITE_THROTTLER = new Throttler(100);
15
26
  /**
16
27
  * Helper function to convert a number/string representation of a number into a valid offset.
@@ -23,6 +34,18 @@ const parseOffset = (_offset) => {
23
34
  const candidate = parseInt(_offset);
24
35
  return isNaN(candidate) ? 0 : candidate;
25
36
  };
37
+ /**
38
+ * Helper function to determine the selected value based on URL params and route context.
39
+ */
40
+ const getSelectedValue = (params, pathname, bundleId) => {
41
+ if (params.has('selected')) {
42
+ return params.get('selected');
43
+ }
44
+ if (pathname.startsWith('/bundles') && bundleId) {
45
+ return bundleId;
46
+ }
47
+ return null;
48
+ };
26
49
  /**
27
50
  * Context responsible for tracking updates to query operations in hit and view search.
28
51
  */
@@ -32,28 +55,39 @@ const ParameterProvider = ({ children }) => {
32
55
  const [params, setParams] = useSearchParams();
33
56
  const pendingChanges = useRef({});
34
57
  const [values, _setValues] = useState({
35
- selected: params.get('selected'),
58
+ selected: getSelectedValue(params, location.pathname, routeParams.id),
36
59
  query: params.get('query') ?? DEFAULT_VALUES.query,
37
60
  sort: params.get('sort') ?? DEFAULT_VALUES.sort,
38
61
  span: params.get('span') ?? DEFAULT_VALUES.span,
39
- filter: params.get('filter'),
62
+ filters: params.getAll('filter'),
63
+ views: params.getAll('view'),
40
64
  startDate: params.get('start_date'),
41
65
  endDate: params.get('end_date'),
42
66
  offset: parseOffset(params.get('offset')),
43
67
  trackTotalHits: (params.get('track_total_hits') ?? 'false') !== 'false'
44
68
  });
45
69
  // TODO: SELECTING A BUNDLE STILL CAUSES A FREAKOUT
46
- const set = useCallback((key) => value => {
70
+ const set = useCallback(key => value => {
71
+ if (key === 'filters') {
72
+ // eslint-disable-next-line no-console
73
+ console.error('Cannot use set() for filters. Use addFilter/removeFilter/clearFilters instead.');
74
+ return;
75
+ }
76
+ if (key === 'views') {
77
+ // eslint-disable-next-line no-console
78
+ console.error('Cannot use set() for views. Use addView/removeView/clearViews instead.');
79
+ return;
80
+ }
47
81
  if (value === values[key]) {
48
82
  return;
49
83
  }
50
- if (key === 'selected' && !value && location.pathname.startsWith('/bundles')) {
51
- pendingChanges.current[key] = routeParams.id;
84
+ if (key === 'selected' && !value) {
85
+ pendingChanges.current.selected = getSelectedValue(params, location.pathname, routeParams.id);
52
86
  }
53
87
  else {
54
88
  pendingChanges.current[key] = value ?? DEFAULT_VALUES[key] ?? null;
55
89
  }
56
- if (key === 'span' && !value.endsWith('custom')) {
90
+ if (key === 'span' && typeof value === 'string' && !value.endsWith('custom')) {
57
91
  pendingChanges.current.startDate = null;
58
92
  pendingChanges.current.endDate = null;
59
93
  }
@@ -61,7 +95,7 @@ const ParameterProvider = ({ children }) => {
61
95
  _setValues(_current => ({ ..._current, ...pendingChanges.current }));
62
96
  pendingChanges.current = {};
63
97
  });
64
- }, [location.pathname, routeParams.id, values]);
98
+ }, [location.pathname, routeParams.id, values, params]);
65
99
  const setOffset = useCallback(_offset => _setValues(_current => ({ ..._current, offset: parseOffset(_offset) })), []);
66
100
  const setCustomSpan = useCallback((startDate, endDate) => {
67
101
  _setValues(_values => ({
@@ -70,101 +104,216 @@ const ParameterProvider = ({ children }) => {
70
104
  endDate
71
105
  }));
72
106
  }, []);
73
- const getDiff = useCallback((operation) => {
74
- /**
75
- * A record of changes necessary to synchronize the query string and the internal values store.
76
- */
77
- const changes = {};
78
- const standardKeys = [
79
- ['query', values.query],
80
- ['sort', values.sort],
81
- ['span', values.span],
82
- ['filter', values.filter],
83
- ['start_date', values.startDate],
84
- ['end_date', values.endDate]
85
- ];
86
- standardKeys.forEach(([key, value]) => {
87
- // Get the value from the URL, using the default values as fallback (and set to undefined if neither is set)
88
- const fromSearchWithFallback = params.has(key) ? params.get(key) : (DEFAULT_VALUES[key] ?? undefined);
89
- // If there's a difference between the search value and the value in the internal store, we append the key and new value to changes
90
- // This is based on the operation
91
- if (fromSearchWithFallback !== value) {
92
- changes[key] = operation === 'write' ? value : fromSearchWithFallback;
107
+ /**
108
+ * Filter manipulation
109
+ */
110
+ const addFilter = useCallback(filter => {
111
+ _setValues(_current => ({
112
+ ..._current,
113
+ filters: uniq([..._current.filters, filter])
114
+ }));
115
+ }, []);
116
+ const removeFilter = useCallback(filter => {
117
+ _setValues(_current => {
118
+ const index = _current.filters.indexOf(filter);
119
+ if (index === -1) {
120
+ return _current;
93
121
  }
94
- // This is where things get a bit tricky. We use the fact that undefined and null are different concepts in Javascript here
95
- // "undefined" is later filtered out, meaning "no change", while null means "remove this value"
96
- if (operation === 'write') {
97
- // If the change is to just set it to default in the query string, set it to null to just remove that entry altogether
98
- // Due to the DEFAULT_VALUES check in fromSearchWithFallback above, this will use the defeault value.
99
- // i.e., this has the same effect, but cleans up the query string
100
- if (params.has(key) && !isUndefined(changes[key]) && changes[key] === DEFAULT_VALUES[key]) {
101
- changes[key] = null;
102
- }
122
+ return {
123
+ ..._current,
124
+ filters: _current.filters.filter((_, i) => i !== index)
125
+ };
126
+ });
127
+ }, []);
128
+ const setFilter = useCallback((index, filter) => {
129
+ _setValues(_current => {
130
+ // Validate index
131
+ if (index < 0 || index >= _current.filters.length) {
132
+ return _current;
103
133
  }
134
+ const newFilters = [..._current.filters];
135
+ newFilters[index] = filter;
136
+ return {
137
+ ..._current,
138
+ filters: newFilters
139
+ };
104
140
  });
105
- // Logic for the selected key - this can vary depending on the context
106
- if (operation === 'write') {
107
- // Are we in a bundle, with a selected parameter?
108
- // If so, does it match the bundle ID? If so, it's redundant and should be removed
109
- if (location.pathname.startsWith('/bundles') &&
110
- (!params.has('selected') || values.selected === params.get('selected'))) {
111
- changes.selected = null;
141
+ }, []);
142
+ const clearFilters = useCallback(() => {
143
+ _setValues(_current => ({
144
+ ..._current,
145
+ filters: []
146
+ }));
147
+ }, []);
148
+ /**
149
+ * View manipulation
150
+ */
151
+ const addView = useCallback(view => {
152
+ _setValues(_current => ({
153
+ ..._current,
154
+ views: uniq([..._current.views, view])
155
+ }));
156
+ }, []);
157
+ const removeView = useCallback(view => {
158
+ _setValues(_current => {
159
+ const index = _current.views.indexOf(view);
160
+ if (index === -1) {
161
+ return _current;
112
162
  }
113
- else {
114
- // We're either not in a bundle, or in a bundle and a hit different from the main bundle is selected
115
- changes.selected = values.selected;
163
+ return {
164
+ ..._current,
165
+ views: _current.views.filter((_, i) => i !== index)
166
+ };
167
+ });
168
+ }, []);
169
+ const setView = useCallback((index, view) => {
170
+ _setValues(_current => {
171
+ // Validate index
172
+ if (index < 0 || index >= _current.views.length) {
173
+ return _current;
116
174
  }
117
- }
118
- else {
119
- if (params.has('selected')) {
120
- // If we have a selected hit, that's the selected value to use
121
- changes.selected = params.get('selected');
175
+ const newViews = [..._current.views];
176
+ newViews[index] = view;
177
+ return {
178
+ ..._current,
179
+ views: newViews
180
+ };
181
+ });
182
+ }, []);
183
+ const clearViews = useCallback(() => {
184
+ _setValues(_current => ({
185
+ ..._current,
186
+ views: []
187
+ }));
188
+ }, []);
189
+ /**
190
+ * Get URL parameter changes needed to sync internal state to the address bar.
191
+ * Returns null values for params that should be removed from URL.
192
+ */
193
+ const getUrlFromState = useCallback(() => {
194
+ const changes = {};
195
+ PARAM_MAPPINGS.forEach(([urlKey, stateKey]) => {
196
+ const stateValue = values[stateKey];
197
+ const urlValue = params.get(urlKey);
198
+ if (stateValue !== urlValue) {
199
+ // If the value matches the default, remove it from URL (null signals removal)
200
+ if (params.has(urlKey) && stateValue === DEFAULT_VALUES[stateKey]) {
201
+ changes[urlKey] = null;
202
+ }
203
+ else if (stateValue !== DEFAULT_VALUES[stateKey]) {
204
+ changes[urlKey] = stateValue;
205
+ }
122
206
  }
123
- else if (location.pathname.startsWith('/bundles')) {
124
- // If not, fallback to the bundle ID
125
- changes.selected = routeParams.id;
207
+ });
208
+ // Handle filters: compare arrays with isEqual
209
+ const urlFilters = params.getAll('filter');
210
+ if (!isEqual(values.filters, urlFilters)) {
211
+ // Coerce empty array to null for removal signal
212
+ changes.filters = values.filters.length === 0 ? null : values.filters;
213
+ }
214
+ // Handle views: compare arrays with isEqual
215
+ const urlViews = params.getAll('view');
216
+ if (!isEqual(values.views, urlViews)) {
217
+ // Coerce empty array to null for removal signal
218
+ changes.views = values.views.length === 0 ? null : values.views;
219
+ }
220
+ // Handle selected: remove if redundant in bundle context, otherwise set
221
+ if (location.pathname.startsWith('/bundles') &&
222
+ (!params.has('selected') || values.selected === params.get('selected'))) {
223
+ changes.selected = null;
224
+ }
225
+ else if (values.selected !== params.get('selected')) {
226
+ changes.selected = values.selected;
227
+ }
228
+ // Handle offset: remove if 0, otherwise set
229
+ const urlOffset = parseOffset(params.get('offset'));
230
+ if (urlOffset !== values.offset) {
231
+ changes.offset = values.offset || null;
232
+ }
233
+ // Filter out values that already match the URL (skip 'filters', 'views' as they're handled above)
234
+ return omitBy(changes, (val, key) => {
235
+ if (['filters', 'views'].includes(key)) {
236
+ return false;
126
237
  }
127
- else {
128
- // Otherwise nothing has been selected
129
- changes.selected = null;
238
+ return val == params.get(key);
239
+ });
240
+ }, [values, params, location.pathname]);
241
+ /**
242
+ * Get state changes needed to sync URL parameters to internal state.
243
+ */
244
+ const getStateFromUrl = useCallback(() => {
245
+ const changes = {};
246
+ PARAM_MAPPINGS.forEach(([urlKey, stateKey]) => {
247
+ const urlValue = params.has(urlKey) ? params.get(urlKey) : (DEFAULT_VALUES[stateKey] ?? undefined);
248
+ if (urlValue !== values[stateKey]) {
249
+ changes[stateKey] = urlValue;
130
250
  }
251
+ });
252
+ // Handle filters: compare arrays with isEqual
253
+ const urlFilters = uniq(params.getAll('filter'));
254
+ if (!isEqual(urlFilters, values.filters)) {
255
+ changes.filters = urlFilters;
131
256
  }
132
- if (parseOffset(params.get('offset')) !== values.offset) {
133
- changes.offset = operation === 'write' ? values.offset : parseOffset(params.get('offset'));
134
- // Same deal - if offset is 0, just remove it entirely (it'll default to 0 offset)
135
- if (operation === 'write' && !changes.offset) {
136
- changes.offset = null;
137
- }
257
+ // Handle filters: compare arrays with isEqual
258
+ const urlViews = uniq(params.getAll('view'));
259
+ if (!isEqual(urlViews, values.views)) {
260
+ changes.views = urlViews;
261
+ }
262
+ // Handle selected using helper
263
+ const selectedValue = getSelectedValue(params, location.pathname, routeParams.id);
264
+ if (selectedValue !== values.selected) {
265
+ changes.selected = selectedValue;
138
266
  }
139
- // This is where we check for what has actually changed against the given store
140
- // We first omit undefined keys (fromSearchWithFallback can introduce these)
141
- // Then we omit any values that already match the store we're updating
142
- // (query string or internal state).
143
- return omitBy(omitBy(changes, isUndefined), (val, key) => operation === 'write' ? val == params.get(key) : val == values[key]);
267
+ // Handle offset
268
+ const urlOffset = parseOffset(params.get('offset'));
269
+ if (urlOffset !== values.offset) {
270
+ changes.offset = urlOffset;
271
+ }
272
+ // Filter out undefined values and values that already match state
273
+ return omitBy(omitBy(changes, isUndefined), (val, key) => val == values[key]);
144
274
  }, [values, params, location.pathname, routeParams.id]);
145
275
  /**
146
276
  * Effect to synchronize the context's state with the address bar
147
277
  */
148
278
  useEffect(() => {
149
- const changes = getDiff('write');
150
- if (!isEmpty(changes)) {
151
- const existingParams = Object.fromEntries(params.entries());
152
- setParams(_params => {
153
- const newParams = new URLSearchParams({ ...existingParams, ...changes });
154
- Object.entries(pickBy(changes, isNull)).forEach(([key]) => newParams.delete(key));
155
- return newParams;
156
- }, { replace: !changes.query && !Object.keys(changes).includes('offset') });
279
+ const changes = getUrlFromState();
280
+ if (isEmpty(changes)) {
281
+ return;
157
282
  }
283
+ setParams(_params => {
284
+ // Build fresh URLSearchParams from existing params
285
+ const newParams = new URLSearchParams(_params);
286
+ // Handle standard params
287
+ Object.entries(changes).forEach(([key, value]) => {
288
+ if (['filters', 'views'].includes(key)) {
289
+ const multiFieldKey = key.replace(/s$/, '');
290
+ // Special handling for arrays
291
+ newParams.delete(multiFieldKey);
292
+ if (Array.isArray(value)) {
293
+ value.forEach(val => newParams.append(multiFieldKey, val));
294
+ }
295
+ // null/undefined means remove (already deleted above)
296
+ }
297
+ else if (value === null || value === undefined) {
298
+ newParams.delete(key);
299
+ }
300
+ else {
301
+ newParams.set(key, String(value));
302
+ }
303
+ });
304
+ return newParams;
305
+ }, { replace: !changes.query && !Object.keys(changes).includes('offset') });
158
306
  // eslint-disable-next-line react-hooks/exhaustive-deps
159
307
  }, [values]);
160
308
  useEffect(() => {
161
- const changes = getDiff('read');
162
- if (!isEmpty(changes)) {
163
- _setValues(_current => ({
164
- ..._current,
165
- ...changes
166
- }));
309
+ const changes = getStateFromUrl();
310
+ if (isEmpty(changes)) {
311
+ return;
167
312
  }
313
+ _setValues(_current => ({
314
+ ..._current,
315
+ ...changes
316
+ }));
168
317
  // eslint-disable-next-line react-hooks/exhaustive-deps
169
318
  }, [location.search, location.pathname, routeParams.id]);
170
319
  return (_jsx(ParameterContext.Provider, { value: {
@@ -175,7 +324,14 @@ const ParameterProvider = ({ children }) => {
175
324
  setQuery: useMemo(() => set('query'), [set]),
176
325
  setSort: useMemo(() => set('sort'), [set]),
177
326
  setSpan: useMemo(() => set('span'), [set]),
178
- setFilter: useMemo(() => set('filter'), [set])
327
+ addFilter,
328
+ removeFilter,
329
+ setFilter,
330
+ clearFilters,
331
+ addView,
332
+ removeView,
333
+ setView,
334
+ clearViews
179
335
  }, children: children }));
180
336
  };
181
337
  export const useParameterContextSelector = (selector) => {