mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-14 00:19:16 +01:00
fix: handle loading states for project details for a single project (#8492)
This PR updates the use of references on the project details page to handle the loading state for a single project. Now, if a project is loading, it'll show skeleton loaders for the relevant boxes: ![image](https://github.com/user-attachments/assets/a156cc88-e4bf-421a-8afe-2b46e26d5544) I've also updated the state type we use for this to be more accurate. Shamelessly stolen from Elm. ```ts type RemoteData<T> = | { state: 'error', error: Error } | { state: 'loading' } | { state: 'success', data: T } ``` After refactoring: ![image](https://github.com/user-attachments/assets/03d655de-1ab8-4289-9f0c-d158ede8e116)
This commit is contained in:
parent
9fecc02462
commit
a8206f5118
@ -1,9 +1,11 @@
|
|||||||
|
import type { RemoteData } from './RemoteData';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
IconButton,
|
IconButton,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemButton,
|
ListItemButton,
|
||||||
Typography,
|
Typography,
|
||||||
|
styled,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ProjectIcon } from '../common/ProjectIcon/ProjectIcon';
|
import { ProjectIcon } from '../common/ProjectIcon/ProjectIcon';
|
||||||
import LinkIcon from '@mui/icons-material/ArrowForward';
|
import LinkIcon from '@mui/icons-material/ArrowForward';
|
||||||
@ -11,9 +13,10 @@ import { ProjectSetupComplete } from './ProjectSetupComplete';
|
|||||||
import { ConnectSDK, CreateFlag, ExistingFlag } from './ConnectSDK';
|
import { ConnectSDK, CreateFlag, ExistingFlag } from './ConnectSDK';
|
||||||
import { LatestProjectEvents } from './LatestProjectEvents';
|
import { LatestProjectEvents } from './LatestProjectEvents';
|
||||||
import { RoleAndOwnerInfo } from './RoleAndOwnerInfo';
|
import { RoleAndOwnerInfo } from './RoleAndOwnerInfo';
|
||||||
import { type ReactNode, forwardRef, useEffect, useRef, type FC } from 'react';
|
import { type ReactNode, useEffect, useRef, type FC } from 'react';
|
||||||
import type {
|
import type {
|
||||||
PersonalDashboardProjectDetailsSchema,
|
PersonalDashboardProjectDetailsSchema,
|
||||||
|
PersonalDashboardProjectDetailsSchemaRolesItem,
|
||||||
PersonalDashboardSchemaAdminsItem,
|
PersonalDashboardSchemaAdminsItem,
|
||||||
PersonalDashboardSchemaProjectOwnersItem,
|
PersonalDashboardSchemaProjectOwnersItem,
|
||||||
PersonalDashboardSchemaProjectsItem,
|
PersonalDashboardSchemaProjectsItem,
|
||||||
@ -33,6 +36,7 @@ import { ContactAdmins, DataError } from './ProjectDetailsError';
|
|||||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ActionBox } from './ActionBox';
|
import { ActionBox } from './ActionBox';
|
||||||
|
import useLoading from 'hooks/useLoading';
|
||||||
import { NoProjectsContactAdmin } from './NoProjectsContactAdmin';
|
import { NoProjectsContactAdmin } from './NoProjectsContactAdmin';
|
||||||
import { AskOwnerToAddYouToTheirProject } from './AskOwnerToAddYouToTheirProject';
|
import { AskOwnerToAddYouToTheirProject } from './AskOwnerToAddYouToTheirProject';
|
||||||
|
|
||||||
@ -69,6 +73,10 @@ const ActiveProjectDetails: FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SkeletonDiv = styled('div')({
|
||||||
|
height: '80%',
|
||||||
|
});
|
||||||
|
|
||||||
const ProjectListItem: FC<{
|
const ProjectListItem: FC<{
|
||||||
project: PersonalDashboardSchemaProjectsItem;
|
project: PersonalDashboardSchemaProjectsItem;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@ -122,186 +130,145 @@ const ProjectListItem: FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type MyProjectsState = 'no projects' | 'projects' | 'projects with error';
|
export const MyProjects: React.FC<{
|
||||||
|
projects: PersonalDashboardSchemaProjectsItem[];
|
||||||
|
personalDashboardProjectDetails: RemoteData<PersonalDashboardProjectDetailsSchema>;
|
||||||
|
activeProject: string;
|
||||||
|
setActiveProject: (project: string) => void;
|
||||||
|
admins: PersonalDashboardSchemaAdminsItem[];
|
||||||
|
owners: PersonalDashboardSchemaProjectOwnersItem[];
|
||||||
|
}> = ({
|
||||||
|
projects,
|
||||||
|
personalDashboardProjectDetails,
|
||||||
|
setActiveProject,
|
||||||
|
activeProject,
|
||||||
|
admins,
|
||||||
|
owners,
|
||||||
|
}) => {
|
||||||
|
const ref = useLoading(personalDashboardProjectDetails.state === 'loading');
|
||||||
|
|
||||||
export const MyProjects = forwardRef<
|
const getGridContents = (): {
|
||||||
HTMLDivElement,
|
list: ReactNode;
|
||||||
{
|
box1: ReactNode;
|
||||||
projects: PersonalDashboardSchemaProjectsItem[];
|
box2: ReactNode;
|
||||||
personalDashboardProjectDetails?: PersonalDashboardProjectDetailsSchema;
|
} => {
|
||||||
activeProject: string;
|
if (projects.length === 0) {
|
||||||
setActiveProject: (project: string) => void;
|
return {
|
||||||
admins: PersonalDashboardSchemaAdminsItem[];
|
list: (
|
||||||
owners: PersonalDashboardSchemaProjectOwnersItem[];
|
<ActionBox>
|
||||||
}
|
<Typography>
|
||||||
>(
|
You don't currently have access to any projects in
|
||||||
(
|
the system.
|
||||||
{
|
</Typography>
|
||||||
projects,
|
<Typography>
|
||||||
personalDashboardProjectDetails,
|
To get started, you can{' '}
|
||||||
setActiveProject,
|
<Link to='/projects?create=true'>
|
||||||
activeProject,
|
create your own project
|
||||||
admins,
|
</Link>
|
||||||
owners,
|
. Alternatively, you can review the available
|
||||||
},
|
projects in the system and ask the owner for access.
|
||||||
ref,
|
</Typography>
|
||||||
) => {
|
</ActionBox>
|
||||||
const state: MyProjectsState = projects.length
|
),
|
||||||
? personalDashboardProjectDetails
|
box1: <NoProjectsContactAdmin admins={admins} />,
|
||||||
? 'projects'
|
box2: <AskOwnerToAddYouToTheirProject owners={owners} />,
|
||||||
: 'projects with error'
|
};
|
||||||
: 'no projects';
|
}
|
||||||
|
|
||||||
const activeProjectStage =
|
const list = (
|
||||||
personalDashboardProjectDetails?.onboardingStatus.status ??
|
<StyledList>
|
||||||
'loading';
|
{projects.map((project) => (
|
||||||
const setupIncomplete =
|
<ProjectListItem
|
||||||
activeProjectStage === 'onboarding-started' ||
|
key={project.id}
|
||||||
activeProjectStage === 'first-flag-created';
|
project={project}
|
||||||
|
selected={project.id === activeProject}
|
||||||
const getGridContents = (): {
|
onClick={() => setActiveProject(project.id)}
|
||||||
list: ReactNode;
|
/>
|
||||||
box1: ReactNode;
|
))}
|
||||||
box2: ReactNode;
|
</StyledList>
|
||||||
} => {
|
|
||||||
switch (state) {
|
|
||||||
case 'no projects':
|
|
||||||
return {
|
|
||||||
list: (
|
|
||||||
<ActionBox>
|
|
||||||
<Typography>
|
|
||||||
You don't currently have access to any
|
|
||||||
projects in the system.
|
|
||||||
</Typography>
|
|
||||||
<Typography>
|
|
||||||
To get started, you can{' '}
|
|
||||||
<Link to='/projects?create=true'>
|
|
||||||
create your own project
|
|
||||||
</Link>
|
|
||||||
. Alternatively, you can review the
|
|
||||||
available projects in the system and ask the
|
|
||||||
owner for access.
|
|
||||||
</Typography>
|
|
||||||
</ActionBox>
|
|
||||||
),
|
|
||||||
box1: <NoProjectsContactAdmin admins={admins} />,
|
|
||||||
box2: (
|
|
||||||
<AskOwnerToAddYouToTheirProject owners={owners} />
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'projects with error':
|
|
||||||
return {
|
|
||||||
list: (
|
|
||||||
<StyledList>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<ProjectListItem
|
|
||||||
key={project.id}
|
|
||||||
project={project}
|
|
||||||
selected={project.id === activeProject}
|
|
||||||
onClick={() =>
|
|
||||||
setActiveProject(project.id)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</StyledList>
|
|
||||||
),
|
|
||||||
box1: <DataError project={activeProject} />,
|
|
||||||
box2: <ContactAdmins admins={admins} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'projects': {
|
|
||||||
const box1 = (() => {
|
|
||||||
if (
|
|
||||||
activeProjectStage === 'onboarded' &&
|
|
||||||
personalDashboardProjectDetails
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<ProjectSetupComplete
|
|
||||||
project={activeProject}
|
|
||||||
insights={
|
|
||||||
personalDashboardProjectDetails.insights
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
activeProjectStage === 'onboarding-started' ||
|
|
||||||
activeProjectStage === 'loading'
|
|
||||||
) {
|
|
||||||
return <CreateFlag project={activeProject} />;
|
|
||||||
} else if (
|
|
||||||
activeProjectStage === 'first-flag-created'
|
|
||||||
) {
|
|
||||||
return <ExistingFlag project={activeProject} />;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const box2 = (() => {
|
|
||||||
if (
|
|
||||||
activeProjectStage === 'onboarded' &&
|
|
||||||
personalDashboardProjectDetails
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<LatestProjectEvents
|
|
||||||
latestEvents={
|
|
||||||
personalDashboardProjectDetails.latestEvents
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
setupIncomplete ||
|
|
||||||
activeProjectStage === 'loading'
|
|
||||||
) {
|
|
||||||
return <ConnectSDK project={activeProject} />;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return {
|
|
||||||
list: (
|
|
||||||
<StyledList>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<ProjectListItem
|
|
||||||
key={project.id}
|
|
||||||
project={project}
|
|
||||||
selected={project.id === activeProject}
|
|
||||||
onClick={() =>
|
|
||||||
setActiveProject(project.id)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</StyledList>
|
|
||||||
),
|
|
||||||
box1,
|
|
||||||
box2,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { list, box1, box2 } = getGridContents();
|
|
||||||
return (
|
|
||||||
<ContentGridContainer ref={ref}>
|
|
||||||
<ProjectGrid>
|
|
||||||
<SpacedGridItem gridArea='projects'>{list}</SpacedGridItem>
|
|
||||||
<SpacedGridItem gridArea='box1'>{box1}</SpacedGridItem>
|
|
||||||
<SpacedGridItem gridArea='box2'>{box2}</SpacedGridItem>
|
|
||||||
<EmptyGridItem />
|
|
||||||
<GridItem gridArea='owners'>
|
|
||||||
<RoleAndOwnerInfo
|
|
||||||
roles={
|
|
||||||
personalDashboardProjectDetails?.roles.map(
|
|
||||||
(role) => role.name,
|
|
||||||
) ?? []
|
|
||||||
}
|
|
||||||
owners={
|
|
||||||
personalDashboardProjectDetails?.owners ?? [
|
|
||||||
{ ownerType: 'user', name: '?' },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</GridItem>
|
|
||||||
</ProjectGrid>
|
|
||||||
</ContentGridContainer>
|
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
const [box1, box2] = (() => {
|
||||||
|
switch (personalDashboardProjectDetails.state) {
|
||||||
|
case 'success': {
|
||||||
|
const activeProjectStage =
|
||||||
|
personalDashboardProjectDetails.data.onboardingStatus
|
||||||
|
.status ?? 'loading';
|
||||||
|
const setupIncomplete =
|
||||||
|
activeProjectStage === 'onboarding-started' ||
|
||||||
|
activeProjectStage === 'first-flag-created';
|
||||||
|
|
||||||
|
if (activeProjectStage === 'onboarded') {
|
||||||
|
return [
|
||||||
|
<ProjectSetupComplete
|
||||||
|
project={activeProject}
|
||||||
|
insights={
|
||||||
|
personalDashboardProjectDetails.data
|
||||||
|
.insights
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
<LatestProjectEvents
|
||||||
|
latestEvents={
|
||||||
|
personalDashboardProjectDetails.data
|
||||||
|
.latestEvents
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
];
|
||||||
|
} else if (setupIncomplete) {
|
||||||
|
return [
|
||||||
|
<CreateFlag project={activeProject} />,
|
||||||
|
<ConnectSDK project={activeProject} />,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
<ExistingFlag project={activeProject} />,
|
||||||
|
<ConnectSDK project={activeProject} />,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'error':
|
||||||
|
return [
|
||||||
|
<DataError project={activeProject} />,
|
||||||
|
<ContactAdmins admins={admins} />,
|
||||||
|
];
|
||||||
|
default: // loading
|
||||||
|
return [
|
||||||
|
<SkeletonDiv data-loading />,
|
||||||
|
<SkeletonDiv data-loading />,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { list, box1, box2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { list, box1, box2 } = getGridContents();
|
||||||
|
return (
|
||||||
|
<ContentGridContainer ref={ref}>
|
||||||
|
<ProjectGrid>
|
||||||
|
<SpacedGridItem gridArea='projects'>{list}</SpacedGridItem>
|
||||||
|
<SpacedGridItem gridArea='box1'>{box1}</SpacedGridItem>
|
||||||
|
<SpacedGridItem gridArea='box2'>{box2}</SpacedGridItem>
|
||||||
|
<EmptyGridItem />
|
||||||
|
<GridItem gridArea='owners'>
|
||||||
|
<RoleAndOwnerInfo
|
||||||
|
roles={
|
||||||
|
personalDashboardProjectDetails.state === 'success'
|
||||||
|
? personalDashboardProjectDetails.data.roles.map(
|
||||||
|
(
|
||||||
|
role: PersonalDashboardProjectDetailsSchemaRolesItem,
|
||||||
|
) => role.name,
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
owners={
|
||||||
|
personalDashboardProjectDetails.state === 'success'
|
||||||
|
? personalDashboardProjectDetails.data.owners
|
||||||
|
: [{ ownerType: 'user', name: '?' }]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</GridItem>
|
||||||
|
</ProjectGrid>
|
||||||
|
</ContentGridContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -11,7 +11,6 @@ import { WelcomeDialog } from './WelcomeDialog';
|
|||||||
import { useLocalStorageState } from 'hooks/useLocalStorageState';
|
import { useLocalStorageState } from 'hooks/useLocalStorageState';
|
||||||
import { usePersonalDashboard } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboard';
|
import { usePersonalDashboard } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboard';
|
||||||
import { usePersonalDashboardProjectDetails } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboardProjectDetails';
|
import { usePersonalDashboardProjectDetails } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboardProjectDetails';
|
||||||
import useLoading from '../../hooks/useLoading';
|
|
||||||
import { MyProjects } from './MyProjects';
|
import { MyProjects } from './MyProjects';
|
||||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||||
@ -20,6 +19,7 @@ import { useAuthSplash } from 'hooks/api/getters/useAuth/useAuthSplash';
|
|||||||
import { useDashboardState } from './useDashboardState';
|
import { useDashboardState } from './useDashboardState';
|
||||||
import { MyFlags } from './MyFlags';
|
import { MyFlags } from './MyFlags';
|
||||||
import { usePageTitle } from 'hooks/usePageTitle';
|
import { usePageTitle } from 'hooks/usePageTitle';
|
||||||
|
import { fromPersonalDashboardProjectDetailsOutput } from './RemoteData';
|
||||||
|
|
||||||
const WelcomeSection = styled('div')(({ theme }) => ({
|
const WelcomeSection = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -130,15 +130,10 @@ export const PersonalDashboard = () => {
|
|||||||
splash?.personalDashboardKeyConcepts ? 'closed' : 'open',
|
splash?.personalDashboardKeyConcepts ? 'closed' : 'open',
|
||||||
);
|
);
|
||||||
|
|
||||||
const { personalDashboardProjectDetails, error: detailsError } =
|
const personalDashboardProjectDetails =
|
||||||
usePersonalDashboardProjectDetails(activeProject);
|
fromPersonalDashboardProjectDetailsOutput(
|
||||||
|
usePersonalDashboardProjectDetails(activeProject),
|
||||||
const activeProjectStage =
|
);
|
||||||
personalDashboardProjectDetails?.onboardingStatus.status ?? 'loading';
|
|
||||||
|
|
||||||
const projectStageRef = useLoading(
|
|
||||||
!detailsError && activeProjectStage === 'loading',
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainContent>
|
<MainContent>
|
||||||
@ -192,7 +187,6 @@ export const PersonalDashboard = () => {
|
|||||||
<MyProjects
|
<MyProjects
|
||||||
owners={personalDashboard?.projectOwners ?? []}
|
owners={personalDashboard?.projectOwners ?? []}
|
||||||
admins={personalDashboard?.admins ?? []}
|
admins={personalDashboard?.admins ?? []}
|
||||||
ref={projectStageRef}
|
|
||||||
projects={projects}
|
projects={projects}
|
||||||
activeProject={activeProject || ''}
|
activeProject={activeProject || ''}
|
||||||
setActiveProject={setActiveProject}
|
setActiveProject={setActiveProject}
|
||||||
|
@ -5,10 +5,7 @@ import { ActionBox } from './ActionBox';
|
|||||||
|
|
||||||
export const DataError: FC<{ project: string }> = ({ project }) => {
|
export const DataError: FC<{ project: string }> = ({ project }) => {
|
||||||
return (
|
return (
|
||||||
<ActionBox
|
<ActionBox title={`Couldn't fetch data for project "${project}".`}>
|
||||||
data-loading
|
|
||||||
title={`Couldn't fetch data for project "${project}".`}
|
|
||||||
>
|
|
||||||
<p>
|
<p>
|
||||||
The API request to get data for this project returned with an
|
The API request to get data for this project returned with an
|
||||||
error.
|
error.
|
||||||
|
28
frontend/src/component/personalDashboard/RemoteData.ts
Normal file
28
frontend/src/component/personalDashboard/RemoteData.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import type { IPersonalDashboardProjectDetailsOutput } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboardProjectDetails';
|
||||||
|
import type { PersonalDashboardProjectDetailsSchema } from 'openapi';
|
||||||
|
|
||||||
|
export type RemoteData<T> =
|
||||||
|
| { state: 'error'; error: Error }
|
||||||
|
| { state: 'loading' }
|
||||||
|
| { state: 'success'; data: T };
|
||||||
|
|
||||||
|
export const fromPersonalDashboardProjectDetailsOutput = ({
|
||||||
|
personalDashboardProjectDetails,
|
||||||
|
error,
|
||||||
|
}: IPersonalDashboardProjectDetailsOutput): RemoteData<PersonalDashboardProjectDetailsSchema> => {
|
||||||
|
const converted = error
|
||||||
|
? {
|
||||||
|
state: 'error',
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
: personalDashboardProjectDetails
|
||||||
|
? {
|
||||||
|
state: 'success',
|
||||||
|
data: personalDashboardProjectDetails,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
state: 'loading' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return converted as RemoteData<PersonalDashboardProjectDetailsSchema>;
|
||||||
|
};
|
@ -51,7 +51,7 @@ export const RoleAndOwnerInfo = ({ roles, owners }: Props) => {
|
|||||||
const firstRoles = roles.slice(0, 3);
|
const firstRoles = roles.slice(0, 3);
|
||||||
const extraRoles = roles.slice(3);
|
const extraRoles = roles.slice(3);
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper data-loading>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
{roles.length > 0 ? (
|
{roles.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
|
Loading…
Reference in New Issue
Block a user