From a8206f51185c38f6fb4f14f4add1b3cc17ba1797 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Mon, 21 Oct 2024 14:27:43 +0200 Subject: [PATCH] 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 = | { state: 'error', error: Error } | { state: 'loading' } | { state: 'success', data: T } ``` After refactoring: ![image](https://github.com/user-attachments/assets/03d655de-1ab8-4289-9f0c-d158ede8e116) --- .../personalDashboard/MyProjects.tsx | 321 ++++++++---------- .../personalDashboard/PersonalDashboard.tsx | 16 +- .../personalDashboard/ProjectDetailsError.tsx | 5 +- .../component/personalDashboard/RemoteData.ts | 28 ++ .../personalDashboard/RoleAndOwnerInfo.tsx | 2 +- 5 files changed, 179 insertions(+), 193 deletions(-) create mode 100644 frontend/src/component/personalDashboard/RemoteData.ts diff --git a/frontend/src/component/personalDashboard/MyProjects.tsx b/frontend/src/component/personalDashboard/MyProjects.tsx index d509a93c37c3..402e12e1ac0d 100644 --- a/frontend/src/component/personalDashboard/MyProjects.tsx +++ b/frontend/src/component/personalDashboard/MyProjects.tsx @@ -1,9 +1,11 @@ +import type { RemoteData } from './RemoteData'; import { Box, IconButton, ListItem, ListItemButton, Typography, + styled, } from '@mui/material'; import { ProjectIcon } from '../common/ProjectIcon/ProjectIcon'; import LinkIcon from '@mui/icons-material/ArrowForward'; @@ -11,9 +13,10 @@ import { ProjectSetupComplete } from './ProjectSetupComplete'; import { ConnectSDK, CreateFlag, ExistingFlag } from './ConnectSDK'; import { LatestProjectEvents } from './LatestProjectEvents'; 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 { PersonalDashboardProjectDetailsSchema, + PersonalDashboardProjectDetailsSchemaRolesItem, PersonalDashboardSchemaAdminsItem, PersonalDashboardSchemaProjectOwnersItem, PersonalDashboardSchemaProjectsItem, @@ -33,6 +36,7 @@ import { ContactAdmins, DataError } from './ProjectDetailsError'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { Link } from 'react-router-dom'; import { ActionBox } from './ActionBox'; +import useLoading from 'hooks/useLoading'; import { NoProjectsContactAdmin } from './NoProjectsContactAdmin'; import { AskOwnerToAddYouToTheirProject } from './AskOwnerToAddYouToTheirProject'; @@ -69,6 +73,10 @@ const ActiveProjectDetails: FC<{ ); }; +const SkeletonDiv = styled('div')({ + height: '80%', +}); + const ProjectListItem: FC<{ project: PersonalDashboardSchemaProjectsItem; selected: boolean; @@ -122,186 +130,145 @@ const ProjectListItem: FC<{ ); }; -type MyProjectsState = 'no projects' | 'projects' | 'projects with error'; - -export const MyProjects = forwardRef< - HTMLDivElement, - { - projects: PersonalDashboardSchemaProjectsItem[]; - personalDashboardProjectDetails?: PersonalDashboardProjectDetailsSchema; - activeProject: string; - setActiveProject: (project: string) => void; - admins: PersonalDashboardSchemaAdminsItem[]; - owners: PersonalDashboardSchemaProjectOwnersItem[]; - } ->( - ( - { - projects, - personalDashboardProjectDetails, - setActiveProject, - activeProject, - admins, - owners, - }, - ref, - ) => { - 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 = (): { - list: ReactNode; - box1: ReactNode; - box2: ReactNode; - } => { - switch (state) { - case 'no projects': - return { - list: ( - - - You don't currently have access to any - projects in the system. - - - To get started, you can{' '} - - create your own project - - . Alternatively, you can review the - available projects in the system and ask the - owner for access. - - - ), - box1: , - box2: ( - - ), - }; +export const MyProjects: React.FC<{ + projects: PersonalDashboardSchemaProjectsItem[]; + personalDashboardProjectDetails: RemoteData; + activeProject: string; + setActiveProject: (project: string) => void; + admins: PersonalDashboardSchemaAdminsItem[]; + owners: PersonalDashboardSchemaProjectOwnersItem[]; +}> = ({ + projects, + personalDashboardProjectDetails, + setActiveProject, + activeProject, + admins, + owners, +}) => { + const ref = useLoading(personalDashboardProjectDetails.state === 'loading'); - case 'projects with error': - return { - list: ( - - {projects.map((project) => ( - - setActiveProject(project.id) - } - /> - ))} - - ), - box1: , - box2: , - }; + const getGridContents = (): { + list: ReactNode; + box1: ReactNode; + box2: ReactNode; + } => { + if (projects.length === 0) { + return { + list: ( + + + You don't currently have access to any projects in + the system. + + + To get started, you can{' '} + + create your own project + + . Alternatively, you can review the available + projects in the system and ask the owner for access. + + + ), + box1: , + box2: , + }; + } - case 'projects': { - const box1 = (() => { - if ( - activeProjectStage === 'onboarded' && - personalDashboardProjectDetails - ) { - return ( - - ); - } else if ( - activeProjectStage === 'onboarding-started' || - activeProjectStage === 'loading' - ) { - return ; - } else if ( - activeProjectStage === 'first-flag-created' - ) { - return ; - } - })(); + const list = ( + + {projects.map((project) => ( + setActiveProject(project.id)} + /> + ))} + + ); - const box2 = (() => { - if ( - activeProjectStage === 'onboarded' && - personalDashboardProjectDetails - ) { - return ( - - ); - } else if ( - setupIncomplete || - activeProjectStage === 'loading' - ) { - return ; - } - })(); + const [box1, box2] = (() => { + switch (personalDashboardProjectDetails.state) { + case 'success': { + const activeProjectStage = + personalDashboardProjectDetails.data.onboardingStatus + .status ?? 'loading'; + const setupIncomplete = + activeProjectStage === 'onboarding-started' || + activeProjectStage === 'first-flag-created'; - return { - list: ( - - {projects.map((project) => ( - - setActiveProject(project.id) - } - /> - ))} - - ), - box1, - box2, - }; + if (activeProjectStage === 'onboarded') { + return [ + , + , + ]; + } else if (setupIncomplete) { + return [ + , + , + ]; + } else { + return [ + , + , + ]; + } } + case 'error': + return [ + , + , + ]; + default: // loading + return [ + , + , + ]; } - }; + })(); - const { list, box1, box2 } = getGridContents(); - return ( - - - {list} - {box1} - {box2} - - - role.name, - ) ?? [] - } - owners={ - personalDashboardProjectDetails?.owners ?? [ - { ownerType: 'user', name: '?' }, - ] - } - /> - - - - ); - }, -); + return { list, box1, box2 }; + }; + + const { list, box1, box2 } = getGridContents(); + return ( + + + {list} + {box1} + {box2} + + + role.name, + ) + : [] + } + owners={ + personalDashboardProjectDetails.state === 'success' + ? personalDashboardProjectDetails.data.owners + : [{ ownerType: 'user', name: '?' }] + } + /> + + + + ); +}; diff --git a/frontend/src/component/personalDashboard/PersonalDashboard.tsx b/frontend/src/component/personalDashboard/PersonalDashboard.tsx index bba512e01923..34ca3e8ab346 100644 --- a/frontend/src/component/personalDashboard/PersonalDashboard.tsx +++ b/frontend/src/component/personalDashboard/PersonalDashboard.tsx @@ -11,7 +11,6 @@ import { WelcomeDialog } from './WelcomeDialog'; import { useLocalStorageState } from 'hooks/useLocalStorageState'; import { usePersonalDashboard } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboard'; import { usePersonalDashboardProjectDetails } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboardProjectDetails'; -import useLoading from '../../hooks/useLoading'; import { MyProjects } from './MyProjects'; import ExpandMore from '@mui/icons-material/ExpandMore'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; @@ -20,6 +19,7 @@ import { useAuthSplash } from 'hooks/api/getters/useAuth/useAuthSplash'; import { useDashboardState } from './useDashboardState'; import { MyFlags } from './MyFlags'; import { usePageTitle } from 'hooks/usePageTitle'; +import { fromPersonalDashboardProjectDetailsOutput } from './RemoteData'; const WelcomeSection = styled('div')(({ theme }) => ({ display: 'flex', @@ -130,15 +130,10 @@ export const PersonalDashboard = () => { splash?.personalDashboardKeyConcepts ? 'closed' : 'open', ); - const { personalDashboardProjectDetails, error: detailsError } = - usePersonalDashboardProjectDetails(activeProject); - - const activeProjectStage = - personalDashboardProjectDetails?.onboardingStatus.status ?? 'loading'; - - const projectStageRef = useLoading( - !detailsError && activeProjectStage === 'loading', - ); + const personalDashboardProjectDetails = + fromPersonalDashboardProjectDetailsOutput( + usePersonalDashboardProjectDetails(activeProject), + ); return ( @@ -192,7 +187,6 @@ export const PersonalDashboard = () => { = ({ project }) => { return ( - +

The API request to get data for this project returned with an error. diff --git a/frontend/src/component/personalDashboard/RemoteData.ts b/frontend/src/component/personalDashboard/RemoteData.ts new file mode 100644 index 000000000000..19edc74dfd5d --- /dev/null +++ b/frontend/src/component/personalDashboard/RemoteData.ts @@ -0,0 +1,28 @@ +import type { IPersonalDashboardProjectDetailsOutput } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboardProjectDetails'; +import type { PersonalDashboardProjectDetailsSchema } from 'openapi'; + +export type RemoteData = + | { state: 'error'; error: Error } + | { state: 'loading' } + | { state: 'success'; data: T }; + +export const fromPersonalDashboardProjectDetailsOutput = ({ + personalDashboardProjectDetails, + error, +}: IPersonalDashboardProjectDetailsOutput): RemoteData => { + const converted = error + ? { + state: 'error', + error, + } + : personalDashboardProjectDetails + ? { + state: 'success', + data: personalDashboardProjectDetails, + } + : { + state: 'loading' as const, + }; + + return converted as RemoteData; +}; diff --git a/frontend/src/component/personalDashboard/RoleAndOwnerInfo.tsx b/frontend/src/component/personalDashboard/RoleAndOwnerInfo.tsx index fce80ebf17ed..f0b62053e608 100644 --- a/frontend/src/component/personalDashboard/RoleAndOwnerInfo.tsx +++ b/frontend/src/component/personalDashboard/RoleAndOwnerInfo.tsx @@ -51,7 +51,7 @@ export const RoleAndOwnerInfo = ({ roles, owners }: Props) => { const firstRoles = roles.slice(0, 3); const extraRoles = roles.slice(3); return ( - + {roles.length > 0 ? ( <>