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/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/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts new file mode 100644 index 00000000..d0efbb3e --- /dev/null +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -0,0 +1,1344 @@ +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", + url: "https://github.com/acme/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", + url: "https://github.com/acme/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"]) +}) + +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: `/api/proxy?url=${encodeURIComponent("https://example.com/huey.yml")}` + }, { + id: "dewey", + name: "Dewey", + url: `/api/proxy?url=${encodeURIComponent("https://example.com/dewey.yml")}` + }] + }, { + id: "bobby", + name: "Bobby", + isDefault: false, + specifications: [{ + id: "louie", + name: "Louie", + url: `/api/proxy?url=${encodeURIComponent("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: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}` + }] + }, { + id: "bar2", + name: "Bar", + isDefault: false, + specifications: [{ + id: "hello", + name: "Hello", + url: `/api/proxy?url=${encodeURIComponent("https://example.com/hello.yml")}` + }] + }]) +}) + +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/__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") +}) 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 06c30ac1..e80d4d26 100644 --- a/__test__/projects/projectNavigator.test.ts +++ b/__test__/projects/projectNavigator.test.ts @@ -1,14 +1,21 @@ -import { projectNavigator } from "../../src/features/projects/domain" +import { ProjectNavigator } from "../../src/features/projects/domain" test("It navigates to the correct path", async () => { let pushedPath: string | undefined - const router = { - push: (path: string) => { - pushedPath = path + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/" + } }, - replace: () => {} - } - projectNavigator.navigate(router, "foo", "bar", "hello.yml") + router: { + push: (path: string) => { + pushedPath = path + }, + replace: () => {} + } + }) + sut.navigate("foo", "bar", "hello.yml") expect(pushedPath).toEqual("/foo/bar/hello.yml") }) @@ -38,13 +45,20 @@ test("It navigates to first specification when changing version", async () => { }] } let pushedPath: string | undefined - const router = { - push: (path: string) => { - pushedPath = path + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/" + } }, - replace: () => {} - } - projectNavigator.navigateToVersion(router, project, "hello", "baz.yml") + router: { + push: (path: string) => { + pushedPath = path + }, + replace: () => {} + } + }) + sut.navigateToVersion(project, "hello", "baz.yml") expect(pushedPath).toEqual("/foo/hello/world.yml") }) @@ -90,29 +104,39 @@ test("It finds a specification with the same name when changing version", async }] } let pushedPath: string | undefined - const router = { - push: (path: string) => { - pushedPath = path + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/" + } }, - replace: () => {} - } - projectNavigator.navigateToVersion(router, project, "baz", "earth.yml") + router: { + push: (path: string) => { + pushedPath = path + }, + replace: () => {} + } + }) + sut.navigateToVersion(project, "baz", "earth.yml") expect(pushedPath).toEqual("/foo/baz/earth.yml") }) test("It skips navigating when URL matches selection", async () => { let didNavigate = false - const router = { - push: () => {}, - replace: () => { - didNavigate = true + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/foo/bar/baz" + } + }, + router: { + push: () => {}, + replace: () => { + didNavigate = true + } } - } - projectNavigator.navigateIfNeeded(router, { - projectId: "foo", - versionId: "bar", - specificationId: "baz" - }, { + }) + sut.navigateIfNeeded({ projectId: "foo", versionId: "bar", specificationId: "baz" @@ -122,60 +146,69 @@ test("It skips navigating when URL matches selection", async () => { test("It navigates when project ID in URL does not match ID of selected project", async () => { let didNavigate = false - const router = { - push: () => {}, - replace: () => { - didNavigate = true + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/hello/bar/baz" + } + }, + router: { + push: () => {}, + replace: () => { + didNavigate = true + } } - } - projectNavigator.navigateIfNeeded(router, { + }) + sut.navigateIfNeeded({ projectId: "foo", versionId: "bar", specificationId: "baz" - }, { - projectId: "hello", - versionId: "bar", - specificationId: "baz" }) expect(didNavigate).toBeTruthy() }) test("It navigates when version ID in URL does not match ID of selected version", async () => { let didNavigate = false - const router = { - push: () => {}, - replace: () => { - didNavigate = true + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/foo/hello/baz" + } + }, + router: { + push: () => {}, + replace: () => { + didNavigate = true + } } - } - projectNavigator.navigateIfNeeded(router, { + }) + sut.navigateIfNeeded({ projectId: "foo", versionId: "bar", specificationId: "baz" - }, { - projectId: "foo", - versionId: "hello", - specificationId: "baz" }) expect(didNavigate).toBeTruthy() }) test("It navigates when specification ID in URL does not match ID of selected specification", async () => { let didNavigate = false - const router = { - push: () => {}, - replace: () => { - didNavigate = true + const sut = new ProjectNavigator({ + pathnameReader: { + get pathname() { + return "/foo/bar/hello" + } + }, + router: { + push: () => {}, + replace: () => { + didNavigate = true + } } - } - projectNavigator.navigateIfNeeded(router, { + }) + sut.navigateIfNeeded({ projectId: "foo", versionId: "bar", specificationId: "baz" - }, { - projectId: "foo", - versionId: "bar", - specificationId: "hello" }) expect(didNavigate).toBeTruthy() }) diff --git a/package-lock.json b/package-lock.json index 30c02321..56da8722 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", @@ -25,8 +25,8 @@ "figma-squircle": "^0.3.1", "install": "^0.13.0", "ioredis": "^5.3.2", - "mobx": "^6.10.2", - "next": "13.5.6", + "mobx": "^6.11.0", + "next": "14.0.2", "npm": "^10.2.3", "octokit": "^3.1.1", "react": "^18.2.0", @@ -41,8 +41,8 @@ "zod": "^3.22.4" }, "devDependencies": { - "@types/jest": "^29.5.6", - "@types/node": "^20.8.9", + "@types/jest": "^29.5.8", + "@types/node": "^20.9.0", "@types/react": "^18", "@types/react-dom": "^18", "@types/swagger-ui-react": "^4.18.2", @@ -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" @@ -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" ], @@ -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", @@ -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" } @@ -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", @@ -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" @@ -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 260edcf5..1101af5d 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", @@ -31,8 +31,8 @@ "figma-squircle": "^0.3.1", "install": "^0.13.0", "ioredis": "^5.3.2", - "mobx": "^6.10.2", - "next": "13.5.6", + "mobx": "^6.11.0", + "next": "14.0.2", "npm": "^10.2.3", "octokit": "^3.1.1", "react": "^18.2.0", @@ -47,8 +47,8 @@ "zod": "^3.22.4" }, "devDependencies": { - "@types/jest": "^29.5.6", - "@types/node": "^20.8.9", + "@types/jest": "^29.5.8", + "@types/node": "^20.9.0", "@types/react": "^18", "@types/react-dom": "^18", "@types/swagger-ui-react": "^4.18.2", 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/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 }) +} 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/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..52580197 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -1,134 +1,34 @@ -import { IGitHubClient } from "@/common" +import GitHubProjectRepository, { + GitHubProjectRepositoryRef +} from "./GitHubProjectRepository" import { Project, + Version, IProjectConfig, IProjectDataSource, - Version, - ProjectConfigParser + ProjectConfigParser, + ProjectConfigRemoteVersion, } 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 -} - -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[] - } - } +interface IGitHubProjectRepositoryDataSource { + getRepositories(): Promise } -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 +38,39 @@ 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 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(searchResult).filter(version => { - return version.specifications.length > 0 - }), - imageURL: imageURL + versions, + imageURL: imageURL, + url: `https://github.com/${repository.owner.login}/${repository.name}` } } - 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,64 +78,58 @@ 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) - }) - const tagVersions = searchResult.tags.edges.map((edge: Edge) => { - return this.mapVersionFromRef(searchResult.owner.login, searchResult.name, edge.node) - }) - const defaultBranchName = searchResult.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) + private getVersions(repository: GitHubProjectRepository): Version[] { + const branchVersions = repository.branches.edges.map(edge => { + const isDefaultRef = edge.node.name == repository.defaultBranchRef.name + return this.mapVersionFromRef({ + ownerName: repository.owner.login, + repositoryName: repository.name, + ref: edge.node, + isDefaultRef + }) }) - // 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 + const tagVersions = repository.tags.edges.map(edge => { + return this.mapVersionFromRef({ + ownerName: repository.owner.login, + repositoryName: repository.name, + ref: edge.node }) - if (defaultBranchIndex !== -1) { - const branchVersion = allVersions[defaultBranchIndex] - allVersions.splice(defaultBranchIndex, 1) - allVersions.splice(0, 0, branchVersion) - } - } - return allVersions + }) + return branchVersions.concat(tagVersions) } - 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 +139,78 @@ 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}` + } + + 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 || 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.id || 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", "trunk" + ] + // 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(/ /g, "-") + .replace(/[^A-Za-z0-9-]/g, "") } } 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 { diff --git a/src/features/projects/domain/IProjectConfig.ts b/src/features/projects/domain/IProjectConfig.ts index 3dc32a0e..7f047f19 100644 --- a/src/features/projects/domain/IProjectConfig.ts +++ b/src/features/projects/domain/IProjectConfig.ts @@ -1,4 +1,17 @@ +export type ProjectConfigRemoteVersion = { + readonly id?: string + readonly name: string + readonly specifications: ProjectConfigRemoteSpecification[] +} + +export type ProjectConfigRemoteSpecification = { + readonly id?: string + 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/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 diff --git a/src/features/projects/domain/projectNavigator.ts b/src/features/projects/domain/ProjectNavigator.ts similarity index 52% rename from src/features/projects/domain/projectNavigator.ts rename to src/features/projects/domain/ProjectNavigator.ts index 6aa1ce8c..1fcd8933 100644 --- a/src/features/projects/domain/projectNavigator.ts +++ b/src/features/projects/domain/ProjectNavigator.ts @@ -1,13 +1,29 @@ import Project from "./Project" -export interface IProjectRouter { +interface IPathnameReader { + readonly pathname: string +} + +export interface IRouter { push(path: string): void replace(path: string): void } -const projectNavigator = { +type ProjectNavigatorConfig = { + readonly pathnameReader: IPathnameReader + readonly router: IRouter +} + +export default class ProjectNavigator { + private readonly pathnameReader: IPathnameReader + private readonly router: IRouter + + constructor(config: ProjectNavigatorConfig) { + this.pathnameReader = config.pathnameReader + this.router = config.router + } + navigateToVersion( - router: IProjectRouter, project: Project, versionId: string, preferredSpecificationName: string @@ -23,27 +39,22 @@ const projectNavigator = { return e.name == preferredSpecificationName }) if (candidateSpecification) { - router.push(`/${project.id}/${newVersion.id}/${candidateSpecification.id}`) + this.router.push(`/${project.id}/${newVersion.id}/${candidateSpecification.id}`) } else { const firstSpecification = newVersion.specifications[0] - router.push(`/${project.id}/${newVersion.id}/${firstSpecification.id}`) + this.router.push(`/${project.id}/${newVersion.id}/${firstSpecification.id}`) } - }, + } + navigate( - router: IProjectRouter, projectId: string, versionId: string, specificationId: string ) { - router.push(`/${projectId}/${versionId}/${specificationId}`) - }, + this.router.push(`/${projectId}/${versionId}/${specificationId}`) + } + navigateIfNeeded( - router: IProjectRouter, - urlComponents: { - projectId?: string, - versionId?: string, - specificationId?: string - }, selection: { projectId?: string, versionId?: string, @@ -53,15 +64,9 @@ const projectNavigator = { if (!selection.projectId || !selection.versionId || !selection.specificationId) { return } - if ( - urlComponents.projectId != selection.projectId || - urlComponents.versionId != selection.versionId || - urlComponents.specificationId != selection.specificationId - ) { - const path = `/${selection.projectId}/${selection.versionId}/${selection.specificationId}` - router.replace(path) + const path = `/${selection.projectId}/${selection.versionId}/${selection.specificationId}` + if (path !== this.pathnameReader.pathname) { + this.router.replace(path) } } } - -export default projectNavigator \ No newline at end of file diff --git a/src/features/projects/domain/getSelection.ts b/src/features/projects/domain/getSelection.ts index a9aa2caf..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..bfce5ee2 100644 --- a/src/features/projects/domain/index.ts +++ b/src/features/projects/domain/index.ts @@ -1,11 +1,13 @@ 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" export { default as ProjectConfigParser } from "./ProjectConfigParser" -export { default as projectNavigator } from "./projectNavigator" +export { default as ProjectNavigator } from "./ProjectNavigator" export { default as ProjectRepository } from "./ProjectRepository" export { default as updateWindowTitle } from "./updateWindowTitle" export type { default as Version } from "./Version" +export { default as useProjectNavigator } from "./useProjectNavigator" diff --git a/src/features/projects/domain/useProjectNavigator.ts b/src/features/projects/domain/useProjectNavigator.ts new file mode 100644 index 00000000..e0f5fac9 --- /dev/null +++ b/src/features/projects/domain/useProjectNavigator.ts @@ -0,0 +1,15 @@ +import { useRouter } from "next/navigation" +import ProjectNavigator from "./ProjectNavigator" + +export default function useProjectNavigator() { + const router = useRouter() + const pathnameReader = { + get pathname() { + if (typeof window === "undefined") { + return "" + } + return window.location.pathname + } + } + return new ProjectNavigator({ router, pathnameReader }) +} diff --git a/src/features/projects/view/ProjectsPage.tsx b/src/features/projects/view/ProjectsPage.tsx index 5e5118d4..be16f64c 100644 --- a/src/features/projects/view/ProjectsPage.tsx +++ b/src/features/projects/view/ProjectsPage.tsx @@ -4,14 +4,10 @@ import ClientProjectsPage from "./client/ProjectsPage" export default async function ProjectsPage({ projectRepository, - projectId, - versionId, - specificationId + path }: { projectRepository: ProjectRepository - projectId?: string - versionId?: string - specificationId?: string + path: string }) { const isGuest = await session.getIsGuest() const projects = await projectRepository.get() @@ -19,9 +15,7 @@ export default async function ProjectsPage({ ) } diff --git a/src/features/projects/view/client/ProjectsPage.tsx b/src/features/projects/view/client/ProjectsPage.tsx index 0fa9d384..e218a26d 100644 --- a/src/features/projects/view/client/ProjectsPage.tsx +++ b/src/features/projects/view/client/ProjectsPage.tsx @@ -1,43 +1,38 @@ "use client" import { useEffect } from "react" -import { useRouter } from "next/navigation" import { useTheme } from "@mui/material/styles" import useMediaQuery from "@mui/material/useMediaQuery" import SidebarContainer from "@/features/sidebar/view/client/SidebarContainer" import { useProjects } from "../../data" -import { Project, getSelection, projectNavigator, updateWindowTitle } from "../../domain" import ProjectList from "../ProjectList" import MainContent from "../MainContent" import MobileToolbar from "../toolbar/MobileToolbar" import TrailingToolbarItem from "../toolbar/TrailingToolbarItem" import useSidebarOpen from "@/common/state/useSidebarOpen" +import { + Project, + getSelection, + updateWindowTitle, + useProjectNavigator +} from "../../domain" export default function ProjectsPage({ enableGitHubLinks, projects: serverProjects, - projectId, - versionId, - specificationId + path }: { - enableGitHubLinks: boolean, + enableGitHubLinks: boolean projects?: Project[] - projectId?: string - versionId?: string - specificationId?: string + path: string }) { - const router = useRouter() const theme = useTheme() + const projectNavigator = useProjectNavigator() const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() const isDesktopLayout = useMediaQuery(theme.breakpoints.up("sm")) const { projects: clientProjects, error, isLoading: isClientLoading } = useProjects() const projects = isClientLoading ? (serverProjects || []) : clientProjects - const { project, version, specification } = getSelection({ - projects, - projectId, - versionId, - specificationId - }) + const { project, version, specification } = getSelection({ projects, path }) useEffect(() => { updateWindowTitle({ storage: document, @@ -49,35 +44,33 @@ export default function ProjectsPage({ }, [project, version, specification]) useEffect(() => { // Ensure the URL reflects the current selection of project, version, and specification. - const urlSelection = { projectId, versionId, specificationId } - const selection = { + projectNavigator.navigateIfNeeded({ projectId: project?.id, versionId: version?.id, specificationId: specification?.id - } - projectNavigator.navigateIfNeeded(router, urlSelection, selection) - }, [router, projectId, versionId, specificationId, project, version, specification]) + }) + }, [projectNavigator, project, version, specification]) useEffect(() => { // Show the sidebar if no project is selected. - if (projectId === undefined) { + if (project === undefined) { setSidebarOpen(true) } - }, [projectId, setSidebarOpen]) + }, [project, setSidebarOpen]) const selectProject = (project: Project) => { if (!isDesktopLayout) { setSidebarOpen(false) } const version = project.versions[0] const specification = version.specifications[0] - projectNavigator.navigate(router, project.id, version.id, specification.id) + projectNavigator.navigate(project.id, version.id, specification.id) } const selectVersion = (versionId: string) => { - projectNavigator.navigateToVersion(router, project!, versionId, specification!.name) + projectNavigator.navigateToVersion(project!, versionId, specification!.name) } const selectSpecification = (specificationId: string) => { - projectNavigator.navigate(router, projectId!, versionId!, specificationId) + projectNavigator.navigate(project!.id, version!.id, specificationId) } - const canCloseSidebar = projectId !== undefined + const canCloseSidebar = project !== undefined const toggleSidebar = (isOpen: boolean) => { if (!isOpen && canCloseSidebar) { setSidebarOpen(false) @@ -94,7 +87,7 @@ export default function ProjectsPage({ } @@ -119,7 +112,7 @@ export default function ProjectsPage({ } > {/* If the user has not selected any project then we do not render any content */} - {projectId && + {project && ) -} +} \ No newline at end of file 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 ( <> - + /