From e61cca224de9ba23e664f6bd097d7fb154d7d803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=2E=20St=C3=B8vring?= Date: Tue, 20 Aug 2024 10:51:37 +0200 Subject: [PATCH] Uses Suspense when loading projects (#322) * Moves MenuItemHover to client * Removes unused SidebarContext * Uses Suspense to load projects * Moves ProjectsContextProvider to fix unit tests * Moves contexts.ts to client-side * Moves ProjectsContextProvider.tsx to client-side * Fixes build errors --- src/app/(authed)/(home)/[...slug]/page.tsx | 9 +- src/app/(authed)/(home)/layout.tsx | 36 -------- src/app/(authed)/layout.tsx | 13 +-- src/app/api/user/projects/route.ts | 26 ------ src/common/contexts.ts | 19 ++-- src/common/ui/MenuItemHover.tsx | 2 + src/features/projects/data/index.ts | 1 - .../projects/data/useProjectSelection.ts | 4 +- src/features/projects/data/useProjects.ts | 19 ---- .../projects/view/ProjectsContextProvider.tsx | 22 +++++ .../view/ServerSideCachedProjectsProvider.tsx | 20 ----- .../sidebar/view/SecondarySplitHeader.tsx | 11 +-- src/features/sidebar/view/SplitView.tsx | 62 ++++--------- .../sidebar/view/internal/ClientSplitView.tsx | 53 ++++++++++++ .../sidebar/view/internal/sidebar/Sidebar.tsx | 17 ++-- .../sidebar/projects/PopulatedProjectList.tsx | 22 +++++ .../internal/sidebar/projects/ProjectList.tsx | 86 +++++++++---------- .../sidebar/projects/ProjectListFallback.tsx | 30 +++++++ .../sidebar/projects/ProjectListItem.tsx | 17 ++-- .../welcome/view/ShowSectionsLayer.tsx | 6 +- 20 files changed, 227 insertions(+), 248 deletions(-) delete mode 100644 src/app/(authed)/(home)/layout.tsx delete mode 100644 src/app/api/user/projects/route.ts delete mode 100644 src/features/projects/data/useProjects.ts create mode 100644 src/features/projects/view/ProjectsContextProvider.tsx delete mode 100644 src/features/projects/view/ServerSideCachedProjectsProvider.tsx create mode 100644 src/features/sidebar/view/internal/ClientSplitView.tsx create mode 100644 src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx create mode 100644 src/features/sidebar/view/internal/sidebar/projects/ProjectListFallback.tsx diff --git a/src/app/(authed)/(home)/[...slug]/page.tsx b/src/app/(authed)/(home)/[...slug]/page.tsx index e70225d5..b43b7b80 100644 --- a/src/app/(authed)/(home)/[...slug]/page.tsx +++ b/src/app/(authed)/(home)/[...slug]/page.tsx @@ -1,15 +1,12 @@ "use client" -import { useContext, useEffect } from "react" -import { ProjectsContainerContext } from "@/common" -import DelayedLoadingIndicator from "@/common/ui/DelayedLoadingIndicator" +import { useEffect } from "react" import ErrorMessage from "@/common/ui/ErrorMessage" import { updateWindowTitle } from "@/features/projects/domain" import { useProjectSelection } from "@/features/projects/data" import Documentation from "@/features/projects/view/Documentation" export default function Page() { - const { error, isLoading } = useContext(ProjectsContainerContext) const { project, version, specification, navigateToSelectionIfNeeded } = useProjectSelection() // Ensure the URL reflects the current selection of project, version, and specification. useEffect(() => { @@ -32,10 +29,6 @@ export default function Page() { return } else if (project && !specification) { return - } else if (isLoading) { - return - } else if (error) { - return } else { // No project is selected so we will not show anything. return <> diff --git a/src/app/(authed)/(home)/layout.tsx b/src/app/(authed)/(home)/layout.tsx deleted file mode 100644 index 49b27871..00000000 --- a/src/app/(authed)/(home)/layout.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client" - -import { useContext } from "react" -import { SplitView } from "@/features/sidebar/view" -import { useProjects, useProjectSelection } from "@/features/projects/data" -import { - ProjectsContainerContext, - ServerSideCachedProjectsContext -} from "@/common" - -export default function Layout({ children }: { children: React.ReactNode }) { - const { projects, error, isLoading } = useProjects() - // Update projects provided to child components, using cached projects from the server if needed. - const serverSideCachedProjects = useContext(ServerSideCachedProjectsContext) - const newProjectsContainer = { projects, error, isLoading } - if (isLoading && serverSideCachedProjects) { - newProjectsContainer.isLoading = false - newProjectsContainer.projects = serverSideCachedProjects - } - return ( - - - {children} - - - ) -} - -const SplitViewWrapper = ({ children }: { children: React.ReactNode }) => { - const { project } = useProjectSelection() - return ( - - {children} - - ) -} \ No newline at end of file diff --git a/src/app/(authed)/layout.tsx b/src/app/(authed)/layout.tsx index bc977cf5..84da124e 100644 --- a/src/app/(authed)/layout.tsx +++ b/src/app/(authed)/layout.tsx @@ -3,7 +3,8 @@ import { SessionProvider } from "next-auth/react" import { session, projectRepository } from "@/composition" import ErrorHandler from "@/common/ui/ErrorHandler" import SessionBarrier from "@/features/auth/view/SessionBarrier" -import ServerSideCachedProjectsProvider from "@/features/projects/view/ServerSideCachedProjectsProvider" +import ProjectsContextProvider from "@/features/projects/view/ProjectsContextProvider" +import { SplitView } from "@/features/sidebar/view" export default async function Layout({ children }: { children: React.ReactNode }) { const isAuthenticated = await session.getIsAuthenticated() @@ -15,11 +16,13 @@ export default async function Layout({ children }: { children: React.ReactNode } - - {children} - + + + {children} + + ) -} \ No newline at end of file +} diff --git a/src/app/api/user/projects/route.ts b/src/app/api/user/projects/route.ts deleted file mode 100644 index 53b0876d..00000000 --- a/src/app/api/user/projects/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NextResponse } from "next/server" -import { - makeAPIErrorResponse, - UnauthorizedError, - makeUnauthenticatedAPIErrorResponse -} from "@/common" -import { session, projectDataSource } from "@/composition" - -export async function GET() { - const isAuthenticated = await session.getIsAuthenticated() - if (!isAuthenticated) { - return makeUnauthenticatedAPIErrorResponse() - } - try { - const projects = await projectDataSource.getProjects() - return NextResponse.json({projects}) - } catch (error) { - if (error instanceof UnauthorizedError) { - return makeAPIErrorResponse(401, error.message) - } else if (error instanceof Error) { - return makeAPIErrorResponse(500, error.message) - } else { - return makeAPIErrorResponse(500, "Unknown error") - } - } -} \ No newline at end of file diff --git a/src/common/contexts.ts b/src/common/contexts.ts index e455e5b1..ba3d74fb 100644 --- a/src/common/contexts.ts +++ b/src/common/contexts.ts @@ -1,19 +1,14 @@ "use client" import { createContext } from "react" -import { Project, } from "@/features/projects/domain" +import { Project } from "@/features/projects/domain" -export const SidebarContext = createContext<{ isToggleable: boolean }>({ isToggleable: true }) - -type ProjectsContainer = { - readonly projects: Project[] - readonly isLoading: boolean - readonly error?: Error +type ProjectsContextValue = { + projects: Project[], + setProjects: (projects: Project[]) => void } -export const ProjectsContainerContext = createContext({ - isLoading: true, - projects: [] +export const ProjectsContext = createContext({ + projects: [], + setProjects: () => {} }) - -export const ServerSideCachedProjectsContext = createContext(undefined) diff --git a/src/common/ui/MenuItemHover.tsx b/src/common/ui/MenuItemHover.tsx index d627a980..fcb33795 100644 --- a/src/common/ui/MenuItemHover.tsx +++ b/src/common/ui/MenuItemHover.tsx @@ -1,3 +1,5 @@ +"use client" + import { SxProps } from "@mui/system" import { Box } from "@mui/material" import useMediaQuery from "@mui/material/useMediaQuery" diff --git a/src/features/projects/data/index.ts b/src/features/projects/data/index.ts index affecff1..748a41b9 100644 --- a/src/features/projects/data/index.ts +++ b/src/features/projects/data/index.ts @@ -1,6 +1,5 @@ export { default as GitHubProjectDataSource } from "./GitHubProjectDataSource" export * from "./GitHubProjectDataSource" -export { default as useProjects } from "./useProjects" export { default as useProjectSelection } from "./useProjectSelection" export { default as GitHubLoginDataSource } from "./GitHubLoginDataSource" export { default as GitHubRepositoryDataSource } from "./GitHubRepositoryDataSource" diff --git a/src/features/projects/data/useProjectSelection.ts b/src/features/projects/data/useProjectSelection.ts index 6c9229e1..13765896 100644 --- a/src/features/projects/data/useProjectSelection.ts +++ b/src/features/projects/data/useProjectSelection.ts @@ -4,14 +4,14 @@ import { useRouter, usePathname } from "next/navigation" import { useContext } from "react" import useMediaQuery from "@mui/material/useMediaQuery" import { useTheme } from "@mui/material/styles" -import { ProjectsContainerContext } from "@/common" +import { ProjectsContext } from "@/common" import { Project, ProjectNavigator, getProjectSelectionFromPath } from "../domain" import { useSidebarOpen } from "@/features/sidebar/data" export default function useProjectSelection() { const router = useRouter() const pathname = usePathname() - const { projects } = useContext(ProjectsContainerContext) + const { projects } = useContext(ProjectsContext) const selection = getProjectSelectionFromPath({ projects, path: pathname }) const pathnameReader = { get pathname() { diff --git a/src/features/projects/data/useProjects.ts b/src/features/projects/data/useProjects.ts deleted file mode 100644 index a0b2cb16..00000000 --- a/src/features/projects/data/useProjects.ts +++ /dev/null @@ -1,19 +0,0 @@ -"use client" - -import useSWR from "swr" -import { fetcher } from "@/common" -import { Project } from "../domain" - -type ProjectContainer = { projects: Project[] } - -export default function useProjects() { - const { data, error, isLoading } = useSWR( - "/api/user/projects", - fetcher - ) - return { - projects: data?.projects || [], - isLoading, - error - } -} diff --git a/src/features/projects/view/ProjectsContextProvider.tsx b/src/features/projects/view/ProjectsContextProvider.tsx new file mode 100644 index 00000000..cd3725e9 --- /dev/null +++ b/src/features/projects/view/ProjectsContextProvider.tsx @@ -0,0 +1,22 @@ +"use client" + +import { useState } from "react" +import { ProjectsContext } from "@/common" +import { Project } from "@/features/projects/domain" + +const ProjectsContextProvider = ({ + initialProjects, + children +}: { + initialProjects?: Project[], + children?: React.ReactNode +}) => { + const [projects, setProjects] = useState(initialProjects || []) + return ( + + {children} + + ) +} + +export default ProjectsContextProvider \ No newline at end of file diff --git a/src/features/projects/view/ServerSideCachedProjectsProvider.tsx b/src/features/projects/view/ServerSideCachedProjectsProvider.tsx deleted file mode 100644 index 810a6633..00000000 --- a/src/features/projects/view/ServerSideCachedProjectsProvider.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client" - -import { Project } from "../domain" -import { ServerSideCachedProjectsContext } from "@/common" - -const ServerSideCachedProjectsProvider = ({ - projects, - children -}: { - projects: Project[] | undefined - children: React.ReactNode -}) => { - return ( - - {children} - - ) -} - -export default ServerSideCachedProjectsProvider diff --git a/src/features/sidebar/view/SecondarySplitHeader.tsx b/src/features/sidebar/view/SecondarySplitHeader.tsx index d809d3b0..0a9d59ee 100644 --- a/src/features/sidebar/view/SecondarySplitHeader.tsx +++ b/src/features/sidebar/view/SecondarySplitHeader.tsx @@ -1,12 +1,12 @@ "use client" -import { useState, useEffect, useContext } from "react" +import { useState, useEffect } from "react" import { useSessionStorage } from "usehooks-ts" import { Box, IconButton, Stack, Tooltip, Divider, Collapse } from "@mui/material" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons" import { useTheme } from "@mui/material/styles" -import { SidebarContext, isMac as checkIsMac } from "@/common" +import { isMac as checkIsMac } from "@/common" import { useSidebarOpen } from "@/features/sidebar/data" import ToggleMobileToolbarButton from "./internal/secondary/ToggleMobileToolbarButton" @@ -23,7 +23,6 @@ const Header = ({ }) => { const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() const [isMac, setIsMac] = useState(false) - const { isToggleable: isSidebarToggleable } = useContext(SidebarContext) const [isMobileToolbarVisible, setMobileToolbarVisible] = useSessionStorage("isMobileToolbarVisible", true) useEffect(() => { // checkIsMac uses window so we delay the check. @@ -31,7 +30,6 @@ const Header = ({ }, [isMac, setIsMac]) const openCloseKeyboardShortcut = `(${isMac ? "⌘" : "^"} + .)` const theme = useTheme() - return ( - {isSidebarToggleable && !isSidebarOpen && + {!isSidebarOpen && } - {isSidebarToggleable && isSidebarOpen && + {isSidebarOpen && } {showDivider && } - ) } diff --git a/src/features/sidebar/view/SplitView.tsx b/src/features/sidebar/view/SplitView.tsx index 78cadccf..3f2387c4 100644 --- a/src/features/sidebar/view/SplitView.tsx +++ b/src/features/sidebar/view/SplitView.tsx @@ -1,52 +1,22 @@ -"use client" +import ClientSplitView from "./internal/ClientSplitView" +import { projectDataSource } from "@/composition" +import BaseSidebar from "./internal/sidebar/Sidebar" +import ProjectList from "./internal/sidebar/projects/ProjectList" -import { useEffect } from "react" -import { Stack } from "@mui/material" -import { isMac, useKeyboardShortcut } from "@/common" -import { useSidebarOpen } from "../data" -import PrimaryContainer from "./internal/primary/Container" -import SecondaryContainer from "./internal/secondary/Container" -import Sidebar from "./internal/sidebar/Sidebar" - -const SplitView = ({ - canToggleSidebar: _canToggleSidebar, - children -}: { - canToggleSidebar?: boolean - children?: React.ReactNode -}) => { - const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() - const canToggleSidebar = _canToggleSidebar !== undefined ? _canToggleSidebar : true - useEffect(() => { - // Show the sidebar if no project is selected. - if (!canToggleSidebar) { - setSidebarOpen(true) - } - }, [canToggleSidebar, setSidebarOpen]) - useKeyboardShortcut(event => { - const isActionKey = isMac() ? event.metaKey : event.ctrlKey - if (isActionKey && event.key === ".") { - event.preventDefault() - if (canToggleSidebar) { - setSidebarOpen(!isSidebarOpen) - } - } - }, [canToggleSidebar, setSidebarOpen]) - const sidebarWidth = 320 +const SplitView = ({ children }: { children?: React.ReactNode }) => { return ( - - setSidebarOpen(false)} - > - - - - {children} - - + }> + {children} + ) } export default SplitView + +const Sidebar = () => { + return ( + + + + ) +} diff --git a/src/features/sidebar/view/internal/ClientSplitView.tsx b/src/features/sidebar/view/internal/ClientSplitView.tsx new file mode 100644 index 00000000..f59f1b48 --- /dev/null +++ b/src/features/sidebar/view/internal/ClientSplitView.tsx @@ -0,0 +1,53 @@ +"use client" + +import { useEffect } from "react" +import { Stack } from "@mui/material" +import { isMac, useKeyboardShortcut } from "@/common" +import { useSidebarOpen } from "../../data" +import { useProjectSelection } from "@/features/projects/data" +import PrimaryContainer from "./primary/Container" +import SecondaryContainer from "./secondary/Container" + +const ClientSplitView = ({ + sidebar, + children +}: { + sidebar: React.ReactNode + children?: React.ReactNode +}) => { + const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() + const { project } = useProjectSelection() + const canToggleSidebar = project !== undefined + useEffect(() => { + // Show the sidebar if no project is selected. + if (!canToggleSidebar) { + setSidebarOpen(true) + } + }, [canToggleSidebar, setSidebarOpen]) + useKeyboardShortcut(event => { + const isActionKey = isMac() ? event.metaKey : event.ctrlKey + if (isActionKey && event.key === ".") { + event.preventDefault() + if (canToggleSidebar) { + setSidebarOpen(!isSidebarOpen) + } + } + }, [canToggleSidebar, setSidebarOpen]) + const sidebarWidth = 320 + return ( + + setSidebarOpen(false)} + > + {sidebar} + + + {children} + + + ) +} + +export default ClientSplitView diff --git a/src/features/sidebar/view/internal/sidebar/Sidebar.tsx b/src/features/sidebar/view/internal/sidebar/Sidebar.tsx index c4b99ae5..258f4f0e 100644 --- a/src/features/sidebar/view/internal/sidebar/Sidebar.tsx +++ b/src/features/sidebar/view/internal/sidebar/Sidebar.tsx @@ -1,16 +1,17 @@ +"use client" + import { useRef, useEffect, useState } from "react" import { Box, Divider } from "@mui/material" import Header from "./Header" import UserButton from "./user/UserButton" import SettingsList from "./settings/SettingsList" -import ProjectList from "./projects/ProjectList" - -const Sidebar = () => { + +const Sidebar = ({ children }: { children?: React.ReactNode }) => { const [isScrolledToTop, setScrolledToTop] = useState(true) const [isScrolledToBottom, setScrolledToBottom] = useState(true) - const projectListRef = useRef(null) + const scrollableAreaRef = useRef(null) const handleScroll = () => { - const element = projectListRef.current + const element = scrollableAreaRef.current if (!element) { return } @@ -18,7 +19,7 @@ const Sidebar = () => { setScrolledToBottom(element.scrollHeight - element.scrollTop - element.clientHeight < 10) } useEffect(() => { - const element = projectListRef.current + const element = scrollableAreaRef.current if (element) { element.addEventListener("scroll", handleScroll) handleScroll() @@ -30,8 +31,8 @@ const Sidebar = () => { return <>
- - + + {children} diff --git a/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx b/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx new file mode 100644 index 00000000..6245274d --- /dev/null +++ b/src/features/sidebar/view/internal/sidebar/projects/PopulatedProjectList.tsx @@ -0,0 +1,22 @@ +"use client" + +import { useContext } from "react" +import { ProjectsContext } from "@/common" +import SpacedList from "@/common/ui/SpacedList" +import { Project } from "@/features/projects/domain" +import ProjectListItem from "./ProjectListItem" + +const PopulatedProjectList = ({ projects }: { projects: Project[] }) => { + // Ensure that context reflects the displayed projects. + const { setProjects } = useContext(ProjectsContext) + setProjects(projects) + return ( + + {projects.map(project => ( + + ))} + + ) +} + +export default PopulatedProjectList diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx index 87ea22e9..9be428b2 100644 --- a/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx +++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx @@ -1,51 +1,47 @@ -import { useContext } from "react" +import { Suspense } from "react" +import ProjectListFallback from "./ProjectListFallback" import { Box, Typography } from "@mui/material" -import { ProjectsContainerContext } from "@/common" -import SpacedList from "@/common/ui/SpacedList" -import { useProjectSelection } from "@/features/projects/data" -import ProjectListItem, { Skeleton as ProjectListItemSkeleton } from "./ProjectListItem" +import PopulatedProjectList from "./PopulatedProjectList" +import { IProjectDataSource } from "@/features/projects/domain" -const ProjectList = () => { - const { projects, isLoading } = useContext(ProjectsContainerContext) - const projectSelection = useProjectSelection() - const itemSpacing = 1 - if (isLoading) { - return ( - - { - [...new Array(6)].map((_, idx) => ( - - )) - } - - ) - } else if (projects.length > 0) { - return ( - - {projects.map(project => ( - projectSelection.selectProject(project)} - /> - ))} - - ) +const ProjectList = ({ + projectDataSource +}: { + projectDataSource: IProjectDataSource +}) => { + return ( + }> + + + ) +} + +export default ProjectList + +const DataFetchingProjectList = async ({ + projectDataSource +}: { + projectDataSource: IProjectDataSource +}) => { + const projects = await projectDataSource.getProjects() + if (projects.length > 0) { + return } else { - return ( - - - Your list of projects is empty. - - - ) + return } } -export default ProjectList +const EmptyProjectList = () => { + return ( + + + Your list of projects is empty. + + + ) +} \ No newline at end of file diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectListFallback.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectListFallback.tsx new file mode 100644 index 00000000..9084ca2a --- /dev/null +++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectListFallback.tsx @@ -0,0 +1,30 @@ +"use client" + +import { useContext } from "react" +import { ProjectsContext } from "@/common" +import SpacedList from "@/common/ui/SpacedList" +import PopulatedProjectList from "./PopulatedProjectList" +import { Skeleton as ProjectListItemSkeleton } from "./ProjectListItem" + +const StaleProjectList = () => { + const { projects } = useContext(ProjectsContext) + if (projects.length > 0) { + return + } else { + return + } +} + +export default StaleProjectList + +const LoadingProjectList = () => { + return ( + + { + [...new Array(6)].map((_, idx) => ( + + )) + } + + ) +} \ No newline at end of file diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx index 3e8e8989..72686fe4 100644 --- a/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx +++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx @@ -1,3 +1,5 @@ +"use client" + import { Box, ListItem, @@ -11,22 +13,17 @@ import MenuItemHover from "@/common/ui/MenuItemHover" import { Project } from "@/features/projects/domain" import ProjectAvatar from "./ProjectAvatar" import ProjectAvatarSquircle from "./ProjectAvatarSquircle" +import { useProjectSelection } from "@/features/projects/data" const AVATAR_SIZE = { width: 40, height: 40 } -const ProjectListItem = ({ - project, - selected, - onSelect -}: { - project: Project - selected: boolean - onSelect: () => void -}) => { +const ProjectListItem = ({ project }: { project: Project }) => { + const { project: selectedProject, selectProject } = useProjectSelection() + const selected = project.id === selectedProject?.id return (