diff --git a/.env.example b/.env.example
index 42dcfe76..ad45d3e5 100644
--- a/.env.example
+++ b/.env.example
@@ -2,6 +2,7 @@ SHAPE_DOCS_BASE_URL=http://localhost:3000
SHAPE_DOCS_PROJECT_CONFIGURATION_FILENAME=.shape-docs.yml
NEXT_PUBLIC_SHAPE_DOCS_TITLE=Shape Docs
NEXT_PUBLIC_SHAPE_DOCS_DESCRIPTION=Documentation for Shape's APIs
+NEXT_PUBLIC_SHAPE_DOCS_HELP_URL=https://github.com/shapehq/shape-docs/wiki
NEXTAUTH_URL_INTERNAL=http://localhost:3000
NEXTAUTH_SECRET=use [openssl rand -base64 32] to generate a 32 bytes value
REDIS_URL=localhost
@@ -10,6 +11,8 @@ POSTGRESQL_USER=dbuser
POSTGRESQL_PASSWORD=
POSTGRESQL_DB=shape-docs
REPOSITORY_NAME_SUFFIX=-openapi
+HIDDEN_REPOSITORIES=
+NEW_PROJECT_TEMPLATE_REPOSITORY=shapehq/starter-openapi
GITHUB_WEBHOOK_SECRET=preshared secret also put in app configuration in GitHub
GITHUB_WEBHOK_REPOSITORY_ALLOWLIST=
GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST=
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index fe03f521..cda52edb 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -11,3 +11,6 @@ updates:
octokit:
patterns:
- "@octokit/*"
+ mui:
+ patterns:
+ - "@mui/*"
diff --git a/__test__/auth/AuthjsAccountsOAuthTokenRepository.test.ts b/__test__/auth/AuthjsAccountsOAuthTokenRepository.test.ts
index 685c2c02..f31b7e7f 100644
--- a/__test__/auth/AuthjsAccountsOAuthTokenRepository.test.ts
+++ b/__test__/auth/AuthjsAccountsOAuthTokenRepository.test.ts
@@ -1,4 +1,4 @@
-import { AuthjsAccountsOAuthTokenRepository } from "../../src/features/auth/domain"
+import { AuthjsAccountsOAuthTokenRepository } from "@/features/auth/domain"
test("It gets token for user ID and provider", async () => {
let queryUserId: string | undefined
diff --git a/__test__/auth/CompositeLogOutHandler.test.ts b/__test__/auth/CompositeLogOutHandler.test.ts
index f95757bf..83c00fe1 100644
--- a/__test__/auth/CompositeLogOutHandler.test.ts
+++ b/__test__/auth/CompositeLogOutHandler.test.ts
@@ -1,4 +1,4 @@
-import { CompositeLogOutHandler } from "../../src/features/auth/domain"
+import { CompositeLogOutHandler } from "@/features/auth/domain"
test("It invokes all log out handlers", async () => {
let didCallLogOutHandler1 = false
diff --git a/__test__/auth/ErrorIgnoringLogOutHandler.test.ts b/__test__/auth/ErrorIgnoringLogOutHandler.test.ts
index a7bacdd0..d6375f3f 100644
--- a/__test__/auth/ErrorIgnoringLogOutHandler.test.ts
+++ b/__test__/auth/ErrorIgnoringLogOutHandler.test.ts
@@ -1,4 +1,4 @@
-import { ErrorIgnoringLogOutHandler } from "../../src/features/auth/domain"
+import { ErrorIgnoringLogOutHandler } from "@/features/auth/domain"
test("It ignores errors", async () => {
const sut = new ErrorIgnoringLogOutHandler({
diff --git a/__test__/auth/LockingAccessTokenRefresher.test.ts b/__test__/auth/LockingAccessTokenRefresher.test.ts
index 00ee777f..736f519c 100644
--- a/__test__/auth/LockingAccessTokenRefresher.test.ts
+++ b/__test__/auth/LockingAccessTokenRefresher.test.ts
@@ -1,4 +1,4 @@
-import { LockingOAuthTokenRefresher } from "../../src/features/auth/domain"
+import { LockingOAuthTokenRefresher } from "@/features/auth/domain"
test("It acquires a lock", async () => {
let didAcquireLock = false
diff --git a/__test__/auth/LogInHandler.test.ts b/__test__/auth/LogInHandler.test.ts
index d1dd0c0f..05f87227 100644
--- a/__test__/auth/LogInHandler.test.ts
+++ b/__test__/auth/LogInHandler.test.ts
@@ -1,4 +1,4 @@
-import { LogInHandler } from "../../src/features/auth/domain"
+import { LogInHandler } from "@/features/auth/domain"
test("It disallows logging in when account is undefined", async () => {
const sut = new LogInHandler({
diff --git a/__test__/auth/OAuthTokenDataSource.test.ts b/__test__/auth/OAuthTokenDataSource.test.ts
index 28390b61..857b4e8e 100644
--- a/__test__/auth/OAuthTokenDataSource.test.ts
+++ b/__test__/auth/OAuthTokenDataSource.test.ts
@@ -1,4 +1,4 @@
-import { OAuthTokenDataSource } from "../../src/features/auth/domain"
+import { OAuthTokenDataSource } from "@/features/auth/domain"
test("It reads OAuth token for user's ID", async () => {
let readUserId: string | undefined
diff --git a/__test__/auth/OAuthTokenRepository.test.ts b/__test__/auth/OAuthTokenRepository.test.ts
index e7f1a124..bf2c964f 100644
--- a/__test__/auth/OAuthTokenRepository.test.ts
+++ b/__test__/auth/OAuthTokenRepository.test.ts
@@ -1,4 +1,4 @@
-import { OAuthTokenRepository } from "../../src/features/auth/domain"
+import { OAuthTokenRepository } from "@/features/auth/domain"
test("It reads the auth token for the specified user", async () => {
let readProvider: string | undefined
diff --git a/__test__/auth/OAuthTokenSessionValidator.test.ts b/__test__/auth/OAuthTokenSessionValidator.test.ts
index 2abadbbd..60f75e3f 100644
--- a/__test__/auth/OAuthTokenSessionValidator.test.ts
+++ b/__test__/auth/OAuthTokenSessionValidator.test.ts
@@ -1,4 +1,4 @@
-import { OAuthTokenSessionValidator, SessionValidity } from "../../src/features/auth/domain"
+import { OAuthTokenSessionValidator, SessionValidity } from "@/features/auth/domain"
test("It reads the access token", async () => {
let didReadOAuthToken = false
diff --git a/__test__/auth/PersistingOAuthTokenRefresher.test.ts b/__test__/auth/PersistingOAuthTokenRefresher.test.ts
index a2537939..791c1b0c 100644
--- a/__test__/auth/PersistingOAuthTokenRefresher.test.ts
+++ b/__test__/auth/PersistingOAuthTokenRefresher.test.ts
@@ -1,4 +1,4 @@
-import { PersistingOAuthTokenRefresher, OAuthToken } from "../../src/features/auth/domain"
+import { PersistingOAuthTokenRefresher, OAuthToken } from "@/features/auth/domain"
test("It refreshes OAuth token using provided refresh token", async () => {
let usedRefreshToken: string | undefined
diff --git a/__test__/auth/UserDataCleanUpLogOutHandler.test.ts b/__test__/auth/UserDataCleanUpLogOutHandler.test.ts
index 6cbfc03d..9b7ec310 100644
--- a/__test__/auth/UserDataCleanUpLogOutHandler.test.ts
+++ b/__test__/auth/UserDataCleanUpLogOutHandler.test.ts
@@ -1,4 +1,4 @@
-import { UserDataCleanUpLogOutHandler } from "../../src/features/auth/domain"
+import { UserDataCleanUpLogOutHandler } from "@/features/auth/domain"
test("It deletes data for the read user ID", async () => {
let deletedUserId: string | undefined
diff --git a/__test__/common/github/OAuthTokenRefreshingGitHubClient.test.ts b/__test__/common/github/OAuthTokenRefreshingGitHubClient.test.ts
index 7e353354..548d85a3 100644
--- a/__test__/common/github/OAuthTokenRefreshingGitHubClient.test.ts
+++ b/__test__/common/github/OAuthTokenRefreshingGitHubClient.test.ts
@@ -1,10 +1,10 @@
-import { OAuthTokenRefreshingGitHubClient } from "@/common"
import {
+ OAuthTokenRefreshingGitHubClient,
GraphQLQueryRequest,
GetRepositoryContentRequest,
GetPullRequestCommentsRequest,
AddCommentToPullRequestRequest
-} from "@/common/github/IGitHubClient"
+} from "@/common"
test("It forwards a GraphQL request", async () => {
let forwardedRequest: GraphQLQueryRequest | undefined
diff --git a/__test__/common/utils/listFromCommaSeparatedString.test.ts b/__test__/common/utils/listFromCommaSeparatedString.test.ts
index e3f75d43..08fad85b 100644
--- a/__test__/common/utils/listFromCommaSeparatedString.test.ts
+++ b/__test__/common/utils/listFromCommaSeparatedString.test.ts
@@ -1,4 +1,4 @@
-import listFromCommaSeparatedString from "@/common/utils/listFromCommaSeparatedString"
+import { listFromCommaSeparatedString } from "@/common"
test("It returns an empty list given undefined", async () => {
const result = listFromCommaSeparatedString(undefined)
diff --git a/__test__/common/utils/saneParseInt.test.ts b/__test__/common/utils/saneParseInt.test.ts
index ebd217d4..5acc1e28 100644
--- a/__test__/common/utils/saneParseInt.test.ts
+++ b/__test__/common/utils/saneParseInt.test.ts
@@ -1,4 +1,4 @@
-import saneParseInt from "@/common/utils/saneParseInt"
+import { saneParseInt } from "@/common"
test("It parses an integer", async () => {
// @ts-ignore
diff --git a/__test__/hooks/FilteringPullRequestEventHandler.test.ts b/__test__/hooks/FilteringPullRequestEventHandler.test.ts
index 2c1e8045..65b5836f 100644
--- a/__test__/hooks/FilteringPullRequestEventHandler.test.ts
+++ b/__test__/hooks/FilteringPullRequestEventHandler.test.ts
@@ -1,4 +1,4 @@
-import { FilteringPullRequestEventHandler } from "../../src/features/hooks/domain"
+import { FilteringPullRequestEventHandler } from "@/features/hooks/domain"
test("It calls pullRequestOpened(_:) when event is included", async () => {
let didCall = false
diff --git a/__test__/hooks/PostCommentPullRequestEventHandler.test.ts b/__test__/hooks/PostCommentPullRequestEventHandler.test.ts
index 46f7ee55..153c3fa7 100644
--- a/__test__/hooks/PostCommentPullRequestEventHandler.test.ts
+++ b/__test__/hooks/PostCommentPullRequestEventHandler.test.ts
@@ -1,4 +1,4 @@
-import { PostCommentPullRequestEventHandler } from "../../src/features/hooks/domain"
+import { PostCommentPullRequestEventHandler } from "@/features/hooks/domain"
test("It comments when opening a pull request", async () => {
let didComment = false
diff --git a/__test__/hooks/PullRequestCommenter.test.ts b/__test__/hooks/PullRequestCommenter.test.ts
index 358c5af5..ff4cadde 100644
--- a/__test__/hooks/PullRequestCommenter.test.ts
+++ b/__test__/hooks/PullRequestCommenter.test.ts
@@ -1,4 +1,4 @@
-import { PullRequestCommenter } from "../../src/features/hooks/domain"
+import { PullRequestCommenter } from "@/features/hooks/domain"
test("It adds comment when none exist", async () => {
let didAddComment = false
diff --git a/__test__/hooks/RepositoryNameEventFilter.test.ts b/__test__/hooks/RepositoryNameEventFilter.test.ts
index 473ce34b..2b06ca1d 100644
--- a/__test__/hooks/RepositoryNameEventFilter.test.ts
+++ b/__test__/hooks/RepositoryNameEventFilter.test.ts
@@ -1,4 +1,4 @@
-import { RepositoryNameEventFilter } from "../../src/features/hooks/domain"
+import { RepositoryNameEventFilter } from "@/features/hooks/domain"
test("It does not include repositories that do not have the \"-openapi\" suffix", async () => {
const sut = new RepositoryNameEventFilter({
diff --git a/__test__/projects/CachingProjectDataSource.test.ts b/__test__/projects/CachingProjectDataSource.test.ts
index 0a9fc418..9365f53a 100644
--- a/__test__/projects/CachingProjectDataSource.test.ts
+++ b/__test__/projects/CachingProjectDataSource.test.ts
@@ -1,4 +1,4 @@
-import { Project, CachingProjectDataSource } from "../../src/features/projects/domain"
+import { Project, CachingProjectDataSource } from "@/features/projects/domain"
test("It caches projects read from the data source", async () => {
const projects: Project[] = [{
diff --git a/__test__/projects/FilteringGitHubRepositoryDataSource.test.ts b/__test__/projects/FilteringGitHubRepositoryDataSource.test.ts
new file mode 100644
index 00000000..e834d7b0
--- /dev/null
+++ b/__test__/projects/FilteringGitHubRepositoryDataSource.test.ts
@@ -0,0 +1,125 @@
+import { FilteringGitHubRepositoryDataSource } from "@/features/projects/domain"
+
+test("It returns all repositories when no hidden repositories are provided", async () => {
+ const sut = new FilteringGitHubRepositoryDataSource({
+ hiddenRepositories: [],
+ dataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ branches: [],
+ tags: []
+ }, {
+ owner: "acme",
+ name: "bar-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "bar"
+ },
+ branches: [],
+ tags: []
+ }]
+ }
+ }
+ })
+ const repositories = await sut.getRepositories()
+ expect(repositories.length).toEqual(2)
+})
+
+test("It removes hidden repository", async () => {
+ const sut = new FilteringGitHubRepositoryDataSource({
+ hiddenRepositories: ["acme/foo-openapi"],
+ dataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ branches: [],
+ tags: []
+ }, {
+ owner: "acme",
+ name: "bar-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "bar"
+ },
+ branches: [],
+ tags: []
+ }]
+ }
+ }
+ })
+ const repositories = await sut.getRepositories()
+ expect(repositories.length).toEqual(1)
+})
+
+test("It returns unmodified list when hidden repository was not found", async () => {
+ const sut = new FilteringGitHubRepositoryDataSource({
+ hiddenRepositories: ["acme/baz-openapi"],
+ dataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ branches: [],
+ tags: []
+ }, {
+ owner: "acme",
+ name: "bar-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "bar"
+ },
+ branches: [],
+ tags: []
+ }]
+ }
+ }
+ })
+ const repositories = await sut.getRepositories()
+ expect(repositories.length).toEqual(2)
+})
+
+test("It removes multiple hidden repositories", async () => {
+ const sut = new FilteringGitHubRepositoryDataSource({
+ hiddenRepositories: ["acme/foo-openapi", "acme/bar-openapi"],
+ dataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ branches: [],
+ tags: []
+ }, {
+ owner: "acme",
+ name: "bar-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "bar"
+ },
+ branches: [],
+ tags: []
+ }]
+ }
+ }
+ })
+ const repositories = await sut.getRepositories()
+ expect(repositories.length).toEqual(0)
+})
diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts
index b4aeb857..ac01eaa1 100644
--- a/__test__/projects/GitHubProjectDataSource.test.ts
+++ b/__test__/projects/GitHubProjectDataSource.test.ts
@@ -1,25 +1,13 @@
-import {
- GitHubProjectDataSource
- } from "../../src/features/projects/data"
+import { GitHubProjectDataSource } from "@/features/projects/data"
test("It loads repositories from data source", async () => {
let didLoadRepositories = false
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
+ repositoryDataSource: {
+ async getRepositories() {
didLoadRepositories = true
- return {
- search: {
- results: []
- }
- }
+ return []
}
}
})
@@ -30,60 +18,30 @@ test("It loads repositories from data source", async () => {
test("It maps projects including branches and tags", async () => {
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- 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"
- }]
- }
- }
- }
- }]
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml"
}]
- }
- }
+ }],
+ tags: [{
+ id: "12345678",
+ name: "1.0",
+ files: [{
+ name: "openapi.yml"
+ }]
+ }]
+ }]
}
}
})
@@ -121,63 +79,33 @@ test("It maps projects including branches and tags", async () => {
}])
})
-test("It removes \"-openapi\" suffix from project name", async () => {
+test("It removes suffix from project name", async () => {
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- 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"
- }]
- }
- }
- }
- }]
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml"
}]
- }
- }
+ }],
+ tags: [{
+ id: "12345678",
+ name: "1.0",
+ files: [{
+ name: "openapi.yml"
+ }]
+ }]
+ }]
}
}
})
@@ -190,64 +118,34 @@ test("It removes \"-openapi\" suffix from project name", async () => {
test("It supports multiple OpenAPI specifications on a branch", async () => {
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- name: "foo-openapi",
- 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"
- }]
- }
- }
- }
- }]
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "foo-service.yml",
+ }, {
+ name: "bar-service.yml",
+ }, {
+ name: "baz-service.yml",
}]
- }
- }
+ }],
+ tags: [{
+ id: "12345678",
+ name: "1.0",
+ files: [{
+ name: "openapi.yml"
+ }]
+ }]
+ }]
}
}
})
@@ -295,105 +193,21 @@ test("It supports multiple OpenAPI specifications on a branch", async () => {
}])
})
-test("It removes \"-openapi\" suffix from project name", async () => {
- const sut = new GitHubProjectDataSource({
- repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- 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("acme-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({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- name: "foo",
- owner: {
- login: "acme"
- },
- defaultBranchRef: {
- name: "main",
- target: {
- oid: "12345678"
- }
- },
- branches: {
- edges: []
- },
- tags: {
- edges: []
- }
- }]
- }
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ branches: [],
+ tags: []
+ }]
}
}
})
@@ -404,60 +218,30 @@ test("It filters away projects with no versions", async () => {
test("It filters away branches with no specifications", async () => {
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- 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: []
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml",
}]
- }
- }
+ }, {
+ id: "12345678",
+ name: "bugfix",
+ files: [{
+ name: "README.md",
+ }]
+ }],
+ tags: []
+ }]
}
}
})
@@ -468,72 +252,36 @@ test("It filters away branches with no specifications", async () => {
test("It filters away tags with no specifications", async () => {
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- 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"
- }]
- }
- }
- }
- }]
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "foo-service.yml",
}]
- }
- }
+ }],
+ tags: [{
+ id: "12345678",
+ name: "1.0",
+ files: [{
+ name: "openapi.yml"
+ }]
+ }, {
+ id: "12345678",
+ name: "0.1",
+ files: [{
+ name: "README.md"
+ }]
+ }]
+ }]
}
}
})
@@ -544,51 +292,27 @@ test("It filters away tags with no specifications", async () => {
test("It reads image from configuration file with .yml extension", async () => {
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- 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: []
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ configYml: {
+ text: "image: icon.png"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml",
}]
- }
- }
+ }],
+ tags: []
+ }]
}
}
})
@@ -596,130 +320,30 @@ test("It reads image from configuration file with .yml extension", async () => {
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({
- repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- 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 configuration file with .yml extension", async () => {
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- 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: []
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ configYml: {
+ text: "name: Hello World"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml",
}]
- }
- }
+ }],
+ tags: []
+ }]
}
}
})
@@ -729,54 +353,30 @@ test("It reads display name from configuration file with .yml extension", async
expect(projects[0].displayName).toEqual("Hello World")
})
-test("It reads image from configuration file with .yml extension", async () => {
+test("It reads image from configuration file with .yaml extension", async () => {
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- 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: []
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ configYaml: {
+ text: "image: icon.png"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml",
}]
- }
- }
+ }],
+ tags: []
+ }]
}
}
})
@@ -787,51 +387,27 @@ test("It reads image from configuration file with .yml extension", async () => {
test("It reads display name from configuration file with .yaml extension", async () => {
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- 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: []
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ configYaml: {
+ text: "name: Hello World"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml",
}]
- }
- }
+ }],
+ tags: []
+ }]
}
}
})
@@ -841,164 +417,66 @@ test("It reads display name from configuration file with .yaml extension", async
expect(projects[0].displayName).toEqual("Hello World")
})
-test("It reads image from configuration file with .yaml extension", async () => {
- const sut = new GitHubProjectDataSource({
- repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- 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({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- name: "cathrine-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: []
- }
- }, {
- name: "anne-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: []
- }
- }, {
- name: "bobby-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: []
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "cathrine-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ configYaml: {
+ text: "name: Hello World"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml",
+ }]
+ }],
+ tags: []
+ }, {
+ owner: "acme",
+ name: "bobby-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ configYaml: {
+ text: "name: Hello World"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml",
+ }]
+ }],
+ tags: []
+ }, {
+ owner: "acme",
+ name: "anne-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ configYaml: {
+ text: "name: Hello World"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml",
}]
- }
- }
+ }],
+ tags: []
+ }]
}
}
})
@@ -1011,84 +489,45 @@ test("It sorts projects alphabetically", async () => {
test("It sorts versions alphabetically", async () => {
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- name: "foo-openapi",
- 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"
- }]
- }
- }
- }
- }]
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ configYaml: {
+ text: "name: Hello World"
+ },
+ branches: [{
+ id: "12345678",
+ name: "anne",
+ files: [{
+ name: "openapi.yml",
+ }]
+ }, {
+ id: "12345678",
+ name: "bobby",
+ files: [{
+ name: "openapi.yml",
+ }]
+ }],
+ tags: [{
+ id: "12345678",
+ name: "cathrine",
+ files: [{
+ name: "openapi.yml",
+ }]
+ }, {
+ id: "12345678",
+ name: "1.0",
+ files: [{
+ name: "openapi.yml",
}]
- }
- }
+ }]
+ }]
}
}
})
@@ -1102,108 +541,57 @@ test("It sorts versions alphabetically", async () => {
test("It prioritizes main, master, develop, and development branch names when sorting verisons", async () => {
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- name: "foo-openapi",
- 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"
- }]
- }
- }
- }
- }]
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ configYaml: {
+ text: "name: Hello World"
+ },
+ branches: [{
+ id: "12345678",
+ name: "anne",
+ files: [{
+ name: "openapi.yml",
+ }]
+ }, {
+ id: "12345678",
+ name: "develop",
+ files: [{
+ name: "openapi.yml",
+ }]
+ }, {
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml",
+ }]
+ }, {
+ id: "12345678",
+ name: "development",
+ files: [{
+ name: "openapi.yml",
}]
- }
- }
+ }, {
+ id: "12345678",
+ name: "master",
+ files: [{
+ name: "openapi.yml",
+ }]
+ }],
+ tags: [{
+ id: "12345678",
+ name: "1.0",
+ files: [{
+ name: "openapi.yml",
+ }]
+ }]
+ }]
}
}
})
@@ -1219,72 +607,39 @@ test("It prioritizes main, master, develop, and development branch names when so
test("It identifies the default branch in returned versions", async () => {
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- name: "foo-openapi",
- 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: []
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "development"
+ },
+ configYaml: {
+ text: "name: Hello World"
+ },
+ branches: [{
+ id: "12345678",
+ name: "anne",
+ files: [{
+ name: "openapi.yml",
}]
- }
- }
+ }, {
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml",
+ }]
+ }, {
+ id: "12345678",
+ name: "development",
+ files: [{
+ name: "openapi.yml",
+ }]
+ }],
+ tags: []
+ }]
}
}
})
@@ -1297,54 +652,35 @@ test("It identifies the default branch in returned versions", async () => {
})
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({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- name: "foo-openapi",
- owner: {
- login: "acme"
- },
- defaultBranchRef: {
- name: "main",
- target: {
- oid: "12345678"
- }
- },
- configYml: {
- text: rawProjectConfig
- },
- branches: {
- edges: []
- },
- tags: {
- edges: []
- }
- }]
- }
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ configYaml: {
+ text: `
+ 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
+ `
+ },
+ branches: [],
+ tags: []
+ }]
}
}
})
@@ -1375,64 +711,39 @@ test("It adds remote versions from the project configuration", async () => {
})
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({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- name: "foo-openapi",
- 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: []
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "bar"
+ },
+ configYaml: {
+ text: `
+ remoteVersions:
+ - name: Bar
+ specifications:
+ - name: Baz
+ url: https://example.com/baz.yml
+ - name: Bar
+ specifications:
+ - name: Hello
+ url: https://example.com/hello.yml
+ `
+ },
+ branches: [{
+ id: "12345678",
+ name: "bar",
+ files: [{
+ name: "openapi.yml"
}]
- }
- }
+ }],
+ tags: []
+ }]
}
}
})
@@ -1470,49 +781,30 @@ test("It modifies ID of remote version if the ID already exists", async () => {
})
test("It lets users specify the ID of a remote version", async () => {
- const rawProjectConfig = `
- remoteVersions:
- - id: some-version
- name: Bar
- specifications:
- - name: Baz
- url: https://example.com/baz.yml
- `
const sut = new GitHubProjectDataSource({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- name: "foo-openapi",
- owner: {
- login: "acme"
- },
- defaultBranchRef: {
- name: "bar",
- target: {
- oid: "12345678"
- }
- },
- configYml: {
- text: rawProjectConfig
- },
- branches: {
- edges: []
- },
- tags: {
- edges: []
- }
- }]
- }
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "bar"
+ },
+ configYaml: {
+ text: `
+ remoteVersions:
+ - id: some-version
+ name: Bar
+ specifications:
+ - name: Baz
+ url: https://example.com/baz.yml
+ `
+ },
+ branches: [],
+ tags: []
+ }]
}
}
})
@@ -1530,49 +822,30 @@ test("It lets users specify the ID of a remote version", async () => {
})
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({
repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql() {
- return {
- search: {
- results: [{
- name: "foo-openapi",
- owner: {
- login: "acme"
- },
- defaultBranchRef: {
- name: "bar",
- target: {
- oid: "12345678"
- }
- },
- configYml: {
- text: rawProjectConfig
- },
- branches: {
- edges: []
- },
- tags: {
- edges: []
- }
- }]
- }
- }
+ repositoryDataSource: {
+ async getRepositories() {
+ return [{
+ owner: "acme",
+ name: "foo-openapi",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "bar"
+ },
+ configYaml: {
+ text: `
+ remoteVersions:
+ - name: Bar
+ specifications:
+ - id: some-spec
+ name: Baz
+ url: https://example.com/baz.yml
+ `
+ },
+ branches: [],
+ tags: []
+ }]
}
}
})
@@ -1588,112 +861,3 @@ test("It lets users specify the ID of a remote specification", async () => {
}]
}])
})
-
-test("It queries for both .yml and .yaml file extension with specifying .yml extension", async () => {
- let query: string | undefined
- const sut = new GitHubProjectDataSource({
- repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql(request) {
- query = request.query
- return {
- search: {
- results: []
- }
- }
- }
- }
- })
- await sut.getProjects()
- expect(query).toContain(".demo-docs.yml")
- expect(query).toContain(".demo-docs.yaml")
-})
-
-test("It queries for both .yml and .yaml file extension with specifying .yaml extension", async () => {
- let query: string | undefined
- const sut = new GitHubProjectDataSource({
- repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs.yml",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql(request) {
- query = request.query
- return {
- search: {
- results: []
- }
- }
- }
- }
- })
- await sut.getProjects()
- expect(query).toContain(".demo-docs.yml")
- expect(query).toContain(".demo-docs.yaml")
-})
-
-test("It queries for both .yml and .yaml file extension with no extension", async () => {
- let query: string | undefined
- const sut = new GitHubProjectDataSource({
- repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs",
- loginsDataSource: {
- async getLogins() {
- return ["acme"]
- }
- },
- graphQlClient: {
- async graphql(request) {
- query = request.query
- return {
- search: {
- results: []
- }
- }
- }
- }
- })
- await sut.getProjects()
- expect(query).toContain(".demo-docs.yml")
- expect(query).toContain(".demo-docs.yaml")
-})
-
-test("It loads projects for all logins", async () => {
- let searchQueries: string[] = []
- const sut = new GitHubProjectDataSource({
- repositoryNameSuffix: "-openapi",
- projectConfigurationFilename: ".demo-docs",
- loginsDataSource: {
- async getLogins() {
- return ["acme", "somecorp", "techsystems"]
- }
- },
- graphQlClient: {
- async graphql(request) {
- if (request.variables?.searchQuery) {
- searchQueries.push(request.variables.searchQuery)
- }
- return {
- search: {
- results: []
- }
- }
- }
- }
- })
- await sut.getProjects()
- expect(searchQueries.length).toEqual(4)
- expect(searchQueries).toContain("\"-openapi\" in:name is:private")
- expect(searchQueries).toContain("\"-openapi\" in:name user:acme is:public")
- expect(searchQueries).toContain("\"-openapi\" in:name user:somecorp is:public")
- expect(searchQueries).toContain("\"-openapi\" in:name user:techsystems is:public")
-})
diff --git a/__test__/projects/GitHubRepositoryDataSource.test.ts b/__test__/projects/GitHubRepositoryDataSource.test.ts
new file mode 100644
index 00000000..9235a28b
--- /dev/null
+++ b/__test__/projects/GitHubRepositoryDataSource.test.ts
@@ -0,0 +1,220 @@
+import { GitHubRepositoryDataSource } from "@/features/projects/data"
+
+test("It loads repositories from data source", async () => {
+ let didLoadRepositories = false
+ const sut = new GitHubRepositoryDataSource({
+ repositoryNameSuffix: "-openapi",
+ projectConfigurationFilename: ".demo-docs.yml",
+ loginsDataSource: {
+ async getLogins() {
+ return ["acme"]
+ }
+ },
+ graphQlClient: {
+ async graphql() {
+ didLoadRepositories = true
+ return {
+ search: {
+ results: []
+ }
+ }
+ }
+ }
+ })
+ await sut.getRepositories()
+ expect(didLoadRepositories).toBeTruthy()
+})
+
+test("It maps repositories from GraphQL to the GitHubRepository model", async () => {
+ const sut = new GitHubRepositoryDataSource({
+ repositoryNameSuffix: "-openapi",
+ projectConfigurationFilename: ".demo-docs.yml",
+ loginsDataSource: {
+ async getLogins() {
+ return ["acme"]
+ }
+ },
+ graphQlClient: {
+ async graphql() {
+ return {
+ search: {
+ results: [{
+ 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 repositories = await sut.getRepositories()
+ expect(repositories).toEqual([{
+ name: "foo-openapi",
+ owner: "acme",
+ defaultBranchRef: {
+ id: "12345678",
+ name: "main"
+ },
+ branches: [{
+ id: "12345678",
+ name: "main",
+ files: [{
+ name: "openapi.yml"
+ }]
+ }],
+ tags: [{
+ id: "12345678",
+ name: "1.0",
+ files: [{
+ name: "openapi.yml"
+ }]
+ }]
+ }])
+})
+
+test("It queries for both .yml and .yaml file extension with specifying .yml extension", async () => {
+ let query: string | undefined
+ const sut = new GitHubRepositoryDataSource({
+ repositoryNameSuffix: "-openapi",
+ projectConfigurationFilename: ".demo-docs.yml",
+ loginsDataSource: {
+ async getLogins() {
+ return ["acme"]
+ }
+ },
+ graphQlClient: {
+ async graphql(request) {
+ query = request.query
+ return {
+ search: {
+ results: []
+ }
+ }
+ }
+ }
+ })
+ await sut.getRepositories()
+ expect(query).toContain(".demo-docs.yml")
+ expect(query).toContain(".demo-docs.yaml")
+})
+
+test("It queries for both .yml and .yaml file extension with specifying .yaml extension", async () => {
+ let query: string | undefined
+ const sut = new GitHubRepositoryDataSource({
+ repositoryNameSuffix: "-openapi",
+ projectConfigurationFilename: ".demo-docs.yml",
+ loginsDataSource: {
+ async getLogins() {
+ return ["acme"]
+ }
+ },
+ graphQlClient: {
+ async graphql(request) {
+ query = request.query
+ return {
+ search: {
+ results: []
+ }
+ }
+ }
+ }
+ })
+ await sut.getRepositories()
+ expect(query).toContain(".demo-docs.yml")
+ expect(query).toContain(".demo-docs.yaml")
+})
+
+test("It queries for both .yml and .yaml file extension with no extension", async () => {
+ let query: string | undefined
+ const sut = new GitHubRepositoryDataSource({
+ repositoryNameSuffix: "-openapi",
+ projectConfigurationFilename: ".demo-docs",
+ loginsDataSource: {
+ async getLogins() {
+ return ["acme"]
+ }
+ },
+ graphQlClient: {
+ async graphql(request) {
+ query = request.query
+ return {
+ search: {
+ results: []
+ }
+ }
+ }
+ }
+ })
+ await sut.getRepositories()
+ expect(query).toContain(".demo-docs.yml")
+ expect(query).toContain(".demo-docs.yaml")
+})
+
+test("It loads repositories for all logins", async () => {
+ let searchQueries: string[] = []
+ const sut = new GitHubRepositoryDataSource({
+ repositoryNameSuffix: "-openapi",
+ projectConfigurationFilename: ".demo-docs",
+ loginsDataSource: {
+ async getLogins() {
+ return ["acme", "somecorp", "techsystems"]
+ }
+ },
+ graphQlClient: {
+ async graphql(request) {
+ if (request.variables?.searchQuery) {
+ searchQueries.push(request.variables.searchQuery)
+ }
+ return {
+ search: {
+ results: []
+ }
+ }
+ }
+ }
+ })
+ await sut.getRepositories()
+ expect(searchQueries.length).toEqual(4)
+ expect(searchQueries).toContain("\"-openapi\" in:name is:private")
+ expect(searchQueries).toContain("\"-openapi\" in:name user:acme is:public")
+ expect(searchQueries).toContain("\"-openapi\" in:name user:somecorp is:public")
+ expect(searchQueries).toContain("\"-openapi\" in:name user:techsystems is:public")
+})
diff --git a/__test__/projects/ProjectConfigParser.test.ts b/__test__/projects/ProjectConfigParser.test.ts
index 4cb0ee0e..4b2256fb 100644
--- a/__test__/projects/ProjectConfigParser.test.ts
+++ b/__test__/projects/ProjectConfigParser.test.ts
@@ -1,4 +1,4 @@
-import { ProjectConfigParser } from "../../src/features/projects/domain"
+import { ProjectConfigParser } from "@/features/projects/domain"
test("It parses an empty string", async () => {
const sut = new ProjectConfigParser()
diff --git a/__test__/projects/getSelection.test.ts b/__test__/projects/getProjectSelectionFromPath.test.ts
similarity index 93%
rename from __test__/projects/getSelection.test.ts
rename to __test__/projects/getProjectSelectionFromPath.test.ts
index a5a544ca..7991fb1d 100644
--- a/__test__/projects/getSelection.test.ts
+++ b/__test__/projects/getProjectSelectionFromPath.test.ts
@@ -1,7 +1,7 @@
-import { getSelection } from "../../src/features/projects/domain"
+import { getProjectSelectionFromPath } from "@/features/projects/domain"
test("It selects the first project when there is only one project and path is empty", () => {
- const sut = getSelection({
+ const sut = getProjectSelectionFromPath({
path: "",
projects: [{
id: "foo",
@@ -28,7 +28,7 @@ test("It selects the first project when there is only one project and path is em
})
test("It selects the first version and specification of the specified project", () => {
- const sut = getSelection({
+ const sut = getProjectSelectionFromPath({
path: "/acme/bar",
projects: [{
id: "foo",
@@ -71,7 +71,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({
+ const sut = getProjectSelectionFromPath({
path: "/acme/bar/baz2",
projects: [{
id: "foo",
@@ -110,7 +110,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({
+ const sut = getProjectSelectionFromPath({
path: "/acme/bar/baz2",
projects: [{
id: "foo",
@@ -153,7 +153,7 @@ test("It selects the specification of the specified version", () => {
})
test("It selects the specified project, version, and specification", () => {
- const sut = getSelection({
+ const sut = getProjectSelectionFromPath({
path: "/acme/bar/baz2/hello2",
projects: [{
id: "foo",
@@ -196,7 +196,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({
+ const sut = getProjectSelectionFromPath({
path: "/acme/foo",
projects: [{
id: "bar",
@@ -213,7 +213,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({
+ const sut = getProjectSelectionFromPath({
path: "/acme/foo/bar",
projects: [{
id: "foo",
@@ -236,7 +236,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({
+ const sut = getProjectSelectionFromPath({
path: "/acme/foo/bar/baz",
projects: [{
id: "foo",
@@ -263,7 +263,7 @@ test("It returns a undefined specification when the selected specification canno
})
test("It moves specification ID to version ID if needed", () => {
- const sut = getSelection({
+ const sut = getProjectSelectionFromPath({
path: "/acme/foo/bar/baz",
projects: [{
id: "foo",
diff --git a/__test__/projects/projectNavigator.test.ts b/__test__/projects/projectNavigator.test.ts
index 4cc0cb6a..d1d71825 100644
--- a/__test__/projects/projectNavigator.test.ts
+++ b/__test__/projects/projectNavigator.test.ts
@@ -1,4 +1,4 @@
-import { ProjectNavigator } from "../../src/features/projects/domain"
+import { ProjectNavigator } from "@/features/projects/domain"
test("It navigates to the correct path", async () => {
let pushedPath: string | undefined
diff --git a/__test__/projects/updateWindowTitle.test.ts b/__test__/projects/updateWindowTitle.test.ts
index 5521f406..86509b38 100644
--- a/__test__/projects/updateWindowTitle.test.ts
+++ b/__test__/projects/updateWindowTitle.test.ts
@@ -1,4 +1,4 @@
-import { updateWindowTitle } from "../../src/features/projects/domain"
+import { updateWindowTitle } from "@/features/projects/domain"
test("It uses default title when there is no selection", async () => {
const store: { title: string } = { title: "" }
diff --git a/__test__/utils/splitOwnerAndRepository.test.ts b/__test__/utils/splitOwnerAndRepository.test.ts
new file mode 100644
index 00000000..d4c162a6
--- /dev/null
+++ b/__test__/utils/splitOwnerAndRepository.test.ts
@@ -0,0 +1,26 @@
+import { splitOwnerAndRepository } from "@/common"
+
+test("It returns undefined when string includes no slash", async () => {
+ const result = splitOwnerAndRepository("foo")
+ expect(result).toBeUndefined()
+})
+
+test("It returns undefined when repository is empty", async () => {
+ const result = splitOwnerAndRepository("foo/")
+ expect(result).toBeUndefined()
+})
+
+test("It returns undefined when owner is empty", async () => {
+ const result = splitOwnerAndRepository("/foo")
+ expect(result).toBeUndefined()
+})
+
+test("It splits owner and repository", async () => {
+ const result = splitOwnerAndRepository("acme/foo")
+ expect(result).toEqual({ owner: "acme", repository: "foo" })
+})
+
+test("It splits owner and repository for repository name containing a slash", async () => {
+ const result = splitOwnerAndRepository("acme/foo/bar")
+ expect(result).toEqual({ owner: "acme", repository: "foo/bar" })
+})
diff --git a/package-lock.json b/package-lock.json
index bf0d253e..3ec07985 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,10 +11,11 @@
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
+ "@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
- "@mui/icons-material": "^5.16.4",
- "@mui/material": "^5.16.4",
+ "@mui/icons-material": "^5.16.5",
+ "@mui/material": "^5.16.5",
"@octokit/auth-app": "^7.1.0",
"@octokit/core": "^6.1.2",
"@octokit/webhooks": "^13.3.0",
@@ -26,7 +27,7 @@
"ioredis": "^5.4.1",
"mobx": "^6.13.1",
"next": "^14.2.5",
- "next-auth": "^5.0.0-beta.19",
+ "next-auth": "^5.0.0-beta.20",
"nodemailer": "^6.9.14",
"npm": "^10.8.2",
"octokit": "^4.0.2",
@@ -39,13 +40,13 @@
"swagger-ui-react": "^5.17.14",
"swr": "^2.2.5",
"usehooks-ts": "^3.1.0",
- "yaml": "^2.4.5",
+ "yaml": "^2.5.0",
"zod": "^3.23.8"
},
"devDependencies": {
- "@auth/pg-adapter": "^1.4.1",
+ "@auth/pg-adapter": "^1.4.2",
"@types/jest": "^29.5.12",
- "@types/node": "^20.14.11",
+ "@types/node": "^22.0.0",
"@types/nodemailer": "^6.4.15",
"@types/pg": "^8.11.6",
"@types/react": "^18.3.3",
@@ -57,8 +58,8 @@
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.5",
"pg": "^8.12.0",
- "postcss": "^8.4.39",
- "tailwindcss": "^3.4.6",
+ "postcss": "^8.4.40",
+ "tailwindcss": "^3.4.7",
"ts-jest": "^29.2.3",
"typescript": "^5.5.4"
},
@@ -94,10 +95,9 @@
}
},
"node_modules/@auth/core": {
- "version": "0.34.1",
- "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.1.tgz",
- "integrity": "sha512-tuYU2VIbI8rFbkSwP710LmybB2FXJsPN7j3sjRVfN9SXVQBK2ej6LdewQaofpBGp4Mk+cC2UeiGNH0or4tgaeA==",
- "dev": true,
+ "version": "0.34.2",
+ "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.2.tgz",
+ "integrity": "sha512-KywHKRgLiF3l7PLyL73fjLSIBe1YNcA6sMeew4yMP6cfCWGXZrkkXd32AjRi1hlJ9nvovUBGZHvbn+LijO6ZeQ==",
"dependencies": {
"@panva/hkdf": "^1.1.1",
"@types/cookie": "0.6.0",
@@ -125,12 +125,12 @@
}
},
"node_modules/@auth/pg-adapter": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/@auth/pg-adapter/-/pg-adapter-1.4.1.tgz",
- "integrity": "sha512-pkZr5NFeju7ONHm/l0VnibjqGFcshhk34HyuglPZVWJba6dXyK7WGkKFV4vAbxKKkfkn3paAfTrjjkWgRNL87Q==",
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@auth/pg-adapter/-/pg-adapter-1.4.2.tgz",
+ "integrity": "sha512-L7IAHv6yZN695OFKro0cbny7Nq5XwAF7R84YglQUcNkVFn3IffMWFVFGT6T+WM9Shdc3e1whceqcBJ1+A8WW4g==",
"dev": true,
"dependencies": {
- "@auth/core": "0.34.1"
+ "@auth/core": "0.34.2"
},
"peerDependencies": {
"pg": "^8"
@@ -964,6 +964,17 @@
"node": ">=6"
}
},
+ "node_modules/@fortawesome/free-brands-svg-icons": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz",
+ "integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==",
+ "dependencies": {
+ "@fortawesome/fontawesome-common-types": "6.6.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz",
@@ -2315,18 +2326,18 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@mui/core-downloads-tracker": {
- "version": "5.16.4",
- "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.4.tgz",
- "integrity": "sha512-rNdHXhclwjEZnK+//3SR43YRx0VtjdHnUFhMSGYmAMJve+KiwEja/41EYh8V3pZKqF2geKyfcFUenTfDTYUR4w==",
+ "version": "5.16.5",
+ "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.5.tgz",
+ "integrity": "sha512-ziFn1oPm6VjvHQcdGcAO+fXvOQEgieIj0BuSqcltFU+JXIxjPdVYNTdn2HU7/Ak5Gabk6k2u7+9PV7oZ6JT5sA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
}
},
"node_modules/@mui/icons-material": {
- "version": "5.16.4",
- "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.16.4.tgz",
- "integrity": "sha512-j9/CWctv6TH6Dou2uR2EH7UOgu79CW/YcozxCYVLJ7l03pCsiOlJ5sBArnWJxJ+nGkFwyL/1d1k8JEPMDR125A==",
+ "version": "5.16.5",
+ "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.16.5.tgz",
+ "integrity": "sha512-bn88xxU/J9UV0s6+eutq7o3TTOrOlbCX+KshFb8kxgIxJZZfYz3JbAXVMivvoMF4Md6jCVUzM9HEkf4Ajab4tw==",
"dependencies": {
"@babel/runtime": "^7.23.9"
},
@@ -2349,15 +2360,15 @@
}
},
"node_modules/@mui/material": {
- "version": "5.16.4",
- "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.4.tgz",
- "integrity": "sha512-dBnh3/zRYgEVIS3OE4oTbujse3gifA0qLMmuUk13ywsDCbngJsdgwW5LuYeiT5pfA8PGPGSqM7mxNytYXgiMCw==",
+ "version": "5.16.5",
+ "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.5.tgz",
+ "integrity": "sha512-eQrjjg4JeczXvh/+8yvJkxWIiKNHVptB/AqpsKfZBWp5mUD5U3VsjODMuUl1K2BSq0omV3CiO/mQmWSSMKSmaA==",
"dependencies": {
"@babel/runtime": "^7.23.9",
- "@mui/core-downloads-tracker": "^5.16.4",
- "@mui/system": "^5.16.4",
+ "@mui/core-downloads-tracker": "^5.16.5",
+ "@mui/system": "^5.16.5",
"@mui/types": "^7.2.15",
- "@mui/utils": "^5.16.4",
+ "@mui/utils": "^5.16.5",
"@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.10",
"clsx": "^2.1.0",
@@ -2393,12 +2404,12 @@
}
},
"node_modules/@mui/private-theming": {
- "version": "5.16.4",
- "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.4.tgz",
- "integrity": "sha512-ZsAm8cq31SJ37SVWLRlu02v9SRthxnfQofaiv14L5Bht51B0dz6yQEoVU/V8UduZDCCIrWkBHuReVfKhE/UuXA==",
+ "version": "5.16.5",
+ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.5.tgz",
+ "integrity": "sha512-CSLg0YkpDqg0aXOxtjo3oTMd3XWMxvNb5d0v4AYVqwOltU8q6GvnZjhWyCLjGSCrcgfwm6/VDjaKLPlR14wxIA==",
"dependencies": {
"@babel/runtime": "^7.23.9",
- "@mui/utils": "^5.16.4",
+ "@mui/utils": "^5.16.5",
"prop-types": "^15.8.1"
},
"engines": {
@@ -2450,15 +2461,15 @@
}
},
"node_modules/@mui/system": {
- "version": "5.16.4",
- "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.4.tgz",
- "integrity": "sha512-ET1Ujl2/8hbsD611/mqUuNArMCGv/fIWO/f8B3ZqF5iyPHM2aS74vhTNyjytncc4i6dYwGxNk+tLa7GwjNS0/w==",
+ "version": "5.16.5",
+ "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.5.tgz",
+ "integrity": "sha512-uzIUGdrWddUx1HPxW4+B2o4vpgKyRxGe/8BxbfXVDPNPHX75c782TseoCnR/VyfnZJfqX87GcxDmnZEE1c031g==",
"dependencies": {
"@babel/runtime": "^7.23.9",
- "@mui/private-theming": "^5.16.4",
+ "@mui/private-theming": "^5.16.5",
"@mui/styled-engine": "^5.16.4",
"@mui/types": "^7.2.15",
- "@mui/utils": "^5.16.4",
+ "@mui/utils": "^5.16.5",
"clsx": "^2.1.0",
"csstype": "^3.1.3",
"prop-types": "^15.8.1"
@@ -2502,11 +2513,12 @@
}
},
"node_modules/@mui/utils": {
- "version": "5.16.4",
- "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.4.tgz",
- "integrity": "sha512-nlppYwq10TBIFqp7qxY0SvbACOXeOjeVL3pOcDsK0FT8XjrEXh9/+lkg8AEIzD16z7YfiJDQjaJG2OLkE7BxNg==",
+ "version": "5.16.5",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.5.tgz",
+ "integrity": "sha512-CwhcA9y44XwK7k2joL3Y29mRUnoBt+gOZZdGyw7YihbEwEErJYBtDwbZwVgH68zAljGe/b+Kd5bzfl63Gi3R2A==",
"dependencies": {
"@babel/runtime": "^7.23.9",
+ "@mui/types": "^7.2.15",
"@types/prop-types": "^15.7.12",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
@@ -4818,11 +4830,11 @@
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA=="
},
"node_modules/@types/node": {
- "version": "20.14.11",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz",
- "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==",
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.0.0.tgz",
+ "integrity": "sha512-VT7KSYudcPOzP5Q0wfbowyNLaVR8QWUdw+088uFWwfvpY6uCWaXpqV6ieLAu9WBcnTa7H4Z5RLK8I5t2FuOcqw==",
"dependencies": {
- "undici-types": "~5.26.4"
+ "undici-types": "~6.11.1"
}
},
"node_modules/@types/nodemailer": {
@@ -13001,16 +13013,16 @@
}
},
"node_modules/next-auth": {
- "version": "5.0.0-beta.19",
- "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.19.tgz",
- "integrity": "sha512-YHu1igcAxZPh8ZB7GIM93dqgY6gcAzq66FOhQFheAdOx1raxNcApt05nNyNCSB6NegSiyJ4XOPsaNow4pfDmsg==",
+ "version": "5.0.0-beta.20",
+ "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.20.tgz",
+ "integrity": "sha512-+48SjV9k9AtUU3JbEIa4PXNjKIewfFjVGL7Xs2RKkuQ5QqegDNIQiIG8sLk6/qo7RTScQYIGKgeQ5IuQRtrTQg==",
"dependencies": {
- "@auth/core": "0.32.0"
+ "@auth/core": "0.34.2"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
- "next": "^14 || ^15.0.0-0",
+ "next": "^14.0.0-0 || ^15.0.0-0",
"nodemailer": "^6.6.5",
"react": "^18.2.0 || ^19.0.0-0"
},
@@ -13026,36 +13038,6 @@
}
}
},
- "node_modules/next-auth/node_modules/@auth/core": {
- "version": "0.32.0",
- "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.32.0.tgz",
- "integrity": "sha512-3+ssTScBd+1fd0/fscAyQN1tSygXzuhysuVVzB942ggU4mdfiTbv36P0ccVnExKWYJKvu3E2r3/zxXCCAmTOrg==",
- "dependencies": {
- "@panva/hkdf": "^1.1.1",
- "@types/cookie": "0.6.0",
- "cookie": "0.6.0",
- "jose": "^5.1.3",
- "oauth4webapi": "^2.9.0",
- "preact": "10.11.3",
- "preact-render-to-string": "5.2.3"
- },
- "peerDependencies": {
- "@simplewebauthn/browser": "^9.0.1",
- "@simplewebauthn/server": "^9.0.2",
- "nodemailer": "^6.8.0"
- },
- "peerDependenciesMeta": {
- "@simplewebauthn/browser": {
- "optional": true
- },
- "@simplewebauthn/server": {
- "optional": true
- },
- "nodemailer": {
- "optional": true
- }
- }
- },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -16395,9 +16377,9 @@
}
},
"node_modules/postcss": {
- "version": "8.4.39",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
- "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
+ "version": "8.4.40",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz",
+ "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==",
"dev": true,
"funding": [
{
@@ -18938,9 +18920,9 @@
}
},
"node_modules/tailwindcss": {
- "version": "3.4.6",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz",
- "integrity": "sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA==",
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz",
+ "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==",
"dev": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -19459,9 +19441,9 @@
}
},
"node_modules/undici-types": {
- "version": "5.26.5",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
- "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
+ "version": "6.11.1",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.11.1.tgz",
+ "integrity": "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ=="
},
"node_modules/unified": {
"version": "9.2.2",
@@ -20226,9 +20208,9 @@
"peer": true
},
"node_modules/yaml": {
- "version": "2.4.5",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz",
- "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz",
+ "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==",
"bin": {
"yaml": "bin.mjs"
},
diff --git a/package.json b/package.json
index 50ec2a4a..24eea081 100644
--- a/package.json
+++ b/package.json
@@ -18,10 +18,11 @@
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
+ "@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
- "@mui/icons-material": "^5.16.4",
- "@mui/material": "^5.16.4",
+ "@mui/icons-material": "^5.16.5",
+ "@mui/material": "^5.16.5",
"@octokit/auth-app": "^7.1.0",
"@octokit/core": "^6.1.2",
"@octokit/webhooks": "^13.3.0",
@@ -33,7 +34,7 @@
"ioredis": "^5.4.1",
"mobx": "^6.13.1",
"next": "^14.2.5",
- "next-auth": "^5.0.0-beta.19",
+ "next-auth": "^5.0.0-beta.20",
"nodemailer": "^6.9.14",
"npm": "^10.8.2",
"octokit": "^4.0.2",
@@ -46,13 +47,13 @@
"swagger-ui-react": "^5.17.14",
"swr": "^2.2.5",
"usehooks-ts": "^3.1.0",
- "yaml": "^2.4.5",
+ "yaml": "^2.5.0",
"zod": "^3.23.8"
},
"devDependencies": {
- "@auth/pg-adapter": "^1.4.1",
+ "@auth/pg-adapter": "^1.4.2",
"@types/jest": "^29.5.12",
- "@types/node": "^20.14.11",
+ "@types/node": "^22.0.0",
"@types/nodemailer": "^6.4.15",
"@types/pg": "^8.11.6",
"@types/react": "^18.3.3",
@@ -64,8 +65,8 @@
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.5",
"pg": "^8.12.0",
- "postcss": "^8.4.39",
- "tailwindcss": "^3.4.6",
+ "postcss": "^8.4.40",
+ "tailwindcss": "^3.4.7",
"ts-jest": "^29.2.3",
"typescript": "^5.5.4"
}
diff --git a/src/app/(authed)/(home)/[[...slug]]/layout.tsx b/src/app/(authed)/(home)/[[...slug]]/layout.tsx
new file mode 100644
index 00000000..36fb09c1
--- /dev/null
+++ b/src/app/(authed)/(home)/[[...slug]]/layout.tsx
@@ -0,0 +1,23 @@
+"use client"
+
+import SecondarySplitHeader from "@/features/sidebar/view/SecondarySplitHeader"
+import TrailingToolbarItem from "@/features/projects/view/toolbar/TrailingToolbarItem"
+import MobileToolbar from "@/features/projects/view/toolbar/MobileToolbar"
+import { useProjectSelection } from "@/features/projects/data"
+
+export default function Page({ children }: { children: React.ReactNode }) {
+ const { project } = useProjectSelection()
+ if (!project) {
+ return <>>
+ }
+ return (
+ <>
+ >
+
+
+
+ {children}
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/src/app/(authed)/(home)/[[...slug]]/page.tsx b/src/app/(authed)/(home)/[[...slug]]/page.tsx
new file mode 100644
index 00000000..e70225d5
--- /dev/null
+++ b/src/app/(authed)/(home)/[[...slug]]/page.tsx
@@ -0,0 +1,43 @@
+"use client"
+
+import { useContext, useEffect } from "react"
+import { ProjectsContainerContext } from "@/common"
+import DelayedLoadingIndicator from "@/common/ui/DelayedLoadingIndicator"
+import ErrorMessage from "@/common/ui/ErrorMessage"
+import { updateWindowTitle } from "@/features/projects/domain"
+import { useProjectSelection } from "@/features/projects/data"
+import Documentation from "@/features/projects/view/Documentation"
+
+export default function Page() {
+ const { error, isLoading } = useContext(ProjectsContainerContext)
+ const { project, version, specification, navigateToSelectionIfNeeded } = useProjectSelection()
+ // Ensure the URL reflects the current selection of project, version, and specification.
+ useEffect(() => {
+ navigateToSelectionIfNeeded()
+ }, [project, version, specification, navigateToSelectionIfNeeded])
+ // Update the window title to match selected project.
+ const siteName = process.env.NEXT_PUBLIC_SHAPE_DOCS_TITLE || ""
+ useEffect(() => {
+ updateWindowTitle({
+ storage: document,
+ defaultTitle: siteName,
+ project,
+ version,
+ specification
+ })
+ }, [siteName, project, version, specification])
+ if (project && version && specification) {
+ return
+ } else if (project && !version) {
+ return
+ } else if (project && !specification) {
+ return
+ } else if (isLoading) {
+ return
+ } else if (error) {
+ return
+ } else {
+ // No project is selected so we will not show anything.
+ return <>>
+ }
+}
diff --git a/src/app/(authed)/(home)/layout.tsx b/src/app/(authed)/(home)/layout.tsx
new file mode 100644
index 00000000..49b27871
--- /dev/null
+++ b/src/app/(authed)/(home)/layout.tsx
@@ -0,0 +1,36 @@
+"use client"
+
+import { useContext } from "react"
+import { SplitView } from "@/features/sidebar/view"
+import { useProjects, useProjectSelection } from "@/features/projects/data"
+import {
+ ProjectsContainerContext,
+ ServerSideCachedProjectsContext
+} from "@/common"
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ const { projects, error, isLoading } = useProjects()
+ // Update projects provided to child components, using cached projects from the server if needed.
+ const serverSideCachedProjects = useContext(ServerSideCachedProjectsContext)
+ const newProjectsContainer = { projects, error, isLoading }
+ if (isLoading && serverSideCachedProjects) {
+ newProjectsContainer.isLoading = false
+ newProjectsContainer.projects = serverSideCachedProjects
+ }
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+const SplitViewWrapper = ({ children }: { children: React.ReactNode }) => {
+ const { project } = useProjectSelection()
+ return (
+
+ {children}
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/(authed)/(home)/new/page.tsx b/src/app/(authed)/(home)/new/page.tsx
new file mode 100644
index 00000000..bdbbd356
--- /dev/null
+++ b/src/app/(authed)/(home)/new/page.tsx
@@ -0,0 +1,56 @@
+import Link from "next/link"
+import { env, splitOwnerAndRepository } from "@/common"
+
+const Page = () => {
+ const repositoryNameSuffix = env.getOrThrow("REPOSITORY_NAME_SUFFIX")
+ const templateName = env.get("NEW_PROJECT_TEMPLATE_REPOSITORY")
+ const projectName = "Nordisk Film"
+ const suffixedRepositoryName = makeFullRepositoryName({
+ name: projectName,
+ suffix: repositoryNameSuffix
+ })
+ const newGitHubRepositoryLink = makeNewGitHubRepositoryLink({
+ templateName,
+ repositoryName: suffixedRepositoryName,
+ description: `Contains OpenAPI specifications for ${projectName}`
+ })
+ return (
+
+ {newGitHubRepositoryLink}
+
+ )
+}
+
+export default Page
+
+function makeFullRepositoryName({ name, suffix }: { name: string, suffix: string }) {
+ const safeRepositoryName = name
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9-]+/g, "")
+ .replace(/\s+/g, "-")
+ return `${safeRepositoryName}${suffix}`
+}
+
+function makeNewGitHubRepositoryLink({
+ templateName,
+ repositoryName,
+ description
+}: {
+ templateName?: string,
+ repositoryName: string,
+ description: string
+}) {
+ let url = `https://github.com/new`
+ + `?name=${encodeURIComponent(repositoryName)}`
+ + `&description=${encodeURIComponent(description)}`
+ + `&visibility=private`
+ if (templateName) {
+ const templateRepository = splitOwnerAndRepository(templateName)
+ if (templateRepository) {
+ url += `&template_owner=${encodeURIComponent(templateRepository.owner)}`
+ url += `&template_name=${encodeURIComponent(templateRepository.repository)}`
+ }
+ }
+ return url
+}
diff --git a/src/app/(authed)/layout.tsx b/src/app/(authed)/layout.tsx
new file mode 100644
index 00000000..bc977cf5
--- /dev/null
+++ b/src/app/(authed)/layout.tsx
@@ -0,0 +1,25 @@
+import { redirect } from "next/navigation"
+import { SessionProvider } from "next-auth/react"
+import { session, projectRepository } from "@/composition"
+import ErrorHandler from "@/common/ui/ErrorHandler"
+import SessionBarrier from "@/features/auth/view/SessionBarrier"
+import ServerSideCachedProjectsProvider from "@/features/projects/view/ServerSideCachedProjectsProvider"
+
+export default async function Layout({ children }: { children: React.ReactNode }) {
+ const isAuthenticated = await session.getIsAuthenticated()
+ if (!isAuthenticated) {
+ return redirect("/api/auth/signin")
+ }
+ const projects = await projectRepository.get()
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/[[...slug]]/page.tsx b/src/app/[[...slug]]/page.tsx
deleted file mode 100644
index 092f7f40..00000000
--- a/src/app/[[...slug]]/page.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { redirect } from "next/navigation"
-import { SessionProvider } from "next-auth/react"
-import { session, projectRepository } from "@/composition"
-import ErrorHandler from "@/common/errors/client/ErrorHandler"
-import SessionBarrier from "@/features/auth/view/SessionBarrier"
-import ProjectsPage from "@/features/projects/view/ProjectsPage"
-
-type PageParams = { slug: string | string[] }
-
-export default async function Page({ params }: { params: PageParams }) {
- const isAuthenticated = await session.getIsAuthenticated()
- if (!isAuthenticated) {
- return redirect("/api/auth/signin")
- }
- return (
-
-
-
-
-
-
-
- )
-}
-
-function getPath(slug: string | string[] | undefined) {
- if (slug === undefined) {
- return "/"
- } else if (typeof slug === "string") {
- return "/" + slug
- } else {
- return slug.reduce((e, acc) => `${e}/${acc}`, "")
- }
-}
diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts
index 1d38a1d9..85cc4d25 100644
--- a/src/app/api/auth/[...nextauth]/route.ts
+++ b/src/app/api/auth/[...nextauth]/route.ts
@@ -1,3 +1,3 @@
-import { auth } from "@/composition"
+import { authHandlers } from "@/composition"
-export const { GET, POST } = auth.handlers
+export const { GET, POST } = authHandlers
diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx
new file mode 100644
index 00000000..97e53ae0
--- /dev/null
+++ b/src/app/auth/signin/page.tsx
@@ -0,0 +1,127 @@
+import Image from "next/image"
+import Link from "next/link"
+import { Box, Button, Stack, Typography } from "@mui/material"
+import { signIn } from "@/composition"
+import { env } from "@/common"
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
+import { faGithub } from "@fortawesome/free-brands-svg-icons"
+import SignInTexts from "@/features/auth/view/SignInTexts"
+
+const SITE_NAME = env.getOrThrow("NEXT_PUBLIC_SHAPE_DOCS_TITLE")
+const HELP_URL = env.get("NEXT_PUBLIC_SHAPE_DOCS_HELP_URL")
+
+export default async function SignInPage() {
+ return (
+
+
+
+
+ )
+}
+
+const InfoColumn = () => {
+ return (
+
+
+
+ )
+}
+
+const SignInColumn = () => {
+ const title = `Get started with ${SITE_NAME}`
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ {title}
+
+
+
+
+
+
+
+
+ )
+}
+
+const SignInWithGitHub = () => {
+ return (
+
+ )
+}
+
+const Footer = () => {
+ return (
+
+ {HELP_URL &&
+
+
+ Learn more about {SITE_NAME}
+
+
+ }
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/globals.css b/src/app/globals.css
index 0a70c413..d9f9059a 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -5,3 +5,22 @@
html, body {
height: 100%;
}
+
+*::-webkit-scrollbar {
+ width: 12px;
+}
+
+*::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+*::-webkit-scrollbar-thumb {
+ background-color: rgba(0, 0, 0, 0.25);
+ border-radius: 6px;
+ border: 3px solid transparent;
+ background-clip: padding-box;
+}
+
+*::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(0, 0, 0, 0.5);
+}
diff --git a/src/common/contexts.ts b/src/common/contexts.ts
new file mode 100644
index 00000000..e455e5b1
--- /dev/null
+++ b/src/common/contexts.ts
@@ -0,0 +1,19 @@
+"use client"
+
+import { createContext } from "react"
+import { Project, } from "@/features/projects/domain"
+
+export const SidebarContext = createContext<{ isToggleable: boolean }>({ isToggleable: true })
+
+type ProjectsContainer = {
+ readonly projects: Project[]
+ readonly isLoading: boolean
+ readonly error?: Error
+}
+
+export const ProjectsContainerContext = createContext({
+ isLoading: true,
+ projects: []
+})
+
+export const ServerSideCachedProjectsContext = createContext(undefined)
diff --git a/src/common/index.ts b/src/common/index.ts
index fdb90059..83b57c0c 100644
--- a/src/common/index.ts
+++ b/src/common/index.ts
@@ -1,3 +1,4 @@
+export * from "./contexts"
export * from "./db"
export * from "./errors"
export * from "./github"
diff --git a/src/common/session/AuthjsSession.ts b/src/common/session/AuthjsSession.ts
index 5d3ff360..5b3686ae 100644
--- a/src/common/session/AuthjsSession.ts
+++ b/src/common/session/AuthjsSession.ts
@@ -1,23 +1,21 @@
-import { NextAuthResult } from "next-auth"
+import { Session } from "next-auth"
import { UnauthorizedError } from "@/common"
import ISession from "./ISession"
export default class AuthjsSession implements ISession {
- private readonly auth: NextAuthResult
+ private readonly auth: () => Promise
- constructor(config: { auth: NextAuthResult }) {
+ constructor(config: { auth: () => Promise }) {
this.auth = config.auth
}
async getIsAuthenticated(): Promise {
- const { auth } = this.auth
- const session = await auth()
+ const session = await this.auth()
return session != null
}
async getUserId(): Promise {
- const { auth } = this.auth
- const session = await auth()
+ const session = await this.auth()
if (!session || !session.user || !session.user.id) {
throw new UnauthorizedError("User ID is unavailable because the user is not authenticated.")
}
diff --git a/src/common/state/useSidebarOpen.tsx b/src/common/state/useSidebarOpen.tsx
deleted file mode 100644
index 2d0c3b9e..00000000
--- a/src/common/state/useSidebarOpen.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { useSessionStorage } from "usehooks-ts"
-
-export default function useSidebarOpen() {
- return useSessionStorage("isSidebarOpen", true)
-}
\ No newline at end of file
diff --git a/src/common/theme/ThemeRegistry.tsx b/src/common/theme/ThemeRegistry.tsx
index ffb45ab7..95dbeadd 100644
--- a/src/common/theme/ThemeRegistry.tsx
+++ b/src/common/theme/ThemeRegistry.tsx
@@ -1,6 +1,6 @@
"use client"
-import { ReactNode, useState } from "react"
+import { useState } from "react"
import createCache, { Options } from "@emotion/cache"
import { useServerInsertedHTML } from "next/navigation"
import { CacheProvider } from "@emotion/react"
@@ -10,7 +10,7 @@ import theme from "./theme"
type ThemeRegistryProps = {
options: Options
- children: ReactNode
+ children: React.ReactNode
}
// This implementation is from emotion-js
diff --git a/src/common/loading/DelayedLoadingIndicator.tsx b/src/common/ui/DelayedLoadingIndicator.tsx
similarity index 82%
rename from src/common/loading/DelayedLoadingIndicator.tsx
rename to src/common/ui/DelayedLoadingIndicator.tsx
index ce90d90b..74071f9e 100644
--- a/src/common/loading/DelayedLoadingIndicator.tsx
+++ b/src/common/ui/DelayedLoadingIndicator.tsx
@@ -1,11 +1,9 @@
+"use client"
+
import { useState, useEffect } from "react"
import LoadingIndicator from "./LoadingIndicator"
-const DelayedLoadingIndicator = ({
- delay
-}: {
- delay?: number
-}) => {
+const DelayedLoadingIndicator = ({ delay }: { delay?: number }) => {
const [isVisible, setVisible] = useState(false)
useEffect(() => {
const timer = setTimeout(() => {
diff --git a/src/common/errors/client/ErrorHandler.tsx b/src/common/ui/ErrorHandler.tsx
similarity index 81%
rename from src/common/errors/client/ErrorHandler.tsx
rename to src/common/ui/ErrorHandler.tsx
index 7e928289..d206fa4b 100644
--- a/src/common/errors/client/ErrorHandler.tsx
+++ b/src/common/ui/ErrorHandler.tsx
@@ -3,11 +3,7 @@
import { SWRConfig } from "swr"
import { FetcherError } from "@/common"
-export default function ErrorHandler({
- children
-}: {
- children: React.ReactNode
-}) {
+export default function ErrorHandler({ children }: { children: React.ReactNode }) {
const onSWRError = (error: FetcherError) => {
if (typeof window === "undefined") {
return
diff --git a/src/features/projects/view/ErrorMessage.tsx b/src/common/ui/ErrorMessage.tsx
similarity index 100%
rename from src/features/projects/view/ErrorMessage.tsx
rename to src/common/ui/ErrorMessage.tsx
diff --git a/src/common/loading/LoadingIndicator.tsx b/src/common/ui/LoadingIndicator.tsx
similarity index 98%
rename from src/common/loading/LoadingIndicator.tsx
rename to src/common/ui/LoadingIndicator.tsx
index dc464219..f56dd4d8 100644
--- a/src/common/loading/LoadingIndicator.tsx
+++ b/src/common/ui/LoadingIndicator.tsx
@@ -1,3 +1,5 @@
+"use client"
+
import { useState, useEffect } from "react"
import { Box, Typography } from "@mui/material"
import { useTheme } from "@mui/material/styles"
diff --git a/src/common/ui/MenuItemHover.tsx b/src/common/ui/MenuItemHover.tsx
index 0326248e..d627a980 100644
--- a/src/common/ui/MenuItemHover.tsx
+++ b/src/common/ui/MenuItemHover.tsx
@@ -1,4 +1,3 @@
-import { ReactNode } from "react"
import { SxProps } from "@mui/system"
import { Box } from "@mui/material"
import useMediaQuery from "@mui/material/useMediaQuery"
@@ -9,7 +8,7 @@ const MenuItemHover = ({
sx
}: {
disabled?: boolean
- children: ReactNode
+ children: React.ReactNode
sx?: SxProps
}) => {
const isHoverSupported = useMediaQuery("(hover: hover)")
diff --git a/src/common/ui/ThickDivider.tsx b/src/common/ui/ThickDivider.tsx
index f73cf914..1dc1da3c 100644
--- a/src/common/ui/ThickDivider.tsx
+++ b/src/common/ui/ThickDivider.tsx
@@ -2,11 +2,7 @@ import { SxProps } from "@mui/system"
import { Box } from "@mui/material"
import { useTheme } from "@mui/material/styles"
-const ThickDivider = ({
- sx
-}: {
- sx?: SxProps
-}) => {
+const ThickDivider = ({ sx }: { sx?: SxProps }) => {
const theme = useTheme()
return (
{
- return window.navigator.userAgent.toLowerCase().includes("mac")
+ return window.navigator.userAgent.toLowerCase().includes("mac")
}
export default isMac
\ No newline at end of file
diff --git a/src/common/utils/splitOwnerAndRepository.ts b/src/common/utils/splitOwnerAndRepository.ts
new file mode 100644
index 00000000..4bd3a96a
--- /dev/null
+++ b/src/common/utils/splitOwnerAndRepository.ts
@@ -0,0 +1,16 @@
+// Split full repository names into owner and repository.
+// shapehq/foo becomes { owner: "shapehq", "repository": "foo" }
+const splitOwnerAndRepository = (str: string) => {
+ const index = str.indexOf("/")
+ if (index === -1) {
+ return undefined
+ }
+ const owner = str.substring(0, index)
+ const repository = str.substring(index + 1)
+ if (owner.length == 0 || repository.length == 0) {
+ return undefined
+ }
+ return { owner, repository }
+}
+
+export default splitOwnerAndRepository
diff --git a/src/composition.ts b/src/composition.ts
index 0fdeb7e0..7284f036 100644
--- a/src/composition.ts
+++ b/src/composition.ts
@@ -17,10 +17,12 @@ import {
} from "@/common"
import {
GitHubLoginDataSource,
- GitHubProjectDataSource
+ GitHubProjectDataSource,
+ GitHubRepositoryDataSource
} from "@/features/projects/data"
import {
CachingProjectDataSource,
+ FilteringGitHubRepositoryDataSource,
ProjectRepository
} from "@/features/projects/domain"
import {
@@ -77,7 +79,7 @@ const oauthTokenRepository = new FallbackOAuthTokenRepository({
const logInHandler = new LogInHandler({ oauthTokenRepository })
-export const auth = NextAuth({
+export const { signIn, auth, handlers: authHandlers } = NextAuth({
adapter: PostgresAdapter(pool),
secret: env.getOrThrow("NEXTAUTH_SECRET"),
theme: {
@@ -85,6 +87,9 @@ export const auth = NextAuth({
colorScheme: "light",
brandColor: "black"
},
+ pages: {
+ signIn: "/auth/signin"
+ },
providers: [
GithubProvider({
clientId: env.getOrThrow("GITHUB_CLIENT_ID"),
@@ -157,12 +162,18 @@ export const projectRepository = new ProjectRepository({
export const projectDataSource = new CachingProjectDataSource({
dataSource: new GitHubProjectDataSource({
- loginsDataSource: new GitHubLoginDataSource({
- graphQlClient: userGitHubClient
+ repositoryDataSource: new FilteringGitHubRepositoryDataSource({
+ hiddenRepositories: listFromCommaSeparatedString(env.get("HIDDEN_REPOSITORIES")),
+ dataSource: new GitHubRepositoryDataSource({
+ loginsDataSource: new GitHubLoginDataSource({
+ graphQlClient: userGitHubClient
+ }),
+ graphQlClient: userGitHubClient,
+ repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"),
+ projectConfigurationFilename: env.getOrThrow("SHAPE_DOCS_PROJECT_CONFIGURATION_FILENAME")
+ })
}),
- graphQlClient: userGitHubClient,
- repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"),
- projectConfigurationFilename: env.getOrThrow("SHAPE_DOCS_PROJECT_CONFIGURATION_FILENAME")
+ repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX")
}),
repository: projectRepository
})
diff --git a/src/features/auth/view/SessionBarrier.tsx b/src/features/auth/view/SessionBarrier.tsx
index 8f1fdf0d..2e5e02ad 100644
--- a/src/features/auth/view/SessionBarrier.tsx
+++ b/src/features/auth/view/SessionBarrier.tsx
@@ -1,16 +1,17 @@
-import { ReactNode } from "react"
+import { redirect } from "next/navigation"
import { blockingSessionValidator } from "@/composition"
-import ClientSessionBarrier from "./client/SessionBarrier"
+import { SessionValidity } from "../domain"
export default async function SessionBarrier({
children
}: {
- children: ReactNode
+ children: React.ReactNode
}) {
const sessionValidity = await blockingSessionValidator.validateSession()
- return (
-
- {children}
-
- )
+ switch (sessionValidity) {
+ case SessionValidity.VALID:
+ return <>{children}>
+ case SessionValidity.INVALID_ACCESS_TOKEN:
+ return redirect("/api/auth/signout")
+ }
}
diff --git a/src/features/auth/view/SignInTexts.tsx b/src/features/auth/view/SignInTexts.tsx
new file mode 100644
index 00000000..602ce70f
--- /dev/null
+++ b/src/features/auth/view/SignInTexts.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import { Box, Typography, SxProps } from "@mui/material"
+import { useEffect, useState, useMemo } from "react"
+
+const SignInTexts = () => {
+ const getRandomTextColor = ({ excluding }: { excluding?: string }) => {
+ const colors = ["#01BBFE", "#00AE47", "#FCB23D"]
+ .filter(e => e !== excluding)
+ return colors[Math.floor(Math.random() * colors.length)]
+ }
+ const [characterIndex, setCharacterIndex] = useState(0)
+ const [textIndex, setTextIndex] = useState(0)
+ const [displayedText, setDisplayedText] = useState("")
+ const [textColor, setTextColor] = useState(getRandomTextColor({}))
+ const texts = useMemo(() => [
+ "is a great OpenAPI viewer",
+ "facilitates spec-driven development",
+ "puts your documentation in one place",
+ "adds documentation previews to pull requests"
+ ], [])
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setDisplayedText("")
+ setCharacterIndex(0)
+ setTextColor(getRandomTextColor({ excluding: textColor }))
+ setTextIndex((prevIndex) => (prevIndex + 1) % texts.length)
+ }, 5000)
+ return () => clearInterval(interval)
+ }, [texts.length, textColor])
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setCharacterIndex(characterIndex + 1)
+ setDisplayedText(texts[textIndex].substring(0, characterIndex))
+ if (characterIndex === texts[textIndex].length) {
+ clearInterval(interval)
+ }
+ }, 50)
+ return () => clearInterval(interval)
+ }, [texts, textIndex, characterIndex])
+ const longestText = texts.reduce((a, b) => (a.length > b.length ? a : b))
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export default SignInTexts
+
+const Text = ({
+ text,
+ textColor,
+ children,
+ sx
+}: {
+ text: string,
+ textColor?: string,
+ children?: React.ReactNode,
+ sx?: SxProps
+}) => {
+ return (
+
+ Shape Docs {text}
+ {children}
+
+ )
+}
diff --git a/src/features/auth/view/client/InvalidSessionPage.tsx b/src/features/auth/view/client/InvalidSessionPage.tsx
deleted file mode 100644
index 8a796a46..00000000
--- a/src/features/auth/view/client/InvalidSessionPage.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-"use client"
-
-import { ReactNode } from "react"
-import { Stack, Typography } from "@mui/material"
-import SidebarContainer from "@/features/sidebar/view/client/SidebarContainer"
-import useSidebarOpen from "@/common/state/useSidebarOpen"
-
-export default function InvalidSessionPage({
- title,
- children
-}: {
- title?: ReactNode
- children?: ReactNode
-}) {
- const [isSidebarOpen, setSidebarOpen] = useSidebarOpen()
- return (
-
-
-
- {title &&
-
- {title}
-
- }
-
- {children}
-
-
-
-
- )
-}
diff --git a/src/features/auth/view/client/SessionBarrier.tsx b/src/features/auth/view/client/SessionBarrier.tsx
deleted file mode 100644
index 55408cfd..00000000
--- a/src/features/auth/view/client/SessionBarrier.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-"use client"
-
-import { ReactNode } from "react"
-import { SessionValidity } from "../../domain"
-import InvalidSessionPage from "./InvalidSessionPage"
-
-export default function SessionBarrier({
- sessionValidity,
- children
-}: {
- sessionValidity: SessionValidity
- children: ReactNode
-}) {
- switch (sessionValidity) {
- case SessionValidity.VALID:
- return <>{children}>
- case SessionValidity.INVALID_ACCESS_TOKEN:
- return (
-
- It was not possible to obtain access to the repositories on GitHub.
-
- )
- }
-}
diff --git a/src/features/auth/view/client/SessionProvider.tsx b/src/features/auth/view/client/SessionProvider.tsx
deleted file mode 100644
index 37c3aee7..00000000
--- a/src/features/auth/view/client/SessionProvider.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-"use client"
-
-import { SessionProvider as NextAuthSessionProvider } from "next-auth/react"
-
-export default function SessionProvider({
- children
-}: {
- children: React.ReactNode
-}) {
- return (
-
- {children}
-
- )
-}
diff --git a/src/features/docs/view/LoadingWrapper.tsx b/src/features/docs/view/LoadingWrapper.tsx
index 65c70157..0a86e04b 100644
--- a/src/features/docs/view/LoadingWrapper.tsx
+++ b/src/features/docs/view/LoadingWrapper.tsx
@@ -1,13 +1,12 @@
-import { ReactNode } from "react"
import { Box } from "@mui/material"
-import LoadingIndicator from "@/common/loading/LoadingIndicator"
+import LoadingIndicator from "@/common/ui/LoadingIndicator"
const LoadingWrapper = ({
showLoadingIndicator,
children
}: {
showLoadingIndicator: boolean,
- children: ReactNode
+ children: React.ReactNode
}) => {
return (
{
const [isLoading, setLoading] = useState(true)
return (
- setLoading(false)} deepLinking />
+ setLoading(false)} deepLinking persistAuthorization />
)
}
diff --git a/src/features/projects/data/GitHubLoginDataSource.ts b/src/features/projects/data/GitHubLoginDataSource.ts
index 3d60d1bf..b71c254a 100644
--- a/src/features/projects/data/GitHubLoginDataSource.ts
+++ b/src/features/projects/data/GitHubLoginDataSource.ts
@@ -1,5 +1,4 @@
-import IGitHubLoginDataSource from "./IGitHubLoginDataSource"
-import IGitHubGraphQLClient from "./IGitHubGraphQLClient"
+import { IGitHubLoginDataSource, IGitHubGraphQLClient } from "../domain"
export default class GitHubLoginDataSource implements IGitHubLoginDataSource {
private readonly graphQlClient: IGitHubGraphQLClient
@@ -33,7 +32,8 @@ export default class GitHubLoginDataSource implements IGitHubLoginDataSource {
throw new Error("organizations property not found on viewer in response")
}
const viewer = response.viewer
- const organizations = viewer.organizations.nodes.map((e: { login: string }) => e.login)
- return [viewer.login].concat(organizations)
+ const organizationLogins = viewer.organizations.nodes
+ .map((e: { login: string }) => e.login)
+ return [viewer.login].concat(organizationLogins)
}
}
diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts
index 848c4859..bc79fe25 100644
--- a/src/features/projects/data/GitHubProjectDataSource.ts
+++ b/src/features/projects/data/GitHubProjectDataSource.ts
@@ -1,38 +1,29 @@
-import GitHubProjectRepository, {
- GitHubProjectRepositoryRef
-} from "./GitHubProjectRepository"
-import IGitHubLoginDataSource from "./IGitHubLoginDataSource"
-import IGitHubGraphQLClient from "./IGitHubGraphQLClient"
import {
Project,
Version,
IProjectConfig,
IProjectDataSource,
ProjectConfigParser,
- ProjectConfigRemoteVersion
+ ProjectConfigRemoteVersion,
+ IGitHubRepositoryDataSource,
+ GitHubRepository,
+ GitHubRepositoryRef
} from "../domain"
export default class GitHubProjectDataSource implements IProjectDataSource {
- private readonly loginsDataSource: IGitHubLoginDataSource
- private readonly graphQlClient: IGitHubGraphQLClient
+ private readonly repositoryDataSource: IGitHubRepositoryDataSource
private readonly repositoryNameSuffix: string
- private readonly projectConfigurationFilename: string
constructor(config: {
- loginsDataSource: IGitHubLoginDataSource,
- graphQlClient: IGitHubGraphQLClient,
- repositoryNameSuffix: string,
- projectConfigurationFilename: string
+ repositoryDataSource: IGitHubRepositoryDataSource
+ repositoryNameSuffix: string
}) {
- this.loginsDataSource = config.loginsDataSource
- this.graphQlClient = config.graphQlClient
+ this.repositoryDataSource = config.repositoryDataSource
this.repositoryNameSuffix = config.repositoryNameSuffix
- this.projectConfigurationFilename = config.projectConfigurationFilename.replace(/\.ya?ml$/, "")
}
async getProjects(): Promise {
- const logins = await this.loginsDataSource.getLogins()
- const repositories = await this.getRepositories({ logins })
+ const repositories = await this.repositoryDataSource.getRepositories()
return repositories.map(repository => {
return this.mapProject(repository)
})
@@ -44,15 +35,15 @@ export default class GitHubProjectDataSource implements IProjectDataSource {
})
}
- private mapProject(repository: GitHubProjectRepository): Project {
+ private mapProject(repository: GitHubRepository): Project {
const config = this.getConfig(repository)
let imageURL: string | undefined
if (config && config.image) {
imageURL = this.getGitHubBlobURL({
- ownerName: repository.owner.login,
+ ownerName: repository.owner,
repositoryName: repository.name,
path: config.image,
- ref: repository.defaultBranchRef.target.oid
+ ref: repository.defaultBranchRef.id
})
}
const versions = this.sortVersions(
@@ -66,18 +57,18 @@ export default class GitHubProjectDataSource implements IProjectDataSource {
})
const defaultName = repository.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "")
return {
- id: `${repository.owner.login}-${defaultName}`,
- owner: repository.owner.login,
+ id: `${repository.owner}-${defaultName}`,
+ owner: repository.owner,
name: defaultName,
displayName: config?.name || defaultName,
versions,
imageURL: imageURL,
- ownerUrl: `https://github.com/${repository.owner.login}`,
- url: `https://github.com/${repository.owner.login}/${repository.name}`
+ ownerUrl: `https://github.com/${repository.owner}`,
+ url: `https://github.com/${repository.owner}/${repository.name}`
}
}
- private getConfig(repository: GitHubProjectRepository): IProjectConfig | null {
+ private getConfig(repository: GitHubRepository): IProjectConfig | null {
const yml = repository.configYml || repository.configYaml
if (!yml || !yml.text || yml.text.length == 0) {
return null
@@ -86,21 +77,21 @@ export default class GitHubProjectDataSource implements IProjectDataSource {
return parser.parse(yml.text)
}
- private getVersions(repository: GitHubProjectRepository): Version[] {
- const branchVersions = repository.branches.edges.map(edge => {
- const isDefaultRef = edge.node.name == repository.defaultBranchRef.name
+ private getVersions(repository: GitHubRepository): Version[] {
+ const branchVersions = repository.branches.map(branch => {
+ const isDefaultRef = branch.name == repository.defaultBranchRef.name
return this.mapVersionFromRef({
- ownerName: repository.owner.login,
+ ownerName: repository.owner,
repositoryName: repository.name,
- ref: edge.node,
+ ref: branch,
isDefaultRef
})
})
- const tagVersions = repository.tags.edges.map(edge => {
+ const tagVersions = repository.tags.map(tag => {
return this.mapVersionFromRef({
- ownerName: repository.owner.login,
+ ownerName: repository.owner,
repositoryName: repository.name,
- ref: edge.node
+ ref: tag
})
})
return branchVersions.concat(tagVersions)
@@ -114,10 +105,10 @@ export default class GitHubProjectDataSource implements IProjectDataSource {
}: {
ownerName: string
repositoryName: string
- ref: GitHubProjectRepositoryRef
+ ref: GitHubRepositoryRef
isDefaultRef?: boolean
}): Version {
- const specifications = ref.target.tree.entries.filter(file => {
+ const specifications = ref.files.filter(file => {
return this.isOpenAPISpecification(file.name)
}).map(file => {
return {
@@ -127,7 +118,7 @@ export default class GitHubProjectDataSource implements IProjectDataSource {
ownerName,
repositoryName,
path: file.name,
- ref: ref.target.oid
+ ref: ref.id
}),
editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${file.name}`
}
@@ -221,128 +212,4 @@ export default class GitHubProjectDataSource implements IProjectDataSource {
.replace(/ /g, "-")
.replace(/[^A-Za-z0-9-]/g, "")
}
-
- private async getRepositories({ logins }: { logins: string[] }): Promise {
- let searchQueries: string[] = []
- // Search for all private repositories the user has access to. This is needed to find
- // repositories for external collaborators who do not belong to an organization.
- searchQueries.push(`"${this.repositoryNameSuffix}" in:name is:private`)
- // Search for public repositories belonging to a user or organization.
- searchQueries = searchQueries.concat(logins.map(login => {
- return `"${this.repositoryNameSuffix}" in:name user:${login} is:public`
- }))
- return await Promise.all(searchQueries.map(searchQuery => {
- return this.getRepositoriesForSearchQuery({ searchQuery })
- }))
- .then(e => e.flat())
- .then(repositories => {
- // GitHub's search API does not enable searching for repositories whose name ends with "-openapi",
- // only repositories whose names include "openapi" so we filter the results ourselves.
- return repositories.filter(repository => {
- return repository.name.endsWith(this.repositoryNameSuffix)
- })
- })
- .then(repositories => {
- // Ensure we don't have duplicates in the resulting repositories.
- const uniqueIdentifiers = new Set()
- return repositories.filter(repository => {
- const identifier = `${repository.owner.login}-${repository.name}`
- const alreadyAdded = uniqueIdentifiers.has(identifier)
- uniqueIdentifiers.add(identifier)
- return !alreadyAdded
- })
- })
- }
-
- private async getRepositoriesForSearchQuery(params: {
- searchQuery: string,
- cursor?: string
- }): Promise {
- const { searchQuery, cursor } = params
- const request = {
- query: `
- query Repositories($searchQuery: String!, $cursor: String) {
- search(query: $searchQuery, type: REPOSITORY, first: 100, after: $cursor) {
- results: nodes {
- ... on Repository {
- name
- owner {
- login
- }
- defaultBranchRef {
- name
- target {
- ...on Commit {
- oid
- }
- }
- }
- configYml: object(expression: "HEAD:${this.projectConfigurationFilename}.yml") {
- ...ConfigParts
- }
- configYaml: object(expression: "HEAD:${this.projectConfigurationFilename}.yaml") {
- ...ConfigParts
- }
- branches: refs(refPrefix: "refs/heads/", first: 100) {
- ...RefConnectionParts
- }
- tags: refs(refPrefix: "refs/tags/", first: 100) {
- ...RefConnectionParts
- }
- }
- }
-
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
-
- 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, cursor }
- }
- const response = await this.graphQlClient.graphql(request)
- if (!response.search || !response.search.results) {
- return []
- }
- const pageInfo = response.search.pageInfo
- if (!pageInfo) {
- return response.search.results
- }
- if (!pageInfo.hasNextPage || !pageInfo.endCursor) {
- return response.search.results
- }
- const nextResults = await this.getRepositoriesForSearchQuery({
- searchQuery,
- cursor: pageInfo.endCursor
- })
- return response.search.results.concat(nextResults)
- }
}
diff --git a/src/features/projects/data/GitHubProjectRepository.ts b/src/features/projects/data/GitHubProjectRepository.ts
deleted file mode 100644
index 20b88af6..00000000
--- a/src/features/projects/data/GitHubProjectRepository.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-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/GitHubRepositoryDataSource.ts b/src/features/projects/data/GitHubRepositoryDataSource.ts
new file mode 100644
index 00000000..569a4bd1
--- /dev/null
+++ b/src/features/projects/data/GitHubRepositoryDataSource.ts
@@ -0,0 +1,224 @@
+import {
+ GitHubRepository,
+ IGitHubRepositoryDataSource,
+ IGitHubLoginDataSource,
+ IGitHubGraphQLClient
+} from "../domain"
+
+type GraphQLGitHubRepository = {
+ 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 GraphQLGitHubRepositoryRef = {
+ readonly name: string
+ readonly target: {
+ readonly oid: string
+ readonly tree: {
+ readonly entries: {
+ readonly name: string
+ }[]
+ }
+ }
+}
+
+export default class GitHubProjectDataSource implements IGitHubRepositoryDataSource {
+ private readonly loginsDataSource: IGitHubLoginDataSource
+ private readonly graphQlClient: IGitHubGraphQLClient
+ private readonly repositoryNameSuffix: string
+ private readonly projectConfigurationFilename: string
+
+ constructor(config: {
+ loginsDataSource: IGitHubLoginDataSource,
+ graphQlClient: IGitHubGraphQLClient,
+ repositoryNameSuffix: string,
+ projectConfigurationFilename: string
+ }) {
+ this.loginsDataSource = config.loginsDataSource
+ this.graphQlClient = config.graphQlClient
+ this.repositoryNameSuffix = config.repositoryNameSuffix
+ this.projectConfigurationFilename = config.projectConfigurationFilename.replace(/\.ya?ml$/, "")
+ }
+
+ async getRepositories(): Promise {
+ const logins = await this.loginsDataSource.getLogins()
+ return await this.getRepositoriesForLogins({ logins })
+ }
+
+ private async getRepositoriesForLogins({ logins }: { logins: string[] }): Promise {
+ let searchQueries: string[] = []
+ // Search for all private repositories the user has access to. This is needed to find
+ // repositories for external collaborators who do not belong to an organization.
+ searchQueries.push(`"${this.repositoryNameSuffix}" in:name is:private`)
+ // Search for public repositories belonging to a user or organization.
+ searchQueries = searchQueries.concat(logins.map(login => {
+ return `"${this.repositoryNameSuffix}" in:name user:${login} is:public`
+ }))
+ return await Promise.all(searchQueries.map(searchQuery => {
+ return this.getRepositoriesForSearchQuery({ searchQuery })
+ }))
+ .then(e => e.flat())
+ .then(repositories => {
+ // GitHub's search API does not enable searching for repositories whose name ends with "-openapi",
+ // only repositories whose names include "openapi" so we filter the results ourselves.
+ return repositories.filter(repository => {
+ return repository.name.endsWith(this.repositoryNameSuffix)
+ })
+ })
+ .then(repositories => {
+ // Ensure we don't have duplicates in the resulting repositories.
+ const uniqueIdentifiers = new Set()
+ return repositories.filter(repository => {
+ const identifier = `${repository.owner.login}-${repository.name}`
+ const alreadyAdded = uniqueIdentifiers.has(identifier)
+ uniqueIdentifiers.add(identifier)
+ return !alreadyAdded
+ })
+ })
+ .then(repositories => {
+ // Map from the internal model to the public model.
+ return repositories.map(repository => {
+ return {
+ name: repository.name,
+ owner: repository.owner.login,
+ defaultBranchRef: {
+ id: repository.defaultBranchRef.target.oid,
+ name: repository.defaultBranchRef.name
+ },
+ configYml: repository.configYml,
+ configYaml: repository.configYaml,
+ branches: repository.branches.edges.map(branch => {
+ return {
+ id: branch.node.target.oid,
+ name: branch.node.name,
+ files: branch.node.target.tree.entries
+ }
+ }),
+ tags: repository.tags.edges.map(branch => {
+ return {
+ id: branch.node.target.oid,
+ name: branch.node.name,
+ files: branch.node.target.tree.entries
+ }
+ })
+ }
+ })
+ })
+ }
+
+ private async getRepositoriesForSearchQuery(params: {
+ searchQuery: string,
+ cursor?: string
+ }): Promise {
+ const { searchQuery, cursor } = params
+ const request = {
+ query: `
+ query Repositories($searchQuery: String!, $cursor: String) {
+ search(query: $searchQuery, type: REPOSITORY, first: 100, after: $cursor) {
+ results: nodes {
+ ... on Repository {
+ name
+ owner {
+ login
+ }
+ defaultBranchRef {
+ name
+ target {
+ ...on Commit {
+ oid
+ }
+ }
+ }
+ configYml: object(expression: "HEAD:${this.projectConfigurationFilename}.yml") {
+ ...ConfigParts
+ }
+ configYaml: object(expression: "HEAD:${this.projectConfigurationFilename}.yaml") {
+ ...ConfigParts
+ }
+ branches: refs(refPrefix: "refs/heads/", first: 100) {
+ ...RefConnectionParts
+ }
+ tags: refs(refPrefix: "refs/tags/", first: 100) {
+ ...RefConnectionParts
+ }
+ }
+ }
+
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+ }
+
+ 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, cursor }
+ }
+ const response = await this.graphQlClient.graphql(request)
+ if (!response.search || !response.search.results) {
+ return []
+ }
+ const pageInfo = response.search.pageInfo
+ if (!pageInfo) {
+ return response.search.results
+ }
+ if (!pageInfo.hasNextPage || !pageInfo.endCursor) {
+ return response.search.results
+ }
+ const nextResults = await this.getRepositoriesForSearchQuery({
+ searchQuery,
+ cursor: pageInfo.endCursor
+ })
+ return response.search.results.concat(nextResults)
+ }
+}
diff --git a/src/features/projects/data/index.ts b/src/features/projects/data/index.ts
index cf44de31..affecff1 100644
--- a/src/features/projects/data/index.ts
+++ b/src/features/projects/data/index.ts
@@ -1,6 +1,6 @@
export { default as GitHubProjectDataSource } from "./GitHubProjectDataSource"
export * from "./GitHubProjectDataSource"
export { default as useProjects } from "./useProjects"
-export type { default as IGitHubLoginDataSource } from "./IGitHubLoginDataSource"
+export { default as useProjectSelection } from "./useProjectSelection"
export { default as GitHubLoginDataSource } from "./GitHubLoginDataSource"
-export * from "./IGitHubGraphQLClient"
+export { default as GitHubRepositoryDataSource } from "./GitHubRepositoryDataSource"
diff --git a/src/features/projects/data/useProjectSelection.ts b/src/features/projects/data/useProjectSelection.ts
new file mode 100644
index 00000000..6c9229e1
--- /dev/null
+++ b/src/features/projects/data/useProjectSelection.ts
@@ -0,0 +1,71 @@
+"use client"
+
+import { useRouter, usePathname } from "next/navigation"
+import { useContext } from "react"
+import useMediaQuery from "@mui/material/useMediaQuery"
+import { useTheme } from "@mui/material/styles"
+import { ProjectsContainerContext } from "@/common"
+import { Project, ProjectNavigator, getProjectSelectionFromPath } from "../domain"
+import { useSidebarOpen } from "@/features/sidebar/data"
+
+export default function useProjectSelection() {
+ const router = useRouter()
+ const pathname = usePathname()
+ const { projects } = useContext(ProjectsContainerContext)
+ const selection = getProjectSelectionFromPath({ projects, path: pathname })
+ const pathnameReader = {
+ get pathname() {
+ return pathname
+ }
+ }
+ const projectNavigator = new ProjectNavigator({ router, pathnameReader })
+ const [, setSidebarOpen] = useSidebarOpen()
+ const theme = useTheme()
+ const isDesktopLayout = useMediaQuery(theme.breakpoints.up("sm"))
+ return {
+ get project() {
+ return selection.project
+ },
+ get version() {
+ return selection.version
+ },
+ get specification() {
+ return selection.specification
+ },
+ selectProject: (project: Project) => {
+ if (!isDesktopLayout) {
+ setSidebarOpen(false)
+ }
+ const version = project.versions[0]
+ const specification = version.specifications[0]
+ projectNavigator.navigate(
+ project.owner,
+ project.name,
+ version.id,
+ specification.id
+ )
+ },
+ selectVersion: (versionId: string) => {
+ projectNavigator.navigateToVersion(
+ selection.project!,
+ versionId,
+ selection.specification!.name
+ )
+ },
+ selectSpecification: (specificationId: string) => {
+ projectNavigator.navigate(
+ selection.project!.owner,
+ selection.project!.name,
+ selection.version!.id, specificationId
+ )
+ },
+ navigateToSelectionIfNeeded: () => {
+ projectNavigator.navigateIfNeeded({
+ projectOwner: selection.project?.owner,
+ projectName: selection.project?.name,
+ versionId: selection.version?.id,
+ specificationId: selection.specification?.id
+ })
+ }
+ }
+}
diff --git a/src/features/projects/domain/FilteringGitHubRepositoryDataSource.ts b/src/features/projects/domain/FilteringGitHubRepositoryDataSource.ts
new file mode 100644
index 00000000..647770c6
--- /dev/null
+++ b/src/features/projects/domain/FilteringGitHubRepositoryDataSource.ts
@@ -0,0 +1,30 @@
+import IGitHubRepositoryDataSource, {
+ GitHubRepository
+} from "./IGitHubRepositoryDataSource"
+import { splitOwnerAndRepository } from "@/common"
+
+export default class FilteringGitHubRepositoryDataSource implements IGitHubRepositoryDataSource {
+ private readonly dataSource: IGitHubRepositoryDataSource
+ private readonly rawHiddenRepositories: string[]
+
+ constructor(config: {
+ dataSource: IGitHubRepositoryDataSource,
+ hiddenRepositories: string[]
+ }) {
+ this.dataSource = config.dataSource
+ this.rawHiddenRepositories = config.hiddenRepositories
+ }
+
+ async getRepositories(): Promise {
+ const repositories = await this.dataSource.getRepositories()
+ const hiddenRepositories = this.rawHiddenRepositories
+ .map(splitOwnerAndRepository)
+ .filter(e => e !== undefined)
+ return repositories.filter(repository => {
+ const hiddenMatch = hiddenRepositories.find(e =>
+ e.owner == repository.owner && e.repository == repository.name
+ )
+ return hiddenMatch === undefined
+ })
+ }
+}
\ No newline at end of file
diff --git a/src/features/projects/data/IGitHubGraphQLClient.ts b/src/features/projects/domain/IGitHubGraphQLClient.ts
similarity index 100%
rename from src/features/projects/data/IGitHubGraphQLClient.ts
rename to src/features/projects/domain/IGitHubGraphQLClient.ts
diff --git a/src/features/projects/data/IGitHubLoginDataSource.ts b/src/features/projects/domain/IGitHubLoginDataSource.ts
similarity index 100%
rename from src/features/projects/data/IGitHubLoginDataSource.ts
rename to src/features/projects/domain/IGitHubLoginDataSource.ts
diff --git a/src/features/projects/domain/IGitHubRepositoryDataSource.ts b/src/features/projects/domain/IGitHubRepositoryDataSource.ts
new file mode 100644
index 00000000..b48ff43e
--- /dev/null
+++ b/src/features/projects/domain/IGitHubRepositoryDataSource.ts
@@ -0,0 +1,32 @@
+export type GitHubRepository = {
+ readonly name: string
+ readonly owner: string
+ readonly defaultBranchRef: {
+ readonly id: string
+ readonly name: string
+ }
+ readonly configYml?: {
+ readonly text: string
+ }
+ readonly configYaml?: {
+ readonly text: string
+ }
+ readonly branches: GitHubRepositoryRef[]
+ readonly tags: GitHubRepositoryRef[]
+}
+
+export type GitHubRepositoryRef = {
+ readonly id: string
+ readonly name: string
+ readonly files: {
+ readonly name: string
+ }[]
+}
+
+export default interface IGitHubRepositoryDataSource {
+ getRepositories(): Promise
+}
+
+export default interface IGitHubRepositoryDataSource {
+ getRepositories(): Promise
+}
diff --git a/src/features/projects/domain/getSelection.ts b/src/features/projects/domain/getProjectSelectionFromPath.ts
similarity index 98%
rename from src/features/projects/domain/getSelection.ts
rename to src/features/projects/domain/getProjectSelectionFromPath.ts
index 087dc6f9..e98307eb 100644
--- a/src/features/projects/domain/getSelection.ts
+++ b/src/features/projects/domain/getProjectSelectionFromPath.ts
@@ -2,7 +2,7 @@ import Project from "./Project"
import Version from "./Version"
import OpenApiSpecification from "./OpenApiSpecification"
-export default function getSelection({
+export default function getProjectSelectionFromPath({
projects,
path
}: {
diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts
index bfce5ee2..2b3f10cf 100644
--- a/src/features/projects/domain/index.ts
+++ b/src/features/projects/domain/index.ts
@@ -1,5 +1,11 @@
export { default as CachingProjectDataSource } from "./CachingProjectDataSource"
-export { default as getSelection } from "./getSelection"
+export { default as FilteringGitHubRepositoryDataSource } from "./FilteringGitHubRepositoryDataSource"
+export { default as getProjectSelectionFromPath } from "./getProjectSelectionFromPath"
+export type { default as IGitHubLoginDataSource } from "./IGitHubLoginDataSource"
+export type { default as IGitHubRepositoryDataSource } from "./IGitHubRepositoryDataSource"
+export * from "./IGitHubRepositoryDataSource"
+export type { default as IGitHubGraphQLClient } from "./IGitHubGraphQLClient"
+export * from "./IGitHubGraphQLClient"
export type { default as IProjectConfig } from "./IProjectConfig"
export * from "./IProjectConfig"
export type { default as IProjectDataSource } from "./IProjectDataSource"
@@ -10,4 +16,3 @@ 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
deleted file mode 100644
index ae2b5d43..00000000
--- a/src/features/projects/domain/useProjectNavigator.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-"use client"
-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/DocumentationIframe.tsx b/src/features/projects/view/DocumentationIframe.tsx
index 788601f4..64cfac3a 100644
--- a/src/features/projects/view/DocumentationIframe.tsx
+++ b/src/features/projects/view/DocumentationIframe.tsx
@@ -1,5 +1,5 @@
import { Box } from "@mui/material"
-import DelayedLoadingIndicator from "@/common/loading/DelayedLoadingIndicator"
+import DelayedLoadingIndicator from "@/common/ui/DelayedLoadingIndicator"
import { DocumentationVisualizer } from "@/features/settings/domain"
const DocumentationIframe = ({
diff --git a/src/features/projects/view/MainContent.tsx b/src/features/projects/view/MainContent.tsx
deleted file mode 100644
index df22698d..00000000
--- a/src/features/projects/view/MainContent.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Project, Version, OpenApiSpecification } from "../domain"
-import DelayedLoadingIndicator from "@/common/loading/DelayedLoadingIndicator"
-import ErrorMessage from "./ErrorMessage"
-import Documentation from "./Documentation"
-
-const MainContent = ({
- isLoading,
- error,
- project,
- version,
- specification
-}: {
- isLoading: boolean,
- error?: Error,
- project?: Project,
- version?: Version,
- specification?: OpenApiSpecification
-}) => {
- if (project && version && specification) {
- return
- } else if (isLoading) {
- return
- } else if (error) {
- return
- } else if (!project) {
- return
- } else if (!version) {
- return
- } else {
- return
- }
-}
-
-export default MainContent
\ No newline at end of file
diff --git a/src/features/projects/view/ProjectAvatar.tsx b/src/features/projects/view/ProjectAvatar.tsx
deleted file mode 100644
index 2a6056d6..00000000
--- a/src/features/projects/view/ProjectAvatar.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { SxProps } from "@mui/system"
-import { Avatar, Box } from "@mui/material"
-import { alpha, useTheme } from "@mui/material/styles"
-import { Project } from "../domain"
-import ProjectAvatarSquircleClip from "./ProjectAvatarSquircleClip"
-
-function ProjectAvatar({
- project,
- width,
- height
-}: {
- project: Project,
- width: number,
- height: number
-}) {
- const theme = useTheme()
- const borderRadius = 1
- return (
-
-
-
-
- {project.imageURL &&
- /* eslint-disable-next-line @next/next/no-img-element */
-
- }
-
-
- )
-}
-
-export default ProjectAvatar
-
-const PlaceholderAvatar = ({ text, sx }: { text: string, sx?: SxProps }) => {
- return (
-
- {Array.from(text)[0]}
-
- )
-}
diff --git a/src/features/projects/view/ProjectAvatarSquircleClip.tsx b/src/features/projects/view/ProjectAvatarSquircleClip.tsx
deleted file mode 100644
index 0ac099c5..00000000
--- a/src/features/projects/view/ProjectAvatarSquircleClip.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { ReactNode } from "react"
-import { SxProps } from "@mui/system"
-import { Box } from "@mui/material"
-import { getSvgPath } from "figma-squircle"
-
-const ProjectAvatarSquircleClip = ({
- width,
- height,
- children,
- sx
-}: {
- width: number,
- height: number,
- children?: ReactNode,
- sx?: SxProps
-}) => {
- const svgPath = getSvgPath({
- width,
- height,
- cornerRadius: 10,
- cornerSmoothing: 0.8
- })
- return (
-
- {children}
-
- )
-}
-
-export default ProjectAvatarSquircleClip
\ No newline at end of file
diff --git a/src/features/projects/view/ProjectsPage.tsx b/src/features/projects/view/ProjectsPage.tsx
deleted file mode 100644
index 298920de..00000000
--- a/src/features/projects/view/ProjectsPage.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { ProjectRepository } from "../domain"
-import ClientProjectsPage from "./client/ProjectsPage"
-
-export default async function ProjectsPage({
- projectRepository,
- path
-}: {
- projectRepository: ProjectRepository
- path: string
-}) {
- const projects = await projectRepository.get()
- return (
-
- )
-}
diff --git a/src/features/projects/view/ServerSideCachedProjectsProvider.tsx b/src/features/projects/view/ServerSideCachedProjectsProvider.tsx
new file mode 100644
index 00000000..810a6633
--- /dev/null
+++ b/src/features/projects/view/ServerSideCachedProjectsProvider.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import { Project } from "../domain"
+import { ServerSideCachedProjectsContext } from "@/common"
+
+const ServerSideCachedProjectsProvider = ({
+ projects,
+ children
+}: {
+ projects: Project[] | undefined
+ children: React.ReactNode
+}) => {
+ return (
+
+ {children}
+
+ )
+}
+
+export default ServerSideCachedProjectsProvider
diff --git a/src/features/projects/view/client/ProjectsPage.tsx b/src/features/projects/view/client/ProjectsPage.tsx
deleted file mode 100644
index efd27800..00000000
--- a/src/features/projects/view/client/ProjectsPage.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-"use client"
-
-import { useEffect } from "react"
-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 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({
- projects: serverProjects,
- path
-}: {
- projects?: Project[]
- path: string
-}) {
- 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, path })
- const siteName = process.env.NEXT_PUBLIC_SHAPE_DOCS_TITLE || ""
- useEffect(() => {
- updateWindowTitle({
- storage: document,
- defaultTitle: siteName,
- project,
- version,
- specification
- })
- }, [project, version, specification, siteName])
- useEffect(() => {
- // Ensure the URL reflects the current selection of project, version, and specification.
- projectNavigator.navigateIfNeeded({
- projectOwner: project?.owner,
- projectName: project?.name,
- versionId: version?.id,
- specificationId: specification?.id
- })
- }, [projectNavigator, project, version, specification])
- useEffect(() => {
- // Show the sidebar if no project is selected.
- if (project === undefined) {
- setSidebarOpen(true)
- }
- }, [project, setSidebarOpen])
- const selectProject = (project: Project) => {
- if (!isDesktopLayout) {
- setSidebarOpen(false)
- }
- const version = project.versions[0]
- const specification = version.specifications[0]
- projectNavigator.navigate(project.owner, project.name, version.id, specification.id)
- }
- const selectVersion = (versionId: string) => {
- projectNavigator.navigateToVersion(project!, versionId, specification!.name)
- }
- const selectSpecification = (specificationId: string) => {
- projectNavigator.navigate(project!.owner, project!.name, version!.id, specificationId)
- }
- const canCloseSidebar = project !== undefined
- const toggleSidebar = (isOpen: boolean) => {
- if (!isOpen && canCloseSidebar) {
- setSidebarOpen(false)
- } else if (isOpen) {
- setSidebarOpen(true)
- }
- }
- return (
-
- }
- toolbarTrailingItem={project && version && specification &&
-
- }
- mobileToolbar={project && version && specification &&
-
- }
- >
- {/* If the user has not selected any project then we do not render any content */}
- {project &&
-
- }
-
- )
-}
\ No newline at end of file
diff --git a/src/features/projects/view/toolbar/MobileToolbar.tsx b/src/features/projects/view/toolbar/MobileToolbar.tsx
index 2564fb92..1fa7b6ae 100644
--- a/src/features/projects/view/toolbar/MobileToolbar.tsx
+++ b/src/features/projects/view/toolbar/MobileToolbar.tsx
@@ -1,21 +1,21 @@
+"use client"
+
import { Stack } from "@mui/material"
-import { Project, Version, OpenApiSpecification } from "../../domain"
import VersionSelector from "./VersionSelector"
import SpecificationSelector from "./SpecificationSelector"
+import { useProjectSelection } from "../../data"
-const MobileToolbar = ({
- project,
- version,
- specification,
- onSelectVersion,
- onSelectSpecification
-}: {
- project: Project
- version: Version
- specification: OpenApiSpecification
- onSelectVersion: (versionId: string) => void,
- onSelectSpecification: (specificationId: string) => void
-}) => {
+const MobileToolbar = () => {
+ const {
+ project,
+ version,
+ specification,
+ selectVersion,
+ selectSpecification
+ } = useProjectSelection()
+ if (!project || !version || !specification) {
+ return <>>
+ }
return (
diff --git a/src/features/projects/view/toolbar/SpecificationSelector.tsx b/src/features/projects/view/toolbar/SpecificationSelector.tsx
index c08aba1d..b1ba3394 100644
--- a/src/features/projects/view/toolbar/SpecificationSelector.tsx
+++ b/src/features/projects/view/toolbar/SpecificationSelector.tsx
@@ -1,5 +1,11 @@
import { SxProps } from "@mui/system"
-import { SelectChangeEvent, Select, MenuItem, FormControl } from "@mui/material"
+import {
+ SelectChangeEvent,
+ Select,
+ MenuItem,
+ FormControl,
+ Typography
+} from "@mui/material"
import MenuItemHover from "@/common/ui/MenuItemHover"
import { OpenApiSpecification } from "../../domain"
@@ -23,7 +29,13 @@ const SpecificationSelector = ({
{specifications.map(specification =>
)}
diff --git a/src/features/projects/view/toolbar/TrailingToolbarItem.tsx b/src/features/projects/view/toolbar/TrailingToolbarItem.tsx
index 537d4e20..85c9a556 100644
--- a/src/features/projects/view/toolbar/TrailingToolbarItem.tsx
+++ b/src/features/projects/view/toolbar/TrailingToolbarItem.tsx
@@ -1,24 +1,24 @@
+"use client"
+
import { SxProps } from "@mui/system"
import { Stack, IconButton, Typography, Link, Tooltip } from "@mui/material"
-import { Project, Version, OpenApiSpecification } from "../../domain"
import VersionSelector from "./VersionSelector"
import SpecificationSelector from "./SpecificationSelector"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { faPen } from "@fortawesome/free-solid-svg-icons"
+import { useProjectSelection } from "../../data"
-const TrailingToolbarItem = ({
- project,
- version,
- specification,
- onSelectVersion,
- onSelectSpecification
-}: {
- project: Project
- version: Version
- specification: OpenApiSpecification
- onSelectVersion: (versionId: string) => void,
- onSelectSpecification: (specificationId: string) => void
-}) => {
+const TrailingToolbarItem = () => {
+ const {
+ project,
+ version,
+ specification,
+ selectVersion,
+ selectSpecification
+ } = useProjectSelection()
+ if (!project || !version || !specification) {
+ return <>>
+ }
const projectNameURL = version.url || project.url
return (
<>
@@ -55,14 +55,14 @@ const TrailingToolbarItem = ({
/
{specification.editURL &&
diff --git a/src/features/projects/view/toolbar/VersionSelector.tsx b/src/features/projects/view/toolbar/VersionSelector.tsx
index d8cdc8fc..5989592e 100644
--- a/src/features/projects/view/toolbar/VersionSelector.tsx
+++ b/src/features/projects/view/toolbar/VersionSelector.tsx
@@ -1,5 +1,11 @@
import { SxProps } from "@mui/system"
-import { Select, MenuItem, SelectChangeEvent, FormControl } from "@mui/material"
+import {
+ Select,
+ MenuItem,
+ SelectChangeEvent,
+ FormControl,
+ Typography
+} from "@mui/material"
import MenuItemHover from "@/common/ui/MenuItemHover"
import { Version } from "../../domain"
@@ -26,7 +32,13 @@ const VersionSelector = ({
{versions.map(version =>
)}
diff --git a/src/features/sidebar/data/index.ts b/src/features/sidebar/data/index.ts
new file mode 100644
index 00000000..c0168b3a
--- /dev/null
+++ b/src/features/sidebar/data/index.ts
@@ -0,0 +1 @@
+export { default as useSidebarOpen } from "./useSidebarOpen"
diff --git a/src/common/state/sidebarOpen.tsx b/src/features/sidebar/data/useSidebarOpen.ts
similarity index 65%
rename from src/common/state/sidebarOpen.tsx
rename to src/features/sidebar/data/useSidebarOpen.ts
index 2d0c3b9e..3d4c1be7 100644
--- a/src/common/state/sidebarOpen.tsx
+++ b/src/features/sidebar/data/useSidebarOpen.ts
@@ -1,5 +1,5 @@
import { useSessionStorage } from "usehooks-ts"
export default function useSidebarOpen() {
- return useSessionStorage("isSidebarOpen", true)
+ return useSessionStorage("isSidebarOpen", true)
}
\ No newline at end of file
diff --git a/src/features/sidebar/view/SecondarySplitHeader.tsx b/src/features/sidebar/view/SecondarySplitHeader.tsx
new file mode 100644
index 00000000..785bce43
--- /dev/null
+++ b/src/features/sidebar/view/SecondarySplitHeader.tsx
@@ -0,0 +1,105 @@
+"use client"
+
+import { useState, useEffect, useContext } from "react"
+import { useSessionStorage } from "usehooks-ts"
+import { Box, IconButton, Stack, Tooltip, Divider, Collapse } from "@mui/material"
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
+import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons"
+import { useTheme } from "@mui/material/styles"
+import { SidebarContext, isMac as checkIsMac } from "@/common"
+import { useSidebarOpen } from "@/features/sidebar/data"
+import ToggleMobileToolbarButton from "./internal/secondary/ToggleMobileToolbarButton"
+
+const Header = ({
+ mobileToolbar,
+ children
+}: {
+ mobileToolbar?: React.ReactNode
+ children?: React.ReactNode
+}) => {
+ const [isSidebarOpen, setSidebarOpen] = useSidebarOpen()
+ const [isMac, setIsMac] = useState(false)
+ const { isToggleable: isSidebarToggleable } = useContext(SidebarContext)
+ const [isMobileToolbarVisible, setMobileToolbarVisible] = useSessionStorage("isMobileToolbarVisible", true)
+ useEffect(() => {
+ // checkIsMac uses window so we delay the check.
+ setIsMac(checkIsMac())
+ }, [isMac, setIsMac])
+ const openCloseKeyboardShortcut = `(${isMac ? "⌘" : "^"} + .)`
+ const theme = useTheme()
+ return (
+
+
+ {isSidebarToggleable && !isSidebarOpen &&
+
+ setSidebarOpen(true)}
+ edge="start"
+ >
+
+
+
+ }
+ {isSidebarToggleable && isSidebarOpen &&
+
+ setSidebarOpen(false)}
+ edge="start"
+ >
+
+
+
+ }
+
+
+ {children}
+ {mobileToolbar &&
+ setMobileToolbarVisible(!isMobileToolbarVisible) }
+ />
+ }
+
+
+
+ {mobileToolbar &&
+
+
+ {mobileToolbar}
+
+
+ }
+
+
+ )
+}
+
+export default Header
diff --git a/src/features/sidebar/view/Sidebar.tsx b/src/features/sidebar/view/Sidebar.tsx
deleted file mode 100644
index d8873876..00000000
--- a/src/features/sidebar/view/Sidebar.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { ReactNode } from "react"
-import { Box } from "@mui/material"
-import SidebarHeader from "./SidebarHeader"
-import UserFooter from "@/features/user/view/UserFooter"
-
-const Sidebar = ({ children }: { children: ReactNode }) => {
- return (
- <>
-
-
- {children}
-
-
- >
- )
-}
-
-export default Sidebar
diff --git a/src/features/sidebar/view/SplitView.tsx b/src/features/sidebar/view/SplitView.tsx
new file mode 100644
index 00000000..78cadccf
--- /dev/null
+++ b/src/features/sidebar/view/SplitView.tsx
@@ -0,0 +1,52 @@
+"use client"
+
+import { useEffect } from "react"
+import { Stack } from "@mui/material"
+import { isMac, useKeyboardShortcut } from "@/common"
+import { useSidebarOpen } from "../data"
+import PrimaryContainer from "./internal/primary/Container"
+import SecondaryContainer from "./internal/secondary/Container"
+import Sidebar from "./internal/sidebar/Sidebar"
+
+const SplitView = ({
+ canToggleSidebar: _canToggleSidebar,
+ children
+}: {
+ canToggleSidebar?: boolean
+ children?: React.ReactNode
+}) => {
+ const [isSidebarOpen, setSidebarOpen] = useSidebarOpen()
+ const canToggleSidebar = _canToggleSidebar !== undefined ? _canToggleSidebar : true
+ useEffect(() => {
+ // Show the sidebar if no project is selected.
+ if (!canToggleSidebar) {
+ setSidebarOpen(true)
+ }
+ }, [canToggleSidebar, setSidebarOpen])
+ useKeyboardShortcut(event => {
+ const isActionKey = isMac() ? event.metaKey : event.ctrlKey
+ if (isActionKey && event.key === ".") {
+ event.preventDefault()
+ if (canToggleSidebar) {
+ setSidebarOpen(!isSidebarOpen)
+ }
+ }
+ }, [canToggleSidebar, setSidebarOpen])
+ const sidebarWidth = 320
+ return (
+
+ setSidebarOpen(false)}
+ >
+
+
+
+ {children}
+
+
+ )
+}
+
+export default SplitView
diff --git a/src/features/sidebar/view/TrailingToolbar.tsx b/src/features/sidebar/view/TrailingToolbar.tsx
deleted file mode 100644
index 1ab9f95c..00000000
--- a/src/features/sidebar/view/TrailingToolbar.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { ReactNode } from "react"
-import { Box } from "@mui/material"
-import { useTheme } from "@mui/material/styles"
-
-export default function TrailingToolbar({
- children
-}: {
- children?: ReactNode
-}) {
- const theme = useTheme()
- return (
-
- {children}
-
- )
-}
diff --git a/src/features/sidebar/view/base/Drawer.tsx b/src/features/sidebar/view/base/Drawer.tsx
deleted file mode 100644
index 5a335588..00000000
--- a/src/features/sidebar/view/base/Drawer.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { ReactNode } from "react"
-import { SxProps } from "@mui/system"
-import { Drawer as MuiDrawer } from "@mui/material"
-
-export default function Drawer({
- variant,
- width,
- isOpen,
- onClose,
- keepMounted,
- sx,
- children
-}: {
- variant: "persistent" | "temporary",
- width: number
- isOpen: boolean
- onClose?: () => void
- keepMounted?: boolean
- sx: SxProps,
- children?: ReactNode
-}) {
- return (
-
- {children}
-
- )
-}
\ No newline at end of file
diff --git a/src/features/sidebar/view/base/SecondaryHeader.tsx b/src/features/sidebar/view/base/SecondaryHeader.tsx
deleted file mode 100644
index 32048015..00000000
--- a/src/features/sidebar/view/base/SecondaryHeader.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { ReactNode } from "react"
-import { SxProps } from "@mui/system"
-import { Box, Divider, IconButton, Tooltip } from "@mui/material"
-import { useTheme } from "@mui/material/styles"
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
-import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons"
-import { isMac, useKeyboardShortcut } from "@/common"
-
-export default function SecondaryHeader({
- showOpenSidebar,
- showCloseSidebar,
- onToggleSidebarOpen,
- trailingItem,
- children,
- sx
-}: {
- showOpenSidebar: boolean
- showCloseSidebar: boolean
- onToggleSidebarOpen: (isOpen: boolean) => void
- trailingItem?: ReactNode
- children?: ReactNode
- sx?: SxProps
-}) {
- useKeyboardShortcut(event => {
- const isActionKey = isMac() ? event.metaKey : event.ctrlKey
- if (isActionKey && event.key === ".") {
- event.preventDefault()
- if (showOpenSidebar) {
- onToggleSidebarOpen(true)
- } else if (showCloseSidebar) {
- onToggleSidebarOpen(false)
- }
- }
- }, [showOpenSidebar, showCloseSidebar, onToggleSidebarOpen])
- const openCloseShortcutString = isMac() ? " (⌘ + .)" : "(^ + .)"
- const theme = useTheme()
- return (
-
-
- {showOpenSidebar &&
-
- onToggleSidebarOpen(true)}
- edge="start"
- >
-
-
-
- }
- {showCloseSidebar &&
-
- onToggleSidebarOpen(false)}
- edge="start"
- >
-
-
-
- }
-
- {trailingItem}
-
-
- {children}
-
-
- )
-}
diff --git a/src/features/sidebar/view/base/responsive/Drawer.tsx b/src/features/sidebar/view/base/responsive/Drawer.tsx
deleted file mode 100644
index 8a027f77..00000000
--- a/src/features/sidebar/view/base/responsive/Drawer.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { ReactNode } from "react"
-import Drawer from "../Drawer"
-
-export default function RespnsiveDrawer({
- width,
- isOpen,
- onClose,
- children
-}: {
- width: number
- isOpen: boolean
- onClose?: () => void
- children?: ReactNode
-}) {
- return (
- <>
-
- {children}
-
-
- {children}
-
- >
- )
-}
\ No newline at end of file
diff --git a/src/features/sidebar/view/base/responsive/SecondaryHeader.tsx b/src/features/sidebar/view/base/responsive/SecondaryHeader.tsx
deleted file mode 100644
index f48dd778..00000000
--- a/src/features/sidebar/view/base/responsive/SecondaryHeader.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { ReactNode } from "react"
-import { SxProps } from "@mui/system"
-import { Box, IconButton, Stack, Collapse } from "@mui/material"
-import SecondaryHeader from "../SecondaryHeader"
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
-import { faChevronDown } from "@fortawesome/free-solid-svg-icons"
-
-export default function ResponsiveSecondaryHeader({
- showOpenSidebar,
- showCloseSidebar,
- onToggleSidebarOpen,
- showMobileToolbar,
- onToggleMobileToolbar,
- trailingItem,
- mobileToolbar,
- sx
-}: {
- showOpenSidebar: boolean
- showCloseSidebar: boolean
- onToggleSidebarOpen: (isOpen: boolean) => void
- showMobileToolbar: boolean
- onToggleMobileToolbar: (showMobileToolbar: boolean) => void
- trailingItem?: ReactNode
- mobileToolbar?: ReactNode
- sx?: SxProps
-}) {
- return (
-
- {trailingItem}
- {mobileToolbar &&
- onToggleMobileToolbar(!showMobileToolbar) }
- sx={{ display: { sm: "flex", md: "none" } }}
- >
-
-
- }
-
- }
- >
- {mobileToolbar &&
-
-
- {mobileToolbar}
-
-
- }
-
- )
-}
diff --git a/src/features/sidebar/view/base/responsive/SecondaryWrapper.tsx b/src/features/sidebar/view/base/responsive/SecondaryWrapper.tsx
deleted file mode 100644
index 171bf342..00000000
--- a/src/features/sidebar/view/base/responsive/SecondaryWrapper.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { ReactNode } from "react"
-import SecondaryWrapper from "../SecondaryWrapper"
-
-export default function ResponsiveSecondaryWrapper({
- sidebarWidth,
- offsetContent,
- children
-}: {
- sidebarWidth: number
- offsetContent: boolean
- children: ReactNode
-}) {
- const sx = { overflow: "hidden" }
- return (
- <>
-
- {children}
-
-
- {children}
-
- >
- )
-}
\ No newline at end of file
diff --git a/src/features/sidebar/view/base/responsive/SidebarContainer.tsx b/src/features/sidebar/view/base/responsive/SidebarContainer.tsx
deleted file mode 100644
index 248660ab..00000000
--- a/src/features/sidebar/view/base/responsive/SidebarContainer.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { ReactNode } from "react"
-import { Stack } from "@mui/material"
-import Drawer from "./Drawer"
-import SecondaryWrapper from "./SecondaryWrapper"
-
-const SidebarContainer = ({
- isSidebarOpen,
- onToggleSidebarOpen,
- sidebar,
- header,
- children
-}: {
- isSidebarOpen: boolean,
- onToggleSidebarOpen: (isSidebarOpen: boolean) => void
- sidebar: ReactNode
- header?: ReactNode
- children?: ReactNode
-}) => {
- const sidebarWidth = 320
- return (
-
- onToggleSidebarOpen(false)}
- >
- {sidebar}
-
-
- {header}
-
- {children}
-
-
-
- )
-}
-
-export default SidebarContainer
diff --git a/src/features/sidebar/view/client/SidebarContainer.tsx b/src/features/sidebar/view/client/SidebarContainer.tsx
deleted file mode 100644
index ec7b6fc2..00000000
--- a/src/features/sidebar/view/client/SidebarContainer.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-"use client"
-
-import dynamic from "next/dynamic"
-import { ReactNode } from "react"
-import { useSessionStorage } from "usehooks-ts"
-import ResponsiveSidebarContainer from "../base/responsive/SidebarContainer"
-import ResponsiveSecondaryHeader from "../base/responsive/SecondaryHeader"
-import Sidebar from "../Sidebar"
-
-const SidebarContainer = ({
- isSidebarOpen,
- onToggleSidebarOpen,
- showHeader: _showHeader,
- sidebar,
- children,
- toolbarTrailingItem,
- mobileToolbar
-}: {
- isSidebarOpen: boolean,
- onToggleSidebarOpen: (isSidebarOpen: boolean) => void,
- showHeader?: boolean,
- sidebar?: ReactNode
- children?: ReactNode
- toolbarTrailingItem?: ReactNode
- mobileToolbar?: ReactNode
-}) => {
- const [showMobileToolbar, setShowMobileToolbar] = useSessionStorage("isMobileToolbarVisible", true)
- const showHeader = _showHeader || _showHeader === undefined
- return (
-
- {sidebar}
-
- }
- header={showHeader &&
-
- }
- >
- {children}
-
- )
-}
-
-// Disable server-side rendering as this component uses the window instance to manage its state.
-export default dynamic(() => Promise.resolve(SidebarContainer), {
- ssr: false
-})
diff --git a/src/features/sidebar/view/index.ts b/src/features/sidebar/view/index.ts
new file mode 100644
index 00000000..47482d01
--- /dev/null
+++ b/src/features/sidebar/view/index.ts
@@ -0,0 +1 @@
+export { default as SplitView } from "./SplitView"
diff --git a/src/features/sidebar/view/internal/primary/Container.tsx b/src/features/sidebar/view/internal/primary/Container.tsx
new file mode 100644
index 00000000..b6714e1e
--- /dev/null
+++ b/src/features/sidebar/view/internal/primary/Container.tsx
@@ -0,0 +1,81 @@
+import { SxProps } from "@mui/system"
+import { Drawer as MuiDrawer } from "@mui/material"
+
+const PrimaryContainer = ({
+ width,
+ isOpen,
+ onClose,
+ children
+}: {
+ width: number
+ isOpen: boolean
+ onClose?: () => void
+ children?: React.ReactNode
+}) => {
+ return (
+ <>
+ <_PrimaryContainer
+ variant="temporary"
+ width={width}
+ isOpen={isOpen}
+ onClose={onClose}
+ keepMounted={true}
+ sx={{ display: { xs: "block", sm: "none" } }}
+ >
+ {children}
+
+ <_PrimaryContainer
+ variant="persistent"
+ width={width}
+ isOpen={isOpen}
+ keepMounted={false}
+ sx={{ display: { xs: "none", sm: "block" } }}
+ >
+ {children}
+
+ >
+ )
+}
+
+export default PrimaryContainer
+
+const _PrimaryContainer = ({
+ variant,
+ width,
+ isOpen,
+ onClose,
+ keepMounted,
+ sx,
+ children
+}: {
+ variant: "persistent" | "temporary",
+ width: number
+ isOpen: boolean
+ onClose?: () => void
+ keepMounted?: boolean
+ sx: SxProps,
+ children?: React.ReactNode
+}) => {
+ return (
+
+ {children}
+
+ )
+}
\ No newline at end of file
diff --git a/src/features/sidebar/view/base/SecondaryWrapper.tsx b/src/features/sidebar/view/internal/secondary/Container.tsx
similarity index 54%
rename from src/features/sidebar/view/base/SecondaryWrapper.tsx
rename to src/features/sidebar/view/internal/secondary/Container.tsx
index fef93718..7de24922 100644
--- a/src/features/sidebar/view/base/SecondaryWrapper.tsx
+++ b/src/features/sidebar/view/internal/secondary/Container.tsx
@@ -1,15 +1,46 @@
-import { ReactNode } from "react"
import { SxProps } from "@mui/system"
import { Stack } from "@mui/material"
import { styled } from "@mui/material/styles"
-interface WrapperStackProps {
+const SecondaryContainer = ({
+ sidebarWidth,
+ offsetContent,
+ children
+}: {
sidebarWidth: number
- isSidebarOpen: boolean
+ offsetContent: boolean
+ children?: React.ReactNode
+}) => {
+ const sx = { overflow: "hidden" }
+ return (
+ <>
+ <_SecondaryContainer
+ sidebarWidth={0}
+ isSidebarOpen={false}
+ sx={{ ...sx, display: { xs: "flex", sm: "none" } }}
+ >
+ {children}
+
+ <_SecondaryContainer
+ sidebarWidth={sidebarWidth}
+ isSidebarOpen={offsetContent}
+ sx={{ ...sx, display: { xs: "none", sm: "flex" } }}
+ >
+ {children}
+
+ >
+ )
+}
+
+export default SecondaryContainer
+
+interface WrapperStackProps {
+ readonly sidebarWidth: number
+ readonly isSidebarOpen: boolean
}
const WrapperStack = styled(Stack, {
- shouldForwardProp: (prop) => prop !== "isSidebarOpen"
+ shouldForwardProp: (prop) => prop !== "isSidebarOpen" && prop !== "sidebarWidth"
})(({ theme, sidebarWidth, isSidebarOpen }) => ({
transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.sharp,
@@ -25,7 +56,7 @@ const WrapperStack = styled(Stack, {
})
}))
-export default function SecondaryWrapper({
+const _SecondaryContainer = ({
sidebarWidth,
isSidebarOpen,
children,
@@ -33,9 +64,9 @@ export default function SecondaryWrapper({
}: {
sidebarWidth: number
isSidebarOpen: boolean
- children: ReactNode
+ children: React.ReactNode
sx?: SxProps
-}) {
+}) => {
return (
void
+}) => {
+ return <>
+
+
+
+ >
+}
+
+export default ToggleMobileToolbarButton
diff --git a/src/features/sidebar/view/SidebarHeader.tsx b/src/features/sidebar/view/internal/sidebar/Header.tsx
similarity index 50%
rename from src/features/sidebar/view/SidebarHeader.tsx
rename to src/features/sidebar/view/internal/sidebar/Header.tsx
index 5bc88a4d..f266aff5 100644
--- a/src/features/sidebar/view/SidebarHeader.tsx
+++ b/src/features/sidebar/view/internal/sidebar/Header.tsx
@@ -1,11 +1,20 @@
import Image from "next/image"
-import { Box, Typography } from "@mui/material"
import Link from "next/link"
+import { Box, Typography } from "@mui/material"
+// import { Box, Typography, IconButton, Tooltip } from "@mui/material"
+// import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
+// import { faPlus } from "@fortawesome/free-solid-svg-icons"
-export default function SidebarHeader() {
+const Header = () => {
const siteName = process.env.NEXT_PUBLIC_SHAPE_DOCS_TITLE
return (
-
+
{siteName}
-
+
+ {/*
+
+
+
+
+
+ */}
)
}
+
+export default Header
diff --git a/src/features/sidebar/view/internal/sidebar/Sidebar.tsx b/src/features/sidebar/view/internal/sidebar/Sidebar.tsx
new file mode 100644
index 00000000..c4b99ae5
--- /dev/null
+++ b/src/features/sidebar/view/internal/sidebar/Sidebar.tsx
@@ -0,0 +1,43 @@
+import { useRef, useEffect, useState } from "react"
+import { Box, Divider } from "@mui/material"
+import Header from "./Header"
+import UserButton from "./user/UserButton"
+import SettingsList from "./settings/SettingsList"
+import ProjectList from "./projects/ProjectList"
+
+const Sidebar = () => {
+ const [isScrolledToTop, setScrolledToTop] = useState(true)
+ const [isScrolledToBottom, setScrolledToBottom] = useState(true)
+ const projectListRef = useRef(null)
+ const handleScroll = () => {
+ const element = projectListRef.current
+ if (!element) {
+ return
+ }
+ setScrolledToTop(element.scrollTop < 10)
+ setScrolledToBottom(element.scrollHeight - element.scrollTop - element.clientHeight < 10)
+ }
+ useEffect(() => {
+ const element = projectListRef.current
+ if (element) {
+ element.addEventListener("scroll", handleScroll)
+ handleScroll()
+ return () => {
+ element.removeEventListener("scroll", handleScroll)
+ }
+ }
+ }, [])
+ return <>
+
+
+
+
+
+
+
+
+
+ >
+}
+
+export default Sidebar
diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectAvatar.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectAvatar.tsx
new file mode 100644
index 00000000..98e0706f
--- /dev/null
+++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectAvatar.tsx
@@ -0,0 +1,41 @@
+import { SxProps } from "@mui/system"
+import { Avatar } from "@mui/material"
+import { Project } from "@/features/projects/domain"
+import ProjectAvatarSquircle from "./ProjectAvatarSquircle"
+
+function ProjectAvatar({
+ project,
+ width,
+ height
+}: {
+ project: Project,
+ width: number,
+ height: number
+}) {
+ return (
+
+
+ {project.imageURL &&
+ /* eslint-disable-next-line @next/next/no-img-element */
+
+ }
+
+ )
+}
+
+export default ProjectAvatar
+
+const PlaceholderAvatar = ({ text, sx }: { text: string, sx?: SxProps }) => {
+ return (
+
+ {Array.from(text)[0]}
+
+ )
+}
diff --git a/src/features/sidebar/view/internal/sidebar/projects/ProjectAvatarSquircle.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectAvatarSquircle.tsx
new file mode 100644
index 00000000..58d0d394
--- /dev/null
+++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectAvatarSquircle.tsx
@@ -0,0 +1,36 @@
+import { SxProps } from "@mui/system"
+import { Box } from "@mui/material"
+import { getSvgPath } from "figma-squircle"
+
+const ProjectAvatarSquircle = ({
+ width,
+ height,
+ children,
+ sx
+}: {
+ width: number,
+ height: number,
+ children?: React.ReactNode,
+ sx?: SxProps
+}) => {
+ const svgPath = getSvgPath({
+ width,
+ height,
+ cornerRadius: 12,
+ cornerSmoothing: 0.8
+ })
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export default ProjectAvatarSquircle
diff --git a/src/features/projects/view/ProjectList.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx
similarity index 54%
rename from src/features/projects/view/ProjectList.tsx
rename to src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx
index 30fb06ad..d96def6b 100644
--- a/src/features/projects/view/ProjectList.tsx
+++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectList.tsx
@@ -1,23 +1,13 @@
+import { useContext } from "react"
import { List, Box, Typography } from "@mui/material"
+import { ProjectsContainerContext } from "@/common"
+import { useProjectSelection } from "@/features/projects/data"
import ProjectListItem from "./ProjectListItem"
import ProjectListItemPlaceholder from "./ProjectListItemPlaceholder"
-import { Project } from "../domain"
-interface ProjectListProps {
- readonly isLoading: boolean
- readonly projects: Project[]
- readonly selectedProjectId?: string
- readonly onSelectProject: (project: Project) => void
-}
-
-const ProjectList = (
- {
- isLoading,
- projects,
- selectedProjectId,
- onSelectProject
- }: ProjectListProps
-) => {
+const ProjectList = () => {
+ const { projects, isLoading } = useContext(ProjectsContainerContext)
+ const projectSelection = useProjectSelection()
const loadingItemCount = 6
if (isLoading || projects.length > 0) {
return (
@@ -27,13 +17,16 @@ const ProjectList = (
))
}
- {!isLoading && projects.map(project => (
-
+ {!isLoading && projects.map((project, idx) => (
+
+
+
))}
)
diff --git a/src/features/projects/view/ProjectListItem.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx
similarity index 59%
rename from src/features/projects/view/ProjectListItem.tsx
rename to src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx
index c9472256..ba9c7291 100644
--- a/src/features/projects/view/ProjectListItem.tsx
+++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectListItem.tsx
@@ -1,4 +1,5 @@
import {
+ Box,
ListItem,
ListItemButton,
ListItemText,
@@ -6,7 +7,7 @@ import {
Typography
} from "@mui/material"
import MenuItemHover from "@/common/ui/MenuItemHover"
-import { Project } from "../domain"
+import { Project } from "@/features/projects/domain"
import ProjectAvatar from "./ProjectAvatar"
const ProjectListItem = ({
@@ -26,16 +27,30 @@ const ProjectListItem = ({
disableGutters
sx={{ padding: 0 }}
>
-
-
-
+
+
+
+
+
{project.displayName}
diff --git a/src/features/projects/view/ProjectListItemPlaceholder.tsx b/src/features/sidebar/view/internal/sidebar/projects/ProjectListItemPlaceholder.tsx
similarity index 78%
rename from src/features/projects/view/ProjectListItemPlaceholder.tsx
rename to src/features/sidebar/view/internal/sidebar/projects/ProjectListItemPlaceholder.tsx
index d5fcde1f..77de983a 100644
--- a/src/features/projects/view/ProjectListItemPlaceholder.tsx
+++ b/src/features/sidebar/view/internal/sidebar/projects/ProjectListItemPlaceholder.tsx
@@ -1,20 +1,20 @@
import { ListItem, ListItemText, Stack, Skeleton } from "@mui/material"
import MenuItemHover from "@/common/ui/MenuItemHover"
-import ProjectAvatarSquircleClip from "./ProjectAvatarSquircleClip"
+import ProjectAvatarSquircle from "./ProjectAvatarSquircle"
const ProjectListItemPlaceholder = () => {
return (
- {
animation="wave"
sx={{ width: 42, height: 42 }}
/>
-
+
void
+ icon?: IconProp
+ children?: React.ReactNode
+}) => {
+ return (
+
+ )
+}
+
+const SettingsList = () => {
+ const helpURL = process.env.NEXT_PUBLIC_SHAPE_DOCS_HELP_URL
+ return (
+
+
+
+ {helpURL &&
+
+
+
+ Help
+
+
+
+ }
+ signOut()} icon={faRightFromBracket}>
+
+ Log out
+
+
+
+ )
+}
+
+export default SettingsList
diff --git a/src/features/user/view/UserButton.tsx b/src/features/sidebar/view/internal/sidebar/user/UserButton.tsx
similarity index 73%
rename from src/features/user/view/UserButton.tsx
rename to src/features/sidebar/view/internal/sidebar/user/UserButton.tsx
index 749d8e5a..0b2a57ff 100644
--- a/src/features/user/view/UserButton.tsx
+++ b/src/features/sidebar/view/internal/sidebar/user/UserButton.tsx
@@ -1,8 +1,13 @@
+"use client"
+
+import { useSession } from "next-auth/react"
import { useState } from "react"
import { Session } from "next-auth"
import {
Avatar,
Box,
+ List,
+ ListItem,
ListItemButton,
Stack,
Popover,
@@ -11,9 +16,34 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { faEllipsis } from "@fortawesome/free-solid-svg-icons"
import MenuItemHover from "@/common/ui/MenuItemHover"
-import SettingsList from "./SettingsList"
+import UserSkeleton from "./UserSkeleton"
+
+const UserButton = ({ children }: { children: React.ReactNode }) => {
+ const { data: session, status } = useSession()
+ const isLoading = status == "loading"
+ return (
+
+
+ {!isLoading && session &&
+
+ {children}
+
+ }
+ {isLoading && }
+
+
+ )
+}
-const UserButton = ({ session }: { session: Session }) => {
+export default UserButton
+
+const UserButtonWithSession = ({
+ session,
+ children
+}: {
+ session: Session
+ children?: React.ReactNode
+}) => {
const [popoverAnchorElement, setPopoverAnchorElement] = useState(null)
const handlePopoverClick = (event: React.MouseEvent) => {
setPopoverAnchorElement(event.currentTarget)
@@ -40,7 +70,7 @@ const UserButton = ({ session }: { session: Session }) => {
horizontal: "left"
}}
>
-
+ {children}
{
>
)
}
-
-export default UserButton
diff --git a/src/features/user/view/UserSkeleton.tsx b/src/features/sidebar/view/internal/sidebar/user/UserSkeleton.tsx
similarity index 100%
rename from src/features/user/view/UserSkeleton.tsx
rename to src/features/sidebar/view/internal/sidebar/user/UserSkeleton.tsx
diff --git a/src/features/user/view/SettingsList.tsx b/src/features/user/view/SettingsList.tsx
deleted file mode 100644
index 93fe7987..00000000
--- a/src/features/user/view/SettingsList.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { List, Button } from "@mui/material"
-import ThickDivider from "@/common/ui/ThickDivider"
-import DocumentationVisualizationPicker from "./DocumentationVisualizationPicker"
-import { signOut } from "next-auth/react"
-
-const SettingsItem = ({ onClick, href, children }: {
- onClick?: () => void;
- href?: string;
- children?: string;
-}) =>
-
-
-const SettingsList = () => {
- return (
-
-
-
- signOut()}>
- Log out
-
-
- )
-}
-
-export default SettingsList
diff --git a/src/features/user/view/UserFooter.tsx b/src/features/user/view/UserFooter.tsx
deleted file mode 100644
index a5b750c6..00000000
--- a/src/features/user/view/UserFooter.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { useSession } from "next-auth/react"
-import { List, ListItem } from "@mui/material"
-import UserButton from "./UserButton"
-import UserSkeleton from "./UserSkeleton"
-
-const UserFooter = () => {
- const { data: session, status } = useSession()
- const isLoading = status == "loading"
- return (
-
-
- {!isLoading && session && }
- {isLoading && }
-
-
- )
-}
-
-export default UserFooter
diff --git a/test b/test
deleted file mode 100644
index db13c084..00000000
--- a/test
+++ /dev/null
@@ -1 +0,0 @@
-testing automatic deployment
diff --git a/wiki/login.png b/wiki/login.png
index c41680bf..fa471489 100644
Binary files a/wiki/login.png and b/wiki/login.png differ