From 10194ab2063d30de9fb155d357bdbc8d31d33664 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Mon, 28 Apr 2025 08:49:39 +0200 Subject: [PATCH 1/3] Allows specifying default specification via config The default specification is the one displayed when no specification has been selected via the URL or when the project is opened from the sidebar. --- .../projects/GitHubProjectDataSource.test.ts | 202 ++++++++++++++++-- .../projects/data/GitHubProjectDataSource.ts | 17 +- .../projects/data/useProjectSelection.ts | 2 +- .../projects/domain/IProjectConfig.ts | 1 + .../projects/domain/OpenApiSpecification.ts | 3 +- .../projects/domain/ProjectNavigator.ts | 4 +- .../domain/getProjectSelectionFromPath.ts | 2 +- 7 files changed, 210 insertions(+), 21 deletions(-) diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts index 6dcbda37..c98b3a8e 100644 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -87,7 +87,8 @@ test("It maps projects including branches and tags", async () => { id: "openapi.yml", name: "openapi.yml", url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/openapi.yml" + editURL: "https://github.com/acme/foo-openapi/edit/main/openapi.yml", + isDefault: false }], url: "https://github.com/acme/foo-openapi/tree/main", isDefault: true @@ -98,7 +99,8 @@ test("It maps projects including branches and tags", async () => { id: "openapi.yml", name: "openapi.yml", url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/1.0/openapi.yml" + editURL: "https://github.com/acme/foo-openapi/edit/1.0/openapi.yml", + isDefault: false }], url: "https://github.com/acme/foo-openapi/tree/1.0", isDefault: false @@ -195,17 +197,20 @@ test("It supports multiple OpenAPI specifications on a branch", async () => { id: "foo-service.yml", name: "foo-service.yml", url: "/api/blob/acme/foo-openapi/foo-service.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/foo-service.yml" + editURL: "https://github.com/acme/foo-openapi/edit/main/foo-service.yml", + isDefault: false }, { id: "bar-service.yml", name: "bar-service.yml", url: "/api/blob/acme/foo-openapi/bar-service.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/bar-service.yml" + editURL: "https://github.com/acme/foo-openapi/edit/main/bar-service.yml", + isDefault: false }, { id: "baz-service.yml", name: "baz-service.yml", url: "/api/blob/acme/foo-openapi/baz-service.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/baz-service.yml" + editURL: "https://github.com/acme/foo-openapi/edit/main/baz-service.yml", + isDefault: false }], url: "https://github.com/acme/foo-openapi/tree/main", isDefault: true @@ -216,7 +221,8 @@ test("It supports multiple OpenAPI specifications on a branch", async () => { id: "openapi.yml", name: "openapi.yml", url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/1.0/openapi.yml" + editURL: "https://github.com/acme/foo-openapi/edit/1.0/openapi.yml", + isDefault: false }], url: "https://github.com/acme/foo-openapi/tree/1.0", isDefault: false @@ -749,11 +755,13 @@ test("It adds remote versions from the project configuration", async () => { specifications: [{ id: "huey", name: "Huey", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/huey.yml" })}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/huey.yml" })}`, + isDefault: false }, { id: "dewey", name: "Dewey", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/dewey.yml" })}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/dewey.yml" })}`, + isDefault: false }] }, { id: "bobby", @@ -762,7 +770,8 @@ test("It adds remote versions from the project configuration", async () => { specifications: [{ id: "louie", name: "Louie", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/louie.yml" })}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/louie.yml" })}`, + isDefault: false }] }]) }) @@ -816,7 +825,8 @@ test("It modifies ID of remote version if the ID already exists", async () => { id: "openapi.yml", name: "openapi.yml", url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/bar/openapi.yml" + editURL: "https://github.com/acme/foo-openapi/edit/bar/openapi.yml", + isDefault: false }] }, { id: "bar1", @@ -825,7 +835,8 @@ test("It modifies ID of remote version if the ID already exists", async () => { specifications: [{ id: "baz", name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, + isDefault: false }] }, { id: "bar2", @@ -834,7 +845,8 @@ test("It modifies ID of remote version if the ID already exists", async () => { specifications: [{ id: "hello", name: "Hello", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/hello.yml" })}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/hello.yml" })}`, + isDefault: false }] }]) }) @@ -877,7 +889,8 @@ test("It lets users specify the ID of a remote version", async () => { specifications: [{ id: "baz", name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, + isDefault: false }] }]) }) @@ -920,7 +933,168 @@ test("It lets users specify the ID of a remote specification", async () => { specifications: [{ id: "some-spec", name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}` + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, + isDefault: false }] }]) }) + +test("It sets isDefault on the correct specification based on defaultSpecificationName in config", async () => { + const sut = new GitHubProjectDataSource({ + repositoryNameSuffix: "-openapi", + repositoryDataSource: { + async getRepositories() { + return [{ + owner: "acme", + name: "foo-openapi", + defaultBranchRef: { + id: "12345678", + name: "main" + }, + configYml: { + text: ` + defaultSpecificationName: bar-service.yml + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Baz + url: https://example.com/baz.yml + ` + }, + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ] + }], + tags: [] + }] + } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) + const projects = await sut.getProjects() + const specs = projects[0].versions[0].specifications + expect(specs.find(s => s.name === "bar-service.yml")!.isDefault).toBe(true) + expect(specs.find(s => s.name === "foo-service.yml")!.isDefault).toBe(false) + expect(specs.find(s => s.name === "baz-service.yml")!.isDefault).toBe(false) + expect(projects[0].versions[1].specifications.find(s => s.name === "Baz")!.isDefault).toBe(false) +}) + +test("It sets a remote specification as the default if specified", async () => { + const sut = new GitHubProjectDataSource({ + repositoryNameSuffix: "-openapi", + repositoryDataSource: { + async getRepositories() { + return [{ + owner: "acme", + name: "foo-openapi", + defaultBranchRef: { + id: "12345678", + name: "main" + }, + configYaml: { + text: ` + defaultSpecificationName: Baz + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Baz + url: https://example.com/baz.yml + - id: another-spec + name: Qux + url: https://example.com/qux.yml + ` + }, + branches: [], + tags: [] + }] + } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) + const projects = await sut.getProjects() + const remoteSpecs = projects[0].versions[0].specifications + expect(remoteSpecs.find(s => s.id === "some-spec")!.isDefault).toBe(true) + expect(remoteSpecs.find(s => s.id === "another-spec")!.isDefault).toBe(false) +}) + + +test("It sets isDefault to false for all specifications if defaultSpecificationName is not set", async () => { + const sut = new GitHubProjectDataSource({ + repositoryNameSuffix: "-openapi", + repositoryDataSource: { + async getRepositories() { + return [{ + owner: "acme", + name: "foo-openapi", + defaultBranchRef: { + id: "12345678", + name: "main" + }, + configYml: { + text: `` + }, + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ] + }], + tags: [] + }] + } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) + const projects = await sut.getProjects() + const specs = projects[0].versions[0].specifications + expect(specs.every(s => s.isDefault === false)).toBe(true) +}) + +test("It silently ignores defaultSpecificationName if no matching spec is found", async () => { + const sut = new GitHubProjectDataSource({ + repositoryNameSuffix: "-openapi", + repositoryDataSource: { + async getRepositories() { + return [{ + owner: "acme", + name: "foo-openapi", + defaultBranchRef: { + id: "12345678", + name: "main" + }, + configYml: { + text: `defaultSpecificationName: non-existent.yml` + }, + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ] + }], + tags: [] + }] + } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) + const projects = await sut.getProjects() + const specs = projects[0].versions[0].specifications + expect(specs.every(s => s.isDefault === false)).toBe(true) +}) diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index dc6de577..385471eb 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -65,6 +65,7 @@ export default class GitHubProjectDataSource implements IProjectDataSource { ).filter(version => { return version.specifications.length > 0 }) + .map(version => this.setDefaultSpecification(version, config?.defaultSpecificationName)); const defaultName = repository.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "") return { id: `${repository.owner}-${defaultName}`, @@ -130,7 +131,8 @@ export default class GitHubProjectDataSource implements IProjectDataSource { path: file.name, ref: ref.id }), - editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${file.name}` + editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${file.name}`, + isDefault: false // initial value } }) return { @@ -187,7 +189,8 @@ export default class GitHubProjectDataSource implements IProjectDataSource { return { id: this.makeURLSafeID((e.id || e.name).toLowerCase()), name: e.name, - url: `/api/remotes/${encodedRemoteConfig}` + url: `/api/remotes/${encodedRemoteConfig}`, + isDefault: false // initial value }; }) versions.push({ @@ -246,4 +249,14 @@ export default class GitHubProjectDataSource implements IProjectDataSource { return undefined } } + + private setDefaultSpecification(version: Version, defaultSpecificationName?: string): Version { + return { + ...version, + specifications: version.specifications.map(spec => ({ + ...spec, + isDefault: spec.name == defaultSpecificationName + })) + } + } } diff --git a/src/features/projects/data/useProjectSelection.ts b/src/features/projects/data/useProjectSelection.ts index 2ff26e96..80b7327b 100644 --- a/src/features/projects/data/useProjectSelection.ts +++ b/src/features/projects/data/useProjectSelection.ts @@ -29,7 +29,7 @@ export default function useProjectSelection() { }, selectProject: (project: Project) => { const version = project.versions[0] - const specification = version.specifications[0] + const specification = version.specifications.find(spec => spec.isDefault) || version.specifications[0] NProgress.start() projectNavigator.navigate( project.owner, diff --git a/src/features/projects/domain/IProjectConfig.ts b/src/features/projects/domain/IProjectConfig.ts index 08e27f7c..34cf660c 100644 --- a/src/features/projects/domain/IProjectConfig.ts +++ b/src/features/projects/domain/IProjectConfig.ts @@ -20,6 +20,7 @@ export const ProjectConfigRemoteVersionSchema = z.object({ export const IProjectConfigSchema = z.object({ name: z.coerce.string().optional(), image: z.string().optional(), + defaultSpecificationName: z.string().optional(), remoteVersions: ProjectConfigRemoteVersionSchema.array().optional() }) diff --git a/src/features/projects/domain/OpenApiSpecification.ts b/src/features/projects/domain/OpenApiSpecification.ts index 32ed1012..b0c3bfa5 100644 --- a/src/features/projects/domain/OpenApiSpecification.ts +++ b/src/features/projects/domain/OpenApiSpecification.ts @@ -4,7 +4,8 @@ export const OpenApiSpecificationSchema = z.object({ id: z.string(), name: z.string(), url: z.string(), - editURL: z.string().optional() + editURL: z.string().optional(), + isDefault: z.boolean() }) type OpenApiSpecification = z.infer diff --git a/src/features/projects/domain/ProjectNavigator.ts b/src/features/projects/domain/ProjectNavigator.ts index aa87c239..f9094732 100644 --- a/src/features/projects/domain/ProjectNavigator.ts +++ b/src/features/projects/domain/ProjectNavigator.ts @@ -36,8 +36,8 @@ export default class ProjectNavigator { if (candidateSpecification) { this.router.push(`/${project.owner}/${project.name}/${newVersion.id}/${candidateSpecification.id}`) } else { - const firstSpecification = newVersion.specifications[0] - this.router.push(`/${project.owner}/${project.name}/${newVersion.id}/${firstSpecification.id}`) + const defaultOrFirstSpecification = newVersion.specifications.find(spec => spec.isDefault) || newVersion.specifications[0] + this.router.push(`/${project.owner}/${project.name}/${newVersion.id}/${defaultOrFirstSpecification.id}`) } } diff --git a/src/features/projects/domain/getProjectSelectionFromPath.ts b/src/features/projects/domain/getProjectSelectionFromPath.ts index b18c405c..7f56ba95 100644 --- a/src/features/projects/domain/getProjectSelectionFromPath.ts +++ b/src/features/projects/domain/getProjectSelectionFromPath.ts @@ -61,7 +61,7 @@ export default function getProjectSelectionFromPath({ if (specificationId && !didMoveSpecificationIdToVersionId) { specification = version.specifications.find(e => e.id == specificationId) } else if (version.specifications.length > 0) { - specification = version.specifications[0] + specification = version.specifications.find(spec => spec.isDefault) || version.specifications[0] } return { project, version, specification } } From ca487b2002b0eb11eb45401200e1bd3e1bc0ad09 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Mon, 28 Apr 2025 09:07:51 +0200 Subject: [PATCH 2/3] Update src/features/projects/data/GitHubProjectDataSource.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/features/projects/data/GitHubProjectDataSource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index 385471eb..acc33ed5 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -255,7 +255,7 @@ export default class GitHubProjectDataSource implements IProjectDataSource { ...version, specifications: version.specifications.map(spec => ({ ...spec, - isDefault: spec.name == defaultSpecificationName + isDefault: spec.name === defaultSpecificationName })) } } From 2057efd46859c45901e843b15759341b574159d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 28 Apr 2025 10:21:11 +0200 Subject: [PATCH 3/3] Introduces getDefaultSpecification --- src/features/projects/data/useProjectSelection.ts | 10 ++++++++-- src/features/projects/domain/ProjectNavigator.ts | 3 ++- src/features/projects/domain/Version.ts | 4 ++++ .../projects/domain/getProjectSelectionFromPath.ts | 4 ++-- src/features/projects/domain/index.ts | 1 + 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/features/projects/data/useProjectSelection.ts b/src/features/projects/data/useProjectSelection.ts index 80b7327b..de8327b4 100644 --- a/src/features/projects/data/useProjectSelection.ts +++ b/src/features/projects/data/useProjectSelection.ts @@ -4,7 +4,13 @@ import NProgress from "nprogress" import { useRouter, usePathname } from "next/navigation" import { useContext } from "react" import { ProjectsContext } from "@/common" -import { Project, ProjectNavigator, getProjectSelectionFromPath } from "../domain" +import { + Project, + ProjectNavigator, + getProjectSelectionFromPath, + getDefaultSpecification + +} from "../domain" export default function useProjectSelection() { const router = useRouter() @@ -29,7 +35,7 @@ export default function useProjectSelection() { }, selectProject: (project: Project) => { const version = project.versions[0] - const specification = version.specifications.find(spec => spec.isDefault) || version.specifications[0] + const specification = getDefaultSpecification(version) NProgress.start() projectNavigator.navigate( project.owner, diff --git a/src/features/projects/domain/ProjectNavigator.ts b/src/features/projects/domain/ProjectNavigator.ts index f9094732..e3d03217 100644 --- a/src/features/projects/domain/ProjectNavigator.ts +++ b/src/features/projects/domain/ProjectNavigator.ts @@ -1,4 +1,5 @@ import Project from "./Project" +import { getDefaultSpecification } from "./Version" interface IPathnameReader { readonly pathname: string @@ -36,7 +37,7 @@ export default class ProjectNavigator { if (candidateSpecification) { this.router.push(`/${project.owner}/${project.name}/${newVersion.id}/${candidateSpecification.id}`) } else { - const defaultOrFirstSpecification = newVersion.specifications.find(spec => spec.isDefault) || newVersion.specifications[0] + const defaultOrFirstSpecification = getDefaultSpecification(newVersion) this.router.push(`/${project.owner}/${project.name}/${newVersion.id}/${defaultOrFirstSpecification.id}`) } } diff --git a/src/features/projects/domain/Version.ts b/src/features/projects/domain/Version.ts index 2eee9225..f6b69989 100644 --- a/src/features/projects/domain/Version.ts +++ b/src/features/projects/domain/Version.ts @@ -12,3 +12,7 @@ export const VersionSchema = z.object({ type Version = z.infer export default Version + +export function getDefaultSpecification(version: Version) { + return version.specifications.find((spec) => spec.isDefault) || version.specifications[0] +} diff --git a/src/features/projects/domain/getProjectSelectionFromPath.ts b/src/features/projects/domain/getProjectSelectionFromPath.ts index 7f56ba95..dfa5497e 100644 --- a/src/features/projects/domain/getProjectSelectionFromPath.ts +++ b/src/features/projects/domain/getProjectSelectionFromPath.ts @@ -1,5 +1,5 @@ import Project from "./Project" -import Version from "./Version" +import Version, { getDefaultSpecification } from "./Version" import OpenApiSpecification from "./OpenApiSpecification" export default function getProjectSelectionFromPath({ @@ -61,7 +61,7 @@ export default function getProjectSelectionFromPath({ if (specificationId && !didMoveSpecificationIdToVersionId) { specification = version.specifications.find(e => e.id == specificationId) } else if (version.specifications.length > 0) { - specification = version.specifications.find(spec => spec.isDefault) || version.specifications[0] + specification = getDefaultSpecification(version) } return { project, version, specification } } diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts index 2b3f10cf..2bf08445 100644 --- a/src/features/projects/domain/index.ts +++ b/src/features/projects/domain/index.ts @@ -16,3 +16,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 { getDefaultSpecification } from "./Version"