@cccsaurora/howler-ui 2.19.0-cases.1138 → 2.19.0-cases.1192

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 (80) hide show
  1. package/components/app/App.js +4 -2
  2. package/components/app/providers/GridColumnsProvider.d.ts +52 -0
  3. package/components/app/providers/GridColumnsProvider.js +177 -0
  4. package/components/app/providers/GridColumnsProvider.test.d.ts +1 -0
  5. package/components/app/providers/GridColumnsProvider.test.js +255 -0
  6. package/components/app/providers/RecordSearchProvider.js +10 -18
  7. package/components/elements/addons/buttons/CustomIconButton.test.d.ts +1 -0
  8. package/components/elements/addons/buttons/CustomIconButton.test.js +81 -0
  9. package/components/elements/addons/layout/FlexVertical.test.d.ts +1 -0
  10. package/components/elements/addons/layout/FlexVertical.test.js +32 -0
  11. package/components/elements/addons/layout/vsbox/VSBoxContent.test.d.ts +1 -0
  12. package/components/elements/addons/layout/vsbox/VSBoxContent.test.js +26 -0
  13. package/components/elements/addons/search/phrase/WordLexer.test.d.ts +1 -0
  14. package/components/elements/addons/search/phrase/WordLexer.test.js +55 -0
  15. package/components/elements/hit/HitLabels.test.d.ts +1 -0
  16. package/components/elements/hit/HitLabels.test.js +77 -0
  17. package/components/elements/hit/HitLinks.d.ts +5 -4
  18. package/components/elements/hit/HitLinks.js +28 -10
  19. package/components/elements/hit/HitNotebooks.js +23 -22
  20. package/components/elements/hit/NotebookTooltip.d.ts +3 -0
  21. package/components/elements/hit/NotebookTooltip.js +8 -0
  22. package/components/elements/hit/PivotTooltip.d.ts +7 -0
  23. package/components/elements/hit/PivotTooltip.js +9 -0
  24. package/components/elements/hit/RelatedLinkTooltip.d.ts +6 -0
  25. package/components/elements/hit/RelatedLinkTooltip.js +8 -0
  26. package/components/elements/hit/ResolvePivotUrl.d.ts +4 -0
  27. package/components/elements/hit/ResolvePivotUrl.js +22 -0
  28. package/components/{routes/hits/search → elements/hit}/grid/ColumnHeader.d.ts +2 -1
  29. package/components/{routes/hits/search → elements/hit}/grid/ColumnHeader.js +3 -3
  30. package/components/{routes/hits/search → elements/hit}/grid/RecordRow.d.ts +1 -1
  31. package/components/elements/hit/grid/RecordTable.d.ts +13 -0
  32. package/components/elements/hit/grid/RecordTable.js +84 -0
  33. package/components/elements/hit/related/PivotLink.d.ts +3 -0
  34. package/components/elements/hit/related/PivotLink.js +5 -2
  35. package/components/elements/hit/related/RelatedLink.d.ts +1 -0
  36. package/components/elements/hit/related/RelatedLink.js +18 -15
  37. package/components/elements/view/LayoutToggle.d.ts +8 -0
  38. package/components/elements/view/LayoutToggle.js +12 -0
  39. package/components/elements/view/ViewTitle.d.ts +3 -0
  40. package/components/elements/view/ViewTitle.js +6 -3
  41. package/components/hooks/useMyTheme.test.d.ts +1 -0
  42. package/components/hooks/useMyTheme.test.js +35 -0
  43. package/components/routes/advanced/eqlTokenProvider.test.d.ts +1 -0
  44. package/components/routes/advanced/eqlTokenProvider.test.js +34 -0
  45. package/components/routes/advanced/luceneTokenProvider.test.d.ts +1 -0
  46. package/components/routes/advanced/luceneTokenProvider.test.js +34 -0
  47. package/components/routes/hits/search/RecordBrowser.js +4 -3
  48. package/components/routes/hits/search/SearchPane.js +4 -5
  49. package/components/routes/hits/search/ViewLink.js +3 -2
  50. package/components/routes/hits/search/grid/RecordGrid.d.ts +3 -0
  51. package/components/routes/hits/search/grid/RecordGrid.js +64 -0
  52. package/components/routes/hits/search/shared/LayoutSettings.js +25 -0
  53. package/components/routes/hits/search/shared/SearchActionMenu.d.ts +4 -0
  54. package/components/routes/hits/search/shared/SearchActionMenu.js +11 -0
  55. package/components/routes/home/HomeSettings.test.d.ts +1 -0
  56. package/components/routes/home/HomeSettings.test.js +44 -0
  57. package/components/routes/overviews/markdownExtendedTokenProvider.test.d.ts +1 -0
  58. package/components/routes/overviews/markdownExtendedTokenProvider.test.js +50 -0
  59. package/components/routes/views/ViewComposer.js +32 -6
  60. package/locales/en/translation.json +11 -3
  61. package/locales/fr/translation.json +11 -3
  62. package/models/entities/generated/Column.d.ts +7 -0
  63. package/models/entities/generated/Host.d.ts +1 -0
  64. package/models/entities/generated/Settings.d.ts +4 -0
  65. package/package.json +10 -12
  66. package/rest/AxiosClient.test.d.ts +1 -0
  67. package/rest/AxiosClient.test.js +184 -0
  68. package/utils/stringUtils.d.ts +1 -0
  69. package/utils/stringUtils.js +7 -0
  70. package/utils/stringUtils.test.js +21 -1
  71. package/utils/utils.test.js +119 -2
  72. package/components/routes/hits/search/LayoutSettings.js +0 -24
  73. package/components/routes/hits/search/grid/HitGrid.d.ts +0 -3
  74. package/components/routes/hits/search/grid/HitGrid.js +0 -134
  75. /package/components/{routes/hits/search → elements/hit}/grid/AddColumnModal.d.ts +0 -0
  76. /package/components/{routes/hits/search → elements/hit}/grid/AddColumnModal.js +0 -0
  77. /package/components/{routes/hits/search → elements/hit}/grid/EnhancedCell.d.ts +0 -0
  78. /package/components/{routes/hits/search → elements/hit}/grid/EnhancedCell.js +0 -0
  79. /package/components/{routes/hits/search → elements/hit}/grid/RecordRow.js +0 -0
  80. /package/components/routes/hits/search/{LayoutSettings.d.ts → shared/LayoutSettings.d.ts} +0 -0
