@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
- declare const CaseFolder: FC<{
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 { get, last, omit, set } from 'lodash-es';
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
- const buildTree = (items = []) => {
11
- // Root tree node stores direct children in `leaves` and nested folders as object keys.
12
- const tree = { leaves: [] };
13
- items.forEach(item => {
14
- // Ignore items that cannot be placed in the folder structure.
15
- if (!item?.path) {
16
- return;
17
- }
18
- // Split path into folder segments + item name, then remove the item name.
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 [openCases, setOpenCases] = useState({});
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
- // Metadata for hit-type items
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
- const ids = tree.leaves?.filter(leaf => leaf.type?.toLowerCase() === 'hit').map(leaf => leaf.id);
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:(${ids.join(' OR ')})` }), { throwError: false }).then(result => {
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
- }, [tree.leaves, dispatchApi]);
58
- const getIconColor = (itemType, itemKey, leafId) => {
59
- if (itemType === 'hit' && leafId) {
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 meta = hitMetadata[leafId];
76
- if (meta?.escalation && ESCALATION_COLORS[meta.escalation]) {
77
- return `${ESCALATION_COLORS[meta.escalation]}.light`;
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 caseData = nestedCases[itemKey];
82
- if (caseData?.escalation && ESCALATION_COLORS[caseData.escalation]) {
83
- return `${ESCALATION_COLORS[caseData.escalation]}.light`;
84
- }
53
+ const color = ESCALATION_COLORS[caseStates[itemKey]?.data?.escalation];
54
+ if (color)
55
+ return color;
85
56
  }
86
- return 'text.secondary';
57
+ return undefined;
87
58
  };
88
- const toggleCase = (item, itemKey) => {
89
- // Use the fully-qualified path key when available so nested case toggles are unique.
90
- const resolvedItemKey = itemKey || item.path || item.id;
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
- // Toggle expand/collapse state for this case node.
95
- const shouldOpen = !openCases[resolvedItemKey];
96
- setOpenCases(current => ({ ...current, [resolvedItemKey]: shouldOpen }));
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
- setLoadingCases(current => ({ ...current, [resolvedItemKey]: true }));
102
- // Lazy-load the nested case content and cache it by the same unique key.
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
- setLoadingCases(current => ({ ...current, [resolvedItemKey]: false }));
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 isCaseOpen = !!(itemKey && openCases[itemKey]);
130
- const isCaseLoading = !!(itemKey && loadingCases[itemKey]);
131
- const nestedCase = itemKey ? nestedCases[itemKey] : null;
132
- const itemPath = fullRelativePath
133
- ? `/cases/${currentRootCaseId}/${fullRelativePath}`
134
- : `/cases/${currentRootCaseId}`;
135
- const getIconForType = () => {
136
- const iconColor = getIconColor(itemType, itemKey, leaf.id);
137
- switch (itemType) {
138
- case 'case':
139
- return _jsx(BookRounded, { fontSize: "small", color: iconColor });
140
- case 'observable':
141
- return _jsx(Visibility, { fontSize: "small", color: iconColor });
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
- ] }), getIconForType(), _jsx(Typography, { variant: "caption", color: leafColor, sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: last(leaf.path?.split('/') || []) })] }), 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}`));
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,3 @@
1
+ import type { Item } from '@cccsaurora/howler-ui/models/entities/generated/Item';
2
+ import type { Tree } from './types';
3
+ export declare const buildTree: (items?: Item[]) => Tree;
@@ -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
+ };
package/package.json CHANGED
@@ -101,7 +101,7 @@
101
101
  "internal-slot": "1.0.7"
102
102
  },
103
103
  "type": "module",
104
- "version": "2.17.0-dev.600",
104
+ "version": "2.17.0-dev.617",
105
105
  "exports": {
106
106
  "./i18n": "./i18n.js",
107
107
  "./index.css": "./index.css",