@cccsaurora/howler-ui 2.19.0-dev.950 → 2.19.0-dev.959
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/providers/ModalProvider.d.ts +1 -0
- package/components/app/providers/ModalProvider.js +5 -1
- package/components/routes/action/view/ActionDetails.js +4 -2
- package/components/routes/action/view/ActionSearch.js +12 -7
- package/components/routes/analytics/AnalyticDetails.js +9 -5
- package/components/routes/dossiers/Dossiers.js +15 -11
- package/components/routes/overviews/OverviewViewer.js +12 -8
- package/components/routes/overviews/Overviews.js +15 -11
- package/components/routes/templates/TemplateViewer.js +18 -13
- package/components/routes/views/Views.js +8 -4
- package/package.json +1 -1
- package/rest/FetchClient.test.d.ts +1 -0
- package/rest/FetchClient.test.js +81 -0
- package/utils/Throttler.test.d.ts +1 -0
- package/utils/Throttler.test.js +135 -0
- package/utils/hitFunctions.test.d.ts +1 -0
- package/utils/hitFunctions.test.js +52 -0
- package/utils/localStorage.test.d.ts +1 -0
- package/utils/localStorage.test.js +96 -0
- package/utils/menuUtils.test.d.ts +1 -0
- package/utils/menuUtils.test.js +198 -0
- package/utils/sessionStorage.test.d.ts +1 -0
- package/utils/sessionStorage.test.js +81 -0
- package/utils/socketUtils.test.d.ts +1 -0
- package/utils/socketUtils.test.js +26 -0
- package/utils/utils.test.js +1 -1
- package/utils/xsrf.test.d.ts +1 -0
- package/utils/xsrf.test.js +41 -0
|
@@ -6,6 +6,7 @@ export interface ModalOptions {
|
|
|
6
6
|
}
|
|
7
7
|
interface ModalContextType {
|
|
8
8
|
showModal: (children: ReactNode, options?: ModalOptions) => () => void;
|
|
9
|
+
withConfirmDeleteModal: (onConfirm: () => void, preferDelete?: boolean, preferCancel?: boolean) => () => void;
|
|
9
10
|
content?: ReactNode;
|
|
10
11
|
setContent: (children: ReactNode) => void;
|
|
11
12
|
options?: ModalOptions;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import ConfirmDeleteModal from '@cccsaurora/howler-ui/components/elements/display/modals/ConfirmDeleteModal';
|
|
2
3
|
import { createContext, useCallback, useEffect, useState } from 'react';
|
|
3
4
|
const defaultOptions = {
|
|
4
5
|
disableClose: false
|
|
@@ -23,6 +24,9 @@ const ModalProvider = ({ children }) => {
|
|
|
23
24
|
return () => setContent(null);
|
|
24
25
|
}, [options]);
|
|
25
26
|
const close = useCallback(() => setContent(null), []);
|
|
26
|
-
|
|
27
|
+
const withConfirmDeleteModal = useCallback((onConfirm, preferDelete, preferCancel) => {
|
|
28
|
+
return showModal(_jsx(ConfirmDeleteModal, { onConfirm: onConfirm, preferDelete: preferDelete, preferCancel: preferCancel }));
|
|
29
|
+
}, [showModal]);
|
|
30
|
+
return (_jsx(ModalContext.Provider, { value: { showModal, withConfirmDeleteModal, content, setContent, options, close }, children: children }));
|
|
27
31
|
};
|
|
28
32
|
export default ModalProvider;
|
|
@@ -4,13 +4,14 @@ import { Button, Checkbox, FormControlLabel, FormGroup, IconButton, LinearProgre
|
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks';
|
|
6
6
|
import PageCenter from '@cccsaurora/howler-ui/commons/components/pages/PageCenter';
|
|
7
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
7
8
|
import FlexOne from '@cccsaurora/howler-ui/components/elements/addons/layout/FlexOne';
|
|
8
9
|
import Phrase from '@cccsaurora/howler-ui/components/elements/addons/search/phrase/Phrase';
|
|
9
10
|
import HowlerAvatar from '@cccsaurora/howler-ui/components/elements/display/HowlerAvatar';
|
|
10
11
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
11
12
|
import OperationEntry from '@cccsaurora/howler-ui/components/routes/action/shared/OperationEntry';
|
|
12
13
|
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
13
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
14
|
+
import { useCallback, useContext, useEffect, useState } from 'react';
|
|
14
15
|
import { useTranslation } from 'react-i18next';
|
|
15
16
|
import { usePluginStore } from 'react-pluggable';
|
|
16
17
|
import { Link, useParams } from 'react-router-dom';
|
|
@@ -26,6 +27,7 @@ const ActionDetails = () => {
|
|
|
26
27
|
const { response, onSearch, loading, setLoading, executeAction, deleteAction, progress, report } = useMyActionFunctions();
|
|
27
28
|
const [operations, setOperations] = useState([]);
|
|
28
29
|
const [action, setAction] = useState();
|
|
30
|
+
const { withConfirmDeleteModal } = useContext(ModalContext);
|
|
29
31
|
const onTriggerChange = useCallback(async (e) => {
|
|
30
32
|
let newTriggers = action.triggers ?? [];
|
|
31
33
|
if (e.target.checked && !newTriggers.includes(e.target.name)) {
|
|
@@ -63,7 +65,7 @@ const ActionDetails = () => {
|
|
|
63
65
|
user.roles.includes('admin') ||
|
|
64
66
|
user.roles.includes('actionrunner_basic') ||
|
|
65
67
|
user.roles.includes('actionrunner_advanced');
|
|
66
|
-
return (_jsx(PageCenter, { maxWidth: "1500px", textAlign: "left", height: "100%", children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", justifyContent: "space-between", children: [_jsx(Typography, { variant: "h5", children: action?.name }), action?.owner_id && _jsx(HowlerAvatar, { sx: { width: 32, height: 32 }, userId: action.owner_id })] }), _jsx(Phrase, { fullWidth: true, value: action?.query, disabled: true, size: "small", onChange: () => { }, startAdornment: _jsx(IconButton, { onClick: () => onSearch(action?.query), children: _jsx(Search, { fontSize: "small" }) }) }), _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [response && _jsx(QueryResultText, { count: response.total, query: action?.query }), _jsx(FlexOne, {}), ((action?.owner_id === user.username && editRoles) || user.roles?.includes('admin')) && (_jsx(Button, { startIcon: _jsx(Delete, {}), size: "small", variant: "outlined", color: "error", onClick: () => deleteAction(action?.action_id), children: t('button.delete') })), execRoles && (_jsx(Button, { startIcon: _jsx(PlayCircleOutline, {}), size: "small", variant: "outlined", color: "success", onClick: () => executeAction(action?.action_id), children: t('route.actions.execute') })), ((action?.owner_id === user.username && editRoles) || user.roles?.includes('admin')) && (_jsx(Button, { startIcon: _jsx(Edit, {}), size: "small", variant: "outlined", component: Link, to: `/action/${params.id}/edit`, children: t('route.actions.edit') }))] }), user.roles.includes('automation_advanced') && (_jsx(FormGroup, { children: _jsx(Stack, { direction: "row", spacing: 1, children: action?.operations
|
|
68
|
+
return (_jsx(PageCenter, { maxWidth: "1500px", textAlign: "left", height: "100%", children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", justifyContent: "space-between", children: [_jsx(Typography, { variant: "h5", children: action?.name }), action?.owner_id && _jsx(HowlerAvatar, { sx: { width: 32, height: 32 }, userId: action.owner_id })] }), _jsx(Phrase, { fullWidth: true, value: action?.query, disabled: true, size: "small", onChange: () => { }, startAdornment: _jsx(IconButton, { onClick: () => onSearch(action?.query), children: _jsx(Search, { fontSize: "small" }) }) }), _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [response && _jsx(QueryResultText, { count: response.total, query: action?.query }), _jsx(FlexOne, {}), ((action?.owner_id === user.username && editRoles) || user.roles?.includes('admin')) && (_jsx(Button, { startIcon: _jsx(Delete, {}), size: "small", variant: "outlined", color: "error", onClick: () => withConfirmDeleteModal(() => deleteAction(action?.action_id)), children: t('button.delete') })), execRoles && (_jsx(Button, { startIcon: _jsx(PlayCircleOutline, {}), size: "small", variant: "outlined", color: "success", onClick: () => executeAction(action?.action_id), children: t('route.actions.execute') })), ((action?.owner_id === user.username && editRoles) || user.roles?.includes('admin')) && (_jsx(Button, { startIcon: _jsx(Edit, {}), size: "small", variant: "outlined", component: Link, to: `/action/${params.id}/edit`, children: t('route.actions.edit') }))] }), user.roles.includes('automation_advanced') && (_jsx(FormGroup, { children: _jsx(Stack, { direction: "row", spacing: 1, children: action?.operations
|
|
67
69
|
?.map(a => (operations ?? []).find(_action => _action.id === a.operation_id)?.triggers ?? [])
|
|
68
70
|
.reduce((acc, triggers) => acc.filter(_t => triggers.includes(_t)))
|
|
69
71
|
.map(trigger => (_jsx(FormControlLabel, { control: _jsx(Checkbox, { name: trigger, onChange: onTriggerChange, checked: action?.triggers?.includes(trigger) ?? false }), label: t(`route.actions.trigger.${trigger}`) }, trigger))) }) })), loading &&
|
|
@@ -3,6 +3,7 @@ import { Delete, Engineering, Terminal } from '@mui/icons-material';
|
|
|
3
3
|
import { Autocomplete, Card, CardContent, CardHeader, Chip, Grid, IconButton, Stack, TextField, Tooltip, Typography } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks';
|
|
6
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
6
7
|
import FlexOne from '@cccsaurora/howler-ui/components/elements/addons/layout/FlexOne';
|
|
7
8
|
import { TuiListProvider } from '@cccsaurora/howler-ui/components/elements/addons/lists';
|
|
8
9
|
import { TuiListMethodContext } from '@cccsaurora/howler-ui/components/elements/addons/lists/TuiListProvider';
|
|
@@ -22,6 +23,7 @@ const ActionSearch = () => {
|
|
|
22
23
|
const { user } = useAppUser();
|
|
23
24
|
const { dispatchApi } = useMyApi();
|
|
24
25
|
const { load } = useContext(TuiListMethodContext);
|
|
26
|
+
const { withConfirmDeleteModal } = useContext(ModalContext);
|
|
25
27
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
26
28
|
const { deleteAction } = useMyActionFunctions();
|
|
27
29
|
const pageCount = useMyLocalStorageItem(StorageKey.PAGE_COUNT, 25)[0];
|
|
@@ -70,6 +72,14 @@ const ActionSearch = () => {
|
|
|
70
72
|
setOffset(_offset);
|
|
71
73
|
}
|
|
72
74
|
}, [offset, searchParams, setSearchParams]);
|
|
75
|
+
const onDelete = useCallback((e, actionId) => {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
e.stopPropagation();
|
|
78
|
+
withConfirmDeleteModal(async () => {
|
|
79
|
+
await deleteAction(actionId);
|
|
80
|
+
onSearch();
|
|
81
|
+
});
|
|
82
|
+
}, [deleteAction, onSearch, withConfirmDeleteModal]);
|
|
73
83
|
// Effect to initialize list of users.
|
|
74
84
|
useEffect(() => {
|
|
75
85
|
onSearch();
|
|
@@ -105,13 +115,8 @@ const ActionSearch = () => {
|
|
|
105
115
|
transitionProperty: 'border-color',
|
|
106
116
|
cursor: 'pointer',
|
|
107
117
|
mt: 1
|
|
108
|
-
}, children: [_jsx(CardHeader, { title: _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { variant: "h5", children: item.item.name }), item.item.triggers.length > 0 && (_jsx(Tooltip, { title: _jsx(Trans, { i18nKey: "route.actions.trigger.description", values: { triggers: item.item.triggers.join(', ') }, components: { bold: _jsx("strong", {}) } }), children: _jsx(Engineering, {}) })), _jsx(FlexOne, {}), ((item.item.owner_id === user.username && editRoles) || user.roles?.includes('admin')) && (_jsx(IconButton, { size: "small", onClick:
|
|
109
|
-
|
|
110
|
-
e.stopPropagation();
|
|
111
|
-
await deleteAction(item.item.action_id);
|
|
112
|
-
onSearch();
|
|
113
|
-
}, children: _jsx(Delete, {}) })), _jsx(HowlerAvatar, { sx: { width: 24, height: 24, marginRight: '8px !important' }, userId: item.item.owner_id })] }), subheader: item.item.query }), _jsx(CardContent, { sx: { paddingTop: 0 }, children: _jsx(Grid, { container: true, spacing: 1, children: item.item.operations.map(d => (_jsx(Grid, { item: true, children: _jsx(Chip, { size: "small", label: t(`operations.${d.operation_id}`) }) }, d.operation_id))) }) })] }, item.item.name));
|
|
114
|
-
}, [deleteAction, editRoles, navigate, onSearch, t, user.roles, user.username]);
|
|
118
|
+
}, children: [_jsx(CardHeader, { title: _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { variant: "h5", children: item.item.name }), item.item.triggers.length > 0 && (_jsx(Tooltip, { title: _jsx(Trans, { i18nKey: "route.actions.trigger.description", values: { triggers: item.item.triggers.join(', ') }, components: { bold: _jsx("strong", {}) } }), children: _jsx(Engineering, {}) })), _jsx(FlexOne, {}), ((item.item.owner_id === user.username && editRoles) || user.roles?.includes('admin')) && (_jsx(IconButton, { size: "small", onClick: e => onDelete(e, item.item.action_id), children: _jsx(Delete, {}) })), _jsx(HowlerAvatar, { sx: { width: 24, height: 24, marginRight: '8px !important' }, userId: item.item.owner_id })] }), subheader: item.item.query }), _jsx(CardContent, { sx: { paddingTop: 0 }, children: _jsx(Grid, { container: true, spacing: 1, children: item.item.operations.map(d => (_jsx(Grid, { item: true, children: _jsx(Chip, { size: "small", label: t(`operations.${d.operation_id}`) }) }, d.operation_id))) }) })] }, item.item.name));
|
|
119
|
+
}, [editRoles, navigate, onDelete, t, user.roles, user.username]);
|
|
115
120
|
return (_jsx(ItemManager, { onSearch: onSearch, onCreate: editRoles ? () => navigate('/action/execute') : undefined, onPageChange: onPageChange, phrase: phrase, setPhrase: setPhrase, hasError: hasError, searching: searching, aboveSearch: _jsx(Typography, { sx: theme => ({ fontStyle: 'italic', color: theme.palette.text.disabled, mb: 0.5 }), variant: "body2", children: t('route.actions.search.prompt') }), searchFilters: _jsx(Autocomplete, { multiple: true, size: "small", value: searchModifiers, onChange: (__, values) => setSearchModifiers(values), getOptionLabel: trigger => t(`route.actions.trigger.${trigger}`), options: VALID_ACTION_TRIGGERS, renderInput: params => (_jsx(TextField, { ...params, sx: { maxWidth: '500px' }, label: t('route.actions.trigger') })) }), renderer: renderer, response: response, createPrompt: "route.actions.create", searchPrompt: "route.actions.search", createIcon: _jsx(Terminal, { sx: { mr: 1 } }) }));
|
|
116
121
|
};
|
|
117
122
|
const ActionSearchProvider = () => {
|
|
@@ -5,6 +5,7 @@ import api from '@cccsaurora/howler-ui/api';
|
|
|
5
5
|
import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks';
|
|
6
6
|
import PageCenter from '@cccsaurora/howler-ui/commons/components/pages/PageCenter';
|
|
7
7
|
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
8
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
8
9
|
import { UserListContext } from '@cccsaurora/howler-ui/components/app/providers/UserListProvider';
|
|
9
10
|
import UserList from '@cccsaurora/howler-ui/components/elements/UserList';
|
|
10
11
|
import HowlerAvatar from '@cccsaurora/howler-ui/components/elements/display/HowlerAvatar';
|
|
@@ -31,6 +32,7 @@ const AnalyticDetails = () => {
|
|
|
31
32
|
const { dispatchApi } = useMyApi();
|
|
32
33
|
const theme = useTheme();
|
|
33
34
|
const { showSuccessMessage } = useMySnackbar();
|
|
35
|
+
const { withConfirmDeleteModal } = useContext(ModalContext);
|
|
34
36
|
const { users, searchUsers } = useContext(UserListContext);
|
|
35
37
|
const { config } = useContext(ApiConfigContext);
|
|
36
38
|
const [analytic, setAnalytic] = useState(null);
|
|
@@ -58,11 +60,13 @@ const AnalyticDetails = () => {
|
|
|
58
60
|
});
|
|
59
61
|
setAnalytic(result);
|
|
60
62
|
}, [analytic?.analytic_id, dispatchApi]);
|
|
61
|
-
const onDelete = useCallback(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
const onDelete = useCallback(() => {
|
|
64
|
+
withConfirmDeleteModal(async () => {
|
|
65
|
+
await dispatchApi(api.analytic.del(analytic?.analytic_id));
|
|
66
|
+
showSuccessMessage(t('route.analytics.deleted'));
|
|
67
|
+
navigate('/analytics');
|
|
68
|
+
});
|
|
69
|
+
}, [analytic?.analytic_id, dispatchApi, navigate, withConfirmDeleteModal, showSuccessMessage, t]);
|
|
66
70
|
const onEdit = useCallback(async () => {
|
|
67
71
|
if (editingInterval) {
|
|
68
72
|
setIntervalLoading(true);
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { Topic } from '@mui/icons-material';
|
|
3
3
|
import { Typography } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
5
6
|
import { TuiListProvider } from '@cccsaurora/howler-ui/components/elements/addons/lists';
|
|
6
7
|
import { TuiListMethodContext } from '@cccsaurora/howler-ui/components/elements/addons/lists/TuiListProvider';
|
|
7
8
|
import ItemManager from '@cccsaurora/howler-ui/components/elements/display/ItemManager';
|
|
@@ -18,6 +19,7 @@ const DossiersBase = () => {
|
|
|
18
19
|
const navigate = useNavigate();
|
|
19
20
|
const { dispatchApi } = useMyApi();
|
|
20
21
|
const { showSuccessMessage } = useMySnackbar();
|
|
22
|
+
const { withConfirmDeleteModal } = useContext(ModalContext);
|
|
21
23
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
22
24
|
const { load } = useContext(TuiListMethodContext);
|
|
23
25
|
const pageCount = useMyLocalStorageItem(StorageKey.PAGE_COUNT, 25)[0];
|
|
@@ -73,19 +75,21 @@ const DossiersBase = () => {
|
|
|
73
75
|
setOffset(_offset);
|
|
74
76
|
}
|
|
75
77
|
}, [offset, searchParams, setSearchParams]);
|
|
76
|
-
const onDelete = useCallback(
|
|
78
|
+
const onDelete = useCallback((e, id) => {
|
|
77
79
|
e.preventDefault();
|
|
78
80
|
e.stopPropagation();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
withConfirmDeleteModal(async () => {
|
|
82
|
+
try {
|
|
83
|
+
await dispatchApi(api.dossier.del(id), { throwError: false, showError: true });
|
|
84
|
+
await onSearch();
|
|
85
|
+
showSuccessMessage(t('route.dossiers.manager.delete.success'));
|
|
86
|
+
}
|
|
87
|
+
catch (_err) {
|
|
88
|
+
// eslint-disable-next-line no-console
|
|
89
|
+
console.warn(_err);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}, [dispatchApi, onSearch, withConfirmDeleteModal, showSuccessMessage, t]);
|
|
89
93
|
useEffect(() => {
|
|
90
94
|
onSearch();
|
|
91
95
|
if (!searchParams.has('offset')) {
|
|
@@ -8,6 +8,7 @@ import { Check, DarkMode, Delete, SsidChart, WbSunny } from '@mui/icons-material
|
|
|
8
8
|
import { useApp } from '@cccsaurora/howler-ui/commons/components/app/hooks';
|
|
9
9
|
import AppInfoPanel from '@cccsaurora/howler-ui/commons/components/display/AppInfoPanel';
|
|
10
10
|
import useThemeBuilder from '@cccsaurora/howler-ui/commons/components/utils/hooks/useThemeBuilder';
|
|
11
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
11
12
|
import { OverviewContext } from '@cccsaurora/howler-ui/components/app/providers/OverviewProvider';
|
|
12
13
|
import HitOverview from '@cccsaurora/howler-ui/components/elements/hit/HitOverview';
|
|
13
14
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
@@ -25,6 +26,7 @@ const OverviewViewer = () => {
|
|
|
25
26
|
const [params, setParams] = useSearchParams();
|
|
26
27
|
const { getOverviews } = useContext(OverviewContext);
|
|
27
28
|
const { dispatchApi } = useMyApi();
|
|
29
|
+
const { withConfirmDeleteModal } = useContext(ModalContext);
|
|
28
30
|
const [overviewList, setOverviewList] = useState([]);
|
|
29
31
|
const [selectedOverview, setSelectedOverview] = useState(null);
|
|
30
32
|
const [content, setContent] = useState('');
|
|
@@ -124,15 +126,17 @@ const OverviewViewer = () => {
|
|
|
124
126
|
replace: true
|
|
125
127
|
});
|
|
126
128
|
}, [analytic, detection, params, setParams]);
|
|
127
|
-
const onDelete = useCallback(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
const onDelete = useCallback(() => {
|
|
130
|
+
withConfirmDeleteModal(async () => {
|
|
131
|
+
await dispatchApi(api.overview.del(selectedOverview.overview_id), {
|
|
132
|
+
logError: false,
|
|
133
|
+
showError: true,
|
|
134
|
+
throwError: false
|
|
135
|
+
});
|
|
136
|
+
setSelectedOverview(null);
|
|
137
|
+
setContent('');
|
|
132
138
|
});
|
|
133
|
-
|
|
134
|
-
setContent('');
|
|
135
|
-
}, [dispatchApi, selectedOverview?.overview_id]);
|
|
139
|
+
}, [dispatchApi, selectedOverview?.overview_id, withConfirmDeleteModal]);
|
|
136
140
|
const onSave = useCallback(async () => {
|
|
137
141
|
if (analytic && detection) {
|
|
138
142
|
try {
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { Article } from '@mui/icons-material';
|
|
3
3
|
import { Typography } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
5
6
|
import { TuiListProvider } from '@cccsaurora/howler-ui/components/elements/addons/lists';
|
|
6
7
|
import { TuiListMethodContext } from '@cccsaurora/howler-ui/components/elements/addons/lists/TuiListProvider';
|
|
7
8
|
import ItemManager from '@cccsaurora/howler-ui/components/elements/display/ItemManager';
|
|
@@ -18,6 +19,7 @@ const OverviewsBase = () => {
|
|
|
18
19
|
const navigate = useNavigate();
|
|
19
20
|
const { dispatchApi } = useMyApi();
|
|
20
21
|
const { showSuccessMessage } = useMySnackbar();
|
|
22
|
+
const { withConfirmDeleteModal } = useContext(ModalContext);
|
|
21
23
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
22
24
|
const { load } = useContext(TuiListMethodContext);
|
|
23
25
|
const pageCount = useMyLocalStorageItem(StorageKey.PAGE_COUNT, 25)[0];
|
|
@@ -73,19 +75,21 @@ const OverviewsBase = () => {
|
|
|
73
75
|
setOffset(_offset);
|
|
74
76
|
}
|
|
75
77
|
}, [offset, searchParams, setSearchParams]);
|
|
76
|
-
const onDelete = useCallback(
|
|
78
|
+
const onDelete = useCallback((e, id) => {
|
|
77
79
|
e.preventDefault();
|
|
78
80
|
e.stopPropagation();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
withConfirmDeleteModal(async () => {
|
|
82
|
+
try {
|
|
83
|
+
await dispatchApi(api.overview.del(id), { throwError: false, showError: true });
|
|
84
|
+
await onSearch();
|
|
85
|
+
showSuccessMessage(t('route.overviews.manager.delete.success'));
|
|
86
|
+
}
|
|
87
|
+
catch (_err) {
|
|
88
|
+
// eslint-disable-next-line no-console
|
|
89
|
+
console.warn(_err);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}, [dispatchApi, onSearch, withConfirmDeleteModal, showSuccessMessage, t]);
|
|
89
93
|
useEffect(() => {
|
|
90
94
|
onSearch();
|
|
91
95
|
if (!searchParams.has('offset')) {
|
|
@@ -3,10 +3,11 @@ import { Autocomplete, Button, CircularProgress, Divider, FormControl, LinearPro
|
|
|
3
3
|
import api from '@cccsaurora/howler-ui/api';
|
|
4
4
|
import PageCenter from '@cccsaurora/howler-ui/commons/components/pages/PageCenter';
|
|
5
5
|
import TemplateEditor from '@cccsaurora/howler-ui/components/routes/templates/TemplateEditor';
|
|
6
|
-
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
6
|
+
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
7
7
|
import { useTranslation } from 'react-i18next';
|
|
8
8
|
import { Check, Delete, SsidChart } from '@mui/icons-material';
|
|
9
9
|
import AppInfoPanel from '@cccsaurora/howler-ui/commons/components/display/AppInfoPanel';
|
|
10
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
10
11
|
import { DEFAULT_FIELDS } from '@cccsaurora/howler-ui/components/elements/hit/HitOutline';
|
|
11
12
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
12
13
|
import isEqual from 'lodash-es/isEqual';
|
|
@@ -17,6 +18,7 @@ const TemplateViewer = () => {
|
|
|
17
18
|
const { t } = useTranslation();
|
|
18
19
|
const [params, setParams] = useSearchParams();
|
|
19
20
|
const { dispatchApi } = useMyApi();
|
|
21
|
+
const { withConfirmDeleteModal } = useContext(ModalContext);
|
|
20
22
|
const [templateList, setTemplateList] = useState([]);
|
|
21
23
|
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
|
22
24
|
const [sessionTemplateList, setSessionTemplateList] = useState([]);
|
|
@@ -115,24 +117,27 @@ const TemplateViewer = () => {
|
|
|
115
117
|
}
|
|
116
118
|
return { ..._hit };
|
|
117
119
|
}, [analytic]);
|
|
118
|
-
const onDelete = useCallback(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
const onDelete = useCallback(() => {
|
|
121
|
+
withConfirmDeleteModal(async () => {
|
|
122
|
+
await dispatchApi(api.template.del(selectedTemplate.template_id), {
|
|
123
|
+
logError: false,
|
|
124
|
+
showError: true,
|
|
125
|
+
throwError: false
|
|
126
|
+
});
|
|
127
|
+
setSessionTemplateList(l => l.filter(v => v.analytic != selectedTemplate.analytic ||
|
|
128
|
+
v.detection != selectedTemplate.detection ||
|
|
129
|
+
v.type != selectedTemplate.type));
|
|
130
|
+
setTemplateList(l => l.filter(v => v.analytic != selectedTemplate.analytic ||
|
|
131
|
+
v.detection != selectedTemplate.detection ||
|
|
132
|
+
v.type != selectedTemplate.type));
|
|
123
133
|
});
|
|
124
|
-
setSessionTemplateList(l => l.filter(v => v.analytic != selectedTemplate.analytic ||
|
|
125
|
-
v.detection != selectedTemplate.detection ||
|
|
126
|
-
v.type != selectedTemplate.type));
|
|
127
|
-
setTemplateList(l => l.filter(v => v.analytic != selectedTemplate.analytic ||
|
|
128
|
-
v.detection != selectedTemplate.detection ||
|
|
129
|
-
v.type != selectedTemplate.type));
|
|
130
134
|
}, [
|
|
131
135
|
dispatchApi,
|
|
132
136
|
selectedTemplate?.analytic,
|
|
133
137
|
selectedTemplate?.detection,
|
|
134
138
|
selectedTemplate?.template_id,
|
|
135
|
-
selectedTemplate?.type
|
|
139
|
+
selectedTemplate?.type,
|
|
140
|
+
withConfirmDeleteModal
|
|
136
141
|
]);
|
|
137
142
|
const onSave = useCallback(async () => {
|
|
138
143
|
if (analytic && detection) {
|
|
@@ -4,6 +4,7 @@ import { Clear, Edit, SavedSearch, Star, StarBorder } from '@mui/icons-material'
|
|
|
4
4
|
import { Autocomplete, Card, Checkbox, IconButton, Skeleton, Stack, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material';
|
|
5
5
|
import api from '@cccsaurora/howler-ui/api';
|
|
6
6
|
import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks';
|
|
7
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
7
8
|
import { ViewContext } from '@cccsaurora/howler-ui/components/app/providers/ViewProvider';
|
|
8
9
|
import FlexOne from '@cccsaurora/howler-ui/components/elements/addons/layout/FlexOne';
|
|
9
10
|
import { TuiListProvider } from '@cccsaurora/howler-ui/components/elements/addons/lists';
|
|
@@ -27,6 +28,7 @@ const ViewsBase = () => {
|
|
|
27
28
|
const { user } = useAppUser();
|
|
28
29
|
const navigate = useNavigate();
|
|
29
30
|
const { dispatchApi } = useMyApi();
|
|
31
|
+
const { withConfirmDeleteModal } = useContext(ModalContext);
|
|
30
32
|
const fetchViews = useContextSelector(ViewContext, ctx => ctx.fetchViews);
|
|
31
33
|
const addFavourite = useContextSelector(ViewContext, ctx => ctx.addFavourite);
|
|
32
34
|
const removeFavourite = useContextSelector(ViewContext, ctx => ctx.removeFavourite);
|
|
@@ -105,12 +107,14 @@ const ViewsBase = () => {
|
|
|
105
107
|
setOffset(_offset);
|
|
106
108
|
}
|
|
107
109
|
}, [offset, searchParams, setSearchParams]);
|
|
108
|
-
const onDelete = useCallback(
|
|
110
|
+
const onDelete = useCallback((event, id) => {
|
|
109
111
|
event.preventDefault();
|
|
110
112
|
event.stopPropagation();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
withConfirmDeleteModal(async () => {
|
|
114
|
+
await removeView(id);
|
|
115
|
+
onSearch();
|
|
116
|
+
});
|
|
117
|
+
}, [onSearch, removeView, withConfirmDeleteModal]);
|
|
114
118
|
const onFavourite = useCallback(async (event, id) => {
|
|
115
119
|
event.preventDefault();
|
|
116
120
|
if (user.favourite_views?.includes(id)) {
|
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/// <reference types="vitest" />
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import FetchClient from './FetchClient';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
const mockFetch = (status, body, headers = {}) => {
|
|
8
|
+
const response = {
|
|
9
|
+
status,
|
|
10
|
+
headers,
|
|
11
|
+
json: vi.fn().mockResolvedValue(body)
|
|
12
|
+
};
|
|
13
|
+
return vi.spyOn(globalThis, 'fetch').mockResolvedValue(response);
|
|
14
|
+
};
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Tests
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
describe('FetchClient', () => {
|
|
19
|
+
let client;
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
client = new FetchClient();
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
it('calls fetch with the provided URL and GET method by default', async () => {
|
|
27
|
+
const spy = mockFetch(200, { api_response: 'ok' });
|
|
28
|
+
await client.fetch('/api/v1/test');
|
|
29
|
+
expect(spy).toHaveBeenCalledWith('/api/v1/test', expect.objectContaining({ method: 'get', credentials: 'same-origin' }));
|
|
30
|
+
});
|
|
31
|
+
it('appends query params to the URL when provided', async () => {
|
|
32
|
+
const spy = mockFetch(200, {});
|
|
33
|
+
const params = new URLSearchParams({ q: 'hello world' });
|
|
34
|
+
await client.fetch('/api/v1/search', 'get', undefined, params);
|
|
35
|
+
expect(spy).toHaveBeenCalledWith(expect.stringContaining('q=hello+world'), expect.anything());
|
|
36
|
+
});
|
|
37
|
+
it('returns [json, status, headers] on a successful response', async () => {
|
|
38
|
+
const payload = { api_response: { items: [] } };
|
|
39
|
+
mockFetch(200, payload);
|
|
40
|
+
const result = await client.fetch('/api/v1/hit');
|
|
41
|
+
expect(result[0]).toEqual(payload);
|
|
42
|
+
expect(result[1]).toBe(200);
|
|
43
|
+
});
|
|
44
|
+
it('serialises the body as JSON when provided', async () => {
|
|
45
|
+
const spy = mockFetch(201, {});
|
|
46
|
+
await client.fetch('/api/v1/hit', 'post', { key: 'val' });
|
|
47
|
+
const callArgs = spy.mock.calls[0][1];
|
|
48
|
+
expect(callArgs.body).toBe(JSON.stringify({ key: 'val' }));
|
|
49
|
+
});
|
|
50
|
+
it('sends null body when no body is provided', async () => {
|
|
51
|
+
const spy = mockFetch(200, {});
|
|
52
|
+
await client.fetch('/api/v1/hit', 'get');
|
|
53
|
+
const callArgs = spy.mock.calls[0][1];
|
|
54
|
+
expect(callArgs.body).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
it('forwards custom headers to fetch', async () => {
|
|
57
|
+
const spy = mockFetch(200, {});
|
|
58
|
+
await client.fetch('/api/v1/hit', 'get', undefined, undefined, { Authorization: '******' });
|
|
59
|
+
const callArgs = spy.mock.calls[0][1];
|
|
60
|
+
expect(callArgs.headers.Authorization).toBe('******');
|
|
61
|
+
});
|
|
62
|
+
it('returns null for a 204 No Content response', async () => {
|
|
63
|
+
mockFetch(204, null);
|
|
64
|
+
const result = await client.fetch('/api/v1/hit', 'delete');
|
|
65
|
+
expect(result).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
it('works with a PUT method', async () => {
|
|
68
|
+
const spy = mockFetch(200, {});
|
|
69
|
+
await client.fetch('/api/v1/hit/123', 'put', { status: 'open' });
|
|
70
|
+
expect(spy).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ method: 'put' }));
|
|
71
|
+
});
|
|
72
|
+
it('works with a DELETE method', async () => {
|
|
73
|
+
const spy = mockFetch(200, {});
|
|
74
|
+
await client.fetch('/api/v1/hit/123', 'delete');
|
|
75
|
+
expect(spy).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ method: 'delete' }));
|
|
76
|
+
});
|
|
77
|
+
it('propagates errors thrown by the underlying fetch', async () => {
|
|
78
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network error'));
|
|
79
|
+
await expect(client.fetch('/api/v1/hit')).rejects.toThrow('network error');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/// <reference types="vitest" />
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import Throttler from './Throttler';
|
|
4
|
+
describe('Throttler', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
});
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.useRealTimers();
|
|
10
|
+
});
|
|
11
|
+
// -------------------------------------------------------------------------
|
|
12
|
+
// throttle
|
|
13
|
+
// -------------------------------------------------------------------------
|
|
14
|
+
describe('throttle', () => {
|
|
15
|
+
it('calls fn immediately on the first invocation', () => {
|
|
16
|
+
const fn = vi.fn();
|
|
17
|
+
const t = new Throttler(100);
|
|
18
|
+
t.throttle(fn);
|
|
19
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
20
|
+
});
|
|
21
|
+
it('suppresses subsequent calls within the throttle window', () => {
|
|
22
|
+
const fn = vi.fn();
|
|
23
|
+
const t = new Throttler(100);
|
|
24
|
+
t.throttle(fn);
|
|
25
|
+
t.throttle(fn);
|
|
26
|
+
t.throttle(fn);
|
|
27
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
28
|
+
});
|
|
29
|
+
it('allows another call after the throttle interval expires', () => {
|
|
30
|
+
const fn = vi.fn();
|
|
31
|
+
const t = new Throttler(100);
|
|
32
|
+
t.throttle(fn);
|
|
33
|
+
vi.advanceTimersByTime(150);
|
|
34
|
+
t.throttle(fn);
|
|
35
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
36
|
+
});
|
|
37
|
+
it('does not fire a second call if the interval has not yet elapsed', () => {
|
|
38
|
+
const fn = vi.fn();
|
|
39
|
+
const t = new Throttler(200);
|
|
40
|
+
t.throttle(fn);
|
|
41
|
+
vi.advanceTimersByTime(100);
|
|
42
|
+
t.throttle(fn);
|
|
43
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
// -------------------------------------------------------------------------
|
|
47
|
+
// debounce
|
|
48
|
+
// -------------------------------------------------------------------------
|
|
49
|
+
describe('debounce', () => {
|
|
50
|
+
it('does not call fn immediately', () => {
|
|
51
|
+
const fn = vi.fn();
|
|
52
|
+
const t = new Throttler(100);
|
|
53
|
+
t.debounce(fn);
|
|
54
|
+
expect(fn).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
it('calls fn after the delay elapses', () => {
|
|
57
|
+
const fn = vi.fn();
|
|
58
|
+
const t = new Throttler(100);
|
|
59
|
+
t.debounce(fn);
|
|
60
|
+
vi.advanceTimersByTime(100);
|
|
61
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
it('resets the delay when called again before it fires', () => {
|
|
64
|
+
const fn = vi.fn();
|
|
65
|
+
const t = new Throttler(100);
|
|
66
|
+
t.debounce(fn);
|
|
67
|
+
vi.advanceTimersByTime(80);
|
|
68
|
+
t.debounce(fn);
|
|
69
|
+
vi.advanceTimersByTime(80);
|
|
70
|
+
expect(fn).not.toHaveBeenCalled();
|
|
71
|
+
vi.advanceTimersByTime(30);
|
|
72
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
73
|
+
});
|
|
74
|
+
it('calls fn only once after many rapid invocations', () => {
|
|
75
|
+
const fn = vi.fn();
|
|
76
|
+
const t = new Throttler(100);
|
|
77
|
+
for (let i = 0; i < 10; i++) {
|
|
78
|
+
t.debounce(fn);
|
|
79
|
+
}
|
|
80
|
+
vi.advanceTimersByTime(200);
|
|
81
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
82
|
+
});
|
|
83
|
+
it('fires a second call correctly after the first has resolved', () => {
|
|
84
|
+
const fn = vi.fn();
|
|
85
|
+
const t = new Throttler(100);
|
|
86
|
+
t.debounce(fn);
|
|
87
|
+
vi.advanceTimersByTime(150);
|
|
88
|
+
t.debounce(fn);
|
|
89
|
+
vi.advanceTimersByTime(150);
|
|
90
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
// -------------------------------------------------------------------------
|
|
94
|
+
// delayAsync
|
|
95
|
+
// -------------------------------------------------------------------------
|
|
96
|
+
describe('delayAsync', () => {
|
|
97
|
+
it('does not invoke fn before the delay', async () => {
|
|
98
|
+
const fn = vi.fn().mockResolvedValue('x');
|
|
99
|
+
const t = new Throttler(100);
|
|
100
|
+
t.delayAsync(fn);
|
|
101
|
+
expect(fn).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
it('resolves with the return value of fn', async () => {
|
|
104
|
+
const fn = vi.fn().mockResolvedValue('result');
|
|
105
|
+
const t = new Throttler(100);
|
|
106
|
+
const promise = t.delayAsync(fn);
|
|
107
|
+
vi.advanceTimersByTime(100);
|
|
108
|
+
await expect(promise).resolves.toBe('result');
|
|
109
|
+
});
|
|
110
|
+
it('passes variadic arguments through to fn', async () => {
|
|
111
|
+
const fn = vi.fn().mockImplementation(async (a, b) => a + b);
|
|
112
|
+
const t = new Throttler(100);
|
|
113
|
+
const promise = t.delayAsync(fn, 3, 4);
|
|
114
|
+
vi.advanceTimersByTime(100);
|
|
115
|
+
await expect(promise).resolves.toBe(7);
|
|
116
|
+
});
|
|
117
|
+
it('rejects when fn throws', async () => {
|
|
118
|
+
const fn = vi.fn().mockRejectedValue(new Error('boom'));
|
|
119
|
+
const t = new Throttler(100);
|
|
120
|
+
const promise = t.delayAsync(fn);
|
|
121
|
+
vi.advanceTimersByTime(100);
|
|
122
|
+
await expect(promise).rejects.toThrow('boom');
|
|
123
|
+
});
|
|
124
|
+
it('cancels the previous pending call when invoked again', async () => {
|
|
125
|
+
const fn = vi.fn().mockResolvedValue(42);
|
|
126
|
+
const t = new Throttler(100);
|
|
127
|
+
t.delayAsync(fn);
|
|
128
|
+
vi.advanceTimersByTime(50);
|
|
129
|
+
const second = t.delayAsync(fn);
|
|
130
|
+
vi.advanceTimersByTime(100);
|
|
131
|
+
await expect(second).resolves.toBe(42);
|
|
132
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getUserList } from './hitFunctions';
|
|
3
|
+
const makeHit = (howlerOverrides = {}) => ({
|
|
4
|
+
howler: {
|
|
5
|
+
log: [],
|
|
6
|
+
comment: [],
|
|
7
|
+
...howlerOverrides
|
|
8
|
+
}
|
|
9
|
+
});
|
|
10
|
+
describe('getUserList', () => {
|
|
11
|
+
it('returns an empty Set when the hit is null', () => {
|
|
12
|
+
expect(getUserList(null)).toEqual(new Set());
|
|
13
|
+
});
|
|
14
|
+
it('returns an empty Set when the hit is undefined', () => {
|
|
15
|
+
expect(getUserList(undefined)).toEqual(new Set());
|
|
16
|
+
});
|
|
17
|
+
it('returns an empty Set when both log and comment are empty', () => {
|
|
18
|
+
expect(getUserList(makeHit())).toEqual(new Set());
|
|
19
|
+
});
|
|
20
|
+
it('collects users from the log array', () => {
|
|
21
|
+
const hit = makeHit({ log: [{ user: 'alice' }, { user: 'bob' }] });
|
|
22
|
+
expect(getUserList(hit)).toEqual(new Set(['alice', 'bob']));
|
|
23
|
+
});
|
|
24
|
+
it('collects users from the comment array', () => {
|
|
25
|
+
const hit = makeHit({ comment: [{ user: 'carol' }] });
|
|
26
|
+
expect(getUserList(hit)).toEqual(new Set(['carol']));
|
|
27
|
+
});
|
|
28
|
+
it('collects users from both log and comment', () => {
|
|
29
|
+
const hit = makeHit({
|
|
30
|
+
log: [{ user: 'alice' }],
|
|
31
|
+
comment: [{ user: 'bob' }]
|
|
32
|
+
});
|
|
33
|
+
expect(getUserList(hit)).toEqual(new Set(['alice', 'bob']));
|
|
34
|
+
});
|
|
35
|
+
it('deduplicates users that appear in both arrays', () => {
|
|
36
|
+
const hit = makeHit({
|
|
37
|
+
log: [{ user: 'alice' }],
|
|
38
|
+
comment: [{ user: 'alice' }]
|
|
39
|
+
});
|
|
40
|
+
const result = getUserList(hit);
|
|
41
|
+
expect(result).toEqual(new Set(['alice']));
|
|
42
|
+
expect(result.size).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
it('deduplicates users that appear multiple times in the same array', () => {
|
|
45
|
+
const hit = makeHit({ log: [{ user: 'alice' }, { user: 'alice' }] });
|
|
46
|
+
expect(getUserList(hit).size).toBe(1);
|
|
47
|
+
});
|
|
48
|
+
it('handles a hit with no howler field gracefully', () => {
|
|
49
|
+
const hit = {};
|
|
50
|
+
expect(getUserList(hit)).toEqual(new Set());
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/// <reference types="vitest" />
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { StorageKey } from './constants';
|
|
4
|
+
import { getStored, removeStored, saveLoginCredential, setStored } from './localStorage';
|
|
5
|
+
const PREFIX = 'howler.ui';
|
|
6
|
+
describe('localStorage utilities', () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
localStorage.clear();
|
|
9
|
+
});
|
|
10
|
+
// -------------------------------------------------------------------------
|
|
11
|
+
// setStored / getStored
|
|
12
|
+
// -------------------------------------------------------------------------
|
|
13
|
+
describe('setStored / getStored', () => {
|
|
14
|
+
it('stores and retrieves a string value', () => {
|
|
15
|
+
setStored(StorageKey.USERNAME, 'alice');
|
|
16
|
+
expect(getStored(StorageKey.USERNAME)).toBe('alice');
|
|
17
|
+
});
|
|
18
|
+
it('stores and retrieves an object value', () => {
|
|
19
|
+
const value = { token: 'abc', expires: 123 };
|
|
20
|
+
setStored(StorageKey.APP_TOKEN, value);
|
|
21
|
+
expect(getStored(StorageKey.APP_TOKEN)).toEqual(value);
|
|
22
|
+
});
|
|
23
|
+
it('uses the prefixed key in the underlying localStorage', () => {
|
|
24
|
+
setStored(StorageKey.USERNAME, 'bob');
|
|
25
|
+
expect(localStorage.getItem(`${PREFIX}.${StorageKey.USERNAME}`)).toBe(JSON.stringify('bob'));
|
|
26
|
+
});
|
|
27
|
+
it('returns null for a key that has not been set', () => {
|
|
28
|
+
expect(getStored(StorageKey.USERNAME)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
it('overwrites an existing value', () => {
|
|
31
|
+
setStored(StorageKey.USERNAME, 'alice');
|
|
32
|
+
setStored(StorageKey.USERNAME, 'bob');
|
|
33
|
+
expect(getStored(StorageKey.USERNAME)).toBe('bob');
|
|
34
|
+
});
|
|
35
|
+
it('stores a number value', () => {
|
|
36
|
+
setStored(StorageKey.PAGE_COUNT, 25);
|
|
37
|
+
expect(getStored(StorageKey.PAGE_COUNT)).toBe(25);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
// -------------------------------------------------------------------------
|
|
41
|
+
// removeStored
|
|
42
|
+
// -------------------------------------------------------------------------
|
|
43
|
+
describe('removeStored', () => {
|
|
44
|
+
it('removes an existing key so it returns null on subsequent reads', () => {
|
|
45
|
+
setStored(StorageKey.USERNAME, 'alice');
|
|
46
|
+
removeStored(StorageKey.USERNAME);
|
|
47
|
+
expect(getStored(StorageKey.USERNAME)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
it('does not throw when removing a key that was never stored', () => {
|
|
50
|
+
expect(() => removeStored(StorageKey.USERNAME)).not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
it('removes only the targeted key and leaves others intact', () => {
|
|
53
|
+
setStored(StorageKey.USERNAME, 'alice');
|
|
54
|
+
setStored(StorageKey.PROVIDER, 'howler');
|
|
55
|
+
removeStored(StorageKey.USERNAME);
|
|
56
|
+
expect(getStored(StorageKey.USERNAME)).toBeNull();
|
|
57
|
+
expect(getStored(StorageKey.PROVIDER)).toBe('howler');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
// -------------------------------------------------------------------------
|
|
61
|
+
// saveLoginCredential
|
|
62
|
+
// -------------------------------------------------------------------------
|
|
63
|
+
describe('saveLoginCredential', () => {
|
|
64
|
+
it('returns true and stores app_token when app_token is present', () => {
|
|
65
|
+
const result = saveLoginCredential({ app_token: 'tok123' });
|
|
66
|
+
expect(result).toBe(true);
|
|
67
|
+
expect(getStored(StorageKey.APP_TOKEN)).toBe('tok123');
|
|
68
|
+
});
|
|
69
|
+
it('also stores refresh_token and provider when they are present', () => {
|
|
70
|
+
saveLoginCredential({ app_token: 'tok', refresh_token: 'ref', provider: 'howler' });
|
|
71
|
+
expect(getStored(StorageKey.REFRESH_TOKEN)).toBe('ref');
|
|
72
|
+
expect(getStored(StorageKey.PROVIDER)).toBe('howler');
|
|
73
|
+
});
|
|
74
|
+
it('does not store refresh_token when it is absent', () => {
|
|
75
|
+
saveLoginCredential({ app_token: 'tok' });
|
|
76
|
+
expect(getStored(StorageKey.REFRESH_TOKEN)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
it('returns false and clears stored tokens when app_token is absent', () => {
|
|
79
|
+
setStored(StorageKey.APP_TOKEN, 'old-token');
|
|
80
|
+
setStored(StorageKey.REFRESH_TOKEN, 'old-refresh');
|
|
81
|
+
const result = saveLoginCredential({});
|
|
82
|
+
expect(result).toBe(false);
|
|
83
|
+
expect(getStored(StorageKey.APP_TOKEN)).toBeNull();
|
|
84
|
+
expect(getStored(StorageKey.REFRESH_TOKEN)).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
it('clears APP_TOKEN, REFRESH_TOKEN, and PROVIDER on empty credential', () => {
|
|
87
|
+
setStored(StorageKey.APP_TOKEN, 't');
|
|
88
|
+
setStored(StorageKey.REFRESH_TOKEN, 'r');
|
|
89
|
+
setStored(StorageKey.PROVIDER, 'p');
|
|
90
|
+
saveLoginCredential({});
|
|
91
|
+
expect(getStored(StorageKey.APP_TOKEN)).toBeNull();
|
|
92
|
+
expect(getStored(StorageKey.REFRESH_TOKEN)).toBeNull();
|
|
93
|
+
expect(getStored(StorageKey.PROVIDER)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import AppMenuBuilder from './menuUtils';
|
|
3
|
+
// Avoid pulling in react-pluggable (and its transitive React deps) by mocking the store module.
|
|
4
|
+
// vi.mock is hoisted by Vitest before any imports, so the mock is applied even though this call appears below the imports.
|
|
5
|
+
vi.mock('plugins/store', () => ({
|
|
6
|
+
MainMenuInsertOperation: {
|
|
7
|
+
Insert: 'INSERT',
|
|
8
|
+
InsertAfter: 'AFTER',
|
|
9
|
+
InsertBefore: 'BEFORE'
|
|
10
|
+
}
|
|
11
|
+
}));
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const makeItem = (id, overrides = {}) => ({
|
|
16
|
+
type: 'item',
|
|
17
|
+
element: { id, i18nKey: `key.${id}`, route: `/${id}`, ...overrides }
|
|
18
|
+
});
|
|
19
|
+
const makeGroup = (id, items = []) => ({
|
|
20
|
+
type: 'group',
|
|
21
|
+
element: { id, i18nKey: `key.${id}`, items }
|
|
22
|
+
});
|
|
23
|
+
const makeDivider = () => ({ type: 'divider', element: null });
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Tests
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
describe('AppMenuBuilder', () => {
|
|
28
|
+
let initial;
|
|
29
|
+
let builder;
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
initial = [makeItem('home'), makeItem('search'), makeGroup('tools', [{ id: 'view', route: '/view' }])];
|
|
32
|
+
builder = new AppMenuBuilder(initial);
|
|
33
|
+
});
|
|
34
|
+
// -------------------------------------------------------------------------
|
|
35
|
+
// menu getter
|
|
36
|
+
// -------------------------------------------------------------------------
|
|
37
|
+
describe('menu getter', () => {
|
|
38
|
+
it('returns the initial menu unchanged when no operations are applied', () => {
|
|
39
|
+
expect(builder.menu).toHaveLength(3);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
// -------------------------------------------------------------------------
|
|
43
|
+
// indexOfMenuId
|
|
44
|
+
// -------------------------------------------------------------------------
|
|
45
|
+
describe('indexOfMenuId', () => {
|
|
46
|
+
it('returns index -1 for the "root" pseudo-target', () => {
|
|
47
|
+
expect(builder.indexOfMenuId('root')).toEqual({ index: -1 });
|
|
48
|
+
});
|
|
49
|
+
it('returns the correct top-level index for a root item', () => {
|
|
50
|
+
expect(builder.indexOfMenuId('home')).toEqual({ index: 0 });
|
|
51
|
+
});
|
|
52
|
+
it('returns both index and subIndex for a nested item', () => {
|
|
53
|
+
expect(builder.indexOfMenuId('view')).toEqual({ index: 2, subIndex: 0 });
|
|
54
|
+
});
|
|
55
|
+
it('throws for an unknown id', () => {
|
|
56
|
+
expect(() => builder.indexOfMenuId('unknown-id')).toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
// -------------------------------------------------------------------------
|
|
60
|
+
// insert
|
|
61
|
+
// -------------------------------------------------------------------------
|
|
62
|
+
describe('insert', () => {
|
|
63
|
+
it('appends a new item to the root when targetId is "root"', () => {
|
|
64
|
+
builder.insert('root', makeItem('new'));
|
|
65
|
+
expect(builder.menu).toHaveLength(4);
|
|
66
|
+
expect(builder.menu[3].element.id).toBe('new');
|
|
67
|
+
});
|
|
68
|
+
it('appends a new item to an existing group', () => {
|
|
69
|
+
builder.insert('tools', makeItem('extra'));
|
|
70
|
+
const group = builder.menu.find(el => el.type === 'group');
|
|
71
|
+
expect(group.element.items).toHaveLength(2);
|
|
72
|
+
expect(group.element.items[1].id).toBe('extra');
|
|
73
|
+
});
|
|
74
|
+
it('converts a root-level item into a group when inserting into it', () => {
|
|
75
|
+
builder.insert('home', makeItem('sub-home'));
|
|
76
|
+
const converted = builder.menu.find(el => el.type === 'group');
|
|
77
|
+
// The first group found should be the newly converted one.
|
|
78
|
+
expect(converted).toBeDefined();
|
|
79
|
+
expect(converted.element.items.some((i) => i.id === 'sub-home')).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
it('marks nested items added to a group as nested:true', () => {
|
|
82
|
+
builder.insert('tools', makeItem('nested-item'));
|
|
83
|
+
const group = builder.menu.find(el => el.type === 'group');
|
|
84
|
+
const last = group.element.items[group.element.items.length - 1];
|
|
85
|
+
expect(last.nested).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
it('does not insert a divider into a group (logs warning and returns)', () => {
|
|
88
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
89
|
+
const before = builder.menu.find(el => el.type === 'group').element.items.length;
|
|
90
|
+
builder.insert('tools', makeDivider());
|
|
91
|
+
const after = builder.menu.find(el => el.type === 'group').element.items.length;
|
|
92
|
+
expect(after).toBe(before);
|
|
93
|
+
warnSpy.mockRestore();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
// -------------------------------------------------------------------------
|
|
97
|
+
// insertBefore
|
|
98
|
+
// -------------------------------------------------------------------------
|
|
99
|
+
describe('insertBefore', () => {
|
|
100
|
+
it('inserts a new root-level item before the target', () => {
|
|
101
|
+
builder.insertBefore('search', makeItem('before-search'));
|
|
102
|
+
// 'search' was at index 1; the new item should now be at index 1.
|
|
103
|
+
expect(builder.menu[1].element.id).toBe('before-search');
|
|
104
|
+
expect(builder.menu[2].element.id).toBe('search');
|
|
105
|
+
});
|
|
106
|
+
it('inserts before the first root item (index 0)', () => {
|
|
107
|
+
builder.insertBefore('home', makeItem('new-first'));
|
|
108
|
+
expect(builder.menu[0].element.id).toBe('new-first');
|
|
109
|
+
});
|
|
110
|
+
it('inserts a new nested item before a sub-item within its group', () => {
|
|
111
|
+
builder.insertBefore('view', makeItem('before-view'));
|
|
112
|
+
const group = builder.menu.find(el => el.type === 'group');
|
|
113
|
+
expect(group.element.items[0].id).toBe('before-view');
|
|
114
|
+
expect(group.element.items[1].id).toBe('view');
|
|
115
|
+
});
|
|
116
|
+
it('marks items inserted into a group as nested:true', () => {
|
|
117
|
+
builder.insertBefore('view', makeItem('nested-before'));
|
|
118
|
+
const group = builder.menu.find(el => el.type === 'group');
|
|
119
|
+
expect(group.element.items[0].nested).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
it('does not insert a divider before a sub-item (logs warning)', () => {
|
|
122
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
123
|
+
const before = builder.menu.find(el => el.type === 'group').element.items.length;
|
|
124
|
+
builder.insertBefore('view', makeDivider());
|
|
125
|
+
const after = builder.menu.find(el => el.type === 'group').element.items.length;
|
|
126
|
+
expect(after).toBe(before);
|
|
127
|
+
warnSpy.mockRestore();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
// -------------------------------------------------------------------------
|
|
131
|
+
// insertAfter
|
|
132
|
+
// -------------------------------------------------------------------------
|
|
133
|
+
describe('insertAfter', () => {
|
|
134
|
+
it('inserts a new root-level item after the target', () => {
|
|
135
|
+
builder.insertAfter('home', makeItem('after-home'));
|
|
136
|
+
expect(builder.menu[1].element.id).toBe('after-home');
|
|
137
|
+
expect(builder.menu[2].element.id).toBe('search');
|
|
138
|
+
});
|
|
139
|
+
it('inserts after the last root item', () => {
|
|
140
|
+
builder.insertAfter('tools', makeItem('very-last'));
|
|
141
|
+
expect(builder.menu[builder.menu.length - 1].element.id).toBe('very-last');
|
|
142
|
+
});
|
|
143
|
+
it('inserts a new nested item after a sub-item within its group', () => {
|
|
144
|
+
builder.insertAfter('view', makeItem('after-view'));
|
|
145
|
+
const group = builder.menu.find(el => el.type === 'group');
|
|
146
|
+
expect(group.element.items[0].id).toBe('view');
|
|
147
|
+
expect(group.element.items[1].id).toBe('after-view');
|
|
148
|
+
});
|
|
149
|
+
it('marks items inserted into a group as nested:true', () => {
|
|
150
|
+
builder.insertAfter('view', makeItem('nested-after'));
|
|
151
|
+
const group = builder.menu.find(el => el.type === 'group');
|
|
152
|
+
expect(group.element.items[1].nested).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
it('does not insert a divider after a sub-item (logs warning)', () => {
|
|
155
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
156
|
+
const before = builder.menu.find(el => el.type === 'group').element.items.length;
|
|
157
|
+
builder.insertAfter('view', makeDivider());
|
|
158
|
+
const after = builder.menu.find(el => el.type === 'group').element.items.length;
|
|
159
|
+
expect(after).toBe(before);
|
|
160
|
+
warnSpy.mockRestore();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
// -------------------------------------------------------------------------
|
|
164
|
+
// applyOperations
|
|
165
|
+
// -------------------------------------------------------------------------
|
|
166
|
+
describe('applyOperations', () => {
|
|
167
|
+
it('applies an INSERT operation to the root', () => {
|
|
168
|
+
builder.applyOperations([{ operation: 'INSERT', targetId: 'root', item: makeItem('op-item') }]);
|
|
169
|
+
expect(builder.menu.some((el) => el.element?.id === 'op-item')).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
it('applies a BEFORE operation', () => {
|
|
172
|
+
builder.applyOperations([{ operation: 'BEFORE', targetId: 'search', item: makeItem('before-op') }]);
|
|
173
|
+
const idx = builder.menu.findIndex((el) => el.element?.id === 'before-op');
|
|
174
|
+
const searchIdx = builder.menu.findIndex((el) => el.element?.id === 'search');
|
|
175
|
+
expect(idx).toBeLessThan(searchIdx);
|
|
176
|
+
});
|
|
177
|
+
it('applies an AFTER operation', () => {
|
|
178
|
+
builder.applyOperations([{ operation: 'AFTER', targetId: 'home', item: makeItem('after-op') }]);
|
|
179
|
+
const homeIdx = builder.menu.findIndex((el) => el.element?.id === 'home');
|
|
180
|
+
const afterIdx = builder.menu.findIndex((el) => el.element?.id === 'after-op');
|
|
181
|
+
expect(afterIdx).toBe(homeIdx + 1);
|
|
182
|
+
});
|
|
183
|
+
it('applies multiple operations in order', () => {
|
|
184
|
+
builder.applyOperations([
|
|
185
|
+
{ operation: 'INSERT', targetId: 'root', item: makeItem('first') },
|
|
186
|
+
{ operation: 'INSERT', targetId: 'root', item: makeItem('second') }
|
|
187
|
+
]);
|
|
188
|
+
const ids = builder.menu.map((el) => el.element?.id).filter(Boolean);
|
|
189
|
+
expect(ids).toContain('first');
|
|
190
|
+
expect(ids).toContain('second');
|
|
191
|
+
});
|
|
192
|
+
it('ignores operations with unknown operation strings', () => {
|
|
193
|
+
const lenBefore = builder.menu.length;
|
|
194
|
+
builder.applyOperations([{ operation: 'UNKNOWN', targetId: 'root', item: makeItem('x') }]);
|
|
195
|
+
expect(builder.menu.length).toBe(lenBefore);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/// <reference types="vitest" />
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { StorageKey } from './constants';
|
|
4
|
+
import { getAxiosCache, getStored, removeStored, setAxiosCache, setStored } from './sessionStorage';
|
|
5
|
+
// The sessionStorage module uses a debounced write (3 000 ms). Tests that need to verify
|
|
6
|
+
// persistence to sessionStorage use fake timers and advance past the debounce delay.
|
|
7
|
+
// afterEach flushes any pending changes so module-level state is clean for the next test.
|
|
8
|
+
const DEBOUNCE_MS = 3100; // slightly past the 3 000 ms throttle
|
|
9
|
+
const SESSION_PREFIX = 'howler.ui.cache';
|
|
10
|
+
describe('sessionStorage utilities', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.useFakeTimers();
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
// Flush pending debounced writes – this also resets the internal `changes` map.
|
|
16
|
+
vi.advanceTimersByTime(DEBOUNCE_MS);
|
|
17
|
+
vi.useRealTimers();
|
|
18
|
+
sessionStorage.clear();
|
|
19
|
+
});
|
|
20
|
+
// -------------------------------------------------------------------------
|
|
21
|
+
// getAxiosCache / setAxiosCache
|
|
22
|
+
// -------------------------------------------------------------------------
|
|
23
|
+
describe('getAxiosCache / setAxiosCache', () => {
|
|
24
|
+
it('returns an empty object when no cache has been stored', () => {
|
|
25
|
+
expect(getAxiosCache()).toEqual({});
|
|
26
|
+
});
|
|
27
|
+
it('stores and retrieves a cached entry after the debounce fires', () => {
|
|
28
|
+
setAxiosCache('etag-1', { data: 'value' });
|
|
29
|
+
vi.advanceTimersByTime(DEBOUNCE_MS);
|
|
30
|
+
expect(getAxiosCache()['etag-1']).toEqual({ data: 'value' });
|
|
31
|
+
});
|
|
32
|
+
it('accumulates multiple distinct cache entries', () => {
|
|
33
|
+
setAxiosCache('etag-a', { data: 'a' });
|
|
34
|
+
setAxiosCache('etag-b', { data: 'b' });
|
|
35
|
+
vi.advanceTimersByTime(DEBOUNCE_MS);
|
|
36
|
+
const cache = getAxiosCache();
|
|
37
|
+
expect(cache['etag-a']).toEqual({ data: 'a' });
|
|
38
|
+
expect(cache['etag-b']).toEqual({ data: 'b' });
|
|
39
|
+
});
|
|
40
|
+
it('overwrites an earlier entry for the same etag', () => {
|
|
41
|
+
setAxiosCache('etag-1', { data: 'old' });
|
|
42
|
+
setAxiosCache('etag-1', { data: 'new' });
|
|
43
|
+
vi.advanceTimersByTime(DEBOUNCE_MS);
|
|
44
|
+
expect(getAxiosCache()['etag-1']).toEqual({ data: 'new' });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
// -------------------------------------------------------------------------
|
|
48
|
+
// setStored / getStored (object values)
|
|
49
|
+
// -------------------------------------------------------------------------
|
|
50
|
+
describe('setStored / getStored', () => {
|
|
51
|
+
it('makes an object value immediately readable via getStored (in-memory, before flush)', () => {
|
|
52
|
+
setStored(StorageKey.AXIOS_CACHE, { immediate: true });
|
|
53
|
+
// Timer has NOT advanced yet – data is in the in-memory `changes` map.
|
|
54
|
+
const result = getStored(StorageKey.AXIOS_CACHE);
|
|
55
|
+
expect(result.immediate).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
it('persists an object value to sessionStorage after the debounce delay', () => {
|
|
58
|
+
const key = `${SESSION_PREFIX}.${StorageKey.AXIOS_CACHE}`;
|
|
59
|
+
setStored(StorageKey.AXIOS_CACHE, { persisted: true });
|
|
60
|
+
// Before flush: sessionStorage is still empty.
|
|
61
|
+
expect(sessionStorage.getItem(key)).toBeNull();
|
|
62
|
+
vi.advanceTimersByTime(DEBOUNCE_MS);
|
|
63
|
+
expect(JSON.parse(sessionStorage.getItem(key))).toEqual({ persisted: true });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
// -------------------------------------------------------------------------
|
|
67
|
+
// removeStored
|
|
68
|
+
// -------------------------------------------------------------------------
|
|
69
|
+
describe('removeStored', () => {
|
|
70
|
+
it('removes a key from sessionStorage', () => {
|
|
71
|
+
setStored(StorageKey.AXIOS_CACHE, { val: 1 });
|
|
72
|
+
vi.advanceTimersByTime(DEBOUNCE_MS);
|
|
73
|
+
removeStored(StorageKey.AXIOS_CACHE);
|
|
74
|
+
const key = `${SESSION_PREFIX}.${StorageKey.AXIOS_CACHE}`;
|
|
75
|
+
expect(sessionStorage.getItem(key)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
it('does not throw when removing a key that was never set', () => {
|
|
78
|
+
expect(() => removeStored(StorageKey.AXIOS_CACHE)).not.toThrow();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/// <reference types="vitest" />
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { isHitUpdate } from './socketUtils';
|
|
4
|
+
describe('isHitUpdate', () => {
|
|
5
|
+
it('returns truthy when data has both a truthy version and a hit', () => {
|
|
6
|
+
expect(isHitUpdate({ version: 1, hit: {} })).toBeTruthy();
|
|
7
|
+
});
|
|
8
|
+
it('returns truthy when version is a non-empty string', () => {
|
|
9
|
+
expect(isHitUpdate({ version: 'v1', hit: { howler: {} } })).toBeTruthy();
|
|
10
|
+
});
|
|
11
|
+
it('returns falsy when version is missing', () => {
|
|
12
|
+
expect(isHitUpdate({ hit: {} })).toBeFalsy();
|
|
13
|
+
});
|
|
14
|
+
it('returns falsy when hit is missing', () => {
|
|
15
|
+
expect(isHitUpdate({ version: 1 })).toBeFalsy();
|
|
16
|
+
});
|
|
17
|
+
it('returns falsy when both fields are missing', () => {
|
|
18
|
+
expect(isHitUpdate({})).toBeFalsy();
|
|
19
|
+
});
|
|
20
|
+
it('returns falsy when version is 0 (falsy)', () => {
|
|
21
|
+
expect(isHitUpdate({ version: 0, hit: {} })).toBeFalsy();
|
|
22
|
+
});
|
|
23
|
+
it('returns falsy when hit is null (falsy)', () => {
|
|
24
|
+
expect(isHitUpdate({ version: 1, hit: null })).toBeFalsy();
|
|
25
|
+
});
|
|
26
|
+
});
|
package/utils/utils.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="vitest" />
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { bytesToSize, compareTimestamp, convertCustomDateRangeToLucene, convertDateToLucene, convertLuceneToDate, flattenDeep, formatDate, getTimeRange, hashCode, humanReadableNumber, removeEmpty, searchObject, sortByTimestamp,
|
|
3
|
+
import { bytesToSize, compareTimestamp, convertCustomDateRangeToLucene, convertDateToLucene, convertLuceneToDate, flattenDeep, formatDate, getTimeRange, hashCode, humanReadableNumber, removeEmpty, searchObject, sortByTimestamp, tryParse, twitterShort } from './utils';
|
|
4
4
|
describe('bytesToSize', () => {
|
|
5
5
|
it('returns "0 B" for 0', () => {
|
|
6
6
|
expect(bytesToSize(0)).toBe('0 B');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/// <reference types="vitest" />
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import getXSRFCookie from './xsrf';
|
|
4
|
+
describe('getXSRFCookie', () => {
|
|
5
|
+
const originalDescriptor = Object.getOwnPropertyDescriptor(document, 'cookie');
|
|
6
|
+
const setCookie = (value) => {
|
|
7
|
+
Object.defineProperty(document, 'cookie', {
|
|
8
|
+
get: () => value,
|
|
9
|
+
configurable: true
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
if (originalDescriptor) {
|
|
14
|
+
Object.defineProperty(document, 'cookie', originalDescriptor);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
it('returns null when document.cookie is empty', () => {
|
|
18
|
+
setCookie('');
|
|
19
|
+
expect(getXSRFCookie()).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
it('returns null when XSRF-TOKEN is not among the cookies', () => {
|
|
22
|
+
setCookie('session=abc; theme=dark');
|
|
23
|
+
expect(getXSRFCookie()).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
it('returns the XSRF-TOKEN value when it is the only cookie', () => {
|
|
26
|
+
setCookie('XSRF-TOKEN=my-token');
|
|
27
|
+
expect(getXSRFCookie()).toBe('my-token');
|
|
28
|
+
});
|
|
29
|
+
it('returns the XSRF-TOKEN value when it appears among other cookies', () => {
|
|
30
|
+
setCookie('session=abc; XSRF-TOKEN=csrf-value; theme=dark');
|
|
31
|
+
expect(getXSRFCookie()).toBe('csrf-value');
|
|
32
|
+
});
|
|
33
|
+
it('returns the XSRF-TOKEN value when it appears first', () => {
|
|
34
|
+
setCookie('XSRF-TOKEN=first-token; session=abc');
|
|
35
|
+
expect(getXSRFCookie()).toBe('first-token');
|
|
36
|
+
});
|
|
37
|
+
it('returns the XSRF-TOKEN value when it appears last', () => {
|
|
38
|
+
setCookie('session=abc; XSRF-TOKEN=last-token');
|
|
39
|
+
expect(getXSRFCookie()).toBe('last-token');
|
|
40
|
+
});
|
|
41
|
+
});
|