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:
parent
93883b3767
commit
67f036c0ab
@ -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>
|
||||
|
@ -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 />);
|
||||
|
@ -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
|
||||
|
@ -11,7 +11,7 @@ export interface IPersonalDashboardProjectDetailsOutput {
|
||||
}
|
||||
|
||||
export const usePersonalDashboardProjectDetails = (
|
||||
project: string,
|
||||
project?: string,
|
||||
): IPersonalDashboardProjectDetailsOutput => {
|
||||
const { data, error, mutate } = useConditionalSWR(
|
||||
Boolean(project),
|
||||
|
Loading…
Reference in New Issue
Block a user