@cccsaurora/howler-ui 2.19.0-dev.1099 → 2.19.0-dev.1143

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 (45) 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.js +255 -0
  5. package/components/elements/hit/HitContextMenu.test.d.ts +1 -0
  6. package/components/{routes/hits/search → elements/hit}/grid/ColumnHeader.d.ts +2 -1
  7. package/components/{routes/hits/search → elements/hit}/grid/ColumnHeader.js +3 -3
  8. package/components/{routes/hits/search → elements/hit}/grid/HitRow.d.ts +1 -1
  9. package/components/elements/hit/grid/HitTable.d.ts +12 -0
  10. package/components/elements/hit/grid/HitTable.js +82 -0
  11. package/components/elements/view/LayoutToggle.d.ts +8 -0
  12. package/components/elements/view/LayoutToggle.js +12 -0
  13. package/components/elements/view/ViewTitle.d.ts +3 -0
  14. package/components/elements/view/ViewTitle.js +6 -3
  15. package/components/routes/hits/search/HitBrowser.js +2 -1
  16. package/components/routes/hits/search/SearchPane.js +6 -8
  17. package/components/routes/hits/search/ViewLink.js +3 -2
  18. package/components/routes/hits/search/grid/HitGrid.js +17 -85
  19. package/components/routes/hits/search/shared/LayoutSettings.js +25 -0
  20. package/components/routes/hits/search/shared/SearchActionMenu.d.ts +4 -0
  21. package/components/routes/hits/search/shared/SearchActionMenu.js +17 -0
  22. package/components/routes/home/ViewCard.js +1 -1
  23. package/components/routes/views/ViewComposer.js +32 -6
  24. package/locales/en/translation.json +6 -3
  25. package/locales/fr/translation.json +6 -3
  26. package/models/entities/generated/Column.d.ts +7 -0
  27. package/models/entities/generated/Host.d.ts +1 -0
  28. package/models/entities/generated/Settings.d.ts +4 -0
  29. package/package.json +2 -1
  30. package/utils/stringUtils.d.ts +1 -0
  31. package/utils/stringUtils.js +7 -0
  32. package/utils/stringUtils.test.js +21 -1
  33. package/components/routes/hits/search/LayoutSettings.js +0 -24
  34. /package/components/{routes/hits/search/HitContextMenu.test.d.ts → app/providers/GridColumnsProvider.test.d.ts} +0 -0
  35. /package/components/{routes/hits/search → elements/hit}/HitContextMenu.d.ts +0 -0
  36. /package/components/{routes/hits/search → elements/hit}/HitContextMenu.js +0 -0
  37. /package/components/{routes/hits/search → elements/hit}/HitContextMenu.test.js +0 -0
  38. /package/components/{routes/hits/search → elements/hit}/grid/AddColumnModal.d.ts +0 -0
  39. /package/components/{routes/hits/search → elements/hit}/grid/AddColumnModal.js +0 -0
  40. /package/components/{routes/hits/search → elements/hit}/grid/EnhancedCell.d.ts +0 -0
  41. /package/components/{routes/hits/search → elements/hit}/grid/EnhancedCell.js +0 -0
  42. /package/components/{routes/hits/search → elements/hit}/grid/HitRow.js +0 -0
  43. /package/components/routes/hits/search/{BundleParentMenu.d.ts → shared/BundleParentMenu.d.ts} +0 -0
  44. /package/components/routes/hits/search/{BundleParentMenu.js → shared/BundleParentMenu.js} +0 -0
  45. /package/components/routes/hits/search/{LayoutSettings.d.ts → shared/LayoutSettings.d.ts} +0 -0
@@ -74,7 +74,9 @@ import AvatarProvider from './providers/AvatarProvider';
74
74
  import CustomPluginProvider from './providers/CustomPluginProvider';
75
75
  import FavouriteProvider from './providers/FavouritesProvider';
76
76
  import FieldProvider from './providers/FieldProvider';
77
+ import GridColumnsProvider from './providers/GridColumnsProvider';
77
78
  import HitProvider from './providers/HitProvider';
79
+ import HitSearchProvider from './providers/HitSearchProvider';
78
80
  import LocalStorageProvider from './providers/LocalStorageProvider';
79
81
  import ModalProvider from './providers/ModalProvider';
80
82
  import OverviewProvider from './providers/OverviewProvider';