@@ -82,11 +82,13 @@ import AvatarProvider from './providers/AvatarProvider';
82
82
  import CustomPluginProvider from './providers/CustomPluginProvider';
83
83
  import FavouriteProvider from './providers/FavouritesProvider';
84
84
  import FieldProvider from './providers/FieldProvider';
85
+ import GridColumnsProvider from './providers/GridColumnsProvider';
85
86
  import LocalStorageProvider from './providers/LocalStorageProvider';
86
87
  import ModalProvider from './providers/ModalProvider';
87
88
  import OverviewProvider from './providers/OverviewProvider';
88
89
  import ParameterProvider from './providers/ParameterProvider';
89
90
  import RecordProvider from './providers/RecordProvider';
91
+ import RecordSearchProvider from './providers/RecordSearchProvider';
90
92
  import SocketProvider from './providers/SocketProvider';
91
93
  import UserListProvider from './providers/UserListProvider';
92
94
  import ViewProvider from './providers/ViewProvider';
@@ -270,7 +272,7 @@ const createRouter = () => createBrowserRouter([
270
272
  },
271
273
  {
272
274
  path: 'views/create',
273
- element: (_jsx(ParameterProvider, { children: _jsx(ViewComposer, {}) }))
275
+ element: (_jsx(ParameterProvider, { children: _jsx(RecordSearchProvider, { children: _jsx(GridColumnsProvider, { viewSource: "path", children: _jsx(ViewComposer, {}) }) }) }))
274
276
  },
275
277
  {
276
278
  path: 'views/:id',
@@ -278,7 +280,7 @@ const createRouter = () => createBrowserRouter([
278
280
  },
279
281
  {
280
282
  path: 'views/:id/edit',
281
- element: (_jsx(ParameterProvider, { children: _jsx(ViewComposer, {}) }))
283
+ element: (_jsx(ParameterProvider, { children: _jsx(RecordSearchProvider, { children: _jsx(GridColumnsProvider, { viewSource: "path", children: _jsx(ViewComposer, {}) }) }) }))
282
284
  },
283
285
  {
284
286
  path: 'admin/users',
@@ -0,0 +1,52 @@
1
+ import { type PropsWithChildren, type SetStateAction } from 'react';
2
+ /**
3
+ * Shape of the context value exposed by {@link GridColumnsProvider}.
4
+ *
5
+ * Column state is sourced from active views when present, otherwise from local storage.
6
+ * Mutations follow the same rule: they write back to local storage only when there are no
7
+ * active views (or when `syncLocal` is explicitly set to `true`).
8
+ */
9
+ export type GridColumnsContextType = {
10
+ /** Ordered list of field keys to display as grid columns. */
11
+ columns: string[];
12
+ /**
13
+ * Replace or update the active column list.
14
+ * @param syncLocal - When `true`, also persists the change to local storage even if views are active.
15
+ */
16
+ setColumns: (columns: SetStateAction<string[]>, syncLocal?: boolean) => void;
17
+ /** Per-column widths (pixels, e.g. `120`), keyed by field name. */
18
+ columnWidths: Record<string, number>;
19
+ /**
20
+ * Set the width of a single column.
21
+ * @param syncLocal - When `true`, also persists the change to local storage even if views are active.
22
+ */
23
+ setColumnWidth: (column: string, width: number, syncLocal?: boolean) => void;
24
+ /**
25
+ * Maps each column field to the list of view titles that declared it.
26
+ * Empty array for columns that came from local storage rather than a view.
27
+ */
28
+ columnSources: Record<string, string[]>;
29
+ /** Persist the current in-memory column configuration (columns + widths) to local storage. */
30
+ syncToStorage: () => void;
31
+ /** `false` while an async view fetch is in flight; `true` once the column state is settled. */
32
+ isReady: boolean;
33
+ };
34
+ export declare const GridColumnsContext: import("react").Context<GridColumnsContextType>;
35
+ /**
36
+ * Manages which grid columns are active and how wide they are.
37
+ *
38
+ * **Priority order:**
39
+ * 1. Active views (fetched asynchronously) — columns and widths are derived from view settings.
40
+ * 2. Local storage — used as fallback when no views are active.
41
+ *
42
+ * Local storage is only written when there are no active views, keeping view-driven state
43
+ * isolated from the user's personal preferences.
44
+ *
45
+ * @param viewSource - Determines where view IDs are read from:
46
+ * - `"params"` (default) — reads from `ParameterContext` (query-string driven).
47
+ * - `"path"` — reads from the current route's `:id` param.
48
+ */
49
+ declare const GridColumnsProvider: ({ children, viewSource }: PropsWithChildren<{
50
+ viewSource?: "params" | "path";
51
+ }>) => import("react/jsx-runtime").JSX.Element;
52
+ export default GridColumnsProvider;
@@ -0,0 +1,177 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/useMyLocalStorage';
3
+ import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { useParams } from 'react-router-dom';
5
+ import { useContextSelector } from 'use-context-selector';
6
+ import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
7
+ import { parsePixelSizeStringToInt } from '@cccsaurora/howler-ui/utils/stringUtils';
8
+ import { ParameterContext } from './ParameterProvider';
9
+ import { RecordSearchContext } from './RecordSearchProvider';
10
+ import { ViewContext } from './ViewProvider';
11
+ export const GridColumnsContext = createContext(null);
12
+ /**
13
+ * Manages which grid columns are active and how wide they are.
14
+ *
15
+ * **Priority order:**
16
+ * 1. Active views (fetched asynchronously) — columns and widths are derived from view settings.
17
+ * 2. Local storage — used as fallback when no views are active.
18
+ *
19
+ * Local storage is only written when there are no active views, keeping view-driven state
20
+ * isolated from the user's personal preferences.
21
+ *
22
+ * @param viewSource - Determines where view IDs are read from:
23
+ * - `"params"` (default) — reads from `ParameterContext` (query-string driven).
24
+ * - `"path"` — reads from the current route's `:id` param.
25
+ */
26
+ const GridColumnsProvider = ({ children, viewSource = 'params' }) => {
27
+ const routeParams = useParams();
28
+ // Resolve view IDs from the appropriate source.
29
+ const parameterViewIds = useContextSelector(ParameterContext, ctx => ctx.views);
30
+ const pathViewIds = useMemo(() => (routeParams.id ? [routeParams.id] : []), [routeParams.id]);
31
+ const viewIds = viewSource === 'params' ? parameterViewIds : pathViewIds;
32
+ const getCurrentViews = useContextSelector(ViewContext, ctx => ctx.getCurrentViews);
33
+ const setDisplayType = useContextSelector(RecordSearchContext, ctx => ctx.setDisplayType);
34
+ // --- Local storage state (user's personal preferences) ---
35
+ const [localStorageColumns, setLocalStorageColumns] = useMyLocalStorageItem(StorageKey.GRID_COLUMNS, [
36
+ 'howler.outline.threat',
37
+ 'howler.outline.target',
38
+ 'howler.outline.indicators',
39
+ 'howler.outline.summary'
40
+ ]);
41
+ const [localStorageColumnWidths, setLocalStorageColumnWidths] = useMyLocalStorageItem(StorageKey.GRID_COLUMN_WIDTHS, {});
42
+ // --- In-memory state (may be overridden by view data) ---
43
+ const [contextColumns, setContextColumns] = useState(localStorageColumns);
44
+ /** Non-null only when columns were loaded from views; null means fall back to local storage widths. */
45
+ const [viewColumnWidths, setViewColumnWidths] = useState(null);
46
+ /** Non-null only when columns were loaded from views; tracks which view(s) declared each column. */
47
+ const [viewColumnSources, setViewColumnSources] = useState(null);
48
+ const [isReady, setIsReady] = useState(false);
49
+ // Parse local storage widths into numbers for backwards compatibility with css style strings
50
+ const parsedLocalStorageColumnWidths = useMemo(() => {
51
+ const parsed = {};
52
+ for (const [col, width] of Object.entries(localStorageColumnWidths)) {
53
+ const numWidth = typeof width === 'string' ? parsePixelSizeStringToInt(width) : width;
54
+ if (numWidth !== null) {
55
+ parsed[col] = numWidth;
56
+ }
57
+ }
58
+ return parsed;
59
+ }, [localStorageColumnWidths]);
60
+ /**
61
+ * Tracks the current async load "cycle" to guard against stale async results.
62
+ * - `viewIds` is snapshotted at the start of each effect run; the callback checks
63
+ * identity (`===`) before applying results to confirm it is still the current run.
64
+ * - `hasLocalEdits` is set to `true` the moment the user makes a manual change mid-flight,
65
+ * which causes the pending async response to be discarded.
66
+ */
67
+ const currentLoadRef = useRef({ viewIds, hasLocalEdits: false });
68
+ const hasViews = viewIds?.length > 0;
69
+ // Fetch and apply view column settings whenever the set of active views changes.
70
+ useEffect(() => {
71
+ // Start a new load cycle.
72
+ currentLoadRef.current = { viewIds, hasLocalEdits: false };
73
+ if (!hasViews) {
74
+ // No views active — fall back to local storage immediately.
75
+ setContextColumns(localStorageColumns);
76
+ setViewColumnWidths(null);
77
+ setViewColumnSources(null);
78
+ setIsReady(true);
79
+ return;
80
+ }
81
+ setIsReady(false);
82
+ getCurrentViews({ views: viewIds }).then(_views => {
83
+ // Discard stale results: either the user already made a manual edit, or viewIds changed.
84
+ if (currentLoadRef.current.hasLocalEdits) {
85
+ setIsReady(true);
86
+ return;
87
+ }
88
+ if (currentLoadRef.current.viewIds !== viewIds) {
89
+ return;
90
+ }
91
+ const views = _views.filter(Boolean);
92
+ if (!views.length) {
93
+ // Views resolved to nothing (e.g. all deleted) — fall back to local storage.
94
+ setContextColumns(localStorageColumns);
95
+ setViewColumnWidths(null);
96
+ setViewColumnSources(null);
97
+ setIsReady(true);
98
+ return;
99
+ }
100
+ // Honour the display-type declared by the highest-priority view.
101
+ if (views[0].settings?.display === 'list') {
102
+ setDisplayType('list');
103
+ }
104
+ else if (views[0].settings?.display === 'grid') {
105
+ setDisplayType('grid');
106
+ // Sort grid views by their position in the requested viewIds array so that
107
+ // the first-listed view has the highest precedence for column order and width.
108
+ const idRankMap = new Map(viewIds.map((id, i) => [id, i]));
109
+ const gridViews = views
110
+ .filter(v => v.settings?.display === 'grid')
111
+ .sort((a, b) => (idRankMap.get(a.view_id) ?? Infinity) - (idRankMap.get(b.view_id) ?? Infinity));
112
+ const columns = [];
113
+ const widths = {};
114
+ const sources = {};
115
+ for (const view of gridViews) {
116
+ for (const { field, width } of view.settings?.columns ?? []) {
117
+ if (!sources[field]) {
118
+ // First occurrence wins for column order and width.
119
+ columns.push(field);
120
+ widths[field] = width ?? undefined;
121
+ sources[field] = [view.title];
122
+ }
123
+ else {
124
+ // Subsequent views that also declare this field are recorded as additional sources.
125
+ sources[field].push(view.title);
126
+ }
127
+ }
128
+ }
129
+ setContextColumns(columns);
130
+ setViewColumnWidths(widths);
131
+ setViewColumnSources(sources);
132
+ }
133
+ setIsReady(true);
134
+ });
135
+ // eslint-disable-next-line react-hooks/exhaustive-deps
136
+ }, [viewIds, hasViews, getCurrentViews, localStorageColumns, setDisplayType]);
137
+ const setColumns = useCallback((columns, syncLocal) => {
138
+ // Persist to local storage only when there are no active views (or when explicitly requested).
139
+ if (!hasViews || syncLocal) {
140
+ const newColumns = typeof columns === 'function' ? columns(contextColumns) : columns;
141
+ setLocalStorageColumns(newColumns);
142
+ }
143
+ // Flag that a user edit has occurred so any in-flight async fetch won't overwrite this change.
144
+ currentLoadRef.current.hasLocalEdits = true;
145
+ setContextColumns(columns);
146
+ }, [hasViews, contextColumns, setLocalStorageColumns]);
147
+ // When views are active, use their widths; otherwise fall back to local storage widths.
148
+ const columnWidths = useMemo(() => viewColumnWidths ?? parsedLocalStorageColumnWidths, [viewColumnWidths, parsedLocalStorageColumnWidths]);
149
+ const setColumnWidth = useCallback((column, width, syncLocal) => {
150
+ if (!hasViews || syncLocal) {
151
+ // No active views — persist directly to local storage.
152
+ setLocalStorageColumnWidths({ ...parsedLocalStorageColumnWidths, [column]: width });
153
+ }
154
+ else {
155
+ // Active views — update the in-memory view widths only, and flag a local edit.
156
+ currentLoadRef.current.hasLocalEdits = true;
157
+ setViewColumnWidths(prev => ({ ...prev, [column]: width }));
158
+ }
159
+ }, [hasViews, parsedLocalStorageColumnWidths, setLocalStorageColumnWidths]);
160
+ // Map each column to the view(s) that declared it, or an empty array for local-storage columns.
161
+ const columnSources = useMemo(() => Object.fromEntries(contextColumns.map(col => [col, viewColumnSources?.[col] ?? []])), [viewColumnSources, contextColumns]);
162
+ /** Flush the current in-memory state to local storage (used by the "save to storage" action). */
163
+ const syncToStorage = useCallback(() => {
164
+ setLocalStorageColumns(contextColumns);
165
+ setLocalStorageColumnWidths(columnWidths);
166
+ }, [contextColumns, columnWidths, setLocalStorageColumns, setLocalStorageColumnWidths]);
167
+ return (_jsx(GridColumnsContext.Provider, { value: {
168
+ columns: contextColumns,
169
+ setColumns,
170
+ columnWidths,
171
+ setColumnWidth,
172
+ columnSources,
173
+ syncToStorage,
174
+ isReady
175
+ }, children: children }));
176
+ };
177
+ export default GridColumnsProvider;
@@ -0,0 +1,255 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { act, renderHook, waitFor } from '@testing-library/react';
3
+ import { useContext } from 'react';
4
+ import { setupContextSelectorMock, setupLocalStorageMock, setupReactRouterMock } from '@cccsaurora/howler-ui/tests/mocks';
5
+ import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
6
+ import GridColumnsProvider, { GridColumnsContext } from './GridColumnsProvider';
7
+ import { ParameterContext } from './ParameterProvider';
8
+ import { RecordSearchContext } from './RecordSearchProvider';
9
+ import { ViewContext } from './ViewProvider';
10
+ setupContextSelectorMock();
11
+ setupReactRouterMock();
12
+ const mockLocalStorage = setupLocalStorageMock();
13
+ import { useParams } from 'react-router-dom';
14
+ const mockParams = vi.mocked(useParams);
15
+ const mockGetCurrentViews = vi.fn();
16
+ const mockSetDisplayType = vi.fn();
17
+ const mockViewsContext = {
18
+ getCurrentViews: mockGetCurrentViews
19
+ };
20
+ const mockRecordSearchContext = {
21
+ setDisplayType: mockSetDisplayType
22
+ };
23
+ const makeWrapper = (parameterViewIds = [], routeId) => {
24
+ mockParams.mockReturnValue({ id: routeId });
25
+ const parameterContextValue = {
26
+ views: parameterViewIds
27
+ };
28
+ const Wrapper = ({ children }) => (_jsx(ParameterContext.Provider, { value: parameterContextValue, children: _jsx(ViewContext.Provider, { value: mockViewsContext, children: _jsx(RecordSearchContext.Provider, { value: mockRecordSearchContext, children: _jsx(GridColumnsProvider, { viewSource: routeId ? 'path' : 'params', children: children }) }) }) }));
29
+ return Wrapper;
30
+ };
31
+ const _prefixStorageKey = (key) => `howler.ui.${key}`;
32
+ describe('no views', () => {
33
+ beforeEach(() => {
34
+ mockLocalStorage.clear();
35
+ mockGetCurrentViews.mockClear();
36
+ mockSetDisplayType.mockClear();
37
+ mockGetCurrentViews.mockResolvedValue([]);
38
+ mockLocalStorage.setItem(_prefixStorageKey(StorageKey.GRID_COLUMNS), JSON.stringify(['col1', 'col2']));
39
+ mockLocalStorage.setItem(_prefixStorageKey(StorageKey.GRID_COLUMN_WIDTHS), JSON.stringify({ col1: '100px' }));
40
+ });
41
+ it('should get columns from local storage', () => {
42
+ const Wrapper = makeWrapper();
43
+ const hook = renderHook(() => useContext(GridColumnsContext), { wrapper: Wrapper });
44
+ expect(mockLocalStorage.getItem).toHaveBeenCalledWith(_prefixStorageKey(StorageKey.GRID_COLUMNS));
45
+ expect(hook.result.current.columns).toEqual(['col1', 'col2']);
46
+ expect(mockLocalStorage.getItem).toHaveBeenCalledWith(_prefixStorageKey(StorageKey.GRID_COLUMN_WIDTHS));
47
+ expect(hook.result.current.columnWidths).toEqual({ col1: 100 });
48
+ expect(mockSetDisplayType).not.toHaveBeenCalled();
49
+ });
50
+ it('should update columns in local storage', () => {
51
+ const Wrapper = makeWrapper();
52
+ const hook = renderHook(() => useContext(GridColumnsContext), { wrapper: Wrapper });
53
+ act(() => {
54
+ hook.result.current.setColumns(['col1', 'col3']);
55
+ });
56
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(_prefixStorageKey(StorageKey.GRID_COLUMNS), JSON.stringify(['col1', 'col3']));
57
+ expect(hook.result.current.columns).toEqual(['col1', 'col3']);
58
+ });
59
+ it('should update column widths in local storage', () => {
60
+ const Wrapper = makeWrapper();
61
+ const hook = renderHook(() => useContext(GridColumnsContext), { wrapper: Wrapper });
62
+ act(() => {
63
+ hook.result.current.setColumnWidth('col1', 150);
64
+ });
65
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(_prefixStorageKey(StorageKey.GRID_COLUMN_WIDTHS), JSON.stringify({ col1: 150 }));
66
+ expect(hook.result.current.columnWidths).toEqual({ col1: 150 });
67
+ });
68
+ it("should add a column width to local storage if it doesn't exist", () => {
69
+ const Wrapper = makeWrapper();
70
+ const hook = renderHook(() => useContext(GridColumnsContext), { wrapper: Wrapper });
71
+ act(() => {
72
+ hook.result.current.setColumnWidth('col2', 200);
73
+ });
74
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(_prefixStorageKey(StorageKey.GRID_COLUMN_WIDTHS), JSON.stringify({ col1: 100, col2: 200 }));
75
+ expect(hook.result.current.columnWidths).toEqual({ col1: 100, col2: 200 });
76
+ });
77
+ });
78
+ describe('with views', () => {
79
+ const mockViews = new Map([
80
+ [
81
+ 'view1',
82
+ {
83
+ view_id: 'view1',
84
+ settings: {
85
+ display: 'grid',
86
+ columns: [
87
+ {
88
+ field: 'viewCol1',
89
+ width: 101
90
+ },
91
+ {
92
+ field: 'viewCol2',
93
+ width: 102
94
+ },
95
+ {
96
+ field: 'viewCol3'
97
+ }
98
+ ]
99
+ }
100
+ }
101
+ ],
102
+ [
103
+ 'view2',
104
+ {
105
+ view_id: 'view2',
106
+ settings: {
107
+ display: 'grid',
108
+ columns: [
109
+ {
110
+ field: 'viewCol3',
111
+ width: 103
112
+ },
113
+ {
114
+ field: 'viewCol4',
115
+ width: 204
116
+ },
117
+ {
118
+ field: 'viewCol2',
119
+ width: 202
120
+ }
121
+ ]
122
+ }
123
+ }
124
+ ],
125
+ [
126
+ 'view3',
127
+ {
128
+ view_id: 'view3',
129
+ settings: {
130
+ display: 'grid',
131
+ columns: [
132
+ {
133
+ field: 'viewCol5',
134
+ width: 300
135
+ },
136
+ {
137
+ field: 'viewCol6',
138
+ width: 301
139
+ }
140
+ ]
141
+ }
142
+ }
143
+ ],
144
+ ['listView', { view_id: 'listView', settings: { display: 'list' } }]
145
+ ]);
146
+ const getDelayedCurrentViews = (timeout) => {
147
+ return ({ views }) => new Promise(resolve => setTimeout(() => resolve(views ? views.map((viewId) => mockViews.get(viewId)) : Array.from(mockViews.values())), timeout));
148
+ };
149
+ const localStorageColumns = JSON.stringify(['localCol1', 'localCol2']);
150
+ const localStorageColumnWidths = JSON.stringify({ localCol1: '100px' });
151
+ beforeEach(() => {
152
+ mockLocalStorage.clear();
153
+ mockGetCurrentViews.mockClear();
154
+ mockSetDisplayType.mockClear();
155
+ mockGetCurrentViews.mockImplementation(({ views }) => Promise.resolve(views ? views.map((viewId) => mockViews.get(viewId)) : Array.from(mockViews.values())));
156
+ mockLocalStorage.setItem(_prefixStorageKey(StorageKey.GRID_COLUMNS), localStorageColumns);
157
+ mockLocalStorage.setItem(_prefixStorageKey(StorageKey.GRID_COLUMN_WIDTHS), localStorageColumnWidths);
158
+ });
159
+ it('should get columns from parameters', async () => {
160
+ const Wrapper = makeWrapper(['view1', 'view2']);
161
+ const hook = renderHook(() => useContext(GridColumnsContext), { wrapper: Wrapper });
162
+ expect(mockGetCurrentViews).toHaveBeenCalledWith({ views: ['view1', 'view2'] });
163
+ await waitFor(() => expect(hook.result.current.isReady).toBe(true));
164
+ expect(hook.result.current.columns).toEqual(['viewCol1', 'viewCol2', 'viewCol3', 'viewCol4']);
165
+ expect(hook.result.current.columnWidths).toEqual({
166
+ viewCol1: 101,
167
+ viewCol2: 102,
168
+ viewCol4: 204
169
+ });
170
+ expect(mockSetDisplayType).toHaveBeenCalledWith('grid');
171
+ expect(mockLocalStorage.getItem(_prefixStorageKey(StorageKey.GRID_COLUMNS))).toEqual(localStorageColumns);
172
+ expect(mockLocalStorage.getItem(_prefixStorageKey(StorageKey.GRID_COLUMN_WIDTHS))).toEqual(localStorageColumnWidths);
173
+ });
174
+ it('should get columns from path', async () => {
175
+ const Wrapper = makeWrapper(['view1', 'view2'], 'view3');
176
+ const hook = renderHook(() => useContext(GridColumnsContext), { wrapper: Wrapper });
177
+ expect(mockGetCurrentViews).toHaveBeenCalledWith({ views: ['view3'] });
178
+ await waitFor(() => expect(hook.result.current.isReady).toBe(true));
179
+ expect(hook.result.current.columns).toEqual(['viewCol5', 'viewCol6']);
180
+ expect(hook.result.current.columnWidths).toEqual({ viewCol5: 300, viewCol6: 301 });
181
+ expect(mockSetDisplayType).toHaveBeenCalledWith('grid');
182
+ expect(mockLocalStorage.getItem(_prefixStorageKey(StorageKey.GRID_COLUMNS))).toEqual(localStorageColumns);
183
+ expect(mockLocalStorage.getItem(_prefixStorageKey(StorageKey.GRID_COLUMN_WIDTHS))).toEqual(localStorageColumnWidths);
184
+ });
185
+ it('should update columns in context', async () => {
186
+ const Wrapper = makeWrapper(['view1']);
187
+ const hook = renderHook(() => useContext(GridColumnsContext), { wrapper: Wrapper });
188
+ await waitFor(() => expect(hook.result.current.isReady).toBe(true));
189
+ await act(async () => {
190
+ hook.result.current.setColumns(['newCol1', 'newCol2']);
191
+ });
192
+ expect(hook.result.current.columns).toEqual(['newCol1', 'newCol2']);
193
+ expect(mockLocalStorage.getItem(_prefixStorageKey(StorageKey.GRID_COLUMNS))).toEqual(localStorageColumns);
194
+ expect(mockLocalStorage.getItem(_prefixStorageKey(StorageKey.GRID_COLUMN_WIDTHS))).toEqual(localStorageColumnWidths);
195
+ });
196
+ it('should update column widths in context', async () => {
197
+ const Wrapper = makeWrapper(['view1']);
198
+ const hook = renderHook(() => useContext(GridColumnsContext), { wrapper: Wrapper });
199
+ await waitFor(() => expect(hook.result.current.isReady).toBe(true));
200
+ await act(async () => {
201
+ hook.result.current.setColumnWidth('viewCol1', 150);
202
+ });
203
+ expect(hook.result.current.columnWidths).toEqual({
204
+ viewCol1: 150,
205
+ viewCol2: 102
206
+ });
207
+ expect(mockLocalStorage.getItem(_prefixStorageKey(StorageKey.GRID_COLUMNS))).toEqual(localStorageColumns);
208
+ expect(mockLocalStorage.getItem(_prefixStorageKey(StorageKey.GRID_COLUMN_WIDTHS))).toEqual(localStorageColumnWidths);
209
+ });
210
+ it("should add a column width to context if it doesn't exist", async () => {
211
+ const Wrapper = makeWrapper(['view1']);
212
+ const hook = renderHook(() => useContext(GridColumnsContext), { wrapper: Wrapper });
213
+ await waitFor(() => expect(hook.result.current.isReady).toBe(true));
214
+ await act(async () => {
215
+ hook.result.current.setColumnWidth('viewCol3', 150);
216
+ });
217
+ expect(hook.result.current.columnWidths).toEqual({
218
+ viewCol1: 101,
219
+ viewCol2: 102,
220
+ viewCol3: 150
221
+ });
222
+ expect(mockLocalStorage.getItem(_prefixStorageKey(StorageKey.GRID_COLUMNS))).toEqual(localStorageColumns);
223
+ expect(mockLocalStorage.getItem(_prefixStorageKey(StorageKey.GRID_COLUMN_WIDTHS))).toEqual(localStorageColumnWidths);
224
+ });
225
+ it('should not update context if views change but local edits have been made', async () => {
226
+ // force race condition
227
+ mockGetCurrentViews.mockImplementationOnce(getDelayedCurrentViews(100));
228
+ const Wrapper = makeWrapper(['view1']);
229
+ const hook = renderHook(() => useContext(GridColumnsContext), { wrapper: Wrapper });
230
+ await act(async () => {
231
+ hook.result.current.setColumns(['newCol1', 'newCol2']);
232
+ });
233
+ // dirty takes precedence
234
+ expect(hook.result.current.columns).toEqual(['newCol1', 'newCol2']);
235
+ });
236
+ it('should not update context if async view load returns for an outdated view set', async () => {
237
+ // force race condition
238
+ mockGetCurrentViews.mockImplementationOnce(getDelayedCurrentViews(100));
239
+ const hook = renderHook(() => useContext(GridColumnsContext), { wrapper: makeWrapper([], 'view1') });
240
+ mockParams.mockReturnValue({ id: 'view2' });
241
+ hook.rerender();
242
+ await waitFor(() => expect(hook.result.current.isReady).toBe(true));
243
+ // most recent request takes precedence
244
+ expect(hook.result.current.columns).toEqual(['viewCol3', 'viewCol4', 'viewCol2']);
245
+ });
246
+ it('should keep isReady false until all async view loads are complete', async () => {
247
+ mockGetCurrentViews.mockImplementation(getDelayedCurrentViews(100));
248
+ const hook = renderHook(() => useContext(GridColumnsContext), { wrapper: makeWrapper([], 'view1') });
249
+ mockGetCurrentViews.mockImplementation(getDelayedCurrentViews(200));
250
+ mockParams.mockReturnValue({ id: 'view2' });
251
+ hook.rerender();
252
+ await waitFor(() => expect(hook.result.current.isReady).toBe(true));
253
+ expect(hook.result.current.columns).toEqual(['viewCol3', 'viewCol4', 'viewCol2']);
254
+ });
255
+ });
@@ -4,11 +4,11 @@ import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
4
4
  import useMyLocalStorage, { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/useMyLocalStorage';
5
5
  import dayjs from 'dayjs';
6
6
  import i18n from '@cccsaurora/howler-ui/i18n';
7
- import { cloneDeep } from 'lodash-es';
7
+ import { cloneDeep, isNil } from 'lodash-es';
8
8
  import isNull from 'lodash-es/isNull';
9
9
  import isUndefined from 'lodash-es/isUndefined';
10
10
  import { useCallback, useEffect, useMemo, useState } from 'react';
11
- import { useLocation, useParams } from 'react-router-dom';
11
+ import { useLocation } from 'react-router-dom';
12
12
  import { createContext, useContextSelector } from 'use-context-selector';
13
13
  import { DEFAULT_QUERY, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
14
14
  import Throttler from '@cccsaurora/howler-ui/utils/Throttler';
@@ -20,7 +20,6 @@ export const RecordSearchContext = createContext(null);
20
20
  const THROTTLER = new Throttler(500);
21
21
  const RecordSearchProvider = ({ children }) => {
22
22
  const { get } = useMyLocalStorage();
23
- const routeParams = useParams();
24
23
  const location = useLocation();
25
24
  const { dispatchApi } = useMyApi();
26
25
  const pageCount = useMyLocalStorageItem(StorageKey.PAGE_COUNT, 25)[0];
@@ -101,7 +100,7 @@ const RecordSearchProvider = ({ children }) => {
101
100
  setError(null);
102
101
  try {
103
102
  const _response = await dispatchApi(api.v2.search.post(indexes, {
104
- offset: appendResults && response ? response.rows : offset,
103
+ offset: appendResults && !isNil(response?.rows) ? response.rows : offset,
105
104
  rows: pageCount,
106
105
  query: _query || DEFAULT_QUERY,
107
106
  sort,
@@ -134,29 +133,22 @@ const RecordSearchProvider = ({ children }) => {
134
133
  setSearching(false);
135
134
  }
136
135
  });
137
- },
138
- // We skip reloading when the response changes
139
- // eslint-disable-next-line react-hooks/exhaustive-deps
140
- [
136
+ }, [
141
137
  sort,
142
138
  span,
143
139
  query,
144
- startDate,
145
- endDate,
146
- filters,
147
140
  indexes,
148
141
  setQuery,
149
- location.pathname,
150
- routeParams.id,
151
- views,
152
- dispatchApi,
142
+ setQueryHistory,
143
+ queryHistory,
144
+ response?.rows,
153
145
  offset,
146
+ dispatchApi,
154
147
  pageCount,
148
+ getFilters,
155
149
  trackTotalHits,
156
150
  loadHits,
157
- getCurrentViews,
158
- setOffset,
159
- getFilters
151
+ setOffset
160
152
  ]);
161
153
  // We only run this when ancillary properties (i.e. filters, sorting) change
162
154
  useEffect(() => {