@cccsaurora/howler-ui 2.15.0-dev.299 → 2.15.0-dev.308
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/app/providers/ApiConfigProvider.d.ts +3 -1
- package/components/app/providers/ApiConfigProvider.js +8 -8
- package/components/elements/ThemedEditor.d.ts +3 -1
- package/components/elements/ThemedEditor.js +2 -2
- package/components/routes/advanced/QueryEditor.d.ts +1 -0
- package/components/routes/advanced/QueryEditor.js +2 -2
- package/components/routes/dossiers/DossierEditor.js +67 -15
- package/components/routes/dossiers/DossierEditor.test.d.ts +1 -0
- package/components/routes/dossiers/DossierEditor.test.js +360 -0
- package/components/routes/dossiers/LeadEditor.js +2 -2
- package/components/routes/dossiers/LeadForm.js +2 -2
- package/components/routes/dossiers/PivotForm.js +2 -2
- package/components/routes/help/markdown/en/retention.md.js +1 -1
- package/components/routes/help/markdown/fr/retention.md.js +1 -1
- package/components/routes/hits/search/HitQuery.js +1 -1
- package/locales/en/translation.json +15 -1
- package/locales/fr/translation.json +15 -1
- package/package.json +1 -1
|
@@ -5,5 +5,7 @@ export type ApiConfigContextType = {
|
|
|
5
5
|
setConfig: (config: ApiType) => void;
|
|
6
6
|
};
|
|
7
7
|
export declare const ApiConfigContext: import("react").Context<ApiConfigContextType>;
|
|
8
|
-
declare const ApiConfigProvider: FC<PropsWithChildren
|
|
8
|
+
declare const ApiConfigProvider: FC<PropsWithChildren<{
|
|
9
|
+
defaultConfig?: ApiType;
|
|
10
|
+
}>>;
|
|
9
11
|
export default ApiConfigProvider;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useMemo, useState } from 'react';
|
|
3
3
|
export const ApiConfigContext = createContext(null);
|
|
4
|
-
const ApiConfigProvider = ({ children
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
const ApiConfigProvider = ({ children, defaultConfig = {
|
|
5
|
+
indexes: null,
|
|
6
|
+
lookups: null,
|
|
7
|
+
configuration: null,
|
|
8
|
+
c12nDef: null,
|
|
9
|
+
mapping: null
|
|
10
|
+
} }) => {
|
|
11
|
+
const [config, setConfig] = useState(defaultConfig);
|
|
12
12
|
const context = useMemo(() => ({
|
|
13
13
|
config,
|
|
14
14
|
setConfig
|
|
@@ -4,7 +4,7 @@ import { useTheme } from '@mui/material';
|
|
|
4
4
|
import useThemeBuilder from '@cccsaurora/howler-ui/commons/components/utils/hooks/useThemeBuilder';
|
|
5
5
|
import useMyTheme from '@cccsaurora/howler-ui/components/hooks/useMyTheme';
|
|
6
6
|
import { memo, useCallback, useEffect, useMemo } from 'react';
|
|
7
|
-
const ThemedEditor = ({ beforeMount, options = {}, ...otherProps }) => {
|
|
7
|
+
const ThemedEditor = ({ beforeMount, options = {}, id, ...otherProps }) => {
|
|
8
8
|
const myTheme = useMyTheme();
|
|
9
9
|
const themeBuilder = useThemeBuilder(myTheme);
|
|
10
10
|
const theme = useTheme();
|
|
@@ -99,6 +99,6 @@ const ThemedEditor = ({ beforeMount, options = {}, ...otherProps }) => {
|
|
|
99
99
|
},
|
|
100
100
|
...options
|
|
101
101
|
}), [options]);
|
|
102
|
-
return (_jsx(Editor, { ...otherProps, theme: theme.palette.mode === 'light' ? 'howler' : 'howler-dark', beforeMount: _beforeMount, options: _options }));
|
|
102
|
+
return (_jsx(Editor, { ...otherProps, wrapperProps: { id }, theme: theme.palette.mode === 'light' ? 'howler' : 'howler-dark', beforeMount: _beforeMount, options: _options }));
|
|
103
103
|
};
|
|
104
104
|
export default memo(ThemedEditor);
|
|
@@ -12,7 +12,7 @@ import useHistoryCompletionProvider from './historyCompletionProvider';
|
|
|
12
12
|
import useLuceneCompletionProvider from './luceneCompletionProvider';
|
|
13
13
|
import LUCENE_TOKEN_PROVIDER from './luceneTokenProvider';
|
|
14
14
|
import useYamlCompletionProvider from './yamlCompletionProvider';
|
|
15
|
-
const QueryEditor = ({ query, setQuery, onMount, language = 'lucene', fontSize = 16, height = '100%', width = '100%', editorOptions = {} }) => {
|
|
15
|
+
const QueryEditor = ({ query, setQuery, onMount, language = 'lucene', fontSize = 16, height = '100%', width = '100%', editorOptions = {}, id }) => {
|
|
16
16
|
const theme = useTheme();
|
|
17
17
|
const monaco = useMonaco();
|
|
18
18
|
const { config } = useContext(ApiConfigContext);
|
|
@@ -85,6 +85,6 @@ const QueryEditor = ({ query, setQuery, onMount, language = 'lucene', fontSize =
|
|
|
85
85
|
setFzfSearch(!fzfSearch);
|
|
86
86
|
}
|
|
87
87
|
}, [fzfSearch, setFzfSearch]);
|
|
88
|
-
return (_jsx(Box, { sx: { flex: 1 }, onKeyDownCapture: handleKeyPress, children: _jsx(ThemedEditor, { height: height, width: width, theme: theme.palette.mode === 'light' ? 'howler' : 'howler-dark', value: query, onChange: value => setQuery(value), beforeMount: beforeEditorMount, onMount: onMount, options: options }) }));
|
|
88
|
+
return (_jsx(Box, { sx: { flex: 1 }, onKeyDownCapture: handleKeyPress, children: _jsx(ThemedEditor, { height: height, width: width, theme: theme.palette.mode === 'light' ? 'howler' : 'howler-dark', value: query, onChange: value => setQuery(value), beforeMount: beforeEditorMount, onMount: onMount, options: options, wrapperProps: { id } }) }));
|
|
89
89
|
};
|
|
90
90
|
export default memo(QueryEditor);
|
|
@@ -52,23 +52,75 @@ const DossierEditor = () => {
|
|
|
52
52
|
if ((dossier.leads ?? []).length < 1 && (dossier.pivots ?? []).length < 1) {
|
|
53
53
|
return t('route.dossiers.manager.validation.error.items');
|
|
54
54
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
lead.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
55
|
+
for (const lead of dossier.leads ?? []) {
|
|
56
|
+
if (!lead.label) {
|
|
57
|
+
// You have not configured a lead label.
|
|
58
|
+
return t('route.dossiers.manager.validation.error.leads.label');
|
|
59
|
+
}
|
|
60
|
+
if (!lead.label.en) {
|
|
61
|
+
// You have not configured an english lead label.
|
|
62
|
+
return t('route.dossiers.manager.validation.error.leads.label.en');
|
|
63
|
+
}
|
|
64
|
+
if (!lead.label.fr) {
|
|
65
|
+
// You have not configured a french lead label.
|
|
66
|
+
return t('route.dossiers.manager.validation.error.leads.label.fr');
|
|
67
|
+
}
|
|
68
|
+
if (!lead.format) {
|
|
69
|
+
// You have not set the format for the lead with label <label>
|
|
70
|
+
return t('route.dossiers.manager.validation.error.leads.format', { label: lead.label[i18n.language] });
|
|
71
|
+
}
|
|
72
|
+
if (!lead.content) {
|
|
73
|
+
// You have not set the content for the lead with label <label>
|
|
74
|
+
return t('route.dossiers.manager.validation.error.leads.content', { label: lead.label[i18n.language] });
|
|
75
|
+
}
|
|
76
|
+
if (!lead.icon || !iconExists(lead.icon)) {
|
|
77
|
+
// You are missing an icon, or the specified icon does not exist for lead with label <label>
|
|
78
|
+
return t('route.dossiers.manager.validation.error.leads.icon', { label: lead.label[i18n.language] });
|
|
79
|
+
}
|
|
66
80
|
}
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
for (const pivot of dossier.pivots ?? []) {
|
|
82
|
+
if (!pivot.label) {
|
|
83
|
+
// You have not configured a pivot label.
|
|
84
|
+
return t('route.dossiers.manager.validation.error.pivots.label');
|
|
85
|
+
}
|
|
86
|
+
if (!pivot.label.en) {
|
|
87
|
+
// You have not configured an english pivot label.
|
|
88
|
+
return t('route.dossiers.manager.validation.error.pivots.label.en');
|
|
89
|
+
}
|
|
90
|
+
if (!pivot.label.fr) {
|
|
91
|
+
// You have not configured a french pivot label.
|
|
92
|
+
return t('route.dossiers.manager.validation.error.pivots.label.fr');
|
|
93
|
+
}
|
|
94
|
+
if (!pivot.format) {
|
|
95
|
+
// You have not set the format for the pivot with label <label>
|
|
96
|
+
return t('route.dossiers.manager.validation.error.pivots.format', { label: pivot.label[i18n.language] });
|
|
97
|
+
}
|
|
98
|
+
if (!pivot.value) {
|
|
99
|
+
// You have not set the value for the pivot with label <label>
|
|
100
|
+
return t('route.dossiers.manager.validation.error.pivots.value', { label: pivot.label[i18n.language] });
|
|
101
|
+
}
|
|
102
|
+
if (!pivot.icon || !iconExists(pivot.icon)) {
|
|
103
|
+
// You are missing an icon, or the specified icon does not exist for pivot with label <label>
|
|
104
|
+
return t('route.dossiers.manager.validation.error.pivots.icon', { label: pivot.label[i18n.language] });
|
|
105
|
+
}
|
|
106
|
+
if (!pivot.mappings || pivot.mappings.length < 1) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if ((pivot.mappings ?? []).length !== uniqBy(pivot.mappings ?? [], 'key').length) {
|
|
110
|
+
// You have a duplicate for pivot with label <label>
|
|
111
|
+
return t('route.dossiers.manager.validation.error.pivots.duplicate', { label: pivot.label[i18n.language] });
|
|
112
|
+
}
|
|
113
|
+
if (pivot.mappings?.some(mapping => !mapping.key)) {
|
|
114
|
+
// You have not configured a key for a mapping for pivot with label <label>
|
|
115
|
+
return t('route.dossiers.manager.validation.error.pivots.key', { label: pivot.label[i18n.language] });
|
|
116
|
+
}
|
|
117
|
+
if (pivot.mappings?.some(mapping => !mapping.field || (mapping.field === 'custom' && !mapping.custom_value))) {
|
|
118
|
+
// You have not configured a field or custom value for a mapping for pivot with label <label>
|
|
119
|
+
return t('route.dossiers.manager.validation.error.pivots.field', { label: pivot.label[i18n.language] });
|
|
120
|
+
}
|
|
69
121
|
}
|
|
70
122
|
return null;
|
|
71
|
-
}, [dossier, searchDirty, searchTotal, t]);
|
|
123
|
+
}, [dossier, i18n.language, searchDirty, searchTotal, t]);
|
|
72
124
|
const save = useCallback(async () => {
|
|
73
125
|
setLoading(true);
|
|
74
126
|
try {
|
|
@@ -119,7 +171,7 @@ const DossierEditor = () => {
|
|
|
119
171
|
whiteSpace: 'nowrap',
|
|
120
172
|
pointerEvents: 'initial !important',
|
|
121
173
|
...(isNarrow ? { bottom: theme.spacing(1) } : { top: 0 })
|
|
122
|
-
}), onClick: save, children: [loading ? _jsx(CircularProgress, { size: 24, sx: { mr: 1 } }) : _jsx(Save, { sx: { mr: 1 } }), _jsx(Typography, { children: t('save') })] }) }) }), _jsxs(Stack, { spacing: 1, height: "100%", children: [_jsx(Paper, { sx: { p: 1 }, children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { spacing: 1, direction: "row", children: [_jsx(TextField, { disabled: !dossier || loading, label: "Title", size: "small", value: dossier.title ?? '', onChange: ev => setDossier(_dossier => ({ ..._dossier, title: ev.target.value })), fullWidth: true }), _jsxs(ToggleButtonGroup, { disabled: !dossier || loading, exclusive: true, value: dossier.type ?? 'global', onChange: (_ev, type) => setDossier(_dossier => ({ ..._dossier, type })), children: [_jsx(Tooltip, { title: t('route.dossiers.manager.global'), children: _jsx(ToggleButton, { value: "global", size: "small", children: _jsx(Language, { fontSize: "small" }) }) }), _jsx(Tooltip, { title: t('route.dossiers.manager.personal'), children: _jsx(ToggleButton, { value: "personal", size: "small", children: _jsx(Person, { fontSize: "small" }) }) })] })] }), _jsx(Typography, { sx: theme => ({
|
|
174
|
+
}), onClick: save, children: [loading ? _jsx(CircularProgress, { size: 24, sx: { mr: 1 } }) : _jsx(Save, { sx: { mr: 1 } }), _jsx(Typography, { children: t('save') })] }) }) }), _jsxs(Stack, { spacing: 1, height: "100%", children: [_jsx(Paper, { sx: { p: 1 }, children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { spacing: 1, direction: "row", children: [_jsx(TextField, { id: "dossier-title", disabled: !dossier || loading, label: "Title", size: "small", value: dossier.title ?? '', onChange: ev => setDossier(_dossier => ({ ..._dossier, title: ev.target.value })), fullWidth: true }), _jsxs(ToggleButtonGroup, { disabled: !dossier || loading, exclusive: true, value: dossier.type ?? 'global', onChange: (_ev, type) => setDossier(_dossier => ({ ..._dossier, type })), children: [_jsx(Tooltip, { title: t('route.dossiers.manager.global'), children: _jsx(ToggleButton, { value: "global", size: "small", children: _jsx(Language, { fontSize: "small" }) }) }), _jsx(Tooltip, { title: t('route.dossiers.manager.personal'), children: _jsx(ToggleButton, { value: "personal", size: "small", children: _jsx(Person, { fontSize: "small" }) }) })] })] }), _jsx(Typography, { sx: theme => ({
|
|
123
175
|
color: theme.palette.text.secondary,
|
|
124
176
|
fontSize: '0.9em',
|
|
125
177
|
fontStyle: 'italic',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/* eslint-disable import/imports-first */
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import omit from 'lodash-es/omit';
|
|
6
|
+
// Mock the API
|
|
7
|
+
const mockApiSearchHitPost = vi.fn();
|
|
8
|
+
const mockApiDossierGet = vi.fn();
|
|
9
|
+
const mockApiDossierPost = vi.fn();
|
|
10
|
+
const mockApiDossierPut = vi.fn();
|
|
11
|
+
vi.mock('api', () => ({
|
|
12
|
+
default: {
|
|
13
|
+
search: {
|
|
14
|
+
hit: {
|
|
15
|
+
post: (...args) => mockApiSearchHitPost(...args)
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
dossier: {
|
|
19
|
+
get: (...args) => mockApiDossierGet(...args),
|
|
20
|
+
post: (...args) => mockApiDossierPost(...args),
|
|
21
|
+
put: (...args) => mockApiDossierPut(...args)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}));
|
|
25
|
+
// Mock react-router-dom
|
|
26
|
+
vi.mock('react-router-dom', async () => {
|
|
27
|
+
const actual = await vi.importActual('react-router-dom');
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
useParams: vi.fn(),
|
|
31
|
+
useNavigate: () => vi.fn()
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
// Mock ParameterContext
|
|
35
|
+
const mockSetQuery = vi.fn();
|
|
36
|
+
vi.mock('use-context-selector', async () => {
|
|
37
|
+
const actual = await vi.importActual('use-context-selector');
|
|
38
|
+
return {
|
|
39
|
+
...actual,
|
|
40
|
+
useContextSelector: (_context, selector) => {
|
|
41
|
+
const mockContext = { setQuery: mockSetQuery };
|
|
42
|
+
return selector(mockContext);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
vi.mock('components/elements/ThemedEditor', () => ({
|
|
47
|
+
default: ({ value, onChange, id }) => {
|
|
48
|
+
return (_jsx("input", { id: id || 'themed-editor', value: value, onChange: e => {
|
|
49
|
+
onChange?.(e.target.value, true);
|
|
50
|
+
} }));
|
|
51
|
+
}
|
|
52
|
+
}));
|
|
53
|
+
vi.mock('@monaco-editor/react', async () => {
|
|
54
|
+
const actual = await vi.importActual('@monaco-editor/react');
|
|
55
|
+
return { ...actual, useMonaco: vi.fn() };
|
|
56
|
+
});
|
|
57
|
+
// Mock iconExists
|
|
58
|
+
vi.mock('@iconify/react/dist/iconify.js', () => ({
|
|
59
|
+
Icon: ({ ...args }) => _jsx("div", { ...args, children: 'iconify' }),
|
|
60
|
+
iconExists: (icon) => icon?.startsWith('material-symbols:') || icon === 'test-icon'
|
|
61
|
+
}));
|
|
62
|
+
// Mock MUI components
|
|
63
|
+
vi.mock('@mui/material', async () => {
|
|
64
|
+
const actual = await vi.importActual('@mui/material');
|
|
65
|
+
return {
|
|
66
|
+
...actual,
|
|
67
|
+
Autocomplete: ({ ...props }) => {
|
|
68
|
+
return _jsx(actual.Autocomplete, { ...props });
|
|
69
|
+
},
|
|
70
|
+
CircularProgress: ({ ...props }) => _jsx("div", { role: "progressbar", id: "loading", ...omit(props, ['flexItem', 'sx']) }),
|
|
71
|
+
LinearProgress: ({ ...props }) => _jsx("div", { role: "progressbar", id: "loading", ...omit(props, ['flexItem', 'sx']) })
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
// Mock child components
|
|
75
|
+
vi.mock('commons/components/pages/PageCenter', () => ({
|
|
76
|
+
default: ({ children, ...props }) => (_jsx("div", { id: "page-center", ...omit(props, ['textAlign', 'maxWidth']), children: children }))
|
|
77
|
+
}));
|
|
78
|
+
vi.mock('../../elements/display/QueryResultText', () => ({
|
|
79
|
+
default: ({ count, query }) => (_jsxs("div", { id: "query-result-text", children: [count, " ", ' results for ', " ", query] }))
|
|
80
|
+
}));
|
|
81
|
+
vi.mock('../hits/search/HitQuery', () => ({
|
|
82
|
+
default: ({ onChange, triggerSearch, disabled }) => {
|
|
83
|
+
return (_jsxs("div", { id: "hit-query", children: [_jsx("input", { id: "query-input", disabled: disabled, onChange: e => {
|
|
84
|
+
onChange?.(e.target.value, false);
|
|
85
|
+
}, onKeyDown: e => {
|
|
86
|
+
if (e.key === 'Enter') {
|
|
87
|
+
triggerSearch(e.target.value);
|
|
88
|
+
}
|
|
89
|
+
} }), _jsx("button", { id: "trigger-search", onClick: () => triggerSearch('howler.id:*'), children: 'search' })] }));
|
|
90
|
+
}
|
|
91
|
+
}));
|
|
92
|
+
import ApiConfigProvider from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
93
|
+
import i18n from '@cccsaurora/howler-ui/i18n';
|
|
94
|
+
import { I18nextProvider } from 'react-i18next';
|
|
95
|
+
import { useNavigate, useParams } from 'react-router-dom';
|
|
96
|
+
import DossierEditor from './DossierEditor';
|
|
97
|
+
const mockUseParams = vi.mocked(useParams);
|
|
98
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
99
|
+
const mockNavigate = vi.mocked(useNavigate());
|
|
100
|
+
// Mock data
|
|
101
|
+
const mockDossier = {
|
|
102
|
+
dossier_id: 'test-dossier-1',
|
|
103
|
+
id: 'test-dossier-1',
|
|
104
|
+
title: 'Test Dossier',
|
|
105
|
+
type: 'global',
|
|
106
|
+
query: 'howler.id:*',
|
|
107
|
+
leads: [
|
|
108
|
+
{
|
|
109
|
+
label: { en: 'Lead 1', fr: 'Piste 1' },
|
|
110
|
+
icon: 'material-symbols:info',
|
|
111
|
+
format: 'markdown',
|
|
112
|
+
content: '# Hello, World'
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
pivots: [
|
|
116
|
+
{
|
|
117
|
+
label: { en: 'Pivot 1', fr: 'Pivot 1' },
|
|
118
|
+
icon: 'material-symbols:link',
|
|
119
|
+
format: 'link',
|
|
120
|
+
value: 'pivot.field',
|
|
121
|
+
mappings: [{ key: 'key1', field: 'howler.id' }]
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
};
|
|
125
|
+
const Wrapper = ({ children }) => {
|
|
126
|
+
return (_jsx(I18nextProvider, { i18n: i18n, defaultNS: "translation", children: _jsx(ApiConfigProvider, { defaultConfig: {
|
|
127
|
+
indexes: { hit: { 'howler.id': {} } },
|
|
128
|
+
lookups: {},
|
|
129
|
+
configuration: {},
|
|
130
|
+
c12nDef: {},
|
|
131
|
+
mapping: {}
|
|
132
|
+
}, children: children }) }));
|
|
133
|
+
};
|
|
134
|
+
describe('DossierEditor', () => {
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
mockApiSearchHitPost.mockClear();
|
|
137
|
+
mockApiDossierGet.mockClear();
|
|
138
|
+
mockApiDossierPost.mockClear();
|
|
139
|
+
mockApiDossierPut.mockClear();
|
|
140
|
+
mockNavigate.mockClear();
|
|
141
|
+
mockSetQuery.mockClear();
|
|
142
|
+
// Default mock implementations
|
|
143
|
+
mockApiSearchHitPost.mockResolvedValue({ total: 42, items: [] });
|
|
144
|
+
});
|
|
145
|
+
describe('Component initialization', () => {
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
mockApiDossierGet.mockClear();
|
|
148
|
+
});
|
|
149
|
+
it('should render with default values for new dossier', async () => {
|
|
150
|
+
mockUseParams.mockReturnValueOnce({ id: null });
|
|
151
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
152
|
+
await waitFor(() => {
|
|
153
|
+
expect(screen.getByTestId('dossier-title')).toBeInTheDocument();
|
|
154
|
+
});
|
|
155
|
+
expect(screen.getByTestId('dossier-title')).toHaveValue('');
|
|
156
|
+
expect(screen.getByTestId('hit-query')).toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
it('should load existing dossier when id is provided', async () => {
|
|
159
|
+
mockApiDossierGet.mockResolvedValueOnce(mockDossier);
|
|
160
|
+
mockUseParams.mockReturnValue({ id: 'test-dossier-1' });
|
|
161
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
expect(mockApiDossierGet).toHaveBeenCalledOnce();
|
|
164
|
+
});
|
|
165
|
+
await waitFor(() => {
|
|
166
|
+
expect(screen.getByTestId('dossier-title')).toHaveValue('Test Dossier');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
it('should show loading state while fetching dossier', async () => {
|
|
170
|
+
let resolvePromise;
|
|
171
|
+
const delayedPromise = new Promise(resolve => {
|
|
172
|
+
resolvePromise = resolve;
|
|
173
|
+
});
|
|
174
|
+
mockApiDossierGet.mockReturnValue(delayedPromise);
|
|
175
|
+
mockUseParams.mockReturnValue({ id: 'test-dossier-1' });
|
|
176
|
+
const { rerender } = render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
177
|
+
await waitFor(() => {
|
|
178
|
+
expect(mockApiDossierGet).toHaveBeenCalledOnce();
|
|
179
|
+
});
|
|
180
|
+
rerender(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
181
|
+
await waitFor(() => {
|
|
182
|
+
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
|
183
|
+
});
|
|
184
|
+
// Resolve the promise
|
|
185
|
+
resolvePromise(mockDossier);
|
|
186
|
+
rerender(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
expect(mockApiDossierGet).toHaveBeenCalled();
|
|
189
|
+
expect(screen.getByTestId('dossier-title')).toHaveValue('Test Dossier');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe('Form interactions', () => {
|
|
193
|
+
it('should update title field', async () => {
|
|
194
|
+
const user = userEvent.setup();
|
|
195
|
+
const { rerender } = render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
196
|
+
const titleInput = screen.getByTestId('dossier-title');
|
|
197
|
+
await user.click(titleInput);
|
|
198
|
+
await user.keyboard('{Control>}a{/Control}{Backspace}');
|
|
199
|
+
await user.keyboard('My New Dossier');
|
|
200
|
+
rerender(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
expect(titleInput).toHaveValue('My New Dossier');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
it('should toggle between global and personal type', async () => {
|
|
206
|
+
const user = userEvent.setup();
|
|
207
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
208
|
+
const personalButton = screen.getByRole('button', { name: /personal/i });
|
|
209
|
+
const globalButton = screen.getByRole('button', { name: /global/i });
|
|
210
|
+
await user.click(personalButton);
|
|
211
|
+
// The toggle button should now show personal as selected
|
|
212
|
+
expect(personalButton).toHaveAttribute('aria-pressed', 'true');
|
|
213
|
+
expect(globalButton).toHaveAttribute('aria-pressed', 'false');
|
|
214
|
+
await user.click(globalButton);
|
|
215
|
+
// The toggle button should now show personal as selected
|
|
216
|
+
expect(personalButton).toHaveAttribute('aria-pressed', 'false');
|
|
217
|
+
expect(globalButton).toHaveAttribute('aria-pressed', 'true');
|
|
218
|
+
});
|
|
219
|
+
it('should trigger search when query changes', async () => {
|
|
220
|
+
const user = userEvent.setup();
|
|
221
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
222
|
+
const queryInput = screen.getByTestId('query-input');
|
|
223
|
+
await user.click(queryInput);
|
|
224
|
+
await user.keyboard('{Control>}a{/Control}{Backspace}');
|
|
225
|
+
await user.keyboard('test query');
|
|
226
|
+
await user.keyboard('{Enter}');
|
|
227
|
+
await waitFor(() => {
|
|
228
|
+
expect(mockApiSearchHitPost).toHaveBeenCalledWith({
|
|
229
|
+
query: 'test query',
|
|
230
|
+
rows: 0
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
it('should display query result count after search', async () => {
|
|
235
|
+
const user = userEvent.setup();
|
|
236
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
237
|
+
const queryInput = screen.getByTestId('query-input');
|
|
238
|
+
await user.type(queryInput, 'test query');
|
|
239
|
+
await user.keyboard('{Enter}');
|
|
240
|
+
await waitFor(() => {
|
|
241
|
+
expect(screen.getByTestId('query-result-text')).toBeInTheDocument();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
describe('Tabs', () => {
|
|
246
|
+
it('should render leads and pivots tabs', async () => {
|
|
247
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
248
|
+
await waitFor(() => {
|
|
249
|
+
expect(screen.getByRole('tab', { name: /leads/i })).toBeInTheDocument();
|
|
250
|
+
expect(screen.getByRole('tab', { name: /pivots/i })).toBeInTheDocument();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
it('should show LeadForm by default', async () => {
|
|
254
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
255
|
+
await waitFor(() => {
|
|
256
|
+
expect(screen.getByTestId('lead-form')).toBeInTheDocument();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
it('should switch to PivotForm when pivots tab is clicked', async () => {
|
|
260
|
+
const user = userEvent.setup();
|
|
261
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
262
|
+
const pivotsTab = screen.getByRole('tab', { name: /pivots/i });
|
|
263
|
+
await user.click(pivotsTab);
|
|
264
|
+
await waitFor(() => {
|
|
265
|
+
expect(screen.getByTestId('pivot-form')).toBeInTheDocument();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
describe('Validation', () => {
|
|
270
|
+
it('should disable save button when title is missing', async () => {
|
|
271
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
272
|
+
await waitFor(() => {
|
|
273
|
+
const saveButton = screen.getByRole('button', { name: /save/i });
|
|
274
|
+
expect(saveButton).toBeDisabled();
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
it('should disable save button when no leads or pivots exist', async () => {
|
|
278
|
+
mockUseParams.mockReturnValue({ id: null });
|
|
279
|
+
const user = userEvent.setup();
|
|
280
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
281
|
+
const titleInput = screen.getByTestId('dossier-title');
|
|
282
|
+
await user.type(titleInput, 'Test Title');
|
|
283
|
+
const queryInput = screen.getByTestId('query-input');
|
|
284
|
+
user.click(queryInput);
|
|
285
|
+
await user.keyboard('test query');
|
|
286
|
+
await user.keyboard('{Enter}');
|
|
287
|
+
await waitFor(() => {
|
|
288
|
+
expect(screen.getByTestId('query-result-text')).toBeInTheDocument();
|
|
289
|
+
});
|
|
290
|
+
const saveButton = screen.getByRole('button', { name: /save/i });
|
|
291
|
+
expect(saveButton).toBeDisabled();
|
|
292
|
+
});
|
|
293
|
+
it('should enable save button when all required fields are filled', async () => {
|
|
294
|
+
const user = userEvent.setup();
|
|
295
|
+
mockUseParams.mockReturnValue({ id: null });
|
|
296
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
297
|
+
// Fill title
|
|
298
|
+
const titleInput = screen.getByTestId('dossier-title');
|
|
299
|
+
await user.type(titleInput, 'Test Title');
|
|
300
|
+
// Add query and trigger search
|
|
301
|
+
const queryInput = screen.getByTestId('query-input');
|
|
302
|
+
await user.click(queryInput);
|
|
303
|
+
await user.keyboard('test query');
|
|
304
|
+
await user.keyboard('{Enter}');
|
|
305
|
+
await waitFor(() => {
|
|
306
|
+
expect(screen.getByTestId('query-result-text')).toBeInTheDocument();
|
|
307
|
+
expect(screen.getByTestId('create-lead-alert')).toBeInTheDocument();
|
|
308
|
+
});
|
|
309
|
+
// Add a lead
|
|
310
|
+
await user.click(screen.getByTestId('add-lead'));
|
|
311
|
+
await waitFor(() => {
|
|
312
|
+
expect(screen.getByTestId('lead-format')).toBeInTheDocument();
|
|
313
|
+
});
|
|
314
|
+
await user.click(screen.getByTestId('lead-format'));
|
|
315
|
+
await user.keyboard('markdown');
|
|
316
|
+
await user.keyboard('[ArrowDown]');
|
|
317
|
+
await user.keyboard('{Enter}');
|
|
318
|
+
await user.click(screen.getByTestId('lead-markdown'));
|
|
319
|
+
await user.keyboard('hello world');
|
|
320
|
+
await waitFor(() => {
|
|
321
|
+
const saveButton = screen.getByRole('button', { name: /save/i });
|
|
322
|
+
expect(saveButton).not.toBeDisabled();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
describe('Dirty state tracking', () => {
|
|
327
|
+
it('should mark form as dirty when changes are made', async () => {
|
|
328
|
+
const user = userEvent.setup();
|
|
329
|
+
mockUseParams.mockReturnValue({ id: 'test-dossier-1' });
|
|
330
|
+
mockApiDossierGet.mockResolvedValueOnce(mockDossier);
|
|
331
|
+
mockApiSearchHitPost.mockResolvedValueOnce({ total: -1, items: [] });
|
|
332
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
333
|
+
await waitFor(() => {
|
|
334
|
+
expect(screen.getByTestId('dossier-title')).toHaveValue('Test Dossier');
|
|
335
|
+
});
|
|
336
|
+
const saveButton = screen.getByRole('button', { name: /save/i });
|
|
337
|
+
expect(saveButton).toBeDisabled();
|
|
338
|
+
// Make a change
|
|
339
|
+
const queryInput = screen.getByTestId('query-input');
|
|
340
|
+
await user.click(queryInput);
|
|
341
|
+
await user.keyboard('test query');
|
|
342
|
+
await user.keyboard('{Enter}');
|
|
343
|
+
await waitFor(() => {
|
|
344
|
+
expect(saveButton).not.toBeDisabled();
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
it('should not mark form as dirty when no changes are made', async () => {
|
|
348
|
+
mockUseParams.mockReturnValue({ id: 'test-dossier-1' });
|
|
349
|
+
mockApiDossierGet.mockResolvedValueOnce(mockDossier);
|
|
350
|
+
mockApiSearchHitPost.mockResolvedValueOnce({ total: 10, items: [] });
|
|
351
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
352
|
+
await waitFor(() => {
|
|
353
|
+
expect(screen.getByTestId('dossier-title')).toHaveValue('Test Dossier');
|
|
354
|
+
});
|
|
355
|
+
const saveButton = screen.getByRole('button', { name: /save/i });
|
|
356
|
+
expect(saveButton).toBeDisabled();
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -25,8 +25,8 @@ const LeadEditor = ({ lead, update }) => {
|
|
|
25
25
|
}
|
|
26
26
|
monaco.editor.getModels().forEach(model => model.setEOL(monaco.editor.EndOfLineSequence.LF));
|
|
27
27
|
}, [monaco]);
|
|
28
|
-
return (_jsxs(Stack, { spacing: 2, pt: 2, sx: { flex: 1 }, children: [_jsxs(Stack, { direction: "row", alignItems: "center", position: "relative", children: [_jsx(TextField, { size: "small", label: t('route.dossiers.manager.icon'), value: icon, disabled: !lead, fullWidth: true, error: !iconExists(icon), sx: { '& input': { paddingLeft: '2.25rem' } }, onChange: ev => update({ icon: ev.target.value }) }), _jsx(Icon, { fontSize: "1.75rem", icon: icon, style: { position: 'absolute', left: '0.5rem' } }), _jsx(Button, { variant: "outlined", color: "error", disabled: !lead, sx: { minWidth: '0 !important', ml: 1 }, onClick: () => update(null), children: _jsx(Delete, {}) })] }), _jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", sx: { mt: `${theme.spacing(0.5)} !important` }, children: [_jsx(Typography, { color: "text.secondary", children: t('route.dossiers.manager.icon.description') }), _jsx(IconButton, { size: "small", component: "a", href: "https://icon-sets.iconify.design/", children: _jsx(OpenInNew, { fontSize: "small" }) })] }), _jsxs(Stack, { direction: "row", spacing: 2, children: [_jsx(TextField, { size: "small", label: t('route.dossiers.manager.label.en'), disabled: !lead, value: lead?.label?.en ?? '', fullWidth: true, onChange: ev => update({ label: { en: ev.target.value } }) }), _jsx(TextField, { size: "small", label: t('route.dossiers.manager.label.fr'), disabled: !lead, value: lead?.label?.fr ?? '', fullWidth: true, onChange: ev => update({ label: { fr: ev.target.value } }) })] }), _jsx(Autocomplete, { disabled: !lead, options: ['markdown', ...howlerPluginStore.leadFormats], renderInput: params => _jsx(TextField, { ...params, size: "small", label: t('route.dossiers.manager.format') }), value: lead?.format ??
|
|
29
|
-
(lead.format === 'markdown' ? (_jsx(ThemedEditor, { height: "100%", width: "100%", language: "markdown", theme: theme.palette.mode === 'light' ? 'howler' : 'howler-dark', value: lead?.content ?? '', onChange: content => update({ content }), options: {} })) : (pluginStore.executeFunction(`lead.${lead.format}.form`, {
|
|
28
|
+
return (_jsxs(Stack, { spacing: 2, pt: 2, sx: { flex: 1 }, id: "lead-editor", children: [_jsxs(Stack, { direction: "row", alignItems: "center", position: "relative", children: [_jsx(TextField, { size: "small", label: t('route.dossiers.manager.icon'), value: icon, disabled: !lead, fullWidth: true, error: !iconExists(icon), sx: { '& input': { paddingLeft: '2.25rem' } }, onChange: ev => update({ icon: ev.target.value }) }), _jsx(Icon, { fontSize: "1.75rem", icon: icon, style: { position: 'absolute', left: '0.5rem' } }), _jsx(Button, { variant: "outlined", color: "error", disabled: !lead, sx: { minWidth: '0 !important', ml: 1 }, onClick: () => update(null), children: _jsx(Delete, {}) })] }), _jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", sx: { mt: `${theme.spacing(0.5)} !important` }, children: [_jsx(Typography, { color: "text.secondary", children: t('route.dossiers.manager.icon.description') }), _jsx(IconButton, { size: "small", component: "a", href: "https://icon-sets.iconify.design/", children: _jsx(OpenInNew, { fontSize: "small" }) })] }), _jsxs(Stack, { direction: "row", spacing: 2, children: [_jsx(TextField, { size: "small", label: t('route.dossiers.manager.label.en'), disabled: !lead, value: lead?.label?.en ?? '', fullWidth: true, onChange: ev => update({ label: { en: ev.target.value } }) }), _jsx(TextField, { size: "small", label: t('route.dossiers.manager.label.fr'), disabled: !lead, value: lead?.label?.fr ?? '', fullWidth: true, onChange: ev => update({ label: { fr: ev.target.value } }) })] }), _jsx(Autocomplete, { disabled: !lead, options: ['markdown', ...howlerPluginStore.leadFormats], id: "lead-format", renderInput: params => _jsx(TextField, { ...params, size: "small", label: t('route.dossiers.manager.format') }), value: lead?.format ?? null, onChange: (_ev, format) => update({ format, metadata: '{}', content: '' }) }), !!lead?.format &&
|
|
29
|
+
(lead.format === 'markdown' ? (_jsx(ThemedEditor, { id: "lead-markdown", height: "100%", width: "100%", language: "markdown", theme: theme.palette.mode === 'light' ? 'howler' : 'howler-dark', value: lead?.content ?? '', onChange: content => update({ content }), options: {} })) : (pluginStore.executeFunction(`lead.${lead.format}.form`, {
|
|
30
30
|
lead,
|
|
31
31
|
metadata,
|
|
32
32
|
update,
|
|
@@ -10,7 +10,7 @@ import LeadEditor from './LeadEditor';
|
|
|
10
10
|
const LeadForm = ({ dossier, setDossier, loading }) => {
|
|
11
11
|
const { t, i18n } = useTranslation();
|
|
12
12
|
const [tab, setTab] = useState(0);
|
|
13
|
-
return (_jsxs(Paper, { sx: { p: 1, display: 'flex', flexDirection: 'column', flex: 1 }, children: [_jsxs(Stack, { direction: "row", children: [!dossier?.leads || dossier.leads.length < 1 ? (_jsx(Alert, { variant: "outlined", severity: "warning", sx: {
|
|
13
|
+
return (_jsxs(Paper, { sx: { p: 1, display: 'flex', flexDirection: 'column', flex: 1 }, id: "lead-form", children: [_jsxs(Stack, { direction: "row", children: [!dossier?.leads || dossier.leads.length < 1 ? (_jsx(Alert, { id: "create-lead-alert", variant: "outlined", severity: "warning", sx: {
|
|
14
14
|
mr: 1,
|
|
15
15
|
px: 1,
|
|
16
16
|
py: 0,
|
|
@@ -24,7 +24,7 @@ const LeadForm = ({ dossier, setDossier, loading }) => {
|
|
|
24
24
|
'& .MuiAlert-message': {
|
|
25
25
|
py: 0.7
|
|
26
26
|
}
|
|
27
|
-
}, children: t('route.dossiers.manager.lead.create') })) : (_jsx(Tabs, { value: tab, onChange: (_, _tab) => setTab(_tab), sx: { minHeight: '0 !important' }, children: dossier.leads?.map((lead, index) => (_jsx(Tab, { disabled: !dossier || loading, sx: { py: 1, minHeight: '0 !important' }, label: _jsxs(Stack, { direction: "row", spacing: 0.5, children: [lead.icon && _jsx(Icon, { icon: lead.icon }), _jsx("span", { children: i18n.language === 'en' ? lead.label.en : lead.label.fr })] }), value: index }, lead.content))) })), _jsx(Button, { sx: { ml: 'auto', alignSelf: 'end', minWidth: '0 !important' }, size: "small", variant: "contained", onClick: () => {
|
|
27
|
+
}, children: t('route.dossiers.manager.lead.create') })) : (_jsx(Tabs, { value: tab, onChange: (_, _tab) => setTab(_tab), sx: { minHeight: '0 !important' }, children: dossier.leads?.map((lead, index) => (_jsx(Tab, { disabled: !dossier || loading, sx: { py: 1, minHeight: '0 !important' }, label: _jsxs(Stack, { direction: "row", spacing: 0.5, children: [lead.icon && _jsx(Icon, { icon: lead.icon }), _jsx("span", { children: i18n.language === 'en' ? lead.label.en : lead.label.fr })] }), value: index }, lead.label?.en + lead.content))) })), _jsx(Button, { id: "add-lead", sx: { ml: 'auto', alignSelf: 'end', minWidth: '0 !important' }, size: "small", variant: "contained", onClick: () => {
|
|
28
28
|
setTab(dossier.leads?.length ?? 0);
|
|
29
29
|
setDossier(_dossier => ({
|
|
30
30
|
..._dossier,
|
|
@@ -22,7 +22,7 @@ const LinkForm = ({ pivot, update }) => {
|
|
|
22
22
|
mappings: pivot.mappings.filter((_m, _index) => index !== _index)
|
|
23
23
|
}), children: _jsx(Remove, {}) })] }), _mapping.field === 'custom' && (_jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.mapping.custom'), disabled: !pivot, value: _mapping?.custom_value ?? '', onChange: ev => update({
|
|
24
24
|
mappings: pivot.mappings.map((_m, _index) => index === _index ? { ..._m, custom_value: ev.target.value } : _m)
|
|
25
|
-
}) }))] }, index))), _jsx(Button, { disabled: !pivot, sx: { ml: 'auto', alignSelf: 'end', minWidth: '0 !important' }, size: "small", variant: "contained", onClick: () => {
|
|
25
|
+
}) }))] }, index))), _jsx(Button, { id: "add-pivot", disabled: !pivot, sx: { ml: 'auto', alignSelf: 'end', minWidth: '0 !important' }, size: "small", variant: "contained", onClick: () => {
|
|
26
26
|
update({
|
|
27
27
|
mappings: [...(pivot.mappings ?? []), { key: 'key' }]
|
|
28
28
|
});
|
|
@@ -53,7 +53,7 @@ const PivotForm = ({ dossier, setDossier, loading }) => {
|
|
|
53
53
|
})), [setDossier, tab]);
|
|
54
54
|
const pivot = useMemo(() => dossier.pivots?.[tab] ?? null, [dossier.pivots, tab]);
|
|
55
55
|
const icon = useMemo(() => pivot?.icon ?? 'material-symbols:find-in-page', [pivot?.icon]);
|
|
56
|
-
return (_jsx(Paper, { sx: { p: 1, display: 'flex', flexDirection: 'column', flex: 1 }, children: _jsxs(Stack, { spacing: 2, children: [_jsxs(Stack, { direction: "row", children: [!dossier?.pivots || dossier.pivots.length < 1 ? (_jsx(Alert, { variant: "outlined", severity: "warning", sx: {
|
|
56
|
+
return (_jsx(Paper, { sx: { p: 1, display: 'flex', flexDirection: 'column', flex: 1 }, id: "pivot-form", children: _jsxs(Stack, { spacing: 2, children: [_jsxs(Stack, { direction: "row", children: [!dossier?.pivots || dossier.pivots.length < 1 ? (_jsx(Alert, { variant: "outlined", severity: "warning", sx: {
|
|
57
57
|
mr: 1,
|
|
58
58
|
px: 1,
|
|
59
59
|
py: 0,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export default "# Retention in Howler\n\nIn order to comply with organizational policies, Howler is configured to purge stale alerts after a specific amount of
|
|
1
|
+
export default "# Retention in Howler\n\nIn order to comply with organizational policies, Howler is configured to purge stale alerts after a specific amount of\ntime. On this instance, that duration is `duration`.\n\n## How Retention Works\n\nHowler uses an automated retention job that runs on a configurable schedule (typically nightly) to remove\nalerts that have exceeded their retention period. The system evaluates two criteria for deletion:\n\n1. **Standard Retention**: Alerts are deleted when `event.created` exceeds the configured retention period\n2. **Custom Expiry**: Alerts are deleted when the `howler.expiry` field indicates the alert should expire\n\nAn alert will be removed when **either** condition is met - whichever comes first.\n\n## Custom Expiry (`howler.expiry`)\n\nThe `howler.expiry` field allows detection engineers to set custom retention periods for specific alerts\nduring ingestion. This field overrides the standard retention calculation and is commonly used when:\n\n- Clients have requested shorter data retention periods than the deployment default\n- Specific operations require time-limited data storage (e.g., a cybersecurity operation where data can\n only be retained for two weeks after ingest)\n- Regulatory requirements mandate earlier deletion for certain types of data\n\n```alert\nThe howler.expiry field can only shorten retention periods, not extend them. No matter\nwhat, alerts cannot be retained longer than the system-wide retention cutoff based on event.created.\n```\n\n## Configuration\n\nAdministrators can configure retention settings in the system configuration:\n\n```yaml\nsystem:\n type: staging\n retention:\n limit_amount: 120 # Retention period duration\n limit_unit: days # Time unit (days, hours, etc.)\n crontab: \"0 0 * * *\" # Schedule (nightly at midnight)\n enabled: true # Whether retention is active\n```\n\n## User Interface\n\nTo communicate retention timing to users, see the example alert below:\n\n`alert`\n\nIn the top right, hovering over the timestamp will outline how long users have before the alert is\nremoved. In order to ensure compliance with policy, ensure that `event.created` matches the date the\nunderlying data was collected, allowing Howler to ensure data is purged in time.\n"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export default "# R\u00e9tention dans Howler\n\nAfin de se conformer
|
|
1
|
+
export default "# R\u00e9tention dans Howler\n\nAfin de se conformer aux politiques organisationnelles, Howler est configur\u00e9 pour purger les alertes\np\u00e9rim\u00e9es apr\u00e8s une p\u00e9riode de temps sp\u00e9cifique. Dans cette instance, cette dur\u00e9e est `duration`.\n\n## Comment fonctionne la r\u00e9tention\n\nHowler utilise un travail de r\u00e9tention automatis\u00e9 qui s'ex\u00e9cute selon un calendrier configurable\n(g\u00e9n\u00e9ralement nocturne) pour supprimer les alertes qui ont d\u00e9pass\u00e9 leur p\u00e9riode de r\u00e9tention. Le syst\u00e8me\n\u00e9value deux crit\u00e8res de suppression :\n\n1. **R\u00e9tention standard** : Les alertes sont supprim\u00e9es lorsque `event.created` d\u00e9passe la p\u00e9riode de\n r\u00e9tention configur\u00e9e\n2. **Expiration personnalis\u00e9e** : Les alertes sont supprim\u00e9es lorsque le champ `howler.expiry` indique\n que l'alerte doit expirer\n\nUne alerte sera supprim\u00e9e lorsque **l'une ou l'autre** condition est remplie - selon celle qui arrive en\npremier.\n\n## Expiration personnalis\u00e9e (`howler.expiry`)\n\nLe champ `howler.expiry` permet aux ing\u00e9nieurs de d\u00e9tection de d\u00e9finir des p\u00e9riodes de r\u00e9tention\npersonnalis\u00e9es pour des alertes sp\u00e9cifiques lors de l'ingestion. Ce champ remplace le calcul de\nr\u00e9tention standard et est couramment utilis\u00e9 quand :\n\n- Les clients ont demand\u00e9 des p\u00e9riodes de r\u00e9tention de donn\u00e9es plus courtes que la valeur par d\u00e9faut\n du d\u00e9ploiement\n- Des op\u00e9rations sp\u00e9cifiques n\u00e9cessitent un stockage de donn\u00e9es \u00e0 dur\u00e9e limit\u00e9e (par ex., une op\u00e9ration\n de cybers\u00e9curit\u00e9 o\u00f9 les donn\u00e9es ne peuvent \u00eatre conserv\u00e9es que deux semaines apr\u00e8s ingestion)\n- Les exigences r\u00e9glementaires imposent une suppression plus pr\u00e9coce pour certains types de donn\u00e9es\n\n```alert\nLe champ howler.expiry ne peut que raccourcir les p\u00e9riodes de r\u00e9tention, pas les\nprolonger. Quoi qu'il arrive, les alertes ne peuvent pas \u00eatre conserv\u00e9es plus longtemps que la limite de\nr\u00e9tention syst\u00e8me bas\u00e9e sur event.created.\n```\n\n## Configuration\n\nLes administrateurs peuvent configurer les param\u00e8tres de r\u00e9tention dans la configuration syst\u00e8me :\n\n```yaml\nsystem:\n type: staging\n retention:\n limit_amount: 120 # Dur\u00e9e de la p\u00e9riode de r\u00e9tention\n limit_unit: days # Unit\u00e9 de temps (days, hours, etc.)\n crontab: \"0 0 * * *\" # Calendrier (nocturne \u00e0 minuit)\n enabled: true # Si la r\u00e9tention est active\n```\n\n## Interface utilisateur\n\nAfin de communiquer le d\u00e9lai de r\u00e9tention aux utilisateurs, voir l'exemple d'alerte ci-dessous :\n\n`alert`\n\nEn haut \u00e0 droite, le survol de l'horodatage indique le temps dont dispose l'utilisateur avant que\nl'alerte ne soit supprim\u00e9e. Afin de se conformer aux politiques, assurez-vous que `event.created`\ncorrespond \u00e0 la date \u00e0 laquelle les donn\u00e9es sous-jacentes ont \u00e9t\u00e9 collect\u00e9es, permettant \u00e0 Howler de\ns'assurer que les donn\u00e9es sont purg\u00e9es \u00e0 temps.\n"
|
|
@@ -128,7 +128,7 @@ const HitQuery = ({ searching = false, disabled = false, compact = false, trigge
|
|
|
128
128
|
p: 0.5,
|
|
129
129
|
height: multiline ? `${DEFAULT_MULTILINE_HEIGHT + y}px` : theme.spacing(5)
|
|
130
130
|
}
|
|
131
|
-
], onKeyDown: e => e.stopPropagation(), children: [_jsx(TuiIconButton, { disabled: query.includes('\n#') || disabled, sx: { mr: 1, alignSelf: 'start' }, onClick: () => setMultiline(!multiline), color: multiline ? 'primary' : theme.palette.text.primary, transparent: !multiline, size: compact ? 'small' : 'medium', children: _jsx(Height, { sx: { fontSize: '20px' } }) }), _jsx(QueryEditor, { query: preppedQuery, setQuery: setQuery, language: "lucene", height: multiline ? `${DEFAULT_MULTILINE_HEIGHT - 30}px` : '20px', onMount: onMount, editorOptions: options }), fzfSearch && (_jsx(Tooltip, { title: t('route.history'), children: _jsx(History, {}) })), _jsx(TuiIconButton, { disabled: searching || disabled, onClick: () => setQuery('howler.id:*'), sx: { ml: 1, alignSelf: 'start', flexShrink: 0 }, size: compact ? 'small' : 'medium', children: _jsx(Tooltip, { title: t('route.clear'), children: _jsx(Clear, { sx: { fontSize: '20px' } }) }) }), _jsx(TuiIconButton, { disabled: searching || disabled, onClick: search, sx: { ml: 1, alignSelf: 'start', flexShrink: 0 }, size: compact ? 'small' : 'medium', children: _jsx(Tooltip, { title: t('route.search'), children: _jsx(Badge, { invisible: !isDirty, color: "warning", variant: "dot", children: _jsx(Search, { sx: { fontSize: '20px' } }) }) }) }), !loaded && (_jsx(Skeleton, { variant: "rectangular", sx: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }, height: "100%" })), multiline && (_jsx(Box, { sx: {
|
|
131
|
+
], onKeyDown: e => e.stopPropagation(), children: [_jsx(TuiIconButton, { disabled: query.includes('\n#') || disabled, sx: { mr: 1, alignSelf: 'start' }, onClick: () => setMultiline(!multiline), color: multiline ? 'primary' : theme.palette.text.primary, transparent: !multiline, size: compact ? 'small' : 'medium', children: _jsx(Height, { sx: { fontSize: '20px' } }) }), _jsx(QueryEditor, { id: "hit-query", query: preppedQuery, setQuery: setQuery, language: "lucene", height: multiline ? `${DEFAULT_MULTILINE_HEIGHT - 30}px` : '20px', onMount: onMount, editorOptions: options }), fzfSearch && (_jsx(Tooltip, { title: t('route.history'), children: _jsx(History, {}) })), _jsx(TuiIconButton, { disabled: searching || disabled, onClick: () => setQuery('howler.id:*'), sx: { ml: 1, alignSelf: 'start', flexShrink: 0 }, size: compact ? 'small' : 'medium', children: _jsx(Tooltip, { title: t('route.clear'), children: _jsx(Clear, { sx: { fontSize: '20px' } }) }) }), _jsx(TuiIconButton, { disabled: searching || disabled, onClick: search, sx: { ml: 1, alignSelf: 'start', flexShrink: 0 }, size: compact ? 'small' : 'medium', children: _jsx(Tooltip, { title: t('route.search'), children: _jsx(Badge, { invisible: !isDirty, color: "warning", variant: "dot", children: _jsx(Search, { sx: { fontSize: '20px' } }) }) }) }), !loaded && (_jsx(Skeleton, { variant: "rectangular", sx: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }, height: "100%" })), multiline && (_jsx(Box, { sx: {
|
|
132
132
|
position: 'absolute',
|
|
133
133
|
left: 0,
|
|
134
134
|
right: 0,
|
|
@@ -639,8 +639,22 @@
|
|
|
639
639
|
"route.dossiers.manager.validation.search": "You must search to validate your query before submitting.",
|
|
640
640
|
"route.dossiers.manager.validation.error.missing": "{{field}} is invalid.",
|
|
641
641
|
"route.dossiers.manager.validation.error.leads": "A lead is misconfigured.",
|
|
642
|
+
"route.dossiers.manager.validation.error.leads.label": "You have not configured a lead label.",
|
|
643
|
+
"route.dossiers.manager.validation.error.leads.label.en": "You have not configured an English lead label.",
|
|
644
|
+
"route.dossiers.manager.validation.error.leads.label.fr": "You have not configured a French lead label.",
|
|
645
|
+
"route.dossiers.manager.validation.error.leads.format": "You have not set the format for the lead '{{label}}'.",
|
|
646
|
+
"route.dossiers.manager.validation.error.leads.content": "You have not set the content for the lead '{{label}}'.",
|
|
647
|
+
"route.dossiers.manager.validation.error.leads.icon": "You are missing an icon, or the specified icon does not exist for lead '{{label}}'.",
|
|
642
648
|
"route.dossiers.manager.validation.error.pivots": "A pivot is misconfigured.",
|
|
643
|
-
"route.dossiers.manager.validation.error.pivots.
|
|
649
|
+
"route.dossiers.manager.validation.error.pivots.label": "You have not configured a pivot label.",
|
|
650
|
+
"route.dossiers.manager.validation.error.pivots.label.en": "You have not configured an English pivot label.",
|
|
651
|
+
"route.dossiers.manager.validation.error.pivots.label.fr": "You have not configured a French pivot label.",
|
|
652
|
+
"route.dossiers.manager.validation.error.pivots.format": "You have not set the format for the pivot '{{label}}'.",
|
|
653
|
+
"route.dossiers.manager.validation.error.pivots.value": "You have not set the value for the pivot '{{label}}'.",
|
|
654
|
+
"route.dossiers.manager.validation.error.pivots.icon": "You are missing an icon, or the specified icon does not exist for pivot '{{label}}'.",
|
|
655
|
+
"route.dossiers.manager.validation.error.pivots.duplicate": "The pivot '{{label}}' has a duplicate key.",
|
|
656
|
+
"route.dossiers.manager.validation.error.pivots.key": "You have not configured a key for a mapping in the pivot '{{label}}'.",
|
|
657
|
+
"route.dossiers.manager.validation.error.pivots.field": "You have not configured a field or custom value for a mapping in the pivot '{{label}}'.",
|
|
644
658
|
"route.dossiers.manager.validation.error.items": "You have not configured any leads or pivots.",
|
|
645
659
|
"route.views": "Views",
|
|
646
660
|
"route.views.create.success": "View Created.",
|
|
@@ -640,8 +640,22 @@
|
|
|
640
640
|
"route.dossiers.manager.validation.search": "Vous devez effectuer une recherche pour valider votre requête avant de la soumettre.",
|
|
641
641
|
"route.dossiers.manager.validation.error.missing": "{{field}} est invalide.",
|
|
642
642
|
"route.dossiers.manager.validation.error.leads": "Une piste est mal configuré.",
|
|
643
|
+
"route.dossiers.manager.validation.error.leads.label": "Vous n'avez pas configuré d'étiquette de piste.",
|
|
644
|
+
"route.dossiers.manager.validation.error.leads.label.en": "Vous n'avez pas configuré d'étiquette de piste en anglais.",
|
|
645
|
+
"route.dossiers.manager.validation.error.leads.label.fr": "Vous n'avez pas configuré d'étiquette de piste en français.",
|
|
646
|
+
"route.dossiers.manager.validation.error.leads.format": "Vous n'avez pas défini le format pour la piste '{{label}}'.",
|
|
647
|
+
"route.dossiers.manager.validation.error.leads.content": "Vous n'avez pas défini le contenu pour la piste '{{label}}'.",
|
|
648
|
+
"route.dossiers.manager.validation.error.leads.icon": "Il manque une icône, ou l'icône spécifiée n'existe pas pour la piste '{{label}}'.",
|
|
643
649
|
"route.dossiers.manager.validation.error.pivots": "Un pivot est mal configuré.",
|
|
644
|
-
"route.dossiers.manager.validation.error.pivots.
|
|
650
|
+
"route.dossiers.manager.validation.error.pivots.label": "Vous n'avez pas configuré d'étiquette de pivot.",
|
|
651
|
+
"route.dossiers.manager.validation.error.pivots.label.en": "Vous n'avez pas configuré d'étiquette de pivot en anglais.",
|
|
652
|
+
"route.dossiers.manager.validation.error.pivots.label.fr": "Vous n'avez pas configuré d'étiquette de pivot en français.",
|
|
653
|
+
"route.dossiers.manager.validation.error.pivots.format": "Vous n'avez pas défini le format pour le pivot '{{label}}'.",
|
|
654
|
+
"route.dossiers.manager.validation.error.pivots.value": "Vous n'avez pas défini la valeur pour le pivot '{{label}}'.",
|
|
655
|
+
"route.dossiers.manager.validation.error.pivots.icon": "Il manque une icône, ou l'icône spécifiée n'existe pas pour le pivot '{{label}}'.",
|
|
656
|
+
"route.dossiers.manager.validation.error.pivots.duplicate": "Le pivot '{{label}}' a une clé dupliquée.",
|
|
657
|
+
"route.dossiers.manager.validation.error.pivots.key": "Vous n'avez pas configuré de clé pour un mappage dans le pivot '{{label}}'.",
|
|
658
|
+
"route.dossiers.manager.validation.error.pivots.field": "Vous n'avez pas configuré de champ ou de valeur personnalisée pour un mappage dans le pivot '{{label}}'.",
|
|
645
659
|
"route.dossiers.manager.validation.error.items": "Vous n'avez pas configuré de pistes ou de pivots.",
|
|
646
660
|
"route.views.create.success": "Vue crée.",
|
|
647
661
|
"route.views.update.success": "Vue actualisée.",
|