@cccsaurora/howler-ui 3.1.0-dev.1609 → 3.1.0-dev.1617

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.
Files changed (64) hide show
  1. package/api/dossier/groups.d.ts +2 -0
  2. package/api/dossier/groups.js +4 -0
  3. package/api/dossier/groups.test.d.ts +1 -0
  4. package/api/dossier/groups.test.js +26 -0
  5. package/api/{dossier.d.ts → dossier/index.d.ts} +2 -2
  6. package/api/{dossier.js → dossier/index.js} +2 -2
  7. package/components/elements/display/HowlerCard.d.ts +1 -1
  8. package/components/elements/display/HowlerCard.js +2 -2
  9. package/components/elements/hit/HitLinks.js +25 -21
  10. package/components/elements/hit/HitLinks.test.d.ts +1 -0
  11. package/components/elements/hit/HitLinks.test.js +118 -0
  12. package/components/elements/hit/related/RelatedIcon.js +2 -1
  13. package/components/elements/hit/related/RelatedIcon.test.d.ts +1 -0
  14. package/components/elements/hit/related/RelatedIcon.test.js +79 -0
  15. package/components/elements/hit/related/RelatedLink.d.ts +5 -0
  16. package/components/elements/hit/related/RelatedLink.js +23 -6
  17. package/components/elements/hit/related/RelatedLink.test.d.ts +1 -0
  18. package/components/elements/hit/related/RelatedLink.test.js +31 -0
  19. package/components/elements/hit/related/pivots/PivotFlyout.d.ts +25 -0
  20. package/components/elements/hit/related/pivots/PivotFlyout.js +26 -0
  21. package/components/elements/hit/related/pivots/PivotFlyoutContent.d.ts +4 -0
  22. package/components/elements/hit/related/pivots/PivotFlyoutContent.js +42 -0
  23. package/components/elements/hit/related/pivots/PivotFlyoutContent.test.d.ts +1 -0
  24. package/components/elements/hit/related/pivots/PivotFlyoutContent.test.js +41 -0
  25. package/components/elements/hit/related/pivots/PivotFolderMenu.d.ts +4 -0
  26. package/components/elements/hit/related/pivots/PivotFolderMenu.js +37 -0
  27. package/components/elements/hit/related/pivots/PivotFolderMenu.test.d.ts +1 -0
  28. package/components/elements/hit/related/pivots/PivotFolderMenu.test.js +55 -0
  29. package/components/elements/hit/related/{PivotLink.d.ts → pivots/PivotLink.d.ts} +9 -2
  30. package/components/elements/hit/related/pivots/PivotLink.js +53 -0
  31. package/components/elements/hit/related/pivots/PivotLink.test.d.ts +1 -0
  32. package/components/elements/hit/related/pivots/PivotLink.test.js +117 -0
  33. package/components/elements/hit/related/pivots/types.d.ts +29 -0
  34. package/components/elements/hit/related/pivots/utils.d.ts +12 -0
  35. package/components/elements/hit/related/pivots/utils.js +51 -0
  36. package/components/elements/hit/related/pivots/utils.test.d.ts +1 -0
  37. package/components/elements/hit/related/pivots/utils.test.js +30 -0
  38. package/components/hooks/useMyPreferences.d.ts +1 -1
  39. package/components/hooks/useMyPreferences.js +20 -2
  40. package/components/hooks/useMyPreferences.test.d.ts +1 -0
  41. package/components/hooks/useMyPreferences.test.js +40 -0
  42. package/components/routes/dossiers/DossierEditor.js +6 -0
  43. package/components/routes/dossiers/DossierEditor.test.js +30 -1
  44. package/components/routes/dossiers/PivotForm.js +41 -4
  45. package/components/routes/dossiers/PivotForm.test.d.ts +1 -0
  46. package/components/routes/dossiers/PivotForm.test.js +126 -0
  47. package/components/routes/dossiers/utils.d.ts +44 -0
  48. package/components/routes/dossiers/utils.js +135 -0
  49. package/components/routes/dossiers/utils.test.d.ts +1 -0
  50. package/components/routes/dossiers/utils.test.js +168 -0
  51. package/locales/en/translation.json +5 -0
  52. package/locales/fr/translation.json +5 -0
  53. package/models/entities/generated/Pivot.d.ts +1 -0
  54. package/package.json +8 -5
  55. package/plugins/HowlerPlugin.d.ts +1 -1
  56. package/plugins/clue/components/CluePivot.d.ts +1 -1
  57. package/plugins/clue/components/CluePivot.js +17 -1
  58. package/plugins/clue/components/CluePivot.test.d.ts +1 -0
  59. package/plugins/clue/components/CluePivot.test.js +94 -0
  60. package/utils/constants.d.ts +1 -0
  61. package/utils/constants.js +1 -0
  62. package/components/elements/hit/ResolvePivotUrl.d.ts +0 -4
  63. package/components/elements/hit/ResolvePivotUrl.js +0 -22
  64. package/components/elements/hit/related/PivotLink.js +0 -93
@@ -2,15 +2,22 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { Icon, iconExists } from '@iconify/react';
3
3
  import { Add, Delete, OpenInNew, Remove } from '@mui/icons-material';
4
4
  import { Alert, Autocomplete, Button, Divider, IconButton, Paper, Stack, Tab, Tabs, TextField, Typography, useTheme } from '@mui/material';
5
+ import api from '@cccsaurora/howler-ui/api';
5
6
  import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
