From 05577d0e470ca964275d8484b1feaab0995d69ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 12:06:02 +0100 Subject: [PATCH 01/31] Refactors GitHubProjectDataSource to be testable --- .../projects/CachingProjectDataSource.test.ts | 23 +- src/composition.ts | 19 +- .../projects/data/GitHubProjectDataSource.ts | 224 ++++++------------ .../projects/data/GitHubProjectRepository.ts | 44 ++++ .../data/GitHubProjectRepositoryDataSource.ts | 102 ++++++++ src/features/projects/data/index.ts | 2 + src/features/projects/data/useProjects.ts | 2 +- .../domain/CachingProjectDataSource.ts | 14 +- 8 files changed, 255 insertions(+), 175 deletions(-) create mode 100644 src/features/projects/data/GitHubProjectRepository.ts create mode 100644 src/features/projects/data/GitHubProjectRepositoryDataSource.ts diff --git a/__test__/projects/CachingProjectDataSource.test.ts b/__test__/projects/CachingProjectDataSource.test.ts index 7f4974cf..bf4c0cea 100644 --- a/__test__/projects/CachingProjectDataSource.test.ts +++ b/__test__/projects/CachingProjectDataSource.test.ts @@ -27,17 +27,20 @@ test("It caches projects read from the data source", async () => { }] let cachedProjects: Project[] | undefined const sut = new CachingProjectDataSource({ - async getProjects() { - return projects - } - }, { - async get() { - return [] - }, - async set(projects) { - cachedProjects = projects + dataSource: { + async getProjects() { + return projects + } }, - async delete() {} + repository: { + async get() { + return [] + }, + async set(projects) { + cachedProjects = projects + }, + async delete() {} + } }) await sut.getProjects() expect(cachedProjects).toEqual(projects) diff --git a/src/composition.ts b/src/composition.ts index 52e5e75d..926d2396 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -8,7 +8,8 @@ import { SessionMutexFactory } from "@/common" import { - GitHubProjectDataSource + GitHubProjectDataSource, + GitHubProjectRepositoryDataSource } from "@/features/projects/data" import { CachingProjectDataSource, @@ -181,13 +182,15 @@ export const projectRepository = new ProjectRepository( projectUserDataRepository ) -export const projectDataSource = new CachingProjectDataSource( - new GitHubProjectDataSource( - userGitHubClient, - GITHUB_ORGANIZATION_NAME - ), - projectRepository -) +export const projectDataSource = new CachingProjectDataSource({ + dataSource: new GitHubProjectDataSource({ + dataSource: new GitHubProjectRepositoryDataSource({ + graphQlClient: userGitHubClient, + organizationName: GITHUB_ORGANIZATION_NAME + }) + }), + repository: projectRepository +}) export const logInHandler = new CompositeLogInHandler([ new CredentialsTransferringLogInHandler({ diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index e055b4e3..8218263d 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -1,4 +1,6 @@ -import { IGitHubClient } from "@/common" +import GitHubProjectRepository, { + GitHubProjectRepositoryRef +} from "./GitHubProjectRepository" import { Project, IProjectConfig, @@ -7,128 +9,25 @@ import { ProjectConfigParser } from "../domain" -type SearchResult = { - readonly name: string - readonly owner: { - readonly login: string - } - readonly defaultBranchRef: { - readonly name: string - readonly target: { - readonly oid: string - } - } - readonly configYml?: { - readonly text: string - } - readonly configYaml?: { - readonly text: string - } - readonly branches: EdgesContainer - readonly tags: EdgesContainer +interface IGitHubProjectRepositoryDataSource { + getRepositories(): Promise } -type EdgesContainer = { - readonly edges: Edge[] -} - -type Edge = { - readonly node: T -} - -type Ref = { - readonly name: string - readonly target: { - readonly oid: string - readonly tree: { - readonly entries: File[] - } - } -} - -type File = { - readonly name: string +type GitHubProjectDataSourceConfig = { + readonly dataSource: IGitHubProjectRepositoryDataSource } export default class GitHubProjectDataSource implements IProjectDataSource { - private gitHubClient: IGitHubClient - private organizationName: string + private dataSource: IGitHubProjectRepositoryDataSource - constructor(gitHubClient: IGitHubClient, organizationName: string) { - this.gitHubClient = gitHubClient - this.organizationName = organizationName + constructor(config: GitHubProjectDataSourceConfig) { + this.dataSource = config.dataSource } async getProjects(): Promise { - const request = { - query: ` - query Repositories($searchQuery: String!) { - search(query: $searchQuery, type: REPOSITORY, first: 100) { - results: nodes { - ... on Repository { - name - owner { - login - } - defaultBranchRef { - name - target { - ...on Commit { - oid - } - } - } - configYml: object(expression: "HEAD:.shape-docs.yml") { - ...ConfigParts - } - configYaml: object(expression: "HEAD:.shape-docs.yaml") { - ...ConfigParts - } - branches: refs(refPrefix: "refs/heads/", first: 100) { - ...RefConnectionParts - } - tags: refs(refPrefix: "refs/tags/", first: 100) { - ...RefConnectionParts - } - } - } - } - } - - fragment RefConnectionParts on RefConnection { - edges { - node { - name - ... on Ref { - name - target { - ... on Commit { - oid - tree { - entries { - name - } - } - } - } - } - } - } - } - - fragment ConfigParts on GitObject { - ... on Blob { - text - } - } - `, - variables: { - searchQuery: `org:${this.organizationName} openapi in:name` - } - } - const response = await this.gitHubClient.graphql(request) - return response.search.results.map((searchResult: SearchResult) => { - return this.mapProject(searchResult) + const repositories = await this.dataSource.getRepositories() + return repositories.map(repository => { + return this.mapProject(repository) }) .filter((project: Project) => { return project.versions.length > 0 @@ -138,31 +37,31 @@ export default class GitHubProjectDataSource implements IProjectDataSource { }) } - private mapProject(searchResult: SearchResult): Project { - const config = this.getConfig(searchResult) + private mapProject(repository: GitHubProjectRepository): Project { + const config = this.getConfig(repository) let imageURL: string | undefined if (config && config.image) { - imageURL = this.getGitHubBlobURL( - searchResult.owner.login, - searchResult.name, - config.image, - searchResult.defaultBranchRef.target.oid - ) + imageURL = this.getGitHubBlobURL({ + ownerName: repository.owner.login, + repositoryName: repository.name, + path: config.image, + ref: repository.defaultBranchRef.target.oid + }) } - const defaultName = searchResult.name.replace(/-openapi$/, "") + const defaultName = repository.name.replace(/-openapi$/, "") return { id: defaultName, name: defaultName, displayName: config?.name || defaultName, - versions: this.getVersions(searchResult).filter(version => { + versions: this.getVersions(repository).filter(version => { return version.specifications.length > 0 }), imageURL: imageURL } } - private getConfig(searchResult: SearchResult): IProjectConfig | null { - const yml = searchResult.configYml || searchResult.configYaml + private getConfig(repository: GitHubProjectRepository): IProjectConfig | null { + const yml = repository.configYml || repository.configYaml if (!yml || !yml.text || yml.text.length == 0) { return null } @@ -170,15 +69,25 @@ export default class GitHubProjectDataSource implements IProjectDataSource { return parser.parse(yml.text) } - private getVersions(searchResult: SearchResult): Version[] { - const branchVersions = searchResult.branches.edges.map((edge: Edge) => { - const isDefaultRef = edge.node.target.oid == searchResult.defaultBranchRef.target.oid - return this.mapVersionFromRef(searchResult.owner.login, searchResult.name, edge.node, isDefaultRef) + private getVersions(repository: GitHubProjectRepository): Version[] { + const branchVersions = repository.branches.edges.map(edge => { + const isDefaultRef = edge.node.name == repository.defaultBranchRef.name + console.log(repository.defaultBranchRef) + return this.mapVersionFromRef({ + ownerName: repository.owner.login, + repositoryName: repository.name, + ref: edge.node, + isDefaultRef + }) }) - const tagVersions = searchResult.tags.edges.map((edge: Edge) => { - return this.mapVersionFromRef(searchResult.owner.login, searchResult.name, edge.node) + const tagVersions = repository.tags.edges.map(edge => { + return this.mapVersionFromRef({ + ownerName: repository.owner.login, + repositoryName: repository.name, + ref: edge.node + }) }) - const defaultBranchName = searchResult.defaultBranchRef.name + const defaultBranchName = repository.defaultBranchRef.name const candidateDefaultBranches = [ defaultBranchName, "main", "master", "develop", "development" ] @@ -201,33 +110,38 @@ export default class GitHubProjectDataSource implements IProjectDataSource { return allVersions } - private mapVersionFromRef( - owner: string, - repository: string, - ref: Ref, - isDefaultRef: boolean = false - ): Version { + private mapVersionFromRef({ + ownerName, + repositoryName, + ref, + isDefaultRef + }: { + ownerName: string + repositoryName: string + ref: GitHubProjectRepositoryRef + isDefaultRef?: boolean + }): Version { const specifications = ref.target.tree.entries.filter(file => { return this.isOpenAPISpecification(file.name) }).map(file => { return { id: file.name, name: file.name, - url: this.getGitHubBlobURL( - owner, - repository, - file.name, - ref.target.oid - ), - editURL: `https://github.com/${owner}/${repository}/edit/${ref.name}/${file.name}` + url: this.getGitHubBlobURL({ + ownerName, + repositoryName, + path: file.name, + ref: ref.target.oid + }), + editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${file.name}` } }) return { id: ref.name, name: ref.name, specifications: specifications, - url: `https://github.com/${owner}/${repository}/tree/${ref.name}`, - isDefault: isDefaultRef + url: `https://github.com/${ownerName}/${repositoryName}/tree/${ref.name}`, + isDefault: isDefaultRef || false } } @@ -237,7 +151,17 @@ export default class GitHubProjectDataSource implements IProjectDataSource { ) } - private getGitHubBlobURL(owner: string, repository: string, path: string, ref: string): string { - return `/api/blob/${owner}/${repository}/${path}?ref=${ref}` + private getGitHubBlobURL({ + ownerName, + repositoryName, + path, + ref + }: { + ownerName: string + repositoryName: string + path: string + ref: string + }): string { + return `/api/blob/${ownerName}/${repositoryName}/${path}?ref=${ref}` } } diff --git a/src/features/projects/data/GitHubProjectRepository.ts b/src/features/projects/data/GitHubProjectRepository.ts new file mode 100644 index 00000000..20b88af6 --- /dev/null +++ b/src/features/projects/data/GitHubProjectRepository.ts @@ -0,0 +1,44 @@ +type GitHubProjectRepository = { + readonly name: string + readonly owner: { + readonly login: string + } + readonly defaultBranchRef: { + readonly name: string + readonly target: { + readonly oid: string + } + } + readonly configYml?: { + readonly text: string + } + readonly configYaml?: { + readonly text: string + } + readonly branches: EdgesContainer + readonly tags: EdgesContainer +} + +export default GitHubProjectRepository + +type EdgesContainer = { + readonly edges: Edge[] +} + +type Edge = { + readonly node: T +} + +export type GitHubProjectRepositoryRef = { + readonly name: string + readonly target: { + readonly oid: string + readonly tree: { + readonly entries: GitHubProjectRepositoryFile[] + } + } +} + +export type GitHubProjectRepositoryFile = { + readonly name: string +} \ No newline at end of file diff --git a/src/features/projects/data/GitHubProjectRepositoryDataSource.ts b/src/features/projects/data/GitHubProjectRepositoryDataSource.ts new file mode 100644 index 00000000..2373df7a --- /dev/null +++ b/src/features/projects/data/GitHubProjectRepositoryDataSource.ts @@ -0,0 +1,102 @@ +import GitHubProjectRepository from "./GitHubProjectRepository" + +export type GitHubGraphQLClientRequest = { + readonly query: string + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + readonly variables: {[key: string]: any} +} + +export type GitHubGraphQLClientResponse = { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + readonly [key: string]: any +} + +interface IGitHubGraphQLClient { + graphql(request: GitHubGraphQLClientRequest): Promise +} + +type GitHubProjectRepositoryDataSourceConfig = { + readonly graphQlClient: IGitHubGraphQLClient + readonly organizationName: string +} + +export default class GitHubProjectRepositoryDataSource { + private graphQlClient: IGitHubGraphQLClient + private organizationName: string + + constructor(config: GitHubProjectRepositoryDataSourceConfig) { + this.graphQlClient = config.graphQlClient + this.organizationName = config.organizationName + } + + async getRepositories(): Promise { + const request = { + query: ` + query Repositories($searchQuery: String!) { + search(query: $searchQuery, type: REPOSITORY, first: 100) { + results: nodes { + ... on Repository { + name + owner { + login + } + defaultBranchRef { + name + target { + ...on Commit { + oid + } + } + } + configYml: object(expression: "HEAD:.shape-docs.yml") { + ...ConfigParts + } + configYaml: object(expression: "HEAD:.shape-docs.yaml") { + ...ConfigParts + } + branches: refs(refPrefix: "refs/heads/", first: 100) { + ...RefConnectionParts + } + tags: refs(refPrefix: "refs/tags/", first: 100) { + ...RefConnectionParts + } + } + } + } + } + + fragment RefConnectionParts on RefConnection { + edges { + node { + name + ... on Ref { + name + target { + ... on Commit { + oid + tree { + entries { + name + } + } + } + } + } + } + } + } + + fragment ConfigParts on GitObject { + ... on Blob { + text + } + } + `, + variables: { + searchQuery: `org:${this.organizationName} openapi in:name` + } + } + const response = await this.graphQlClient.graphql(request) + return response.search.results + } +} diff --git a/src/features/projects/data/index.ts b/src/features/projects/data/index.ts index 8199a31a..936a7bd9 100644 --- a/src/features/projects/data/index.ts +++ b/src/features/projects/data/index.ts @@ -1,2 +1,4 @@ export { default as GitHubProjectDataSource } from "./GitHubProjectDataSource" +export { default as GitHubProjectRepositoryDataSource } from "./GitHubProjectRepositoryDataSource" +export * from "./GitHubProjectRepositoryDataSource" export { default as useProjects } from "./useProjects" diff --git a/src/features/projects/data/useProjects.ts b/src/features/projects/data/useProjects.ts index a0b2cb16..8f1d6f11 100644 --- a/src/features/projects/data/useProjects.ts +++ b/src/features/projects/data/useProjects.ts @@ -1,7 +1,7 @@ "use client" import useSWR from "swr" -import { fetcher } from "@/common" +import { fetcher } from "../../../common" import { Project } from "../domain" type ProjectContainer = { projects: Project[] } diff --git a/src/features/projects/domain/CachingProjectDataSource.ts b/src/features/projects/domain/CachingProjectDataSource.ts index a95dae13..f47f4160 100644 --- a/src/features/projects/domain/CachingProjectDataSource.ts +++ b/src/features/projects/domain/CachingProjectDataSource.ts @@ -2,16 +2,18 @@ import Project from "./Project" import IProjectDataSource from "./IProjectDataSource" import IProjectRepository from "./IProjectRepository" +type CachingProjectDataSourceConfig = { + readonly dataSource: IProjectDataSource + readonly repository: IProjectRepository +} + export default class CachingProjectDataSource implements IProjectDataSource { private dataSource: IProjectDataSource private repository: IProjectRepository - constructor( - dataSource: IProjectDataSource, - repository: IProjectRepository - ) { - this.dataSource = dataSource - this.repository = repository + constructor(config: CachingProjectDataSourceConfig) { + this.dataSource = config.dataSource + this.repository = config.repository } async getProjects(): Promise { From fa4e0a39a1de627d84a3098b36b3c0d4c7c28ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 12:06:16 +0100 Subject: [PATCH 02/31] Adds tests for GitHubProjectDataSource --- .../projects/GitHubProjectDataSource.test.ts | 1093 +++++++++++++++++ 1 file changed, 1093 insertions(+) create mode 100644 __test__/projects/GitHubProjectDataSource.test.ts diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts new file mode 100644 index 00000000..874caf3d --- /dev/null +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -0,0 +1,1093 @@ +import { + GitHubProjectDataSource + } from "../../src/features/projects/data" + +test("It loads repositories from data source", async () => { + let didLoadRepositories = false + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + didLoadRepositories = true + return [] + } + } + }) + await sut.getProjects() + expect(didLoadRepositories).toBeTruthy() +}) + +test("It maps projects including branches and tags", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [{ + node: { + name: "1.0", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects).toEqual([{ + id: "foo", + name: "foo", + displayName: "foo", + versions: [{ + id: "main", + name: "main", + specifications: [{ + id: "openapi.yml", + name: "openapi.yml", + url: "/api/blob/acme/foo/openapi.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/main/openapi.yml" + }], + url: "https://github.com/acme/foo/tree/main", + isDefault: true + }, { + id: "1.0", + name: "1.0", + specifications: [{ + id: "openapi.yml", + name: "openapi.yml", + url: "/api/blob/acme/foo/openapi.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/1.0/openapi.yml" + }], + url: "https://github.com/acme/foo/tree/1.0", + isDefault: false + }] + }]) +}) + +test("It removes \"-openapi\" suffix from project name", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo-openapi", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [{ + node: { + name: "1.0", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].id).toEqual("foo") + expect(projects[0].name).toEqual("foo") + expect(projects[0].displayName).toEqual("foo") +}) + +test("It supports multiple OpenAPI specifications on a branch", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "foo-service.yml" + }, { + name: "bar-service.yml" + }, { + name: "baz-service.yml" + }] + } + } + } + }] + }, + tags: { + edges: [{ + node: { + name: "1.0", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects).toEqual([{ + id: "foo", + name: "foo", + displayName: "foo", + versions: [{ + id: "main", + name: "main", + specifications: [{ + id: "foo-service.yml", + name: "foo-service.yml", + url: "/api/blob/acme/foo/foo-service.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/main/foo-service.yml" + }, { + id: "bar-service.yml", + name: "bar-service.yml", + url: "/api/blob/acme/foo/bar-service.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/main/bar-service.yml" + }, { + id: "baz-service.yml", + name: "baz-service.yml", + url: "/api/blob/acme/foo/baz-service.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/main/baz-service.yml" + }], + url: "https://github.com/acme/foo/tree/main", + isDefault: true + }, { + id: "1.0", + name: "1.0", + specifications: [{ + id: "openapi.yml", + name: "openapi.yml", + url: "/api/blob/acme/foo/openapi.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/1.0/openapi.yml" + }], + url: "https://github.com/acme/foo/tree/1.0", + isDefault: false + }] + }]) +}) + +test("It removes \"-openapi\" suffix from project name", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo-openapi", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [{ + node: { + name: "1.0", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].id).toEqual("foo") + expect(projects[0].name).toEqual("foo") + expect(projects[0].displayName).toEqual("foo") +}) + +test("It filters away projects with no versions", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects.length).toEqual(0) +}) + +test("It filters away branches with no specifications", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo-openapi", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }, { + node: { + name: "bugfix", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "foo.txt" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions.length).toEqual(1) +}) + +test("It filters away tags with no specifications", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo-openapi", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [{ + node: { + name: "1.0", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }, { + node: { + name: "1.1", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "foo.txt" + }] + } + } + } + }] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions.length).toEqual(2) +}) + +test("It reads image from .shape-config.yml", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo-openapi", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + configYml: { + text: "image: icon.png" + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") +}) + +test("It filters away tags with no specifications", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo-openapi", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [{ + node: { + name: "1.0", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }, { + node: { + name: "1.1", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "foo.txt" + }] + } + } + } + }] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions.length).toEqual(2) +}) + +test("It reads display name from .shape-config.yml", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo-openapi", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + configYml: { + text: "name: Hello World" + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].id).toEqual("foo") + expect(projects[0].name).toEqual("foo") + expect(projects[0].displayName).toEqual("Hello World") +}) + +test("It reads image from .shape-config.yml", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo-openapi", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + configYml: { + text: "image: icon.png" + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") +}) + +test("It reads display name from .shape-config.yaml", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo-openapi", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + configYaml: { + text: "name: Hello World" + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].id).toEqual("foo") + expect(projects[0].name).toEqual("foo") + expect(projects[0].displayName).toEqual("Hello World") +}) + +test("It reads image from .shape-config.yaml", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo-openapi", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + configYaml: { + text: "image: icon.png" + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") +}) + +test("It sorts projects alphabetically", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "cathrine", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }, { + name: "anne", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }, { + name: "bobby", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].name).toEqual("anne") + expect(projects[1].name).toEqual("bobby") + expect(projects[2].name).toEqual("cathrine") +}) + +test("It sorts versions alphabetically", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "bobby", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }, { + node: { + name: "anne", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [{ + node: { + name: "1.0", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }, { + node: { + name: "cathrine", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions[0].name).toEqual("1.0") + expect(projects[0].versions[1].name).toEqual("anne") + expect(projects[0].versions[2].name).toEqual("bobby") + expect(projects[0].versions[3].name).toEqual("cathrine") +}) + +test("It prioritizes main, master, develop, and development branch names when sorting verisons", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "anne", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }, { + node: { + name: "develop", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }, { + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }, { + node: { + name: "development", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }, { + node: { + name: "master", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [{ + node: { + name: "1.0", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions[0].name).toEqual("main") + expect(projects[0].versions[1].name).toEqual("master") + expect(projects[0].versions[2].name).toEqual("develop") + expect(projects[0].versions[3].name).toEqual("development") + expect(projects[0].versions[4].name).toEqual("1.0") + expect(projects[0].versions[5].name).toEqual("anne") +}) + +test("It identifies the default branch in returned versions", async () => { + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "development", + target: { + oid: "12345678" + } + }, + branches: { + edges: [{ + node: { + name: "anne", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }, { + node: { + name: "main", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }, { + node: { + name: "development", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + const defaultVersionNames = projects[0] + .versions + .filter(e => e.isDefault) + .map(e => e.name) + expect(defaultVersionNames).toEqual(["development"]) +}) From 867b4a826bf39448cb58af9e57f5a84efbf580dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 12:06:21 +0100 Subject: [PATCH 03/31] Adds tests for GitHubProjectRepositoryDataSource --- .../GitHubProjectRepositoryDataSource.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 __test__/projects/GitHubProjectRepositoryDataSource.test.ts diff --git a/__test__/projects/GitHubProjectRepositoryDataSource.test.ts b/__test__/projects/GitHubProjectRepositoryDataSource.test.ts new file mode 100644 index 00000000..7d8d79fd --- /dev/null +++ b/__test__/projects/GitHubProjectRepositoryDataSource.test.ts @@ -0,0 +1,20 @@ +import { + GitHubProjectRepositoryDataSource, + GitHubGraphQLClientRequest + } from "../../src/features/projects/data" + +test("It requests data for the specified organization", async () => { + let sentRequest: GitHubGraphQLClientRequest | undefined + const sut = new GitHubProjectRepositoryDataSource({ + organizationName: "foo", + graphQlClient: { + async graphql(request) { + sentRequest = request + return { search: { results: [] } } + } + } + }) + await sut.getRepositories() + expect(sentRequest).not.toBeUndefined() + expect(sentRequest?.variables.searchQuery).toContain("org:foo") +}) From 385003c82798c93ee4c498456df7c74475b3afd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 12:06:41 +0100 Subject: [PATCH 04/31] Removes debug log --- src/features/projects/data/GitHubProjectDataSource.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index 8218263d..bdf126c4 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -72,7 +72,6 @@ export default class GitHubProjectDataSource implements IProjectDataSource { private getVersions(repository: GitHubProjectRepository): Version[] { const branchVersions = repository.branches.edges.map(edge => { const isDefaultRef = edge.node.name == repository.defaultBranchRef.name - console.log(repository.defaultBranchRef) return this.mapVersionFromRef({ ownerName: repository.owner.login, repositoryName: repository.name, From aba2ea586676e03e3f4361d75c994cda53e9d421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 14:30:59 +0100 Subject: [PATCH 05/31] Removes file extension requirement from specification IDs --- __test__/common/utils/url.test.ts | 6 +-- src/common/utils/url.ts | 85 +++++++++++++++---------------- 2 files changed, 43 insertions(+), 48 deletions(-) diff --git a/__test__/common/utils/url.test.ts b/__test__/common/utils/url.test.ts index dd5968d6..f52e7c95 100644 --- a/__test__/common/utils/url.test.ts +++ b/__test__/common/utils/url.test.ts @@ -44,14 +44,14 @@ test("It reads path containing project, version, and specification with .yaml ex expect(specificationId).toBe("openapi.yaml") }) -test("It reads version containing a slash", async () => { +test("It parses specification without trailing file extension", 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() + expect(versionId).toEqual("bar") + expect(specificationId).toBe("baz") }) test("It read specification when version contains a slash", async () => { diff --git a/src/common/utils/url.ts b/src/common/utils/url.ts index 119afc89..507750b9 100644 --- a/src/common/utils/url.ts +++ b/src/common/utils/url.ts @@ -1,52 +1,47 @@ -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 getPathname(url?: string) { + if (typeof window !== "undefined") { + url = window.location.pathname + } + if (!url) { + return undefined + } + if (!url.startsWith("/")) { + return url + } + return url.substring(1) } -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 - }; +function getProjectSelection(tmpPathname?: string) { + const pathname = getPathname(tmpPathname) + if (!pathname) { + return undefined + } + 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] } - return {}; + } else { + const projectId = comps[0] + const versionId = comps.slice(1, -1).join("/") + const specificationId = comps[comps.length - 1] + return { projectId, versionId, specificationId } + } } -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 getProjectId(tmpPathname?: string) { + return getProjectSelection(tmpPathname)?.projectId } -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 +export function getVersionId(tmpPathname?: string) { + return getProjectSelection(tmpPathname)?.versionId +} + +export function getSpecificationId(tmpPathname?: string) { + return getProjectSelection(tmpPathname)?.specificationId +} From 7150813e2afc0c3e5e185100d89ce17c6289d054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 14:31:23 +0100 Subject: [PATCH 06/31] Supports moving specification ID to version ID --- __test__/projects/getSelection.test.ts | 26 ++++++++++++++++++++ src/features/projects/domain/getSelection.ts | 22 ++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/__test__/projects/getSelection.test.ts b/__test__/projects/getSelection.test.ts index b2b54c5f..02b22cc4 100644 --- a/__test__/projects/getSelection.test.ts +++ b/__test__/projects/getSelection.test.ts @@ -236,3 +236,29 @@ 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({ + projectId: "foo", + versionId: "bar", + specificationId: "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 a9aa2caf..50fd7f2e 100644 --- a/src/features/projects/domain/getSelection.ts +++ b/src/features/projects/domain/getSelection.ts @@ -29,8 +29,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 +54,14 @@ 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 } +} + +function isSpecificationIdFilename(specificationId: string): boolean { + return specificationId.endsWith(".yml") || specificationId.endsWith(".yaml") } \ No newline at end of file From afa8e60999d241154fd4027e5c4fa3068c894bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 14:31:44 +0100 Subject: [PATCH 07/31] Reads remote versions from .shape-docs.yml --- .../projects/GitHubProjectDataSource.test.ts | 151 ++++++++++++++++++ .../projects/data/GitHubProjectDataSource.ts | 99 +++++++++--- .../projects/domain/IProjectConfig.ts | 12 ++ src/features/projects/domain/index.ts | 1 + 4 files changed, 237 insertions(+), 26 deletions(-) diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts index 874caf3d..db8acb59 100644 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -1091,3 +1091,154 @@ test("It identifies the default branch in returned versions", async () => { .map(e => e.name) expect(defaultVersionNames).toEqual(["development"]) }) + +test("It adds remote versions from the project configuration", async () => { + const rawProjectConfig = ` + remoteVersions: + - name: Anne + specifications: + - name: Huey + url: https://example.com/huey.yml + - name: Dewey + url: https://example.com/dewey.yml + - name: Bobby + specifications: + - name: Louie + url: https://example.com/louie.yml + ` + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + configYml: { + text: rawProjectConfig + }, + branches: { + edges: [] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions).toEqual([{ + id: "anne", + name: "Anne", + isDefault: false, + specifications: [{ + id: "huey", + name: "Huey", + url: "https://example.com/huey.yml" + }, { + id: "dewey", + name: "Dewey", + url: "https://example.com/dewey.yml" + }] + }, { + id: "bobby", + name: "Bobby", + isDefault: false, + specifications: [{ + id: "louie", + name: "Louie", + url: "https://example.com/louie.yml" + }] + }]) +}) + +test("It modifies ID of remote version if the ID already exists", async () => { + const rawProjectConfig = ` + remoteVersions: + - name: Bar + specifications: + - name: Baz + url: https://example.com/baz.yml + - name: Bar + specifications: + - name: Hello + url: https://example.com/hello.yml + ` + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "bar", + target: { + oid: "12345678" + } + }, + configYml: { + text: rawProjectConfig + }, + branches: { + edges: [{ + node: { + name: "bar", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions).toEqual([{ + id: "bar", + name: "bar", + url: "https://github.com/acme/foo/tree/bar", + isDefault: true, + specifications: [{ + id: "openapi.yml", + name: "openapi.yml", + url: "/api/blob/acme/foo/openapi.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/bar/openapi.yml" + }] + }, { + id: "bar1", + name: "Bar", + isDefault: false, + specifications: [{ + id: "baz", + name: "Baz", + url: "https://example.com/baz.yml" + }] + }, { + id: "bar2", + name: "Bar", + isDefault: false, + specifications: [{ + id: "hello", + name: "Hello", + url: "https://example.com/hello.yml" + }] + }]) +}) diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index bdf126c4..9225ceeb 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -3,10 +3,11 @@ import GitHubProjectRepository, { } from "./GitHubProjectRepository" import { Project, + Version, IProjectConfig, IProjectDataSource, - Version, - ProjectConfigParser + ProjectConfigParser, + ProjectConfigRemoteVersion, } from "../domain" interface IGitHubProjectRepositoryDataSource { @@ -48,14 +49,21 @@ export default class GitHubProjectDataSource implements IProjectDataSource { ref: repository.defaultBranchRef.target.oid }) } + const versions = this.sortVersions( + this.addRemoteVersions( + this.getVersions(repository), + config?.remoteVersions || [] + ), + repository.defaultBranchRef.name + ).filter(version => { + return version.specifications.length > 0 + }) const defaultName = repository.name.replace(/-openapi$/, "") return { id: defaultName, name: defaultName, displayName: config?.name || defaultName, - versions: this.getVersions(repository).filter(version => { - return version.specifications.length > 0 - }), + versions, imageURL: imageURL } } @@ -86,27 +94,7 @@ export default class GitHubProjectDataSource implements IProjectDataSource { ref: edge.node }) }) - const defaultBranchName = repository.defaultBranchRef.name - const candidateDefaultBranches = [ - defaultBranchName, "main", "master", "develop", "development" - ] - // Reverse them so the top-priority branches end up at the top of the list. - .reverse() - const allVersions = branchVersions.concat(tagVersions).sort((a: Version, b: Version) => { - return a.name.localeCompare(b.name) - }) - // Move the top-priority branches to the top of the list. - for (const candidateDefaultBranch of candidateDefaultBranches) { - const defaultBranchIndex = allVersions.findIndex((version: Version) => { - return version.name === candidateDefaultBranch - }) - if (defaultBranchIndex !== -1) { - const branchVersion = allVersions[defaultBranchIndex] - allVersions.splice(defaultBranchIndex, 1) - allVersions.splice(0, 0, branchVersion) - } - } - return allVersions + return branchVersions.concat(tagVersions) } private mapVersionFromRef({ @@ -163,4 +151,63 @@ export default class GitHubProjectDataSource implements IProjectDataSource { }): string { return `/api/blob/${ownerName}/${repositoryName}/${path}?ref=${ref}` } + + private addRemoteVersions( + existingVersions: Version[], + remoteVersions: ProjectConfigRemoteVersion[] + ): Version[] { + const versions = [...existingVersions] + const versionIds = versions.map(e => e.id) + for (const remoteVersion of remoteVersions) { + const baseVersionId = this.makeURLSafeID( + remoteVersion.id?.toLowerCase() || remoteVersion.name.toLowerCase() + ) + // If the version ID exists then we suffix it with a number to ensure unique versions. + // E.g. if "foo" already exists, we make it "foo1". + const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length + const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") + const specifications = remoteVersion.specifications.map(e => { + return { + id: this.makeURLSafeID(e.name.toLowerCase()), + name: e.name, + url: `/api/proxy?url=${encodeURIComponent(e.url)}` + } + }) + versions.push({ + id: versionId, + name: remoteVersion.name, + specifications, + isDefault: false + }) + versionIds.push(baseVersionId) + } + return versions + } + + private sortVersions(versions: Version[], defaultBranchName: string): Version[] { + const candidateDefaultBranches = [ + defaultBranchName, "main", "master", "develop", "development" + ] + // Reverse them so the top-priority branches end up at the top of the list. + .reverse() + const copiedVersions = [...versions].sort((a, b) => { + return a.name.localeCompare(b.name) + }) + // Move the top-priority branches to the top of the list. + for (const candidateDefaultBranch of candidateDefaultBranches) { + const defaultBranchIndex = copiedVersions.findIndex(version => { + return version.name === candidateDefaultBranch + }) + if (defaultBranchIndex !== -1) { + const branchVersion = copiedVersions[defaultBranchIndex] + copiedVersions.splice(defaultBranchIndex, 1) + copiedVersions.splice(0, 0, branchVersion) + } + } + return copiedVersions + } + + private makeURLSafeID(str: string): string { + return str.replace(/\s/g, "") + } } diff --git a/src/features/projects/domain/IProjectConfig.ts b/src/features/projects/domain/IProjectConfig.ts index 3dc32a0e..b0c3f015 100644 --- a/src/features/projects/domain/IProjectConfig.ts +++ b/src/features/projects/domain/IProjectConfig.ts @@ -1,4 +1,16 @@ +export type ProjectConfigRemoteVersion = { + readonly id?: string + readonly name: string + readonly specifications: ProjectConfigRemoteSpecification[] +} + +export type ProjectConfigRemoteSpecification = { + readonly name: string + readonly url: string +} + export default interface IProjectConfig { readonly name?: string readonly image?: string + readonly remoteVersions?: ProjectConfigRemoteVersion[] } diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts index 3d8006dc..63eca5d7 100644 --- a/src/features/projects/domain/index.ts +++ b/src/features/projects/domain/index.ts @@ -1,6 +1,7 @@ export { default as CachingProjectDataSource } from "./CachingProjectDataSource" export { default as getSelection } from "./getSelection" export type { default as IProjectConfig } from "./IProjectConfig" +export * from "./IProjectConfig" export type { default as IProjectDataSource } from "./IProjectDataSource" export type { default as OpenApiSpecification } from "./OpenApiSpecification" export type { default as Project } from "./Project" From 7805b8c1e316e7d5bf722c764b0fc27931ef41fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 14:31:52 +0100 Subject: [PATCH 08/31] Adds /api/proxy endpoint --- src/app/api/proxy/route.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/app/api/proxy/route.ts diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts new file mode 100644 index 00000000..7245898c --- /dev/null +++ b/src/app/api/proxy/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server" +import { makeAPIErrorResponse } from "@/common" + +export async function GET(req: NextRequest) { + const rawURL = req.nextUrl.searchParams.get("url") + if (!rawURL) { + return makeAPIErrorResponse(400, "Missing \"url\" query parameter.") + } + let url: URL + try { + url = new URL(rawURL) + } catch { + return makeAPIErrorResponse(400, "Invalid \"url\" query parameter.") + } + const file = await fetch(url).then(r => r.blob()) + return new NextResponse(file, { status: 200 }) +} From 43cc10a72db4073729a74923e0364ff88d4a9903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 15:11:12 +0100 Subject: [PATCH 09/31] Fixes unit tests --- __test__/projects/GitHubProjectDataSource.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts index db8acb59..4ab00e67 100644 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -1141,11 +1141,11 @@ test("It adds remote versions from the project configuration", async () => { specifications: [{ id: "huey", name: "Huey", - url: "https://example.com/huey.yml" + url: `/api/proxy?url=${encodeURIComponent("https://example.com/huey.yml")}` }, { id: "dewey", name: "Dewey", - url: "https://example.com/dewey.yml" + url: `/api/proxy?url=${encodeURIComponent("https://example.com/dewey.yml")}` }] }, { id: "bobby", @@ -1154,7 +1154,7 @@ test("It adds remote versions from the project configuration", async () => { specifications: [{ id: "louie", name: "Louie", - url: "https://example.com/louie.yml" + url: `/api/proxy?url=${encodeURIComponent("https://example.com/louie.yml")}` }] }]) }) @@ -1229,7 +1229,7 @@ test("It modifies ID of remote version if the ID already exists", async () => { specifications: [{ id: "baz", name: "Baz", - url: "https://example.com/baz.yml" + url: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}` }] }, { id: "bar2", @@ -1238,7 +1238,7 @@ test("It modifies ID of remote version if the ID already exists", async () => { specifications: [{ id: "hello", name: "Hello", - url: "https://example.com/hello.yml" + url: `/api/proxy?url=${encodeURIComponent("https://example.com/hello.yml")}` }] }]) }) From 8b5922b4418bb2a8a9d27775085d565aa4d8370b 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 10/31] Simplifies project selection --- __test__/common/utils/url.test.ts | 85 ------------------- __test__/projects/getSelection.test.ts | 28 +++--- src/app/[...slug]/page.tsx | 19 ++--- src/app/page.tsx | 2 +- src/common/utils/index.ts | 1 - src/common/utils/url.ts | 47 ---------- src/features/projects/domain/getSelection.ts | 33 +++++-- .../projects/domain/projectNavigator.ts | 16 ++-- src/features/projects/view/ProjectsPage.tsx | 12 +-- .../projects/view/client/ProjectsPage.tsx | 39 +++------ 10 files changed, 65 insertions(+), 217 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 f52e7c95..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 parses specification without trailing file extension", 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") - expect(specificationId).toBe("baz") -}) - -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 02b22cc4..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", @@ -239,9 +233,7 @@ test("It returns a undefined specification when the selected specification canno test("It moves specification ID to version ID if needed", () => { const sut = getSelection({ - projectId: "foo", - versionId: "bar", - specificationId: "baz", + path: "/foo/bar/baz", projects: [{ id: "foo", name: "foo", 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 507750b9..00000000 --- a/src/common/utils/url.ts +++ /dev/null @@ -1,47 +0,0 @@ -function getPathname(url?: string) { - if (typeof window !== "undefined") { - url = window.location.pathname - } - if (!url) { - return undefined - } - if (!url.startsWith("/")) { - return url - } - return url.substring(1) -} - -function getProjectSelection(tmpPathname?: string) { - const pathname = getPathname(tmpPathname) - if (!pathname) { - return undefined - } - 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 } - } -} - -export function getProjectId(tmpPathname?: string) { - return getProjectSelection(tmpPathname)?.projectId -} - -export function getVersionId(tmpPathname?: string) { - return getProjectSelection(tmpPathname)?.versionId -} - -export function getSpecificationId(tmpPathname?: string) { - return getProjectSelection(tmpPathname)?.specificationId -} diff --git a/src/features/projects/domain/getSelection.ts b/src/features/projects/domain/getSelection.ts index 50fd7f2e..b289e82b 100644 --- a/src/features/projects/domain/getSelection.ts +++ b/src/features/projects/domain/getSelection.ts @@ -4,19 +4,19 @@ 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) + } + let { projectId, versionId, specificationId } = guessSelection(path) // If no project is selected and the user only has a single project then we select that. if (!projectId && projects.length == 1) { projectId = projects[0].id @@ -62,6 +62,25 @@ export default function getSelection({ return { project, version, specification } } +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") -} \ No newline at end of file +} diff --git a/src/features/projects/domain/projectNavigator.ts b/src/features/projects/domain/projectNavigator.ts index 6aa1ce8c..d85db8b3 100644 --- a/src/features/projects/domain/projectNavigator.ts +++ b/src/features/projects/domain/projectNavigator.ts @@ -39,26 +39,20 @@ const projectNavigator = { }, navigateIfNeeded( router: IProjectRouter, - urlComponents: { - projectId?: string, - versionId?: string, - specificationId?: string - }, selection: { projectId?: string, versionId?: string, specificationId?: string } ) { + if (typeof window === "undefined") { + return + } 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}` + const path = `/${selection.projectId}/${selection.versionId}/${selection.specificationId}` + if (path !== window.location.pathname) { router.replace(path) } } 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..167965d8 100644 --- a/src/features/projects/view/client/ProjectsPage.tsx +++ b/src/features/projects/view/client/ProjectsPage.tsx @@ -16,15 +16,11 @@ import useSidebarOpen from "@/common/state/useSidebarOpen" 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() @@ -32,12 +28,7 @@ export default function ProjectsPage({ 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,20 +40,18 @@ 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(router, { projectId: project?.id, versionId: version?.id, specificationId: specification?.id - } - projectNavigator.navigateIfNeeded(router, urlSelection, selection) - }, [router, projectId, versionId, specificationId, project, version, specification]) + }) + }, [router, project, version, specification, project, version, specification]) useEffect(() => { // Show the sidebar if no project is selected. - if (projectId === undefined) { + if (project?.id === undefined) { setSidebarOpen(true) } - }, [projectId, setSidebarOpen]) + }, [project?.id, setSidebarOpen]) const selectProject = (project: Project) => { if (!isDesktopLayout) { setSidebarOpen(false) @@ -75,9 +64,9 @@ export default function ProjectsPage({ projectNavigator.navigateToVersion(router, project!, versionId, specification!.name) } const selectSpecification = (specificationId: string) => { - projectNavigator.navigate(router, projectId!, versionId!, specificationId) + projectNavigator.navigate(router, project!.id, version!.id, specificationId) } - const canCloseSidebar = projectId !== undefined + const canCloseSidebar = project?.id !== undefined const toggleSidebar = (isOpen: boolean) => { if (!isOpen && canCloseSidebar) { setSidebarOpen(false) @@ -94,7 +83,7 @@ export default function ProjectsPage({ } @@ -119,7 +108,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 bb67443f9b8483e7e299ee714c177bbbfd926fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 15:36:17 +0100 Subject: [PATCH 11/31] Fixes projectNavigator unit tests --- __test__/projects/projectNavigator.test.ts | 23 ++++++------------- .../projects/domain/projectNavigator.ts | 6 ++--- .../projects/view/client/ProjectsPage.tsx | 15 ++++++++---- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/__test__/projects/projectNavigator.test.ts b/__test__/projects/projectNavigator.test.ts index 06c30ac1..441ae383 100644 --- a/__test__/projects/projectNavigator.test.ts +++ b/__test__/projects/projectNavigator.test.ts @@ -3,6 +3,7 @@ import { projectNavigator } from "../../src/features/projects/domain" test("It navigates to the correct path", async () => { let pushedPath: string | undefined const router = { + pathname: "/", push: (path: string) => { pushedPath = path }, @@ -39,6 +40,7 @@ test("It navigates to first specification when changing version", async () => { } let pushedPath: string | undefined const router = { + pathname: "/", push: (path: string) => { pushedPath = path }, @@ -91,6 +93,7 @@ test("It finds a specification with the same name when changing version", async } let pushedPath: string | undefined const router = { + pathname: "/", push: (path: string) => { pushedPath = path }, @@ -103,6 +106,7 @@ test("It finds a specification with the same name when changing version", async test("It skips navigating when URL matches selection", async () => { let didNavigate = false const router = { + pathname: "/foo/bar/baz", push: () => {}, replace: () => { didNavigate = true @@ -112,10 +116,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() }) @@ -123,6 +123,7 @@ 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 = { + pathname: "/hello/bar/baz", push: () => {}, replace: () => { didNavigate = true @@ -132,10 +133,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() }) @@ -143,6 +140,7 @@ 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 = { + pathname: "/foo/hello/baz", push: () => {}, replace: () => { didNavigate = true @@ -152,10 +150,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() }) @@ -163,6 +157,7 @@ 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 = { + pathname: "/foo/bar/hello", push: () => {}, replace: () => { didNavigate = true @@ -172,10 +167,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/features/projects/domain/projectNavigator.ts b/src/features/projects/domain/projectNavigator.ts index d85db8b3..8eb0aab4 100644 --- a/src/features/projects/domain/projectNavigator.ts +++ b/src/features/projects/domain/projectNavigator.ts @@ -1,6 +1,7 @@ import Project from "./Project" export interface IProjectRouter { + readonly pathname: string push(path: string): void replace(path: string): void } @@ -45,14 +46,11 @@ const projectNavigator = { specificationId?: string } ) { - if (typeof window === "undefined") { - return - } if (!selection.projectId || !selection.versionId || !selection.specificationId) { return } const path = `/${selection.projectId}/${selection.versionId}/${selection.specificationId}` - if (path !== window.location.pathname) { + if (path !== router.pathname) { router.replace(path) } } diff --git a/src/features/projects/view/client/ProjectsPage.tsx b/src/features/projects/view/client/ProjectsPage.tsx index 167965d8..1348e75c 100644 --- a/src/features/projects/view/client/ProjectsPage.tsx +++ b/src/features/projects/view/client/ProjectsPage.tsx @@ -23,6 +23,13 @@ export default function ProjectsPage({ path: string }) { const router = useRouter() + const projectNavigatorRouter = { + get pathname() { + return window.location.pathname + }, + push: router.push, + replace: router.replace + } const theme = useTheme() const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() const isDesktopLayout = useMediaQuery(theme.breakpoints.up("sm")) @@ -40,7 +47,7 @@ export default function ProjectsPage({ }, [project, version, specification]) useEffect(() => { // Ensure the URL reflects the current selection of project, version, and specification. - projectNavigator.navigateIfNeeded(router, { + projectNavigator.navigateIfNeeded(projectNavigatorRouter, { projectId: project?.id, versionId: version?.id, specificationId: specification?.id @@ -58,13 +65,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(projectNavigatorRouter, project.id, version.id, specification.id) } const selectVersion = (versionId: string) => { - projectNavigator.navigateToVersion(router, project!, versionId, specification!.name) + projectNavigator.navigateToVersion(projectNavigatorRouter, project!, versionId, specification!.name) } const selectSpecification = (specificationId: string) => { - projectNavigator.navigate(router, project!.id, version!.id, specificationId) + projectNavigator.navigate(projectNavigatorRouter, project!.id, version!.id, specificationId) } const canCloseSidebar = project?.id !== undefined const toggleSidebar = (isOpen: boolean) => { From c13b155164f2e6a38b8efbf813a52ca80862b9a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 15:36:37 +0100 Subject: [PATCH 12/31] Enables configuring specification ID --- .../projects/GitHubProjectDataSource.test.ts | 98 +++++++++++++++++++ .../projects/data/GitHubProjectDataSource.ts | 4 +- .../projects/domain/IProjectConfig.ts | 1 + 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts index 4ab00e67..14d0df5b 100644 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -1242,3 +1242,101 @@ test("It modifies ID of remote version if the ID already exists", async () => { }] }]) }) + +test("It lets users specify the ID of a remote version", async () => { + const rawProjectConfig = ` + remoteVersions: + - id: some-version + name: Bar + specifications: + - name: Baz + url: https://example.com/baz.yml + ` + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "bar", + target: { + oid: "12345678" + } + }, + configYml: { + text: rawProjectConfig + }, + branches: { + edges: [] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions).toEqual([{ + id: "some-version", + name: "Bar", + isDefault: false, + specifications: [{ + id: "baz", + name: "Baz", + url: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}` + }] + }]) +}) + +test("It lets users specify the ID of a remote specification", async () => { + const rawProjectConfig = ` + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Baz + url: https://example.com/baz.yml + ` + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "bar", + target: { + oid: "12345678" + } + }, + configYml: { + text: rawProjectConfig + }, + branches: { + edges: [] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions).toEqual([{ + id: "bar", + name: "Bar", + isDefault: false, + specifications: [{ + id: "some-spec", + name: "Baz", + url: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}` + }] + }]) +}) diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index 9225ceeb..3de5dda9 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -160,7 +160,7 @@ export default class GitHubProjectDataSource implements IProjectDataSource { const versionIds = versions.map(e => e.id) for (const remoteVersion of remoteVersions) { const baseVersionId = this.makeURLSafeID( - remoteVersion.id?.toLowerCase() || remoteVersion.name.toLowerCase() + (remoteVersion.id || remoteVersion.name).toLowerCase() ) // If the version ID exists then we suffix it with a number to ensure unique versions. // E.g. if "foo" already exists, we make it "foo1". @@ -168,7 +168,7 @@ export default class GitHubProjectDataSource implements IProjectDataSource { const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") const specifications = remoteVersion.specifications.map(e => { return { - id: this.makeURLSafeID(e.name.toLowerCase()), + id: this.makeURLSafeID((e.id || e.name).toLowerCase()), name: e.name, url: `/api/proxy?url=${encodeURIComponent(e.url)}` } diff --git a/src/features/projects/domain/IProjectConfig.ts b/src/features/projects/domain/IProjectConfig.ts index b0c3f015..7f047f19 100644 --- a/src/features/projects/domain/IProjectConfig.ts +++ b/src/features/projects/domain/IProjectConfig.ts @@ -5,6 +5,7 @@ export type ProjectConfigRemoteVersion = { } export type ProjectConfigRemoteSpecification = { + readonly id?: string readonly name: string readonly url: string } From 4f64b6ddb221f9cbcb8ae1b4bdb3810aa934b3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 15:36:46 +0100 Subject: [PATCH 13/31] Improves automatic specification ID --- src/features/projects/data/GitHubProjectDataSource.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index 3de5dda9..40efddcc 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -208,6 +208,8 @@ export default class GitHubProjectDataSource implements IProjectDataSource { } private makeURLSafeID(str: string): string { - return str.replace(/\s/g, "") + return str + .replace(/ /g, "-") + .replace(/[^A-Za-z0-9-]/g, "") } } From ed6f3c71a03321f70587a1b9f2191b3ac087939d 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 14/31] Improves navigation logic --- __test__/projects/projectNavigator.test.ts | 142 ++++++++++++------ .../projects/domain/WindowPathnameReader.ts | 8 + src/features/projects/domain/getSelection.ts | 3 +- src/features/projects/domain/index.ts | 3 +- .../projects/domain/projectNavigator.ts | 41 +++-- .../projects/view/client/ProjectsPage.tsx | 27 ++-- 6 files changed, 145 insertions(+), 79 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 441ae383..e80d4d26 100644 --- a/__test__/projects/projectNavigator.test.ts +++ b/__test__/projects/projectNavigator.test.ts @@ -1,15 +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 = { - pathname: "/", - 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") }) @@ -39,14 +45,20 @@ test("It navigates to first specification when changing version", async () => { }] } let pushedPath: string | undefined - const router = { - pathname: "/", - 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") }) @@ -92,27 +104,39 @@ test("It finds a specification with the same name when changing version", async }] } let pushedPath: string | undefined - const router = { - pathname: "/", - 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 = { - pathname: "/foo/bar/baz", - 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,14 +146,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 = { - pathname: "/hello/bar/baz", - 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" @@ -139,14 +169,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 = { - pathname: "/foo/hello/baz", - 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" @@ -156,14 +192,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 = { - pathname: "/foo/bar/hello", - 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 b289e82b..b76559ad 100644 --- a/src/features/projects/domain/getSelection.ts +++ b/src/features/projects/domain/getSelection.ts @@ -16,8 +16,9 @@ export default function getSelection({ if (path.startsWith("/")) { path = path.substring(1) } - let { projectId, versionId, specificationId } = guessSelection(path) + 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 } diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts index 63eca5d7..84d75c9a 100644 --- a/src/features/projects/domain/index.ts +++ b/src/features/projects/domain/index.ts @@ -6,7 +6,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 8eb0aab4..1fcd8933 100644 --- a/src/features/projects/domain/projectNavigator.ts +++ b/src/features/projects/domain/projectNavigator.ts @@ -1,14 +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 @@ -24,22 +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, selection: { projectId?: string, versionId?: string, @@ -50,10 +65,8 @@ const projectNavigator = { return } const path = `/${selection.projectId}/${selection.versionId}/${selection.specificationId}` - if (path !== router.pathname) { - router.replace(path) + 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 1348e75c..7dd2a887 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, @@ -23,19 +29,14 @@ export default function ProjectsPage({ path: string }) { const router = useRouter() - const projectNavigatorRouter = { - get pathname() { - return window.location.pathname - }, - push: router.push, - replace: router.replace - } 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, path }) + const projectNavigator = new ProjectNavigator({ router, pathnameReader }) useEffect(() => { updateWindowTitle({ storage: document, @@ -47,12 +48,12 @@ export default function ProjectsPage({ }, [project, version, specification]) useEffect(() => { // Ensure the URL reflects the current selection of project, version, and specification. - projectNavigator.navigateIfNeeded(projectNavigatorRouter, { + projectNavigator.navigateIfNeeded({ projectId: project?.id, versionId: version?.id, specificationId: specification?.id }) - }, [router, project, version, specification, project, version, specification]) + }, [project, version, specification]) useEffect(() => { // Show the sidebar if no project is selected. if (project?.id === undefined) { @@ -65,13 +66,13 @@ export default function ProjectsPage({ } const version = project.versions[0] const specification = version.specifications[0] - projectNavigator.navigate(projectNavigatorRouter, project.id, version.id, specification.id) + projectNavigator.navigate(project.id, version.id, specification.id) } const selectVersion = (versionId: string) => { - projectNavigator.navigateToVersion(projectNavigatorRouter, project!, versionId, specification!.name) + projectNavigator.navigateToVersion(project!, versionId, specification!.name) } const selectSpecification = (specificationId: string) => { - projectNavigator.navigate(projectNavigatorRouter, project!.id, version!.id, specificationId) + projectNavigator.navigate(project!.id, version!.id, specificationId) } const canCloseSidebar = project?.id !== undefined const toggleSidebar = (isOpen: boolean) => { 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 15/31] 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 16/31] 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 17/31] 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 18/31] 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 19/31] 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 0f51d4c39f62b28813f08aac593994a0e4ab65d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Nov 2023 16:18:31 +0100 Subject: [PATCH 20/31] Removes unneeded file --- src/features/projects/domain/WindowPathnameReader.ts | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 src/features/projects/domain/WindowPathnameReader.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 - } -} 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 21/31] 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 From bd08803f50e1b4aab92a487f8e8f7ea64c962513 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Nov 2023 16:25:10 +0000 Subject: [PATCH 22/31] Bump axios from 1.5.1 to 1.6.1 Bumps [axios](https://github.com/axios/axios) from 1.5.1 to 1.6.1. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.5.1...v1.6.1) --- updated-dependencies: - dependency-name: axios dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 30c02321..62f951af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3829,9 +3829,9 @@ } }, "node_modules/axios": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", - "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.1.tgz", + "integrity": "sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", From 0d6dbafd4f816a1a73d05e61be3dd56eb9cfc525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 12 Nov 2023 11:25:27 +0100 Subject: [PATCH 23/31] Adds link to proejcts --- src/features/projects/data/GitHubProjectDataSource.ts | 3 ++- src/features/projects/domain/Project.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index 40efddcc..4780f0cb 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -64,7 +64,8 @@ export default class GitHubProjectDataSource implements IProjectDataSource { name: defaultName, displayName: config?.name || defaultName, versions, - imageURL: imageURL + imageURL: imageURL, + url: `https://github.com/${repository.owner.login}/${repository.name}` } } diff --git a/src/features/projects/domain/Project.ts b/src/features/projects/domain/Project.ts index a618bcfd..fb05e3a1 100644 --- a/src/features/projects/domain/Project.ts +++ b/src/features/projects/domain/Project.ts @@ -6,7 +6,8 @@ export const ProjectSchema = z.object({ name: z.string(), displayName: z.string(), versions: VersionSchema.array(), - imageURL: z.string().optional() + imageURL: z.string().optional(), + url: z.string().optional() }) type Project = z.infer From 9aa16f208fd6e3889ba9a4d9c4f12f62ed878e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 12 Nov 2023 11:25:37 +0100 Subject: [PATCH 24/31] Fallback to project URL --- src/features/projects/view/toolbar/TrailingToolbarItem.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/features/projects/view/toolbar/TrailingToolbarItem.tsx b/src/features/projects/view/toolbar/TrailingToolbarItem.tsx index 18a4ffe1..b95db6ab 100644 --- a/src/features/projects/view/toolbar/TrailingToolbarItem.tsx +++ b/src/features/projects/view/toolbar/TrailingToolbarItem.tsx @@ -21,6 +21,7 @@ const TrailingToolbarItem = ({ onSelectVersion: (versionId: string) => void, onSelectSpecification: (specificationId: string) => void }) => { + const projectNameURL = enableGitHubLinks ? version.url || project.url : undefined return ( <> - + / From d635ec8d145e6988fdbc4c7b39daff5aca4214dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 12 Nov 2023 11:25:43 +0100 Subject: [PATCH 25/31] Fixes tests --- __test__/projects/GitHubProjectDataSource.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts index 14d0df5b..d0efbb3e 100644 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -70,6 +70,7 @@ test("It maps projects including branches and tags", async () => { id: "foo", name: "foo", displayName: "foo", + url: "https://github.com/acme/foo", versions: [{ id: "main", name: "main", @@ -209,6 +210,7 @@ test("It supports multiple OpenAPI specifications on a branch", async () => { id: "foo", name: "foo", displayName: "foo", + url: "https://github.com/acme/foo", versions: [{ id: "main", name: "main", From 5aea30e6f0cceee5ad107463889dd30fa41b62f1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Nov 2023 07:55:06 +0000 Subject: [PATCH 26/31] Bump @types/jest from 29.5.6 to 29.5.8 Bumps [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest) from 29.5.6 to 29.5.8. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest) --- updated-dependencies: - dependency-name: "@types/jest" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62f951af..c1245fed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "zod": "^3.22.4" }, "devDependencies": { - "@types/jest": "^29.5.6", + "@types/jest": "^29.5.8", "@types/node": "^20.8.9", "@types/react": "^18", "@types/react-dom": "^18", @@ -3042,9 +3042,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.6", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.6.tgz", - "integrity": "sha512-/t9NnzkOpXb4Nfvg17ieHE6EeSjDS2SGSpNYfoLbUAeL/EOueU/RSdOWFpfQTXBEM7BguYW1XQ0EbM+6RlIh6w==", + "version": "29.5.8", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz", + "integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==", "dev": true, "dependencies": { "expect": "^29.0.0", diff --git a/package.json b/package.json index 260edcf5..5d7fd1d0 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "zod": "^3.22.4" }, "devDependencies": { - "@types/jest": "^29.5.6", + "@types/jest": "^29.5.8", "@types/node": "^20.8.9", "@types/react": "^18", "@types/react-dom": "^18", From 49eb0eb6d6d0221c54a9065d5db93ec91c66a1ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Nov 2023 07:55:17 +0000 Subject: [PATCH 27/31] Bump mobx from 6.10.2 to 6.11.0 Bumps [mobx](https://github.com/mobxjs/mobx) from 6.10.2 to 6.11.0. - [Release notes](https://github.com/mobxjs/mobx/releases) - [Commits](https://github.com/mobxjs/mobx/commits) --- updated-dependencies: - dependency-name: mobx dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62f951af..d6365482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "figma-squircle": "^0.3.1", "install": "^0.13.0", "ioredis": "^5.3.2", - "mobx": "^6.10.2", + "mobx": "^6.11.0", "next": "13.5.6", "npm": "^10.2.3", "octokit": "^3.1.1", @@ -8030,9 +8030,9 @@ "optional": true }, "node_modules/mobx": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.10.2.tgz", - "integrity": "sha512-B1UGC3ieK3boCjnMEcZSwxqRDMdzX65H/8zOHbuTY8ZhvrIjTUoLRR2TP2bPqIgYRfb3+dUigu8yMZufNjn0LQ==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.11.0.tgz", + "integrity": "sha512-qngYCmr0WJiFRSAtYe82DB7SbzvbhehkJjONs8ydynUwoazzUQHZdAlaJqUfks5j4HarhWsZrMRhV7HtSO9HOQ==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" diff --git a/package.json b/package.json index 260edcf5..4bdcfc22 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "figma-squircle": "^0.3.1", "install": "^0.13.0", "ioredis": "^5.3.2", - "mobx": "^6.10.2", + "mobx": "^6.11.0", "next": "13.5.6", "npm": "^10.2.3", "octokit": "^3.1.1", From 5375bd0155252f3dcb208e391858b7047505cbd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Nov 2023 07:56:16 +0000 Subject: [PATCH 28/31] Bump @mui/icons-material from 5.14.14 to 5.14.16 Bumps [@mui/icons-material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material) from 5.14.14 to 5.14.16. - [Release notes](https://github.com/mui/material-ui/releases) - [Changelog](https://github.com/mui/material-ui/blob/master/CHANGELOG.md) - [Commits](https://github.com/mui/material-ui/commits/v5.14.16/packages/mui-icons-material) --- updated-dependencies: - dependency-name: "@mui/icons-material" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62f951af..d4d294c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@fortawesome/fontawesome-svg-core": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", - "@mui/icons-material": "^5.14.14", + "@mui/icons-material": "^5.14.16", "@mui/material": "^5.14.15", "@octokit/auth-app": "^6.0.1", "@octokit/core": "^5.0.1", @@ -1679,11 +1679,11 @@ } }, "node_modules/@mui/icons-material": { - "version": "5.14.14", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.14.14.tgz", - "integrity": "sha512-vwuaMsKvI7AWTeYqR8wYbpXijuU8PzMAJWRAq2DDIuOZPxjKyHlr8WQ25+azZYkIXtJ7AqnVb1ZmHdEyB4/kug==", + "version": "5.14.16", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.14.16.tgz", + "integrity": "sha512-wmOgslMEGvbHZjFLru8uH5E+pif/ciXAvKNw16q6joK6EWVWU5rDYWFknDaZhCvz8ZE/K8ZnJQ+lMG6GgHzXbg==", "dependencies": { - "@babel/runtime": "^7.23.1" + "@babel/runtime": "^7.23.2" }, "engines": { "node": ">=12.0.0" diff --git a/package.json b/package.json index 260edcf5..b76ac189 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@fortawesome/fontawesome-svg-core": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", - "@mui/icons-material": "^5.14.14", + "@mui/icons-material": "^5.14.16", "@mui/material": "^5.14.15", "@octokit/auth-app": "^6.0.1", "@octokit/core": "^5.0.1", From 20d3a0ffb456768a589242c3fb0532ba50d16c38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Nov 2023 12:50:27 +0000 Subject: [PATCH 29/31] Bump @types/node from 20.8.9 to 20.9.0 Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.8.9 to 20.9.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 47f31e4e..29b24e85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@types/jest": "^29.5.8", - "@types/node": "^20.8.9", + "@types/node": "^20.9.0", "@types/react": "^18", "@types/react-dom": "^18", "@types/swagger-ui-react": "^4.18.2", @@ -3071,9 +3071,9 @@ } }, "node_modules/@types/node": { - "version": "20.8.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", - "integrity": "sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==", + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.0.tgz", + "integrity": "sha512-nekiGu2NDb1BcVofVcEKMIwzlx4NjHlcjhoxxKBNLtz15Y1z7MYf549DFvkHSId02Ax6kGwWntIBPC3l/JZcmw==", "dependencies": { "undici-types": "~5.26.4" } diff --git a/package.json b/package.json index fe624ff8..994c36c8 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "devDependencies": { "@types/jest": "^29.5.8", - "@types/node": "^20.8.9", + "@types/node": "^20.9.0", "@types/react": "^18", "@types/react-dom": "^18", "@types/swagger-ui-react": "^4.18.2", From 68f5949a1c40c3c36f9b74a8bc7952844de34450 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Nov 2023 13:44:14 +0000 Subject: [PATCH 30/31] Bump next from 13.5.6 to 14.0.2 Bumps [next](https://github.com/vercel/next.js) from 13.5.6 to 14.0.2. - [Release notes](https://github.com/vercel/next.js/releases) - [Changelog](https://github.com/vercel/next.js/blob/canary/release.js) - [Commits](https://github.com/vercel/next.js/compare/v13.5.6...v14.0.2) --- updated-dependencies: - dependency-name: next dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package-lock.json | 90 +++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec07c35b..65e5bb92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "install": "^0.13.0", "ioredis": "^5.3.2", "mobx": "^6.11.0", - "next": "13.5.6", + "next": "14.0.2", "npm": "^10.2.3", "octokit": "^3.1.1", "react": "^18.2.0", @@ -1894,9 +1894,9 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, "node_modules/@next/env": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.6.tgz", - "integrity": "sha512-Yac/bV5sBGkkEXmAX5FWPS9Mmo2rthrOPRQQNfycJPkjUAUclomCPH7QFVCDQ4Mp2k2K1SSM6m0zrxYrOwtFQw==" + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.2.tgz", + "integrity": "sha512-HAW1sljizEaduEOes/m84oUqeIDAUYBR1CDwu2tobNlNDFP3cSm9d6QsOsGeNlIppU1p/p1+bWbYCbvwjFiceA==" }, "node_modules/@next/eslint-plugin-next": { "version": "13.5.6", @@ -1908,9 +1908,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.6.tgz", - "integrity": "sha512-5nvXMzKtZfvcu4BhtV0KH1oGv4XEW+B+jOfmBdpFI3C7FrB/MfujRpWYSBBO64+qbW8pkZiSyQv9eiwnn5VIQA==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.2.tgz", + "integrity": "sha512-i+jQY0fOb8L5gvGvojWyZMfQoQtDVB2kYe7fufOEiST6sicvzI2W5/EXo4lX5bLUjapHKe+nFxuVv7BA+Pd7LQ==", "cpu": [ "arm64" ], @@ -1923,9 +1923,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.6.tgz", - "integrity": "sha512-6cgBfxg98oOCSr4BckWjLLgiVwlL3vlLj8hXg2b+nDgm4bC/qVXXLfpLB9FHdoDu4057hzywbxKvmYGmi7yUzA==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.2.tgz", + "integrity": "sha512-zRCAO0d2hW6gBEa4wJaLn+gY8qtIqD3gYd9NjruuN98OCI6YyelmhWVVLlREjS7RYrm9OUQIp/iVJFeB6kP1hg==", "cpu": [ "x64" ], @@ -1938,9 +1938,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.6.tgz", - "integrity": "sha512-txagBbj1e1w47YQjcKgSU4rRVQ7uF29YpnlHV5xuVUsgCUf2FmyfJ3CPjZUvpIeXCJAoMCFAoGnbtX86BK7+sg==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.2.tgz", + "integrity": "sha512-tSJmiaon8YaKsVhi7GgRizZoV0N1Sx5+i+hFTrCKKQN7s3tuqW0Rov+RYdPhAv/pJl4qiG+XfSX4eJXqpNg3dA==", "cpu": [ "arm64" ], @@ -1953,9 +1953,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.6.tgz", - "integrity": "sha512-cGd+H8amifT86ZldVJtAKDxUqeFyLWW+v2NlBULnLAdWsiuuN8TuhVBt8ZNpCqcAuoruoSWynvMWixTFcroq+Q==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.2.tgz", + "integrity": "sha512-dXJLMSEOwqJKcag1BeX1C+ekdPPJ9yXbWIt3nAadhbLx5CjACoB2NQj9Xcqu2tmdr5L6m34fR+fjGPs+ZVPLzA==", "cpu": [ "arm64" ], @@ -1968,9 +1968,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.6.tgz", - "integrity": "sha512-Mc2b4xiIWKXIhBy2NBTwOxGD3nHLmq4keFk+d4/WL5fMsB8XdJRdtUlL87SqVCTSaf1BRuQQf1HvXZcy+rq3Nw==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.2.tgz", + "integrity": "sha512-WC9KAPSowj6as76P3vf1J3mf2QTm3Wv3FBzQi7UJ+dxWjK3MhHVWsWUo24AnmHx9qDcEtHM58okgZkXVqeLB+Q==", "cpu": [ "x64" ], @@ -1983,9 +1983,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.6.tgz", - "integrity": "sha512-CFHvP9Qz98NruJiUnCe61O6GveKKHpJLloXbDSWRhqhkJdZD2zU5hG+gtVJR//tyW897izuHpM6Gtf6+sNgJPQ==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.2.tgz", + "integrity": "sha512-KSSAwvUcjtdZY4zJFa2f5VNJIwuEVnOSlqYqbQIawREJA+gUI6egeiRu290pXioQXnQHYYdXmnVNZ4M+VMB7KQ==", "cpu": [ "x64" ], @@ -1998,9 +1998,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.6.tgz", - "integrity": "sha512-aFv1ejfkbS7PUa1qVPwzDHjQWQtknzAZWGTKYIAaS4NMtBlk3VyA6AYn593pqNanlicewqyl2jUhQAaFV/qXsg==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.2.tgz", + "integrity": "sha512-2/O0F1SqJ0bD3zqNuYge0ok7OEWCQwk55RPheDYD0va5ij7kYwrFkq5ycCRN0TLjLfxSF6xI5NM6nC5ux7svEQ==", "cpu": [ "arm64" ], @@ -2013,9 +2013,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.6.tgz", - "integrity": "sha512-XqqpHgEIlBHvzwG8sp/JXMFkLAfGLqkbVsyN+/Ih1mR8INb6YCc2x/Mbwi6hsAgUnqQztz8cvEbHJUbSl7RHDg==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.2.tgz", + "integrity": "sha512-vJI/x70Id0oN4Bq/R6byBqV1/NS5Dl31zC+lowO8SDu1fHmUxoAdILZR5X/sKbiJpuvKcCrwbYgJU8FF/Gh50Q==", "cpu": [ "ia32" ], @@ -2028,9 +2028,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.6.tgz", - "integrity": "sha512-Cqfe1YmOS7k+5mGu92nl5ULkzpKuxJrP3+4AEuPmrpFZ3BHxTY3TnHmU1On3bFmFFs6FbTcdF58CCUProGpIGQ==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.2.tgz", + "integrity": "sha512-Ut4LXIUvC5m8pHTe2j0vq/YDnTEyq6RSR9vHYPqnELrDapPhLNz9Od/L5Ow3J8RNDWpEnfCiQXuVdfjlNEJ7ug==", "cpu": [ "x64" ], @@ -8135,11 +8135,11 @@ "dev": true }, "node_modules/next": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/next/-/next-13.5.6.tgz", - "integrity": "sha512-Y2wTcTbO4WwEsVb4A8VSnOsG1I9ok+h74q0ZdxkwM3EODqrs4pasq7O0iUxbcS9VtWMicG7f3+HAj0r1+NtKSw==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/next/-/next-14.0.2.tgz", + "integrity": "sha512-jsAU2CkYS40GaQYOiLl9m93RTv2DA/tTJ0NRlmZIBIL87YwQ/xR8k796z7IqgM3jydI8G25dXvyYMC9VDIevIg==", "dependencies": { - "@next/env": "13.5.6", + "@next/env": "14.0.2", "@swc/helpers": "0.5.2", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001406", @@ -8151,18 +8151,18 @@ "next": "dist/bin/next" }, "engines": { - "node": ">=16.14.0" + "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "13.5.6", - "@next/swc-darwin-x64": "13.5.6", - "@next/swc-linux-arm64-gnu": "13.5.6", - "@next/swc-linux-arm64-musl": "13.5.6", - "@next/swc-linux-x64-gnu": "13.5.6", - "@next/swc-linux-x64-musl": "13.5.6", - "@next/swc-win32-arm64-msvc": "13.5.6", - "@next/swc-win32-ia32-msvc": "13.5.6", - "@next/swc-win32-x64-msvc": "13.5.6" + "@next/swc-darwin-arm64": "14.0.2", + "@next/swc-darwin-x64": "14.0.2", + "@next/swc-linux-arm64-gnu": "14.0.2", + "@next/swc-linux-arm64-musl": "14.0.2", + "@next/swc-linux-x64-gnu": "14.0.2", + "@next/swc-linux-x64-musl": "14.0.2", + "@next/swc-win32-arm64-msvc": "14.0.2", + "@next/swc-win32-ia32-msvc": "14.0.2", + "@next/swc-win32-x64-msvc": "14.0.2" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", diff --git a/package.json b/package.json index c413cee6..0650cafa 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "install": "^0.13.0", "ioredis": "^5.3.2", "mobx": "^6.11.0", - "next": "13.5.6", + "next": "14.0.2", "npm": "^10.2.3", "octokit": "^3.1.1", "react": "^18.2.0", From e9811e148f2ee50be053f0fcb6deef4195cc57f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 13 Nov 2023 19:46:57 +0100 Subject: [PATCH 31/31] Adds trunk as candidate default branch --- 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 40efddcc..f94d58f6 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -186,7 +186,7 @@ export default class GitHubProjectDataSource implements IProjectDataSource { private sortVersions(versions: Version[], defaultBranchName: string): Version[] { const candidateDefaultBranches = [ - defaultBranchName, "main", "master", "develop", "development" + defaultBranchName, "main", "master", "develop", "development", "trunk" ] // Reverse them so the top-priority branches end up at the top of the list. .reverse()