mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-09 01:17:06 +02:00
chore: event timeline persistent state (#8240)
https://linear.app/unleash/issue/2-2700/persist-timeline-state-in-local-storage Implements persistent state management for the event timeline using local storage. I believe this improves UX by persisting both the timeline toggle (visibility) state and applied filters across page refreshes. Includes some scouting/refactoring and some workarounds to prevent the timeline from animating on page load (in most cases).
This commit is contained in:
parent
a1a24ea0b1
commit
a95c8d183f
@ -1,15 +1,10 @@
|
|||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import type { EventSchema, EventSchemaType } from 'openapi';
|
import type { EventSchema, EventSchemaType } from 'openapi';
|
||||||
import { useState } from 'react';
|
|
||||||
import { startOfDay, sub } from 'date-fns';
|
import { startOfDay, sub } from 'date-fns';
|
||||||
import type { IEnvironment } from 'interfaces/environments';
|
|
||||||
import { useEventSearch } from 'hooks/api/getters/useEventSearch/useEventSearch';
|
import { useEventSearch } from 'hooks/api/getters/useEventSearch/useEventSearch';
|
||||||
import { EventTimelineEvent } from './EventTimelineEvent/EventTimelineEvent';
|
import { EventTimelineEvent } from './EventTimelineEvent/EventTimelineEvent';
|
||||||
import {
|
import { EventTimelineHeader } from './EventTimelineHeader/EventTimelineHeader';
|
||||||
EventTimelineHeader,
|
import { useEventTimeline } from './useEventTimeline';
|
||||||
type TimeSpanOption,
|
|
||||||
timeSpanOptions,
|
|
||||||
} from './EventTimelineHeader/EventTimelineHeader';
|
|
||||||
|
|
||||||
export type EnrichedEvent = EventSchema & {
|
export type EnrichedEvent = EventSchema & {
|
||||||
label: string;
|
label: string;
|
||||||
@ -88,10 +83,8 @@ const RELEVANT_EVENT_TYPES: EventSchemaType[] = [
|
|||||||
const toISODateString = (date: Date) => date.toISOString().split('T')[0];
|
const toISODateString = (date: Date) => date.toISOString().split('T')[0];
|
||||||
|
|
||||||
export const EventTimeline = () => {
|
export const EventTimeline = () => {
|
||||||
const [timeSpan, setTimeSpan] = useState<TimeSpanOption>(
|
const { timeSpan, environment, setTimeSpan, setEnvironment } =
|
||||||
timeSpanOptions[0],
|
useEventTimeline();
|
||||||
);
|
|
||||||
const [environment, setEnvironment] = useState<IEnvironment | undefined>();
|
|
||||||
|
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
const startDate = sub(endDate, timeSpan.value);
|
const startDate = sub(endDate, timeSpan.value);
|
||||||
|
@ -3,6 +3,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
|
|||||||
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
||||||
import type { IEnvironment } from 'interfaces/environments';
|
import type { IEnvironment } from 'interfaces/environments';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { type TimeSpanOption, timeSpanOptions } from '../useEventTimeline';
|
||||||
|
|
||||||
const StyledCol = styled('div')(({ theme }) => ({
|
const StyledCol = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -22,62 +23,6 @@ const StyledFilter = styled(TextField)(({ theme }) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export type TimeSpanOption = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
value: Duration;
|
|
||||||
markers: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const timeSpanOptions: TimeSpanOption[] = [
|
|
||||||
{
|
|
||||||
key: '30m',
|
|
||||||
label: 'last 30 min',
|
|
||||||
value: { minutes: 30 },
|
|
||||||
markers: ['30 min ago'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '1h',
|
|
||||||
label: 'last hour',
|
|
||||||
value: { hours: 1 },
|
|
||||||
markers: ['1 hour ago', '30 min ago'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '3h',
|
|
||||||
label: 'last 3 hours',
|
|
||||||
value: { hours: 3 },
|
|
||||||
markers: ['3 hours ago', '2 hours ago', '1 hour ago'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '12h',
|
|
||||||
label: 'last 12 hours',
|
|
||||||
value: { hours: 12 },
|
|
||||||
markers: ['12 hours ago', '9 hours ago', '6 hours ago', '3 hours ago'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '24h',
|
|
||||||
label: 'last 24 hours',
|
|
||||||
value: { hours: 24 },
|
|
||||||
markers: [
|
|
||||||
'24 hours ago',
|
|
||||||
'18 hours ago',
|
|
||||||
'12 hours ago',
|
|
||||||
'6 hours ago',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '48h',
|
|
||||||
label: 'last 48 hours',
|
|
||||||
value: { hours: 48 },
|
|
||||||
markers: [
|
|
||||||
'48 hours ago',
|
|
||||||
'36 hours ago',
|
|
||||||
'24 hours ago',
|
|
||||||
'12 hours ago',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface IEventTimelineHeaderProps {
|
interface IEventTimelineHeaderProps {
|
||||||
totalEvents: number;
|
totalEvents: number;
|
||||||
timeSpan: TimeSpanOption;
|
timeSpan: TimeSpanOption;
|
||||||
@ -101,7 +46,7 @@ export const EventTimelineHeader = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeEnvironments.length > 0) {
|
if (activeEnvironments.length > 0 && !environment) {
|
||||||
const defaultEnvironment =
|
const defaultEnvironment =
|
||||||
activeEnvironments.find(({ type }) => type === 'production') ||
|
activeEnvironments.find(({ type }) => type === 'production') ||
|
||||||
activeEnvironments[0];
|
activeEnvironments[0];
|
||||||
|
@ -0,0 +1,92 @@
|
|||||||
|
import { useLocalStorageState } from 'hooks/useLocalStorageState';
|
||||||
|
import type { IEnvironment } from 'interfaces/environments';
|
||||||
|
|
||||||
|
export type TimeSpanOption = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: Duration;
|
||||||
|
markers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const timeSpanOptions: TimeSpanOption[] = [
|
||||||
|
{
|
||||||
|
key: '30m',
|
||||||
|
label: 'last 30 min',
|
||||||
|
value: { minutes: 30 },
|
||||||
|
markers: ['30 min ago'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '1h',
|
||||||
|
label: 'last hour',
|
||||||
|
value: { hours: 1 },
|
||||||
|
markers: ['1 hour ago', '30 min ago'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3h',
|
||||||
|
label: 'last 3 hours',
|
||||||
|
value: { hours: 3 },
|
||||||
|
markers: ['3 hours ago', '2 hours ago', '1 hour ago'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '12h',
|
||||||
|
label: 'last 12 hours',
|
||||||
|
value: { hours: 12 },
|
||||||
|
markers: ['12 hours ago', '9 hours ago', '6 hours ago', '3 hours ago'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '24h',
|
||||||
|
label: 'last 24 hours',
|
||||||
|
value: { hours: 24 },
|
||||||
|
markers: [
|
||||||
|
'24 hours ago',
|
||||||
|
'18 hours ago',
|
||||||
|
'12 hours ago',
|
||||||
|
'6 hours ago',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '48h',
|
||||||
|
label: 'last 48 hours',
|
||||||
|
value: { hours: 48 },
|
||||||
|
markers: [
|
||||||
|
'48 hours ago',
|
||||||
|
'36 hours ago',
|
||||||
|
'24 hours ago',
|
||||||
|
'12 hours ago',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type EventTimelineState = {
|
||||||
|
open: boolean;
|
||||||
|
timeSpan: TimeSpanOption;
|
||||||
|
environment?: IEnvironment;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultState: EventTimelineState = {
|
||||||
|
open: true,
|
||||||
|
timeSpan: timeSpanOptions[0],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEventTimeline = () => {
|
||||||
|
const [state, setState] = useLocalStorageState<EventTimelineState>(
|
||||||
|
'event-timeline:v1',
|
||||||
|
defaultState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const setField = <K extends keyof EventTimelineState>(
|
||||||
|
key: K,
|
||||||
|
value: EventTimelineState[K],
|
||||||
|
) => {
|
||||||
|
setState((prevState) => ({ ...prevState, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
setOpen: (open: boolean) => setField('open', open),
|
||||||
|
setTimeSpan: (timeSpan: TimeSpanOption) =>
|
||||||
|
setField('timeSpan', timeSpan),
|
||||||
|
setEnvironment: (environment: IEnvironment) =>
|
||||||
|
setField('environment', environment),
|
||||||
|
};
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
import { forwardRef, useState, type ReactNode } from 'react';
|
import { forwardRef, type ReactNode } from 'react';
|
||||||
import { Box, Grid, styled, useMediaQuery, useTheme } from '@mui/material';
|
import { Box, Grid, styled, useMediaQuery, useTheme } from '@mui/material';
|
||||||
import Header from 'component/menu/Header/Header';
|
import Header from 'component/menu/Header/Header';
|
||||||
import OldHeader from 'component/menu/Header/OldHeader';
|
import OldHeader from 'component/menu/Header/OldHeader';
|
||||||
@ -17,8 +17,8 @@ import { DraftBanner } from './DraftBanner/DraftBanner';
|
|||||||
import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
|
import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
|
||||||
import { NavigationSidebar } from './NavigationSidebar/NavigationSidebar';
|
import { NavigationSidebar } from './NavigationSidebar/NavigationSidebar';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
import { EventTimeline } from 'component/events/EventTimeline/EventTimeline';
|
import { useEventTimeline } from 'component/events/EventTimeline/useEventTimeline';
|
||||||
import AnimateOnMount from 'component/common/AnimateOnMount/AnimateOnMount';
|
import { MainLayoutEventTimeline } from './MainLayoutEventTimeline';
|
||||||
|
|
||||||
interface IMainLayoutProps {
|
interface IMainLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@ -107,29 +107,16 @@ const MainLayoutContentContainer = styled('div')(({ theme }) => ({
|
|||||||
zIndex: 200,
|
zIndex: 200,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const timelineAnimations = {
|
|
||||||
start: {
|
|
||||||
maxHeight: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: 'max-height 0.3s ease-in-out',
|
|
||||||
},
|
|
||||||
enter: {
|
|
||||||
maxHeight: '105px',
|
|
||||||
},
|
|
||||||
leave: {
|
|
||||||
maxHeight: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MainLayout = forwardRef<HTMLDivElement, IMainLayoutProps>(
|
export const MainLayout = forwardRef<HTMLDivElement, IMainLayoutProps>(
|
||||||
({ children }, ref) => {
|
({ children }, ref) => {
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig, isOss } = useUiConfig();
|
||||||
const projectId = useOptionalPathParam('projectId');
|
const projectId = useOptionalPathParam('projectId');
|
||||||
const { isChangeRequestConfiguredInAnyEnv } = useChangeRequestsEnabled(
|
const { isChangeRequestConfiguredInAnyEnv } = useChangeRequestsEnabled(
|
||||||
projectId || '',
|
projectId || '',
|
||||||
);
|
);
|
||||||
const eventTimeline = useUiFlag('eventTimeline');
|
const eventTimeline = useUiFlag('eventTimeline') && !isOss();
|
||||||
const [showTimeline, setShowTimeline] = useState(false);
|
const { open: showTimeline, setOpen: setShowTimeline } =
|
||||||
|
useEventTimeline();
|
||||||
|
|
||||||
const sidebarNavigationEnabled = useUiFlag('navigationSidebar');
|
const sidebarNavigationEnabled = useUiFlag('navigationSidebar');
|
||||||
const StyledMainLayoutContent = sidebarNavigationEnabled
|
const StyledMainLayoutContent = sidebarNavigationEnabled
|
||||||
@ -189,22 +176,9 @@ export const MainLayout = forwardRef<HTMLDivElement, IMainLayoutProps>(
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AnimateOnMount
|
<MainLayoutEventTimeline
|
||||||
mounted={eventTimeline && showTimeline}
|
open={eventTimeline && showTimeline}
|
||||||
start={timelineAnimations.start}
|
/>
|
||||||
enter={timelineAnimations.enter}
|
|
||||||
leave={timelineAnimations.leave}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={(theme) => ({
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
backgroundColor:
|
|
||||||
theme.palette.background.paper,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<EventTimeline />
|
|
||||||
</Box>
|
|
||||||
</AnimateOnMount>
|
|
||||||
|
|
||||||
<StyledMainLayoutContent>
|
<StyledMainLayoutContent>
|
||||||
<MainLayoutContentContainer ref={ref}>
|
<MainLayoutContentContainer ref={ref}>
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { EventTimeline } from 'component/events/EventTimeline/EventTimeline';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface IMainLayoutEventTimelineProps {
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MainLayoutEventTimeline = ({
|
||||||
|
open,
|
||||||
|
}: IMainLayoutEventTimelineProps) => {
|
||||||
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsInitialLoad(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: isInitialLoad
|
||||||
|
? 'none'
|
||||||
|
: 'max-height 0.3s ease-in-out',
|
||||||
|
maxHeight: open ? '105px' : '0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={(theme) => ({
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<EventTimeline />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user