@@ -232,7 +234,7 @@ const createRouter = () => createBrowserRouter([
232
234
  },
233
235
  {
234
236
  path: 'views/create',
235
- element: (_jsx(ParameterProvider, { children: _jsx(ViewComposer, {}) }))
237
+ element: (_jsx(ParameterProvider, { children: _jsx(HitSearchProvider, { children: _jsx(GridColumnsProvider, { viewSource: "path", children: _jsx(ViewComposer, {}) }) }) }))
236
238
  },
237
239
  {
238
240
  path: 'views/:id',
@@ -240,7 +242,7 @@ const createRouter = () => createBrowserRouter([
240
242
  },
241
243
  {
242
244
  path: 'views/:id/edit',
243
- element: (_jsx(ParameterProvider, { children: _jsx(ViewComposer, {}) }))
245
+ element: (_jsx(ParameterProvider, { children: _jsx(HitSearchProvider, { children: _jsx(GridColumnsProvider, { viewSource: "path", children: _jsx(ViewComposer, {}) }) }) }))
244
246
  },
245
247
  {
246
248
  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 { HitSearchContext } from './HitSearchProvider';
9
+ import { ParameterContext } from './ParameterProvider';
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(HitSearchContext, 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 { HitSearchContext } from './HitSearchProvider';
8
+ import { ParameterContext } from './ParameterProvider';
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 mockHitSearchContext = {
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(HitSearchContext.Provider, { value: mockHitSearchContext, 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
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -1,7 +1,8 @@
1
1
  import { type FC, type SetStateAction } from 'react';
2
2
  declare const ColumnHeader: FC<{
3
- width: string;
3
+ width: number;
4
4
  col: string;
5
+ colSource?: string[];
5
6
  setColumns: (val: SetStateAction<string[]>) => void;
6
7
  onMouseDown: (col: string, event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
7
8
  }>;
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useSortable } from '@dnd-kit/sortable';
3
3
  import { CSS } from '@dnd-kit/utilities';
4
- import { DragIndicator, Remove } from '@mui/icons-material';
4
+ import { DragIndicator, JoinFull, Remove } from '@mui/icons-material';
5
5
  import { Box, IconButton, Stack, TableCell, Tooltip, useTheme } from '@mui/material';
6
6
  import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
7
7
  import FlexOne from '@cccsaurora/howler-ui/components/elements/addons/layout/FlexOne';
8
8
  import { useContext } from 'react';
9
- const ColumnHeader = ({ col, width, setColumns, onMouseDown }) => {
9
+ const ColumnHeader = ({ col, width, colSource, setColumns, onMouseDown }) => {
10
10
  const theme = useTheme();
11
11
  const { config } = useContext(ApiConfigContext);
12
12
  const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: col });
@@ -15,7 +15,7 @@ const ColumnHeader = ({ col, width, setColumns, onMouseDown }) => {
15
15
  borderRightColor: 'divider',
16
16
  py: 0.5,
17
17
  position: 'relative'
18
- }, style: { transform: CSS.Translate.toString(transform), transition }, children: _jsxs(Stack, { className: `col-${col.replaceAll('.', '-')}`, direction: "row", spacing: 1, alignItems: "center", sx: [{ minWidth: '220px' }, !!width ? { width, maxWidth: width } : { maxWidth: '300px' }], children: [_jsx(Tooltip, { title: config.indexes.hit[col].description, children: _jsx("span", { children: col }) }), _jsx(FlexOne, {}), _jsx(IconButton, { size: "small", sx: { fontSize: '1rem' }, onClick: () => setColumns(_columns => _columns.filter(_col => _col !== col)), children: _jsx(Remove, { fontSize: "inherit" }) }), _jsx(DragIndicator, { fontSize: "small", sx: { cursor: 'grab' }, ...attributes, ...listeners }), _jsx(Box, { sx: {
18
+ }, style: { transform: CSS.Translate.toString(transform), transition }, children: _jsxs(Stack, { className: `col-${col.replaceAll('.', '-')}`, direction: "row", spacing: 1, alignItems: "center", sx: [{ minWidth: '220px' }, !!width ? { width, maxWidth: width } : { maxWidth: '300px' }], children: [_jsx(Tooltip, { title: config.indexes.hit[col].description, children: _jsx("span", { children: col }) }), colSource?.length > 1 && (_jsx(Tooltip, { title: colSource.join(', '), children: _jsx(JoinFull, { fontSize: "small" }) })), _jsx(FlexOne, {}), _jsx(IconButton, { size: "small", sx: { fontSize: '1rem' }, onClick: () => setColumns(_columns => _columns.filter(_col => _col !== col)), children: _jsx(Remove, { fontSize: "inherit" }) }), _jsx(DragIndicator, { fontSize: "small", sx: { cursor: 'grab' }, ...attributes, ...listeners }), _jsx(Box, { sx: {
19
19
  position: 'absolute',
20
20
  top: theme.spacing(0.75),
21
21
  bottom: theme.spacing(0.75),
@@ -3,7 +3,7 @@ declare const _default: import("react").NamedExoticComponent<{
3
3
  hit: Hit;
4
4
  analyticIds: Record<string, string>;
5
5
  columns: string[];
6
- columnWidths: Record<string, string>;
6
+ columnWidths: Record<string, number>;
7
7
  collapseMainColumn: boolean;
8
8
  onClick: (e: React.MouseEvent<HTMLDivElement>, hit: Hit) => void;
9
9
  }>;
@@ -0,0 +1,12 @@
1
+ import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
2
+ import type { WithMetadata } from '@cccsaurora/howler-ui/models/WithMetadata';
3
+ import React, { type PropsWithChildren } from 'react';
4
+ declare const HitTable: ({ query, items, refreshItems, ContextMenu, contextMenuProps, onItemClick }: {
5
+ query: string;
6
+ items?: WithMetadata<Hit>[];
7
+ refreshItems?: (query: string, append?: boolean) => void;
8
+ ContextMenu?: React.FC<PropsWithChildren<object>>;
9
+ contextMenuProps?: object;
10
+ onItemClick?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>, hit: Hit) => void;
11
+ }) => import("react/jsx-runtime").JSX.Element;
12
+ export default HitTable;