From 1cc2a0adca31c6140e9b9de436400dd4b823e551 Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Mon, 12 Jun 2023 16:56:27 +0200 Subject: [PATCH] Operation Collections in Lab (#1610) Co-authored-by: Laurin Quast Co-authored-by: Dotan Simha --- .eslintrc.cjs | 2 +- .github/workflows/lint.yaml | 2 +- .github/workflows/typescript-typecheck.yaml | 2 + .vscode/extensions.json | 3 +- codegen.cjs | 8 +- cypress.config.ts | 25 +- cypress/support/commands.ts | 41 +- docs/TESTING.md | 3 +- integration-tests/testkit/collections.ts | 204 ++++++++ integration-tests/testkit/seed.ts | 163 ++++++ .../collections/document-collections.spec.ts | 358 +++++++++++++ package.json | 1 + ...2023.03.01T09.07.53.create_collections.sql | 35 ++ ...2023.03.01T09.07.53.create_collections.sql | 1 + packages/services/api/package.json | 3 +- packages/services/api/src/create.ts | 2 + .../alerts/providers/alerts-manager.ts | 14 +- .../api/src/modules/collection/index.ts | 12 + .../src/modules/collection/module.graphql.ts | 164 ++++++ .../providers/collection.provider.ts | 99 ++++ .../api/src/modules/collection/resolvers.ts | 251 ++++++++++ .../providers/operations-manager.ts | 5 +- .../src/modules/shared/providers/storage.ts | 131 +++++ .../target/providers/target-manager.ts | 4 +- packages/services/api/src/shared/entities.ts | 48 ++ packages/services/api/src/shared/mappers.ts | 5 + .../services/server/src/graphql-handler.ts | 2 +- packages/services/storage/package.json | 1 + packages/services/storage/src/db/types.ts | 24 + packages/services/storage/src/index.ts | 384 +++++++++++++- packages/web/app/package.json | 9 +- .../[projectId]/[targetId]/laboratory.tsx | 449 ++++++++++++++++- .../[projectId]/[targetId]/settings.tsx | 20 +- packages/web/app/pages/_app.tsx | 1 + .../pages/action/transfer/[orgId]/[code].tsx | 4 +- packages/web/app/pages/index.tsx | 2 +- packages/web/app/pages/join/[inviteCode].tsx | 4 +- .../app/src/components/layouts/project.tsx | 2 +- .../web/app/src/components/layouts/target.tsx | 11 +- .../components/organization/Permissions.tsx | 1 + .../target/settings/cdn-access-tokens.tsx | 39 +- .../web/app/src/components/v2/accordion.tsx | 21 +- .../web/app/src/components/v2/empty-list.tsx | 8 +- packages/web/app/src/components/v2/icon.tsx | 20 +- packages/web/app/src/components/v2/modal.tsx | 6 +- .../v2/modals/create-collection.tsx | 270 ++++++++++ .../components/v2/modals/create-operation.tsx | 201 ++++++++ .../v2/modals/delete-collection.tsx | 83 +++ .../components/v2/modals/delete-members.tsx | 4 +- .../components/v2/modals/delete-operation.tsx | 95 ++++ .../v2/modals/delete-organization.tsx | 4 +- .../components/v2/modals/delete-project.tsx | 4 +- .../components/v2/modals/delete-target.tsx | 4 +- .../web/app/src/components/v2/modals/index.ts | 5 + packages/web/app/src/components/v2/select.tsx | 2 +- packages/web/app/src/lib/access/common.ts | 2 +- packages/web/app/src/lib/access/target.ts | 2 +- packages/web/app/src/lib/hooks/index.ts | 1 + .../web/app/src/lib/hooks/use-collections.ts | 70 +++ .../app/src/lib/hooks/use-route-selector.ts | 32 +- ...party-email-password-node-oidc-provider.ts | 6 +- packages/web/app/src/lib/urql-cache.ts | 101 +++- packages/web/app/src/lib/urql.ts | 3 +- .../web/app/src/stories/callout.stories.tsx | 2 +- packages/web/app/src/wdyr.ts | 10 + pnpm-lock.yaml | 474 +++++++----------- 66 files changed, 3510 insertions(+), 459 deletions(-) create mode 100644 integration-tests/testkit/collections.ts create mode 100644 integration-tests/tests/api/collections/document-collections.spec.ts create mode 100644 packages/migrations/src/actions/2023.03.01T09.07.53.create_collections.sql create mode 100644 packages/migrations/src/actions/down/2023.03.01T09.07.53.create_collections.sql create mode 100644 packages/services/api/src/modules/collection/index.ts create mode 100644 packages/services/api/src/modules/collection/module.graphql.ts create mode 100644 packages/services/api/src/modules/collection/providers/collection.provider.ts create mode 100644 packages/services/api/src/modules/collection/resolvers.ts create mode 100644 packages/web/app/src/components/v2/modals/create-collection.tsx create mode 100644 packages/web/app/src/components/v2/modals/create-operation.tsx create mode 100644 packages/web/app/src/components/v2/modals/delete-collection.tsx create mode 100644 packages/web/app/src/components/v2/modals/delete-operation.tsx create mode 100644 packages/web/app/src/lib/hooks/use-collections.ts create mode 100644 packages/web/app/src/wdyr.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 4b4b8d953..9075a945f 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -144,7 +144,7 @@ module.exports = { }, tailwindcss: { config: 'packages/web/app/tailwind.config.cjs', - whitelist: ['drag-none'], + whitelist: ['drag-none', 'graphiql-toolbar-icon', 'graphiql-toolbar-button'], }, }, }, diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index d0c645413..52cc3a8b4 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -62,7 +62,7 @@ jobs: - name: Operation Check run: | npx graphql-inspector validate \ - "packages/web/app/**/*.graphql|packages/libraries/cli/**/*.graphql|packages/web/app/**/*.tsx|packages/web/app/src/lib/**/*.ts" \ + "packages/web/app/{src,pages}/**/*.{graphql,tsx}|packages/libraries/cli/**/*.graphql|packages/web/app/src/lib/**/*.ts" \ "packages/**/module.graphql.ts" \ --maxDepth=20 \ --maxAliasCount=20 \ diff --git a/.github/workflows/typescript-typecheck.yaml b/.github/workflows/typescript-typecheck.yaml index 141d0ac2b..a382719a9 100644 --- a/.github/workflows/typescript-typecheck.yaml +++ b/.github/workflows/typescript-typecheck.yaml @@ -18,3 +18,5 @@ jobs: - name: typecheck run: pnpm typecheck + env: + NODE_OPTIONS: '--max-old-space-size=4096' diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 6de3fab57..ac9e66f84 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,6 +4,7 @@ "fabiospampinato.vscode-commands", "esbenp.prettier-vscode", "thebearingedge.vscode-sql-lit", - "hashicorp.hcl" + "hashicorp.hcl", + "GraphQL.vscode-graphql" ] } diff --git a/codegen.cjs b/codegen.cjs index 905e78e49..e05d52b6e 100644 --- a/codegen.cjs +++ b/codegen.cjs @@ -112,6 +112,12 @@ const config = { SchemaPolicy: '../shared/entities#SchemaPolicy as SchemaPolicyMapper', SchemaPolicyRule: '../shared/entities#SchemaPolicyAvailableRuleObject', SchemaCoordinateUsage: '../shared/mappers#SchemaCoordinateUsageTypeMapper', + DocumentCollection: '../shared/entities#DocumentCollection as DocumentCollectionEntity', + DocumentCollectionOperation: + '../shared/entities#DocumentCollectionOperation as DocumentCollectionOperationEntity', + DocumentCollectionConnection: '../shared/entities#PaginatedDocumentCollections', + DocumentCollectionOperationsConnection: + '../shared/entities#PaginatedDocumentCollectionOperations', }, }, }, @@ -168,7 +174,7 @@ const config = { }, // Integration tests './integration-tests/testkit/gql/': { - documents: './integration-tests/(testkit|tests)/**/*.ts', + documents: ['./integration-tests/(testkit|tests)/**/*.ts'], preset: 'client', plugins: [], }, diff --git a/cypress.config.ts b/cypress.config.ts index 32f399d36..f95bcc232 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,11 +1,34 @@ // eslint-disable-next-line import/no-extraneous-dependencies -- cypress SHOULD be a dev dependency import { defineConfig } from 'cypress'; +// eslint-disable-next-line import/no-extraneous-dependencies +import pg from 'pg'; export default defineConfig({ video: false, // TODO: can it be useful for CI? screenshotOnRunFailure: false, // TODO: can it be useful for CI? defaultCommandTimeout: 8000, // sometimes the app takes longer to load, especially in the CI + env: { + POSTGRES_URL: 'postgresql://postgres:postgres@localhost:5432/registry', + }, e2e: { - // defaults + setupNodeEvents(on, config) { + on('task', { + async connectDB(query: string) { + const dbUrl = new URL(config.env.POSTGRES_URL); + const client = new pg.Client({ + user: dbUrl.username, + password: dbUrl.password, + host: dbUrl.hostname, + database: dbUrl.pathname.slice(1), + port: Number(dbUrl.port), + ssl: false, + }); + await client.connect(); + const res = await client.query(query); + await client.end(); + return res.rows; + }, + }); + }, }, }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 1056d0fa4..ceda95a6a 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,8 +1,14 @@ namespace Cypress { export interface Chainable { - fillSupertokensFormAndSubmit(user: { email: string; password: string }): Chainable; - signup(user: { email: string; password: string }): Chainable; - login(user: { email: string; password: string }): Chainable; + fillSupertokensFormAndSubmit(data: { email: string; password: string }): Chainable; + + signup(data: { email: string; password: string }): Chainable; + + login(data: { email: string; password: string }): Chainable; + + loginAndSetCookie(data: { email: string; password: string }): Chainable; + + dataCy(name: string): Chainable>; } } @@ -32,3 +38,32 @@ Cypress.Commands.add('login', user => { cy.contains('Create Organization'); }); + +Cypress.Commands.add('loginAndSetCookie', ({ email, password }) => { + cy.request({ + method: 'POST', + url: '/api/auth/signin', + body: { + formFields: [ + { id: 'email', value: email }, + { id: 'password', value: password }, + ], + }, + }).then(response => { + const { status, headers, body } = response; + if (status !== 200) { + throw new Error(`Create session failed. ${status}.\n${JSON.stringify(body)}`); + } + const frontToken = headers['front-token'] as string; + const accessToken = headers['st-access-token'] as string; + const timeJoined = String(body.user.timeJoined); + + cy.setCookie('sAccessToken', accessToken); + cy.setCookie('sFrontToken', frontToken); + cy.setCookie('st-last-access-token-update', timeJoined); + }); +}); + +Cypress.Commands.add('dataCy', value => { + return cy.get(`[data-cy="${value}"]`); +}); diff --git a/docs/TESTING.md b/docs/TESTING.md index 8f92d8fc2..a55874343 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -19,7 +19,8 @@ Integration tests are based pre-built Docker images, so you can run it in 2 mode #### Running from Source Code **TL;DR**: Use `pnpm integration:prepare` command to setup the complete environment from locally -running integration tests. +running integration tests. You can ignore the rest of the commands in this section, if this script +worked for you, and just run `pnpm test:integration` to run the actual tests. To run integration tests locally, from the local source code, you need to build a valid Docker image. diff --git a/integration-tests/testkit/collections.ts b/integration-tests/testkit/collections.ts new file mode 100644 index 000000000..9cff9d4a0 --- /dev/null +++ b/integration-tests/testkit/collections.ts @@ -0,0 +1,204 @@ +import { graphql } from './gql'; + +export const FindCollectionQuery = graphql(` + query Collection($selector: TargetSelectorInput!, $id: ID!) { + target(selector: $selector) { + id + documentCollection(id: $id) { + id + name + description + } + } + } +`); + +export const CreateCollectionMutation = graphql(` + mutation CreateCollection( + $selector: TargetSelectorInput! + $input: CreateDocumentCollectionInput! + ) { + createDocumentCollection(selector: $selector, input: $input) { + error { + message + } + ok { + updatedTarget { + id + documentCollections { + edges { + cursor + node { + id + name + } + } + } + } + collection { + id + name + operations(first: 100) { + edges { + cursor + node { + id + name + } + cursor + } + } + } + } + } + } +`); + +export const UpdateCollectionMutation = graphql(` + mutation UpdateCollection( + $selector: TargetSelectorInput! + $input: UpdateDocumentCollectionInput! + ) { + updateDocumentCollection(selector: $selector, input: $input) { + error { + message + } + ok { + updatedTarget { + id + documentCollections { + edges { + node { + id + name + } + cursor + } + } + } + collection { + id + name + description + operations(first: 100) { + edges { + cursor + node { + id + name + } + } + } + } + } + } + } +`); + +export const DeleteCollectionMutation = graphql(` + mutation DeleteCollection($selector: TargetSelectorInput!, $id: ID!) { + deleteDocumentCollection(selector: $selector, id: $id) { + error { + message + } + ok { + deletedId + updatedTarget { + id + documentCollections { + edges { + cursor + node { + id + } + } + } + } + } + } + } +`); + +export const CreateOperationMutation = graphql(` + mutation CreateOperation( + $selector: TargetSelectorInput! + $input: CreateDocumentCollectionOperationInput! + ) { + createOperationInDocumentCollection(selector: $selector, input: $input) { + error { + message + } + ok { + operation { + id + name + } + collection { + id + operations { + edges { + cursor + node { + id + } + } + } + } + } + } + } +`); + +export const UpdateOperationMutation = graphql(` + mutation UpdateOperation( + $selector: TargetSelectorInput! + $input: UpdateDocumentCollectionOperationInput! + ) { + updateOperationInDocumentCollection(selector: $selector, input: $input) { + error { + message + } + ok { + operation { + id + name + query + variables + headers + } + } + } + } +`); + +export const DeleteOperationMutation = graphql(` + mutation DeleteOperation($selector: TargetSelectorInput!, $id: ID!) { + deleteOperationInDocumentCollection(selector: $selector, id: $id) { + error { + message + } + ok { + deletedId + updatedTarget { + id + documentCollections { + edges { + cursor + node { + id + operations { + edges { + node { + id + } + cursor + } + } + } + } + } + } + } + } + } +`); diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index f64a591cd..1143e7b4c 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -8,6 +8,14 @@ import { TargetAccessScope, } from '@app/gql/graphql'; import { authenticate, userEmail } from './auth'; +import { + CreateCollectionMutation, + CreateOperationMutation, + DeleteCollectionMutation, + DeleteOperationMutation, + UpdateCollectionMutation, + UpdateOperationMutation, +} from './collections'; import { ensureEnv } from './env'; import { checkSchema, @@ -199,6 +207,161 @@ export function initSeed() { .then(r => r.expectNoGraphQLErrors()) .then(r => r.deleteTokens.deletedTokens); }, + async createDocumentCollection({ + name, + description, + token = ownerToken, + }: { + name: string; + description: string; + token?: string; + }) { + const result = await execute({ + document: CreateCollectionMutation, + variables: { + input: { + name, + description, + }, + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + }, + authToken: token, + }).then(r => r.expectNoGraphQLErrors()); + + return result.createDocumentCollection; + }, + async updateDocumentCollection({ + collectionId, + name, + description, + token = ownerToken, + }: { + collectionId: string; + name: string; + description: string; + token?: string; + }) { + const result = await execute({ + document: UpdateCollectionMutation, + variables: { + input: { + collectionId, + name, + description, + }, + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + }, + authToken: token, + }).then(r => r.expectNoGraphQLErrors()); + + return result.updateDocumentCollection; + }, + async deleteDocumentCollection({ + collectionId, + token = ownerToken, + }: { + collectionId: string; + token?: string; + }) { + const result = await execute({ + document: DeleteCollectionMutation, + variables: { + id: collectionId, + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + }, + authToken: token, + }).then(r => r.expectNoGraphQLErrors()); + + return result.deleteDocumentCollection; + }, + async createOperationInCollection(input: { + collectionId: string; + name: string; + query: string; + variables?: string; + headers?: string; + token?: string; + }) { + const result = await execute({ + document: CreateOperationMutation, + variables: { + input: { + collectionId: input.collectionId, + name: input.name, + query: input.query, + headers: input.headers, + variables: input.variables, + }, + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + }, + authToken: input.token || ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + return result.createOperationInDocumentCollection; + }, + async deleteOperationInCollection(input: { operationId: string; token?: string }) { + const result = await execute({ + document: DeleteOperationMutation, + variables: { + id: input.operationId, + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + }, + authToken: input.token || ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + return result.deleteOperationInDocumentCollection; + }, + async updateOperationInCollection(input: { + operationId: string; + collectionId: string; + name: string; + query: string; + variables?: string; + headers?: string; + token?: string; + }) { + const result = await execute({ + document: UpdateOperationMutation, + variables: { + input: { + operationId: input.operationId, + collectionId: input.collectionId, + name: input.name, + query: input.query, + headers: input.headers, + variables: input.variables, + }, + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + }, + authToken: input.token || ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + return result.updateOperationInDocumentCollection; + }, async createToken({ targetScopes = [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite], projectScopes = [], diff --git a/integration-tests/tests/api/collections/document-collections.spec.ts b/integration-tests/tests/api/collections/document-collections.spec.ts new file mode 100644 index 000000000..cb08a1231 --- /dev/null +++ b/integration-tests/tests/api/collections/document-collections.spec.ts @@ -0,0 +1,358 @@ +/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ +import { ProjectType, TargetAccessScope } from '@app/gql/graphql'; +import { initSeed } from '../../../testkit/seed'; + +describe('Document Collections', () => { + describe('CRUD', () => { + it.concurrent('Create, update and delete a Collection', async () => { + const { createDocumentCollection, updateDocumentCollection, deleteDocumentCollection } = + await initSeed() + .createOwner() + .then(r => r.createOrg()) + .then(r => r.createProject(ProjectType.Single)); + + // Create a collection + const createDocumentCollectionResult = await createDocumentCollection({ + name: 'My Collection', + description: 'My favorite queries', + }); + expect(createDocumentCollectionResult.error).toBeNull(); + expect(createDocumentCollectionResult.ok?.collection.id).toBeDefined(); + expect( + createDocumentCollectionResult.ok?.updatedTarget.documentCollections.edges.length, + ).toBe(1); + + // Update the collection + const updateDocumentCollectionResult = await updateDocumentCollection({ + collectionId: createDocumentCollectionResult.ok?.collection.id!, + name: 'Best Queries #3', + description: 'My favorite queries updated', + }); + expect(updateDocumentCollectionResult.error).toBeNull(); + expect(updateDocumentCollectionResult.ok?.collection.id).toBeDefined(); + expect(updateDocumentCollectionResult.ok?.collection.name).toBe('Best Queries #3'); + expect(updateDocumentCollectionResult.ok?.collection.description).toBe( + 'My favorite queries updated', + ); + expect( + updateDocumentCollectionResult.ok?.updatedTarget.documentCollections.edges.length, + ).toBe(1); + + // Delete the collection + const deleteDocumentCollectionResult = await deleteDocumentCollection({ + collectionId: createDocumentCollectionResult.ok?.collection.id!, + }); + expect(deleteDocumentCollectionResult.error).toBeNull(); + expect(deleteDocumentCollectionResult.ok?.deletedId).toBe( + updateDocumentCollectionResult.ok?.collection.id, + ); + expect( + deleteDocumentCollectionResult.ok?.updatedTarget.documentCollections.edges.length, + ).toBe(0); + }); + + it.concurrent('Create, update and delete an operation inside a collection', async () => { + const { + createDocumentCollection, + createOperationInCollection, + updateOperationInCollection, + deleteOperationInCollection, + } = await initSeed() + .createOwner() + .then(r => r.createOrg()) + .then(r => r.createProject(ProjectType.Single)); + const createDocumentCollectionResult = await createDocumentCollection({ + name: 'My Collection', + description: 'My favorite queries', + }); + expect(createDocumentCollectionResult.error).toBeNull(); + const collectionId = createDocumentCollectionResult.ok?.collection.id!; + expect(collectionId).toBeDefined(); + + const createResult = await createOperationInCollection({ + collectionId, + name: 'My Operation', + query: 'query { hello }', + }); + + expect(createResult.error).toBeNull(); + const operationId = createResult.ok?.operation?.id!; + expect(operationId).toBeDefined(); + expect(createResult.ok?.operation?.name).toBe('My Operation'); + expect(createResult.ok?.collection.operations.edges.length).toBe(1); + + const updateResult = await updateOperationInCollection({ + collectionId, + operationId, + name: 'My Updated Operation', + query: 'query { hello world }', + variables: JSON.stringify({ + id: '1', + }), + headers: JSON.stringify({ + Key: '3', + }), + }); + + expect(updateResult.error).toBeNull(); + expect(updateResult.ok?.operation?.id).toBeDefined(); + expect(updateResult.ok?.operation?.name).toBe('My Updated Operation'); + expect(updateResult.ok?.operation?.query).toBe('query { hello world }'); + expect(updateResult.ok?.operation?.headers).toBe( + JSON.stringify({ + Key: '3', + }), + ); + expect(updateResult.ok?.operation?.variables).toBe( + JSON.stringify({ + id: '1', + }), + ); + const deleteResult = await deleteOperationInCollection({ + operationId, + }); + + expect(deleteResult.error).toBeNull(); + expect(deleteResult.ok?.deletedId).toBe(operationId); + expect(deleteResult.ok?.updatedTarget.documentCollections.edges.length).toBe(1); + expect( + deleteResult.ok?.updatedTarget.documentCollections.edges[0].node.operations.edges.length, + ).toBe(0); + }); + + describe('Permissions Check', () => { + it('Prevent creating collection without the write permission to the target', async () => { + const { createDocumentCollection, createToken } = await initSeed() + .createOwner() + .then(r => r.createOrg()) + .then(r => r.createProject(ProjectType.Single)); + const { secret: readOnlyToken } = await createToken({ + targetScopes: [TargetAccessScope.Read], + organizationScopes: [], + projectScopes: [], + }); + + // Create a collection + await expect( + createDocumentCollection({ + name: 'My Collection', + description: 'My favorite queries', + token: readOnlyToken, + }), + ).rejects.toMatchInlineSnapshot(` + [Error: Expected GraphQL response to have no errors, but got 1 errors: + No access (reason: "Missing target:settings permission") + endpoint: http://localhost:8082/graphql + query: + mutation CreateCollection($selector: TargetSelectorInput!, $input: CreateDocumentCollectionInput!) { + createDocumentCollection(selector: $selector, input: $input) { + error { + message + } + ok { + updatedTarget { + id + documentCollections { + edges { + cursor + node { + id + name + } + } + } + } + collection { + id + name + operations(first: 100) { + edges { + cursor + node { + id + name + } + cursor + } + } + } + } + } + } + body: + { + "errors": [ + { + "message": "No access (reason: \\"Missing target:settings permission\\")", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "createDocumentCollection" + ] + } + ], + "data": null + }] + `); + }); + + it('Prevent updating collection without the write permission to the target', async () => { + const { createDocumentCollection, updateDocumentCollection, createToken } = await initSeed() + .createOwner() + .then(r => r.createOrg()) + .then(r => r.createProject(ProjectType.Single)); + + const createResult = await createDocumentCollection({ + name: 'My Collection', + description: 'My favorite queries', + }); + + const { secret: readOnlyToken } = await createToken({ + targetScopes: [TargetAccessScope.Read], + organizationScopes: [], + projectScopes: [], + }); + + await expect( + updateDocumentCollection({ + collectionId: createResult.ok?.collection.id!, + token: readOnlyToken, + name: 'My Collection', + description: 'My favorite queries', + }), + ).rejects.toMatchInlineSnapshot(` + [Error: Expected GraphQL response to have no errors, but got 1 errors: + No access (reason: "Missing target:settings permission") + endpoint: http://localhost:8082/graphql + query: + mutation UpdateCollection($selector: TargetSelectorInput!, $input: UpdateDocumentCollectionInput!) { + updateDocumentCollection(selector: $selector, input: $input) { + error { + message + } + ok { + updatedTarget { + id + documentCollections { + edges { + node { + id + name + } + cursor + } + } + } + collection { + id + name + description + operations(first: 100) { + edges { + cursor + node { + id + name + } + } + } + } + } + } + } + body: + { + "errors": [ + { + "message": "No access (reason: \\"Missing target:settings permission\\")", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "updateDocumentCollection" + ] + } + ], + "data": null + }] + `); + }); + + it('Prevent deleting collection without the write permission to the target', async () => { + const { createDocumentCollection, deleteDocumentCollection, createToken } = await initSeed() + .createOwner() + .then(r => r.createOrg()) + .then(r => r.createProject(ProjectType.Single)); + + const createResult = await createDocumentCollection({ + name: 'My Collection', + description: 'My favorite queries', + }); + + const { secret: readOnlyToken } = await createToken({ + targetScopes: [TargetAccessScope.Read], + organizationScopes: [], + projectScopes: [], + }); + + await expect( + deleteDocumentCollection({ + collectionId: createResult.ok?.collection.id!, + token: readOnlyToken, + }), + ).rejects.toMatchInlineSnapshot(` + [Error: Expected GraphQL response to have no errors, but got 1 errors: + No access (reason: "Missing target:settings permission") + endpoint: http://localhost:8082/graphql + query: + mutation DeleteCollection($selector: TargetSelectorInput!, $id: ID!) { + deleteDocumentCollection(selector: $selector, id: $id) { + error { + message + } + ok { + deletedId + updatedTarget { + id + documentCollections { + edges { + cursor + node { + id + } + } + } + } + } + } + } + body: + { + "errors": [ + { + "message": "No access (reason: \\"Missing target:settings permission\\")", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "deleteDocumentCollection" + ] + } + ], + "data": null + }] + `); + }); + }); + }); +}); diff --git a/package.json b/package.json index 11e4c8dd8..1930200ad 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "husky": "8.0.3", "jest-snapshot-serializer-raw": "1.2.0", "lint-staged": "13.2.2", + "pg": "^8.10.0", "prettier": "2.8.8", "prettier-plugin-sql": "0.14.0", "prettier-plugin-tailwindcss": "0.3.0", diff --git a/packages/migrations/src/actions/2023.03.01T09.07.53.create_collections.sql b/packages/migrations/src/actions/2023.03.01T09.07.53.create_collections.sql new file mode 100644 index 000000000..700a0b2b8 --- /dev/null +++ b/packages/migrations/src/actions/2023.03.01T09.07.53.create_collections.sql @@ -0,0 +1,35 @@ +CREATE TABLE public."document_collections" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "title" text NOT NULL, + "description" text, + "target_id" uuid NOT NULL REFERENCES public."targets"("id") ON DELETE CASCADE, + "created_by_user_id" uuid REFERENCES public."users"("id") ON DELETE SET NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("id") +); + +CREATE INDEX "document_collections_connection_pagination" ON "document_collections" ( + "target_id" ASC, + "created_at" DESC, + "id" DESC +); + +CREATE TABLE public."document_collection_documents" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "title" text NOT NULL, + "contents" text NOT NULL, + "variables" text, + "headers" text, + "created_by_user_id" uuid REFERENCES public."users"("id") ON DELETE SET NULL, + "document_collection_id" uuid NOT NULL REFERENCES public."document_collections"("id") ON DELETE CASCADE, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("id") +); + +CREATE INDEX "document_collection_documents_connection_pagination" ON "document_collection_documents" ( + "document_collection_id" ASC, + "created_at" DESC, + "id" DESC +); diff --git a/packages/migrations/src/actions/down/2023.03.01T09.07.53.create_collections.sql b/packages/migrations/src/actions/down/2023.03.01T09.07.53.create_collections.sql new file mode 100644 index 000000000..faab50cea --- /dev/null +++ b/packages/migrations/src/actions/down/2023.03.01T09.07.53.create_collections.sql @@ -0,0 +1 @@ +raise 'down migration not implemented' diff --git a/packages/services/api/package.json b/packages/services/api/package.json index 559964725..10fabffa7 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -42,7 +42,8 @@ "prom-client": "14.2.0", "redlock": "5.0.0-beta.2", "supertokens-node": "13.4.2", - "zod": "3.21.4" + "zod": "3.21.4", + "zod-validation-error": "1.3.0" }, "devDependencies": { "@graphql-hive/core": "0.2.3", diff --git a/packages/services/api/src/create.ts b/packages/services/api/src/create.ts index 7dc8a0892..1b57146cb 100644 --- a/packages/services/api/src/create.ts +++ b/packages/services/api/src/create.ts @@ -9,6 +9,7 @@ import { billingModule } from './modules/billing'; import { BILLING_CONFIG, BillingConfig } from './modules/billing/providers/tokens'; import { cdnModule } from './modules/cdn'; import { CDN_CONFIG, CDNConfig } from './modules/cdn/providers/tokens'; +import { collectionModule } from './modules/collection'; import { feedbackModule } from './modules/feedback'; import { FEEDBACK_SLACK_CHANNEL, FEEDBACK_SLACK_TOKEN } from './modules/feedback/providers/tokens'; import { integrationsModule } from './modules/integrations'; @@ -84,6 +85,7 @@ const modules = [ billingModule, oidcIntegrationsModule, schemaPolicyModule, + collectionModule, ]; export function createRegistry({ diff --git a/packages/services/api/src/modules/alerts/providers/alerts-manager.ts b/packages/services/api/src/modules/alerts/providers/alerts-manager.ts index b5178f1f1..bdc3271a3 100644 --- a/packages/services/api/src/modules/alerts/providers/alerts-manager.ts +++ b/packages/services/api/src/modules/alerts/providers/alerts-manager.ts @@ -33,9 +33,7 @@ export class AlertsManager { private projectManager: ProjectManager, private storage: Storage, ) { - this.logger = logger.child({ - source: 'AlertsManager', - }); + this.logger = logger.child({ source: 'AlertsManager' }); } async addChannel(input: AlertsModule.AddAlertChannelInput): Promise { @@ -174,14 +172,8 @@ export class AlertsManager { }); const [channels, alerts] = await Promise.all([ - this.getChannels({ - organization, - project, - }), - this.getAlerts({ - organization, - project, - }), + this.getChannels({ organization, project }), + this.getAlerts({ organization, project }), ]); const matchingAlerts = alerts.filter( diff --git a/packages/services/api/src/modules/collection/index.ts b/packages/services/api/src/modules/collection/index.ts new file mode 100644 index 000000000..21077932c --- /dev/null +++ b/packages/services/api/src/modules/collection/index.ts @@ -0,0 +1,12 @@ +import { createModule } from 'graphql-modules'; +import { CollectionProvider } from './providers/collection.provider'; +import { resolvers } from './resolvers'; +import { typeDefs } from './module.graphql'; + +export const collectionModule = createModule({ + id: 'collection', + dirname: __dirname, + typeDefs, + resolvers, + providers: [CollectionProvider], +}); diff --git a/packages/services/api/src/modules/collection/module.graphql.ts b/packages/services/api/src/modules/collection/module.graphql.ts new file mode 100644 index 000000000..28a839f6e --- /dev/null +++ b/packages/services/api/src/modules/collection/module.graphql.ts @@ -0,0 +1,164 @@ +import { gql } from 'graphql-modules'; + +export const typeDefs = gql` + type DocumentCollection { + id: ID! + name: String! + description: String + createdAt: DateTime! + updatedAt: DateTime! + createdBy: User! + operations(first: Int = 100, after: String = null): DocumentCollectionOperationsConnection! + } + + type DocumentCollectionEdge { + node: DocumentCollection! + cursor: String! + } + + type DocumentCollectionConnection { + edges: [DocumentCollectionEdge!]! + pageInfo: PageInfo! + } + + type DocumentCollectionOperationsConnection { + edges: [DocumentCollectionOperationEdge!]! + pageInfo: PageInfo! + } + + type DocumentCollectionOperation { + id: ID! + name: String! + query: String! + variables: String + headers: String + createdAt: DateTime! + updatedAt: DateTime! + collection: DocumentCollection! + } + + type DocumentCollectionOperationEdge { + node: DocumentCollectionOperation! + cursor: String! + } + + input CreateDocumentCollectionInput { + name: String! + description: String + } + + input UpdateDocumentCollectionInput { + collectionId: ID! + name: String! + description: String + } + + input CreateDocumentCollectionOperationInput { + collectionId: ID! + name: String! + query: String! + variables: String + headers: String + } + + input UpdateDocumentCollectionOperationInput { + operationId: ID! + collectionId: ID! + name: String! + query: String! + variables: String + headers: String + } + + extend type Mutation { + createOperationInDocumentCollection( + selector: TargetSelectorInput! + input: CreateDocumentCollectionOperationInput! + ): ModifyDocumentCollectionOperationResult! + updateOperationInDocumentCollection( + selector: TargetSelectorInput! + input: UpdateDocumentCollectionOperationInput! + ): ModifyDocumentCollectionOperationResult! + deleteOperationInDocumentCollection( + selector: TargetSelectorInput! + id: ID! + ): DeleteDocumentCollectionOperationResult! + + createDocumentCollection( + selector: TargetSelectorInput! + input: CreateDocumentCollectionInput! + ): ModifyDocumentCollectionResult! + updateDocumentCollection( + selector: TargetSelectorInput! + input: UpdateDocumentCollectionInput! + ): ModifyDocumentCollectionResult! + deleteDocumentCollection( + selector: TargetSelectorInput! + id: ID! + ): DeleteDocumentCollectionResult! + } + + type ModifyDocumentCollectionError implements Error { + message: String! + } + + """ + @oneOf + """ + type DeleteDocumentCollectionResult { + ok: DeleteDocumentCollectionOkPayload + error: ModifyDocumentCollectionError + } + + type DeleteDocumentCollectionOkPayload { + updatedTarget: Target! + deletedId: ID! + } + + """ + @oneOf + """ + type DeleteDocumentCollectionOperationResult { + ok: DeleteDocumentCollectionOperationOkPayload + error: ModifyDocumentCollectionError + } + + type DeleteDocumentCollectionOperationOkPayload { + updatedTarget: Target! + updatedCollection: DocumentCollection! + deletedId: ID! + } + + """ + @oneOf + """ + type ModifyDocumentCollectionResult { + ok: ModifyDocumentCollectionOkPayload + error: ModifyDocumentCollectionError + } + + type ModifyDocumentCollectionOkPayload { + collection: DocumentCollection! + updatedTarget: Target! + } + + """ + @oneOf + """ + type ModifyDocumentCollectionOperationResult { + ok: ModifyDocumentCollectionOperationOkPayload + error: ModifyDocumentCollectionError + } + + type ModifyDocumentCollectionOperationOkPayload { + operation: DocumentCollectionOperation! + collection: DocumentCollection! + updatedTarget: Target! + } + + extend type Target { + documentCollection(id: ID!): DocumentCollection + documentCollections(first: Int = 100, after: String = null): DocumentCollectionConnection! + documentCollectionOperation(id: ID!): DocumentCollectionOperation + } +`; diff --git a/packages/services/api/src/modules/collection/providers/collection.provider.ts b/packages/services/api/src/modules/collection/providers/collection.provider.ts new file mode 100644 index 000000000..bd092d2c1 --- /dev/null +++ b/packages/services/api/src/modules/collection/providers/collection.provider.ts @@ -0,0 +1,99 @@ +import { Injectable, Scope } from 'graphql-modules'; +import { + CreateDocumentCollectionInput, + CreateDocumentCollectionOperationInput, + UpdateDocumentCollectionInput, + UpdateDocumentCollectionOperationInput, +} from '@/graphql'; +import { AuthManager } from '../../auth/providers/auth-manager'; +import { Logger } from '../../shared/providers/logger'; +import { Storage } from '../../shared/providers/storage'; + +@Injectable({ + global: true, + scope: Scope.Operation, +}) +export class CollectionProvider { + private logger: Logger; + + constructor(logger: Logger, private storage: Storage, private authManager: AuthManager) { + this.logger = logger.child({ source: 'CollectionProvider' }); + } + + getCollections(targetId: string, first: number, cursor: string | null) { + return this.storage.getPaginatedDocumentCollectionsForTarget({ + targetId, + first, + cursor, + }); + } + + getCollection(id: string) { + return this.storage.getDocumentCollection({ id }); + } + + getOperations(documentCollectionId: string, first: number, cursor: string | null) { + return this.storage.getPaginatedDocumentsForDocumentCollection({ + documentCollectionId, + first, + cursor, + }); + } + + getOperation(id: string) { + return this.storage.getDocumentCollectionDocument({ id }); + } + + async createCollection( + targetId: string, + { name, description }: Pick, + ) { + const currentUser = await this.authManager.getCurrentUser(); + + return this.storage.createDocumentCollection({ + createdByUserId: currentUser.id, + title: name, + description: description || '', + targetId, + }); + } + + deleteCollection(id: string) { + return this.storage.deleteDocumentCollection({ documentCollectionId: id }); + } + + async createOperation(input: CreateDocumentCollectionOperationInput) { + const currentUser = await this.authManager.getCurrentUser(); + + return this.storage.createDocumentCollectionDocument({ + documentCollectionId: input.collectionId, + title: input.name, + contents: input.query, + variables: input.variables ?? null, + headers: input.headers ?? null, + createdByUserId: currentUser.id, + }); + } + + updateOperation(input: UpdateDocumentCollectionOperationInput) { + return this.storage.updateDocumentCollectionDocument({ + documentCollectionDocumentId: input.operationId, + title: input.name, + contents: input.query, + variables: input.variables ?? null, + headers: input.headers ?? null, + }); + } + + updateCollection(input: UpdateDocumentCollectionInput) { + return this.storage.updateDocumentCollection({ + documentCollectionId: input.collectionId, + description: input.description || null, + title: input.name, + }); + } + + deleteOperation(id: string) { + return this.storage.deleteDocumentCollectionDocument({ documentCollectionDocumentId: id }); + } +} diff --git a/packages/services/api/src/modules/collection/resolvers.ts b/packages/services/api/src/modules/collection/resolvers.ts new file mode 100644 index 000000000..37d9a7a23 --- /dev/null +++ b/packages/services/api/src/modules/collection/resolvers.ts @@ -0,0 +1,251 @@ +import { Injector } from 'graphql-modules'; +import * as zod from 'zod'; +import { fromZodError } from 'zod-validation-error'; +import { TargetSelectorInput } from '../../__generated__/types'; +import { AuthManager } from '../auth/providers/auth-manager'; +import { TargetAccessScope } from '../auth/providers/scopes'; +import { IdTranslator } from '../shared/providers/id-translator'; +import { Storage } from '../shared/providers/storage'; +import { CollectionModule } from './__generated__/types'; +import { CollectionProvider } from './providers/collection.provider'; + +const MAX_INPUT_LENGTH = 5000; + +// The following validates the length and the validity of the JSON object incoming as string. +const inputObjectSchema = zod + .string() + .max(MAX_INPUT_LENGTH) + .optional() + .nullable() + .refine(v => { + if (!v) { + return true; + } + + try { + JSON.parse(v); + return true; + } catch { + return false; + } + }); + +const OperationValidationInputModel = zod + .object({ + collectionId: zod.string(), + name: zod.string().min(1).max(100), + query: zod.string().min(1).max(MAX_INPUT_LENGTH), + variables: inputObjectSchema, + headers: inputObjectSchema, + }) + .partial() + .passthrough(); + +async function validateTargetAccess( + injector: Injector, + selector: TargetSelectorInput, + scope: TargetAccessScope = TargetAccessScope.REGISTRY_READ, +) { + const translator = injector.get(IdTranslator); + const [organization, project, target] = await Promise.all([ + translator.translateOrganizationId(selector), + translator.translateProjectId(selector), + translator.translateTargetId(selector), + ]); + + await injector.get(AuthManager).ensureTargetAccess({ + organization, + project, + target, + scope, + }); + + return await injector.get(Storage).getTarget({ target, organization, project }); +} + +export const resolvers: CollectionModule.Resolvers = { + DocumentCollection: { + id: root => root.id, + name: root => root.title, + description: root => root.description, + operations: (root, args, { injector }) => + injector.get(CollectionProvider).getOperations(root.id, args.first, args.after), + }, + DocumentCollectionOperation: { + name: op => op.title, + query: op => op.contents, + async collection(op, args, { injector }) { + const collection = await injector + .get(CollectionProvider) + .getCollection(op.documentCollectionId); + + // This should not happen, but we do want to flag this as an unexpected error. + if (!collection) { + throw new Error('Collection not found'); + } + + return collection; + }, + }, + Target: { + documentCollections: (target, args, { injector }) => + injector.get(CollectionProvider).getCollections(target.id, args.first, args.after), + documentCollectionOperation: (_, args, { injector }) => + injector.get(CollectionProvider).getOperation(args.id), + documentCollection: (_, args, { injector }) => + injector.get(CollectionProvider).getCollection(args.id), + }, + Mutation: { + async createDocumentCollection(_, { selector, input }, { injector }) { + const target = await validateTargetAccess(injector, selector, TargetAccessScope.SETTINGS); + const result = await injector.get(CollectionProvider).createCollection(target.id, input); + + return { + ok: { + __typename: 'ModifyDocumentCollectionOkPayload', + collection: result, + updatedTarget: target, + }, + }; + }, + async updateDocumentCollection(_, { selector, input }, { injector }) { + const target = await validateTargetAccess(injector, selector, TargetAccessScope.SETTINGS); + const result = await injector.get(CollectionProvider).updateCollection(input); + + if (!result) { + return { + error: { + __typename: 'ModifyDocumentCollectionError', + message: 'Failed to locate a document collection', + }, + }; + } + + return { + ok: { + __typename: 'ModifyDocumentCollectionOkPayload', + collection: result, + updatedTarget: target, + }, + }; + }, + async deleteDocumentCollection(_, { selector, id }, { injector }) { + const target = await validateTargetAccess(injector, selector, TargetAccessScope.SETTINGS); + await injector.get(CollectionProvider).deleteCollection(id); + + return { + ok: { + __typename: 'DeleteDocumentCollectionOkPayload', + deletedId: id, + updatedTarget: target, + }, + }; + }, + async createOperationInDocumentCollection(_, { selector, input }, { injector }) { + try { + OperationValidationInputModel.parse(input); + const target = await validateTargetAccess(injector, selector, TargetAccessScope.SETTINGS); + const result = await injector.get(CollectionProvider).createOperation(input); + const collection = await injector + .get(CollectionProvider) + .getCollection(result.documentCollectionId); + + if (!result || !collection) { + return { + error: { + __typename: 'ModifyDocumentCollectionError', + message: 'Failed to locate a document collection', + }, + }; + } + + return { + ok: { + __typename: 'ModifyDocumentCollectionOperationOkPayload', + operation: result, + updatedTarget: target, + collection, + }, + }; + } catch (e) { + if (e instanceof zod.ZodError) { + return { + error: { + __typename: 'ModifyDocumentCollectionError', + message: fromZodError(e).message, + }, + }; + } + + throw e; + } + }, + async updateOperationInDocumentCollection(_, { selector, input }, { injector }) { + try { + OperationValidationInputModel.parse(input); + const target = await validateTargetAccess(injector, selector, TargetAccessScope.SETTINGS); + const result = await injector.get(CollectionProvider).updateOperation(input); + + if (!result) { + return { + error: { + __typename: 'ModifyDocumentCollectionError', + message: 'Failed to locate a document collection', + }, + }; + } + + const collection = await injector + .get(CollectionProvider) + .getCollection(result.documentCollectionId); + + return { + ok: { + __typename: 'ModifyDocumentCollectionOperationOkPayload', + operation: result, + updatedTarget: target, + collection: collection!, + }, + }; + } catch (e) { + if (e instanceof zod.ZodError) { + return { + error: { + __typename: 'ModifyDocumentCollectionError', + message: fromZodError(e).message, + }, + }; + } + + throw e; + } + }, + async deleteOperationInDocumentCollection(_, { selector, id }, { injector }) { + const target = await validateTargetAccess(injector, selector, TargetAccessScope.SETTINGS); + const operation = await injector.get(CollectionProvider).getOperation(id); + + if (!operation) { + return { + error: { + __typename: 'ModifyDocumentCollectionError', + message: 'Failed to locate a operation', + }, + }; + } + + const collection = await injector + .get(CollectionProvider) + .getCollection(operation.documentCollectionId); + await injector.get(CollectionProvider).deleteOperation(id); + + return { + ok: { + __typename: 'DeleteDocumentCollectionOperationOkPayload', + deletedId: id, + updatedTarget: target, + updatedCollection: collection!, + }, + }; + }, + }, +}; diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index 056849294..de4f9b395 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -207,6 +207,7 @@ export class OperationsManager { percentage: total === 0 ? 0 : (totalField / total) * 100, }; } + async readFieldListStats({ fields, period, @@ -575,9 +576,7 @@ export class OperationsManager { return false; } - const total = await this.reader.countOperationsForTargets({ - targets, - }); + const total = await this.reader.countOperationsForTargets({ targets }); if (total > 0) { await this.storage.completeGetStartedStep({ diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index e2805661a..08c1a2d16 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -12,11 +12,15 @@ import type { AlertChannel, CDNAccessToken, DeletedCompositeSchema, + DocumentCollection, + DocumentCollectionOperation, Member, OIDCIntegration, Organization, OrganizationBilling, OrganizationInvitation, + PaginatedDocumentCollectionOperations, + PaginatedDocumentCollections, PersistedOperation, Project, Schema, @@ -94,27 +98,34 @@ export interface Storage { reservedNames: string[]; }, ): Promise; + deleteOrganization(_: OrganizationSelector): Promise< | (Organization & { tokens: string[]; }) | never >; + updateOrganizationName( _: OrganizationSelector & Pick & { user: string }, ): Promise; + updateOrganizationPlan( _: OrganizationSelector & Pick, ): Promise; + updateOrganizationRateLimits( _: OrganizationSelector & Pick, ): Promise; + createOrganizationInvitation( _: OrganizationSelector & { email: string }, ): Promise; + deleteOrganizationInvitationByEmail( _: OrganizationSelector & { email: string }, ): Promise; + createOrganizationTransferRequest( _: OrganizationSelector & { user: string; @@ -122,6 +133,7 @@ export interface Storage { ): Promise<{ code: string; }>; + getOrganizationTransferRequest( _: OrganizationSelector & { code: string; @@ -130,6 +142,7 @@ export interface Storage { ): Promise<{ code: string; } | null>; + answerOrganizationTransferRequest( _: OrganizationSelector & { code: string; @@ -142,21 +155,29 @@ export interface Storage { ): Promise; getOrganizationMembers(_: OrganizationSelector): Promise; + getOrganizationInvitations(_: OrganizationSelector): Promise; + getOrganizationOwnerId(_: OrganizationSelector): Promise; + getOrganizationOwner(_: OrganizationSelector): Promise; + getOrganizationMember(_: OrganizationSelector & { user: string }): Promise; + getOrganizationMemberAccessPairs( _: readonly (OrganizationSelector & { user: string })[], ): Promise< ReadonlyArray> >; + hasOrganizationMemberPairs( _: readonly (OrganizationSelector & { user: string })[], ): Promise; + hasOrganizationProjectMemberPairs( _: readonly (ProjectSelector & { user: string })[], ): Promise; + addOrganizationMemberViaInvitationCode( _: OrganizationSelector & { code: string; @@ -164,7 +185,9 @@ export interface Storage { scopes: ReadonlyArray; }, ): Promise; + deleteOrganizationMembers(_: OrganizationSelector & { users: readonly string[] }): Promise; + updateOrganizationMemberAccess( _: OrganizationSelector & { user: string; @@ -175,31 +198,41 @@ export interface Storage { getPersistedOperationId(_: PersistedOperationSelector): Promise; getProject(_: ProjectSelector): Promise; + getProjectId(_: ProjectSelector): Promise; + getProjectByCleanId(_: { cleanId: string } & OrganizationSelector): Promise; + getProjects(_: OrganizationSelector): Promise; + createProject( _: Pick & OrganizationSelector, ): Promise; + deleteProject(_: ProjectSelector): Promise< | (Project & { tokens: string[]; }) | never >; + updateProjectName( _: ProjectSelector & Pick & { user: string }, ): Promise; + updateProjectGitRepository( _: ProjectSelector & Pick, ): Promise; + enableExternalSchemaComposition( _: ProjectSelector & { endpoint: string; encryptedSecret: string; }, ): Promise; + disableExternalSchemaComposition(_: ProjectSelector): Promise; + updateProjectRegistryModel( _: ProjectSelector & { model: RegistryModel; @@ -207,33 +240,44 @@ export interface Storage { ): Promise; getTargetId(_: TargetSelector & { useIds?: boolean }): Promise; + getTargetByCleanId( _: { cleanId: string; } & ProjectSelector, ): Promise; + createTarget(_: Pick & ProjectSelector): Promise; + updateTargetName( _: TargetSelector & Pick & { user: string }, ): Promise; + deleteTarget(_: TargetSelector): Promise< | (Target & { tokens: string[]; }) | never >; + getTarget(_: TargetSelector): Promise; + getTargets(_: ProjectSelector): Promise; + getTargetIdsOfOrganization(_: OrganizationSelector): Promise; + getTargetSettings(_: TargetSelector): Promise; + setTargetValidation( _: TargetSelector & { enabled: boolean }, ): Promise; + updateTargetValidationSettings( _: TargetSelector & Omit, ): Promise; hasSchema(_: TargetSelector): Promise; + getLatestSchemas( _: { onlyComposable?: boolean; @@ -243,9 +287,13 @@ export interface Storage { version: string; valid: boolean; } | null>; + getLatestValidVersion(_: TargetSelector): Promise; + getMaybeLatestValidVersion(_: TargetSelector): Promise; + getLatestVersion(_: TargetSelector): Promise; + getMaybeLatestVersion(_: TargetSelector): Promise; getMatchingServiceSchemaOfVersions(versions: { @@ -281,6 +329,7 @@ export interface Storage { } | never >; + getVersion(_: TargetSelector & { version: string }): Promise; deleteSchema( _: { @@ -389,27 +438,35 @@ export interface Storage { deletePersistedOperation(_: PersistedOperationSelector): Promise; addSlackIntegration(_: OrganizationSelector & { token: string }): Promise; + deleteSlackIntegration(_: OrganizationSelector): Promise; + getSlackIntegrationToken(_: OrganizationSelector): Promise; addGitHubIntegration(_: OrganizationSelector & { installationId: string }): Promise; + deleteGitHubIntegration(_: OrganizationSelector): Promise; + getGitHubIntegrationInstallationId(_: OrganizationSelector): Promise; addAlertChannel(_: AddAlertChannelInput): Promise; + deleteAlertChannels( _: ProjectSelector & { channels: readonly string[]; }, ): Promise; + getAlertChannels(_: ProjectSelector): Promise; addAlert(_: AddAlertInput): Promise; + deleteAlerts( _: ProjectSelector & { alerts: readonly string[]; }, ): Promise; + getAlerts(_: ProjectSelector): Promise; adminGetStats(period: { from: Date; to: Date }): Promise< @@ -448,12 +505,15 @@ export interface Storage { >; getBillingParticipants(): Promise>; + getOrganizationBilling(_: OrganizationSelector): Promise; + deleteOrganizationBilling(_: OrganizationSelector): Promise; createOrganizationBilling(_: OrganizationBilling): Promise; getBaseSchema(_: TargetSelector): Promise; + updateBaseSchema(_: TargetSelector, base: string | null): Promise; completeGetStartedStep( @@ -463,7 +523,9 @@ export interface Storage { ): Promise; getOIDCIntegrationForOrganization(_: { organizationId: string }): Promise; + getOIDCIntegrationById(_: { oidcIntegrationId: string }): Promise; + createOIDCIntegrationForOrganization(_: { organizationId: string; clientId: string; @@ -472,6 +534,7 @@ export interface Storage { userinfoEndpoint: string; authorizationEndpoint: string; }): Promise<{ type: 'ok'; oidcIntegration: OIDCIntegration } | { type: 'error'; reason: string }>; + updateOIDCIntegration(_: { oidcIntegrationId: string; clientId: string | null; @@ -480,6 +543,7 @@ export interface Storage { userinfoEndpoint: string | null; authorizationEndpoint: string | null; }): Promise; + deleteOIDCIntegration(_: { oidcIntegrationId: string }): Promise; createCDNAccessToken(_: { @@ -527,6 +591,73 @@ export interface Storage { findInheritedPolicies(selector: ProjectSelector): Promise; getSchemaPolicyForOrganization(organizationId: string): Promise; getSchemaPolicyForProject(projectId: string): Promise; + + /** Document Collections */ + getPaginatedDocumentCollectionsForTarget(_: { + targetId: string; + first: number | null; + cursor: null | string; + }): Promise; + + createDocumentCollection(_: { + targetId: string; + title: string; + description: string; + createdByUserId: string | null; + }): Promise; + + /** + * Returns null if the document collection does not exist (did not get deleted). + * Returns the id of the deleted document collection if it got deleted + */ + deleteDocumentCollection(_: { documentCollectionId: string }): Promise; + + /** + * Returns null if the document collection does not exist (did not get updated). + */ + updateDocumentCollection(_: { + documentCollectionId: string; + title: string | null; + description: string | null; + }): Promise; + + getDocumentCollection(_: { id: string }): Promise; + + getPaginatedDocumentsForDocumentCollection(_: { + documentCollectionId: string; + first: number | null; + cursor: null | string; + }): Promise; + + createDocumentCollectionDocument(_: { + documentCollectionId: string; + title: string; + contents: string; + variables: string | null; + headers: string | null; + createdByUserId: string | null; + }): Promise; + + /** + * Returns null if the document collection document does not exist (did not get deleted). + * Returns the id of the deleted document collection document if it got deleted + */ + deleteDocumentCollectionDocument(_: { + documentCollectionDocumentId: string; + }): Promise; + + /** + * Returns null if the document collection document does not exist (did not get updated). + */ + updateDocumentCollectionDocument(_: { + documentCollectionDocumentId: string; + title: string | null; + contents: string | null; + variables: string | null; + headers: string | null; + }): Promise; + + getDocumentCollectionDocument(_: { id: string }): Promise; } @Injectable() diff --git a/packages/services/api/src/modules/target/providers/target-manager.ts b/packages/services/api/src/modules/target/providers/target-manager.ts index 97d58cb57..f6009b343 100644 --- a/packages/services/api/src/modules/target/providers/target-manager.ts +++ b/packages/services/api/src/modules/target/providers/target-manager.ts @@ -126,11 +126,11 @@ export class TargetManager { return this.storage.getTargets(selector); } - async getTarget(selector: TargetSelector): Promise { + async getTarget(selector: TargetSelector, scope = TargetAccessScope.READ): Promise { this.logger.debug('Fetching target (selector=%o)', selector); await this.authManager.ensureTargetAccess({ ...selector, - scope: TargetAccessScope.READ, + scope, }); return this.storage.getTarget(selector); } diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index c26467aaa..cfbe348d3 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -194,6 +194,54 @@ export interface CDNAccessToken { readonly createdAt: string; } +export interface DocumentCollection { + id: string; + title: string; + description: string | null; + targetId: string; + createdByUserId: string | null; + createdAt: string; + updatedAt: string; +} + +export type PaginatedDocumentCollections = Readonly<{ + edges: ReadonlyArray<{ + node: DocumentCollection; + cursor: string; + }>; + pageInfo: Readonly<{ + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; + }>; +}>; + +export interface DocumentCollectionOperation { + id: string; + title: string; + contents: string; + variables: string | null; + headers: string | null; + createdByUserId: string | null; + documentCollectionId: string; + createdAt: string; + updatedAt: string; +} + +export type PaginatedDocumentCollectionOperations = Readonly<{ + edges: ReadonlyArray<{ + node: DocumentCollectionOperation; + cursor: string; + }>; + pageInfo: Readonly<{ + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; + }>; +}>; + export interface Project { id: string; cleanId: string; diff --git a/packages/services/api/src/shared/mappers.ts b/packages/services/api/src/shared/mappers.ts index 083d3092a..16bdb8ec0 100644 --- a/packages/services/api/src/shared/mappers.ts +++ b/packages/services/api/src/shared/mappers.ts @@ -25,6 +25,8 @@ import type { ActivityObject, DateRange, DeletedCompositeSchema as DeletedCompositeSchemaEntity, + DocumentCollection, + DocumentCollectionOperation, Member, Organization, PersistedOperation, @@ -231,3 +233,6 @@ export type SchemaCoordinateUsageTypeMapper = { total: number; usedByClients: PromiseOrValue | null>; }; + +export type DocumentCollectionConnection = ReadonlyArray; +export type DocumentCollectionOperationsConnection = ReadonlyArray; diff --git a/packages/services/server/src/graphql-handler.ts b/packages/services/server/src/graphql-handler.ts index 5eb91c80a..e346a3040 100644 --- a/packages/services/server/src/graphql-handler.ts +++ b/packages/services/server/src/graphql-handler.ts @@ -170,7 +170,7 @@ export const graphqlHandler = (options: GraphQLHandlerOptions): RouteHandlerMeth if (authHeaderParts.length === 2 && authHeaderParts[0] === 'Bearer') { const accessToken = authHeaderParts[1]; // The token issued by Hive is always 32 characters long. - // Everything longer should be treated as an supertokens token (JWT). + // Everything longer should be treated as a supertokens token (JWT). if (accessToken.length > 32) { return await verifySuperTokensSession( options.supertokens.connectionUri, diff --git a/packages/services/storage/package.json b/packages/services/storage/package.json index 12df71c27..c490c512e 100644 --- a/packages/services/storage/package.json +++ b/packages/services/storage/package.json @@ -7,6 +7,7 @@ "engines": { "node": ">=12" }, + "main": "./src/index.ts", "exports": { ".": "./src/index.ts" }, diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 45e025654..c57906947 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -53,6 +53,28 @@ export interface cdn_access_tokens { target_id: string; } +export interface document_collection_documents { + contents: string; + created_at: Date; + created_by_user_id: string | null; + document_collection_id: string; + headers: string | null; + id: string; + title: string; + updated_at: Date; + variables: string | null; +} + +export interface document_collections { + created_at: Date; + created_by_user_id: string | null; + description: string | null; + id: string; + target_id: string; + title: string; + updated_at: Date; +} + export interface migration { date: Date; hash: string; @@ -257,6 +279,8 @@ export interface DBTables { alert_channels: alert_channels; alerts: alerts; cdn_access_tokens: cdn_access_tokens; + document_collection_documents: document_collection_documents; + document_collections: document_collections; migration: migration; oidc_integrations: oidc_integrations; organization_invitations: organization_invitations; diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 256706f59..88a9667e5 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -2871,7 +2871,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; if (args.cursor) { - cursor = decodeCDNAccessTokenCursor(args.cursor); + cursor = decodeCreatedAtAndUUIDIdBasedCursor(args.cursor); } const result = await pool.any(sql` @@ -2913,7 +2913,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) return { node, get cursor() { - return encodeCDNAccessTokenCursor(node); + return encodeCreatedAtAndUUIDIdBasedCursor(node); }, }; }); @@ -3008,21 +3008,373 @@ export async function createStorage(connection: string, maximumPoolSize: number) return result ? transformSchemaPolicy(result) : null; }, + async getPaginatedDocumentCollectionsForTarget(args) { + let cursor: null | { + createdAt: string; + id: string; + } = null; + + const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; + + if (args.cursor) { + cursor = decodeCreatedAtAndUUIDIdBasedCursor(args.cursor); + } + + const result = await pool.any(sql` + SELECT + "id" + , "title" + , "description" + , "target_id" as "targetId" + , "created_by_user_id" as "createdByUserId" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + FROM + "public"."document_collections" + WHERE + "target_id" = ${args.targetId} + ${ + cursor + ? sql` + AND ( + ( + "created_at" = ${cursor.createdAt} + AND "id" < ${cursor.id} + ) + OR "created_at" < ${cursor.createdAt} + ) + ` + : sql`` + } + ORDER BY + "target_id" ASC + , "created_at" DESC + , "id" DESC + LIMIT ${limit + 1} + `); + + let items = result.map(row => { + const node = DocumentCollectionModel.parse(row); + + return { + node, + get cursor() { + return encodeCreatedAtAndUUIDIdBasedCursor(node); + }, + }; + }); + + const hasNextPage = items.length > limit; + + items = items.slice(0, limit); + + return { + edges: items, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + get endCursor() { + return items[items.length - 1]?.cursor ?? ''; + }, + get startCursor() { + return items[0]?.cursor ?? ''; + }, + }, + }; + }, + + async createDocumentCollection(args) { + const result = await pool.maybeOne(sql` + INSERT INTO "public"."document_collections" ( + "title" + , "description" + , "target_id" + , "created_by_user_id" + ) + VALUES ( + ${args.title}, + ${args.description}, + ${args.targetId}, + ${args.createdByUserId} + ) + RETURNING + "id" + , "title" + , "description" + , "target_id" as "targetId" + , "created_by_user_id" as "createdByUserId" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + `); + + return DocumentCollectionModel.parse(result); + }, + async deleteDocumentCollection(args) { + const result = await pool.maybeOneFirst(sql` + DELETE + FROM + "public"."document_collections" + WHERE + "id" = ${args.documentCollectionId} + RETURNING + "id" + `); + + if (result == null) { + return null; + } + + return zod.string().parse(result); + }, + + async updateDocumentCollection(args) { + const result = await pool.maybeOne(sql` + UPDATE + "public"."document_collections" + SET + "title" = COALESCE(${args.title}, "title") + , "description" = COALESCE(${args.description}, "description") + , "updated_at" = NOW() + WHERE + "id" = ${args.documentCollectionId} + RETURNING + "id" + , "title" + , "description" + , "target_id" as "targetId" + , "created_by_user_id" as "createdByUserId" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + `); + + if (result == null) { + return null; + } + + return DocumentCollectionModel.parse(result); + }, + + async getPaginatedDocumentsForDocumentCollection(args) { + let cursor: null | { + createdAt: string; + id: string; + } = null; + + const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; + + if (args.cursor) { + cursor = decodeCreatedAtAndUUIDIdBasedCursor(args.cursor); + } + + const result = await pool.any(sql` + SELECT + "id" + , "title" + , "contents" + , "variables" + , "headers" + , "created_by_user_id" as "createdByUserId" + , "document_collection_id" as "documentCollectionId" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + FROM + "public"."document_collection_documents" + WHERE + "document_collection_id" = ${args.documentCollectionId} + ${ + cursor + ? sql` + AND ( + ( + "created_at" = ${cursor.createdAt} + AND "id" < ${cursor.id} + ) + OR "created_at" < ${cursor.createdAt} + ) + ` + : sql`` + } + ORDER BY + "document_collection_id" ASC + , "created_at" DESC + , "id" DESC + LIMIT ${limit + 1} + `); + + let items = result.map(row => { + const node = DocumentCollectionDocumentModel.parse(row); + + return { + node, + get cursor() { + return encodeCreatedAtAndUUIDIdBasedCursor(node); + }, + }; + }); + + const hasNextPage = items.length > limit; + + items = items.slice(0, limit); + + return { + edges: items, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + get endCursor() { + return items[items.length - 1]?.cursor ?? ''; + }, + get startCursor() { + return items[0]?.cursor ?? ''; + }, + }, + }; + }, + + async createDocumentCollectionDocument(args) { + const result = await pool.one(sql` + INSERT INTO "public"."document_collection_documents" ( + "title" + , "contents" + , "variables" + , "headers" + , "created_by_user_id" + , "document_collection_id" + ) + VALUES ( + ${args.title} + , ${args.contents} + , ${args.variables} + , ${args.headers} + , ${args.createdByUserId} + , ${args.documentCollectionId} + ) + RETURNING + "id" + , "title" + , "contents" + , "variables" + , "headers" + , "created_by_user_id" as "createdByUserId" + , "document_collection_id" as "documentCollectionId" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + `); + + return DocumentCollectionDocumentModel.parse(result); + }, + + async deleteDocumentCollectionDocument(args) { + const result = await pool.maybeOneFirst(sql` + DELETE + FROM + "public"."document_collection_documents" + WHERE + "id" = ${args.documentCollectionDocumentId} + RETURNING + "id" + `); + + if (result == null) { + return null; + } + + return zod.string().parse(result); + }, + + async getDocumentCollectionDocument(args) { + const result = await pool.maybeOne(sql` + SELECT + "id" + , "title" + , "contents" + , "variables" + , "headers" + , "created_by_user_id" as "createdByUserId" + , "document_collection_id" as "documentCollectionId" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + FROM + "public"."document_collection_documents" + WHERE + "id" = ${args.id} + `); + + if (result === null) { + return null; + } + + return DocumentCollectionDocumentModel.parse(result); + }, + + async getDocumentCollection(args) { + const result = await pool.maybeOne(sql` + SELECT + "id" + , "title" + , "description" + , "target_id" as "targetId" + , "created_by_user_id" as "createdByUserId" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + FROM + "public"."document_collections" + WHERE + "id" = ${args.id} + `); + + if (result === null) { + return null; + } + + return DocumentCollectionModel.parse(result); + }, + + async updateDocumentCollectionDocument(args) { + const result = await pool.maybeOne(sql` + UPDATE + "public"."document_collection_documents" + SET + "title" = COALESCE(${args.title}, "title") + , "contents" = COALESCE(${args.contents}, "contents") + , "variables" = COALESCE(${args.variables}, "variables") + , "headers" = COALESCE(${args.headers}, "headers") + , "updated_at" = NOW() + WHERE + "id" = ${args.documentCollectionDocumentId} + RETURNING + "id" + , "title" + , "contents" + , "variables" + , "headers" + , "created_by_user_id" as "createdByUserId" + , "document_collection_id" as "documentCollectionId" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + `); + + if (result === null) { + return null; + } + + return DocumentCollectionDocumentModel.parse(result); + }, }; return storage; } -function encodeCDNAccessTokenCursor(cursor: { createdAt: string; id: string }) { +function encodeCreatedAtAndUUIDIdBasedCursor(cursor: { createdAt: string; id: string }) { return Buffer.from(`${cursor.createdAt}|${cursor.id}`).toString('base64'); } -function decodeCDNAccessTokenCursor(cursor: string) { +function decodeCreatedAtAndUUIDIdBasedCursor(cursor: string) { const [createdAt, id] = Buffer.from(cursor, 'base64').toString('utf8').split('|'); if ( Number.isNaN(Date.parse(createdAt)) || id === undefined || - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id) === false + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id) ) { throw new Error('Invalid cursor'); } @@ -3159,6 +3511,28 @@ const SchemaVersionModel = zod schemaCompositionErrors: value.schema_composition_errors, })); +const DocumentCollectionModel = zod.object({ + id: zod.string(), + title: zod.string(), + description: zod.union([zod.string(), zod.null()]), + targetId: zod.string(), + createdByUserId: zod.union([zod.string(), zod.null()]), + createdAt: zod.string(), + updatedAt: zod.string(), +}); + +const DocumentCollectionDocumentModel = zod.object({ + id: zod.string(), + title: zod.string(), + contents: zod.string(), + variables: zod.string().nullable(), + headers: zod.string().nullable(), + createdByUserId: zod.union([zod.string(), zod.null()]), + documentCollectionId: zod.string(), + createdAt: zod.string(), + updatedAt: zod.string(), +}); + /** * Insert a schema version changes into the database. */ diff --git a/packages/web/app/package.json b/packages/web/app/package.json index 3bb3a4301..c38c761e1 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc" }, "dependencies": { + "@graphiql/react": "0.18.0-alpha.0", "@graphiql/toolkit": "0.8.4", "@graphql-tools/mock": "9.0.0", "@headlessui/react": "1.7.15", @@ -42,9 +43,9 @@ "@theguild/editor": "1.2.5", "@trpc/client": "10.29.1", "@trpc/server": "10.29.1", - "@urql/core": "3.1.1", + "@urql/core": "4.0.10", "@urql/devtools": "2.0.3", - "@urql/exchange-graphcache": "5.0.9", + "@urql/exchange-graphcache": "6.1.1", "@whatwg-node/fetch": "0.9.3", "clsx": "1.2.1", "cookies": "0.8.0", @@ -53,7 +54,7 @@ "echarts": "5.4.2", "echarts-for-react": "3.0.2", "formik": "2.2.9", - "graphiql": "2.4.7", + "graphiql": "3.0.0-alpha.0", "graphql": "16.6.0", "hyperid": "3.1.1", "immer": "10.0.2", @@ -81,7 +82,7 @@ "supertokens-node": "13.4.2", "supertokens-web-js": "0.5.0", "tslib": "2.5.3", - "urql": "3.0.3", + "urql": "4.0.3", "use-debounce": "9.0.4", "valtio": "1.10.5", "wonka": "6.3.2", diff --git a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/laboratory.tsx b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/laboratory.tsx index b20815b65..277256eab 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/laboratory.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/laboratory.tsx @@ -1,17 +1,425 @@ -import { ReactElement } from 'react'; +import { ReactElement, useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/router'; +import clsx from 'clsx'; import { GraphiQL } from 'graphiql'; +import { useMutation, useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; import { TargetLayout } from '@/components/layouts'; -import { Button, DocsLink, DocsNote, Title } from '@/components/v2'; -import { HiveLogo, Link2Icon } from '@/components/v2/icon'; -import { ConnectLabModal } from '@/components/v2/modals/connect-lab'; -import { graphql } from '@/gql'; -import { useRouteSelector, useToggle } from '@/lib/hooks'; +import { TargetLayout_OrganizationFragment } from '@/components/layouts/target'; +import { + Accordion, + Button, + DocsLink, + DocsNote, + EmptyList, + Heading, + Link, + Spinner, + Title, +} from '@/components/v2'; +import { HiveLogo, SaveIcon } from '@/components/v2/icon'; +import { + ConnectLabModal, + CreateCollectionModal, + CreateOperationModal, + DeleteCollectionModal, + DeleteOperationModal, +} from '@/components/v2/modals'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { TargetAccessScope } from '@/gql/graphql'; +import { canAccessTarget, CanAccessTarget_MemberFragment } from '@/lib/access/target'; +import { useClipboard, useNotifications, useRouteSelector, useToggle } from '@/lib/hooks'; +import { useCollections } from '@/lib/hooks/use-collections'; import { withSessionProtection } from '@/lib/supertokens/guard'; +import { DropdownMenu, GraphiQLPlugin, Tooltip, useEditorContext } from '@graphiql/react'; import { createGraphiQLFetcher } from '@graphiql/toolkit'; +import { BookmarkIcon, DotsVerticalIcon, Link1Icon, Share2Icon } from '@radix-ui/react-icons'; import 'graphiql/graphiql.css'; -const Page = ({ endpoint }: { endpoint: string }): ReactElement => { +function Share(): ReactElement { + const label = 'Share query'; + const copyToClipboard = useClipboard(); + const { href } = window.location; + const router = useRouter(); + return ( + + + + ); +} + +const OperationQuery = graphql(` + query Operation($selector: TargetSelectorInput!, $id: ID!) { + target(selector: $selector) { + id + documentCollectionOperation(id: $id) { + id + name + query + headers + variables + collection { + id + } + } + } + } +`); + +function useCurrentOperation() { + const router = useRouteSelector(); + const operationId = router.query.operation as string; + const [{ data }] = useQuery({ + query: OperationQuery, + variables: { + selector: { + target: router.targetId, + project: router.projectId, + organization: router.organizationId, + }, + id: operationId, + }, + pause: !operationId, + }); + // if operationId is undefined `data` could contain previous state + return operationId ? data?.target?.documentCollectionOperation : null; +} + +function useOperationCollectionsPlugin(props: { + meRef: FragmentType; +}) { + const propsRef = useRef(props); + propsRef.current = props; + const pluginRef = useRef(); + pluginRef.current ||= { + title: 'Operation Collections', + icon: BookmarkIcon, + content: function Content() { + const [isCollectionModalOpen, toggleCollectionModal] = useToggle(); + const { collections, loading } = useCollections(); + const [collectionId, setCollectionId] = useState(''); + const [operationId, setOperationId] = useState(''); + const [isDeleteCollectionModalOpen, toggleDeleteCollectionModalOpen] = useToggle(); + const [isDeleteOperationModalOpen, toggleDeleteOperationModalOpen] = useToggle(); + const copyToClipboard = useClipboard(); + const router = useRouteSelector(); + + const currentOperation = useCurrentOperation(); + const editorContext = useEditorContext({ nonNull: true }); + + const hasAllEditors = !!( + editorContext.queryEditor && + editorContext.variableEditor && + editorContext.headerEditor + ); + + const queryParamsOperationId = router.query.operation as string; + + const tabsCount = editorContext.tabs.length; + + useEffect(() => { + if (tabsCount !== 1) { + for (let index = 1; index < tabsCount; index++) { + // Workaround to close opened tabs from end, to avoid bug when tabs are still opened + editorContext.closeTab(tabsCount - index); + } + const { operation: _paramToRemove, ...query } = router.query; + void router.push({ query }); + } + }, [tabsCount]); + + useEffect(() => { + if (!hasAllEditors) return; + + if (queryParamsOperationId && currentOperation) { + // Set selected operation in editors + editorContext.queryEditor.setValue(currentOperation.query); + editorContext.variableEditor.setValue(currentOperation.variables); + editorContext.headerEditor.setValue(currentOperation.headers); + } else { + // Clear editors if operation not selected + editorContext.queryEditor.setValue(''); + editorContext.variableEditor.setValue(''); + editorContext.headerEditor.setValue(''); + } + }, [hasAllEditors, queryParamsOperationId, currentOperation]); + + const canEdit = canAccessTarget(TargetAccessScope.Settings, props.meRef); + const canDelete = canAccessTarget(TargetAccessScope.Delete, props.meRef); + const shouldShowMenu = canEdit || canDelete; + + const initialSelectedCollection = + currentOperation?.id && + collections?.find(c => + c.operations.edges.some(({ node }) => node.id === currentOperation.id), + )?.id; + + return ( + <> +
+ Operation Collections + {canEdit ? ( + + ) : null} +
+

Shared across your organization

+ {loading ? ( + + ) : ( + + + + + {collections?.length ? ( + collections.map(collection => ( + +
+ {collection.name} + + {shouldShowMenu ? ( + + + + + + + { + setCollectionId(collection.id); + toggleCollectionModal(); + }} + data-cy="collection-edit" + > + Edit + + { + setCollectionId(collection.id); + toggleDeleteCollectionModalOpen(); + }} + className="!text-red-500" + data-cy="collection-delete" + > + Delete + + + + ) : null} +
+ + {collection.operations.edges.length + ? collection.operations.edges.map(({ node }) => ( +
+ + {node.name} + + + + + + + + { + const url = new URL(window.location.href); + await copyToClipboard( + `${url.origin}${url.pathname}?operation=${node.id}`, + ); + }} + > + Copy link to operation + + {canDelete ? ( + { + setOperationId(node.id); + toggleDeleteOperationModalOpen(); + }} + className="!text-red-500" + data-cy="remove-operation" + > + Delete + + ) : null} + + +
+ )) + : 'No operations yet. Use the editor to create an operation, and click Save to store and share it.'} +
+
+ )) + ) : ( + + )} +
+ )} + + ); + }, + }; + + return pluginRef.current; +} + +const UpdateOperationMutation = graphql(` + mutation UpdateOperation( + $selector: TargetSelectorInput! + $input: UpdateDocumentCollectionOperationInput! + ) { + updateOperationInDocumentCollection(selector: $selector, input: $input) { + error { + message + } + ok { + operation { + id + name + query + variables + headers + } + } + } + } +`); + +function Save(): ReactElement { + const [isOpen, toggle] = useToggle(); + const { collections } = useCollections(); + const notify = useNotifications(); + const routeSelector = useRouteSelector(); + const currentOperation = useCurrentOperation(); + const [, mutateUpdate] = useMutation(UpdateOperationMutation); + const { queryEditor, variableEditor, headerEditor } = useEditorContext()!; + const isSame = + !!currentOperation && + currentOperation.query === queryEditor?.getValue() && + currentOperation.variables === variableEditor?.getValue() && + currentOperation.headers === headerEditor?.getValue(); + const operationId = currentOperation?.id; + const label = isSame ? undefined : operationId ? 'Update saved operation' : 'Save operation'; + const button = ( + + ); + + return ( + <> + {label ? {button} : button} + {isOpen ? : null} + + ); +} + +// Save.whyDidYouRender = true; + +function Page({ + endpoint, + organizationRef, +}: { + endpoint: string; + organizationRef: FragmentType; +}): ReactElement { + const { me } = useFragment(TargetLayout_OrganizationFragment, organizationRef); + const operationCollectionsPlugin = useOperationCollectionsPlugin({ meRef: me }); return ( <> @@ -22,16 +430,29 @@ const Page = ({ endpoint }: { endpoint: string }): ReactElement => { .graphiql-container { --color-base: transparent !important; --color-primary: 40, 89%, 60% !important; + min-height: 600px; } `} - + + + + + ), + }} + plugins={[operationCollectionsPlugin]} + visiblePlugin={operationCollectionsPlugin} + > - + ); -}; +} const TargetLaboratoryPageQuery = graphql(` query TargetLaboratoryPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) { @@ -56,7 +477,7 @@ const TargetLaboratoryPageQuery = graphql(` function LaboratoryPage(): ReactElement { const [isModalOpen, toggleModalOpen] = useToggle(); const router = useRouteSelector(); - const endpoint = `${location.origin}/api/lab/${router.organizationId}/${router.projectId}/${router.targetId}`; + const endpoint = `${window.location.origin}/api/lab/${router.organizationId}/${router.projectId}/${router.targetId}`; return ( <> @@ -69,7 +490,7 @@ function LaboratoryPage(): ReactElement { <> } > - {() => } + {({ organization }) => ( + + )} ); diff --git a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/settings.tsx b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/settings.tsx index 1d71fb6d1..0f4d84971 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/settings.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/settings.tsx @@ -87,23 +87,19 @@ function RegistryAccessTokens(props: { targets/projects. In most cases, this token is used from the Hive CLI.
- Learn more about Registry Access Token + Learn more about Registry Access Tokens
{canManage && (
- - + {checked.length === 0 ? null : ( + + )}
)} diff --git a/packages/web/app/pages/_app.tsx b/packages/web/app/pages/_app.tsx index d3d56d81d..81d32f8dd 100644 --- a/packages/web/app/pages/_app.tsx +++ b/packages/web/app/pages/_app.tsx @@ -14,6 +14,7 @@ import { env } from '@/env/frontend'; import * as gtag from '@/lib/gtag'; import { urqlClient } from '@/lib/urql'; import { configureScope, init } from '@sentry/nextjs'; +import '../src/wdyr'; import '../public/styles.css'; import 'react-toastify/dist/ReactToastify.css'; diff --git a/packages/web/app/pages/action/transfer/[orgId]/[code].tsx b/packages/web/app/pages/action/transfer/[orgId]/[code].tsx index 65c98ec4f..7322b05e4 100644 --- a/packages/web/app/pages/action/transfer/[orgId]/[code].tsx +++ b/packages/web/app/pages/action/transfer/[orgId]/[code].tsx @@ -76,7 +76,7 @@ function OrganizationTransferPage() { if (accept) { notify('The organization is now yours!', 'success'); } - router.visitOrganization({ + void router.visitOrganization({ organizationId: orgId, }); } else { @@ -95,7 +95,7 @@ function OrganizationTransferPage() { const reject = useCallback(() => answer(false), [answer]); const goBack = useCallback(() => { - router.visitHome(); + void router.visitHome(); }, [router]); return ( diff --git a/packages/web/app/pages/index.tsx b/packages/web/app/pages/index.tsx index f11734c49..c9bd85b8e 100644 --- a/packages/web/app/pages/index.tsx +++ b/packages/web/app/pages/index.tsx @@ -77,7 +77,7 @@ function Home(): ReactElement { const org = query.data.organizations.nodes[0]; if (org) { - router.visitOrganization({ organizationId: org.cleanId }); + void router.visitOrganization({ organizationId: org.cleanId }); } } }, [router, query.data]); diff --git a/packages/web/app/pages/join/[inviteCode].tsx b/packages/web/app/pages/join/[inviteCode].tsx index 271a5a5bd..b24c1226e 100644 --- a/packages/web/app/pages/join/[inviteCode].tsx +++ b/packages/web/app/pages/join/[inviteCode].tsx @@ -32,13 +32,13 @@ function OrganizationPage() { } else { const org = result.data.joinOrganization.organization; notify(`You joined "${org.name}" organization`, 'success'); - router.visitOrganization({ organizationId: org.cleanId }); + void router.visitOrganization({ organizationId: org.cleanId }); } } }, [mutate, code, router, notify]); const goBack = useCallback(() => { - router.visitHome(); + void router.visitHome(); }, [router]); return ( diff --git a/packages/web/app/src/components/layouts/project.tsx b/packages/web/app/src/components/layouts/project.tsx index ba1591c74..1e58f76c9 100644 --- a/packages/web/app/src/components/layouts/project.tsx +++ b/packages/web/app/src/components/layouts/project.tsx @@ -97,7 +97,7 @@ export function ProjectLayout< useEffect(() => { if (projectQuery.error) { // url with # provoke error Maximum update depth exceeded - router.push('/404', router.asPath.replace(/#.*/, '')); + void router.push('/404', router.asPath.replace(/#.*/, '')); } }, [projectQuery.error, router]); diff --git a/packages/web/app/src/components/layouts/target.tsx b/packages/web/app/src/components/layouts/target.tsx index 98956a362..8dab89984 100644 --- a/packages/web/app/src/components/layouts/target.tsx +++ b/packages/web/app/src/components/layouts/target.tsx @@ -8,11 +8,12 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/v2/dropdown'; -import { ArrowDownIcon, Link2Icon } from '@/components/v2/icon'; +import { ArrowDownIcon } from '@/components/v2/icon'; import { ConnectSchemaModal } from '@/components/v2/modals'; import { FragmentType, graphql, useFragment } from '@/gql'; import { canAccessTarget, TargetAccessScope, useTargetAccess } from '@/lib/access/target'; import { useRouteSelector, useToggle } from '@/lib/hooks'; +import { Link1Icon } from '@radix-ui/react-icons'; import { QueryError } from '../common/DataWrapper'; import { ProjectMigrationToast } from '../project/migration-toast'; @@ -25,7 +26,7 @@ enum TabValue { Settings = 'settings', } -const TargetLayout_OrganizationFragment = graphql(` +export const TargetLayout_OrganizationFragment = graphql(` fragment TargetLayout_OrganizationFragment on Organization { me { ...CanAccessTarget_MemberFragment @@ -114,14 +115,14 @@ export const TargetLayout = < useEffect(() => { if (!data.fetching && !target) { // url with # provoke error Maximum update depth exceeded - router.push('/404', router.asPath.replace(/#.*/, '')); + void router.push('/404', router.asPath.replace(/#.*/, '')); } }, [router, target, data.fetching]); useEffect(() => { if (!data.fetching && !project) { // url with # provoke error Maximum update depth exceeded - router.push('/404', router.asPath.replace(/#.*/, '')); + void router.push('/404', router.asPath.replace(/#.*/, '')); } }, [router, project, data.fetching]); @@ -200,7 +201,7 @@ export const TargetLayout = < className="ml-auto" > Connect to CDN - + diff --git a/packages/web/app/src/components/organization/Permissions.tsx b/packages/web/app/src/components/organization/Permissions.tsx index ad29e5c53..3bbc85346 100644 --- a/packages/web/app/src/components/organization/Permissions.tsx +++ b/packages/web/app/src/components/organization/Permissions.tsx @@ -277,6 +277,7 @@ export function usePermissionsManager({ projectScopes, targetScopes, state, + noneSelected: !organizationScopes.length && !projectScopes.length && !targetScopes.length, // Methods canAccessOrganization: useCallback( (scope: OrganizationAccessScope) => canAccessOrganization(scope, organization.me), diff --git a/packages/web/app/src/components/target/settings/cdn-access-tokens.tsx b/packages/web/app/src/components/target/settings/cdn-access-tokens.tsx index 40343acbc..bd34eeb07 100644 --- a/packages/web/app/src/components/target/settings/cdn-access-tokens.tsx +++ b/packages/web/app/src/components/target/settings/cdn-access-tokens.tsx @@ -387,7 +387,7 @@ export function CDNAccessTokens(props: { artifacts.
- Learn more about CDN Access Token + Learn more about CDN Access Tokens {canManage && ( @@ -395,7 +395,7 @@ export function CDNAccessTokens(props: { )} @@ -457,22 +457,23 @@ export function CDNAccessTokens(props: { Previous Page ) : null} - + {target.data?.target?.cdnAccessTokens.pageInfo.hasNextPage ? ( + + ) : null} {isCreateCDNAccessTokensModalOpen ? ( {children} @@ -51,9 +52,15 @@ function Item({ ); } -function Header({ children }: { children: ReactNode }): ReactElement { +function Header({ + children, + className, +}: { + children: ReactNode; + className?: string; +}): ReactElement { return ( - + +
{children}
); diff --git a/packages/web/app/src/components/v2/empty-list.tsx b/packages/web/app/src/components/v2/empty-list.tsx index 8dbd3b738..ad9223bd4 100644 --- a/packages/web/app/src/components/v2/empty-list.tsx +++ b/packages/web/app/src/components/v2/empty-list.tsx @@ -10,10 +10,10 @@ export const EmptyList = ({ }: { title: string; description: string; - docsUrl: string | null; + docsUrl?: string; }): ReactElement => { return ( - + {title} {description} - {docsUrl === null ? null : ( - Read about it in the documentation - )} + {docsUrl && Read about it in the documentation} ); }; diff --git a/packages/web/app/src/components/v2/icon.tsx b/packages/web/app/src/components/v2/icon.tsx index b8b7245ff..e3c5c7e71 100644 --- a/packages/web/app/src/components/v2/icon.tsx +++ b/packages/web/app/src/components/v2/icon.tsx @@ -28,6 +28,15 @@ export const GraphQLIcon = ({ className }: IconProps): ReactElement => ( ); +export const SaveIcon = ({ className }: IconProps): ReactElement => ( + + + + + + +); + export const TrendingUpIcon = ({ className }: IconProps): ReactElement => ( ( ); -export const Link2Icon = ({ className }: IconProps): ReactElement => ( - - - - -); - export const HiveLogo = ({ className }: IconProps): ReactElement => ( (null); diff --git a/packages/web/app/src/components/v2/modals/create-collection.tsx b/packages/web/app/src/components/v2/modals/create-collection.tsx new file mode 100644 index 000000000..0eb2031a0 --- /dev/null +++ b/packages/web/app/src/components/v2/modals/create-collection.tsx @@ -0,0 +1,270 @@ +import { ReactElement, useEffect } from 'react'; +import { useFormik } from 'formik'; +import { useMutation, useQuery } from 'urql'; +import * as Yup from 'yup'; +import { Button, Heading, Input, Modal } from '@/components/v2'; +import { graphql } from '@/gql'; +import { TargetDocument } from '@/graphql'; +import { useRouteSelector } from '@/lib/hooks'; + +const CollectionQuery = graphql(` + query Collection($selector: TargetSelectorInput!, $id: ID!) { + target(selector: $selector) { + id + documentCollection(id: $id) { + id + name + description + } + } + } +`); + +export const CreateCollectionMutation = graphql(` + mutation CreateCollection( + $selector: TargetSelectorInput! + $input: CreateDocumentCollectionInput! + ) { + createDocumentCollection(selector: $selector, input: $input) { + error { + message + } + ok { + updatedTarget { + id + documentCollections { + edges { + cursor + node { + id + name + } + } + } + } + collection { + id + name + description + operations(first: 100) { + edges { + cursor + node { + id + name + } + cursor + } + } + } + } + } + } +`); + +const UpdateCollectionMutation = graphql(` + mutation UpdateCollection( + $selector: TargetSelectorInput! + $input: UpdateDocumentCollectionInput! + ) { + updateDocumentCollection(selector: $selector, input: $input) { + error { + message + } + ok { + updatedTarget { + id + documentCollections { + edges { + node { + id + name + } + cursor + } + } + } + collection { + id + name + description + operations(first: 100) { + edges { + cursor + node { + id + name + } + } + } + } + } + } + } +`); + +export function CreateCollectionModal({ + isOpen, + toggleModalOpen, + collectionId, +}: { + isOpen: boolean; + toggleModalOpen: () => void; + collectionId?: string; +}): ReactElement { + const router = useRouteSelector(); + const [mutationCreate, mutateCreate] = useMutation(CreateCollectionMutation); + const [mutationUpdate, mutateUpdate] = useMutation(UpdateCollectionMutation); + + const [result] = useQuery({ + query: TargetDocument, + variables: { + targetId: router.targetId, + organizationId: router.organizationId, + projectId: router.projectId, + }, + }); + + const [{ data, error: collectionError, fetching: loadingCollection }] = useQuery({ + query: CollectionQuery, + variables: { + id: collectionId!, + selector: { + target: router.targetId, + organization: router.organizationId, + project: router.projectId, + }, + }, + pause: !collectionId, + }); + + const error = mutationCreate.error || result.error || collectionError || mutationUpdate.error; + const fetching = loadingCollection || result.fetching; + + useEffect(() => { + if (!collectionId) { + resetForm(); + } else if (data) { + const { documentCollection } = data.target!; + + if (documentCollection) { + void setValues({ + name: documentCollection.name, + description: documentCollection.description || '', + }); + } + } + }, [data, collectionId]); + + const { + handleSubmit, + values, + handleChange, + errors, + touched, + isSubmitting, + setValues, + resetForm, + } = useFormik({ + initialValues: { + name: '', + description: '', + }, + validationSchema: Yup.object().shape({ + name: Yup.string().required(), + description: Yup.string(), + }), + async onSubmit(values) { + const { error } = collectionId + ? await mutateUpdate({ + selector: { + target: router.targetId, + organization: router.organizationId, + project: router.projectId, + }, + input: { + collectionId, + + name: values.name, + description: values.description, + }, + }) + : await mutateCreate({ + selector: { + target: router.targetId, + organization: router.organizationId, + project: router.projectId, + }, + input: values, + }); + if (!error) { + resetForm(); + toggleModalOpen(); + } + }, + }); + + return ( + + {!fetching && ( +
+ + {collectionId ? 'Update' : 'Create'} Shared Collection + + +
+ + + {touched.name && errors.name && ( +
{errors.name}
+ )} +
+ +
+ + + + {touched.description && errors.description && ( +
{errors.description}
+ )} +
+ + {error &&
{error.message}
} + +
+ + +
+ + )} + + ); +} diff --git a/packages/web/app/src/components/v2/modals/create-operation.tsx b/packages/web/app/src/components/v2/modals/create-operation.tsx new file mode 100644 index 000000000..83d195bb9 --- /dev/null +++ b/packages/web/app/src/components/v2/modals/create-operation.tsx @@ -0,0 +1,201 @@ +import { ReactElement } from 'react'; +import { useFormik } from 'formik'; +import { useMutation } from 'urql'; +import * as Yup from 'yup'; +import { Button, Heading, Input, Modal, Select } from '@/components/v2'; +import { graphql } from '@/gql'; +import { useRouteSelector } from '@/lib/hooks'; +import { useCollections } from '@/lib/hooks/use-collections'; +import { useEditorContext } from '@graphiql/react'; + +const CreateOperationMutation = graphql(` + mutation CreateOperation( + $selector: TargetSelectorInput! + $input: CreateDocumentCollectionOperationInput! + ) { + createOperationInDocumentCollection(selector: $selector, input: $input) { + error { + message + } + ok { + operation { + id + name + } + updatedTarget { + id + documentCollections { + edges { + cursor + node { + id + operations { + edges { + node { + id + } + cursor + } + } + } + } + } + } + } + } + } +`); + +export type CreateOperationMutationType = typeof CreateOperationMutation; + +export function CreateOperationModal({ + isOpen, + toggleModalOpen, +}: { + isOpen: boolean; + toggleModalOpen: () => void; +}): ReactElement { + const router = useRouteSelector(); + const [mutationCreate, mutateCreate] = useMutation(CreateOperationMutation); + + const { collections, loading } = useCollections(); + + const { queryEditor, variableEditor, headerEditor } = useEditorContext({ + nonNull: true, + }); + + const { error } = mutationCreate; + + const fetching = loading; + + const { + handleSubmit, + values, + handleChange, + handleBlur, + errors, + isValid, + touched, + isSubmitting, + resetForm, + } = useFormik({ + initialValues: { + name: '', + collectionId: '', + }, + validationSchema: Yup.object().shape({ + name: Yup.string().min(3).required(), + collectionId: Yup.string().required('Collection is a required field'), + }), + async onSubmit(values) { + const response = await mutateCreate({ + selector: { + target: router.targetId, + organization: router.organizationId, + project: router.projectId, + }, + input: { + name: values.name, + collectionId: values.collectionId, + query: queryEditor?.getValue(), + variables: variableEditor?.getValue(), + headers: headerEditor?.getValue(), + }, + }); + const result = response.data; + const error = response.error || response.data?.createOperationInDocumentCollection.error; + + if (!error) { + if (result) { + const data = result.createOperationInDocumentCollection; + + void router.push({ + query: { + ...router.query, + operation: data.ok?.operation.id, + }, + }); + } + + resetForm(); + toggleModalOpen(); + } + }, + }); + + return ( + + {!fetching && ( +
+ Create Operation + +
+ + + {touched.name && errors.name && ( +
{errors.name}
+ )} +
+ +
+ +