diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 6b5165a7..32b14341 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,10 +2,24 @@ name: Build
on:
workflow_dispatch: {}
pull_request:
- branches: [main]
+ branches: [main, develop]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
+env:
+ AUTH0_SECRET: 336f7a926310cff425cea29556dce2a98859b8d234aa27968696c2e6f1cb7d34
+ AUTH0_BASE_URL: http://dev.local:3000
+ AUTH0_ISSUER_BASE_URL: https://shape-docs-dev.eu.auth0.com
+ AUTH0_CLIENT_ID: this-is-our-client-id
+ AUTH0_CLIENT_SECRET: this-is-our-client-secret
+ AUTH0_MANAGEMENT_DOMAIN: shape-docs-dev.eu.auth0.com
+ AUTH0_MANAGEMENT_CLIENT_ID: this-is-our-management-client-id
+ AUTH0_MANAGEMENT_CLIENT_SECRET: this-is-our-management-client-secret
+ GITHUB_CLIENT_ID: this-is-our-github-client-id
+ GITHUB_CLIENT_SECRET: this-is-our-github-client-secret
+ GITHUB_APP_ID: 12345
+ GITHUB_PRIVATE_KEY_BASE_64: LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0KYjNCbGJuTnphQzFyWlhrdGRqRUFBQUFBQkc1dmJtVUFBQUFFYm05dVpRQUFBQUFBQUFBQkFBQUFNd0FBQUF0emMyZ3RaVwpReU5UVXhPUUFBQUNCS2NaL3UyL3dubDA4R1BjeXdiSVc2QVdKdnhVL2o0V0g2UnkxODVPSHZDd0FBQUpBYW01MzdHcHVkCit3QUFBQXR6YzJndFpXUXlOVFV4T1FBQUFDQktjWi91Mi93bmwwOEdQY3l3YklXNkFXSnZ4VS9qNFdINlJ5MTg1T0h2Q3cKQUFBRUJ6T2htRGpuY3R0QSt3ai9USHRnWnN6MklRWjdLM2ovb0Z1OEZBNTMxTDhrcHhuKzdiL0NlWFR3WTl6TEJzaGJvQgpZbS9GVCtQaFlmcEhMWHprNGU4TEFBQUFDMlp2YjBCb1lYQmxMbVJyQVFJPQotLS0tLUVORCBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0K
+ GITHUB_WEBHOOK_SECRET: super-duper-secret
jobs:
build:
name: Build
@@ -16,7 +30,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
- node-version: 16
+ node-version: 20
- name: Install Dependencies
run: npm install
- name: Build
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a966d375..132067af 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,7 +2,7 @@ name: Test
on:
workflow_dispatch: {}
pull_request:
- branches: [main]
+ branches: [main, develop]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
diff --git a/README.md b/README.md
index 632d828b..7aa13dfe 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,12 @@
+
+
+
+
# shape-docs
Portal displaying our projects that are documented with OpenAPI.
+[](https://github.com/shapehq/shape-docs/actions/workflows/build.yml)
[](https://github.com/shapehq/shape-docs/actions/workflows/test.yml)
## Running the App Locally
@@ -9,6 +14,7 @@ Portal displaying our projects that are documented with OpenAPI.
Create a file named `.env.local` in the root of the project with the following contents. Make sure to replace any placeholders and generate a random secret using OpenSSL.
```
+SHAPE_DOCS_BASE_URL='https://docs.shapetools.io'
AUTH0_SECRET='use [openssl rand -hex 32] to generate a 32 bytes value'
AUTH0_BASE_URL='http://dev.local:3000'
AUTH0_ISSUER_BASE_URL='https://shape-docs-dev.eu.auth0.com'
@@ -22,9 +28,31 @@ GITHUB_CLIENT_SECRET='GitHub App client secret'
GITHUB_APP_ID='the GitHub App id'
GITHUB_PRIVATE_KEY_BASE_64='base 64 encoded version of the private key'
GITHUB_WEBHOOK_SECRET='preshared secret also put in app conf in GitHub'
+GITHUB_WEBHOK_REPOSITORY_ALLOWLIST=''
+GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST=''
```
-Note that you need the following two Auth0 apps.
+Each environment variable is described in the table below.
+
+|Environment Variable|Description|
+|-|-|
+|SHAPE_DOCS_BASE_URL|The URL where Shape Docs is hosted.|
+|AUTH0_SECRET|A long secret value used to encrypt the session cookie. Generate it using `openssl rand -hex 32`.|AUTH0_BASE_URL|The base URL of your Auth0 application. `http://dev.local:3000` during development.|
+|AUTH0_ISSUER_BASE_URL|The URL of your Auth0 tenant domain.|
+|AUTH0_CLIENT_ID|The client ID of your default Auth0 application.|
+|AUTH0_CLIENT_SECRET|The client secret of your default Auth0 application.|
+|AUTH0_MANAGEMENT_DOMAIN|The URL of your Auth0 tenant domain. It is key that this does not contain "http" or "https".|
+|AUTH0_MANAGEMENT_CLIENT_ID|The client ID of your Auth0 Machine to Machine application.|
+|AUTH0_MANAGEMENT_CLIENT_SECRET|The client secret of your Machine to Machine Auth0 application.|
+|GITHUB_CLIENT_ID|The client ID of your GitHub app.|
+|GITHUB_CLIENT_SECRET|The client secret of your GitHub app.|
+|GITHUB_APP_ID|The ID of your GitHub app.|
+|GITHUB_PRIVATE_KEY_BASE_64|Your GitHub app's private key encoded to base 64. Can be created using `cat my-key.pem | base64 | pbcopy`.|
+|GITHUB_WEBHOOK_SECRET|Secret shared with the GitHub app to validate a webhook call.|
+|GITHUB_WEBHOK_REPOSITORY_ALLOWLIST|Comma-separated list of repositories from which webhook calls should be accepted. Leave empty to accept calls from all repositories.|
+|GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST|Comma-separated list of repositories from which webhook calls should be ignored. The list of disallowed repositories takes precedence over the list of allowed repositories.|
+
+You need the following two Auth0 apps.
| |Type|Description|
|-|-|-|
diff --git a/__test__/auth/AccessTokenService.test.ts b/__test__/auth/AccessTokenService.test.ts
new file mode 100644
index 00000000..907e7914
--- /dev/null
+++ b/__test__/auth/AccessTokenService.test.ts
@@ -0,0 +1,104 @@
+import AccessTokenService from "../../src/features/auth/domain/AccessTokenService"
+import { IOAuthToken } from "../../src/features/auth/domain/IOAuthTokenRepository"
+
+test("It reads the access token from the repository", async () => {
+ const sut = new AccessTokenService({
+ async getOAuthToken() {
+ return {
+ accessToken: "foo",
+ refreshToken: "bar",
+ accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000),
+ refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000)
+ }
+ },
+ async storeOAuthToken(_token: IOAuthToken) {}
+ }, {
+ async refreshAccessToken(_refreshToken: string) {
+ return {
+ accessToken: "foo",
+ refreshToken: "bar",
+ accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000),
+ refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000)
+ }
+ }
+ })
+ const accessToken = await sut.getAccessToken()
+ expect(accessToken).toBe("foo")
+})
+
+test("It refreshes an expired access token", async () => {
+ const sut = new AccessTokenService({
+ async getOAuthToken() {
+ return {
+ accessToken: "old",
+ refreshToken: "bar",
+ accessTokenExpiryDate: new Date(new Date().getTime() - 3600 * 1000),
+ refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000)
+ }
+ },
+ async storeOAuthToken(_token: IOAuthToken) {}
+ }, {
+ async refreshAccessToken(_refreshToken: string) {
+ return {
+ accessToken: "new",
+ refreshToken: "bar",
+ accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000),
+ refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000)
+ }
+ }
+ })
+ const accessToken = await sut.getAccessToken()
+ expect(accessToken).toBe("new")
+})
+
+test("It stores the refreshed access token", async () => {
+ let didStoreRefreshedToken = false
+ const sut = new AccessTokenService({
+ async getOAuthToken() {
+ return {
+ accessToken: "old",
+ refreshToken: "bar",
+ accessTokenExpiryDate: new Date(new Date().getTime() - 3600 * 1000),
+ refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000)
+ }
+ },
+ async storeOAuthToken(_token: IOAuthToken) {
+ didStoreRefreshedToken = true
+ }
+ }, {
+ async refreshAccessToken(_refreshToken: string) {
+ return {
+ accessToken: "new",
+ refreshToken: "bar",
+ accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000),
+ refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000)
+ }
+ }
+ })
+ await sut.getAccessToken()
+ expect(didStoreRefreshedToken).toBeTruthy()
+})
+
+test("It errors when the refresh token has expired", async () => {
+ const sut = new AccessTokenService({
+ async getOAuthToken() {
+ return {
+ accessToken: "old",
+ refreshToken: "bar",
+ accessTokenExpiryDate: new Date(new Date().getTime() - 3600 * 1000),
+ refreshTokenExpiryDate: new Date(new Date().getTime() - 3600 * 1000)
+ }
+ },
+ async storeOAuthToken(_token: IOAuthToken) {}
+ }, {
+ async refreshAccessToken(_refreshToken: string) {
+ return {
+ accessToken: "new",
+ refreshToken: "bar",
+ accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000),
+ refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000)
+ }
+ }
+ })
+ await expect(sut.getAccessToken()).rejects.toThrow()
+})
\ No newline at end of file
diff --git a/__test__/auth/IdentityAccessTokenProvider.test.ts b/__test__/auth/IdentityAccessTokenProvider.test.ts
deleted file mode 100644
index 120e005f..00000000
--- a/__test__/auth/IdentityAccessTokenProvider.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { IdentityAccessTokenProvider } from "../../src/lib/auth/IdentityAccessTokenProvider"
-
-test("It finds the access token for the specified provider", async () => {
- const sut = new IdentityAccessTokenProvider({
- async getUserDetails() {
- return {identities: [{provider: "github", accessToken: "foo"}]}
- }
- }, "github")
- const accessToken = await sut.getAccessToken()
- expect(accessToken).toBe("foo")
-})
-
-test("It errors when access token could not be found for provider", async () => {
- const sut = new IdentityAccessTokenProvider({
- async getUserDetails() {
- return {identities: [{provider: "google", accessToken: "foo"}]}
- }
- }, "github")
- expect(sut.getAccessToken()).rejects.toThrow()
-})
-
-test("It errors when there are no identities", async () => {
- const sut = new IdentityAccessTokenProvider({
- async getUserDetails() {
- return {identities: []}
- }
- }, "github")
- expect(sut.getAccessToken()).rejects.toThrow()
-})
diff --git a/__test__/hooks/ExistingCommentCheckingPullRequestEventHandler.test.ts b/__test__/hooks/ExistingCommentCheckingPullRequestEventHandler.test.ts
new file mode 100644
index 00000000..4b80d11e
--- /dev/null
+++ b/__test__/hooks/ExistingCommentCheckingPullRequestEventHandler.test.ts
@@ -0,0 +1,119 @@
+import ExistingCommentCheckingPullRequestEventHandler from "../../src/features/hooks/domain/ExistingCommentCheckingPullRequestEventHandler"
+
+test("It fetches comments from the repository", async () => {
+ let didFetchComments = false
+ const sut = new ExistingCommentCheckingPullRequestEventHandler({
+ async pullRequestOpened(_event) {}
+ }, {
+ async getComments(_operation) {
+ didFetchComments = true
+ return []
+ },
+ async addComment(_operation) {}
+ }, "https://docs.shapetools.io")
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "foo",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(didFetchComments).toBeTruthy()
+})
+
+test("It does calls decorated event handler if a comment does not exist in the repository", async () => {
+ let didCallEventHandler = false
+ const sut = new ExistingCommentCheckingPullRequestEventHandler({
+ async pullRequestOpened(_event) {
+ didCallEventHandler = true
+ }
+ }, {
+ async getComments(_operation) {
+ return []
+ },
+ async addComment(_operation) {}
+ }, "https://docs.shapetools.io")
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "foo",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(didCallEventHandler).toBeTruthy()
+})
+
+test("It does not call the event handler if a comment already exists in the repository", async () => {
+ let didCallEventHandler = false
+ const sut = new ExistingCommentCheckingPullRequestEventHandler({
+ async pullRequestOpened(_event) {
+ didCallEventHandler = true
+ }
+ }, {
+ async getComments(_operation) {
+ return [{
+ body: "The documentation is available on https://docs.shapetools.io",
+ isFromBot: true
+ }]
+ },
+ async addComment(_operation) {}
+ }, "https://docs.shapetools.io")
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "foo",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(didCallEventHandler).toBeFalsy()
+})
+
+test("It calls the event handler if a comment exists matching the needle domain but that comment is not from a bot", async () => {
+ let didCallEventHandler = false
+ const sut = new ExistingCommentCheckingPullRequestEventHandler({
+ async pullRequestOpened(_event) {
+ didCallEventHandler = true
+ }
+ }, {
+ async getComments(_operation) {
+ return [{
+ body: "The documentation is available on https://docs.shapetools.io",
+ isFromBot: false
+ }]
+ },
+ async addComment(_operation) {}
+ }, "https://docs.shapetools.io")
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "foo",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(didCallEventHandler).toBeTruthy()
+})
+
+test("It calls the event handler if the repository contains a comment from a bot but that comment does not contain the needle domain", async () => {
+ let didCallEventHandler = false
+ const sut = new ExistingCommentCheckingPullRequestEventHandler({
+ async pullRequestOpened(_event) {
+ didCallEventHandler = true
+ }
+ }, {
+ async getComments(_operation) {
+ return [{
+ body: "Hello world!",
+ isFromBot: true
+ }]
+ },
+ async addComment(_operation) {}
+ }, "https://docs.shapetools.io")
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "foo",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(didCallEventHandler).toBeTruthy()
+})
diff --git a/__test__/hooks/GitHubCommentFactory.test.ts b/__test__/hooks/GitHubCommentFactory.test.ts
new file mode 100644
index 00000000..8347ab03
--- /dev/null
+++ b/__test__/hooks/GitHubCommentFactory.test.ts
@@ -0,0 +1,8 @@
+import GitHubCommentFactory from "../../src/features/hooks/domain/GitHubCommentFactory"
+
+test("It includes a link to the documentation", async () => {
+ const text = GitHubCommentFactory.makeDocumentationPreviewReadyComment(
+ "https://docs.shapetools.io/foo/bar"
+ )
+ expect(text).toContain("https://docs.shapetools.io/foo/bar")
+})
\ No newline at end of file
diff --git a/__test__/hooks/PostCommentPullRequestEventHandler.test.ts b/__test__/hooks/PostCommentPullRequestEventHandler.test.ts
new file mode 100644
index 00000000..2b2fe619
--- /dev/null
+++ b/__test__/hooks/PostCommentPullRequestEventHandler.test.ts
@@ -0,0 +1,61 @@
+import PostCommentPullRequestEventHandler from "../../src/features/hooks/domain/PostCommentPullRequestEventHandler"
+
+test("It adds a comment to the repository", async () => {
+ let didAddComment = false
+ const sut = new PostCommentPullRequestEventHandler({
+ async getComments(_operation) {
+ return []
+ },
+ async addComment(_operation) {
+ didAddComment = true
+ }
+ }, "https://docs.shapetools.io")
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "foo",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(didAddComment).toBeTruthy()
+})
+
+test("It adds a comment containing a link to the documentation", async () => {
+ let commentBody: string | undefined
+ const sut = new PostCommentPullRequestEventHandler({
+ async getComments(_operation) {
+ return []
+ },
+ async addComment(operation) {
+ commentBody = operation.body
+ }
+ }, "https://docs.shapetools.io")
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "foo",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(commentBody).toContain("https://docs.shapetools.io/foo/bar")
+})
+
+test("It removes the \"openapi\" suffix of the repository name", async () => {
+ let commentBody: string | undefined
+ const sut = new PostCommentPullRequestEventHandler({
+ async getComments(_operation) {
+ return []
+ },
+ async addComment(operation) {
+ commentBody = operation.body
+ }
+ }, "https://docs.shapetools.io")
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "foo-openapi",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(commentBody).toContain("https://docs.shapetools.io/foo/bar")
+})
diff --git a/__test__/hooks/RepositoryNameCheckingPullRequestEventHandler.test.ts b/__test__/hooks/RepositoryNameCheckingPullRequestEventHandler.test.ts
new file mode 100644
index 00000000..f527577f
--- /dev/null
+++ b/__test__/hooks/RepositoryNameCheckingPullRequestEventHandler.test.ts
@@ -0,0 +1,103 @@
+import RepositoryNameCheckingPullRequestEventHandler from "../../src/features/hooks/domain/RepositoryNameCheckingPullRequestEventHandler"
+
+test("It does not call event handler when repository name does not have \"-openapi\" suffix", async () => {
+ let didCallEventHandler = false
+ const sut = new RepositoryNameCheckingPullRequestEventHandler({
+ async pullRequestOpened(_event) {
+ didCallEventHandler = true
+ }
+ }, [], [])
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "foo",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(didCallEventHandler).toBeFalsy()
+})
+
+test("It does not call event handler when repository name contains \"-openapi\" but it is not the last part of the repository name", async () => {
+ let didCallEventHandler = false
+ const sut = new RepositoryNameCheckingPullRequestEventHandler({
+ async pullRequestOpened(_event) {
+ didCallEventHandler = true
+ }
+ }, [], [])
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "foo-openapi-bar",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(didCallEventHandler).toBeFalsy()
+})
+
+test("It calls event handler when no repositories have been allowed or disallowed", async () => {
+ let didCallEventHandler = false
+ const sut = new RepositoryNameCheckingPullRequestEventHandler({
+ async pullRequestOpened(_event) {
+ didCallEventHandler = true
+ }
+ }, [], [])
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "foo-openapi",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(didCallEventHandler).toBeTruthy()
+})
+
+test("It does not call event handler for repository that is not on the allowlist", async () => {
+ let didCallEventHandler = false
+ const sut = new RepositoryNameCheckingPullRequestEventHandler({
+ async pullRequestOpened(_event) {
+ didCallEventHandler = true
+ }
+ }, ["example-openapi"], [])
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "foo",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(didCallEventHandler).toBeFalsy()
+})
+
+test("It does not call event handler for repository that is on the disallowlist", async () => {
+ let didCallEventHandler = false
+ const sut = new RepositoryNameCheckingPullRequestEventHandler({
+ async pullRequestOpened(_event) {
+ didCallEventHandler = true
+ }
+ }, [], ["example-openapi"])
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "example-openapi",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(didCallEventHandler).toBeFalsy()
+})
+
+test("It lets the disallowlist takes precedence over the allowlist", async () => {
+ let didCallEventHandler = false
+ const sut = new RepositoryNameCheckingPullRequestEventHandler({
+ async pullRequestOpened(_event) {
+ didCallEventHandler = true
+ }
+ }, ["example-openapi"], ["example-openapi"])
+ await sut.pullRequestOpened({
+ appInstallationId: 42,
+ repositoryOwner: "shapehq",
+ repositoryName: "example-openapi",
+ ref: "bar",
+ pullRequestNumber: 1337
+ })
+ expect(didCallEventHandler).toBeFalsy()
+})
diff --git a/__test__/projects/ProjectConfigParser.test.ts b/__test__/projects/ProjectConfigParser.test.ts
index ccb07fee..ba5c5fdc 100644
--- a/__test__/projects/ProjectConfigParser.test.ts
+++ b/__test__/projects/ProjectConfigParser.test.ts
@@ -1,4 +1,4 @@
-import { ProjectConfigParser } from "../../src/lib/projects/ProjectConfigParser"
+import ProjectConfigParser from "../../src/features/projects/domain/ProjectConfigParser"
test("It parses an empty string", async () => {
const sut = new ProjectConfigParser()
diff --git a/__test__/projects/ProjectPageState.test.ts b/__test__/projects/ProjectPageState.test.ts
new file mode 100644
index 00000000..a67bf3dd
--- /dev/null
+++ b/__test__/projects/ProjectPageState.test.ts
@@ -0,0 +1,276 @@
+import {
+ getProjectPageState,
+ ProjectPageState
+} from "../../src/features/projects/domain/ProjectPageState"
+
+test("It enters the loading state", async () => {
+ const sut = getProjectPageState({ isLoading: true })
+ expect(sut.state).toEqual(ProjectPageState.LOADING)
+})
+
+test("It enters the error state", async () => {
+ const sut = getProjectPageState({
+ isLoading: false,
+ error: new Error("foo")
+ })
+ expect(sut.state).toEqual(ProjectPageState.ERROR)
+ expect(sut.error).toEqual(new Error("foo"))
+})
+
+test("It gracefully errors when no project has been selected", async () => {
+ const sut = getProjectPageState({
+ projects: [{
+ id: "foo",
+ name: "foo",
+ versions: []
+ }, {
+ id: "bar",
+ name: "bar",
+ versions: []
+ }]
+ })
+ expect(sut.state).toEqual(ProjectPageState.NO_PROJECT_SELECTED)
+})
+
+test("It selects the first project when there is only one project", async () => {
+ const sut = getProjectPageState({
+ projects: [{
+ id: "foo",
+ name: "foo",
+ versions: [{
+ id: "bar",
+ name: "bar",
+ specifications: [{
+ id: "hello",
+ name: "hello.yml",
+ url: "https://example.com/hello.yml"
+ }]
+ }]
+ }]
+ })
+ expect(sut.state).toEqual(ProjectPageState.HAS_SELECTION)
+ expect(sut.selection!.project.id).toEqual("foo")
+ expect(sut.selection!.version.id).toEqual("bar")
+ expect(sut.selection!.specification.id).toEqual("hello")
+})
+
+test("It selects the first version and specification of the specified project", async () => {
+ const sut = getProjectPageState({
+ selectedProjectId: "bar",
+ projects: [{
+ id: "foo",
+ name: "foo",
+ versions: []
+ }, {
+ id: "bar",
+ name: "bar",
+ versions: [{
+ id: "baz1",
+ name: "baz1",
+ specifications: [{
+ id: "hello1",
+ name: "hello1.yml",
+ url: "https://example.com/hello.yml"
+ }, {
+ id: "hello2",
+ name: "hello2.yml",
+ url: "https://example.com/hello.yml"
+ }]
+ }, {
+ id: "baz2",
+ name: "baz2",
+ specifications: []
+ }]
+ }]
+ })
+ expect(sut.state).toEqual(ProjectPageState.HAS_SELECTION)
+ expect(sut.selection!.project.id).toEqual("bar")
+ expect(sut.selection!.version.id).toEqual("baz1")
+ expect(sut.selection!.specification.id).toEqual("hello1")
+})
+
+test("It selects the first specification of the specified project and version", async () => {
+ const sut = getProjectPageState({
+ selectedProjectId: "bar",
+ selectedVersionId: "baz2",
+ projects: [{
+ id: "foo",
+ name: "foo",
+ versions: []
+ }, {
+ id: "bar",
+ name: "bar",
+ versions: [{
+ id: "baz1",
+ name: "baz1",
+ specifications: []
+ }, {
+ id: "baz2",
+ name: "baz2",
+ specifications: [{
+ id: "hello1",
+ name: "hello1.yml",
+ url: "https://example.com/hello.yml"
+ }]
+ }]
+ }]
+ })
+ expect(sut.state).toEqual(ProjectPageState.HAS_SELECTION)
+ expect(sut.selection!.project.id).toEqual("bar")
+ expect(sut.selection!.version.id).toEqual("baz2")
+ expect(sut.selection!.specification.id).toEqual("hello1")
+})
+
+test("It selects the specification of the specified version", async () => {
+ const sut = getProjectPageState({
+ selectedProjectId: "bar",
+ selectedVersionId: "baz2",
+ projects: [{
+ id: "foo",
+ name: "foo",
+ versions: []
+ }, {
+ id: "bar",
+ name: "bar",
+ versions: [{
+ id: "baz1",
+ name: "baz1",
+ specifications: []
+ }, {
+ id: "baz2",
+ name: "baz2",
+ specifications: [{
+ id: "hello1",
+ name: "hello1.yml",
+ url: "https://example.com/hello.yml"
+ }, {
+ id: "hello2",
+ name: "hello2.yml",
+ url: "https://example.com/hello.yml"
+ }]
+ }]
+ }]
+ })
+ expect(sut.state).toEqual(ProjectPageState.HAS_SELECTION)
+ expect(sut.selection!.project.id).toEqual("bar")
+ expect(sut.selection!.version.id).toEqual("baz2")
+ expect(sut.selection!.specification.id).toEqual("hello1")
+})
+
+test("It selects the specified project, version, and specification", async () => {
+ const sut = getProjectPageState({
+ selectedProjectId: "bar",
+ selectedVersionId: "baz2",
+ selectedSpecificationId: "hello2",
+ projects: [{
+ id: "foo",
+ name: "foo",
+ versions: []
+ }, {
+ id: "bar",
+ name: "bar",
+ versions: [{
+ id: "baz1",
+ name: "baz1",
+ specifications: []
+ }, {
+ id: "baz2",
+ name: "baz2",
+ specifications: [{
+ id: "hello1",
+ name: "hello1.yml",
+ url: "https://example.com/hello.yml"
+ }, {
+ id: "hello2",
+ name: "hello2.yml",
+ url: "https://example.com/hello.yml"
+ }]
+ }]
+ }]
+ })
+ expect(sut.state).toEqual(ProjectPageState.HAS_SELECTION)
+ expect(sut.selection!.project.id).toEqual("bar")
+ expect(sut.selection!.version.id).toEqual("baz2")
+ expect(sut.selection!.specification.id).toEqual("hello2")
+})
+
+test("It errors when the selected project cannot be found", async () => {
+ const sut = getProjectPageState({
+ selectedProjectId: "foo",
+ projects: [{
+ id: "bar",
+ name: "bar",
+ versions: []
+ }]
+ })
+ expect(sut.state).toEqual(ProjectPageState.PROJECT_NOT_FOUND)
+})
+
+test("It errors when the selected version cannot be found", async () => {
+ const sut = getProjectPageState({
+ selectedProjectId: "foo",
+ selectedVersionId: "bar",
+ projects: [{
+ id: "foo",
+ name: "foo",
+ versions: [{
+ id: "baz",
+ name: "baz",
+ specifications: []
+ }]
+ }]
+ })
+ expect(sut.state).toEqual(ProjectPageState.VERSION_NOT_FOUND)
+})
+
+test("It errors when the selected specification cannot be found", async () => {
+ const sut = getProjectPageState({
+ selectedProjectId: "foo",
+ selectedVersionId: "bar",
+ selectedSpecificationId: "baz",
+ projects: [{
+ id: "foo",
+ name: "foo",
+ versions: [{
+ id: "bar",
+ name: "bar",
+ specifications: [{
+ id: "hello",
+ name: "hello.yml",
+ url: "https://example.com/hello.yml"
+ }]
+ }]
+ }]
+ })
+ expect(sut.state).toEqual(ProjectPageState.SPECIFICATION_NOT_FOUND)
+})
+
+test("It errors when the selected project has no versions", async () => {
+ const sut = getProjectPageState({
+ selectedProjectId: "foo",
+ projects: [{
+ id: "foo",
+ name: "foo",
+ versions: []
+ }]
+ })
+ expect(sut.state).toEqual(ProjectPageState.VERSION_NOT_FOUND)
+})
+
+test("It errors when the selected version has no specifications", async () => {
+ const sut = getProjectPageState({
+ selectedProjectId: "foo",
+ selectedVersionId: "bar",
+ projects: [{
+ id: "foo",
+ name: "foo",
+ versions: [{
+ id: "bar",
+ name: "bar",
+ specifications: []
+ }]
+ }]
+ })
+ expect(sut.state).toEqual(ProjectPageState.SPECIFICATION_NOT_FOUND)
+})
+
diff --git a/__test__/projects/projectNavigator.test.ts b/__test__/projects/projectNavigator.test.ts
new file mode 100644
index 00000000..9e601d2c
--- /dev/null
+++ b/__test__/projects/projectNavigator.test.ts
@@ -0,0 +1,181 @@
+import ProjectPageSelection from "../../src/features/projects/domain/ProjectPageSelection"
+import projectNavigator from "../../src/features/projects/domain/projectNavigator"
+
+test("It navigates to first specification when changing version", async () => {
+ const selection: ProjectPageSelection = {
+ project: {
+ id: "foo",
+ name: "foo",
+ versions: [{
+ id: "bar",
+ name: "bar",
+ specifications: [{
+ id: "baz.yml",
+ name: "baz.yml",
+ url: "https://example.com/baz.yml"
+ }]
+ }, {
+ id: "hello",
+ name: "hello",
+ specifications: [{
+ id: "world.yml",
+ name: "world.yml",
+ url: "https://example.com/world.yml"
+ }]
+ }]
+ },
+ version: {
+ id: "bar",
+ name: "bar",
+ specifications: []
+ },
+ specification: {
+ id: "baz.yml",
+ name: "baz.yml",
+ url: "https://example.com/baz.yml"
+ }
+ }
+ let pushedPath: string | undefined
+ const router = {
+ push: (path: string) => {
+ pushedPath = path
+ },
+ replace: (_path: string) => {}
+ }
+ projectNavigator.navigateToVersion(selection, "hello", router)
+ expect(pushedPath).toEqual("/foo/hello/world.yml")
+})
+
+test("It navigates when selecting specification", async () => {
+ const selection: ProjectPageSelection = {
+ project: {
+ id: "foo",
+ name: "foo",
+ versions: [{
+ id: "bar",
+ name: "bar",
+ specifications: [{
+ id: "hello.yml",
+ name: "hello.yml",
+ url: "https://example.com/hello.yml"
+ }]
+ }]
+ },
+ version: {
+ id: "bar",
+ name: "bar",
+ specifications: [{
+ id: "hello.yml",
+ name: "hello.yml",
+ url: "https://example.com/hello.yml"
+ }]
+ },
+ specification: {
+ id: "baz.yml",
+ name: "baz.yml",
+ url: "https://example.com/baz.yml"
+ }
+ }
+ let pushedPath: string | undefined
+ const router = {
+ push: (path: string) => {
+ pushedPath = path
+ },
+ replace: (_path: string) => {}
+ }
+ projectNavigator.navigateToSpecification(selection, "hello.yml", router)
+ expect(pushedPath).toEqual("/foo/bar/hello.yml")
+})
+
+test("It navigates even when new specification could not be found", async () => {
+ // We allow navigating to a specification that could not be found
+ // and rely on the view to show an error saying that the specification
+ // could not be found.
+ const selection: ProjectPageSelection = {
+ project: {
+ id: "foo",
+ name: "foo",
+ versions: []
+ },
+ version: {
+ id: "bar",
+ name: "bar",
+ specifications: []
+ },
+ specification: {
+ id: "baz.yml",
+ name: "baz.yml",
+ url: "https://example.com/baz.yml"
+ }
+ }
+ let pushedPath: string | undefined
+ const router = {
+ push: (path: string) => {
+ pushedPath = path
+ },
+ replace: (_path: string) => {}
+ }
+ projectNavigator.navigateToSpecification(selection, "hello.yml", router)
+ expect(pushedPath).toBe("/foo/bar/hello.yml")
+})
+
+test("It finds a specification with the same name when changing version", async () => {
+ const selection: ProjectPageSelection = {
+ project: {
+ id: "foo",
+ name: "foo",
+ versions: [{
+ id: "bar",
+ name: "bar",
+ specifications: [{
+ id: "hello.yml",
+ name: "hello.yml",
+ url: "https://example.com/hello.yml"
+ }, {
+ id: "earth.yml",
+ name: "earth.yml",
+ url: "https://example.com/earth.yml"
+ }]
+ }, {
+ id: "baz",
+ name: "baz",
+ specifications: [{
+ id: "moon.yml",
+ name: "moon.yml",
+ url: "https://example.com/moon.yml"
+ }, {
+ id: "saturn.yml",
+ name: "saturn.yml",
+ url: "https://example.com/saturn.yml"
+ }, {
+ id: "earth.yml",
+ name: "earth.yml",
+ url: "https://example.com/earth.yml"
+ }, {
+ id: "jupiter.yml",
+ name: "jupiter.yml",
+ url: "https://example.com/jupiter.yml"
+ }]
+ }]
+ },
+ version: {
+ id: "bar",
+ name: "bar",
+ specifications: []
+ },
+ specification: {
+ id: "earth.yml",
+ name: "earth.yml",
+ url: "https://example.com/earth.yml"
+ }
+ }
+ let pushedPath: string | undefined
+ const router = {
+ push: (path: string) => {
+ pushedPath = path
+ },
+ replace: (_path: string) => {}
+ }
+ projectNavigator.navigateToVersion(selection, "baz", router)
+ expect(pushedPath).toEqual("/foo/baz/earth.yml")
+})
\ No newline at end of file
diff --git a/logo.png b/logo.png
new file mode 100644
index 00000000..9895edf6
Binary files /dev/null and b/logo.png differ
diff --git a/package-lock.json b/package-lock.json
index 6d8f1b8e..9916eef2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,8 +17,8 @@
"@octokit/core": "^5.0.1",
"@octokit/webhooks": "^12.0.3",
"auth0": "^4.0.1",
- "axios": "^1.5.1",
"core-js": "^3.33.0",
+ "encoding": "^0.1.13",
"mobx": "^6.10.2",
"next": "13.5.4",
"octokit": "^3.1.1",
@@ -27,6 +27,8 @@
"redoc": "^2.1.2",
"styled-components": "^6.0.8",
"swagger-ui-react": "^5.9.0",
+ "swr": "^2.2.4",
+ "usehooks-ts": "^2.9.1",
"yaml": "^2.3.2"
},
"devDependencies": {
@@ -42,6 +44,10 @@
"tailwindcss": "^3",
"ts-jest": "^29.1.1",
"typescript": "^5"
+ },
+ "engines": {
+ "node": "20.8.1",
+ "npm": "10.1.0"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -5995,6 +6001,14 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true
},
+ "node_modules/encoding": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
+ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
+ "dependencies": {
+ "iconv-lite": "^0.6.2"
+ }
+ },
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -7293,6 +7307,17 @@
"node": ">=10.17.0"
}
},
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -11040,6 +11065,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
"node_modules/scheduler": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
@@ -11742,6 +11772,18 @@
"node": ">= 6"
}
},
+ "node_modules/swr": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.4.tgz",
+ "integrity": "sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ==",
+ "dependencies": {
+ "client-only": "^0.0.1",
+ "use-sync-external-store": "^1.2.0"
+ },
+ "peerDependencies": {
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/tailwindcss": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz",
@@ -12323,6 +12365,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
+ "node_modules/usehooks-ts": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.9.1.tgz",
+ "integrity": "sha512-2FAuSIGHlY+apM9FVlj8/oNhd+1y+Uwv5QNkMQz1oSfdHk4PXo1qoCw9I5M7j0vpH8CSWFJwXbVPeYDjLCx9PA==",
+ "engines": {
+ "node": ">=16.15.0",
+ "npm": ">=8"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
diff --git a/package.json b/package.json
index 034fbdcc..ccf78b39 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,10 @@
"name": "shape-docs",
"version": "0.1.0",
"private": true,
+ "engines": {
+ "node": "20.8.1",
+ "npm": "10.1.0"
+ },
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -19,8 +23,8 @@
"@octokit/core": "^5.0.1",
"@octokit/webhooks": "^12.0.3",
"auth0": "^4.0.1",
- "axios": "^1.5.1",
"core-js": "^3.33.0",
+ "encoding": "^0.1.13",
"mobx": "^6.10.2",
"next": "13.5.4",
"octokit": "^3.1.1",
@@ -29,6 +33,8 @@
"redoc": "^2.1.2",
"styled-components": "^6.0.8",
"swagger-ui-react": "^5.9.0",
+ "swr": "^2.2.4",
+ "usehooks-ts": "^2.9.1",
"yaml": "^2.3.2"
},
"devDependencies": {
diff --git a/public/redocly.png b/public/redocly.png
deleted file mode 100644
index bc3c35dc..00000000
Binary files a/public/redocly.png and /dev/null differ
diff --git a/public/shape2023.svg b/public/shape2023.svg
deleted file mode 100644
index 2f9aece0..00000000
--- a/public/shape2023.svg
+++ /dev/null
@@ -1,77 +0,0 @@
-
diff --git a/public/swagger.png b/public/swagger.png
deleted file mode 100644
index 9776be93..00000000
Binary files a/public/swagger.png and /dev/null differ
diff --git a/src/app/[...slug]/page.tsx b/src/app/[...slug]/page.tsx
index aa4c7d52..5c2cf968 100644
--- a/src/app/[...slug]/page.tsx
+++ b/src/app/[...slug]/page.tsx
@@ -1,91 +1,26 @@
-import ProjectListComponent from "@/lib/components/ProjectListComponent";
-import UserComponent from "@/lib/components/UserComponent";
-import DocumentationViewerPage from "@/lib/pages/DocumentationViewerPage";
-import {
- userProvider,
- projectRepository,
- gitHubOpenApiSpecificationRepository,
- githubVersionRepository,
-} from "../startup";
-import App from "@/lib/pages/App";
-import OpenApiSpecificationsComponent from "@/lib/components/OpenApiSpecificationsComponent";
-import VersionsComponent from "@/lib/components/VersionsComponent";
-import { getProject, getSpecification, getVersion } from "@/lib/utils/UrlUtils";
-import { IOpenApiSpecification } from "@/lib/projects/IOpenAPISpecification";
-import WelcomePage from "@/lib/pages/WelcomePage";
+import { getProjectId, getSpecificationId, getVersionId } from "@/common/UrlUtils"
+import ProjectsPage from "@/features/projects/view/ProjectsPage"
-export default async function Page({
- params,
-}: {
- params: { slug: string | string[] };
-}) {
- const user = await userProvider.getUser();
- let url: string;
+type PageParams = { slug: string | string[] }
+
+export default function Page({ params }: { params: PageParams }) {
+ const url = getURL(params)
+ return (
+
+ )
+}
+
+function getURL(params: PageParams) {
if (typeof params.slug === "string") {
- url = "/" + params.slug;
+ return "/" + params.slug
} else {
- url = params.slug.reduce(
+ return params.slug.reduce(
(previousValue, currentValue) => `${previousValue}/${currentValue}`,
""
- );
+ )
}
- let openApiSpecification: IOpenApiSpecification | undefined;
- const projectName = getProject(url);
- const versionName = getVersion(url);
- const specificationName = getSpecification(url);
- if (projectName && versionName && specificationName) {
- const specifications =
- await gitHubOpenApiSpecificationRepository.getOpenAPISpecifications({
- name: versionName,
- owner: "shapehq",
- repository: projectName,
- });
- openApiSpecification = specifications.find(
- (x) => x.name == specificationName
- );
- }
-
- return (
- }
- projectListComponent={
-
- }
- {...(projectName && projectName.length > 0
- ? {
- versionSelectorComponent: (
-
- ),
- }
- : {})}
- {...(projectName &&
- projectName.length > 0 &&
- versionName &&
- versionName.length > 0
- ? {
- openApiSpecificationsComponent: (
-
- ),
- }
- : {})}
- >
- {openApiSpecification ? (
-
- ) : (
-
- )}
-
- );
-}
+}
\ No newline at end of file
diff --git a/src/app/api/github/blob/[owner]/[repository]/[...path]/route.ts b/src/app/api/github/blob/[owner]/[repository]/[...path]/route.ts
new file mode 100644
index 00000000..3c0aca58
--- /dev/null
+++ b/src/app/api/github/blob/[owner]/[repository]/[...path]/route.ts
@@ -0,0 +1,26 @@
+import { Octokit } from "octokit"
+import { NextRequest, NextResponse } from "next/server"
+import { accessTokenService } from "@/common/startup"
+
+type GitHubContentItem = {download_url: string}
+
+interface GetBlobParams {
+ owner: string
+ repository: string
+ path: [string]
+}
+
+export async function GET(req: NextRequest, { params }: { params: GetBlobParams }) {
+ const accessToken = await accessTokenService.getAccessToken()
+ const octokit = new Octokit({ auth: accessToken })
+ const fullPath = params.path.join("/")
+ const ref = req.nextUrl.searchParams.get("ref")
+ const response = await octokit.rest.repos.getContent({
+ owner: params.owner,
+ repo: params.repository,
+ path: fullPath,
+ ref: ref || undefined
+ })
+ let item = response.data as GitHubContentItem
+ return NextResponse.redirect(new URL(item.download_url))
+}
diff --git a/src/app/api/hooks/github/route.ts b/src/app/api/hooks/github/route.ts
index c445cbc7..5d227f36 100644
--- a/src/app/api/hooks/github/route.ts
+++ b/src/app/api/hooks/github/route.ts
@@ -1,123 +1,52 @@
-import { createAppAuth } from "@octokit/auth-app";
-import { Webhooks } from "@octokit/webhooks";
import { NextRequest, NextResponse } from "next/server"
-import { Octokit } from "octokit";
-import { Repository, PullRequest } from "@octokit/webhooks-types";
+import GitHubHookHandler from "@/features/hooks/data/GitHubHookHandler"
+import GitHubPullRequestCommentRepository from "@/features/hooks/data/GitHubPullRequestCommentRepository"
+import PostCommentPullRequestEventHandler from "@/features/hooks/domain/PostCommentPullRequestEventHandler"
+import RepositoryNameCheckingPullRequestEventHandler from "@/features/hooks/domain/RepositoryNameCheckingPullRequestEventHandler"
+import ExistingCommentCheckingPullRequestEventHandler from "@/features/hooks/domain/ExistingCommentCheckingPullRequestEventHandler"
-// load env vars
const {
- GITHUB_APP_ID,
- GITHUB_CLIENT_ID,
- GITHUB_CLIENT_SECRET,
- GITHUB_PRIVATE_KEY_BASE_64,
- GITHUB_WEBHOOK_SECRET
+ SHAPE_DOCS_BASE_URL,
+ GITHUB_APP_ID,
+ GITHUB_CLIENT_ID,
+ GITHUB_CLIENT_SECRET,
+ GITHUB_PRIVATE_KEY_BASE_64,
+ GITHUB_WEBHOOK_SECRET,
+ GITHUB_WEBHOK_REPOSITORY_ALLOWLIST,
+ GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST
} = process.env
-const privateKey = Buffer.from(GITHUB_PRIVATE_KEY_BASE_64!!, 'base64').toString('utf-8')
-// we will only add comments in these repos for now
-const repositoryWhitelist = [
- 'example-openapi',
- 'test-openapi',
- 'moonboon-openapi'
-]
-
-const webhooks = new Webhooks({ secret: GITHUB_WEBHOOK_SECRET!! })
-
-const auth = createAppAuth({
- appId: GITHUB_APP_ID!!,
- privateKey: privateKey,
- clientId: GITHUB_CLIENT_ID!!,
- clientSecret: GITHUB_CLIENT_SECRET!!,
+const privateKey = Buffer.from(GITHUB_PRIVATE_KEY_BASE_64, "base64").toString("utf-8")
+const allowedRepositoryNames = (GITHUB_WEBHOK_REPOSITORY_ALLOWLIST || "")
+ .split(",")
+ .map(e => e.trim())
+const disallowedRepositoryNames = (GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST || "")
+ .split(",")
+ .map(e => e.trim())
+
+const commentRepository = new GitHubPullRequestCommentRepository({
+ appId: GITHUB_APP_ID,
+ privateKey: privateKey,
+ clientId: GITHUB_CLIENT_ID,
+ clientSecret: GITHUB_WEBHOOK_SECRET
+})
+const hookHandler = new GitHubHookHandler({
+ secret: GITHUB_WEBHOOK_SECRET,
+ pullRequestEventHandler: new RepositoryNameCheckingPullRequestEventHandler(
+ new ExistingCommentCheckingPullRequestEventHandler(
+ new PostCommentPullRequestEventHandler(
+ commentRepository,
+ SHAPE_DOCS_BASE_URL
+ ),
+ commentRepository,
+ SHAPE_DOCS_BASE_URL
+ ),
+ allowedRepositoryNames,
+ disallowedRepositoryNames
+ )
})
-
-const generateCommentBody = (repository: Repository, ref: string) => {
- const link = `https://docs.shapetools.io/${repository.name.replace("-openapi", "")}/${ref}`
-
- return `### 📖 Documentation Preview
-
-These edits are available for preview at [Shape Docs](${link}).
-
-
-
- | Status: | ✅ Ready! |
-
-
- | Preview URL: | ${link} |
-
-
`
-}
-
-const createComment = async (repository: Repository, pullRequest: PullRequest, appInstallationId: number) => {
- const installationAuthentication = await auth({
- type: "installation",
- installationId: appInstallationId,
- });
-
- const octokit = new Octokit({ auth: installationAuthentication.token })
-
- const isOpenApiRepo = repository.name.includes("openapi") && repositoryWhitelist.includes(repository.name)
-
- if (isOpenApiRepo) {
- console.log("This is an openapi repo")
- console.log("Checking if we need to create a comment")
-
- const comments = await octokit.rest.issues.listComments({ // TODO: We need to fetch all pages here
- owner: repository.owner.login,
- repo: repository.name,
- issue_number: pullRequest.number,
- })
-
- const containsOurComment = comments.data.filter(comment => comment.body?.includes("docs.shapetools.io")).length > 0
-
- if (!containsOurComment) {
- console.log("Comment does not exist - creating one")
- await octokit.rest.issues.createComment({
- owner: repository.owner.login,
- repo: repository.name,
- issue_number: pullRequest.number,
- body: generateCommentBody(repository, pullRequest.head.ref)
- })
- } else {
- console.log("Comment exists - not creating")
- }
- }
-}
-
-// keep track of processed events
-// TODO: Add cleanup for this set. It will grow forever otherwise.
-const alreadyProcessedEvents = new Set()
export const POST = async (req: NextRequest): Promise => {
- await webhooks.verifyAndReceive({
- id: req.headers.get('X-GitHub-Delivery') as string,
- name: req.headers.get('X-GitHub-Event') as any,
- payload: await req.text(),
- signature: req.headers.get('X-Hub-Signature') as string,
- }).catch((error) => {
- console.error(`Error: ${error.message}`)
- return false
- })
-
- webhooks.on("pull_request.opened", async ({ id, name, payload }) => {
- console.log(`Received event ${name}#${id} with action`, payload.action)
- if (!alreadyProcessedEvents.has(id)) {
- console.log("Processing event")
- alreadyProcessedEvents.add(id)
- await createComment(payload.repository, payload.pull_request, payload.installation!.id)
- } else {
- console.log("Already processed this event")
- }
- })
- webhooks.on("pull_request.reopened", async ({ id, name, payload }) => {
- console.log(`Received event ${name}#${id} with action`, payload.action)
- if (!alreadyProcessedEvents.has(id)) {
- console.log("Processing event")
- alreadyProcessedEvents.add(id)
- await createComment(payload.repository, payload.pull_request, payload.installation!.id)
- } else {
- console.log("Already processed this event")
- }
- })
-
- return NextResponse.json({ status: "OK" })
+ await hookHandler.handle(req)
+ return NextResponse.json({ status: "OK" })
}
diff --git a/src/app/api/user/projects/route.ts b/src/app/api/user/projects/route.ts
new file mode 100644
index 00000000..0d959f1f
--- /dev/null
+++ b/src/app/api/user/projects/route.ts
@@ -0,0 +1,7 @@
+import { NextRequest, NextResponse } from "next/server"
+import { projectRepository } from "@/common/startup"
+
+export async function GET(_req: NextRequest) {
+ const projects = await projectRepository.getProjects()
+ return NextResponse.json({projects})
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 9d1a5955..0a70c413 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2,28 +2,6 @@
@tailwind components;
@tailwind utilities;
-:root {
- --foreground-rgb: 0, 0, 0;
- --background-rgb: 255, 255, 255;
- --primary-tint: 255, 77, 91;
+html, body {
+ height: 100%;
}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --foreground-rgb: 255, 255, 255;
- --background-rgb: 0, 0, 0;
- }
-}
-
-body {
- color: rgb(var(--foreground-rgb));
- background: rgb(var(--background-rgb));
-}
-
-a {
- color: rgb(var(--primary-tint));
-}
-
-a:hover {
- text-decoration: underline;
-}
\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index d38019aa..53d809fb 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,22 +1,28 @@
-import './globals.css'
-import type { Metadata } from 'next'
-import { Inter } from 'next/font/google'
+import "./globals.css"
+import type { Metadata } from "next"
+import { Inter } from "next/font/google"
+import { CssBaseline } from "@mui/material"
+import ThemeRegistry from "@/common/client/ThemeRegistry"
+import { UserProvider } from '@auth0/nextjs-auth0/client'
-const inter = Inter({ subsets: ['latin'] })
+const inter = Inter({ subsets: ["latin"] })
export const metadata: Metadata = {
- title: 'Shape Docs',
- description: 'Documentation for Shape\'s APIs',
+ title: "Shape Docs",
+ description: "Documentation for Shape\"s APIs",
}
-export default function RootLayout({
- children,
- }: {
- children: React.ReactNode
-}) {
+export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
- {children}
+
+
+
+
+ {children}
+
+
+
)
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 6c1515d7..94c29ddc 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,19 +1,5 @@
-import UserComponent from "@/lib/components/UserComponent";
-import ProjectListComponent from "@/lib/components/ProjectListComponent";
-import App from "@/lib/pages/App";
-import WelcomePage from "@/lib/pages/WelcomePage";
-import { projectRepository, userProvider } from "./startup";
+import ProjectsPage from "@/features/projects/view/ProjectsPage"
export default async function Page() {
- const user = await userProvider.getUser();
- return (
- }
- projectListComponent={
-
- }
- >
-
-
- );
+ return
}
diff --git a/src/app/startup.ts b/src/app/startup.ts
deleted file mode 100644
index df53122a..00000000
--- a/src/app/startup.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Auth0UserDetailsProvider } from "@/lib/auth/Auth0UserDetailsProvider";
-import { Auth0UserProvider } from "@/lib/auth/Auth0UserProvider";
-import { IdentityAccessTokenProvider } from "@/lib/auth/IdentityAccessTokenProvider";
-import { DeferredGitHubClient } from "@/lib/github/DeferredGitHubClient";
-import { HardcodedGitHubOrganizationNameProvider } from "@/lib/github/HardcodedGitHubOrganizationNameProvider";
-import { OctokitGitHubClient } from "@/lib/github/OctokitGitHubClient";
-import { AxiosNetworkClient } from "@/lib/networking/AxiosNetworkClient";
-import { GitHubOpenApiSpecificationRepository } from "@/lib/projects/GitHubOpenAPISpecificationRepository";
-import { GitHubProjectRepository } from "@/lib/projects/GitHubProjectRepository";
-import { GitHubVersionRepository } from "@/lib/projects/GitHubVersionRepository";
-
-export const organizationNameProvider = new HardcodedGitHubOrganizationNameProvider(
- "shapehq"
-);
-export const userProvider = new Auth0UserProvider();
-export const userDetailsProvider = new Auth0UserDetailsProvider(userProvider, {
- domain: process.env.AUTH0_MANAGEMENT_DOMAIN,
- clientId: process.env.AUTH0_MANAGEMENT_CLIENT_ID,
- clientSecret: process.env.AUTH0_MANAGEMENT_CLIENT_SECRET,
-});
-export const accessTokenProvider = new IdentityAccessTokenProvider(
- userDetailsProvider,
- "github"
-);
-export const gitHubClientFactory = (
- accessToken: string,
- organizationName: string
-) => {
- return new OctokitGitHubClient(accessToken, organizationName);
-};
-export const gitHubClient = new DeferredGitHubClient(
- organizationNameProvider,
- accessTokenProvider,
- gitHubClientFactory
-);
-export const networkClient = new AxiosNetworkClient();
-export const projectRepository = new GitHubProjectRepository(gitHubClient, networkClient);
-export const githubVersionRepository = new GitHubVersionRepository(gitHubClient);
-export const gitHubOpenApiSpecificationRepository =
- new GitHubOpenApiSpecificationRepository(gitHubClient);
\ No newline at end of file
diff --git a/src/common/SidebarContainer.tsx b/src/common/SidebarContainer.tsx
new file mode 100644
index 00000000..985b8c70
--- /dev/null
+++ b/src/common/SidebarContainer.tsx
@@ -0,0 +1,59 @@
+import dynamic from "next/dynamic"
+import { ReactNode } from "react"
+import Image from "next/image"
+import { Box, Stack } from "@mui/material"
+import { useTheme } from "@mui/material/styles"
+import { useSessionStorage } from "usehooks-ts"
+import ClientSidebarContainer from "./client/SidebarContainer"
+import SidebarContent from "./SidebarContent"
+
+interface SidebarContainerProps {
+ readonly primary: ReactNode
+ readonly secondary: ReactNode
+ readonly toolbarTrailing?: ReactNode
+}
+
+const SidebarContainer: React.FC = ({
+ primary,
+ secondary,
+ toolbarTrailing
+}) => {
+ const [open, setOpen] = useSessionStorage("isDrawerOpen", true)
+ const theme = useTheme()
+ return (
+
+
+
+ }
+ primary={
+
+ {primary}
+
+ }
+ secondaryHeader={
+ <>
+ {toolbarTrailing != undefined &&
+
+ {toolbarTrailing}
+
+ }
+ >
+ }
+ secondary={secondary}
+ />
+ )
+}
+
+// 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/common/SidebarContent.tsx b/src/common/SidebarContent.tsx
new file mode 100644
index 00000000..e755d0f5
--- /dev/null
+++ b/src/common/SidebarContent.tsx
@@ -0,0 +1,21 @@
+import { ReactNode } from "react"
+import { Box } from "@mui/material"
+import UserListItem from "@/features/user/view/UserListItem"
+import SettingsButton from "@/features/settings/view/SettingsButton"
+
+const SidebarContent: React.FC<{
+ readonly children: ReactNode
+}> = ({
+ children
+}) => {
+ return (
+ <>
+
+ {children}
+
+ } />
+ >
+ )
+}
+
+export default SidebarContent
diff --git a/src/lib/utils/UrlUtils.ts b/src/common/UrlUtils.ts
similarity index 84%
rename from src/lib/utils/UrlUtils.ts
rename to src/common/UrlUtils.ts
index 47f54b23..d2350aa3 100644
--- a/src/lib/utils/UrlUtils.ts
+++ b/src/common/UrlUtils.ts
@@ -1,4 +1,4 @@
-export function getProject(url?: string) {
+export function getProjectId(url?: string) {
if (typeof window !== 'undefined') {
url = window.location.pathname;// remove first slash
}
@@ -8,11 +8,11 @@ export function getProject(url?: string) {
if (firstSlash != -1 && url) {
project = decodeURI(url.substring(0, firstSlash));
}
- return project ? project + "-openapi" : project;
+ return project
}
function getVersionAndSpecification(url?: string) {
- const project = getProject(url)?.replace('-openapi', '');
+ const project = getProjectId(url)?.replace('-openapi', '');
if (url && project) {
const versionAndSpecification = url.substring(project.length + 2)// remove first slash
let specification: string | undefined = undefined;
@@ -35,7 +35,7 @@ function getVersionAndSpecification(url?: string) {
return {};
}
-export function getVersion(url?: string) {
+export function getVersionId(url?: string) {
if (typeof window !== 'undefined') {
url = window.location.pathname
}
@@ -43,12 +43,10 @@ export function getVersion(url?: string) {
return version ? decodeURI(version) : undefined;
}
-export function getSpecification(url?: string) {
+export function getSpecificationId(url?: string) {
if (typeof window !== 'undefined') {
url = window.location.pathname
- console.log(url, url)
}
const specification = getVersionAndSpecification(url).specification;
- console.log(specification, specification)
return specification ? decodeURI(specification) : undefined;
}
\ No newline at end of file
diff --git a/src/common/client/SidebarContainer.tsx b/src/common/client/SidebarContainer.tsx
new file mode 100644
index 00000000..b41a9cb4
--- /dev/null
+++ b/src/common/client/SidebarContainer.tsx
@@ -0,0 +1,161 @@
+import { ReactNode } from "react"
+import { Box, Drawer, Divider, IconButton, Toolbar } from "@mui/material"
+import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar"
+import { ChevronLeft, Menu } from "@mui/icons-material"
+import { styled, useTheme } from "@mui/material/styles"
+
+const drawerWidth = 320
+
+const Main = styled("main", { shouldForwardProp: (prop) => prop !== "open" })<{
+ open?: boolean
+}>(({ theme, open }) => ({
+ display: "flex",
+ flexDirection: "column",
+ flexGrow: 1,
+ overflowY: "auto",
+ transition: theme.transitions.create("margin", {
+ easing: theme.transitions.easing.sharp,
+ duration: theme.transitions.duration.leavingScreen,
+ }),
+ marginLeft: `-${drawerWidth}px`,
+ ...(open && {
+ transition: theme.transitions.create("margin", {
+ easing: theme.transitions.easing.easeOut,
+ duration: theme.transitions.duration.enteringScreen,
+ }),
+ marginLeft: 0,
+ })
+}))
+
+const DrawerHeaderWrapper = styled("div")(({ theme }) => ({
+ display: "flex",
+ alignItems: "center",
+ // necessary for content to be below app bar
+ ...theme.mixins.toolbar
+}))
+
+const DrawerHeader = ({
+ primaryHeader,
+ handleDrawerClose
+}: {
+ primaryHeader: ReactNode,
+ handleDrawerClose: () => void
+}) => {
+ return (
+
+
+
+
+ {primaryHeader != null &&
+
+ {primaryHeader}
+
+ }
+
+ )
+}
+
+interface AppBarProps extends MuiAppBarProps {
+ open?: boolean
+}
+
+const AppBar = styled(MuiAppBar, {
+ shouldForwardProp: (prop) => prop !== "open",
+})(({ theme, open }) => ({
+ transition: theme.transitions.create(["margin", "width"], {
+ easing: theme.transitions.easing.sharp,
+ duration: theme.transitions.duration.leavingScreen,
+ }),
+ ...(open && {
+ width: `calc(100% - ${drawerWidth}px)`,
+ marginLeft: `${drawerWidth}px`,
+ transition: theme.transitions.create(["margin", "width"], {
+ easing: theme.transitions.easing.easeOut,
+ duration: theme.transitions.duration.enteringScreen,
+ }),
+ }),
+}))
+
+interface SidebarContainerProps {
+ isDrawerOpen: boolean
+ onToggleDrawerOpen: (isDrawerOpen: boolean) => void
+ primaryHeader?: ReactNode
+ primary: ReactNode
+ secondaryHeader?: ReactNode
+ secondary: ReactNode
+}
+
+const SidebarContainer: React.FC = ({
+ isDrawerOpen,
+ onToggleDrawerOpen,
+ primaryHeader,
+ primary,
+ secondaryHeader,
+ secondary
+}) => {
+ const theme = useTheme()
+ return (
+
+
+
+ onToggleDrawerOpen(true)}
+ edge="start"
+ sx={{
+ mr: 2,
+ color: theme.palette.text.primary,
+ ...(isDrawerOpen && { display: "none" })
+ }}
+ >
+
+
+ {secondaryHeader != null && secondaryHeader}
+
+
+
+
+ onToggleDrawerOpen(false)}
+ primaryHeader={primaryHeader}
+ />
+ {primary}
+
+
+
+
+ {secondary}
+
+
+
+ )
+}
+
+export default SidebarContainer
diff --git a/src/common/client/ThemeRegistry.tsx b/src/common/client/ThemeRegistry.tsx
new file mode 100644
index 00000000..18e71bff
--- /dev/null
+++ b/src/common/client/ThemeRegistry.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import { useState } from "react"
+import createCache from "@emotion/cache"
+import { useServerInsertedHTML } from "next/navigation"
+import { CacheProvider } from "@emotion/react"
+import { ThemeProvider } from "@mui/material/styles"
+import CssBaseline from "@mui/material/CssBaseline"
+import theme from "./theme"
+import useMediaQuery from "@mui/material/useMediaQuery"
+
+// This implementation is from emotion-js
+// https://github.com/emotion-js/emotion/issues/2928#issuecomment-1319747902
+export default function ThemeRegistry(props: any) {
+ const { options, children } = props;
+ const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
+ const [{ cache, flush }] = useState(() => {
+ const cache = createCache(options);
+ cache.compat = true;
+ const prevInsert = cache.insert;
+ let inserted: string[] = [];
+ cache.insert = (...args) => {
+ const serialized = args[1];
+ if (cache.inserted[serialized.name] === undefined) {
+ inserted.push(serialized.name);
+ }
+ return prevInsert(...args);
+ };
+ const flush = () => {
+ const prevInserted = inserted;
+ inserted = [];
+ return prevInserted;
+ };
+ return { cache, flush };
+ });
+
+ useServerInsertedHTML(() => {
+ const names = flush();
+ if (names.length === 0) {
+ return null;
+ }
+ let styles = '';
+ for (const name of names) {
+ styles += cache.inserted[name];
+ }
+ return (
+
+ );
+ });
+
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/common/client/startup.ts b/src/common/client/startup.ts
new file mode 100644
index 00000000..325e4e60
--- /dev/null
+++ b/src/common/client/startup.ts
@@ -0,0 +1,3 @@
+import SettingsStore from "@/features/settings/data/SettingsStore"
+
+export const settingsStore = new SettingsStore()
\ No newline at end of file
diff --git a/src/common/client/theme.ts b/src/common/client/theme.ts
new file mode 100644
index 00000000..73886874
--- /dev/null
+++ b/src/common/client/theme.ts
@@ -0,0 +1,28 @@
+import { createTheme } from "@mui/material/styles"
+import { blue } from "@mui/material/colors"
+
+const theme = (_prefersDarkMode: boolean) => createTheme({
+ palette: {
+ mode: "light",
+ primary: {
+ main: blue[700]
+ },
+ secondary: {
+ main: blue[700]
+ }
+ },
+ typography: {
+ button: {
+ textTransform: "none"
+ }
+ },
+ components: {
+ MuiButtonBase: {
+ defaultProps: {
+ disableRipple: true
+ }
+ }
+ }
+})
+
+export default theme
diff --git a/src/common/events/BaseEvent.ts b/src/common/events/BaseEvent.ts
new file mode 100644
index 00000000..d2f23262
--- /dev/null
+++ b/src/common/events/BaseEvent.ts
@@ -0,0 +1,10 @@
+export enum Events {
+ SETTINGS_CHANGED = "SETTINGS_CHANGED",
+ PROJECT_CHANGED = "PROJECT_CHANGED",
+ VERSION_CHANGED = "VERSION_CHANGED",
+ OPEN_API_SPECIFICATION_CHANGED = "OPEN_API_SPECIFICATION_CHANGED",
+}
+
+export default abstract class BaseEvent {
+ constructor(public name: Events, public data: T) {}
+}
diff --git a/src/common/events/OpenApiSpecificationChangedEvent.ts b/src/common/events/OpenApiSpecificationChangedEvent.ts
new file mode 100644
index 00000000..729a3c96
--- /dev/null
+++ b/src/common/events/OpenApiSpecificationChangedEvent.ts
@@ -0,0 +1,12 @@
+import IOpenApiSpecification from "@/features/projects/domain/IOpenApiSpecification"
+import BaseEvent, { Events } from "./BaseEvent"
+
+export interface OpenApiSpecificationChangedEventData {
+ openApiSpecification: IOpenApiSpecification
+}
+
+export default class OpenApiSpecificationChangedEvent extends BaseEvent {
+ constructor(openApiSpecification: IOpenApiSpecification) {
+ super(Events.OPEN_API_SPECIFICATION_CHANGED, { openApiSpecification })
+ }
+}
diff --git a/src/common/events/ProjectChangedEvent.ts b/src/common/events/ProjectChangedEvent.ts
new file mode 100644
index 00000000..9031a250
--- /dev/null
+++ b/src/common/events/ProjectChangedEvent.ts
@@ -0,0 +1,11 @@
+import BaseEvent, { Events } from "./BaseEvent"
+
+export interface ProjectChangedEventData {
+ projectName: string
+}
+
+export default class ProjectChangedEvent extends BaseEvent {
+ constructor(projectName: string) {
+ super(Events.PROJECT_CHANGED, {projectName})
+ }
+}
diff --git a/src/common/events/SettingsChangedEvent.ts b/src/common/events/SettingsChangedEvent.ts
new file mode 100644
index 00000000..3be43c6a
--- /dev/null
+++ b/src/common/events/SettingsChangedEvent.ts
@@ -0,0 +1,7 @@
+import BaseEvent, { Events } from "./BaseEvent"
+
+export default class SettingsChangedEvent extends BaseEvent {
+ constructor() {
+ super(Events.SETTINGS_CHANGED, undefined)
+ }
+}
diff --git a/src/common/events/VersionChangedEvent.ts b/src/common/events/VersionChangedEvent.ts
new file mode 100644
index 00000000..69ec1953
--- /dev/null
+++ b/src/common/events/VersionChangedEvent.ts
@@ -0,0 +1,11 @@
+import BaseEvent, { Events } from "./BaseEvent"
+
+export interface VersionChangedEventData {
+ versionName: string
+}
+
+export default class VersionChangedEvent extends BaseEvent {
+ constructor(versionName: string) {
+ super(Events.VERSION_CHANGED, { versionName })
+ }
+}
\ No newline at end of file
diff --git a/src/common/events/utils.ts b/src/common/events/utils.ts
new file mode 100644
index 00000000..44fe2dd8
--- /dev/null
+++ b/src/common/events/utils.ts
@@ -0,0 +1,18 @@
+import BaseEvent, { Events } from "./BaseEvent"
+
+function subscribe(eventName: Events, listener: (event: CustomEvent) => void) {
+ document.addEventListener(eventName, listener as () => void);
+}
+
+function unsubscribe(eventName: Events, listener: (event: CustomEvent) => void) {
+ document.removeEventListener(eventName, listener as () => void);
+}
+
+function publish(event: BaseEvent) {
+ const customEvent = new CustomEvent(event.name, {
+ detail: event.data
+ });
+ document.dispatchEvent(customEvent);
+}
+
+export { publish, subscribe, unsubscribe };
\ No newline at end of file
diff --git a/src/common/fetcher.ts b/src/common/fetcher.ts
new file mode 100644
index 00000000..715ce2f3
--- /dev/null
+++ b/src/common/fetcher.ts
@@ -0,0 +1,7 @@
+export default async function fetcher(
+ input: RequestInfo,
+ init?: RequestInit
+): Promise {
+ const res = await fetch(input, init)
+ return res.json()
+}
diff --git a/src/common/startup.ts b/src/common/startup.ts
new file mode 100644
index 00000000..d84e55b2
--- /dev/null
+++ b/src/common/startup.ts
@@ -0,0 +1,23 @@
+import Auth0OAuthTokenRepository from "@/features/auth/data/Auth0OAuthTokenRepository"
+import GitHubOAuthTokenRefresher from "@/features/auth/data/GitHubOAuthTokenRefresher"
+import AccessTokenService from "@/features/auth/domain/AccessTokenService"
+import HardcodedGitHubOrganizationNameProvider from "@/features/projects/data/HardcodedGitHubOrganizationNameProvider"
+import GitHubProjectRepository from "@/features/projects/data/GitHubProjectRepository"
+
+export const accessTokenService = new AccessTokenService(
+ new Auth0OAuthTokenRepository({
+ domain: process.env.AUTH0_MANAGEMENT_DOMAIN,
+ clientId: process.env.AUTH0_MANAGEMENT_CLIENT_ID,
+ clientSecret: process.env.AUTH0_MANAGEMENT_CLIENT_SECRET,
+ connection: "github"
+ }),
+ new GitHubOAuthTokenRefresher({
+ clientId: process.env.GITHUB_CLIENT_ID,
+ clientSecret: process.env.GITHUB_CLIENT_SECRET
+ })
+)
+
+export const projectRepository = new GitHubProjectRepository(
+ new HardcodedGitHubOrganizationNameProvider("shapehq"),
+ accessTokenService
+)
diff --git a/src/common/useForceUpdate.ts b/src/common/useForceUpdate.ts
new file mode 100644
index 00000000..e7fc26d8
--- /dev/null
+++ b/src/common/useForceUpdate.ts
@@ -0,0 +1,6 @@
+import { useState } from "react"
+
+export const useForceUpdate = () => {
+ const [, setState] = useState({})
+ return () => setState({})
+}
diff --git a/src/features/auth/data/Auth0OAuthTokenRepository.ts b/src/features/auth/data/Auth0OAuthTokenRepository.ts
new file mode 100644
index 00000000..635b8546
--- /dev/null
+++ b/src/features/auth/data/Auth0OAuthTokenRepository.ts
@@ -0,0 +1,133 @@
+import { ManagementClient } from "auth0"
+import { getSession } from "@auth0/nextjs-auth0"
+import IOAuthTokenRepository, { IOAuthToken } from "../domain/IOAuthTokenRepository"
+
+type Auth0UserAppMetadataAuthToken = {
+ readonly access_token: string
+ readonly refresh_token: string
+ readonly access_token_expires_at: string
+ readonly refresh_token_expires_at: string
+}
+
+type Auth0UserIdentity = {
+ readonly connection: string
+ readonly access_token: string
+ readonly refresh_token: string
+}
+
+type Auth0User = {
+ readonly user_id: string
+ readonly identities: Auth0UserIdentity[]
+ readonly app_metadata?: {[key: string]: any}
+}
+
+interface Auth0OAuthIdentityProviderConfig {
+ readonly domain: string
+ readonly clientId: string
+ readonly clientSecret: string
+ readonly connection: string
+}
+
+export default class Auth0OAuthTokenRepository implements IOAuthTokenRepository {
+ private readonly managementClient: ManagementClient
+ private readonly connection: string
+
+ constructor(config: Auth0OAuthIdentityProviderConfig) {
+ this.connection = config.connection
+ this.managementClient = new ManagementClient({
+ domain: config.domain,
+ clientId: config.clientId,
+ clientSecret: config.clientSecret
+ })
+ }
+
+ async getOAuthToken(): Promise {
+ const user = await this.getUser()
+ const metadataAuthToken = this.getAuthTokenFromMetadata(user)
+ if (!metadataAuthToken) {
+ return this.getDefaultAuth0AuthToken(user)
+ }
+ const accessTokenExpiryDate = new Date(metadataAuthToken.access_token_expires_at)
+ const refreshTokenExpiryDate = new Date(metadataAuthToken.refresh_token_expires_at)
+ const now = new Date()
+ if (refreshTokenExpiryDate.getTime() <= now.getTime()) {
+ return this.getDefaultAuth0AuthToken(user)
+ }
+ return {
+ accessToken: metadataAuthToken.access_token,
+ refreshToken: metadataAuthToken.refresh_token,
+ accessTokenExpiryDate: accessTokenExpiryDate,
+ refreshTokenExpiryDate: refreshTokenExpiryDate
+ }
+ }
+
+ async storeOAuthToken(token: IOAuthToken): Promise {
+ const user = await this.getUser()
+ const authTokenKey = this.getAuthTokenMetadataKey(this.connection)
+ const appMetadataToken: Auth0UserAppMetadataAuthToken = {
+ access_token: token.accessToken,
+ refresh_token: token.refreshToken,
+ access_token_expires_at: token.accessTokenExpiryDate.toISOString(),
+ refresh_token_expires_at: token.refreshTokenExpiryDate.toISOString()
+ }
+ const appMetadata: any = {}
+ appMetadata[authTokenKey] = appMetadataToken
+ await this.managementClient.users.update({ id: user.user_id }, {
+ app_metadata: appMetadata
+ })
+ }
+
+ private async getDefaultAuth0AuthToken(user: Auth0User) {
+ const identity = this.getIdentity(user)
+ if (!identity) {
+ throw new Error("User have no identities")
+ }
+ return {
+ accessToken: identity.access_token,
+ refreshToken: identity.refresh_token,
+ accessTokenExpiryDate: new Date(new Date().getTime() - 30 * 60 * 1000),
+ refreshTokenExpiryDate: new Date(new Date().getTime() + 30 * 60 * 1000)
+ }
+ }
+
+ private getIdentity(user: Auth0User): Auth0UserIdentity | undefined {
+ if (!user.identities) {
+ return undefined
+ }
+ return user.identities.find(e => {
+ return e.connection.toLowerCase() === this.connection.toLowerCase()
+ })
+ }
+
+ private getAuthTokenFromMetadata(user: Auth0User): Auth0UserAppMetadataAuthToken | undefined {
+ if (!user.app_metadata) {
+ return undefined
+ }
+ const authTokenKey = this.getAuthTokenMetadataKey(this.connection)
+ const authToken = user.app_metadata[authTokenKey]
+ if (
+ authToken.access_token && authToken.access_token.length > 0 &&
+ authToken.refresh_token && authToken.refresh_token.length > 0 &&
+ authToken.access_token_expires_at && authToken.access_token_expires_at.length > 0 &&
+ authToken.refresh_token_expires_at && authToken.refresh_token_expires_at.length > 0
+ ) {
+ return authToken
+ } else {
+ return undefined
+ }
+ }
+
+ private getAuthTokenMetadataKey(connection: string): string {
+ return `authToken[${connection.toLowerCase()}]`
+ }
+
+ private async getUser(): Promise {
+ const session = await getSession()
+ const user = session?.user
+ if (!user) {
+ throw new Error("User is not authenticated")
+ }
+ const userResponse = await this.managementClient.users.get({ id: user.sub })
+ return userResponse.data
+ }
+}
diff --git a/src/features/auth/data/GitHubOAuthTokenRefresher.ts b/src/features/auth/data/GitHubOAuthTokenRefresher.ts
new file mode 100644
index 00000000..185a5e5a
--- /dev/null
+++ b/src/features/auth/data/GitHubOAuthTokenRefresher.ts
@@ -0,0 +1,68 @@
+import IOAuthTokenRefresher, { IOAuthTokenRefreshResult } from "../domain/IOAuthTokenRefresher"
+
+export interface GitHubOAuthTokenRefresherConfig {
+ readonly clientId: string
+ readonly clientSecret: string
+}
+
+export default class GitHubOAuthTokenRefresher implements IOAuthTokenRefresher {
+ private readonly config: GitHubOAuthTokenRefresherConfig
+
+ constructor(config: GitHubOAuthTokenRefresherConfig) {
+ this.config = config
+ }
+
+ async refreshAccessToken(refreshToken: string): Promise {
+ const url = this.getAccessTokenURL(refreshToken)
+ const response = await fetch(url, { method: "POST" })
+ if (response.status != 200) {
+ throw new Error(
+ `Failed refreshing access token with HTTP status ${response.status}: ${response.statusText}`
+ )
+ }
+ const data = await response.text()
+ const params = new URLSearchParams(data)
+ const error = params.get("error")
+ const errorDescription = params.get("error_description")
+ if (error && error.length > 0) {
+ if (errorDescription && errorDescription.length > 0) {
+ throw new Error(errorDescription)
+ } else {
+ throw new Error(error)
+ }
+ }
+ const newAccessToken = params.get("access_token")
+ const newRefreshToken = params.get("refresh_token")
+ const newRawAccessTokenExpiryDate = params.get("expires_in")
+ const newRawRefreshTokenExpiryDate = params.get("refresh_token_expires_in")
+ if (
+ !newAccessToken || newAccessToken.length <= 0 ||
+ !newRefreshToken || newRefreshToken.length <= 0 ||
+ !newRawAccessTokenExpiryDate || newRawAccessTokenExpiryDate.length <= 0 ||
+ !newRawRefreshTokenExpiryDate || newRawRefreshTokenExpiryDate.length <= 0
+ ) {
+ throw new Error("Refreshing access token did not produce a valid access token")
+ }
+ const accessTokenExpiryDate = new Date(
+ new Date().getTime() + parseInt(newRawAccessTokenExpiryDate) * 1000
+ )
+ const refreshTokenExpiryDate = new Date(
+ new Date().getTime() + parseInt(newRawRefreshTokenExpiryDate) * 1000
+ )
+ return {
+ accessToken: newAccessToken,
+ refreshToken: newRefreshToken,
+ accessTokenExpiryDate: accessTokenExpiryDate,
+ refreshTokenExpiryDate: refreshTokenExpiryDate
+ }
+ }
+
+ private getAccessTokenURL(refreshToken: string): URL {
+ const url = new URL("https://github.com/login/oauth/access_token")
+ url.searchParams.set("client_id", this.config.clientId)
+ url.searchParams.set("client_secret", this.config.clientSecret)
+ url.searchParams.set("refresh_token", refreshToken)
+ url.searchParams.set("grant_type", "refresh_token")
+ return url
+ }
+}
diff --git a/src/features/auth/domain/AccessTokenService.ts b/src/features/auth/domain/AccessTokenService.ts
new file mode 100644
index 00000000..b15def10
--- /dev/null
+++ b/src/features/auth/domain/AccessTokenService.ts
@@ -0,0 +1,40 @@
+import IOAuthTokenRepository from "../domain/IOAuthTokenRepository"
+import IOAuthTokenRefresher from "../domain/IOAuthTokenRefresher"
+
+export default class AccessTokenService {
+ private readonly tokenRepository: IOAuthTokenRepository
+ private readonly tokenRefresher: IOAuthTokenRefresher
+ private readonly tokenExpirationThreshold = 5 * 60 * 1000
+
+ constructor(
+ tokenRepository: IOAuthTokenRepository,
+ tokenRefresher: IOAuthTokenRefresher
+ ) {
+ this.tokenRepository = tokenRepository
+ this.tokenRefresher = tokenRefresher
+ }
+
+ async getAccessToken(): Promise {
+ const authToken = await this.tokenRepository.getOAuthToken()
+ const accessTokenExpiryDate = new Date(
+ authToken.accessTokenExpiryDate.getTime() - this.tokenExpirationThreshold
+ )
+ const refreshTokenExpiryDate = new Date(
+ authToken.refreshTokenExpiryDate.getTime() - this.tokenExpirationThreshold
+ )
+ const now = new Date()
+ if (accessTokenExpiryDate.getTime() > now.getTime()) {
+ return authToken.accessToken
+ } else if (refreshTokenExpiryDate.getTime() > now.getTime()) {
+ return await this.refreshAccessToken(authToken.refreshToken)
+ } else {
+ throw new Error("Tokens have expired")
+ }
+ }
+
+ private async refreshAccessToken(refreshToken: string): Promise {
+ const refreshResult = await this.tokenRefresher.refreshAccessToken(refreshToken)
+ await this.tokenRepository.storeOAuthToken(refreshResult)
+ return refreshResult.accessToken
+ }
+}
diff --git a/src/features/auth/domain/IOAuthTokenRefresher.ts b/src/features/auth/domain/IOAuthTokenRefresher.ts
new file mode 100644
index 00000000..9e6a6599
--- /dev/null
+++ b/src/features/auth/domain/IOAuthTokenRefresher.ts
@@ -0,0 +1,10 @@
+export interface IOAuthTokenRefreshResult {
+ readonly accessToken: string
+ readonly refreshToken: string
+ readonly accessTokenExpiryDate: Date
+ readonly refreshTokenExpiryDate: Date
+}
+
+export default interface IOAuthTokenRefresher {
+ refreshAccessToken(refreshToken: string): Promise
+}
diff --git a/src/features/auth/domain/IOAuthTokenRepository.ts b/src/features/auth/domain/IOAuthTokenRepository.ts
new file mode 100644
index 00000000..9edb24c1
--- /dev/null
+++ b/src/features/auth/domain/IOAuthTokenRepository.ts
@@ -0,0 +1,11 @@
+export interface IOAuthToken {
+ readonly accessToken: string
+ readonly refreshToken: string
+ readonly accessTokenExpiryDate: Date
+ readonly refreshTokenExpiryDate: Date
+}
+
+export default interface IOAuthTokenRepository {
+ getOAuthToken(): Promise
+ storeOAuthToken(token: IOAuthToken): Promise
+}
diff --git a/src/features/hooks/data/GitHubHookHandler.ts b/src/features/hooks/data/GitHubHookHandler.ts
new file mode 100644
index 00000000..3a4afa05
--- /dev/null
+++ b/src/features/hooks/data/GitHubHookHandler.ts
@@ -0,0 +1,60 @@
+import { NextRequest } from "next/server"
+import { Webhooks } from "@octokit/webhooks"
+import IPullRequestEventHandler from "../domain/IPullRequestEventHandler"
+
+interface GitHubHookHandlerConfig {
+ readonly secret: string
+ readonly pullRequestEventHandler: IPullRequestEventHandler
+}
+
+class GitHubHookHandler {
+ private readonly webhooks: Webhooks
+ private readonly pullRequestEventHandler: IPullRequestEventHandler
+
+ constructor(config: GitHubHookHandlerConfig) {
+ this.webhooks = new Webhooks({ secret: config.secret })
+ this.pullRequestEventHandler = config.pullRequestEventHandler
+ this.registerEventHandlers()
+ }
+
+ async handle(req: NextRequest): Promise {
+ await this.webhooks.verifyAndReceive({
+ id: req.headers.get('X-GitHub-Delivery') as string,
+ name: req.headers.get('X-GitHub-Event') as any,
+ payload: await req.text(),
+ signature: req.headers.get('X-Hub-Signature') as string,
+ }).catch((error) => {
+ console.error(`Error: ${error.message}`)
+ return false
+ })
+ }
+
+ private registerEventHandlers() {
+ this.webhooks.on("pull_request.opened", async ({ payload }) => {
+ if (!payload.installation) {
+ throw new Error("Payload does not contain information about the app installation.")
+ }
+ await this.pullRequestEventHandler.pullRequestOpened({
+ appInstallationId: payload.installation.id,
+ repositoryOwner: payload.repository.owner.login,
+ repositoryName: payload.repository.name,
+ ref: payload.pull_request.head.ref,
+ pullRequestNumber: payload.pull_request.number
+ })
+ })
+ this.webhooks.on("pull_request.reopened", async ({ payload }) => {
+ if (!payload.installation) {
+ throw new Error("Payload does not contain information about the app installation.")
+ }
+ await this.pullRequestEventHandler.pullRequestOpened({
+ appInstallationId: payload.installation.id,
+ repositoryOwner: payload.repository.owner.login,
+ repositoryName: payload.repository.name,
+ ref: payload.pull_request.head.ref,
+ pullRequestNumber: payload.pull_request.number
+ })
+ })
+ }
+}
+
+export default GitHubHookHandler
diff --git a/src/features/hooks/data/GitHubPullRequestCommentRepository.ts b/src/features/hooks/data/GitHubPullRequestCommentRepository.ts
new file mode 100644
index 00000000..79ead466
--- /dev/null
+++ b/src/features/hooks/data/GitHubPullRequestCommentRepository.ts
@@ -0,0 +1,58 @@
+import { Octokit } from "octokit"
+import { createAppAuth } from "@octokit/auth-app"
+import IPullRequestCommentRepository, {
+ PullRequestComment,
+ GetPullRequestCommentsOperation,
+ AddPullRequestCommentOperation
+} from "../domain/IPullRequestCommentRepository"
+
+export type GitHubPullRequestCommentRepositoryConfig = {
+ appId: string,
+ privateKey: string,
+ clientId: string,
+ clientSecret: string
+}
+
+type InstallationAuthenticator = (installationId: number) => Promise<{token: string}>
+
+export default class GitHubPullRequestCommentRepository implements IPullRequestCommentRepository {
+ readonly auth: InstallationAuthenticator
+
+ constructor(config: GitHubPullRequestCommentRepositoryConfig) {
+ const appAuth = createAppAuth(config)
+ this.auth = async (installationId: number) => {
+ return await appAuth({ type: "installation", installationId })
+ }
+ }
+
+ async getComments(
+ operation: GetPullRequestCommentsOperation
+ ): Promise {
+ const installationAuthentication = await this.auth(operation.appInstallationId)
+ const octokit = new Octokit({ auth: installationAuthentication.token })
+ const comments = await octokit.paginate(octokit.rest.issues.listComments, {
+ owner: operation.repositoryOwner,
+ repo: operation.repositoryName,
+ issue_number: operation.pullRequestNumber,
+ })
+ let result: PullRequestComment[] = []
+ for await (const comment of comments) {
+ result.push({
+ body: comment.body || "",
+ isFromBot: comment.user?.type == "Bot"
+ })
+ }
+ return result
+ }
+
+ async addComment(operation: AddPullRequestCommentOperation): Promise {
+ const installationAuthentication = await this.auth(operation.appInstallationId)
+ const octokit = new Octokit({ auth: installationAuthentication.token })
+ await octokit.rest.issues.createComment({
+ owner: operation.repositoryOwner,
+ repo: operation.repositoryName,
+ issue_number: operation.pullRequestNumber,
+ body: operation.body
+ })
+ }
+}
diff --git a/src/features/hooks/domain/ExistingCommentCheckingPullRequestEventHandler.ts b/src/features/hooks/domain/ExistingCommentCheckingPullRequestEventHandler.ts
new file mode 100644
index 00000000..98447c4c
--- /dev/null
+++ b/src/features/hooks/domain/ExistingCommentCheckingPullRequestEventHandler.ts
@@ -0,0 +1,29 @@
+import IPullRequestEventHandler, { IPullRequestOpenedEvent } from "./IPullRequestEventHandler"
+import IPullRequestCommentRepository from "./IPullRequestCommentRepository"
+
+export default class ExistingCommentCheckingPullRequestEventHandler implements IPullRequestEventHandler {
+ private readonly eventHandler: IPullRequestEventHandler
+ private readonly commentRepository: IPullRequestCommentRepository
+ private readonly needleDomain: string
+
+ constructor(
+ eventHandler: IPullRequestEventHandler,
+ commentRepository: IPullRequestCommentRepository,
+ needleDomain: string
+ ) {
+ this.eventHandler = eventHandler
+ this.commentRepository = commentRepository
+ this.needleDomain = needleDomain
+ }
+
+ async pullRequestOpened(event: IPullRequestOpenedEvent): Promise {
+ const comments = await this.commentRepository.getComments(event)
+ const containsOurComment = comments.find(comment => {
+ return comment.isFromBot && comment.body.includes(this.needleDomain)
+ })
+ if (containsOurComment) {
+ return
+ }
+ await this.eventHandler.pullRequestOpened(event)
+ }
+}
diff --git a/src/features/hooks/domain/GitHubCommentFactory.ts b/src/features/hooks/domain/GitHubCommentFactory.ts
new file mode 100644
index 00000000..27f27f3d
--- /dev/null
+++ b/src/features/hooks/domain/GitHubCommentFactory.ts
@@ -0,0 +1,16 @@
+export default class GitHubCommentFactory {
+ static makeDocumentationPreviewReadyComment(link: string): string {
+ return `### 📖 Documentation Preview
+
+These edits are available for preview at [Shape Docs](${link}).
+
+
+
+ | Status: | ✅ Ready! |
+
+
+ | Preview URL: | ${link} |
+
+
`
+ }
+}
diff --git a/src/features/hooks/domain/IPullRequestCommentRepository.ts b/src/features/hooks/domain/IPullRequestCommentRepository.ts
new file mode 100644
index 00000000..6ed2e534
--- /dev/null
+++ b/src/features/hooks/domain/IPullRequestCommentRepository.ts
@@ -0,0 +1,24 @@
+export type PullRequestComment = {
+ readonly isFromBot: boolean
+ readonly body: string
+}
+
+export type GetPullRequestCommentsOperation = {
+ readonly appInstallationId: number
+ readonly repositoryOwner: string
+ readonly repositoryName: string
+ readonly pullRequestNumber: number
+}
+
+export type AddPullRequestCommentOperation = {
+ readonly appInstallationId: number
+ readonly repositoryOwner: string
+ readonly repositoryName: string
+ readonly pullRequestNumber: number
+ readonly body: string
+}
+
+export default interface IPullRequestCommentRepository {
+ getComments(operation: GetPullRequestCommentsOperation): Promise
+ addComment(operation: AddPullRequestCommentOperation): Promise
+}
diff --git a/src/features/hooks/domain/IPullRequestEventHandler.ts b/src/features/hooks/domain/IPullRequestEventHandler.ts
new file mode 100644
index 00000000..4f9d6490
--- /dev/null
+++ b/src/features/hooks/domain/IPullRequestEventHandler.ts
@@ -0,0 +1,11 @@
+export interface IPullRequestOpenedEvent {
+ readonly appInstallationId: number
+ readonly repositoryOwner: string
+ readonly repositoryName: string
+ readonly ref: string
+ readonly pullRequestNumber: number
+}
+
+export default interface IPullRequestEventHandler {
+ pullRequestOpened(event: IPullRequestOpenedEvent): Promise
+}
diff --git a/src/features/hooks/domain/PostCommentPullRequestEventHandler.ts b/src/features/hooks/domain/PostCommentPullRequestEventHandler.ts
new file mode 100644
index 00000000..f7ea34d3
--- /dev/null
+++ b/src/features/hooks/domain/PostCommentPullRequestEventHandler.ts
@@ -0,0 +1,29 @@
+import IPullRequestEventHandler, { IPullRequestOpenedEvent } from "./IPullRequestEventHandler"
+import IPullRequestCommentRepository from "./IPullRequestCommentRepository"
+import GitHubCommentFactory from "./GitHubCommentFactory"
+
+export default class PostCommentPullRequestEventHandler implements IPullRequestEventHandler {
+ private readonly commentRepository: IPullRequestCommentRepository
+ private readonly domain: string
+
+ constructor(
+ commentRepository: IPullRequestCommentRepository,
+ domain: string
+ ) {
+ this.commentRepository = commentRepository
+ this.domain = domain
+ }
+
+ async pullRequestOpened(event: IPullRequestOpenedEvent): Promise {
+ const projectId = event.repositoryName.replace(/-openapi$/, "")
+ const link = `${this.domain}/${projectId}/${event.ref}`
+ const commentBody = GitHubCommentFactory.makeDocumentationPreviewReadyComment(link)
+ await this.commentRepository.addComment({
+ appInstallationId: event.appInstallationId,
+ repositoryOwner: event.repositoryOwner,
+ repositoryName: event.repositoryName,
+ pullRequestNumber: event.pullRequestNumber,
+ body: commentBody
+ })
+ }
+}
diff --git a/src/features/hooks/domain/RepositoryNameCheckingPullRequestEventHandler.ts b/src/features/hooks/domain/RepositoryNameCheckingPullRequestEventHandler.ts
new file mode 100644
index 00000000..82edc1f9
--- /dev/null
+++ b/src/features/hooks/domain/RepositoryNameCheckingPullRequestEventHandler.ts
@@ -0,0 +1,33 @@
+import IPullRequestEventHandler, { IPullRequestOpenedEvent } from "./IPullRequestEventHandler"
+
+export default class RepositoryNameCheckingPullRequestEventHandler implements IPullRequestEventHandler {
+ private readonly eventHandler: IPullRequestEventHandler
+ private readonly allowedRepositoryNames: string[]
+ private readonly disallowedRepositoryNames: string[]
+
+ constructor(
+ eventHandler: IPullRequestEventHandler,
+ allowedRepositoryNames?: string[],
+ disallowedRepositoryNames?: string[]
+ ) {
+ this.eventHandler = eventHandler
+ this.allowedRepositoryNames = allowedRepositoryNames || []
+ this.disallowedRepositoryNames = disallowedRepositoryNames || []
+ }
+
+ async pullRequestOpened(event: IPullRequestOpenedEvent): Promise {
+ if (!event.repositoryName.match(/-openapi$/)) {
+ return
+ }
+ if (
+ this.allowedRepositoryNames.length != 0 &&
+ !this.allowedRepositoryNames.includes(event.repositoryName)
+ ) {
+ return
+ }
+ if (this.disallowedRepositoryNames.includes(event.repositoryName)) {
+ return
+ }
+ return await this.eventHandler.pullRequestOpened(event)
+ }
+}
diff --git a/src/features/projects/data/GitHubProjectRepository.ts b/src/features/projects/data/GitHubProjectRepository.ts
new file mode 100644
index 00000000..07cdc9e8
--- /dev/null
+++ b/src/features/projects/data/GitHubProjectRepository.ts
@@ -0,0 +1,191 @@
+import { Octokit } from "octokit"
+import AccessTokenService from "@/features/auth/domain/AccessTokenService"
+import IProject from "../domain/IProject"
+import IProjectConfig from "../domain/IProjectConfig"
+import IProjectRepository from "../domain/IProjectRepository"
+import IVersion from "../domain/IVersion"
+import IOpenApiSpecification from "../domain/IOpenApiSpecification"
+import ProjectConfigParser from "../domain/ProjectConfigParser"
+import IGitHubOrganizationNameProvider from "./IGitHubOrganizationNameProvider"
+
+export default class GitHubProjectRepository implements IProjectRepository {
+ private organizationNameProvider: IGitHubOrganizationNameProvider
+ private accessTokenService: AccessTokenService
+
+ constructor(
+ organizationNameProvider: IGitHubOrganizationNameProvider,
+ accessTokenService: AccessTokenService
+ ) {
+ this.organizationNameProvider = organizationNameProvider
+ this.accessTokenService = accessTokenService
+ }
+
+ async getProjects(): Promise {
+ const organizationName = await this.organizationNameProvider.getOrganizationName()
+ const accessToken = await this.accessTokenService.getAccessToken()
+ const octokit = new Octokit({ auth: accessToken })
+ const response: any = await octokit.graphql(`
+ query Repositories($searchQuery: String!) {
+ search(query: $searchQuery, type: REPOSITORY, first: 100) {
+ results: nodes {
+ ... on Repository {
+ name
+ owner {
+ login
+ }
+ defaultBranchRef {
+ name
+ }
+ configYml: object(expression: "HEAD:.shape-docs.yml") {
+ ...ConfigParts
+ }
+ configYaml: object(expression: "HEAD:.shape-docs.yaml") {
+ ...ConfigParts
+ }
+ branches: refs(refPrefix: "refs/heads/", first: 100) {
+ ...RefConnectionParts
+ }
+ tags: refs(refPrefix: "refs/tags/", first: 100) {
+ ...RefConnectionParts
+ }
+ }
+ }
+ }
+ }
+
+ fragment RefConnectionParts on RefConnection {
+ nodes {
+ name
+ target {
+ ... on Commit {
+ tree {
+ entries {
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fragment ConfigParts on GitObject {
+ ... on Blob {
+ text
+ }
+ }
+ `,
+ {
+ searchQuery: `org:${organizationName} openapi in:name`
+ }
+ )
+ return response.search.results.map((e: any) => {
+ return this.mapProject(e)
+ })
+ .filter((e: IProject) => {
+ return e.versions.length > 0
+ })
+ .sort((a: any, b: any) => {
+ return a.name.localeCompare(b.name)
+ })
+ }
+
+ private mapProject(searchResult: any): IProject {
+ const config = this.getConfig(searchResult)
+ let imageURL: string | undefined
+ if (config && config.image) {
+ imageURL = this.getGitHubBlobURL(
+ searchResult.owner.login,
+ searchResult.name,
+ config.image,
+ searchResult.defaultBranchRef.name
+ )
+ }
+ const defaultName = searchResult.name.replace(/\-openapi$/, "")
+ return {
+ id: defaultName,
+ name: defaultName,
+ displayName: config?.name || defaultName,
+ versions: this.getVersions(searchResult).filter(e => {
+ return e.specifications.length > 0
+ }),
+ imageURL: imageURL
+ }
+ }
+
+ private getConfig(searchResult: any): IProjectConfig | null {
+ const yml = searchResult.configYml || searchResult.configYaml
+ if (!yml || !yml.text || yml.text.length == 0) {
+ return null
+ }
+ const parser = new ProjectConfigParser()
+ return parser.parse(yml.text)
+ }
+
+ private getVersions(searchResult: any): IVersion[] {
+ const branchVersions = searchResult.branches.nodes.map((ref: any) => {
+ return this.mapVersionFromRef(searchResult.owner.login, searchResult.name, ref)
+ })
+ const tagVersions = searchResult.tags.nodes.map((ref: any) => {
+ return this.mapVersionFromRef(searchResult.owner.login, searchResult.name, ref)
+ })
+ const defaultBranchName = searchResult.defaultBranchRef.name
+ const candidateDefaultBranches = [
+ defaultBranchName, "main", "master", "develop", "development"
+ ]
+ // Reverse them so the top-priority branches end up at the top of the list.
+ .reverse()
+ let allVersions = branchVersions.concat(tagVersions).sort((a: any, b: any) => {
+ return a.name.localeCompare(b.name)
+ })
+ // Move the top-priority branches to the top of the list.
+ for (const candidateDefaultBranch of candidateDefaultBranches) {
+ const defaultBranchIndex = allVersions.findIndex((e: any) => {
+ return e.name === candidateDefaultBranch
+ })
+ if (defaultBranchIndex !== -1) {
+ const branchVersion = allVersions[defaultBranchIndex]
+ allVersions.splice(defaultBranchIndex, 1)
+ allVersions.splice(0, 0, branchVersion)
+ }
+ }
+ return allVersions
+ }
+
+ private mapVersionFromRef(owner: string, repository: string, ref: any): IVersion {
+ const specifications = ref.target.tree.entries.map((item: any) => {
+ if (!this.isOpenAPISpecification(item.name)) {
+ return null
+ }
+ return {
+ id: item.name,
+ name: item.name,
+ url: this.getGitHubBlobURL(
+ owner,
+ repository,
+ item.name,
+ ref.name
+ ),
+ editURL: `https://github.com/${owner}/${repository}/edit/${ref.name}/${item.name}`
+ }
+ })
+ .filter((e: IOpenApiSpecification | null) => {
+ return e != null
+ })
+ return {
+ id: ref.name,
+ name: ref.name,
+ specifications: specifications,
+ url: `https://github.com/${owner}/${repository}/tree/${ref.name}`
+ }
+ }
+
+ private isOpenAPISpecification(filename: string) {
+ return !filename.startsWith(".") && (
+ filename.endsWith(".yml") || filename.endsWith(".yaml")
+ )
+ }
+
+ private getGitHubBlobURL(owner: string, repository: string, path: string, ref: string): string {
+ return `/api/github/blob/${owner}/${repository}/${path}?ref=${ref}`
+ }
+}
diff --git a/src/lib/github/HardcodedGitHubOrganizationNameProvider.ts b/src/features/projects/data/HardcodedGitHubOrganizationNameProvider.ts
similarity index 54%
rename from src/lib/github/HardcodedGitHubOrganizationNameProvider.ts
rename to src/features/projects/data/HardcodedGitHubOrganizationNameProvider.ts
index 5ab4132a..1afc8dd4 100644
--- a/src/lib/github/HardcodedGitHubOrganizationNameProvider.ts
+++ b/src/features/projects/data/HardcodedGitHubOrganizationNameProvider.ts
@@ -1,6 +1,6 @@
-import { IGitHubOrganizationNameProvider } from "./IGitHubOrganizationNameProvider"
+import IGitHubOrganizationNameProvider from "./IGitHubOrganizationNameProvider"
-export class HardcodedGitHubOrganizationNameProvider implements IGitHubOrganizationNameProvider {
+export default class HardcodedGitHubOrganizationNameProvider implements IGitHubOrganizationNameProvider {
private organizationName: string
constructor(organizationName: string) {
diff --git a/src/features/projects/data/IGitHubOrganizationNameProvider.ts b/src/features/projects/data/IGitHubOrganizationNameProvider.ts
new file mode 100644
index 00000000..e1da61d3
--- /dev/null
+++ b/src/features/projects/data/IGitHubOrganizationNameProvider.ts
@@ -0,0 +1,3 @@
+export default interface IGitHubOrganizationNameProvider {
+ getOrganizationName(): Promise
+}
diff --git a/src/features/projects/data/useProjects.ts b/src/features/projects/data/useProjects.ts
new file mode 100644
index 00000000..d7c80c69
--- /dev/null
+++ b/src/features/projects/data/useProjects.ts
@@ -0,0 +1,17 @@
+import useSWR from "swr"
+import fetcher from "@/common/fetcher"
+import IProject from "../domain/IProject"
+
+type ProjectContainer = { projects: IProject[] }
+
+export default function useProjects() {
+ const { data, error, isLoading } = useSWR(
+ "/api/user/projects",
+ fetcher
+ )
+ return {
+ projects: data?.projects || [],
+ isLoading,
+ error
+ }
+}
diff --git a/src/features/projects/domain/IOpenApiSpecification.ts b/src/features/projects/domain/IOpenApiSpecification.ts
new file mode 100644
index 00000000..30a52a35
--- /dev/null
+++ b/src/features/projects/domain/IOpenApiSpecification.ts
@@ -0,0 +1,6 @@
+export default interface IOpenApiSpecification {
+ readonly id: string
+ readonly name: string
+ readonly url: string
+ readonly editURL?: string
+}
diff --git a/src/features/projects/domain/IProject.ts b/src/features/projects/domain/IProject.ts
new file mode 100644
index 00000000..662f3112
--- /dev/null
+++ b/src/features/projects/domain/IProject.ts
@@ -0,0 +1,9 @@
+import IVersion from "./IVersion"
+
+export default interface IProject {
+ readonly id: string
+ readonly name: string
+ readonly displayName?: string
+ readonly versions: IVersion[]
+ readonly imageURL?: string
+}
diff --git a/src/lib/projects/IProjectConfig.ts b/src/features/projects/domain/IProjectConfig.ts
similarity index 55%
rename from src/lib/projects/IProjectConfig.ts
rename to src/features/projects/domain/IProjectConfig.ts
index 1ed1cd52..3dc32a0e 100644
--- a/src/lib/projects/IProjectConfig.ts
+++ b/src/features/projects/domain/IProjectConfig.ts
@@ -1,4 +1,4 @@
-export interface IProjectConfig {
+export default interface IProjectConfig {
readonly name?: string
readonly image?: string
}
diff --git a/src/features/projects/domain/IProjectRepository.ts b/src/features/projects/domain/IProjectRepository.ts
new file mode 100644
index 00000000..1ff50ccf
--- /dev/null
+++ b/src/features/projects/domain/IProjectRepository.ts
@@ -0,0 +1,5 @@
+import IProject from "./IProject"
+
+export default interface IProjectRepository {
+ getProjects(): Promise
+}
diff --git a/src/features/projects/domain/IVersion.ts b/src/features/projects/domain/IVersion.ts
new file mode 100644
index 00000000..bf780ca1
--- /dev/null
+++ b/src/features/projects/domain/IVersion.ts
@@ -0,0 +1,8 @@
+import IOpenApiSpecification from "./IOpenApiSpecification"
+
+export default interface IVersion {
+ readonly id: string
+ readonly name: string
+ readonly specifications: IOpenApiSpecification[]
+ readonly url?: string
+}
diff --git a/src/lib/projects/ProjectConfigParser.ts b/src/features/projects/domain/ProjectConfigParser.ts
similarity index 68%
rename from src/lib/projects/ProjectConfigParser.ts
rename to src/features/projects/domain/ProjectConfigParser.ts
index 67a92812..9f1ee5f3 100644
--- a/src/lib/projects/ProjectConfigParser.ts
+++ b/src/features/projects/domain/ProjectConfigParser.ts
@@ -1,7 +1,7 @@
import { parse } from 'yaml'
-import { IProjectConfig } from "./IProjectConfig"
+import IProjectConfig from "./IProjectConfig"
-export class ProjectConfigParser {
+export default class ProjectConfigParser {
parse(rawConfig: string): IProjectConfig {
const obj = parse(rawConfig)
if (obj !== null) {
diff --git a/src/features/projects/domain/ProjectPageSelection.ts b/src/features/projects/domain/ProjectPageSelection.ts
new file mode 100644
index 00000000..1af9ffe1
--- /dev/null
+++ b/src/features/projects/domain/ProjectPageSelection.ts
@@ -0,0 +1,11 @@
+import IProject from "../domain/IProject"
+import IVersion from "../domain/IVersion"
+import IOpenApiSpecification from "../domain/IOpenApiSpecification"
+
+type ProjectPageSelection = {
+ readonly project: IProject
+ readonly version: IVersion
+ readonly specification: IOpenApiSpecification
+}
+
+export default ProjectPageSelection
diff --git a/src/features/projects/domain/ProjectPageState.ts b/src/features/projects/domain/ProjectPageState.ts
new file mode 100644
index 00000000..9cefd814
--- /dev/null
+++ b/src/features/projects/domain/ProjectPageState.ts
@@ -0,0 +1,91 @@
+import IProject from "./IProject"
+import IVersion from "./IVersion"
+import IOpenApiSpecification from "./IOpenApiSpecification"
+import ProjectPageSelection from "./ProjectPageSelection"
+
+export enum ProjectPageState {
+ LOADING,
+ ERROR,
+ HAS_SELECTION,
+ NO_PROJECT_SELECTED,
+ PROJECT_NOT_FOUND,
+ VERSION_NOT_FOUND,
+ SPECIFICATION_NOT_FOUND
+}
+
+export type ProjectPageStateContainer = {
+ readonly state: ProjectPageState
+ readonly selection?: ProjectPageSelection
+ readonly error?: Error
+}
+
+type GetProjectPageStateProps = {
+ isLoading?: boolean
+ error?: Error
+ projects?: IProject[]
+ selectedProjectId?: string
+ selectedVersionId?: string
+ selectedSpecificationId?: string
+}
+
+export function getProjectPageState({
+ isLoading,
+ error,
+ projects,
+ selectedProjectId,
+ selectedVersionId,
+ selectedSpecificationId
+}: GetProjectPageStateProps): ProjectPageStateContainer {
+ if (isLoading) {
+ return { state: ProjectPageState.LOADING }
+ }
+ if (error) {
+ return { state: ProjectPageState.ERROR, error }
+ }
+ projects = projects || []
+ if (!selectedProjectId && projects.length == 1) {
+ // If no project is selected and the user only has a single project then we select that.
+ selectedProjectId = projects[0].id
+ }
+ if (!selectedProjectId) {
+ return { state: ProjectPageState.NO_PROJECT_SELECTED }
+ }
+ const project = projects.find(e => e.id == selectedProjectId)
+ if (!project) {
+ return { state: ProjectPageState.PROJECT_NOT_FOUND }
+ }
+ // Find selected version or default to first version if none is selected.
+ let version: IVersion
+ if (selectedVersionId) {
+ const selectedVersion = project.versions.find(e => e.id == selectedVersionId)
+ if (selectedVersion) {
+ version = selectedVersion
+ } else {
+ return { state: ProjectPageState.VERSION_NOT_FOUND }
+ }
+ } else if (project.versions.length > 0) {
+ version = project.versions[0]
+ } else {
+ return { state: ProjectPageState.VERSION_NOT_FOUND }
+ }
+ // Find selected specification or default to first specification if none is selected.
+ let specification: IOpenApiSpecification
+ if (selectedSpecificationId) {
+ const selectedSpecification = version.specifications.find(e => e.id == selectedSpecificationId)
+ if (selectedSpecification) {
+ specification = selectedSpecification
+ } else {
+ return { state: ProjectPageState.SPECIFICATION_NOT_FOUND }
+ }
+ } else if (version.specifications.length > 0) {
+ specification = version.specifications[0]
+ } else {
+ return { state: ProjectPageState.SPECIFICATION_NOT_FOUND }
+ }
+ return {
+ state: ProjectPageState.HAS_SELECTION,
+ selection: { project, version, specification }
+ }
+}
+
+export default ProjectPageState
diff --git a/src/features/projects/domain/projectNavigator.ts b/src/features/projects/domain/projectNavigator.ts
new file mode 100644
index 00000000..1282c83a
--- /dev/null
+++ b/src/features/projects/domain/projectNavigator.ts
@@ -0,0 +1,57 @@
+import ProjectPageSelection from "./ProjectPageSelection"
+
+export interface IProjectRouter {
+ push(path: string): void
+ replace(path: string): void
+}
+
+export default {
+ navigateToVersion(
+ selection: ProjectPageSelection,
+ versionId: string,
+ router: IProjectRouter
+ ) {
+ // Let's see if we can find a specification with the same name.
+ const newVersion = selection.project.versions.find(e => {
+ return e.id == versionId
+ })
+ if (!newVersion) {
+ return
+ }
+ const candidateSpecification = newVersion.specifications.find(e => {
+ return e.name == selection.specification.name
+ })
+ if (candidateSpecification) {
+ router.push(`/${selection.project.id}/${newVersion.id}/${candidateSpecification.id}`)
+ } else {
+ const firstSpecification = newVersion.specifications[0]
+ router.push(`/${selection.project.id}/${newVersion.id}/${firstSpecification.id}`)
+ }
+ },
+ navigateToSpecification(
+ selection: ProjectPageSelection,
+ specificationId: string,
+ router: IProjectRouter
+ ) {
+ router.push(`/${selection.project.id}/${selection.version.id}/${specificationId}`)
+ },
+ navigateToCurrentSelection(
+ candidateSelection: {
+ projectId?: string,
+ versionId?: string,
+ specificationId?: string
+ },
+ actualSelection: ProjectPageSelection,
+ router: IProjectRouter
+ ) {
+ if (
+ actualSelection.project.id != candidateSelection.projectId ||
+ actualSelection.version.id != candidateSelection.versionId ||
+ actualSelection.specification.id != candidateSelection.specificationId
+ ) {
+ router.replace(
+ `/${actualSelection.project.id}/${actualSelection.version.id}/${actualSelection.specification.id}`
+ )
+ }
+ }
+}
diff --git a/src/features/projects/view/ProjectAvatar.tsx b/src/features/projects/view/ProjectAvatar.tsx
new file mode 100644
index 00000000..3aaa8f09
--- /dev/null
+++ b/src/features/projects/view/ProjectAvatar.tsx
@@ -0,0 +1,34 @@
+import { alpha, useTheme } from "@mui/material/styles"
+import { SxProps } from "@mui/system"
+import { Avatar } from "@mui/material"
+import IProject from "../domain/IProject"
+
+function ProjectAvatar(
+ {project, sx}: {project: ProjectType, sx?: SxProps}
+) {
+ const theme = useTheme()
+ if (project.imageURL) {
+ return (
+
+ {Array.from(project.displayName || project.name)[0]}
+
+ )
+ } else {
+ return (
+
+ {Array.from(project.displayName || project.name)[0]}
+
+ )
+ }
+}
+
+export default ProjectAvatar
diff --git a/src/features/projects/view/ProjectErrorContent.tsx b/src/features/projects/view/ProjectErrorContent.tsx
new file mode 100644
index 00000000..1228c830
--- /dev/null
+++ b/src/features/projects/view/ProjectErrorContent.tsx
@@ -0,0 +1,22 @@
+import { Box, Typography } from "@mui/material"
+
+const ProjectErrorContent = ({ text }: { text: string }) => {
+ return (
+
+
+ {text}
+
+
+ )
+}
+
+export default ProjectErrorContent
diff --git a/src/features/projects/view/ProjectList.tsx b/src/features/projects/view/ProjectList.tsx
new file mode 100644
index 00000000..2140232d
--- /dev/null
+++ b/src/features/projects/view/ProjectList.tsx
@@ -0,0 +1,56 @@
+import { List, Box, Typography } from "@mui/material"
+import ProjectListItem from "./ProjectListItem"
+import ProjectListItemPlaceholder from "./ProjectListItemPlaceholder"
+import IProject from "../domain/IProject"
+
+interface ProjectListProps {
+ readonly isLoading: boolean
+ readonly projects: ProjectType[]
+ readonly selectedProjectId?: string
+}
+
+const ProjectList = (
+ {
+ isLoading,
+ projects,
+ selectedProjectId
+ }: ProjectListProps
+) => {
+ const loadingItemCount = 6
+ if (isLoading || projects.length > 0) {
+ return (
+
+ {isLoading &&
+ [...new Array(loadingItemCount)].map((_, index) => (
+
+ ))
+ }
+ {!isLoading && projects.map(project => (
+
+ ))}
+
+ )
+ } else {
+ return (
+
+
+ Your list of projects is empty.
+
+
+ )
+ }
+}
+
+export default ProjectList
diff --git a/src/features/projects/view/ProjectListItem.tsx b/src/features/projects/view/ProjectListItem.tsx
new file mode 100644
index 00000000..77796d0d
--- /dev/null
+++ b/src/features/projects/view/ProjectListItem.tsx
@@ -0,0 +1,51 @@
+import { useRouter } from "next/navigation"
+import { ListItem, ListItemButton, ListItemText, Typography } from "@mui/material"
+import IProject from "../domain/IProject"
+import ProjectAvatar from "./ProjectAvatar"
+
+interface ProjectListItemProps {
+ readonly project: ProjectType
+ readonly isSelected: boolean
+}
+
+const ProjectListItem = (
+ {
+ project,
+ isSelected
+ }: ProjectListItemProps
+) => {
+ const router = useRouter()
+ return (
+
+ router.push(`/${project.id}`)}
+ selected={isSelected}
+ sx={{
+ paddingLeft: "15px",
+ paddingRight: "15px",
+ paddingTop: "15px",
+ paddingBottom: "15px"
+ }}
+ disableGutters
+ >
+
+
+ {project.displayName || project.name}
+
+ }
+ />
+
+
+ )
+}
+
+export default ProjectListItem
diff --git a/src/features/projects/view/ProjectListItemPlaceholder.tsx b/src/features/projects/view/ProjectListItemPlaceholder.tsx
new file mode 100644
index 00000000..e18e2208
--- /dev/null
+++ b/src/features/projects/view/ProjectListItemPlaceholder.tsx
@@ -0,0 +1,26 @@
+import { ListItem, ListItemText, Skeleton} from "@mui/material"
+
+const ProjectListItemPlaceholder = () => {
+ return (
+
+
+
+ } />
+
+ )
+}
+
+export default ProjectListItemPlaceholder
\ No newline at end of file
diff --git a/src/features/projects/view/ProjectsPage.tsx b/src/features/projects/view/ProjectsPage.tsx
new file mode 100644
index 00000000..b0177af0
--- /dev/null
+++ b/src/features/projects/view/ProjectsPage.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import { useRouter } from "next/navigation"
+import SidebarContainer from "@/common/SidebarContainer"
+import ProjectList from "./ProjectList"
+import ProjectsPageSecondaryContent from "./ProjectsPageSecondaryContent"
+import ProjectsPageTrailingToolbarItem from "./ProjectsPageTrailingToolbarItem"
+import useProjects from "../data/useProjects"
+import { getProjectPageState } from "../domain/ProjectPageState"
+import projectNavigator from "../domain/projectNavigator"
+
+interface ProjectsPageProps {
+ readonly projectId?: string
+ readonly versionId?: string
+ readonly specificationId?: string
+}
+
+export default function ProjectsPage(
+ { projectId, versionId, specificationId }: ProjectsPageProps
+) {
+ const router = useRouter()
+ const { projects, error, isLoading } = useProjects()
+ const stateContainer = getProjectPageState({
+ isLoading,
+ error,
+ projects,
+ selectedProjectId: projectId,
+ selectedVersionId: versionId,
+ selectedSpecificationId: specificationId
+ })
+ // Ensure the URL reflects the current selection of project, version, and specification.
+ if (stateContainer.selection) {
+ const candidateSelection = { projectId, versionId, specificationId }
+ projectNavigator.navigateToCurrentSelection(
+ candidateSelection,
+ stateContainer.selection,
+ router
+ )
+ }
+ return (
+
+ }
+ secondary={
+
+ }
+ toolbarTrailing={
+ {
+ projectNavigator.navigateToVersion(stateContainer.selection!, versionId, router)
+ }}
+ onSelectSpecification={(specificationId: string) => {
+ projectNavigator.navigateToSpecification(stateContainer.selection!, specificationId, router)
+ }}
+ />
+ }
+ />
+ )
+}
diff --git a/src/features/projects/view/ProjectsPageSecondaryContent.tsx b/src/features/projects/view/ProjectsPageSecondaryContent.tsx
new file mode 100644
index 00000000..5cb8b095
--- /dev/null
+++ b/src/features/projects/view/ProjectsPageSecondaryContent.tsx
@@ -0,0 +1,27 @@
+import { ProjectPageStateContainer, ProjectPageState } from "../domain/ProjectPageState"
+import ProjectErrorContent from "./ProjectErrorContent"
+import DocumentationViewer from "./docs/DocumentationViewer"
+
+const ProjectsPageSecondaryContent = ({
+ stateContainer
+}: {
+ stateContainer: ProjectPageStateContainer
+}) => {
+ switch (stateContainer.state) {
+ case ProjectPageState.LOADING:
+ case ProjectPageState.NO_PROJECT_SELECTED:
+ return <>>
+ case ProjectPageState.ERROR:
+ return
+ case ProjectPageState.HAS_SELECTION:
+ return
+ case ProjectPageState.PROJECT_NOT_FOUND:
+ return
+ case ProjectPageState.VERSION_NOT_FOUND:
+ return
+ case ProjectPageState.SPECIFICATION_NOT_FOUND:
+ return
+ }
+}
+
+export default ProjectsPageSecondaryContent
\ No newline at end of file
diff --git a/src/features/projects/view/ProjectsPageTrailingToolbarItem.tsx b/src/features/projects/view/ProjectsPageTrailingToolbarItem.tsx
new file mode 100644
index 00000000..b6f7537f
--- /dev/null
+++ b/src/features/projects/view/ProjectsPageTrailingToolbarItem.tsx
@@ -0,0 +1,66 @@
+import { Stack, IconButton, Typography, Link } from "@mui/material"
+import { ProjectPageStateContainer, ProjectPageState } from "../domain/ProjectPageState"
+import VersionSelector from "./docs/VersionSelector"
+import SpecificationSelector from "./docs/SpecificationSelector"
+import EditIcon from "@mui/icons-material/Edit"
+
+const ProjectsPageTrailingToolbarItem = (
+ {
+ stateContainer,
+ onSelectVersion,
+ onSelectSpecification
+ }: {
+ stateContainer: ProjectPageStateContainer,
+ onSelectVersion: (versionId: string) => void,
+ onSelectSpecification: (specificationId: string) => void
+ }
+) => {
+ switch (stateContainer.state) {
+ case ProjectPageState.HAS_SELECTION:
+ return (
+
+ {stateContainer.selection!.version.url &&
+
+ {stateContainer.selection!.project.name}
+
+ }
+ {!stateContainer.selection!.version.url &&
+
+ {stateContainer.selection!.project.name}
+
+ }
+ /
+
+ /
+
+ {stateContainer.selection!.specification.editURL &&
+
+
+
+ }
+
+ )
+ case ProjectPageState.LOADING:
+ case ProjectPageState.NO_PROJECT_SELECTED:
+ case ProjectPageState.PROJECT_NOT_FOUND:
+ case ProjectPageState.VERSION_NOT_FOUND:
+ case ProjectPageState.SPECIFICATION_NOT_FOUND:
+ return <>>
+ }
+}
+
+export default ProjectsPageTrailingToolbarItem
\ No newline at end of file
diff --git a/src/features/projects/view/docs/DocumentationViewer.tsx b/src/features/projects/view/docs/DocumentationViewer.tsx
new file mode 100644
index 00000000..6d501cdb
--- /dev/null
+++ b/src/features/projects/view/docs/DocumentationViewer.tsx
@@ -0,0 +1,29 @@
+import { useEffect } from "react"
+import { Events } from "@/common/events/BaseEvent"
+import { subscribe, unsubscribe } from "@/common/events/utils"
+import { useForceUpdate } from "@/common/useForceUpdate"
+import { settingsStore } from "@/common/client/startup"
+import Swagger from "./Swagger"
+import Redocly from "./Redocly"
+import DocumentationVisualizer from "@/features/settings/domain/DocumentationVisualizer"
+
+const DocumentationViewer: React.FC<{ url: string }> = ({ url }) => {
+ const forceUpdate = useForceUpdate()
+ const visualizer = settingsStore.documentationVisualizer
+
+ useEffect(() => {
+ subscribe(Events.SETTINGS_CHANGED, forceUpdate)
+ return () => {
+ unsubscribe(Events.SETTINGS_CHANGED, forceUpdate)
+ }
+ })
+
+ switch (visualizer.toString()) {
+ case DocumentationVisualizer.SWAGGER.toString():
+ return
+ case DocumentationVisualizer.REDOCLY.toString():
+ return
+ }
+}
+
+export default DocumentationViewer
diff --git a/src/features/projects/view/docs/Redocly.tsx b/src/features/projects/view/docs/Redocly.tsx
new file mode 100644
index 00000000..66628d6d
--- /dev/null
+++ b/src/features/projects/view/docs/Redocly.tsx
@@ -0,0 +1,7 @@
+import { RedocStandalone } from "redoc"
+
+const Redocly = ({ url }: { url: string }) => {
+ return
+}
+
+export default Redocly
diff --git a/src/features/projects/view/docs/SpecificationSelector.tsx b/src/features/projects/view/docs/SpecificationSelector.tsx
new file mode 100644
index 00000000..2684456f
--- /dev/null
+++ b/src/features/projects/view/docs/SpecificationSelector.tsx
@@ -0,0 +1,33 @@
+import { SelectChangeEvent, Select, MenuItem, FormControl } from "@mui/material"
+import IOpenApiSpecification from "../../domain/IOpenApiSpecification"
+
+interface SpecificationSelectorProps {
+ specifications: IOpenApiSpecification[]
+ selection: string
+ onSelect: (specificationId: string) => void
+}
+
+const SpecificationSelector: React.FC<
+ SpecificationSelectorProps
+> = ({
+ specifications,
+ selection,
+ onSelect
+}) => {
+ const handleVersionChange = (event: SelectChangeEvent) => {
+ onSelect(event.target.value)
+ }
+ return (
+
+
+
+ )
+}
+
+export default SpecificationSelector
diff --git a/src/features/projects/view/docs/Swagger.tsx b/src/features/projects/view/docs/Swagger.tsx
new file mode 100644
index 00000000..634afb75
--- /dev/null
+++ b/src/features/projects/view/docs/Swagger.tsx
@@ -0,0 +1,8 @@
+import SwaggerUI from "swagger-ui-react"
+import "swagger-ui-react/swagger-ui.css"
+
+const Swagger = ({ url }: { url: string }) => {
+ return
+}
+
+export default Swagger
diff --git a/src/features/projects/view/docs/VersionSelector.tsx b/src/features/projects/view/docs/VersionSelector.tsx
new file mode 100644
index 00000000..d5197eee
--- /dev/null
+++ b/src/features/projects/view/docs/VersionSelector.tsx
@@ -0,0 +1,31 @@
+import { Select, MenuItem, SelectChangeEvent, FormControl } from "@mui/material"
+import IVersion from "../../domain/IVersion"
+
+interface VersionSelectorProps {
+ versions: IVersion[]
+ selection: string
+ onSelect: (versionId: string) => void
+}
+
+const VersionSelector: React.FC = ({
+ versions,
+ selection,
+ onSelect
+}) => {
+ const handleVersionChange = (event: SelectChangeEvent) => {
+ onSelect(event.target.value)
+ }
+ return (
+
+
+
+ )
+}
+
+export default VersionSelector
diff --git a/src/features/settings/data/SettingsStore.ts b/src/features/settings/data/SettingsStore.ts
new file mode 100644
index 00000000..c7f8c561
--- /dev/null
+++ b/src/features/settings/data/SettingsStore.ts
@@ -0,0 +1,36 @@
+import DocumentationVisualizer from "../domain/DocumentationVisualizer"
+import ISettingsStore from "../domain/ISettingsStore"
+import SettingsChangedEvent from "@/common/events/SettingsChangedEvent"
+import { publish } from "@/common/events/utils"
+
+const LOCAL_STORAGE_SETTINGS_KEY = "settings"
+
+export default class SettingsStore implements ISettingsStore {
+ get documentationVisualizer(): DocumentationVisualizer {
+ return getSettings().documentationVisualizer
+ }
+
+ set documentationVisualizer(documentationVisualizer: DocumentationVisualizer) {
+ setSettings({ ...getSettings(), documentationVisualizer })
+ }
+}
+
+interface ISettings {
+ documentationVisualizer: DocumentationVisualizer
+}
+
+function getSettings(): ISettings {
+ const savedSettings = window.localStorage.getItem(LOCAL_STORAGE_SETTINGS_KEY)
+ return savedSettings ? JSON.parse(savedSettings) : getDefaultSettings()
+}
+
+function setSettings(settings: ISettings) {
+ window.localStorage.setItem(LOCAL_STORAGE_SETTINGS_KEY, JSON.stringify(settings))
+ publish(new SettingsChangedEvent())
+}
+
+function getDefaultSettings(): ISettings {
+ return {
+ documentationVisualizer: DocumentationVisualizer.SWAGGER
+ }
+}
diff --git a/src/features/settings/domain/DocumentationVisualizer.ts b/src/features/settings/domain/DocumentationVisualizer.ts
new file mode 100644
index 00000000..95b7c0ae
--- /dev/null
+++ b/src/features/settings/domain/DocumentationVisualizer.ts
@@ -0,0 +1,6 @@
+enum DocumentationVisualizer {
+ SWAGGER,
+ REDOCLY
+}
+
+export default DocumentationVisualizer
diff --git a/src/features/settings/domain/ISettingsStore.ts b/src/features/settings/domain/ISettingsStore.ts
new file mode 100644
index 00000000..3d68d3a8
--- /dev/null
+++ b/src/features/settings/domain/ISettingsStore.ts
@@ -0,0 +1,5 @@
+import DocumentationVisualizer from "./DocumentationVisualizer"
+
+export default interface ISettingsStore {
+ documentationVisualizer: DocumentationVisualizer
+}
diff --git a/src/features/settings/view/DocumentationVisualizationPicker.tsx b/src/features/settings/view/DocumentationVisualizationPicker.tsx
new file mode 100644
index 00000000..2c91714b
--- /dev/null
+++ b/src/features/settings/view/DocumentationVisualizationPicker.tsx
@@ -0,0 +1,36 @@
+import { useState } from "react"
+import { ToggleButtonGroup, ToggleButton } from "@mui/material"
+import DocumentationVisualizer from "../domain/DocumentationVisualizer"
+import { settingsStore } from "@/common/client/startup"
+
+const DocumentationVisualizationPicker: React.FC = () => {
+ const [value, setValue] = useState(settingsStore.documentationVisualizer)
+ const handleChange = (
+ _event: React.MouseEvent,
+ documentationVisualizer: DocumentationVisualizer
+ ) => {
+ setValue(documentationVisualizer)
+ setTimeout(() => {
+ settingsStore.documentationVisualizer = documentationVisualizer
+ })
+ }
+ return (
+
+
+ Swagger
+
+
+ Redocly
+
+
+ )
+}
+
+export default DocumentationVisualizationPicker
diff --git a/src/features/settings/view/SettingsButton.tsx b/src/features/settings/view/SettingsButton.tsx
new file mode 100644
index 00000000..f12bbca2
--- /dev/null
+++ b/src/features/settings/view/SettingsButton.tsx
@@ -0,0 +1,41 @@
+import { useState } from "react"
+import { Popover, IconButton } from "@mui/material"
+import { MoreHoriz } from "@mui/icons-material"
+import SettingsList from "./SettingsList"
+
+const SettingsButton: React.FC = () => {
+ const [popoverAnchorElement, setPopoverAnchorElement] = useState(null)
+
+ const handlePopoverClick = (event: React.MouseEvent) => {
+ setPopoverAnchorElement(event.currentTarget)
+ }
+
+ const handlePopoverClose = () => {
+ setPopoverAnchorElement(null)
+ }
+
+ const isPopoverOpen = Boolean(popoverAnchorElement)
+ const id = isPopoverOpen ? 'settings-popover' : undefined
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ )
+}
+
+export default SettingsButton
diff --git a/src/features/settings/view/SettingsList.tsx b/src/features/settings/view/SettingsList.tsx
new file mode 100644
index 00000000..165c2078
--- /dev/null
+++ b/src/features/settings/view/SettingsList.tsx
@@ -0,0 +1,22 @@
+import { List, Button } from "@mui/material"
+import DocumentationVisualizationPicker from "./DocumentationVisualizationPicker"
+
+const SettingsList: React.FC = () => {
+ return (
+
+
+
+
+ )
+}
+
+export default SettingsList
diff --git a/src/features/user/view/UserListItem.tsx b/src/features/user/view/UserListItem.tsx
new file mode 100644
index 00000000..195263e5
--- /dev/null
+++ b/src/features/user/view/UserListItem.tsx
@@ -0,0 +1,41 @@
+import { ReactNode } from "react"
+import { Avatar, Box, Typography, Skeleton } from "@mui/material"
+import { useUser } from '@auth0/nextjs-auth0/client'
+
+const UserListItem: React.FC<{
+ readonly secondaryItem?: ReactNode
+}> = ({
+ secondaryItem
+}) => {
+ const { user, isLoading } = useUser()
+ return (
+
+ {!isLoading && user && user.picture &&
+
+ }
+ {isLoading &&
+
+ }
+
+ {!isLoading && user && {user.name} }
+ {isLoading && }
+
+ {user && !isLoading && secondaryItem != null &&
+ <>
+
+ {secondaryItem}
+ >
+ }
+
+ )
+}
+
+export default UserListItem
diff --git a/src/features/user/view/UserListItemSkeleton.tsx b/src/features/user/view/UserListItemSkeleton.tsx
new file mode 100644
index 00000000..bc2241cd
--- /dev/null
+++ b/src/features/user/view/UserListItemSkeleton.tsx
@@ -0,0 +1,22 @@
+import { Box, Skeleton } from "@mui/material"
+
+const UserListItemSkeleton: React.FC = () => {
+ return (
+
+
+
+
+
+
+ )
+}
+
+export default UserListItemSkeleton
diff --git a/src/lib/auth/Auth0UserDetailsProvider.ts b/src/lib/auth/Auth0UserDetailsProvider.ts
deleted file mode 100644
index 07bd929e..00000000
--- a/src/lib/auth/Auth0UserDetailsProvider.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { IUserProvider } from './IUserProvider'
-import { IUserDetails } from './IUserDetails'
-import { IUserDetailsProvider } from './IUserDetailsProvider'
-import { ManagementClient } from 'auth0'
-
-interface Auth0UserDetailsProviderConfig {
- domain: string
- clientId: string
- clientSecret: string
-}
-
-export class Auth0UserDetailsProvider implements IUserDetailsProvider {
- private userProvider: IUserProvider
- private config: Auth0UserDetailsProviderConfig
-
- constructor(userProvider: IUserProvider, config: Auth0UserDetailsProviderConfig) {
- this.userProvider = userProvider
- this.config = config
- }
-
- async getUserDetails(): Promise {
- const user = await this.userProvider.getUser()
- const managementClient = new ManagementClient({
- domain: this.config.domain,
- clientId: this.config.clientId,
- clientSecret: this.config.clientSecret
- })
- const userResponse = await managementClient.users.get({ id: user.id })
- const identities = userResponse.data.identities.map(e => {
- return {
- provider: e.provider,
- accessToken: e.access_token
- }
- })
- return {identities}
- }
-}
diff --git a/src/lib/auth/Auth0UserProvider.ts b/src/lib/auth/Auth0UserProvider.ts
deleted file mode 100644
index b0830d87..00000000
--- a/src/lib/auth/Auth0UserProvider.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { IUser } from './IUser'
-import { IUserProvider } from './IUserProvider'
-import { getSession } from '@auth0/nextjs-auth0'
-
-export class Auth0UserProvider implements IUserProvider {
- async getUser(): Promise {
- const session = await getSession()
- const user = session?.user
- if (!user) {
- throw new Error("User is not authenticated")
- }
- return {
- id: user.sub,
- name: user.name,
- userName: user.nickname,
- avatarURL: user.picture
- }
- }
-}
diff --git a/src/lib/auth/IAccessTokenProvider.ts b/src/lib/auth/IAccessTokenProvider.ts
deleted file mode 100644
index e37bd99d..00000000
--- a/src/lib/auth/IAccessTokenProvider.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export interface IAccessTokenProvider {
- getAccessToken(): Promise
-}
diff --git a/src/lib/auth/IUser.ts b/src/lib/auth/IUser.ts
deleted file mode 100644
index 5d0a5329..00000000
--- a/src/lib/auth/IUser.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export interface IUser {
- readonly id: string
- readonly name: string
- readonly userName: string
- readonly avatarURL: string
-}
diff --git a/src/lib/auth/IUserDetails.ts b/src/lib/auth/IUserDetails.ts
deleted file mode 100644
index c1636af5..00000000
--- a/src/lib/auth/IUserDetails.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export interface IUserDetails {
- readonly identities: {provider: string, accessToken: string}[]
-}
diff --git a/src/lib/auth/IUserDetailsProvider.ts b/src/lib/auth/IUserDetailsProvider.ts
deleted file mode 100644
index 248317d5..00000000
--- a/src/lib/auth/IUserDetailsProvider.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { IUserDetails } from './IUserDetails'
-
-export interface IUserDetailsProvider {
- getUserDetails(): Promise
-}
diff --git a/src/lib/auth/IUserProvider.ts b/src/lib/auth/IUserProvider.ts
deleted file mode 100644
index d6bbc35f..00000000
--- a/src/lib/auth/IUserProvider.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { IUser } from './IUser'
-
-export interface IUserProvider {
- getUser(): Promise
-}
diff --git a/src/lib/auth/IdentityAccessTokenProvider.ts b/src/lib/auth/IdentityAccessTokenProvider.ts
deleted file mode 100644
index 96e288eb..00000000
--- a/src/lib/auth/IdentityAccessTokenProvider.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { IAccessTokenProvider } from "./IAccessTokenProvider"
-import { IUserDetailsProvider } from "./IUserDetailsProvider"
-
-export class IdentityAccessTokenProvider implements IAccessTokenProvider {
- private userDetailsProvider: IUserDetailsProvider
- private identityProvider: string
-
- constructor(
- userDetailsProvider: IUserDetailsProvider,
- identityProvider: string
- ) {
- this.userDetailsProvider = userDetailsProvider
- this.identityProvider = identityProvider
- }
-
- async getAccessToken(): Promise {
- const userDetails = await this.userDetailsProvider.getUserDetails()
- const identity = userDetails.identities.find(e => {
- return e.provider.toLowerCase() === this.identityProvider.toLowerCase()
- })
- if (!identity) {
- throw new Error("No identity found for provider '" + this.identityProvider + "'")
- }
- return identity.accessToken
- }
-}
\ No newline at end of file
diff --git a/src/lib/components/AppBarComponent.tsx b/src/lib/components/AppBarComponent.tsx
deleted file mode 100644
index bfb85c52..00000000
--- a/src/lib/components/AppBarComponent.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-"use client";
-
-import { Menu } from "@mui/icons-material";
-import { AppBar, Toolbar, IconButton, Box, Divider } from "@mui/material";
-import { ReactNode } from "react";
-import Image from "next/image";
-import ShapeLogo from "../../../public/shape2023.svg";
-
-interface AppBarComponentProps {
- drawerWidth: number;
- handleDrawerToggle: () => void;
- versionSelectorComponent?: ReactNode;
- openApiSpecificationsComponent?: ReactNode;
-}
-
-const AppBarComponent: React.FC = ({
- drawerWidth,
- handleDrawerToggle,
- versionSelectorComponent,
- openApiSpecificationsComponent,
-}) => {
- return (
-
-
- theme.zIndex.drawer + 1,
- }}
- >
-
-
-
-
-
-
-
-
- {versionSelectorComponent ?? <>>}
- {openApiSpecificationsComponent ?? <>>}
-
-
- );
-};
-
-export default AppBarComponent;
diff --git a/src/lib/components/DocumentationViewerComponent.tsx b/src/lib/components/DocumentationViewerComponent.tsx
deleted file mode 100644
index 068988b2..00000000
--- a/src/lib/components/DocumentationViewerComponent.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-"use client";
-
-import SwaggerComponent from "./SwaggerComponent";
-import RedoclyComponent from "./RedoclyComponent";
-import { getSettings } from "../utils/SettingsUtils";
-import { subscribe, unsubscribe } from "../utils/EventsUtils";
-import { Events } from "../events/BaseEvent";
-import { useEffect } from "react";
-import { useForceUpdate } from "../utils/Hooks";
-
-export enum DocumentationVisualizer {
- SWAGGER,
- REDOCLY,
-}
-
-export interface DocumentationViewerComponentProps {
- url: string;
-}
-
-const DocumentationViewerComponent: React.FC<
- DocumentationViewerComponentProps
-> = ({ url }) => {
- const forceUpdate = useForceUpdate();
- const visualizer = getSettings().documentationVisualizer;
-
- useEffect(() => {
- subscribe(Events.SETTINGS_CHANGED, forceUpdate);
- return () => {
- unsubscribe(Events.SETTINGS_CHANGED, forceUpdate);
- };
- });
-
- switch (visualizer.toString()) {
- case DocumentationVisualizer.SWAGGER.toString():
- console.log("here");
- return ;
-
- case DocumentationVisualizer.REDOCLY.toString():
- console.log("here2");
- return ;
- }
-};
-
-export default DocumentationViewerComponent;
diff --git a/src/lib/components/OpenApiSpecificationSelectorComponent.tsx b/src/lib/components/OpenApiSpecificationSelectorComponent.tsx
deleted file mode 100644
index 0f5543d4..00000000
--- a/src/lib/components/OpenApiSpecificationSelectorComponent.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-"use client";
-
-import { SelectChangeEvent, Select, MenuItem, Divider } from "@mui/material";
-import { useState } from "react";
-import { IOpenApiSpecification } from "../projects/IOpenAPISpecification";
-import OpenApiSpecificationChangedEvent from "../events/OpenApiSpecificationChangedEvent";
-import { publish } from "../utils/EventsUtils";
-import { getProject, getSpecification, getVersion } from "../utils/UrlUtils";
-import { useRouter } from "next/navigation";
-import { useForceUpdate } from "../utils/Hooks";
-
-interface OpenApiSpecificationSelectorComponentProps {
- openApiSpecifications: IOpenApiSpecification[];
- openAPISpecification?: string;
- versionName: string;
- projectName: string;
-}
-
-const OpenApiSpecificationSelectorComponent: React.FC<
- OpenApiSpecificationSelectorComponentProps
-> = ({ openApiSpecifications, openAPISpecification, projectName, versionName }) => {
- const router = useRouter();
- const firstOpenAPISpecification = openApiSpecifications[0];
- if (
- (!openAPISpecification || openAPISpecification.length == 0) &&
- firstOpenAPISpecification
- ) {
- router.push(
- `/${projectName.replace("-openapi", "")}/${versionName}/${firstOpenAPISpecification.name}`
- );
- }
-
- const handleVersionChange = (event: SelectChangeEvent) => {
- const openApiSpecificationName = event.target.value;
- router.push(`/${getProject()?.replace("-openapi", "")}/${getVersion()}/${openApiSpecificationName}`);
- };
-
- return (
-
- );
-};
-
-export default OpenApiSpecificationSelectorComponent;
diff --git a/src/lib/components/OpenApiSpecificationsComponent.tsx b/src/lib/components/OpenApiSpecificationsComponent.tsx
deleted file mode 100644
index 26f1ef8e..00000000
--- a/src/lib/components/OpenApiSpecificationsComponent.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { IUser } from "../auth/IUser";
-import { IGitHubVersion } from "../projects/IGitHubVersion";
-import { IOpenApiSpecificationRepository } from "../projects/IOpenAPISpecificationRepository";
-import OpenApiSpecificationSelectorComponent from "./OpenApiSpecificationSelectorComponent";
-
-interface OpenApiSpecificationsComponentProps {
- versionName: string;
- projectName: string;
- openApiSpecificationRepository: IOpenApiSpecificationRepository;
- specificationName?: string;
-}
-
-const OpenApiSpecificationsComponent: React.FC<
- OpenApiSpecificationsComponentProps
-> = async ({
- versionName,
- openApiSpecificationRepository,
- projectName,
- specificationName,
-}) => {
- const openApiSpecifications =
- await openApiSpecificationRepository.getOpenAPISpecifications({
- owner: "shapehq",
- repository: projectName,
- name: versionName,
- } as IGitHubVersion);
-
- return (
-
- );
-};
-
-export default OpenApiSpecificationsComponent;
diff --git a/src/lib/components/ProjectComponent.tsx b/src/lib/components/ProjectComponent.tsx
deleted file mode 100644
index 81face8d..00000000
--- a/src/lib/components/ProjectComponent.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-"use client";
-
-import { Avatar, ListItem, ListItemButton, ListItemText, Typography } from "@mui/material";
-import { useTheme } from "@mui/material/styles";
-import { IProject } from "../projects/IProject";
-import { Folder, FolderOpen } from "@mui/icons-material";
-import { getProject } from "../utils/UrlUtils";
-import { useState } from "react";
-import ProjectChangedEvent from "../events/ProjectChangedEvent";
-import { publish } from "../utils/EventsUtils";
-import { useRouter } from "next/navigation";
-import StringAvatar from "./StringAvatar";
-import { IGitHubProject } from "../projects/IGitHubProject";
-
-interface ProjectComponentProps {
- project: IGitHubProject;
- selectedProject: boolean;
-}
-
-const ProjectComponent: React.FC = ({ project, selectedProject }) => {
- const router = useRouter();
-
- const selectProject = () => {
- router.push(`/${project.repository.replace("-openapi", "")}`);
- };
- const theme = useTheme();
-
- return (
-
-
- {project.image && (
-
- )}
- {!project.image && (
-
- )}
- {selectedProject &&
-
- {project.name}
-
- }
- />
- }
- {!selectedProject && }
-
-
- );
-};
-
-export default ProjectComponent;
diff --git a/src/lib/components/ProjectListComponent.tsx b/src/lib/components/ProjectListComponent.tsx
deleted file mode 100644
index 8b716106..00000000
--- a/src/lib/components/ProjectListComponent.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { List, Divider } from "@mui/material";
-import ProjectComponent from "./ProjectComponent";
-import { IProjectRepository } from "../projects/IProjectRepository";
-import { IGitHubProject } from "../projects/IGitHubProject";
-import { getProject } from "../utils/UrlUtils";
-
-interface ProjectListComponentProps {
- projectRepository: IProjectRepository;
- projectName?: string;
-}
-
-const ProjectListComponent: React.FC = async ({
- projectRepository,
- projectName
-}) => {
- const projects = (await projectRepository.getProjects()) as IGitHubProject[];
- console.log(projects, projects)
- console.log(projectName, projectName)
- // projects.push(...projects);
- // projects.push(...projects);
- // projects.push(...projects);
- // projects.push(...projects);
- // projects.push(...projects);
- // projects.push(...projects);
- // projects.push(...projects);
- // projects.push(...projects);
- // projects.push(...projects);
- // projects.push(...projects);
-
- return (
-
- {projects.map((project, index) => (
-
-
- {index < projects.length - 1 &&
}
-
- ))}
-
- );
-};
-
-export default ProjectListComponent;
diff --git a/src/lib/components/RedoclyComponent.tsx b/src/lib/components/RedoclyComponent.tsx
deleted file mode 100644
index 9cb47f61..00000000
--- a/src/lib/components/RedoclyComponent.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-"use client";
-
-import { RedocStandalone } from "redoc";
-
-interface RedoclyComponentProps {
- url: string;
-}
-
-const RedoclyComponent: React.FC = ({ url }) => {
- return ;
-};
-
-export default RedoclyComponent;
diff --git a/src/lib/components/SettingsComponent.tsx b/src/lib/components/SettingsComponent.tsx
deleted file mode 100644
index 2b31e23d..00000000
--- a/src/lib/components/SettingsComponent.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-"use client"
-
-import { Popover, List, Button, IconButton } from "@mui/material";
-import { MoreHoriz } from '@mui/icons-material';
-import { useState } from "react";
-import DocumentationViewerSelectorComponent from "./settings/DocumentationViewerSelectorComponent";
-import { DocumentationVisualizer } from "./DocumentationViewerComponent";
-
-export interface ISettings {
- documentationVisualizer: DocumentationVisualizer;
-}
-
-const SettingsComponent: React.FC = () => {
- const [popoverAnchorElement, setPopoverAnchorElement] = useState(null);
-
- const handlePopoverClick = (event: React.MouseEvent) => {
- setPopoverAnchorElement(event.currentTarget);
- };
-
- const handlePopoverClose = () => {
- setPopoverAnchorElement(null);
- };
-
- const isPopoverOpen = Boolean(popoverAnchorElement);
- const id = isPopoverOpen ? 'settings-popover' : undefined;
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default SettingsComponent;
diff --git a/src/lib/components/SidebarComponent.tsx b/src/lib/components/SidebarComponent.tsx
deleted file mode 100644
index 1490c5b5..00000000
--- a/src/lib/components/SidebarComponent.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-"use client";
-
-import { Divider, Box, Drawer, IconButton, Typography } from "@mui/material";
-import { ReactNode } from "react";
-import { LibraryBooks } from '@mui/icons-material';
-import { SIDEBAR_SPACING } from "../style/dimensions";
-
-interface SidebarComponentProps {
- projectListComponent: ReactNode;
- userComponent: ReactNode;
- drawerWidth: number;
- open: boolean;
- handleDrawerToggle: () => void;
-}
-const SidebarComponent: React.FC = ({
- projectListComponent,
- userComponent,
- drawerWidth,
- open,
- handleDrawerToggle,
-}) => {
- const container =
- window !== undefined ? () => window.document.body : undefined;
- const drawer = (
-
-
-
-
- Projects
-
-
- {projectListComponent}
-
- {userComponent}
-
-
- );
-
- return (
-
-
- {drawer}
-
- {/* Desktop drawer*/}
-
- {drawer}
-
-
- );
-};
-
-export default SidebarComponent;
diff --git a/src/lib/components/StringAvatar.tsx b/src/lib/components/StringAvatar.tsx
deleted file mode 100644
index 668546e4..00000000
--- a/src/lib/components/StringAvatar.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-"use client";
-
-import { Avatar } from "@mui/material";
-import { SxProps } from '@mui/system';
-
-function stringToColor(string: string, saturation: number = 100, lightness: number = 68) {
- let hash = 0;
- for (let i = 0; i < string.length; i++) {
- hash = string.charCodeAt(i) + ((hash << 5) - hash);
- hash = hash & hash;
- }
- return `hsl(${(hash % 360)}, ${saturation}%, ${lightness}%)`;
-}
-
-interface StringAvatarProps {
- string: string;
- sx?: SxProps
-}
-
-const StringAvatar: React.FC = ({
- string,
- sx
-}) => {
- return (
-
- {Array.from(string)[0]}
-
- )
-}
-
-export default StringAvatar
diff --git a/src/lib/components/SwaggerComponent.tsx b/src/lib/components/SwaggerComponent.tsx
deleted file mode 100644
index 293a37af..00000000
--- a/src/lib/components/SwaggerComponent.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-"use client";
-
-import SwaggerUI from "swagger-ui-react";
-import "swagger-ui-react/swagger-ui.css";
-
-interface SwaggerComponentProps {
- url: string;
-}
-
-const SwaggerComponent: React.FC = ({ url }) => {
- return ;
-};
-
-export default SwaggerComponent;
diff --git a/src/lib/components/UserComponent.tsx b/src/lib/components/UserComponent.tsx
deleted file mode 100644
index 11511781..00000000
--- a/src/lib/components/UserComponent.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Avatar, Box } from "@mui/material";
-import { IUserProvider } from "../auth/IUserProvider";
-import SettingsComponent from "./SettingsComponent"
-import { SIDEBAR_SPACING } from "../style/dimensions";
-import { IUser } from "../auth/IUser";
-
-interface UserComponentProps {
- user: IUser;
-}
-
-const UserComponent: React.FC = async ({
- user,
-}) => {
- const name = user.name !== "" ? user.name : user.userName;
-
- return (
-
-
- Hi {name} 👋
-
-
- );
-};
-
-export default UserComponent;
diff --git a/src/lib/components/VersionSelectorComponent.tsx b/src/lib/components/VersionSelectorComponent.tsx
deleted file mode 100644
index 200a9c7b..00000000
--- a/src/lib/components/VersionSelectorComponent.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-"use client";
-
-import { MenuItem, Select, SelectChangeEvent, Divider } from "@mui/material";
-import { IVersion } from "../projects/IVersion";
-import { useState } from "react";
-import { getProject, getVersion } from "../utils/UrlUtils";
-import { publish } from "../utils/EventsUtils";
-import { Events } from "../events/BaseEvent";
-import VersionChangedEvent from "../events/VersionChangedEvent";
-import { useRouter } from "next/navigation";
-
-interface VersionSelectorComponentProps {
- versions: IVersion[];
- version?: string;
- projectName: string;
-}
-
-const VersionSelectorComponent: React.FC = ({
- versions,
- version,
- projectName,
-}) => {
- const router = useRouter();
- const firstVersion = versions[0];
- if ((!version || version.length == 0) && firstVersion) {
- router.push(
- `/${projectName?.replace("-openapi", "")}/${firstVersion.name}`
- );
- }
-
- const handleVersionChange = (event: SelectChangeEvent) => {
- const versionName = event.target.value;
- router.push(`/${getProject()?.replace("-openapi", "")}/${versionName}`);
- };
-
- return (
-
- );
-};
-
-export default VersionSelectorComponent;
diff --git a/src/lib/components/VersionsComponent.tsx b/src/lib/components/VersionsComponent.tsx
deleted file mode 100644
index a62b2d58..00000000
--- a/src/lib/components/VersionsComponent.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { IUser } from "../auth/IUser";
-import { IGitHubProject } from "../projects/IGitHubProject";
-import { IProject } from "../projects/IProject";
-import { IVersionRepository } from "../projects/IVersionRepository";
-import VersionSelectorComponent from "./VersionSelectorComponent";
-
-interface VersionsComponentProps {
- versionRepository: IVersionRepository;
- projectName: string;
- versionName?: string;
- user: IUser;
-}
-
-const VersionsComponent: React.FC = async ({
- versionRepository,
- projectName,
- versionName,
- user,
-}) => {
- const versions = await versionRepository.getVersions({
- name: projectName,
- owner: "shapehq",
- } as IGitHubProject);
-
- return (
-
- );
-};
-
-export default VersionsComponent;
diff --git a/src/lib/components/settings/DocumentationViewerSelectorComponent.tsx b/src/lib/components/settings/DocumentationViewerSelectorComponent.tsx
deleted file mode 100644
index a919547f..00000000
--- a/src/lib/components/settings/DocumentationViewerSelectorComponent.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { ToggleButtonGroup, ToggleButton } from "@mui/material";
-import { useState } from "react";
-import { DocumentationVisualizer } from "../DocumentationViewerComponent";
-import { getSettings, setSettings } from "@/lib/utils/SettingsUtils";
-import Image from "next/image";
-import SettingsChangedEvent from "@/lib/events/SettingsChangeEvent";
-import { publish } from "@/lib/utils/EventsUtils";
-
-const DocumentationViewerSelectorComponent: React.FC = () => {
- const [visualizer, setVisualizer] = useState(
- getSettings().documentationVisualizer
- );
- const handleChange = (
- _event: React.MouseEvent,
- documentationVisualizer: DocumentationVisualizer
- ) => {
- setVisualizer(documentationVisualizer);
- setTimeout(() =>
- setSettings({
- ...getSettings(),
- documentationVisualizer,
- })
- );
- publish(new SettingsChangedEvent());
- };
- return (
-
-
-
-
-
-
-
-
- );
-};
-
-export default DocumentationViewerSelectorComponent;
diff --git a/src/lib/events/BaseEvent.ts b/src/lib/events/BaseEvent.ts
deleted file mode 100644
index bf4e1c15..00000000
--- a/src/lib/events/BaseEvent.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export enum Events {
- SETTINGS_CHANGED = "SETTINGS_CHANGED",
- PROJECT_CHANGED = "PROJECT_CHANGED",
- VERSION_CHANGED = "VERSION_CHANGED",
- OPEN_API_SPECIFICATION_CHANGED = "OPEN_API_SPECIFICATION_CHANGED",
-}
-
-export default abstract class BaseEvent {
- constructor(public name: Events, public data: T) {}
-}
\ No newline at end of file
diff --git a/src/lib/events/OpenApiSpecificationChangedEvent.ts b/src/lib/events/OpenApiSpecificationChangedEvent.ts
deleted file mode 100644
index ea7098e4..00000000
--- a/src/lib/events/OpenApiSpecificationChangedEvent.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { IOpenApiSpecification } from "../projects/IOpenAPISpecification";
-import BaseEvent, { Events } from "./BaseEvent";
-
-export interface OpenApiSpecificationChangedEventData {
- openApiSpecification: IOpenApiSpecification;
-}
-
-export default class OpenApiSpecificationChangedEvent extends BaseEvent {
- constructor(openApiSpecification: IOpenApiSpecification) {
- super(Events.OPEN_API_SPECIFICATION_CHANGED, { openApiSpecification });
- }
-}
\ No newline at end of file
diff --git a/src/lib/events/ProjectChangedEvent.ts b/src/lib/events/ProjectChangedEvent.ts
deleted file mode 100644
index 873afbaf..00000000
--- a/src/lib/events/ProjectChangedEvent.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import BaseEvent, { Events } from "./BaseEvent";
-
-export interface ProjectChangedEventData {
- projectName: string;
-}
-
-export default class ProjectChangedEvent extends BaseEvent {
- constructor(projectName: string) {
- super(Events.PROJECT_CHANGED, {projectName});
- }
-}
\ No newline at end of file
diff --git a/src/lib/events/SettingsChangeEvent.ts b/src/lib/events/SettingsChangeEvent.ts
deleted file mode 100644
index c828f562..00000000
--- a/src/lib/events/SettingsChangeEvent.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import BaseEvent, { Events } from "./BaseEvent";
-
-export default class SettingsChangedEvent extends BaseEvent {
- constructor() {
- super(Events.SETTINGS_CHANGED, undefined);
- }
-}
\ No newline at end of file
diff --git a/src/lib/events/VersionChangedEvent.ts b/src/lib/events/VersionChangedEvent.ts
deleted file mode 100644
index 61b59550..00000000
--- a/src/lib/events/VersionChangedEvent.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import BaseEvent, { Events } from "./BaseEvent";
-
-export interface VersionChangedEventData {
- versionName: string;
-}
-
-export default class VersionChangedEvent extends BaseEvent {
- constructor(versionName: string) {
- super(Events.VERSION_CHANGED, { versionName });
- }
-}
\ No newline at end of file
diff --git a/src/lib/github/DeferredGitHubClient.ts b/src/lib/github/DeferredGitHubClient.ts
deleted file mode 100644
index 628d70d7..00000000
--- a/src/lib/github/DeferredGitHubClient.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { IAccessTokenProvider } from "@/lib/auth/IAccessTokenProvider"
-import { IGitHubOrganizationNameProvider } from "./IGitHubOrganizationNameProvider"
-import { IGitHubClient } from "./IGitHubClient"
-import { IGitHubContentItem } from "./IGitHubContentItem"
-import { IGitHubBranch } from "./IGitHubBranch"
-import { IGitHubRepository } from "./IGitHubRepository"
-import { IGitHubTag } from "./IGitHubTag"
-
-type GitHubClientFactory = (organizationName: string, accessToken: string) => IGitHubClient
-
-export class DeferredGitHubClient implements IGitHubClient {
- private organizationNameProvider: IGitHubOrganizationNameProvider
- private accessTokenProvider: IAccessTokenProvider
- private gitHubClientFactory: GitHubClientFactory
- private gitHubClient?: IGitHubClient
-
- constructor(
- organizationNameProvider: IGitHubOrganizationNameProvider,
- accessTokenProvider: IAccessTokenProvider,
- gitHubClientFactory: GitHubClientFactory
- ) {
- this.organizationNameProvider = organizationNameProvider
- this.accessTokenProvider = accessTokenProvider
- this.gitHubClientFactory = gitHubClientFactory
- }
-
- async getRepositories(suffix: string): Promise {
- const gitHubClient = await this.getGitHubClient()
- return await gitHubClient.getRepositories(suffix)
- }
-
- async getBranches(owner: string, repository: string): Promise {
- const gitHubClient = await this.getGitHubClient()
- return await gitHubClient.getBranches(owner, repository)
- }
-
- async getTags(owner: string, repository: string): Promise {
- const gitHubClient = await this.getGitHubClient()
- return await gitHubClient.getTags(owner, repository)
- }
-
- async getContent(
- owner: string,
- repository: string,
- ref?: string,
- path?: string
- ): Promise {
- const gitHubClient = await this.getGitHubClient()
- return await gitHubClient.getContent(owner, repository, ref, path)
- }
-
- private async getGitHubClient(): Promise {
- if (this.gitHubClient != null) {
- return this.gitHubClient
- } else {
- const organizationName = await this.organizationNameProvider.getOrganizationName()
- const accessToken = await this.accessTokenProvider.getAccessToken()
- const gitHubClient = this.gitHubClientFactory(
- organizationName,
- accessToken
- )
- this.gitHubClient = gitHubClient
- return gitHubClient
- }
- }
-}
diff --git a/src/lib/github/IGitHubBranch.ts b/src/lib/github/IGitHubBranch.ts
deleted file mode 100644
index 55d8879d..00000000
--- a/src/lib/github/IGitHubBranch.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export interface IGitHubBranch {
- readonly name: string
-}
diff --git a/src/lib/github/IGitHubClient.ts b/src/lib/github/IGitHubClient.ts
deleted file mode 100644
index 5ff461ba..00000000
--- a/src/lib/github/IGitHubClient.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { IGitHubBranch } from "./IGitHubBranch"
-import { IGitHubContentItem } from "./IGitHubContentItem"
-import { IGitHubRepository } from "./IGitHubRepository"
-import { IGitHubTag } from "./IGitHubTag"
-
-export interface IGitHubClient {
- getRepositories(suffix: string): Promise
- getBranches(owner: string, repository: string): Promise
- getTags(owner: string, repository: string): Promise
- getContent(
- owner: string,
- repository: string,
- ref?: string,
- path?: string
- ): Promise
-}
diff --git a/src/lib/github/IGitHubContentItem.ts b/src/lib/github/IGitHubContentItem.ts
deleted file mode 100644
index c3b77157..00000000
--- a/src/lib/github/IGitHubContentItem.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export interface IGitHubContentItem {
- readonly name: string
- readonly path: string
- readonly url: string
-}
\ No newline at end of file
diff --git a/src/lib/github/IGitHubOrganizationNameProvider.ts b/src/lib/github/IGitHubOrganizationNameProvider.ts
deleted file mode 100644
index 56b237b8..00000000
--- a/src/lib/github/IGitHubOrganizationNameProvider.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export interface IGitHubOrganizationNameProvider {
- getOrganizationName(): Promise
-}
diff --git a/src/lib/github/IGitHubRepository.ts b/src/lib/github/IGitHubRepository.ts
deleted file mode 100644
index 8acaafd9..00000000
--- a/src/lib/github/IGitHubRepository.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export interface IGitHubRepository {
- readonly name: string
- readonly owner: string
- readonly defaultBranch: string
-}
diff --git a/src/lib/github/IGitHubTag.ts b/src/lib/github/IGitHubTag.ts
deleted file mode 100644
index e0f10ea0..00000000
--- a/src/lib/github/IGitHubTag.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export interface IGitHubTag {
- readonly name: string
-}
diff --git a/src/lib/github/OctokitGitHubClient.ts b/src/lib/github/OctokitGitHubClient.ts
deleted file mode 100644
index 4232a9ce..00000000
--- a/src/lib/github/OctokitGitHubClient.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { IGitHubBranch } from "./IGitHubBranch"
-import { IGitHubClient } from "./IGitHubClient"
-import { IGitHubContentItem } from "./IGitHubContentItem"
-import { IGitHubRepository } from "./IGitHubRepository"
-import { IGitHubTag } from "./IGitHubTag"
-import { Octokit } from "octokit"
-
-type GitHubItem = {name: string, path: string, download_url: string}
-
-export class OctokitGitHubClient implements IGitHubClient {
- private organizationName: string
- private octokit: Octokit
-
- constructor(organizationName: string, accessToken: string) {
- this.organizationName = organizationName
- this.octokit = new Octokit({ auth: accessToken })
- }
-
- async getRepositories(suffix: string): Promise {
- let repositories: IGitHubRepository[] = []
- for await (const response of this.octokit.paginate.iterator(
- this.octokit.rest.search.repos,
- {
- q: "org:" + this.organizationName + " " + suffix + " in:name"
- }
- )) {
- repositories = repositories.concat(response.data
- .filter(e => {
- return e.name.endsWith(suffix)
- })
- .filter(e => e.owner != null )
- .map(e => {
- return {
- name: e.name,
- owner: e.owner!.login,
- defaultBranch: e.default_branch
- }
- })
- )
- }
- return repositories
- }
-
- async getBranches(owner: string, repository: string): Promise {
- let branches: IGitHubBranch[] = []
- for await (const response of this.octokit.paginate.iterator(
- this.octokit.rest.repos.listBranches,
- {
- owner,
- repo: repository
- }
- )) {
- branches = branches.concat(response.data.map(e => {
- return { name: e.name }
- }))
- }
- return branches
- }
-
- async getTags(owner: string, repository: string): Promise {
- let tags: IGitHubTag[] = []
- for await (const response of this.octokit.paginate.iterator(
- this.octokit.rest.repos.listTags,
- {
- owner,
- repo: repository
- }
- )) {
- tags = tags.concat(response.data.map(e => {
- return { name: e.name }
- }))
- }
- return tags
- }
-
- async getContent(
- owner: string,
- repository: string,
- ref?: string,
- path?: string
- ): Promise {
- const response = await this.octokit.rest.repos.getContent({
- owner: owner,
- repo: repository,
- path: path || '',
- ref: ref
- })
- let items: GitHubItem[] = []
- if (Array.isArray(response.data)) {
- items = response.data as GitHubItem[]
- } else {
- items = [response.data as GitHubItem]
- }
- return items
- .map(e => {
- return {
- name: e.name,
- path: e.path,
- url: e.download_url
- }
- })
- }
-}
diff --git a/src/lib/networking/AxiosNetworkClient.ts b/src/lib/networking/AxiosNetworkClient.ts
deleted file mode 100644
index 46be66ab..00000000
--- a/src/lib/networking/AxiosNetworkClient.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import axios from 'axios'
-import { INetworkClient } from './INetworkClient'
-import { INetworkResponse } from './INetworkResponse'
-import { INetworkRequest } from './INetworkRequest'
-
-export class AxiosNetworkClient implements INetworkClient {
- async get(request: INetworkRequest): Promise> {
- return await this.send({
- method: 'get',
- url: request.url,
- headers: request.headers
- })
- }
-
- async post(request: INetworkRequest): Promise> {
- return await this.send({
- method: 'post',
- url: request.url,
- headers: request.headers,
- data: request.body
- })
- }
-
- async send(request: {
- method: string,
- url: string,
- headers?: {[key: string]: string},
- data?: any
- }): Promise> {
- const response = await axios({
- method: request.method,
- url: request.url,
- headers: request.headers || {},
- data: request.data
- })
- return {status: response.status, body: response.data}
- }
-}
diff --git a/src/lib/networking/INetworkClient.ts b/src/lib/networking/INetworkClient.ts
deleted file mode 100644
index 440b8554..00000000
--- a/src/lib/networking/INetworkClient.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { INetworkRequest } from "./INetworkRequest"
-import { INetworkResponse } from "./INetworkResponse"
-
-export interface INetworkClient {
- get(request: INetworkRequest): Promise>
- post(request: INetworkRequest): Promise>
-}
diff --git a/src/lib/networking/INetworkRequest.ts b/src/lib/networking/INetworkRequest.ts
deleted file mode 100644
index 4d5fcc88..00000000
--- a/src/lib/networking/INetworkRequest.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export interface INetworkRequest {
- url: string
- headers?: {[key: string]: string}
- body?: any
-}
diff --git a/src/lib/networking/INetworkResponse.ts b/src/lib/networking/INetworkResponse.ts
deleted file mode 100644
index 9aeb49e4..00000000
--- a/src/lib/networking/INetworkResponse.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export interface INetworkResponse {
- status: number
- body: Body
-}
diff --git a/src/lib/pages/App.tsx b/src/lib/pages/App.tsx
deleted file mode 100644
index ec36b5ee..00000000
--- a/src/lib/pages/App.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-"use client";
-
-import { Box, CssBaseline } from "@mui/material";
-import { ReactNode, useState } from "react";
-import React from "react";
-import SidebarComponent from "../components/SidebarComponent";
-import AppBarComponent from "../components/AppBarComponent";
-import { createTheme, ThemeProvider } from '@mui/material/styles';
-
-const theme = createTheme({
- palette: {
- mode: 'light',
- primary: {
- main: '#ffffff',
- light: '#3b1490',
- dark: '#2b262a',
- contrastText: 'rgba(0,0,0,0.87)',
- },
- secondary: {
- main: '#FF4D5B',
- light: '#FF4D5B',
- dark: '#FF4D5B',
- },
- error: {
- main: '#a8101e',
- },
- warning: {
- main: '#e8c01e',
- },
- success: {
- main: '#47a84c',
- },
- info: {
- main: '#17a0f1',
- },
- },
- typography: {
- button: {
- textTransform: 'none'
- }
- }
-})
-
-interface AppProps {
- projectListComponent: ReactNode;
- userComponent: ReactNode;
- versionSelectorComponent?: ReactNode;
- openApiSpecificationsComponent?: ReactNode;
- children: ReactNode;
-}
-
-const App: React.FC = ({
- userComponent,
- projectListComponent,
- versionSelectorComponent,
- openApiSpecificationsComponent,
- children,
-}) => {
- const drawerWidth = 320;
- const [isDrawerOpen, setDrawerOpen] = useState(false);
- const handleDrawerToggle = () => {
- setDrawerOpen(!isDrawerOpen);
- };
-
- return (
-
-
-
-
-
-
- {children}
-
-
-
- );
-};
-
-export default App;
diff --git a/src/lib/pages/DocumentationViewerPage.tsx b/src/lib/pages/DocumentationViewerPage.tsx
deleted file mode 100644
index acd3233c..00000000
--- a/src/lib/pages/DocumentationViewerPage.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-"use client";
-
-import { Box } from "@mui/material";
-import DocumentationViewerComponent from "../components/DocumentationViewerComponent";
-import { IOpenApiSpecification } from "../projects/IOpenAPISpecification";
-
-interface DocumentationViewerPageProps {
- openApiSpecification: IOpenApiSpecification;
-}
-
-const DocumentationViewerPage: React.FC = ({
- openApiSpecification,
-}) => {
- return (
-
-
-
- );
-};
-
-export default DocumentationViewerPage;
diff --git a/src/lib/pages/NotFoundPage.tsx b/src/lib/pages/NotFoundPage.tsx
deleted file mode 100644
index 4fa6e056..00000000
--- a/src/lib/pages/NotFoundPage.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-"use client";
-
-const NotFoundPage: React.FC = () => {
- return (
-
- );
-};
-
-export default NotFoundPage;
diff --git a/src/lib/pages/WelcomePage.tsx b/src/lib/pages/WelcomePage.tsx
deleted file mode 100644
index a20f11f9..00000000
--- a/src/lib/pages/WelcomePage.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-const WelcomePage: React.FC = () => {
- return
-

-
-}
-
-export default WelcomePage;
diff --git a/src/lib/projects/GitHubOpenAPISpecificationRepository.ts b/src/lib/projects/GitHubOpenAPISpecificationRepository.ts
deleted file mode 100644
index f6c1214e..00000000
--- a/src/lib/projects/GitHubOpenAPISpecificationRepository.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-
-import { IGitHubClient } from "@/lib/github/IGitHubClient"
-import { IGitHubVersion } from "./IGitHubVersion"
-import { IOpenApiSpecificationRepository } from "./IOpenAPISpecificationRepository"
-import { IOpenApiSpecification } from "./IOpenAPISpecification"
-
-export class GitHubOpenApiSpecificationRepository implements IOpenApiSpecificationRepository {
- private gitHubClient: IGitHubClient
-
- constructor(gitHubClient: IGitHubClient) {
- this.gitHubClient = gitHubClient
- }
-
- async getOpenAPISpecifications(version: IGitHubVersion): Promise {
- const content = await this.gitHubClient.getContent(version.owner, version.repository, version.name)
- return content.filter(e => {
- return this.isOpenAPISpecification(e.name)
- })
- }
-
- private isOpenAPISpecification(filename: string): boolean {
- return !filename.startsWith(".") && (
- filename.endsWith(".yml") || filename.endsWith(".yaml")
- )
- }
-}
diff --git a/src/lib/projects/GitHubProjectRepository.ts b/src/lib/projects/GitHubProjectRepository.ts
deleted file mode 100644
index f4ee80b2..00000000
--- a/src/lib/projects/GitHubProjectRepository.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { IGitHubClient } from "@/lib/github/IGitHubClient"
-import { IGitHubRepository } from "@/lib/github/IGitHubRepository"
-import { IGitHubProject } from "./IGitHubProject"
-import { INetworkClient } from "@/lib/networking/INetworkClient"
-import { INetworkResponse } from "@/lib/networking/INetworkResponse"
-import { IProjectRepository } from "./IProjectRepository"
-import { ProjectConfigParser } from "./ProjectConfigParser"
-
-export class GitHubProjectRepository implements IProjectRepository {
- private gitHubClient: IGitHubClient
- private networkClient: INetworkClient
-
- constructor(gitHubClient: IGitHubClient, networkClient: INetworkClient) {
- this.gitHubClient = gitHubClient
- this.networkClient = networkClient
- }
-
- async getProjects(): Promise {
- const repositories = await this.gitHubClient.getRepositories("-openapi")
- const projects = await Promise.all(repositories.map(repository => {
- return this.getProject(repository)
- }))
- return projects.sort((a, b) => a.name.localeCompare(b.name))
- }
-
- private async getProject(repository: IGitHubRepository): Promise {
- const content = await this.gitHubClient.getContent(
- repository.owner,
- repository.name,
- repository.defaultBranch
- )
- const defaultName = repository.name.replace(/\-openapi$/, "")
- const configFile = content.find(e => this.isConfigFile(e.name))
- if (!configFile) {
- return {
- name: defaultName,
- repository: repository.name,
- owner: repository.owner,
- defaultBranch: repository.defaultBranch
- }
- }
- const configResponse: INetworkResponse = await this.networkClient.get({
- url: configFile.url
- })
- const configParser = new ProjectConfigParser()
- const config = configParser.parse(configResponse.body)
- let imageURL: string | undefined = undefined
- if (config.image && config.image.length > 0) {
- imageURL = await this.getImageURL(repository, config.image)
- }
- return {
- name: config.name || defaultName,
- image: imageURL,
- repository: repository.name,
- owner: repository.owner,
- defaultBranch: repository.defaultBranch
- }
- }
-
- private async getImageURL(
- repository: IGitHubRepository,
- imagePath: string
- ): Promise {
- const isValidURL = (string: string) => {
- try {
- return Boolean(new URL(string))
- } catch(e) {
- return false
- }
- }
- if (isValidURL(imagePath)) {
- return imagePath
- }
- const items = await this.gitHubClient.getContent(
- repository.owner,
- repository.name,
- repository.defaultBranch,
- imagePath
- )
- return items.find(e => e.path == imagePath)?.url
- }
-
- private isConfigFile(filename: string): boolean {
- return ["yml", "yaml"].find(ext => filename === ".shape-docs." + ext) != null
- }
-}
diff --git a/src/lib/projects/GitHubVersionRepository.ts b/src/lib/projects/GitHubVersionRepository.ts
deleted file mode 100644
index dfcb55af..00000000
--- a/src/lib/projects/GitHubVersionRepository.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { IVersionRepository } from "./IVersionRepository"
-import { IGitHubClient } from "@/lib/github/IGitHubClient"
-import { IGitHubProject } from "./IGitHubProject"
-import { IGitHubVersion } from "./IGitHubVersion"
-
-export class GitHubVersionRepository implements IVersionRepository {
- private gitHubClient: IGitHubClient
-
- constructor(gitHubClient: IGitHubClient) {
- this.gitHubClient = gitHubClient
- }
-
- async getVersions(project: IGitHubProject): Promise {
- const branchesPromise = this.gitHubClient.getBranches(project.owner, project.name)
- const tagsPromise = this.gitHubClient.getTags(project.owner, project.name)
- return Promise.all([branchesPromise, tagsPromise]).then(data => {
- const branches = data[0]
- const tags = data[1]
- const branchVersions = branches.map(b => {
- return {
- owner: project.owner,
- repository: project.name,
- name: b.name
- }
- }).sort((a, b) => {
- return a.name.localeCompare(b.name)
- })
- let candidateDefaultBranches = ["main", "master", "develop", "development"]
- if (project.defaultBranch) {
- candidateDefaultBranches.splice(0, 0, project.defaultBranch)
- }
- // Reverse them so the top-priority branches end up at the top of the list.
- candidateDefaultBranches = candidateDefaultBranches.reverse()
- // Move the top-priority branches to the top of the list.
- for (const candidateDefaultBranch of candidateDefaultBranches) {
- const defaultBranchIndex = branchVersions.findIndex(e => e.name === candidateDefaultBranch)
- if (defaultBranchIndex !== -1) {
- const defaultBranchVersion = branchVersions[defaultBranchIndex]
- delete branchVersions[defaultBranchIndex]
- branchVersions.splice(0, 0, defaultBranchVersion)
- }
- }
- const tagVersions = tags.map(t => {
- return {
- owner: project.owner,
- repository: project.name,
- name: t.name
- }
- }).sort((a, b) => {
- return a.name.localeCompare(b.name)
- })
- return branchVersions.concat(tagVersions)
- })
- }
-}
\ No newline at end of file
diff --git a/src/lib/projects/IGitHubProject.ts b/src/lib/projects/IGitHubProject.ts
deleted file mode 100644
index ca45ed1d..00000000
--- a/src/lib/projects/IGitHubProject.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { IProject } from "./IProject";
-
-export interface IGitHubProject extends IProject {
- readonly owner: string
- readonly repository: string
- readonly defaultBranch: string
-}
diff --git a/src/lib/projects/IGitHubVersion.ts b/src/lib/projects/IGitHubVersion.ts
deleted file mode 100644
index 2da13e9f..00000000
--- a/src/lib/projects/IGitHubVersion.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { IVersion } from "./IVersion"
-
-export interface IGitHubVersion extends IVersion {
- readonly owner: string
- readonly repository: string
-}
diff --git a/src/lib/projects/IOpenAPISpecification.ts b/src/lib/projects/IOpenAPISpecification.ts
deleted file mode 100644
index 083373db..00000000
--- a/src/lib/projects/IOpenAPISpecification.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export interface IOpenApiSpecification {
- readonly name: string
- readonly url: string
-}
diff --git a/src/lib/projects/IOpenAPISpecificationRepository.ts b/src/lib/projects/IOpenAPISpecificationRepository.ts
deleted file mode 100644
index 3944e098..00000000
--- a/src/lib/projects/IOpenAPISpecificationRepository.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { IOpenApiSpecification } from "./IOpenAPISpecification"
-import { IVersion } from "./IVersion"
-
-export interface IOpenApiSpecificationRepository {
- getOpenAPISpecifications(version: IVersion): Promise
-}
diff --git a/src/lib/projects/IProject.ts b/src/lib/projects/IProject.ts
deleted file mode 100644
index 27a77e1a..00000000
--- a/src/lib/projects/IProject.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export interface IProject {
- readonly name: string
- readonly image?: string
-}
diff --git a/src/lib/projects/IProjectRepository.ts b/src/lib/projects/IProjectRepository.ts
deleted file mode 100644
index 0c022f75..00000000
--- a/src/lib/projects/IProjectRepository.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { IProject } from "./IProject"
-
-export interface IProjectRepository {
- getProjects(): Promise
-}
diff --git a/src/lib/projects/IVersion.ts b/src/lib/projects/IVersion.ts
deleted file mode 100644
index eb8bb6ae..00000000
--- a/src/lib/projects/IVersion.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export interface IVersion {
- readonly name: string
-}
diff --git a/src/lib/projects/IVersionRepository.ts b/src/lib/projects/IVersionRepository.ts
deleted file mode 100644
index 486c18d8..00000000
--- a/src/lib/projects/IVersionRepository.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { IVersion } from "./IVersion"
-import { IProject } from "./IProject"
-
-export interface IVersionRepository {
- getVersions(project: IProject): Promise
-}
\ No newline at end of file
diff --git a/src/lib/style/dimensions.ts b/src/lib/style/dimensions.ts
deleted file mode 100644
index d78372fe..00000000
--- a/src/lib/style/dimensions.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const SIDEBAR_SPACING = "15px";
\ No newline at end of file
diff --git a/src/lib/utils/EventsUtils.ts b/src/lib/utils/EventsUtils.ts
deleted file mode 100644
index c84a1d0c..00000000
--- a/src/lib/utils/EventsUtils.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import BaseEvent, { Events } from "../events/BaseEvent";
-
-
-function subscribe(eventName: Events, listener: (event: CustomEvent) => void) {
- document.addEventListener(eventName, listener as () => void);
-}
-
-function unsubscribe(eventName: Events, listener: (event: CustomEvent) => void) {
- document.removeEventListener(eventName, listener as () => void);
-}
-
-function publish(event: BaseEvent) {
- const customEvent = new CustomEvent(event.name, {
- detail: event.data
- });
- document.dispatchEvent(customEvent);
-}
-
-export { publish, subscribe, unsubscribe };
\ No newline at end of file
diff --git a/src/lib/utils/Hooks.ts b/src/lib/utils/Hooks.ts
deleted file mode 100644
index 19033d0d..00000000
--- a/src/lib/utils/Hooks.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { useState } from "react";
-
-export const useForceUpdate = () => {
- const [, setState] = useState({});
- return () => setState({});
- };
\ No newline at end of file
diff --git a/src/lib/utils/SettingsUtils.ts b/src/lib/utils/SettingsUtils.ts
deleted file mode 100644
index 8a71b712..00000000
--- a/src/lib/utils/SettingsUtils.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { DocumentationVisualizer } from "../components/DocumentationViewerComponent";
-import { ISettings } from "../components/SettingsComponent";
-import SettingsChangedEvent from "../events/SettingsChangeEvent";
-import { publish } from "./EventsUtils";
-
-const LOCAL_STORAGE_SETTINGS_KEY = "settings";
-
-export function getSettings(): ISettings {
- const savedSettings = window.localStorage.getItem(LOCAL_STORAGE_SETTINGS_KEY);
- return savedSettings ? JSON.parse(savedSettings) : getDefaultSettings();
-}
-
-export function setSettings(settings: ISettings) {
- window.localStorage.setItem(LOCAL_STORAGE_SETTINGS_KEY, JSON.stringify(settings));
- publish(new SettingsChangedEvent())
-}
-
-function getDefaultSettings(): ISettings {
- return {
- documentationVisualizer: DocumentationVisualizer.SWAGGER,
- };
-}
\ No newline at end of file
diff --git a/src/middleware.ts b/src/middleware.ts
index cea33d5e..43391c53 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,7 +1,7 @@
-import { withMiddlewareAuthRequired } from '@auth0/nextjs-auth0/edge'
+import { withMiddlewareAuthRequired } from "@auth0/nextjs-auth0/edge"
export const config = {
- matcher: '/((?!api).*)' // do not apply to api routes
- }
+ matcher: '/((?!api/hooks).*)' // do not apply to api routes
+}
export default withMiddlewareAuthRequired()
diff --git a/types/env.d.ts b/types/env.d.ts
index 5b8881e8..d0154427 100644
--- a/types/env.d.ts
+++ b/types/env.d.ts
@@ -1,5 +1,6 @@
namespace NodeJS {
interface ProcessEnv {
+ SHAPE_DOCS_BASE_URL: string
AUTH0_SECRET: string
AUTH0_BASE_URL: string
AUTH0_ISSUER_BASE_URL: string
@@ -8,6 +9,13 @@ namespace NodeJS {
AUTH0_MANAGEMENT_DOMAIN: string
AUTH0_MANAGEMENT_CLIENT_ID: string
AUTH0_MANAGEMENT_CLIENT_SECRET: string
+ GITHUB_CLIENT_ID: string
+ GITHUB_CLIENT_SECRET: string
+ GITHUB_APP_ID: string
+ GITHUB_PRIVATE_KEY_BASE_64: string
+ GITHUB_WEBHOOK_SECRET: string
+ GITHUB_WEBHOK_REPOSITORY_ALLOWLIST?: string
+ GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST?: string
DATABASE_URL: string
}
}
diff --git a/update-github-connection.sh b/update-github-connection.sh
new file mode 100755
index 00000000..4fad95eb
--- /dev/null
+++ b/update-github-connection.sh
@@ -0,0 +1,51 @@
+GITHUB_CONNECTION_NAME="github"
+GITHUB_CONNECTION_DISPLAY_NAME="GitHub"
+GITHUB_CONNECTION_ICON_URL="https://upload.wikimedia.org/wikipedia/commons/thumb/c/c2/GitHub_Invertocat_Logo.svg/400px-GitHub_Invertocat_Logo.svg.png"
+
+if [ -z ${AUTH0_MANAGEMENT_DOMAIN} ]; then
+ echo "AUTH0_MANAGEMENT_DOMAIN must be set to the Auth0 domain, e.g. shape-docs.eu.auth0.com"
+ exit 1
+fi
+if [ -z ${AUTH0_MANAGEMENT_CLIENT_ID} ]; then
+ echo "AUTH0_MANAGEMENT_CLIENT_ID must be to the client ID of the app used to communicate with Auth0's Management API."
+ exit 1
+fi
+if [ -z ${AUTH0_MANAGEMENT_CLIENT_SECRET} ]; then
+ echo "AUTH0_MANAGEMENT_CLIENT_SECRET must be to the client secret of the app used to communicate with Auth0's Management API."
+ exit 1
+fi
+
+# Get an access token for the Management API.
+TOKEN_RESPONSE=$(
+ curl -s --request POST \
+ --url "https://${AUTH0_MANAGEMENT_DOMAIN}/oauth/token" \
+ --header "Content-Type: application/x-www-form-urlencoded" \
+ --data grant_type=client_credentials \
+ --data "client_id=${AUTH0_MANAGEMENT_CLIENT_ID}" \
+ --data "client_secret=${AUTH0_MANAGEMENT_CLIENT_SECRET}" \
+ --data "audience=https://${AUTH0_MANAGEMENT_DOMAIN}/api/v2/"
+)
+TOKEN=$(echo $TOKEN_RESPONSE | jq -r .access_token)
+
+# Fetch all connections.
+CONNECTIONS_RESPONSE=$(
+ curl -s --request GET \
+ --url "https://${AUTH0_MANAGEMENT_DOMAIN}/api/v2/connections" \
+ --header "Authorization: Bearer ${TOKEN}"
+)
+SAFE_CONNECTIONS_RESPONSE=${CONNECTIONS_RESPONSE//\\n/\\\\n}
+
+# Modify the GitHub Connection.
+GITHUB_CONNECTION_ID=$(
+ echo $SAFE_CONNECTIONS_RESPONSE | jq -r ".[] | select(.name == \"${GITHUB_CONNECTION_NAME}\") | .id"
+)
+GITHUB_CONNECTION_OPTIONS=$(
+ echo $SAFE_CONNECTIONS_RESPONSE | jq -r ".[] | select(.name == \"${GITHUB_CONNECTION_NAME}\") | .options += {\"display_name\": \"${GITHUB_CONNECTION_DISPLAY_NAME}\", \"icon_url\": \"${GITHUB_CONNECTION_ICON_URL}\"} | .options"
+)
+
+# Post the updated options.
+curl -s --request PATCH \
+ --url "https://${AUTH0_MANAGEMENT_DOMAIN}/api/v2/connections/${GITHUB_CONNECTION_ID}" \
+ --header "Content-Type: application/json" \
+ --header "Authorization: Bearer ${TOKEN}" \
+ --data "{ \"options\": ${GITHUB_CONNECTION_OPTIONS} }"