@cccsaurora/howler-ui 2.17.0-dev.600 → 2.17.0-dev.617
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.
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
2
|
import { type FC } from 'react';
|
|
3
3
|
import type { Tree } from './types';
|
|
4
|
-
|
|
4
|
+
interface CaseFolderProps {
|
|
5
5
|
case: Case;
|
|
6
6
|
folder?: Tree;
|
|
7
7
|
name?: string;
|
|
8
8
|
step?: number;
|
|
9
9
|
rootCaseId?: string;
|
|
10
10
|
pathPrefix?: string;
|
|
11
|
-
}
|
|
11
|
+
}
|
|
12
|
+
declare const CaseFolder: FC<CaseFolderProps>;
|
|
12
13
|
export default CaseFolder;
|
|
@@ -3,114 +3,82 @@ import { Article, BookRounded, CheckCircle, ChevronRight, Folder as FolderIcon,
|
|
|
3
3
|
import { Skeleton, Stack, Typography, useTheme } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
6
|
-
import {
|
|
7
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
6
|
+
import { omit } from 'lodash-es';
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const parts = item.path.split('/');
|
|
20
|
-
parts.pop();
|
|
21
|
-
if (parts.length > 0) {
|
|
22
|
-
// Use dot notation so lodash `get/set` can address nested folder objects.
|
|
23
|
-
const key = parts.join('.');
|
|
24
|
-
const size = get(tree, key)?.leaves?.length || 0;
|
|
25
|
-
// Append this item to the folder's `leaves` array.
|
|
26
|
-
set(tree, `${key}.leaves.${size}`, item);
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
// Items without parent folders are top-level leaves.
|
|
30
|
-
tree.leaves.push(item);
|
|
31
|
-
});
|
|
32
|
-
return tree;
|
|
10
|
+
import { buildTree } from './utils';
|
|
11
|
+
// Static map: item type → MUI icon component (avoids re-creating closures on each render)
|
|
12
|
+
const ICON_FOR_TYPE = {
|
|
13
|
+
case: BookRounded,
|
|
14
|
+
observable: Visibility,
|
|
15
|
+
hit: CheckCircle,
|
|
16
|
+
table: TableChart,
|
|
17
|
+
lead: Lightbulb,
|
|
18
|
+
reference: LinkIcon
|
|
33
19
|
};
|
|
34
20
|
const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPrefix = '' }) => {
|
|
35
21
|
const theme = useTheme();
|
|
36
22
|
const location = useLocation();
|
|
37
23
|
const { dispatchApi } = useMyApi();
|
|
38
24
|
const [open, setOpen] = useState(true);
|
|
39
|
-
const [
|
|
40
|
-
const [loadingCases, setLoadingCases] = useState({});
|
|
41
|
-
const [nestedCases, setNestedCases] = useState({});
|
|
25
|
+
const [caseStates, setCaseStates] = useState({});
|
|
42
26
|
const [hitMetadata, setHitMetadata] = useState({});
|
|
43
27
|
const tree = useMemo(() => folder || buildTree(_case?.items), [folder, _case?.items]);
|
|
44
28
|
const currentRootCaseId = rootCaseId || _case?.case_id;
|
|
45
|
-
//
|
|
29
|
+
// Stable string key so the effect only re-runs when the actual hit IDs change,
|
|
30
|
+
// not on every array reference change.
|
|
31
|
+
const hitIds = useMemo(() => tree.leaves
|
|
32
|
+
?.filter(l => l.type?.toLowerCase() === 'hit')
|
|
33
|
+
.map(l => l.id)
|
|
34
|
+
.filter(id => !!id) ?? [], [tree.leaves]);
|
|
46
35
|
useEffect(() => {
|
|
47
|
-
|
|
48
|
-
if (!ids || ids.length < 1) {
|
|
36
|
+
if (hitIds.length < 1) {
|
|
49
37
|
return;
|
|
50
38
|
}
|
|
51
|
-
dispatchApi(api.search.hit.post({ query: `howler.id:(${
|
|
52
|
-
if (result?.items?.length < 1)
|
|
39
|
+
dispatchApi(api.search.hit.post({ query: `howler.id:(${hitIds.join(' OR ')})` }), { throwError: false }).then(result => {
|
|
40
|
+
if ((result?.items?.length ?? 0) < 1)
|
|
53
41
|
return;
|
|
54
|
-
}
|
|
55
42
|
setHitMetadata(Object.fromEntries(result.items.map(hit => [hit.howler.id, hit.howler])));
|
|
56
43
|
});
|
|
57
|
-
}, [
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const meta = hitMetadata[leafId];
|
|
61
|
-
if (meta?.escalation && ESCALATION_COLORS[meta.escalation]) {
|
|
62
|
-
return ESCALATION_COLORS[meta.escalation];
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
if (itemType === 'case' && itemKey) {
|
|
66
|
-
const caseData = nestedCases[itemKey];
|
|
67
|
-
if (caseData?.escalation && ESCALATION_COLORS[caseData.escalation]) {
|
|
68
|
-
return ESCALATION_COLORS[caseData.escalation];
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return 'default';
|
|
72
|
-
};
|
|
73
|
-
const getItemColor = (itemType, itemKey, leafId) => {
|
|
44
|
+
}, [hitIds, dispatchApi]);
|
|
45
|
+
// Returns the MUI colour token for the item's escalation, or undefined if none.
|
|
46
|
+
const getEscalationColor = (itemType, itemKey, leafId) => {
|
|
74
47
|
if (itemType === 'hit' && leafId) {
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
return
|
|
78
|
-
}
|
|
48
|
+
const color = ESCALATION_COLORS[hitMetadata[leafId]?.escalation];
|
|
49
|
+
if (color)
|
|
50
|
+
return color;
|
|
79
51
|
}
|
|
80
52
|
if (itemType === 'case' && itemKey) {
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
return
|
|
84
|
-
}
|
|
53
|
+
const color = ESCALATION_COLORS[caseStates[itemKey]?.data?.escalation];
|
|
54
|
+
if (color)
|
|
55
|
+
return color;
|
|
85
56
|
}
|
|
86
|
-
return
|
|
57
|
+
return undefined;
|
|
87
58
|
};
|
|
88
|
-
const toggleCase = (item, itemKey) => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (!resolvedItemKey) {
|
|
59
|
+
const toggleCase = useCallback((item, itemKey) => {
|
|
60
|
+
const resolvedKey = itemKey || item.path || item.id;
|
|
61
|
+
if (!resolvedKey)
|
|
92
62
|
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// Only fetch when opening, with a valid case id, and when no fetch/data is already in-flight/cached.
|
|
98
|
-
if (!shouldOpen || !item.id || nestedCases[resolvedItemKey] || loadingCases[resolvedItemKey]) {
|
|
63
|
+
const prev = caseStates[resolvedKey] ?? { open: false, loading: false, data: null };
|
|
64
|
+
const shouldOpen = !prev.open;
|
|
65
|
+
setCaseStates(current => ({ ...current, [resolvedKey]: { ...prev, open: shouldOpen } }));
|
|
66
|
+
if (!shouldOpen || !item.id || prev.data || prev.loading)
|
|
99
67
|
return;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
68
|
+
setCaseStates(current => ({
|
|
69
|
+
...current,
|
|
70
|
+
[resolvedKey]: { ...(current[resolvedKey] ?? prev), loading: true }
|
|
71
|
+
}));
|
|
103
72
|
dispatchApi(api.v2.case.get(item.id), { throwError: false })
|
|
104
73
|
.then(caseResponse => {
|
|
105
|
-
if (!caseResponse)
|
|
74
|
+
if (!caseResponse)
|
|
106
75
|
return;
|
|
107
|
-
}
|
|
108
|
-
setNestedCases(current => ({ ...current, [resolvedItemKey]: caseResponse }));
|
|
76
|
+
setCaseStates(current => ({ ...current, [resolvedKey]: { ...current[resolvedKey], data: caseResponse } }));
|
|
109
77
|
})
|
|
110
78
|
.finally(() => {
|
|
111
|
-
|
|
79
|
+
setCaseStates(current => ({ ...current, [resolvedKey]: { ...current[resolvedKey], loading: false } }));
|
|
112
80
|
});
|
|
113
|
-
};
|
|
81
|
+
}, [caseStates, dispatchApi]);
|
|
114
82
|
return (_jsxs(Stack, { sx: { overflow: 'visible' }, children: [name && (_jsxs(Stack, { direction: "row", pl: step * 1.5, py: 0.25, sx: {
|
|
115
83
|
cursor: 'pointer',
|
|
116
84
|
transition: theme.transitions.create('background', { duration: 50 }),
|
|
@@ -126,32 +94,19 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
|
|
|
126
94
|
const isCase = itemType === 'case';
|
|
127
95
|
const fullRelativePath = [pathPrefix, leaf.path].filter(Boolean).join('/');
|
|
128
96
|
const itemKey = fullRelativePath || leaf.id;
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
case 'hit':
|
|
143
|
-
return _jsx(CheckCircle, { fontSize: "small", color: iconColor });
|
|
144
|
-
case 'table':
|
|
145
|
-
return _jsx(TableChart, { fontSize: "small", color: iconColor });
|
|
146
|
-
case 'lead':
|
|
147
|
-
return _jsx(Lightbulb, { fontSize: "small", color: iconColor });
|
|
148
|
-
case 'reference':
|
|
149
|
-
return _jsx(LinkIcon, { fontSize: "small", color: iconColor });
|
|
150
|
-
default:
|
|
151
|
-
return _jsx(Article, { fontSize: "small", color: iconColor });
|
|
152
|
-
}
|
|
153
|
-
};
|
|
154
|
-
const leafColor = getItemColor(itemType, itemKey, leaf.id);
|
|
97
|
+
const nodeState = itemKey ? caseStates[itemKey] : null;
|
|
98
|
+
const isCaseOpen = !!nodeState?.open;
|
|
99
|
+
const isCaseLoading = !!nodeState?.loading;
|
|
100
|
+
const nestedCase = nodeState?.data ?? null;
|
|
101
|
+
const itemPath = itemType !== 'reference'
|
|
102
|
+
? fullRelativePath
|
|
103
|
+
? `/cases/${currentRootCaseId}/${fullRelativePath}`
|
|
104
|
+
: `/cases/${currentRootCaseId}`
|
|
105
|
+
: leaf.value;
|
|
106
|
+
const escalationColor = getEscalationColor(itemType, itemKey, leaf.id);
|
|
107
|
+
const iconColor = escalationColor ?? 'inherit';
|
|
108
|
+
const leafColor = escalationColor ? `${escalationColor}.light` : 'text.secondary';
|
|
109
|
+
const Icon = ICON_FOR_TYPE[itemType ?? ''] ?? Article;
|
|
155
110
|
return (_jsxs(Stack, { children: [_jsxs(Stack, { direction: "row", pl: step * 1.5 + 1, py: 0.25, sx: [
|
|
156
111
|
{
|
|
157
112
|
cursor: 'pointer',
|
|
@@ -167,13 +122,13 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
|
|
|
167
122
|
decodeURIComponent(location.pathname) === itemPath && {
|
|
168
123
|
background: theme.palette.grey[800]
|
|
169
124
|
}
|
|
170
|
-
], onClick: () => isCase && toggleCase(leaf, itemKey), component: Link, to: itemPath, children: [_jsx(ChevronRight, { fontSize: "small", sx: [
|
|
125
|
+
], 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: [
|
|
171
126
|
!isCase && { opacity: 0 },
|
|
172
127
|
isCase && {
|
|
173
128
|
transition: theme.transitions.create('transform', { duration: 100 }),
|
|
174
129
|
transform: isCaseOpen ? 'rotate(90deg)' : 'rotate(0deg)'
|
|
175
130
|
}
|
|
176
|
-
] }),
|
|
131
|
+
] }), _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.id })] }), 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.id}-${leaf.path}`));
|
|
177
132
|
})] }))] }));
|
|
178
133
|
};
|
|
179
134
|
export default CaseFolder;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { get, set } from 'lodash-es';
|
|
2
|
+
export const buildTree = (items = []) => {
|
|
3
|
+
// Root tree node stores direct children in `leaves` and nested folders as object keys.
|
|
4
|
+
const tree = { leaves: [] };
|
|
5
|
+
items.forEach(item => {
|
|
6
|
+
// Ignore items that cannot be placed in the folder structure.
|
|
7
|
+
if (!item?.path) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
// Split path into folder segments + item name, then remove the item name.
|
|
11
|
+
const parts = item.path.split('/');
|
|
12
|
+
parts.pop();
|
|
13
|
+
if (parts.length > 0) {
|
|
14
|
+
// Use dot notation so lodash `get/set` can address nested folder objects.
|
|
15
|
+
const key = parts.join('.');
|
|
16
|
+
const size = get(tree, key)?.leaves?.length || 0;
|
|
17
|
+
// Append this item to the folder's `leaves` array.
|
|
18
|
+
set(tree, `${key}.leaves.${size}`, item);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// Items without parent folders are top-level leaves.
|
|
22
|
+
tree.leaves.push(item);
|
|
23
|
+
});
|
|
24
|
+
return tree;
|
|
25
|
+
};
|