mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02: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:  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: 
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,160 +130,116 @@ const ProjectListItem: FC<{ | |||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| type MyProjectsState = 'no projects' | 'projects' | 'projects with error'; | export const MyProjects: React.FC<{ | ||||||
| 
 |  | ||||||
| export const MyProjects = forwardRef< |  | ||||||
|     HTMLDivElement, |  | ||||||
|     { |  | ||||||
|     projects: PersonalDashboardSchemaProjectsItem[]; |     projects: PersonalDashboardSchemaProjectsItem[]; | ||||||
|         personalDashboardProjectDetails?: PersonalDashboardProjectDetailsSchema; |     personalDashboardProjectDetails: RemoteData<PersonalDashboardProjectDetailsSchema>; | ||||||
|     activeProject: string; |     activeProject: string; | ||||||
|     setActiveProject: (project: string) => void; |     setActiveProject: (project: string) => void; | ||||||
|     admins: PersonalDashboardSchemaAdminsItem[]; |     admins: PersonalDashboardSchemaAdminsItem[]; | ||||||
|     owners: PersonalDashboardSchemaProjectOwnersItem[]; |     owners: PersonalDashboardSchemaProjectOwnersItem[]; | ||||||
|     } | }> = ({ | ||||||
| >( |  | ||||||
|     ( |  | ||||||
|         { |  | ||||||
|     projects, |     projects, | ||||||
|     personalDashboardProjectDetails, |     personalDashboardProjectDetails, | ||||||
|     setActiveProject, |     setActiveProject, | ||||||
|     activeProject, |     activeProject, | ||||||
|     admins, |     admins, | ||||||
|     owners, |     owners, | ||||||
|         }, | }) => { | ||||||
|         ref, |     const ref = useLoading(personalDashboardProjectDetails.state === 'loading'); | ||||||
|     ) => { |  | ||||||
|         const state: MyProjectsState = projects.length |  | ||||||
|             ? personalDashboardProjectDetails |  | ||||||
|                 ? 'projects' |  | ||||||
|                 : 'projects with error' |  | ||||||
|             : 'no projects'; |  | ||||||
| 
 |  | ||||||
|         const activeProjectStage = |  | ||||||
|             personalDashboardProjectDetails?.onboardingStatus.status ?? |  | ||||||
|             'loading'; |  | ||||||
|         const setupIncomplete = |  | ||||||
|             activeProjectStage === 'onboarding-started' || |  | ||||||
|             activeProjectStage === 'first-flag-created'; |  | ||||||
| 
 | 
 | ||||||
|     const getGridContents = (): { |     const getGridContents = (): { | ||||||
|         list: ReactNode; |         list: ReactNode; | ||||||
|         box1: ReactNode; |         box1: ReactNode; | ||||||
|         box2: ReactNode; |         box2: ReactNode; | ||||||
|     } => { |     } => { | ||||||
|             switch (state) { |         if (projects.length === 0) { | ||||||
|                 case 'no projects': |  | ||||||
|             return { |             return { | ||||||
|                 list: ( |                 list: ( | ||||||
|                     <ActionBox> |                     <ActionBox> | ||||||
|                         <Typography> |                         <Typography> | ||||||
|                                     You don't currently have access to any |                             You don't currently have access to any projects in | ||||||
|                                     projects in the system. |                             the system. | ||||||
|                         </Typography> |                         </Typography> | ||||||
|                         <Typography> |                         <Typography> | ||||||
|                             To get started, you can{' '} |                             To get started, you can{' '} | ||||||
|                             <Link to='/projects?create=true'> |                             <Link to='/projects?create=true'> | ||||||
|                                 create your own project |                                 create your own project | ||||||
|                             </Link> |                             </Link> | ||||||
|                                     . Alternatively, you can review the |                             . Alternatively, you can review the available | ||||||
|                                     available projects in the system and ask the |                             projects in the system and ask the owner for access. | ||||||
|                                     owner for access. |  | ||||||
|                         </Typography> |                         </Typography> | ||||||
|                     </ActionBox> |                     </ActionBox> | ||||||
|                 ), |                 ), | ||||||
|                 box1: <NoProjectsContactAdmin admins={admins} />, |                 box1: <NoProjectsContactAdmin admins={admins} />, | ||||||
|                         box2: ( |                 box2: <AskOwnerToAddYouToTheirProject owners={owners} />, | ||||||
|                             <AskOwnerToAddYouToTheirProject owners={owners} /> |  | ||||||
|                         ), |  | ||||||
|             }; |             }; | ||||||
|  |         } | ||||||
| 
 | 
 | ||||||
|                 case 'projects with error': |         const list = ( | ||||||
|                     return { |  | ||||||
|                         list: ( |  | ||||||
|             <StyledList> |             <StyledList> | ||||||
|                 {projects.map((project) => ( |                 {projects.map((project) => ( | ||||||
|                     <ProjectListItem |                     <ProjectListItem | ||||||
|                         key={project.id} |                         key={project.id} | ||||||
|                         project={project} |                         project={project} | ||||||
|                         selected={project.id === activeProject} |                         selected={project.id === activeProject} | ||||||
|                                         onClick={() => |                         onClick={() => setActiveProject(project.id)} | ||||||
|                                             setActiveProject(project.id) |  | ||||||
|                                         } |  | ||||||
|                     /> |                     /> | ||||||
|                 ))} |                 ))} | ||||||
|             </StyledList> |             </StyledList> | ||||||
|                         ), |         ); | ||||||
|                         box1: <DataError project={activeProject} />, |  | ||||||
|                         box2: <ContactAdmins admins={admins} />, |  | ||||||
|                     }; |  | ||||||
| 
 | 
 | ||||||
|                 case 'projects': { |         const [box1, box2] = (() => { | ||||||
|                     const box1 = (() => { |             switch (personalDashboardProjectDetails.state) { | ||||||
|                         if ( |                 case 'success': { | ||||||
|                             activeProjectStage === 'onboarded' && |                     const activeProjectStage = | ||||||
|                             personalDashboardProjectDetails |                         personalDashboardProjectDetails.data.onboardingStatus | ||||||
|                         ) { |                             .status ?? 'loading'; | ||||||
|                             return ( |                     const setupIncomplete = | ||||||
|  |                         activeProjectStage === 'onboarding-started' || | ||||||
|  |                         activeProjectStage === 'first-flag-created'; | ||||||
|  | 
 | ||||||
|  |                     if (activeProjectStage === 'onboarded') { | ||||||
|  |                         return [ | ||||||
|                             <ProjectSetupComplete |                             <ProjectSetupComplete | ||||||
|                                 project={activeProject} |                                 project={activeProject} | ||||||
|                                 insights={ |                                 insights={ | ||||||
|                                         personalDashboardProjectDetails.insights |                                     personalDashboardProjectDetails.data | ||||||
|  |                                         .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 |                             <LatestProjectEvents | ||||||
|                                 latestEvents={ |                                 latestEvents={ | ||||||
|                                         personalDashboardProjectDetails.latestEvents |                                     personalDashboardProjectDetails.data | ||||||
|  |                                         .latestEvents | ||||||
|                                 } |                                 } | ||||||
|                                 /> |                             />, | ||||||
|                             ); |                         ]; | ||||||
|                         } else if ( |                     } else if (setupIncomplete) { | ||||||
|                             setupIncomplete || |                         return [ | ||||||
|                             activeProjectStage === 'loading' |                             <CreateFlag project={activeProject} />, | ||||||
|                         ) { |                             <ConnectSDK project={activeProject} />, | ||||||
|                             return <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 { |         return { list, box1, box2 }; | ||||||
|                         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(); |     const { list, box1, box2 } = getGridContents(); | ||||||
| @ -289,19 +253,22 @@ export const MyProjects = forwardRef< | |||||||
|                 <GridItem gridArea='owners'> |                 <GridItem gridArea='owners'> | ||||||
|                     <RoleAndOwnerInfo |                     <RoleAndOwnerInfo | ||||||
|                         roles={ |                         roles={ | ||||||
|                                 personalDashboardProjectDetails?.roles.map( |                             personalDashboardProjectDetails.state === 'success' | ||||||
|                                     (role) => role.name, |                                 ? personalDashboardProjectDetails.data.roles.map( | ||||||
|                                 ) ?? [] |                                       ( | ||||||
|  |                                           role: PersonalDashboardProjectDetailsSchemaRolesItem, | ||||||
|  |                                       ) => role.name, | ||||||
|  |                                   ) | ||||||
|  |                                 : [] | ||||||
|                         } |                         } | ||||||
|                         owners={ |                         owners={ | ||||||
|                                 personalDashboardProjectDetails?.owners ?? [ |                             personalDashboardProjectDetails.state === 'success' | ||||||
|                                     { ownerType: 'user', name: '?' }, |                                 ? personalDashboardProjectDetails.data.owners | ||||||
|                                 ] |                                 : [{ ownerType: 'user', name: '?' }] | ||||||
|                         } |                         } | ||||||
|                     /> |                     /> | ||||||
|                 </GridItem> |                 </GridItem> | ||||||
|             </ProjectGrid> |             </ProjectGrid> | ||||||
|         </ContentGridContainer> |         </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,14 +130,9 @@ 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 ( | ||||||
| @ -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