@cccsaurora/howler-ui 3.1.0-dev.1610 → 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.
- package/api/dossier/groups.d.ts +2 -0
- package/api/dossier/groups.js +4 -0
- package/api/dossier/groups.test.d.ts +1 -0
- package/api/dossier/groups.test.js +26 -0
- package/api/{dossier.d.ts → dossier/index.d.ts} +2 -2
- package/api/{dossier.js → dossier/index.js} +2 -2
- package/components/elements/display/HowlerCard.d.ts +1 -1
- package/components/elements/display/HowlerCard.js +2 -2
- package/components/elements/hit/HitLinks.js +25 -21
- package/components/elements/hit/HitLinks.test.d.ts +1 -0
- package/components/elements/hit/HitLinks.test.js +118 -0
- package/components/elements/hit/related/RelatedIcon.js +2 -1
- package/components/elements/hit/related/RelatedIcon.test.d.ts +1 -0
- package/components/elements/hit/related/RelatedIcon.test.js +79 -0
- package/components/elements/hit/related/RelatedLink.d.ts +5 -0
- package/components/elements/hit/related/RelatedLink.js +23 -6
- package/components/elements/hit/related/RelatedLink.test.d.ts +1 -0
- package/components/elements/hit/related/RelatedLink.test.js +31 -0
- package/components/elements/hit/related/pivots/PivotFlyout.d.ts +25 -0
- package/components/elements/hit/related/pivots/PivotFlyout.js +26 -0
- package/components/elements/hit/related/pivots/PivotFlyoutContent.d.ts +4 -0
- package/components/elements/hit/related/pivots/PivotFlyoutContent.js +42 -0
- package/components/elements/hit/related/pivots/PivotFlyoutContent.test.d.ts +1 -0
- package/components/elements/hit/related/pivots/PivotFlyoutContent.test.js +41 -0
- package/components/elements/hit/related/pivots/PivotFolderMenu.d.ts +4 -0
- package/components/elements/hit/related/pivots/PivotFolderMenu.js +37 -0
- package/components/elements/hit/related/pivots/PivotFolderMenu.test.d.ts +1 -0
- package/components/elements/hit/related/pivots/PivotFolderMenu.test.js +55 -0
- package/components/elements/hit/related/{PivotLink.d.ts → pivots/PivotLink.d.ts} +9 -2
- package/components/elements/hit/related/pivots/PivotLink.js +53 -0
- package/components/elements/hit/related/pivots/PivotLink.test.d.ts +1 -0
- package/components/elements/hit/related/pivots/PivotLink.test.js +117 -0
- package/components/elements/hit/related/pivots/types.d.ts +29 -0
- package/components/elements/hit/related/pivots/utils.d.ts +12 -0
- package/components/elements/hit/related/pivots/utils.js +51 -0
- package/components/elements/hit/related/pivots/utils.test.d.ts +1 -0
- package/components/elements/hit/related/pivots/utils.test.js +30 -0
- package/components/hooks/useMyPreferences.d.ts +1 -1
- package/components/hooks/useMyPreferences.js +20 -2
- package/components/hooks/useMyPreferences.test.d.ts +1 -0
- package/components/hooks/useMyPreferences.test.js +40 -0
- package/components/routes/dossiers/DossierEditor.js +6 -0
- package/components/routes/dossiers/DossierEditor.test.js +30 -1
- package/components/routes/dossiers/PivotForm.js +41 -4
- package/components/routes/dossiers/PivotForm.test.d.ts +1 -0
- package/components/routes/dossiers/PivotForm.test.js +126 -0
- package/components/routes/dossiers/utils.d.ts +44 -0
- package/components/routes/dossiers/utils.js +135 -0
- package/components/routes/dossiers/utils.test.d.ts +1 -0
- package/components/routes/dossiers/utils.test.js +168 -0
- package/locales/en/translation.json +5 -0
- package/locales/fr/translation.json +5 -0
- package/models/entities/generated/Pivot.d.ts +1 -0
- package/package.json +4 -1
- package/plugins/HowlerPlugin.d.ts +1 -1
- package/plugins/clue/components/CluePivot.d.ts +1 -1
- package/plugins/clue/components/CluePivot.js +17 -1
- package/plugins/clue/components/CluePivot.test.d.ts +1 -0
- package/plugins/clue/components/CluePivot.test.js +94 -0
- package/utils/constants.d.ts +1 -0
- package/utils/constants.js +1 -0
- package/components/elements/hit/ResolvePivotUrl.d.ts +0 -4
- package/components/elements/hit/ResolvePivotUrl.js +0 -22
- package/components/elements/hit/related/PivotLink.js +0 -93
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Folder, KeyboardArrowDown } from '@mui/icons-material';
|
|
3
|
+
import { Button } from '@mui/material';
|
|
4
|
+
import { useId, useState } from 'react';
|
|
5
|
+
import PivotFlyout from './PivotFlyout';
|
|
6
|
+
import PivotFlyoutContent from './PivotFlyoutContent';
|
|
7
|
+
import { useHoverMenu } from './utils';
|
|
8
|
+
const PivotFolderMenu = ({ node, hit }) => {
|
|
9
|
+
const { isOpen, shouldFocusMenu, openMenu, cancelClose, scheduleClose, closeMenu } = useHoverMenu();
|
|
10
|
+
const menuId = useId();
|
|
11
|
+
const [rowElement, setRowElement] = useState(null);
|
|
12
|
+
const onKeyDown = (event) => {
|
|
13
|
+
if (event.key === 'Escape') {
|
|
14
|
+
closeMenu();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
|
18
|
+
event.preventDefault();
|
|
19
|
+
openMenu(true);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { ref: setRowElement, variant: "outlined", startIcon: _jsx(Folder, {}), endIcon: _jsx(KeyboardArrowDown, {}), "aria-haspopup": "menu", "aria-controls": isOpen ? menuId : undefined, "aria-expanded": isOpen, onClick: event => openMenu(event.detail === 0), onKeyDown: onKeyDown, onMouseEnter: () => openMenu(), onMouseLeave: () => scheduleClose(), sx: theme => ({
|
|
23
|
+
color: 'text.primary',
|
|
24
|
+
textTransform: 'none',
|
|
25
|
+
backgroundColor: 'transparent',
|
|
26
|
+
borderColor: 'divider',
|
|
27
|
+
transition: theme.transitions.create(['border-color', 'color']),
|
|
28
|
+
'&:hover': {
|
|
29
|
+
borderColor: 'primary.main',
|
|
30
|
+
color: 'primary.main'
|
|
31
|
+
}
|
|
32
|
+
}), children: node.path }), _jsx(PivotFlyout, { id: menuId, anchorEl: rowElement, focusOnOpen: shouldFocusMenu, open: isOpen, placement: "bottom-start", onClose: () => {
|
|
33
|
+
closeMenu();
|
|
34
|
+
rowElement?.focus();
|
|
35
|
+
}, onMouseEnter: cancelClose, onMouseLeave: () => scheduleClose(), children: _jsx(PivotFlyoutContent, { pivots: node.pivots ?? [], groups: node.children ?? [], hit: hit, onMenuEnter: cancelClose, onNavigate: closeMenu }) })] }));
|
|
36
|
+
};
|
|
37
|
+
export default PivotFolderMenu;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import PivotFolderMenu from './PivotFolderMenu';
|
|
5
|
+
vi.mock('@iconify/react', () => ({
|
|
6
|
+
Icon: () => _jsx("span", {})
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('components/elements/hit/related/pivots/PivotLink', () => ({
|
|
9
|
+
default: ({ pivot, variant }) => pivot.format === 'link' ? (_jsx("a", { href: `https://example.test/${pivot.value}`, role: variant === 'menu-item' ? 'menuitem' : undefined, children: pivot.label?.en })) : (_jsx("button", { type: "button", role: variant === 'menu-item' ? 'menuitem' : undefined, onClick: event => event.stopPropagation(), children: pivot.label?.en }))
|
|
10
|
+
}));
|
|
11
|
+
const dossier = { dossier_id: 'dossier-1' };
|
|
12
|
+
const mainPivot = { format: 'link', value: 'main', label: { en: 'Main', fr: 'Principal' } };
|
|
13
|
+
const nestedPivot = { format: 'link', value: 'nested', label: { en: 'Nested', fr: 'Imbrique' } };
|
|
14
|
+
const pluginPivot = { format: 'clue', value: 'plugin', label: { en: 'Plugin', fr: 'Extension' } };
|
|
15
|
+
const node = {
|
|
16
|
+
path: 'example',
|
|
17
|
+
pivots: [
|
|
18
|
+
{ pivot: mainPivot, dossier },
|
|
19
|
+
{ pivot: nestedPivot, dossier }
|
|
20
|
+
],
|
|
21
|
+
children: []
|
|
22
|
+
};
|
|
23
|
+
describe('PivotFolderMenu', () => {
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
it('opens a grouped pivot menu with the keyboard', async () => {
|
|
28
|
+
const user = userEvent.setup();
|
|
29
|
+
render(_jsx(PivotFolderMenu, { node: node }));
|
|
30
|
+
const trigger = screen.getByRole('button', { name: /example/i });
|
|
31
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
32
|
+
await user.tab();
|
|
33
|
+
expect(trigger).toHaveFocus();
|
|
34
|
+
await user.keyboard('{Enter}');
|
|
35
|
+
const link = await screen.findByRole('menuitem', { name: 'Nested' });
|
|
36
|
+
expect(link).toHaveAttribute('href', 'https://example.test/nested');
|
|
37
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
38
|
+
await user.keyboard('{Escape}');
|
|
39
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
40
|
+
expect(trigger).toHaveFocus();
|
|
41
|
+
});
|
|
42
|
+
it('leaves plugin pivot activation under the plugin control', async () => {
|
|
43
|
+
const user = userEvent.setup();
|
|
44
|
+
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
|
45
|
+
const pluginNode = {
|
|
46
|
+
...node,
|
|
47
|
+
pivots: [node.pivots[0], { pivot: pluginPivot, dossier }]
|
|
48
|
+
};
|
|
49
|
+
render(_jsx(PivotFolderMenu, { node: pluginNode }));
|
|
50
|
+
await user.click(screen.getByRole('button', { name: /example/i }));
|
|
51
|
+
await user.click(await screen.findByRole('menuitem', { name: 'Plugin' }));
|
|
52
|
+
expect(open).not.toHaveBeenCalled();
|
|
53
|
+
expect(screen.getByRole('menuitem', { name: 'Plugin' })).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -3,11 +3,18 @@ import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
|
3
3
|
import type { Pivot } from '@cccsaurora/howler-ui/models/entities/generated/Pivot';
|
|
4
4
|
import { type FC } from 'react';
|
|
5
5
|
export interface PivotLinkProps {
|
|
6
|
+
/** Pivot definition used to build the link or plugin rendering. */
|
|
6
7
|
pivot: Pivot;
|
|
7
|
-
|
|
8
|
+
/** Hit whose data may be used to resolve a dynamic pivot URL. */
|
|
9
|
+
hit?: Hit;
|
|
10
|
+
/** Uses the compact related-link presentation. */
|
|
8
11
|
compact?: boolean;
|
|
12
|
+
/** Dossier associated with the pivot. */
|
|
9
13
|
dossier: Dossier;
|
|
10
|
-
|
|
14
|
+
/** Presentation used for the pivot: a card or a menu item. */
|
|
15
|
+
variant?: 'card' | 'menu-item';
|
|
16
|
+
/** Called after navigation is requested. */
|
|
17
|
+
onNavigate?: () => void;
|
|
11
18
|
}
|
|
12
19
|
declare const PivotLink: FC<PivotLinkProps>;
|
|
13
20
|
export default PivotLink;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ErrorOutline } from '@mui/icons-material';
|
|
3
|
+
import { Box, Tooltip, Typography } from '@mui/material';
|
|
4
|
+
import { useHelpers } from '@cccsaurora/howler-ui/components/elements/display/handlebars/helpers';
|
|
5
|
+
import HowlerCard from '@cccsaurora/howler-ui/components/elements/display/HowlerCard';
|
|
6
|
+
import PivotTooltip from '@cccsaurora/howler-ui/components/elements/hit/PivotTooltip';
|
|
7
|
+
import { resolvePivotUrl } from '@cccsaurora/howler-ui/components/elements/hit/related/pivots/utils';
|
|
8
|
+
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
9
|
+
import React, { useMemo } from 'react';
|
|
10
|
+
import { useTranslation } from 'react-i18next';
|
|
11
|
+
import { usePluginStore } from 'react-pluggable';
|
|
12
|
+
import RelatedLink from '../RelatedLink';
|
|
13
|
+
const PivotLink = ({ pivot, hit, compact = false, dossier, variant = 'card', onNavigate }) => {
|
|
14
|
+
const { i18n } = useTranslation();
|
|
15
|
+
const helpers = useHelpers({ async: false, components: false });
|
|
16
|
+
const pluginStore = usePluginStore();
|
|
17
|
+
const resolvedUrl = useMemo(() => pivot.format === 'link'
|
|
18
|
+
? resolvePivotUrl(pivot, hit, helpers) || `/dossier/${dossier.dossier_id}`
|
|
19
|
+
: `/dossier/${dossier.dossier_id}`, [dossier.dossier_id, helpers, hit, pivot]);
|
|
20
|
+
if (pivot.format === 'link') {
|
|
21
|
+
return (_jsx(RelatedLink, { title: pivot.label?.[i18n.language] ?? pivot.value ?? '', href: resolvedUrl, icon: pivot.icon, target: "_blank", rel: "noopener noreferrer", compact: compact, dense: variant === 'menu-item', secondary: variant === 'menu-item' ? (_jsxs(_Fragment, { children: [_jsx(Typography, { variant: "caption", display: "block", color: "text.secondary", noWrap: true, children: [dossier.title, dossier.owner].filter(Boolean).join(' • ') }), _jsx(Typography, { variant: "caption", display: "block", color: "text.secondary", noWrap: true, sx: { maxWidth: 260 }, children: resolvedUrl })] })) : undefined, tooltip: variant === 'menu-item' ? undefined : _jsx(PivotTooltip, { dossier: dossier, resolvedUrl: resolvedUrl }), menuItem: variant === 'menu-item', onNavigate: onNavigate }));
|
|
22
|
+
}
|
|
23
|
+
const pluginPivot = howlerPluginStore.pivotFormats.includes(pivot.format ?? '')
|
|
24
|
+
? pluginStore.executeFunction(`pivot.${pivot.format}`, { pivot, hit, compact, variant, onNavigate })
|
|
25
|
+
: null;
|
|
26
|
+
if (pluginPivot) {
|
|
27
|
+
if (variant === 'menu-item') {
|
|
28
|
+
return pluginPivot;
|
|
29
|
+
}
|
|
30
|
+
return (_jsx(Tooltip, { title: _jsx(PivotTooltip, { dossier: dossier, resolvedUrl: resolvedUrl }), children: _jsx(Box, { component: "span", sx: { display: 'inline-flex' }, children: pluginPivot }) }));
|
|
31
|
+
}
|
|
32
|
+
if (variant === 'menu-item') {
|
|
33
|
+
return (_jsxs(Box, { role: "menuitem", "aria-disabled": "true", tabIndex: -1, sx: { display: 'flex', alignItems: 'center', gap: 1, px: 1, py: 0.5 }, children: [_jsx(ErrorOutline, { color: "error", fontSize: "small" }), _jsx(Typography, { noWrap: true, children: `Missing Pivot Implementation ${pivot.format}` })] }));
|
|
34
|
+
}
|
|
35
|
+
return (_jsx(HowlerCard, { sx: [
|
|
36
|
+
theme => ({
|
|
37
|
+
p: 0.75,
|
|
38
|
+
backgroundColor: 'transparent',
|
|
39
|
+
transition: theme.transitions.create(['border-color']),
|
|
40
|
+
'&:hover': { borderColor: 'error.main' }
|
|
41
|
+
}),
|
|
42
|
+
{ border: 'thin solid', borderColor: 'transparent' }
|
|
43
|
+
], children: _jsx(Tooltip, { title: _jsxs(_Fragment, { children: [_jsx("span", { children: `Missing Pivot Implementation ${pivot.format}` }), _jsx("code", { children: _jsx("pre", { children: JSON.stringify(pivot, null, 4) }) })] }), slotProps: {
|
|
44
|
+
popper: {
|
|
45
|
+
sx: {
|
|
46
|
+
'& > .MuiTooltip-tooltip': {
|
|
47
|
+
maxWidth: '90vw !important'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}, children: _jsx(ErrorOutline, { color: "error" }) }) }));
|
|
52
|
+
};
|
|
53
|
+
export default PivotLink;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { MemoryRouter } from 'react-router';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import PivotLink from './PivotLink';
|
|
6
|
+
const executeFunction = vi.hoisted(() => vi.fn());
|
|
7
|
+
vi.mock('@iconify/react', () => ({
|
|
8
|
+
Icon: ({ icon }) => _jsx("span", { id: "icon", "data-icon": icon })
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('components/elements/display/handlebars/helpers', () => ({
|
|
11
|
+
useHelpers: () => [
|
|
12
|
+
{
|
|
13
|
+
keyword: 'upper',
|
|
14
|
+
callback: (...args) => String(args[0]).toUpperCase()
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('components/elements/display/HowlerCard', () => ({
|
|
19
|
+
default: ({ children }) => _jsx("div", { id: "howler-card", children: children })
|
|
20
|
+
}));
|
|
21
|
+
vi.mock('components/elements/hit/PivotTooltip', () => ({
|
|
22
|
+
default: () => _jsx("span", { "data-testid": "pivot-tooltip" })
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('components/elements/hit/related/RelatedLink', () => ({
|
|
25
|
+
default: ({ title, href, target, rel, dense, secondary, action }) => (_jsxs("div", { id: "related-link", "data-dense": String(dense), children: [_jsx("a", { href: href, target: target, rel: rel, children: title }), secondary, action] }))
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('react-i18next', () => ({
|
|
28
|
+
useTranslation: () => ({
|
|
29
|
+
i18n: { language: 'en' },
|
|
30
|
+
t: (key) => key
|
|
31
|
+
})
|
|
32
|
+
}));
|
|
33
|
+
vi.mock('react-pluggable', () => ({
|
|
34
|
+
usePluginStore: () => ({ executeFunction })
|
|
35
|
+
}));
|
|
36
|
+
vi.mock('plugins/store', () => ({
|
|
37
|
+
default: { pivotFormats: ['clue'] }
|
|
38
|
+
}));
|
|
39
|
+
const hit = {
|
|
40
|
+
__index: 'hit',
|
|
41
|
+
timestamp: '2026-01-01T00:00:00Z',
|
|
42
|
+
howler: {},
|
|
43
|
+
event: { domain: ['example.test'] },
|
|
44
|
+
user: { name: 'alice' }
|
|
45
|
+
};
|
|
46
|
+
const dossier = {
|
|
47
|
+
dossier_id: 'dossier-1',
|
|
48
|
+
title: 'Example dossier',
|
|
49
|
+
owner: 'analyst',
|
|
50
|
+
query: 'foo bar'
|
|
51
|
+
};
|
|
52
|
+
const linkPivot = {
|
|
53
|
+
format: 'link',
|
|
54
|
+
label: { en: 'Open result' },
|
|
55
|
+
value: 'https://example.test/{{domain}}/{{token}}/{{upper user}}',
|
|
56
|
+
mappings: [
|
|
57
|
+
{ key: 'domain', field: 'event.domain' },
|
|
58
|
+
{ key: 'token', field: 'custom', custom_value: 'fixed-token' },
|
|
59
|
+
{ key: 'user', field: 'user.name' }
|
|
60
|
+
]
|
|
61
|
+
};
|
|
62
|
+
const renderPivotLink = (pivot, options = {}) => render(_jsx(MemoryRouter, { children: _jsx(PivotLink, { pivot: pivot, hit: hit, dossier: dossier, ...options }) }));
|
|
63
|
+
describe('PivotLink', () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
executeFunction.mockReset();
|
|
66
|
+
executeFunction.mockReturnValue(null);
|
|
67
|
+
});
|
|
68
|
+
it('resolves mapped hit values, custom values, and helpers in a link pivot', () => {
|
|
69
|
+
renderPivotLink(linkPivot, { compact: true });
|
|
70
|
+
const link = screen.getByRole('link', { name: 'Open result' });
|
|
71
|
+
expect(link).toHaveAttribute('href', 'https://example.test/example.test/fixed-token/ALICE');
|
|
72
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
73
|
+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
|
74
|
+
expect(screen.getByTestId('related-link')).toHaveAttribute('data-dense', 'false');
|
|
75
|
+
expect(executeFunction).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
it('renders dossier details for menu-item link pivots', () => {
|
|
78
|
+
renderPivotLink(linkPivot, { variant: 'menu-item' });
|
|
79
|
+
expect(screen.getByTestId('related-link')).toHaveAttribute('data-dense', 'true');
|
|
80
|
+
expect(screen.getByText('Example dossier • analyst')).toBeInTheDocument();
|
|
81
|
+
expect(screen.getByText('https://example.test/example.test/fixed-token/ALICE')).toBeInTheDocument();
|
|
82
|
+
expect(screen.queryByRole('link', { name: 'pivot.dossier.open' })).not.toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
it('falls back to the dossier when a link pivot cannot resolve without a hit', () => {
|
|
85
|
+
const pivot = {
|
|
86
|
+
format: 'link',
|
|
87
|
+
label: { en: 'Open dossier' },
|
|
88
|
+
value: '{{domain}}',
|
|
89
|
+
mappings: [{ key: 'domain', field: 'event.domain' }]
|
|
90
|
+
};
|
|
91
|
+
render(_jsx(MemoryRouter, { children: _jsx(PivotLink, { pivot: pivot, dossier: dossier }) }));
|
|
92
|
+
expect(screen.getByRole('link', { name: 'Open dossier' })).toHaveAttribute('href', '/dossier/dossier-1');
|
|
93
|
+
});
|
|
94
|
+
it('renders a plugin-provided pivot implementation', () => {
|
|
95
|
+
const pluginPivot = _jsx("button", { type: "button", children: "Plugin pivot" });
|
|
96
|
+
executeFunction.mockReturnValue(pluginPivot);
|
|
97
|
+
const pivot = { format: 'clue', value: 'plugin-value' };
|
|
98
|
+
renderPivotLink(pivot, { compact: true });
|
|
99
|
+
expect(screen.getByRole('button', { name: 'Plugin pivot' })).toBeInTheDocument();
|
|
100
|
+
expect(executeFunction).toHaveBeenCalledWith('pivot.clue', {
|
|
101
|
+
pivot,
|
|
102
|
+
hit,
|
|
103
|
+
compact: true,
|
|
104
|
+
variant: 'card',
|
|
105
|
+
onNavigate: undefined
|
|
106
|
+
});
|
|
107
|
+
expect(screen.queryByTestId('related-link')).not.toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
it('renders an error indicator when no pivot implementation is available', () => {
|
|
110
|
+
const pivot = { format: 'unsupported', value: 'unsupported-value' };
|
|
111
|
+
renderPivotLink(pivot);
|
|
112
|
+
expect(screen.getByTestId('howler-card')).toBeInTheDocument();
|
|
113
|
+
expect(document.querySelector('[data-testid="ErrorOutlineIcon"]')).toBeInTheDocument();
|
|
114
|
+
expect(screen.queryByTestId('related-link')).not.toBeInTheDocument();
|
|
115
|
+
expect(executeFunction).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DossierPivot, MenuPathNode } from 'components/routes/dossiers/utils';
|
|
2
|
+
import type { Hit } from 'models/entities/generated/Hit';
|
|
3
|
+
|
|
4
|
+
/** Shared props for pivot navigation components. */
|
|
5
|
+
export interface PivotSharedProps {
|
|
6
|
+
/** Hit whose data may be used by pivot links. */
|
|
7
|
+
hit?: Hit;
|
|
8
|
+
|
|
9
|
+
/** Keeps a parent menu open while the pointer is inside it. */
|
|
10
|
+
onMenuEnter?: () => void;
|
|
11
|
+
|
|
12
|
+
/** Called when navigating to a pivot. */
|
|
13
|
+
onNavigate?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Props for the root pivot folder menu. */
|
|
17
|
+
export interface PivotFolderMenuProps extends PivotSharedProps {
|
|
18
|
+
/** Folder node represented by the menu. */
|
|
19
|
+
node: MenuPathNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Props for the contents of a pivot flyout menu. */
|
|
23
|
+
export interface PivotFlyoutContentProps extends PivotSharedProps {
|
|
24
|
+
/** Pivots rendered at the current menu level. */
|
|
25
|
+
pivots: DossierPivot[];
|
|
26
|
+
|
|
27
|
+
/** Child folder groups rendered as nested menus. */
|
|
28
|
+
groups: MenuPathNode[];
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { HowlerHelper } from '@cccsaurora/howler-ui/components/elements/display/handlebars/helpers';
|
|
2
|
+
import type { Dossier } from '@cccsaurora/howler-ui/models/entities/generated/Dossier';
|
|
3
|
+
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
4
|
+
export declare const useHoverMenu: () => {
|
|
5
|
+
isOpen: boolean;
|
|
6
|
+
shouldFocusMenu: boolean;
|
|
7
|
+
openMenu: (focusMenu?: boolean) => void;
|
|
8
|
+
cancelClose: () => void;
|
|
9
|
+
scheduleClose: (delay?: number) => void;
|
|
10
|
+
closeMenu: () => void;
|
|
11
|
+
};
|
|
12
|
+
export declare const resolvePivotUrl: (pivot: NonNullable<Dossier['pivots']>[number], currentHit?: Hit, helpers?: HowlerHelper[]) => string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import Handlebars from 'handlebars';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { flattenDeep } from '@cccsaurora/howler-ui/utils/utils';
|
|
4
|
+
const CLOSE_DELAY = 200;
|
|
5
|
+
export const useHoverMenu = () => {
|
|
6
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
7
|
+
const [shouldFocusMenu, setShouldFocusMenu] = useState(false);
|
|
8
|
+
const closeTimer = useRef(undefined);
|
|
9
|
+
useEffect(() => () => clearTimeout(closeTimer.current), []);
|
|
10
|
+
const openMenu = useCallback((focusMenu = false) => {
|
|
11
|
+
clearTimeout(closeTimer.current);
|
|
12
|
+
setShouldFocusMenu(focusMenu);
|
|
13
|
+
setIsOpen(true);
|
|
14
|
+
}, []);
|
|
15
|
+
const cancelClose = useCallback(() => clearTimeout(closeTimer.current), []);
|
|
16
|
+
const scheduleClose = useCallback((delay = CLOSE_DELAY) => {
|
|
17
|
+
clearTimeout(closeTimer.current);
|
|
18
|
+
closeTimer.current = setTimeout(() => setIsOpen(false), delay);
|
|
19
|
+
}, []);
|
|
20
|
+
const closeMenu = useCallback(() => {
|
|
21
|
+
clearTimeout(closeTimer.current);
|
|
22
|
+
setShouldFocusMenu(false);
|
|
23
|
+
setIsOpen(false);
|
|
24
|
+
}, []);
|
|
25
|
+
return { isOpen, shouldFocusMenu, openMenu, cancelClose, scheduleClose, closeMenu };
|
|
26
|
+
};
|
|
27
|
+
export const resolvePivotUrl = (pivot, currentHit, helpers = []) => {
|
|
28
|
+
const flatHit = flattenDeep(currentHit ?? {});
|
|
29
|
+
const handlebars = Handlebars.create();
|
|
30
|
+
helpers.forEach(helper => {
|
|
31
|
+
if (helper.callback && !handlebars.helpers[helper.keyword]) {
|
|
32
|
+
handlebars.registerHelper(helper.keyword, helper.callback);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
const templateObject = Object.fromEntries((pivot.mappings ?? []).map(mapping => {
|
|
36
|
+
const value = mapping.field === 'custom'
|
|
37
|
+
? mapping.custom_value
|
|
38
|
+
: Array.isArray(flatHit[mapping.field])
|
|
39
|
+
? flatHit[mapping.field][0]
|
|
40
|
+
: flatHit[mapping.field];
|
|
41
|
+
return [mapping.key, value];
|
|
42
|
+
}));
|
|
43
|
+
try {
|
|
44
|
+
return handlebars.compile(pivot.value)(templateObject);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
// eslint-disable-next-line no-console
|
|
48
|
+
console.error(`Failed to compile pivot template for value "${pivot.value}":`, e);
|
|
49
|
+
return pivot.value;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { resolvePivotUrl } from './utils';
|
|
3
|
+
const hit = {
|
|
4
|
+
event: { domains: ['first.example', 'second.example'] },
|
|
5
|
+
user: { name: 'alice' }
|
|
6
|
+
};
|
|
7
|
+
describe('resolvePivotUrl', () => {
|
|
8
|
+
it('resolves custom, hit, array, and helper mappings', () => {
|
|
9
|
+
const helper = {
|
|
10
|
+
keyword: 'upper',
|
|
11
|
+
callback: (value) => value.toUpperCase()
|
|
12
|
+
};
|
|
13
|
+
const pivot = {
|
|
14
|
+
value: '{{domain}}/{{custom}}/{{upper user}}',
|
|
15
|
+
mappings: [
|
|
16
|
+
{ key: 'domain', field: 'event.domains' },
|
|
17
|
+
{ key: 'custom', field: 'custom', custom_value: 'fixed' },
|
|
18
|
+
{ key: 'user', field: 'user.name' }
|
|
19
|
+
]
|
|
20
|
+
};
|
|
21
|
+
expect(resolvePivotUrl(pivot, hit, [helper])).toBe('first.example/fixed/ALICE');
|
|
22
|
+
});
|
|
23
|
+
it('returns the original template when handlebars compilation fails', () => {
|
|
24
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
25
|
+
const pivot = { value: '{{#if', mappings: [] };
|
|
26
|
+
expect(resolvePivotUrl(pivot)).toBe('{{#if');
|
|
27
|
+
expect(error).toHaveBeenCalledOnce();
|
|
28
|
+
error.mockRestore();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -1,14 +1,28 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Api, Article, Book, BookRounded, Code, Dashboard, Description, ExitToApp, FormatListBulleted, Help, HelpCenter, Key, ManageSearch, QueryStats, SavedSearch, Search, Settings, SettingsSuggest, Shield, Storage, SupervisorAccount, Terminal, Topic } from '@mui/icons-material';
|
|
3
|
-
import { Divider, Stack } from '@mui/material';
|
|
3
|
+
import { Divider, List, ListItem, ListItemButton, ListItemText, Stack, Switch } from '@mui/material';
|
|
4
|
+
import { useCookiesStore } from '@tui/core';
|
|
4
5
|
import { AppBarContext } from '@cccsaurora/howler-ui/components/app/providers/AppBarProvider';
|
|
5
6
|
import Classification from '@cccsaurora/howler-ui/components/elements/display/Classification';
|
|
6
7
|
import DocumentationButton from '@cccsaurora/howler-ui/components/elements/display/DocumentationButton';
|
|
7
8
|
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
8
9
|
import { Fragment, useContext, useMemo } from 'react';
|
|
10
|
+
import { useTranslation } from 'react-i18next';
|
|
11
|
+
import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
9
12
|
import { applyMainMenuOperations } from '@cccsaurora/howler-ui/utils/menuUtils';
|
|
13
|
+
import { useMyLocalStorageItem } from './useMyLocalStorage';
|
|
10
14
|
// This is your App Name that will be displayed in the left drawer and the top navbar
|
|
11
15
|
const APP_NAME = 'howler';
|
|
16
|
+
const PersonalizationMenuItems = () => {
|
|
17
|
+
const { t } = useTranslation();
|
|
18
|
+
const resetCookies = useCookiesStore(store => store.reset);
|
|
19
|
+
const [pivotGroupEnabled, setPivotGroupEnabled, resetPivotGroup] = useMyLocalStorageItem(StorageKey.PIVOT_GROUP, true);
|
|
20
|
+
const resetPreferences = () => {
|
|
21
|
+
resetCookies();
|
|
22
|
+
resetPivotGroup();
|
|
23
|
+
};
|
|
24
|
+
return (_jsxs(_Fragment, { children: [_jsx(List, { dense: true, children: _jsx(ListItem, { disablePadding: true, secondaryAction: _jsx(Switch, { checked: pivotGroupEnabled, edge: "end", onChange: () => setPivotGroupEnabled(!pivotGroupEnabled), onClick: event => event.stopPropagation() }), children: _jsx(ListItemButton, { id: "personalization-pivot-group", onClick: () => setPivotGroupEnabled(!pivotGroupEnabled), children: _jsx(ListItemText, { children: t('personalization.pivotGroup') }) }) }) }), _jsx(Divider, {}), _jsx(List, { dense: true, children: _jsx(ListItemButton, { dense: true, id: "personalization-reset", onClick: resetPreferences, children: _jsx(ListItemText, { children: t('personalization.reset_text') }) }) })] }));
|
|
25
|
+
};
|
|
12
26
|
const useMyPreferences = () => {
|
|
13
27
|
const { leftItems, rightItems } = useContext(AppBarContext);
|
|
14
28
|
// The following menu items will show up in the Left Navigation Drawer
|
|
@@ -227,6 +241,7 @@ const useMyPreferences = () => {
|
|
|
227
241
|
}
|
|
228
242
|
},
|
|
229
243
|
appLink: '/',
|
|
244
|
+
allowReset: false,
|
|
230
245
|
allowThemeSelection: true,
|
|
231
246
|
topnav: {
|
|
232
247
|
quickSearchParam: 'query',
|
|
@@ -235,6 +250,9 @@ const useMyPreferences = () => {
|
|
|
235
250
|
menus: {
|
|
236
251
|
user: { i18nKey: 'usermenu', slot: USER_MENU_ITEMS },
|
|
237
252
|
admin: { i18nKey: 'adminmenu', slot: ADMIN_MENU_ITEMS }
|
|
253
|
+
},
|
|
254
|
+
slots: {
|
|
255
|
+
bottom: [_jsx(PersonalizationMenuItems, {}, "personalization-menu-items")]
|
|
238
256
|
}
|
|
239
257
|
},
|
|
240
258
|
slots: {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { AppBarContext } from '@cccsaurora/howler-ui/components/app/providers/AppBarProvider';
|
|
5
|
+
import { setupLocalStorageMock } from '@cccsaurora/howler-ui/tests/mocks';
|
|
6
|
+
import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
7
|
+
import { vi } from 'vitest';
|
|
8
|
+
import useMyPreferences from './useMyPreferences';
|
|
9
|
+
const resetCookies = vi.hoisted(() => vi.fn());
|
|
10
|
+
vi.mock('@tui/core', async () => {
|
|
11
|
+
const actual = await vi.importActual('@tui/core');
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
useCookiesStore: (selector) => selector({ reset: resetCookies })
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
vi.mock('react-i18next', () => ({
|
|
18
|
+
useTranslation: () => ({ t: (key) => key })
|
|
19
|
+
}));
|
|
20
|
+
const localStorage = setupLocalStorageMock();
|
|
21
|
+
const PersonalizationMenu = () => {
|
|
22
|
+
const preferences = useMyPreferences();
|
|
23
|
+
return _jsx(_Fragment, { children: preferences.topnav.profile.slots.bottom });
|
|
24
|
+
};
|
|
25
|
+
describe('PersonalizationMenuItems', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
localStorage.clear();
|
|
28
|
+
resetCookies.mockReset();
|
|
29
|
+
});
|
|
30
|
+
it('resets the pivot-grouping preference with the other personalization settings', async () => {
|
|
31
|
+
const user = userEvent.setup();
|
|
32
|
+
render(_jsx(AppBarContext.Provider, { value: { leftItems: [], rightItems: [], addToAppBar: vi.fn(), removeFromAppBar: vi.fn() }, children: _jsx(PersonalizationMenu, {}) }));
|
|
33
|
+
await user.click(screen.getByRole('switch'));
|
|
34
|
+
expect(localStorage.getItem(`howler.ui.${StorageKey.PIVOT_GROUP}`)).toBe('false');
|
|
35
|
+
await user.click(screen.getByTestId('personalization-reset'));
|
|
36
|
+
expect(resetCookies).toHaveBeenCalledOnce();
|
|
37
|
+
expect(localStorage.getItem(`howler.ui.${StorageKey.PIVOT_GROUP}`)).toBeNull();
|
|
38
|
+
expect(screen.getByRole('switch')).toBeChecked();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -17,6 +17,7 @@ import QueryResultText from '../../elements/display/QueryResultText';
|
|
|
17
17
|
import RecordQuery from '../hits/search/RecordQuery';
|
|
18
18
|
import LeadForm from './LeadForm';
|
|
19
19
|
import PivotForm from './PivotForm';
|
|
20
|
+
import { pivotGroupValidation } from './utils';
|
|
20
21
|
const DossierEditor = () => {
|
|
21
22
|
const { t, i18n } = useTranslation();
|
|
22
23
|
const params = useParams();
|
|
@@ -91,6 +92,11 @@ const DossierEditor = () => {
|
|
|
91
92
|
}
|
|
92
93
|
}
|
|
93
94
|
for (const pivot of dossier.pivots ?? []) {
|
|
95
|
+
const groupErr = pivotGroupValidation(pivot.group ?? '');
|
|
96
|
+
if (groupErr) {
|
|
97
|
+
// There's an issue with the group you've chosen.
|
|
98
|
+
return t(groupErr);
|
|
99
|
+
}
|
|
94
100
|
if (!pivot.label) {
|
|
95
101
|
// You have not configured a pivot label.
|
|
96
102
|
return t('route.dossiers.manager.validation.error.pivots.label');
|
|
@@ -22,7 +22,8 @@ vi.mock('api', () => ({
|
|
|
22
22
|
dossier: {
|
|
23
23
|
get: (...args) => mockApiDossierGet(...args),
|
|
24
24
|
post: (...args) => mockApiDossierPost(...args),
|
|
25
|
-
put: (...args) => mockApiDossierPut(...args)
|
|
25
|
+
put: (...args) => mockApiDossierPut(...args),
|
|
26
|
+
groups: { get: vi.fn() }
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
}));
|
|
@@ -264,6 +265,34 @@ describe('DossierEditor', () => {
|
|
|
264
265
|
expect(screen.getByTestId('pivot-form')).toBeInTheDocument();
|
|
265
266
|
});
|
|
266
267
|
});
|
|
268
|
+
it('removes the group property when clearing a pivot group', async () => {
|
|
269
|
+
const user = userEvent.setup();
|
|
270
|
+
mockUseParams.mockReturnValue({ id: 'test-dossier-1' });
|
|
271
|
+
mockApiDossierGet.mockResolvedValueOnce({
|
|
272
|
+
...mockDossier,
|
|
273
|
+
pivots: [{ ...mockDossier.pivots[0], group: 'network' }]
|
|
274
|
+
});
|
|
275
|
+
render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
|
|
276
|
+
await waitFor(() => {
|
|
277
|
+
expect(screen.getByTestId('dossier-title')).toHaveValue('Test Dossier');
|
|
278
|
+
});
|
|
279
|
+
await user.click(screen.getByRole('tab', { name: /pivots/i }));
|
|
280
|
+
await waitFor(() => {
|
|
281
|
+
expect(screen.getByTestId('pivot-form')).toBeInTheDocument();
|
|
282
|
+
});
|
|
283
|
+
const [groupInput] = screen.getAllByRole('combobox');
|
|
284
|
+
expect(groupInput).toHaveValue('network');
|
|
285
|
+
await user.clear(groupInput);
|
|
286
|
+
await waitFor(() => {
|
|
287
|
+
expect(screen.getByRole('button', { name: /save/i })).not.toBeDisabled();
|
|
288
|
+
});
|
|
289
|
+
await user.click(screen.getByRole('button', { name: /save/i }));
|
|
290
|
+
await waitFor(() => {
|
|
291
|
+
expect(mockApiDossierPut).toHaveBeenCalledOnce();
|
|
292
|
+
});
|
|
293
|
+
const savedDossier = mockApiDossierPut.mock.calls[0][1];
|
|
294
|
+
expect(savedDossier.pivots[0]).not.toHaveProperty('group');
|
|
295
|
+
});
|
|
267
296
|
});
|
|
268
297
|
describe('Validation', () => {
|
|
269
298
|
it('should disable save button when title is missing', async () => {
|