@cccsaurora/howler-ui 2.19.0-dev.938 → 2.19.0-dev.958

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.
@@ -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
- return (_jsx(ModalContext.Provider, { value: { showModal, content, setContent, options, close }, children: children }));
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: async (e) => {
109
- e.preventDefault();
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(async () => {
62
- await dispatchApi(api.analytic.del(analytic?.analytic_id));
63
- showSuccessMessage(t('route.analytics.deleted'));
64
- navigate('/analytics');
65
- }, [analytic?.analytic_id, dispatchApi, navigate, showSuccessMessage, t]);
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(async (e, id) => {
78
+ const onDelete = useCallback((e, id) => {
77
79
  e.preventDefault();
78
80
  e.stopPropagation();
79
- try {
80
- await dispatchApi(api.dossier.del(id), { throwError: false, showError: true });
81
- await onSearch();
82
- showSuccessMessage(t('route.dossiers.manager.delete.success'));
83
- }
84
- catch (_err) {
85
- // eslint-disable-next-line no-console
86
- console.warn(_err);
87
- }
88
- }, [dispatchApi, onSearch, showSuccessMessage, t]);
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(async () => {
128
- await dispatchApi(api.overview.del(selectedOverview.overview_id), {
129
- logError: false,
130
- showError: true,
131
- throwError: false
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
- setSelectedOverview(null);
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(async (e, id) => {
78
+ const onDelete = useCallback((e, id) => {
77
79
  e.preventDefault();
78
80
  e.stopPropagation();
79
- try {
80
- await dispatchApi(api.overview.del(id), { throwError: false, showError: true });
81
- await onSearch();
82
- showSuccessMessage(t('route.overviews.manager.delete.success'));
83
- }
84
- catch (_err) {
85
- // eslint-disable-next-line no-console
86
- console.warn(_err);
87
- }
88
- }, [dispatchApi, onSearch, showSuccessMessage, t]);
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(async () => {
119
- await dispatchApi(api.template.del(selectedTemplate.template_id), {
120
- logError: false,
121
- showError: true,
122
- throwError: false
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(async (event, id) => {
110
+ const onDelete = useCallback((event, id) => {
109
111
  event.preventDefault();
110
112
  event.stopPropagation();
111
- await removeView(id);
112
- onSearch();
113
- }, [onSearch, removeView]);
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
@@ -96,7 +96,7 @@
96
96
  "internal-slot": "1.0.7"
97
97
  },
98
98
  "type": "module",
99
- "version": "2.19.0-dev.938",
99
+ "version": "2.19.0-dev.958",
100
100
  "exports": {
101
101
  "./i18n": "./i18n.js",
102
102
  "./index.css": "./index.css",
package/setupTests.js CHANGED
@@ -2,7 +2,12 @@
2
2
  import * as matchers from '@testing-library/jest-dom/matchers';
3
3
  import '@testing-library/jest-dom/vitest';
4
4
  import { configure } from '@testing-library/react';
5
+ import dayjs from 'dayjs';
6
+ import relativeTime from 'dayjs/plugin/relativeTime';
7
+ import utc from 'dayjs/plugin/utc';
5
8
  import { server } from '@cccsaurora/howler-ui/tests/server';
9
+ dayjs.extend(utc);
10
+ dayjs.extend(relativeTime);
6
11
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
7
12
  // Extend vitest with the dom matchers from jest-dom.
8
13
  expect.extend(matchers);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { checkArgsAreFilled, getArgsByContext, getOptionsByContext, operationReady } from './actionUtils';
3
+ // ---------------------------------------------------------------------------
4
+ // Helpers
5
+ // ---------------------------------------------------------------------------
6
+ const makeStep = (args, options = {}) => ({
7
+ args,
8
+ options,
9
+ validation: {}
10
+ });
11
+ const makeAction = (steps) => ({
12
+ id: 'test-action',
13
+ title: 'Test Action',
14
+ i18nKey: 'test.action',
15
+ description: { short: '', long: '' },
16
+ roles: [],
17
+ steps,
18
+ triggers: []
19
+ });
20
+ // ---------------------------------------------------------------------------
21
+ // checkArgsAreFilled
22
+ // ---------------------------------------------------------------------------
23
+ describe('checkArgsAreFilled', () => {
24
+ it('returns false when values is empty/falsy', () => {
25
+ const step = makeStep({ status: [] });
26
+ expect(checkArgsAreFilled(step, '')).toBe(false);
27
+ });
28
+ it('returns true when all required args are present and truthy', () => {
29
+ const step = makeStep({ status: [] });
30
+ expect(checkArgsAreFilled(step, JSON.stringify({ status: 'open' }))).toBe(true);
31
+ });
32
+ it('returns false when a required arg is missing from values', () => {
33
+ const step = makeStep({ status: [], assignment: [] });
34
+ expect(checkArgsAreFilled(step, JSON.stringify({ status: 'open' }))).toBe(false);
35
+ });
36
+ it('returns false when a required arg is present but falsy (empty string)', () => {
37
+ const step = makeStep({ status: [] });
38
+ expect(checkArgsAreFilled(step, JSON.stringify({ status: '' }))).toBe(false);
39
+ });
40
+ it('returns true when there are no required args', () => {
41
+ const step = makeStep({});
42
+ expect(checkArgsAreFilled(step, JSON.stringify({}))).toBe(true);
43
+ });
44
+ });
45
+ // ---------------------------------------------------------------------------
46
+ // getOptionsByContext
47
+ // ---------------------------------------------------------------------------
48
+ describe('getOptionsByContext', () => {
49
+ it('returns an empty array when the arg is not in options', () => {
50
+ const options = {};
51
+ expect(getOptionsByContext(options, 'status', '{}')).toEqual([]);
52
+ });
53
+ it('returns the full list when the arg maps to a plain array', () => {
54
+ const options = { status: ['open', 'in-progress', 'resolved'] };
55
+ expect(getOptionsByContext(options, 'status', '{}')).toEqual(['open', 'in-progress', 'resolved']);
56
+ });
57
+ it('returns options matching the current context when arg has conditional options', () => {
58
+ const options = {
59
+ assessment: {
60
+ 'status:open': ['false_positive', 'legitimate'],
61
+ 'status:resolved': ['correct']
62
+ }
63
+ };
64
+ const context = JSON.stringify({ status: 'open' });
65
+ expect(getOptionsByContext(options, 'assessment', context)).toEqual(['false_positive', 'legitimate']);
66
+ });
67
+ it('returns an empty array when context does not match any conditional key', () => {
68
+ const options = {
69
+ assessment: {
70
+ 'status:open': ['false_positive']
71
+ }
72
+ };
73
+ const context = JSON.stringify({ status: 'resolved' });
74
+ expect(getOptionsByContext(options, 'assessment', context)).toEqual([]);
75
+ });
76
+ });
77
+ // ---------------------------------------------------------------------------
78
+ // getArgsByContext
79
+ // ---------------------------------------------------------------------------
80
+ describe('getArgsByContext', () => {
81
+ it('always returns an arg with an empty conditions array', () => {
82
+ const args = { status: [] };
83
+ expect(getArgsByContext(args, '{}')).toContain('status');
84
+ });
85
+ it('includes an arg whose condition matches the current context', () => {
86
+ const args = { assessment: ['status:open'] };
87
+ const values = JSON.stringify({ status: 'open' });
88
+ expect(getArgsByContext(args, values)).toContain('assessment');
89
+ });
90
+ it('excludes an arg whose condition does not match the current context', () => {
91
+ const args = { assessment: ['status:open'] };
92
+ const values = JSON.stringify({ status: 'resolved' });
93
+ expect(getArgsByContext(args, values)).not.toContain('assessment');
94
+ });
95
+ it('includes an arg when any one of multiple conditions matches (OR logic)', () => {
96
+ const args = { rationale: ['status:open', 'status:in-progress'] };
97
+ const values = JSON.stringify({ status: 'in-progress' });
98
+ expect(getArgsByContext(args, values)).toContain('rationale');
99
+ });
100
+ it('returns an empty array when no args match', () => {
101
+ const args = { assessment: ['status:open'] };
102
+ const values = JSON.stringify({ status: 'resolved' });
103
+ expect(getArgsByContext(args, values)).toEqual([]);
104
+ });
105
+ });
106
+ // ---------------------------------------------------------------------------
107
+ // operationReady
108
+ // ---------------------------------------------------------------------------
109
+ describe('operationReady', () => {
110
+ it('returns false when data is null/falsy', () => {
111
+ const action = makeAction([makeStep({ status: [] })]);
112
+ expect(operationReady(null, action)).toBe(false);
113
+ });
114
+ it('returns falsy when action is falsy', () => {
115
+ expect(operationReady(JSON.stringify({ status: 'open' }), null)).toBeFalsy();
116
+ });
117
+ it('returns true when all unconditional args are present', () => {
118
+ const action = makeAction([makeStep({ status: [] })]);
119
+ const data = JSON.stringify({ status: 'open' });
120
+ expect(operationReady(data, action)).toBe(true);
121
+ });
122
+ it('returns false when a required unconditional arg is missing', () => {
123
+ const action = makeAction([makeStep({ status: [], assessment: [] })]);
124
+ const data = JSON.stringify({ status: 'open' });
125
+ expect(operationReady(data, action)).toBe(false);
126
+ });
127
+ it('ignores conditional args that do not apply in the current context', () => {
128
+ // assessment only required when status=open; here status=resolved so assessment is not required
129
+ const action = makeAction([makeStep({ status: [], assessment: ['status:open'] })]);
130
+ const data = JSON.stringify({ status: 'resolved' });
131
+ expect(operationReady(data, action)).toBe(true);
132
+ });
133
+ it('merges args across multiple steps', () => {
134
+ const step1 = makeStep({ status: [] });
135
+ const step2 = makeStep({ assignment: [] });
136
+ const action = makeAction([step1, step2]);
137
+ const data = JSON.stringify({ status: 'open', assignment: 'alice' });
138
+ expect(operationReady(data, action)).toBe(true);
139
+ });
140
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,159 @@
1
+ /// <reference types="vitest" />
2
+ import { describe, expect, it } from 'vitest';
3
+ import { maxLenStr, nameToInitials, safeFieldValue, safeFieldValueURI, safeStringPropertyCompare, sanitizeLuceneQuery, sanitizeMultilineLucene, validateRegex } from './stringUtils';
4
+ describe('nameToInitials', () => {
5
+ it('returns initials from a standard first-last name', () => {
6
+ expect(nameToInitials('John Doe')).toEqual(['J', 'D']);
7
+ });
8
+ it('returns a single initial when there is only one word', () => {
9
+ expect(nameToInitials('John')).toEqual(['J']);
10
+ });
11
+ it('uses only the first two words even when there are more', () => {
12
+ expect(nameToInitials('John Middle Doe')).toEqual(['J', 'M']);
13
+ });
14
+ it('reverses order when the name is in "last, first" comma format', () => {
15
+ // "Smith, John" → parts = ["Smith,", "John"] → reversed → ["John", "Smith,"] → initials ["J", "S"]
16
+ expect(nameToInitials('Smith, John')).toEqual(['J', 'S']);
17
+ });
18
+ it('returns uppercase initials regardless of input case', () => {
19
+ expect(nameToInitials('alice bob')).toEqual(['A', 'B']);
20
+ });
21
+ });
22
+ describe('maxLenStr', () => {
23
+ it('returns the string unchanged when shorter than the limit', () => {
24
+ expect(maxLenStr('hello', 10)).toBe('hello');
25
+ });
26
+ it('returns the string unchanged when length equals the limit', () => {
27
+ expect(maxLenStr('hello', 5)).toBe('hello');
28
+ });
29
+ it('truncates and appends "..." when string exceeds the limit', () => {
30
+ expect(maxLenStr('hello world', 8)).toBe('hello...');
31
+ });
32
+ it('handles an empty string', () => {
33
+ expect(maxLenStr('', 5)).toBe('');
34
+ });
35
+ it('truncation accounts for the three-character ellipsis', () => {
36
+ // len=6 → keeps first 3 chars then "..."
37
+ expect(maxLenStr('abcdefgh', 6)).toBe('abc...');
38
+ });
39
+ });
40
+ describe('safeFieldValue', () => {
41
+ it('wraps a plain string in double quotes', () => {
42
+ expect(safeFieldValue('test')).toBe('"test"');
43
+ });
44
+ it('escapes backslashes', () => {
45
+ expect(safeFieldValue('back\\slash')).toBe('"back\\\\slash"');
46
+ });
47
+ it('escapes embedded double quotes', () => {
48
+ expect(safeFieldValue('say "hello"')).toBe('"say \\"hello\\""');
49
+ });
50
+ it('converts a number to a quoted string', () => {
51
+ expect(safeFieldValue(42)).toBe('"42"');
52
+ });
53
+ it('converts a boolean to a quoted string', () => {
54
+ expect(safeFieldValue(true)).toBe('"true"');
55
+ });
56
+ it('escapes backslash before escaping quotes (order matters)', () => {
57
+ expect(safeFieldValue('\\"')).toBe('"\\\\\\"\"');
58
+ });
59
+ });
60
+ describe('safeFieldValueURI', () => {
61
+ it('URI-encodes the safe field value of a plain string', () => {
62
+ expect(safeFieldValueURI('hello')).toBe(encodeURIComponent('"hello"'));
63
+ });
64
+ it('URI-encodes special characters', () => {
65
+ expect(safeFieldValueURI('hello world')).toBe(encodeURIComponent('"hello world"'));
66
+ });
67
+ it('handles strings that already contain lucene special chars', () => {
68
+ const input = 'field:value';
69
+ expect(safeFieldValueURI(input)).toBe(encodeURIComponent(safeFieldValue(input)));
70
+ });
71
+ });
72
+ describe('sanitizeLuceneQuery', () => {
73
+ it('escapes colons', () => {
74
+ expect(sanitizeLuceneQuery('field:value')).toBe('field\\:value');
75
+ });
76
+ it('escapes forward slashes', () => {
77
+ expect(sanitizeLuceneQuery('path/to')).toBe('path\\/to');
78
+ });
79
+ it('escapes opening parenthesis', () => {
80
+ expect(sanitizeLuceneQuery('(term)')).toBe('\\(term\\)');
81
+ });
82
+ it('escapes square brackets', () => {
83
+ expect(sanitizeLuceneQuery('[a TO b]')).toBe('\\[a TO b\\]');
84
+ });
85
+ it('escapes curly braces', () => {
86
+ expect(sanitizeLuceneQuery('{a TO b}')).toBe('\\{a TO b\\}');
87
+ });
88
+ it('escapes carets', () => {
89
+ expect(sanitizeLuceneQuery('term^2')).toBe('term\\^2');
90
+ });
91
+ it('escapes double-ampersand (&&)', () => {
92
+ expect(sanitizeLuceneQuery('a && b')).toBe('a \\&& b');
93
+ });
94
+ it('escapes double-pipe (||)', () => {
95
+ expect(sanitizeLuceneQuery('a || b')).toBe('a \\|| b');
96
+ });
97
+ it('leaves a plain alphanumeric string unchanged', () => {
98
+ expect(sanitizeLuceneQuery('plainterm')).toBe('plainterm');
99
+ });
100
+ });
101
+ describe('safeStringPropertyCompare', () => {
102
+ const compare = safeStringPropertyCompare('name');
103
+ it('returns a negative number when a sorts before b', () => {
104
+ expect(compare({ name: 'Alice' }, { name: 'Bob' })).toBeLessThan(0);
105
+ });
106
+ it('returns a positive number when a sorts after b', () => {
107
+ expect(compare({ name: 'Bob' }, { name: 'Alice' })).toBeGreaterThan(0);
108
+ });
109
+ it('returns 0 for equal strings', () => {
110
+ expect(compare({ name: 'Alice' }, { name: 'Alice' })).toBe(0);
111
+ });
112
+ it('returns 1 when only a has the property', () => {
113
+ expect(compare({ name: 'Alice' }, {})).toBe(1);
114
+ });
115
+ it('returns 0 when only b has the property', () => {
116
+ expect(compare({}, { name: 'Bob' })).toBe(0);
117
+ });
118
+ it('returns 0 when neither object has the property', () => {
119
+ expect(compare({}, {})).toBe(0);
120
+ });
121
+ it('supports nested property paths', () => {
122
+ const nestedCompare = safeStringPropertyCompare('user.name');
123
+ expect(nestedCompare({ user: { name: 'Alice' } }, { user: { name: 'Bob' } })).toBeLessThan(0);
124
+ });
125
+ });
126
+ describe('sanitizeMultilineLucene', () => {
127
+ it('removes a trailing inline comment', () => {
128
+ expect(sanitizeMultilineLucene('query # comment')).toBe('query ');
129
+ });
130
+ it('removes a full-line comment', () => {
131
+ expect(sanitizeMultilineLucene('# full line\nquery')).toBe('\nquery');
132
+ });
133
+ it('collapses two or more consecutive newlines into one', () => {
134
+ expect(sanitizeMultilineLucene('a\n\n\nb')).toBe('a\nb');
135
+ });
136
+ it('collapses exactly two consecutive newlines', () => {
137
+ expect(sanitizeMultilineLucene('a\n\nb')).toBe('a\nb');
138
+ });
139
+ it('leaves a clean single-line query unchanged', () => {
140
+ expect(sanitizeMultilineLucene('howler.status:open')).toBe('howler.status:open');
141
+ });
142
+ });
143
+ describe('validateRegex', () => {
144
+ it('returns true for a valid regex pattern', () => {
145
+ expect(validateRegex('[a-z]+')).toBe(true);
146
+ });
147
+ it('returns true for an empty string (valid zero-length pattern)', () => {
148
+ expect(validateRegex('')).toBe(true);
149
+ });
150
+ it('returns false for an invalid regex pattern', () => {
151
+ expect(validateRegex('[unclosed')).toBe(false);
152
+ });
153
+ it('returns true for a complex but valid regex', () => {
154
+ expect(validateRegex('^(\\d{4})-(\\d{2})-(\\d{2})$')).toBe(true);
155
+ });
156
+ it('returns true for a pattern with quantifiers', () => {
157
+ expect(validateRegex('a{2,5}')).toBe(true);
158
+ });
159
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,292 @@
1
+ /// <reference types="vitest" />
2
+ import { describe, expect, it } from 'vitest';
3
+ import { bytesToSize, compareTimestamp, convertCustomDateRangeToLucene, convertDateToLucene, convertLuceneToDate, flattenDeep, formatDate, getTimeRange, hashCode, humanReadableNumber, removeEmpty, searchObject, sortByTimestamp, twitterShort, tryParse } from './utils';
4
+ describe('bytesToSize', () => {
5
+ it('returns "0 B" for 0', () => {
6
+ expect(bytesToSize(0)).toBe('0 B');
7
+ });
8
+ it('returns "0 B" for null', () => {
9
+ expect(bytesToSize(null)).toBe('0 B');
10
+ });
11
+ it('formats bytes', () => {
12
+ expect(bytesToSize(512)).toBe('512 B');
13
+ });
14
+ it('formats kilobytes', () => {
15
+ expect(bytesToSize(1024)).toBe('1 KB');
16
+ });
17
+ it('formats megabytes', () => {
18
+ expect(bytesToSize(1024 * 1024)).toBe('1 MB');
19
+ });
20
+ it('formats gigabytes', () => {
21
+ expect(bytesToSize(1024 * 1024 * 1024)).toBe('1 GB');
22
+ });
23
+ });
24
+ describe('humanReadableNumber', () => {
25
+ it('returns "0 " for 0', () => {
26
+ expect(humanReadableNumber(0)).toBe('0 ');
27
+ });
28
+ it('returns "0 " for null', () => {
29
+ expect(humanReadableNumber(null)).toBe('0 ');
30
+ });
31
+ it('returns the number with a trailing space for values below 1000', () => {
32
+ expect(humanReadableNumber(500)).toBe('500 ');
33
+ });
34
+ it('formats thousands with "k"', () => {
35
+ expect(humanReadableNumber(1000)).toBe('1k ');
36
+ });
37
+ it('formats millions with "m"', () => {
38
+ expect(humanReadableNumber(1_000_000)).toBe('1m ');
39
+ });
40
+ });
41
+ describe('compareTimestamp', () => {
42
+ it('returns a negative number when a is earlier than b', () => {
43
+ expect(compareTimestamp('2021-01-01T00:00:00Z', '2021-01-02T00:00:00Z')).toBeLessThan(0);
44
+ });
45
+ it('returns a positive number when a is later than b', () => {
46
+ expect(compareTimestamp('2021-01-02T00:00:00Z', '2021-01-01T00:00:00Z')).toBeGreaterThan(0);
47
+ });
48
+ it('returns 0 for identical timestamps', () => {
49
+ expect(compareTimestamp('2021-01-01T00:00:00Z', '2021-01-01T00:00:00Z')).toBe(0);
50
+ });
51
+ it('returns a difference in seconds', () => {
52
+ // 86400 seconds = 1 day
53
+ expect(compareTimestamp('2021-01-02T00:00:00Z', '2021-01-01T00:00:00Z')).toBe(86400);
54
+ });
55
+ });
56
+ describe('hashCode', () => {
57
+ it('returns a number', () => {
58
+ expect(typeof hashCode('hello')).toBe('number');
59
+ });
60
+ it('returns the same value for the same input', () => {
61
+ expect(hashCode('hello')).toBe(hashCode('hello'));
62
+ });
63
+ it('returns different values for different inputs', () => {
64
+ expect(hashCode('hello')).not.toBe(hashCode('world'));
65
+ });
66
+ it('returns 0 for an empty string', () => {
67
+ expect(hashCode('')).toBe(0);
68
+ });
69
+ });
70
+ describe('sortByTimestamp', () => {
71
+ it('returns an empty array for an empty input', () => {
72
+ expect(sortByTimestamp([])).toEqual([]);
73
+ });
74
+ it('sorts items in descending timestamp order (most recent first)', () => {
75
+ const items = [
76
+ { timestamp: '2021-01-01T00:00:00Z' },
77
+ { timestamp: '2021-03-01T00:00:00Z' },
78
+ { timestamp: '2021-02-01T00:00:00Z' }
79
+ ];
80
+ const sorted = sortByTimestamp(items);
81
+ expect(sorted[0].timestamp).toBe('2021-03-01T00:00:00Z');
82
+ expect(sorted[2].timestamp).toBe('2021-01-01T00:00:00Z');
83
+ });
84
+ it('does not mutate the original array', () => {
85
+ const original = [{ timestamp: '2021-01-01T00:00:00Z' }, { timestamp: '2021-03-01T00:00:00Z' }];
86
+ const copy = [...original];
87
+ sortByTimestamp(original);
88
+ expect(original).toEqual(copy);
89
+ });
90
+ it('handles items with missing timestamps', () => {
91
+ const items = [{ timestamp: '2021-01-01T00:00:00Z' }, {}];
92
+ expect(() => sortByTimestamp(items)).not.toThrow();
93
+ });
94
+ });
95
+ describe('getTimeRange', () => {
96
+ it('returns [earliest, latest] from an array of timestamps', () => {
97
+ const timestamps = ['2021-03-01T00:00:00Z', '2021-01-01T00:00:00Z', '2021-02-01T00:00:00Z'];
98
+ const [start, end] = getTimeRange(timestamps);
99
+ expect(start).toBe('2021-01-01T00:00:00Z');
100
+ expect(end).toBe('2021-03-01T00:00:00Z');
101
+ });
102
+ it('returns the same value for both when given a single timestamp', () => {
103
+ const [start, end] = getTimeRange(['2021-01-01T00:00:00Z']);
104
+ expect(start).toBe(end);
105
+ });
106
+ });
107
+ describe('removeEmpty', () => {
108
+ it('removes null values from a flat object', () => {
109
+ expect(removeEmpty({ a: null, b: 'val' })).toEqual({ b: 'val' });
110
+ });
111
+ it('removes undefined values from a flat object', () => {
112
+ expect(removeEmpty({ a: undefined, b: 'val' })).toEqual({ b: 'val' });
113
+ });
114
+ it('recursively removes null values from nested objects', () => {
115
+ expect(removeEmpty({ nested: { a: null, b: 'val' } })).toEqual({ nested: { b: 'val' } });
116
+ });
117
+ it('handles an empty object', () => {
118
+ expect(removeEmpty({})).toEqual({});
119
+ });
120
+ it('handles null input gracefully', () => {
121
+ expect(removeEmpty(null)).toEqual({});
122
+ });
123
+ it('keeps arrays as-is', () => {
124
+ const result = removeEmpty({ arr: [1, 2, 3] });
125
+ expect(result.arr).toEqual([1, 2, 3]);
126
+ });
127
+ });
128
+ describe('searchObject', () => {
129
+ const obj = { name: 'Alice', role: 'admin', nested: { city: 'Ottawa' } };
130
+ it('returns the full object when query is empty', () => {
131
+ const result = searchObject(obj, '');
132
+ expect(result).toMatchObject(obj);
133
+ });
134
+ it('returns matching entries for a key match', () => {
135
+ const result = searchObject(obj, 'name');
136
+ expect(result.name).toBe('Alice');
137
+ });
138
+ it('returns matching entries for a value match', () => {
139
+ const result = searchObject(obj, 'Alice');
140
+ expect(result.name).toBe('Alice');
141
+ });
142
+ it('returns an empty object when nothing matches', () => {
143
+ const result = searchObject(obj, 'zzznomatch');
144
+ expect(result).toEqual({});
145
+ });
146
+ it('returns flat result when returnFlat=true', () => {
147
+ const result = searchObject(obj, 'city', true);
148
+ expect(result['nested.city']).toBe('Ottawa');
149
+ });
150
+ it('returns full flat object when query is empty and returnFlat=true', () => {
151
+ const result = searchObject({ a: 1 }, '', true);
152
+ expect(result.a).toBe(1);
153
+ });
154
+ it('handles an invalid regex gracefully by returning the full object', () => {
155
+ const result = searchObject(obj, '[invalid');
156
+ expect(result).toMatchObject(obj);
157
+ });
158
+ });
159
+ describe('convertDateToLucene', () => {
160
+ it('returns "[now-1d TO now]" for a 1-day range', () => {
161
+ expect(convertDateToLucene('date.range.1.day')).toBe('[now-1d TO now]');
162
+ });
163
+ it('returns "[now-1w TO now]" for a 1-week range', () => {
164
+ expect(convertDateToLucene('date.range.1.week')).toBe('[now-1w TO now]');
165
+ });
166
+ it('returns "[now-1M TO now]" for a 1-month range', () => {
167
+ expect(convertDateToLucene('date.range.1.month')).toBe('[now-1M TO now]');
168
+ });
169
+ it('returns "[now-1y TO now]" for a 1-year range', () => {
170
+ expect(convertDateToLucene('date.range.1.year')).toBe('[now-1y TO now]');
171
+ });
172
+ it('returns "*" for the "all" range', () => {
173
+ expect(convertDateToLucene('date.range.all')).toBe('*');
174
+ });
175
+ it('returns the default 1-day range when input does not start with "date.range."', () => {
176
+ expect(convertDateToLucene('something.else')).toBe('[now-1d TO now]');
177
+ });
178
+ it('uses the day unit as fallback for an unknown period type', () => {
179
+ expect(convertDateToLucene('date.range.3.unknown')).toBe('[now-3d TO now]');
180
+ });
181
+ it('handles multi-unit amounts', () => {
182
+ expect(convertDateToLucene('date.range.3.day')).toBe('[now-3d TO now]');
183
+ });
184
+ });
185
+ describe('convertCustomDateRangeToLucene', () => {
186
+ it('formats a custom date range', () => {
187
+ expect(convertCustomDateRangeToLucene('2021-01-01', '2021-12-31')).toBe('[2021-01-01 TO 2021-12-31]');
188
+ });
189
+ it('works with ISO datetime strings', () => {
190
+ expect(convertCustomDateRangeToLucene('2021-01-01T00:00:00Z', '2021-12-31T23:59:59Z')).toBe('[2021-01-01T00:00:00Z TO 2021-12-31T23:59:59Z]');
191
+ });
192
+ });
193
+ describe('convertLuceneToDate', () => {
194
+ it('converts a 1-day lucene range back to "date.range.1.day"', () => {
195
+ expect(convertLuceneToDate('event.created:[now-1d TO now]')).toBe('date.range.1.day');
196
+ });
197
+ it('converts a 1-week lucene range back to "date.range.1.week"', () => {
198
+ expect(convertLuceneToDate('event.created:[now-1w TO now]')).toBe('date.range.1.week');
199
+ });
200
+ it('converts a 1-month lucene range back to "date.range.1.month"', () => {
201
+ expect(convertLuceneToDate('event.created:[now-1M TO now]')).toBe('date.range.1.month');
202
+ });
203
+ it('returns the input unchanged when there is no colon (not a field query)', () => {
204
+ expect(convertLuceneToDate('*')).toBe('*');
205
+ });
206
+ it('falls back to "day" for an unrecognised unit suffix', () => {
207
+ expect(convertLuceneToDate('event.created:[now-5z TO now]')).toBe('date.range.5.day');
208
+ });
209
+ });
210
+ describe('tryParse', () => {
211
+ it('parses valid JSON and returns the value', () => {
212
+ expect(tryParse('{"a":1}')).toEqual({ a: 1 });
213
+ });
214
+ it('parses a JSON array', () => {
215
+ expect(tryParse('[1,2,3]')).toEqual([1, 2, 3]);
216
+ });
217
+ it('returns the raw string when JSON is invalid', () => {
218
+ expect(tryParse('not json')).toBe('not json');
219
+ });
220
+ it('parses a quoted JSON string', () => {
221
+ expect(tryParse('"hello"')).toBe('hello');
222
+ });
223
+ it('returns the raw string for partially-valid JSON', () => {
224
+ expect(tryParse('{invalid}')).toBe('{invalid}');
225
+ });
226
+ });
227
+ describe('flattenDeep', () => {
228
+ it('flattens a simple nested object', () => {
229
+ const result = flattenDeep({ a: { b: 1 } });
230
+ expect(result).toEqual({ 'a.b': 1 });
231
+ });
232
+ it('leaves a flat object unchanged', () => {
233
+ const result = flattenDeep({ a: 1, b: 2 });
234
+ expect(result).toEqual({ a: 1, b: 2 });
235
+ });
236
+ it('flattens arrays of objects by merging values under a common key', () => {
237
+ const result = flattenDeep({ items: [{ id: 'x' }, { id: 'y' }] });
238
+ expect(result['items.id']).toEqual(['x', 'y']);
239
+ });
240
+ it('keeps a primitive array as-is', () => {
241
+ const result = flattenDeep({ tags: ['a', 'b', 'c'] });
242
+ expect(result.tags).toEqual(['a', 'b', 'c']);
243
+ });
244
+ it('handles an empty object', () => {
245
+ expect(flattenDeep({})).toEqual({});
246
+ });
247
+ });
248
+ describe('formatDate', () => {
249
+ it('returns "?" for a falsy value', () => {
250
+ expect(formatDate(null)).toBe('?');
251
+ });
252
+ it('returns "?" for an empty string', () => {
253
+ expect(formatDate('')).toBe('?');
254
+ });
255
+ it('formats an ISO string as UTC in YYYY/MM/DD HH:mm:ss format', () => {
256
+ // 2021-06-15T12:30:45Z → UTC → "2021/06/15 12:30:45"
257
+ expect(formatDate('2021-06-15T12:30:45Z')).toBe('2021/06/15 12:30:45');
258
+ });
259
+ it('formats a Date object correctly', () => {
260
+ const date = new Date('2023-01-01T00:00:00Z');
261
+ expect(formatDate(date)).toBe('2023/01/01 00:00:00');
262
+ });
263
+ it('formats a unix timestamp (ms) correctly', () => {
264
+ // 1000ms = 1970-01-01T00:00:01Z
265
+ expect(formatDate(1000)).toBe('1970/01/01 00:00:01');
266
+ });
267
+ it('returns "?" for a numeric 0 (treated as falsy by the guard)', () => {
268
+ expect(formatDate(0)).toBe('?');
269
+ });
270
+ });
271
+ describe('twitterShort', () => {
272
+ it('returns "?" for a falsy value', () => {
273
+ expect(twitterShort(null)).toBe('?');
274
+ });
275
+ it('returns "?" for the literal string "?"', () => {
276
+ expect(twitterShort('?')).toBe('?');
277
+ });
278
+ it('returns a non-empty relative string for a recent date', () => {
279
+ const result = twitterShort(new Date().toISOString());
280
+ expect(result).toBeTruthy();
281
+ expect(typeof result).toBe('string');
282
+ });
283
+ it('returns "a few seconds ago" for a date just in the past', () => {
284
+ const recent = new Date(Date.now() - 2000).toISOString();
285
+ expect(twitterShort(recent)).toBe('a few seconds ago');
286
+ });
287
+ it('returns a sensible relative string for a date one year in the past', () => {
288
+ const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
289
+ const result = twitterShort(oneYearAgo);
290
+ expect(result).toMatch(/year/);
291
+ });
292
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildViewUrl } from './viewUtils';
3
+ const makeView = (overrides = {}) => ({
4
+ view_id: 'view-1',
5
+ title: 'Test View',
6
+ query: 'howler.status:open',
7
+ sort: 'event.created desc',
8
+ span: 'date.range.1.month',
9
+ type: 'personal',
10
+ owner: 'testuser',
11
+ ...overrides
12
+ });
13
+ describe('buildViewUrl', () => {
14
+ it('includes the view_id as the "view" query param', () => {
15
+ const url = buildViewUrl(makeView({ view_id: 'abc-123' }));
16
+ expect(url).toContain('view=abc-123');
17
+ });
18
+ it('starts with /search', () => {
19
+ const url = buildViewUrl(makeView());
20
+ expect(url.startsWith('/search?')).toBe(true);
21
+ });
22
+ it('includes the span param when provided', () => {
23
+ const url = buildViewUrl(makeView({ span: 'date.range.1.week' }));
24
+ expect(url).toContain('span=date.range.1.week');
25
+ });
26
+ it('omits the span param when span is undefined', () => {
27
+ const url = buildViewUrl(makeView({ span: undefined }));
28
+ expect(url).not.toContain('span=');
29
+ });
30
+ it('includes the sort param when provided', () => {
31
+ const url = buildViewUrl(makeView({ sort: 'event.created asc' }));
32
+ expect(url).toContain('sort=event.created+asc');
33
+ });
34
+ it('omits the sort param when sort is undefined', () => {
35
+ const url = buildViewUrl(makeView({ sort: undefined }));
36
+ expect(url).not.toContain('sort=');
37
+ });
38
+ it('builds a complete URL with all fields present', () => {
39
+ const url = buildViewUrl(makeView({ view_id: 'v1', span: 'date.range.1.day', sort: 'event.created desc' }));
40
+ const parsed = new URL(url, 'http://localhost');
41
+ expect(parsed.searchParams.get('view')).toBe('v1');
42
+ expect(parsed.searchParams.get('span')).toBe('date.range.1.day');
43
+ expect(parsed.searchParams.get('sort')).toBe('event.created desc');
44
+ });
45
+ });