6
7
  import { isNil } from 'lodash-es';
7
8
  import merge from 'lodash-es/merge';
8
9
  import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
9
- import { Fragment, useCallback, useContext, useEffect, useMemo, useState } from 'react';
10
+ import { Fragment, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
10
11
  import { useTranslation } from 'react-i18next';
11
12
  import { usePluginStore } from 'react-pluggable';
12
13
  import { useSearchParams } from 'react-router';
14
+ import Throttler from '@cccsaurora/howler-ui/utils/Throttler';
13
15
  import { notNil } from '@cccsaurora/howler-ui/utils/utils';
16
+ import { pivotGroupValidation } from './utils';
17
+ // Maximum number of group suggestions to request/display at once, mirroring the backend's own cap
18
+ const MAX_GROUP_SUGGESTIONS = 10;
19
+ // Minimum delay between group suggestion requests, so fast typing can't flood the backend
20
+ const GROUP_SUGGESTION_THROTTLE_MS = 500;
14
21
  const LinkForm = ({ pivot, update }) => {
15
22
  const { t } = useTranslation();
16
23
  const { config } = useContext(ApiConfigContext);
@@ -34,6 +41,9 @@ const PivotForm = ({ dossier, setDossier, loading }) => {
34
41
  const pluginStore = usePluginStore();
35
42
  const [searchParams, setSearchParams] = useSearchParams();
36
43
  const [tab, setTab] = useState(parseInt(searchParams.get('pivot') ?? '0'));
44
+ const [groupOptions, setGroupOptions] = useState([]);
45
+ const groupThrottler = useMemo(() => new Throttler(GROUP_SUGGESTION_THROTTLE_MS), []);
46
+ const latestGroupRequest = useRef(0);
37
47
  const update = useCallback((data) => setDossier(_dossier => ({
38
48
  ..._dossier,
39
49
  pivots: (_dossier.pivots ?? [])
@@ -45,6 +55,9 @@ const PivotForm = ({ dossier, setDossier, loading }) => {
45
55
  return;
46
56
  }
47
57
  const merged = merge({}, pivot, data);
58
+ if (Object.hasOwn(data, 'group') && data.group === undefined) {
59
+ delete merged.group;
60
+ }
48
61
  if (data.mappings) {
49
62
  merged.mappings = data.mappings;
50
63
  }
@@ -53,7 +66,7 @@ const PivotForm = ({ dossier, setDossier, loading }) => {
53
66
  .filter(notNil)
54
67
  })), [setDossier, tab]);
55
68
  const pivot = useMemo(() => dossier.pivots?.[tab], [dossier.pivots, tab]);
56
- const icon = useMemo(() => pivot?.icon ?? 'material-symbols:find-in-page', [pivot?.icon]);
69
+ const icon = useMemo(() => pivot?.icon ?? 'material-symbols:link', [pivot?.icon]);
57
70
  useEffect(() => {
58
71
  searchParams.delete('lead');
59
72
  if (searchParams.get('pivot') !== tab.toString()) {
@@ -62,6 +75,25 @@ const PivotForm = ({ dossier, setDossier, loading }) => {
62
75
  setSearchParams(searchParams, { replace: true });
63
76
  // eslint-disable-next-line react-hooks/exhaustive-deps
64
77
  }, [setSearchParams, tab]);
78
+ // Validate the pivot's folder/group path to provide inline feedback before the dossier is saved.
79
+ const groupError = pivotGroupValidation(pivot?.group ?? '');
80
+ // Suggest existing group paths as the user types, throttled so we never issue more than one request per second
81
+ const fetchGroupSuggestions = useCallback((prefix) => {
82
+ const requestId = ++latestGroupRequest.current;
83
+ groupThrottler.debounce(async () => {
84
+ try {
85
+ const suggestions = await api.dossier.groups.get(prefix);
86
+ if (requestId === latestGroupRequest.current && suggestions) {
87
+ setGroupOptions(suggestions.slice(0, MAX_GROUP_SUGGESTIONS));
88
+ }
89
+ }
90
+ catch {
91
+ if (requestId === latestGroupRequest.current) {
92
+ setGroupOptions([]);
93
+ }
94
+ }
95
+ });
96
+ }, [groupThrottler]);
65
97
  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: {
66
98
  mr: 1,
67
99
  px: 1,
@@ -82,10 +114,15 @@ const PivotForm = ({ dossier, setDossier, loading }) => {
82
114
  ..._dossier,
83
115
  pivots: [
84
116
  ...(_dossier.pivots ?? []),
85
- { icon: 'material-symbols:add-ad', label: { en: 'New Pivot', fr: 'Nouvelle pivot' }, mappings: [] }
117
+ { icon: 'material-symbols:link', label: { en: 'New Pivot', fr: 'Nouvelle pivot' }, mappings: [] }
86
118
  ]
87
119
  }));
88
- }, disabled: !dossier || loading, children: _jsx(Add, {}) })] }), _jsxs(Stack, { spacing: 2, children: [_jsxs(Stack, { direction: "row", alignItems: "center", position: "relative", children: [_jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.icon'), value: icon, disabled: !pivot, 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: !pivot, sx: { minWidth: '0 !important', ml: 1 }, onClick: () => update(undefined), 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.pivot.label.en'), disabled: !pivot, value: pivot?.label?.en ?? '', fullWidth: true, onChange: ev => update({ label: { en: ev.target.value } }) }), _jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.label.fr'), disabled: !pivot, value: pivot?.label?.fr ?? '', fullWidth: true, onChange: ev => update({ label: { fr: ev.target.value } }) })] }), _jsx(Autocomplete, { disabled: !pivot, options: ['link', ...howlerPluginStore.pivotFormats], renderInput: params => (_jsx(TextField, { ...params, size: "small", label: t('route.dossiers.manager.pivot.format') })), value: pivot?.format ?? null, onChange: (_ev, format) => update({ format: format ?? undefined, value: '', mappings: [] }) }), !!pivot?.format &&
120
+ }, disabled: !dossier || loading, children: _jsx(Add, {}) })] }), _jsxs(Stack, { spacing: 2, children: [_jsxs(Stack, { direction: "row", alignItems: "center", position: "relative", children: [_jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.icon'), value: icon, disabled: !pivot, 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: !pivot, sx: { minWidth: '0 !important', ml: 1 }, onClick: () => update(undefined), children: _jsx(Delete, {}) })] }), _jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", sx: { mt: `${theme.spacing(0.5)} !important` }, children: [_jsx(Typography, { variant: "caption", 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, { sx: { fontSize: '12px' } }) })] }), _jsx(Autocomplete, { freeSolo: true, disableClearable: true, disabled: !dossier || loading, options: groupOptions, inputValue: pivot?.group ?? '', onInputChange: (_ev, value, reason) => {
121
+ update({ group: value || undefined });
122
+ if (reason === 'input') {
123
+ fetchGroupSuggestions(value);
124
+ }
125
+ }, renderInput: params => (_jsx(TextField, { ...params, id: "dossier-group", label: t('route.pivots.groups.label'), size: "small", fullWidth: true, error: !!groupError, onFocus: () => fetchGroupSuggestions(pivot?.group ?? '') })) }), _jsx(Typography, { variant: "caption", color: "text.secondary", sx: { mt: `${theme.spacing(0.5)} !important` }, children: groupError ? t(groupError) : t('route.dossiers.pivot.explanation') }), _jsxs(Stack, { direction: "row", spacing: 2, children: [_jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.label.en'), disabled: !pivot, value: pivot?.label?.en ?? '', fullWidth: true, onChange: ev => update({ label: { en: ev.target.value } }) }), _jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.label.fr'), disabled: !pivot, value: pivot?.label?.fr ?? '', fullWidth: true, onChange: ev => update({ label: { fr: ev.target.value } }) })] }), _jsx(Autocomplete, { disabled: !pivot, options: ['link', ...howlerPluginStore.pivotFormats], renderInput: params => (_jsx(TextField, { ...params, size: "small", label: t('route.dossiers.manager.pivot.format') })), value: pivot?.format ?? null, onChange: (_ev, format) => update({ format: format ?? undefined, value: '', mappings: [] }) }), !!pivot?.format &&
89
126
  (pivot.format === 'link' ? (_jsx(LinkForm, { pivot: pivot, update: update })) : (pluginStore.executeFunction(`pivot.${pivot.format}.form`, { pivot, update })))] })] }) }));
