@cccsaurora/howler-ui 2.17.0-dev.564 → 2.17.0-dev.617

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 (170) hide show
  1. package/api/index.d.ts +2 -0
  2. package/api/index.js +4 -2
  3. package/api/search/case.d.ts +4 -0
  4. package/api/search/case.js +8 -0
  5. package/api/search/index.d.ts +2 -1
  6. package/api/search/index.js +2 -1
  7. package/api/v2/case/index.d.ts +6 -0
  8. package/api/v2/case/index.js +18 -0
  9. package/api/v2/index.d.ts +4 -0
  10. package/api/v2/index.js +6 -0
  11. package/api/v2/search/facet.d.ts +3 -0
  12. package/api/v2/search/facet.js +12 -0
  13. package/api/v2/search/index.d.ts +5 -0
  14. package/api/v2/search/index.js +24 -0
  15. package/commons/components/leftnav/LeftNavDrawer.js +1 -1
  16. package/components/app/App.js +14 -0
  17. package/components/app/providers/FavouritesProvider.js +2 -2
  18. package/components/app/providers/HitSearchProvider.d.ts +0 -1
  19. package/components/app/providers/HitSearchProvider.js +6 -11
  20. package/components/app/providers/HitSearchProvider.test.js +11 -32
  21. package/components/app/providers/ParameterProvider.d.ts +9 -2
  22. package/components/app/providers/ParameterProvider.js +165 -240
  23. package/components/app/providers/ParameterProvider.test.js +307 -14
  24. package/components/{routes/overviews/OverviewEditor.js → elements/MarkdownEditor.js} +3 -3
  25. package/components/elements/ObjectDetails.d.ts +6 -0
  26. package/components/elements/{hit/HitDetails.js → ObjectDetails.js} +17 -17
  27. package/components/elements/PluginTypography.d.ts +2 -1
  28. package/components/elements/PluginTypography.js +3 -2
  29. package/components/elements/UserList.d.ts +5 -2
  30. package/components/elements/UserList.js +14 -5
  31. package/components/elements/addons/search/phrase/Phrase.js +1 -1
  32. package/components/elements/case/CaseCard.d.ts +8 -0
  33. package/components/elements/case/CaseCard.js +39 -0
  34. package/components/elements/case/CasePreview.d.ts +6 -0
  35. package/components/elements/case/CasePreview.js +17 -0
  36. package/components/elements/case/StatusIcon.d.ts +5 -0
  37. package/components/elements/case/StatusIcon.js +13 -0
  38. package/components/elements/display/ChipPopper.d.ts +1 -0
  39. package/components/elements/display/ChipPopper.js +2 -2
  40. package/components/elements/display/HowlerCard.js +1 -1
  41. package/components/elements/display/Modal.js +1 -0
  42. package/components/elements/hit/HitBanner.js +28 -48
  43. package/components/elements/hit/HitCard.js +1 -1
  44. package/components/elements/hit/{HitQuickSearch.d.ts → HitPreview.d.ts} +3 -3
  45. package/components/elements/hit/{HitQuickSearch.js → HitPreview.js} +10 -4
  46. package/components/elements/hit/HitRelated.d.ts +1 -1
  47. package/components/elements/hit/HitRelated.js +30 -3
  48. package/components/elements/hit/elements/AnalyticLink.d.ts +8 -0
  49. package/components/elements/hit/elements/AnalyticLink.js +22 -0
  50. package/components/elements/hit/outlines/DefaultOutline.js +1 -1
  51. package/components/elements/hit/related/RelatedRecords.js +63 -0
  52. package/components/elements/observable/ObservableCard.d.ts +5 -0
  53. package/components/elements/observable/ObservableCard.js +7 -0
  54. package/components/elements/observable/ObservablePreview.d.ts +6 -0
  55. package/components/elements/observable/ObservablePreview.js +12 -0
  56. package/components/elements/view/ViewTitle.js +1 -1
  57. package/components/hooks/useHitActions.d.ts +1 -1
  58. package/components/hooks/useHitActions.js +2 -2
  59. package/components/hooks/useHitSelection.js +3 -24
  60. package/components/hooks/useMyPreferences.js +10 -1
  61. package/components/hooks/useMySearch.js +2 -2
  62. package/components/hooks/useMySitemap.js +4 -1
  63. package/components/hooks/useMyTheme.js +9 -2
  64. package/components/hooks/useRelatedRecords.d.ts +13 -0
  65. package/components/hooks/useRelatedRecords.js +32 -0
  66. package/components/routes/action/view/ActionSearch.js +1 -1
  67. package/components/routes/advanced/QueryBuilder.js +1 -1
  68. package/components/routes/analytics/AnalyticDetails.js +2 -2
  69. package/components/routes/analytics/AnalyticSearch.js +1 -1
  70. package/components/routes/cases/CaseViewer.d.ts +2 -0
  71. package/components/routes/cases/CaseViewer.js +24 -0
  72. package/components/routes/cases/Cases.d.ts +2 -0
  73. package/components/routes/cases/Cases.js +101 -0
  74. package/components/routes/cases/constants.d.ts +5 -0
  75. package/components/routes/cases/constants.js +5 -0
  76. package/components/routes/cases/detail/AlertPanel.d.ts +6 -0
  77. package/components/routes/cases/detail/AlertPanel.js +32 -0
  78. package/components/routes/cases/detail/CaseDashboard.d.ts +7 -0
  79. package/components/routes/cases/detail/CaseDashboard.js +49 -0
  80. package/components/routes/cases/detail/CaseDetails.d.ts +6 -0
  81. package/components/routes/cases/detail/CaseDetails.js +61 -0
  82. package/components/routes/cases/detail/CaseOverview.d.ts +7 -0
  83. package/components/routes/cases/detail/CaseOverview.js +43 -0
  84. package/components/routes/cases/detail/CaseSidebar.d.ts +6 -0
  85. package/components/routes/cases/detail/CaseSidebar.js +36 -0
  86. package/components/routes/cases/detail/CaseTask.d.ts +11 -0
  87. package/components/routes/cases/detail/CaseTask.js +57 -0
  88. package/components/routes/cases/detail/ItemPage.d.ts +6 -0
  89. package/components/routes/cases/detail/ItemPage.js +93 -0
  90. package/components/routes/cases/detail/RelatedCasePanel.d.ts +6 -0
  91. package/components/routes/cases/detail/RelatedCasePanel.js +31 -0
  92. package/components/routes/cases/detail/TaskPanel.d.ts +7 -0
  93. package/components/routes/cases/detail/TaskPanel.js +52 -0
  94. package/components/routes/cases/detail/aggregates/CaseAggregate.d.ts +12 -0
  95. package/components/routes/cases/detail/aggregates/CaseAggregate.js +19 -0
  96. package/components/routes/cases/detail/aggregates/SourceAggregate.d.ts +6 -0
  97. package/components/routes/cases/detail/aggregates/SourceAggregate.js +27 -0
  98. package/components/routes/cases/detail/sidebar/CaseFolder.d.ts +13 -0
  99. package/components/routes/cases/detail/sidebar/CaseFolder.js +134 -0
  100. package/components/routes/cases/detail/sidebar/types.d.ts +3 -0
  101. package/components/routes/cases/detail/sidebar/utils.d.ts +3 -0
  102. package/components/routes/cases/detail/sidebar/utils.js +25 -0
  103. package/components/routes/cases/hooks/useCase.d.ts +13 -0
  104. package/components/routes/cases/hooks/useCase.js +38 -0
  105. package/components/routes/cases/modals/ResolveModal.d.ts +7 -0
  106. package/components/routes/cases/modals/ResolveModal.js +59 -0
  107. package/components/routes/help/ApiDocumentation.js +1 -1
  108. package/components/routes/help/HitDocumentation.js +1 -3
  109. package/components/routes/hits/search/HitContextMenu.js +3 -2
  110. package/components/routes/hits/search/InformationPane.d.ts +1 -0
  111. package/components/routes/hits/search/InformationPane.js +6 -28
  112. package/components/routes/hits/search/QuerySettings.js +2 -1
  113. package/components/routes/hits/search/QuerySettings.test.js +14 -9
  114. package/components/routes/hits/search/SearchPane.js +7 -32
  115. package/components/routes/hits/search/ViewLink.js +1 -1
  116. package/components/routes/hits/search/grid/EnhancedCell.js +1 -1
  117. package/components/routes/hits/search/shared/IndexPicker.d.ts +2 -0
  118. package/components/routes/hits/search/shared/IndexPicker.js +20 -0
  119. package/components/routes/hits/view/HitViewer.js +3 -4
  120. package/components/routes/home/ViewCard.js +1 -1
  121. package/components/routes/observables/ObservableViewer.d.ts +7 -0
  122. package/components/routes/observables/ObservableViewer.js +27 -0
  123. package/components/routes/overviews/OverviewViewer.js +2 -2
  124. package/locales/en/translation.json +437 -398
  125. package/locales/fr/translation.json +442 -408
  126. package/models/WithMetadata.d.ts +2 -1
  127. package/models/entities/generated/AttachmentsFile.d.ts +12 -0
  128. package/models/entities/generated/Case.d.ts +28 -0
  129. package/models/entities/generated/DestinationOriginal.d.ts +19 -0
  130. package/models/entities/generated/EmailAttachment.d.ts +8 -0
  131. package/models/entities/generated/EmailParent.d.ts +19 -0
  132. package/models/entities/generated/Enrichments.d.ts +7 -0
  133. package/models/entities/generated/EnrichmentsIndicator.d.ts +21 -0
  134. package/models/entities/generated/Howler.d.ts +0 -4
  135. package/models/entities/generated/HttpResponse.d.ts +11 -0
  136. package/models/entities/generated/Item.d.ts +9 -0
  137. package/models/entities/generated/Observable.d.ts +84 -0
  138. package/models/entities/generated/ObservableCloud.d.ts +20 -0
  139. package/models/entities/generated/ObservableDestination.d.ts +23 -0
  140. package/models/entities/generated/ObservableEmail.d.ts +30 -0
  141. package/models/entities/generated/ObservableFile.d.ts +36 -0
  142. package/models/entities/generated/ObservableHowler.d.ts +44 -0
  143. package/models/entities/generated/ObservableHttp.d.ts +11 -0
  144. package/models/entities/generated/ObservableObserver.d.ts +21 -0
  145. package/models/entities/generated/ObservableOrganization.d.ts +7 -0
  146. package/models/entities/generated/ObservableProcess.d.ts +34 -0
  147. package/models/entities/generated/ObservableSource.d.ts +23 -0
  148. package/models/entities/generated/ObservableThreat.d.ts +21 -0
  149. package/models/entities/generated/ObservableTls.d.ts +12 -0
  150. package/models/entities/generated/ObserverIngress.d.ts +9 -0
  151. package/models/entities/generated/Rule.d.ts +2 -10
  152. package/models/entities/generated/Task.d.ts +10 -0
  153. package/models/entities/generated/Threat.d.ts +2 -2
  154. package/models/entities/generated/{Enrichment.d.ts → ThreatEnrichment.d.ts} +1 -1
  155. package/package.json +16 -1
  156. package/plugins/clue/components/ClueTypography.js +2 -2
  157. package/plugins/clue/utils.d.ts +2 -1
  158. package/utils/constants.d.ts +3 -3
  159. package/utils/typeUtils.d.ts +7 -0
  160. package/utils/typeUtils.js +18 -0
  161. package/components/elements/display/icons/BundleButton.d.ts +0 -6
  162. package/components/elements/display/icons/BundleButton.js +0 -32
  163. package/components/routes/help/BundleDocumentation.d.ts +0 -3
  164. package/components/routes/help/BundleDocumentation.js +0 -12
  165. package/components/routes/help/markdown/en/bundles.md.js +0 -1
  166. package/components/routes/help/markdown/fr/bundles.md.js +0 -1
  167. package/components/routes/hits/search/BundleParentMenu.d.ts +0 -6
  168. package/components/routes/hits/search/BundleParentMenu.js +0 -32
  169. /package/components/{routes/overviews/OverviewEditor.d.ts → elements/MarkdownEditor.d.ts} +0 -0
  170. /package/components/elements/hit/{HitDetails.d.ts → related/RelatedRecords.d.ts} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { isEmpty, isEqual, isUndefined, omitBy, uniq } from 'lodash-es';
