diff --git a/README.md b/README.md index 0bb0c526..be3c1c4f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Portal displaying our projects that are documented with OpenAPI. Hosted on [docs Create a file named `.env.local` in the root of the project with the following contents. Make sure to replace any placeholders and generate a random secret using OpenSSL. ``` +NEXT_PUBLIC_SHAPE_DOCS_TITLE='Shape Docs' SHAPE_DOCS_BASE_URL='https://docs.shapetools.io' AUTH0_SECRET='use [openssl rand -hex 32] to generate a 32 bytes value' AUTH0_BASE_URL='http://dev.local:3000' @@ -39,6 +40,7 @@ Each environment variable is described in the table below. |Environment Variable|Description| |-|-| +|NEXT_PUBLIC_SHAPE_DOCS_TITLE|Title of the portal. Displayed to the user in the browser.| |SHAPE_DOCS_BASE_URL|The URL where Shape Docs is hosted.| |AUTH0_SECRET|A long secret value used to encrypt the session cookie. Generate it using `openssl rand -hex 32`.|AUTH0_BASE_URL|The base URL of your Auth0 application. `http://dev.local:3000` during development.| |AUTH0_ISSUER_BASE_URL|The URL of your Auth0 tenant domain.| diff --git a/__test__/projects/CachingProjectDataSource.test.ts b/__test__/projects/CachingProjectDataSource.test.ts index 8e458c3c..f92efe23 100644 --- a/__test__/projects/CachingProjectDataSource.test.ts +++ b/__test__/projects/CachingProjectDataSource.test.ts @@ -2,12 +2,14 @@ import Project from "../../src/features/projects/domain/Project" import CachingProjectDataSource from "../../src/features/projects/domain/CachingProjectDataSource" test("It caches projects read from the data source", async () => { - const projects = [{ + const projects: Project[] = [{ id: "foo", name: "foo", + displayName: "foo", versions: [{ id: "bar", name: "bar", + isDefault: false, specifications: [{ id: "baz.yml", name: "baz.yml", @@ -16,6 +18,7 @@ test("It caches projects read from the data source", async () => { }, { id: "hello", name: "hello", + isDefault: false, specifications: [{ id: "world.yml", name: "world.yml", diff --git a/__test__/projects/ProjectPageState.test.ts b/__test__/projects/ProjectPageState.test.ts index 3e54da10..22e6de92 100644 --- a/__test__/projects/ProjectPageState.test.ts +++ b/__test__/projects/ProjectPageState.test.ts @@ -22,10 +22,12 @@ test("It gracefully errors when no project has been selected", async () => { projects: [{ id: "foo", name: "foo", + displayName: "foo", versions: [] }, { id: "bar", name: "bar", + displayName: "bar", versions: [] }] }) @@ -38,13 +40,16 @@ test("It selects the first version and specification of the specified project", projects: [{ id: "foo", name: "foo", + displayName: "foo", versions: [] }, { id: "bar", name: "bar", + displayName: "bar", versions: [{ id: "baz1", name: "baz1", + isDefault: false, specifications: [{ id: "hello1", name: "hello1.yml", @@ -57,6 +62,7 @@ test("It selects the first version and specification of the specified project", }, { id: "baz2", name: "baz2", + isDefault: false, specifications: [] }] }] @@ -74,17 +80,21 @@ test("It selects the first specification of the specified project and version", projects: [{ id: "foo", name: "foo", + displayName: "foo", versions: [] }, { id: "bar", name: "bar", + displayName: "bar", versions: [{ id: "baz1", name: "baz1", + isDefault: false, specifications: [] }, { id: "baz2", name: "baz2", + isDefault: false, specifications: [{ id: "hello1", name: "hello1.yml", @@ -106,17 +116,21 @@ test("It selects the specification of the specified version", async () => { projects: [{ id: "foo", name: "foo", + displayName: "foo", versions: [] }, { id: "bar", name: "bar", + displayName: "bar", versions: [{ id: "baz1", name: "baz1", + isDefault: false, specifications: [] }, { id: "baz2", name: "baz2", + isDefault: false, specifications: [{ id: "hello1", name: "hello1.yml", @@ -143,17 +157,21 @@ test("It selects the specified project, version, and specification", async () => projects: [{ id: "foo", name: "foo", + displayName: "foo", versions: [] }, { id: "bar", name: "bar", + displayName: "bar", versions: [{ id: "baz1", name: "baz1", + isDefault: false, specifications: [] }, { id: "baz2", name: "baz2", + isDefault: false, specifications: [{ id: "hello1", name: "hello1.yml", @@ -178,6 +196,7 @@ test("It errors when the selected project cannot be found", async () => { projects: [{ id: "bar", name: "bar", + displayName: "bar", versions: [] }] }) @@ -191,9 +210,11 @@ test("It errors when the selected version cannot be found", async () => { projects: [{ id: "foo", name: "foo", + displayName: "foo", versions: [{ id: "baz", name: "baz", + isDefault: false, specifications: [] }] }] @@ -209,9 +230,11 @@ test("It errors when the selected specification cannot be found", async () => { projects: [{ id: "foo", name: "foo", + displayName: "foo", versions: [{ id: "bar", name: "bar", + isDefault: false, specifications: [{ id: "hello", name: "hello.yml", @@ -229,6 +252,7 @@ test("It errors when the selected project has no versions", async () => { projects: [{ id: "foo", name: "foo", + displayName: "foo", versions: [] }] }) @@ -242,9 +266,11 @@ test("It errors when the selected version has no specifications", async () => { projects: [{ id: "foo", name: "foo", + displayName: "foo", versions: [{ id: "bar", name: "bar", + isDefault: false, specifications: [] }] }] diff --git a/__test__/projects/projectNavigator.test.ts b/__test__/projects/projectNavigator.test.ts index 9e601d2c..406e7afc 100644 --- a/__test__/projects/projectNavigator.test.ts +++ b/__test__/projects/projectNavigator.test.ts @@ -6,9 +6,11 @@ test("It navigates to first specification when changing version", async () => { project: { id: "foo", name: "foo", + displayName: "foo", versions: [{ id: "bar", name: "bar", + isDefault: false, specifications: [{ id: "baz.yml", name: "baz.yml", @@ -17,6 +19,7 @@ test("It navigates to first specification when changing version", async () => { }, { id: "hello", name: "hello", + isDefault: false, specifications: [{ id: "world.yml", name: "world.yml", @@ -27,6 +30,7 @@ test("It navigates to first specification when changing version", async () => { version: { id: "bar", name: "bar", + isDefault: false, specifications: [] }, specification: { @@ -51,9 +55,11 @@ test("It navigates when selecting specification", async () => { project: { id: "foo", name: "foo", + displayName: "foo", versions: [{ id: "bar", name: "bar", + isDefault: false, specifications: [{ id: "hello.yml", name: "hello.yml", @@ -64,6 +70,7 @@ test("It navigates when selecting specification", async () => { version: { id: "bar", name: "bar", + isDefault: false, specifications: [{ id: "hello.yml", name: "hello.yml", @@ -95,11 +102,13 @@ test("It navigates even when new specification could not be found", async () => project: { id: "foo", name: "foo", + displayName: "foo", versions: [] }, version: { id: "bar", name: "bar", + isDefault: false, specifications: [] }, specification: { @@ -124,9 +133,11 @@ test("It finds a specification with the same name when changing version", async project: { id: "foo", name: "foo", + displayName: "foo", versions: [{ id: "bar", name: "bar", + isDefault: false, specifications: [{ id: "hello.yml", name: "hello.yml", @@ -139,6 +150,7 @@ test("It finds a specification with the same name when changing version", async }, { id: "baz", name: "baz", + isDefault: false, specifications: [{ id: "moon.yml", name: "moon.yml", @@ -161,6 +173,7 @@ test("It finds a specification with the same name when changing version", async version: { id: "bar", name: "bar", + isDefault: false, specifications: [] }, specification: { diff --git a/__test__/projects/updateWindowTitle.test.ts b/__test__/projects/updateWindowTitle.test.ts new file mode 100644 index 00000000..13464868 --- /dev/null +++ b/__test__/projects/updateWindowTitle.test.ts @@ -0,0 +1,102 @@ +import updateWindowTitle from "../../src/features/projects/domain/updateWindowTitle" + +test("It uses default title when there is no selection", async () => { + const store: { title: string } = { title: "" } + updateWindowTitle(store, "Shape Docs") + expect(store.title).toEqual("Shape Docs") +}) + +test("It leaves out specification when the specification has a generic name", async () => { + const store: { title: string } = { title: "" } + updateWindowTitle(store, "Shape Docs", { + project: { + id: "foo", + name: "foo", + displayName: "foo", + versions: [] + }, + version: { + id: "bar", + name: "bar", + isDefault: false, + specifications: [{ + id: "hello.yml", + name: "hello.yml", + url: "https://example.com/hello.yml" + }, { + id: "openapi.yml", + name: "openapi.yml", + url: "https://example.com/openapi.yml" + }] + }, + specification: { + id: "openapi.yml", + name: "openapi.yml", + url: "https://example.com/openapi.yml" + } + }) + expect(store.title).toEqual("foo / bar") +}) + +test("It leaves out version when it is the defualt version", async () => { + const store: { title: string } = { title: "" } + updateWindowTitle(store, "Shape Docs", { + project: { + id: "foo", + name: "foo", + displayName: "foo", + versions: [] + }, + version: { + id: "bar", + name: "bar", + isDefault: true, + specifications: [{ + id: "openapi.yml", + name: "openapi.yml", + url: "https://example.com/openapi.yml" + }] + }, + specification: { + id: "openapi.yml", + name: "openapi.yml", + url: "https://example.com/openapi.yml" + } + }) + expect(store.title).toEqual("foo") +}) + +test("It adds version when it is not the defualt version", async () => { + const store: { title: string } = { title: "" } + updateWindowTitle(store, "Shape Docs", { + project: { + id: "foo", + name: "foo", + displayName: "foo", + versions: [] + }, + version: { + id: "bar", + name: "bar", + isDefault: false, + specifications: [{ + id: "openapi.yml", + name: "openapi.yml", + url: "https://example.com/openapi.yml" + }] + }, + specification: { + id: "openapi.yml", + name: "openapi.yml", + url: "https://example.com/openapi.yml" + } + }) + expect(store.title).toEqual("foo / bar") +}) + +// } else if (selection.version.isDefault) { +// storage.title = selection.project.displayName +// } else { +// storage.title = `${selection.project.displayName} / ${selection.version.name}` +// } +// } diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index e21cf27b..c51a8199 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -149,7 +149,8 @@ export default class GitHubProjectDataSource implements IProjectDataSource { private getVersions(searchResult: SearchResult): Version[] { const branchVersions = searchResult.branches.nodes.map((ref: Ref) => { - return this.mapVersionFromRef(searchResult.owner.login, searchResult.name, ref) + const isDefaultRef = ref.name == searchResult.defaultBranchRef.name + return this.mapVersionFromRef(searchResult.owner.login, searchResult.name, ref, isDefaultRef) }) const tagVersions = searchResult.tags.nodes.map((ref: Ref) => { return this.mapVersionFromRef(searchResult.owner.login, searchResult.name, ref) @@ -177,7 +178,12 @@ export default class GitHubProjectDataSource implements IProjectDataSource { return allVersions } - private mapVersionFromRef(owner: string, repository: string, ref: Ref): Version { + private mapVersionFromRef( + owner: string, + repository: string, + ref: Ref, + isDefaultRef: boolean = false + ): Version { const specifications = ref.target.tree.entries.filter(file => { return this.isOpenAPISpecification(file.name) }).map(file => { @@ -197,7 +203,8 @@ export default class GitHubProjectDataSource implements IProjectDataSource { id: ref.name, name: ref.name, specifications: specifications, - url: `https://github.com/${owner}/${repository}/tree/${ref.name}` + url: `https://github.com/${owner}/${repository}/tree/${ref.name}`, + isDefault: isDefaultRef } } diff --git a/src/features/projects/domain/Project.ts b/src/features/projects/domain/Project.ts index 7430a897..a618bcfd 100644 --- a/src/features/projects/domain/Project.ts +++ b/src/features/projects/domain/Project.ts @@ -4,7 +4,7 @@ import { VersionSchema } from "./Version" export const ProjectSchema = z.object({ id: z.string(), name: z.string(), - displayName: z.string().optional(), + displayName: z.string(), versions: VersionSchema.array(), imageURL: z.string().optional() }) diff --git a/src/features/projects/domain/Version.ts b/src/features/projects/domain/Version.ts index 4abde158..2eee9225 100644 --- a/src/features/projects/domain/Version.ts +++ b/src/features/projects/domain/Version.ts @@ -5,7 +5,8 @@ export const VersionSchema = z.object({ id: z.string(), name: z.string(), specifications: OpenApiSpecificationSchema.array(), - url: z.string().optional() + url: z.string().optional(), + isDefault: z.boolean().default(false) }) type Version = z.infer diff --git a/src/features/projects/domain/updateWindowTitle.ts b/src/features/projects/domain/updateWindowTitle.ts new file mode 100644 index 00000000..747229bc --- /dev/null +++ b/src/features/projects/domain/updateWindowTitle.ts @@ -0,0 +1,39 @@ +import ProjectPageSelection from "./ProjectPageSelection" + +export default function updateWindowTitle( + storage: { title: string }, + defaultTitle: string, + selection?: ProjectPageSelection +) { + if (!selection) { + storage.title = defaultTitle + return + } + if (!isSpecificationNameGeneric(selection.specification.name)) { + storage.title = `${selection.project.displayName} / ${selection.version.name} / ${selection.specification.name}` + } else if (!selection.version.isDefault) { + storage.title = `${selection.project.displayName} / ${selection.version.name}` + } else { + storage.title = selection.project.displayName + } +} + +function isSpecificationNameGeneric(name: string): boolean { + const comps = name.split(".") + if (comps.length > 1) { + comps.pop() + name = comps.join(".") + } + name = name.replace(/[^0-9a-zA-Z]/g, "") + return [ + "api", + "apispec", + "apispecification", + "openapi", + "openapispec", + "openapispecification", + "spec", + "specification", + "swagger" + ].includes(name) +} diff --git a/src/features/projects/view/ProjectAvatar.tsx b/src/features/projects/view/ProjectAvatar.tsx index 481ed74c..c7e3d8bd 100644 --- a/src/features/projects/view/ProjectAvatar.tsx +++ b/src/features/projects/view/ProjectAvatar.tsx @@ -20,16 +20,16 @@ function ProjectAvatar({ bgcolor: theme.palette.divider, border: `1px solid ${alpha(theme.palette.divider, 0.02)}` }} - alt={project.displayName || project.name} + alt={project.displayName} variant="rounded" > - {Array.from(project.displayName || project.name)[0]} + {Array.from(project.displayName)[0]} ) } else { return ( - - {Array.from(project.displayName || project.name)[0]} + + {Array.from(project.displayName)[0]} ) } diff --git a/src/features/projects/view/ProjectListItem.tsx b/src/features/projects/view/ProjectListItem.tsx index 35887e67..d9485c92 100644 --- a/src/features/projects/view/ProjectListItem.tsx +++ b/src/features/projects/view/ProjectListItem.tsx @@ -37,7 +37,7 @@ const ProjectListItem = ( - {project.displayName || project.name} + {project.displayName} } /> diff --git a/src/features/projects/view/client/ProjectsPage.tsx b/src/features/projects/view/client/ProjectsPage.tsx index 4cce8a7b..ea0477cd 100644 --- a/src/features/projects/view/client/ProjectsPage.tsx +++ b/src/features/projects/view/client/ProjectsPage.tsx @@ -1,5 +1,6 @@ "use client" +import { useEffect } from "react" import { useRouter } from "next/navigation" import SidebarContainer from "@/features/sidebar/view/client/SidebarContainer" import Project from "../../domain/Project" @@ -8,6 +9,7 @@ import ProjectsPageSecondaryContent from "../ProjectsPageSecondaryContent" import ProjectsPageTrailingToolbarItem from "../ProjectsPageTrailingToolbarItem" import { getProjectPageState } from "../../domain/ProjectPageState" import projectNavigator from "../../domain/projectNavigator" +import updateWindowTitle from "../../domain/updateWindowTitle" import useProjects from "../../data/useProjects" export default function ProjectsPage({ @@ -38,6 +40,13 @@ export default function ProjectsPage({ const specification = version.specifications[0] router.push(`/${project.id}/${version.id}/${specification.id}`) } + useEffect(() => { + updateWindowTitle( + document, + process.env.NEXT_PUBLIC_SHAPE_DOCS_TITLE, + stateContainer.selection + ) + }, [stateContainer.selection]) return (