POC: Unified History (#94318)

This commit is contained in:
Joao Silva 2024-12-19 09:26:42 +00:00 committed by GitHub
parent f5d44ff51d
commit e0af98dec8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 395 additions and 12 deletions

View File

@ -10,9 +10,10 @@ import { isShallowEqual } from 'app/core/utils/isShallowEqual';
import { KioskMode } from 'app/types';
import { RouteDescriptor } from '../../navigation/types';
import { buildBreadcrumbs } from '../Breadcrumbs/utils';
import { ReturnToPreviousProps } from './ReturnToPrevious/ReturnToPrevious';
import { TOP_BAR_LEVEL_HEIGHT } from './types';
import { HistoryEntry, TOP_BAR_LEVEL_HEIGHT } from './types';
export interface AppChromeState {
chromeless?: boolean;
@ -31,6 +32,7 @@ export interface AppChromeState {
export const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked';
export const DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY = 'grafana.navigation.open';
export const HISTORY_LOCAL_STORAGE_KEY = 'grafana.navigation.history';
export class AppChromeService {
searchBarStorageKey = 'SearchBar_Hidden';
@ -99,6 +101,8 @@ export class AppChromeService {
newState.chromeless = newState.kioskMode === KioskMode.Full || this.currentRoute?.chromeless;
if (!this.ignoreStateUpdate(newState, current)) {
config.featureToggles.unifiedHistory &&
store.setObject(HISTORY_LOCAL_STORAGE_KEY, this.getUpdatedHistory(newState));
this.state.next(newState);
}
}
@ -127,6 +131,29 @@ export class AppChromeService {
window.sessionStorage.removeItem('returnToPrevious');
};
private getUpdatedHistory(newState: AppChromeState): HistoryEntry[] {
const breadcrumbs = buildBreadcrumbs(newState.sectionNav.node, newState.pageNav, { text: 'Home', url: '/' }, true);
const newPageNav = newState.pageNav || newState.sectionNav.node;
let entries = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []);
const clickedHistory = store.getObject<boolean>('CLICKING_HISTORY');
if (clickedHistory) {
store.setObject('CLICKING_HISTORY', false);
return entries;
}
if (!newPageNav) {
return entries;
}
let lastEntry = entries[0];
if (!lastEntry || lastEntry.name !== newPageNav.text) {
lastEntry = { name: newPageNav.text, views: [], breadcrumbs, time: Date.now(), url: window.location.href };
}
if (lastEntry !== entries[0]) {
entries = [lastEntry, ...entries];
}
return entries;
}
private ignoreStateUpdate(newState: AppChromeState, current: AppChromeState) {
if (isShallowEqual(newState, current)) {
return true;

View File

@ -0,0 +1,80 @@
import { css } from '@emotion/css';
import { useEffect } from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2, store } from '@grafana/data';
import { Drawer, ToolbarButton, useStyles2 } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { t } from 'app/core/internationalization';
import { RecordHistoryEntryEvent } from 'app/types/events';
import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService';
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
import { HistoryEntry } from '../types';
import { HistoryWrapper } from './HistoryWrapper';
export function HistoryContainer() {
const [showHistoryDrawer, onToggleShowHistoryDrawer] = useToggle(false);
const styles = useStyles2(getStyles);
useEffect(() => {
const sub = appEvents.subscribe(RecordHistoryEntryEvent, (ev) => {
const clickedHistory = store.getObject<boolean>('CLICKING_HISTORY');
if (clickedHistory) {
store.setObject('CLICKING_HISTORY', false);
return;
}
const history = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []);
let lastEntry = history[0];
const newUrl = ev.payload.url;
const lastUrl = lastEntry.views[0]?.url;
if (lastUrl !== newUrl) {
lastEntry.views = [
{
name: ev.payload.name,
description: ev.payload.description,
url: newUrl,
time: Date.now(),
},
...lastEntry.views,
];
store.setObject(HISTORY_LOCAL_STORAGE_KEY, [...history]);
}
return () => {
sub.unsubscribe();
};
});
}, []);
return (
<>
<ToolbarButton
onClick={onToggleShowHistoryDrawer}
iconOnly
icon="history"
aria-label={t('nav.history-container.drawer-tittle', 'History')}
/>
<NavToolbarSeparator className={styles.separator} />
{showHistoryDrawer && (
<Drawer
title={t('nav.history-container.drawer-tittle', 'History')}
onClose={onToggleShowHistoryDrawer}
size="md"
>
<HistoryWrapper onClose={() => onToggleShowHistoryDrawer(false)} />
</Drawer>
)}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
separator: css({
[theme.breakpoints.down('sm')]: {
display: 'none',
},
}),
};
};

View File

@ -0,0 +1,204 @@
import { css } from '@emotion/css';
import moment from 'moment';
import { useState } from 'react';
import { FieldType, GrafanaTheme2, store } from '@grafana/data';
import { Button, Card, IconButton, Space, Stack, Text, useStyles2, Box, Sparkline, useTheme2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService';
import { HistoryEntry } from '../types';
export function HistoryWrapper({ onClose }: { onClose: () => void }) {
const history = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []).filter((entry) => {
return moment(entry.time).isAfter(moment().subtract(2, 'day').startOf('day'));
});
const [numItemsToShow, setNumItemsToShow] = useState(5);
const selectedTime = history.find((entry) => {
return entry.url === window.location.href || entry.views.some((view) => view.url === window.location.href);
})?.time;
const hist = history.slice(0, numItemsToShow).reduce((acc: { [key: string]: HistoryEntry[] }, entry) => {
const date = moment(entry.time);
let key = '';
if (date.isSame(moment(), 'day')) {
key = t('nav.history-wrapper.today', 'Today');
} else if (date.isSame(moment().subtract(1, 'day'), 'day')) {
key = t('nav.history-wrapper.yesterday', 'Yesterday');
} else {
key = date.format('YYYY-MM-DD');
}
acc[key] = [...(acc[key] || []), entry];
return acc;
}, {});
return (
<Stack direction="column" alignItems="flex-start">
<Box width="100%">
{Object.keys(hist).map((entries, date) => {
return (
<Stack key={date} direction="column" gap={1}>
<Text color="secondary" variant="bodySmall">
{entries}
</Text>
{hist[entries].map((entry, index) => {
return (
<HistoryEntryAppView
key={index}
entry={entry}
isSelected={entry.time === selectedTime}
onClick={() => onClose()}
/>
);
})}
</Stack>
);
})}
</Box>
{history.length > numItemsToShow && (
<Button variant="secondary" fill="text" onClick={() => setNumItemsToShow(numItemsToShow + 5)}>
{t('nav.history-wrapper.show-more', 'Show more')}
</Button>
)}
</Stack>
);
}
interface ItemProps {
entry: HistoryEntry;
isSelected: boolean;
onClick: () => void;
}
function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) {
const styles = useStyles2(getStyles);
const theme = useTheme2();
const [isExpanded, setIsExpanded] = useState(isSelected && entry.views.length > 0);
const { breadcrumbs, views, time, url, sparklineData } = entry;
const expandedLabel = isExpanded
? t('nav.history-wrapper.collapse', 'Collapse')
: t('nav.history-wrapper.expand', 'Expand');
const selectedViewTime =
isSelected &&
entry.views.find((entry) => {
return entry.url === window.location.href;
})?.time;
return (
<Stack direction="column" gap={1}>
<Stack>
{views.length > 0 ? (
<IconButton
name={isExpanded ? 'angle-down' : 'angle-right'}
onClick={() => setIsExpanded(!isExpanded)}
aria-label={expandedLabel}
className={styles.iconButton}
/>
) : (
<Space h={2} />
)}
<Card
onClick={() => {
store.setObject('CLICKING_HISTORY', true);
onClick();
}}
href={url}
isCompact={true}
className={isSelected ? undefined : styles.card}
>
<Stack direction="column">
<div>
{breadcrumbs.map((breadcrumb, index) => (
<Text key={index}>
{breadcrumb.text} {index !== breadcrumbs.length - 1 ? '> ' : ''}
</Text>
))}
</div>
<Text color="secondary">{moment(time).format('h:mm A')}</Text>
{sparklineData && (
<Sparkline
theme={theme}
width={240}
height={40}
config={{
custom: {
fillColor: 'rgba(130, 181, 216, 0.1)',
lineColor: '#82B5D8',
},
}}
sparkline={{
y: {
type: FieldType.number,
name: 'test',
config: {},
values: sparklineData.values,
state: {
range: {
...sparklineData.range,
},
},
},
}}
/>
)}
</Stack>
</Card>
</Stack>
{isExpanded && (
<div className={styles.expanded}>
{views.map((view, index) => {
return (
<Card
key={index}
href={view.url}
onClick={() => {
store.setObject('CLICKING_HISTORY', true);
onClick();
}}
isCompact={true}
className={view.time === selectedViewTime ? undefined : styles.card}
>
<Stack direction="column" gap={0}>
<Text variant="bodySmall">{view.name}</Text>
{view.description && (
<Text color="secondary" variant="bodySmall">
{view.description}
</Text>
)}
</Stack>
</Card>
);
})}
</div>
)}
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
card: css({
background: 'none',
}),
iconButton: css({
margin: 0,
}),
expanded: css({
display: 'flex',
flexDirection: 'column',
marginLeft: theme.spacing(5),
gap: theme.spacing(1),
position: 'relative',
'&:before': {
content: '""',
position: 'absolute',
left: theme.spacing(-2),
top: 0,
height: '100%',
width: '1px',
background: theme.colors.border.weak,
},
}),
};
};

View File

@ -14,6 +14,7 @@ import { useSelector } from 'app/types';
import { Branding } from '../../Branding/Branding';
import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs';
import { buildBreadcrumbs } from '../../Breadcrumbs/utils';
import { HistoryContainer } from '../History/HistoryContainer';
import { enrichHelpItem } from '../MegaMenu/utils';
import { NewsContainer } from '../News/NewsContainer';
import { QuickAdd } from '../QuickAdd/QuickAdd';
@ -49,6 +50,7 @@ export const SingleTopBar = memo(function SingleTopBar({
const profileNode = navIndex['profile'];
const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID];
const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav);
const unifiedHistoryEnabled = config.featureToggles.unifiedHistory;
return (
<div className={styles.layout}>
@ -72,6 +74,7 @@ export const SingleTopBar = memo(function SingleTopBar({
<Stack gap={0.5} alignItems="center">
<TopSearchBarCommandPaletteTrigger />
<QuickAdd />
{unifiedHistoryEnabled && <HistoryContainer />}
{enrichedHelpNode && (
<Dropdown overlay={() => <TopNavBarMenu node={enrichedHelpNode} />} placement="bottom-end">
<ToolbarButton iconOnly icon="question-circle" aria-label="Help" />

View File

@ -5,3 +5,28 @@ export interface ToolbarUpdateProps {
pageNav?: NavModelItem;
actions?: React.ReactNode;
}
export interface HistoryEntryView {
name: string;
description: string;
url: string;
time: number;
}
export interface HistoryEntrySparkline {
values: number[];
range: {
min: number;
max: number;
delta: number;
};
}
export interface HistoryEntry {
name: string;
time: number;
breadcrumbs: NavModelItem[];
url: string;
views: HistoryEntryView[];
sparklineData?: HistoryEntrySparkline;
}

View File

@ -2,7 +2,12 @@ import { NavModelItem } from '@grafana/data';
import { Breadcrumb } from './types';
export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelItem, homeNav?: NavModelItem) {
export function buildBreadcrumbs(
sectionNav: NavModelItem,
pageNav?: NavModelItem,
homeNav?: NavModelItem,
skipHome?: boolean
) {
const crumbs: Breadcrumb[] = [];
let foundHome = false;
let lastPath: string | undefined = undefined;
@ -22,7 +27,9 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
// Check if we found home/root if if so return early
if (homeNav && urlToMatch === homeNav.url) {
if (!skipHome) {
crumbs.unshift({ text: homeNav.text, href: node.url ?? '' });
}
foundHome = true;
return;
}

View File

@ -3,6 +3,7 @@ import { useMemo } from 'react';
import { getTimeZoneInfo, GrafanaTheme2, InternalTimeZones, TIME_FORMAT } from '@grafana/data';
import { convertRawToRange } from '@grafana/data/src/datetime/rangeutil';
import { config } from '@grafana/runtime';
import {
SceneComponentProps,
SceneObjectBase,
@ -15,6 +16,8 @@ import {
SceneVariableValueChangedEvent,
} from '@grafana/scenes';
import { Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { RecordHistoryEntryEvent } from 'app/types/events';
import { DataTrail, DataTrailState, getTopSceneFor } from './DataTrail';
import { SerializedTrailHistory } from './TrailStore/TrailStore';
@ -148,17 +151,26 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
return;
}
this.addTrailStep(
trail,
'time',
parseTimeTooltip({
const tooltip = parseTimeTooltip({
from: newState.from,
to: newState.to,
timeZone: newState.timeZone,
});
this.addTrailStep(trail, 'time', tooltip);
if (config.featureToggles.unifiedHistory) {
appEvents.publish(
new RecordHistoryEntryEvent({
name: 'Time range changed',
description: tooltip,
url: window.location.href,
time: Date.now(),
})
);
}
}
}
});
}

View File

@ -1,5 +1,6 @@
import { AnnotationQuery, BusEventBase, BusEventWithPayload, eventFactory } from '@grafana/data';
import { IconName, ButtonVariant } from '@grafana/ui';
import { HistoryEntryView } from 'app/core/components/AppChrome/types';
/**
* Event Payloads
@ -209,3 +210,7 @@ export class PanelEditEnteredEvent extends BusEventWithPayload<number> {
export class PanelEditExitedEvent extends BusEventWithPayload<number> {
static type = 'panel-edit-finished';
}
export class RecordHistoryEntryEvent extends BusEventWithPayload<HistoryEntryView> {
static type = 'record-history-entry';
}

View File

@ -2176,6 +2176,16 @@
"help/documentation": "Documentation",
"help/keyboard-shortcuts": "Keyboard shortcuts",
"help/support": "Support",
"history-container": {
"drawer-tittle": "History"
},
"history-wrapper": {
"collapse": "Collapse",
"expand": "Expand",
"show-more": "Show more",
"today": "Today",
"yesterday": "Yesterday"
},
"home": {
"title": "Home"
},

View File

@ -2176,6 +2176,16 @@
"help/documentation": "Đőčūmęʼnŧäŧįőʼn",
"help/keyboard-shortcuts": "Ķęyþőäřđ şĥőřŧčūŧş",
"help/support": "Ŝūppőřŧ",
"history-container": {
"drawer-tittle": "Ħįşŧőřy"
},
"history-wrapper": {
"collapse": "Cőľľäpşę",
"expand": "Ēχpäʼnđ",
"show-more": "Ŝĥőŵ mőřę",
"today": "Ŧőđäy",
"yesterday": "Ÿęşŧęřđäy"
},
"home": {
"title": "Ħőmę"
},