2
+ import { identity, 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';
@@ -9,12 +9,10 @@ export const ParameterContext = createContext(null);
9
9
  const DEFAULT_VALUES = {
10
10
  query: DEFAULT_QUERY,
11
11
  sort: 'event.created desc',
12
- span: 'date.range.1.month'
12
+ span: 'date.range.1.month',
13
+ indexes: ['hit']
13
14
  };
14
- /**
15
- * Mapping of URL parameter keys to internal state keys
16
- * Note: 'filters' is handled separately due to multi-value support
17
- */
15
+ /** Scalar URL params that map 1:1 to a state key */
18
16
  const PARAM_MAPPINGS = [
19
17
  ['query', 'query'],
20
18
  ['sort', 'sort'],
@@ -22,15 +20,21 @@ const PARAM_MAPPINGS = [
22
20
  ['start_date', 'startDate'],
23
21
  ['end_date', 'endDate']
24
22
  ];
23
+ /** Multi-value URL params that map to array state keys */
24
+ const ARRAY_PARAMS = [
25
+ { urlKey: 'filter', stateKey: 'filters' },
26
+ { urlKey: 'view', stateKey: 'views' },
27
+ { urlKey: 'index', stateKey: 'indexes', default: DEFAULT_VALUES.indexes }
28
+ ];
29
+ const ARRAY_URL_KEYS = new Set(ARRAY_PARAMS.map(p => p.urlKey));
25
30
  const WRITE_THROTTLER = new Throttler(100);
26
31
  /**
27
32
  * Helper function to convert a number/string representation of a number into a valid offset.
28
33
  * @returns
29
34
  */
30
35
  const parseOffset = (_offset) => {
31
- if (typeof _offset === 'number') {
36
+ if (typeof _offset === 'number')
32
37
  return _offset;
33
- }
34
38
  const candidate = parseInt(_offset);
35
39
  return isNaN(candidate) ? 0 : candidate;
36
40
  };
@@ -38,261 +42,131 @@ const parseOffset = (_offset) => {
38
42
  * Helper function to determine the selected value based on URL params and route context.
39
43
  */
40
44
  const getSelectedValue = (params, pathname, bundleId) => {
41
- if (params.has('selected')) {
45
+ if (params.has('selected'))
42
46
  return params.get('selected');
43
- }
44
- if (pathname.startsWith('/bundles') && bundleId) {
47
+ if (pathname.startsWith('/bundles') && bundleId)
45
48
  return bundleId;
46
- }
47
49
  return null;
48
50
  };
49
51
  /**
50
- * Context responsible for tracking updates to query operations in hit and view search.
52
+ * Returns stable add / remove / setAt / setAll / clear handlers for a list field in
53
+ * SearchValues. All returned functions are memoized; since _setValues (from useState)
54
+ * and key are both stable for the lifetime of the component, the deps array is empty.
51
55
  */
52
- const ParameterProvider = ({ children }) => {
53
- const location = useLocation();
54
- const routeParams = useParams();
55
- const [params, setParams] = useSearchParams();
56
- const pendingChanges = useRef({});
57
- const [values, _setValues] = useState({
58
- selected: getSelectedValue(params, location.pathname, routeParams.id),
59
- query: params.get('query') ?? DEFAULT_VALUES.query,
60
- sort: params.get('sort') ?? DEFAULT_VALUES.sort,
61
- span: params.get('span') ?? DEFAULT_VALUES.span,
62
- filters: params.getAll('filter'),
63
- views: params.getAll('view'),
64
- startDate: params.get('start_date'),
65
- endDate: params.get('end_date'),
66
- offset: parseOffset(params.get('offset')),
67
- trackTotalHits: (params.get('track_total_hits') ?? 'false') !== 'false'
68
- });
69
- // TODO: SELECTING A BUNDLE STILL CAUSES A FREAKOUT
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
- }
81
- if (value === values[key]) {
82
- return;
83
- }
84
- if (key === 'selected') {
85
- pendingChanges.current.selected = value;
86
- }
87
- else {
88
- pendingChanges.current[key] = value ?? DEFAULT_VALUES[key] ?? null;
89
- }
90
- if (key === 'span' && typeof value === 'string' && !value.endsWith('custom')) {
91
- pendingChanges.current.startDate = null;
92
- pendingChanges.current.endDate = null;
93
- }
94
- WRITE_THROTTLER.debounce(() => {
95
- _setValues(_current => ({ ..._current, ...pendingChanges.current }));
96
- pendingChanges.current = {};
97
- });
98
- }, [values]);
99
- const setOffset = useCallback(_offset => _setValues(_current => ({ ..._current, offset: parseOffset(_offset) })), []);
100
- const setCustomSpan = useCallback((startDate, endDate) => {
101
- _setValues(_values => ({
102
- ..._values,
103
- startDate,
104
- endDate
105
- }));
106
- }, []);
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;
121
- }
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;
133
- }
134
- const newFilters = [..._current.filters];
135
- newFilters[index] = filter;
136
- return {
137
- ..._current,
138
- filters: newFilters
139
- };
140
- });
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;
162
- }
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;
174
- }
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
- */
56
+ const useListHandlers = (key, _setValues) => {
57
+ const add = useCallback((item) => _setValues(c => ({ ...c, [key]: uniq([...c[key], item]) })),
58
+ // eslint-disable-next-line react-hooks/exhaustive-deps
59
+ []);
60
+ const remove = useCallback((item) => _setValues(c => {
61
+ const arr = c[key];
62
+ const i = arr.indexOf(item);
63
+ return i === -1 ? c : { ...c, [key]: arr.filter((_, idx) => idx !== i) };
64
+ }),
65
+ // eslint-disable-next-line react-hooks/exhaustive-deps
66
+ []);
67
+ const setAt = useCallback((pos, item) => _setValues(c => {
68
+ const arr = c[key];
69
+ if (pos < 0 || pos >= arr.length)
70
+ return c;
71
+ const next = [...arr];
72
+ next[pos] = item;
73
+ return { ...c, [key]: next };
74
+ }),
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ []);
77
+ const setAll = useCallback((items) => _setValues(c => ({ ...c, [key]: uniq(items) })),
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
+ []);
80
+ const reset = useCallback((defaultValue = []) => _setValues(c => ({ ...c, [key]: defaultValue })),
81
+ // eslint-disable-next-line react-hooks/exhaustive-deps
82
+ []);
83
+ return { add, remove, setAt, setAll, reset };
84
+ };
85
+ /**
86
+ * Synchronizes SearchValues state with the URL search string, and vice-versa.
87
+ */
88
+ const useUrlSync = (values, _setValues, params, setParams, pathname, search, routeId) => {
193
89
  const getUrlFromState = useCallback(() => {
194
90
  const changes = {};
91
+ // Scalar params: write if changed from URL, remove if back to default
195
92
  PARAM_MAPPINGS.forEach(([urlKey, stateKey]) => {
196
93
  const stateValue = values[stateKey];
197
94
  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
- }
95
+ if (stateValue === urlValue)
96
+ return;
97
+ if (params.has(urlKey) && stateValue === DEFAULT_VALUES[stateKey]) {
98
+ changes[urlKey] = null; // remove
99
+ }
100
+ else if (stateValue !== DEFAULT_VALUES[stateKey]) {
101
+ changes[urlKey] = stateValue; // write
206
102
  }
207
103
  });
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'))) {
104
+ // Array params: skip when state equals default and URL is already empty
105
+ ARRAY_PARAMS.forEach(({ urlKey, stateKey, default: def }) => {
106
+ const stateArr = values[stateKey];
107
+ const urlArr = params.getAll(urlKey);
108
+ if (isEqual(stateArr, urlArr))
109
+ return;
110
+ const isDefault = def ? isEqual(stateArr, def) : stateArr.length === 0;
111
+ if (!isDefault) {
112
+ changes[urlKey] = stateArr.length === 0 ? null : stateArr;
113
+ }
114
+ else if (urlArr.length > 0) {
115
+ changes[urlKey] = null; // state is default but URL isn't — remove
116
+ }
117
+ });
118
+ // selected
119
+ if (pathname.startsWith('/bundles') && (!params.has('selected') || values.selected === params.get('selected'))) {
223
120
  changes.selected = null;
224
121
  }
225
122
  else if (values.selected !== params.get('selected')) {
226
123
  changes.selected = values.selected;
227
124
  }
228
- // Handle offset: remove if 0, otherwise set
229
- const urlOffset = parseOffset(params.get('offset'));
230
- if (urlOffset !== values.offset) {
125
+ // offset: remove when 0
126
+ if (parseOffset(params.get('offset')) !== values.offset) {
231
127
  changes.offset = values.offset || null;
232
128
  }
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;
237
- }
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
- */
129
+ // Drop scalar entries that already match the URL
130
+ return omitBy(changes, (val, key) => !ARRAY_URL_KEYS.has(key) && val == params.get(key));
131
+ }, [values, params, pathname]);
244
132
  const getStateFromUrl = useCallback(() => {
245
133
  const changes = {};
134
+ // Scalar params: fall back to default when absent from URL
246
135
  PARAM_MAPPINGS.forEach(([urlKey, stateKey]) => {
247
136
  const urlValue = params.has(urlKey) ? params.get(urlKey) : (DEFAULT_VALUES[stateKey] ?? undefined);
248
137
  if (urlValue !== values[stateKey]) {
249
138
  changes[stateKey] = urlValue;
250
139
  }
251
140
  });
252
- // Handle filters: compare arrays with isEqual
253
- const urlFilters = uniq(params.getAll('filter'));
254
- if (!isEqual(urlFilters, values.filters)) {
255
- changes.filters = urlFilters;
256
- }
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) {
141
+ // Array params: fall back to their declared default when absent from URL
142
+ ARRAY_PARAMS.forEach(({ urlKey, stateKey, default: def }) => {
143
+ const raw = params.getAll(urlKey);
144
+ const resolved = (isEmpty(raw) && def ? def : uniq(raw));
145
+ if (!isEqual(resolved, values[stateKey])) {
146
+ changes[stateKey] = resolved;
147
+ }
148
+ });
149
+ // selected
150
+ const selectedValue = getSelectedValue(params, pathname, routeId);
151
+ if (selectedValue !== values.selected)
265
152
  changes.selected = selectedValue;
266
- }
267
- // Handle offset
153
+ // offset
268
154
  const urlOffset = parseOffset(params.get('offset'));
269
- if (urlOffset !== values.offset) {
155
+ if (urlOffset !== values.offset)
270
156
  changes.offset = urlOffset;
271
- }
272
- // Filter out undefined values and values that already match state
273
157
  return omitBy(omitBy(changes, isUndefined), (val, key) => val == values[key]);
274
- }, [values, params, location.pathname, routeParams.id]);
275
- /**
276
- * Effect to synchronize the context's state with the address bar
277
- */
158
+ }, [values, params, pathname, routeId]);
159
+ // State → URL
278
160
  useEffect(() => {
279
161
  const changes = getUrlFromState();
280
- if (isEmpty(changes)) {
162
+ if (isEmpty(changes))
281
163
  return;
282
- }
283
164
  setParams(_params => {
284
- // Build fresh URLSearchParams from existing params
285
165
  const newParams = new URLSearchParams(_params);
286
- // Handle standard params
287
166
  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)
167
+ if (Array.isArray(value)) {
168
+ newParams.delete(key);
169
+ value.forEach(val => newParams.append(key, val));
296
170
  }
297
171
  else if (value === null || value === undefined) {
298
172
  newParams.delete(key);
@@ -305,17 +179,63 @@ const ParameterProvider = ({ children }) => {
305
179
  }, { replace: !changes.query && !Object.keys(changes).includes('offset') });
306
180
  // eslint-disable-next-line react-hooks/exhaustive-deps
307
181
  }, [values]);
182
+ // URL → State
308
183
  useEffect(() => {
309
184
  const changes = getStateFromUrl();
310
- if (isEmpty(changes)) {
185
+ if (isEmpty(changes))
311
186
  return;
312
- }
313
- _setValues(_current => ({
314
- ..._current,
315
- ...changes
316
- }));
187
+ _setValues(c => ({ ...c, ...changes }));
317
188
  // eslint-disable-next-line react-hooks/exhaustive-deps
318
- }, [location.search, location.pathname, routeParams.id]);
189
+ }, [search, pathname, routeId]);
190
+ };
191
+ /**
192
+ * Context responsible for tracking updates to query operations in hit and view search.
193
+ */
194
+ const ParameterProvider = ({ children }) => {
195
+ const location = useLocation();
196
+ const routeParams = useParams();
197
+ const [params, setParams] = useSearchParams();
198
+ const pendingChanges = useRef({});
199
+ const [values, _setValues] = useState({
200
+ selected: getSelectedValue(params, location.pathname, routeParams.id),
201
+ query: params.get('query') ?? DEFAULT_VALUES.query,
202
+ sort: params.get('sort') ?? DEFAULT_VALUES.sort,
203
+ span: params.get('span') ?? DEFAULT_VALUES.span,
204
+ indexes: params.has('index')
205
+ ? uniq(params.getAll('index')).filter(identity)
206
+ : DEFAULT_VALUES.indexes,
207
+ filters: params.getAll('filter'),
208
+ views: params.getAll('view'),
209
+ startDate: params.get('start_date'),
210
+ endDate: params.get('end_date'),
211
+ offset: parseOffset(params.get('offset')),
212
+ trackTotalHits: (params.get('track_total_hits') ?? 'false') !== 'false'
213
+ });
214
+ // TODO: SELECTING A BUNDLE STILL CAUSES A FREAKOUT
215
+ useUrlSync(values, _setValues, params, setParams, location.pathname, location.search, routeParams.id);
216
+ const set = useCallback((key) => (value) => {
217
+ if (value === values[key])
218
+ return;
219
+ if (key === 'selected') {
220
+ pendingChanges.current.selected = value;
221
+ }
222
+ else {
223
+ pendingChanges.current[key] = value ?? DEFAULT_VALUES[key] ?? null;
224
+ }
225
+ if (key === 'span' && typeof value === 'string' && !value.endsWith('custom')) {
226
+ pendingChanges.current.startDate = null;
227
+ pendingChanges.current.endDate = null;
228
+ }
229
+ WRITE_THROTTLER.debounce(() => {
230
+ _setValues(c => ({ ...c, ...pendingChanges.current }));
231
+ pendingChanges.current = {};
232
+ });
233
+ }, [values]);
234
+ const setOffset = useCallback((_offset) => _setValues(c => ({ ...c, offset: parseOffset(_offset) })), []);
235
+ const setCustomSpan = useCallback((startDate, endDate) => _setValues(c => ({ ...c, startDate, endDate })), []);
236
+ const filters = useListHandlers('filters', _setValues);
237
+ const indexes = useListHandlers('indexes', _setValues);
238
+ const views = useListHandlers('views', _setValues);
319
239
  return (_jsx(ParameterContext.Provider, { value: {
320
240
  ...values,
321
241
  setOffset,
@@ -324,14 +244,19 @@ const ParameterProvider = ({ children }) => {
324
244
  setQuery: useMemo(() => set('query'), [set]),
325
245
  setSort: useMemo(() => set('sort'), [set]),
326
246
  setSpan: useMemo(() => set('span'), [set]),
327
- addFilter,
328
- removeFilter,
329
- setFilter,
330
- clearFilters,
331
- addView,
332
- removeView,
333
- setView,
334
- clearViews
247
+ addFilter: filters.add,
248
+ removeFilter: filters.remove,
249
+ setFilter: filters.setAt,
250
+ resetFilters: filters.reset,
251
+ addIndex: indexes.add,
252
+ removeIndex: indexes.remove,
253
+ setIndex: indexes.setAt,
254
+ setIndexes: indexes.setAll,
255
+ resetIndexes: useCallback(() => indexes.reset(DEFAULT_VALUES.indexes), [indexes]),
256
+ addView: views.add,
257
+ removeView: views.remove,
258
+ setView: views.setAt,
259
+ resetViews: views.reset
335
260
  }, children: children }));
336
261
  };
337
262
  export const useParameterContextSelector = (selector) => {