@cccsaurora/howler-ui 2.19.0-dev.1000 → 2.19.0-dev.1085
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/api/action/index.d.ts +5 -4
- package/api/action/index.js +8 -8
- package/api/analytic/index.d.ts +3 -2
- package/api/analytic/index.js +4 -4
- package/api/analytic/owner.d.ts +2 -1
- package/api/analytic/owner.js +2 -2
- package/api/analytic/rules.d.ts +2 -1
- package/api/analytic/rules.js +2 -2
- package/api/dossier/index.d.ts +4 -3
- package/api/dossier/index.js +6 -6
- package/api/hit/index.d.ts +3 -2
- package/api/hit/index.js +4 -4
- package/api/hit/labels.d.ts +3 -2
- package/api/hit/labels.js +4 -4
- package/api/hit/overwrite.d.ts +2 -1
- package/api/hit/overwrite.js +2 -2
- package/api/hit/transition.d.ts +2 -1
- package/api/hit/transition.js +2 -2
- package/api/index.d.ts +5 -4
- package/api/index.js +8 -8
- package/api/overview/index.d.ts +4 -3
- package/api/overview/index.js +6 -6
- package/api/search/overview.d.ts +2 -2
- package/api/template/index.d.ts +4 -3
- package/api/template/index.js +6 -6
- package/api/user/index.d.ts +2 -1
- package/api/user/index.js +2 -2
- package/api/view/index.d.ts +4 -3
- package/api/view/index.js +6 -6
- package/components/app/providers/SearchResponseProvider.d.ts +21 -0
- package/components/app/providers/SearchResponseProvider.js +81 -0
- package/components/app/providers/SearchResponseProvider.test.d.ts +1 -0
- package/components/app/providers/SearchResponseProvider.test.js +406 -0
- package/components/app/providers/ViewProvider.test.js +4 -1
- package/components/elements/addons/search/SearchPagination.d.ts +2 -1
- package/components/elements/addons/search/SearchPagination.js +4 -4
- package/components/elements/addons/search/SearchTotal.test.js +3 -1
- package/components/elements/display/ItemManager.d.ts +2 -2
- package/components/elements/display/ItemManager.js +1 -1
- package/components/hooks/useMyApi.d.ts +1 -1
- package/components/routes/action/useMyActionFunctions.js +5 -2
- package/components/routes/action/view/ActionDetails.js +7 -1
- package/components/routes/action/view/ActionSearch.js +20 -14
- package/components/routes/analytics/AnalyticDetails.js +1 -1
- package/components/routes/analytics/AnalyticSearch.js +6 -6
- package/components/routes/dossiers/Dossiers.js +13 -11
- package/components/routes/home/AddNewCard.test.js +20 -2
- package/components/routes/home/ViewRefresh.test.js +1 -1
- package/components/routes/overviews/OverviewViewer.js +6 -2
- package/components/routes/overviews/Overviews.js +13 -11
- package/components/routes/templates/TemplateViewer.js +7 -2
- package/components/routes/templates/Templates.js +16 -11
- package/components/routes/views/Views.js +15 -12
- package/locales/en/translation.json +4 -1
- package/locales/fr/translation.json +4 -1
- package/package.json +103 -106
|
@@ -4,6 +4,7 @@ import { AvatarGroup, Card, CardContent, CardHeader, Chip, Divider, Grid, IconBu
|
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks';
|
|
6
6
|
import useLocalStorageItem from '@cccsaurora/howler-ui/commons/components/utils/hooks/useLocalStorageItem';
|
|
7
|
+
import SearchResponseProvider, { SearchResponseContext } from '@cccsaurora/howler-ui/components/app/providers/SearchResponseProvider';
|
|
7
8
|
import FlexOne from '@cccsaurora/howler-ui/components/elements/addons/layout/FlexOne';
|
|
8
9
|
import { TuiListProvider } from '@cccsaurora/howler-ui/components/elements/addons/lists';
|
|
9
10
|
import { TuiListMethodContext } from '@cccsaurora/howler-ui/components/elements/addons/lists/TuiListProvider';
|
|
@@ -25,12 +26,12 @@ const AnalyticSearchBase = () => {
|
|
|
25
26
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
26
27
|
const pageCount = useMyLocalStorageItem(StorageKey.PAGE_COUNT, 25)[0];
|
|
27
28
|
const appUser = useAppUser();
|
|
29
|
+
const { response, request } = useContext(SearchResponseContext);
|
|
28
30
|
const [onlyRules, setOnlyRules] = useLocalStorageItem(StorageKey.ONLY_RULES, 0);
|
|
29
31
|
const [searching, setSearching] = useState(false);
|
|
30
32
|
const [hasError, setHasError] = useState(false);
|
|
31
33
|
const [phrase, setPhrase] = useState(searchParams.get('phrase') || '');
|
|
32
34
|
const [offset, setOffset] = useState(parseInt(searchParams.get('offset')) || 0);
|
|
33
|
-
const [response, setResponse] = useState(null);
|
|
34
35
|
const addFavourite = useCallback(async (analytic) => {
|
|
35
36
|
await dispatchApi(api.analytic.favourite.post(analytic.analytic_id));
|
|
36
37
|
appUser.setUser({
|
|
@@ -58,13 +59,12 @@ const AnalyticSearchBase = () => {
|
|
|
58
59
|
setSearchParams(searchParams, { replace: true });
|
|
59
60
|
try {
|
|
60
61
|
const sanitizedPhrase = sanitizeLuceneQuery(phrase);
|
|
61
|
-
const _response = await
|
|
62
|
+
const _response = await request(api.search.analytic.post, {
|
|
62
63
|
query: `name:*${sanitizedPhrase}* OR detections:*${sanitizedPhrase}*` +
|
|
63
64
|
(onlyRules > 0 ? ' AND _exists_:rule_type' : onlyRules < 0 ? ' AND -_exists_:rule_type' : ''),
|
|
64
65
|
rows: pageCount,
|
|
65
66
|
offset
|
|
66
|
-
})
|
|
67
|
-
setResponse(_response);
|
|
67
|
+
});
|
|
68
68
|
load(_response.items.map(u => ({ id: u.analytic_id, item: u })));
|
|
69
69
|
}
|
|
70
70
|
catch (e) {
|
|
@@ -73,7 +73,7 @@ const AnalyticSearchBase = () => {
|
|
|
73
73
|
finally {
|
|
74
74
|
setSearching(false);
|
|
75
75
|
}
|
|
76
|
-
}, [
|
|
76
|
+
}, [request, load, offset, onlyRules, pageCount, phrase, searchParams, setSearchParams]);
|
|
77
77
|
const onPageChange = useCallback((_offset) => {
|
|
78
78
|
if (_offset !== offset) {
|
|
79
79
|
searchParams.set('offset', _offset.toString());
|
|
@@ -133,6 +133,6 @@ const AnalyticSearchBase = () => {
|
|
|
133
133
|
return (_jsx(ItemManager, { onSearch: onSearch, onPageChange: onPageChange, phrase: phrase, setPhrase: setPhrase, hasError: hasError, searching: searching, searchAdornment: _jsx(InputAdornment, { position: "end", children: _jsx(Tooltip, { title: t(`route.analytics.search.filter.rules.${onlyRules < 0 ? 'hide' : onlyRules > 0 ? 'show' : 'toggle'}`), children: _jsx(IconButton, { onClick: () => setOnlyRules((((onlyRules + 2) % 3) - 1)), children: _jsx(SsidChart, { color: onlyRules < 0 ? 'error' : onlyRules > 0 ? 'info' : 'inherit', sx: { transition: theme.transitions.create(['color']) } }) }) }) }), aboveSearch: _jsx(Typography, { sx: { fontStyle: 'italic', color: theme.palette.text.disabled, mb: 0.5 }, variant: "body2", children: t('route.analytics.search.prompt') }), renderer: renderer, response: response, searchPrompt: "route.analytics.manager.search" }));
|
|
134
134
|
};
|
|
135
135
|
const AnalyticSearch = () => {
|
|
136
|
-
return (_jsx(TuiListProvider, { children: _jsx(AnalyticSearchBase, {}) }));
|
|
136
|
+
return (_jsx(TuiListProvider, { children: _jsx(SearchResponseProvider, { idField: "analytic_id", children: _jsx(AnalyticSearchBase, {}) }) }));
|
|
137
137
|
};
|
|
138
138
|
export default AnalyticSearch;
|
|
@@ -3,6 +3,7 @@ import { Topic } from '@mui/icons-material';
|
|
|
3
3
|
import { Typography } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
6
|
+
import SearchResponseProvider, { SearchResponseContext } from '@cccsaurora/howler-ui/components/app/providers/SearchResponseProvider';
|
|
6
7
|
import { TuiListProvider } from '@cccsaurora/howler-ui/components/elements/addons/lists';
|
|
7
8
|
import { TuiListMethodContext } from '@cccsaurora/howler-ui/components/elements/addons/lists/TuiListProvider';
|
|
8
9
|
import ItemManager from '@cccsaurora/howler-ui/components/elements/display/ItemManager';
|
|
@@ -23,9 +24,9 @@ const DossiersBase = () => {
|
|
|
23
24
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
24
25
|
const { load } = useContext(TuiListMethodContext);
|
|
25
26
|
const pageCount = useMyLocalStorageItem(StorageKey.PAGE_COUNT, 25)[0];
|
|
27
|
+
const { response, request, remove, getSearchRequestData } = useContext(SearchResponseContext);
|
|
26
28
|
const [phrase, setPhrase] = useState('');
|
|
27
29
|
const [offset, setOffset] = useState(parseInt(searchParams.get('offset')) || 0);
|
|
28
|
-
const [response, setResponse] = useState(null);
|
|
29
30
|
const [hasError, setHasError] = useState(false);
|
|
30
31
|
const [loading, setLoading] = useState(false);
|
|
31
32
|
const onSearch = useCallback(async () => {
|
|
@@ -42,11 +43,11 @@ const DossiersBase = () => {
|
|
|
42
43
|
// Check for the actual search query
|
|
43
44
|
const query = phrase ? `*:*${phrase}*` : '*:*';
|
|
44
45
|
// Ensure the overview should be visible and/or matches the type we are filtering for
|
|
45
|
-
|
|
46
|
+
await request(api.search.dossier.post, {
|
|
46
47
|
query,
|
|
47
48
|
rows: pageCount,
|
|
48
49
|
offset
|
|
49
|
-
})
|
|
50
|
+
});
|
|
50
51
|
}
|
|
51
52
|
catch (e) {
|
|
52
53
|
setHasError(true);
|
|
@@ -54,7 +55,7 @@ const DossiersBase = () => {
|
|
|
54
55
|
finally {
|
|
55
56
|
setLoading(false);
|
|
56
57
|
}
|
|
57
|
-
}, [phrase, setSearchParams, searchParams,
|
|
58
|
+
}, [phrase, setSearchParams, searchParams, request, pageCount, offset]);
|
|
58
59
|
// Load the items into list when response changes.
|
|
59
60
|
// This hook should only trigger when the 'response' changes.
|
|
60
61
|
useEffect(() => {
|
|
@@ -70,18 +71,19 @@ const DossiersBase = () => {
|
|
|
70
71
|
}, [response, load]);
|
|
71
72
|
const onPageChange = useCallback((_offset) => {
|
|
72
73
|
if (_offset !== offset) {
|
|
73
|
-
|
|
74
|
+
const modifiedRequest = getSearchRequestData({ offset: _offset });
|
|
75
|
+
searchParams.set('offset', modifiedRequest.offset.toString());
|
|
74
76
|
setSearchParams(searchParams, { replace: true });
|
|
75
|
-
setOffset(
|
|
77
|
+
setOffset(modifiedRequest.offset);
|
|
76
78
|
}
|
|
77
|
-
}, [offset, searchParams, setSearchParams]);
|
|
79
|
+
}, [offset, searchParams, setSearchParams, getSearchRequestData]);
|
|
78
80
|
const onDelete = useCallback((e, id) => {
|
|
79
81
|
e.preventDefault();
|
|
80
82
|
e.stopPropagation();
|
|
81
83
|
withConfirmDeleteModal(async () => {
|
|
82
84
|
try {
|
|
83
|
-
await dispatchApi(api.dossier.del(id), { throwError:
|
|
84
|
-
|
|
85
|
+
await dispatchApi(api.dossier.del(id), { throwError: true, showError: true });
|
|
86
|
+
remove(id);
|
|
85
87
|
showSuccessMessage(t('route.dossiers.manager.delete.success'));
|
|
86
88
|
}
|
|
87
89
|
catch (_err) {
|
|
@@ -89,7 +91,7 @@ const DossiersBase = () => {
|
|
|
89
91
|
console.warn(_err);
|
|
90
92
|
}
|
|
91
93
|
});
|
|
92
|
-
}, [dispatchApi,
|
|
94
|
+
}, [dispatchApi, remove, withConfirmDeleteModal, showSuccessMessage, t]);
|
|
93
95
|
useEffect(() => {
|
|
94
96
|
onSearch();
|
|
95
97
|
if (!searchParams.has('offset')) {
|
|
@@ -115,6 +117,6 @@ const DossiersBase = () => {
|
|
|
115
117
|
return (_jsx(ItemManager, { onSearch: onSearch, onPageChange: onPageChange, phrase: phrase, setPhrase: setPhrase, hasError: hasError, searching: loading, aboveSearch: _jsx(Typography, { sx: theme => ({ fontStyle: 'italic', color: theme.palette.text.disabled, mb: 0.5 }), variant: "body2", children: t('route.dossiers.search.prompt') }), renderer: ({ item }, classRenderer) => renderer(item.item, classRenderer()), response: response, onSelect: (item) => navigate(`/dossiers/${item.id}/edit`), onCreate: () => navigate('/dossiers/create'), createPrompt: "route.dossiers.create", searchPrompt: "route.dossiers.manager.search", createIcon: _jsx(Topic, { sx: { mr: 1 } }) }));
|
|
116
118
|
};
|
|
117
119
|
const Dossiers = () => {
|
|
118
|
-
return (_jsx(TuiListProvider, { children: _jsx(DossiersBase, {}) }));
|
|
120
|
+
return (_jsx(TuiListProvider, { children: _jsx(SearchResponseProvider, { idField: "dossier_id", children: _jsx(DossiersBase, {}) }) }));
|
|
119
121
|
};
|
|
120
122
|
export default Dossiers;
|
|
@@ -25,8 +25,26 @@ const mockFetchViews = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
|
25
25
|
vi.mock('components/app/providers/ViewProvider', () => ({
|
|
26
26
|
ViewContext: React.createContext({
|
|
27
27
|
views: {
|
|
28
|
-
'view-1': {
|
|
29
|
-
|
|
28
|
+
'view-1': {
|
|
29
|
+
view_id: 'view-1',
|
|
30
|
+
title: 'My View',
|
|
31
|
+
query: 'howler.status:open',
|
|
32
|
+
sort: 'event.created desc',
|
|
33
|
+
span: 'date.range.1.month',
|
|
34
|
+
type: 'personal',
|
|
35
|
+
owner: 'testuser',
|
|
36
|
+
settings: { advance_on_triage: false }
|
|
37
|
+
},
|
|
38
|
+
'view-2': {
|
|
39
|
+
view_id: 'view-2',
|
|
40
|
+
title: 'Second View',
|
|
41
|
+
query: 'howler.status:open',
|
|
42
|
+
sort: 'event.created desc',
|
|
43
|
+
span: 'date.range.1.month',
|
|
44
|
+
type: 'personal',
|
|
45
|
+
owner: 'testuser',
|
|
46
|
+
settings: { advance_on_triage: false }
|
|
47
|
+
}
|
|
30
48
|
},
|
|
31
49
|
fetchViews: mockFetchViews
|
|
32
50
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
/// <reference types="vitest" />
|
|
3
|
-
import { render, screen
|
|
3
|
+
import { act, render, screen } from '@testing-library/react';
|
|
4
4
|
import i18n from '@cccsaurora/howler-ui/i18n';
|
|
5
5
|
import React from 'react';
|
|
6
6
|
import { I18nextProvider } from 'react-i18next';
|
|
@@ -12,6 +12,7 @@ import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/Mod
|
|
|
12
12
|
import { OverviewContext } from '@cccsaurora/howler-ui/components/app/providers/OverviewProvider';
|
|
13
13
|
import HitOverview from '@cccsaurora/howler-ui/components/elements/hit/HitOverview';
|
|
14
14
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
15
|
+
import useMySnackbar from '@cccsaurora/howler-ui/components/hooks/useMySnackbar';
|
|
15
16
|
import useMyTheme from '@cccsaurora/howler-ui/components/hooks/useMyTheme';
|
|
16
17
|
import { useSearchParams } from 'react-router-dom';
|
|
17
18
|
import hitsData from '@cccsaurora/howler-ui/utils/hit.json';
|
|
@@ -27,6 +28,7 @@ const OverviewViewer = () => {
|
|
|
27
28
|
const { getOverviews } = useContext(OverviewContext);
|
|
28
29
|
const { dispatchApi } = useMyApi();
|
|
29
30
|
const { withConfirmDeleteModal } = useContext(ModalContext);
|
|
31
|
+
const { showSuccessMessage } = useMySnackbar();
|
|
30
32
|
const [overviewList, setOverviewList] = useState([]);
|
|
31
33
|
const [selectedOverview, setSelectedOverview] = useState(null);
|
|
32
34
|
const [content, setContent] = useState('');
|
|
@@ -131,12 +133,14 @@ const OverviewViewer = () => {
|
|
|
131
133
|
await dispatchApi(api.overview.del(selectedOverview.overview_id), {
|
|
132
134
|
logError: false,
|
|
133
135
|
showError: true,
|
|
134
|
-
throwError:
|
|
136
|
+
throwError: true
|
|
135
137
|
});
|
|
138
|
+
setOverviewList(l => l.filter(v => v.overview_id !== selectedOverview.overview_id));
|
|
136
139
|
setSelectedOverview(null);
|
|
137
140
|
setContent('');
|
|
141
|
+
showSuccessMessage(t('route.overviews.manager.delete.success'));
|
|
138
142
|
});
|
|
139
|
-
}, [dispatchApi, selectedOverview?.overview_id, withConfirmDeleteModal]);
|
|
143
|
+
}, [dispatchApi, selectedOverview?.overview_id, withConfirmDeleteModal, showSuccessMessage, t]);
|
|
140
144
|
const onSave = useCallback(async () => {
|
|
141
145
|
if (analytic && detection) {
|
|
142
146
|
try {
|
|
@@ -3,6 +3,7 @@ import { Article } from '@mui/icons-material';
|
|
|
3
3
|
import { Typography } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
6
|
+
import SearchResponseProvider, { SearchResponseContext } from '@cccsaurora/howler-ui/components/app/providers/SearchResponseProvider';
|
|
6
7
|
import { TuiListProvider } from '@cccsaurora/howler-ui/components/elements/addons/lists';
|
|
7
8
|
import { TuiListMethodContext } from '@cccsaurora/howler-ui/components/elements/addons/lists/TuiListProvider';
|
|
8
9
|
import ItemManager from '@cccsaurora/howler-ui/components/elements/display/ItemManager';
|
|
@@ -25,9 +26,9 @@ const OverviewsBase = () => {
|
|
|
25
26
|
const pageCount = useMyLocalStorageItem(StorageKey.PAGE_COUNT, 25)[0];
|
|
26
27
|
const [phrase, setPhrase] = useState('');
|
|
27
28
|
const [offset, setOffset] = useState(parseInt(searchParams.get('offset')) || 0);
|
|
28
|
-
const [response, setResponse] = useState(null);
|
|
29
29
|
const [hasError, setHasError] = useState(false);
|
|
30
30
|
const [loading, setLoading] = useState(false);
|
|
31
|
+
const { response, request, remove, getSearchRequestData } = useContext(SearchResponseContext);
|
|
31
32
|
const onSearch = useCallback(async () => {
|
|
32
33
|
try {
|
|
33
34
|
setLoading(true);
|
|
@@ -42,11 +43,11 @@ const OverviewsBase = () => {
|
|
|
42
43
|
// Check for the actual search query
|
|
43
44
|
const query = phrase ? `*:*${phrase}*` : '*:*';
|
|
44
45
|
// Ensure the overview should be visible and/or matches the type we are filtering for
|
|
45
|
-
|
|
46
|
+
await request(api.search.overview.post, {
|
|
46
47
|
query,
|
|
47
48
|
rows: pageCount,
|
|
48
49
|
offset
|
|
49
|
-
})
|
|
50
|
+
});
|
|
50
51
|
}
|
|
51
52
|
catch (e) {
|
|
52
53
|
setHasError(true);
|
|
@@ -54,7 +55,7 @@ const OverviewsBase = () => {
|
|
|
54
55
|
finally {
|
|
55
56
|
setLoading(false);
|
|
56
57
|
}
|
|
57
|
-
}, [phrase, setSearchParams, searchParams,
|
|
58
|
+
}, [phrase, setSearchParams, searchParams, request, pageCount, offset]);
|
|
58
59
|
// Load the items into list when response changes.
|
|
59
60
|
// This hook should only trigger when the 'response' changes.
|
|
60
61
|
useEffect(() => {
|
|
@@ -70,18 +71,19 @@ const OverviewsBase = () => {
|
|
|
70
71
|
}, [response, load]);
|
|
71
72
|
const onPageChange = useCallback((_offset) => {
|
|
72
73
|
if (_offset !== offset) {
|
|
73
|
-
|
|
74
|
+
const modifiedRequest = getSearchRequestData({ offset: _offset });
|
|
75
|
+
searchParams.set('offset', modifiedRequest.offset.toString());
|
|
74
76
|
setSearchParams(searchParams, { replace: true });
|
|
75
|
-
setOffset(
|
|
77
|
+
setOffset(modifiedRequest.offset);
|
|
76
78
|
}
|
|
77
|
-
}, [offset, searchParams, setSearchParams]);
|
|
79
|
+
}, [offset, searchParams, setSearchParams, getSearchRequestData]);
|
|
78
80
|
const onDelete = useCallback((e, id) => {
|
|
79
81
|
e.preventDefault();
|
|
80
82
|
e.stopPropagation();
|
|
81
83
|
withConfirmDeleteModal(async () => {
|
|
82
84
|
try {
|
|
83
|
-
await dispatchApi(api.overview.del(id), { throwError:
|
|
84
|
-
|
|
85
|
+
await dispatchApi(api.overview.del(id), { throwError: true, showError: true });
|
|
86
|
+
remove(id);
|
|
85
87
|
showSuccessMessage(t('route.overviews.manager.delete.success'));
|
|
86
88
|
}
|
|
87
89
|
catch (_err) {
|
|
@@ -89,7 +91,7 @@ const OverviewsBase = () => {
|
|
|
89
91
|
console.warn(_err);
|
|
90
92
|
}
|
|
91
93
|
});
|
|
92
|
-
}, [dispatchApi,
|
|
94
|
+
}, [dispatchApi, remove, withConfirmDeleteModal, showSuccessMessage, t]);
|
|
93
95
|
useEffect(() => {
|
|
94
96
|
onSearch();
|
|
95
97
|
if (!searchParams.has('offset')) {
|
|
@@ -115,6 +117,6 @@ const OverviewsBase = () => {
|
|
|
115
117
|
return (_jsx(ItemManager, { onSearch: onSearch, onPageChange: onPageChange, phrase: phrase, setPhrase: setPhrase, hasError: hasError, searching: loading, aboveSearch: _jsx(Typography, { sx: theme => ({ fontStyle: 'italic', color: theme.palette.text.disabled, mb: 0.5 }), variant: "body2", children: t('route.overviews.search.prompt') }), renderer: ({ item }, classRenderer) => renderer(item.item, classRenderer()), response: response, onSelect: (item) => navigate(`/overviews/view?analytic=${item.item.analytic}${item.item.detection ? '&detection=' + item.item.detection : ''}`), onCreate: () => navigate('/overviews/view'), createPrompt: "route.overviews.create", searchPrompt: "route.overviews.manager.search", createIcon: _jsx(Article, { sx: { mr: 1 } }) }));
|
|
116
118
|
};
|
|
117
119
|
const Overviews = () => {
|
|
118
|
-
return (_jsx(TuiListProvider, { children: _jsx(OverviewsBase, {}) }));
|
|
120
|
+
return (_jsx(TuiListProvider, { children: _jsx(SearchResponseProvider, { idField: "overview_id", children: _jsx(OverviewsBase, {}) }) }));
|
|
119
121
|
};
|
|
120
122
|
export default Overviews;
|
|
@@ -10,6 +10,7 @@ import AppInfoPanel from '@cccsaurora/howler-ui/commons/components/display/AppIn
|
|
|
10
10
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
11
11
|
import { DEFAULT_FIELDS } from '@cccsaurora/howler-ui/components/elements/hit/HitOutline';
|
|
12
12
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
13
|
+
import useMySnackbar from '@cccsaurora/howler-ui/components/hooks/useMySnackbar';
|
|
13
14
|
import isEqual from 'lodash-es/isEqual';
|
|
14
15
|
import { useSearchParams } from 'react-router-dom';
|
|
15
16
|
import hitsData from '@cccsaurora/howler-ui/utils/hit.json';
|
|
@@ -19,6 +20,7 @@ const TemplateViewer = () => {
|
|
|
19
20
|
const [params, setParams] = useSearchParams();
|
|
20
21
|
const { dispatchApi } = useMyApi();
|
|
21
22
|
const { withConfirmDeleteModal } = useContext(ModalContext);
|
|
23
|
+
const { showSuccessMessage } = useMySnackbar();
|
|
22
24
|
const [templateList, setTemplateList] = useState([]);
|
|
23
25
|
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
|
24
26
|
const [sessionTemplateList, setSessionTemplateList] = useState([]);
|
|
@@ -122,7 +124,7 @@ const TemplateViewer = () => {
|
|
|
122
124
|
await dispatchApi(api.template.del(selectedTemplate.template_id), {
|
|
123
125
|
logError: false,
|
|
124
126
|
showError: true,
|
|
125
|
-
throwError:
|
|
127
|
+
throwError: true
|
|
126
128
|
});
|
|
127
129
|
setSessionTemplateList(l => l.filter(v => v.analytic != selectedTemplate.analytic ||
|
|
128
130
|
v.detection != selectedTemplate.detection ||
|
|
@@ -130,6 +132,7 @@ const TemplateViewer = () => {
|
|
|
130
132
|
setTemplateList(l => l.filter(v => v.analytic != selectedTemplate.analytic ||
|
|
131
133
|
v.detection != selectedTemplate.detection ||
|
|
132
134
|
v.type != selectedTemplate.type));
|
|
135
|
+
showSuccessMessage(t('route.templates.manager.delete.success'));
|
|
133
136
|
});
|
|
134
137
|
}, [
|
|
135
138
|
dispatchApi,
|
|
@@ -137,7 +140,9 @@ const TemplateViewer = () => {
|
|
|
137
140
|
selectedTemplate?.detection,
|
|
138
141
|
selectedTemplate?.template_id,
|
|
139
142
|
selectedTemplate?.type,
|
|
140
|
-
withConfirmDeleteModal
|
|
143
|
+
withConfirmDeleteModal,
|
|
144
|
+
showSuccessMessage,
|
|
145
|
+
t
|
|
141
146
|
]);
|
|
142
147
|
const onSave = useCallback(async () => {
|
|
143
148
|
if (analytic && detection) {
|
|
@@ -4,11 +4,13 @@ import { Stack, ToggleButton, ToggleButtonGroup, Typography } from '@mui/materia
|
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks';
|
|
6
6
|
import { AnalyticContext } from '@cccsaurora/howler-ui/components/app/providers/AnalyticProvider';
|
|
7
|
+
import SearchResponseProvider, { SearchResponseContext } from '@cccsaurora/howler-ui/components/app/providers/SearchResponseProvider';
|
|
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';
|
|
9
10
|
import ItemManager from '@cccsaurora/howler-ui/components/elements/display/ItemManager';
|
|
10
11
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
11
12
|
import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/useMyLocalStorage';
|
|
13
|
+
import useMySnackbar from '@cccsaurora/howler-ui/components/hooks/useMySnackbar';
|
|
12
14
|
import { useCallback, useContext, useEffect, useState } from 'react';
|
|
13
15
|
import { useTranslation } from 'react-i18next';
|
|
14
16
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
@@ -20,12 +22,13 @@ const TemplatesBase = () => {
|
|
|
20
22
|
const navigate = useNavigate();
|
|
21
23
|
const { dispatchApi } = useMyApi();
|
|
22
24
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
23
|
-
const { load
|
|
25
|
+
const { load } = useContext(TuiListMethodContext);
|
|
24
26
|
const pageCount = useMyLocalStorageItem(StorageKey.PAGE_COUNT, 25)[0];
|
|
27
|
+
const { showSuccessMessage } = useMySnackbar();
|
|
25
28
|
const { analytics } = useContext(AnalyticContext);
|
|
29
|
+
const { response, request, remove, getSearchRequestData } = useContext(SearchResponseContext);
|
|
26
30
|
const [phrase, setPhrase] = useState('');
|
|
27
31
|
const [offset, setOffset] = useState(parseInt(searchParams.get('offset')) || 0);
|
|
28
|
-
const [response, setResponse] = useState(null);
|
|
29
32
|
const [types, setTypes] = useState([]);
|
|
30
33
|
const [hasError, setHasError] = useState(false);
|
|
31
34
|
const [searching, setSearching] = useState(false);
|
|
@@ -44,11 +47,11 @@ const TemplatesBase = () => {
|
|
|
44
47
|
const phraseQuery = phrase ? `*:*${phrase}*` : '*:*';
|
|
45
48
|
// Ensure the template should be visible and/or matches the type we are filtering for
|
|
46
49
|
const typeQuery = `(type:global OR owner:(${user.username} OR none)) AND type:(${types.join(' ') || '*'})`;
|
|
47
|
-
|
|
50
|
+
await request(api.search.template.post, {
|
|
48
51
|
query: `${phraseQuery} AND ${typeQuery}`,
|
|
49
52
|
rows: pageCount,
|
|
50
53
|
offset
|
|
51
|
-
})
|
|
54
|
+
});
|
|
52
55
|
}
|
|
53
56
|
catch (e) {
|
|
54
57
|
setHasError(true);
|
|
@@ -56,7 +59,7 @@ const TemplatesBase = () => {
|
|
|
56
59
|
finally {
|
|
57
60
|
setSearching(false);
|
|
58
61
|
}
|
|
59
|
-
}, [phrase, setSearchParams, searchParams, user.username, types,
|
|
62
|
+
}, [phrase, setSearchParams, searchParams, user.username, types, request, pageCount, offset]);
|
|
60
63
|
// Load the items into list when response changes.
|
|
61
64
|
// This hook should only trigger when the 'response' changes.
|
|
62
65
|
// or if the analytic list changes to refresh the disabled state
|
|
@@ -77,11 +80,12 @@ const TemplatesBase = () => {
|
|
|
77
80
|
}, [response, load, analytics]);
|
|
78
81
|
const onPageChange = useCallback((_offset) => {
|
|
79
82
|
if (_offset !== offset) {
|
|
80
|
-
|
|
83
|
+
const modifiedRequest = getSearchRequestData({ offset: _offset });
|
|
84
|
+
searchParams.set('offset', modifiedRequest.offset.toString());
|
|
81
85
|
setSearchParams(searchParams, { replace: true });
|
|
82
|
-
setOffset(
|
|
86
|
+
setOffset(modifiedRequest.offset);
|
|
83
87
|
}
|
|
84
|
-
}, [offset, searchParams, setSearchParams]);
|
|
88
|
+
}, [offset, searchParams, setSearchParams, getSearchRequestData]);
|
|
85
89
|
useEffect(() => {
|
|
86
90
|
onSearch();
|
|
87
91
|
if (!searchParams.has('offset')) {
|
|
@@ -107,10 +111,11 @@ const TemplatesBase = () => {
|
|
|
107
111
|
await dispatchApi(api.template.del(templateId), {
|
|
108
112
|
logError: false,
|
|
109
113
|
showError: true,
|
|
110
|
-
throwError:
|
|
114
|
+
throwError: true
|
|
111
115
|
});
|
|
112
116
|
remove(templateId);
|
|
113
|
-
|
|
117
|
+
showSuccessMessage(t('route.templates.manager.delete.success'));
|
|
118
|
+
}, [dispatchApi, remove, showSuccessMessage, t]);
|
|
114
119
|
const renderer = useCallback((item, error, className) => (_jsx(TemplateCard, { template: item, error: error, onRemove: removeTemplate, className: className })), [removeTemplate]);
|
|
115
120
|
return (_jsx(ItemManager, { onSearch: onSearch, onPageChange: onPageChange, phrase: phrase, setPhrase: setPhrase, hasError: hasError, searching: searching, searchFilters: _jsx(Stack, { direction: "row", spacing: 1, alignItems: "center", children: _jsxs(ToggleButtonGroup, { sx: { display: 'grid', gridTemplateColumns: '1fr 1fr', alignSelf: 'start' }, size: "small", value: types, onChange: (__, _types) => {
|
|
116
121
|
if (_types) {
|
|
@@ -121,6 +126,6 @@ const TemplatesBase = () => {
|
|
|
121
126
|
}, onCreate: () => navigate('/templates/view'), createPrompt: "route.templates.create", searchPrompt: "route.templates.manager.search", createIcon: _jsx(Article, { sx: { mr: 1 } }) }));
|
|
122
127
|
};
|
|
123
128
|
const Templates = () => {
|
|
124
|
-
return (_jsx(TuiListProvider, { children: _jsx(TemplatesBase, {}) }));
|
|
129
|
+
return (_jsx(TuiListProvider, { children: _jsx(SearchResponseProvider, { idField: "template_id", children: _jsx(TemplatesBase, {}) }) }));
|
|
125
130
|
};
|
|
126
131
|
export default Templates;
|
|
@@ -5,6 +5,7 @@ import { Autocomplete, Card, Checkbox, IconButton, Skeleton, Stack, TextField, T
|
|
|
5
5
|
import api from '@cccsaurora/howler-ui/api';
|
|
6
6
|
import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks';
|
|
7
7
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
8
|
+
import SearchResponseProvider, { SearchResponseContext } from '@cccsaurora/howler-ui/components/app/providers/SearchResponseProvider';
|
|
8
9
|
import { ViewContext } from '@cccsaurora/howler-ui/components/app/providers/ViewProvider';
|
|
9
10
|
import FlexOne from '@cccsaurora/howler-ui/components/elements/addons/layout/FlexOne';
|
|
10
11
|
import { TuiListProvider } from '@cccsaurora/howler-ui/components/elements/addons/lists';
|
|
@@ -12,8 +13,8 @@ import { TuiListMethodContext } from '@cccsaurora/howler-ui/components/elements/
|
|
|
12
13
|
import HowlerAvatar from '@cccsaurora/howler-ui/components/elements/display/HowlerAvatar';
|
|
13
14
|
import ItemManager from '@cccsaurora/howler-ui/components/elements/display/ItemManager';
|
|
14
15
|
import { ViewTitle } from '@cccsaurora/howler-ui/components/elements/view/ViewTitle';
|
|
15
|
-
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
16
16
|
import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/useMyLocalStorage';
|
|
17
|
+
import useMySnackbar from '@cccsaurora/howler-ui/components/hooks/useMySnackbar';
|
|
17
18
|
import { isNull, omitBy, size } from 'lodash-es';
|
|
18
19
|
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
|
19
20
|
import { useTranslation } from 'react-i18next';
|
|
@@ -27,8 +28,8 @@ const ViewsBase = () => {
|
|
|
27
28
|
const { t } = useTranslation();
|
|
28
29
|
const { user } = useAppUser();
|
|
29
30
|
const navigate = useNavigate();
|
|
30
|
-
const { dispatchApi } = useMyApi();
|
|
31
31
|
const { withConfirmDeleteModal } = useContext(ModalContext);
|
|
32
|
+
const { showSuccessMessage } = useMySnackbar();
|
|
32
33
|
const fetchViews = useContextSelector(ViewContext, ctx => ctx.fetchViews);
|
|
33
34
|
const addFavourite = useContextSelector(ViewContext, ctx => ctx.addFavourite);
|
|
34
35
|
const removeFavourite = useContextSelector(ViewContext, ctx => ctx.removeFavourite);
|
|
@@ -39,9 +40,9 @@ const ViewsBase = () => {
|
|
|
39
40
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
40
41
|
const { load } = useContext(TuiListMethodContext);
|
|
41
42
|
const pageCount = useMyLocalStorageItem(StorageKey.PAGE_COUNT, 25)[0];
|
|
43
|
+
const { response, request, remove, getSearchRequestData } = useContext(SearchResponseContext);
|
|
42
44
|
const [phrase, setPhrase] = useState('');
|
|
43
45
|
const [offset, setOffset] = useState(parseInt(searchParams.get('offset')) || 0);
|
|
44
|
-
const [response, setResponse] = useState(null);
|
|
45
46
|
const [type, setType] = useState(searchParams.get('type') || null);
|
|
46
47
|
const [hasError, setHasError] = useState(false);
|
|
47
48
|
const [searching, setSearching] = useState(false);
|
|
@@ -63,11 +64,11 @@ const ViewsBase = () => {
|
|
|
63
64
|
const phraseQuery = FIELDS_TO_SEARCH.map(_field => `${_field}:${searchTerm}`).join(' OR ');
|
|
64
65
|
const typeQuery = `(type:global OR owner:(${user.username} OR none)) AND type:(${type ?? '*'}${type === 'personal' ? ' OR readonly' : ''})`;
|
|
65
66
|
const favouritesQuery = favouritesOnly && user.favourite_views.length > 0 ? ` AND view_id:(${user.favourite_views.join(' OR ')})` : '';
|
|
66
|
-
|
|
67
|
+
await request(api.search.view.post, {
|
|
67
68
|
query: `(${phraseQuery}) AND ${typeQuery}${favouritesQuery}`,
|
|
68
69
|
rows: pageCount,
|
|
69
70
|
offset
|
|
70
|
-
})
|
|
71
|
+
});
|
|
71
72
|
}
|
|
72
73
|
catch (e) {
|
|
73
74
|
setHasError(true);
|
|
@@ -83,7 +84,7 @@ const ViewsBase = () => {
|
|
|
83
84
|
user.favourite_views,
|
|
84
85
|
type,
|
|
85
86
|
favouritesOnly,
|
|
86
|
-
|
|
87
|
+
request,
|
|
87
88
|
pageCount,
|
|
88
89
|
offset
|
|
89
90
|
]);
|
|
@@ -102,19 +103,21 @@ const ViewsBase = () => {
|
|
|
102
103
|
}, [response, load]);
|
|
103
104
|
const onPageChange = useCallback((_offset) => {
|
|
104
105
|
if (_offset !== offset) {
|
|
105
|
-
|
|
106
|
+
const modifiedRequest = getSearchRequestData({ offset: _offset });
|
|
107
|
+
searchParams.set('offset', modifiedRequest.offset.toString());
|
|
106
108
|
setSearchParams(searchParams, { replace: true });
|
|
107
|
-
setOffset(
|
|
109
|
+
setOffset(modifiedRequest.offset);
|
|
108
110
|
}
|
|
109
|
-
}, [offset, searchParams, setSearchParams]);
|
|
111
|
+
}, [offset, searchParams, setSearchParams, getSearchRequestData]);
|
|
110
112
|
const onDelete = useCallback((event, id) => {
|
|
111
113
|
event.preventDefault();
|
|
112
114
|
event.stopPropagation();
|
|
113
115
|
withConfirmDeleteModal(async () => {
|
|
114
116
|
await removeView(id);
|
|
115
|
-
|
|
117
|
+
remove(id);
|
|
118
|
+
showSuccessMessage(t('route.views.manager.delete.success'));
|
|
116
119
|
});
|
|
117
|
-
}, [
|
|
120
|
+
}, [remove, removeView, withConfirmDeleteModal, showSuccessMessage, t]);
|
|
118
121
|
const onFavourite = useCallback(async (event, id) => {
|
|
119
122
|
event.preventDefault();
|
|
120
123
|
if (user.favourite_views?.includes(id)) {
|
|
@@ -182,6 +185,6 @@ const ViewsBase = () => {
|
|
|
182
185
|
(item.item.type === 'global' && user.is_admin)) && (_jsx(Tooltip, { title: t('button.edit'), children: _jsx(IconButton, { component: Link, to: `/views/${item.item.view_id}/edit?query=${item.item.query}`, children: _jsx(Edit, {}) }) })), item.item.owner === user.username && item.item.type !== 'readonly' && (_jsx(Tooltip, { title: t('button.delete'), children: _jsx(IconButton, { onClick: event => onDelete(event, item.item.view_id), children: _jsx(Clear, {}) }) })), item.item.type === 'global' && item.item.owner !== user.username && (_jsx(Tooltip, { title: item.item.owner, children: _jsx("div", { children: _jsx(HowlerAvatar, { sx: { width: 24, height: 24, marginRight: '8px !important', marginLeft: '8px !important' }, userId: item.item.owner }) }) })), _jsx(Tooltip, { title: t('button.pin'), children: _jsx(IconButton, { onClick: e => onFavourite(e, item.item.view_id), children: user.favourite_views?.includes(item.item.view_id) ? _jsx(Star, {}) : _jsx(StarBorder, {}) }) })] }) }, item.item.view_id)), response: response, searchPrompt: "route.views.manager.search", onCreate: () => navigate('/views/create'), createPrompt: "route.views.create", createIcon: _jsx(SavedSearch, { sx: { mr: 1 } }) }));
|
|
183
186
|
};
|
|
184
187
|
const Views = () => {
|
|
185
|
-
return (_jsx(TuiListProvider, { children: _jsx(ViewsBase, {}) }));
|
|
188
|
+
return (_jsx(TuiListProvider, { children: _jsx(SearchResponseProvider, { idField: "view_id", children: _jsx(ViewsBase, {}) }) }));
|
|
186
189
|
};
|
|
187
190
|
export default Views;
|
|
@@ -448,6 +448,7 @@
|
|
|
448
448
|
"route.actions.edit": "Edit",
|
|
449
449
|
"route.actions.execute": "Execute",
|
|
450
450
|
"route.actions.manager": "Action Manager",
|
|
451
|
+
"route.actions.manager.delete.success": "Action removed.",
|
|
451
452
|
"route.actions.name": "Action Name",
|
|
452
453
|
"route.actions.open": "Open Query",
|
|
453
454
|
"route.actions.operation.add": "Add New Operation",
|
|
@@ -661,7 +662,7 @@
|
|
|
661
662
|
"route.overviews.default": "Default",
|
|
662
663
|
"route.overviews.detection": "Choose Detection",
|
|
663
664
|
"route.overviews.manager.delete": "Delete Overview",
|
|
664
|
-
"route.overviews.manager.delete.success": "Overview
|
|
665
|
+
"route.overviews.manager.delete.success": "Overview removed.",
|
|
665
666
|
"route.overviews.manager.search": "Search Overviews",
|
|
666
667
|
"route.overviews.prompt": "Activate autocomplete using [ctrl + space].",
|
|
667
668
|
"route.overviews.search.prompt": "Search by title, content, analytic or detection.",
|
|
@@ -679,6 +680,7 @@
|
|
|
679
680
|
"route.templates.default": "Default",
|
|
680
681
|
"route.templates.detection": "Choose Detection",
|
|
681
682
|
"route.templates.global": "Global",
|
|
683
|
+
"route.templates.manager.delete.success": "Template removed.",
|
|
682
684
|
"route.templates.manager.error.action": "Click to open quick fix options",
|
|
683
685
|
"route.templates.manager.error.message": "Invalid Detection",
|
|
684
686
|
"route.templates.manager.error.modal.description": "The template fields are read only and will not be used. Do you want to remove the template?",
|
|
@@ -702,6 +704,7 @@
|
|
|
702
704
|
"route.views.manager": "View Manager",
|
|
703
705
|
"route.views.manager.all": "All",
|
|
704
706
|
"route.views.manager.default": "Default View",
|
|
707
|
+
"route.views.manager.delete.success": "View removed.",
|
|
705
708
|
"route.views.manager.favourites": "Show favourites only",
|
|
706
709
|
"route.views.manager.global": "Global",
|
|
707
710
|
"route.views.manager.open": "Open View",
|
|
@@ -448,6 +448,7 @@
|
|
|
448
448
|
"route.actions.edit": "Modifier",
|
|
449
449
|
"route.actions.execute": "Exécuter",
|
|
450
450
|
"route.actions.manager": "Gestion des actions",
|
|
451
|
+
"route.actions.manager.delete.success": "Action supprimée.",
|
|
451
452
|
"route.actions.name": "Nom de l'action",
|
|
452
453
|
"route.actions.open": "Ouvrir la requête",
|
|
453
454
|
"route.actions.operation.add": "Ajouter une nouvelle opération",
|
|
@@ -661,7 +662,7 @@
|
|
|
661
662
|
"route.overviews.default": "Défaut",
|
|
662
663
|
"route.overviews.detection": "Choisir une détection",
|
|
663
664
|
"route.overviews.manager.delete": "Supprimer la vue d'ensemble",
|
|
664
|
-
"route.overviews.manager.delete.success": "Vue d'ensemble
|
|
665
|
+
"route.overviews.manager.delete.success": "Vue d'ensemble supprimée.",
|
|
665
666
|
"route.overviews.manager.search": "Rechercher les vues d'ensemble",
|
|
666
667
|
"route.overviews.prompt": "Activer l'autocomplétion en utilisant [ctrl + espace].",
|
|
667
668
|
"route.overviews.search.prompt": "Recherche par titre, contenu, clé d'analyse ou de détection.",
|
|
@@ -679,6 +680,7 @@
|
|
|
679
680
|
"route.templates.default": "Défaut",
|
|
680
681
|
"route.templates.detection": "Choisir une détection",
|
|
681
682
|
"route.templates.global": "Global",
|
|
683
|
+
"route.templates.manager.delete.success": "Modèle supprimé.",
|
|
682
684
|
"route.templates.manager.error.action": "Cliquez ici pour afficher les options de correction rapide",
|
|
683
685
|
"route.templates.manager.error.message": "Détection non valide",
|
|
684
686
|
"route.templates.manager.error.modal.description": "Les clés du modèle sont en lecture seule et ne seront pas utilisées. Voulez-vous supprimer le modèle ?",
|
|
@@ -702,6 +704,7 @@
|
|
|
702
704
|
"route.views.manager": "Gérer vue",
|
|
703
705
|
"route.views.manager.all": "Tous",
|
|
704
706
|
"route.views.manager.default": "Vue par défaut",
|
|
707
|
+
"route.views.manager.delete.success": "Vue supprimée.",
|
|
705
708
|
"route.views.manager.favourites": "Afficher uniquement les favoris",
|
|
706
709
|
"route.views.manager.global": "Global",
|
|
707
710
|
"route.views.manager.open": "Ouvrier la vue",
|