@cccsaurora/howler-ui 2.19.0-dev.1099 → 2.19.0-dev.1148
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/app/App.js +4 -2
- package/components/app/providers/GridColumnsProvider.d.ts +52 -0
- package/components/app/providers/GridColumnsProvider.js +177 -0
- package/components/app/providers/GridColumnsProvider.test.js +255 -0
- package/components/elements/addons/buttons/CustomIconButton.test.d.ts +1 -0
- package/components/elements/addons/buttons/CustomIconButton.test.js +81 -0
- package/components/elements/addons/layout/FlexVertical.test.d.ts +1 -0
- package/components/elements/addons/layout/FlexVertical.test.js +32 -0
- package/components/elements/addons/layout/vsbox/VSBoxContent.test.d.ts +1 -0
- package/components/elements/addons/layout/vsbox/VSBoxContent.test.js +26 -0
- package/components/elements/addons/search/phrase/WordLexer.test.d.ts +1 -0
- package/components/elements/addons/search/phrase/WordLexer.test.js +55 -0
- package/components/elements/hit/HitContextMenu.test.d.ts +1 -0
- package/components/{routes/hits/search → elements/hit}/grid/ColumnHeader.d.ts +2 -1
- package/components/{routes/hits/search → elements/hit}/grid/ColumnHeader.js +3 -3
- package/components/{routes/hits/search → elements/hit}/grid/HitRow.d.ts +1 -1
- package/components/elements/hit/grid/HitTable.d.ts +12 -0
- package/components/elements/hit/grid/HitTable.js +82 -0
- package/components/elements/view/LayoutToggle.d.ts +8 -0
- package/components/elements/view/LayoutToggle.js +12 -0
- package/components/elements/view/ViewTitle.d.ts +3 -0
- package/components/elements/view/ViewTitle.js +6 -3
- package/components/hooks/useMyTheme.test.d.ts +1 -0
- package/components/hooks/useMyTheme.test.js +35 -0
- package/components/routes/advanced/eqlTokenProvider.test.d.ts +1 -0
- package/components/routes/advanced/eqlTokenProvider.test.js +34 -0
- package/components/routes/advanced/luceneTokenProvider.test.d.ts +1 -0
- package/components/routes/advanced/luceneTokenProvider.test.js +34 -0
- package/components/routes/hits/search/HitBrowser.js +2 -1
- package/components/routes/hits/search/SearchPane.js +6 -8
- package/components/routes/hits/search/ViewLink.js +3 -2
- package/components/routes/hits/search/grid/HitGrid.js +17 -85
- package/components/routes/hits/search/shared/LayoutSettings.js +25 -0
- package/components/routes/hits/search/shared/SearchActionMenu.d.ts +4 -0
- package/components/routes/hits/search/shared/SearchActionMenu.js +17 -0
- package/components/routes/home/HomeSettings.test.d.ts +1 -0
- package/components/routes/home/HomeSettings.test.js +44 -0
- package/components/routes/home/ViewCard.js +1 -1
- package/components/routes/overviews/markdownExtendedTokenProvider.test.d.ts +1 -0
- package/components/routes/overviews/markdownExtendedTokenProvider.test.js +50 -0
- package/components/routes/views/ViewComposer.js +32 -6
- package/locales/en/translation.json +6 -3
- package/locales/fr/translation.json +6 -3
- package/models/entities/generated/Column.d.ts +7 -0
- package/models/entities/generated/Host.d.ts +1 -0
- package/models/entities/generated/Settings.d.ts +4 -0
- package/package.json +2 -1
- package/rest/AxiosClient.test.d.ts +1 -0
- package/rest/AxiosClient.test.js +184 -0
- package/utils/stringUtils.d.ts +1 -0
- package/utils/stringUtils.js +7 -0
- package/utils/stringUtils.test.js +21 -1
- package/utils/utils.test.js +119 -2
- package/components/routes/hits/search/LayoutSettings.js +0 -24
- /package/components/{routes/hits/search/HitContextMenu.test.d.ts → app/providers/GridColumnsProvider.test.d.ts} +0 -0
- /package/components/{routes/hits/search → elements/hit}/HitContextMenu.d.ts +0 -0
- /package/components/{routes/hits/search → elements/hit}/HitContextMenu.js +0 -0
- /package/components/{routes/hits/search → elements/hit}/HitContextMenu.test.js +0 -0
- /package/components/{routes/hits/search → elements/hit}/grid/AddColumnModal.d.ts +0 -0
- /package/components/{routes/hits/search → elements/hit}/grid/AddColumnModal.js +0 -0
- /package/components/{routes/hits/search → elements/hit}/grid/EnhancedCell.d.ts +0 -0
- /package/components/{routes/hits/search → elements/hit}/grid/EnhancedCell.js +0 -0
- /package/components/{routes/hits/search → elements/hit}/grid/HitRow.js +0 -0
- /package/components/routes/hits/search/{BundleParentMenu.d.ts → shared/BundleParentMenu.d.ts} +0 -0
- /package/components/routes/hits/search/{BundleParentMenu.js → shared/BundleParentMenu.js} +0 -0
- /package/components/routes/hits/search/{LayoutSettings.d.ts → shared/LayoutSettings.d.ts} +0 -0
package/components/app/App.js
CHANGED
|
@@ -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 {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/// <reference types="vitest" />
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import { setupReactRouterMock } from '@cccsaurora/howler-ui/tests/mocks';
|
|
5
|
+
import { vi } from 'vitest';
|
|
6
|
+
setupReactRouterMock();
|
|
7
|
+
import CustomIconButton from './CustomIconButton';
|
|
8
|
+
describe('CustomIconButton', () => {
|
|
9
|
+
afterAll(() => vi.resetModules());
|
|
10
|
+
describe('rendering', () => {
|
|
11
|
+
it('renders a button element', () => {
|
|
12
|
+
render(_jsx(CustomIconButton, { "aria-label": "test", children: "icon" }));
|
|
13
|
+
expect(screen.getByRole('button', { name: 'test' })).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
it('renders children inside the button', () => {
|
|
16
|
+
render(_jsx(CustomIconButton, { children: "\u2605" }));
|
|
17
|
+
expect(screen.getByText('★')).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
it('renders with MUI IconButton classes', () => {
|
|
20
|
+
render(_jsx(CustomIconButton, { children: "i" }));
|
|
21
|
+
expect(screen.getByRole('button')).toHaveClass('MuiIconButton-root');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe('disabled state', () => {
|
|
25
|
+
it('is disabled when disabled prop is true', () => {
|
|
26
|
+
render(_jsx(CustomIconButton, { disabled: true, children: "icon" }));
|
|
27
|
+
expect(screen.getByRole('button')).toBeDisabled();
|
|
28
|
+
});
|
|
29
|
+
it('is disabled when progress is truthy', () => {
|
|
30
|
+
render(_jsx(CustomIconButton, { progress: true, children: "icon" }));
|
|
31
|
+
expect(screen.getByRole('button')).toBeDisabled();
|
|
32
|
+
});
|
|
33
|
+
it('is NOT disabled when progress is set but clickableWithProgress=true', () => {
|
|
34
|
+
render(_jsx(CustomIconButton, { progress: true, clickableWithProgress: true, children: "icon" }));
|
|
35
|
+
expect(screen.getByRole('button')).not.toBeDisabled();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('progress indicator', () => {
|
|
39
|
+
it('renders a CircularProgress when progress is truthy', () => {
|
|
40
|
+
render(_jsx(CustomIconButton, { progress: true, children: "icon" }));
|
|
41
|
+
// CircularProgress renders an svg with role="progressbar"
|
|
42
|
+
expect(document.querySelector('circle')).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
it('does not render CircularProgress when progress is falsy', () => {
|
|
45
|
+
render(_jsx(CustomIconButton, { children: "icon" }));
|
|
46
|
+
expect(document.querySelector('[role="progressbar"]')).not.toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('tooltip', () => {
|
|
50
|
+
it('wraps the button in a Tooltip when tooltip prop is provided', () => {
|
|
51
|
+
render(_jsx(CustomIconButton, { tooltip: "Save", children: "icon" }));
|
|
52
|
+
// The button is wrapped in a <span> when a Tooltip is used
|
|
53
|
+
const span = screen.getByRole('button').closest('span');
|
|
54
|
+
expect(span).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
it('does not add a surrounding span when no tooltip is given', () => {
|
|
57
|
+
render(_jsx(CustomIconButton, { children: "icon" }));
|
|
58
|
+
const button = screen.getByRole('button');
|
|
59
|
+
// Without tooltip the immediate parent should NOT be a span wrapper
|
|
60
|
+
expect(button.parentElement?.tagName).not.toBe('SPAN');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe('route link', () => {
|
|
64
|
+
it('wraps the button in a Link when route prop is provided', () => {
|
|
65
|
+
render(_jsx(CustomIconButton, { route: "/hits", children: "icon" }));
|
|
66
|
+
expect(screen.getByRole('link')).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
it('does not render a link when no route is given', () => {
|
|
69
|
+
render(_jsx(CustomIconButton, { children: "icon" }));
|
|
70
|
+
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('href link', () => {
|
|
74
|
+
it('wraps the button in an anchor tag when href is provided', () => {
|
|
75
|
+
render(_jsx(CustomIconButton, { href: "https://example.com", children: "icon" }));
|
|
76
|
+
const anchor = screen.getByRole('link');
|
|
77
|
+
expect(anchor).toBeInTheDocument();
|
|
78
|
+
expect(anchor).toHaveAttribute('href', 'https://example.com');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/// <reference types="vitest" />
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import FlexVertical from './FlexVertical';
|
|
6
|
+
describe('FlexVertical', () => {
|
|
7
|
+
it('renders a div with display:flex and flex-direction:column', () => {
|
|
8
|
+
const { container } = render(_jsx(FlexVertical, { children: _jsx("span", { children: "child" }) }));
|
|
9
|
+
const div = container.firstChild;
|
|
10
|
+
expect(div).toBeInTheDocument();
|
|
11
|
+
expect(div).toHaveStyle({ display: 'flex', flexDirection: 'column' });
|
|
12
|
+
});
|
|
13
|
+
it('renders children inside the flex container', () => {
|
|
14
|
+
render(_jsx(FlexVertical, { children: _jsx("span", { id: "inner", children: "hello" }) }));
|
|
15
|
+
expect(screen.getByTestId('inner')).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
it('renders multiple children', () => {
|
|
18
|
+
render(_jsxs(FlexVertical, { children: [_jsx("span", { id: "a", children: "A" }), _jsx("span", { id: "b", children: "B" })] }));
|
|
19
|
+
expect(screen.getByTestId('a')).toBeInTheDocument();
|
|
20
|
+
expect(screen.getByTestId('b')).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
it('applies the default flex value of 1', () => {
|
|
23
|
+
const { container } = render(_jsx(FlexVertical, { children: _jsx("span", { children: "child" }) }));
|
|
24
|
+
const div = container.firstChild;
|
|
25
|
+
expect(div).toHaveStyle({ flex: 1 });
|
|
26
|
+
});
|
|
27
|
+
it('applies a custom flex value when provided', () => {
|
|
28
|
+
const { container } = render(_jsx(FlexVertical, { flex: 2, children: _jsx("span", { children: "child" }) }));
|
|
29
|
+
const div = container.firstChild;
|
|
30
|
+
expect(div).toHaveStyle({ flex: 2 });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|