90
127
  };
91
128
  export default PivotForm;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,126 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
5
+ import { useState } from 'react';
6
+ import { MemoryRouter } from 'react-router';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import PivotForm from './PivotForm';
9
+ const mockGroupsGet = vi.hoisted(() => vi.fn());
10
+ const mockExecuteFunction = vi.hoisted(() => vi.fn());
11
+ vi.mock('api', () => ({
12
+ default: {
13
+ dossier: {
14
+ groups: {
15
+ get: mockGroupsGet
16
+ }
17
+ }
18
+ }
19
+ }));
20
+ vi.mock('@iconify/react', () => ({
21
+ Icon: ({ icon }) => _jsx("span", { "data-testid": "pivot-icon", children: icon }),
22
+ iconExists: (icon) => icon.startsWith('material-symbols:')
23
+ }));
24
+ vi.mock('plugins/store', () => ({
25
+ default: {
26
+ pivotFormats: []
27
+ }
28
+ }));
29
+ vi.mock('react-i18next', () => ({
30
+ useTranslation: () => ({
31
+ i18n: { language: 'en' },
32
+ t: (key) => key
33
+ })
34
+ }));
35
+ vi.mock('react-pluggable', () => ({
36
+ usePluginStore: () => ({ executeFunction: mockExecuteFunction })
37
+ }));
38
+ const basePivot = (overrides = {}) => ({
39
+ icon: 'material-symbols:link',
40
+ label: { en: 'Example', fr: 'Exemple' },
41
+ format: 'link',
42
+ value: 'https://example.test/{{id}}',
43
+ mappings: [],
44
+ ...overrides
45
+ });
46
+ const baseDossier = (pivot) => ({
47
+ dossier_id: 'dossier-1',
48
+ pivots: [pivot]
49
+ });
50
+ const renderForm = (pivot = basePivot(), loading = false) => {
51
+ const Wrapper = ({ children }) => {
52
+ const [dossier, setDossier] = useState(baseDossier(pivot));
53
+ return (_jsxs(ApiConfigContext.Provider, { value: {
54
+ config: { indexes: { hit: { 'howler.id': {}, 'event.domain': {} } } },
55
+ setConfig: vi.fn(),
56
+ loaded: true
57
+ }, children: [children, _jsx("output", { id: "dossier-state", children: JSON.stringify(dossier) }), _jsx(PivotForm, { dossier: dossier, setDossier: setDossier, loading: loading })] }));
58
+ };
59
+ return render(_jsx(MemoryRouter, { initialEntries: ['/dossiers/dossier-1/edit?pivot=0'], children: _jsx(Wrapper, {}) }));
60
+ };
61
+ describe('PivotForm', () => {
62
+ beforeEach(() => {
63
+ mockGroupsGet.mockReset();
64
+ mockExecuteFunction.mockReset();
65
+ });
66
+ afterEach(() => {
67
+ vi.useRealTimers();
68
+ });
69
+ it('shows inline validation feedback for malformed groups and removes the group when cleared', async () => {
70
+ const user = userEvent.setup();
71
+ renderForm(basePivot({ group: 'network//dns' }));
72
+ const groupInput = screen.getByRole('combobox', { name: 'route.pivots.groups.label' });
73
+ expect(groupInput).toHaveValue('network//dns');
74
+ expect(groupInput).toHaveAttribute('aria-invalid', 'true');
75
+ expect(screen.getByText('route.dossiers.pivots.invalid.format')).toBeInTheDocument();
76
+ await user.clear(groupInput);
77
+ expect(groupInput).toHaveValue('');
78
+ expect(screen.getByText('route.dossiers.pivot.explanation')).toBeInTheDocument();
79
+ expect(screen.getByTestId('dossier-state')).not.toHaveTextContent('"group"');
80
+ });
81
+ it('loads at most ten group suggestions after the throttled request', async () => {
82
+ const user = userEvent.setup();
83
+ mockGroupsGet.mockResolvedValue(Array.from({ length: 12 }, (_, index) => `network/${index}`));
84
+ renderForm(basePivot({ group: 'network' }));
85
+ const groupInput = screen.getByRole('combobox', { name: 'route.pivots.groups.label' });
86
+ await user.click(groupInput);
87
+ await waitFor(() => {
88
+ expect(mockGroupsGet).toHaveBeenCalledWith('network');
89
+ }, { timeout: 3000 });
90
+ expect(await screen.findByRole('option', { name: 'network/0' })).toBeInTheDocument();
91
+ expect(screen.getAllByRole('option')).toHaveLength(10);
92
+ expect(screen.queryByRole('option', { name: 'network/10' })).not.toBeInTheDocument();
93
+ });
94
+ it('clears suggestions when the latest request fails', async () => {
95
+ mockGroupsGet.mockImplementation((prefix) => prefix === 'network' ? Promise.resolve(['network/dns']) : Promise.reject(new Error('request failed')));
96
+ renderForm();
97
+ const groupInput = screen.getByRole('combobox', { name: 'route.pivots.groups.label' });
98
+ fireEvent.change(groupInput, { target: { value: 'network' } });
99
+ await waitFor(() => {
100
+ expect(mockGroupsGet).toHaveBeenCalledWith('network');
101
+ }, { timeout: 3000 });
102
+ fireEvent.mouseDown(screen.getByTitle('Open'));
103
+ await waitFor(() => expect(screen.getByRole('option', { name: 'network/dns' })).toBeInTheDocument());
104
+ fireEvent.change(groupInput, { target: { value: 'other' } });
105
+ await waitFor(() => {
106
+ expect(mockGroupsGet).toHaveBeenLastCalledWith('other');
107
+ }, { timeout: 3000 });
108
+ await waitFor(() => expect(screen.queryByRole('option', { name: 'network/dns' })).not.toBeInTheDocument());
109
+ });
110
+ it('adds a link mapping and displays the custom value field when selected', async () => {
111
+ const user = userEvent.setup();
112
+ renderForm();
113
+ await user.click(screen.getByTestId('add-pivot'));
114
+ const mappingKey = screen.getByRole('textbox', { name: 'route.dossiers.manager.pivot.mapping.key' });
115
+ await user.clear(mappingKey);
116
+ await user.type(mappingKey, 'id');
117
+ const field = screen.getByRole('combobox', { name: 'route.dossiers.manager.pivot.mapping.field' });
118
+ await user.click(field);
119
+ await user.click(screen.getByRole('option', { name: 'custom' }));
120
+ const customValue = screen.getByRole('textbox', { name: 'route.dossiers.manager.pivot.mapping.custom' });
121
+ await user.type(customValue, 'fixed-value');
122
+ expect(screen.getByTestId('dossier-state')).toHaveTextContent('"key":"id"');
123
+ expect(screen.getByTestId('dossier-state')).toHaveTextContent('"field":"custom"');
124
+ expect(screen.getByTestId('dossier-state')).toHaveTextContent('"custom_value":"fixed-value"');
125
+ });
126
+ });
@@ -0,0 +1,44 @@
1
+ import type { Dossier } from '@cccsaurora/howler-ui/models/entities/generated/Dossier';
2
+ import type { Pivot } from '@cccsaurora/howler-ui/models/entities/generated/Pivot';
3
+ export interface MenuPathNode {
4
+ path: string;
5
+ pivots?: DossierPivot[];
6
+ children?: MenuPathNode[];
7
+ }
8
+ export interface DossierPivot {
9
+ pivot: Pivot;
10
+ dossier: Dossier;
11
+ }
12
+ /**
13
+ * Validates a pivot group path using the same rules as the backend service.
14
+ *
15
+ * Rules:
16
+ * - Allows alphabetic characters (including supported accented letters), digits, and '/'.
17
+ * - Rejects malformed paths: consecutive '/', leading '/', or trailing '/'.
18
+ *
19
+ * @param group Requested pivot group path.
20
+ * @returns null when valid, otherwise the i18n error key describing the failure.
21
+ */
22
+ export declare const pivotGroupValidation: (group: string) => string | undefined;
23
+ /**
24
+ * Returns a stable menu key for a pivot within its dossier.
25
+ *
26
+ * Pivots currently attached to the dossier use their array index. Detached
27
+ * pivots receive an object-identity-based fallback key for the module lifetime.
28
+ *
29
+ * @param dossier Dossier containing the pivot, when it is attached.
30
+ * @param pivot Pivot for which to generate a key.
31
+ * @returns A dossier-scoped key suitable for identifying the pivot in the UI.
32
+ */
33
+ export declare const getDossierPivotKey: (dossier: Dossier, pivot: Pivot) => string;
34
+ /**
35
+ * Produces the final forest consumed by grouped pivot menus.
36
+ *
37
+ * Root-level pivots (no group path) are emitted as a node with an empty path,
38
+ * then grouped branches are appended from `buildPathMap`.
39
+ *
40
+ * @param dossiers Dossiers to convert into grouped pivot menu nodes.
41
+ * @param language Language used to sort labels inside each node.
42
+ * @returns Forest of menu nodes for hit-related pivot navigation.
43
+ */
44
+ export declare const pivotForest: (dossiers: Dossier[], language?: 'en' | 'fr') => MenuPathNode[];
@@ -0,0 +1,135 @@
1
+ import { sortBy } from 'lodash-es';
2
+ // Keep node-local pivots separate from user-defined group keys and Object.keys traversal.
3
+ const PIVOTS = Symbol('pivots');
4
+ /**
5
+ * Validates a pivot group path using the same rules as the backend service.
6
+ *
7
+ * Rules:
8
+ * - Allows alphabetic characters (including supported accented letters), digits, and '/'.
9
+ * - Rejects malformed paths: consecutive '/', leading '/', or trailing '/'.
10
+ *
11
+ * @param group Requested pivot group path.
12
+ * @returns null when valid, otherwise the i18n error key describing the failure.
13
+ */
14
+ export const pivotGroupValidation = (group) => {
15
+ if (!group || group === '')
16
+ return;
17
+ // Only contain, French, English, numeral character as well as /.
18
+ if (!/^[0-9A-Za-zùûüÿàâæçéèêëïîôœÙÛÜŸÀÂÆÇÉÈÊËÏÎÔŒ/]*$/.test(group)) {
19
+ return 'route.dossiers.pivots.invalid.character';
20
+ }
21
+ // Protection against wrongly formated /. We need words inbetween and they should not start or end with a /.
22
+ if (group.includes('//') || group.startsWith('/') || group.endsWith('/')) {
23
+ return 'route.dossiers.pivots.invalid.format';
24
+ }
25
+ };
26
+ const detachedPivotKeys = new WeakMap();
27
+ let nextDetachedPivotKey = 0;
28
+ /**
29
+ * Returns a stable menu key for a pivot within its dossier.
30
+ *
31
+ * Pivots currently attached to the dossier use their array index. Detached
32
+ * pivots receive an object-identity-based fallback key for the module lifetime.
33
+ *
34
+ * @param dossier Dossier containing the pivot, when it is attached.
35
+ * @param pivot Pivot for which to generate a key.
36
+ * @returns A dossier-scoped key suitable for identifying the pivot in the UI.
37
+ */
38
+ export const getDossierPivotKey = (dossier, pivot) => {
39
+ const pivotIndex = dossier.pivots?.indexOf(pivot) ?? -1;
40
+ if (pivotIndex >= 0) {
41
+ return `${dossier.dossier_id}:${pivotIndex}`;
42
+ }
43
+ let detachedPivotKey = detachedPivotKeys.get(pivot);
44
+ if (detachedPivotKey === undefined) {
45
+ detachedPivotKey = nextDetachedPivotKey++;
46
+ detachedPivotKeys.set(pivot, detachedPivotKey);
47
+ }
48
+ return `${dossier.dossier_id}:${detachedPivotKey}`;
49
+ };
50
+ /**
51
+ * Builds an intermediate tree keyed by each pivot group segment.
52
+ *
53
+ * Each slash-delimited segment in `pivot.group` creates or reuses a nested
54
+ * node, and pivots at that location are stored in an internal symbol-keyed array.
55
+ * This raw tree is later converted into `menuPathNode[]` for menu rendering.
56
+ *
57
+ * @param dossiers Dossiers whose pivots should be grouped.
58
+ * @returns A nested grouping tree where branch keys are path segments and
59
+ * each node may contain a symbol-keyed pivot array.
60
+ */
61
+ const getGroupPivot = (dossiers) => {
62
+ // no prototype: group segments are user-defined words (e.g. "constructor", "toString") and must not resolve to inherited Object.prototype keys
63
+ const groupPivot = Object.create(null);
64
+ dossiers.forEach(dossier => {
65
+ dossier.pivots?.forEach(pivot => {
66
+ let current = groupPivot;
67
+ // if we have a group we move the pointer to the proper location
68
+ if (pivot.group) {
69
+ pivot.group.split('/').forEach(groupSegment => {
70
+ if (!(groupSegment in current)) {
71
+ current[groupSegment] = Object.create(null);
72
+ }
73
+ current = current[groupSegment];
74
+ });
75
+ }
76
+ // Add the pivot to its location.
77
+ current[PIVOTS] ??= [];
78
+ current[PIVOTS].push({ pivot, dossier });
79
+ });
80
+ });
81
+ return groupPivot;
82
+ };
83
+ /**
84
+ * Converts the intermediate grouping tree into UI menu nodes.
85
+ *
86
+ * While traversing, non-branching chains with no local pivots are squashed
87
+ * into a single `path` label to avoid empty submenu levels in the UI.
88
+ *
89
+ * @param tree Intermediate tree from `getGroupPivot`.
90
+ * @param language Language used to sort pivot labels.
91
+ * @returns A menu node list ready for nested folder rendering.
92
+ */
93
+ const buildPathMap = (tree, language = 'en') => {
94
+ const nodes = [];
95
+ for (const key of Object.keys(tree).sort()) {
96
+ let path = key;
97
+ let current = tree[key];
98
+ // squash a chain as long as it neither branches nor carries pivots of its own; that's a pure "pass-through"
99
+ // segment, so its name is folded into the path instead of forcing its own empty menu level
100
+ while (Object.keys(current).length === 1 && !current[PIVOTS]) {
101
+ const newKey = Object.keys(current)[0];
102
+ path = path + `/${newKey}`;
103
+ current = current[newKey];
104
+ }
105
+ nodes.push({
106
+ path: path,
107
+ pivots: sortBy(current[PIVOTS] ?? [], item => item.pivot.label?.[language]),
108
+ children: buildPathMap(current, language)
109
+ });
110
+ }
111
+ return nodes;
112
+ };
113
+ /**
114
+ * Produces the final forest consumed by grouped pivot menus.
115
+ *
116
+ * Root-level pivots (no group path) are emitted as a node with an empty path,
117
+ * then grouped branches are appended from `buildPathMap`.
118
+ *
119
+ * @param dossiers Dossiers to convert into grouped pivot menu nodes.
120
+ * @param language Language used to sort labels inside each node.
121
+ * @returns Forest of menu nodes for hit-related pivot navigation.
122
+ */
123
+ export const pivotForest = (dossiers, language = 'en') => {
124
+ const group = getGroupPivot(dossiers);
125
+ const nodes = [];
126
+ if (group[PIVOTS]) {
127
+ nodes.push({
128
+ path: '',
129
+ pivots: sortBy(group[PIVOTS], item => item.pivot.label?.[language]),
130
+ children: []
131
+ });
132
+ }
133
+ nodes.push(...buildPathMap(group, language));
134
+ return nodes;
135
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { pivotForest, pivotGroupValidation } from './utils';
3
+ describe('pivotGroupValidation', () => {
4
+ it('returns null for an empty string', () => {
5
+ expect(pivotGroupValidation('')).toBeUndefined();
6
+ });
7
+ it('returns null for undefined/null-like input', () => {
8
+ expect(pivotGroupValidation(undefined)).toBeUndefined();
9
+ expect(pivotGroupValidation(null)).toBeUndefined();
10
+ });
11
+ it('returns null for a valid single-level group', () => {
12
+ expect(pivotGroupValidation('network')).toBeUndefined();
13
+ });
14
+ it('returns null for a valid multi-level group', () => {
15
+ expect(pivotGroupValidation('network/dns/query')).toBeUndefined();
16
+ });
17
+ it('accepts accented characters', () => {
18
+ expect(pivotGroupValidation('réseauÙÛÜŸÀÂÆÇÉÈÊËÏÎÔŒ')).toBeUndefined();
19
+ });
20
+ it('accepts digits but still rejects other disallowed characters', () => {
21
+ expect(pivotGroupValidation('0')).toBeUndefined();
22
+ expect(pivotGroupValidation('network1')).toBeUndefined();
23
+ expect(pivotGroupValidation('net2work')).toBeUndefined();
24
+ expect(pivotGroupValidation('network/zone2')).toBeUndefined();
25
+ expect(pivotGroupValidation('network-dns')).toBe('route.dossiers.pivots.invalid.character');
26
+ expect(pivotGroupValidation('network_dns')).toBe('route.dossiers.pivots.invalid.character');
27
+ });
28
+ it('rejects consecutive slashes', () => {
29
+ expect(pivotGroupValidation('network//dns')).toBe('route.dossiers.pivots.invalid.format');
30
+ });
31
+ it('rejects a leading slash', () => {
32
+ expect(pivotGroupValidation('/network')).toBe('route.dossiers.pivots.invalid.format');
33
+ });
34
+ it('rejects a trailing slash', () => {
35
+ expect(pivotGroupValidation('network/')).toBe('route.dossiers.pivots.invalid.format');
36
+ });
37
+ });
38
+ const makePivot = (overrides = {}) => ({
39
+ value: 'value',
40
+ ...overrides
41
+ });
42
+ const makeDossier = (pivots) => ({
43
+ pivots
44
+ });
45
+ describe('pivotForest', () => {
46
+ it('returns an empty forest when there are no dossiers', () => {
47
+ expect(pivotForest([])).toEqual([]);
48
+ });
49
+ it('skips dossiers without pivots', () => {
50
+ expect(pivotForest([makeDossier(undefined)])).toEqual([]);
51
+ });
52
+ it('returns ungrouped pivots as a single root node with an empty path', () => {
53
+ const pivot = makePivot({ value: 'ungrouped' });
54
+ const dossier = makeDossier([pivot]);
55
+ expect(pivotForest([dossier])).toEqual([{ path: '', pivots: [{ pivot, dossier }], children: [] }]);
56
+ });
57
+ it('treats an empty string group the same as no group', () => {
58
+ const pivot = makePivot({ group: '' });
59
+ const dossier = makeDossier([pivot]);
60
+ expect(pivotForest([dossier])).toEqual([{ path: '', pivots: [{ pivot, dossier }], children: [] }]);
61
+ });
62
+ it('sorts root pivots by their label in the requested language', () => {
63
+ const pivotZ = makePivot({ label: { en: 'Zulu', fr: 'Alpha' } });
64
+ const pivotA = makePivot({ label: { en: 'Alpha', fr: 'Zulu' } });
65
+ const dossier = makeDossier([pivotZ, pivotA]);
66
+ expect(pivotForest([dossier], 'en')).toEqual([
67
+ {
68
+ path: '',
69
+ pivots: [
70
+ { pivot: pivotA, dossier },
71
+ { pivot: pivotZ, dossier }
72
+ ],
73
+ children: []
74
+ }
75
+ ]);
76
+ });
77
+ it('returns a node for a single-level group', () => {
78
+ const pivot = makePivot({ group: 'network' });
79
+ const dossier = makeDossier([pivot]);
80
+ expect(pivotForest([dossier])).toEqual([{ path: 'network', pivots: [{ pivot, dossier }], children: [] }]);
81
+ });
82
+ it('collapses an unbranched multi-level group path into a single node', () => {
83
+ const pivot = makePivot({ group: 'network/dns/query' });
84
+ const dossier = makeDossier([pivot]);
85
+ expect(pivotForest([dossier])).toEqual([{ path: 'network/dns/query', pivots: [{ pivot, dossier }], children: [] }]);
86
+ });
87
+ it('collapses repeated segment names the same as any other unbranched chain', () => {
88
+ const pivot = makePivot({ group: 'test/test' });
89
+ const dossier = makeDossier([pivot]);
90
+ expect(pivotForest([dossier])).toEqual([{ path: 'test/test', pivots: [{ pivot, dossier }], children: [] }]);
91
+ });
92
+ it('keeps all pivots sharing the same group on the same node', () => {
93
+ const pivotA = makePivot({ value: 'a', group: 'network' });
94
+ const pivotB = makePivot({ value: 'b', group: 'network' });
95
+ const dossierA = makeDossier([pivotA]);
96
+ const dossierB = makeDossier([pivotB]);
97
+ expect(pivotForest([dossierA, dossierB])).toEqual([
98
+ {
99
+ path: 'network',
100
+ pivots: [
101
+ { pivot: pivotA, dossier: dossierA },
102
+ { pivot: pivotB, dossier: dossierB }
103
+ ],
104
+ children: []
105
+ }
106
+ ]);
107
+ });
108
+ it('keeps grouped and ungrouped pivots from the same dossier as separate nodes', () => {
109
+ const grouped = makePivot({ value: 'grouped', group: 'network' });
110
+ const ungrouped = makePivot({ value: 'ungrouped' });
111
+ const dossier = makeDossier([grouped, ungrouped]);
112
+ expect(pivotForest([dossier])).toEqual([
113
+ { path: '', pivots: [{ pivot: ungrouped, dossier }], children: [] },
114
+ { path: 'network', pivots: [{ pivot: grouped, dossier }], children: [] }
115
+ ]);
116
+ });
117
+ it('branches sibling groups into children', () => {
118
+ const dnsPivot = makePivot({ value: 'dns', group: 'network/dns' });
119
+ const httpPivot = makePivot({ value: 'http', group: 'network/http' });
120
+ const dossier = makeDossier([dnsPivot, httpPivot]);
121
+ expect(pivotForest([dossier])).toEqual([
122
+ {
123
+ path: 'network',
124
+ pivots: [],
125
+ children: [
126
+ { path: 'dns', pivots: [{ pivot: dnsPivot, dossier }], children: [] },
127
+ { path: 'http', pivots: [{ pivot: httpPivot, dossier }], children: [] }
128
+ ]
129
+ }
130
+ ]);
131
+ });
132
+ it('keeps a branch point as its own node but still collapses each unbranched chain below it', () => {
133
+ const v4Pivot = makePivot({ value: 'v4', group: 'network/ip/v4/local' });
134
+ const v6Pivot = makePivot({ value: 'v6', group: 'network/ip/v6/local' });
135
+ const dossier = makeDossier([v4Pivot, v6Pivot]);
136
+ expect(pivotForest([dossier])).toEqual([
137
+ {
138
+ path: 'network/ip',
139
+ pivots: [],
140
+ children: [
141
+ { path: 'v4/local', pivots: [{ pivot: v4Pivot, dossier }], children: [] },
142
+ { path: 'v6/local', pivots: [{ pivot: v6Pivot, dossier }], children: [] }
143
+ ]
144
+ }
145
+ ]);
146
+ });
147
+ it('returns a node for each distinct top-level group, without merging them together', () => {
148
+ const networkPivot = makePivot({ value: 'network', group: 'network' });
149
+ const systemPivot = makePivot({ value: 'system', group: 'system' });
150
+ const dossier = makeDossier([networkPivot, systemPivot]);
151
+ expect(pivotForest([dossier])).toEqual([
152
+ { path: 'network', pivots: [{ pivot: networkPivot, dossier }], children: [] },
153
+ { path: 'system', pivots: [{ pivot: systemPivot, dossier }], children: [] }
154
+ ]);
155
+ });
156
+ it('keeps a group own pivots alongside a nested child branch, without collapsing', () => {
157
+ const parentPivot = makePivot({ value: 'parent', group: 'network' });
158
+ const childPivot = makePivot({ value: 'child', group: 'network/dns' });
159
+ const dossier = makeDossier([parentPivot, childPivot]);
160
+ expect(pivotForest([dossier])).toEqual([
161
+ {
162
+ path: 'network',
163
+ pivots: [{ pivot: parentPivot, dossier }],
164
+ children: [{ path: 'dns', pivots: [{ pivot: childPivot, dossier }], children: [] }]
165
+ }
166
+ ]);
167
+ });
168
+ });
@@ -574,6 +574,7 @@
574
574
  "personalization.autohideappbar": "Auto Hide Topbar",
575
575
  "personalization.dark": "Dark Mode",
576
576
  "personalization.minimizebreadcrumbs": "Minimize Breadcrumbs",
577
+ "personalization.pivotGroup": "Group Pivot",
577
578
  "personalization.quicksearch": "Show Quick Search",
578
579
  "personalization.reset_text": "Reset to site defaults",
579
580
  "personalization.showbreadcrumbs": "Show Breadcrumbs",
@@ -756,6 +757,9 @@
756
757
  "route.dossiers.manager.validation.error.pivots.label.fr": "You have not configured a French pivot label.",
757
758
  "route.dossiers.manager.validation.error.pivots.value": "You have not set the value for the pivot '{{label}}'.",
758
759
  "route.dossiers.manager.validation.search": "You must search to validate your query before submitting.",
760
+ "route.dossiers.pivot.explanation": "Add a group value allowing this pivot to merge with other inside of the dossier buttons.",
761
+ "route.dossiers.pivots.invalid.character": "Group contain invalid character, only English, French and numeral splited with / are allowed",
762
+ "route.dossiers.pivots.invalid.format": "A group must consist of a relative path with no empty segments (e.g. a/b/c)",
759
763
  "route.dossiers.prompt": "Activate autocomplete using [ctrl + space].",
760
764
  "route.dossiers.search.prompt": "Search by title, query, or owner.",
761
765
  "route.dossiers.view": "View Dossier",
@@ -826,6 +830,7 @@
826
830
  "route.overviews.theme.dark": "Preview in Dark Mode",
827
831
  "route.overviews.theme.light": "Preview in Light Mode",
828
832
  "route.overviews.view": "View Overview",
833
+ "route.pivots.groups.label": "Pivot Group",
829
834
  "route.search": "Search",
830
835
  "route.templates": "Templates",
831
836
  "route.templates.analytic": "Choose Analytic",
@@ -574,6 +574,7 @@
574
574
  "personalization.autohideappbar": "Masquer la barre supérieure",
575
575
  "personalization.dark": "Mode Sombre",
576
576
  "personalization.minimizebreadcrumbs": "Minimiser fils d'ariane",
577
+ "personalization.pivotGroup": "Grouper les Pivots",
577
578
  "personalization.quicksearch": "Afficher recherche rapide",
578
579
  "personalization.reset_text": "Réinitialiser les paramètres",
579
580
  "personalization.showbreadcrumbs": "Afficher fils d'ariane",
@@ -756,6 +757,9 @@
756
757
  "route.dossiers.manager.validation.error.pivots.label.fr": "Vous n'avez pas configuré d'étiquette de pivot en français.",
757
758
  "route.dossiers.manager.validation.error.pivots.value": "Vous n'avez pas défini la valeur pour le pivot '{{label}}'.",
758
759
  "route.dossiers.manager.validation.search": "Vous devez effectuer une recherche pour valider votre requête avant de la soumettre.",
760
+ "route.dossiers.pivot.explanation": "Ajouter un group a ce pivot pour permettre de fusionner avec d'autre bouttons sur les hits et alerts.",
761
+ "route.dossiers.pivots.invalid.character": "Le group contient des charactère invalide. Seulement les character Français, Anglais et numérale séparée par / sont acceptées",
762
+ "route.dossiers.pivots.invalid.format": "Un groupe doit être constitué d'un chemin relatif ne comportant aucun segment vide (par exemple : a/b/c)",
759
763
  "route.dossiers.prompt": "Activez l'autocomplétion en utilisant [ctrl + espace].",
760
764
  "route.dossiers.search.prompt": "Recherche par titre, requête ou propriétaire.",
761
765
  "route.dossiers.view": "Voir le dossier",
@@ -826,6 +830,7 @@
826
830
  "route.overviews.theme.dark": "Prévoyez en mode sombre",
827
831
  "route.overviews.theme.light": "Prévoyez en mode clair",
828
832
  "route.overviews.view": "Voir la vue d'ensemble",
833
+ "route.pivots.groups.label": "Group du pivot",
829
834
  "route.search": "Rechercher",
830
835
  "route.templates": "Modèles",
831
836
  "route.templates.analytic": "Choisir une analyse",
@@ -10,4 +10,5 @@ export interface Pivot {
10
10
  label?: Label;
11
11
  mappings?: Mapping[];
12
12
  value?: string;
13
+ group?: string;
13
14
  }