1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-31 01:16:01 +02:00

feat: store dashboard state (#8382)

This PR stores the dashboard state (selected project and flag) in
localstorage so that you get taken back to the same project and flag
when you refresh the page or navigate away and back.

It also handles scrolling the selected items into view in case they're
below the fold.
This commit is contained in:
Thomas Heartman 2024-10-08 08:21:23 +02:00 committed by GitHub
parent 93883b3767
commit 67f036c0ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 139 additions and 58 deletions

View File

@ -14,7 +14,7 @@ import { ProjectSetupComplete } from './ProjectSetupComplete';
import { ConnectSDK, CreateFlag, ExistingFlag } from './ConnectSDK';
import { LatestProjectEvents } from './LatestProjectEvents';
import { RoleAndOwnerInfo } from './RoleAndOwnerInfo';
import type { FC } from 'react';
import { useEffect, useRef, type FC } from 'react';
import { StyledCardTitle } from './PersonalDashboard';
import type {
PersonalDashboardProjectDetailsSchema,
@ -63,6 +63,51 @@ const ActiveProjectDetails: FC<{
);
};
const ProjectListItem: FC<{
project: PersonalDashboardSchemaProjectsItem;
selected: boolean;
onClick: () => void;
}> = ({ project, selected, onClick }) => {
const activeProjectRef = useRef<HTMLLIElement>(null);
useEffect(() => {
if (activeProjectRef.current) {
activeProjectRef.current.scrollIntoView({
block: 'nearest',
inline: 'start',
});
}
}, []);
return (
<ListItem
disablePadding={true}
sx={{ mb: 1 }}
ref={selected ? activeProjectRef : null}
>
<ListItemButton
sx={listItemStyle}
selected={selected}
onClick={onClick}
>
<ListItemBox>
<ProjectIcon color='primary' />
<StyledCardTitle>{project.name}</StyledCardTitle>
<IconButton
component={Link}
href={`projects/${project.id}`}
size='small'
sx={{ ml: 'auto' }}
>
<LinkIcon titleAccess={`projects/${project.id}`} />
</IconButton>
</ListItemBox>
{selected ? <ActiveProjectDetails project={project} /> : null}
</ListItemButton>
</ListItem>
);
};
export const MyProjects: FC<{
projects: PersonalDashboardSchemaProjectsItem[];
personalDashboardProjectDetails?: PersonalDashboardProjectDetailsSchema;
@ -104,41 +149,12 @@ export const MyProjects: FC<{
>
{projects.map((project) => {
return (
<ListItem
<ProjectListItem
key={project.id}
disablePadding={true}
sx={{ mb: 1 }}
>
<ListItemButton
sx={listItemStyle}
selected={project.id === activeProject}
onClick={() =>
setActiveProject(project.id)
}
>
<ListItemBox>
<ProjectIcon color='primary' />
<StyledCardTitle>
{project.name}
</StyledCardTitle>
<IconButton
component={Link}
href={`projects/${project.id}`}
size='small'
sx={{ ml: 'auto' }}
>
<LinkIcon
titleAccess={`projects/${project.id}`}
/>
</IconButton>
</ListItemBox>
{project.id === activeProject ? (
<ActiveProjectDetails
project={project}
/>
) : null}
</ListItemButton>
</ListItem>
project={project}
selected={project.id === activeProject}
onClick={() => setActiveProject(project.id)}
/>
);
})}
</List>

View File

@ -117,6 +117,9 @@ const setupNewProject = () => {
// @ts-ignore
HTMLCanvasElement.prototype.getContext = () => {};
//scrollIntoView is not implemented in jsdom
HTMLElement.prototype.scrollIntoView = () => {};
test('Render personal dashboard for a long running project', async () => {
setupLongRunningProject();
render(<PersonalDashboard />);

View File

@ -8,14 +8,14 @@ import {
styled,
Typography,
} from '@mui/material';
import React, { type FC, useEffect, useState } from 'react';
import React, { type FC, useEffect, useRef } from 'react';
import LinkIcon from '@mui/icons-material/ArrowForward';
import { WelcomeDialog } from './WelcomeDialog';
import { useLocalStorageState } from 'hooks/useLocalStorageState';
import { usePersonalDashboard } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboard';
import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons';
import type {
PersonalDashboardSchema,
PersonalDashboardSchemaFlagsItem,
PersonalDashboardSchemaProjectsItem,
} from '../../openapi';
import { FlagExposure } from 'component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FlagExposure';
@ -61,9 +61,24 @@ const FlagListItem: FC<{
selected: boolean;
onClick: () => void;
}> = ({ flag, selected, onClick }) => {
const activeFlagRef = useRef<HTMLLIElement>(null);
useEffect(() => {
if (activeFlagRef.current) {
activeFlagRef.current.scrollIntoView({
block: 'nearest',
inline: 'start',
});
}
}, []);
const IconComponent = getFeatureTypeIcons(flag.type);
return (
<ListItem key={flag.name} disablePadding={true} sx={{ mb: 1 }}>
<ListItem
key={flag.name}
disablePadding={true}
sx={{ mb: 1 }}
ref={selected ? activeFlagRef : null}
>
<ListItemButton
sx={listItemStyle}
selected={selected}
@ -88,16 +103,69 @@ const FlagListItem: FC<{
);
};
const useActiveProject = (projects: PersonalDashboardSchemaProjectsItem[]) => {
const [activeProject, setActiveProject] = useState(projects[0]?.id);
const useDashboardState = (
projects: PersonalDashboardSchemaProjectsItem[],
flags: PersonalDashboardSchemaFlagsItem[],
) => {
type State = {
activeProject: string | undefined;
activeFlag: PersonalDashboardSchemaFlagsItem | undefined;
};
const defaultState = {
activeProject: undefined,
activeFlag: undefined,
};
const [state, setState] = useLocalStorageState<State>(
'personal-dashboard:v1',
defaultState,
);
useEffect(() => {
if (!activeProject && projects.length > 0) {
setActiveProject(projects[0].id);
}
}, [JSON.stringify(projects)]);
const setDefaultFlag =
flags.length &&
(!state.activeFlag ||
!flags.some((flag) => flag.name === state.activeFlag?.name));
const setDefaultProject =
projects.length &&
(!state.activeProject ||
!projects.some(
(project) => project.id === state.activeProject,
));
return [activeProject, setActiveProject] as const;
if (setDefaultFlag || setDefaultProject) {
setState({
activeFlag: setDefaultFlag ? flags[0] : state.activeFlag,
activeProject: setDefaultProject
? projects[0].id
: state.activeProject,
});
}
});
const { activeFlag, activeProject } = state;
const setActiveFlag = (flag: PersonalDashboardSchemaFlagsItem) => {
setState({
...state,
activeFlag: flag,
});
};
const setActiveProject = (projectId: string) => {
setState({
...state,
activeProject: projectId,
});
};
return {
activeFlag,
setActiveFlag,
activeProject,
setActiveProject,
};
};
export const PersonalDashboard = () => {
@ -110,22 +178,16 @@ export const PersonalDashboard = () => {
refetch: refetchDashboard,
loading: personalDashboardLoading,
} = usePersonalDashboard();
const [activeFlag, setActiveFlag] = useState<
PersonalDashboardSchema['flags'][0] | null
>(null);
useEffect(() => {
if (personalDashboard?.flags.length) {
setActiveFlag(personalDashboard.flags[0]);
}
}, [JSON.stringify(personalDashboard?.flags)]);
const projects = personalDashboard?.projects || [];
const { activeProject, setActiveProject, activeFlag, setActiveFlag } =
useDashboardState(projects, personalDashboard?.flags ?? []);
const [welcomeDialog, setWelcomeDialog] = useLocalStorageState<
'open' | 'closed'
>('welcome-dialog:v1', 'open');
const projects = personalDashboard?.projects || [];
const [activeProject, setActiveProject] = useActiveProject(projects);
const { personalDashboardProjectDetails, loading: loadingDetails } =
usePersonalDashboardProjectDetails(activeProject);
@ -174,7 +236,7 @@ export const PersonalDashboard = () => {
) : (
<MyProjects
projects={projects}
activeProject={activeProject}
activeProject={activeProject || ''}
setActiveProject={setActiveProject}
personalDashboardProjectDetails={
personalDashboardProjectDetails

View File

@ -11,7 +11,7 @@ export interface IPersonalDashboardProjectDetailsOutput {
}
export const usePersonalDashboardProjectDetails = (
project: string,
project?: string,
): IPersonalDashboardProjectDetailsOutput => {
const { data, error, mutate } = useConditionalSWR(
Boolean(project),