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 (
<>
-
+
/
]