diff --git a/__test__/common/utils/url.test.ts b/__test__/common/utils/url.test.ts deleted file mode 100644 index dd5968d6..00000000 --- a/__test__/common/utils/url.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - getProjectId, - getVersionId, - getSpecificationId -} from "../../../src/common" - -test("It reads path containing project only", async () => { - const url = "/foo" - const projectId = getProjectId(url) - const versionId = getVersionId(url) - const specificationId = getSpecificationId(url) - expect(projectId).toEqual("foo") - expect(versionId).toBeUndefined() - expect(specificationId).toBeUndefined() -}) - -test("It reads path containing project and version", async () => { - const url = "/foo/bar" - const projectId = getProjectId(url) - const versionId = getVersionId(url) - const specificationId = getSpecificationId(url) - expect(projectId).toEqual("foo") - expect(versionId).toEqual("bar") - expect(specificationId).toBeUndefined() -}) - -test("It reads path containing project, version, and specification with .yml extension", async () => { - const url = "/foo/bar/openapi.yml" - const projectId = getProjectId(url) - const versionId = getVersionId(url) - const specificationId = getSpecificationId(url) - expect(projectId).toEqual("foo") - expect(versionId).toEqual("bar") - expect(specificationId).toBe("openapi.yml") -}) - -test("It reads path containing project, version, and specification with .yaml extension", async () => { - const url = "/foo/bar/openapi.yaml" - const projectId = getProjectId(url) - const versionId = getVersionId(url) - const specificationId = getSpecificationId(url) - expect(projectId).toEqual("foo") - expect(versionId).toEqual("bar") - expect(specificationId).toBe("openapi.yaml") -}) - -test("It reads version containing a slash", async () => { - const url = "/foo/bar/baz" - const projectId = getProjectId(url) - const versionId = getVersionId(url) - const specificationId = getSpecificationId(url) - expect(projectId).toEqual("foo") - expect(versionId).toEqual("bar/baz") - expect(specificationId).toBeUndefined() -}) - -test("It read specification when version contains a slash", async () => { - const url = "/foo/bar/baz/openapi.yml" - const projectId = getProjectId(url) - const versionId = getVersionId(url) - const specificationId = getSpecificationId(url) - expect(projectId).toEqual("foo") - expect(versionId).toEqual("bar/baz") - expect(specificationId).toBe("openapi.yml") -}) - -test("It read specification when version contains three slashes", async () => { - const url = "/foo/bar/baz/hello/openapi.yml" - const projectId = getProjectId(url) - const versionId = getVersionId(url) - const specificationId = getSpecificationId(url) - expect(projectId).toEqual("foo") - expect(versionId).toEqual("bar/baz/hello") - expect(specificationId).toBe("openapi.yml") -}) - -test("It does not remove \"-openapi\" suffix from project", async () => { - const url = "/foo-openapi" - const projectId = getProjectId(url) - const versionId = getVersionId(url) - const specificationId = getSpecificationId(url) - expect(projectId).toEqual("foo-openapi") - expect(versionId).toBeUndefined() - expect(specificationId).toBeUndefined() -}) diff --git a/__test__/projects/getSelection.test.ts b/__test__/projects/getSelection.test.ts index b2b54c5f..f2abf99f 100644 --- a/__test__/projects/getSelection.test.ts +++ b/__test__/projects/getSelection.test.ts @@ -1,7 +1,8 @@ import { getSelection } from "../../src/features/projects/domain" -test("It selects the first project when there is only one project", () => { +test("It selects the first project when there is only one project and path is empty", () => { const sut = getSelection({ + path: "", projects: [{ id: "foo", name: "foo", @@ -25,7 +26,7 @@ test("It selects the first project when there is only one project", () => { test("It selects the first version and specification of the specified project", () => { const sut = getSelection({ - projectId: "bar", + path: "/bar", projects: [{ id: "foo", name: "foo", @@ -63,8 +64,7 @@ test("It selects the first version and specification of the specified project", test("It selects the first specification of the specified project and version", () => { const sut = getSelection({ - projectId: "bar", - versionId: "baz2", + path: "/bar/baz2", projects: [{ id: "foo", name: "foo", @@ -98,8 +98,7 @@ test("It selects the first specification of the specified project and version", test("It selects the specification of the specified version", () => { const sut = getSelection({ - projectId: "bar", - versionId: "baz2", + path: "/bar/baz2", projects: [{ id: "foo", name: "foo", @@ -137,9 +136,7 @@ test("It selects the specification of the specified version", () => { test("It selects the specified project, version, and specification", () => { const sut = getSelection({ - projectId: "bar", - versionId: "baz2", - specificationId: "hello2", + path: "/bar/baz2/hello2", projects: [{ id: "foo", name: "foo", @@ -177,7 +174,7 @@ test("It selects the specified project, version, and specification", () => { test("It returns a undefined project, version, and specification when the selected project cannot be found", () => { const sut = getSelection({ - projectId: "foo", + path: "/foo", projects: [{ id: "bar", name: "bar", @@ -192,8 +189,7 @@ test("It returns a undefined project, version, and specification when the select test("It returns a undefined version and specification when the selected version cannot be found", () => { const sut = getSelection({ - projectId: "foo", - versionId: "bar", + path: "/foo/bar", projects: [{ id: "foo", name: "foo", @@ -213,9 +209,7 @@ test("It returns a undefined version and specification when the selected version test("It returns a undefined specification when the selected specification cannot be found", () => { const sut = getSelection({ - projectId: "foo", - versionId: "bar", - specificationId: "baz", + path: "/foo/bar/baz", projects: [{ id: "foo", name: "foo", diff --git a/__test__/projects/projectNavigator.test.ts b/__test__/projects/projectNavigator.test.ts index 06c30ac1..e80d4d26 100644 --- a/__test__/projects/projectNavigator.test.ts +++ b/__test__/projects/projectNavigator.test.ts @@ -1,14 +1,21 @@ -import { projectNavigator } from "../../src/features/projects/domain" +import { ProjectNavigator } from "../../src/features/projects/domain" test("It navigates to the correct path", async () => { let pushedPath: string | undefined - const router = { - push: (path: string) => { - pushedPath = path + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/" + } }, - replace: () => {} - } - projectNavigator.navigate(router, "foo", "bar", "hello.yml") + router: { + push: (path: string) => { + pushedPath = path + }, + replace: () => {} + } + }) + sut.navigate("foo", "bar", "hello.yml") expect(pushedPath).toEqual("/foo/bar/hello.yml") }) @@ -38,13 +45,20 @@ test("It navigates to first specification when changing version", async () => { }] } let pushedPath: string | undefined - const router = { - push: (path: string) => { - pushedPath = path + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/" + } }, - replace: () => {} - } - projectNavigator.navigateToVersion(router, project, "hello", "baz.yml") + router: { + push: (path: string) => { + pushedPath = path + }, + replace: () => {} + } + }) + sut.navigateToVersion(project, "hello", "baz.yml") expect(pushedPath).toEqual("/foo/hello/world.yml") }) @@ -90,29 +104,39 @@ test("It finds a specification with the same name when changing version", async }] } let pushedPath: string | undefined - const router = { - push: (path: string) => { - pushedPath = path + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/" + } }, - replace: () => {} - } - projectNavigator.navigateToVersion(router, project, "baz", "earth.yml") + router: { + push: (path: string) => { + pushedPath = path + }, + replace: () => {} + } + }) + sut.navigateToVersion(project, "baz", "earth.yml") expect(pushedPath).toEqual("/foo/baz/earth.yml") }) test("It skips navigating when URL matches selection", async () => { let didNavigate = false - const router = { - push: () => {}, - replace: () => { - didNavigate = true + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/foo/bar/baz" + } + }, + router: { + push: () => {}, + replace: () => { + didNavigate = true + } } - } - projectNavigator.navigateIfNeeded(router, { - projectId: "foo", - versionId: "bar", - specificationId: "baz" - }, { + }) + sut.navigateIfNeeded({ projectId: "foo", versionId: "bar", specificationId: "baz" @@ -122,60 +146,69 @@ test("It skips navigating when URL matches selection", async () => { test("It navigates when project ID in URL does not match ID of selected project", async () => { let didNavigate = false - const router = { - push: () => {}, - replace: () => { - didNavigate = true + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/hello/bar/baz" + } + }, + router: { + push: () => {}, + replace: () => { + didNavigate = true + } } - } - projectNavigator.navigateIfNeeded(router, { + }) + sut.navigateIfNeeded({ projectId: "foo", versionId: "bar", specificationId: "baz" - }, { - projectId: "hello", - versionId: "bar", - specificationId: "baz" }) expect(didNavigate).toBeTruthy() }) test("It navigates when version ID in URL does not match ID of selected version", async () => { let didNavigate = false - const router = { - push: () => {}, - replace: () => { - didNavigate = true + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/foo/hello/baz" + } + }, + router: { + push: () => {}, + replace: () => { + didNavigate = true + } } - } - projectNavigator.navigateIfNeeded(router, { + }) + sut.navigateIfNeeded({ projectId: "foo", versionId: "bar", specificationId: "baz" - }, { - projectId: "foo", - versionId: "hello", - specificationId: "baz" }) expect(didNavigate).toBeTruthy() }) test("It navigates when specification ID in URL does not match ID of selected specification", async () => { let didNavigate = false - const router = { - push: () => {}, - replace: () => { - didNavigate = true + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/foo/bar/hello" + } + }, + router: { + push: () => {}, + replace: () => { + didNavigate = true + } } - } - projectNavigator.navigateIfNeeded(router, { + }) + sut.navigateIfNeeded({ projectId: "foo", versionId: "bar", specificationId: "baz" - }, { - projectId: "foo", - versionId: "bar", - specificationId: "hello" }) expect(didNavigate).toBeTruthy() }) diff --git a/src/app/[...slug]/page.tsx b/src/app/[...slug]/page.tsx index 984a5cc9..76b93de3 100644 --- a/src/app/[...slug]/page.tsx +++ b/src/app/[...slug]/page.tsx @@ -1,4 +1,3 @@ -import { getProjectId, getSpecificationId, getVersionId } from "../../common" import SessionBarrier from "@/features/auth/view/SessionBarrier" import ProjectsPage from "@/features/projects/view/ProjectsPage" import { projectRepository } from "@/composition" @@ -6,26 +5,20 @@ import { projectRepository } from "@/composition" type PageParams = { slug: string | string[] } export default async function Page({ params }: { params: PageParams }) { - const url = getURL(params) return ( ) } -function getURL(params: PageParams) { - if (typeof params.slug === "string") { - return "/" + params.slug +function getPath(slug: string | string[]) { + if (typeof slug === "string") { + return "/" + slug } else { - return params.slug.reduce( - (previousValue, currentValue) => `${previousValue}/${currentValue}`, - "" - ) + return slug.reduce((e, acc) => `${e}/${acc}`, "") } -} \ No newline at end of file +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 01dfa211..0d97feea 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,7 +5,7 @@ import { projectRepository } from "@/composition" export default async function Page() { return ( - + ) } diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 33352dd4..0b808be9 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -1,4 +1,3 @@ export * from "./fetcher" export { default as fetcher } from "./fetcher" -export * from "./url" export { default as ZodJSONCoder } from "./ZodJSONCoder" diff --git a/src/common/utils/url.ts b/src/common/utils/url.ts deleted file mode 100644 index 119afc89..00000000 --- a/src/common/utils/url.ts +++ /dev/null @@ -1,52 +0,0 @@ -export function getProjectId(url?: string) { - if (typeof window !== 'undefined') { - url = window.location.pathname;// remove first slash - } - url = url?.substring(1);// remove first slash - const firstSlash = url?.indexOf('/'); - let project = url ? decodeURI(url) : undefined - if (firstSlash != -1 && url) { - project = decodeURI(url.substring(0, firstSlash)); - } - return project -} - -function getVersionAndSpecification(url?: string) { - const project = getProjectId(url) - if (url && project) { - const versionAndSpecification = url.substring(project.length + 2)// remove first slash - let specification: string | undefined = undefined; - let version = versionAndSpecification; - if (versionAndSpecification) { - const lastSlash = versionAndSpecification?.lastIndexOf('/'); - if (lastSlash != -1) { - const potentialSpecification = versionAndSpecification?.substring(lastSlash) - if (potentialSpecification?.endsWith('.yml') || potentialSpecification?.endsWith('.yaml')) { - version = versionAndSpecification?.substring(0, lastSlash) - specification = versionAndSpecification?.substring(lastSlash + 1) - } - } - } - return { - version, - specification - }; - } - return {}; -} - -export function getVersionId(url?: string) { - if (typeof window !== 'undefined') { - url = window.location.pathname - } - const version = getVersionAndSpecification(url).version; - return version ? decodeURI(version) : undefined; -} - -export function getSpecificationId(url?: string) { - if (typeof window !== 'undefined') { - url = window.location.pathname - } - const specification = getVersionAndSpecification(url).specification; - return specification ? decodeURI(specification) : undefined; -} \ No newline at end of file diff --git a/src/features/projects/domain/projectNavigator.ts b/src/features/projects/domain/ProjectNavigator.ts similarity index 52% rename from src/features/projects/domain/projectNavigator.ts rename to src/features/projects/domain/ProjectNavigator.ts index 6aa1ce8c..1fcd8933 100644 --- a/src/features/projects/domain/projectNavigator.ts +++ b/src/features/projects/domain/ProjectNavigator.ts @@ -1,13 +1,29 @@ import Project from "./Project" -export interface IProjectRouter { +interface IPathnameReader { + readonly pathname: string +} + +export interface IRouter { push(path: string): void replace(path: string): void } -const projectNavigator = { +type ProjectNavigatorConfig = { + readonly pathnameReader: IPathnameReader + readonly router: IRouter +} + +export default class ProjectNavigator { + private readonly pathnameReader: IPathnameReader + private readonly router: IRouter + + constructor(config: ProjectNavigatorConfig) { + this.pathnameReader = config.pathnameReader + this.router = config.router + } + navigateToVersion( - router: IProjectRouter, project: Project, versionId: string, preferredSpecificationName: string @@ -23,27 +39,22 @@ const projectNavigator = { return e.name == preferredSpecificationName }) if (candidateSpecification) { - router.push(`/${project.id}/${newVersion.id}/${candidateSpecification.id}`) + this.router.push(`/${project.id}/${newVersion.id}/${candidateSpecification.id}`) } else { const firstSpecification = newVersion.specifications[0] - router.push(`/${project.id}/${newVersion.id}/${firstSpecification.id}`) + this.router.push(`/${project.id}/${newVersion.id}/${firstSpecification.id}`) } - }, + } + navigate( - router: IProjectRouter, projectId: string, versionId: string, specificationId: string ) { - router.push(`/${projectId}/${versionId}/${specificationId}`) - }, + this.router.push(`/${projectId}/${versionId}/${specificationId}`) + } + navigateIfNeeded( - router: IProjectRouter, - urlComponents: { - projectId?: string, - versionId?: string, - specificationId?: string - }, selection: { projectId?: string, versionId?: string, @@ -53,15 +64,9 @@ const projectNavigator = { if (!selection.projectId || !selection.versionId || !selection.specificationId) { return } - if ( - urlComponents.projectId != selection.projectId || - urlComponents.versionId != selection.versionId || - urlComponents.specificationId != selection.specificationId - ) { - const path = `/${selection.projectId}/${selection.versionId}/${selection.specificationId}` - router.replace(path) + const path = `/${selection.projectId}/${selection.versionId}/${selection.specificationId}` + if (path !== this.pathnameReader.pathname) { + this.router.replace(path) } } } - -export default projectNavigator \ No newline at end of file diff --git a/src/features/projects/domain/getSelection.ts b/src/features/projects/domain/getSelection.ts index a9aa2caf..307f6b7c 100644 --- a/src/features/projects/domain/getSelection.ts +++ b/src/features/projects/domain/getSelection.ts @@ -4,20 +4,21 @@ import OpenApiSpecification from "./OpenApiSpecification" export default function getSelection({ projects, - projectId, - versionId, - specificationId, + path }: { projects: Project[], - projectId?: string, - versionId?: string, - specificationId?: string + path: string }): { project?: Project, version?: Version, specification?: OpenApiSpecification } { + if (path.startsWith("/")) { + path = path.substring(1) + } + const { projectId: _projectId, versionId, specificationId } = guessSelection(path) // If no project is selected and the user only has a single project then we select that. + let projectId = _projectId if (!projectId && projects.length == 1) { projectId = projects[0].id } @@ -44,4 +45,23 @@ export default function getSelection({ specification = version.specifications[0] } return { project, version, specification } -} \ No newline at end of file +} + +function guessSelection(pathname: string) { + const comps = pathname.split("/") + if (comps.length == 0) { + return {} + } else if (comps.length == 1) { + return { projectId: comps[0] } + } else if (comps.length == 2) { + return { + projectId: comps[0], + versionId: comps[1] + } + } else { + const projectId = comps[0] + const versionId = comps.slice(1, -1).join("/") + const specificationId = comps[comps.length - 1] + return { projectId, versionId, specificationId } + } +} diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts index 3d8006dc..240996ef 100644 --- a/src/features/projects/domain/index.ts +++ b/src/features/projects/domain/index.ts @@ -5,7 +5,8 @@ export type { default as IProjectDataSource } from "./IProjectDataSource" export type { default as OpenApiSpecification } from "./OpenApiSpecification" export type { default as Project } from "./Project" export { default as ProjectConfigParser } from "./ProjectConfigParser" -export { default as projectNavigator } from "./projectNavigator" +export { default as ProjectNavigator } from "./ProjectNavigator" export { default as ProjectRepository } from "./ProjectRepository" export { default as updateWindowTitle } from "./updateWindowTitle" export type { default as Version } from "./Version" +export { default as useProjectNavigator } from "./useProjectNavigator" diff --git a/src/features/projects/domain/useProjectNavigator.ts b/src/features/projects/domain/useProjectNavigator.ts new file mode 100644 index 00000000..e0f5fac9 --- /dev/null +++ b/src/features/projects/domain/useProjectNavigator.ts @@ -0,0 +1,15 @@ +import { useRouter } from "next/navigation" +import ProjectNavigator from "./ProjectNavigator" + +export default function useProjectNavigator() { + const router = useRouter() + const pathnameReader = { + get pathname() { + if (typeof window === "undefined") { + return "" + } + return window.location.pathname + } + } + return new ProjectNavigator({ router, pathnameReader }) +} diff --git a/src/features/projects/view/ProjectsPage.tsx b/src/features/projects/view/ProjectsPage.tsx index 5e5118d4..be16f64c 100644 --- a/src/features/projects/view/ProjectsPage.tsx +++ b/src/features/projects/view/ProjectsPage.tsx @@ -4,14 +4,10 @@ import ClientProjectsPage from "./client/ProjectsPage" export default async function ProjectsPage({ projectRepository, - projectId, - versionId, - specificationId + path }: { projectRepository: ProjectRepository - projectId?: string - versionId?: string - specificationId?: string + path: string }) { const isGuest = await session.getIsGuest() const projects = await projectRepository.get() @@ -19,9 +15,7 @@ export default async function ProjectsPage({ ) } diff --git a/src/features/projects/view/client/ProjectsPage.tsx b/src/features/projects/view/client/ProjectsPage.tsx index 0fa9d384..e218a26d 100644 --- a/src/features/projects/view/client/ProjectsPage.tsx +++ b/src/features/projects/view/client/ProjectsPage.tsx @@ -1,43 +1,38 @@ "use client" import { useEffect } from "react" -import { useRouter } from "next/navigation" import { useTheme } from "@mui/material/styles" import useMediaQuery from "@mui/material/useMediaQuery" import SidebarContainer from "@/features/sidebar/view/client/SidebarContainer" import { useProjects } from "../../data" -import { Project, getSelection, projectNavigator, updateWindowTitle } from "../../domain" import ProjectList from "../ProjectList" import MainContent from "../MainContent" import MobileToolbar from "../toolbar/MobileToolbar" import TrailingToolbarItem from "../toolbar/TrailingToolbarItem" import useSidebarOpen from "@/common/state/useSidebarOpen" +import { + Project, + getSelection, + updateWindowTitle, + useProjectNavigator +} from "../../domain" export default function ProjectsPage({ enableGitHubLinks, projects: serverProjects, - projectId, - versionId, - specificationId + path }: { - enableGitHubLinks: boolean, + enableGitHubLinks: boolean projects?: Project[] - projectId?: string - versionId?: string - specificationId?: string + path: string }) { - const router = useRouter() const theme = useTheme() + const projectNavigator = useProjectNavigator() const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() const isDesktopLayout = useMediaQuery(theme.breakpoints.up("sm")) const { projects: clientProjects, error, isLoading: isClientLoading } = useProjects() const projects = isClientLoading ? (serverProjects || []) : clientProjects - const { project, version, specification } = getSelection({ - projects, - projectId, - versionId, - specificationId - }) + const { project, version, specification } = getSelection({ projects, path }) useEffect(() => { updateWindowTitle({ storage: document, @@ -49,35 +44,33 @@ export default function ProjectsPage({ }, [project, version, specification]) useEffect(() => { // Ensure the URL reflects the current selection of project, version, and specification. - const urlSelection = { projectId, versionId, specificationId } - const selection = { + projectNavigator.navigateIfNeeded({ projectId: project?.id, versionId: version?.id, specificationId: specification?.id - } - projectNavigator.navigateIfNeeded(router, urlSelection, selection) - }, [router, projectId, versionId, specificationId, project, version, specification]) + }) + }, [projectNavigator, project, version, specification]) useEffect(() => { // Show the sidebar if no project is selected. - if (projectId === undefined) { + if (project === undefined) { setSidebarOpen(true) } - }, [projectId, setSidebarOpen]) + }, [project, setSidebarOpen]) const selectProject = (project: Project) => { if (!isDesktopLayout) { setSidebarOpen(false) } const version = project.versions[0] const specification = version.specifications[0] - projectNavigator.navigate(router, project.id, version.id, specification.id) + projectNavigator.navigate(project.id, version.id, specification.id) } const selectVersion = (versionId: string) => { - projectNavigator.navigateToVersion(router, project!, versionId, specification!.name) + projectNavigator.navigateToVersion(project!, versionId, specification!.name) } const selectSpecification = (specificationId: string) => { - projectNavigator.navigate(router, projectId!, versionId!, specificationId) + projectNavigator.navigate(project!.id, version!.id, specificationId) } - const canCloseSidebar = projectId !== undefined + const canCloseSidebar = project !== undefined const toggleSidebar = (isOpen: boolean) => { if (!isOpen && canCloseSidebar) { setSidebarOpen(false) @@ -94,7 +87,7 @@ export default function ProjectsPage({ } @@ -119,7 +112,7 @@ export default function ProjectsPage({ } > {/* If the user has not selected any project then we do not render any content */} - {projectId && + {project && ) -} +} \ No newline at end of file