@cccsaurora/howler-ui 2.18.0-dev.688 → 2.18.0-dev.699

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.
@@ -2,4 +2,5 @@ import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case'
2
2
  import type { Item } from '@cccsaurora/howler-ui/models/entities/generated/Item';
3
3
  export declare const uri: (id: string) => string;
4
4
  export declare const post: (id: string, newData: Item) => Promise<Case>;
5
- export declare const del: (id: string, value: string) => Promise<void>;
5
+ export declare const del: (id: string, values: string | string[]) => Promise<Case>;
6
+ export declare const patch: (id: string, value: string, newPath: string) => Promise<Case>;
@@ -1,5 +1,5 @@
1
1
  // eslint-disable-next-line import/no-cycle
2
- import { hdelete, hpost, joinUri } from '@cccsaurora/howler-ui/api';
2
+ import { hdelete, hpatch, hpost, joinUri } from '@cccsaurora/howler-ui/api';
3
3
  import { uri as parentUri } from '@cccsaurora/howler-ui/api/v2/case';
4
4
  export const uri = (id) => {
5
5
  return joinUri(parentUri(id), 'items');
@@ -7,6 +7,12 @@ export const uri = (id) => {
7
7
  export const post = (id, newData) => {
8
8
  return hpost(uri(id), newData);
9
9
  };
10
- export const del = (id, value) => {
11
- return hdelete(uri(id), { value });
10
+ export const del = (id, values) => {
11
+ if (!Array.isArray(values)) {
12
+ values = [values];
13
+ }
14
+ return hdelete(uri(id), { values });
15
+ };
16
+ export const patch = (id, value, newPath) => {
17
+ return hpatch(uri(id), { value, new_path: newPath });
12
18
  };
@@ -1,6 +1,7 @@
1
1
  import type { FC, PropsWithChildren, ReactNode } from 'react';
2
2
  export interface ModalOptions {
3
3
  disableClose?: boolean;
4
+ height?: number | string | null;
4
5
  maxWidth?: string;
5
6
  maxHeight?: string;
6
7
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Modal as MuiModal } from '@mui/material';
3
3
  import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
4
+ import { has } from 'lodash-es';
4
5
  import { useCallback, useContext } from 'react';
5
6
  const Modal = () => {
6
7
  const { content, setContent, options } = useContext(ModalContext);
@@ -15,7 +16,7 @@ const Modal = () => {
15
16
  left: '50%',
16
17
  maxWidth: options.maxWidth || '1200px',
17
18
  maxHeight: options.maxHeight || '400px',
18
- height: '100%',
19
+ height: has(options, 'height') ? options.height : '100%',
19
20
  transform: 'translate(-50%, -50%)',
20
21
  backgroundColor: 'background.paper',
21
22
  borderRadius: theme.shape.borderRadius,
@@ -9,11 +9,11 @@ import CaseSidebar from './detail/CaseSidebar';
9
9
  import useCase from './hooks/useCase';
10
10
  const CaseViewer = () => {
11
11
  const params = useParams();
12
- const { case: _case, missing } = useCase({ caseId: params.id });
12
+ const { case: _case, missing, update } = useCase({ caseId: params.id });
13
13
  if (missing) {
14
14
  return _jsx(NotFoundPage, {});
15
15
  }
16
- return (_jsxs(Stack, { direction: "row", height: "100%", children: [_jsx(CaseSidebar, { case: _case }), _jsx(Box, { sx: {
16
+ return (_jsxs(Stack, { direction: "row", height: "100%", children: [_jsx(CaseSidebar, { case: _case, update: updatedCase => update(updatedCase, false) }), _jsx(Box, { sx: {
17
17
  maxHeight: 'calc(100vh - 64px)',
18
18
  flex: 1,
19
19
  overflow: 'auto'
@@ -31,7 +31,7 @@ const CaseDashboard = ({ case: providedCase, caseId }) => {
31
31
  const { dispatchApi } = useMyApi();
32
32
  const theme = useTheme();
33
33
  const routeCase = useOutletContext();
34
- const { case: _case, updateCase } = useCase({ case: providedCase ?? routeCase, caseId });
34
+ const { case: _case, update: updateCase } = useCase({ case: providedCase ?? routeCase, caseId });
35
35
  const [records, setRecords] = useState(null);
36
36
  const ids = useMemo(() => (_case?.items ?? [])
37
37
  .filter(item => ['hit', 'observable'].includes(item.type))
@@ -12,7 +12,7 @@ import ResolveModal from '../modals/ResolveModal';
12
12
  import SourceAggregate from './aggregates/SourceAggregate';
13
13
  const CaseDetails = ({ case: providedCase }) => {
14
14
  const { t } = useTranslation();
15
- const { case: _case, updateCase } = useCase({ case: providedCase });
15
+ const { case: _case, update: updateCase } = useCase({ case: providedCase });
16
16
  const { showModal } = useContext(ModalContext);
17
17
  const { config } = useContext(ApiConfigContext);
18
18
  const [loading, setLoading] = useState(false);
@@ -1,6 +1,8 @@
1
1
  import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
2
  import { type FC } from 'react';
3
- declare const CaseSidebar: FC<{
3
+ interface CaseSidebarProps {
4
4
  case: Case;
5
- }>;
5
+ update: (newCase: Case) => void;
6
+ }
7
+ declare const CaseSidebar: FC<CaseSidebarProps>;
6
8
  export default CaseSidebar;
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
7
7
  import { Link, useLocation } from 'react-router-dom';
8
8
  import { ESCALATION_COLOR_MAP } from '../constants';
9
9
  import CaseFolder from './sidebar/CaseFolder';
10
- const CaseSidebar = ({ case: _case }) => {
10
+ const CaseSidebar = ({ case: _case, update }) => {
11
11
  const { t } = useTranslation();
12
12
  const location = useLocation();
13
13
  const theme = useTheme();
@@ -56,6 +56,6 @@ const CaseSidebar = ({ case: _case }) => {
56
56
  ], component: Link, to: `/cases/${_case?.case_id}/assets`, children: [_jsx(Dataset, {}), _jsx(Typography, { sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: t('page.cases.assets') })] }), _jsx(Divider, {}), _case && (_jsx(Box, { flex: 1, overflow: "auto", width: "100%", sx: {
57
57
  position: 'relative',
58
58
  borderRight: `thin solid ${theme.palette.divider}`
59
- }, children: _jsx(Box, { position: "absolute", sx: { left: 0, right: 0 }, children: _jsx(CaseFolder, { case: _case }) }) }))] }));
59
+ }, children: _jsx(Box, { position: "absolute", sx: { left: 0, right: 0 }, children: _jsx(CaseFolder, { case: _case, onItemUpdated: update }) }) }))] }));
60
60
  };
61
61
  export default CaseSidebar;
@@ -8,6 +8,7 @@ interface CaseFolderProps {
8
8
  step?: number;
9
9
  rootCaseId?: string;
10
10
  pathPrefix?: string;
11
+ onItemUpdated?: (newCase: Case) => void;
11
12
  }
12
13
  declare const CaseFolder: FC<CaseFolderProps>;
13
14
  export default CaseFolder;
@@ -7,6 +7,7 @@ import { omit } from 'lodash-es';
7
7
  import { useCallback, useEffect, useMemo, useState } from 'react';
8
8
  import { Link, useLocation } from 'react-router-dom';
9
9
  import { ESCALATION_COLORS } from '@cccsaurora/howler-ui/utils/constants';
10
+ import CaseFolderContextMenu from './CaseFolderContextMenu';
10
11
  import { buildTree } from './utils';
11
12
  // Static map: item type → MUI icon component (avoids re-creating closures on each render)
12
13
  const ICON_FOR_TYPE = {
@@ -17,7 +18,7 @@ const ICON_FOR_TYPE = {
17
18
  lead: Lightbulb,
18
19
  reference: LinkIcon
19
20
  };
20
- const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPrefix = '' }) => {
21
+ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPrefix = '', onItemUpdated }) => {
21
22
  const theme = useTheme();
22
23
  const location = useLocation();
23
24
  const { dispatchApi } = useMyApi();
@@ -75,17 +76,17 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
75
76
  setCaseStates(current => ({ ...current, [resolvedKey]: { ...current[resolvedKey], loading: false } }));
76
77
  });
77
78
  }, [caseStates, dispatchApi]);
78
- return (_jsxs(Stack, { sx: { overflow: 'visible' }, children: [name && (_jsxs(Stack, { direction: "row", pl: step * 1.5, py: 0.25, sx: {
79
- cursor: 'pointer',
80
- transition: theme.transitions.create('background', { duration: 50 }),
81
- background: 'transparent',
82
- '&:hover': {
83
- background: theme.palette.grey[800]
84
- }
85
- }, onClick: () => setOpen(_open => !_open), children: [_jsx(ChevronRight, { fontSize: "small", color: "disabled", sx: [
86
- { transition: theme.transitions.create('transform', { duration: 100 }), transform: 'rotate(0deg)' },
87
- open && { transform: 'rotate(90deg)' }
88
- ] }), _jsx(FolderIcon, { fontSize: "small", color: "disabled" }), _jsx(Typography, { variant: "caption", color: "textSecondary", sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: name })] })), open && (_jsxs(_Fragment, { children: [Object.entries(omit(tree, 'leaves')).map(([path, subfolder]) => (_jsx(CaseFolder, { name: path, case: _case, folder: subfolder, step: step + 1, rootCaseId: currentRootCaseId, pathPrefix: pathPrefix }, `${_case?.case_id}-${path}`))), tree.leaves?.map(leaf => {
79
+ return (_jsxs(Stack, { sx: { overflow: 'visible' }, children: [name && (_jsx(CaseFolderContextMenu, { _case: _case, tree: tree, onUpdate: onItemUpdated, children: _jsxs(Stack, { direction: "row", pl: step * 1.5, py: 0.25, sx: {
80
+ cursor: 'pointer',
81
+ transition: theme.transitions.create('background', { duration: 50 }),
82
+ background: 'transparent',
83
+ '&:hover': {
84
+ background: theme.palette.grey[800]
85
+ }
86
+ }, onClick: () => setOpen(_open => !_open), children: [_jsx(ChevronRight, { fontSize: "small", color: "disabled", sx: [
87
+ { transition: theme.transitions.create('transform', { duration: 100 }), transform: 'rotate(0deg)' },
88
+ open && { transform: 'rotate(90deg)' }
89
+ ] }), _jsx(FolderIcon, { fontSize: "small", color: "disabled" }), _jsx(Typography, { variant: "caption", color: "textSecondary", sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: name })] }) })), open && (_jsxs(_Fragment, { children: [Object.entries(omit(tree, 'leaves')).map(([path, subfolder]) => (_jsx(CaseFolder, { name: path, case: _case, folder: subfolder, step: step + 1, rootCaseId: currentRootCaseId, pathPrefix: pathPrefix, onItemUpdated: onItemUpdated }, `${_case?.case_id}-${path}`))), tree.leaves?.map(leaf => {
89
90
  const itemType = leaf.type?.toLowerCase();
90
91
  const isCase = itemType === 'case';
91
92
  const fullRelativePath = [pathPrefix, leaf.path].filter(Boolean).join('/');
@@ -103,30 +104,30 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
103
104
  const iconColor = escalationColor ?? 'inherit';
104
105
  const leafColor = escalationColor ? `${escalationColor}.light` : 'text.secondary';
105
106
  const Icon = ICON_FOR_TYPE[itemType ?? ''] ?? Article;
106
- return (_jsxs(Stack, { children: [_jsxs(Stack, { direction: "row", pl: step * 1.5 + 1, py: 0.25, sx: [
107
- {
108
- cursor: 'pointer',
109
- overflow: 'visible',
110
- color: `${theme.palette.text.secondary} !important`,
111
- textDecoration: 'none',
112
- transition: theme.transitions.create('background', { duration: 100 }),
113
- background: 'transparent',
114
- '&:hover': {
115
- background: theme.palette.grey[800]
107
+ return (_jsx(CaseFolderContextMenu, { _case: _case, leaf: leaf, onUpdate: onItemUpdated, children: _jsxs(Stack, { children: [_jsxs(Stack, { direction: "row", pl: step * 1.5 + 1, py: 0.25, sx: [
108
+ {
109
+ cursor: 'pointer',
110
+ overflow: 'visible',
111
+ color: `${theme.palette.text.secondary} !important`,
112
+ textDecoration: 'none',
113
+ transition: theme.transitions.create('background', { duration: 100 }),
114
+ background: 'transparent',
115
+ '&:hover': {
116
+ background: theme.palette.grey[800]
117
+ },
118
+ borderRight: '3px solid transparent'
116
119
  },
117
- borderRight: '3px solid transparent'
118
- },
119
- decodeURIComponent(location.pathname) === itemPath && {
120
- background: alpha(theme.palette.grey[600], 0.15),
121
- borderRightColor: theme.palette.primary.main
122
- }
123
- ], onClick: () => isCase && toggleCase(leaf, itemKey), component: Link, to: itemPath, target: itemType === 'reference' ? '_blank' : undefined, rel: itemType === 'reference' ? 'noopener noreferrer' : undefined, children: [_jsx(ChevronRight, { fontSize: "small", sx: [
124
- !isCase && { opacity: 0 },
125
- isCase && {
126
- transition: theme.transitions.create('transform', { duration: 100 }),
127
- transform: isCaseOpen ? 'rotate(90deg)' : 'rotate(0deg)'
128
- }
129
- ] }), _jsx(Icon, { fontSize: "small", color: iconColor }), _jsx(Typography, { variant: "caption", color: leafColor, sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: leaf.path?.split('/').pop() || leaf.value })] }), isCase && isCaseOpen && isCaseLoading && (_jsx(Stack, { pl: step * 1.5 + 4, py: 0.25, children: _jsx(Skeleton, { width: 140, height: 16 }) })), isCase && isCaseOpen && nestedCase && (_jsx(CaseFolder, { case: nestedCase, step: step + 1, rootCaseId: currentRootCaseId, pathPrefix: fullRelativePath }))] }, `${_case?.case_id}-${leaf.value}-${leaf.path}`));
120
+ decodeURIComponent(location.pathname) === itemPath && {
121
+ background: alpha(theme.palette.grey[600], 0.15),
122
+ borderRightColor: theme.palette.primary.main
123
+ }
124
+ ], onClick: () => isCase && toggleCase(leaf, itemKey), component: Link, to: itemPath, target: itemType === 'reference' ? '_blank' : undefined, rel: itemType === 'reference' ? 'noopener noreferrer' : undefined, children: [_jsx(ChevronRight, { fontSize: "small", sx: [
125
+ !isCase && { opacity: 0 },
126
+ isCase && {
127
+ transition: theme.transitions.create('transform', { duration: 100 }),
128
+ transform: isCaseOpen ? 'rotate(90deg)' : 'rotate(0deg)'
129
+ }
130
+ ] }), _jsx(Icon, { fontSize: "small", color: iconColor }), _jsx(Typography, { variant: "caption", color: leafColor, sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: leaf.path?.split('/').pop() || leaf.value })] }), isCase && isCaseOpen && isCaseLoading && (_jsx(Stack, { pl: step * 1.5 + 4, py: 0.25, children: _jsx(Skeleton, { width: 140, height: 16 }) })), isCase && isCaseOpen && nestedCase && (_jsx(CaseFolder, { case: nestedCase, step: step + 1, rootCaseId: currentRootCaseId, pathPrefix: fullRelativePath, onItemUpdated: onItemUpdated }))] }) }, `${_case?.case_id}-${leaf.value}-${leaf.path}`));
130
131
  })] }))] }));
131
132
  };
132
133
  export default CaseFolder;
@@ -0,0 +1,34 @@
1
+ import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
+ import type { Item } from '@cccsaurora/howler-ui/models/entities/generated/Item';
3
+ import { type FC, type PropsWithChildren } from 'react';
4
+ import type { Tree } from './types';
5
+ /**
6
+ * Recursively collects all leaf items from a folder tree.
7
+ */
8
+ export declare const collectAllLeaves: (tree: Tree) => Item[];
9
+ /**
10
+ * Returns the URL to open for a given leaf item, or null if no URL applies.
11
+ * - reference: the item's value (an external URL)
12
+ * - hit: /hits/<id>
13
+ * - observable: /observables/<id>
14
+ * - case: /cases/<id>
15
+ * - table / lead: null (no dedicated detail page)
16
+ */
17
+ export declare const getOpenUrl: (leaf: Item) => string | null;
18
+ export interface CaseFolderContextMenuProps extends PropsWithChildren {
19
+ /** The case that owns the item(s). */
20
+ _case: Case;
21
+ /** Present when the context menu is for a single leaf item. */
22
+ leaf?: Item;
23
+ /** Present when the context menu is for a folder (all leaves within it will be removed). */
24
+ tree?: Tree;
25
+ /** Called after item(s) have been successfully removed. */
26
+ onUpdate?: (updatedCase: Case) => void;
27
+ }
28
+ /**
29
+ * Wraps its children with a right-click context menu providing:
30
+ * - **Open item** – opens the item in a new tab (only for leaf items with a navigable URL).
31
+ * - **Remove item / Remove folder** – deletes the leaf item or all items under a folder.
32
+ */
33
+ declare const CaseFolderContextMenu: FC<CaseFolderContextMenuProps>;
34
+ export default CaseFolderContextMenu;
@@ -0,0 +1,105 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Delete, DriveFileRenameOutline, OpenInNew } from '@mui/icons-material';
3
+ import api from '@cccsaurora/howler-ui/api';
4
+ import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
5
+ import ContextMenu, {} from '@cccsaurora/howler-ui/components/elements/ContextMenu';
6
+ import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
7
+ import RenameItemModal from '@cccsaurora/howler-ui/components/routes/cases/modals/RenameItemModal';
8
+ import { useContext, useMemo } from 'react';
9
+ import { useTranslation } from 'react-i18next';
10
+ /**
11
+ * Recursively collects all leaf items from a folder tree.
12
+ */
13
+ export const collectAllLeaves = (tree) => {
14
+ const result = [...(tree.leaves ?? [])];
15
+ for (const key of Object.keys(tree)) {
16
+ if (key !== 'leaves') {
17
+ result.push(...collectAllLeaves(tree[key]));
18
+ }
19
+ }
20
+ return result;
21
+ };
22
+ /**
23
+ * Returns the URL to open for a given leaf item, or null if no URL applies.
24
+ * - reference: the item's value (an external URL)
25
+ * - hit: /hits/<id>
26
+ * - observable: /observables/<id>
27
+ * - case: /cases/<id>
28
+ * - table / lead: null (no dedicated detail page)
29
+ */
30
+ export const getOpenUrl = (leaf) => {
31
+ const type = leaf.type?.toLowerCase();
32
+ if (type === 'reference') {
33
+ return leaf.value ?? null;
34
+ }
35
+ if (type === 'hit') {
36
+ return leaf.value ? `/hits/${leaf.value}` : null;
37
+ }
38
+ if (type === 'observable') {
39
+ return leaf.value ? `/observables/${leaf.value}` : null;
40
+ }
41
+ if (type === 'case') {
42
+ return leaf.value ? `/cases/${leaf.value}` : null;
43
+ }
44
+ return null;
45
+ };
46
+ /**
47
+ * Wraps its children with a right-click context menu providing:
48
+ * - **Open item** – opens the item in a new tab (only for leaf items with a navigable URL).
49
+ * - **Remove item / Remove folder** – deletes the leaf item or all items under a folder.
50
+ */
51
+ const CaseFolderContextMenu = ({ _case, leaf, tree, onUpdate, children }) => {
52
+ const { dispatchApi } = useMyApi();
53
+ const { t } = useTranslation();
54
+ const { showModal } = useContext(ModalContext);
55
+ const items = useMemo(() => {
56
+ const entries = [];
57
+ if (leaf) {
58
+ const openUrl = getOpenUrl(leaf);
59
+ if (openUrl) {
60
+ entries.push({
61
+ kind: 'item',
62
+ id: 'open-item',
63
+ label: t('page.cases.sidebar.item.open'),
64
+ icon: _jsx(OpenInNew, { fontSize: "small" }),
65
+ onClick: () => window.open(openUrl, '_blank', 'noopener noreferrer')
66
+ });
67
+ }
68
+ entries.push({
69
+ kind: 'item',
70
+ id: 'rename-item',
71
+ label: t('page.cases.sidebar.item.rename'),
72
+ icon: _jsx(DriveFileRenameOutline, { fontSize: "small" }),
73
+ onClick: () => showModal(_jsx(RenameItemModal, { _case: _case, leaf: leaf, onRenamed: onUpdate }), { height: null })
74
+ });
75
+ }
76
+ if (entries.length > 0) {
77
+ entries.push({ kind: 'divider', id: 'divider-remove' });
78
+ }
79
+ const isFolder = !leaf && !!tree;
80
+ entries.push({
81
+ kind: 'item',
82
+ id: 'remove-item',
83
+ label: isFolder ? t('page.cases.sidebar.folder.remove') : t('page.cases.sidebar.item.remove'),
84
+ icon: _jsx(Delete, { fontSize: "small" }),
85
+ onClick: () => {
86
+ if (!_case.case_id) {
87
+ return;
88
+ }
89
+ const itemsToDelete = leaf ? [leaf] : tree ? collectAllLeaves(tree) : [];
90
+ const values = itemsToDelete.filter(i => !!i.value).map(i => i.value);
91
+ if (!values.length) {
92
+ return;
93
+ }
94
+ dispatchApi(api.v2.case.items.del(_case.case_id, values), { throwError: false }).then(updatedCase => {
95
+ if (updatedCase) {
96
+ onUpdate?.(updatedCase);
97
+ }
98
+ });
99
+ }
100
+ });
101
+ return entries;
102
+ }, [_case, leaf, tree, dispatchApi, onUpdate, showModal, t]);
103
+ return _jsx(ContextMenu, { items: items, children: children });
104
+ };
105
+ export default CaseFolderContextMenu;