From e95fa5fcaac44962dfccbcbac2c320b3afc6c8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 15:52:37 +0100 Subject: [PATCH 1/6] Improves navigation logic # Conflicts: # __test__/projects/projectNavigator.test.ts # src/features/projects/domain/getSelection.ts # src/features/projects/domain/projectNavigator.ts # src/features/projects/view/client/ProjectsPage.tsx --- __test__/projects/projectNavigator.test.ts | 135 ++++++++++++------ .../projects/domain/WindowPathnameReader.ts | 8 ++ src/features/projects/domain/getSelection.ts | 56 ++++++-- src/features/projects/domain/index.ts | 3 +- .../projects/domain/projectNavigator.ts | 53 +++---- .../projects/view/client/ProjectsPage.tsx | 31 ++-- 6 files changed, 195 insertions(+), 91 deletions(-) create mode 100644 src/features/projects/domain/WindowPathnameReader.ts diff --git a/__test__/projects/projectNavigator.test.ts b/__test__/projects/projectNavigator.test.ts index 06c30ac1..ece71611 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,25 +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, { + }) + sut.navigateIfNeeded({ projectId: "foo", versionId: "bar", specificationId: "baz" @@ -122,13 +150,20 @@ 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" @@ -142,13 +177,20 @@ test("It navigates when project ID in URL does not match ID of selected project" 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" @@ -162,13 +204,20 @@ test("It navigates when version ID in URL does not match ID of selected version" 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" diff --git a/src/features/projects/domain/WindowPathnameReader.ts b/src/features/projects/domain/WindowPathnameReader.ts new file mode 100644 index 00000000..a92874d6 --- /dev/null +++ b/src/features/projects/domain/WindowPathnameReader.ts @@ -0,0 +1,8 @@ +export default class WindowPathnameReader { + get pathname() { + if (typeof window === "undefined") { + return "" + } + return window.location.pathname + } +} diff --git a/src/features/projects/domain/getSelection.ts b/src/features/projects/domain/getSelection.ts index a9aa2caf..b76559ad 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 } @@ -29,8 +30,24 @@ export default function getSelection({ return {} } let version: Version | undefined + let didMoveSpecificationIdToVersionId = false if (versionId) { version = project.versions.find(e => e.id == versionId) + if (!version && specificationId && !isSpecificationIdFilename(specificationId)) { + // With the introduction of remote versions that are specified in the .shape-docs.yml + // configuration file, it has become impossible to tell if the last component in a URL + // is the specification ID or if it belongs to the version ID. Previously, we required + // specification IDs to end with either ".yml" or ".yaml" but that no longer makes + // sense when users can define versions. + // Instead we assume that the last component is the specification ID but if we cannot + // find a version with what we then believe to be the version ID, then we attempt to + // finding a version with the ID `{versionId}/{specificationId}` and if that succeeds, + // we select the first specification in that version by flagging that the ID of the + // specification is considered part of the version ID. + const longId = [versionId, specificationId].join("/") + version = project.versions.find(e => e.id == longId) + didMoveSpecificationIdToVersionId = version != undefined + } } else if (project.versions.length > 0) { version = project.versions[0] } @@ -38,10 +55,33 @@ export default function getSelection({ return { project } } let specification: OpenApiSpecification | undefined - if (specificationId) { + if (specificationId && !didMoveSpecificationIdToVersionId) { specification = version.specifications.find(e => e.id == specificationId) } else if (version.specifications.length > 0) { 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 } + } +} + +function isSpecificationIdFilename(specificationId: string): boolean { + return specificationId.endsWith(".yml") || specificationId.endsWith(".yaml") +} diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts index 3d8006dc..2ebb7012 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 WindowPathnameReader } from "./WindowPathnameReader" diff --git a/src/features/projects/domain/projectNavigator.ts b/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/view/client/ProjectsPage.tsx b/src/features/projects/view/client/ProjectsPage.tsx index 0fa9d384..a9e607b5 100644 --- a/src/features/projects/view/client/ProjectsPage.tsx +++ b/src/features/projects/view/client/ProjectsPage.tsx @@ -6,12 +6,18 @@ 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, + ProjectNavigator, + WindowPathnameReader, + getSelection, + updateWindowTitle, +} from "../../domain" export default function ProjectsPage({ enableGitHubLinks, @@ -28,16 +34,13 @@ export default function ProjectsPage({ }) { const router = useRouter() const theme = useTheme() + const pathnameReader = new WindowPathnameReader() 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 }) + const projectNavigator = new ProjectNavigator({ router, pathnameReader }) useEffect(() => { updateWindowTitle({ storage: document, @@ -49,14 +52,12 @@ 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]) + }) + }, [project, version, specification]) useEffect(() => { // Show the sidebar if no project is selected. if (projectId === undefined) { @@ -69,13 +70,13 @@ export default function ProjectsPage({ } 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 toggleSidebar = (isOpen: boolean) => { From 35ab1e29902f3c111a10ff18e9a062c933e13ce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 15:25:00 +0100 Subject: [PATCH 2/6] Simplifies project selection # Conflicts: # __test__/common/utils/url.test.ts # __test__/projects/getSelection.test.ts # src/common/utils/url.ts # src/features/projects/domain/getSelection.ts # src/features/projects/domain/projectNavigator.ts # src/features/projects/view/client/ProjectsPage.tsx --- __test__/common/utils/url.test.ts | 85 ------------------- __test__/projects/getSelection.test.ts | 48 +++++++---- __test__/projects/projectNavigator.test.ts | 16 ---- src/app/[...slug]/page.tsx | 19 ++--- src/app/page.tsx | 2 +- src/common/utils/index.ts | 1 - src/common/utils/url.ts | 52 ------------ src/features/projects/view/ProjectsPage.tsx | 12 +-- .../projects/view/client/ProjectsPage.tsx | 22 ++--- 9 files changed, 52 insertions(+), 205 deletions(-) delete mode 100644 __test__/common/utils/url.test.ts delete mode 100644 src/common/utils/url.ts 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..a3979b44 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", @@ -236,3 +230,27 @@ test("It returns a undefined specification when the selected specification canno expect(sut.version!.id).toEqual("bar") expect(sut.specification).toBeUndefined() }) + +test("It moves specification ID to version ID if needed", () => { + const sut = getSelection({ + path: "/foo/bar/baz", + projects: [{ + id: "foo", + name: "foo", + displayName: "foo", + versions: [{ + id: "bar/baz", + name: "bar", + isDefault: false, + specifications: [{ + id: "hello", + name: "hello.yml", + url: "https://example.com/hello.yml" + }] + }] + }] + }) + expect(sut.project!.id).toEqual("foo") + expect(sut.version!.id).toEqual("bar/baz") + expect(sut.specification!.id).toEqual("hello") +}) diff --git a/__test__/projects/projectNavigator.test.ts b/__test__/projects/projectNavigator.test.ts index ece71611..e80d4d26 100644 --- a/__test__/projects/projectNavigator.test.ts +++ b/__test__/projects/projectNavigator.test.ts @@ -140,10 +140,6 @@ test("It skips navigating when URL matches selection", async () => { projectId: "foo", versionId: "bar", specificationId: "baz" - }, { - projectId: "foo", - versionId: "bar", - specificationId: "baz" }) expect(didNavigate).toBeFalsy() }) @@ -167,10 +163,6 @@ test("It navigates when project ID in URL does not match ID of selected project" projectId: "foo", versionId: "bar", specificationId: "baz" - }, { - projectId: "hello", - versionId: "bar", - specificationId: "baz" }) expect(didNavigate).toBeTruthy() }) @@ -194,10 +186,6 @@ test("It navigates when version ID in URL does not match ID of selected version" projectId: "foo", versionId: "bar", specificationId: "baz" - }, { - projectId: "foo", - versionId: "hello", - specificationId: "baz" }) expect(didNavigate).toBeTruthy() }) @@ -221,10 +209,6 @@ test("It navigates when specification ID in URL does not match ID of selected sp 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/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 a9e607b5..170f6ba8 100644 --- a/src/features/projects/view/client/ProjectsPage.tsx +++ b/src/features/projects/view/client/ProjectsPage.tsx @@ -22,15 +22,11 @@ import { 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() @@ -60,10 +56,10 @@ export default function ProjectsPage({ }, [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) @@ -78,7 +74,7 @@ export default function ProjectsPage({ const selectSpecification = (specificationId: string) => { projectNavigator.navigate(project!.id, version!.id, specificationId) } - const canCloseSidebar = projectId !== undefined + const canCloseSidebar = project !== undefined const toggleSidebar = (isOpen: boolean) => { if (!isOpen && canCloseSidebar) { setSidebarOpen(false) @@ -95,7 +91,7 @@ export default function ProjectsPage({ } @@ -120,7 +116,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 From b23f284c7c2ee64c86fad0ef3556d8d7ed27435f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 16:05:14 +0100 Subject: [PATCH 3/6] Removes logic that belongs to the introduction of remote versions --- __test__/projects/getSelection.test.ts | 24 -------------------- src/features/projects/domain/getSelection.ts | 22 +----------------- 2 files changed, 1 insertion(+), 45 deletions(-) diff --git a/__test__/projects/getSelection.test.ts b/__test__/projects/getSelection.test.ts index a3979b44..f2abf99f 100644 --- a/__test__/projects/getSelection.test.ts +++ b/__test__/projects/getSelection.test.ts @@ -230,27 +230,3 @@ test("It returns a undefined specification when the selected specification canno expect(sut.version!.id).toEqual("bar") expect(sut.specification).toBeUndefined() }) - -test("It moves specification ID to version ID if needed", () => { - const sut = getSelection({ - path: "/foo/bar/baz", - projects: [{ - id: "foo", - name: "foo", - displayName: "foo", - versions: [{ - id: "bar/baz", - name: "bar", - isDefault: false, - specifications: [{ - id: "hello", - name: "hello.yml", - url: "https://example.com/hello.yml" - }] - }] - }] - }) - expect(sut.project!.id).toEqual("foo") - expect(sut.version!.id).toEqual("bar/baz") - expect(sut.specification!.id).toEqual("hello") -}) diff --git a/src/features/projects/domain/getSelection.ts b/src/features/projects/domain/getSelection.ts index b76559ad..307f6b7c 100644 --- a/src/features/projects/domain/getSelection.ts +++ b/src/features/projects/domain/getSelection.ts @@ -30,24 +30,8 @@ export default function getSelection({ return {} } let version: Version | undefined - let didMoveSpecificationIdToVersionId = false if (versionId) { version = project.versions.find(e => e.id == versionId) - if (!version && specificationId && !isSpecificationIdFilename(specificationId)) { - // With the introduction of remote versions that are specified in the .shape-docs.yml - // configuration file, it has become impossible to tell if the last component in a URL - // is the specification ID or if it belongs to the version ID. Previously, we required - // specification IDs to end with either ".yml" or ".yaml" but that no longer makes - // sense when users can define versions. - // Instead we assume that the last component is the specification ID but if we cannot - // find a version with what we then believe to be the version ID, then we attempt to - // finding a version with the ID `{versionId}/{specificationId}` and if that succeeds, - // we select the first specification in that version by flagging that the ID of the - // specification is considered part of the version ID. - const longId = [versionId, specificationId].join("/") - version = project.versions.find(e => e.id == longId) - didMoveSpecificationIdToVersionId = version != undefined - } } else if (project.versions.length > 0) { version = project.versions[0] } @@ -55,7 +39,7 @@ export default function getSelection({ return { project } } let specification: OpenApiSpecification | undefined - if (specificationId && !didMoveSpecificationIdToVersionId) { + if (specificationId) { specification = version.specifications.find(e => e.id == specificationId) } else if (version.specifications.length > 0) { specification = version.specifications[0] @@ -81,7 +65,3 @@ function guessSelection(pathname: string) { return { projectId, versionId, specificationId } } } - -function isSpecificationIdFilename(specificationId: string): boolean { - return specificationId.endsWith(".yml") || specificationId.endsWith(".yaml") -} From 0715b1d19778ab5d20bf979a94cd678445b70d2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 16:16:01 +0100 Subject: [PATCH 4/6] Introduces useProjectNavigator --- .../projects/domain/WindowPathnameReader.ts | 8 -------- src/features/projects/domain/index.ts | 2 +- .../projects/domain/useProjectNavigator.ts | 15 +++++++++++++++ .../projects/view/client/ProjectsPage.tsx | 11 ++++------- 4 files changed, 20 insertions(+), 16 deletions(-) delete mode 100644 src/features/projects/domain/WindowPathnameReader.ts create mode 100644 src/features/projects/domain/useProjectNavigator.ts diff --git a/src/features/projects/domain/WindowPathnameReader.ts b/src/features/projects/domain/WindowPathnameReader.ts deleted file mode 100644 index a92874d6..00000000 --- a/src/features/projects/domain/WindowPathnameReader.ts +++ /dev/null @@ -1,8 +0,0 @@ -export default class WindowPathnameReader { - get pathname() { - if (typeof window === "undefined") { - return "" - } - return window.location.pathname - } -} diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts index 2ebb7012..240996ef 100644 --- a/src/features/projects/domain/index.ts +++ b/src/features/projects/domain/index.ts @@ -9,4 +9,4 @@ 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 WindowPathnameReader } from "./WindowPathnameReader" +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/client/ProjectsPage.tsx b/src/features/projects/view/client/ProjectsPage.tsx index 170f6ba8..fef4184f 100644 --- a/src/features/projects/view/client/ProjectsPage.tsx +++ b/src/features/projects/view/client/ProjectsPage.tsx @@ -1,7 +1,6 @@ "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" @@ -13,10 +12,9 @@ import TrailingToolbarItem from "../toolbar/TrailingToolbarItem" import useSidebarOpen from "@/common/state/useSidebarOpen" import { Project, - ProjectNavigator, - WindowPathnameReader, getSelection, updateWindowTitle, + useProjectNavigator } from "../../domain" export default function ProjectsPage({ @@ -28,15 +26,13 @@ export default function ProjectsPage({ projects?: Project[] path: string }) { - const router = useRouter() const theme = useTheme() - const pathnameReader = new WindowPathnameReader() + 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, path }) - const projectNavigator = new ProjectNavigator({ router, pathnameReader }) useEffect(() => { updateWindowTitle({ storage: document, @@ -48,12 +44,13 @@ export default function ProjectsPage({ }, [project, version, specification]) useEffect(() => { // Ensure the URL reflects the current selection of project, version, and specification. + console.log("NAVIGATE IF NEEDED") projectNavigator.navigateIfNeeded({ projectId: project?.id, versionId: version?.id, specificationId: specification?.id }) - }, [project, version, specification]) + }, [projectNavigator, project, version, specification]) useEffect(() => { // Show the sidebar if no project is selected. if (project === undefined) { From 7a432079901ca80e70286a8308b3c30863e0e172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 16:17:35 +0100 Subject: [PATCH 5/6] Remove debug log --- src/features/projects/view/client/ProjectsPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/features/projects/view/client/ProjectsPage.tsx b/src/features/projects/view/client/ProjectsPage.tsx index fef4184f..e218a26d 100644 --- a/src/features/projects/view/client/ProjectsPage.tsx +++ b/src/features/projects/view/client/ProjectsPage.tsx @@ -44,7 +44,6 @@ export default function ProjectsPage({ }, [project, version, specification]) useEffect(() => { // Ensure the URL reflects the current selection of project, version, and specification. - console.log("NAVIGATE IF NEEDED") projectNavigator.navigateIfNeeded({ projectId: project?.id, versionId: version?.id, From 865ca669d6c1bc2e8f53b6a92ff8b2e86b5e1755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 16:24:07 +0100 Subject: [PATCH 6/6] Renames file --- .../projects/domain/{projectNavigator.ts => ProjectNavigator.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/features/projects/domain/{projectNavigator.ts => ProjectNavigator.ts} (100%) diff --git a/src/features/projects/domain/projectNavigator.ts b/src/features/projects/domain/ProjectNavigator.ts similarity index 100% rename from src/features/projects/domain/projectNavigator.ts rename to src/features/projects/domain/ProjectNavigator.ts