@cccsaurora/howler-ui 2.17.0-dev.547 → 2.17.0-dev.550
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/commons/components/app/AppUserService.d.ts +1 -1
- package/components/app/providers/HitProvider.js +2 -2
- package/components/app/providers/SocketProvider.js +1 -1
- package/components/elements/hit/actions/ButtonActions.js +3 -1
- package/components/hooks/useMyUserFunctions.d.ts +6 -0
- package/components/hooks/useMyUserFunctions.js +6 -0
- package/components/routes/hits/search/HitContextMenu.js +3 -3
- package/components/routes/hits/search/shared/CustomSpan.js +1 -1
- package/components/routes/hits/view/HitViewer.js +1 -1
- package/components/routes/home/ViewCard.d.ts +3 -1
- package/components/routes/home/ViewCard.js +125 -14
- package/components/routes/home/index.js +91 -6
- package/locales/en/translation.json +3 -0
- package/locales/fr/translation.json +3 -0
- package/models/entities/HowlerUser.d.ts +1 -0
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ export type AppUser = {
|
|
|
11
11
|
};
|
|
12
12
|
export type AppUserService<T extends AppUser> = {
|
|
13
13
|
user: T;
|
|
14
|
-
setUser: (user: T) => void;
|
|
14
|
+
setUser: (user: T | ((prev: T) => T)) => void;
|
|
15
15
|
isReady: () => boolean;
|
|
16
16
|
validateProps?: (props: AppUserValidatedProp[]) => boolean;
|
|
17
17
|
};
|
|
@@ -42,8 +42,8 @@ const HitProvider = ({ children }) => {
|
|
|
42
42
|
}
|
|
43
43
|
}, []);
|
|
44
44
|
useEffect(() => {
|
|
45
|
-
addListener('
|
|
46
|
-
return () => removeListener('
|
|
45
|
+
addListener('hits', handler);
|
|
46
|
+
return () => removeListener('hits');
|
|
47
47
|
}, [addListener, handler, removeListener]);
|
|
48
48
|
/**
|
|
49
49
|
* A method to retrieve a hit from the context. It first checks the hit state,
|
|
@@ -121,7 +121,7 @@ const SocketProvider = ({ children }) => {
|
|
|
121
121
|
setRetry(false);
|
|
122
122
|
// Here we go!
|
|
123
123
|
setStatus(Status.CONNECTING);
|
|
124
|
-
const host = window.location.host
|
|
124
|
+
const host = window.location.host;
|
|
125
125
|
const protocol = window.location.protocol.startsWith('http:') ? 'ws' : 'wss';
|
|
126
126
|
const ws = new WebSocket(`${protocol}://${host}/socket/v1/connect`);
|
|
127
127
|
// Add our listeners to the websocket
|
|
@@ -91,7 +91,9 @@ const ButtonActions = ({ actions, loading, orientation, shortcuts, currentVote }
|
|
|
91
91
|
gridRow: rowIndex + 2
|
|
92
92
|
}
|
|
93
93
|
: { gridColumn: rowIndex + 1, gridRow: index + 3 + actionRows[0].length };
|
|
94
|
-
const button = (
|
|
94
|
+
const button = (
|
|
95
|
+
// Set tooltip placement to be uniform for all buttons
|
|
96
|
+
_jsx(Tooltip, { title: t(`hit.details.asessments.${action.name}.description`), placement: "top", children: _jsx(Button, { variant: "outlined", size: "small", disabled: loading, onClick: action.actionFunction, sx: [
|
|
95
97
|
{
|
|
96
98
|
width: '100%',
|
|
97
99
|
p: 0.6
|
|
@@ -19,6 +19,7 @@ declare const useMyUserFunctions: () => {
|
|
|
19
19
|
type: "view" | "analytic";
|
|
20
20
|
config: string;
|
|
21
21
|
}[];
|
|
22
|
+
refresh_rate?: number;
|
|
22
23
|
avatar?: string;
|
|
23
24
|
is_admin?: boolean;
|
|
24
25
|
}>;
|
|
@@ -41,6 +42,7 @@ declare const useMyUserFunctions: () => {
|
|
|
41
42
|
type: "view" | "analytic";
|
|
42
43
|
config: string;
|
|
43
44
|
}[];
|
|
45
|
+
refresh_rate?: number;
|
|
44
46
|
avatar?: string;
|
|
45
47
|
is_admin?: boolean;
|
|
46
48
|
}>;
|
|
@@ -64,6 +66,7 @@ declare const useMyUserFunctions: () => {
|
|
|
64
66
|
type: "view" | "analytic";
|
|
65
67
|
config: string;
|
|
66
68
|
}[];
|
|
69
|
+
refresh_rate?: number;
|
|
67
70
|
avatar?: string;
|
|
68
71
|
is_admin?: boolean;
|
|
69
72
|
}>;
|
|
@@ -87,6 +90,7 @@ declare const useMyUserFunctions: () => {
|
|
|
87
90
|
type: "view" | "analytic";
|
|
88
91
|
config: string;
|
|
89
92
|
}[];
|
|
93
|
+
refresh_rate?: number;
|
|
90
94
|
avatar?: string;
|
|
91
95
|
is_admin?: boolean;
|
|
92
96
|
}>;
|
|
@@ -109,10 +113,12 @@ declare const useMyUserFunctions: () => {
|
|
|
109
113
|
type: "view" | "analytic";
|
|
110
114
|
config: string;
|
|
111
115
|
}[];
|
|
116
|
+
refresh_rate?: number;
|
|
112
117
|
avatar?: string;
|
|
113
118
|
is_admin?: boolean;
|
|
114
119
|
}>;
|
|
115
120
|
viewGroups: () => Promise<void>;
|
|
116
121
|
setDashboard: (dashboard: HowlerUser["dashboard"]) => Promise<void>;
|
|
122
|
+
setRefreshRate: (refresh_rate: number) => Promise<void>;
|
|
117
123
|
};
|
|
118
124
|
export default useMyUserFunctions;
|
|
@@ -107,6 +107,12 @@ const useMyUserFunctions = () => {
|
|
|
107
107
|
throwError: true,
|
|
108
108
|
showError: true
|
|
109
109
|
});
|
|
110
|
+
}, [currentUser.username, dispatchApi]),
|
|
111
|
+
setRefreshRate: useCallback(async (refresh_rate) => {
|
|
112
|
+
await dispatchApi(api.user.put(currentUser.username, { refresh_rate }), {
|
|
113
|
+
throwError: true,
|
|
114
|
+
showError: true
|
|
115
|
+
});
|
|
110
116
|
}, [currentUser.username, dispatchApi])
|
|
111
117
|
};
|
|
112
118
|
};
|
|
@@ -49,8 +49,8 @@ const HitContextMenu = ({ children, getSelectedId, Component = Box }) => {
|
|
|
49
49
|
const { config } = useContext(ApiConfigContext);
|
|
50
50
|
const pluginStore = usePluginStore();
|
|
51
51
|
const { getMatchingAnalytic, getMatchingTemplate } = useMatchers();
|
|
52
|
-
const query = useContextSelector(ParameterContext, ctx => ctx
|
|
53
|
-
const setQuery = useContextSelector(ParameterContext, ctx => ctx
|
|
52
|
+
const query = useContextSelector(ParameterContext, ctx => ctx?.query);
|
|
53
|
+
const setQuery = useContextSelector(ParameterContext, ctx => ctx?.setQuery);
|
|
54
54
|
const [id, setId] = useState(null);
|
|
55
55
|
const hit = useContextSelector(HitContext, ctx => ctx.hits[id]);
|
|
56
56
|
const selectedHits = useContextSelector(HitContext, ctx => ctx.selectedHits);
|
|
@@ -172,7 +172,7 @@ const HitContextMenu = ({ children, getSelectedId, Component = Box }) => {
|
|
|
172
172
|
overflow: 'visible !important'
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
|
-
}, MenuListProps: { dense: true, sx: { minWidth: '250px' } }, anchorOrigin: { vertical: 'top', horizontal: 'left' }, onClick: () => setAnchorEl(null), children: [_jsxs(MenuItem, { component: Link, to: `/hits/${hit?.howler.id}`, disabled: !hit, children: [_jsx(ListItemIcon, { children: _jsx(OpenInNew, {}) }), _jsx(ListItemText, { children: t('hit.panel.open') })] }), _jsxs(MenuItem, { component: Link, to: `/analytics/${analytic?.analytic_id}`, disabled: !analytic, children: [_jsx(ListItemIcon, { children: _jsx(QueryStats, {}) }), _jsx(ListItemText, { children: t('hit.panel.analytic.open') })] }), _jsx(Divider, {}), entries.map(([type, items]) => (_jsxs(MenuItem, { id: `${type}-menu-item`, sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, [type]: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, [type]: null })), disabled: rowStatus[type] === false, children: [_jsx(ListItemIcon, { children: ICON_MAP[type] ?? _jsx(Terminal, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t(`hit.details.actions.${type}`) }), rowStatus[type] !== false && (_jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } })), _jsx(Fade, { in: !!show[type], unmountOnExit: true, children: _jsx(Paper, { id: `${type}-submenu`, sx: calculateSubMenuStyles(show[type]), elevation: 8, children: _jsx(MenuList, { sx: { p: 0, borderTopLeftRadius: 0 }, dense: true, role: "group", children: items.map(a => (_jsx(MenuItem, { value: a.name, onClick: a.actionFunction, children: a.i18nKey ? t(a.i18nKey) : capitalize(a.name) }, a.name))) }) }) })] }, type))), _jsxs(MenuItem, { id: "actions-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, actions: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, actions: null })), disabled: actions.length < 1, children: [_jsx(ListItemIcon, { children: _jsx(SettingsSuggest, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('route.actions.change') }), actions.length > 0 && _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.actions, unmountOnExit: true, children: _jsx(Paper, { id: "actions-submenu", sx: calculateSubMenuStyles(show.actions), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: actions.map(action => (_jsx(MenuItem, { onClick: () => executeAction(action.action_id, `howler.id:${hit?.howler.id}`), children: _jsx(ListItemText, { children: action.name }) }, action.action_id))) }) }) })] }), !isEmpty(template?.keys ?? []) && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(MenuItem, { id: "excludes-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, excludes: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, excludes: null })), children: [_jsx(ListItemIcon, { children: _jsx(RemoveCircleOutline, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('hit.panel.exclude') }), _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.excludes, unmountOnExit: true, children: _jsx(Paper, { id: "excludes-submenu", sx: calculateSubMenuStyles(show.excludes), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: template?.keys.map(key => {
|
|
175
|
+
}, MenuListProps: { dense: true, sx: { minWidth: '250px' } }, anchorOrigin: { vertical: 'top', horizontal: 'left' }, onClick: () => setAnchorEl(null), children: [_jsxs(MenuItem, { component: Link, to: `/hits/${hit?.howler.id}`, disabled: !hit, children: [_jsx(ListItemIcon, { children: _jsx(OpenInNew, {}) }), _jsx(ListItemText, { children: t('hit.panel.open') })] }), _jsxs(MenuItem, { component: Link, to: `/analytics/${analytic?.analytic_id}`, disabled: !analytic, children: [_jsx(ListItemIcon, { children: _jsx(QueryStats, {}) }), _jsx(ListItemText, { children: t('hit.panel.analytic.open') })] }), _jsx(Divider, {}), entries.map(([type, items]) => (_jsxs(MenuItem, { id: `${type}-menu-item`, sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, [type]: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, [type]: null })), disabled: rowStatus[type] === false, children: [_jsx(ListItemIcon, { children: ICON_MAP[type] ?? _jsx(Terminal, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t(`hit.details.actions.${type}`) }), rowStatus[type] !== false && (_jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } })), _jsx(Fade, { in: !!show[type], unmountOnExit: true, children: _jsx(Paper, { id: `${type}-submenu`, sx: calculateSubMenuStyles(show[type]), elevation: 8, children: _jsx(MenuList, { sx: { p: 0, borderTopLeftRadius: 0 }, dense: true, role: "group", children: items.map(a => (_jsx(MenuItem, { value: a.name, onClick: a.actionFunction, children: a.i18nKey ? t(a.i18nKey) : capitalize(a.name) }, a.name))) }) }) })] }, type))), _jsxs(MenuItem, { id: "actions-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, actions: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, actions: null })), disabled: actions.length < 1, children: [_jsx(ListItemIcon, { children: _jsx(SettingsSuggest, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('route.actions.change') }), actions.length > 0 && _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.actions, unmountOnExit: true, children: _jsx(Paper, { id: "actions-submenu", sx: calculateSubMenuStyles(show.actions), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: actions.map(action => (_jsx(MenuItem, { onClick: () => executeAction(action.action_id, `howler.id:${hit?.howler.id}`), children: _jsx(ListItemText, { children: action.name }) }, action.action_id))) }) }) })] }), !isEmpty(template?.keys ?? []) && setQuery && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(MenuItem, { id: "excludes-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, excludes: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, excludes: null })), children: [_jsx(ListItemIcon, { children: _jsx(RemoveCircleOutline, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('hit.panel.exclude') }), _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.excludes, unmountOnExit: true, children: _jsx(Paper, { id: "excludes-submenu", sx: calculateSubMenuStyles(show.excludes), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: template?.keys.map(key => {
|
|
176
176
|
// Build exclusion query based on current query and field value
|
|
177
177
|
let newQuery = '';
|
|
178
178
|
if (query !== DEFAULT_QUERY) {
|
|
@@ -18,7 +18,7 @@ const CustomSpan = () => {
|
|
|
18
18
|
const endDate = useContextSelector(ParameterContext, ctx => (ctx.endDate ? dayjs(ctx.endDate) : defaultEndDate));
|
|
19
19
|
useEffect(() => {
|
|
20
20
|
if (span?.endsWith('custom')) {
|
|
21
|
-
setCustomSpan(startDate.
|
|
21
|
+
setCustomSpan(startDate.toISOString(), endDate.toISOString());
|
|
22
22
|
}
|
|
23
23
|
}, [endDate, setCustomSpan, span, startDate]);
|
|
24
24
|
return span?.endsWith('custom') ? (_jsx(LocalizationProvider, { dateAdapter: AdapterDayjs, children: _jsxs(Stack, { direction: "row", spacing: 1, useFlexGap: true, flexWrap: "wrap", children: [_jsx(DateTimePicker, { sx: { minWidth: '175px', flexGrow: 1, marginTop: 1 }, slotProps: { textField: { size: 'small' } }, label: t('date.select.start'), value: startDate ? dayjs(startDate) : dayjs().subtract(1, 'days'), maxDate: endDate, onChange: (newStartDate) => setCustomSpan(newStartDate.toISOString(), endDate.toISOString()), ampm: false, disableFuture: true }), _jsx(DateTimePicker, { sx: { minWidth: '175px', flexGrow: 1, marginTop: 1 }, slotProps: { textField: { size: 'small' } }, label: t('date.select.end'), value: endDate, minDate: startDate, onChange: (newEndDate) => setCustomSpan(startDate.toISOString(), newEndDate.toISOString()), ampm: false, disableFuture: true })] }) })) : null;
|
|
@@ -132,7 +132,7 @@ const HitViewer = () => {
|
|
|
132
132
|
position: 'absolute',
|
|
133
133
|
top: theme.spacing(2),
|
|
134
134
|
right: theme.spacing(-6)
|
|
135
|
-
}, children: [_jsx(Tooltip, { title: t('
|
|
135
|
+
}, children: [_jsx(Tooltip, { title: t('hit.panel.view.layout'), children: _jsx(IconButton, { onClick: onOrientationChange, children: _jsx(ViewAgenda, { sx: { transition: 'rotate 250ms', rotate: orientation === 'vertical' ? '90deg' : '0deg' } }) }) }), _jsx(SocketBadge, { size: "medium" }), analytic && (_jsx(Tooltip, { title: t('hit.panel.analytic.open'), children: _jsx(IconButton, { onClick: () => navigate(`/analytics/${analytic.analytic_id}`), children: _jsx(QueryStats, {}) }) })), hit?.howler.bundles?.length > 0 && _jsx(BundleButton, { ids: hit.howler.bundles })] }))] }), _jsx(HowlerCard, { sx: [orientation === 'horizontal' && { height: '0px' }], children: _jsx(CardContent, { sx: { padding: 1, position: 'relative' }, children: _jsx(HitActions, { hit: hit, orientation: "vertical" }) }) }), _jsx(Box, { sx: { gridColumn: '1 / span 2', mb: 1 }, children: _jsxs(Tabs, { value: tab === 'overview' && !hasOverview ? 'details' : tab, sx: { display: 'flex', flexDirection: 'row', pr: 2, alignItems: 'center' }, children: [hit?.howler?.is_bundle && (_jsx(Tab, { label: t('hit.viewer.aggregate'), value: "hit_aggregate", onClick: () => setTab('hit_aggregate') })), hasOverview && (_jsx(Tab, { label: t('hit.viewer.overview'), value: "overview", onClick: () => setTab('overview') })), _jsx(Tab, { label: t('hit.viewer.details'), value: "details", onClick: () => setTab('details') }), hit?.howler.dossier?.map((lead, index) => (_jsx(Tab
|
|
136
136
|
// eslint-disable-next-line react/no-array-index-key
|
|
137
137
|
, { 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: 'lead:' + index, onClick: () => setTab('lead:' + index) }, 'lead:' + index))), dossiers.flatMap((_dossier, dossierIndex) => (_dossier.leads ?? []).map((_lead, leadIndex) => (_jsx(Tab
|
|
138
138
|
// eslint-disable-next-line react/no-array-index-key
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type FC } from 'react';
|
|
2
2
|
export interface ViewSettings {
|
|
3
3
|
viewId: string;
|
|
4
4
|
limit: number;
|
|
5
|
+
refreshTick?: symbol;
|
|
6
|
+
onRefreshComplete?: () => void;
|
|
5
7
|
}
|
|
6
8
|
declare const ViewCard: FC<ViewSettings>;
|
|
7
9
|
export default ViewCard;
|
|
@@ -3,22 +3,109 @@ import { OpenInNew } from '@mui/icons-material';
|
|
|
3
3
|
import { Card, CardContent, IconButton, Skeleton, Stack, Typography } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import AppListEmpty from '@cccsaurora/howler-ui/commons/components/display/AppListEmpty';
|
|
6
|
+
import { useHitContextSelector } from '@cccsaurora/howler-ui/components/app/providers/HitProvider';
|
|
6
7
|
import { ViewContext } from '@cccsaurora/howler-ui/components/app/providers/ViewProvider';
|
|
7
8
|
import HitBanner from '@cccsaurora/howler-ui/components/elements/hit/HitBanner';
|
|
8
9
|
import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
|
|
9
10
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
10
|
-
import
|
|
11
|
+
import HitContextMenu from '@cccsaurora/howler-ui/components/routes/hits/search/HitContextMenu';
|
|
12
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
13
|
import { useTranslation } from 'react-i18next';
|
|
12
14
|
import { useNavigate } from 'react-router-dom';
|
|
13
15
|
import { useContextSelector } from 'use-context-selector';
|
|
14
|
-
|
|
16
|
+
// Custom hook to select hits by IDs with proper memoization
|
|
17
|
+
const useSelectHitsByIds = (hitIds) => {
|
|
18
|
+
const hitIdsRef = useRef(hitIds);
|
|
19
|
+
const prevResultRef = useRef([]);
|
|
20
|
+
const prevHitIdsRef = useRef([]);
|
|
21
|
+
// Keep ref up to date with latest hitIds
|
|
22
|
+
hitIdsRef.current = hitIds;
|
|
23
|
+
const selector = useCallback(ctx => {
|
|
24
|
+
const currentHitIds = hitIdsRef.current;
|
|
25
|
+
// Fast path: if hitIds array didn't change, check if hit objects changed
|
|
26
|
+
if (prevHitIdsRef.current.length === currentHitIds.length &&
|
|
27
|
+
currentHitIds.every((id, i) => id === prevHitIdsRef.current[i])) {
|
|
28
|
+
// HitIds unchanged - check if any hit objects changed by reference
|
|
29
|
+
const anyHitChanged = currentHitIds.some((id, i) => ctx.hits[id] !== prevResultRef.current[i]);
|
|
30
|
+
if (!anyHitChanged) {
|
|
31
|
+
return prevResultRef.current;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Something changed - rebuild the array
|
|
35
|
+
const currentHits = currentHitIds.map(id => ctx.hits[id]).filter(Boolean);
|
|
36
|
+
prevHitIdsRef.current = currentHitIds;
|
|
37
|
+
prevResultRef.current = currentHits;
|
|
38
|
+
return currentHits;
|
|
39
|
+
}, []); // Empty deps - selector never changes
|
|
40
|
+
return useHitContextSelector(selector);
|
|
41
|
+
};
|
|
42
|
+
// Utility functions
|
|
43
|
+
const normalize = (val) => (val == null ? '' : String(val));
|
|
44
|
+
// Have to normalize the fields as websockets and api return null and undefined respectively. This causes false positives when comparing signatures if not normalized to a consistent value. We also stringify non-primitive values to ensure changes are detected.
|
|
45
|
+
const createHitSignature = (hit) => {
|
|
46
|
+
if (!hit)
|
|
47
|
+
return '';
|
|
48
|
+
return `${hit.howler?.id}:${normalize(hit.howler?.status)}:${normalize(hit.howler?.assignment)}:${normalize(hit.howler?.assessment)}`;
|
|
49
|
+
};
|
|
50
|
+
const createSignatureFromHits = (hits) => {
|
|
51
|
+
if (hits.length === 0)
|
|
52
|
+
return '';
|
|
53
|
+
return hits.map(createHitSignature).join('|');
|
|
54
|
+
};
|
|
55
|
+
const DEBOUNCE_TIME = 1000; // 1 second debounce for signature changes
|
|
56
|
+
const ViewCard = ({ viewId, limit, refreshTick, onRefreshComplete }) => {
|
|
15
57
|
const navigate = useNavigate();
|
|
16
58
|
const { t } = useTranslation();
|
|
17
59
|
const { dispatchApi } = useMyApi();
|
|
18
|
-
const [
|
|
60
|
+
const [hitIds, setHitIds] = useState([]);
|
|
19
61
|
const [loading, setLoading] = useState(false);
|
|
62
|
+
const debounceTimerRef = useRef(null);
|
|
63
|
+
const isRefreshing = useRef(false);
|
|
64
|
+
const lastSignature = useRef('');
|
|
20
65
|
const view = useContextSelector(ViewContext, ctx => ctx.views[viewId]);
|
|
21
66
|
const fetchViews = useContextSelector(ViewContext, ctx => ctx.fetchViews);
|
|
67
|
+
const loadHits = useHitContextSelector(ctx => ctx.loadHits);
|
|
68
|
+
// Subscribe to hits from HitProvider cache based on current hitIds in the view
|
|
69
|
+
// Uses memoized selector to avoid unnecessary re-renders on unrelated hit updates
|
|
70
|
+
const hits = useSelectHitsByIds(hitIds);
|
|
71
|
+
// Create a stable signature that only changes when relevant fields change
|
|
72
|
+
const hitsSignature = useMemo(() => createSignatureFromHits(hits), [hits]);
|
|
73
|
+
const refreshView = useCallback(async () => {
|
|
74
|
+
if (!view?.query || isRefreshing.current) {
|
|
75
|
+
onRefreshComplete?.();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
isRefreshing.current = true;
|
|
79
|
+
try {
|
|
80
|
+
const res = await dispatchApi(api.search.hit.post({
|
|
81
|
+
query: view.query,
|
|
82
|
+
rows: limit,
|
|
83
|
+
metadata: ['analytic']
|
|
84
|
+
}));
|
|
85
|
+
const fetchedHits = res.items ?? [];
|
|
86
|
+
loadHits(fetchedHits);
|
|
87
|
+
setHitIds(fetchedHits.map(h => h.howler.id));
|
|
88
|
+
lastSignature.current = createSignatureFromHits(fetchedHits);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
isRefreshing.current = false;
|
|
92
|
+
onRefreshComplete?.();
|
|
93
|
+
}
|
|
94
|
+
}, [dispatchApi, limit, view?.query, loadHits, onRefreshComplete]);
|
|
95
|
+
const debouncedRefresh = useCallback(() => {
|
|
96
|
+
if (debounceTimerRef.current) {
|
|
97
|
+
clearTimeout(debounceTimerRef.current);
|
|
98
|
+
}
|
|
99
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
100
|
+
refreshView();
|
|
101
|
+
}, DEBOUNCE_TIME);
|
|
102
|
+
}, [refreshView]);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (refreshTick) {
|
|
105
|
+
refreshView();
|
|
106
|
+
}
|
|
107
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
108
|
+
}, [refreshTick]);
|
|
22
109
|
useEffect(() => {
|
|
23
110
|
fetchViews([viewId]);
|
|
24
111
|
}, [fetchViews, viewId]);
|
|
@@ -26,19 +113,43 @@ const ViewCard = ({ viewId, limit }) => {
|
|
|
26
113
|
if (!view?.query) {
|
|
27
114
|
return;
|
|
28
115
|
}
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
rows: limit,
|
|
33
|
-
metadata: ['analytic']
|
|
34
|
-
}))
|
|
35
|
-
.then(res => setHits(res.items ?? []))
|
|
36
|
-
.finally(() => {
|
|
37
|
-
clearTimeout(timeout);
|
|
116
|
+
const loadingTimeout = setTimeout(() => setLoading(true), 200);
|
|
117
|
+
refreshView().finally(() => {
|
|
118
|
+
clearTimeout(loadingTimeout);
|
|
38
119
|
setLoading(false);
|
|
39
120
|
});
|
|
40
|
-
|
|
121
|
+
return () => {
|
|
122
|
+
clearTimeout(loadingTimeout);
|
|
123
|
+
};
|
|
124
|
+
}, [view?.query, limit, refreshView]);
|
|
125
|
+
// Monitor hits currently in the view for changes that might affect query results
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!hitsSignature || hitIds.length === 0 || !lastSignature.current) {
|
|
128
|
+
lastSignature.current = hitsSignature;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Check if signature actually changed
|
|
132
|
+
if (lastSignature.current === hitsSignature) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
debouncedRefresh();
|
|
136
|
+
}, [hitsSignature, hitIds, debouncedRefresh]);
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
return () => {
|
|
139
|
+
if (debounceTimerRef.current) {
|
|
140
|
+
clearTimeout(debounceTimerRef.current);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}, []);
|
|
41
144
|
const onClick = useCallback((query) => navigate('/hits?query=' + query), [navigate]);
|
|
42
|
-
|
|
145
|
+
const getSelectedId = useCallback((event) => {
|
|
146
|
+
const target = event.target;
|
|
147
|
+
const selectedElement = target.closest('[id]');
|
|
148
|
+
if (!selectedElement) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
return selectedElement.id;
|
|
152
|
+
}, []);
|
|
153
|
+
return (_jsx(Card, { variant: "outlined", sx: { height: '100%' }, children: _jsxs(Stack, { spacing: 1, sx: { p: 1, minHeight: 100 }, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { variant: "h6", children: t(view?.title) || _jsx(Skeleton, { variant: "text", height: "2em", width: "100px" }) }), _jsx(IconButton, { size: "small", onClick: () => onClick(view.query), children: _jsx(OpenInNew, { fontSize: "small" }) })] }), loading ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { height: 150, width: "100%", variant: "rounded" }), _jsx(Skeleton, { height: 160, width: "100%", variant: "rounded" }), _jsx(Skeleton, { height: 140, width: "100%", variant: "rounded" })] })) : hits.length > 0 ? (_jsx(HitContextMenu, { getSelectedId: getSelectedId, children: hits.map(h => (_jsx(Card, { id: h.howler.id, variant: "outlined", sx: { cursor: 'pointer' }, onClick: () => navigate((h.howler.is_bundle ? '/bundles/' : '/hits/') + h.howler.id), children: _jsx(CardContent, { children: _jsx(HitBanner, { layout: HitLayout.DENSE, hit: h }) }) }, h.howler.id))) })) : (_jsx(AppListEmpty, {}))] }) }));
|
|
43
154
|
};
|
|
44
155
|
export default ViewCard;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
|
|
3
3
|
import { SortableContext, arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
|
4
|
-
import { Cancel, Check, Close, Edit, OpenInNew } from '@mui/icons-material';
|
|
5
|
-
import { Alert, AlertTitle, CircularProgress, Grid, IconButton, Stack, Typography } from '@mui/material';
|
|
4
|
+
import { Cancel, Check, Close, Edit, MoreVert, OpenInNew, Refresh } from '@mui/icons-material';
|
|
5
|
+
import { Alert, AlertTitle, Box, CircularProgress, FormControl, FormLabel, Grid, IconButton, ListItemIcon, Menu, MenuItem, Slider, Stack, Tooltip, Typography } from '@mui/material';
|
|
6
6
|
import api from '@cccsaurora/howler-ui/api';
|
|
7
7
|
import { AppBrand } from '@cccsaurora/howler-ui/branding/AppBrand';
|
|
8
8
|
import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks';
|
|
@@ -12,7 +12,7 @@ import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/us
|
|
|
12
12
|
import useMyUserFunctions from '@cccsaurora/howler-ui/components/hooks/useMyUserFunctions';
|
|
13
13
|
import dayjs from 'dayjs';
|
|
14
14
|
import isEqual from 'lodash-es/isEqual';
|
|
15
|
-
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
15
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
16
|
import { useTranslation } from 'react-i18next';
|
|
17
17
|
import { Link } from 'react-router-dom';
|
|
18
18
|
import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
@@ -22,16 +22,25 @@ import AnalyticCard, {} from './AnalyticCard';
|
|
|
22
22
|
import EntryWrapper from './EntryWrapper';
|
|
23
23
|
import ViewCard, {} from './ViewCard';
|
|
24
24
|
const LUCENE_DATE_FMT = 'YYYY-MM-DD[T]HH:mm:ss';
|
|
25
|
+
const REFRESH_RATES = [15, 30, 60, 300];
|
|
25
26
|
const Home = () => {
|
|
26
27
|
const { t } = useTranslation();
|
|
27
28
|
const { user, setUser } = useAppUser();
|
|
28
|
-
const { setDashboard } = useMyUserFunctions();
|
|
29
|
+
const { setDashboard, setRefreshRate: setRefreshRateBackend } = useMyUserFunctions();
|
|
29
30
|
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
|
30
31
|
const [lastViewed, setLastViewed] = useMyLocalStorageItem(StorageKey.LAST_VIEW, dayjs().utc().format(LUCENE_DATE_FMT));
|
|
31
32
|
const [loading, setLoading] = useState(false);
|
|
32
33
|
const [isEditing, setIsEditing] = useState(false);
|
|
33
34
|
const [updatedHitTotal, setUpdatedHitTotal] = useState(0);
|
|
34
35
|
const [dashboard, setStateDashboard] = useState(user.dashboard ?? []);
|
|
36
|
+
const [progress, setProgress] = useState(0);
|
|
37
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
38
|
+
const [openSettings, setOpenSettings] = useState(null);
|
|
39
|
+
const [refreshRate, setRefreshRate] = useState(user.refresh_rate ?? 15);
|
|
40
|
+
const [refreshTick, setRefreshTick] = useState(null);
|
|
41
|
+
const pendingRefreshes = useRef(0);
|
|
42
|
+
const timerRef = useRef(null);
|
|
43
|
+
const debounceTimerRef = useRef(null);
|
|
35
44
|
const updateQuery = useMemo(() => `(howler.log.user:${user.username} OR howler.assignment:${user.username}) AND howler.log.timestamp:{${lastViewed} TO now} AND -howler.status:resolved`, [lastViewed, user.username]);
|
|
36
45
|
const getIdFromEntry = useCallback((entry) => {
|
|
37
46
|
const settings = JSON.parse(entry.config);
|
|
@@ -48,6 +57,41 @@ const Home = () => {
|
|
|
48
57
|
const setLocalDashboard = useCallback((_dashboard) => {
|
|
49
58
|
setStateDashboard(_dashboard);
|
|
50
59
|
}, []);
|
|
60
|
+
const handleRefreshComplete = useCallback(() => {
|
|
61
|
+
pendingRefreshes.current -= 1;
|
|
62
|
+
if (pendingRefreshes.current <= 0) {
|
|
63
|
+
setIsRefreshing(false);
|
|
64
|
+
setProgress(0);
|
|
65
|
+
}
|
|
66
|
+
}, []);
|
|
67
|
+
const handleRefreshRateChange = useCallback((newRate) => {
|
|
68
|
+
setRefreshRate(newRate);
|
|
69
|
+
setUser(prev => ({
|
|
70
|
+
...prev,
|
|
71
|
+
refresh_rate: newRate
|
|
72
|
+
}));
|
|
73
|
+
// Debounce the backend API call
|
|
74
|
+
if (debounceTimerRef.current) {
|
|
75
|
+
clearTimeout(debounceTimerRef.current);
|
|
76
|
+
}
|
|
77
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
78
|
+
setRefreshRateBackend(newRate);
|
|
79
|
+
}, 500);
|
|
80
|
+
}, [setRefreshRateBackend, setUser]);
|
|
81
|
+
const refreshViews = useCallback(() => {
|
|
82
|
+
const viewCardCount = (dashboard ?? []).filter(e => e.type === 'view').length;
|
|
83
|
+
setIsRefreshing(true);
|
|
84
|
+
pendingRefreshes.current = viewCardCount;
|
|
85
|
+
if (viewCardCount === 0) {
|
|
86
|
+
setIsRefreshing(false);
|
|
87
|
+
setProgress(0);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setRefreshTick(Symbol());
|
|
91
|
+
}, [dashboard]);
|
|
92
|
+
const handleOpenSettings = (event) => {
|
|
93
|
+
setOpenSettings(event.currentTarget);
|
|
94
|
+
};
|
|
51
95
|
const saveChanges = useCallback(async () => {
|
|
52
96
|
setLoading(true);
|
|
53
97
|
try {
|
|
@@ -82,7 +126,48 @@ const Home = () => {
|
|
|
82
126
|
})
|
|
83
127
|
.then(result => setUpdatedHitTotal(result.total));
|
|
84
128
|
}, [updateQuery]);
|
|
85
|
-
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (isRefreshing)
|
|
131
|
+
return;
|
|
132
|
+
if (progress >= 100) {
|
|
133
|
+
refreshViews();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
timerRef.current = setTimeout(() => {
|
|
137
|
+
setProgress(prev => prev + 1);
|
|
138
|
+
}, refreshRate * 10);
|
|
139
|
+
return () => {
|
|
140
|
+
if (timerRef.current)
|
|
141
|
+
clearTimeout(timerRef.current);
|
|
142
|
+
};
|
|
143
|
+
}, [progress, isRefreshing, refreshRate, refreshViews]);
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
return () => {
|
|
146
|
+
if (debounceTimerRef.current)
|
|
147
|
+
clearTimeout(debounceTimerRef.current);
|
|
148
|
+
};
|
|
149
|
+
}, []);
|
|
150
|
+
return (_jsx(PageCenter, { maxWidth: "1800px", textAlign: "left", height: "100%", children: _jsx(ErrorBoundary, { children: _jsxs(Stack, { direction: "column", spacing: 1, sx: { height: '100%' }, children: [_jsxs(Stack, { direction: "row", justifyContent: "end", spacing: 1, children: [isEditing && (_jsx(CustomButton, { variant: "outlined", size: "small", color: "error", startIcon: _jsx(Cancel, {}), onClick: discardChanges, children: t('cancel') })), isEditing && (_jsx(CustomButton, { variant: "outlined", size: "small", disabled: isEqual(dashboard, user.dashboard), color: 'success', startIcon: loading ? _jsx(CircularProgress, { size: 20 }) : _jsx(Check, {}), onClick: saveChanges, children: t('save') })), _jsxs(Box, { sx: { position: 'relative', display: 'inline-flex' }, children: [isRefreshing ? (_jsx(CircularProgress, { variant: "indeterminate" })) : (_jsx(CircularProgress, { variant: "determinate", value: progress })), _jsx(Box, { sx: {
|
|
151
|
+
top: 0,
|
|
152
|
+
left: 0,
|
|
153
|
+
bottom: 0,
|
|
154
|
+
right: 0,
|
|
155
|
+
position: 'absolute',
|
|
156
|
+
display: 'flex',
|
|
157
|
+
alignItems: 'center',
|
|
158
|
+
justifyContent: 'center'
|
|
159
|
+
}, children: _jsx(Tooltip, { title: t('refresh'), children: _jsx(IconButton, { onClick: refreshViews, disabled: isRefreshing, color: "primary", children: _jsx(Refresh, {}) }) }) })] }), _jsx(IconButton, { size: "small", color: "primary", onClick: handleOpenSettings, children: _jsx(MoreVert, {}) }), _jsxs(Menu, { id: "settings-menu", anchorEl: openSettings, open: !!openSettings, onClose: () => setOpenSettings(null), children: [_jsxs(MenuItem, { disabled: isEditing, onClick: () => {
|
|
160
|
+
setOpenSettings(null);
|
|
161
|
+
setIsEditing(true);
|
|
162
|
+
}, children: [_jsx(ListItemIcon, { children: _jsx(Edit, {}) }), t('page.dashboard.settings.edit')] }), _jsx(MenuItem, { disableRipple: true, disableTouchRipple: true, sx: { '&:hover': { bgcolor: 'transparent' }, cursor: 'default' }, children: _jsxs(FormControl, { sx: { px: 2, py: 1, minWidth: 250, pointerEvents: 'auto' }, children: [_jsx(FormLabel, { id: "refresh-rate-label", sx: { mb: 2 }, children: t('page.dashboard.settings.refreshRate') }), _jsx(Slider, { "aria-labelledby": "refresh-rate-label", value: REFRESH_RATES.indexOf(refreshRate), onChange: (_, value) => handleRefreshRateChange(REFRESH_RATES[value]), step: 1, marks: [
|
|
163
|
+
{ value: 0, label: '15s' },
|
|
164
|
+
{ value: 1, label: '30s' },
|
|
165
|
+
{ value: 2, label: '1m' },
|
|
166
|
+
{ value: 3, label: '5m' }
|
|
167
|
+
], min: 0, max: 3, valueLabelDisplay: "auto", valueLabelFormat: value => {
|
|
168
|
+
const rates = ['15s', '30s', '1m', '5m'];
|
|
169
|
+
return rates[value] || '';
|
|
170
|
+
} })] }) })] })] }), updatedHitTotal > 0 && (_jsxs(Alert, { severity: "info", variant: "outlined", action: _jsxs(Stack, { spacing: 1, direction: "row", children: [_jsx(IconButton, { color: "info", component: Link, to: `/hits?query=${encodeURIComponent(updateQuery)}`, onClick: () => setLastViewed(dayjs().utc().format(LUCENE_DATE_FMT)), children: _jsx(OpenInNew, {}) }), _jsx(IconButton, { color: "info", onClick: () => {
|
|
86
171
|
setLastViewed(dayjs().utc().format(LUCENE_DATE_FMT));
|
|
87
172
|
setUpdatedHitTotal(0);
|
|
88
173
|
}, children: _jsx(Close, {}) })] }), children: [_jsx(AlertTitle, { children: t('route.home.alert.updated.title') }), t('route.home.alert.updated.description', { count: updatedHitTotal })] })), _jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: handleDragEnd, children: _jsx(SortableContext, { items: (dashboard ?? []).map(entry => getIdFromEntry(entry)), children: _jsxs(Grid, { container: true, spacing: 1, alignItems: "stretch", sx: [
|
|
@@ -98,7 +183,7 @@ const Home = () => {
|
|
|
98
183
|
], children: [(dashboard ?? []).map(entry => {
|
|
99
184
|
if (entry.type === 'view') {
|
|
100
185
|
const settings = JSON.parse(entry.config);
|
|
101
|
-
return (_jsx(EntryWrapper, { editing: isEditing, id: settings.viewId, onDelete: () => setLocalDashboard((dashboard ?? []).filter(_entry => _entry.entry_id !== getIdFromEntry(entry))), children: _jsx(ViewCard, { ...settings }, entry.config) }, entry.entry_id));
|
|
186
|
+
return (_jsx(EntryWrapper, { editing: isEditing, id: settings.viewId, onDelete: () => setLocalDashboard((dashboard ?? []).filter(_entry => _entry.entry_id !== getIdFromEntry(entry))), children: _jsx(ViewCard, { refreshTick: refreshTick, onRefreshComplete: handleRefreshComplete, ...settings }, entry.config) }, entry.entry_id));
|
|
102
187
|
}
|
|
103
188
|
else if (entry.type === 'analytic') {
|
|
104
189
|
const settings = JSON.parse(entry.config);
|
|
@@ -175,6 +175,7 @@
|
|
|
175
175
|
"hit.panel.analytic.open": "Open Analytic",
|
|
176
176
|
"hit.panel.bundles.open": "Parent Bundles",
|
|
177
177
|
"hit.panel.bundles.open.prompt": "Open Parent Bundle",
|
|
178
|
+
"hit.panel.view.layout": "Change View Panel",
|
|
178
179
|
"hit.panel.details.show": "Show Hit Details",
|
|
179
180
|
"hit.panel.details.hide": "Hide Hit Details",
|
|
180
181
|
"hit.panel.details.exit": "Close Hit",
|
|
@@ -342,6 +343,8 @@
|
|
|
342
343
|
"page.error.description": "The application stopped working suddenly. If the problem persists please reach out on teams.",
|
|
343
344
|
"page.error.support": "Please click here for teams support",
|
|
344
345
|
"page.dashboard.title": "Dashboard",
|
|
346
|
+
"page.dashboard.settings.edit": "Edit Dashboard",
|
|
347
|
+
"page.dashboard.settings.refreshRate": "Refresh Rate",
|
|
345
348
|
"page.documentation.categories": "API Categories",
|
|
346
349
|
"page.documentation.categories.category": "Category",
|
|
347
350
|
"page.documentation.categories.description": "Description",
|
|
@@ -176,6 +176,7 @@
|
|
|
176
176
|
"hit.panel.analytic.open": "Ouvrir l'analyse",
|
|
177
177
|
"hit.panel.bundles.open": "Groupes parentaux",
|
|
178
178
|
"hit.panel.bundles.open.prompt": "Ouvrir le groupe parent",
|
|
179
|
+
"hit.panel.view.layout": "Modifier le panneau d'affichage",
|
|
179
180
|
"hit.panel.details.show": "Montrer les détails du hit",
|
|
180
181
|
"hit.panel.details.hide": "Cacher les détails du hit",
|
|
181
182
|
"hit.panel.details.exit": "Fermer hit",
|
|
@@ -344,6 +345,8 @@
|
|
|
344
345
|
"page.error.description": "L'application a cessé de fonctionner soudainement. Si le problème persiste, veuillez nous contacter sur teams.",
|
|
345
346
|
"page.error.support": "Cliquez ici pour obtenir du soutien sur teams",
|
|
346
347
|
"page.dashboard.title": "Tableau de bord",
|
|
348
|
+
"page.dashboard.settings.edit": "Modifier le tableau de bord",
|
|
349
|
+
"page.dashboard.settings.refreshRate": "Fréquence de rafraîchissement",
|
|
347
350
|
"page.documentation.categories": "Catégories d'API",
|
|
348
351
|
"page.documentation.categories.category": "Catégorie",
|
|
349
352
|
"page.documentation.categories.description": "Description",
|