Skip to content

Commit

Permalink
chore: event timeline persistent state (#8240)
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
nunogois authored Sep 25, 2024
1 parent a1a24ea commit a95c8d1
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 104 deletions.
15 changes: 4 additions & 11 deletions frontend/src/component/events/EventTimeline/EventTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { styled } from '@mui/material';
import type { EventSchema, EventSchemaType } from 'openapi';
import { useState } from 'react';
import { startOfDay, sub } from 'date-fns';
import type { IEnvironment } from 'interfaces/environments';
import { useEventSearch } from 'hooks/api/getters/useEventSearch/useEventSearch';
import { EventTimelineEvent } from './EventTimelineEvent/EventTimelineEvent';
import {
EventTimelineHeader,
type TimeSpanOption,
timeSpanOptions,
} from './EventTimelineHeader/EventTimelineHeader';
import { EventTimelineHeader } from './EventTimelineHeader/EventTimelineHeader';
import { useEventTimeline } from './useEventTimeline';

export type EnrichedEvent = EventSchema & {
label: string;
Expand Down Expand Up @@ -88,10 +83,8 @@ const RELEVANT_EVENT_TYPES: EventSchemaType[] = [
const toISODateString = (date: Date) => date.toISOString().split('T')[0];

export const EventTimeline = () => {
const [timeSpan, setTimeSpan] = useState<TimeSpanOption>(
timeSpanOptions[0],
);
const [environment, setEnvironment] = useState<IEnvironment | undefined>();
const { timeSpan, environment, setTimeSpan, setEnvironment } =
useEventTimeline();

const endDate = new Date();
const startDate = sub(endDate, timeSpan.value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import type { IEnvironment } from 'interfaces/environments';
import { useEffect, useMemo } from 'react';
import { type TimeSpanOption, timeSpanOptions } from '../useEventTimeline';

const StyledCol = styled('div')(({ theme }) => ({
display: 'flex',
Expand All @@ -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 {
totalEvents: number;
timeSpan: TimeSpanOption;
Expand All @@ -101,7 +46,7 @@ export const EventTimelineHeader = ({
);

useEffect(() => {
if (activeEnvironments.length > 0) {
if (activeEnvironments.length > 0 && !environment) {
const defaultEnvironment =
activeEnvironments.find(({ type }) => type === 'production') ||
activeEnvironments[0];
Expand Down
92 changes: 92 additions & 0 deletions frontend/src/component/events/EventTimeline/useEventTimeline.ts
Original file line number Diff line number Diff line change
@@ -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),
};
};
46 changes: 10 additions & 36 deletions frontend/src/component/layout/MainLayout/MainLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 Header from 'component/menu/Header/Header';
import OldHeader from 'component/menu/Header/OldHeader';
Expand All @@ -17,8 +17,8 @@ import { DraftBanner } from './DraftBanner/DraftBanner';
import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
import { NavigationSidebar } from './NavigationSidebar/NavigationSidebar';
import { useUiFlag } from 'hooks/useUiFlag';
import { EventTimeline } from 'component/events/EventTimeline/EventTimeline';
import AnimateOnMount from 'component/common/AnimateOnMount/AnimateOnMount';
import { useEventTimeline } from 'component/events/EventTimeline/useEventTimeline';
import { MainLayoutEventTimeline } from './MainLayoutEventTimeline';

interface IMainLayoutProps {
children: ReactNode;
Expand Down Expand Up @@ -107,29 +107,16 @@ const MainLayoutContentContainer = styled('div')(({ theme }) => ({
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>(
({ children }, ref) => {
const { uiConfig } = useUiConfig();
const { uiConfig, isOss } = useUiConfig();
const projectId = useOptionalPathParam('projectId');
const { isChangeRequestConfiguredInAnyEnv } = useChangeRequestsEnabled(
projectId || '',
);
const eventTimeline = useUiFlag('eventTimeline');
const [showTimeline, setShowTimeline] = useState(false);
const eventTimeline = useUiFlag('eventTimeline') && !isOss();
const { open: showTimeline, setOpen: setShowTimeline } =
useEventTimeline();

const sidebarNavigationEnabled = useUiFlag('navigationSidebar');
const StyledMainLayoutContent = sidebarNavigationEnabled
Expand Down Expand Up @@ -189,22 +176,9 @@ export const MainLayout = forwardRef<HTMLDivElement, IMainLayoutProps>(
minWidth: 0,
}}
>
<AnimateOnMount
mounted={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>
<MainLayoutEventTimeline
open={eventTimeline && showTimeline}
/>

<StyledMainLayoutContent>
<MainLayoutContentContainer ref={ref}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
};

0 comments on commit a95c8d1

Please sign in to comment.