@cccsaurora/howler-ui 2.17.0-dev.547 → 2.17.0-dev.550

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.
@@ -11,7 +11,7 @@ export type AppUser = {
11
11
  };
12
12
  export type AppUserService<T extends AppUser> = {
13
13
  user: T;
14
- setUser: (user: T) => void;
14
+ setUser: (user: T | ((prev: T) => T)) => void;
15
15
  isReady: () => boolean;
16
16
  validateProps?: (props: AppUserValidatedProp[]) => boolean;
17
17
  };
@@ -42,8 +42,8 @@ const HitProvider = ({ children }) => {
42
42
  }
43
43
  }, []);
44
44
  useEffect(() => {
45
- addListener('hit_provider', handler);
46
- return () => removeListener('hit_provider');
45
+ addListener('hits', handler);
46
+ return () => removeListener('hits');
47
47
  }, [addListener, handler, removeListener]);
48
48
  /**
49
49
  * A method to retrieve a hit from the context. It first checks the hit state,
@@ -121,7 +121,7 @@ const SocketProvider = ({ children }) => {
121
121
  setRetry(false);
122
122
  // Here we go!
123
123
  setStatus(Status.CONNECTING);
124
- const host = window.location.host.includes('localhost') ? 'localhost:5000' : window.location.host;
124
+ const host = window.location.host;
125
125
  const protocol = window.location.protocol.startsWith('http:') ? 'ws' : 'wss';
126
126
  const ws = new WebSocket(`${protocol}://${host}/socket/v1/connect`);
127
127
  // Add our listeners to the websocket
@@ -91,7 +91,9 @@ const ButtonActions = ({ actions, loading, orientation, shortcuts, currentVote }
91
91
  gridRow: rowIndex + 2
92
92
  }
93
93
  : { gridColumn: rowIndex + 1, gridRow: index + 3 + actionRows[0].length };
94
- const button = (_jsx(Tooltip, { title: t(`hit.details.asessments.${action.name}.description`), children: _jsx(Button, { variant: "outlined", size: "small", disabled: loading, onClick: action.actionFunction, sx: [
94
+ const button = (
95
+ // Set tooltip placement to be uniform for all buttons
96
+ _jsx(Tooltip, { title: t(`hit.details.asessments.${action.name}.description`), placement: "top", children: _jsx(Button, { variant: "outlined", size: "small", disabled: loading, onClick: action.actionFunction, sx: [
95
97
  {
96
98
  width: '100%',
97
99
  p: 0.6
@@ -19,6 +19,7 @@ declare const useMyUserFunctions: () => {
19
19
  type: "view" | "analytic";
20
20
  config: string;
21
21
  }[];
22
+ refresh_rate?: number;
22
23
  avatar?: string;
23
24
  is_admin?: boolean;
24
25
  }>;
@@ -41,6 +42,7 @@ declare const useMyUserFunctions: () => {
41
42
  type: "view" | "analytic";
42
43
  config: string;
43
44
  }[];
45
+ refresh_rate?: number;
44
46
  avatar?: string;
45
47
  is_admin?: boolean;
46
48
  }>;
@@ -64,6 +66,7 @@ declare const useMyUserFunctions: () => {
64
66
  type: "view" | "analytic";
65
67
  config: string;
66
68
  }[];
69
+ refresh_rate?: number;
67
70
  avatar?: string;
68
71
  is_admin?: boolean;
69
72
  }>;
@@ -87,6 +90,7 @@ declare const useMyUserFunctions: () => {
87
90
  type: "view" | "analytic";
88
91
  config: string;
89
92
  }[];
93
+ refresh_rate?: number;
90
94
  avatar?: string;
91
95
  is_admin?: boolean;
92
96
  }>;
@@ -109,10 +113,12 @@ declare const useMyUserFunctions: () => {
109
113
  type: "view" | "analytic";
110
114
  config: string;
111
115
  }[];
116
+ refresh_rate?: number;
112
117
  avatar?: string;
113
118
  is_admin?: boolean;
114
119
  }>;
115
120
  viewGroups: () => Promise<void>;
116
121
  setDashboard: (dashboard: HowlerUser["dashboard"]) => Promise<void>;
122
+ setRefreshRate: (refresh_rate: number) => Promise<void>;
117
123
  };
118
124
  export default useMyUserFunctions;
@@ -107,6 +107,12 @@ const useMyUserFunctions = () => {
107
107
  throwError: true,
108
108
  showError: true
109
109
  });
110
+ }, [currentUser.username, dispatchApi]),
111
+ setRefreshRate: useCallback(async (refresh_rate) => {
112
+ await dispatchApi(api.user.put(currentUser.username, { refresh_rate }), {
113
+ throwError: true,
114
+ showError: true
115
+ });
110
116
  }, [currentUser.username, dispatchApi])
111
117
  };
112
118
  };
@@ -49,8 +49,8 @@ const HitContextMenu = ({ children, getSelectedId, Component = Box }) => {
49
49
  const { config } = useContext(ApiConfigContext);
50
50
  const pluginStore = usePluginStore();
51
51
  const { getMatchingAnalytic, getMatchingTemplate } = useMatchers();
52
- const query = useContextSelector(ParameterContext, ctx => ctx.query);
53
- const setQuery = useContextSelector(ParameterContext, ctx => ctx.setQuery);
52
+ const query = useContextSelector(ParameterContext, ctx => ctx?.query);
53
+ const setQuery = useContextSelector(ParameterContext, ctx => ctx?.setQuery);
54
54
  const [id, setId] = useState(null);
55
55
  const hit = useContextSelector(HitContext, ctx => ctx.hits[id]);
56
56
  const selectedHits = useContextSelector(HitContext, ctx => ctx.selectedHits);
@@ -172,7 +172,7 @@ const HitContextMenu = ({ children, getSelectedId, Component = Box }) => {
172
172
  overflow: 'visible !important'
173
173
  }
174
174
  }
175
- }, MenuListProps: { dense: true, sx: { minWidth: '250px' } }, anchorOrigin: { vertical: 'top', horizontal: 'left' }, onClick: () => setAnchorEl(null), children: [_jsxs(MenuItem, { component: Link, to: `/hits/${hit?.howler.id}`, disabled: !hit, children: [_jsx(ListItemIcon, { children: _jsx(OpenInNew, {}) }), _jsx(ListItemText, { children: t('hit.panel.open') })] }), _jsxs(MenuItem, { component: Link, to: `/analytics/${analytic?.analytic_id}`, disabled: !analytic, children: [_jsx(ListItemIcon, { children: _jsx(QueryStats, {}) }), _jsx(ListItemText, { children: t('hit.panel.analytic.open') })] }), _jsx(Divider, {}), entries.map(([type, items]) => (_jsxs(MenuItem, { id: `${type}-menu-item`, sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, [type]: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, [type]: null })), disabled: rowStatus[type] === false, children: [_jsx(ListItemIcon, { children: ICON_MAP[type] ?? _jsx(Terminal, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t(`hit.details.actions.${type}`) }), rowStatus[type] !== false && (_jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } })), _jsx(Fade, { in: !!show[type], unmountOnExit: true, children: _jsx(Paper, { id: `${type}-submenu`, sx: calculateSubMenuStyles(show[type]), elevation: 8, children: _jsx(MenuList, { sx: { p: 0, borderTopLeftRadius: 0 }, dense: true, role: "group", children: items.map(a => (_jsx(MenuItem, { value: a.name, onClick: a.actionFunction, children: a.i18nKey ? t(a.i18nKey) : capitalize(a.name) }, a.name))) }) }) })] }, type))), _jsxs(MenuItem, { id: "actions-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, actions: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, actions: null })), disabled: actions.length < 1, children: [_jsx(ListItemIcon, { children: _jsx(SettingsSuggest, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('route.actions.change') }), actions.length > 0 && _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.actions, unmountOnExit: true, children: _jsx(Paper, { id: "actions-submenu", sx: calculateSubMenuStyles(show.actions), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: actions.map(action => (_jsx(MenuItem, { onClick: () => executeAction(action.action_id, `howler.id:${hit?.howler.id}`), children: _jsx(ListItemText, { children: action.name }) }, action.action_id))) }) }) })] }), !isEmpty(template?.keys ?? []) && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(MenuItem, { id: "excludes-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, excludes: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, excludes: null })), children: [_jsx(ListItemIcon, { children: _jsx(RemoveCircleOutline, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('hit.panel.exclude') }), _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.excludes, unmountOnExit: true, children: _jsx(Paper, { id: "excludes-submenu", sx: calculateSubMenuStyles(show.excludes), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: template?.keys.map(key => {
175
+ }, MenuListProps: { dense: true, sx: { minWidth: '250px' } }, anchorOrigin: { vertical: 'top', horizontal: 'left' }, onClick: () => setAnchorEl(null), children: [_jsxs(MenuItem, { component: Link, to: `/hits/${hit?.howler.id}`, disabled: !hit, children: [_jsx(ListItemIcon, { children: _jsx(OpenInNew, {}) }), _jsx(ListItemText, { children: t('hit.panel.open') })] }), _jsxs(MenuItem, { component: Link, to: `/analytics/${analytic?.analytic_id}`, disabled: !analytic, children: [_jsx(ListItemIcon, { children: _jsx(QueryStats, {}) }), _jsx(ListItemText, { children: t('hit.panel.analytic.open') })] }), _jsx(Divider, {}), entries.map(([type, items]) => (_jsxs(MenuItem, { id: `${type}-menu-item`, sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, [type]: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, [type]: null })), disabled: rowStatus[type] === false, children: [_jsx(ListItemIcon, { children: ICON_MAP[type] ?? _jsx(Terminal, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t(`hit.details.actions.${type}`) }), rowStatus[type] !== false && (_jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } })), _jsx(Fade, { in: !!show[type], unmountOnExit: true, children: _jsx(Paper, { id: `${type}-submenu`, sx: calculateSubMenuStyles(show[type]), elevation: 8, children: _jsx(MenuList, { sx: { p: 0, borderTopLeftRadius: 0 }, dense: true, role: "group", children: items.map(a => (_jsx(MenuItem, { value: a.name, onClick: a.actionFunction, children: a.i18nKey ? t(a.i18nKey) : capitalize(a.name) }, a.name))) }) }) })] }, type))), _jsxs(MenuItem, { id: "actions-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, actions: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, actions: null })), disabled: actions.length < 1, children: [_jsx(ListItemIcon, { children: _jsx(SettingsSuggest, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('route.actions.change') }), actions.length > 0 && _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.actions, unmountOnExit: true, children: _jsx(Paper, { id: "actions-submenu", sx: calculateSubMenuStyles(show.actions), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: actions.map(action => (_jsx(MenuItem, { onClick: () => executeAction(action.action_id, `howler.id:${hit?.howler.id}`), children: _jsx(ListItemText, { children: action.name }) }, action.action_id))) }) }) })] }), !isEmpty(template?.keys ?? []) && setQuery && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(MenuItem, { id: "excludes-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, excludes: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, excludes: null })), children: [_jsx(ListItemIcon, { children: _jsx(RemoveCircleOutline, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('hit.panel.exclude') }), _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.excludes, unmountOnExit: true, children: _jsx(Paper, { id: "excludes-submenu", sx: calculateSubMenuStyles(show.excludes), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: template?.keys.map(key => {
176
176
  // Build exclusion query based on current query and field value
177
177
  let newQuery = '';
178
178
  if (query !== DEFAULT_QUERY) {
@@ -18,7 +18,7 @@ const CustomSpan = () => {
18
18
  const endDate = useContextSelector(ParameterContext, ctx => (ctx.endDate ? dayjs(ctx.endDate) : defaultEndDate));
19
19
  useEffect(() => {
20
20
  if (span?.endsWith('custom')) {
21
- setCustomSpan(startDate.format('YYYY-MM-DD HH:mm'), endDate.format('YYYY-MM-DD HH:mm'));
21
+ setCustomSpan(startDate.toISOString(), endDate.toISOString());
22
22
  }
23
23
  }, [endDate, setCustomSpan, span, startDate]);
24
24
  return span?.endsWith('custom') ? (_jsx(LocalizationProvider, { dateAdapter: AdapterDayjs, children: _jsxs(Stack, { direction: "row", spacing: 1, useFlexGap: true, flexWrap: "wrap", children: [_jsx(DateTimePicker, { sx: { minWidth: '175px', flexGrow: 1, marginTop: 1 }, slotProps: { textField: { size: 'small' } }, label: t('date.select.start'), value: startDate ? dayjs(startDate) : dayjs().subtract(1, 'days'), maxDate: endDate, onChange: (newStartDate) => setCustomSpan(newStartDate.toISOString(), endDate.toISOString()), ampm: false, disableFuture: true }), _jsx(DateTimePicker, { sx: { minWidth: '175px', flexGrow: 1, marginTop: 1 }, slotProps: { textField: { size: 'small' } }, label: t('date.select.end'), value: endDate, minDate: startDate, onChange: (newEndDate) => setCustomSpan(startDate.toISOString(), newEndDate.toISOString()), ampm: false, disableFuture: true })] }) })) : null;
@@ -132,7 +132,7 @@ const HitViewer = () => {
132
132
  position: 'absolute',
133
133
  top: theme.spacing(2),
134
134
  right: theme.spacing(-6)
135
- }, children: [_jsx(Tooltip, { title: t('page.hits.view.layout'), children: _jsx(IconButton, { onClick: onOrientationChange, children: _jsx(ViewAgenda, { sx: { transition: 'rotate 250ms', rotate: orientation === 'vertical' ? '90deg' : '0deg' } }) }) }), _jsx(SocketBadge, { size: "medium" }), analytic && (_jsx(Tooltip, { title: t('hit.panel.analytic.open'), children: _jsx(IconButton, { onClick: () => navigate(`/analytics/${analytic.analytic_id}`), children: _jsx(QueryStats, {}) }) })), hit?.howler.bundles?.length > 0 && _jsx(BundleButton, { ids: hit.howler.bundles })] }))] }), _jsx(HowlerCard, { sx: [orientation === 'horizontal' && { height: '0px' }], children: _jsx(CardContent, { sx: { padding: 1, position: 'relative' }, children: _jsx(HitActions, { hit: hit, orientation: "vertical" }) }) }), _jsx(Box, { sx: { gridColumn: '1 / span 2', mb: 1 }, children: _jsxs(Tabs, { value: tab === 'overview' && !hasOverview ? 'details' : tab, sx: { display: 'flex', flexDirection: 'row', pr: 2, alignItems: 'center' }, children: [hit?.howler?.is_bundle && (_jsx(Tab, { label: t('hit.viewer.aggregate'), value: "hit_aggregate", onClick: () => setTab('hit_aggregate') })), hasOverview && (_jsx(Tab, { label: t('hit.viewer.overview'), value: "overview", onClick: () => setTab('overview') })), _jsx(Tab, { label: t('hit.viewer.details'), value: "details", onClick: () => setTab('details') }), hit?.howler.dossier?.map((lead, index) => (_jsx(Tab
135
+ }, children: [_jsx(Tooltip, { title: t('hit.panel.view.layout'), children: _jsx(IconButton, { onClick: onOrientationChange, children: _jsx(ViewAgenda, { sx: { transition: 'rotate 250ms', rotate: orientation === 'vertical' ? '90deg' : '0deg' } }) }) }), _jsx(SocketBadge, { size: "medium" }), analytic && (_jsx(Tooltip, { title: t('hit.panel.analytic.open'), children: _jsx(IconButton, { onClick: () => navigate(`/analytics/${analytic.analytic_id}`), children: _jsx(QueryStats, {}) }) })), hit?.howler.bundles?.length > 0 && _jsx(BundleButton, { ids: hit.howler.bundles })] }))] }), _jsx(HowlerCard, { sx: [orientation === 'horizontal' && { height: '0px' }], children: _jsx(CardContent, { sx: { padding: 1, position: 'relative' }, children: _jsx(HitActions, { hit: hit, orientation: "vertical" }) }) }), _jsx(Box, { sx: { gridColumn: '1 / span 2', mb: 1 }, children: _jsxs(Tabs, { value: tab === 'overview' && !hasOverview ? 'details' : tab, sx: { display: 'flex', flexDirection: 'row', pr: 2, alignItems: 'center' }, children: [hit?.howler?.is_bundle && (_jsx(Tab, { label: t('hit.viewer.aggregate'), value: "hit_aggregate", onClick: () => setTab('hit_aggregate') })), hasOverview && (_jsx(Tab, { label: t('hit.viewer.overview'), value: "overview", onClick: () => setTab('overview') })), _jsx(Tab, { label: t('hit.viewer.details'), value: "details", onClick: () => setTab('details') }), hit?.howler.dossier?.map((lead, index) => (_jsx(Tab
136
136
  // eslint-disable-next-line react/no-array-index-key
137
137
  , { label: _jsxs(Stack, { direction: "row", spacing: 0.5, children: [lead.icon && _jsx(Icon, { icon: lead.icon }), _jsx("span", { children: i18n.language === 'en' ? lead.label.en : lead.label.fr })] }), value: 'lead:' + index, onClick: () => setTab('lead:' + index) }, 'lead:' + index))), dossiers.flatMap((_dossier, dossierIndex) => (_dossier.leads ?? []).map((_lead, leadIndex) => (_jsx(Tab
138
138
  // eslint-disable-next-line react/no-array-index-key
@@ -1,7 +1,9 @@
1
- import type { FC } from 'react';
1
+ import { type FC } from 'react';
2
2
  export interface ViewSettings {
3
3
  viewId: string;
4
4
  limit: number;
5
+ refreshTick?: symbol;
6
+ onRefreshComplete?: () => void;
5
7
  }
6
8
  declare const ViewCard: FC<ViewSettings>;
7
9
  export default ViewCard;
@@ -3,22 +3,109 @@ import { OpenInNew } from '@mui/icons-material';
3
3
  import { Card, CardContent, IconButton, Skeleton, Stack, Typography } from '@mui/material';
4
4
  import api from '@cccsaurora/howler-ui/api';
5
5
  import AppListEmpty from '@cccsaurora/howler-ui/commons/components/display/AppListEmpty';
6
+ import { useHitContextSelector } from '@cccsaurora/howler-ui/components/app/providers/HitProvider';
6
7
  import { ViewContext } from '@cccsaurora/howler-ui/components/app/providers/ViewProvider';
7
8
  import HitBanner from '@cccsaurora/howler-ui/components/elements/hit/HitBanner';
8
9
  import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
9
10
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
10
- import { useCallback, useEffect, useState } from 'react';
11
+ import HitContextMenu from '@cccsaurora/howler-ui/components/routes/hits/search/HitContextMenu';
12
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
13
  import { useTranslation } from 'react-i18next';
12
14
  import { useNavigate } from 'react-router-dom';
13
15
  import { useContextSelector } from 'use-context-selector';
14
- const ViewCard = ({ viewId, limit }) => {
16
+ // Custom hook to select hits by IDs with proper memoization
17
+ const useSelectHitsByIds = (hitIds) => {
18
+ const hitIdsRef = useRef(hitIds);
19
+ const prevResultRef = useRef([]);
20
+ const prevHitIdsRef = useRef([]);
21
+ // Keep ref up to date with latest hitIds
22
+ hitIdsRef.current = hitIds;
23
+ const selector = useCallback(ctx => {
24
+ const currentHitIds = hitIdsRef.current;
25
+ // Fast path: if hitIds array didn't change, check if hit objects changed
26
+ if (prevHitIdsRef.current.length === currentHitIds.length &&
27
+ currentHitIds.every((id, i) => id === prevHitIdsRef.current[i])) {
28
+ // HitIds unchanged - check if any hit objects changed by reference
29
+ const anyHitChanged = currentHitIds.some((id, i) => ctx.hits[id] !== prevResultRef.current[i]);
30
+ if (!anyHitChanged) {
31
+ return prevResultRef.current;
32
+ }
33
+ }
34
+ // Something changed - rebuild the array
35
+ const currentHits = currentHitIds.map(id => ctx.hits[id]).filter(Boolean);
36
+ prevHitIdsRef.current = currentHitIds;
37
+ prevResultRef.current = currentHits;
38
+ return currentHits;
39
+ }, []); // Empty deps - selector never changes
40
+ return useHitContextSelector(selector);
41
+ };
42
+ // Utility functions
43
+ const normalize = (val) => (val == null ? '' : String(val));
44
+ // Have to normalize the fields as websockets and api return null and undefined respectively. This causes false positives when comparing signatures if not normalized to a consistent value. We also stringify non-primitive values to ensure changes are detected.
45
+ const createHitSignature = (hit) => {
46
+ if (!hit)
47
+ return '';
48
+ return `${hit.howler?.id}:${normalize(hit.howler?.status)}:${normalize(hit.howler?.assignment)}:${normalize(hit.howler?.assessment)}`;
49
+ };
50
+ const createSignatureFromHits = (hits) => {
51
+ if (hits.length === 0)
52
+ return '';
53
+ return hits.map(createHitSignature).join('|');
54
+ };
55
+ const DEBOUNCE_TIME = 1000; // 1 second debounce for signature changes
56
+ const ViewCard = ({ viewId, limit, refreshTick, onRefreshComplete }) => {
15
57
  const navigate = useNavigate();
16
58
  const { t } = useTranslation();
17
59
  const { dispatchApi } = useMyApi();
18
- const [hits, setHits] = useState([]);
60
+ const [hitIds, setHitIds] = useState([]);
19
61
  const [loading, setLoading] = useState(false);
62
+ const debounceTimerRef = useRef(null);
63
+ const isRefreshing = useRef(false);
64
+ const lastSignature = useRef('');
20
65
  const view = useContextSelector(ViewContext, ctx => ctx.views[viewId]);
21
66
  const fetchViews = useContextSelector(ViewContext, ctx => ctx.fetchViews);
67
+ const loadHits = useHitContextSelector(ctx => ctx.loadHits);
68
+ // Subscribe to hits from HitProvider cache based on current hitIds in the view
69
+ // Uses memoized selector to avoid unnecessary re-renders on unrelated hit updates
70
+ const hits = useSelectHitsByIds(hitIds);
71
+ // Create a stable signature that only changes when relevant fields change
72
+ const hitsSignature = useMemo(() => createSignatureFromHits(hits), [hits]);
73
+ const refreshView = useCallback(async () => {
74
+ if (!view?.query || isRefreshing.current) {
75
+ onRefreshComplete?.();
76
+ return;
77
+ }
78
+ isRefreshing.current = true;
79
+ try {
80
+ const res = await dispatchApi(api.search.hit.post({
81
+ query: view.query,
82
+ rows: limit,
83
+ metadata: ['analytic']
84
+ }));
85
+ const fetchedHits = res.items ?? [];
86
+ loadHits(fetchedHits);
87
+ setHitIds(fetchedHits.map(h => h.howler.id));
88
+ lastSignature.current = createSignatureFromHits(fetchedHits);
89
+ }
90
+ finally {
91
+ isRefreshing.current = false;
92
+ onRefreshComplete?.();
93
+ }
94
+ }, [dispatchApi, limit, view?.query, loadHits, onRefreshComplete]);
95
+ const debouncedRefresh = useCallback(() => {
96
+ if (debounceTimerRef.current) {
97
+ clearTimeout(debounceTimerRef.current);
98
+ }
99
+ debounceTimerRef.current = setTimeout(() => {
100
+ refreshView();
101
+ }, DEBOUNCE_TIME);
102
+ }, [refreshView]);
103
+ useEffect(() => {
104
+ if (refreshTick) {
105
+ refreshView();
106
+ }
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
+ }, [refreshTick]);
22
109
  useEffect(() => {
23
110
  fetchViews([viewId]);
24
111
  }, [fetchViews, viewId]);
@@ -26,19 +113,43 @@ const ViewCard = ({ viewId, limit }) => {
26
113
  if (!view?.query) {
27
114
  return;
28
115
  }
29
- const timeout = setTimeout(() => setLoading(true), 200);
30
- dispatchApi(api.search.hit.post({
31
- query: view.query,
32
- rows: limit,
33
- metadata: ['analytic']
34
- }))
35
- .then(res => setHits(res.items ?? []))
36
- .finally(() => {
37
- clearTimeout(timeout);
116
+ const loadingTimeout = setTimeout(() => setLoading(true), 200);
117
+ refreshView().finally(() => {
118
+ clearTimeout(loadingTimeout);
38
119
  setLoading(false);
39
120
  });
40
- }, [dispatchApi, limit, view?.query]);
121
+ return () => {
122
+ clearTimeout(loadingTimeout);
123
+ };
124
+ }, [view?.query, limit, refreshView]);
125
+ // Monitor hits currently in the view for changes that might affect query results
126
+ useEffect(() => {
127
+ if (!hitsSignature || hitIds.length === 0 || !lastSignature.current) {
128
+ lastSignature.current = hitsSignature;
129
+ return;
130
+ }
131
+ // Check if signature actually changed
132
+ if (lastSignature.current === hitsSignature) {
133
+ return;
134
+ }
135
+ debouncedRefresh();
136
+ }, [hitsSignature, hitIds, debouncedRefresh]);
137
+ useEffect(() => {
138
+ return () => {
139
+ if (debounceTimerRef.current) {
140
+ clearTimeout(debounceTimerRef.current);
141
+ }
142
+ };
143
+ }, []);
41
144
  const onClick = useCallback((query) => navigate('/hits?query=' + query), [navigate]);
42
- return (_jsx(Card, { variant: "outlined", sx: { height: '100%' }, children: _jsxs(Stack, { spacing: 1, sx: { p: 1, minHeight: 100 }, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { variant: "h6", children: t(view?.title) || _jsx(Skeleton, { variant: "text", height: "2em", width: "100px" }) }), _jsx(IconButton, { size: "small", onClick: () => onClick(view.query), children: _jsx(OpenInNew, { fontSize: "small" }) })] }), loading ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { height: 150, width: "100%", variant: "rounded" }), _jsx(Skeleton, { height: 160, width: "100%", variant: "rounded" }), _jsx(Skeleton, { height: 140, width: "100%", variant: "rounded" })] })) : hits.length > 0 ? (hits.map(h => (_jsx(Card, { variant: "outlined", sx: { cursor: 'pointer' }, onClick: () => navigate((h.howler.is_bundle ? '/bundles/' : '/hits/') + h.howler.id), children: _jsx(CardContent, { children: _jsx(HitBanner, { layout: HitLayout.DENSE, hit: h }) }) }, h.howler.id)))) : (_jsx(AppListEmpty, {}))] }) }));
145
+ const getSelectedId = useCallback((event) => {
146
+ const target = event.target;
147
+ const selectedElement = target.closest('[id]');
148
+ if (!selectedElement) {
149
+ return;
150
+ }
151
+ return selectedElement.id;
152
+ }, []);
153
+ return (_jsx(Card, { variant: "outlined", sx: { height: '100%' }, children: _jsxs(Stack, { spacing: 1, sx: { p: 1, minHeight: 100 }, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { variant: "h6", children: t(view?.title) || _jsx(Skeleton, { variant: "text", height: "2em", width: "100px" }) }), _jsx(IconButton, { size: "small", onClick: () => onClick(view.query), children: _jsx(OpenInNew, { fontSize: "small" }) })] }), loading ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { height: 150, width: "100%", variant: "rounded" }), _jsx(Skeleton, { height: 160, width: "100%", variant: "rounded" }), _jsx(Skeleton, { height: 140, width: "100%", variant: "rounded" })] })) : hits.length > 0 ? (_jsx(HitContextMenu, { getSelectedId: getSelectedId, children: hits.map(h => (_jsx(Card, { id: h.howler.id, variant: "outlined", sx: { cursor: 'pointer' }, onClick: () => navigate((h.howler.is_bundle ? '/bundles/' : '/hits/') + h.howler.id), children: _jsx(CardContent, { children: _jsx(HitBanner, { layout: HitLayout.DENSE, hit: h }) }) }, h.howler.id))) })) : (_jsx(AppListEmpty, {}))] }) }));
43
154
  };
44
155
  export default ViewCard;
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
3
3
  import { SortableContext, arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
4
- import { Cancel, Check, Close, Edit, OpenInNew } from '@mui/icons-material';
5
- import { Alert, AlertTitle, CircularProgress, Grid, IconButton, Stack, Typography } from '@mui/material';
4
+ import { Cancel, Check, Close, Edit, MoreVert, OpenInNew, Refresh } from '@mui/icons-material';
5
+ import { Alert, AlertTitle, Box, CircularProgress, FormControl, FormLabel, Grid, IconButton, ListItemIcon, Menu, MenuItem, Slider, Stack, Tooltip, Typography } from '@mui/material';
6
6
  import api from '@cccsaurora/howler-ui/api';
7
7
  import { AppBrand } from '@cccsaurora/howler-ui/branding/AppBrand';
8
8
  import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks';
@@ -12,7 +12,7 @@ import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/us
12
12
  import useMyUserFunctions from '@cccsaurora/howler-ui/components/hooks/useMyUserFunctions';
13
13
  import dayjs from 'dayjs';
14
14
  import isEqual from 'lodash-es/isEqual';
15
- import { useCallback, useEffect, useMemo, useState } from 'react';
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
16
  import { useTranslation } from 'react-i18next';
17
17
  import { Link } from 'react-router-dom';
18
18
  import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
@@ -22,16 +22,25 @@ import AnalyticCard, {} from './AnalyticCard';
22
22
  import EntryWrapper from './EntryWrapper';
23
23
  import ViewCard, {} from './ViewCard';
24
24
  const LUCENE_DATE_FMT = 'YYYY-MM-DD[T]HH:mm:ss';
25
+ const REFRESH_RATES = [15, 30, 60, 300];
25
26
  const Home = () => {
26
27
  const { t } = useTranslation();
27
28
  const { user, setUser } = useAppUser();
28
- const { setDashboard } = useMyUserFunctions();
29
+ const { setDashboard, setRefreshRate: setRefreshRateBackend } = useMyUserFunctions();
29
30
  const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
30
31
  const [lastViewed, setLastViewed] = useMyLocalStorageItem(StorageKey.LAST_VIEW, dayjs().utc().format(LUCENE_DATE_FMT));
31
32
  const [loading, setLoading] = useState(false);
32
33
  const [isEditing, setIsEditing] = useState(false);
33
34
  const [updatedHitTotal, setUpdatedHitTotal] = useState(0);
34
35
  const [dashboard, setStateDashboard] = useState(user.dashboard ?? []);
36
+ const [progress, setProgress] = useState(0);
37
+ const [isRefreshing, setIsRefreshing] = useState(false);
38
+ const [openSettings, setOpenSettings] = useState(null);
39
+ const [refreshRate, setRefreshRate] = useState(user.refresh_rate ?? 15);
40
+ const [refreshTick, setRefreshTick] = useState(null);
41
+ const pendingRefreshes = useRef(0);
42
+ const timerRef = useRef(null);
43
+ const debounceTimerRef = useRef(null);
35
44
  const updateQuery = useMemo(() => `(howler.log.user:${user.username} OR howler.assignment:${user.username}) AND howler.log.timestamp:{${lastViewed} TO now} AND -howler.status:resolved`, [lastViewed, user.username]);
36
45
  const getIdFromEntry = useCallback((entry) => {
37
46
  const settings = JSON.parse(entry.config);
@@ -48,6 +57,41 @@ const Home = () => {
48
57
  const setLocalDashboard = useCallback((_dashboard) => {
49
58
  setStateDashboard(_dashboard);
50
59
  }, []);
60
+ const handleRefreshComplete = useCallback(() => {
61
+ pendingRefreshes.current -= 1;
62
+ if (pendingRefreshes.current <= 0) {
63
+ setIsRefreshing(false);
64
+ setProgress(0);
65
+ }
66
+ }, []);
67
+ const handleRefreshRateChange = useCallback((newRate) => {
68
+ setRefreshRate(newRate);
69
+ setUser(prev => ({
70
+ ...prev,
71
+ refresh_rate: newRate
72
+ }));
73
+ // Debounce the backend API call
74
+ if (debounceTimerRef.current) {
75
+ clearTimeout(debounceTimerRef.current);
76
+ }
77
+ debounceTimerRef.current = setTimeout(() => {
78
+ setRefreshRateBackend(newRate);
79
+ }, 500);
80
+ }, [setRefreshRateBackend, setUser]);
81
+ const refreshViews = useCallback(() => {
82
+ const viewCardCount = (dashboard ?? []).filter(e => e.type === 'view').length;
83
+ setIsRefreshing(true);
84
+ pendingRefreshes.current = viewCardCount;
85
+ if (viewCardCount === 0) {
86
+ setIsRefreshing(false);
87
+ setProgress(0);
88
+ return;
89
+ }
90
+ setRefreshTick(Symbol());
91
+ }, [dashboard]);
92
+ const handleOpenSettings = (event) => {
93
+ setOpenSettings(event.currentTarget);
94
+ };
51
95
  const saveChanges = useCallback(async () => {
52
96
  setLoading(true);
53
97
  try {
@@ -82,7 +126,48 @@ const Home = () => {
82
126
  })
83
127
  .then(result => setUpdatedHitTotal(result.total));
84
128
  }, [updateQuery]);
85
- return (_jsx(PageCenter, { maxWidth: "1800px", textAlign: "left", height: "100%", children: _jsx(ErrorBoundary, { children: _jsxs(Stack, { direction: "column", spacing: 1, sx: { height: '100%' }, children: [_jsxs(Stack, { direction: "row", justifyContent: "end", spacing: 1, children: [isEditing && (_jsx(CustomButton, { variant: "outlined", size: "small", color: "error", startIcon: _jsx(Cancel, {}), onClick: discardChanges, children: t('cancel') })), _jsx(CustomButton, { variant: "outlined", size: "small", disabled: isEditing && isEqual(dashboard, user.dashboard), color: isEditing ? 'success' : 'primary', startIcon: isEditing ? loading ? _jsx(CircularProgress, { size: 20 }) : _jsx(Check, {}) : _jsx(Edit, {}), onClick: () => (!isEditing ? setIsEditing(true) : saveChanges()), children: t(isEditing ? 'save' : 'edit') })] }), updatedHitTotal > 0 && (_jsxs(Alert, { severity: "info", variant: "outlined", action: _jsxs(Stack, { spacing: 1, direction: "row", children: [_jsx(IconButton, { color: "info", component: Link, to: `/hits?query=${encodeURIComponent(updateQuery)}`, onClick: () => setLastViewed(dayjs().utc().format(LUCENE_DATE_FMT)), children: _jsx(OpenInNew, {}) }), _jsx(IconButton, { color: "info", onClick: () => {
129
+ useEffect(() => {
130
+ if (isRefreshing)
131
+ return;
132
+ if (progress >= 100) {
133
+ refreshViews();
134
+ return;
135
+ }
136
+ timerRef.current = setTimeout(() => {
137
+ setProgress(prev => prev + 1);
138
+ }, refreshRate * 10);
139
+ return () => {
140
+ if (timerRef.current)
141
+ clearTimeout(timerRef.current);
142
+ };
143
+ }, [progress, isRefreshing, refreshRate, refreshViews]);
144
+ useEffect(() => {
145
+ return () => {
146
+ if (debounceTimerRef.current)
147
+ clearTimeout(debounceTimerRef.current);
148
+ };
149
+ }, []);
150
+ return (_jsx(PageCenter, { maxWidth: "1800px", textAlign: "left", height: "100%", children: _jsx(ErrorBoundary, { children: _jsxs(Stack, { direction: "column", spacing: 1, sx: { height: '100%' }, children: [_jsxs(Stack, { direction: "row", justifyContent: "end", spacing: 1, children: [isEditing && (_jsx(CustomButton, { variant: "outlined", size: "small", color: "error", startIcon: _jsx(Cancel, {}), onClick: discardChanges, children: t('cancel') })), isEditing && (_jsx(CustomButton, { variant: "outlined", size: "small", disabled: isEqual(dashboard, user.dashboard), color: 'success', startIcon: loading ? _jsx(CircularProgress, { size: 20 }) : _jsx(Check, {}), onClick: saveChanges, children: t('save') })), _jsxs(Box, { sx: { position: 'relative', display: 'inline-flex' }, children: [isRefreshing ? (_jsx(CircularProgress, { variant: "indeterminate" })) : (_jsx(CircularProgress, { variant: "determinate", value: progress })), _jsx(Box, { sx: {
151
+ top: 0,
152
+ left: 0,
153
+ bottom: 0,
154
+ right: 0,
155
+ position: 'absolute',
156
+ display: 'flex',
157
+ alignItems: 'center',
158
+ justifyContent: 'center'
159
+ }, children: _jsx(Tooltip, { title: t('refresh'), children: _jsx(IconButton, { onClick: refreshViews, disabled: isRefreshing, color: "primary", children: _jsx(Refresh, {}) }) }) })] }), _jsx(IconButton, { size: "small", color: "primary", onClick: handleOpenSettings, children: _jsx(MoreVert, {}) }), _jsxs(Menu, { id: "settings-menu", anchorEl: openSettings, open: !!openSettings, onClose: () => setOpenSettings(null), children: [_jsxs(MenuItem, { disabled: isEditing, onClick: () => {
160
+ setOpenSettings(null);
161
+ setIsEditing(true);
162
+ }, children: [_jsx(ListItemIcon, { children: _jsx(Edit, {}) }), t('page.dashboard.settings.edit')] }), _jsx(MenuItem, { disableRipple: true, disableTouchRipple: true, sx: { '&:hover': { bgcolor: 'transparent' }, cursor: 'default' }, children: _jsxs(FormControl, { sx: { px: 2, py: 1, minWidth: 250, pointerEvents: 'auto' }, children: [_jsx(FormLabel, { id: "refresh-rate-label", sx: { mb: 2 }, children: t('page.dashboard.settings.refreshRate') }), _jsx(Slider, { "aria-labelledby": "refresh-rate-label", value: REFRESH_RATES.indexOf(refreshRate), onChange: (_, value) => handleRefreshRateChange(REFRESH_RATES[value]), step: 1, marks: [
163
+ { value: 0, label: '15s' },
164
+ { value: 1, label: '30s' },
165
+ { value: 2, label: '1m' },
166
+ { value: 3, label: '5m' }
167
+ ], min: 0, max: 3, valueLabelDisplay: "auto", valueLabelFormat: value => {
168
+ const rates = ['15s', '30s', '1m', '5m'];
169
+ return rates[value] || '';
170
+ } })] }) })] })] }), updatedHitTotal > 0 && (_jsxs(Alert, { severity: "info", variant: "outlined", action: _jsxs(Stack, { spacing: 1, direction: "row", children: [_jsx(IconButton, { color: "info", component: Link, to: `/hits?query=${encodeURIComponent(updateQuery)}`, onClick: () => setLastViewed(dayjs().utc().format(LUCENE_DATE_FMT)), children: _jsx(OpenInNew, {}) }), _jsx(IconButton, { color: "info", onClick: () => {
86
171
  setLastViewed(dayjs().utc().format(LUCENE_DATE_FMT));
87
172
  setUpdatedHitTotal(0);
88
173
  }, children: _jsx(Close, {}) })] }), children: [_jsx(AlertTitle, { children: t('route.home.alert.updated.title') }), t('route.home.alert.updated.description', { count: updatedHitTotal })] })), _jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: handleDragEnd, children: _jsx(SortableContext, { items: (dashboard ?? []).map(entry => getIdFromEntry(entry)), children: _jsxs(Grid, { container: true, spacing: 1, alignItems: "stretch", sx: [
@@ -98,7 +183,7 @@ const Home = () => {
98
183
  ], children: [(dashboard ?? []).map(entry => {
99
184
  if (entry.type === 'view') {
100
185
  const settings = JSON.parse(entry.config);
101
- return (_jsx(EntryWrapper, { editing: isEditing, id: settings.viewId, onDelete: () => setLocalDashboard((dashboard ?? []).filter(_entry => _entry.entry_id !== getIdFromEntry(entry))), children: _jsx(ViewCard, { ...settings }, entry.config) }, entry.entry_id));
186
+ return (_jsx(EntryWrapper, { editing: isEditing, id: settings.viewId, onDelete: () => setLocalDashboard((dashboard ?? []).filter(_entry => _entry.entry_id !== getIdFromEntry(entry))), children: _jsx(ViewCard, { refreshTick: refreshTick, onRefreshComplete: handleRefreshComplete, ...settings }, entry.config) }, entry.entry_id));
102
187
  }
103
188
  else if (entry.type === 'analytic') {
104
189
  const settings = JSON.parse(entry.config);
@@ -175,6 +175,7 @@
175
175
  "hit.panel.analytic.open": "Open Analytic",
176
176
  "hit.panel.bundles.open": "Parent Bundles",
177
177
  "hit.panel.bundles.open.prompt": "Open Parent Bundle",
178
+ "hit.panel.view.layout": "Change View Panel",
178
179
  "hit.panel.details.show": "Show Hit Details",
179
180
  "hit.panel.details.hide": "Hide Hit Details",
180
181
  "hit.panel.details.exit": "Close Hit",
@@ -342,6 +343,8 @@
342
343
  "page.error.description": "The application stopped working suddenly. If the problem persists please reach out on teams.",
343
344
  "page.error.support": "Please click here for teams support",
344
345
  "page.dashboard.title": "Dashboard",
346
+ "page.dashboard.settings.edit": "Edit Dashboard",
347
+ "page.dashboard.settings.refreshRate": "Refresh Rate",
345
348
  "page.documentation.categories": "API Categories",
346
349
  "page.documentation.categories.category": "Category",
347
350
  "page.documentation.categories.description": "Description",
@@ -176,6 +176,7 @@
176
176
  "hit.panel.analytic.open": "Ouvrir l'analyse",
177
177
  "hit.panel.bundles.open": "Groupes parentaux",
178
178
  "hit.panel.bundles.open.prompt": "Ouvrir le groupe parent",
179
+ "hit.panel.view.layout": "Modifier le panneau d'affichage",
179
180
  "hit.panel.details.show": "Montrer les détails du hit",
180
181
  "hit.panel.details.hide": "Cacher les détails du hit",
181
182
  "hit.panel.details.exit": "Fermer hit",
@@ -344,6 +345,8 @@
344
345
  "page.error.description": "L'application a cessé de fonctionner soudainement. Si le problème persiste, veuillez nous contacter sur teams.",
345
346
  "page.error.support": "Cliquez ici pour obtenir du soutien sur teams",
346
347
  "page.dashboard.title": "Tableau de bord",
348
+ "page.dashboard.settings.edit": "Modifier le tableau de bord",
349
+ "page.dashboard.settings.refreshRate": "Fréquence de rafraîchissement",
347
350
  "page.documentation.categories": "Catégories d'API",
348
351
  "page.documentation.categories.category": "Catégorie",
349
352
  "page.documentation.categories.description": "Description",
@@ -15,4 +15,5 @@ export interface HowlerUser extends AppUser {
15
15
  favourite_views?: string[];
16
16
  favourite_analytics?: string[];
17
17
  dashboard?: { entry_id: string; type: 'view' | 'analytic'; config: string }[];
18
+ refresh_rate?: number;
18
19
  }
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.547",
104
+ "version": "2.17.0-dev.550",
105
105
  "exports": {
106
106
  "./i18n": "./i18n.js",
107
107
  "./index.css": "./index.css",