diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 52cc3a8b4..f58c2e11b 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -67,4 +67,4 @@ jobs: --maxDepth=20 \ --maxAliasCount=20 \ --maxDirectiveCount=20 \ - --maxTokenCount=800 + --maxTokenCount=850 diff --git a/codegen.cjs b/codegen.cjs index a32a6357e..5bae4e006 100644 --- a/codegen.cjs +++ b/codegen.cjs @@ -35,6 +35,7 @@ const config = { scalars: { DateTime: 'string', SafeInt: 'number', + ID: 'string', }, mappers: { SchemaChangeConnection: diff --git a/cypress/e2e/app.cy.ts b/cypress/e2e/app.cy.ts index 86a5f219f..9394030fc 100644 --- a/cypress/e2e/app.cy.ts +++ b/cypress/e2e/app.cy.ts @@ -27,8 +27,14 @@ describe('basic user flow', () => { it('should log in and log out', () => { cy.login(user); - cy.get('header').find('button[aria-haspopup="menu"]').click(); - cy.get('a[href="/logout"]').click(); + + cy.get('input[name="name"]').type('Bubatzbieber'); + cy.get('button[type="submit"]').click(); + + // Logout + cy.get('[data-cy="user-menu-trigger"]').click(); + cy.get('[data-cy="user-menu-logout"]').click(); + cy.url().should('include', '/auth?redirectToPath=%2F'); }); }); @@ -38,7 +44,7 @@ it('create organization', () => { cy.signup(user); cy.get('input[name="name"]').type('Bubatzbieber'); cy.get('button[type="submit"]').click(); - cy.get('h1').contains('Bubatzbieber'); + cy.get('[data-cy="organization-picker-current"]').contains('Bubatzbieber'); }); it('oidc login for organization', () => { @@ -47,7 +53,7 @@ it('oidc login for organization', () => { cy.signup(organizationAdminUser); cy.get('input[name="name"]').type('Bubatzbieber'); cy.get('button[type="submit"]').click(); - cy.get('h1').contains('Bubatzbieber'); + cy.get('[data-cy="organization-picker-current"]').contains('Bubatzbieber'); cy.get('a[href$="/view/settings"]').click(); cy.get('a[href$="/view/settings#create-oidc-integration"]').click(); cy.get('input[id="tokenEndpoint"]').type('http://oidc-server-mock:80/connect/token'); @@ -73,6 +79,6 @@ it('oidc login for organization', () => { cy.get('input[id="Input_Password"]').type('password'); cy.get('button[value="login"]').click(); - cy.get('h1').contains('Bubatzbieber'); + cy.get('[data-cy="organization-picker-current"]').contains('Bubatzbieber'); }); }); diff --git a/deployment/services/cloudflare-security.ts b/deployment/services/cloudflare-security.ts index 4db1dccdf..703d13d91 100644 --- a/deployment/services/cloudflare-security.ts +++ b/deployment/services/cloudflare-security.ts @@ -58,7 +58,7 @@ export function deployCloudFlareSecurityTransform(options: { connect-src 'self' {DYNAMIC_HOST_PLACEHOLDER} ${cspHosts}; media-src ${crispHost}; style-src-elem 'self' 'unsafe-inline' ${monacoCdnDynamicBasePath} ${monacoCdnStaticBasePath} fonts.googleapis.com ${crispHost}; - font-src 'self' fonts.gstatic.com ${monacoCdnDynamicBasePath} ${monacoCdnStaticBasePath} ${crispHost}; + font-src 'self' fonts.gstatic.com rsms.me ${monacoCdnDynamicBasePath} ${monacoCdnStaticBasePath} ${crispHost}; img-src * 'self' data: https: https://image.crisp.chat https://storage.crisp.chat ${gtmHost} ${crispHost}; `; diff --git a/package.json b/package.json index 82ce83490..4675cf985 100644 --- a/package.json +++ b/package.json @@ -51,14 +51,14 @@ "devDependencies": { "@changesets/changelog-github": "0.4.8", "@changesets/cli": "2.26.1", - "@graphql-codegen/add": "4.0.1", - "@graphql-codegen/cli": "3.3.1", - "@graphql-codegen/client-preset": "3.0.1", - "@graphql-codegen/graphql-modules-preset": "3.1.3", - "@graphql-codegen/typed-document-node": "4.0.1", - "@graphql-codegen/typescript": "3.0.4", - "@graphql-codegen/typescript-operations": "3.0.4", - "@graphql-codegen/typescript-resolvers": "3.2.1", + "@graphql-codegen/add": "5.0.0", + "@graphql-codegen/cli": "4.0.1", + "@graphql-codegen/client-preset": "4.0.0", + "@graphql-codegen/graphql-modules-preset": "4.0.0", + "@graphql-codegen/typed-document-node": "5.0.0", + "@graphql-codegen/typescript": "4.0.0", + "@graphql-codegen/typescript-operations": "4.0.0", + "@graphql-codegen/typescript-resolvers": "4.0.0", "@graphql-inspector/cli": "3.4.19", "@manypkg/get-packages": "2.2.0", "@next/eslint-plugin-next": "13.3.1", diff --git a/packages/services/api/src/modules/alerts/module.graphql.ts b/packages/services/api/src/modules/alerts/module.graphql.ts index a867cd7c0..c4a304985 100644 --- a/packages/services/api/src/modules/alerts/module.graphql.ts +++ b/packages/services/api/src/modules/alerts/module.graphql.ts @@ -3,14 +3,14 @@ import { gql } from 'graphql-modules'; export default gql` extend type Mutation { addAlertChannel(input: AddAlertChannelInput!): AddAlertChannelResult! - deleteAlertChannels(input: DeleteAlertChannelsInput!): [AlertChannel!]! - addAlert(input: AddAlertInput!): Alert! - deleteAlerts(input: DeleteAlertsInput!): [Alert!]! + deleteAlertChannels(input: DeleteAlertChannelsInput!): DeleteAlertChannelsResult! + addAlert(input: AddAlertInput!): AddAlertResult! + deleteAlerts(input: DeleteAlertsInput!): DeleteAlertsResult! } - extend type Query { - alertChannels(selector: ProjectSelectorInput!): [AlertChannel!]! - alerts(selector: ProjectSelectorInput!): [Alert!]! + extend type Project { + alertChannels: [AlertChannel!]! + alerts: [Alert!]! } enum AlertChannelType { @@ -22,12 +22,53 @@ export default gql` SCHEMA_CHANGE_NOTIFICATIONS } + type DeleteAlertChannelsResult { + ok: DeleteAlertChannelsOk + error: DeleteAlertChannelsError + } + + type DeleteAlertChannelsOk { + updatedProject: Project! + } + + type DeleteAlertChannelsError implements Error { + message: String! + } + + type AddAlertResult { + ok: AddAlertOk + error: AddAlertError + } + + type AddAlertOk { + updatedProject: Project! + addedAlert: Alert! + } + + type AddAlertError implements Error { + message: String! + } + + type DeleteAlertsResult { + ok: DeleteAlertsOk + error: DeleteAlertsError + } + + type DeleteAlertsOk { + updatedProject: Project! + } + + type DeleteAlertsError implements Error { + message: String! + } + type AddAlertChannelResult { ok: AddAlertChannelOk error: AddAlertChannelError } type AddAlertChannelOk { + updatedProject: Project! addedAlertChannel: AlertChannel! } diff --git a/packages/services/api/src/modules/alerts/resolvers.ts b/packages/services/api/src/modules/alerts/resolvers.ts index 9132a07d5..765f00a6c 100644 --- a/packages/services/api/src/modules/alerts/resolvers.ts +++ b/packages/services/api/src/modules/alerts/resolvers.ts @@ -1,4 +1,6 @@ import { z } from 'zod'; +import { HiveError } from '../../shared/errors'; +import { ProjectManager } from '../project/providers/project-manager'; import { IdTranslator } from '../shared/providers/id-translator'; import { TargetManager } from '../target/providers/target-manager'; import type { AlertsModule } from './__generated__/types'; @@ -33,16 +35,20 @@ export const resolvers: AlertsModule.Resolvers = { } const translator = injector.get(IdTranslator); - const [organization, project] = await Promise.all([ + const [organizationId, projectId] = await Promise.all([ translator.translateOrganizationId(input), translator.translateProjectId(input), ]); return { ok: { + updatedProject: injector.get(ProjectManager).getProject({ + organization: organizationId, + project: projectId, + }), addedAlertChannel: injector.get(AlertsManager).addChannel({ - organization, - project, + organization: organizationId, + project: projectId, name: input.name, type: input.type, slack: input.slack, @@ -53,70 +59,128 @@ export const resolvers: AlertsModule.Resolvers = { }, async deleteAlertChannels(_, { input }, { injector }) { const translator = injector.get(IdTranslator); - const [organization, project] = await Promise.all([ + const [organizationId, projectId] = await Promise.all([ translator.translateOrganizationId(input), translator.translateProjectId(input), ]); - return injector.get(AlertsManager).deleteChannels({ - organization, - project, - channels: input.channels, + const project = await injector.get(ProjectManager).getProject({ + organization: organizationId, + project: projectId, }); + + try { + await injector.get(AlertsManager).deleteChannels({ + organization: organizationId, + project: projectId, + channels: input.channels, + }); + + return { + ok: { + updatedProject: project, + }, + }; + } catch (error) { + if (error instanceof HiveError) { + return { + error: { + message: error.message, + }, + }; + } + + throw error; + } }, async addAlert(_, { input }, { injector }) { const translator = injector.get(IdTranslator); - const [organization, project, target] = await Promise.all([ + const [organizationId, projectId, targetId] = await Promise.all([ translator.translateOrganizationId(input), translator.translateProjectId(input), translator.translateTargetId(input), ]); - return injector.get(AlertsManager).addAlert({ - organization, - project, - target, - channel: input.channel, - type: input.type, + const project = await injector.get(ProjectManager).getProject({ + organization: organizationId, + project: projectId, }); + + try { + const alert = await injector.get(AlertsManager).addAlert({ + organization: organizationId, + project: projectId, + target: targetId, + channel: input.channel, + type: input.type, + }); + + return { + ok: { + addedAlert: alert, + updatedProject: project, + }, + }; + } catch (error) { + if (error instanceof HiveError) { + return { + error: { + message: error.message, + }, + }; + } + + throw error; + } }, async deleteAlerts(_, { input }, { injector }) { const translator = injector.get(IdTranslator); - const [organization, project] = await Promise.all([ + const [organizationId, projectId] = await Promise.all([ translator.translateOrganizationId(input), translator.translateProjectId(input), ]); - return injector.get(AlertsManager).deleteAlerts({ - organization, - project, - alerts: input.alerts, + const project = await injector.get(ProjectManager).getProject({ + organization: organizationId, + project: projectId, }); + + try { + await injector.get(AlertsManager).deleteAlerts({ + organization: organizationId, + project: projectId, + alerts: input.alerts, + }); + + return { + ok: { + updatedProject: project, + }, + }; + } catch (error) { + if (error instanceof HiveError) { + return { + error: { + message: error.message, + }, + }; + } + + throw error; + } }, }, - Query: { - async alerts(_, { selector }, { injector }) { - const translator = injector.get(IdTranslator); - const [organization, project] = await Promise.all([ - translator.translateOrganizationId(selector), - translator.translateProjectId(selector), - ]); - + Project: { + async alerts(project, _, { injector }) { return injector.get(AlertsManager).getAlerts({ - organization, - project, + organization: project.orgId, + project: project.id, }); }, - async alertChannels(_, { selector }, { injector }) { - const translator = injector.get(IdTranslator); - const [organization, project] = await Promise.all([ - translator.translateOrganizationId(selector), - translator.translateProjectId(selector), - ]); - + async alertChannels(project, _, { injector }) { return injector.get(AlertsManager).getChannels({ - organization, - project, + organization: project.orgId, + project: project.id, }); }, }, diff --git a/packages/services/api/src/modules/collection/providers/collection.provider.ts b/packages/services/api/src/modules/collection/providers/collection.provider.ts index bd092d2c1..e84e4a14d 100644 --- a/packages/services/api/src/modules/collection/providers/collection.provider.ts +++ b/packages/services/api/src/modules/collection/providers/collection.provider.ts @@ -1,13 +1,8 @@ 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'; +import type { CollectionModule } from './../__generated__/types'; @Injectable({ global: true, @@ -46,7 +41,10 @@ export class CollectionProvider { async createCollection( targetId: string, - { name, description }: Pick, + { + name, + description, + }: Pick, ) { const currentUser = await this.authManager.getCurrentUser(); @@ -62,7 +60,7 @@ export class CollectionProvider { return this.storage.deleteDocumentCollection({ documentCollectionId: id }); } - async createOperation(input: CreateDocumentCollectionOperationInput) { + async createOperation(input: CollectionModule.CreateDocumentCollectionOperationInput) { const currentUser = await this.authManager.getCurrentUser(); return this.storage.createDocumentCollectionDocument({ @@ -75,7 +73,7 @@ export class CollectionProvider { }); } - updateOperation(input: UpdateDocumentCollectionOperationInput) { + updateOperation(input: CollectionModule.UpdateDocumentCollectionOperationInput) { return this.storage.updateDocumentCollectionDocument({ documentCollectionDocumentId: input.operationId, title: input.name, @@ -85,7 +83,7 @@ export class CollectionProvider { }); } - updateCollection(input: UpdateDocumentCollectionInput) { + updateCollection(input: CollectionModule.UpdateDocumentCollectionInput) { return this.storage.updateDocumentCollection({ documentCollectionId: input.collectionId, description: input.description || null, diff --git a/packages/services/api/src/modules/operations/module.graphql.ts b/packages/services/api/src/modules/operations/module.graphql.ts index 65a7c807e..b10e86156 100644 --- a/packages/services/api/src/modules/operations/module.graphql.ts +++ b/packages/services/api/src/modules/operations/module.graphql.ts @@ -164,4 +164,12 @@ export default gql` extend type OrganizationGetStarted { reportingOperations: Boolean! } + + extend type Target { + requestsOverTime(resolution: Int!, period: DateRangeInput!): [RequestsOverTime!]! + } + + extend type Project { + requestsOverTime(resolution: Int!, period: DateRangeInput!): [RequestsOverTime!]! + } `; diff --git a/packages/services/api/src/modules/operations/providers/clickhouse-client.ts b/packages/services/api/src/modules/operations/providers/clickhouse-client.ts index 49fd41a5d..89ced34e4 100644 --- a/packages/services/api/src/modules/operations/providers/clickhouse-client.ts +++ b/packages/services/api/src/modules/operations/providers/clickhouse-client.ts @@ -74,7 +74,10 @@ export class ClickHouse { const startedAt = Date.now(); const endpoint = `${this.config.protocol ?? 'https'}://${this.config.host}:${this.config.port}`; - this.logger.debug(`Executing ClickHouse Query: %s`, query); + this.logger.debug( + `Executing ClickHouse Query: %s`, + query.sql.replace(/\n/g, ' ').replace(/\s+/g, ' '), + ); const response = await this.httpClient .post>( 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 de4f9b395..5e142c818 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -7,7 +7,11 @@ import { cache } from '../../../shared/helpers'; import { AuthManager } from '../../auth/providers/auth-manager'; import { TargetAccessScope } from '../../auth/providers/target-access'; import { Logger } from '../../shared/providers/logger'; -import type { OrganizationSelector, TargetSelector } from '../../shared/providers/storage'; +import type { + OrganizationSelector, + ProjectSelector, + TargetSelector, +} from '../../shared/providers/storage'; import { Storage } from '../../shared/providers/storage'; import { OperationsReader } from './operations-reader'; @@ -58,6 +62,20 @@ interface ReadFieldStatsOutput { }) export class OperationsManager { private logger: Logger; + private requestsOverTimeOfTargetsLoader: DataLoader< + { + targets: readonly string[]; + period: DateRange; + resolution: number; + }, + { + [target: string]: { + date: any; + value: number; + }[]; + }, + string + >; constructor( logger: Logger, @@ -66,6 +84,22 @@ export class OperationsManager { private storage: Storage, ) { this.logger = logger.child({ source: 'OperationsManager' }); + + this.requestsOverTimeOfTargetsLoader = new DataLoader( + async selectors => { + const results = await this.reader.requestsOverTimeOfTargets(selectors); + + return results; + }, + { + cacheKeyFn(selector) { + return `${selector.period.from.toISOString()};${selector.period.to.toISOString()};${ + selector.resolution + };${selector.targets.join(',')}}`; + }, + batchScheduleFn: callback => setTimeout(callback, 100), + }, + ); } async getOperationBody({ @@ -299,6 +333,109 @@ export class OperationsManager { }); } + async readRequestsOverTimeOfProject({ + period, + resolution, + organization, + project, + }: { + period: DateRange; + resolution: number; + } & ProjectSelector): Promise< + Array<{ + date: any; + value: number; + }> + > { + this.logger.debug( + 'Reading requests over time of project (period=%o, resolution=%s, project=%s)', + period, + resolution, + project, + ); + const targets = await this.storage.getTargetIdsOfProject({ + organization, + project, + }); + await Promise.all( + targets.map(target => + this.authManager.ensureTargetAccess({ + organization, + project, + target, + scope: TargetAccessScope.REGISTRY_READ, + }), + ), + ); + + const groups = await this.requestsOverTimeOfTargetsLoader.load({ + targets, + period, + resolution, + }); + + // Because we get data for each target separately, we need to sum(targets) per date + // All dates are the same for all targets as we use `toStartOfInterval` function of clickhouse under the hood, + // with the same interval value. + // The `toStartOfInterval` function gives back the same output for data time points within the same interval window. + // Let's say that interval is 10 minutes. + // `2023-21-10 21:37` and `2023-21-10 21:38` are within 21:30 and 21:40 window, so the output will be `2023-21-10 21:30`. + const dataPointsAggregator = new Map(); + + for (const target in groups) { + const targetDataPoints = groups[target]; + + for (const dataPoint of targetDataPoints) { + const existing = dataPointsAggregator.get(dataPoint.date); + + if (existing == null) { + dataPointsAggregator.set(dataPoint.date, dataPoint.value); + } else { + dataPointsAggregator.set(dataPoint.date, existing + dataPoint.value); + } + } + } + + return Array.from(dataPointsAggregator.entries()) + .map(([date, value]) => ({ date, value })) + .sort((a, b) => a.date - b.date); + } + + async readRequestsOverTimeOfTargets({ + period, + resolution, + organization, + project, + targets, + }: { + period: DateRange; + resolution: number; + targets: string[]; + } & ProjectSelector) { + this.logger.debug( + 'Reading requests over time of targets (period=%o, resolution=%s, targets=%s)', + period, + resolution, + targets.join(';'), + ); + await Promise.all( + targets.map(target => + this.authManager.ensureTargetAccess({ + organization, + project, + target, + scope: TargetAccessScope.REGISTRY_READ, + }), + ), + ); + + return this.requestsOverTimeOfTargetsLoader.load({ + targets, + period, + resolution, + }); + } + async readRequestsOverTime({ period, resolution, diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index 9c6fc1840..62eb9d274 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -834,6 +834,188 @@ export class OperationsReader { }); } + @sentry('OperationsReader.requestsOverTimeOfTargets') + async requestsOverTimeOfTargets( + selectors: ReadonlyArray<{ + targets: readonly string[]; + period: DateRange; + resolution: number; + }>, + ) { + const aggregationMap = new Map< + string, + { + targets: string[]; + period: DateRange; + resolution: number; + } + >(); + + const makeKey = (selector: { period: DateRange; resolution: number }) => + `${selector.period.from.toISOString()};${selector.period.to.toISOString()};${ + selector.resolution + }`; + + const subSelectors = selectors + .map(selector => + selector.targets.map(target => ({ + target, + period: selector.period, + resolution: selector.resolution, + })), + ) + .flat(1); + + // The idea here is to make the least possible number of queries to ClickHouse + // by fetching all data points of the same target, period and resolution. + for (const selector of subSelectors) { + const key = makeKey(selector); + const value = aggregationMap.get(key); + + if (!value) { + aggregationMap.set(key, { + targets: [selector.target], + period: selector.period, + resolution: selector.resolution, + }); + } else { + value.targets.push(selector.target); + } + } + + const aggregationResultMap = new Map< + string, + Promise< + readonly { + date: number; + total: number; + target: string; + }[] + > + >(); + + for (const [key, { targets, period, resolution }] of aggregationMap) { + aggregationResultMap.set( + key, + this.clickHouse + .query<{ + date: number; + target: string; + total: number; + }>( + pickQueryByPeriod( + { + daily: { + query: sql` + SELECT + multiply( + toUnixTimestamp( + toStartOfInterval(timestamp, INTERVAL ${this.clickHouse.translateWindow( + calculateTimeWindow({ period, resolution }), + )}, 'UTC'), + 'UTC'), + 1000) as date, + sum(total) as total, + target + FROM operations_daily + ${this.createFilter({ target: targets, period })} + GROUP BY date, target + ORDER BY date + `, + queryId: 'targets_count_over_time_daily', + timeout: 15_000, + }, + hourly: { + query: sql` + SELECT + multiply( + toUnixTimestamp( + toStartOfInterval(timestamp, INTERVAL ${this.clickHouse.translateWindow( + calculateTimeWindow({ period, resolution }), + )}, 'UTC'), + 'UTC'), + 1000) as date, + sum(total) as total, + target + FROM operations_hourly + ${this.createFilter({ target: targets, period })} + GROUP BY date, target + ORDER BY date + `, + queryId: 'targets_count_over_time_hourly', + timeout: 15_000, + }, + regular: { + query: sql` + SELECT + multiply( + toUnixTimestamp( + toStartOfInterval(timestamp, INTERVAL ${this.clickHouse.translateWindow( + calculateTimeWindow({ period, resolution }), + )}, 'UTC'), + 'UTC'), + 1000) as date, + count(*) as total, + target + FROM operations + ${this.createFilter({ target: targets, period })} + GROUP BY date, target + ORDER BY date + `, + queryId: 'targets_count_over_time_regular', + timeout: 15_000, + }, + }, + period, + resolution, + ), + ) + .then(result => result.data), + ); + } + + // Because the function is used in a DataLoader, + // it has to return a list of promises matching the order of selectors. + return Promise.all( + selectors.map(async selector => { + const key = makeKey(selector); + + const queryPromise = aggregationResultMap.get(key); + + if (!queryPromise) { + throw new Error(`Could not find data for ${key} selector`); + } + + const rows = await queryPromise; + + const resultsPerTarget: { + [target: string]: Array<{ + date: any; + value: number; + }>; + } = {}; + + for (const row of rows) { + if (!selector.targets.includes(row.target)) { + // it's not relevant to the current selector + continue; + } + + if (!resultsPerTarget[row.target]) { + resultsPerTarget[row.target] = []; + } + + resultsPerTarget[row.target].push({ + date: ensureNumber(row.date) as any, + value: ensureNumber(row.total), + }); + } + + return resultsPerTarget; + }), + ); + } + @sentry('OperationsReader.requestsOverTime') async requestsOverTime({ target, @@ -1261,7 +1443,9 @@ export class OperationsReader { >(); const makeKey = (selector: { target: string; period: DateRange }) => - `${selector.target}-${selector.period.from}-${selector.period.to}`; + `${ + selector.target + }-${selector.period.from.toISOString()}-${selector.period.to.toISOString()}`; // Groups the type names by their target and period // The idea here is to make the least possible number of queries to ClickHouse diff --git a/packages/services/api/src/modules/operations/resolvers.ts b/packages/services/api/src/modules/operations/resolvers.ts index e16f4cb5f..8c33d5a49 100644 --- a/packages/services/api/src/modules/operations/resolvers.ts +++ b/packages/services/api/src/modules/operations/resolvers.ts @@ -329,6 +329,29 @@ export const resolvers: OperationsModule.Resolvers = { }); }, }, + Project: { + async requestsOverTime(project, { resolution, period }, { injector }) { + return injector.get(OperationsManager).readRequestsOverTimeOfProject({ + project: project.id, + organization: project.orgId, + period: parseDateRangeInput(period), + resolution, + }); + }, + }, + Target: { + async requestsOverTime(target, { resolution, period }, { injector }) { + const result = await injector.get(OperationsManager).readRequestsOverTimeOfTargets({ + project: target.projectId, + organization: target.orgId, + targets: [target.id], + period: parseDateRangeInput(period), + resolution, + }); + + return result[target.id] ?? []; + }, + }, }; function transformPercentile(value: number | null): number { diff --git a/packages/services/api/src/modules/project/module.graphql.ts b/packages/services/api/src/modules/project/module.graphql.ts index 903aedba7..4d4038423 100644 --- a/packages/services/api/src/modules/project/module.graphql.ts +++ b/packages/services/api/src/modules/project/module.graphql.ts @@ -48,9 +48,9 @@ export default gql` error: CreateProjectError } type CreateProjectOk { - selector: ProjectSelector! createdProject: Project! createdTargets: [Target!]! + updatedOrganization: Organization! } type CreateProjectInputErrors { diff --git a/packages/services/api/src/modules/project/resolvers.ts b/packages/services/api/src/modules/project/resolvers.ts index 3aa55a82e..e0c2e1aef 100644 --- a/packages/services/api/src/modules/project/resolvers.ts +++ b/packages/services/api/src/modules/project/resolvers.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { ProjectType } from '../../shared/entities'; import { createConnection } from '../../shared/schema'; +import { OrganizationManager } from '../organization/providers/organization-manager'; import { IdTranslator } from '../shared/providers/id-translator'; import { TargetManager } from '../target/providers/target-manager'; import type { ProjectModule } from './__generated__/types'; @@ -55,12 +56,15 @@ export const resolvers: ProjectModule.Resolvers & { ProjectType: any } = { } const translator = injector.get(IdTranslator); - const organization = await translator.translateOrganizationId({ + const organizationId = await translator.translateOrganizationId({ organization: input.organization, }); const project = await injector.get(ProjectManager).createProject({ ...input, - organization, + organization: organizationId, + }); + const organization = await injector.get(OrganizationManager).getOrganization({ + organization: organizationId, }); const targetManager = injector.get(TargetManager); @@ -69,28 +73,25 @@ export const resolvers: ProjectModule.Resolvers & { ProjectType: any } = { targetManager.createTarget({ name: 'production', project: project.id, - organization, + organization: organizationId, }), targetManager.createTarget({ name: 'staging', project: project.id, - organization, + organization: organizationId, }), targetManager.createTarget({ name: 'development', project: project.id, - organization, + organization: organizationId, }), ]); return { ok: { - selector: { - organization: input.organization, - project: project.cleanId, - }, createdProject: project, createdTargets: targets, + updatedOrganization: organization, }, }; }, diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index d0cc5c2d0..6e9980a8c 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -122,6 +122,11 @@ export default gql` extend type Project { externalSchemaComposition: ExternalSchemaComposition registryModel: RegistryModel! + schemaVersionsCount(period: DateRangeInput): Int! + } + + extend type Target { + schemaVersionsCount(period: DateRangeInput): Int! } type EnableExternalSchemaCompositionError implements Error { diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index b0c41ef21..e311bb84d 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { Change } from '@graphql-inspector/core'; import type { SchemaCheck, SchemaCompositionError } from '@hive/storage'; import { RegistryModel } from '../../../__generated__/types'; -import { Orchestrator, ProjectType } from '../../../shared/entities'; +import { DateRange, Orchestrator, ProjectType } from '../../../shared/entities'; import { HiveError } from '../../../shared/errors'; import { atomic, stringifySelector } from '../../../shared/helpers'; import { SchemaVersion } from '../../../shared/mappers'; @@ -438,6 +438,24 @@ export class SchemaManager { await this.storage.updateBaseSchema(selector, newBaseSchema); } + countSchemaVersionsOfProject( + selector: ProjectSelector & { + period: DateRange | null; + }, + ): Promise { + this.logger.debug('Fetching schema versions count of project (selector=%o)', selector); + return this.storage.countSchemaVersionsOfProject(selector); + } + + countSchemaVersionsOfTarget( + selector: TargetSelector & { + period: DateRange | null; + }, + ): Promise { + this.logger.debug('Fetching schema versions count of target (selector=%o)', selector); + return this.storage.countSchemaVersionsOfTarget(selector); + } + completeGetStartedCheck( selector: OrganizationSelector & { step: 'publishingSchema' | 'checkingSchema'; diff --git a/packages/services/api/src/modules/schema/resolvers.ts b/packages/services/api/src/modules/schema/resolvers.ts index eec8de7f4..cd2412a4a 100644 --- a/packages/services/api/src/modules/schema/resolvers.ts +++ b/packages/services/api/src/modules/schema/resolvers.ts @@ -671,6 +671,14 @@ export const resolvers: SchemaModule.Resolvers = { pageInfo: result.pageInfo, }; }, + schemaVersionsCount(target, { period }, { injector }) { + return injector.get(SchemaManager).countSchemaVersionsOfTarget({ + organization: target.orgId, + project: target.projectId, + target: target.id, + period: period ? parseDateRangeInput(period) : null, + }); + }, }, SchemaVersion: { async log(version, _, { injector }) { @@ -1010,6 +1018,13 @@ export const resolvers: SchemaModule.Resolvers = { registryModel(project) { return project.legacyRegistryModel ? 'LEGACY' : 'MODERN'; }, + schemaVersionsCount(project, { period }, { injector }) { + return injector.get(SchemaManager).countSchemaVersionsOfProject({ + organization: project.orgId, + project: project.id, + period: period ? parseDateRangeInput(period) : null, + }); + }, }, SchemaExplorer: { async type(source, { name }, { injector }) { diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 6d9c9c0b9..b8b1e4b5d 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -265,7 +265,7 @@ export interface Storage { getTargets(_: ProjectSelector): Promise; getTargetIdsOfOrganization(_: OrganizationSelector): Promise; - + getTargetIdsOfProject(_: ProjectSelector): Promise; getTargetSettings(_: TargetSelector): Promise; setTargetValidation( @@ -276,6 +276,23 @@ export interface Storage { _: TargetSelector & Omit, ): Promise; + countSchemaVersionsOfProject( + _: ProjectSelector & { + period: { + from: Date; + to: Date; + } | null; + }, + ): Promise; + countSchemaVersionsOfTarget( + _: TargetSelector & { + period: { + from: Date; + to: Date; + } | null; + }, + ): Promise; + hasSchema(_: TargetSelector): Promise; getLatestSchemas( diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index af068dec5..3237064c9 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -1460,6 +1460,15 @@ export async function createStorage(connection: string, maximumPoolSize: number) return results.rows.map(r => r.id); }, + async getTargetIdsOfProject({ project }) { + const results = await pool.query>>( + sql` + SELECT id FROM public.targets WHERE project_id = ${project} + `, + ); + + return results.rows.map(r => r.id); + }, async getTargetSettings({ target, project }) { const row = await pool.one< Pick< @@ -1582,6 +1591,47 @@ export async function createStorage(connection: string, maximumPoolSize: number) }), ).validation; }, + + async countSchemaVersionsOfProject({ project, period }) { + if (period) { + const result = await pool.maybeOne<{ total: number }>(sql` + SELECT COUNT(*) as total FROM public.schema_versions as sv + LEFT JOIN public.targets as t ON (t.id = sv.target_id) + WHERE + t.project_id = ${project} + AND sv.created_at >= ${period.from.toISOString()} + AND sv.created_at < ${period.to.toISOString()} + `); + return result?.total ?? 0; + } + + const result = await pool.maybeOne<{ total: number }>(sql` + SELECT COUNT(*) as total FROM public.schema_versions as sv + LEFT JOIN public.targets as t ON (t.id = sv.target_id) + WHERE t.project_id = ${project} + `); + + return result?.total ?? 0; + }, + async countSchemaVersionsOfTarget({ target, period }) { + if (period) { + const result = await pool.maybeOne<{ total: number }>(sql` + SELECT COUNT(*) as total FROM public.schema_versions + WHERE + target_id = ${target} + AND created_at >= ${period.from.toISOString()} + AND created_at < ${period.to.toISOString()} + `); + return result?.total ?? 0; + } + + const result = await pool.maybeOne<{ total: number }>(sql` + SELECT COUNT(*) as total FROM public.schema_versions WHERE target_id = ${target} + `); + + return result?.total ?? 0; + }, + async hasSchema({ target }) { return pool.exists( sql` diff --git a/packages/web/app/package.json b/packages/web/app/package.json index ecb14483b..10a5794e7 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-radio-group": "1.1.3", "@radix-ui/react-select": "1.2.2", "@radix-ui/react-slider": "1.1.2", + "@radix-ui/react-slot": "1.0.2", "@radix-ui/react-switch": "1.0.3", "@radix-ui/react-tabs": "1.0.4", "@radix-ui/react-toggle-group": "1.0.4", @@ -47,6 +48,7 @@ "@urql/devtools": "2.0.3", "@urql/exchange-graphcache": "6.1.4", "@whatwg-node/fetch": "0.9.6", + "class-variance-authority": "0.6.0", "clsx": "1.2.1", "cookies": "0.8.0", "date-fns": "2.30.0", @@ -61,6 +63,7 @@ "js-cookie": "3.0.5", "json-schema-typed": "8.0.1", "json-schema-yup-transformer": "1.6.12", + "lucide-react": "0.236.0", "monaco-editor": "0.39.0", "monaco-themes": "0.4.4", "next": "13.3.1", @@ -70,6 +73,7 @@ "react-date-range": "1.4.0", "react-dom": "18.2.0", "react-highlight-words": "0.20.0", + "react-icons": "4.9.0", "react-select": "5.7.3", "react-string-replace": "1.1.0", "react-toastify": "9.1.3", @@ -81,6 +85,7 @@ "supertokens-js-override": "0.0.4", "supertokens-node": "13.4.2", "supertokens-web-js": "0.5.0", + "tailwind-merge": "1.13.1", "tslib": "2.5.3", "urql": "4.0.4", "use-debounce": "9.0.4", @@ -118,6 +123,7 @@ "rimraf": "4.4.1", "storybook": "7.0.22", "tailwindcss": "3.3.2", + "tailwindcss-animate": "1.0.6", "tailwindcss-radix": "2.8.0" }, "buildOptions": { diff --git a/packages/web/app/pages/404.tsx b/packages/web/app/pages/404.tsx index 206b03bec..690a07bae 100644 --- a/packages/web/app/pages/404.tsx +++ b/packages/web/app/pages/404.tsx @@ -1,12 +1,11 @@ import Image from 'next/image'; import Router from 'next/router'; -import { Button, Header } from '@/components/v2'; +import { Button } from '@/components/v2'; import ghost from '../public/images/figures/ghost.svg'; const NotFoundPage = () => { return ( <> -
{query.data.target.schemaChecks.edges.map(edge => (
(
  • {labelize(edge.node.message)}
  • @@ -253,7 +245,11 @@ const PolicyBlock = (props: { ); }; -const ActiveSchemaCheck = (): React.ReactElement | null => { +const ActiveSchemaCheck = ({ + schemaCheckId, +}: { + schemaCheckId: string | null; +}): React.ReactElement | null => { const router = useRouteSelector(); const [query] = useQuery({ query: ActiveSchemaCheckQuery, @@ -261,9 +257,9 @@ const ActiveSchemaCheck = (): React.ReactElement | null => { organizationId: router.organizationId, projectId: router.projectId, targetId: router.targetId, - schemaCheckId: router.schemaCheckId ?? '', + schemaCheckId: schemaCheckId ?? '', }, - pause: !router.schemaCheckId, + pause: !schemaCheckId, }); const [view, setView] = useState('details'); @@ -333,18 +329,26 @@ const ActiveSchemaCheck = (): React.ReactElement | null => { }, [query.data?.target?.schemaCheck]); if (!query.data?.target?.schemaCheck) { - return null; + return ( + + ); } const { schemaCheck } = query.data.target; return ( -
    -
    - Check {schemaCheck.id} +
    +
    + Check {schemaCheck.id} + Detailed view of the schema check
    -
    -
    +
    +
    Triggered @@ -378,28 +382,30 @@ const ActiveSchemaCheck = (): React.ReactElement | null => {
    - value && setView(value)} - orientation="vertical" - > - {toggleItems.map(item => ( - - {item.icon} - {item.label} - - ))} - +
    + value && setView(value)} + orientation="vertical" + > + {toggleItems.map(item => ( + + {item.icon} + {item.label} + + ))} + +
    {view === 'details' ? ( <> <> @@ -564,25 +570,90 @@ const SchemaPolicyEditor = (props: { ); }; -function ChecksPage() { - const router = useRouteSelector(); +const ChecksPageQuery = graphql(` + query ChecksPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) { + organizations { + ...TargetLayout_OrganizationConnectionFragment + } + organization(selector: { organization: $organizationId }) { + organization { + ...TargetLayout_CurrentOrganizationFragment + } + } + project(selector: { organization: $organizationId, project: $projectId }) { + ...TargetLayout_CurrentProjectFragment + } + target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { + id + schemaChecks(first: 1) { + edges { + node { + id + } + } + } + } + me { + ...TargetLayout_MeFragment + } + ...TargetLayout_IsCDNEnabledFragment + } +`); + +function ChecksPageContent() { const [paginationVariables, setPaginationVariables] = useState>(() => [ null, ]); + + const router = useRouteSelector(); + const [query] = useQuery({ + query: ChecksPageQuery, + variables: { + organizationId: router.organizationId, + projectId: router.projectId, + targetId: router.targetId, + }, + }); + useNotFoundRedirectOnError(!!query.error); + + if (query.error) { + return null; + } + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const currentProject = query.data?.project; + const organizationConnection = query.data?.organizations; + const isCDNEnabled = query.data; + const { schemaCheckId } = router; + const hasSchemaChecks = !!query.data?.target?.schemaChecks?.edges?.length; + const hasActiveSchemaCheck = !!schemaCheckId; + return ( <> - <TargetLayout value="checks" - className="flex h-full items-stretch gap-x-5" - query={ChecksPageQuery} + className="h-full" + currentOrganization={currentOrganization ?? null} + currentProject={currentProject ?? null} + me={me ?? null} + organizations={organizationConnection ?? null} + isCDNEnabled={isCDNEnabled ?? null} > - {() => { - return ( - <> - <div className="flex flex-col gap-4"> - <Heading>Schema Checks</Heading> - <div className="flex h-0 grow flex-col gap-2.5 overflow-y-auto rounded-md border border-gray-800/50 p-2.5 w-[300px]"> + <div + className={cn( + 'flex w-full h-full', + hasSchemaChecks || hasActiveSchemaCheck ? 'flex-row gap-x-6' : '', + )} + > + <div> + <div className="py-6"> + <Title>Schema Checks + Recently checked schemas. +
    + {hasSchemaChecks ? ( +
    +
    {paginationVariables.map((cursor, index) => (
    - - - ); - }} + ) : ( +
    +
    + {hasActiveSchemaCheck ? 'List is empty' : 'Your schema check list is empty'} +
    + + {hasActiveSchemaCheck + ? 'Check you first schema' + : 'Learn how to check your first schema with Hive CLI'} + +
    + )} +
    + {hasActiveSchemaCheck ? ( +
    + {schemaCheckId ? : null} +
    + ) : hasSchemaChecks ? ( + + ) : null} +
    ); } +function ChecksPage() { + return ( + <> + + + + ); +} + export const getServerSideProps = withSessionProtection(); export default authenticated(ChecksPage); diff --git a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/explorer.tsx b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/explorer.tsx index b983c5f33..0c783f7ce 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/explorer.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/explorer.tsx @@ -1,126 +1,73 @@ import { ReactElement } from 'react'; import { useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; -import { TargetLayout } from '@/components/layouts'; +import { TargetLayout } from '@/components/layouts/target'; import { SchemaExplorerFilter } from '@/components/target/explorer/filter'; import { GraphQLObjectTypeComponent } from '@/components/target/explorer/object-type'; import { SchemaExplorerProvider, useSchemaExplorerContext, } from '@/components/target/explorer/provider'; -import { DataWrapper, Title } from '@/components/v2'; +import { Subtitle, Title } from '@/components/ui/page'; +import { MetaTitle } from '@/components/v2'; import { noSchemaVersion } from '@/components/v2/empty-list'; -import { graphql } from '@/gql'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { useRouteSelector } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { withSessionProtection } from '@/lib/supertokens/guard'; -const SchemaView_SchemaExplorer = graphql(` - query SchemaView_SchemaExplorer( - $organization: ID! - $project: ID! - $target: ID! - $period: DateRangeInput! - ) { - target(selector: { organization: $organization, project: $project, target: $target }) { - __typename - id - latestSchemaVersion { - __typename - id - valid - explorer(usage: { period: $period }) { - query { - ...GraphQLObjectTypeComponent_TypeFragment - } - mutation { - ...GraphQLObjectTypeComponent_TypeFragment - } - subscription { - ...GraphQLObjectTypeComponent_TypeFragment - } - } - } +const ExplorerPage_SchemaExplorerFragment = graphql(` + fragment ExplorerPage_SchemaExplorerFragment on SchemaExplorer { + query { + ...GraphQLObjectTypeComponent_TypeFragment } - operationsStats( - selector: { organization: $organization, project: $project, target: $target, period: $period } - ) { - totalRequests + mutation { + ...GraphQLObjectTypeComponent_TypeFragment + } + subscription { + ...GraphQLObjectTypeComponent_TypeFragment } } `); -function SchemaView({ - organizationCleanId, - projectCleanId, - targetCleanId, -}: { - organizationCleanId: string; - projectCleanId: string; - targetCleanId: string; -}): ReactElement | null { - const { period } = useSchemaExplorerContext(); - const [query] = useQuery({ - query: SchemaView_SchemaExplorer, - variables: { - organization: organizationCleanId, - project: projectCleanId, - target: targetCleanId, - period, - }, - requestPolicy: 'cache-first', - }); +function SchemaView(props: { + explorer: FragmentType; + totalRequests: number; +}) { + const { query, mutation, subscription } = useFragment( + ExplorerPage_SchemaExplorerFragment, + props.explorer, + ); + const { totalRequests } = props; return ( - - {({ data }) => { - if (!data.target?.latestSchemaVersion) { - return noSchemaVersion; - } - - const { query, mutation, subscription } = data.target.latestSchemaVersion.explorer; - const { totalRequests } = data.operationsStats; - - return ( - <> -
    -
    The latest published schema.
    -
    -
    - - {query ? ( - - ) : null} - {mutation ? ( - - ) : null} - {subscription ? ( - - ) : null} -
    - - ); - }} -
    +
    + {query ? ( + + ) : null} + {mutation ? ( + + ) : null} + {subscription ? ( + + ) : null} +
    ); } const TargetExplorerPageQuery = graphql(` - query TargetExplorerPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) { + query TargetExplorerPageQuery( + $organizationId: ID! + $projectId: ID! + $targetId: ID! + $period: DateRangeInput! + ) { + organizations { + ...TargetLayout_OrganizationConnectionFragment + } organization(selector: { organization: $organizationId }) { organization { - ...TargetLayout_OrganizationFragment + ...TargetLayout_CurrentOrganizationFragment rateLimit { retentionInDays } @@ -128,38 +75,111 @@ const TargetExplorerPageQuery = graphql(` } } project(selector: { organization: $organizationId, project: $projectId }) { - ...TargetLayout_ProjectFragment + ...TargetLayout_CurrentProjectFragment cleanId } - targets(selector: { organization: $organizationId, project: $projectId }) { - ...TargetLayout_TargetConnectionFragment - } target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { cleanId + latestSchemaVersion { + __typename + id + valid + explorer(usage: { period: $period }) { + ...ExplorerPage_SchemaExplorerFragment + } + } + } + operationsStats( + selector: { + organization: $organizationId + project: $projectId + target: $targetId + period: $period + } + ) { + totalRequests + } + me { + ...TargetLayout_MeFragment } ...TargetLayout_IsCDNEnabledFragment } `); +function ExplorerPageContent() { + const router = useRouteSelector(); + const { period, dataRetentionInDays, setDataRetentionInDays } = useSchemaExplorerContext(); + const [query] = useQuery({ + query: TargetExplorerPageQuery, + variables: { + organizationId: router.organizationId, + projectId: router.projectId, + targetId: router.targetId, + period, + }, + }); + useNotFoundRedirectOnError(!!query.error); + + if (query.error) { + return null; + } + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const currentProject = query.data?.project; + const currentTarget = query.data?.target; + const organizationConnection = query.data?.organizations; + const isCDNEnabled = query.data; + const explorer = currentTarget?.latestSchemaVersion?.explorer; + const latestSchemaVersion = currentTarget?.latestSchemaVersion; + + const retentionInDays = currentOrganization?.rateLimit.retentionInDays; + if (typeof retentionInDays === 'number' && dataRetentionInDays !== retentionInDays) { + setDataRetentionInDays(retentionInDays); + } + + return ( + +
    +
    + Explore + Insights from the latest version. +
    + {latestSchemaVersion ? ( + + ) : null} +
    + {latestSchemaVersion && explorer ? ( + + ) : ( + noSchemaVersion + )} +
    + ); +} + function ExplorerPage(): ReactElement { return ( <> - - <TargetLayout value="explorer" query={TargetExplorerPageQuery}> - {props => - props.organization && props.project && props.target ? ( - <SchemaExplorerProvider - dataRetentionInDays={props.organization.organization.rateLimit.retentionInDays} - > - <SchemaView - organizationCleanId={props.organization.organization.cleanId} - projectCleanId={props.project.cleanId} - targetCleanId={props.target.cleanId} - /> - </SchemaExplorerProvider> - ) : null - } - </TargetLayout> + <MetaTitle title="Schema Explorer" /> + <SchemaExplorerProvider> + <ExplorerPageContent /> + </SchemaExplorerProvider> </> ); } diff --git a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/explorer/[typename].tsx b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/explorer/[typename].tsx index e8a9e39e3..d9bbc241c 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/explorer/[typename].tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/explorer/[typename].tsx @@ -1,7 +1,6 @@ -import { ReactElement } from 'react'; import { useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; -import { TargetLayout } from '@/components/layouts'; +import { TargetLayout } from '@/components/layouts/target'; import { GraphQLEnumTypeComponent } from '@/components/target/explorer/enum-type'; import { SchemaExplorerFilter } from '@/components/target/explorer/filter'; import { GraphQLInputObjectTypeComponent } from '@/components/target/explorer/input-object-type'; @@ -13,42 +12,14 @@ import { } from '@/components/target/explorer/provider'; import { GraphQLScalarTypeComponent } from '@/components/target/explorer/scalar-type'; import { GraphQLUnionTypeComponent } from '@/components/target/explorer/union-type'; -import { DataWrapper, Title } from '@/components/v2'; +import { Subtitle, Title } from '@/components/ui/page'; +import { MetaTitle } from '@/components/v2'; import { noSchemaVersion } from '@/components/v2/empty-list'; import { FragmentType, graphql, useFragment } from '@/gql'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { useRouteSelector } from '@/lib/hooks/use-route-selector'; import { withSessionProtection } from '@/lib/supertokens/guard'; -const SchemaTypeExplorer_Type = graphql(` - query SchemaTypeExplorer_Type( - $organization: ID! - $project: ID! - $target: ID! - $period: DateRangeInput! - $typename: String! - ) { - target(selector: { organization: $organization, project: $project, target: $target }) { - __typename - id - latestSchemaVersion { - __typename - id - valid - explorer(usage: { period: $period }) { - type(name: $typename) { - ...TypeRenderFragment - } - } - } - } - operationsStats( - selector: { organization: $organization, project: $project, target: $target, period: $period } - ) { - totalRequests - } - } -`); - const TypeRenderFragment = graphql(` fragment TypeRenderFragment on GraphQLNamedType { __typename @@ -84,66 +55,20 @@ function TypeRenderer(props: { } } -function SchemaTypeExplorer({ - organizationCleanId, - projectCleanId, - targetCleanId, - typename, -}: { - organizationCleanId: string; - projectCleanId: string; - targetCleanId: string; - typename: string; -}): ReactElement | null { - const { period } = useSchemaExplorerContext(); - const [query] = useQuery({ - query: SchemaTypeExplorer_Type, - variables: { - organization: organizationCleanId, - project: projectCleanId, - target: targetCleanId, - period, - typename, - }, - requestPolicy: 'cache-first', - }); - - return ( - <DataWrapper query={query}> - {({ data }) => { - if (!data.target?.latestSchemaVersion) { - return noSchemaVersion; - } - - const { type } = data.target.latestSchemaVersion.explorer; - const { totalRequests } = data.operationsStats; - - if (!type) { - return <div>No type found</div>; - } - - return ( - <div className="space-y-4"> - <SchemaExplorerFilter - organization={{ cleanId: organizationCleanId }} - project={{ cleanId: projectCleanId }} - target={{ cleanId: targetCleanId }} - period={period} - typename={typename} - /> - <TypeRenderer totalRequests={totalRequests} type={type} /> - </div> - ); - }} - </DataWrapper> - ); -} - const TargetExplorerTypenamePageQuery = graphql(` - query TargetExplorerTypenamePageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) { + query TargetExplorerTypenamePageQuery( + $organizationId: ID! + $projectId: ID! + $targetId: ID! + $period: DateRangeInput! + $typename: String! + ) { + organizations { + ...TargetLayout_OrganizationConnectionFragment + } organization(selector: { organization: $organizationId }) { organization { - ...TargetLayout_OrganizationFragment + ...TargetLayout_CurrentOrganizationFragment cleanId rateLimit { retentionInDays @@ -151,19 +76,107 @@ const TargetExplorerTypenamePageQuery = graphql(` } } project(selector: { organization: $organizationId, project: $projectId }) { - ...TargetLayout_ProjectFragment + ...TargetLayout_CurrentProjectFragment cleanId } - targets(selector: { organization: $organizationId, project: $projectId }) { - ...TargetLayout_TargetConnectionFragment - } target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { cleanId + latestSchemaVersion { + __typename + id + valid + explorer(usage: { period: $period }) { + type(name: $typename) { + ...TypeRenderFragment + } + } + } } + operationsStats( + selector: { + organization: $organizationId + project: $projectId + target: $targetId + period: $period + } + ) { + totalRequests + } + me { + ...TargetLayout_MeFragment + } + ...TargetLayout_IsCDNEnabledFragment } `); -function ExplorerPage(): ReactElement | null { +function TypeExplorerPageContent({ typename }: { typename: string }) { + const router = useRouteSelector(); + const { period, dataRetentionInDays, setDataRetentionInDays } = useSchemaExplorerContext(); + const [query] = useQuery({ + query: TargetExplorerTypenamePageQuery, + variables: { + organizationId: router.organizationId, + projectId: router.projectId, + targetId: router.targetId, + period, + typename, + }, + }); + useNotFoundRedirectOnError(!!query.error); + + if (query.error) { + return null; + } + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const currentProject = query.data?.project; + const currentTarget = query.data?.target; + const organizationConnection = query.data?.organizations; + const isCDNEnabled = query.data; + const type = currentTarget?.latestSchemaVersion?.explorer.type; + const latestSchemaVersion = currentTarget?.latestSchemaVersion; + + const retentionInDays = currentOrganization?.rateLimit.retentionInDays; + if (typeof retentionInDays === 'number' && dataRetentionInDays !== retentionInDays) { + setDataRetentionInDays(retentionInDays); + } + + return ( + <TargetLayout + value="explorer" + currentOrganization={currentOrganization ?? null} + currentProject={currentProject ?? null} + me={me ?? null} + organizations={organizationConnection ?? null} + isCDNEnabled={isCDNEnabled ?? null} + > + <div className="py-6 flex flex-row items-center justify-between"> + <div> + <Title>Explore + Insights from the latest version. +
    + {latestSchemaVersion && type ? ( + + ) : null} +
    + {latestSchemaVersion && type ? ( + + ) : type ? ( + noSchemaVersion + ) : ( +
    Not found
    + )} + + ); +} + +function TypeExplorerPage() { const router = useRouteSelector(); const { typename } = router.query; @@ -173,25 +186,14 @@ function ExplorerPage(): ReactElement | null { return ( <> - - <TargetLayout value="explorer" query={TargetExplorerTypenamePageQuery}> - {props => ( - <SchemaExplorerProvider - dataRetentionInDays={props.organization?.organization.rateLimit.retentionInDays ?? 0} - > - <SchemaTypeExplorer - organizationCleanId={props.organization?.organization.cleanId ?? ''} - projectCleanId={props.project?.cleanId ?? ''} - targetCleanId={props.target?.cleanId ?? ''} - typename={typename} - /> - </SchemaExplorerProvider> - )} - </TargetLayout> + <MetaTitle title={`Type ${typename}`} /> + <SchemaExplorerProvider> + <TypeExplorerPageContent typename={typename} /> + </SchemaExplorerProvider> </> ); } export const getServerSideProps = withSessionProtection(); -export default authenticated(ExplorerPage); +export default authenticated(TypeExplorerPage); diff --git a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/history.tsx b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/history.tsx index 71889de57..c2393aa9d 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/history.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/history.tsx @@ -1,17 +1,19 @@ import { ReactElement, useCallback, useState } from 'react'; import NextLink from 'next/link'; -import { clsx } from 'clsx'; import { useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; -import { TargetLayout } from '@/components/layouts'; +import { TargetLayout } from '@/components/layouts/target'; import { VersionErrorsAndChanges } from '@/components/target/history/errors-and-changes'; -import { Badge, Button, DiffEditor, Heading, Spinner, TimeAgo, Title } from '@/components/v2'; +import { Subtitle, Title } from '@/components/ui/page'; +import { Badge, Button, DiffEditor, MetaTitle, Spinner, TimeAgo } from '@/components/v2'; import { noSchemaVersion } from '@/components/v2/empty-list'; import { DiffIcon } from '@/components/v2/icon'; import { graphql } from '@/gql'; -import { CompareDocument, VersionsDocument } from '@/graphql'; +import { CompareDocument } from '@/graphql'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { useRouteSelector } from '@/lib/hooks/use-route-selector'; import { withSessionProtection } from '@/lib/supertokens/guard'; +import { cn } from '@/lib/utils'; import { CheckCircledIcon, CrossCircledIcon, @@ -21,6 +23,34 @@ import { } from '@radix-ui/react-icons'; import * as ToggleGroup from '@radix-ui/react-toggle-group'; +const HistoryPage_VersionsPageQuery = graphql(` + query HistoryPage_VersionsPageQuery($selector: SchemaVersionsInput!, $limit: Int!, $after: ID) { + schemaVersions(selector: $selector, after: $after, limit: $limit) { + nodes { + id + date + valid + log { + ... on PushedSchemaLog { + id + author + service + commit + } + ... on DeletedSchemaLog { + id + deletedService + } + } + baseSchema + } + pageInfo { + hasNextPage + } + } + } +`); + // URQL's Infinite scrolling pattern // https://formidable.com/open-source/urql/docs/basics/ui-patterns/#infinite-scrolling function ListPage({ @@ -39,7 +69,7 @@ function ListPage({ const router = useRouteSelector(); const [versionsQuery] = useQuery({ - query: VersionsDocument, + query: HistoryPage_VersionsPageQuery, variables: { selector: { organization: router.organizationId, @@ -58,7 +88,7 @@ function ListPage({ <> {versions?.nodes.map(version => ( <div - className={clsx( + className={cn( 'flex flex-col rounded-md p-2.5 hover:bg-gray-800/40', versionId === version.id && 'bg-gray-800/40', )} @@ -79,7 +109,7 @@ function ListPage({ </div> ) : null} <div className="mt-2.5 mb-1.5 flex align-middle text-xs font-medium text-[#c4c4c4]"> - <div className={clsx('w-1/2 ', !version.valid && 'text-red-500')}> + <div className={cn('w-1/2 ', !version.valid && 'text-red-500')}> <Badge color={version.valid ? 'green' : 'red'} /> Published{' '} <TimeAgo date={version.date} /> </div> @@ -191,177 +221,224 @@ function ComparisonView({ versionId }: { versionId: string }) { } | null> ).filter(isDefined); - return ( - <div className="flex grow flex-col gap-4"> - <div className="flex items-center justify-between"> - <Heading>Schema</Heading> - <ToggleGroup.Root - className="flex space-x-1 rounded-md bg-gray-900/50 text-gray-500 p-0.5" - type="single" - defaultValue={availableViews[0]?.value} - onValueChange={onViewChange} - orientation="vertical" - > - {availableViews.map(({ value, icon, label, tooltip }) => ( - <ToggleGroup.Item - key={value} - value={value} - className={clsx( - 'flex items-center rounded-md py-[0.4375rem] px-2 text-xs font-semibold hover:text-white', - view === value && 'bg-gray-800 text-white', - )} - title={tooltip} - > - {icon} - <span className="ml-2">{label}</span> - </ToggleGroup.Item> - ))} - </ToggleGroup.Root> - </div> - <div className="grow rounded-md border border-gray-800/50 overflow-y-auto"> - {isLoading ? ( - <div className="flex w-full h-full flex justify-center items-center"> - <Spinner /> - </div> - ) : error ? ( - <div className="m-3 rounded-lg bg-red-500/20 p-8"> - <div className="mb-3 flex items-center gap-3"> - <CrossCircledIcon className="h-6 w-auto text-red-500" /> - <h2 className="text-lg font-medium text-white">Failed to compare schemas</h2> - </div> - <p className="text-base text-gray-500"> - Previous or current schema is most likely incomplete and was force published - </p> - <pre className="mt-5 whitespace-pre-wrap rounded-lg bg-red-900 p-3 text-xs text-white"> - {error.graphQLErrors?.[0]?.message ?? error.networkError?.message} - </pre> - </div> - ) : showFullSchemaDiff ? ( - <DiffEditor - title="Full schema" - before={comparison.diff.before ?? ''} - after={comparison.diff.after} - /> - ) : showServiceSchemaDiff ? ( - <DiffEditor - title={comparison.service?.name ?? ''} - before={comparison.service?.before ?? ''} - after={comparison.service?.after ?? ''} - /> - ) : showListView ? ( - <VersionErrorsAndChanges - changes={ - hasSchemaChanges - ? comparison.changes - : { - nodes: [], - total: 0, - } - } - errors={ - hasCompositionErrors - ? compositionErrors - : { - nodes: [], - total: 0, - } - } - /> - ) : isServiceSchemaAvailable ? ( - <DiffEditor - title={comparison.service?.name ?? ''} - before={comparison.service?.before ?? ''} - after={comparison.service?.after ?? ''} - /> - ) : ( - <div> - <div className="m-3 rounded-lg bg-emerald-500/20 p-8"> - <div className="mb-3 flex items-center gap-3"> - <CheckCircledIcon className="h-6 w-auto text-emerald-500" /> - <h2 className="text-lg font-medium text-white">First composable version</h2> - </div> - <p className="text-base text-white"> - Congratulations! This is the first version of the schema that is composable. - </p> - </div> - </div> - )} - </div> - </div> - ); -} - -function Page({ versionId, gitRepository }: { versionId: string; gitRepository?: string }) { - const [pageVariables, setPageVariables] = useState([{ limit: 10, after: '' }]); - return ( <> - <div className="flex flex-col gap-5"> - <Heading>Versions</Heading> - <div className="flex h-0 min-w-[420px] grow flex-col gap-2.5 overflow-y-auto rounded-md border border-gray-800/50 p-2.5"> - {pageVariables.map((variables, i) => ( - <ListPage - gitRepository={gitRepository} - key={variables.after || 'initial'} - variables={variables} - isLastPage={i === pageVariables.length - 1} - onLoadMore={after => { - setPageVariables([...pageVariables, { after, limit: 10 }]); - }} - versionId={versionId} + <div className="flex flex-row justify-between items-center"> + <div className="py-6"> + <Title>Details + Explore details of the selected version +
    + {availableViews.length ? ( +
    + + {availableViews.map(({ value, icon, label, tooltip }) => ( + + {icon} + {label} + + ))} + +
    + ) : null} +
    +
    +
    + {isLoading ? ( +
    + +
    + ) : error ? ( +
    +
    + +

    Failed to compare schemas

    +
    +

    + Previous or current schema is most likely incomplete and was force published +

    +
    +                {error.graphQLErrors?.[0]?.message ?? error.networkError?.message}
    +              
    +
    + ) : showFullSchemaDiff ? ( + - ))} + ) : showServiceSchemaDiff ? ( + + ) : showListView ? ( + + ) : isServiceSchemaAvailable ? ( + + ) : ( +
    +
    +
    + +

    First composable version

    +
    +

    + Congratulations! This is the first version of the schema that is composable. +

    +
    +
    + )}
    - ); } const TargetHistoryPageQuery = graphql(` query TargetHistoryPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) { + organizations { + ...TargetLayout_OrganizationConnectionFragment + } organization(selector: { organization: $organizationId }) { organization { - ...TargetLayout_OrganizationFragment + ...TargetLayout_CurrentOrganizationFragment } } project(selector: { organization: $organizationId, project: $projectId }) { - ...TargetLayout_ProjectFragment + ...TargetLayout_CurrentProjectFragment gitRepository } - targets(selector: { organization: $organizationId, project: $projectId }) { - ...TargetLayout_TargetConnectionFragment - } target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { id latestSchemaVersion { id } } + me { + ...TargetLayout_MeFragment + } ...TargetLayout_IsCDNEnabledFragment } `); -function HistoryPage(): ReactElement { +function HistoryPageContent() { const router = useRouteSelector(); + const [query] = useQuery({ + query: TargetHistoryPageQuery, + variables: { + organizationId: router.organizationId, + projectId: router.projectId, + targetId: router.targetId, + }, + }); + useNotFoundRedirectOnError(!!query.error); + const [pageVariables, setPageVariables] = useState([{ limit: 10, after: '' }]); + + if (query.error) { + return null; + } + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const currentProject = query.data?.project; + const currentTarget = query.data?.target; + const organizationConnection = query.data?.organizations; + const isCDNEnabled = query.data; + + const versionId = router.versionId ?? currentTarget?.latestSchemaVersion?.id; + return ( + + {versionId ? ( +
    +
    +
    + Versions + Recently published schemas. +
    +
    +
    + {pageVariables.map((variables, i) => ( + { + setPageVariables([...pageVariables, { after, limit: 10 }]); + }} + versionId={versionId} + /> + ))} +
    +
    +
    +
    + +
    +
    + ) : ( + <> +
    + Versions + Recently published schemas. +
    + {noSchemaVersion} + + )} +
    + ); +} + +function HistoryPage(): ReactElement { return ( <> - - <TargetLayout - value="history" - className="flex h-full items-stretch gap-x-5" - query={TargetHistoryPageQuery} - > - {({ target, project }) => { - const versionId = router.versionId ?? target?.latestSchemaVersion?.id; - return versionId ? ( - <Page gitRepository={project?.gitRepository ?? undefined} versionId={versionId} /> - ) : ( - noSchemaVersion - ); - }} - </TargetLayout> + <MetaTitle title="History" /> + <HistoryPageContent /> </> ); } diff --git a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/index.tsx b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/index.tsx index b1ee0d9d8..77aeaebef 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/index.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/index.tsx @@ -2,24 +2,33 @@ import { ChangeEventHandler, ReactElement, useCallback, useState } from 'react'; import { useQuery } from 'urql'; import { useDebouncedCallback } from 'use-debounce'; import { authenticated } from '@/components/authenticated-container'; -import { TargetLayout } from '@/components/layouts'; +import { TargetLayout } from '@/components/layouts/target'; import { MarkAsValid } from '@/components/target/history/MarkAsValid'; -import { Accordion, DataWrapper, GraphQLBlock, Input, noSchema, Title } from '@/components/v2'; +import { Subtitle, Title } from '@/components/ui/page'; +import { Accordion, GraphQLBlock, Input, MetaTitle, noSchema } from '@/components/v2'; import { noSchemaVersion } from '@/components/v2/empty-list'; import { GraphQLHighlight } from '@/components/v2/graphql-block'; -import { FragmentType, graphql, useFragment } from '@/gql'; -import { CompositeSchemaFieldsFragment, SingleSchemaFieldsFragment } from '@/gql/graphql'; -import { LatestSchemaDocument, ProjectType, RegistryModel } from '@/graphql'; +import { DocumentType, FragmentType, graphql, useFragment } from '@/gql'; +import { ProjectType, RegistryModel } from '@/graphql'; import { TargetAccessScope, useTargetAccess } from '@/lib/access/target'; +import { useRouteSelector } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { withSessionProtection } from '@/lib/supertokens/guard'; +type CompositeSchema = Extract< + DocumentType<typeof SchemaView_SchemaFragment>, + { + __typename?: 'CompositeSchema'; + } +>; + function isCompositeSchema( - schema: SingleSchemaFieldsFragment | CompositeSchemaFieldsFragment, -): schema is CompositeSchemaFieldsFragment { + schema: DocumentType<typeof SchemaView_SchemaFragment>, +): schema is CompositeSchema { return schema.__typename === 'CompositeSchema'; } -function SchemaBlock({ schema }: { schema: CompositeSchemaFieldsFragment }) { +function SchemaBlock({ schema }: { schema: CompositeSchema }) { return ( <Accordion.Item value={schema.id} key={schema.id} className="border-2 border-gray-900/50"> <Accordion.Header> @@ -47,14 +56,14 @@ const Schemas_ProjectFragment = graphql(` function Schemas({ filterService, - schemas = [], ...props }: { project: FragmentType<typeof Schemas_ProjectFragment>; - schemas: Array<SingleSchemaFieldsFragment | CompositeSchemaFieldsFragment>; + schemas: FragmentType<typeof SchemaView_SchemaFragment>[]; filterService?: string; }): ReactElement { const project = useFragment(Schemas_ProjectFragment, props.project); + const schemas = useFragment(SchemaView_SchemaFragment, props.schemas); if (project.type === ProjectType.Single) { const [schema] = schemas; @@ -116,13 +125,35 @@ const SchemaView_ProjectFragment = graphql(` } `); +const SchemaView_SchemaFragment = graphql(` + fragment SchemaView_SchemaFragment on Schema { + ... on SingleSchema { + id + author + source + commit + } + ... on CompositeSchema { + id + author + source + service + url + commit + } + } +`); + const SchemaView_TargetFragment = graphql(` fragment SchemaView_TargetFragment on Target { cleanId latestSchemaVersion { + id + valid schemas { nodes { __typename + ...SchemaView_SchemaFragment } } } @@ -157,104 +188,130 @@ function SchemaView(props: { const isDistributed = project.type === ProjectType.Federation || project.type === ProjectType.Stitching; - const [query] = useQuery({ - query: LatestSchemaDocument, - variables: { - selector: { - organization: organization.cleanId, - project: project.cleanId, - target: target.cleanId, - }, - }, - requestPolicy: 'cache-first', - }); - const canManage = useTargetAccess({ scope: TargetAccessScope.RegistryWrite, member: organization.me, redirect: false, }); + const { latestSchemaVersion } = target; + if (!latestSchemaVersion) { + return noSchemaVersion; + } + + if (!latestSchemaVersion.schemas.nodes.length) { + return noSchema; + } + + const canMarkAsValid = project.registryModel === RegistryModel.Legacy; + const showExtra = canManage && project.registryModel === RegistryModel.Legacy; + return ( - <DataWrapper query={query}> - {query => { - const latestSchemaVersion = query.data?.target?.latestSchemaVersion; - if (!latestSchemaVersion) { - return noSchemaVersion; - } - - if (!latestSchemaVersion.schemas.nodes.length) { - return noSchema; - } - - return ( - <> - <div className="mb-5 flex flex-row items-center justify-between"> - <div className="font-light text-gray-500">The latest published schema.</div> - <div className="flex flex-row items-center gap-4"> - {isDistributed && ( - <Input - placeholder="Find service" - value={term} - onChange={handleChange} - onClear={reset} - size="small" - /> - )} - {canManage && project.registryModel === RegistryModel.Legacy ? ( - <> - <MarkAsValid version={latestSchemaVersion} />{' '} - </> - ) : null} - </div> - </div> - <Schemas - project={project} - filterService={filterService} - schemas={latestSchemaVersion.schemas.nodes ?? []} - /> - </> - ); - }} - </DataWrapper> + <> + {showExtra ? ( + <div className="mb-5 flex flex-row items-center justify-between"> + <div className="flex flex-row items-center gap-4"> + {isDistributed && ( + <Input + placeholder="Find service" + value={term} + onChange={handleChange} + onClear={reset} + size="small" + /> + )} + {canMarkAsValid ? ( + <> + <MarkAsValid version={latestSchemaVersion} />{' '} + </> + ) : null} + </div> + </div> + ) : null} + <Schemas + project={project} + filterService={filterService} + schemas={target.latestSchemaVersion?.schemas.nodes ?? []} + /> + </> ); } const TargetSchemaPageQuery = graphql(` query TargetSchemaPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) { + organizations { + ...TargetLayout_OrganizationConnectionFragment + } organization(selector: { organization: $organizationId }) { organization { - ...TargetLayout_OrganizationFragment + ...TargetLayout_CurrentOrganizationFragment ...SchemaView_OrganizationFragment } } project(selector: { organization: $organizationId, project: $projectId }) { - ...TargetLayout_ProjectFragment + ...TargetLayout_CurrentProjectFragment ...SchemaView_ProjectFragment } - targets(selector: { organization: $organizationId, project: $projectId }) { - ...TargetLayout_TargetConnectionFragment - } target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { ...SchemaView_TargetFragment } + me { + ...TargetLayout_MeFragment + } ...TargetLayout_IsCDNEnabledFragment } `); +function Page() { + const router = useRouteSelector(); + const [query] = useQuery({ + query: TargetSchemaPageQuery, + variables: { + organizationId: router.organizationId, + projectId: router.projectId, + targetId: router.targetId, + }, + }); + useNotFoundRedirectOnError(!!query.error); + + if (query.error) { + return null; + } + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const currentProject = query.data?.project; + const organizationConnection = query.data?.organizations; + const target = query.data?.target; + const isCDNEnabled = query.data; + + return ( + <TargetLayout + value="schema" + currentOrganization={currentOrganization ?? null} + currentProject={currentProject ?? null} + me={me ?? null} + organizations={organizationConnection ?? null} + isCDNEnabled={isCDNEnabled ?? null} + > + <div className="py-6"> + <Title>Schema + The latest published schema. +
    +
    + {currentOrganization && currentProject && target ? ( + + ) : null} +
    + + ); +} + function SchemaPage(): ReactElement { return ( <> - - <TargetLayout value="schema" query={TargetSchemaPageQuery}> - {props => ( - <SchemaView - target={props.target!} - organization={props.organization!.organization} - project={props.project!} - /> - )} - </TargetLayout> + <MetaTitle title="Schema" /> + <Page /> </> ); } diff --git a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/laboratory.tsx b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/laboratory.tsx index 38cb56886..0434e9a79 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/laboratory.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/laboratory.tsx @@ -1,39 +1,43 @@ -import { ReactElement, useEffect, useRef, useState } from 'react'; +import { ReactElement, useEffect, useState } from 'react'; import { useRouter } from 'next/router'; -import clsx from 'clsx'; import { GraphiQL } from 'graphiql'; +import { LinkIcon } from 'lucide-react'; import { useMutation, useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; -import { TargetLayout } from '@/components/layouts'; -import { TargetLayout_OrganizationFragment } from '@/components/layouts/target'; -import { - Accordion, - Button, - DocsLink, - DocsNote, - EmptyList, - Heading, - Link, - Spinner, - Title, -} from '@/components/v2'; +import { TargetLayout } from '@/components/layouts/target'; +import { Button } from '@/components/ui/button'; +import { Subtitle, Title } from '@/components/ui/page'; +import { Accordion, DocsLink, EmptyList, Link, MetaTitle, Spinner } 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 { ConnectLabModal } from '@/components/v2/modals/connect-lab'; +import { graphql } 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 { canAccessTarget } from '@/lib/access/target'; +import { + useClipboard, + useCollections, + useNotifications, + useRouteSelector, + useToggle, +} from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { withSessionProtection } from '@/lib/supertokens/guard'; -import { DropdownMenu, GraphiQLPlugin, Tooltip, useEditorContext } from '@graphiql/react'; +import { cn } from '@/lib/utils'; +import { + Button as GraphiQLButton, + DropdownMenu as GraphiQLDropdownMenu, + GraphiQLPlugin, + Tooltip as GraphiQLTooltip, + useEditorContext, +} from '@graphiql/react'; import { createGraphiQLFetcher } from '@graphiql/toolkit'; -import { BookmarkIcon, DotsVerticalIcon, Link1Icon, Share2Icon } from '@radix-ui/react-icons'; +import { BookmarkIcon, DotsVerticalIcon, Share2Icon } from '@radix-ui/react-icons'; import 'graphiql/graphiql.css'; function Share(): ReactElement { @@ -42,8 +46,8 @@ function Share(): ReactElement { const router = useRouter(); return ( - <Tooltip label={label}> - <Button + <GraphiQLTooltip label={label}> + <GraphiQLButton className="graphiql-toolbar-button" aria-label={label} disabled={!router.query.operation} @@ -52,8 +56,8 @@ function Share(): ReactElement { }} > <Share2Icon className="graphiql-toolbar-icon" /> - </Button> - </Tooltip> + </GraphiQLButton> + </GraphiQLTooltip> ); } @@ -94,13 +98,14 @@ function useCurrentOperation() { return operationId ? data?.target?.documentCollectionOperation : null; } -function useOperationCollectionsPlugin(props: { - meRef: FragmentType<typeof CanAccessTarget_MemberFragment>; -}) { - const propsRef = useRef(props); - propsRef.current = props; - const pluginRef = useRef<GraphiQLPlugin>(); - pluginRef.current ||= { +function useOperationCollectionsPlugin({ + canDelete, + canEdit, +}: { + canEdit: boolean; + canDelete: boolean; +}): GraphiQLPlugin { + return { title: 'Operation Collections', icon: BookmarkIcon, content: function Content() { @@ -153,8 +158,6 @@ function useOperationCollectionsPlugin(props: { } }, [hasAllEditors, queryParamsOperationId, currentOperation]); - const canEdit = canAccessTarget(TargetAccessScope.Settings, props.meRef); - const canDelete = canAccessTarget(TargetAccessScope.Delete, props.meRef); const shouldShowMenu = canEdit || canDelete; const initialSelectedCollection = @@ -166,7 +169,7 @@ function useOperationCollectionsPlugin(props: { return ( <> <div className="flex justify-between"> - <Heading>Collections</Heading> + <Title>Collections {canEdit ? (
    -

    Shared across your organization

    +

    Shared across your organization

    {loading ? ( ) : ( @@ -207,20 +210,20 @@ function useOperationCollectionsPlugin(props: { {collection.name} {shouldShowMenu ? ( - - - + - - + { setCollectionId(collection.id); toggleCollectionModal(); @@ -228,8 +231,8 @@ function useOperationCollectionsPlugin(props: { data-cy="collection-edit" > Edit - - + { setCollectionId(collection.id); toggleDeleteCollectionModalOpen(); @@ -238,9 +241,9 @@ function useOperationCollectionsPlugin(props: { data-cy="collection-delete" > Delete - - - + + + ) : null}
    @@ -256,7 +259,7 @@ function useOperationCollectionsPlugin(props: { targetId: router.targetId, }, }} - className={clsx( + className={cn( 'hover:bg-gray-100/10 w-full rounded p-2 !text-gray-300', router.query.operation === node.id && 'bg-gray-100/10', )} @@ -280,20 +283,20 @@ function useOperationCollectionsPlugin(props: { > {node.name} - - - + - - + { const url = new URL(window.location.href); await copyToClipboard( @@ -302,9 +305,9 @@ function useOperationCollectionsPlugin(props: { }} > Copy link to operation - + {canDelete ? ( - { setOperationId(node.id); toggleDeleteOperationModalOpen(); @@ -313,10 +316,10 @@ function useOperationCollectionsPlugin(props: { data-cy="remove-operation" > Delete - + ) : null} - - + +
    )) : 'No operations yet. Use the editor to create an operation, and click Save to store and share it.'} @@ -335,8 +338,6 @@ function useOperationCollectionsPlugin(props: { ); }, }; - - return pluginRef.current; } const UpdateOperationMutation = graphql(` @@ -377,7 +378,7 @@ function Save(): ReactElement { const operationId = currentOperation?.id; const label = isSame ? undefined : operationId ? 'Update saved operation' : 'Save operation'; const button = ( - + ); return ( <> - {label ? {button} : button} + {label ? {button} : button} {isOpen ? : null} ); } -// Save.whyDidYouRender = true; +const TargetLaboratoryPageQuery = graphql(` + query TargetLaboratoryPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) { + organizations { + ...TargetLayout_OrganizationConnectionFragment + } + organization(selector: { organization: $organizationId }) { + organization { + ...TargetLayout_CurrentOrganizationFragment + me { + ...CanAccessTarget_MemberFragment + } + } + } + project(selector: { organization: $organizationId, project: $projectId }) { + ...TargetLayout_CurrentProjectFragment + } + target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { + id + } + me { + ...TargetLayout_MeFragment + } + ...TargetLayout_IsCDNEnabledFragment + } +`); + +function LaboratoryPageContent() { + const [isModalOpen, toggleModalOpen] = useToggle(); + const router = useRouteSelector(); + const [query] = useQuery({ + query: TargetLaboratoryPageQuery, + variables: { + organizationId: router.organizationId, + projectId: router.projectId, + targetId: router.targetId, + }, + }); + useNotFoundRedirectOnError(!!query.error); + + const endpoint = `${location.origin}/api/lab/${router.organizationId}/${router.projectId}/${router.targetId}`; + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const currentProject = query.data?.project; + const organizationConnection = query.data?.organizations; + const isCDNEnabled = query.data; + + const operationCollectionsPlugin = useOperationCollectionsPlugin({ + canEdit: canAccessTarget(TargetAccessScope.Settings, currentOrganization?.me ?? null), + canDelete: canAccessTarget(TargetAccessScope.Delete, currentOrganization?.me ?? null), + }); + + if (query.error) { + return null; + } -function Page({ - endpoint, - organizationRef, -}: { - endpoint: string; - organizationRef: FragmentType; -}): ReactElement { - const { me } = useFragment(TargetLayout_OrganizationFragment, organizationRef); - const operationCollectionsPlugin = useOperationCollectionsPlugin({ meRef: me }); return ( - <> - - Explore your GraphQL schema and run queries against a mocked version of your GraphQL - service. Learn more about the Laboratory - + + + + + } + > +
    + Laboratory + + Explore your GraphQL schema and run queries against a mocked version of your GraphQL + service. + +

    + + Learn more about the Laboratory + +

    +
    - - - - - ), - }} - showPersistHeadersSettings={false} - shouldPersistHeaders={false} - plugins={[operationCollectionsPlugin]} - visiblePlugin={operationCollectionsPlugin} - > - - - - - + {query.fetching ? null : ( + + + + + ), + }} + showPersistHeadersSettings={false} + shouldPersistHeaders={false} + plugins={[operationCollectionsPlugin]} + visiblePlugin={operationCollectionsPlugin} + > + + + + + )} +
    ); } -const TargetLaboratoryPageQuery = graphql(` - query TargetLaboratoryPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) { - organization(selector: { organization: $organizationId }) { - organization { - ...TargetLayout_OrganizationFragment - } - } - project(selector: { organization: $organizationId, project: $projectId }) { - ...TargetLayout_ProjectFragment - } - targets(selector: { organization: $organizationId, project: $projectId }) { - ...TargetLayout_TargetConnectionFragment - } - target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { - id - } - ...TargetLayout_IsCDNEnabledFragment - } -`); - function LaboratoryPage(): ReactElement { - const [isModalOpen, toggleModalOpen] = useToggle(); - const router = useRouteSelector(); - const endpoint = `${window.location.origin}/api/lab/${router.organizationId}/${router.projectId}/${router.targetId}`; - return ( <> - - <TargetLayout - query={TargetLaboratoryPageQuery} - value="laboratory" - className="flex h-full flex-col" - connect={ - <> - <Button size="large" variant="primary" onClick={toggleModalOpen} className="ml-auto"> - Use Schema Externally - <Link1Icon className="ml-8 h-6 w-auto" /> - </Button> - <ConnectLabModal - isOpen={isModalOpen} - toggleModalOpen={toggleModalOpen} - endpoint={endpoint} - /> - </> - } - > - {({ organization }) => ( - <Page organizationRef={organization!.organization} endpoint={endpoint} /> - )} - </TargetLayout> + <MetaTitle title="Schema laboratory" /> + <LaboratoryPageContent /> </> ); } diff --git a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/operations.tsx b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/operations.tsx index 536be7be1..82b7e72f7 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/operations.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/operations.tsx @@ -1,13 +1,17 @@ import { ReactElement, useCallback, useMemo, useState } from 'react'; import { useRouter } from 'next/router'; import { formatISO, subDays, subHours, subMinutes } from 'date-fns'; +import { useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; -import { TargetLayout } from '@/components/layouts'; +import { TargetLayout } from '@/components/layouts/target'; import { OperationsFilterTrigger } from '@/components/target/operations/Filters'; import { OperationsList } from '@/components/target/operations/List'; import { OperationsStats } from '@/components/target/operations/Stats'; -import { EmptyList, RadixSelect, Title } from '@/components/v2'; +import { Subtitle, Title } from '@/components/ui/page'; +import { EmptyList, MetaTitle, RadixSelect } from '@/components/v2'; import { graphql } from '@/gql'; +import { useRouteSelector } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { withSessionProtection } from '@/lib/supertokens/guard'; function floorDate(date: Date): Date { @@ -66,17 +70,23 @@ function OperationsView({ return ( <> - <div className="flex justify-end gap-2 pb-7"> - <OperationsFilterTrigger - period={period} - selected={selectedOperations} - onFilter={setSelectedOperations} - /> - <RadixSelect - onChange={updatePeriod} - defaultValue={selectedPeriod} - options={Object.entries(DateRange).map(([value, label]) => ({ value, label }))} - /> + <div className="py-6 flex flex-row items-center justify-between"> + <div> + <Title>Versions + Recently published schemas. + +
    + + ({ value, label }))} + /> +
    + {currentOrganization && currentProject && currentTarget ? ( + hasCollectedOperations ? ( + + ) : ( +
    + +
    + ) + ) : null} + + ); +} + function OperationsPage(): ReactElement { return ( <> - - <TargetLayout value="operations" query={TargetOperationsPageQuery}> - {({ organization, project, target, hasCollectedOperations }) => - organization && project && target ? ( - <div className="relative"> - {hasCollectedOperations ? ( - <OperationsView - organizationCleanId={organization.organization.cleanId} - projectCleanId={project.cleanId} - targetCleanId={target.cleanId} - /> - ) : ( - <EmptyList - title="Hive is waiting for your first collected operation" - description="You can collect usage of your GraphQL API with Hive Client" - docsUrl="/features/usage-reporting" - /> - )} - </div> - ) : null - } - </TargetLayout> + <MetaTitle title="Operations" /> + <TargetOperationsPageContent /> </> ); } diff --git a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/settings.tsx b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/settings.tsx index 0f4d84971..17e985c67 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/settings.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/settings.tsx @@ -5,17 +5,18 @@ import { useFormik } from 'formik'; import { useMutation, useQuery } from 'urql'; import * as Yup from 'yup'; import { authenticated } from '@/components/authenticated-container'; -import { TargetLayout } from '@/components/layouts'; +import { TargetLayout } from '@/components/layouts/target'; import { SchemaEditor } from '@/components/schema-editor'; import { CDNAccessTokens } from '@/components/target/settings/cdn-access-tokens'; +import { Subtitle, Title } from '@/components/ui/page'; import { Button, Card, Checkbox, DocsLink, - DocsNote, Heading, Input, + MetaTitle, Spinner, Switch, Table, @@ -23,7 +24,6 @@ import { TBody, Td, TimeAgo, - Title, Tr, } from '@/components/v2'; import { Combobox } from '@/components/v2/combobox'; @@ -32,6 +32,7 @@ import { FragmentType, graphql, useFragment } from '@/gql'; import { DeleteTokensDocument, SetTargetValidationDocument, TokensDocument } from '@/graphql'; import { canAccessTarget, TargetAccessScope } from '@/lib/access/target'; import { useRouteSelector, useToggle } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { withSessionProtection } from '@/lib/supertokens/guard'; const RegistryAccessTokens_MeFragment = graphql(` @@ -79,17 +80,15 @@ function RegistryAccessTokens(props: { return ( <Card> <Heading className="mb-2">Registry Access Tokens</Heading> - <p className="mb-3 font-light text-gray-300"> - Be careful! These tokens allow to read and write your target data. - </p> - <DocsNote> + <div className="text-sm text-gray-400"> Registry Access Tokens are used to access to Hive Registry and perform actions on your targets/projects. In most cases, this token is used from the Hive CLI. - <br /> + </div> + <p> <DocsLink href="/management/targets#registry-access-tokens"> Learn more about Registry Access Tokens </DocsLink> - </DocsNote> + </p> {canManage && ( <div className="my-3.5 flex justify-between"> <Button variant="primary" onClick={toggleModalOpen} size="large" className="px-5"> @@ -167,14 +166,14 @@ const ExtendBaseSchema = (props: { baseSchema: string }): ReactElement => { return ( <Card> <Heading className="mb-2">Extend Your Schema</Heading> - <DocsNote> + <div className="text-sm text-gray-400"> Schema Extensions is pre-defined GraphQL schema that is automatically merged with your published schemas, before being checked and validated. <br /> <DocsLink href="/management/targets#schema-extensions"> You can find more details and examples in the documentation </DocsLink> - </DocsNote> + </div> <SchemaEditor theme="vs-dark" options={{ readOnly: mutation.fetching }} @@ -285,8 +284,8 @@ function ClientExclusion( ); } -const Settings_TargetSettingsQuery = graphql(` - query Settings_TargetSettingsQuery( +const TargetSettingsPage_TargetSettingsQuery = graphql(` + query TargetSettingsPage_TargetSettingsQuery( $selector: TargetSelectorInput! $targetsSelector: ProjectSelectorInput! $organizationSelector: OrganizationSelectorInput! @@ -320,8 +319,10 @@ const Settings_TargetSettingsQuery = graphql(` } `); -const Settings_UpdateTargetValidationSettingsMutation = graphql(` - mutation Settings_UpdateTargetValidationSettings($input: UpdateTargetValidationSettingsInput!) { +const TargetSettingsPage_UpdateTargetValidationSettingsMutation = graphql(` + mutation TargetSettingsPage_UpdateTargetValidationSettings( + $input: UpdateTargetValidationSettingsInput! + ) { updateTargetValidationSettings(input: $input) { ok { target { @@ -350,9 +351,11 @@ function floorDate(date: Date): Date { const ConditionalBreakingChanges = (): ReactElement => { const router = useRouteSelector(); const [targetValidation, setValidation] = useMutation(SetTargetValidationDocument); - const [mutation, updateValidation] = useMutation(Settings_UpdateTargetValidationSettingsMutation); + const [mutation, updateValidation] = useMutation( + TargetSettingsPage_UpdateTargetValidationSettingsMutation, + ); const [targetSettings] = useQuery({ - query: Settings_TargetSettingsQuery, + query: TargetSettingsPage_TargetSettingsQuery, variables: { selector: { organization: router.organizationId, @@ -436,11 +439,11 @@ const ConditionalBreakingChanges = (): ReactElement => { /> )} </Heading> - <DocsNote> + <div className="text-sm text-gray-400"> Conditional Breaking Changes can change the behavior of schema checks, based on real traffic data sent to Hive.{' '} <DocsLink href="/management/targets#conditional-breaking-changes">Learn more</DocsLink> - </DocsNote> + </div> <div className={clsx( 'mb-3 mt-4 flex flex-col items-start gap-3 font-light text-gray-300', @@ -583,8 +586,90 @@ const ConditionalBreakingChanges = (): ReactElement => { ); }; -const Settings_UpdateTargetNameMutation = graphql(` - mutation Settings_UpdateTargetName($input: UpdateTargetNameInput!) { +function TargetName(props: { + targetName: string | null; + organizationId: string; + projectId: string; + targetId: string; +}) { + const router = useRouteSelector(); + + const [mutation, mutate] = useMutation(TargetSettingsPage_UpdateTargetNameMutation); + const { handleSubmit, values, handleChange, handleBlur, isSubmitting, errors, touched } = + useFormik({ + enableReinitialize: true, + initialValues: { + name: props.targetName || '', + }, + validationSchema: Yup.object().shape({ + name: Yup.string().required('Target name is required'), + }), + onSubmit: values => + mutate({ + input: { + organization: props.organizationId, + project: props.projectId, + target: props.targetId, + name: values.name, + }, + }).then(result => { + if (result?.data?.updateTargetName?.ok) { + const newTargetId = result.data.updateTargetName.ok.updatedTarget.cleanId; + void router.replace( + `/${router.organizationId}/${router.projectId}/${newTargetId}/settings`, + ); + } + }), + }); + + return ( + <Card> + <Heading className="mb-2">Target Name</Heading> + <div className="text-sm text-gray-400"> + Changing the name of your target will also change the slug of your target URL, and will + invalidate any existing links to your target. + <br /> + <DocsLink href="/management/targets#rename-a-target"> + You can read more about it in the documentation + </DocsLink> + </div> + <form onSubmit={handleSubmit} className="flex gap-x-2"> + <Input + placeholder="Target name" + name="name" + value={values.name} + onChange={handleChange} + onBlur={handleBlur} + disabled={isSubmitting} + isInvalid={touched.name && !!errors.name} + className="w-96" + /> + <Button + type="submit" + variant="primary" + size="large" + disabled={isSubmitting} + className="px-10" + > + Save + </Button> + </form> + {touched.name && (errors.name || mutation.error) && ( + <div className="mt-2 text-red-500"> + {errors.name ?? mutation.error?.graphQLErrors[0]?.message ?? mutation.error?.message} + </div> + )} + {mutation.data?.updateTargetName.error?.inputErrors?.name && ( + <div className="mt-2 text-red-500"> + {mutation.data.updateTargetName.error.inputErrors.name} + </div> + )} + </Card> + ); +} + +const TargetSettingsPage_UpdateTargetNameMutation = graphql(` + mutation TargetSettingsPage_UpdateTargetName($input: UpdateTargetNameInput!) { updateTargetName(input: $input) { ok { selector { @@ -624,171 +709,156 @@ const TargetSettingsPage_OrganizationFragment = graphql(` } `); -const Page = (props: { - target: FragmentType<typeof TargetSettingsPage_TargetFragment>; - organization: FragmentType<typeof TargetSettingsPage_OrganizationFragment>; -}) => { - const target = useFragment(TargetSettingsPage_TargetFragment, props.target); - const organization = useFragment(TargetSettingsPage_OrganizationFragment, props.organization); - const router = useRouteSelector(); +function TargetDelete(props: { organizationId: string; projectId: string; targetId: string }) { const [isModalOpen, toggleModalOpen] = useToggle(); - const [mutation, mutate] = useMutation(Settings_UpdateTargetNameMutation); - const { handleSubmit, values, handleChange, handleBlur, isSubmitting, errors, touched } = - useFormik({ - enableReinitialize: true, - initialValues: { - name: target?.name || '', - }, - validationSchema: Yup.object().shape({ - name: Yup.string().required('Target name is required'), - }), - onSubmit: values => - mutate({ - input: { - organization: router.organizationId, - project: router.projectId, - target: router.targetId, - name: values.name, - }, - }).then(result => { - if (result?.data?.updateTargetName?.ok) { - const newTargetId = result.data.updateTargetName.ok.updatedTarget.cleanId; - void router.replace( - `/${router.organizationId}/${router.projectId}/${newTargetId}/settings`, - ); - } - }), - }); - - const me = organization?.me; - - const canAccessTokens = canAccessTarget(TargetAccessScope.TokensRead, me); - const canDelete = canAccessTarget(TargetAccessScope.Delete, me); - return ( <> <Card> - <Heading className="mb-2">Target Name</Heading> - <DocsNote warn> - Changing the name of your target will also change the slug of your target URL, and will - invalidate any existing links to your target. - <br /> - <DocsLink href="/management/targets#rename-a-target"> - You can read more about it in the documentation - </DocsLink> - </DocsNote> - <form onSubmit={handleSubmit} className="flex gap-x-2"> - <Input - placeholder="Target name" - name="name" - value={values.name} - onChange={handleChange} - onBlur={handleBlur} - disabled={isSubmitting} - isInvalid={touched.name && !!errors.name} - className="w-96" - /> - <Button - type="submit" - variant="primary" - size="large" - disabled={isSubmitting} - className="px-10" - > - Save - </Button> - </form> - {touched.name && (errors.name || mutation.error) && ( - <div className="mt-2 text-red-500"> - {errors.name ?? mutation.error?.graphQLErrors[0]?.message ?? mutation.error?.message} + <div className="flex items-center justify-between"> + <div> + <Heading className="mb-2">Delete Target</Heading> + <div className="text-sm text-gray-400"> + Deleting an project also delete all schemas and data associated with it. + <br /> + <DocsLink href="/management/targets#delete-a-target"> + <strong>This action is not reversible!</strong> You can find more information about + this process in the documentation + </DocsLink> + </div> </div> - )} - {mutation.data?.updateTargetName.error?.inputErrors?.name && ( - <div className="mt-2 text-red-500"> - {mutation.data.updateTargetName.error.inputErrors.name} + <div className="flex items-center gap-x-2"> + <Button + variant="primary" + size="large" + danger + onClick={toggleModalOpen} + className="px-5" + > + Delete Target + </Button> </div> - )} + </div> </Card> - - {canAccessTokens && <RegistryAccessTokens me={me} />} - - {canAccessTokens && <CDNAccessTokens me={me} />} - - <ConditionalBreakingChanges /> - - <ExtendBaseSchema baseSchema={target?.baseSchema ?? ''} /> - - {canDelete && ( - <Card> - <div className="flex items-center justify-between"> - <div> - <Heading className="mb-2">Delete Target</Heading> - <DocsNote warn> - Deleting an project also delete all schemas and data associated with it. - <br /> - <DocsLink href="/management/targets#delete-a-target"> - <strong>This action is not reversible!</strong> You can find more information - about this process in the documentation - </DocsLink> - </DocsNote> - </div> - <div className="flex items-center gap-x-2"> - <Button - variant="primary" - size="large" - danger - onClick={toggleModalOpen} - className="px-5" - > - Delete Target - </Button> - </div> - </div> - </Card> - )} - <DeleteTargetModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} /> + <DeleteTargetModal + organizationId={props.organizationId} + projectId={props.projectId} + targetId={props.targetId} + isOpen={isModalOpen} + toggleModalOpen={toggleModalOpen} + /> </> ); -}; +} const TargetSettingsPageQuery = graphql(` query TargetSettingsPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) { + organizations { + ...TargetLayout_OrganizationConnectionFragment + } organization(selector: { organization: $organizationId }) { organization { - ...TargetLayout_OrganizationFragment + cleanId + ...TargetLayout_CurrentOrganizationFragment ...TargetSettingsPage_OrganizationFragment + me { + ...CDNAccessTokens_MeFragment + } } } project(selector: { organization: $organizationId, project: $projectId }) { - ...TargetLayout_ProjectFragment - } - targets(selector: { organization: $organizationId, project: $projectId }) { - ...TargetLayout_TargetConnectionFragment + cleanId + ...TargetLayout_CurrentProjectFragment } target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { - id + cleanId + name ...TargetSettingsPage_TargetFragment } + me { + ...TargetLayout_MeFragment + } ...TargetLayout_IsCDNEnabledFragment } `); +function TargetSettingsContent() { + const router = useRouteSelector(); + const [query] = useQuery({ + query: TargetSettingsPageQuery, + variables: { + organizationId: router.organizationId, + projectId: router.projectId, + targetId: router.targetId, + }, + }); + useNotFoundRedirectOnError(!!query.error); + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const currentProject = query.data?.project; + const currentTarget = query.data?.target; + const organizationConnection = query.data?.organizations; + const isCDNEnabled = query.data; + const organizationForSettings = useFragment( + TargetSettingsPage_OrganizationFragment, + currentOrganization, + ); + const targetForSettings = useFragment(TargetSettingsPage_TargetFragment, currentTarget); + + const canAccessTokens = canAccessTarget( + TargetAccessScope.TokensRead, + organizationForSettings?.me ?? null, + ); + const canDelete = canAccessTarget(TargetAccessScope.Delete, organizationForSettings?.me ?? null); + + if (query.error) { + return null; + } + + return ( + <TargetLayout + value="settings" + currentOrganization={currentOrganization ?? null} + currentProject={currentProject ?? null} + me={me ?? null} + organizations={organizationConnection ?? null} + isCDNEnabled={isCDNEnabled ?? null} + > + <div className="py-6"> + <Title>Settings + Manage your target settings. + + {currentOrganization && currentProject && currentTarget && organizationForSettings ? ( +
    + + {canAccessTokens && } + {canAccessTokens && } + + + {canDelete && ( + + )} +
    + ) : null} + + ); +} + function SettingsPage(): ReactElement { return ( <> - - <TargetLayout - value="settings" - className="flex flex-col gap-16" - query={TargetSettingsPageQuery} - > - {props => - props.organization ? ( - <Page target={props.target!} organization={props.organization.organization} /> - ) : null - } - </TargetLayout> + <MetaTitle title="Settings" /> + <TargetSettingsContent /> </> ); } diff --git a/packages/web/app/pages/[orgId]/[projectId]/index.tsx b/packages/web/app/pages/[orgId]/[projectId]/index.tsx index d6b1b87f2..c50f5291d 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/index.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/index.tsx @@ -1,154 +1,369 @@ -import { ReactElement } from 'react'; +import { ReactElement, useMemo, useRef } from 'react'; import NextLink from 'next/link'; -import clsx from 'clsx'; +import { formatISO, subDays } from 'date-fns'; +import * as echarts from 'echarts'; +import ReactECharts from 'echarts-for-react'; +import { Globe, History } from 'lucide-react'; +import AutoSizer from 'react-virtualized-auto-sizer'; import { useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; -import { ProjectLayout } from '@/components/layouts'; +import { ProjectLayout } from '@/components/layouts/project'; import { - Activities, - Badge, - Button, - Card, - EmptyList, - Heading, - TimeAgo, - Title, -} from '@/components/v2'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/v2/dropdown'; -import { LinkIcon, MoreIcon, SettingsIcon } from '@/components/v2/icon'; -import { graphql } from '@/gql'; -import { TargetQuery, TargetsDocument, VersionsDocument } from '@/graphql'; -import { useClipboard } from '@/lib/hooks/use-clipboard'; + createEmptySeries, + fullSeries, + resolutionToMilliseconds, +} from '@/components/target/operations/utils'; +import { Subtitle, Title } from '@/components/ui/page'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Activities, Card, EmptyList, MetaTitle } from '@/components/v2'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { useFormattedNumber } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { useRouteSelector } from '@/lib/hooks/use-route-selector'; import { withSessionProtection } from '@/lib/supertokens/guard'; +import { cn, pluralize } from '@/lib/utils'; -const TargetCard = ({ - target, -}: { - target: Exclude<TargetQuery['target'], null | undefined>; +function floorDate(date: Date): Date { + const time = 1000 * 60; + return new Date(Math.floor(date.getTime() / time) * time); +} + +const TargetCard_TargetFragment = graphql(` + fragment TargetCard_TargetFragment on Target { + id + name + cleanId + } +`); + +const TargetCard = (props: { + target: FragmentType<typeof TargetCard_TargetFragment> | null; + highestNumberOfRequests: number; + period: { + from: string; + to: string; + }; + requestsOverTime: { date: string; value: number }[] | null; + schemaVersionsCount: number | null; + days: number; }): ReactElement => { const router = useRouteSelector(); - const copyToClipboard = useClipboard(); - const [versionsQuery] = useQuery({ - query: VersionsDocument, - variables: { - selector: { - organization: router.organizationId, - project: router.projectId, - target: target.cleanId, - }, - limit: 1, - }, - requestPolicy: 'cache-and-network', - }); - const versions = versionsQuery.data?.schemaVersions; - const lastVersion = versions?.nodes[0]; - const author = lastVersion?.log && 'author' in lastVersion.log ? lastVersion.log.author : null; - const isValid = lastVersion?.valid; - const href = `/${router.organizationId}/${router.projectId}/${target.cleanId}`; + const target = useFragment(TargetCard_TargetFragment, props.target); + const href = target ? `/${router.organizationId}/${router.projectId}/${target.cleanId}` : ''; + const { period, highestNumberOfRequests } = props; + const interval = resolutionToMilliseconds(props.days, period); + const requests = useMemo(() => { + if (props.requestsOverTime?.length) { + return fullSeries( + props.requestsOverTime.map<[string, number]>(node => [node.date, node.value]), + interval, + props.period, + ); + } + + return createEmptySeries({ interval, period }); + }, [interval]); + + const totalNumberOfRequests = useMemo( + () => requests.reduce((acc, [_, value]) => acc + value, 0), + [requests], + ); + const totalNumberOfVersions = props.schemaVersionsCount ?? 0; + const requestsInDateRange = useFormattedNumber(totalNumberOfRequests); + const schemaVersionsInDateRange = useFormattedNumber(totalNumberOfVersions); return ( - <Card as={NextLink} key={target.id} className="hover:bg-gray-800/40" href={href}> - <div className="flex items-start justify-between gap-2"> - <div> - <h2 className="line-clamp-2 text-lg font-bold">{target.name}</h2> - </div> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button> - <MoreIcon /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent sideOffset={5} align="start"> - <DropdownMenuItem - onClick={async e => { - e.stopPropagation(); - await copyToClipboard(`${location.origin}${href}`); - }} - > - <LinkIcon /> - Share Link - </DropdownMenuItem> - <NextLink - href={`/${router.organizationId}/${router.projectId}/${target.cleanId}#settings`} - > - <DropdownMenuItem> - <SettingsIcon /> - Settings - </DropdownMenuItem> - </NextLink> - </DropdownMenuContent> - </DropdownMenu> - </div> - {author && ( - <> - <div className={clsx('mt-2.5 mb-1.5 flex items-center gap-x-2 text-sm text-gray-500')}> - {lastVersion ? ( - <> - <Badge color={isValid ? 'green' : 'red'} /> - <span> - {'commit' in lastVersion.log ? lastVersion.log.commit.substring(0, 7) : ''} - </span> - <span> - - Published <TimeAgo date={lastVersion.date} /> - </span> - </> - ) : ( - <Badge color="yellow" /> - )} + <Card + as={NextLink} + href={href} + className="h-full pt-4 px-0 self-start hover:bg-gray-800/40 hover:shadow-md hover:shadow-gray-800/50" + > + <TooltipProvider> + <div className="flex items-start gap-x-2"> + <div className="grow"> + <div> + <AutoSizer disableHeight> + {size => ( + <ReactECharts + style={{ width: size.width, height: 90 }} + option={{ + animation: !!target, + color: ['#f4b740'], + grid: { + left: 0, + top: 10, + right: 0, + bottom: 10, + }, + tooltip: { + trigger: 'axis', + axisPointer: { + label: { + formatter({ value }: { value: number }) { + return new Date(value).toDateString(); + }, + }, + }, + }, + xAxis: [ + { + show: false, + type: 'time', + boundaryGap: false, + }, + ], + yAxis: [ + { + show: false, + type: 'value', + min: 0, + max: highestNumberOfRequests, + }, + ], + series: [ + { + name: 'Requests', + type: 'line', + smooth: false, + lineStyle: { + width: 2, + }, + showSymbol: false, + areaStyle: { + opacity: 0.8, + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { + offset: 0, + color: 'rgba(244, 184, 64, 0.20)', + }, + { + offset: 1, + color: 'rgba(244, 184, 64, 0)', + }, + ]), + }, + emphasis: { + focus: 'series', + }, + data: requests, + }, + ], + }} + /> + )} + </AutoSizer> + </div> + <div className="flex flex-row gap-y-3 px-4 pt-4 justify-between items-center"> + <div> + {target ? ( + <h4 className="line-clamp-2 text-lg font-bold">{target.name}</h4> + ) : ( + <div className="w-48 h-4 py-2 bg-gray-800 rounded-full animate-pulse" /> + )} + </div> + <div className="flex flex-col gap-y-2 py-1"> + {target ? ( + <> + <Tooltip> + <TooltipTrigger> + <div className="flex flex-row gap-x-2 items-center"> + <Globe className="w-4 h-4 text-gray-500" /> + <div className="text-xs"> + {requestsInDateRange}{' '} + {pluralize(totalNumberOfRequests, 'request', 'requests')} + </div> + </div> + </TooltipTrigger> + <TooltipContent> + Number of GraphQL requests in the last {props.days} days. + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger> + <div className="flex flex-row gap-x-2 items-center"> + <History className="w-4 h-4 text-gray-500" /> + <div className="text-xs"> + {schemaVersionsInDateRange}{' '} + {pluralize(totalNumberOfVersions, 'commit', 'commits')} + </div> + </div> + </TooltipTrigger> + <TooltipContent> + Number of schemas pushed to this project in the last {props.days} days. + </TooltipContent> + </Tooltip> + </> + ) : ( + <> + <div className="w-16 h-2 my-1 bg-gray-800 rounded-full animate-pulse" /> + <div className="w-16 h-2 my-1 bg-gray-800 rounded-full animate-pulse" /> + </> + )} + </div> + </div> </div> - </> - )} + </div> + </TooltipProvider> </Card> ); }; const Page = () => { const router = useRouteSelector(); - const [targetsQuery] = useQuery({ - query: TargetsDocument, + const period = useRef<{ + from: string; + to: string; + }>(); + const days = 14; + + if (!period.current) { + const now = floorDate(new Date()); + const from = formatISO(subDays(now, days)); + const to = formatISO(now); + + period.current = { from, to }; + } + + const [query] = useQuery({ + query: ProjectOverviewPageQuery, variables: { - selector: { - organization: router.organizationId, - project: router.projectId, - }, + organizationId: router.organizationId, + projectId: router.projectId, + chartResolution: days, // 14 days = 14 data points + period: period.current, }, }); - const targets = targetsQuery.data?.targets; + useNotFoundRedirectOnError(!!query.error); + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const currentProject = query.data?.project; + const organizationConnection = query.data?.organizations; + const targetConnection = query.data?.targets; + const targets = targetConnection?.nodes; + + const highestNumberOfRequests = useMemo(() => { + if (targets?.length) { + return targets.reduce((max, target) => { + return Math.max( + max, + target.requestsOverTime.reduce((max, { value }) => Math.max(max, value), 0), + ); + }, 100); + } + + return 100; + }, [targets]); + + if (query.error) { + return null; + } return ( - <> - <div className="flex grow flex-col gap-4"> - <Heading>List of targets</Heading> - {targets && targets.total === 0 ? ( - <EmptyList - title="Hive is waiting for your first target" - description='You can create a target by clicking the "New Target" button' - docsUrl="/management/targets#create-a-new-target" - /> - ) : ( - targets?.nodes.map(target => <TargetCard key={target.id} target={target} />) - )} + <ProjectLayout + value="targets" + className="flex justify-between gap-12" + currentOrganization={currentOrganization ?? null} + currentProject={currentProject ?? null} + me={me ?? null} + organizations={organizationConnection ?? null} + > + <div className="grow"> + <div className="py-6"> + <Title>Targets + A list of available targets in your project. + +
    + {targets ? ( + targets.length === 0 ? ( + + ) : ( + targets + .sort((a, b) => { + const diff = b.schemaVersionsCount - a.schemaVersionsCount; + + if (diff !== 0) { + return diff; + } + + return a.name.localeCompare(b.name); + }) + .map(target => ( + + )) + ) + ) : ( + <> + {Array.from({ length: 4 }).map((_, index) => ( + + ))} + + )} +
    - + ); }; const ProjectOverviewPageQuery = graphql(` - query ProjectOverviewPageQuery($organizationId: ID!, $projectId: ID!) { + query ProjectOverviewPageQuery( + $organizationId: ID! + $projectId: ID! + $chartResolution: Int! + $period: DateRangeInput! + ) { organization(selector: { organization: $organizationId }) { organization { - ...ProjectLayout_OrganizationFragment + ...ProjectLayout_CurrentOrganizationFragment } } project(selector: { organization: $organizationId, project: $projectId }) { - ...ProjectLayout_ProjectFragment + ...ProjectLayout_CurrentProjectFragment + } + organizations { + ...ProjectLayout_OrganizationConnectionFragment + } + targets(selector: { organization: $organizationId, project: $projectId }) { + total + nodes { + id + name + ...TargetCard_TargetFragment + requestsOverTime(resolution: $chartResolution, period: $period) { + date + value + } + schemaVersionsCount(period: $period) + } + } + me { + ...ProjectLayout_MeFragment } } `); @@ -156,10 +371,8 @@ const ProjectOverviewPageQuery = graphql(` function ProjectsPage(): ReactElement { return ( <> - - <ProjectLayout value="targets" className="flex gap-x-5" query={ProjectOverviewPageQuery}> - {() => <Page />} - </ProjectLayout> + <MetaTitle title="Targets" /> + <Page /> </> ); } diff --git a/packages/web/app/pages/[orgId]/[projectId]/view/alerts.tsx b/packages/web/app/pages/[orgId]/[projectId]/view/alerts.tsx index a640b393a..486f5fb06 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/view/alerts.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/view/alerts.tsx @@ -1,124 +1,162 @@ import { ReactElement, useState } from 'react'; -import { useMutation, useQuery } from 'urql'; +import { useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; -import { ProjectLayout } from '@/components/layouts'; +import { ProjectLayout } from '@/components/layouts/project'; +import { AlertsTable, AlertsTable_AlertFragment } from '@/components/project/alerts/alerts-table'; +import { + ChannelsTable, + ChannelsTable_AlertChannelFragment, +} from '@/components/project/alerts/channels-table'; +import { + CreateAlertModal, + CreateAlertModal_AlertChannelFragment, + CreateAlertModal_TargetFragment, +} from '@/components/project/alerts/create-alert'; +import { CreateChannelModal } from '@/components/project/alerts/create-channel'; +import { DeleteAlertsButton } from '@/components/project/alerts/delete-alerts-button'; +import { DeleteChannelsButton } from '@/components/project/alerts/delete-channels-button'; +import { Button } from '@/components/ui/button'; import { - Button, Card, - Checkbox, - DocsLink, - DocsNote, - Heading, - Table, - Tag, - TBody, - Td, - Title, - Tr, -} from '@/components/v2'; -import { CreateAlertModal, CreateChannelModal } from '@/components/v2/modals'; + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Subtitle, Title } from '@/components/ui/page'; +import { DocsLink, MetaTitle } from '@/components/v2'; import { FragmentType, graphql, useFragment } from '@/gql'; -import { - AlertChannelsDocument, - AlertChannelType, - AlertsDocument, - DeleteAlertChannelsDocument, - DeleteAlertsDocument, -} from '@/graphql'; import { ProjectAccessScope, useProjectAccess } from '@/lib/access/project'; import { useRouteSelector, useToggle } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { withSessionProtection } from '@/lib/supertokens/guard'; -function Channels(): ReactElement { +function Channels(props: { + channels: FragmentType<typeof ChannelsTable_AlertChannelFragment>[]; +}): ReactElement { const router = useRouteSelector(); - const [checked, setChecked] = useState<string[]>([]); - const [channelAlertsQuery] = useQuery({ - query: AlertChannelsDocument, - variables: { - selector: { - organization: router.organizationId, - project: router.projectId, - }, - }, - requestPolicy: 'cache-and-network', - }); + const [selected, setSelected] = useState<string[]>([]); const [isModalOpen, toggleModalOpen] = useToggle(); - const [mutation, mutate] = useMutation(DeleteAlertChannelsDocument); - - const channelAlerts = channelAlertsQuery.data?.alertChannels || []; + const channels = props.channels ?? []; return ( <Card> - <Heading className="mb-2">Available Channels</Heading> - <DocsNote> - Alert Channels are a way to configure <strong>how</strong> you want to receive alerts and - notifications from Hive.{' '} - <DocsLink href="/management/projects#alert-channels">Learn more</DocsLink> - </DocsNote> - <Table> - <TBody> - {channelAlerts.map(channelAlert => ( - <Tr key={channelAlert.id}> - <Td width="1"> - <Checkbox - onCheckedChange={isChecked => - setChecked( - isChecked - ? [...checked, channelAlert.id] - : checked.filter(k => k !== channelAlert.id), - ) - } - checked={checked.includes(channelAlert.id)} - /> - </Td> - <Td>{channelAlert.name}</Td> - <Td className="text-xs truncate text-gray-400"> - {channelAlert.__typename === 'AlertSlackChannel' - ? channelAlert.channel - : channelAlert.__typename === 'AlertWebhookChannel' - ? channelAlert.endpoint - : ''} - </Td> - <Td> - <Tag color={channelAlert.type === AlertChannelType.Webhook ? 'green' : 'yellow'}> - {channelAlert.type} - </Tag> - </Td> - </Tr> - ))} - </TBody> - </Table> - <div className="mt-4 flex gap-x-2"> - <Button size="large" variant="primary" onClick={toggleModalOpen}> - Add channel - </Button> - {channelAlerts.length > 0 && ( - <Button - size="large" - danger - disabled={checked.length === 0 || mutation.fetching} - onClick={async () => { - await mutate({ - input: { - organization: router.organizationId, - project: router.projectId, - channels: checked, - }, - }); - setChecked([]); - }} + <CardHeader> + <CardTitle>Channels</CardTitle> + <CardDescription> + Alert Channels are a way to configure <strong>how</strong> you want to receive alerts and + notifications from Hive. + <br /> + <DocsLink + className="text-muted-foreground text-sm" + href="/management/projects#alert-channels" > - Delete {checked.length || null} + Learn more + </DocsLink> + </CardDescription> + </CardHeader> + <CardContent> + <ChannelsTable + channels={channels} + isChecked={channelId => selected.includes(channelId)} + onCheckedChange={(channelId, isChecked) => { + setSelected( + isChecked ? [...selected, channelId] : selected.filter(k => k !== channelId), + ); + }} + /> + </CardContent> + <CardFooter> + <div className="mt-4 flex gap-x-2"> + <Button variant="default" onClick={toggleModalOpen}> + Add channel </Button> - )} - </div> + {channels.length > 0 && ( + <DeleteChannelsButton + organizationId={router.organizationId} + projectId={router.projectId} + selected={selected} + onSuccess={() => { + setSelected([]); + }} + /> + )} + </div> + </CardFooter> {isModalOpen && <CreateChannelModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />} </Card> ); } -const AlertsPage_OrganizationFragment = graphql(` - fragment AlertsPage_OrganizationFragment on Organization { +export function Alerts(props: { + alerts: FragmentType<typeof AlertsTable_AlertFragment>[]; + channels: FragmentType<typeof CreateAlertModal_AlertChannelFragment>[]; + targets: FragmentType<typeof CreateAlertModal_TargetFragment>[]; +}) { + const [selected, setSelected] = useState<string[]>([]); + const router = useRouteSelector(); + const [isModalOpen, toggleModalOpen] = useToggle(); + const alerts = props.alerts ?? []; + + console.log('isModalOpen', isModalOpen); + + return ( + <> + <Card> + <CardHeader> + <CardTitle>Alerts and Notifications</CardTitle> + <CardDescription> + Alerts are a way to configure <strong>when</strong> you want to receive alerts and + notifications from Hive. + <br /> + <DocsLink + className="text-muted-foreground text-sm" + href="/management/projects#alerts-and-notifications-1" + > + Learn more + </DocsLink> + </CardDescription> + </CardHeader> + <CardContent> + <AlertsTable + alerts={alerts} + isChecked={alertId => selected.includes(alertId)} + onCheckedChange={(alertId, isChecked) => { + setSelected(isChecked ? [...selected, alertId] : selected.filter(k => k !== alertId)); + }} + /> + </CardContent> + <CardFooter> + <div className="flex gap-x-2"> + <Button variant="default" onClick={toggleModalOpen}> + Create alert + </Button> + <DeleteAlertsButton + organizationId={router.organizationId} + projectId={router.projectId} + selected={selected} + onSuccess={() => { + setSelected([]); + }} + /> + </div> + </CardFooter> + </Card> + {isModalOpen && ( + <CreateAlertModal + targets={props.targets} + channels={props.channels} + isOpen={isModalOpen} + toggleModalOpen={toggleModalOpen} + /> + )} + </> + ); +} + +const ProjectAlertsPage_OrganizationFragment = graphql(` + fragment ProjectAlertsPage_OrganizationFragment on Organization { cleanId me { ...CanAccessProject_MemberFragment @@ -126,130 +164,104 @@ const AlertsPage_OrganizationFragment = graphql(` } `); -const AlertsPage_ProjectFragment = graphql(` - fragment AlertsPage_ProjectFragment on Project { - cleanId - } -`); - -const Page = (props: { - organization: FragmentType<typeof AlertsPage_OrganizationFragment>; - project: FragmentType<typeof AlertsPage_ProjectFragment>; -}) => { - const organization = useFragment(AlertsPage_OrganizationFragment, props.organization); - const project = useFragment(AlertsPage_ProjectFragment, props.project); - useProjectAccess({ - scope: ProjectAccessScope.Alerts, - member: organization.me, - redirect: true, - }); - const [checked, setChecked] = useState<string[]>([]); - const router = useRouteSelector(); - const [isModalOpen, toggleModalOpen] = useToggle(); - const [mutation, mutate] = useMutation(DeleteAlertsDocument); - const [alertsQuery] = useQuery({ - query: AlertsDocument, - variables: { - selector: { - organization: organization.cleanId, - project: project.cleanId, - }, - }, - requestPolicy: 'cache-and-network', - }); - - const alerts = alertsQuery.data?.alerts || []; - return ( - <> - <Channels /> - <Card> - <Heading className="mb-2">Active Alerts and Notifications</Heading> - <DocsNote> - Alerts are a way to configure <strong>when</strong> you want to receive alerts and - notifications from Hive.{' '} - <DocsLink href="/management/projects#alerts-and-notifications-1">Learn more</DocsLink> - </DocsNote> - <Table> - <TBody> - {alerts.map(alert => ( - <Tr key={alert.id}> - <Td width="1"> - <Checkbox - onCheckedChange={isChecked => - setChecked( - isChecked ? [...checked, alert.id] : checked.filter(k => k !== alert.id), - ) - } - checked={checked.includes(alert.id)} - /> - </Td> - <Td> - <span className="capitalize"> - {alert.type.replaceAll('_', ' ').toLowerCase()} - </span> - </Td> - <Td>Channel: {alert.channel.name}</Td> - <Td>Target: {alert.target.name}</Td> - </Tr> - ))} - </TBody> - </Table> - <div className="mt-4 flex gap-x-2"> - <Button size="large" variant="primary" onClick={toggleModalOpen}> - Create alert - </Button> - {alerts.length > 0 && ( - <Button - size="large" - danger - disabled={checked.length === 0 || mutation.fetching} - onClick={async () => { - await mutate({ - input: { - organization: router.organizationId, - project: router.projectId, - alerts: checked, - }, - }); - setChecked([]); - }} - > - Delete {checked.length || null} - </Button> - )} - </div> - </Card> - {isModalOpen && <CreateAlertModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />} - </> - ); -}; - const ProjectAlertsPageQuery = graphql(` query ProjectAlertsPageQuery($organizationId: ID!, $projectId: ID!) { organization(selector: { organization: $organizationId }) { organization { - ...ProjectLayout_OrganizationFragment - ...AlertsPage_OrganizationFragment + ...ProjectLayout_CurrentOrganizationFragment + ...ProjectAlertsPage_OrganizationFragment } } project(selector: { organization: $organizationId, project: $projectId }) { - ...ProjectLayout_ProjectFragment - ...AlertsPage_ProjectFragment + ...ProjectLayout_CurrentProjectFragment + targets { + nodes { + ...CreateAlertModal_TargetFragment + } + } + alerts { + ...AlertsTable_AlertFragment + } + alertChannels { + ...ChannelsTable_AlertChannelFragment + ...CreateAlertModal_AlertChannelFragment + } + } + organizations { + ...ProjectLayout_OrganizationConnectionFragment + } + me { + ...ProjectLayout_MeFragment } } `); +function AlertsPageContent() { + const router = useRouteSelector(); + const [query] = useQuery({ + query: ProjectAlertsPageQuery, + variables: { + organizationId: router.organizationId, + projectId: router.projectId, + }, + requestPolicy: 'cache-and-network', + }); + + useNotFoundRedirectOnError(!!query.error); + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const currentProject = query.data?.project; + const organizationConnection = query.data?.organizations; + const organizationForAlerts = useFragment( + ProjectAlertsPage_OrganizationFragment, + currentOrganization, + ); + + useProjectAccess({ + scope: ProjectAccessScope.Alerts, + member: organizationForAlerts?.me ?? null, + redirect: true, + }); + + if (query.error) { + return null; + } + + const alerts = currentProject?.alerts || []; + const channels = currentProject?.alertChannels || []; + const targets = currentProject?.targets?.nodes || []; + + return ( + <ProjectLayout + currentOrganization={currentOrganization ?? null} + currentProject={currentProject ?? null} + organizations={organizationConnection ?? null} + me={me ?? null} + value="alerts" + className="flex flex-col gap-y-10" + > + <div> + <div className="py-6"> + <Title>Alerts and Notifications + Configure alerts and notifications for your project. + + {currentProject && currentOrganization ? ( +
    + + +
    + ) : null} + + + ); +} + function AlertsPage(): ReactElement { return ( <> - - <ProjectLayout - value="alerts" - className="flex flex-col gap-y-10" - query={ProjectAlertsPageQuery} - > - {props => <Page organization={props.organization.organization} project={props.project} />} - </ProjectLayout> + <MetaTitle title="Alerts" /> + <AlertsPageContent /> </> ); } diff --git a/packages/web/app/pages/[orgId]/[projectId]/view/policy.tsx b/packages/web/app/pages/[orgId]/[projectId]/view/policy.tsx index 97d2ff750..c9dc4c9cd 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/view/policy.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/view/policy.tsx @@ -1,11 +1,17 @@ import { ReactElement } from 'react'; -import { useMutation } from 'urql'; +import { useMutation, useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; -import { ProjectLayout } from '@/components/layouts'; +import { ProjectLayout } from '@/components/layouts/project'; import { PolicySettings } from '@/components/policy/policy-settings'; -import { Card, DocsLink, DocsNote, Heading, Title } from '@/components/v2'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Subtitle, Title } from '@/components/ui/page'; +import { DocsLink, MetaTitle } from '@/components/v2'; import { graphql } from '@/gql'; +import { ProjectAccessScope } from '@/gql/graphql'; import { RegistryModel } from '@/graphql'; +import { useProjectAccess } from '@/lib/access/project'; +import { useRouteSelector } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { withSessionProtection } from '@/lib/supertokens/guard'; const ProjectPolicyPageQuery = graphql(` @@ -23,12 +29,15 @@ const ProjectPolicyPageQuery = graphql(` } } } - ...ProjectLayout_OrganizationFragment + me { + ...CanAccessProject_MemberFragment + } + ...ProjectLayout_CurrentOrganizationFragment } } project(selector: { organization: $organizationId, project: $projectId }) { id - ...ProjectLayout_ProjectFragment + ...ProjectLayout_CurrentProjectFragment registryModel schemaPolicy { id @@ -36,6 +45,12 @@ const ProjectPolicyPageQuery = graphql(` ...PolicySettings_SchemaPolicyFragment } } + organizations { + ...ProjectLayout_OrganizationConnectionFragment + } + me { + ...ProjectLayout_MeFragment + } } `); @@ -51,7 +66,7 @@ const UpdateSchemaPolicyForProject = graphql(` ok { project { id - ...ProjectLayout_ProjectFragment + ...ProjectLayout_CurrentProjectFragment schemaPolicy { id updatedAt @@ -63,23 +78,60 @@ const UpdateSchemaPolicyForProject = graphql(` } `); -function ProjectPolicyPage(): ReactElement { +function ProjectPolicyContent() { const [mutation, mutate] = useMutation(UpdateSchemaPolicyForProject); + const router = useRouteSelector(); + const [query] = useQuery({ + query: ProjectPolicyPageQuery, + variables: { + organizationId: router.organizationId, + projectId: router.projectId, + }, + requestPolicy: 'cache-and-network', + }); + + useNotFoundRedirectOnError(!!query.error); + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const currentProject = query.data?.project; + const organizationConnection = query.data?.organizations; + + useProjectAccess({ + scope: ProjectAccessScope.Settings, + member: currentOrganization?.me ?? null, + redirect: true, + }); + + if (query.error) { + return null; + } + + const isLegacyProject = currentProject?.registryModel === RegistryModel.Legacy; return ( - <> - <Title title="Project Schema Policy" /> - <ProjectLayout - value="policy" - className="flex flex-col gap-y-10" - query={ProjectPolicyPageQuery} - > - {(props, selector) => { - return props.project && props.organization ? ( - <Card> - <Heading className="mb-2">Project Schema Policy</Heading> - {props.project.registryModel === RegistryModel.Legacy ? ( - <DocsNote warn> + <ProjectLayout + currentOrganization={currentOrganization ?? null} + currentProject={currentProject ?? null} + organizations={organizationConnection ?? null} + me={me ?? null} + value="policy" + className="flex flex-col gap-y-10" + > + <div> + <div className="py-6"> + <Title>Organization Schema Policy + + Schema Policies enable developers to define additional semantic checks on the GraphQL + schema. + + + {currentProject && currentOrganization ? ( + + + Rules + {currentProject && isLegacyProject ? ( + Policy feature is only available for projects that are using the new registry model. @@ -88,51 +140,68 @@ function ProjectPolicyPage(): ReactElement { policy feature.
    - + Learn more - +
    ) : ( - <> - - Schema Policies enable developers to define additional semantic - checks on the GraphQL schema. At the project level, policies can be defined to - affect all targets, and override policy configuration defined at the - organization level.{' '} - Learn more - - {props.organization.organization.schemaPolicy === null || - props.organization.organization.schemaPolicy?.allowOverrides ? ( - r.rule.id, - )} - error={ - mutation.error?.message || - mutation.data?.updateSchemaPolicyForProject.error?.message - } - onSave={async newPolicy => { - await mutate({ - selector, - policy: newPolicy, - }); - }} - currentState={props.project.schemaPolicy} - /> - ) : ( -
    -

    !

    - Organization settings does not allow projects to override policy. Please - consult your organization administrator. -
    - )} - + + At the project level, policies can be defined to affect all targets, and override + policy configuration defined at the organization level. +
    + + Learn more + +
    )} -
    - ) : null; - }} - + + + {currentOrganization.schemaPolicy === null || + currentOrganization.schemaPolicy?.allowOverrides ? ( + r.rule.id)} + error={ + mutation.error?.message || + mutation.data?.updateSchemaPolicyForProject.error?.message + } + onSave={async newPolicy => { + await mutate({ + selector: { + organization: router.organizationId, + project: router.projectId, + }, + policy: newPolicy, + }); + }} + currentState={currentProject.schemaPolicy} + /> + ) : ( +
    +

    !

    + Organization settings does not allow projects to override policy. Please consult + your organization administrator. +
    + )} +
    + + ) : null} + + + ); +} + +function ProjectPolicyPage(): ReactElement { + return ( + <> + + ); } diff --git a/packages/web/app/pages/[orgId]/[projectId]/view/settings.tsx b/packages/web/app/pages/[orgId]/[projectId]/view/settings.tsx index 9fd46d411..e4580eee8 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/view/settings.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/view/settings.tsx @@ -3,30 +3,32 @@ import { useFormik } from 'formik'; import { useMutation, useQuery } from 'urql'; import * as Yup from 'yup'; import { authenticated } from '@/components/authenticated-container'; -import { ProjectLayout } from '@/components/layouts'; +import { ProjectLayout } from '@/components/layouts/project'; import { ExternalCompositionSettings } from '@/components/project/settings/external-composition'; import { ModelMigrationSettings } from '@/components/project/settings/model-migration'; +import { Button } from '@/components/ui/button'; import { - Button, Card, - DocsLink, - DocsNote, - Heading, - Input, - Link, - Select, - Tag, - Title, -} from '@/components/v2'; + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Subtitle, Title } from '@/components/ui/page'; +import { DocsLink, Input, Link, MetaTitle, Select, Tag } from '@/components/v2'; import { DeleteProjectModal } from '@/components/v2/modals'; -import { FragmentType, graphql, useFragment } from '@/gql'; +import { graphql, useFragment } from '@/gql'; import { GetGitHubIntegrationDetailsDocument, ProjectType } from '@/graphql'; import { canAccessProject, ProjectAccessScope, useProjectAccess } from '@/lib/access/project'; import { useRouteSelector, useToggle } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { withSessionProtection } from '@/lib/supertokens/guard'; -const Settings_UpdateProjectGitRepositoryMutation = graphql(` - mutation Settings_UpdateProjectGitRepository($input: UpdateProjectGitRepositoryInput!) { +const ProjectSettingsPage_UpdateProjectGitRepositoryMutation = graphql(` + mutation ProjectSettingsPage_UpdateProjectGitRepository( + $input: UpdateProjectGitRepositoryInput! + ) { updateProjectGitRepository(input: $input) { ok { selector { @@ -59,7 +61,7 @@ function GitHubIntegration({ }, }); - const [mutation, mutate] = useMutation(Settings_UpdateProjectGitRepositoryMutation); + const [mutation, mutate] = useMutation(ProjectSettingsPage_UpdateProjectGitRepositoryMutation); const { handleSubmit, values, handleChange, handleBlur, isSubmitting, errors, touched } = useFormik({ enableReinitialize: true, @@ -83,62 +85,78 @@ function GitHubIntegration({ return null; } - if (integrationQuery.data?.gitHubIntegration) { - return ( - <> -
    - ({ + name: repo.nameWithOwner, + value: repo.nameWithOwner, + }))} + value={values.gitRepository ?? undefined} + onChange={handleChange} + onBlur={handleBlur} + isInvalid={!!(touched.gitRepository && errors.gitRepository)} + /> + {touched.gitRepository && (errors.gitRepository || mutation.error) && ( +
    + {errors.gitRepository ?? + mutation.error?.graphQLErrors[0]?.message ?? + mutation.error?.message} +
    + )} + {mutation.data?.updateProjectGitRepository.error && ( +
    + {mutation.data.updateProjectGitRepository.error.message} +
    + )} + + ) : ( + + The organization is not connected to our GitHub Application. + + Visit settings + + to configure it. + + )} + + {githubIntegration ? ( + + + + ) : null} + +
    ); } -const Settings_UpdateProjectNameMutation = graphql(` - mutation Settings_UpdateProjectName($input: UpdateProjectNameInput!) { +const ProjectSettingsPage_UpdateProjectNameMutation = graphql(` + mutation ProjectSettingsPage_UpdateProjectName($input: UpdateProjectNameInput!) { updateProjectName(input: $input) { ok { selector { @@ -157,8 +175,8 @@ const Settings_UpdateProjectNameMutation = graphql(` } `); -const SettingsPage_OrganizationFragment = graphql(` - fragment SettingsPage_OrganizationFragment on Organization { +const ProjectSettingsPage_OrganizationFragment = graphql(` + fragment ProjectSettingsPage_OrganizationFragment on Organization { cleanId me { ...CanAccessProject_MemberFragment @@ -167,8 +185,8 @@ const SettingsPage_OrganizationFragment = graphql(` } `); -const SettingsPage_ProjectFragment = graphql(` - fragment SettingsPage_ProjectFragment on Project { +const ProjectSettingsPage_ProjectFragment = graphql(` + fragment ProjectSettingsPage_ProjectFragment on Project { name gitRepository type @@ -177,29 +195,63 @@ const SettingsPage_ProjectFragment = graphql(` } `); -const Page = (props: { - organization: FragmentType; - project: FragmentType; -}) => { - const organization = useFragment(SettingsPage_OrganizationFragment, props.organization); - const project = useFragment(SettingsPage_ProjectFragment, props.project); - useProjectAccess({ - scope: ProjectAccessScope.Settings, - member: organization.me, - redirect: true, - }); +const ProjectSettingsPageQuery = graphql(` + query ProjectSettingsPageQuery($organizationId: ID!, $projectId: ID!) { + organization(selector: { organization: $organizationId }) { + organization { + ...ProjectSettingsPage_OrganizationFragment + ...ProjectLayout_CurrentOrganizationFragment + } + } + project(selector: { organization: $organizationId, project: $projectId }) { + ...ProjectLayout_CurrentProjectFragment + ...ProjectSettingsPage_ProjectFragment + } + organizations { + ...ProjectLayout_OrganizationConnectionFragment + } + me { + ...ProjectLayout_MeFragment + } + } +`); + +function ProjectSettingsContent() { const router = useRouteSelector(); const [isModalOpen, toggleModalOpen] = useToggle(); + const [query] = useQuery({ + query: ProjectSettingsPageQuery, + variables: { + organizationId: router.organizationId, + projectId: router.projectId, + }, + requestPolicy: 'cache-and-network', + }); - const [mutation, mutate] = useMutation(Settings_UpdateProjectNameMutation); + useNotFoundRedirectOnError(!!query.error); + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const currentProject = query.data?.project; + const organizationConnection = query.data?.organizations; + + const organization = useFragment(ProjectSettingsPage_OrganizationFragment, currentOrganization); + const project = useFragment(ProjectSettingsPage_ProjectFragment, currentProject); + useProjectAccess({ + scope: ProjectAccessScope.Settings, + member: organization?.me ?? null, + redirect: true, + }); + + const [mutation, mutate] = useMutation(ProjectSettingsPage_UpdateProjectNameMutation); const { handleSubmit, values, handleChange, handleBlur, isSubmitting, errors, touched } = useFormik({ enableReinitialize: true, initialValues: { - name: project?.name, + name: project?.name ?? '', }, - validationSchema: Yup.object().shape({ + validationSchema: Yup.object({ name: Yup.string().required('Project name is required'), }), onSubmit: values => @@ -217,127 +269,120 @@ const Page = (props: { }), }); - return ( - <> - - - Project Name - - Changing the name of your project will also change the slug of your project URL, and will - invalidate any existing links to your project. -
    - - You can read more about it in the documentation - -
    -
    - - -
    - {touched.name && (errors.name || mutation.error) && ( -
    - {errors.name ?? mutation.error?.graphQLErrors[0]?.message ?? mutation.error?.message} -
    - )} - {mutation.data?.updateProjectName.error && ( -
    {mutation.data.updateProjectName.error.message}
    - )} -
    - - - Git Repository - - Associate your project with a Git repository to enable commit linking and to allow CI - integration. -
    - - Learn more about GitHub integration - -
    - -
    - - {project.type === ProjectType.Federation ? ( - - ) : null} - - {canAccessProject(ProjectAccessScope.Delete, organization.me) && ( - -
    -
    - Delete Project - - Deleting an project will delete all the targets, schemas and data associated with - it. -
    - - This action is not reversible! You can find more information - about this process in the documentation - -
    -
    -
    - -
    -
    -
    - )} - - - ); -}; - -const ProjectSettingsPageQuery = graphql(` - query ProjectSettingsPageQuery($organizationId: ID!, $projectId: ID!) { - organization(selector: { organization: $organizationId }) { - organization { - ...SettingsPage_OrganizationFragment - ...ProjectLayout_OrganizationFragment - } - } - project(selector: { organization: $organizationId, project: $projectId }) { - ...ProjectLayout_ProjectFragment - ...SettingsPage_ProjectFragment - } + if (query.error) { + return null; } -`); -function SettingsPage(): ReactElement { + return ( + +
    +
    + Settings + Manage your project settings +
    +
    + {project && organization ? ( + <> + +
    + + + Project Name + + Changing the name of your project will also change the slug of your project + URL, and will invalidate any existing links to your project. +
    + + You can read more about it in the documentation + +
    +
    + + + {touched.name && (errors.name || mutation.error) && ( +
    + {errors.name ?? + mutation.error?.graphQLErrors[0]?.message ?? + mutation.error?.message} +
    + )} + {mutation.data?.updateProjectName.error && ( +
    + {mutation.data.updateProjectName.error.message} +
    + )} +
    + + + +
    +
    + + + + {project.type === ProjectType.Federation ? ( + + ) : null} + + {canAccessProject(ProjectAccessScope.Delete, organization.me) && ( + + + Delete Project + + Deleting an project will delete all the targets, schemas and data associated + with it. +
    + + This action is not reversible! You can find more + information about this process in the documentation + +
    +
    + + + +
    + )} + + + ) : null} +
    +
    +
    + ); +} + +function SettingsPage() { return ( <> - - <ProjectLayout - value="settings" - className="flex flex-col gap-y-10" - query={ProjectSettingsPageQuery} - > - {props => <Page organization={props.organization.organization} project={props.project} />} - </ProjectLayout> + <MetaTitle title="Project settings" /> + <ProjectSettingsContent /> </> ); } diff --git a/packages/web/app/pages/[orgId]/index.tsx b/packages/web/app/pages/[orgId]/index.tsx index e29458078..c7e8903ee 100644 --- a/packages/web/app/pages/[orgId]/index.tsx +++ b/packages/web/app/pages/[orgId]/index.tsx @@ -1,200 +1,387 @@ -import { ReactElement } from 'react'; +import { ReactElement, useMemo, useRef } from 'react'; import NextLink from 'next/link'; -import { onlyText } from 'react-children-utilities'; +import { formatISO, subDays } from 'date-fns'; +import * as echarts from 'echarts'; +import ReactECharts from 'echarts-for-react'; +import { Globe, History } from 'lucide-react'; +import AutoSizer from 'react-virtualized-auto-sizer'; import { useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; -import { OrganizationLayout } from '@/components/layouts'; +import { OrganizationLayout } from '@/components/layouts/organization'; import { - Activities, - Button, - Card, - EmptyList, - Heading, - Skeleton, - TimeAgo, - Title, -} from '@/components/v2'; -import { getActivity } from '@/components/v2/activities'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/v2/dropdown'; -import { LinkIcon, MoreIcon, SettingsIcon } from '@/components/v2/icon'; + createEmptySeries, + fullSeries, + resolutionToMilliseconds, +} from '@/components/target/operations/utils'; +import { Subtitle, Title } from '@/components/ui/page'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Activities, Card, EmptyList, MetaTitle } from '@/components/v2'; import { FragmentType, graphql, useFragment } from '@/gql'; -import { ProjectActivitiesDocument } from '@/graphql'; -import { canAccessProject, ProjectAccessScope } from '@/lib/access/project'; +import { ProjectType } from '@/gql/graphql'; import { writeLastVisitedOrganization } from '@/lib/cookies'; -import { fixDuplicatedFragments } from '@/lib/graphql'; -import { useClipboard } from '@/lib/hooks/use-clipboard'; +import { useFormattedNumber } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { useRouteSelector } from '@/lib/hooks/use-route-selector'; import { withSessionProtection } from '@/lib/supertokens/guard'; +import { pluralize } from '@/lib/utils'; -const projectActivitiesDocument = fixDuplicatedFragments(ProjectActivitiesDocument); +function floorDate(date: Date): Date { + const time = 1000 * 60; + return new Date(Math.floor(date.getTime() / time) * time); +} const ProjectCard_ProjectFragment = graphql(` fragment ProjectCard_ProjectFragment on Project { cleanId id name + type } `); -const ProjectCard_OrganizationFragment = graphql(` - fragment ProjectCard_OrganizationFragment on Organization { - me { - ...CanAccessProject_MemberFragment - } - } -`); +const projectTypeFullNames = { + [ProjectType.Federation]: 'Apollo Federation', + [ProjectType.Stitching]: 'Schema Stitching', + [ProjectType.Single]: 'Monolithic Schema', +}; const ProjectCard = (props: { - project: FragmentType<typeof ProjectCard_ProjectFragment>; - organization: FragmentType<typeof ProjectCard_OrganizationFragment>; + project: FragmentType<typeof ProjectCard_ProjectFragment> | null; + highestNumberOfRequests: number; + period: { + from: string; + to: string; + }; + requestsOverTime: { date: string; value: number }[] | null; + schemaVersionsCount: number | null; + days: number; }): ReactElement | null => { const project = useFragment(ProjectCard_ProjectFragment, props.project); - const organization = useFragment(ProjectCard_OrganizationFragment, props.organization); - const copyToClipboard = useClipboard(); const router = useRouteSelector(); - const [projectActivitiesQuery] = useQuery({ - query: projectActivitiesDocument, - variables: { - selector: { - organization: router.organizationId, - project: project.cleanId, - limit: 3, - }, - }, - requestPolicy: 'cache-and-network', - }); - const href = `/${router.organizationId}/${project.cleanId}`; - const lastActivity = projectActivitiesQuery.data?.projectActivities.nodes[0]; + const href = project ? `/${router.organizationId}/${project.cleanId}` : ''; + const { period, highestNumberOfRequests } = props; + + const interval = resolutionToMilliseconds(props.days, period); + const requests = useMemo(() => { + if (props.requestsOverTime?.length) { + return fullSeries( + props.requestsOverTime.map<[string, number]>(node => [node.date, node.value]), + interval, + props.period, + ); + } + + return createEmptySeries({ interval, period }); + }, [interval]); + + const totalNumberOfRequests = useMemo( + () => requests.reduce((acc, [_, value]) => acc + value, 0), + [requests], + ); + const totalNumberOfVersions = props.schemaVersionsCount ?? 0; + + const requestsInDateRange = useFormattedNumber(totalNumberOfRequests); + const schemaVersionsInDateRange = useFormattedNumber(totalNumberOfVersions); return ( <Card as={NextLink} - key={project.id} href={href} - className="h-full self-start hover:bg-gray-800/40" + className="h-full pt-4 px-0 self-start hover:bg-gray-800/40 hover:shadow-md hover:shadow-gray-800/50" > - <div className="flex items-start gap-x-2"> - <div className="grow"> - <h4 className="line-clamp-2 text-lg font-bold">{project.name}</h4> + <TooltipProvider> + <div className="flex items-start gap-x-2"> + <div className="grow"> + <div> + <AutoSizer disableHeight> + {size => ( + <ReactECharts + style={{ width: size.width, height: 90 }} + option={{ + animation: !!project, + color: ['#f4b740'], + grid: { + left: 0, + top: 10, + right: 0, + bottom: 10, + }, + tooltip: { + trigger: 'axis', + axisPointer: { + label: { + formatter({ value }: { value: number }) { + return new Date(value).toDateString(); + }, + }, + }, + }, + xAxis: [ + { + show: false, + type: 'time', + boundaryGap: false, + }, + ], + yAxis: [ + { + show: false, + type: 'value', + min: 0, + max: highestNumberOfRequests, + }, + ], + series: [ + { + name: 'Requests', + type: 'line', + smooth: false, + lineStyle: { + width: 2, + }, + showSymbol: false, + areaStyle: { + opacity: 0.8, + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { + offset: 0, + color: 'rgba(244, 184, 64, 0.20)', + }, + { + offset: 1, + color: 'rgba(244, 184, 64, 0)', + }, + ]), + }, + emphasis: { + focus: 'series', + }, + data: requests, + }, + ], + }} + /> + )} + </AutoSizer> + </div> + <div className="flex flex-row gap-y-3 px-4 pt-4 justify-between items-center"> + {project ? ( + <div> + <h4 className="line-clamp-2 text-lg font-bold">{project.name}</h4> + <p className="text-gray-300 text-xs">{projectTypeFullNames[project.type]}</p> + </div> + ) : ( + <div> + <div className="w-48 h-4 mb-4 py-2 bg-gray-800 rounded-full animate-pulse" /> + <div className="w-24 h-2 bg-gray-800 rounded-full animate-pulse" /> + </div> + )} + <div className="flex flex-col gap-y-2 py-1"> + {project ? ( + <> + <Tooltip> + <TooltipTrigger> + <div className="flex flex-row gap-x-2 items-center"> + <Globe className="w-4 h-4 text-gray-500" /> + <div className="text-xs"> + {requestsInDateRange}{' '} + {pluralize(totalNumberOfRequests, 'request', 'requests')} + </div> + </div> + </TooltipTrigger> + <TooltipContent> + Number of GraphQL requests in the last {props.days} days. + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger> + <div className="flex flex-row gap-x-2 items-center"> + <History className="w-4 h-4 text-gray-500" /> + <div className="text-xs"> + {schemaVersionsInDateRange}{' '} + {pluralize(totalNumberOfVersions, 'commit', 'commits')} + </div> + </div> + </TooltipTrigger> + <TooltipContent> + Number of schemas pushed to this project in the last {props.days} days. + </TooltipContent> + </Tooltip> + </> + ) : ( + <> + <div className="w-16 h-2 my-1 bg-gray-800 rounded-full animate-pulse" /> + <div className="w-16 h-2 my-1 bg-gray-800 rounded-full animate-pulse" /> + </> + )} + </div> + </div> + </div> </div> - - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button> - <MoreIcon /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent sideOffset={5} align="start"> - <DropdownMenuItem - onClick={async e => { - e.stopPropagation(); - await copyToClipboard(`${location.origin}${href}`); - }} - > - <LinkIcon /> - Share Link - </DropdownMenuItem> - {canAccessProject(ProjectAccessScope.Settings, organization.me) && ( - <NextLink href={`/${router.organizationId}/${project.cleanId}/view/settings`}> - <DropdownMenuItem> - <SettingsIcon /> - Settings - </DropdownMenuItem> - </NextLink> - )} - </DropdownMenuContent> - </DropdownMenu> - </div> - {lastActivity && ( - <div className="mt-5 text-xs font-medium text-gray-500"> - <span className="line-clamp-3"> - {/* fixes Warning: validateDOMNesting(...): <a> cannot appear as a descendant of <a> */} - {onlyText(getActivity(lastActivity).content)}{' '} - <TimeAgo date={lastActivity.createdAt} className="text-gray-300" /> - </span> - </div> - )} + </TooltipProvider> </Card> ); }; const OrganizationProjectsPageQuery = graphql(` - query OrganizationProjectsPageQuery($selector: OrganizationSelectorInput!) { - organization(selector: $selector) { + query OrganizationProjectsPageQuery( + $organizationId: ID! + $chartResolution: Int! + $period: DateRangeInput! + ) { + organization(selector: { organization: $organizationId }) { organization { - ...OrganizationLayout_OrganizationFragment - ...ProjectCard_OrganizationFragment + ...OrganizationLayout_CurrentOrganizationFragment } } - projects(selector: $selector) { + projects(selector: { organization: $organizationId }) { total nodes { id + name ...ProjectCard_ProjectFragment + requestsOverTime(resolution: $chartResolution, period: $period) { + date + value + } + schemaVersionsCount(period: $period) } } + organizations { + ...OrganizationLayout_OrganizationConnectionFragment + } + me { + ...OrganizationLayout_MeFragment + } } `); -function ProjectsPage(): ReactElement { +function OrganizationPageContent() { + const router = useRouteSelector(); + const days = 14; + const period = useRef<{ + from: string; + to: string; + }>(); + + if (!period.current) { + const now = floorDate(new Date()); + const from = formatISO(subDays(now, days)); + const to = formatISO(now); + + period.current = { from, to }; + } + + const [query] = useQuery({ + query: OrganizationProjectsPageQuery, + variables: { + organizationId: router.organizationId, + chartResolution: days, // 14 days = 14 data points + period: period.current, + }, + }); + + useNotFoundRedirectOnError(!!query.error); + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const organizationConnection = query.data?.organizations; + const projects = query.data?.projects; + + const highestNumberOfRequests = useMemo(() => { + let highest = 10; + + if (projects?.nodes.length) { + for (const project of projects.nodes) { + for (const dataPoint of project.requestsOverTime) { + if (dataPoint.value > highest) { + highest = dataPoint.value; + } + } + } + } + + return highest; + }, [projects]); + + if (query.error) { + return null; + } + + return ( + <OrganizationLayout + value="overview" + className="flex justify-between gap-12" + currentOrganization={currentOrganization ?? null} + organizations={organizationConnection ?? null} + me={me ?? null} + > + <> + <div className="grow"> + <div className="py-6"> + <Title>Projects + A list of available project in your organization. + + {currentOrganization && projects ? ( + projects.total === 0 ? ( + + ) : ( +
    + {projects.nodes + .sort((a, b) => { + const diff = b.schemaVersionsCount - a.schemaVersionsCount; + + if (diff !== 0) { + return diff; + } + + return a.name.localeCompare(b.name); + }) + .map(project => ( + + ))} +
    + ) + ) : ( +
    + {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
    + )} + + + + + ); +} + +function OrganizationPage(): ReactElement { return ( <> - - <OrganizationLayout - value="overview" - className="flex justify-between gap-5" - query={OrganizationProjectsPageQuery} - > - {({ projects, organization }) => - projects && - organization?.organization && ( - <> - <div className="grow"> - <Heading className="mb-4">Active Projects</Heading> - {projects.total === 0 ? ( - <EmptyList - title="Hive is waiting for your first project" - description='You can create a project by clicking the "Create Project" button' - docsUrl="/management/projects#create-a-new-project" - /> - ) : ( - <div className="grid grid-cols-2 gap-5 items-stretch"> - {/** TODO: use defer here :) */} - {projects === null - ? [1, 2].map(key => ( - <Card key={key}> - <div className="flex gap-x-2"> - <Skeleton visible className="h-12 w-12" /> - <div> - <Skeleton visible className="mb-2 h-3 w-16" /> - <Skeleton visible className="h-3 w-8" /> - </div> - </div> - <Skeleton visible className="mt-5 mb-3 h-7 w-1/2" /> - <Skeleton visible className="h-7" /> - </Card> - )) - : projects.nodes.map(project => ( - <ProjectCard - key={project.id} - project={project} - organization={organization.organization} - /> - ))} - </div> - )} - </div> - <Activities /> - </> - ) - } - </OrganizationLayout> + <MetaTitle title="Organization" /> + <OrganizationPageContent /> </> ); } @@ -204,4 +391,4 @@ export const getServerSideProps = withSessionProtection(async ({ req, res, resol return { props: {} }; }); -export default authenticated(ProjectsPage); +export default authenticated(OrganizationPage); diff --git a/packages/web/app/pages/[orgId]/view/subscription/manage.tsx b/packages/web/app/pages/[orgId]/view/manage-subscription.tsx similarity index 73% rename from packages/web/app/pages/[orgId]/view/subscription/manage.tsx rename to packages/web/app/pages/[orgId]/view/manage-subscription.tsx index 2cd926c2c..ed5de4e7a 100644 --- a/packages/web/app/pages/[orgId]/view/subscription/manage.tsx +++ b/packages/web/app/pages/[orgId]/view/manage-subscription.tsx @@ -1,12 +1,16 @@ import { ReactElement, useCallback, useEffect, useRef, useState } from 'react'; +import NextLink from 'next/link'; import { useMutation, useQuery } from 'urql'; +import { authenticated } from '@/components/authenticated-container'; import { Section } from '@/components/common'; import { QueryError } from '@/components/common/DataWrapper'; -import { OrganizationLayout } from '@/components/layouts'; +import { OrganizationLayout } from '@/components/layouts/organization'; import { BillingPaymentMethod } from '@/components/organization/billing/BillingPaymentMethod'; import { BillingPlanPicker } from '@/components/organization/billing/BillingPlanPicker'; import { PlanSummary } from '@/components/organization/billing/PlanSummary'; -import { Button, Card, Heading, Input, Slider, Stat, Title } from '@/components/v2'; +import { Button } from '@/components/ui/button'; +import { Subtitle, Title } from '@/components/ui/page'; +import { Card, Heading, Input, MetaTitle, Slider, Stat } from '@/components/v2'; import { FragmentType, graphql, useFragment } from '@/gql'; import { BillingPlanType } from '@/gql/graphql'; import { @@ -16,6 +20,10 @@ import { UpgradeToProDocument, } from '@/graphql'; import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization'; +import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key'; +import { useRouteSelector } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; +import { withSessionProtection } from '@/lib/supertokens/guard'; import { openChatSupport } from '@/utils'; import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js'; @@ -176,7 +184,7 @@ function Inner(props: { if (organization.rateLimit.operations !== operationsRateLimit * 1_000_000) { return ( <> - <Button variant="primary" type="button" onClick={updateLimits}> + <Button type="button" onClick={updateLimits}> Update Limits </Button> <Section.Subtitle className="mt-4"> @@ -191,7 +199,7 @@ function Inner(props: { if (plan === 'ENTERPRISE') { return ( - <Button variant="primary" type="button" onClick={openChatSupport}> + <Button type="button" onClick={openChatSupport}> Contact Us </Button> ); @@ -199,7 +207,7 @@ function Inner(props: { if (plan === 'PRO') { return ( - <Button variant="primary" type="button" onClick={upgrade} disabled={!paymentDetailsValid}> + <Button type="button" onClick={upgrade} disabled={!paymentDetailsValid}> Upgrade to Pro </Button> ); @@ -207,7 +215,7 @@ function Inner(props: { if (plan === 'HOBBY') { return ( - <Button variant="primary" type="button" onClick={downgrade}> + <Button type="button" onClick={downgrade}> Downgrade to Hobby </Button> ); @@ -322,30 +330,118 @@ const ManageSubscriptionPageQuery = graphql(` query ManageSubscriptionPageQuery($selector: OrganizationSelectorInput!) { organization(selector: $selector) { organization { - ...OrganizationLayout_OrganizationFragment + cleanId + ...OrganizationLayout_CurrentOrganizationFragment ...ManageSubscriptionInner_OrganizationFragment } } billingPlans { ...ManageSubscriptionInner_BillingPlansFragment } + organizations { + ...OrganizationLayout_OrganizationConnectionFragment + } + me { + ...OrganizationLayout_MeFragment + } } `); -export default function ManageSubscriptionPage(): ReactElement { +function ManageSubscriptionPageContent() { + const router = useRouteSelector(); + const [query] = useQuery({ + query: ManageSubscriptionPageQuery, + variables: { + selector: { + organization: router.organizationId, + }, + }, + }); + + useNotFoundRedirectOnError(!!query.error); + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const organizationConnection = query.data?.organizations; + const billingPlans = query.data?.billingPlans; + + const organization = useFragment( + ManageSubscriptionInner_OrganizationFragment, + currentOrganization, + ); + const canAccess = useOrganizationAccess({ + scope: OrganizationAccessScope.Settings, + member: organization?.me ?? null, + redirect: true, + }); + + if (query.error) { + return null; + } + + if (!currentOrganization || !me || !organizationConnection || !organization || !billingPlans) { + return null; + } + + if (!canAccess) { + return null; + } + + return ( + <OrganizationLayout + value="subscription" + className="flex flex-col gap-y-10" + currentOrganization={currentOrganization} + organizations={organizationConnection} + me={me} + > + <div className="grow"> + <div className="py-6 flex flex-row justify-between items-center"> + <div> + <Title>Manage subscription + Manage your current plan and invoices. + +
    + +
    + +
    + +
    + + + ); +} + +function ManageSubscriptionPage(): ReactElement { return ( <> - - <OrganizationLayout query={ManageSubscriptionPageQuery}> - {props => - props.organization ? ( - <Inner - organization={props.organization.organization} - billingPlans={props.billingPlans} - /> - ) : null - } - </OrganizationLayout> + <MetaTitle title="Manage Subscription" /> + <ManageSubscriptionPageContent /> </> ); } + +export const getServerSideProps = withSessionProtection(async context => { + /** + * If Stripe is not enabled we redirect the user to the organization. + */ + const isStripeEnabled = getIsStripeEnabled(); + if (!isStripeEnabled) { + const parts = String(context.resolvedUrl).split('/'); + parts.pop(); + return { + redirect: { + destination: parts.join('/'), + permanent: false, + }, + }; + } + return { props: {} }; +}); + +export default authenticated(ManageSubscriptionPage); diff --git a/packages/web/app/pages/[orgId]/view/members.tsx b/packages/web/app/pages/[orgId]/view/members.tsx index 88eaaa1f7..55823a2fd 100644 --- a/packages/web/app/pages/[orgId]/view/members.tsx +++ b/packages/web/app/pages/[orgId]/view/members.tsx @@ -3,18 +3,11 @@ import { useFormik } from 'formik'; import { useMutation, useQuery } from 'urql'; import * as Yup from 'yup'; import { authenticated } from '@/components/authenticated-container'; -import { OrganizationLayout } from '@/components/layouts'; -import { - Avatar, - Button, - Card, - Checkbox, - DocsLink, - DocsNote, - Heading, - Input, - Title, -} from '@/components/v2'; +import { OrganizationLayout } from '@/components/layouts/organization'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Subtitle, Title } from '@/components/ui/page'; +import { Avatar, Card, DocsLink, Heading, Input, MetaTitle } from '@/components/v2'; import { DropdownMenu, DropdownMenuContent, @@ -27,6 +20,7 @@ import { FragmentType, graphql, useFragment } from '@/gql'; import { MeDocument, OrganizationFieldsFragment } from '@/graphql'; import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization'; import { useClipboard } from '@/lib/hooks/use-clipboard'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { useNotifications } from '@/lib/hooks/use-notifications'; import { useRouteSelector } from '@/lib/hooks/use-route-selector'; import { useToggle } from '@/lib/hooks/use-toggle'; @@ -124,7 +118,7 @@ const MemberInvitationForm = ({ return ( <div> - <form onSubmit={handleSubmit} className="flex flex-row gap-2"> + <form onSubmit={handleSubmit} className="flex flex-row gap-2 items-center"> <Input style={{ minWidth: '200px', @@ -139,13 +133,7 @@ const MemberInvitationForm = ({ isInvalid={touched.email && !!errors.email} onClear={resetForm} /> - <Button - type="submit" - size="large" - block - variant="primary" - disabled={isSubmitting || !isValid || !dirty} - > + <Button type="submit" disabled={isSubmitting || !isValid || !dirty}> Send an invite </Button> </form> @@ -201,7 +189,7 @@ const Invitation = (props: { </div> <DropdownMenu> <DropdownMenuTrigger asChild> - <Button> + <Button variant="ghost"> <MoreIcon /> </Button> </DropdownMenuTrigger> @@ -309,13 +297,18 @@ function Page(props: { organization: FragmentType<typeof Page_OrganizationFragme : null; return ( - <> - <DocsNote> - You may invite other members to collaborate with you on this organization.{' '} - <DocsLink href="/management/organizations#members"> - Learn more about membership and invitations - </DocsLink> - </DocsNote> + <div className="flex flex-col gap-y-4"> + <div className="py-6"> + <Title>Members + + You may invite other members to collaborate with you on this organization. + +

    + + Learn more about membership and invitations + +

    + {selectedMember && ( {members?.map(node => { @@ -368,7 +360,7 @@ function Page(props: { organization: FragmentType - @@ -396,7 +388,7 @@ function Page(props: { organization: FragmentType - + ); } @@ -404,26 +396,58 @@ const OrganizationMembersPageQuery = graphql(` query OrganizationMembersPageQuery($selector: OrganizationSelectorInput!) { organization(selector: $selector) { organization { - ...OrganizationLayout_OrganizationFragment + ...OrganizationLayout_CurrentOrganizationFragment ...Page_OrganizationFragment } } + organizations { + ...OrganizationLayout_OrganizationConnectionFragment + } + me { + ...OrganizationLayout_MeFragment + } } `); +function SettingsPageContent() { + const router = useRouteSelector(); + const [query] = useQuery({ + query: OrganizationMembersPageQuery, + variables: { + selector: { + organization: router.organizationId, + }, + }, + }); + + useNotFoundRedirectOnError(!!query.error); + + if (query.error) { + return null; + } + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const organizationConnection = query.data?.organizations; + + return ( + + {currentOrganization ? : null} + + ); +} + function OrganizationMembersPage(): ReactElement { return ( <> - - <OrganizationLayout - value="members" - className="flex w-4/5 flex-col gap-4" - query={OrganizationMembersPageQuery} - > - {({ organization }) => - organization ? <Page organization={organization.organization} /> : null - } - </OrganizationLayout> + <MetaTitle title="Members" /> + <SettingsPageContent /> </> ); } diff --git a/packages/web/app/pages/[orgId]/view/policy.tsx b/packages/web/app/pages/[orgId]/view/policy.tsx index 2f2f67725..c49350c6b 100644 --- a/packages/web/app/pages/[orgId]/view/policy.tsx +++ b/packages/web/app/pages/[orgId]/view/policy.tsx @@ -1,11 +1,16 @@ import { ReactElement } from 'react'; -import { useMutation } from 'urql'; +import { useMutation, useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; -import { OrganizationLayout } from '@/components/layouts'; +import { OrganizationLayout } from '@/components/layouts/organization'; import { PolicySettings } from '@/components/policy/policy-settings'; -import { Card, Checkbox, DocsLink, DocsNote, Heading, Title } from '@/components/v2'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Subtitle, Title } from '@/components/ui/page'; +import { DocsLink, DocsNote, MetaTitle } from '@/components/v2'; import { graphql } from '@/gql'; import { RegistryModel } from '@/graphql'; +import { useRouteSelector } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { withSessionProtection } from '@/lib/supertokens/guard'; const OrganizationPolicyPageQuery = graphql(` @@ -13,7 +18,7 @@ const OrganizationPolicyPageQuery = graphql(` organization(selector: $selector) { organization { id - ...OrganizationLayout_OrganizationFragment + ...OrganizationLayout_CurrentOrganizationFragment projects { nodes { id @@ -28,6 +33,12 @@ const OrganizationPolicyPageQuery = graphql(` } } } + organizations { + ...OrganizationLayout_OrganizationConnectionFragment + } + me { + ...OrganizationLayout_MeFragment + } } `); @@ -48,7 +59,7 @@ const UpdateSchemaPolicyForOrganization = graphql(` ok { organization { id - ...OrganizationLayout_OrganizationFragment + ...OrganizationLayout_CurrentOrganizationFragment schemaPolicy { id updatedAt @@ -61,54 +72,93 @@ const UpdateSchemaPolicyForOrganization = graphql(` } `); -function OrganizationPolicyPage(): ReactElement { +function PolicyPageContent() { + const router = useRouteSelector(); + const [query] = useQuery({ + query: OrganizationPolicyPageQuery, + variables: { + selector: { + organization: router.organizationId, + }, + }, + }); const [mutation, mutate] = useMutation(UpdateSchemaPolicyForOrganization); + useNotFoundRedirectOnError(!!query.error); + + if (query.error) { + return null; + } + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const organizationConnection = query.data?.organizations; + + const legacyProjects = currentOrganization?.projects.nodes.filter( + p => p.registryModel === RegistryModel.Legacy, + ); + return ( - <> - <Title title="Organization Schema Policy" /> - <OrganizationLayout - value="policy" - className="flex flex-col gap-y-10" - query={OrganizationPolicyPageQuery} - > - {(props, selector) => { - if (!props.organization) { - return null; - } - - const legacyProjects = props.organization.organization.projects.nodes.filter( - p => p.registryModel === RegistryModel.Legacy, - ); - - return ( - <Card> - <Heading className="mb-2">Organization Schema Policy</Heading> - <DocsNote> - <strong>Schema Policies</strong> enable developers to define additional semantic - checks on the GraphQL schema. At the organizational level, policies can be defined - to affect all projects and targets. At the project level, policies can be overridden - or extended. <DocsLink href="/features/schema-policy">Learn more</DocsLink> - </DocsNote> - {legacyProjects.length > 0 ? ( - <div className="mt-4"> - <DocsNote warn> - Note: some of your projects ( - {legacyProjects.map(p => ( - <code key={p.cleanId}>{p.cleanId}</code> + <OrganizationLayout + value="policy" + className="flex flex-col gap-y-10" + currentOrganization={currentOrganization ?? null} + organizations={organizationConnection ?? null} + me={me ?? null} + > + <div> + <div className="py-6"> + <Title>Organization Schema Policy + + Schema Policies enable developers to define additional semantic checks on the GraphQL + schema. + + + {currentOrganization ? ( + + + Rules + + At the organizational level, policies can be defined to affect all projects and + targets. +
    + At the project level, policies can be overridden or extended. +
    + + Learn more + +
    +
    + {legacyProjects && legacyProjects.length > 0 ? ( +
    + +

    Some of your projects are using the legacy model of the schema registry.

    +

    + {legacyProjects.map((p, i, all) => ( + <> + + {p.cleanId} + + {all.length === i - 1 ? ' ' : ', '} + ))} - ) are using the legacy model of the schema registry.{' '} - - Policy feature is only available for projects that are using the new registry - model. - -
    - +

    +

    + Policy feature is only available for projects that are using the new registry + model. +

    +

    + Learn more - -

    - ) : null} +

    + + + ) : null} + { await mutate({ - selector, + selector: { + organization: router.organizationId, + }, policy: newPolicy, allowOverrides, }).catch(); }} - currentState={props.organization.organization.schemaPolicy} + currentState={currentOrganization.schemaPolicy} > {form => ( -
    +
    )} - - ); - }} - + + + ) : null} +
    + + ); +} + +function OrganizationPolicyPage(): ReactElement { + return ( + <> + + ); } diff --git a/packages/web/app/pages/[orgId]/view/settings.tsx b/packages/web/app/pages/[orgId]/view/settings.tsx index ed7032060..dc5f55f69 100644 --- a/packages/web/app/pages/[orgId]/view/settings.tsx +++ b/packages/web/app/pages/[orgId]/view/settings.tsx @@ -1,11 +1,22 @@ import { ReactElement } from 'react'; +import NextLink from 'next/link'; import { useFormik } from 'formik'; import { useMutation, useQuery } from 'urql'; import * as Yup from 'yup'; import { authenticated } from '@/components/authenticated-container'; -import { OrganizationLayout } from '@/components/layouts'; +import { OrganizationLayout } from '@/components/layouts/organization'; import { OIDCIntegrationSection } from '@/components/organization/settings/oidc-integration-section'; -import { Button, Card, DocsLink, DocsNote, Heading, Input, Tag, Title } from '@/components/v2'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Subtitle, Title } from '@/components/ui/page'; +import { DocsLink, Input, MetaTitle, Tag } from '@/components/v2'; import { GitHubIcon, SlackIcon } from '@/components/v2/icon'; import { DeleteOrganizationModal, @@ -24,6 +35,7 @@ import { useOrganizationAccess, } from '@/lib/access/organization'; import { useRouteSelector, useToggle } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; import { withSessionProtection } from '@/lib/supertokens/guard'; function Integrations(): ReactElement | null { @@ -57,8 +69,7 @@ function Integrations(): ReactElement | null {
    {hasSlackIntegration ? ( ) : ( - )} Alerts and notifications @@ -86,8 +99,7 @@ function Integrations(): ReactElement | null { {hasGitHubIntegration ? ( <> - ) : ( - )} Allow Hive to communicate with GitHub @@ -194,125 +208,136 @@ const SettingsPageRenderer = (props: { }); return ( - <> - - Organization Name -
    - - -
    - {touched.name && (errors.name || mutation.error) && ( -
    {errors.name || mutation.error?.message}
    - )} - {mutation.data?.updateOrganizationName?.error && ( -
    - {mutation.data?.updateOrganizationName.error.message} -
    - )} - {mutation.error && ( -
    {mutation.error.graphQLErrors[0]?.message ?? mutation.error.message}
    - )} - - Changing the name of your organization will also change the slug of your organization URL, - and will invalidate any existing links to your organization. -
    - - You can read more about it in the documentation - -
    -
    - - {canAccessOrganization(OrganizationAccessScope.Integrations, organization.me) && ( +
    +
    + Organization Settings + Manage your organization settings and integrations. +
    +
    - Integrations - - Authorize external services to make them available for your the projects under this - organization. -
    - - You can find here instructions and full documentation for the available integration - -
    -
    - -
    + + Organization Name + + Changing the name of your organization will also change the slug of your organization + URL, and will invalidate any existing links to your organization. +
    + + You can read more about it in the documentation + +
    +
    + +
    + +
    + {touched.name && (errors.name || mutation.error) && ( +
    {errors.name || mutation.error?.message}
    + )} + {mutation.data?.updateOrganizationName?.error && ( +
    + {mutation.data?.updateOrganizationName.error.message} +
    + )} + {mutation.error && ( +
    {mutation.error.graphQLErrors[0]?.message ?? mutation.error.message}
    + )} +
    + + +
    - )} - {organization.me.isOwner && ( - -
    -
    - Transfer Ownership - + {canAccessOrganization(OrganizationAccessScope.Integrations, organization.me) && ( + + + Integrations + + Authorize external services to make them available for your the projects under this + organization. +
    + + You can find here instructions and full documentation for the available + integration + +
    +
    + +
    + +
    +
    +
    + )} + + {organization.me.isOwner && ( + + + Transfer Ownership + You are currently the owner of the organization. You can transfer the organization to another member of the organization, or to an external user.
    - + Learn more about the process -
    -
    -
    - - -
    -
    -
    - )} + + + +
    +
    + + +
    +
    +
    + + )} - {canAccessOrganization(OrganizationAccessScope.Delete, organization.me) && ( - -
    -
    - Delete Organization - + {canAccessOrganization(OrganizationAccessScope.Delete, organization.me) && ( + + + Delete Organization + Deleting an organization will delete all the projects, targets, schemas and data associated with it.
    - + This action is not reversible! You can find more information about this process in the documentation -
    -
    -
    - -
    -
    -
    - )} - + + + )} +
    +
    ); }; @@ -332,28 +357,58 @@ const OrganizationSettingsPageQuery = graphql(` query OrganizationSettingsPageQuery($selector: OrganizationSelectorInput!) { organization(selector: $selector) { organization { - ...OrganizationLayout_OrganizationFragment + ...OrganizationLayout_CurrentOrganizationFragment ...SettingsPageRenderer_OrganizationFragment } } + organizations { + ...OrganizationLayout_OrganizationConnectionFragment + } + me { + ...OrganizationLayout_MeFragment + } } `); +function SettingsPageContent() { + const router = useRouteSelector(); + const [query] = useQuery({ + query: OrganizationSettingsPageQuery, + variables: { + selector: { + organization: router.organizationId, + }, + }, + }); + + useNotFoundRedirectOnError(!!query.error); + + if (query.error) { + return null; + } + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const organizationConnection = query.data?.organizations; + + return ( + + {currentOrganization ? : null} + + ); +} + function OrganizationSettingsPage(): ReactElement { return ( <> - - <OrganizationLayout - value="settings" - className="flex flex-col gap-y-10" - query={OrganizationSettingsPageQuery} - > - {props => - props.organization ? ( - <SettingsPageRenderer organization={props.organization.organization} /> - ) : null - } - </OrganizationLayout> + <MetaTitle title="Organization settings" /> + <SettingsPageContent /> </> ); } diff --git a/packages/web/app/pages/[orgId]/view/subscription.tsx b/packages/web/app/pages/[orgId]/view/subscription.tsx new file mode 100644 index 000000000..660bc35e9 --- /dev/null +++ b/packages/web/app/pages/[orgId]/view/subscription.tsx @@ -0,0 +1,210 @@ +import { ReactElement } from 'react'; +import NextLink from 'next/link'; +import { endOfMonth, startOfMonth } from 'date-fns'; +import { useQuery } from 'urql'; +import { authenticated } from '@/components/authenticated-container'; +import { OrganizationLayout } from '@/components/layouts/organization'; +import { BillingView } from '@/components/organization/billing/Billing'; +import { CurrencyFormatter } from '@/components/organization/billing/helpers'; +import { InvoicesList } from '@/components/organization/billing/InvoicesList'; +import { OrganizationUsageEstimationView } from '@/components/organization/Usage'; +import { Button } from '@/components/ui/button'; +import { Subtitle, Title } from '@/components/ui/page'; +import { Card, Heading, MetaTitle, Stat } from '@/components/v2'; +import { graphql, useFragment } from '@/gql'; +import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization'; +import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key'; +import { useRouteSelector } from '@/lib/hooks'; +import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error'; +import { withSessionProtection } from '@/lib/supertokens/guard'; + +const DateFormatter = Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', +}); + +const SubscriptionPage_OrganizationFragment = graphql(` + fragment SubscriptionPage_OrganizationFragment on Organization { + me { + ...CanAccessOrganization_MemberFragment + } + billingConfiguration { + hasPaymentIssues + invoices { + id + } + upcomingInvoice { + amount + date + } + } + ...RateLimitWarn_OrganizationFragment + ...OrganizationInvoicesList_OrganizationFragment + ...BillingView_OrganizationFragment + ...OrganizationUsageEstimationView_OrganizationFragment + } +`); + +const SubscriptionPage_QueryFragment = graphql(` + fragment SubscriptionPage_QueryFragment on Query { + ...BillingView_QueryFragment + } +`); + +const SubscriptionPageQuery = graphql(` + query SubscriptionPageQuery($selector: OrganizationSelectorInput!) { + organization(selector: $selector) { + organization { + cleanId + ...OrganizationLayout_CurrentOrganizationFragment + ...SubscriptionPage_OrganizationFragment + } + } + ...SubscriptionPage_QueryFragment + organizations { + ...OrganizationLayout_OrganizationConnectionFragment + } + me { + ...OrganizationLayout_MeFragment + } + } +`); + +function SubscriptionPageContent() { + const router = useRouteSelector(); + const [query] = useQuery({ + query: SubscriptionPageQuery, + variables: { + selector: { + organization: router.organizationId, + }, + }, + }); + + useNotFoundRedirectOnError(!!query.error); + + const me = query.data?.me; + const currentOrganization = query.data?.organization?.organization; + const organizationConnection = query.data?.organizations; + + const organization = useFragment(SubscriptionPage_OrganizationFragment, currentOrganization); + const queryForBilling = useFragment(SubscriptionPage_QueryFragment, query.data); + const canAccess = useOrganizationAccess({ + scope: OrganizationAccessScope.Settings, + member: organization?.me ?? null, + redirect: true, + }); + + if (query.error || query.fetching) { + return null; + } + + if (!currentOrganization || !me || !organizationConnection || !organization || !queryForBilling) { + return null; + } + + if (!canAccess) { + return null; + } + + const today = new Date(); + const start = startOfMonth(today); + const end = endOfMonth(today); + + return ( + <OrganizationLayout + value="subscription" + className="flex flex-col gap-y-10" + currentOrganization={currentOrganization} + organizations={organizationConnection} + me={me} + > + <div className="grow"> + <div className="py-6 flex flex-row justify-between items-center"> + <div> + <Title>Your subscription + Explore your current plan and usage. +
    +
    + +
    +
    +
    + + Your current plan +
    + + {organization.billingConfiguration?.upcomingInvoice && ( + + Next Invoice + + {CurrencyFormatter.format( + organization.billingConfiguration.upcomingInvoice.amount, + )} + + + {DateFormatter.format( + new Date(organization.billingConfiguration.upcomingInvoice.date), + )} + + + )} + +
    +
    + + Current Usage +

    + {DateFormatter.format(start)} — {DateFormatter.format(end)} +

    +
    + +
    +
    + {organization.billingConfiguration?.invoices?.length ? ( + + Invoices +
    + +
    +
    + ) : null} +
    + + + ); +} + +function SubscriptionPage(): ReactElement { + return ( + <> + + + + ); +} + +export const getServerSideProps = withSessionProtection(async context => { + /** + * If Stripe is not enabled we redirect the user to the organization. + */ + const isStripeEnabled = getIsStripeEnabled(); + if (!isStripeEnabled) { + const parts = String(context.resolvedUrl).split('/'); + parts.pop(); + return { + redirect: { + destination: parts.join('/'), + permanent: false, + }, + }; + } + return { props: {} }; +}); + +export default authenticated(SubscriptionPage); diff --git a/packages/web/app/pages/[orgId]/view/subscription/index.tsx b/packages/web/app/pages/[orgId]/view/subscription/index.tsx deleted file mode 100644 index 9af5a97df..000000000 --- a/packages/web/app/pages/[orgId]/view/subscription/index.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { ReactElement } from 'react'; -import dynamic from 'next/dynamic'; -import { endOfMonth, startOfMonth } from 'date-fns'; -import { authenticated } from '@/components/authenticated-container'; -import { OrganizationLayout } from '@/components/layouts'; -import { BillingView } from '@/components/organization/billing/Billing'; -import { CurrencyFormatter } from '@/components/organization/billing/helpers'; -import { InvoicesList } from '@/components/organization/billing/InvoicesList'; -import { OrganizationUsageEstimationView } from '@/components/organization/Usage'; -import { Card, Heading, Stat, Tabs, Title } from '@/components/v2'; -import { FragmentType, graphql, useFragment } from '@/gql'; -import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization'; -import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key'; -import { withSessionProtection } from '@/lib/supertokens/guard'; - -const DateFormatter = Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', -}); - -const ManagePage = dynamic(() => import('./manage')); - -const SubscriptionPage_OrganizationFragment = graphql(` - fragment SubscriptionPage_OrganizationFragment on Organization { - me { - ...CanAccessOrganization_MemberFragment - } - billingConfiguration { - hasPaymentIssues - invoices { - id - } - upcomingInvoice { - amount - date - } - } - ...RateLimitWarn_OrganizationFragment - ...OrganizationInvoicesList_OrganizationFragment - ...BillingView_OrganizationFragment - ...OrganizationUsageEstimationView_OrganizationFragment - } -`); - -const SubscriptionPage_QueryFragment = graphql(` - fragment SubscriptionPage_QueryFragment on Query { - ...BillingView_QueryFragment - } -`); - -function Page(props: { - organization: FragmentType; - query: FragmentType; -}): ReactElement | null { - const organization = useFragment(SubscriptionPage_OrganizationFragment, props.organization); - const query = useFragment(SubscriptionPage_QueryFragment, props.query); - const canAccess = useOrganizationAccess({ - scope: OrganizationAccessScope.Settings, - member: organization?.me, - redirect: true, - }); - - if (!canAccess) { - return null; - } - - const today = new Date(); - const start = startOfMonth(today); - const end = endOfMonth(today); - - return ( - - - - Monthly Usage - - - Manage - - - - - Your current plan -
    - - {organization.billingConfiguration?.upcomingInvoice && ( - - Next Invoice - - {CurrencyFormatter.format( - organization.billingConfiguration.upcomingInvoice.amount, - )} - - - {DateFormatter.format( - new Date(organization.billingConfiguration.upcomingInvoice.date), - )} - - - )} - -
    -
    - - Current Usage -

    - {DateFormatter.format(start)} — {DateFormatter.format(end)} -

    -
    - -
    -
    - {organization.billingConfiguration?.invoices?.length ? ( - - Invoices -
    - -
    -
    - ) : null} -
    - - - -
    - ); -} - -const SubscriptionPageQuery = graphql(` - query SubscriptionPageQuery($selector: OrganizationSelectorInput!) { - organization(selector: $selector) { - organization { - ...OrganizationLayout_OrganizationFragment - ...SubscriptionPage_OrganizationFragment - } - } - ...SubscriptionPage_QueryFragment - } -`); - -function SubscriptionPage(): ReactElement { - return ( - <> - - <OrganizationLayout value="subscription" query={SubscriptionPageQuery}> - {props => - props.organization ? ( - <Page organization={props.organization.organization} query={props} /> - ) : null - } - </OrganizationLayout> - </> - ); -} - -export const getServerSideProps = withSessionProtection(async context => { - /** - * If Strive is not enabled we redirect the user to the organization. - */ - const isStripeEnabled = getIsStripeEnabled(); - if (!isStripeEnabled) { - const parts = String(context.resolvedUrl).split('/'); - parts.pop(); - return { - redirect: { - destination: parts.join('/'), - permanent: false, - }, - }; - } - return { props: {} }; -}); - -export default authenticated(SubscriptionPage); diff --git a/packages/web/app/pages/_document.tsx b/packages/web/app/pages/_document.tsx index 9cedb1258..ee203342e 100644 --- a/packages/web/app/pages/_document.tsx +++ b/packages/web/app/pages/_document.tsx @@ -28,14 +28,11 @@ export default class MyDocument extends Document<{ dangerouslySetInnerHTML={{ __html: // we setup background via style tag to prevent white flash on initial page loading - 'html {background: #0b0d11}', + 'html {background: #030711}', }} /> - <link rel="preconnect" href="https://fonts.gstatic.com" /> - <link - href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" - rel="stylesheet" - /> + <link rel="preconnect" href="https://rsms.me/" /> + <link rel="stylesheet" href="https://rsms.me/inter/inter.css" /> <link rel="icon" href="/just-logo.svg" type="image/svg+xml" /> <script type="module" diff --git a/packages/web/app/pages/org/new.tsx b/packages/web/app/pages/org/new.tsx index e46c26544..cec52cffc 100644 --- a/packages/web/app/pages/org/new.tsx +++ b/packages/web/app/pages/org/new.tsx @@ -1,20 +1,18 @@ import { ReactElement } from 'react'; import { authenticated } from '@/components/authenticated-container'; -import { SubHeader, Title } from '@/components/v2'; +import { MetaTitle } from '@/components/v2'; import { CreateOrganizationForm } from '@/components/v2/modals/create-organization'; import { withSessionProtection } from '@/lib/supertokens/guard'; function CreateOrgPage(): ReactElement { return ( <> - <Title title="Create Organization" /> - <SubHeader> - <div className="flex items-center"> - <div className="container w-1/3"> - <CreateOrganizationForm /> - </div> + <MetaTitle title="Create Organization" /> + <div className="h-full grow flex items-center"> + <div className="container w-1/3"> + <CreateOrganizationForm /> </div> - </SubHeader> + </div> </> ); } diff --git a/packages/web/app/pages/settings.tsx b/packages/web/app/pages/settings.tsx index 2a8044bd4..7b8dd2c9a 100644 --- a/packages/web/app/pages/settings.tsx +++ b/packages/web/app/pages/settings.tsx @@ -3,7 +3,7 @@ import { useFormik } from 'formik'; import { useMutation, useQuery } from 'urql'; import * as Yup from 'yup'; import { authenticated } from '@/components/authenticated-container'; -import { Avatar, Button, Heading, Input, SubHeader, Tabs, Title } from '@/components/v2'; +import { Avatar, Button, Heading, Input, MetaTitle, Tabs } from '@/components/v2'; import { graphql } from '@/gql'; import { MeDocument } from '@/graphql'; import { withSessionProtection } from '@/lib/supertokens/guard'; @@ -51,26 +51,24 @@ function SettingsPage(): ReactElement { return ( <> - <Title title="Profile settings" /> - <SubHeader> - <header className="container flex items-center pb-5"> - <div className="mr-4 rounded-full"> - <Avatar - src={null} - alt="Your profile photo" - shape="circle" - fallback={me?.displayName[0] ?? '?'} - className="!h-[94px] !w-[94px] text-4xl" - /> - </div> - <div className="overflow-hidden"> - <Heading size="2xl" className="line-clamp-1"> - {me?.displayName} - </Heading> - <span className="text-xs font-medium text-gray-500">{me?.email}</span> - </div> - </header> - </SubHeader> + <MetaTitle title="Profile settings" /> + <header className="container flex items-center pb-5"> + <div className="mr-4 rounded-full"> + <Avatar + src={null} + alt="Your profile photo" + shape="circle" + fallback={me?.displayName[0] ?? '?'} + className="!h-[94px] !w-[94px] text-4xl" + /> + </div> + <div className="overflow-hidden"> + <Heading size="2xl" className="line-clamp-1"> + {me?.displayName} + </Heading> + <span className="text-xs font-medium text-gray-500">{me?.email}</span> + </div> + </header> <Tabs defaultValue="personal-info" className="container"> <Tabs.List> <Tabs.Trigger value="personal-info">Personal Info</Tabs.Trigger> diff --git a/packages/web/app/public/styles.css b/packages/web/app/public/styles.css index 41cd4d3ec..bdd7a88e7 100644 --- a/packages/web/app/public/styles.css +++ b/packages/web/app/public/styles.css @@ -2,6 +2,91 @@ @tailwind components; @tailwind utilities; +/* <shadcdn */ + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --popover: 222.86 70% 3.92%; + --popover-foreground: 0 0% 100%; + + --card: 0 0% 100%; + --card-foreground: 222.2 47.4% 11.2%; + + --border: 240 3.7% 15.88%; + --input: 214.3 31.8% 91.4%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --accent: 0 0% 9.02%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 100% 50%; + --destructive-foreground: 210 40% 98%; + + --ring: 215 20.2% 65.1%; + + --radius: 0.5rem; + } + + .dark { + --background: 220, 21.43%, 5.49%; + --foreground: 213 31% 91%; + + --muted: 24 9.8% 10%; + --muted-foreground: 25 5.26% 44.71%; + + --popover: 222.86 70% 3.92%; + --popover-foreground: 0 0% 98%; + + --card: 224 71% 4%; + --card-foreground: 213 31% 91%; + + --border: 12 5% 15%; + --input: 12 5% 15%; + + --primary: 210 40% 98%; /* 40 89% 60%; */ + --primary-foreground: 222.2 47.4% 1.2%; + + /* 221.54 28.89% 8.82% */ + /* 12 5 15 */ + + --secondary: 230 14% 8%; + --secondary-foreground: 210 40% 98%; /* 220, 21.43%, 5.49% */ + + --accent: 230 14% 8%; + --accent-foreground: 210 40% 98%; /* 220, 21.43%, 5.49% */ + + --destructive: 0 63% 31%; + --destructive-foreground: 210 40% 98%; /* 220, 21.43%, 5.49% */ + + --ring: 216 34% 17%; + + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-feature-settings: 'rlig' 1, 'calt' 1; + } +} + +/* shadcdn> */ + :root { color-scheme: dark; } @@ -19,7 +104,7 @@ } #__next { - font-family: inherit !important; + /* font-family: inherit !important; */ color: inherit !important; @apply flex h-full flex-col text-sm text-gray-700 antialiased lg:text-base; } diff --git a/packages/web/app/src/components/authenticated-container.tsx b/packages/web/app/src/components/authenticated-container.tsx index fb86c5ce1..51beb345c 100644 --- a/packages/web/app/src/components/authenticated-container.tsx +++ b/packages/web/app/src/components/authenticated-container.tsx @@ -2,7 +2,6 @@ import { ReactElement, useEffect } from 'react'; import { useRouter } from 'next/router'; import Session, { SessionAuth } from 'supertokens-auth-react/recipe/session'; import { HiveStripeWrapper } from '@/lib/billing/stripe'; -import { Header } from './v2'; /** * Utility for wrapping a component with an authenticated container that has the default application layout. @@ -35,7 +34,7 @@ export const authenticated = return ( <SessionAuth> <HiveStripeWrapper> - <Header /> + {/* <Header /> */} <Component {...props} /> </HiveStripeWrapper> </SessionAuth> diff --git a/packages/web/app/src/components/get-started/wizard.tsx b/packages/web/app/src/components/get-started/wizard.tsx index 3db4cbe2c..12e3f7326 100644 --- a/packages/web/app/src/components/get-started/wizard.tsx +++ b/packages/web/app/src/components/get-started/wizard.tsx @@ -1,12 +1,12 @@ import { ReactElement, ReactNode } from 'react'; import clsx from 'clsx'; import { Drawer } from '@/components/v2'; -import { DocumentType, graphql } from '@/gql'; +import { DocumentType, FragmentType, graphql, useFragment } from '@/gql'; import { getDocsUrl } from '@/lib/docs-url'; import { useToggle } from '@/lib/hooks'; import { CheckCircledIcon } from '@radix-ui/react-icons'; -const GetStartedWizard_GetStartedProgress = graphql(` +export const GetStartedWizard_GetStartedProgress = graphql(` fragment GetStartedWizard_GetStartedProgress on OrganizationGetStarted { creatingProject publishingSchema @@ -17,12 +17,11 @@ const GetStartedWizard_GetStartedProgress = graphql(` } `); -export function GetStartedProgress({ - tasks, -}: { - tasks: DocumentType<typeof GetStartedWizard_GetStartedProgress>; +export function GetStartedProgress(props: { + tasks: FragmentType<typeof GetStartedWizard_GetStartedProgress>; }): ReactElement | null { const [isOpen, toggle] = useToggle(); + const tasks = useFragment(GetStartedWizard_GetStartedProgress, props.tasks); if (!tasks) { return null; diff --git a/packages/web/app/src/components/layouts/index.ts b/packages/web/app/src/components/layouts/index.ts deleted file mode 100644 index 9a11e3c5a..000000000 --- a/packages/web/app/src/components/layouts/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { OrganizationLayout } from './organization'; -export { ProjectLayout } from './project'; -export { TargetLayout } from './target'; diff --git a/packages/web/app/src/components/layouts/organization.tsx b/packages/web/app/src/components/layouts/organization.tsx index 9b14f0800..cd849dbd0 100644 --- a/packages/web/app/src/components/layouts/organization.tsx +++ b/packages/web/app/src/components/layouts/organization.tsx @@ -1,14 +1,12 @@ -import { ReactElement, ReactNode, useEffect } from 'react'; +import { ReactElement, ReactNode } from 'react'; import NextLink from 'next/link'; -import { useRouter } from 'next/router'; -import cookies from 'js-cookie'; -import { TypedDocumentNode, useQuery } from 'urql'; -import { Button, Heading, SubHeader, Tabs } from '@/components/v2'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; +import { UserMenu } from '@/components/ui/user-menu'; +import { HiveLink, Tabs } from '@/components/v2'; import { PlusIcon } from '@/components/v2/icon'; import { CreateProjectModal } from '@/components/v2/modals'; -import { LAST_VISITED_ORG_KEY } from '@/constants'; import { FragmentType, graphql, useFragment } from '@/gql'; -import { Exact } from '@/graphql'; import { canAccessOrganization, OrganizationAccessScope, @@ -27,156 +25,182 @@ enum TabValue { Subscription = 'subscription', } -const OrganizationLayout_OrganizationFragment = graphql(` - fragment OrganizationLayout_OrganizationFragment on Organization { +const OrganizationLayout_CurrentOrganizationFragment = graphql(` + fragment OrganizationLayout_CurrentOrganizationFragment on Organization { + id name + cleanId me { ...CanAccessOrganization_MemberFragment } ...ProPlanBilling_OrganizationFragment ...RateLimitWarn_OrganizationFragment + ...UserMenu_CurrentOrganizationFragment } `); -export function OrganizationLayout< - TSatisfiesType extends { - organization?: - | { - organization?: FragmentType<typeof OrganizationLayout_OrganizationFragment> | null; - } - | null - | undefined; - }, ->({ +const OrganizationLayout_MeFragment = graphql(` + fragment OrganizationLayout_MeFragment on User { + id + ...UserMenu_MeFragment + } +`); + +const OrganizationLayout_OrganizationConnectionFragment = graphql(` + fragment OrganizationLayout_OrganizationConnectionFragment on OrganizationConnection { + nodes { + id + cleanId + name + } + ...UserMenu_OrganizationConnectionFragment + } +`); + +export function OrganizationLayout({ children, value, - query, className, + ...props }: { - children( - props: TSatisfiesType, - selector: { - organization: string; - }, - ): ReactNode; value?: 'overview' | 'members' | 'settings' | 'subscription' | 'policy'; className?: string; - query: TypedDocumentNode< - TSatisfiesType, - Exact<{ - selector: { - organization: string; - }; - }> - >; + me: FragmentType<typeof OrganizationLayout_MeFragment> | null; + currentOrganization: FragmentType<typeof OrganizationLayout_CurrentOrganizationFragment> | null; + organizations: FragmentType<typeof OrganizationLayout_OrganizationConnectionFragment> | null; + children: ReactNode; }): ReactElement | null { const router = useRouteSelector(); - const { push } = useRouter(); const [isModalOpen, toggleModalOpen] = useToggle(); - const orgId = router.organizationId; - - const [organizationQuery] = useQuery({ - query, - variables: { - selector: { - organization: orgId, - }, - }, - }); - - const organization = useFragment( - OrganizationLayout_OrganizationFragment, - organizationQuery.data?.organization?.organization, + const currentOrganization = useFragment( + OrganizationLayout_CurrentOrganizationFragment, + props.currentOrganization, ); - useEffect(() => { - if (organizationQuery.error) { - cookies.remove(LAST_VISITED_ORG_KEY); - // url with # provoke error Maximum update depth exceeded - void push('/404', router.asPath.replace(/#.*/, '')); - } - }, [organizationQuery.error, router]); - useOrganizationAccess({ - member: organization?.me ?? null, + member: currentOrganization?.me ?? null, scope: OrganizationAccessScope.Read, redirect: true, }); - if (organizationQuery.fetching || organizationQuery.error) { - return null; - } - - const me = organization?.me; - - if (!organization || !me) { - return null; - } - - if (!value) { - return ( - <> - {children(organizationQuery.data!, { - organization: orgId, - })} - </> - ); - } + const meInCurrentOrg = currentOrganization?.me; + const me = useFragment(OrganizationLayout_MeFragment, props.me); + const organizationConnection = useFragment( + OrganizationLayout_OrganizationConnectionFragment, + props.organizations, + ); + const organizations = organizationConnection?.nodes; return ( <> - <SubHeader> + <header> <div className="container flex h-[84px] items-center justify-between"> - <div className="truncate"> - <Heading size="2xl" className="inline"> - {organization?.name} - </Heading> - <div className="text-xs font-medium text-gray-500">Organization</div> + <div className="flex flex-row items-center gap-4"> + <HiveLink className="w-8 h-8" /> + {currentOrganization && organizations ? ( + <Select + defaultValue={currentOrganization.cleanId} + onValueChange={id => { + router.visitOrganization({ + organizationId: id, + }); + }} + > + <SelectTrigger variant="default"> + <div className="font-medium" data-cy="organization-picker-current"> + {currentOrganization.name} + </div> + </SelectTrigger> + <SelectContent> + {organizations.map(org => ( + <SelectItem key={org.cleanId} value={org.cleanId}> + {org.name} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <div className="w-48 h-5 bg-gray-800 rounded-full animate-pulse" /> + )} + </div> + <div> + <UserMenu + me={me ?? null} + currentOrganization={currentOrganization ?? null} + organizations={organizationConnection ?? null} + /> </div> - <Button size="large" variant="primary" className="shrink-0" onClick={toggleModalOpen}> - Create Project - <PlusIcon className="ml-2" /> - </Button> - <CreateProjectModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} /> </div> - </SubHeader> - <Tabs className="container" value={value}> - <Tabs.List> - <Tabs.Trigger value={TabValue.Overview} asChild> - <NextLink href={`/${orgId}`}>Overview</NextLink> - </Tabs.Trigger> - {canAccessOrganization(OrganizationAccessScope.Members, me) && ( - <Tabs.Trigger value={TabValue.Members} asChild> - <NextLink href={`/${orgId}/view/${TabValue.Members}`}>Members</NextLink> - </Tabs.Trigger> + </header> + <div className="relative border-b border-gray-800"> + <div className="container flex justify-between items-center"> + {currentOrganization && meInCurrentOrg ? ( + <Tabs value={value}> + <Tabs.List> + <Tabs.Trigger value={TabValue.Overview} asChild> + <NextLink href={`/${currentOrganization.cleanId}`}>Overview</NextLink> + </Tabs.Trigger> + {canAccessOrganization(OrganizationAccessScope.Members, meInCurrentOrg) && ( + <Tabs.Trigger value={TabValue.Members} asChild> + <NextLink href={`/${currentOrganization.cleanId}/view/${TabValue.Members}`}> + Members + </NextLink> + </Tabs.Trigger> + )} + {canAccessOrganization(OrganizationAccessScope.Settings, meInCurrentOrg) && ( + <> + <Tabs.Trigger value={TabValue.Policy} asChild> + <NextLink href={`/${currentOrganization.cleanId}/view/${TabValue.Policy}`}> + Policy + </NextLink> + </Tabs.Trigger> + <Tabs.Trigger value={TabValue.Settings} asChild> + <NextLink href={`/${currentOrganization.cleanId}/view/${TabValue.Settings}`}> + Settings + </NextLink> + </Tabs.Trigger> + </> + )} + {getIsStripeEnabled() && + canAccessOrganization(OrganizationAccessScope.Settings, meInCurrentOrg) && ( + <Tabs.Trigger value={TabValue.Subscription} asChild> + <NextLink + href={`/${currentOrganization.cleanId}/view/${TabValue.Subscription}`} + > + Subscription + </NextLink> + </Tabs.Trigger> + )} + </Tabs.List> + </Tabs> + ) : ( + <div className="flex flex-row gap-x-8 px-4 py-3 border-b-[2px] border-b-transparent"> + <div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" /> + <div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" /> + <div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" /> + </div> )} - {canAccessOrganization(OrganizationAccessScope.Settings, me) && ( + {currentOrganization ? ( <> - <Tabs.Trigger value={TabValue.Policy} asChild> - <NextLink href={`/${orgId}/view/${TabValue.Policy}`}>Policy</NextLink> - </Tabs.Trigger> - <Tabs.Trigger value={TabValue.Settings} asChild> - <NextLink href={`/${orgId}/view/${TabValue.Settings}`}>Settings</NextLink> - </Tabs.Trigger> + <Button onClick={toggleModalOpen} variant="link" className="text-orange-500"> + <PlusIcon size={16} className="mr-2" /> + New project + </Button> + <CreateProjectModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} /> </> - )} - {getIsStripeEnabled() && canAccessOrganization(OrganizationAccessScope.Settings, me) && ( - <Tabs.Trigger value={TabValue.Subscription} asChild> - <NextLink href={`/${orgId}/view/${TabValue.Subscription}`}>Subscription</NextLink> - </Tabs.Trigger> - )} - </Tabs.List> - <Tabs.Content value={value}> - <RateLimitWarn organization={organization} /> - <ProPlanBilling organization={organization} /> - <div className={className}> - {children(organizationQuery.data!, { - organization: orgId, - })} - </div> - </Tabs.Content> - </Tabs> + ) : null} + </div> + </div> + <div className="container pb-7"> + {currentOrganization ? ( + <> + <ProPlanBilling organization={currentOrganization} /> + <RateLimitWarn organization={currentOrganization} /> + </> + ) : null} + <div className={className}>{children}</div> + </div> </> ); } diff --git a/packages/web/app/src/components/layouts/project.tsx b/packages/web/app/src/components/layouts/project.tsx index 1e58f76c9..c51e07942 100644 --- a/packages/web/app/src/components/layouts/project.tsx +++ b/packages/web/app/src/components/layouts/project.tsx @@ -1,17 +1,12 @@ -import { ReactElement, ReactNode, useEffect } from 'react'; +import { ReactElement, ReactNode } from 'react'; import NextLink from 'next/link'; -import { TypedDocumentNode, useQuery } from 'urql'; -import { Button, Heading, Link, SubHeader, Tabs } from '@/components/v2'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/v2/dropdown'; -import { ArrowDownIcon, TargetIcon } from '@/components/v2/icon'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; +import { UserMenu } from '@/components/ui/user-menu'; +import { HiveLink, Tabs } from '@/components/v2'; +import { PlusIcon } from '@/components/v2/icon'; import { CreateTargetModal } from '@/components/v2/modals'; import { FragmentType, graphql, useFragment } from '@/gql'; -import { Exact, ProjectsDocument } from '@/graphql'; import { canAccessProject, ProjectAccessScope, useProjectAccess } from '@/lib/access/project'; import { useRouteSelector, useToggle } from '@/lib/hooks'; import { ProjectMigrationToast } from '../project/migration-toast'; @@ -23,217 +18,204 @@ enum TabValue { Settings = 'settings', } -const ProjectLayout_OrganizationFragment = graphql(` - fragment ProjectLayout_OrganizationFragment on Organization { +const ProjectLayout_CurrentOrganizationFragment = graphql(` + fragment ProjectLayout_CurrentOrganizationFragment on Organization { + id name + cleanId me { ...CanAccessProject_MemberFragment } + ...UserMenu_CurrentOrganizationFragment + projects { + ...ProjectLayout_ProjectConnectionFragment + } } `); -const ProjectLayout_ProjectFragment = graphql(` - fragment ProjectLayout_ProjectFragment on Project { +const ProjectLayout_MeFragment = graphql(` + fragment ProjectLayout_MeFragment on User { + id + ...UserMenu_MeFragment + } +`); + +const ProjectLayout_OrganizationConnectionFragment = graphql(` + fragment ProjectLayout_OrganizationConnectionFragment on OrganizationConnection { + nodes { + id + cleanId + name + } + ...UserMenu_OrganizationConnectionFragment + } +`); + +const ProjectLayout_CurrentProjectFragment = graphql(` + fragment ProjectLayout_CurrentProjectFragment on Project { + id + cleanId name - type registryModel } `); -export function ProjectLayout< - /** - * LOL fire me for this. - * Because of this kind of abstraction in place I invented this complicated generic satisfaction thing. - * I'm not sure if it's worth it, but it's the only way I could think of to make it work without removing this component. - */ - TSatisfiesType extends { - organization?: - | { - organization?: FragmentType<typeof ProjectLayout_OrganizationFragment> | null; - } - | null - | undefined; - project?: FragmentType<typeof ProjectLayout_ProjectFragment> | null; - }, ->({ +const ProjectLayout_ProjectConnectionFragment = graphql(` + fragment ProjectLayout_ProjectConnectionFragment on ProjectConnection { + nodes { + id + cleanId + name + } + } +`); + +export function ProjectLayout({ children, value, className, - query, + ...props }: { - children( - props: { - project: Exclude<TSatisfiesType['project'], null | undefined>; - organization: Exclude<TSatisfiesType['organization'], null | undefined>; - }, - selector: { - organization: string; - project: string; - }, - ): ReactNode; value: 'targets' | 'alerts' | 'settings' | 'policy'; className?: string; - query: TypedDocumentNode< - TSatisfiesType, - Exact<{ - organizationId: string; - projectId: string; - }> - >; + me: FragmentType<typeof ProjectLayout_MeFragment> | null; + currentOrganization: FragmentType<typeof ProjectLayout_CurrentOrganizationFragment> | null; + currentProject: FragmentType<typeof ProjectLayout_CurrentProjectFragment> | null; + organizations: FragmentType<typeof ProjectLayout_OrganizationConnectionFragment> | null; + children: ReactNode; }): ReactElement | null { const router = useRouteSelector(); const [isModalOpen, toggleModalOpen] = useToggle(); const { organizationId: orgId, projectId } = router; - const [projectQuery] = useQuery({ - query, - variables: { - organizationId: orgId, - projectId, - }, - }); - - useEffect(() => { - if (projectQuery.error) { - // url with # provoke error Maximum update depth exceeded - void router.push('/404', router.asPath.replace(/#.*/, '')); - } - }, [projectQuery.error, router]); - - const [projectsQuery] = useQuery({ - query: ProjectsDocument, - variables: { - selector: { - organization: orgId, - }, - }, - }); - - const organization = useFragment( - ProjectLayout_OrganizationFragment, - projectQuery.data?.organization?.organization, + const currentOrganization = useFragment( + ProjectLayout_CurrentOrganizationFragment, + props.currentOrganization, ); + const currentProject = useFragment(ProjectLayout_CurrentProjectFragment, props.currentProject); useProjectAccess({ scope: ProjectAccessScope.Read, - member: organization?.me ?? null, + member: currentOrganization?.me ?? null, redirect: true, }); - if (projectQuery.fetching || projectQuery.error) { - return null; - } - - const project = useFragment(ProjectLayout_ProjectFragment, projectQuery.data?.project); - const projects = projectsQuery.data?.projects; - - if (!organization || !project) { - return null; - } + const me = useFragment(ProjectLayout_MeFragment, props.me); + const organizationConnection = useFragment( + ProjectLayout_OrganizationConnectionFragment, + props.organizations, + ); + const projectConnection = useFragment( + ProjectLayout_ProjectConnectionFragment, + currentOrganization?.projects ?? null, + ); + const projects = projectConnection?.nodes; return ( <> - <SubHeader> - <div className="container flex items-center pb-4"> - <div> - {organization && ( - <Link - href={`/${orgId}`} - className="line-clamp-1 flex max-w-[250px] items-center text-xs font-medium text-gray-500" - > - {organization.name} - </Link> + <header> + <div className="container flex h-[84px] items-center justify-between"> + <div className="flex flex-row items-center gap-4"> + <HiveLink className="w-8 h-8" /> + {currentOrganization ? ( + <NextLink href={`/${orgId}`} className="shrink-0 font-medium truncate max-w-[200px]"> + {currentOrganization.name} + </NextLink> + ) : ( + <div className="w-48 max-w-[200px] h-5 bg-gray-800 rounded-full animate-pulse" /> + )} + {projects?.length && currentProject ? ( + <> + <div className="text-gray-500 italic">/</div> + <Select + defaultValue={currentProject.cleanId} + onValueChange={id => { + router.visitProject({ + organizationId: orgId, + projectId: id, + }); + }} + > + <SelectTrigger variant="default"> + <div className="font-medium">{currentProject.name}</div> + </SelectTrigger> + <SelectContent> + {projects.map(project => ( + <SelectItem key={project.cleanId} value={project.cleanId}> + {project.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </> + ) : ( + <div className="w-48 h-5 bg-gray-800 rounded-full animate-pulse" /> )} - <div className="flex items-center gap-2.5"> - <Heading size="2xl" className="line-clamp-1 max-w-2xl"> - {project?.name} - </Heading> - {projects && projects.total > 1 && ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button size="small"> - <ArrowDownIcon className="h-5 w-5 text-gray-500" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent sideOffset={5} align="end"> - {projects.nodes.map( - node => - node.cleanId !== projectId && ( - <NextLink - key={node.cleanId} - href={`/${orgId}/${node.cleanId}`} - className="line-clamp-1 max-w-2xl" - > - <DropdownMenuItem>{node.name}</DropdownMenuItem> - </NextLink> - ), - )} - </DropdownMenuContent> - </DropdownMenu> - )} - </div> - <span className="text-xs font-bold text-[#34eab9]">{project?.type}</span> </div> - <Button - className="ml-auto shrink-0" - variant="primary" - size="large" - onClick={toggleModalOpen} - > - New Target - <TargetIcon className="ml-6" /> - </Button> - <CreateTargetModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} /> + <div> + <UserMenu + me={me ?? null} + currentOrganization={currentOrganization ?? null} + organizations={organizationConnection ?? null} + /> + </div> </div> - </SubHeader> + </header> - {value === 'settings' || project.registryModel !== 'LEGACY' ? null : ( + {value === 'settings' || currentProject?.registryModel !== 'LEGACY' ? null : ( <ProjectMigrationToast orgId={orgId} projectId={projectId} /> )} - - <Tabs className="container" value={value}> - <Tabs.List> - <Tabs.Trigger value={TabValue.Targets} asChild> - <NextLink href={`/${orgId}/${projectId}`}>Targets</NextLink> - </Tabs.Trigger> - {canAccessProject(ProjectAccessScope.Alerts, organization.me) && ( - <Tabs.Trigger value={TabValue.Alerts} asChild> - <NextLink href={`/${orgId}/${projectId}/view/${TabValue.Alerts}`}>Alerts</NextLink> - </Tabs.Trigger> + <div className="relative border-b border-gray-800"> + <div className="container flex justify-between items-center"> + {currentOrganization ? ( + <Tabs value={value}> + <Tabs.List> + <Tabs.Trigger value={TabValue.Targets} asChild> + <NextLink href={`/${orgId}/${projectId}`}>Targets</NextLink> + </Tabs.Trigger> + {canAccessProject(ProjectAccessScope.Alerts, currentOrganization.me) && ( + <Tabs.Trigger value={TabValue.Alerts} asChild> + <NextLink href={`/${orgId}/${projectId}/view/${TabValue.Alerts}`}> + Alerts + </NextLink> + </Tabs.Trigger> + )} + {canAccessProject(ProjectAccessScope.Settings, currentOrganization.me) && ( + <> + <Tabs.Trigger value={TabValue.Policy} asChild> + <NextLink href={`/${orgId}/${projectId}/view/${TabValue.Policy}`}> + Policy + </NextLink> + </Tabs.Trigger> + <Tabs.Trigger value={TabValue.Settings} asChild> + <NextLink href={`/${orgId}/${projectId}/view/${TabValue.Settings}`}> + Settings + </NextLink> + </Tabs.Trigger> + </> + )} + </Tabs.List> + </Tabs> + ) : ( + <div className="flex flex-row gap-x-8 px-4 py-3 border-b-[2px] border-b-transparent"> + <div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" /> + <div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" /> + <div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" /> + </div> )} - {canAccessProject(ProjectAccessScope.Settings, organization.me) && ( - <> - <Tabs.Trigger value={TabValue.Policy} asChild> - <NextLink href={`/${orgId}/${projectId}/view/${TabValue.Policy}`}>Policy</NextLink> - </Tabs.Trigger> - <Tabs.Trigger value={TabValue.Settings} asChild> - <NextLink href={`/${orgId}/${projectId}/view/${TabValue.Settings}`}> - Settings - </NextLink> - </Tabs.Trigger> - </> - )} - </Tabs.List> - <Tabs.Content value={value} className={className}> - {children( - { - project: projectQuery.data?.project as Exclude< - TSatisfiesType['project'], - null | undefined - >, - organization: projectQuery.data?.organization as Exclude< - TSatisfiesType['organization'], - null | undefined - >, - }, - { - organization: orgId, - project: projectId, - }, - )} - </Tabs.Content> - </Tabs> + {currentProject ? ( + <Button onClick={toggleModalOpen} variant="link" className="text-orange-500"> + <PlusIcon size={16} className="mr-2" /> + New target + </Button> + ) : null} + <CreateTargetModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} /> + </div> + </div> + <div className="container pb-7 h-full"> + <div className={className}>{children}</div> + </div> </> ); } diff --git a/packages/web/app/src/components/layouts/target.tsx b/packages/web/app/src/components/layouts/target.tsx index 9ded85000..b6033fc2d 100644 --- a/packages/web/app/src/components/layouts/target.tsx +++ b/packages/web/app/src/components/layouts/target.tsx @@ -1,20 +1,15 @@ -import { ReactElement, ReactNode, useEffect } from 'react'; +import { ReactElement, ReactNode } from 'react'; import NextLink from 'next/link'; -import { TypedDocumentNode, useQuery } from 'urql'; -import { Button, Heading, Link, SubHeader, Tabs } from '@/components/v2'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/v2/dropdown'; -import { ArrowDownIcon } from '@/components/v2/icon'; +import { Link } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; +import { UserMenu } from '@/components/ui/user-menu'; +import { HiveLink, Tabs } from '@/components/v2'; 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 { cn } from '@/lib/utils'; import { ProjectMigrationToast } from '../project/migration-toast'; enum TabValue { @@ -27,20 +22,48 @@ enum TabValue { Settings = 'settings', } -export const TargetLayout_OrganizationFragment = graphql(` - fragment TargetLayout_OrganizationFragment on Organization { +const TargetLayout_CurrentOrganizationFragment = graphql(` + fragment TargetLayout_CurrentOrganizationFragment on Organization { + id + name + cleanId me { ...CanAccessTarget_MemberFragment } - name + ...UserMenu_CurrentOrganizationFragment + projects { + ...ProjectLayout_ProjectConnectionFragment + } } `); -const TargetLayout_ProjectFragment = graphql(` - fragment TargetLayout_ProjectFragment on Project { - type +const TargetLayout_MeFragment = graphql(` + fragment TargetLayout_MeFragment on User { + id + ...UserMenu_MeFragment + } +`); + +const TargetLayout_OrganizationConnectionFragment = graphql(` + fragment TargetLayout_OrganizationConnectionFragment on OrganizationConnection { + nodes { + id + cleanId + name + } + ...UserMenu_OrganizationConnectionFragment + } +`); + +const TargetLayout_CurrentProjectFragment = graphql(` + fragment TargetLayout_CurrentProjectFragment on Project { + id + cleanId name registryModel + targets { + ...TargetLayout_TargetConnectionFragment + } } `); @@ -60,199 +83,205 @@ const TargetLayout_IsCDNEnabledFragment = graphql(` } `); -export const TargetLayout = < - TSatisfies extends { - organization?: - | { - organization?: FragmentType<typeof TargetLayout_OrganizationFragment> | null; - } - | null - | undefined; - project?: FragmentType<typeof TargetLayout_ProjectFragment> | null; - targets: FragmentType<typeof TargetLayout_TargetConnectionFragment>; - } & FragmentType<typeof TargetLayout_IsCDNEnabledFragment>, ->({ +export const TargetLayout = ({ children, connect, value, className, - query, + ...props }: { - children(props: TSatisfies): ReactNode; value: 'schema' | 'explorer' | 'checks' | 'history' | 'operations' | 'laboratory' | 'settings'; className?: string; + children: ReactNode; connect?: ReactNode; - query: TypedDocumentNode< - TSatisfies, - { - organizationId: string; - projectId: string; - targetId: string; - } - >; + me: FragmentType<typeof TargetLayout_MeFragment> | null; + currentOrganization: FragmentType<typeof TargetLayout_CurrentOrganizationFragment> | null; + currentProject: FragmentType<typeof TargetLayout_CurrentProjectFragment> | null; + organizations: FragmentType<typeof TargetLayout_OrganizationConnectionFragment> | null; + isCDNEnabled: FragmentType<typeof TargetLayout_IsCDNEnabledFragment> | null; }): ReactElement | null => { + const router = useRouteSelector(); const [isModalOpen, toggleModalOpen] = useToggle(); - const router = useRouteSelector(); - const { organizationId: orgId, projectId, targetId } = router; + const { organizationId: orgId, projectId } = router; - const [data] = useQuery({ - query, - variables: { - organizationId: orgId, - projectId, - targetId, - }, - requestPolicy: 'cache-and-network', - }); + const currentOrganization = useFragment( + TargetLayout_CurrentOrganizationFragment, + props.currentOrganization, + ); + const currentProject = useFragment(TargetLayout_CurrentProjectFragment, props.currentProject); - const isCDNEnabled = useFragment(TargetLayout_IsCDNEnabledFragment, data.data); - const targets = useFragment(TargetLayout_TargetConnectionFragment, data.data?.targets); - const target = targets?.nodes.find(node => node.cleanId === targetId); - const org = useFragment(TargetLayout_OrganizationFragment, data.data?.organization?.organization); - const project = useFragment(TargetLayout_ProjectFragment, data.data?.project); - const me = org?.me; - - useEffect(() => { - if (!data.fetching && !target) { - // url with # provoke error Maximum update depth exceeded - void router.push('/404', router.asPath.replace(/#.*/, '')); - } - }, [router, target, data.fetching]); - - useEffect(() => { - if (!data.fetching && !project) { - // url with # provoke error Maximum update depth exceeded - void router.push('/404', router.asPath.replace(/#.*/, '')); - } - }, [router, project, data.fetching]); + const me = useFragment(TargetLayout_MeFragment, props.me); + const organizationConnection = useFragment( + TargetLayout_OrganizationConnectionFragment, + props.organizations, + ); + const targetConnection = useFragment( + TargetLayout_TargetConnectionFragment, + currentProject?.targets, + ); + const targets = targetConnection?.nodes; + const currentTarget = targets?.find(target => target.cleanId === router.targetId); + const isCDNEnabled = useFragment(TargetLayout_IsCDNEnabledFragment, props.isCDNEnabled); useTargetAccess({ scope: TargetAccessScope.Read, - member: me ?? null, + member: currentOrganization?.me ?? null, redirect: true, }); - const canAccessSchema = canAccessTarget(TargetAccessScope.RegistryRead, me ?? null); - const canAccessSettings = canAccessTarget(TargetAccessScope.Settings, me ?? null); - - if (data.fetching) { - return null; - } - - if (data.error) { - return <QueryError error={data.error} />; - } - - if (!org || !project || !target) { - return null; - } + const canAccessSchema = canAccessTarget( + TargetAccessScope.RegistryRead, + currentOrganization?.me ?? null, + ); + const canAccessSettings = canAccessTarget( + TargetAccessScope.Settings, + currentOrganization?.me ?? null, + ); return ( <> - <SubHeader> - <div className="container flex items-center"> - <div> - <div className="flex items-center text-xs font-medium text-gray-500"> - <Link href={`/${orgId}`} className="line-clamp-1 max-w-[250px]"> - {org.name} - </Link> - <ArrowDownIcon className="mx-1 h-4 w-4 -rotate-90 stroke-[1px]" /> - <Link href={`/${orgId}/${projectId}`} className="line-clamp-1 max-w-[250px]"> - {project.name} - </Link> - </div> - <div className="flex items-center gap-2.5"> - <Heading size="2xl" className="line-clamp-1 max-w-2xl"> - {target?.name} - </Heading> - {targets && targets.total > 1 && ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button size="small"> - <ArrowDownIcon className="h-5 w-5 text-gray-500" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent sideOffset={5} align="end"> - {targets.nodes.map( - node => - node.cleanId !== targetId && ( - <NextLink - key={node.cleanId} - href={`/${orgId}/${projectId}/${node.cleanId}`} - className="line-clamp-1 max-w-2xl" - > - <DropdownMenuItem>{node.name}</DropdownMenuItem> - </NextLink> - ), - )} - </DropdownMenuContent> - </DropdownMenu> - )} - </div> - <div className="mb-10 text-xs font-bold text-[#34eab9]">{project?.type}</div> - </div> - {connect ?? - (isCDNEnabled?.isCDNEnabled ? ( + <header> + <div className="container flex h-[84px] items-center justify-between"> + <div className="flex flex-row items-center gap-4"> + <HiveLink className="w-8 h-8" /> + {currentOrganization ? ( + <NextLink href={`/${orgId}`} className="shrink-0 font-medium truncate max-w-[200px]"> + {currentOrganization.name} + </NextLink> + ) : ( + <div className="w-48 max-w-[200px] h-5 bg-gray-800 rounded-full animate-pulse" /> + )} + <div className="text-gray-500 italic">/</div> + {currentProject ? ( + <NextLink + href={`/${orgId}/${projectId}`} + className="shrink-0 font-medium truncate max-w-[200px]" + > + {currentProject.name} + </NextLink> + ) : ( + <div className="w-48 max-w-[200px] h-5 bg-gray-800 rounded-full animate-pulse" /> + )} + <div className="text-gray-500 italic">/</div> + {targets?.length && currentOrganization && currentProject && currentTarget ? ( <> - <Button - size="large" - variant="primary" - onClick={toggleModalOpen} - className="ml-auto" + <Select + defaultValue={currentTarget.cleanId} + onValueChange={id => { + router.visitTarget({ + organizationId: currentOrganization.cleanId, + projectId: currentProject.cleanId, + targetId: id, + }); + }} > - Connect to CDN - <Link1Icon className="ml-8 h-6 w-auto" /> - </Button> - <ConnectSchemaModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} /> + <SelectTrigger variant="default"> + <div className="font-medium">{currentTarget.name}</div> + </SelectTrigger> + <SelectContent> + {targets.map(target => ( + <SelectItem key={target.cleanId} value={target.cleanId}> + {target.name} + </SelectItem> + ))} + </SelectContent> + </Select> </> - ) : null)} + ) : ( + <div className="w-48 max-w-[200px] h-5 bg-gray-800 rounded-full animate-pulse" /> + )} + </div> + <div> + <UserMenu + me={me ?? null} + currentOrganization={currentOrganization ?? null} + organizations={organizationConnection ?? null} + /> + </div> </div> - </SubHeader> + </header> - {project.registryModel === 'LEGACY' ? ( + {currentProject?.registryModel === 'LEGACY' ? ( <ProjectMigrationToast orgId={orgId} projectId={projectId} /> ) : null} - <Tabs className="container flex h-full grow flex-col" value={value}> - <Tabs.List> - {canAccessSchema && ( - <> - <Tabs.Trigger value={TabValue.Schema} asChild> - <NextLink href={`/${orgId}/${projectId}/${targetId}`}>Schema</NextLink> - </Tabs.Trigger> - <Tabs.Trigger value={TabValue.Checks} asChild> - <NextLink href={`/${orgId}/${projectId}/${targetId}/checks`}>Checks</NextLink> - </Tabs.Trigger> - <Tabs.Trigger value={TabValue.Explorer} asChild> - <NextLink href={`/${orgId}/${projectId}/${targetId}/explorer`}>Explorer</NextLink> - </Tabs.Trigger> - <Tabs.Trigger value={TabValue.History} asChild> - <NextLink href={`/${orgId}/${projectId}/${targetId}/history`}>History</NextLink> - </Tabs.Trigger> - <Tabs.Trigger value={TabValue.Operations} asChild> - <NextLink href={`/${orgId}/${projectId}/${targetId}/operations`}> - Operations - </NextLink> - </Tabs.Trigger> - <Tabs.Trigger value={TabValue.Laboratory} asChild> - <NextLink href={`/${orgId}/${projectId}/${targetId}/laboratory`}> - Laboratory - </NextLink> - </Tabs.Trigger> - </> + <div className="relative border-b border-gray-800"> + <div className="container flex justify-between items-center"> + {currentTarget ? ( + <Tabs className="flex h-full grow flex-col" value={value}> + <Tabs.List> + {canAccessSchema && ( + <> + <Tabs.Trigger value={TabValue.Schema} asChild> + <NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}`}> + Schema + </NextLink> + </Tabs.Trigger> + <Tabs.Trigger value={TabValue.Checks} asChild> + <NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}/checks`}> + Checks + </NextLink> + </Tabs.Trigger> + <Tabs.Trigger value={TabValue.Explorer} asChild> + <NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}/explorer`}> + Explorer + </NextLink> + </Tabs.Trigger> + <Tabs.Trigger value={TabValue.History} asChild> + <NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}/history`}> + History + </NextLink> + </Tabs.Trigger> + <Tabs.Trigger value={TabValue.Operations} asChild> + <NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}/operations`}> + Operations + </NextLink> + </Tabs.Trigger> + <Tabs.Trigger value={TabValue.Laboratory} asChild> + <NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}/laboratory`}> + Laboratory + </NextLink> + </Tabs.Trigger> + </> + )} + {canAccessSettings && ( + <Tabs.Trigger value={TabValue.Settings} asChild> + <NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}/settings`}> + Settings + </NextLink> + </Tabs.Trigger> + )} + </Tabs.List> + </Tabs> + ) : ( + <div className="flex flex-row gap-x-8 px-4 py-3 border-b-[2px] border-b-transparent"> + <div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" /> + <div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" /> + <div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" /> + </div> )} - {canAccessSettings && ( - <Tabs.Trigger value={TabValue.Settings} asChild> - <NextLink href={`/${orgId}/${projectId}/${targetId}/settings`}>Settings</NextLink> - </Tabs.Trigger> - )} - </Tabs.List> - - <Tabs.Content value={value} className={className}> - {children(data.data as TSatisfies)} - </Tabs.Content> - </Tabs> + {currentTarget ? ( + // eslint-disable-next-line unicorn/no-negated-condition + connect != null ? ( + connect + ) : isCDNEnabled ? ( + <> + <Button onClick={toggleModalOpen} variant="link" className="text-orange-500"> + <Link size={16} className="mr-2" /> + Connect to CDN + </Button> + <ConnectSchemaModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} /> + </> + ) : null + ) : null} + </div> + </div> + <div className="container pb-7 h-full"> + <div className={cn('flex justify-between gap-12', className)}> + <div className="grow">{children}</div> + </div> + </div> </> ); }; diff --git a/packages/web/app/src/components/policy/policy-list-item.tsx b/packages/web/app/src/components/policy/policy-list-item.tsx index d73a18a84..6afc9710e 100644 --- a/packages/web/app/src/components/policy/policy-list-item.tsx +++ b/packages/web/app/src/components/policy/policy-list-item.tsx @@ -1,9 +1,10 @@ import { ReactElement } from 'react'; import type { JSONSchema } from 'json-schema-typed'; +import { Checkbox } from '@/components/ui/checkbox'; import { Markdown } from '@/components/v2/markdown'; import { FragmentType, graphql, useFragment } from '@/gql'; import { RuleInstanceSeverityLevel } from '@/graphql'; -import { Checkbox, DocsLink, Tooltip } from '../v2'; +import { DocsLink, Tooltip } from '../v2'; import { useConfigurationHelper } from './form-helper'; import { PolicyRuleConfig } from './rules-configuration'; import { SeverityLevelToggle } from './rules-configuration/severity-toggle'; @@ -73,7 +74,7 @@ export function PolicyListItem(props: { </> } > - <label htmlFor={ruleInfo.id} className="font-mono font-bold"> + <label htmlFor={ruleInfo.id} className="font-mono text-sm font-medium"> {ruleInfo.id} </label> </Tooltip> diff --git a/packages/web/app/src/components/policy/policy-settings.tsx b/packages/web/app/src/components/policy/policy-settings.tsx index ea20824f8..b152b1d4e 100644 --- a/packages/web/app/src/components/policy/policy-settings.tsx +++ b/packages/web/app/src/components/policy/policy-settings.tsx @@ -1,6 +1,7 @@ import { ReactElement, useMemo, useRef } from 'react'; import { Formik, FormikHelpers, FormikProps } from 'formik'; import { useQuery } from 'urql'; +import { Button } from '@/components/ui/button'; import { FragmentType, graphql, useFragment } from '@/gql'; import { PolicySettings_SchemaPolicyFragmentFragment, @@ -8,7 +9,7 @@ import { SchemaPolicyInput, } from '@/graphql'; import type { ResultOf } from '@graphql-typed-document-node/core'; -import { Button, Callout, DataWrapper } from '../v2'; +import { Callout, DataWrapper } from '../v2'; import { PolicyListItem } from './policy-list-item'; import { buildValidationSchema, PolicyFormValues } from './rules-configuration'; @@ -108,7 +109,7 @@ function PolicySettingsListForm({ <Button disabled={!props.dirty || saving} type="submit" - variant="primary" + variant="default" onClick={() => props.submitForm()} > Update Policy diff --git a/packages/web/app/src/components/project/alerts/alerts-table.tsx b/packages/web/app/src/components/project/alerts/alerts-table.tsx new file mode 100644 index 000000000..375886368 --- /dev/null +++ b/packages/web/app/src/components/project/alerts/alerts-table.tsx @@ -0,0 +1,51 @@ +import { Checkbox, Table, TBody, Td, Tr } from '@/components/v2'; +import { FragmentType, graphql, useFragment } from '@/gql'; + +export const AlertsTable_AlertFragment = graphql(` + fragment AlertsTable_AlertFragment on Alert { + id + type + channel { + id + name + type + } + target { + id + cleanId + name + } + } +`); + +export function AlertsTable(props: { + alerts: FragmentType<typeof AlertsTable_AlertFragment>[]; + isChecked: (alertId: string) => boolean; + onCheckedChange: (alertId: string, checked: boolean) => void; +}) { + const alerts = useFragment(AlertsTable_AlertFragment, props.alerts); + + return ( + <Table> + <TBody> + {alerts.map(alert => ( + <Tr key={alert.id}> + <Td width="1"> + <Checkbox + onCheckedChange={isChecked => { + props.onCheckedChange(alert.id, isChecked === true); + }} + checked={props.isChecked(alert.id)} + /> + </Td> + <Td> + <span className="capitalize">{alert.type.replaceAll('_', ' ').toLowerCase()}</span> + </Td> + <Td>Channel: {alert.channel.name}</Td> + <Td>Target: {alert.target.name}</Td> + </Tr> + ))} + </TBody> + </Table> + ); +} diff --git a/packages/web/app/src/components/project/alerts/channels-table.tsx b/packages/web/app/src/components/project/alerts/channels-table.tsx new file mode 100644 index 000000000..838566194 --- /dev/null +++ b/packages/web/app/src/components/project/alerts/channels-table.tsx @@ -0,0 +1,57 @@ +import { Checkbox, Table, Tag, TBody, Td, Tr } from '@/components/v2'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { AlertChannelType } from '@/gql/graphql'; + +export const ChannelsTable_AlertChannelFragment = graphql(` + fragment ChannelsTable_AlertChannelFragment on AlertChannel { + id + name + type + ... on AlertSlackChannel { + channel + } + ... on AlertWebhookChannel { + endpoint + } + } +`); + +export function ChannelsTable(props: { + channels: FragmentType<typeof ChannelsTable_AlertChannelFragment>[]; + isChecked: (channelId: string) => boolean; + onCheckedChange: (channelId: string, checked: boolean) => void; +}) { + const channels = useFragment(ChannelsTable_AlertChannelFragment, props.channels); + + return ( + <Table> + <TBody> + {channels.map(channel => ( + <Tr key={channel.id}> + <Td width="1"> + <Checkbox + onCheckedChange={isChecked => { + props.onCheckedChange(channel.id, isChecked === true); + }} + checked={props.isChecked(channel.id)} + /> + </Td> + <Td>{channel.name}</Td> + <Td className="text-xs truncate text-gray-400"> + {channel.__typename === 'AlertSlackChannel' + ? channel.channel + : channel.__typename === 'AlertWebhookChannel' + ? channel.endpoint + : ''} + </Td> + <Td> + <Tag color={channel.type === AlertChannelType.Webhook ? 'green' : 'yellow'}> + {channel.type} + </Tag> + </Td> + </Tr> + ))} + </TBody> + </Table> + ); +} diff --git a/packages/web/app/src/components/v2/modals/create-alert.tsx b/packages/web/app/src/components/project/alerts/create-alert.tsx similarity index 74% rename from packages/web/app/src/components/v2/modals/create-alert.tsx rename to packages/web/app/src/components/project/alerts/create-alert.tsx index 22913bb06..14b50722b 100644 --- a/packages/web/app/src/components/v2/modals/create-alert.tsx +++ b/packages/web/app/src/components/project/alerts/create-alert.tsx @@ -1,43 +1,56 @@ import { ReactElement } from 'react'; import { useFormik } from 'formik'; -import { useMutation, useQuery } from 'urql'; +import { useMutation } from 'urql'; import * as Yup from 'yup'; import { Button, Heading, Modal, Select } from '@/components/v2'; -import { AddAlertDocument, AlertChannelsDocument, AlertType, TargetsDocument } from '@/graphql'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { AlertType } from '@/graphql'; import { useRouteSelector } from '@/lib/hooks'; -export const CreateAlertModal = ({ - isOpen, - toggleModalOpen, -}: { +export const CreateAlertModal_AddAlertMutation = graphql(` + mutation CreateAlertModal_AddAlertMutation($input: AddAlertInput!) { + addAlert(input: $input) { + ok { + updatedProject { + id + } + addedAlert { + ...AlertsTable_AlertFragment + } + } + error { + message + } + } + } +`); + +export const CreateAlertModal_TargetFragment = graphql(` + fragment CreateAlertModal_TargetFragment on Target { + id + cleanId + name + } +`); + +export const CreateAlertModal_AlertChannelFragment = graphql(` + fragment CreateAlertModal_AlertChannelFragment on AlertChannel { + id + name + } +`); + +export const CreateAlertModal = (props: { isOpen: boolean; toggleModalOpen: () => void; + targets: FragmentType<typeof CreateAlertModal_TargetFragment>[]; + channels: FragmentType<typeof CreateAlertModal_AlertChannelFragment>[]; }): ReactElement => { - const [mutation, mutate] = useMutation(AddAlertDocument); + const { isOpen, toggleModalOpen } = props; + const targets = useFragment(CreateAlertModal_TargetFragment, props.targets); + const channels = useFragment(CreateAlertModal_AlertChannelFragment, props.channels); + const [mutation, mutate] = useMutation(CreateAlertModal_AddAlertMutation); const router = useRouteSelector(); - const [targetsQuery] = useQuery({ - query: TargetsDocument, - variables: { - selector: { - organization: router.organizationId, - project: router.projectId, - }, - }, - requestPolicy: 'cache-and-network', - }); - const [channelsQuery] = useQuery({ - query: AlertChannelsDocument, - variables: { - selector: { - organization: router.organizationId, - project: router.projectId, - }, - }, - requestPolicy: 'cache-and-network', - }); - - const channels = channelsQuery.data?.alertChannels || []; - const targets = targetsQuery.data?.targets.nodes || []; const { handleSubmit, values, handleChange, errors, touched, isSubmitting } = useFormik({ initialValues: { @@ -61,7 +74,7 @@ export const CreateAlertModal = ({ ), }), async onSubmit(values) { - const { error } = await mutate({ + const { error, data } = await mutate({ input: { organization: router.organizationId, project: router.projectId, @@ -70,7 +83,8 @@ export const CreateAlertModal = ({ type: values.type, }, }); - if (!error) { + console.log({ data, error }); + if (!error && data?.addAlert) { toggleModalOpen(); } }, diff --git a/packages/web/app/src/components/v2/modals/create-channel.tsx b/packages/web/app/src/components/project/alerts/create-channel.tsx similarity index 94% rename from packages/web/app/src/components/v2/modals/create-channel.tsx rename to packages/web/app/src/components/project/alerts/create-channel.tsx index eccc48c6d..2d4a21113 100644 --- a/packages/web/app/src/components/v2/modals/create-channel.tsx +++ b/packages/web/app/src/components/project/alerts/create-channel.tsx @@ -7,13 +7,15 @@ import { graphql } from '@/gql'; import { AlertChannelType } from '@/graphql'; import { useRouteSelector } from '@/lib/hooks'; -const CreateChannel_AddAlertChannelMutation = graphql(` +export const CreateChannel_AddAlertChannelMutation = graphql(` mutation CreateChannel_AddAlertChannel($input: AddAlertChannelInput!) { addAlertChannel(input: $input) { ok { + updatedProject { + id + } addedAlertChannel { - ...AlertSlackChannelFields - ...AlertWebhookChannelFields + ...ChannelsTable_AlertChannelFragment } } error { @@ -60,7 +62,7 @@ export const CreateChannelModal = ({ ), }), async onSubmit(values) { - const { data } = await mutate({ + const { data, error } = await mutate({ input: { organization: router.organizationId, project: router.projectId, @@ -71,6 +73,13 @@ export const CreateChannelModal = ({ values.type === AlertChannelType.Webhook ? { endpoint: values.endpoint } : null, }, }); + if (error) { + console.error(error); + } + if (data?.addAlertChannel.error) { + console.log('local error'); + console.error(data.addAlertChannel.error); + } if (data?.addAlertChannel.ok) { toggleModalOpen(); } diff --git a/packages/web/app/src/components/project/alerts/delete-alerts-button.tsx b/packages/web/app/src/components/project/alerts/delete-alerts-button.tsx new file mode 100644 index 000000000..6ce6fdaaa --- /dev/null +++ b/packages/web/app/src/components/project/alerts/delete-alerts-button.tsx @@ -0,0 +1,51 @@ +import { useMutation } from 'urql'; +import { Button } from '@/components/ui/button'; +import { graphql } from '@/gql'; + +export const DeleteAlertsButton_DeleteAlertsMutation = graphql(` + mutation DeleteAlertsButton_DeleteAlertsMutation($input: DeleteAlertsInput!) { + deleteAlerts(input: $input) { + ok { + updatedProject { + id + } + } + error { + message + } + } + } +`); + +export function DeleteAlertsButton({ + selected, + onSuccess, + organizationId, + projectId, +}: { + selected: string[]; + onSuccess(): void; + organizationId: string; + projectId: string; +}) { + const [mutation, mutate] = useMutation(DeleteAlertsButton_DeleteAlertsMutation); + + return ( + <Button + variant="destructive" + disabled={selected.length === 0 || mutation.fetching} + onClick={async () => { + await mutate({ + input: { + organization: organizationId, + project: projectId, + alerts: selected, + }, + }); + onSuccess(); + }} + > + Delete {selected.length || null} + </Button> + ); +} diff --git a/packages/web/app/src/components/project/alerts/delete-channels-button.tsx b/packages/web/app/src/components/project/alerts/delete-channels-button.tsx new file mode 100644 index 000000000..f05715089 --- /dev/null +++ b/packages/web/app/src/components/project/alerts/delete-channels-button.tsx @@ -0,0 +1,51 @@ +import { useMutation } from 'urql'; +import { Button } from '@/components/ui/button'; +import { graphql } from '@/gql'; + +export const DeleteChannelsButton_DeleteChannelsMutation = graphql(` + mutation DeleteChannelsButton_DeleteChannelsMutation($input: DeleteAlertChannelsInput!) { + deleteAlertChannels(input: $input) { + ok { + updatedProject { + id + } + } + error { + message + } + } + } +`); + +export function DeleteChannelsButton({ + selected, + onSuccess, + organizationId, + projectId, +}: { + selected: string[]; + onSuccess(): void; + organizationId: string; + projectId: string; +}) { + const [mutation, mutate] = useMutation(DeleteChannelsButton_DeleteChannelsMutation); + + return ( + <Button + variant="destructive" + disabled={selected.length === 0 || mutation.fetching} + onClick={async () => { + await mutate({ + input: { + organization: organizationId, + project: projectId, + channels: selected, + }, + }); + onSuccess(); + }} + > + Delete {selected.length || null} + </Button> + ); +} diff --git a/packages/web/app/src/components/target/explorer/common.tsx b/packages/web/app/src/components/target/explorer/common.tsx index 0045b4395..ab09d838b 100644 --- a/packages/web/app/src/components/target/explorer/common.tsx +++ b/packages/web/app/src/components/target/explorer/common.tsx @@ -179,17 +179,16 @@ const GraphQLTypeCard_SupergraphMetadataFragment = graphql(` } `); -export function GraphQLTypeCard( - props: React.PropsWithChildren<{ - kind: string; - name: string; - description?: string | null; - implements?: string[]; - totalRequests?: number; - usage?: FragmentType<typeof SchemaExplorerUsageStats_UsageFragment>; - supergraphMetadata?: FragmentType<typeof GraphQLTypeCard_SupergraphMetadataFragment> | null; - }>, -): ReactElement { +export function GraphQLTypeCard(props: { + kind: string; + name: string; + description?: string | null; + implements?: string[]; + totalRequests?: number; + usage?: FragmentType<typeof SchemaExplorerUsageStats_UsageFragment>; + supergraphMetadata?: FragmentType<typeof GraphQLTypeCard_SupergraphMetadataFragment> | null; + children: ReactNode; +}): ReactElement { const supergraphMetadata = useFragment( GraphQLTypeCard_SupergraphMetadataFragment, props.supergraphMetadata, diff --git a/packages/web/app/src/components/target/explorer/filter.tsx b/packages/web/app/src/components/target/explorer/filter.tsx index 3021b5bcd..5e12afde6 100644 --- a/packages/web/app/src/components/target/explorer/filter.tsx +++ b/packages/web/app/src/components/target/explorer/filter.tsx @@ -88,9 +88,9 @@ export function SchemaExplorerFilter({ ); return ( - <div className="flex flex-row items-center lg:gap-12 gap-4"> + <div className="flex flex-row items-center gap-x-4"> <Autocomplete - className="grow" + className="grow min-w-[250px]" placeholder="Search for a type" defaultValue={typename ? { value: typename, label: typename } : null} options={types} diff --git a/packages/web/app/src/components/target/explorer/provider.tsx b/packages/web/app/src/components/target/explorer/provider.tsx index 0db716418..ae6acbf62 100644 --- a/packages/web/app/src/components/target/explorer/provider.tsx +++ b/packages/web/app/src/components/target/explorer/provider.tsx @@ -37,9 +37,11 @@ type SchemaExplorerContextType = { isArgumentListCollapsed: boolean; setArgumentListCollapsed(isCollapsed: boolean): void; setPeriodOption(option: PeriodOption): void; + setDataRetentionInDays(days: number): void; periodOption: PeriodOption; availablePeriodOptions: PeriodOption[]; period: Period; + dataRetentionInDays: number; }; const SchemaExplorerContext = createContext<SchemaExplorerContextType>({ @@ -49,15 +51,14 @@ const SchemaExplorerContext = createContext<SchemaExplorerContextType>({ period: createPeriod('7d'), availablePeriodOptions: ['7d'], setPeriodOption: () => {}, + dataRetentionInDays: 7, + setDataRetentionInDays: () => {}, }); -export function SchemaExplorerProvider({ - dataRetentionInDays, - children, -}: { - dataRetentionInDays: number; - children: ReactNode; -}): ReactElement { +export function SchemaExplorerProvider({ children }: { children: ReactNode }): ReactElement { + const [dataRetentionInDays, setDataRetentionInDays] = useState( + 7 /* Minimum possible data retention period - Free plan */, + ); const [isArgumentListCollapsed, setArgumentListCollapsed] = useLocalStorage( 'hive:schema-explorer:collapsed', true, @@ -98,6 +99,8 @@ export function SchemaExplorerProvider({ setPeriodOption: updatePeriod, periodOption, availablePeriodOptions, + dataRetentionInDays, + setDataRetentionInDays, }} > {children} diff --git a/packages/web/app/src/components/target/operations/Stats.tsx b/packages/web/app/src/components/target/operations/Stats.tsx index 9c1f973fc..2b6a75c44 100644 --- a/packages/web/app/src/components/target/operations/Stats.tsx +++ b/packages/web/app/src/components/target/operations/Stats.tsx @@ -21,114 +21,7 @@ import { } from '@/lib/hooks'; import { useChartStyles } from '@/utils'; import { OperationsFallback } from './Fallback'; - -function resolutionToMilliseconds( - resolution: number, - period: { - from: string; - to: string; - }, -) { - const distanceInMinutes = - (new Date(period.to).getTime() - new Date(period.from).getTime()) / 1000 / 60; - - return Math.round(distanceInMinutes / resolution) * 1000 * 60; -} - -/** - * Adds missing samples to left and right sides of the series. We end up with a smooooooth chart - */ -function fullSeries( - data: [string, number][], - interval: number, - period: { - from: string; - to: string; - }, -): [string, number][] { - if (!data.length) { - return createEmptySeries({ interval, period }); - } - - // Find the first sample - const firstSample = new Date(data[0][0]).getTime(); - // Find the last sample - const lastSample = new Date(data[data.length - 1][0]).getTime(); - // Turn `period.from` to a number - const startAt = new Date(period.from).getTime(); - // Turn `period.to` to a number - const endAt = new Date(period.to).getTime(); - - // Calculate the number missing steps by - // 1. comparing two dates (last/first sample and the expected boundary sample) - // 2. dividing by interval - // 3. rounding to floor int - const stepsToAddOnLeft = Math.floor(Math.abs(firstSample - startAt) / interval); - const stepsToAddOnRight = Math.floor(Math.abs(endAt - lastSample) / interval); - - // Add n steps to the left side where each sample has its date decreased by i*interval based on the first sample - for (let i = 1; i <= stepsToAddOnLeft; i++) { - data.unshift([new Date(firstSample - i * interval).toISOString(), 0]); - } - - // Add n steps to the right side where each sample has its date increased by i*interval based on the last sample - for (let i = 1; i <= stepsToAddOnRight; i++) { - data.push([new Date(lastSample + i * interval).toISOString(), 0]); - } - - // Instead of creating a new array, we could move things around but this is easier - const newData: [string, number][] = []; - - for (let i = 0; i < data.length; i++) { - const current = data[i]; - const previous = data[i - 1]; - - if (previous) { - const currentTime = new Date(current[0]).getTime(); - const previousTime = new Date(previous[0]).getTime(); - const diff = currentTime - previousTime; - - // We do subtract the interval to make sure we don't duplicate the current sample - const stepsToAdd = Math.floor(Math.abs(diff - interval) / interval); - - if (stepsToAdd > 0) { - // We start with 1 because we already have one sample on the left side - for (let j = 1; j <= stepsToAdd; j++) { - newData.push([new Date(previousTime + j * interval).toISOString(), 0]); - } - } - } - - newData.push(current); - } - - return newData; -} - -function times<T>(amount: number, f: (index: number) => T) { - const items: Array<T> = []; - for (let i = 0; i < amount; i++) { - items.push(f(i)); - } - return items; -} - -function createEmptySeries({ - interval, - period, -}: { - interval: number; - period: { - from: string; - to: string; - }; -}): [string, number][] { - const startAt = new Date(period.from).getTime(); - const endAt = new Date(period.to).getTime(); - - const steps = Math.floor((endAt - startAt) / interval); - return times(steps, i => [new Date(startAt + i * interval).toISOString(), 0]); -} +import { createEmptySeries, fullSeries, resolutionToMilliseconds } from './utils'; const classes = { root: clsx('text-center'), @@ -446,16 +339,19 @@ function ClientsStats({ <div className="w-full rounded-md bg-gray-900/50 p-5 border border-gray-800"> <Section.Title>Clients</Section.Title> <Section.Subtitle>Top 5 - GraphQL API consumers</Section.Subtitle> - <AutoSizer disableHeight className="mt-5 w-full"> + <AutoSizer disableHeight className="mt-5 w-full flex flex-row gap-x-4"> {size => { if (!size.width) { return <></>; } + const gapX4 = 16; + const innerWidth = size.width - gapX4 * 2; + return ( - <div className="flex w-full flex-row gap-4"> + <> <ReactECharts - style={{ width: size.width / 2, height: 200 }} + style={{ width: innerWidth / 2, height: 200 }} option={{ ...styles, grid: { @@ -495,7 +391,7 @@ function ClientsStats({ }} /> <ReactECharts - style={{ width: size.width / 2, height: 200 }} + style={{ width: innerWidth / 2, height: 200 }} option={{ ...styles, grid: { @@ -534,7 +430,7 @@ function ClientsStats({ ], }} /> - </div> + </> ); }} </AutoSizer> diff --git a/packages/web/app/src/components/target/operations/utils.ts b/packages/web/app/src/components/target/operations/utils.ts new file mode 100644 index 000000000..7864eefe3 --- /dev/null +++ b/packages/web/app/src/components/target/operations/utils.ts @@ -0,0 +1,107 @@ +export function resolutionToMilliseconds( + resolution: number, + period: { + from: string; + to: string; + }, +) { + const distanceInMinutes = + (new Date(period.to).getTime() - new Date(period.from).getTime()) / 1000 / 60; + + return Math.round(distanceInMinutes / resolution) * 1000 * 60; +} + +/** + * Adds missing samples to left and right sides of the series. We end up with a smooooooth chart + */ +export function fullSeries( + data: [string, number][], + interval: number, + period: { + from: string; + to: string; + }, +): [string, number][] { + if (!data.length) { + return createEmptySeries({ interval, period }); + } + + // Find the first sample + const firstSample = new Date(data[0][0]).getTime(); + // Find the last sample + const lastSample = new Date(data[data.length - 1][0]).getTime(); + // Turn `period.from` to a number + const startAt = new Date(period.from).getTime(); + // Turn `period.to` to a number + const endAt = new Date(period.to).getTime(); + + // Calculate the number missing steps by + // 1. comparing two dates (last/first sample and the expected boundary sample) + // 2. dividing by interval + // 3. rounding to floor int + const stepsToAddOnLeft = Math.floor(Math.abs(firstSample - startAt) / interval); + const stepsToAddOnRight = Math.floor(Math.abs(endAt - lastSample) / interval); + + // Add n steps to the left side where each sample has its date decreased by i*interval based on the first sample + for (let i = 1; i <= stepsToAddOnLeft; i++) { + data.unshift([new Date(firstSample - i * interval).toISOString(), 0]); + } + + // Add n steps to the right side where each sample has its date increased by i*interval based on the last sample + for (let i = 1; i <= stepsToAddOnRight; i++) { + data.push([new Date(lastSample + i * interval).toISOString(), 0]); + } + + // Instead of creating a new array, we could move things around but this is easier + const newData: [string, number][] = []; + + for (let i = 0; i < data.length; i++) { + const current = data[i]; + const previous = data[i - 1]; + + if (previous) { + const currentTime = new Date(current[0]).getTime(); + const previousTime = new Date(previous[0]).getTime(); + const diff = currentTime - previousTime; + + // We do subtract the interval to make sure we don't duplicate the current sample + const stepsToAdd = Math.floor(Math.abs(diff - interval) / interval); + + if (stepsToAdd > 0) { + // We start with 1 because we already have one sample on the left side + for (let j = 1; j <= stepsToAdd; j++) { + newData.push([new Date(previousTime + j * interval).toISOString(), 0]); + } + } + } + + newData.push(current); + } + + return newData; +} + +function times<T>(amount: number, f: (index: number) => T) { + const items: Array<T> = []; + for (let i = 0; i < amount; i++) { + items.push(f(i)); + } + return items; +} + +export function createEmptySeries({ + interval, + period, +}: { + interval: number; + period: { + from: string; + to: string; + }; +}): [string, number][] { + const startAt = new Date(period.from).getTime(); + const endAt = new Date(period.to).getTime(); + + const steps = Math.floor((endAt - startAt) / interval); + return times(steps, i => [new Date(startAt + i * interval).toISOString(), 0]); +} 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 bd34eeb07..1b5b3ef44 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 @@ -7,7 +7,6 @@ import { Button, Card, DocsLink, - DocsNote, Heading, Input, Modal, @@ -382,14 +381,14 @@ export function CDNAccessTokens(props: { <Heading id="cdn-access-tokens" className="mb-2"> CDN Access Token </Heading> - <DocsNote> + <div className="text-sm text-gray-400"> CDN Access Tokens are used to access to Hive High-Availability CDN and read your schema artifacts. <br /> <DocsLink href="/management/targets#cdn-access-tokens"> Learn more about CDN Access Tokens </DocsLink> - </DocsNote> + </div> {canManage && ( <div className="my-3.5 flex justify-between"> <Button diff --git a/packages/web/app/src/components/ui/button.tsx b/packages/web/app/src/components/ui/button.tsx new file mode 100644 index 000000000..b37ed60b1 --- /dev/null +++ b/packages/web/app/src/components/ui/button.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; +import { Slot } from '@radix-ui/react-slot'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: 'border border-input hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'underline-offset-4 hover:underline text-primary', + }, + size: { + default: 'h-10 py-2 px-4', + sm: 'h-9 px-3 rounded-md', + lg: 'h-11 px-8 rounded-md', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof buttonVariants> { + asChild?: boolean; +} + +const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /> + ); + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/packages/web/app/src/components/ui/card.tsx b/packages/web/app/src/components/ui/card.tsx new file mode 100644 index 000000000..6b3b20743 --- /dev/null +++ b/packages/web/app/src/components/ui/card.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( + ({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)} + {...props} + /> + ), +); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( + ({ className, ...props }, ref) => ( + <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} /> + ), +); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( + ({ className, ...props }, ref) => ( + // eslint-disable-next-line jsx-a11y/heading-has-content + <h3 + ref={ref} + className={cn('text-lg font-semibold leading-none tracking-tight', className)} + {...props} + /> + ), +); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => ( + <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> +)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( + ({ className, ...props }, ref) => ( + <div ref={ref} className={cn('p-6 pt-0', className)} {...props} /> + ), +); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( + ({ className, ...props }, ref) => ( + <div ref={ref} className={cn(' flex items-center p-6 pt-0', className)} {...props} /> + ), +); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/packages/web/app/src/components/ui/checkbox.tsx b/packages/web/app/src/components/ui/checkbox.tsx new file mode 100644 index 000000000..d899ef6b7 --- /dev/null +++ b/packages/web/app/src/components/ui/checkbox.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; + +const Checkbox = React.forwardRef< + React.ElementRef<typeof CheckboxPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> +>(({ className, ...props }, ref) => ( + <CheckboxPrimitive.Root + ref={ref} + className={cn( + 'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', + className, + )} + {...props} + > + <CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}> + <Check className="h-4 w-4" /> + </CheckboxPrimitive.Indicator> + </CheckboxPrimitive.Root> +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/packages/web/app/src/components/ui/dropdown-menu.tsx b/packages/web/app/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..1545eb136 --- /dev/null +++ b/packages/web/app/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { Check, ChevronRight, Circle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean; + opensToLeft?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + <DropdownMenuPrimitive.SubTrigger + ref={ref} + className={cn( + 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent', + inset && 'pl-8', + className, + )} + {...props} + > + {children} + <ChevronRight className="ml-auto h-4 w-4" /> + </DropdownMenuPrimitive.SubTrigger> +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> +>(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.SubContent + ref={ref} + className={cn( + 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1', + className, + )} + {...props} + /> +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> +>(({ className, sideOffset = 4, ...props }, ref) => ( + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-2 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + className, + )} + {...props} + /> + </DropdownMenuPrimitive.Portal> +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean; + active?: boolean; + } +>(({ className, inset, active, ...props }, ref) => ( + <DropdownMenuPrimitive.Item + ref={ref} + className={cn( + 'relative flex cursor-default select-none items-center rounded-sm px-2 py-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', + inset && 'pl-8', + active && 'bg-accent', + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> +>(({ className, children, checked, ...props }, ref) => ( + <DropdownMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', + className, + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> +>(({ className, children, ...props }, ref) => ( + <DropdownMenuPrimitive.RadioItem + ref={ref} + className={cn( + 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', + className, + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Label + ref={ref} + className={cn('px-2 py-2 text-sm font-semibold', inset && 'pl-8', className)} + {...props} + /> +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.Separator + ref={ref} + className={cn('-mx-1 my-1 h-px bg-muted', className)} + {...props} + /> +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} /> + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/packages/web/app/src/components/ui/page.tsx b/packages/web/app/src/components/ui/page.tsx new file mode 100644 index 000000000..6749328e8 --- /dev/null +++ b/packages/web/app/src/components/ui/page.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +export function Title({ children, className }: { children: ReactNode; className?: string }) { + return <h3 className={cn('text-lg font-semibold tracking-tight', className)}>{children}</h3>; +} + +export function Subtitle({ children, className }: { children: string; className?: string }) { + return <p className={cn('text-sm text-gray-400', className)}>{children}</p>; +} diff --git a/packages/web/app/src/components/ui/select.tsx b/packages/web/app/src/components/ui/select.tsx new file mode 100644 index 000000000..4ce58d7f3 --- /dev/null +++ b/packages/web/app/src/components/ui/select.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Check, ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import * as SelectPrimitive from '@radix-ui/react-select'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +// + +const selectVariants = cva( + 'flex h-10 w-full bg-secondary items-center justify-between rounded-md px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-50', + { + variants: { + variant: { + default: + 'border border-input ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + ghost: '', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +type SelectTriggerProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & + VariantProps<typeof selectVariants>; + +const SelectTrigger = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Trigger>, + SelectTriggerProps +>(({ className, children, variant, ...props }, ref) => ( + <SelectPrimitive.Trigger + ref={ref} + className={cn(selectVariants({ variant, className }))} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="ml-2 h-4 w-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> +>(({ className, children, position = 'popper', ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + 'relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-80', + position === 'popper' && 'translate-y-1', + className, + )} + position={position} + {...props} + > + <SelectPrimitive.Viewport + className={cn( + 'p-1', + position === 'popper' && + 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]', + )} + > + {children} + </SelectPrimitive.Viewport> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + ref={ref} + className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)} + {...props} + /> +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Item + ref={ref} + className={cn( + 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', + className, + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </SelectPrimitive.ItemIndicator> + </span> + + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + ref={ref} + className={cn('-mx-1 my-1 h-px bg-muted', className)} + {...props} + /> +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, +}; diff --git a/packages/web/app/src/components/ui/tooltip.tsx b/packages/web/app/src/components/ui/tooltip.tsx new file mode 100644 index 000000000..1b56163e0 --- /dev/null +++ b/packages/web/app/src/components/ui/tooltip.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ElementRef<typeof TooltipPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> +>(({ className, sideOffset = 4, ...props }, ref) => ( + <TooltipPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + 'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1', + className, + )} + {...props} + /> +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/packages/web/app/src/components/ui/user-menu.tsx b/packages/web/app/src/components/ui/user-menu.tsx new file mode 100644 index 000000000..689d17f56 --- /dev/null +++ b/packages/web/app/src/components/ui/user-menu.tsx @@ -0,0 +1,202 @@ +import NextLink from 'next/link'; +import { FaGithub, FaGoogle, FaKey } from 'react-icons/fa'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Avatar } from '@/components/v2'; +import { + AlertTriangleIcon, + CalendarIcon, + FileTextIcon, + GraphQLIcon, + GridIcon, + LogOutIcon, + PlusIcon, + SettingsIcon, + TrendingUpIcon, +} from '@/components/v2/icon'; +import { env } from '@/env/frontend'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { AuthProvider } from '@/graphql'; +import { getDocsUrl } from '@/lib/docs-url'; +import { cn } from '@/lib/utils'; +import { GetStartedProgress } from '../get-started/wizard'; + +export const UserMenu_CurrentOrganizationFragment = graphql(` + fragment UserMenu_CurrentOrganizationFragment on Organization { + id + cleanId + name + getStarted { + ...GetStartedWizard_GetStartedProgress + } + } +`); + +export const UserMenu_OrganizationConnectionFragment = graphql(` + fragment UserMenu_OrganizationConnectionFragment on OrganizationConnection { + nodes { + id + cleanId + name + } + } +`); + +export const UserMenu_MeFragment = graphql(` + fragment UserMenu_MeFragment on User { + id + email + fullName + displayName + provider + isAdmin + canSwitchOrganization + } +`); + +export function UserMenu(props: { + me: FragmentType<typeof UserMenu_MeFragment> | null; + currentOrganization: FragmentType<typeof UserMenu_CurrentOrganizationFragment> | null; + organizations: FragmentType<typeof UserMenu_OrganizationConnectionFragment> | null; +}) { + const docsUrl = getDocsUrl(); + const me = useFragment(UserMenu_MeFragment, props.me); + const currentOrganization = useFragment( + UserMenu_CurrentOrganizationFragment, + props.currentOrganization, + ); + const organizations = useFragment(UserMenu_OrganizationConnectionFragment, props.organizations); + + return ( + <div className="flex flex-row gap-8 items-center"> + {currentOrganization ? <GetStartedProgress tasks={currentOrganization.getStarted} /> : null} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <div + className={cn('cursor-pointer', currentOrganization ? '' : 'animate-pulse')} + data-cy="user-menu-trigger" + > + <Avatar shape="circle" className="border-2 border-orange-900/50" /> + </div> + </DropdownMenuTrigger> + + {me && organizations ? ( + <DropdownMenuContent sideOffset={5} align="end" className="min-w-[240px]"> + <DropdownMenuLabel> + <div className="flex items-center justify-between"> + <div className="flex flex-col space-y-1"> + <div className="text-sm font-medium leading-none truncate">{me?.displayName}</div> + <div className="text-xs font-normal leading-none text-muted-foreground truncate"> + {me?.email} + </div> + </div> + <div> + {me?.provider === AuthProvider.Google ? ( + <FaGoogle title="Signed in using Google" /> + ) : me?.provider === AuthProvider.Github ? ( + <FaGithub title="Signed in using Github" /> + ) : ( + <FaKey title="Signed in using username and password" /> + )} + </div> + </div> + </DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuSub> + {me?.canSwitchOrganization ? ( + <DropdownMenuSubTrigger> + <GridIcon className="mr-2 h-4 w-4" /> + Switch organization + </DropdownMenuSubTrigger> + ) : null} + <DropdownMenuSubContent className="max-w-[300px]"> + {organizations.nodes.length ? ( + <DropdownMenuLabel>Organizations</DropdownMenuLabel> + ) : null} + <DropdownMenuSeparator /> + {organizations.nodes.map(org => ( + <NextLink href={`/${org.cleanId}`} key={org.cleanId}> + <DropdownMenuItem active={currentOrganization?.cleanId === org.cleanId}> + {org.name} + </DropdownMenuItem> + </NextLink> + ))} + <DropdownMenuSeparator /> + <NextLink href="/org/new"> + <DropdownMenuItem> + <PlusIcon className="mr-2 h-4 w-4" /> + Create an organization + </DropdownMenuItem> + </NextLink> + </DropdownMenuSubContent> + </DropdownMenuSub> + <DropdownMenuItem asChild> + <a + href="https://cal.com/team/the-guild/graphql-hive-15m" + target="_blank" + rel="noreferrer" + > + <CalendarIcon className="mr-2 h-4 w-4" /> + Schedule a meeting + </a> + </DropdownMenuItem> + + <NextLink href="/settings"> + <DropdownMenuItem> + <SettingsIcon className="mr-2 h-4 w-4" /> + Profile settings + </DropdownMenuItem> + <DropdownMenuSeparator /> + </NextLink> + {docsUrl ? ( + <DropdownMenuItem asChild> + <a href={docsUrl} target="_blank" rel="noreferrer"> + <FileTextIcon className="mr-2 h-4 w-4" /> + Documentation + </a> + </DropdownMenuItem> + ) : null} + <DropdownMenuItem asChild> + <a href="https://status.graphql-hive.com" target="_blank" rel="noreferrer"> + <AlertTriangleIcon className="mr-2 h-4 w-4" /> + Status page + </a> + </DropdownMenuItem> + {me.isAdmin === true && ( + <NextLink href="/manage"> + <DropdownMenuItem> + <TrendingUpIcon className="mr-2 h-4 w-4" /> + Manage Instance + </DropdownMenuItem> + </NextLink> + )} + {env.nodeEnv === 'development' && ( + <NextLink href="/dev"> + <DropdownMenuItem> + <GraphQLIcon className="mr-2 h-4 w-4" /> + Dev GraphiQL + </DropdownMenuItem> + </NextLink> + )} + <DropdownMenuSeparator /> + <DropdownMenuItem asChild> + <a href="/logout" data-cy="user-menu-logout"> + <LogOutIcon className="mr-2 h-4 w-4" /> + Log out + </a> + </DropdownMenuItem> + </DropdownMenuContent> + ) : null} + </DropdownMenu> + </div> + ); +} diff --git a/packages/web/app/src/components/v2/activities.tsx b/packages/web/app/src/components/v2/activities.tsx index f0f29ea82..619913098 100644 --- a/packages/web/app/src/components/v2/activities.tsx +++ b/packages/web/app/src/components/v2/activities.tsx @@ -1,14 +1,8 @@ -import React, { ReactElement, ReactNode } from 'react'; +import { ReactElement, ReactNode } from 'react'; import { useQuery } from 'urql'; import { ActivityNode } from '@/components/common/activities/common'; -import { Heading, Link, Skeleton, TimeAgo } from '@/components/v2'; -import { - ArrowDownIcon, - EditIcon, - PlusIcon, - TrashIcon, - UserPlusMinusIcon, -} from '@/components/v2/icon'; +import { Link, TimeAgo } from '@/components/v2'; +import { EditIcon, PlusIcon, TrashIcon, UserPlusMinusIcon } from '@/components/v2/icon'; import { MemberDeletedActivity, OrganizationActivitiesDocument, @@ -20,6 +14,7 @@ import { } from '@/graphql'; import { fixDuplicatedFragments } from '@/lib/graphql'; import { useRouteSelector } from '@/lib/hooks'; +import { Subtitle, Title } from '../ui/page'; const organizationActivitiesDocument = fixDuplicatedFragments(OrganizationActivitiesDocument); @@ -204,14 +199,14 @@ export const getActivity = ( } }; -export const Activities = (props: React.ComponentProps<'div'>): ReactElement => { +export const Activities = (): ReactElement => { const router = useRouteSelector(); const [organizationActivitiesQuery] = useQuery({ query: organizationActivitiesDocument, variables: { selector: { organization: router.organizationId, - limit: 10, + limit: 5, }, }, requestPolicy: 'cache-and-network', @@ -221,42 +216,49 @@ export const Activities = (props: React.ComponentProps<'div'>): ReactElement => const isLoading = organizationActivitiesQuery.fetching; return ( - <div className="w-[450px] shrink-0" {...props}> - <Heading>Recent Activity</Heading> - <ul className="mt-4 w-full break-all rounded-md border border-gray-800 p-5"> + <div className="w-[450px] shrink-0"> + <div className="py-6"> + <Title>Activity + Recent changes in your organization + +
      {isLoading || !activities?.nodes ? new Array(3).fill(null).map((_, index) => ( -
      - - +
      +
      +
      +
      +
      +
      +
      )) : activities.nodes.map(activity => { - const { content, icon } = getActivity(activity); + const { content } = getActivity(activity); return ( <> -
      {icon}
      {'project' in activity && !!activity.project && ( -

      - {activity.project.name} - {'target' in activity && !!activity.target && ( - <> - - {activity.target.name} - - )} -

      +
      +

      + {activity.project.name} + {'target' in activity && !!activity.target && ( + <> + / + {activity.target.name} + + )} +

      + +
      )}
      {content} -   -
      diff --git a/packages/web/app/src/components/v2/avatar.tsx b/packages/web/app/src/components/v2/avatar.tsx index 25ed8a9e1..c7edc1554 100644 --- a/packages/web/app/src/components/v2/avatar.tsx +++ b/packages/web/app/src/components/v2/avatar.tsx @@ -26,7 +26,7 @@ export const Avatar = ({ ( {children} diff --git a/packages/web/app/src/components/v2/docs-note.tsx b/packages/web/app/src/components/v2/docs-note.tsx index 98d4986d0..75f80a9b1 100644 --- a/packages/web/app/src/components/v2/docs-note.tsx +++ b/packages/web/app/src/components/v2/docs-note.tsx @@ -1,20 +1,24 @@ import { ReactElement } from 'react'; -import clsx from 'clsx'; +import { Book } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { getDocsUrl } from '@/lib/docs-url'; -import { ExclamationTriangleIcon, ExternalLinkIcon, InfoCircledIcon } from '@radix-ui/react-icons'; +import { cn } from '@/lib/utils'; +import { ExternalLinkIcon } from '@radix-ui/react-icons'; import { Link } from './link'; export const DocsNote = ({ children, warn }: { warn?: boolean; children: React.ReactNode }) => { return ( -
      -
      +
      + {/*
      {warn ? ( ) : ( )} -
      -
      {children}
      +
      */} +
      {children}
      ); }; @@ -35,14 +39,12 @@ export const DocsLink = ({ : getDocsUrl(href) || 'https://docs.graphql-hive.com/'; return ( - - {children} - {icon ?? } - + ); }; diff --git a/packages/web/app/src/components/v2/dropdown.tsx b/packages/web/app/src/components/v2/dropdown.tsx index 9b36b164e..e2d2865b8 100644 --- a/packages/web/app/src/components/v2/dropdown.tsx +++ b/packages/web/app/src/components/v2/dropdown.tsx @@ -45,11 +45,14 @@ export const DropdownMenuContent = React.forwardRef< flex-col gap-1 rounded-md - bg-gray-800 p-[13px] text-sm - font-semibold + font-normal + bg-[#0b0d11] text-gray-300 + shadow-lg + shadow-black + ring-1 ring-gray-900 `, className, )} @@ -70,8 +73,7 @@ export const DropdownMenuSubTrigger = React.forwardRef< {...props} className={clsx( ` - radix-state-open:bg-orange-500/40 - radix-state-open:sepia + radix-state-open:bg-gray-800/50 flex cursor-pointer items-center @@ -80,9 +82,9 @@ export const DropdownMenuSubTrigger = React.forwardRef< py-2.5 px-2 transition - hover:bg-gray-500/50 + hover:bg-gray-800/50 hover:text-white - focus:bg-gray-500/50 + focus:bg-gray-800/50 focus:text-white `, className, @@ -108,11 +110,14 @@ export const DropdownMenuSubContent = React.forwardRef< flex-col gap-1 rounded-md - bg-gray-800 p-[13px] text-sm - font-semibold + font-normal + bg-[#0b0d11] text-gray-300 + shadow-lg + shadow-black + ring-1 ring-gray-900 `, className, )} @@ -144,9 +149,9 @@ export const DropdownMenuItem = React.forwardRef< py-2.5 px-2 transition - hover:bg-gray-500/50 + hover:bg-gray-800/50 hover:text-white - focus:bg-gray-500/50 + focus:bg-gray-800/50 focus:text-white`, className, )} @@ -168,7 +173,7 @@ export const DropdownMenuSeparator = React.forwardRef< ` my-2 h-px - bg-gray-700/50 + bg-gray-800/50 `, className, )} diff --git a/packages/web/app/src/components/v2/empty-list.tsx b/packages/web/app/src/components/v2/empty-list.tsx index 1870c23e4..e9ad8f6ef 100644 --- a/packages/web/app/src/components/v2/empty-list.tsx +++ b/packages/web/app/src/components/v2/empty-list.tsx @@ -1,19 +1,22 @@ import { ReactElement } from 'react'; import Image from 'next/image'; import { Card, DocsLink, Heading } from '@/components/v2/index'; +import { cn } from '@/lib/utils'; import magnifier from '../../../public/images/figures/magnifier.svg'; export const EmptyList = ({ title, description, docsUrl, + className, }: { title: string; description: string; - docsUrl?: string; + docsUrl?: string | null; + className?: string; }): ReactElement => { return ( - + Magnifier illustration org.cleanId === router.organizationId) - : null; - - // Copied from tailwindcss website - // https://github.com/tailwindlabs/tailwindcss.com/blob/94971856747c159b4896621c3308bcfa629bb736/src/components/Header.js#L149 - useEffect(() => { - const offset = 30; - - const onScroll = () => { - if (!isOpaque && window.scrollY > offset) { - setIsOpaque(true); - } else if (isOpaque && window.scrollY <= offset) { - setIsOpaque(false); - } - }; - - window.addEventListener('scroll', onScroll, { passive: true }); - return () => { - window.removeEventListener('scroll', onScroll); - }; - }, [isOpaque]); - - const docsUrl = getDocsUrl(); - return ( -
      -
      - -
      - {currentOrg ? : null} - - - - - - - - {me?.displayName} - - - {me?.canSwitchOrganization ? ( - - - Switch organization - - - ) : null} - - {organizations.length ? ( - - ORGANIZATIONS - - ) : null} - {organizations.map(org => ( - - - {org.name} - - - ))} - - - - - Create an organization - - - - - - - - Schedule a meeting - - - - - - - Profile settings - - - {docsUrl ? ( - - - - Documentation - - - ) : null} - - - - Status page - - - {meQuery.data?.me?.isAdmin && ( - - - - Manage Instance - - - )} - {env.nodeEnv === 'development' && ( - - - - Dev GraphiQL - - - )} - - - - Log out - - - - -
      -
      -
      - ); -} diff --git a/packages/web/app/src/components/v2/hive-link.tsx b/packages/web/app/src/components/v2/hive-link.tsx index 2bec63c43..269305a02 100644 --- a/packages/web/app/src/components/v2/hive-link.tsx +++ b/packages/web/app/src/components/v2/hive-link.tsx @@ -2,53 +2,11 @@ import { ReactElement } from 'react'; import NextLink from 'next/link'; import clsx from 'clsx'; import { HiveLogo } from '@/components/v2/icon'; -import { useRouteSelector } from '@/lib/hooks'; export const HiveLink = ({ className }: { className?: string }): ReactElement => { - const router = useRouteSelector(); - const orgId = router.organizationId; - return ( - + -
      - - - - - - - - - - - - - - - - - - -
      ); }; diff --git a/packages/web/app/src/components/v2/icon.tsx b/packages/web/app/src/components/v2/icon.tsx index e3c5c7e71..e1c3eb63a 100644 --- a/packages/web/app/src/components/v2/icon.tsx +++ b/packages/web/app/src/components/v2/icon.tsx @@ -452,42 +452,17 @@ export const HiveLogo = ({ className }: IconProps): ReactElement => ( - - - - - - - - - - - ); diff --git a/packages/web/app/src/components/v2/index.ts b/packages/web/app/src/components/v2/index.ts index 7ca129995..f8257ea2c 100644 --- a/packages/web/app/src/components/v2/index.ts +++ b/packages/web/app/src/components/v2/index.ts @@ -13,7 +13,6 @@ export { DiffEditor } from '@/components/v2/diff-editor'; export { Drawer } from '@/components/v2/drawer'; export { EmptyList, noSchema } from '@/components/v2/empty-list'; export { GraphQLBlock } from '@/components/v2/graphql-block'; -export { Header } from '@/components/v2/header'; export { Heading } from '@/components/v2/heading'; export { HiveLink } from '@/components/v2/hive-link'; export { Input } from '@/components/v2/input'; @@ -30,13 +29,12 @@ export { Slider } from '@/components/v2/slider'; export { Sortable } from '@/components/v2/sortable'; export { Spinner } from '@/components/v2/spinner'; export { default as Stat } from '@/components/v2/stat'; -export { SubHeader } from '@/components/v2/sub-header'; export { Switch } from '@/components/v2/switch'; export { Table, TBody, THead, TFoot, Th, Td, Tr } from '@/components/v2/table'; export { Tabs } from '@/components/v2/tabs'; export { Tag } from '@/components/v2/tag'; export { TimeAgo } from '@/components/v2/time-ago'; -export { Title } from '@/components/v2/title'; +export { MetaTitle as MetaTitle } from '@/components/v2/title'; export { ToggleGroup, ToggleGroupItem } from '@/components/v2/toggle-group'; export { Tooltip } from '@/components/v2/tooltip'; export { DocsNote, DocsLink } from '@/components/v2/docs-note'; diff --git a/packages/web/app/src/components/v2/modals/create-access-token.tsx b/packages/web/app/src/components/v2/modals/create-access-token.tsx index b45e0b91b..473cb5072 100644 --- a/packages/web/app/src/components/v2/modals/create-access-token.tsx +++ b/packages/web/app/src/components/v2/modals/create-access-token.tsx @@ -391,7 +391,7 @@ function ModalContent(props: {
      )}
      -
      +
      { if (!collectionId) { diff --git a/packages/web/app/src/components/v2/modals/create-project.tsx b/packages/web/app/src/components/v2/modals/create-project.tsx index b925ed6f5..f24136f96 100644 --- a/packages/web/app/src/components/v2/modals/create-project.tsx +++ b/packages/web/app/src/components/v2/modals/create-project.tsx @@ -8,14 +8,10 @@ import { graphql } from '@/gql'; import { ProjectType } from '@/graphql'; import { useRouteSelector } from '@/lib/hooks'; -const CreateProjectMutation = graphql(` +export const CreateProjectMutation = graphql(` mutation CreateProject_CreateProject($input: CreateProjectInput!) { createProject(input: $input) { ok { - selector { - organization - project - } createdProject { cleanId ...ProjectFields @@ -23,6 +19,9 @@ const CreateProjectMutation = graphql(` createdTargets { ...TargetFields } + updatedOrganization { + id + } } error { message @@ -79,6 +78,8 @@ export const CreateProjectModal = ({ }, }); + console.log(mutation); + return (
      diff --git a/packages/web/app/src/components/v2/modals/delete-target.tsx b/packages/web/app/src/components/v2/modals/delete-target.tsx index 4fa951a64..605a6c98f 100644 --- a/packages/web/app/src/components/v2/modals/delete-target.tsx +++ b/packages/web/app/src/components/v2/modals/delete-target.tsx @@ -3,18 +3,22 @@ import { useRouter } from 'next/router'; import { useMutation } from 'urql'; import { Button, Heading, Modal } from '@/components/v2'; import { DeleteTargetDocument } from '@/graphql'; -import { useRouteSelector } from '@/lib/hooks'; import { TrashIcon } from '@radix-ui/react-icons'; export const DeleteTargetModal = ({ isOpen, toggleModalOpen, + organizationId, + projectId, + targetId, }: { isOpen: boolean; toggleModalOpen: () => void; + organizationId: string; + projectId: string; + targetId: string; }): ReactElement => { const [, mutate] = useMutation(DeleteTargetDocument); - const router = useRouteSelector(); const { replace } = useRouter(); return ( @@ -39,13 +43,13 @@ export const DeleteTargetModal = ({ onClick={async () => { await mutate({ selector: { - organization: router.organizationId, - project: router.projectId, - target: router.targetId, + organization: organizationId, + project: projectId, + target: targetId, }, }); toggleModalOpen(); - void replace(`/${router.organizationId}/${router.projectId}`); + void replace(`/${organizationId}/${projectId}`); }} > Delete diff --git a/packages/web/app/src/components/v2/modals/index.ts b/packages/web/app/src/components/v2/modals/index.ts index 64ab846ed..520d79103 100644 --- a/packages/web/app/src/components/v2/modals/index.ts +++ b/packages/web/app/src/components/v2/modals/index.ts @@ -2,8 +2,6 @@ export { ChangePermissionsModal } from './change-permissions'; export { ConnectLabModal } from './connect-lab'; export { ConnectSchemaModal } from './connect-schema'; export { CreateAccessTokenModal } from './create-access-token'; -export { CreateAlertModal } from './create-alert'; -export { CreateChannelModal } from './create-channel'; export { CreateCollectionModal } from './create-collection'; export { CreateOperationModal } from './create-operation'; export { CreateProjectModal } from './create-project'; diff --git a/packages/web/app/src/components/v2/sub-header.tsx b/packages/web/app/src/components/v2/sub-header.tsx deleted file mode 100644 index dc5baf589..000000000 --- a/packages/web/app/src/components/v2/sub-header.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { ReactElement, ReactNode } from 'react'; - -export const SubHeader = ({ children }: { children: ReactNode }): ReactElement => { - return ( -
      - - - {children} -
      - ); -}; diff --git a/packages/web/app/src/components/v2/tabs.tsx b/packages/web/app/src/components/v2/tabs.tsx index 2ca5e935c..b42e9bd53 100644 --- a/packages/web/app/src/components/v2/tabs.tsx +++ b/packages/web/app/src/components/v2/tabs.tsx @@ -17,8 +17,6 @@ const List = ({ children, className, ...props }: TabsListProps): ReactElement => relative flex items-center - gap-7 - text-xl text-gray-700 `, className, @@ -35,19 +33,18 @@ const Trigger = forwardRef & { hasBorde ref={forwardedRef} className={clsx( '!appearance-none', // unset button styles in Safari - ` - radix-state-active:text-white - font-bold - transition - hover:text-white`, - hasBorder && + 'font-medium text-sm transition text-white', + hasBorder + ? ` + radix-state-active:border-b-orange-500 + hover:border-b-orange-900 + border-b-[2px] + border-b-transparent + px-4 + py-3 + cursor-pointer ` - radix-state-active:border-b-orange-500 - border-b-[5px] - border-b-transparent - pb-3 - cursor-pointer - `, + : null, )} {...props} > diff --git a/packages/web/app/src/components/v2/title.tsx b/packages/web/app/src/components/v2/title.tsx index 19559c9f1..a9de11308 100644 --- a/packages/web/app/src/components/v2/title.tsx +++ b/packages/web/app/src/components/v2/title.tsx @@ -1,7 +1,7 @@ import { ReactElement } from 'react'; import Head from 'next/head'; -export const Title = ({ title }: { title: string }): ReactElement => { +export const MetaTitle = ({ title }: { title: string }): ReactElement => { const pageTitle = `${title} - GraphQL Hive`; return ( diff --git a/packages/web/app/src/graphql/fragments.graphql b/packages/web/app/src/graphql/fragments.graphql index f32e25c90..5547e0926 100644 --- a/packages/web/app/src/graphql/fragments.graphql +++ b/packages/web/app/src/graphql/fragments.graphql @@ -50,24 +50,6 @@ fragment TargetEssentials on Target { hasSchema } -fragment SingleSchemaFields on SingleSchema { - __typename - id - author - source - commit -} - -fragment CompositeSchemaFields on CompositeSchema { - __typename - id - author - source - service - url - commit -} - fragment MemberFields on Member { id user { @@ -376,34 +358,6 @@ fragment TargetValidationSettingsFields on TargetValidationSettings { excludedClients } -fragment AlertSlackChannelFields on AlertSlackChannel { - id - name - type - channel -} - -fragment AlertWebhookChannelFields on AlertWebhookChannel { - id - name - type - endpoint -} -fragment AlertFields on Alert { - id - type - channel { - id - name - type - } - target { - id - cleanId - name - } -} - fragment BillingInvoiceFields on BillingInvoice { id amount diff --git a/packages/web/app/src/graphql/mutation.add-alert-channel.graphql b/packages/web/app/src/graphql/mutation.add-alert-channel.graphql deleted file mode 100644 index 59c7a2764..000000000 --- a/packages/web/app/src/graphql/mutation.add-alert-channel.graphql +++ /dev/null @@ -1,10 +0,0 @@ -mutation addAlertChannel($input: AddAlertChannelInput!) { - addAlertChannel(input: $input) { - ok { - addedAlertChannel { - ...AlertSlackChannelFields - ...AlertWebhookChannelFields - } - } - } -} diff --git a/packages/web/app/src/graphql/mutation.add-alert.graphql b/packages/web/app/src/graphql/mutation.add-alert.graphql deleted file mode 100644 index 952f76c29..000000000 --- a/packages/web/app/src/graphql/mutation.add-alert.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation addAlert($input: AddAlertInput!) { - addAlert(input: $input) { - ...AlertFields - } -} diff --git a/packages/web/app/src/graphql/mutation.create-project.graphql b/packages/web/app/src/graphql/mutation.create-project.graphql deleted file mode 100644 index c8559387d..000000000 --- a/packages/web/app/src/graphql/mutation.create-project.graphql +++ /dev/null @@ -1,16 +0,0 @@ -mutation createProject($input: CreateProjectInput!) { - createProject(input: $input) { - ok { - selector { - organization - project - } - createdProject { - ...ProjectFields - } - createdTargets { - ...TargetFields - } - } - } -} diff --git a/packages/web/app/src/graphql/mutation.delete-alert-channels.graphql b/packages/web/app/src/graphql/mutation.delete-alert-channels.graphql deleted file mode 100644 index 327636a25..000000000 --- a/packages/web/app/src/graphql/mutation.delete-alert-channels.graphql +++ /dev/null @@ -1,6 +0,0 @@ -mutation deleteAlertChannels($input: DeleteAlertChannelsInput!) { - deleteAlertChannels(input: $input) { - __typename - id - } -} diff --git a/packages/web/app/src/graphql/mutation.delete-alerts.graphql b/packages/web/app/src/graphql/mutation.delete-alerts.graphql deleted file mode 100644 index bdc70eb24..000000000 --- a/packages/web/app/src/graphql/mutation.delete-alerts.graphql +++ /dev/null @@ -1,6 +0,0 @@ -mutation deleteAlerts($input: DeleteAlertsInput!) { - deleteAlerts(input: $input) { - __typename - id - } -} diff --git a/packages/web/app/src/graphql/query.alert-channels.graphql b/packages/web/app/src/graphql/query.alert-channels.graphql deleted file mode 100644 index 57d0cbb1a..000000000 --- a/packages/web/app/src/graphql/query.alert-channels.graphql +++ /dev/null @@ -1,6 +0,0 @@ -query alertChannels($selector: ProjectSelectorInput!) { - alertChannels(selector: $selector) { - ...AlertSlackChannelFields - ...AlertWebhookChannelFields - } -} diff --git a/packages/web/app/src/graphql/query.alerts.graphql b/packages/web/app/src/graphql/query.alerts.graphql deleted file mode 100644 index 46e02237e..000000000 --- a/packages/web/app/src/graphql/query.alerts.graphql +++ /dev/null @@ -1,5 +0,0 @@ -query alerts($selector: ProjectSelectorInput!) { - alerts(selector: $selector) { - ...AlertFields - } -} diff --git a/packages/web/app/src/graphql/query.me.graphql b/packages/web/app/src/graphql/query.me.graphql index e57995358..115c2912f 100644 --- a/packages/web/app/src/graphql/query.me.graphql +++ b/packages/web/app/src/graphql/query.me.graphql @@ -1,6 +1,10 @@ query me { me { - ...UserFields - canSwitchOrganization + ...MeFields } } + +fragment MeFields on User { + ...UserFields + canSwitchOrganization +} diff --git a/packages/web/app/src/graphql/query.schemas.graphql b/packages/web/app/src/graphql/query.schemas.graphql deleted file mode 100644 index 181e0ca3b..000000000 --- a/packages/web/app/src/graphql/query.schemas.graphql +++ /dev/null @@ -1,15 +0,0 @@ -query schemas($selector: TargetSelectorInput!) { - target(selector: $selector) { - ...TargetFields - latestSchemaVersion { - id - valid - schemas { - nodes { - ...SingleSchemaFields - ...CompositeSchemaFields - } - } - } - } -} diff --git a/packages/web/app/src/graphql/query.target-latest-schema.graphql b/packages/web/app/src/graphql/query.target-latest-schema.graphql deleted file mode 100644 index 71d3fa0ce..000000000 --- a/packages/web/app/src/graphql/query.target-latest-schema.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query LatestSchema($selector: TargetSelectorInput!) { - target(selector: $selector) { - id - latestSchemaVersion { - id - valid - schemas { - nodes { - __typename - ...SingleSchemaFields - ...CompositeSchemaFields - } - } - } - } -} diff --git a/packages/web/app/src/graphql/query.target.graphql b/packages/web/app/src/graphql/query.target.graphql deleted file mode 100644 index 40ed6af89..000000000 --- a/packages/web/app/src/graphql/query.target.graphql +++ /dev/null @@ -1,23 +0,0 @@ -query target($organizationId: ID!, $targetId: ID!, $projectId: ID!) { - organization(selector: { organization: $organizationId }) { - organization { - ...OrganizationFields - } - } - project(selector: { organization: $organizationId, project: $projectId }) { - ...ProjectFields - } - target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { - ...TargetFields - latestSchemaVersion { - id - valid - schemas { - nodes { - ...SingleSchemaFields - ...CompositeSchemaFields - } - } - } - } -} diff --git a/packages/web/app/src/graphql/query.versions.graphql b/packages/web/app/src/graphql/query.versions.graphql deleted file mode 100644 index ab0d74bab..000000000 --- a/packages/web/app/src/graphql/query.versions.graphql +++ /dev/null @@ -1,10 +0,0 @@ -query versions($selector: SchemaVersionsInput!, $limit: Int!, $after: ID) { - schemaVersions(selector: $selector, after: $after, limit: $limit) { - nodes { - ...SchemaVersionFields - } - pageInfo { - hasNextPage - } - } -} diff --git a/packages/web/app/src/lib/hooks/use-collections.ts b/packages/web/app/src/lib/hooks/use-collections.ts index 2283b4598..f9b6ea6b6 100644 --- a/packages/web/app/src/lib/hooks/use-collections.ts +++ b/packages/web/app/src/lib/hooks/use-collections.ts @@ -1,7 +1,6 @@ import { useEffect } from 'react'; import { useQuery } from 'urql'; import { graphql } from '@/gql'; -import { TargetDocument } from '@/graphql'; import { useNotifications } from '@/lib/hooks/use-notifications'; import { useRouteSelector } from '@/lib/hooks/use-route-selector'; @@ -33,16 +32,6 @@ export const CollectionsQuery = graphql(` export function useCollections() { const router = useRouteSelector(); - const [result] = useQuery({ - query: TargetDocument, - variables: { - targetId: router.targetId, - organizationId: router.organizationId, - projectId: router.projectId, - }, - }); - const targetId = result.data?.target?.id as string; - const [{ data, error, fetching }] = useQuery({ query: CollectionsQuery, variables: { @@ -52,7 +41,6 @@ export function useCollections() { project: router.projectId, }, }, - pause: !targetId, }); const notify = useNotifications(); @@ -65,6 +53,6 @@ export function useCollections() { return { collections: data?.target?.documentCollections.edges.map(v => v.node) || [], - loading: result.fetching || fetching, + loading: fetching, }; } diff --git a/packages/web/app/src/lib/hooks/use-not-found-redirect-on-error.ts b/packages/web/app/src/lib/hooks/use-not-found-redirect-on-error.ts new file mode 100644 index 000000000..ed5cc0ae8 --- /dev/null +++ b/packages/web/app/src/lib/hooks/use-not-found-redirect-on-error.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import cookies from 'js-cookie'; +import { LAST_VISITED_ORG_KEY } from '@/constants'; +import { useRouteSelector } from './use-route-selector'; + +export function useNotFoundRedirectOnError(isError: boolean) { + const { push } = useRouter(); + const router = useRouteSelector(); + useEffect(() => { + if (isError) { + cookies.remove(LAST_VISITED_ORG_KEY); + // url with # provoke error Maximum update depth exceeded + void push('/404', router.asPath.replace(/#.*/, '')); + } + }, [isError, router]); +} diff --git a/packages/web/app/src/lib/hooks/use-route-selector.ts b/packages/web/app/src/lib/hooks/use-route-selector.ts index bf096cc09..345684632 100644 --- a/packages/web/app/src/lib/hooks/use-route-selector.ts +++ b/packages/web/app/src/lib/hooks/use-route-selector.ts @@ -11,13 +11,16 @@ export function useRouteSelector() { const visitHome = useCallback(() => push('/', '/'), [push]); const visitOrganization = useCallback( - ({ organizationId }: { organizationId: string }) => push('/[orgId]', `/${organizationId}`), + ({ organizationId }: { organizationId: string }) => { + void push('/[orgId]', `/${organizationId}`); + }, [push], ); const visitProject = useCallback( - ({ organizationId, projectId }: { organizationId: string; projectId: string }) => - push('/[orgId]/[projectId]', `/${organizationId}/${projectId}`), + ({ organizationId, projectId }: { organizationId: string; projectId: string }) => { + void push('/[orgId]/[projectId]', `/${organizationId}/${projectId}`); + }, [push], ); @@ -30,7 +33,9 @@ export function useRouteSelector() { organizationId: string; projectId: string; targetId: string; - }) => push('/[orgId]/[projectId]/[targetId]', `/${organizationId}/${projectId}/${targetId}`), + }) => { + void push('/[orgId]/[projectId]/[targetId]', `/${organizationId}/${projectId}/${targetId}`); + }, [push], ); diff --git a/packages/web/app/src/lib/urql-cache.ts b/packages/web/app/src/lib/urql-cache.ts index 39a6be167..1fcb58941 100644 --- a/packages/web/app/src/lib/urql-cache.ts +++ b/packages/web/app/src/lib/urql-cache.ts @@ -2,23 +2,21 @@ import { DocumentNode, Kind } from 'graphql'; import { produce } from 'immer'; import { TypedDocumentNode } from 'urql'; +import type { CreateAlertModal_AddAlertMutation } from '@/components/project/alerts/create-alert'; +import type { CreateChannel_AddAlertChannelMutation } from '@/components/project/alerts/create-channel'; +import type { DeleteAlertsButton_DeleteAlertsMutation } from '@/components/project/alerts/delete-alerts-button'; +import type { DeleteChannelsButton_DeleteChannelsMutation } from '@/components/project/alerts/delete-channels-button'; import type { CreateOperationMutationType } from '@/components/v2/modals/create-operation'; +import type { CreateProjectMutation } from '@/components/v2/modals/create-project'; import type { DeleteCollectionMutationType } from '@/components/v2/modals/delete-collection'; import type { DeleteOperationMutationType } from '@/components/v2/modals/delete-operation'; import { ResultOf, VariablesOf } from '@graphql-typed-document-node/core'; import { Cache, QueryInput, UpdateResolver } from '@urql/exchange-graphcache'; import { - AddAlertChannelDocument, - AddAlertDocument, - AlertChannelsDocument, - AlertsDocument, CheckIntegrationsDocument, CreateOrganizationDocument, - CreateProjectDocument, CreateTargetDocument, CreateTokenDocument, - DeleteAlertChannelsDocument, - DeleteAlertsDocument, DeleteGitHubIntegrationDocument, DeleteOrganizationDocument, DeletePersistedOperationDocument, @@ -27,7 +25,6 @@ import { DeleteTargetDocument, DeleteTokensDocument, OrganizationsDocument, - ProjectsDocument, TargetsDocument, TokensDocument, } from '../graphql'; @@ -59,15 +56,13 @@ type TypedDocumentNodeUpdateResolver> VariablesOf >; -const deleteAlerts: TypedDocumentNodeUpdateResolver = ( - { deleteAlerts }, - _args, - cache, -) => { - for (const alert of deleteAlerts) { +const deleteAlerts: TypedDocumentNodeUpdateResolver< + typeof DeleteAlertsButton_DeleteAlertsMutation +> = ({ deleteAlerts }, _args, cache) => { + if (deleteAlerts.ok) { cache.invalidate({ - __typename: alert.__typename, - id: alert.id, + __typename: 'Project', + id: deleteAlerts.ok.updatedProject.id, }); } }; @@ -106,7 +101,7 @@ const deleteOrganization: TypedDocumentNodeUpdateResolver = ( +const createProject: TypedDocumentNodeUpdateResolver = ( { createProject }, _args, cache, @@ -114,24 +109,11 @@ const createProject: TypedDocumentNodeUpdateResolver { - data.projects.nodes.unshift(project); - data.projects.total += 1; - }, - ); + cache.invalidate({ + __typename: 'Organization', + id: createProject.ok.updatedOrganization.id, + }); }; const deleteProject: TypedDocumentNodeUpdateResolver = ( @@ -247,65 +229,43 @@ const deleteTokens: TypedDocumentNodeUpdateResolver ); }; -const addAlertChannel: TypedDocumentNodeUpdateResolver = ( - { addAlertChannel }, - args, - cache, -) => { +const addAlertChannel: TypedDocumentNodeUpdateResolver< + typeof CreateChannel_AddAlertChannelMutation +> = ({ addAlertChannel }, args, cache) => { if (!addAlertChannel.ok) { return; } - const { addedAlertChannel } = addAlertChannel.ok; - - updateQuery( - cache, - { - query: AlertChannelsDocument, - variables: { - selector: { - organization: args.input.organization, - project: args.input.project, - }, - }, - }, - data => { - data.alertChannels.unshift(addedAlertChannel); - }, - ); + const { updatedProject } = addAlertChannel.ok; + cache.invalidate({ + __typename: 'Project', + id: updatedProject.id, + }); }; -const deleteAlertChannels: TypedDocumentNodeUpdateResolver = ( - { deleteAlertChannels }, - _args, - cache, -) => { - for (const channel of deleteAlertChannels) { +const deleteAlertChannels: TypedDocumentNodeUpdateResolver< + typeof DeleteChannelsButton_DeleteChannelsMutation +> = ({ deleteAlertChannels }, _args, cache) => { + if (deleteAlertChannels.ok) { cache.invalidate({ - __typename: channel.__typename, - id: channel.id, + __typename: 'Project', + id: deleteAlertChannels.ok.updatedProject.id, }); } }; -const addAlert: TypedDocumentNodeUpdateResolver = ( +const addAlert: TypedDocumentNodeUpdateResolver = ( { addAlert }, - args, + _args, cache, ) => { - updateQuery( - cache, - { - query: AlertsDocument, - variables: { - selector: { - organization: args.input.organization, - project: args.input.project, - }, - }, - }, - data => { - data.alerts.unshift(addAlert); - }, - ); + if (!addAlert.ok) { + return; + } + + const { updatedProject } = addAlert.ok; + cache.invalidate({ + __typename: 'Project', + id: updatedProject.id, + }); }; const deletePersistedOperation: TypedDocumentNodeUpdateResolver< typeof DeletePersistedOperationDocument diff --git a/packages/web/app/src/lib/utils.ts b/packages/web/app/src/lib/utils.ts new file mode 100644 index 000000000..f1ee40800 --- /dev/null +++ b/packages/web/app/src/lib/utils.ts @@ -0,0 +1,14 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function pluralize(count: number, singular: string, plural: string): string { + if (count === 1) { + return singular; + } + + return plural; +} diff --git a/packages/web/app/tailwind.config.cjs b/packages/web/app/tailwind.config.cjs index fecc217b2..914ee705b 100644 --- a/packages/web/app/tailwind.config.cjs +++ b/packages/web/app/tailwind.config.cjs @@ -1,12 +1,27 @@ const colors = require('tailwindcss/colors'); +const { fontFamily } = require('tailwindcss/defaultTheme'); module.exports = { darkMode: 'class', - content: ['./{pages,src}/**/*.ts{,x}'], + content: ['./{pages,src,components}/**/*.ts{,x}'], important: true, theme: { container: { center: true, + padding: '2rem', + screens: { + '2xl': '1400px', + }, + }, + fontFamily: { + sans: [ + 'Inter var,' + fontFamily.sans.join(','), + { + fontFeatureSettings: 'normal', + fontVariationSettings: '"opsz" 32', + }, + ], + mono: fontFamily.mono, }, colors: { transparent: 'transparent', @@ -53,17 +68,7 @@ module.exports = { cyan: '#0acccc', purple: '#5f2eea', blue: colors.indigo, - gray: { - 100: '#f2f2f4', - 200: '#dfe0e2', - 300: '#cccdd1', - 400: '#a5a7af', - 500: '#7f818c', - 600: '#72747e', - 700: '#5f6169', - 800: '#24272e', // '#4c4d54', - 900: '#202329', - }, + gray: colors.stone, magenta: '#f11197', orange: { 50: '#fefbf5', @@ -79,8 +84,45 @@ module.exports = { }, }, extend: { - fontFamily: { - sans: ['Inter', 'ui-sans-serif', 'system-ui'], + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', }, ringColor: theme => ({ DEFAULT: theme('colors.orange.500/75'), @@ -171,6 +213,14 @@ module.exports = { '0%': { transform: 'translateY(var(--radix-toast-swipe-end-y))' }, '100%': { transform: 'translateY(calc(100% + 1rem))' }, }, + 'accordion-down': { + from: { height: 0 }, + to: { height: 'var(--radix-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: 0 }, + }, }, animation: { // Dropdown menu @@ -197,11 +247,14 @@ module.exports = { 'toast-slide-in-bottom': 'toast-slide-in-bottom 150ms cubic-bezier(0.16, 1, 0.3, 1)', 'toast-swipe-out-x': 'toast-swipe-out-x 100ms ease-out forwards', 'toast-swipe-out-y': 'toast-swipe-out-y 100ms ease-out forwards', + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', }, }, }, plugins: [ // Utilities and variants for styling Radix state require('tailwindcss-radix')(), + require('tailwindcss-animate'), ], }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8eb1932f..1164f96a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,29 +53,29 @@ importers: specifier: 2.26.1 version: 2.26.1 '@graphql-codegen/add': - specifier: 4.0.1 - version: 4.0.1(graphql@16.6.0) + specifier: 5.0.0 + version: 5.0.0(graphql@16.6.0) '@graphql-codegen/cli': - specifier: 3.3.1 - version: 3.3.1(@babel/core@7.21.5)(@types/node@18.16.18)(graphql@16.6.0) - '@graphql-codegen/client-preset': - specifier: 3.0.1 - version: 3.0.1(graphql@16.6.0) - '@graphql-codegen/graphql-modules-preset': - specifier: 3.1.3 - version: 3.1.3(graphql@16.6.0) - '@graphql-codegen/typed-document-node': specifier: 4.0.1 - version: 4.0.1(graphql@16.6.0) + version: 4.0.1(@babel/core@7.21.5)(@types/node@18.16.18)(graphql@16.6.0) + '@graphql-codegen/client-preset': + specifier: 4.0.0 + version: 4.0.0(graphql@16.6.0) + '@graphql-codegen/graphql-modules-preset': + specifier: 4.0.0 + version: 4.0.0(graphql@16.6.0) + '@graphql-codegen/typed-document-node': + specifier: 5.0.0 + version: 5.0.0(graphql@16.6.0) '@graphql-codegen/typescript': - specifier: 3.0.4 - version: 3.0.4(graphql@16.6.0) + specifier: 4.0.0 + version: 4.0.0(graphql@16.6.0) '@graphql-codegen/typescript-operations': - specifier: 3.0.4 - version: 3.0.4(graphql@16.6.0) + specifier: 4.0.0 + version: 4.0.0(graphql@16.6.0) '@graphql-codegen/typescript-resolvers': - specifier: 3.2.1 - version: 3.2.1(graphql@16.6.0) + specifier: 4.0.0 + version: 4.0.0(graphql@16.6.0) '@graphql-inspector/cli': specifier: 3.4.19 version: 3.4.19(@types/node@18.16.18)(graphql@16.6.0) @@ -1501,6 +1501,9 @@ importers: '@radix-ui/react-slider': specifier: 1.1.2 version: 1.1.2(@types/react-dom@18.2.6)(@types/react@18.2.13)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': + specifier: 1.0.2 + version: 1.0.2(@types/react@18.2.13)(react@18.2.0) '@radix-ui/react-switch': specifier: 1.0.3 version: 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.13)(react-dom@18.2.0)(react@18.2.0) @@ -1552,6 +1555,9 @@ importers: '@whatwg-node/fetch': specifier: 0.9.6 version: 0.9.6 + class-variance-authority: + specifier: 0.6.0 + version: 0.6.0(typescript@5.1.3) clsx: specifier: 1.2.1 version: 1.2.1 @@ -1594,6 +1600,9 @@ importers: json-schema-yup-transformer: specifier: 1.6.12 version: 1.6.12 + lucide-react: + specifier: 0.236.0 + version: 0.236.0(react@18.2.0) monaco-editor: specifier: 0.39.0 version: 0.39.0 @@ -1621,6 +1630,9 @@ importers: react-highlight-words: specifier: 0.20.0 version: 0.20.0(react@18.2.0) + react-icons: + specifier: 4.9.0 + version: 4.9.0(react@18.2.0) react-select: specifier: 5.7.3 version: 5.7.3(@babel/core@7.21.5)(@types/react@18.2.13)(react-dom@18.2.0)(react@18.2.0) @@ -1654,6 +1666,9 @@ importers: supertokens-web-js: specifier: 0.5.0 version: 0.5.0 + tailwind-merge: + specifier: 1.13.1 + version: 1.13.1 tslib: specifier: 2.5.3 version: 2.5.3 @@ -1760,6 +1775,9 @@ importers: tailwindcss: specifier: 3.3.2 version: 3.3.2(ts-node@10.9.1) + tailwindcss-animate: + specifier: 1.0.6 + version: 1.0.6(tailwindcss@3.3.2) tailwindcss-radix: specifier: 2.8.0 version: 2.8.0 @@ -3432,16 +3450,6 @@ packages: transitivePeerDependencies: - supports-color - /@babel/generator@7.21.4: - resolution: {integrity: sha512-NieM3pVIYW2SwGzKoqfPrQsf4xGs9M9AIG3ThppsSRmO+m7eQhmI6amajKMUeIO37wFfsvnvcxQFx6x6iqxDnA==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.21.5 - '@jridgewell/gen-mapping': 0.3.2 - '@jridgewell/trace-mapping': 0.3.18 - jsesc: 2.5.2 - dev: true - /@babel/generator@7.21.5: resolution: {integrity: sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==} engines: {node: '>=6.9.0'} @@ -4710,15 +4718,6 @@ packages: transitivePeerDependencies: - supports-color - /@babel/types@7.21.4: - resolution: {integrity: sha512-rU2oY501qDxE8Pyo7i/Orqma4ziCOrby0/9mvbDUGEfvZjb279Nk9k19e2fiCxHbRRpY2ZyrgW1eq22mvmOIzA==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-string-parser': 7.21.5 - '@babel/helper-validator-identifier': 7.19.1 - to-fast-properties: 2.0.0 - dev: true - /@babel/types@7.21.5: resolution: {integrity: sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==} engines: {node: '>=6.9.0'} @@ -6082,45 +6081,45 @@ packages: - '@types/node' dev: false - /@graphql-codegen/add@4.0.1(graphql@16.6.0): - resolution: {integrity: sha512-A7k+9eRfrKyyNfhWEN/0eKz09R5cp4XXxUuNLQAVm/aohmVI2xdMV4lM02rTlM6Pyou3cU/v0iZnhgo6IRpqeg==} + /@graphql-codegen/add@5.0.0(graphql@16.6.0): + resolution: {integrity: sha512-ynWDOsK2yxtFHwcJTB9shoSkUd7YXd6ZE57f0nk7W5cu/nAgxZZpEsnTPEpZB/Mjf14YRGe2uJHQ7AfElHjqUQ==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - '@graphql-codegen/plugin-helpers': 4.1.0(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.6.0) graphql: 16.6.0 tslib: 2.5.3 dev: true - /@graphql-codegen/cli@3.3.1(@babel/core@7.21.5)(@types/node@18.16.18)(graphql@16.6.0): - resolution: {integrity: sha512-4Es8Y9zFeT0Zx2qRL7L3qXDbbqvXK6aID+8v8lP6gaYD+uWx3Jd4Hsq5vxwVBR+6flm0BW/C85Qm0cvmT7O6LA==} + /@graphql-codegen/cli@4.0.1(@babel/core@7.21.5)(@types/node@18.16.18)(graphql@16.6.0): + resolution: {integrity: sha512-/H4imnGOl3hoPXLKmIiGUnXpmBmeIClSZie/YHDzD5N59cZlGGJlIOOrUlOTDpJx5JNU1MTQcRjyTToOYM5IfA==} hasBin: true peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - '@babel/generator': 7.21.4 + '@babel/generator': 7.21.5 '@babel/template': 7.20.7 - '@babel/types': 7.21.4 - '@graphql-codegen/core': 3.1.0(graphql@16.6.0) - '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) - '@graphql-tools/apollo-engine-loader': 7.3.13(graphql@16.6.0) - '@graphql-tools/code-file-loader': 7.3.23(@babel/core@7.21.5)(graphql@16.6.0) - '@graphql-tools/git-loader': 7.2.13(@babel/core@7.21.5)(graphql@16.6.0) - '@graphql-tools/github-loader': 7.3.20(@babel/core@7.21.5)(graphql@16.6.0) - '@graphql-tools/graphql-file-loader': 7.5.17(graphql@16.6.0) - '@graphql-tools/json-file-loader': 7.4.18(graphql@16.6.0) - '@graphql-tools/load': 7.8.14(graphql@16.6.0) - '@graphql-tools/prisma-loader': 7.2.49(@types/node@18.16.18)(graphql@16.6.0) - '@graphql-tools/url-loader': 7.17.18(@types/node@18.16.18)(graphql@16.6.0) - '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@babel/types': 7.21.5 + '@graphql-codegen/core': 4.0.0(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.6.0) + '@graphql-tools/apollo-engine-loader': 8.0.0(graphql@16.6.0) + '@graphql-tools/code-file-loader': 8.0.0(@babel/core@7.21.5)(graphql@16.6.0) + '@graphql-tools/git-loader': 8.0.1(@babel/core@7.21.5)(graphql@16.6.0) + '@graphql-tools/github-loader': 8.0.0(@babel/core@7.21.5)(@types/node@18.16.18)(graphql@16.6.0) + '@graphql-tools/graphql-file-loader': 8.0.0(graphql@16.6.0) + '@graphql-tools/json-file-loader': 8.0.0(graphql@16.6.0) + '@graphql-tools/load': 8.0.0(graphql@16.6.0) + '@graphql-tools/prisma-loader': 8.0.1(@types/node@18.16.18)(graphql@16.6.0) + '@graphql-tools/url-loader': 8.0.0(@types/node@18.16.18)(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) '@parcel/watcher': 2.1.0 '@whatwg-node/fetch': 0.8.8 chalk: 4.1.2 - cosmiconfig: 7.0.1 + cosmiconfig: 8.2.0 debounce: 1.2.1 detect-indent: 6.1.0 graphql: 16.6.0 - graphql-config: 4.5.0(@types/node@18.16.18)(graphql@16.6.0) + graphql-config: 5.0.2(@types/node@18.16.18)(graphql@16.6.0) inquirer: 8.2.5 is-glob: 4.0.3 jiti: 1.18.2 @@ -6133,7 +6132,7 @@ packages: ts-log: 2.2.5 tslib: 2.5.3 yaml: 1.10.2 - yargs: 17.6.2 + yargs: 17.7.2 transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -6149,22 +6148,22 @@ packages: resolution: {integrity: sha512-wibvCKuB+pP1ggNzZnkMLQgfyr0l+71kZsfK9M9GxxV8kaFGypbT3eN2XP4U8wH35GTaceLDg9jTg2JSUpcWdg==} dev: true - /@graphql-codegen/client-preset@3.0.1(graphql@16.6.0): - resolution: {integrity: sha512-aHlnlDWS39kddNJ/I+ItIUj3AX1040aRwHEv2FiPAL0ILrGzHg3AZY1Ay358Ys8fXclrqIN7IeWLmeyI3TIHiA==} + /@graphql-codegen/client-preset@4.0.0(graphql@16.6.0): + resolution: {integrity: sha512-A96Vc+ZHMoBTO7bH/I/iIqCBsDiXblKqhyMQsSfq79Muvtmhjx4E9Xt4s++/zpBbe4M+46EYLlde2ZIrymSqDw==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: '@babel/helper-plugin-utils': 7.20.2 '@babel/template': 7.20.7 - '@graphql-codegen/add': 4.0.1(graphql@16.6.0) - '@graphql-codegen/gql-tag-operations': 3.0.1(graphql@16.6.0) - '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) - '@graphql-codegen/typed-document-node': 4.0.1(graphql@16.6.0) - '@graphql-codegen/typescript': 3.0.4(graphql@16.6.0) - '@graphql-codegen/typescript-operations': 3.0.4(graphql@16.6.0) - '@graphql-codegen/visitor-plugin-common': 3.1.1(graphql@16.6.0) - '@graphql-tools/documents': 0.1.0(graphql@16.6.0) - '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@graphql-codegen/add': 5.0.0(graphql@16.6.0) + '@graphql-codegen/gql-tag-operations': 4.0.0(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.6.0) + '@graphql-codegen/typed-document-node': 5.0.0(graphql@16.6.0) + '@graphql-codegen/typescript': 4.0.0(graphql@16.6.0) + '@graphql-codegen/typescript-operations': 4.0.0(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 4.0.0(graphql@16.6.0) + '@graphql-tools/documents': 1.0.0(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) '@graphql-typed-document-node/core': 3.2.0(graphql@16.6.0) graphql: 16.6.0 tslib: 2.5.3 @@ -6173,26 +6172,26 @@ packages: - supports-color dev: true - /@graphql-codegen/core@3.1.0(graphql@16.6.0): - resolution: {integrity: sha512-DH1/yaR7oJE6/B+c6ZF2Tbdh7LixF1K8L+8BoSubjNyQ8pNwR4a70mvc1sv6H7qgp6y1bPQ9tKE+aazRRshysw==} + /@graphql-codegen/core@4.0.0(graphql@16.6.0): + resolution: {integrity: sha512-JAGRn49lEtSsZVxeIlFVIRxts2lWObR+OQo7V2LHDJ7ohYYw3ilv7nJ8pf8P4GTg/w6ptcYdSdVVdkI8kUHB/Q==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) - '@graphql-tools/schema': 9.0.18(graphql@16.6.0) - '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.6.0) + '@graphql-tools/schema': 10.0.0(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) graphql: 16.6.0 tslib: 2.5.3 dev: true - /@graphql-codegen/gql-tag-operations@3.0.1(graphql@16.6.0): - resolution: {integrity: sha512-8TpJuOiw56wSIS3v+jF5Yr695NYFCpSpChTbUnVLYT6QmnBExv/VwA9bHDchuyUBUE3PfpP/l5JF62Sc0oWmWg==} + /@graphql-codegen/gql-tag-operations@4.0.0(graphql@16.6.0): + resolution: {integrity: sha512-LLbyxjdtK5e78xmcQiy4aXzsttR+3VE8EsiGy9++ih8/JGsqxMcXEy4MtsVGh8KGdP+LCR+jA1o6grzm2tI3cw==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) - '@graphql-codegen/visitor-plugin-common': 3.1.1(graphql@16.6.0) - '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 4.0.0(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) auto-bind: 4.0.0 graphql: 16.6.0 tslib: 2.5.3 @@ -6201,14 +6200,14 @@ packages: - supports-color dev: true - /@graphql-codegen/graphql-modules-preset@3.1.3(graphql@16.6.0): - resolution: {integrity: sha512-UtFh8shm8/iAJPtbAwl7EGiJrIcPdua5oJbhyfF6NGhmRZaaJoW2P/1+chmgD5xBcEDjfMMRt/ukCx2dpGQSuw==} + /@graphql-codegen/graphql-modules-preset@4.0.0(graphql@16.6.0): + resolution: {integrity: sha512-CHoO3JT0iyXFOEI0g+zHid1PU7CtkJHQECZGWq7oF0dKpEKo3NfOytG2Qa4RzlORv6qCD/wylh+dp1/aG/iI/g==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) - '@graphql-codegen/visitor-plugin-common': 3.1.1(graphql@16.6.0) - '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 4.0.0(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) change-case-all: 1.0.15 graphql: 16.6.0 parse-filepath: 1.0.2 @@ -6246,12 +6245,12 @@ packages: tslib: 2.4.1 dev: true - /@graphql-codegen/plugin-helpers@4.1.0(graphql@16.6.0): - resolution: {integrity: sha512-xvSHJb9OGb5CODIls0AI1rCenLz+FuiaNPCsfHMCNsLDjOZK2u0jAQ9zUBdc/Wb+21YXZujBCc0Vm1QX+Zz0nw==} + /@graphql-codegen/plugin-helpers@5.0.0(graphql@16.6.0): + resolution: {integrity: sha512-suL2ZMkBAU2a4YbBHaZvUPsV1z0q3cW6S96Z/eYYfkRIsJoe2vN+wNZ9Xdzmqx0JLmeeFCBSoBGC0imFyXlkDQ==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) change-case-all: 1.0.15 common-tags: 1.8.2 graphql: 16.6.0 @@ -6260,38 +6259,24 @@ packages: tslib: 2.5.3 dev: true - /@graphql-codegen/plugin-helpers@4.2.0(graphql@16.6.0): - resolution: {integrity: sha512-THFTCfg+46PXlXobYJ/OoCX6pzjI+9woQqCjdyKtgoI0tn3Xq2HUUCiidndxUpEYVrXb5pRiRXb7b/ZbMQqD0A==} + /@graphql-codegen/schema-ast@4.0.0(graphql@16.6.0): + resolution: {integrity: sha512-WIzkJFa9Gz28FITAPILbt+7A8+yzOyd1NxgwFh7ie+EmO9a5zQK6UQ3U/BviirguXCYnn+AR4dXsoDrSrtRA1g==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - '@graphql-tools/utils': 9.2.1(graphql@16.6.0) - change-case-all: 1.0.15 - common-tags: 1.8.2 - graphql: 16.6.0 - import-from: 4.0.0 - lodash: 4.17.21 - tslib: 2.5.3 - dev: true - - /@graphql-codegen/schema-ast@3.0.1(graphql@16.6.0): - resolution: {integrity: sha512-rTKTi4XiW4QFZnrEqetpiYEWVsOFNoiR/v3rY9mFSttXFbIwNXPme32EspTiGWmEEdHY8UuTDtZN3vEcs/31zw==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) - '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) graphql: 16.6.0 tslib: 2.5.3 dev: true - /@graphql-codegen/typed-document-node@4.0.1(graphql@16.6.0): - resolution: {integrity: sha512-mQNYCd12JsFSaK6xLry4olY9TdYG7GxQPexU6qU4Om++eKhseGwk2eGmQDRG4Qp8jEDFLMXuHMVUKqMQ1M+F/A==} + /@graphql-codegen/typed-document-node@5.0.0(graphql@16.6.0): + resolution: {integrity: sha512-B5zSs9i9IOBX1Y4E2EduP8Q5qnG5SVcoTgTEx2SY3Jm3GKSbxs+ANawXXuf9G9/5plhJD9nHc53WuNLJ1xyIcg==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) - '@graphql-codegen/visitor-plugin-common': 3.1.1(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 4.0.0(graphql@16.6.0) auto-bind: 4.0.0 change-case-all: 1.0.15 graphql: 16.6.0 @@ -6320,14 +6305,14 @@ packages: - supports-color dev: true - /@graphql-codegen/typescript-operations@3.0.4(graphql@16.6.0): - resolution: {integrity: sha512-6yE2OL2+WJ1vd5MwFEGXpaxsFGzjAGUytPVHDML3Bi3TwP1F3lnQlIko4untwvHW0JhZEGQ7Ck30H9HjcxpdKA==} + /@graphql-codegen/typescript-operations@4.0.0(graphql@16.6.0): + resolution: {integrity: sha512-4juN+rCeyXx97zHg5FF2s6u9lfgVHY2ee+5S+P3X+nr2X0m93yFKJhbbEYKYMdE0d/nPPl5mxUiUGb/vzrDCig==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) - '@graphql-codegen/typescript': 3.0.4(graphql@16.6.0) - '@graphql-codegen/visitor-plugin-common': 3.1.1(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.6.0) + '@graphql-codegen/typescript': 4.0.0(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 4.0.0(graphql@16.6.0) auto-bind: 4.0.0 graphql: 16.6.0 tslib: 2.5.3 @@ -6336,15 +6321,15 @@ packages: - supports-color dev: true - /@graphql-codegen/typescript-resolvers@3.2.1(graphql@16.6.0): - resolution: {integrity: sha512-2ZIHk5J6HTuylse5ZIxw+aega54prHxvj7vM8hiKJ6vejZ94kvVPAq4aWmSFOkZ5lqU3YnM/ZyWfnhT5CUDj1g==} + /@graphql-codegen/typescript-resolvers@4.0.0(graphql@16.6.0): + resolution: {integrity: sha512-FapeQD/phVKAS0NVFUoSMK/nsR/QmHthF1m2/5Cg5WaHd51IFIt4pAdkThxnANd2UsxRKYNCGK3tOaNCQehWsQ==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) - '@graphql-codegen/typescript': 3.0.4(graphql@16.6.0) - '@graphql-codegen/visitor-plugin-common': 3.1.1(graphql@16.6.0) - '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.6.0) + '@graphql-codegen/typescript': 4.0.0(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 4.0.0(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) auto-bind: 4.0.0 graphql: 16.6.0 tslib: 2.5.3 @@ -6353,14 +6338,14 @@ packages: - supports-color dev: true - /@graphql-codegen/typescript@3.0.4(graphql@16.6.0): - resolution: {integrity: sha512-x4O47447DZrWNtE/l5CU9QzzW4m1RbmCEdijlA3s2flG/y1Ckqdemob4CWfilSm5/tZ3w1junVDY616RDTSvZw==} + /@graphql-codegen/typescript@4.0.0(graphql@16.6.0): + resolution: {integrity: sha512-9Wv050+a4O/c3RRDbXKVnm0e45mhmb8XuW3ICsmmwPUVJ5oX8NOLYIMU8ie1/gNTTCfJNwOtZr5EwX2yhXYUfQ==} peerDependencies: graphql: ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) - '@graphql-codegen/schema-ast': 3.0.1(graphql@16.6.0) - '@graphql-codegen/visitor-plugin-common': 3.1.1(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.6.0) + '@graphql-codegen/schema-ast': 4.0.0(graphql@16.6.0) + '@graphql-codegen/visitor-plugin-common': 4.0.0(graphql@16.6.0) auto-bind: 4.0.0 graphql: 16.6.0 tslib: 2.5.3 @@ -6390,15 +6375,15 @@ packages: - supports-color dev: true - /@graphql-codegen/visitor-plugin-common@3.1.1(graphql@16.6.0): - resolution: {integrity: sha512-uAfp+zu/009R3HUAuTK2AamR1bxIltM6rrYYI6EXSmkM3rFtFsLTuJhjUDj98HcUCszJZrADppz8KKLGRUVlNg==} + /@graphql-codegen/visitor-plugin-common@4.0.0(graphql@16.6.0): + resolution: {integrity: sha512-OFWr5tkrG4nCcE7AI9BSAwuA0VLP16uNCLssbmXpBa1rKR6b4mX+rJTQCoz47TFV5hii8yp8xaWfXVUcsNY39w==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: - '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) - '@graphql-tools/optimize': 1.3.1(graphql@16.6.0) - '@graphql-tools/relay-operation-optimizer': 6.5.6(graphql@16.6.0) - '@graphql-tools/utils': 9.2.1(graphql@16.6.0) + '@graphql-codegen/plugin-helpers': 5.0.0(graphql@16.6.0) + '@graphql-tools/optimize': 2.0.0(graphql@16.6.0) + '@graphql-tools/relay-operation-optimizer': 7.0.0(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) auto-bind: 4.0.0 change-case-all: 1.0.15 dependency-graph: 0.11.0 @@ -6755,14 +6740,15 @@ packages: - yargs dev: true - /@graphql-tools/apollo-engine-loader@7.3.13(graphql@16.6.0): - resolution: {integrity: sha512-fr2TcA9fM+H81ymdtyDaocZ/Ua4Vhhf1IvpQoPpuEUwLorREd86N8VORUEIBvEdJ1b7Bz7NqwL3RnM5m9KXftA==} + /@graphql-tools/apollo-engine-loader@8.0.0(graphql@16.6.0): + resolution: {integrity: sha512-axQTbN5+Yxs1rJ6cWQBOfw3AEeC+fvIuZSfJLPLLvFJLj4pUm9fhxey/g6oQZAAQJqKPfw+tLDUQvnfvRK8Kmg==} + engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: '@ardatan/sync-fetch': 0.0.1 - '@graphql-tools/utils': 8.12.0(graphql@16.6.0) - '@whatwg-node/fetch': 0.4.7 + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) + '@whatwg-node/fetch': 0.9.6 graphql: 16.6.0 tslib: 2.5.3 transitivePeerDependencies: @@ -6783,30 +6769,6 @@ packages: value-or-promise: 1.0.12 dev: false - /@graphql-tools/batch-execute@8.5.14(graphql@16.6.0): - resolution: {integrity: sha512-m6yXqqmFAH2V5JuSIC/geiGLBQA1Y6RddOJfUtkc9Z7ttkULRCd1W39TpYS6IlrCwYyTj+klO1/kdWiny38f5g==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 9.1.3(graphql@16.6.0) - dataloader: 2.1.0 - graphql: 16.6.0 - tslib: 2.5.3 - value-or-promise: 1.0.11 - dev: true - - /@graphql-tools/batch-execute@8.5.18(graphql@16.6.0): - resolution: {integrity: sha512-mNv5bpZMLLwhkmPA6+RP81A6u3KF4CSKLf3VX9hbomOkQR4db8pNs8BOvpZU54wKsUzMzdlws/2g/Dabyb2Vsg==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 9.2.1(graphql@16.6.0) - dataloader: 2.2.2 - graphql: 16.6.0 - tslib: 2.5.3 - value-or-promise: 1.0.12 - dev: false - /@graphql-tools/batch-execute@8.5.19(graphql@16.6.0): resolution: {integrity: sha512-eqofTMYPygg9wVPdA+p8lk4NBpaPTcDut6SlnDk9IiYdY23Yfo6pY7mzZ3b27GugI7HDtB2OZUxzZJSGsk6Qew==} peerDependencies: @@ -6829,7 +6791,6 @@ packages: graphql: 16.6.0 tslib: 2.5.3 value-or-promise: 1.0.12 - dev: false /@graphql-tools/code-file-loader@7.3.23(@babel/core@7.21.5)(graphql@16.6.0): resolution: {integrity: sha512-8Wt1rTtyTEs0p47uzsPJ1vAtfAx0jmxPifiNdmo9EOCuUPyQGEbMaik/YkqZ7QUFIEYEQu+Vgfo8tElwOPtx5Q==} @@ -6861,7 +6822,6 @@ packages: transitivePeerDependencies: - '@babel/core' - supports-color - dev: false /@graphql-tools/delegate@10.0.0(graphql@16.6.0): resolution: {integrity: sha512-ZW5/7Q0JqUM+guwn8/cM/1Hz16Zvj6WR6r3gnOwoPO7a9bCbe8QTCk4itT/EO+RiGT8RLUPYaunWR9jxfNqqOA==} @@ -6877,29 +6837,13 @@ packages: graphql: 16.6.0 tslib: 2.5.3 value-or-promise: 1.0.12 - dev: false - - /@graphql-tools/delegate@9.0.21(graphql@16.6.0): - resolution: {integrity: sha512-SM8tFeq6ogFGhIxDE82WTS44/3IQ/wz9QksAKT7xWkcICQnyR9U6Qyt+W7VGnHiybqNsVK3kHNNS/i4KGSF85g==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/batch-execute': 8.5.14(graphql@16.6.0) - '@graphql-tools/executor': 0.0.11(graphql@16.6.0) - '@graphql-tools/schema': 9.0.12(graphql@16.6.0) - '@graphql-tools/utils': 9.1.3(graphql@16.6.0) - dataloader: 2.1.0 - graphql: 16.6.0 - tslib: 2.4.1 - value-or-promise: 1.0.11 - dev: true /@graphql-tools/delegate@9.0.28(graphql@16.6.0): resolution: {integrity: sha512-8j23JCs2mgXqnp+5K0v4J3QBQU/5sXd9miaLvMfRf/6963DznOXTECyS9Gcvj1VEeR5CXIw6+aX/BvRDKDdN1g==} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: - '@graphql-tools/batch-execute': 8.5.18(graphql@16.6.0) + '@graphql-tools/batch-execute': 8.5.19(graphql@16.6.0) '@graphql-tools/executor': 0.0.15(graphql@16.6.0) '@graphql-tools/schema': 9.0.18(graphql@16.6.0) '@graphql-tools/utils': 9.2.1(graphql@16.6.0) @@ -6923,8 +6867,9 @@ packages: tslib: 2.5.3 value-or-promise: 1.0.12 - /@graphql-tools/documents@0.1.0(graphql@16.6.0): - resolution: {integrity: sha512-1WQeovHv5S1M3xMzQxbSrG3yl6QOnsq2JUBnlg5/0aMM5R4GNMx6Ms+ROByez/dnuA81kstRuSK+2qpe+GaRIw==} + /@graphql-tools/documents@1.0.0(graphql@16.6.0): + resolution: {integrity: sha512-rHGjX1vg/nZ2DKqRGfDPNC55CWZBMldEVcH+91BThRa6JeT80NqXknffLLEZLRUxyikCfkwMsk6xR3UNMqG0Rg==} + engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: @@ -6950,24 +6895,6 @@ packages: - bufferutil - utf-8-validate - /@graphql-tools/executor-graphql-ws@0.0.5(graphql@16.6.0): - resolution: {integrity: sha512-1bJfZdSBPCJWz1pJ5g/YHMtGt6YkNRDdmqNQZ8v+VlQTNVfuBpY2vzj15uvf5uDrZLg2MSQThrKlL8av4yFpsA==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 9.1.3(graphql@16.6.0) - '@repeaterjs/repeater': 3.0.4 - '@types/ws': 8.5.3 - graphql: 16.6.0 - graphql-ws: 5.11.2(graphql@16.6.0) - isomorphic-ws: 5.0.0(ws@8.11.0) - tslib: 2.5.3 - ws: 8.11.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - dev: true - /@graphql-tools/executor-graphql-ws@1.0.0(graphql@16.6.0): resolution: {integrity: sha512-voczXmNcEzZKk6dS4pCwN0XCXvDiAVm9rj+54oz7X04IsHBJmTUul1YhCbJie1xUvN1jmgEJ14lfD92tMMMTmQ==} engines: {node: '>=16.0.0'} @@ -6985,26 +6912,6 @@ packages: transitivePeerDependencies: - bufferutil - utf-8-validate - dev: false - - /@graphql-tools/executor-http@0.0.7(@types/node@18.16.18)(graphql@16.6.0): - resolution: {integrity: sha512-g0NV4HVZVABsylk6SIA/gfjQbMIsy3NjZYW0k0JZmTcp9698J37uG50GZC2mKe0F8pIlDvPLvrPloqdKGX3ZAA==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 9.1.3(graphql@16.6.0) - '@repeaterjs/repeater': 3.0.4 - '@whatwg-node/fetch': 0.5.3 - dset: 3.1.2 - extract-files: 11.0.0 - graphql: 16.6.0 - meros: 1.2.1(@types/node@18.16.18) - tslib: 2.5.3 - value-or-promise: 1.0.11 - transitivePeerDependencies: - - '@types/node' - - encoding - dev: true /@graphql-tools/executor-http@0.1.9(@types/node@18.16.18)(graphql@16.6.0): resolution: {integrity: sha512-tNzMt5qc1ptlHKfpSv9wVBVKCZ7gks6Yb/JcYJluxZIT4qRV+TtOFjpptfBU63usgrGVOVcGjzWc/mt7KhmmpQ==} @@ -7040,7 +6947,6 @@ packages: value-or-promise: 1.0.12 transitivePeerDependencies: - '@types/node' - dev: false /@graphql-tools/executor-legacy-ws@0.0.11(graphql@16.6.0): resolution: {integrity: sha512-4ai+NnxlNfvIQ4c70hWFvOZlSUN8lt7yc+ZsrwtNFbFPH/EroIzFMapAxM9zwyv9bH38AdO3TQxZ5zNxgBdvUw==} @@ -7057,22 +6963,6 @@ packages: - bufferutil - utf-8-validate - /@graphql-tools/executor-legacy-ws@0.0.5(graphql@16.6.0): - resolution: {integrity: sha512-j2ZQVTI4rKIT41STzLPK206naYDhHxmGHot0siJKBKX1vMqvxtWBqvL66v7xYEOaX79wJrFc8l6oeURQP2LE6g==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 9.1.3(graphql@16.6.0) - '@types/ws': 8.5.3 - graphql: 16.6.0 - isomorphic-ws: 5.0.0(ws@8.11.0) - tslib: 2.5.3 - ws: 8.11.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - dev: true - /@graphql-tools/executor-legacy-ws@1.0.0(graphql@16.6.0): resolution: {integrity: sha512-8c0wlhYz7G6imuWqHqjpveflN8IVL3gXIxel5lzpAvPvxsSXpiNig3jADkIBB+eaxzR9R1lbwxqonxPUGI4CdQ==} engines: {node: '>=16.0.0'} @@ -7088,20 +6978,6 @@ packages: transitivePeerDependencies: - bufferutil - utf-8-validate - dev: false - - /@graphql-tools/executor@0.0.11(graphql@16.6.0): - resolution: {integrity: sha512-GjtXW0ZMGZGKad6A1HXFPArkfxE0AIpznusZuQdy4laQx+8Ut3Zx8SAFJNnDfZJ2V5kU29B5Xv3Fr0/DiMBHOQ==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 9.1.3(graphql@16.6.0) - '@graphql-typed-document-node/core': 3.1.1(graphql@16.6.0) - '@repeaterjs/repeater': 3.0.4 - graphql: 16.6.0 - tslib: 2.5.3 - value-or-promise: 1.0.11 - dev: true /@graphql-tools/executor@0.0.15(graphql@16.6.0): resolution: {integrity: sha512-6U7QLZT8cEUxAMXDP4xXVplLi6RBwx7ih7TevlBto66A/qFp3PDb6o/VFo07yBKozr8PGMZ4jMfEWBGxmbGdxA==} @@ -7154,23 +7030,6 @@ packages: tslib: 2.5.3 value-or-promise: 1.0.12 - /@graphql-tools/git-loader@7.2.13(@babel/core@7.21.5)(graphql@16.6.0): - resolution: {integrity: sha512-PBAzZWXzKUL+VvlUQOjF++246G1O6TTMzvIlxaecgxvTSlnljEXJcDQlxqXhfFPITc5MP7He0N1UcZPBU/DE7Q==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/graphql-tag-pluck': 7.4.0(@babel/core@7.21.5)(graphql@16.6.0) - '@graphql-tools/utils': 9.1.1(graphql@16.6.0) - graphql: 16.6.0 - is-glob: 4.0.3 - micromatch: 4.0.5 - tslib: 2.5.3 - unixify: 1.0.0 - transitivePeerDependencies: - - '@babel/core' - - supports-color - dev: true - /@graphql-tools/git-loader@7.2.22(@babel/core@7.21.5)(graphql@16.6.0): resolution: {integrity: sha512-9rpHggHiOeqA7/ZlKD3c5yXk5bPGw0zkIgKMerjCmFAQAZ6CEVfsa7nAzEWQxn6rpdaBft4/0A56rPMrsUwGBA==} peerDependencies: @@ -7188,20 +7047,21 @@ packages: - supports-color dev: true - /@graphql-tools/github-loader@7.3.20(@babel/core@7.21.5)(graphql@16.6.0): - resolution: {integrity: sha512-kIgloHb+yJJYR6K47HNBv7vI7IF73eoGsQy77H+2WDA+zwE5PuRXGUTAlJXRQdwiY71/Nvbw44P3l4WWbMRv0Q==} + /@graphql-tools/git-loader@8.0.1(@babel/core@7.21.5)(graphql@16.6.0): + resolution: {integrity: sha512-ivNtxD+iEfpPONYKip0kbpZMRdMCNR3HrIui8NCURmUdvBYGaGcbB3VrGMhxwZuzc+ybhs2ralPt1F8Oxq2jLA==} + engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: - '@ardatan/sync-fetch': 0.0.1 - '@graphql-tools/graphql-tag-pluck': 7.4.0(@babel/core@7.21.5)(graphql@16.6.0) - '@graphql-tools/utils': 9.1.1(graphql@16.6.0) - '@whatwg-node/fetch': 0.5.3 + '@graphql-tools/graphql-tag-pluck': 8.0.1(@babel/core@7.21.5)(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) graphql: 16.6.0 + is-glob: 4.0.3 + micromatch: 4.0.5 tslib: 2.5.3 + unixify: 1.0.0 transitivePeerDependencies: - '@babel/core' - - encoding - supports-color dev: true @@ -7225,6 +7085,27 @@ packages: - supports-color dev: true + /@graphql-tools/github-loader@8.0.0(@babel/core@7.21.5)(@types/node@18.16.18)(graphql@16.6.0): + resolution: {integrity: sha512-VuroArWKcG4yaOWzV0r19ElVIV6iH6UKDQn1MXemND0xu5TzrFme0kf3U9o0YwNo0kUYEk9CyFM0BYg4he17FA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@ardatan/sync-fetch': 0.0.1 + '@graphql-tools/executor-http': 1.0.0(@types/node@18.16.18)(graphql@16.6.0) + '@graphql-tools/graphql-tag-pluck': 8.0.1(@babel/core@7.21.5)(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) + '@whatwg-node/fetch': 0.9.6 + graphql: 16.6.0 + tslib: 2.5.3 + value-or-promise: 1.0.12 + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - encoding + - supports-color + dev: true + /@graphql-tools/graphql-file-loader@7.5.17(graphql@16.6.0): resolution: {integrity: sha512-hVwwxPf41zOYgm4gdaZILCYnKB9Zap7Ys9OhY1hbwuAuC4MMNY9GpUjoTU3CQc3zUiPoYStyRtUGkHSJZ3HxBw==} peerDependencies: @@ -7249,24 +7130,6 @@ packages: graphql: 16.6.0 tslib: 2.5.3 unixify: 1.0.0 - dev: false - - /@graphql-tools/graphql-tag-pluck@7.4.0(@babel/core@7.21.5)(graphql@16.6.0): - resolution: {integrity: sha512-f966Z8cMDiPxWuN3ksuHpNgGE8euZtrL/Gcwz9rRarAb13al4CGHKmw2Cb/ZNdt7GbyhdiLT4wbaddrF0xCpdw==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@babel/parser': 7.21.8 - '@babel/plugin-syntax-import-assertions': 7.20.0(@babel/core@7.21.5) - '@babel/traverse': 7.21.5 - '@babel/types': 7.21.5 - '@graphql-tools/utils': 9.1.1(graphql@16.6.0) - graphql: 16.6.0 - tslib: 2.5.3 - transitivePeerDependencies: - - '@babel/core' - - supports-color - dev: true /@graphql-tools/graphql-tag-pluck@7.5.2(@babel/core@7.21.5)(graphql@16.6.0): resolution: {integrity: sha512-RW+H8FqOOLQw0BPXaahYepVSRjuOHw+7IL8Opaa5G5uYGOBxoXR7DceyQ7BcpMgktAOOmpDNQ2WtcboChOJSRA==} @@ -7300,7 +7163,24 @@ packages: transitivePeerDependencies: - '@babel/core' - supports-color - dev: false + + /@graphql-tools/graphql-tag-pluck@8.0.1(@babel/core@7.21.5)(graphql@16.6.0): + resolution: {integrity: sha512-4sfBJSoXxVB4rRCCp2GTFhAYsUJgAPSKxSV+E3Voc600mK52JO+KsHCCTnPgCeyJFMNR9l94J6+tqxVKmlqKvw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@babel/parser': 7.21.8 + '@babel/plugin-syntax-import-assertions': 7.20.0(@babel/core@7.21.5) + '@babel/traverse': 7.21.5 + '@babel/types': 7.21.5 + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.5.3 + transitivePeerDependencies: + - '@babel/core' + - supports-color + dev: true /@graphql-tools/import@6.7.18(graphql@16.6.0): resolution: {integrity: sha512-XQDdyZTp+FYmT7as3xRWH/x8dx0QZA2WZqfMF5EWb36a0PiH7WwlRQYIdyYXj8YCLpiWkeBXgBRHmMnwEYR8iQ==} @@ -7322,7 +7202,6 @@ packages: graphql: 16.6.0 resolve-from: 5.0.0 tslib: 2.5.3 - dev: false /@graphql-tools/json-file-loader@7.4.18(graphql@16.6.0): resolution: {integrity: sha512-AJ1b6Y1wiVgkwsxT5dELXhIVUPs/u3VZ8/0/oOtpcoyO/vAeM5rOvvWegzicOOnQw8G45fgBRMkkRfeuwVt6+w==} @@ -7346,7 +7225,6 @@ packages: graphql: 16.6.0 tslib: 2.5.3 unixify: 1.0.0 - dev: false /@graphql-tools/load@7.8.14(graphql@16.6.0): resolution: {integrity: sha512-ASQvP+snHMYm+FhIaLxxFgVdRaM0vrN9wW2BKInQpktwWTXVyk+yP5nQUCEGmn0RTdlPKrffBaigxepkEAJPrg==} @@ -7370,17 +7248,6 @@ packages: graphql: 16.6.0 p-limit: 3.1.0 tslib: 2.5.3 - dev: false - - /@graphql-tools/merge@8.3.14(graphql@16.6.0): - resolution: {integrity: sha512-zV0MU1DnxJLIB0wpL4N3u21agEiYFsjm6DI130jqHpwF0pR9HkF+Ni65BNfts4zQelP0GjkHltG+opaozAJ1NA==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/utils': 9.1.3(graphql@16.6.0) - graphql: 16.6.0 - tslib: 2.5.3 - dev: true /@graphql-tools/merge@8.3.18(graphql@16.6.0): resolution: {integrity: sha512-R8nBglvRWPAyLpZL/f3lxsY7wjnAeE0l056zHhcO/CgpvK76KYUt9oEkR05i8Hmt8DLRycBN0FiotJ0yDQWTVA==} @@ -7433,27 +7300,37 @@ packages: tslib: 2.5.3 dev: true - /@graphql-tools/prisma-loader@7.2.49(@types/node@18.16.18)(graphql@16.6.0): - resolution: {integrity: sha512-RIvrEAoKHdR7KaOUQRpZYxFRF+lfxH4MFeErjBA9z/BpL7Iv5QyfIOgFRE8i3E2eToMqDPzEg7RHha2hXBssug==} + /@graphql-tools/optimize@2.0.0(graphql@16.6.0): + resolution: {integrity: sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg==} + engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: - '@graphql-tools/url-loader': 7.16.28(@types/node@18.16.18)(graphql@16.6.0) - '@graphql-tools/utils': 9.1.3(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.5.3 + dev: true + + /@graphql-tools/prisma-loader@8.0.1(@types/node@18.16.18)(graphql@16.6.0): + resolution: {integrity: sha512-bl6e5sAYe35Z6fEbgKXNrqRhXlCJYeWKBkarohgYA338/SD9eEhXtg3Cedj7fut3WyRLoQFpHzfiwxKs7XrgXg==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-tools/url-loader': 8.0.0(@types/node@18.16.18)(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) '@types/js-yaml': 4.0.5 '@types/json-stable-stringify': 1.0.34 - '@types/jsonwebtoken': 8.5.9 + '@whatwg-node/fetch': 0.9.6 chalk: 4.1.2 debug: 4.3.4(supports-color@8.1.1) dotenv: 16.3.1 graphql: 16.6.0 - graphql-request: 5.1.0(graphql@16.6.0) - http-proxy-agent: 5.0.0 - https-proxy-agent: 5.0.1 - isomorphic-fetch: 3.0.0 + graphql-request: 6.1.0(graphql@16.6.0) + http-proxy-agent: 7.0.0 + https-proxy-agent: 7.0.0 + jose: 4.12.0 js-yaml: 4.1.0 json-stable-stringify: 1.0.1 - jsonwebtoken: 9.0.0 lodash: 4.17.21 scuid: 1.1.0 tslib: 2.5.3 @@ -7480,6 +7357,21 @@ packages: - supports-color dev: true + /@graphql-tools/relay-operation-optimizer@7.0.0(graphql@16.6.0): + resolution: {integrity: sha512-UNlJi5y3JylhVWU4MBpL0Hun4Q7IoJwv9xYtmAz+CgRa066szzY7dcuPfxrA7cIGgG/Q6TVsKsYaiF4OHPs1Fw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@ardatan/relay-compiler': 12.0.0(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.5.3 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + /@graphql-tools/schema@10.0.0(graphql@16.6.0): resolution: {integrity: sha512-kf3qOXMFcMs2f/S8Y3A8fm/2w+GaHAkfr3Gnhh2LOug/JgpY/ywgFVxO3jOeSpSEdoYcDKLcXVjMigNbY4AdQg==} engines: {node: '>=16.0.0'} @@ -7492,18 +7384,6 @@ packages: tslib: 2.5.3 value-or-promise: 1.0.12 - /@graphql-tools/schema@9.0.12(graphql@16.6.0): - resolution: {integrity: sha512-DmezcEltQai0V1y96nwm0Kg11FDS/INEFekD4nnVgzBqawvznWqK6D6bujn+cw6kivoIr3Uq//QmU/hBlBzUlQ==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/merge': 8.3.14(graphql@16.6.0) - '@graphql-tools/utils': 9.1.3(graphql@16.6.0) - graphql: 16.6.0 - tslib: 2.5.3 - value-or-promise: 1.0.11 - dev: true - /@graphql-tools/schema@9.0.16(graphql@16.6.0): resolution: {integrity: sha512-kF+tbYPPf/6K2aHG3e1SWIbapDLQaqnIHVRG6ow3onkFoowwtKszvUyOASL6Krcv2x9bIMvd1UkvRf9OaoROQQ==} peerDependencies: @@ -7557,32 +7437,6 @@ packages: tslib: 2.5.3 dev: false - /@graphql-tools/url-loader@7.16.28(@types/node@18.16.18)(graphql@16.6.0): - resolution: {integrity: sha512-C3Qmpr5g3aNf7yKbfqSEmNEoPNkY4kpm+K1FyuGQw8N6ZKdq/70VPL8beSfqE1e2CTJua95pLQCpSD9ZsWfUEg==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@ardatan/sync-fetch': 0.0.1 - '@graphql-tools/delegate': 9.0.21(graphql@16.6.0) - '@graphql-tools/executor-graphql-ws': 0.0.5(graphql@16.6.0) - '@graphql-tools/executor-http': 0.0.7(@types/node@18.16.18)(graphql@16.6.0) - '@graphql-tools/executor-legacy-ws': 0.0.5(graphql@16.6.0) - '@graphql-tools/utils': 9.1.3(graphql@16.6.0) - '@graphql-tools/wrap': 9.2.23(graphql@16.6.0) - '@types/ws': 8.5.3 - '@whatwg-node/fetch': 0.5.3 - graphql: 16.6.0 - isomorphic-ws: 5.0.0(ws@8.11.0) - tslib: 2.5.3 - value-or-promise: 1.0.12 - ws: 8.11.0 - transitivePeerDependencies: - - '@types/node' - - bufferutil - - encoding - - utf-8-validate - dev: true - /@graphql-tools/url-loader@7.17.18(@types/node@18.16.18)(graphql@16.6.0): resolution: {integrity: sha512-ear0CiyTj04jCVAxi7TvgbnGDIN2HgqzXzwsfcqiVg9cvjT40NcMlZ2P1lZDgqMkZ9oyLTV8Bw6j+SyG6A+xPw==} peerDependencies: @@ -7633,7 +7487,6 @@ packages: - bufferutil - encoding - utf-8-validate - dev: false /@graphql-tools/utils@10.0.0(graphql@16.6.0): resolution: {integrity: sha512-ndBPc6zgR+eGU/jHLpuojrs61kYN3Z89JyMLwK3GCRkPv4EQn9EOr1UWqF1JO0iM+/jAVHY0mvfUxyrFFN9DUQ==} @@ -7674,24 +7527,6 @@ packages: tslib: 2.5.3 dev: true - /@graphql-tools/utils@9.1.1(graphql@16.6.0): - resolution: {integrity: sha512-DXKLIEDbihK24fktR2hwp/BNIVwULIHaSTNTNhXS+19vgT50eX9wndx1bPxGwHnVBOONcwjXy0roQac49vdt/w==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - graphql: 16.6.0 - tslib: 2.5.3 - dev: true - - /@graphql-tools/utils@9.1.3(graphql@16.6.0): - resolution: {integrity: sha512-bbJyKhs6awp1/OmP+WKA1GOyu9UbgZGkhIj5srmiMGLHohEOKMjW784Sk0BZil1w2x95UPu0WHw6/d/HVCACCg==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - graphql: 16.6.0 - tslib: 2.5.3 - dev: true - /@graphql-tools/utils@9.2.1(graphql@16.6.0): resolution: {integrity: sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==} peerDependencies: @@ -7713,20 +7548,6 @@ packages: graphql: 16.6.0 tslib: 2.5.3 value-or-promise: 1.0.12 - dev: false - - /@graphql-tools/wrap@9.2.23(graphql@16.6.0): - resolution: {integrity: sha512-R+ar8lHdSnRQtfvkwQMOkBRlYLcBPdmFzZPiAj+tL9Nii4VNr4Oub37jcHiPBvRZSdKa9FHcKq5kKSQcbg1xuQ==} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - dependencies: - '@graphql-tools/delegate': 9.0.21(graphql@16.6.0) - '@graphql-tools/schema': 9.0.12(graphql@16.6.0) - '@graphql-tools/utils': 9.1.3(graphql@16.6.0) - graphql: 16.6.0 - tslib: 2.5.3 - value-or-promise: 1.0.11 - dev: true /@graphql-tools/wrap@9.3.7(graphql@16.6.0): resolution: {integrity: sha512-gavfiWLKgvmC2VPamnMzml3zmkBoo0yt+EmOLIHY6O92o4uMTR281WGM77tZIfq+jzLtjoIOThUSjC/cN/6XKg==} @@ -7753,14 +7574,6 @@ packages: tslib: 2.5.3 value-or-promise: 1.0.12 - /@graphql-typed-document-node/core@3.1.1(graphql@16.6.0): - resolution: {integrity: sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg==} - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - dependencies: - graphql: 16.6.0 - dev: true - /@graphql-typed-document-node/core@3.1.2(graphql@16.6.0): resolution: {integrity: sha512-9anpBMM9mEgZN4wr2v8wHJI2/u5TnnggewRN6OlvXTTnuVyoY19X6rOv9XTqKRw6dcGKwZsBi8n0kDE2I5i4VA==} peerDependencies: @@ -12779,6 +12592,7 @@ packages: resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==} dependencies: '@types/node': 18.16.18 + dev: false /@types/jsonwebtoken@9.0.0: resolution: {integrity: sha512-mM4TkDpA9oixqg1Fv2vVpOFyIVLJjm5x4k0V+K/rEsizfjD7Tk7LKk3GTtbB7KCfP0FEHQtsZqFxYA0+sijNVg==} @@ -13529,36 +13343,6 @@ packages: resolution: {integrity: sha512-AyQEn5hIPV7Ze+xFoXVU3QTHXVbWPrzaOkxtENMPMuNL6VVHrp4hHfDt9nrQpjO7BgvuM95dMtkycX5M/DZR3w==} engines: {node: '>=16.0.0'} - /@whatwg-node/fetch@0.4.7: - resolution: {integrity: sha512-+oKDMGtmUJ7H37VDL5U2Vdk+ZxsIypZxO2q6y42ytu6W3PL6OIIUYZGliNqQgWtCdtxOZ9WPQvbIAuiLpnLlUw==} - dependencies: - '@peculiar/webcrypto': 1.4.0 - abort-controller: 3.0.0 - busboy: 1.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.6.7 - undici: 5.12.0 - web-streams-polyfill: 3.2.1 - transitivePeerDependencies: - - encoding - dev: true - - /@whatwg-node/fetch@0.5.3: - resolution: {integrity: sha512-cuAKL3Z7lrJJuUrfF1wxkQTb24Qd1QO/lsjJpM5ZSZZzUMms5TPnbGeGUKWA3hVKNHh30lVfr2MyRCT5Jfkucw==} - dependencies: - '@peculiar/webcrypto': 1.4.0 - abort-controller: 3.0.0 - busboy: 1.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.6.7 - undici: 5.12.0 - web-streams-polyfill: 3.2.1 - transitivePeerDependencies: - - encoding - dev: true - /@whatwg-node/fetch@0.8.8: resolution: {integrity: sha512-CdcjGC2vdKhc13KKxgsc6/616BQ7ooDIgPeTuAiE8qfCnS0mGzcfCOoZXypQSz73nxI+GWc7ZReIAVhxoE1KCg==} dependencies: @@ -13761,6 +13545,15 @@ packages: transitivePeerDependencies: - supports-color + /agent-base@7.1.0: + resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} + engines: {node: '>= 14'} + dependencies: + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + dev: true + /agentkeepalive@4.3.0: resolution: {integrity: sha512-7Epl1Blf4Sy37j4v9f9FjICCh4+KAQOyXgHEwlyBiAQLbhKdq/i2QQU3amQalS/wPhdPzDXPL5DMR5bkn+YeWg==} engines: {node: '>= 8.0.0'} @@ -14842,6 +14635,7 @@ packages: /buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -15482,6 +15276,18 @@ packages: static-extend: 0.1.2 dev: false + /class-variance-authority@0.6.0(typescript@5.1.3): + resolution: {integrity: sha512-qdRDgfjx3GRb9fpwpSvn+YaidnT7IUJNe4wt5/SWwM+PmUwJUhQRk/8zAyNro0PmVfmen2635UboTjIBXXxy5A==} + peerDependencies: + typescript: '>= 4.5.5 < 6' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + clsx: 1.2.1 + typescript: 5.1.3 + dev: false + /classnames@2.3.2: resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} dev: false @@ -16065,6 +15871,7 @@ packages: js-yaml: 4.1.0 parse-json: 5.2.0 path-type: 4.0.0 + dev: false /cosmiconfig@8.2.0: resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==} @@ -16693,10 +16500,6 @@ packages: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} dev: true - /dataloader@2.1.0: - resolution: {integrity: sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ==} - dev: true - /dataloader@2.2.2: resolution: {integrity: sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==} @@ -17195,6 +16998,7 @@ packages: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} dependencies: safe-buffer: 5.2.1 + dev: false /echarts-for-react@3.0.2(echarts@5.4.2)(react@18.2.0): resolution: {integrity: sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA==} @@ -18831,10 +18635,6 @@ packages: webpack: 5.75.0(@swc/core@1.3.65)(esbuild@0.17.19) dev: true - /form-data-encoder@1.7.2: - resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} - dev: true - /form-data-encoder@2.1.3: resolution: {integrity: sha512-KqU0nnPMgIJcCOFTNJFEA8epcseEaoox4XZffTgy8jlI6pL/5EFyR54NRG7CnCJN0biY7q52DO3MH6/sJ/TKlQ==} engines: {node: '>= 14.17'} @@ -18875,14 +18675,6 @@ packages: mime-types: 2.1.35 dev: false - /formdata-node@4.4.1: - resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} - engines: {node: '>= 12.20'} - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 4.0.0-beta.3 - dev: true - /formik@2.2.9(react@18.2.0): resolution: {integrity: sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==} peerDependencies: @@ -19517,6 +19309,36 @@ packages: - bufferutil - encoding - utf-8-validate + dev: false + + /graphql-config@5.0.2(@types/node@18.16.18)(graphql@16.6.0): + resolution: {integrity: sha512-7TPxOrlbiG0JplSZYCyxn2XQtqVhXomEjXUmWJVSS5ET1nPhOJSsIb/WTwqWhcYX6G0RlHXSj9PLtGTKmxLNGg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + cosmiconfig-toml-loader: ^1.0.0 + graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + cosmiconfig-toml-loader: + optional: true + dependencies: + '@graphql-tools/graphql-file-loader': 8.0.0(graphql@16.6.0) + '@graphql-tools/json-file-loader': 8.0.0(graphql@16.6.0) + '@graphql-tools/load': 8.0.0(graphql@16.6.0) + '@graphql-tools/merge': 9.0.0(graphql@16.6.0) + '@graphql-tools/url-loader': 8.0.0(@types/node@18.16.18)(graphql@16.6.0) + '@graphql-tools/utils': 10.0.1(graphql@16.6.0) + cosmiconfig: 8.2.0 + graphql: 16.6.0 + jiti: 1.18.2 + minimatch: 4.2.3 + string-env-interpolation: 1.0.1 + tslib: 2.5.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - encoding + - utf-8-validate + dev: true /graphql-depth-limit@1.1.0(graphql@16.6.0): resolution: {integrity: sha512-+3B2BaG8qQ8E18kzk9yiSdAa75i/hnnOwgSeAxVJctGQPvmeiLtqKOYF6HETCyRjiF7Xfsyal0HbLlxCQkgkrw==} @@ -19667,6 +19489,18 @@ packages: transitivePeerDependencies: - encoding + /graphql-request@6.1.0(graphql@16.6.0): + resolution: {integrity: sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==} + peerDependencies: + graphql: 14 - 16 + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.6.0) + cross-fetch: 3.1.5 + graphql: 16.6.0 + transitivePeerDependencies: + - encoding + dev: true + /graphql-scalars@1.22.2(graphql@16.6.0): resolution: {integrity: sha512-my9FB4GtghqXqi/lWSVAOPiTzTnnEzdOXCsAC2bb5V7EFNQjVjwy3cSSbUvgYOtDuDibd+ZsCDhz+4eykYOlhQ==} engines: {node: '>=10'} @@ -19686,15 +19520,6 @@ packages: graphql: 16.6.0 tslib: 2.5.3 - /graphql-ws@5.11.2(graphql@16.6.0): - resolution: {integrity: sha512-4EiZ3/UXYcjm+xFGP544/yW1+DVI8ZpKASFbzrV5EDTFWJp0ZvLl4Dy2fSZAzz9imKp5pZMIcjB0x/H69Pv/6w==} - engines: {node: '>=10'} - peerDependencies: - graphql: '>=0.11 <=16' - dependencies: - graphql: 16.6.0 - dev: true - /graphql-ws@5.12.1(graphql@16.6.0): resolution: {integrity: sha512-umt4f5NnMK46ChM2coO36PTFhHouBrK9stWWBczERguwYrGnPNxJ9dimU6IyOBfOkC6Izhkg4H8+F51W/8CYDg==} engines: {node: '>=10'} @@ -19710,7 +19535,6 @@ packages: graphql: '>=0.11 <=16' dependencies: graphql: 16.6.0 - dev: false /graphql-yoga@3.9.1(graphql@16.6.0): resolution: {integrity: sha512-BB6EkN64VBTXWmf9Kym2OsVZFzBC0mAsQNo9eNB5xIr3t+x7qepQ34xW5A353NWol3Js3xpzxwIKFVF6l9VsPg==} @@ -20204,6 +20028,16 @@ packages: transitivePeerDependencies: - supports-color + /http-proxy-agent@7.0.0: + resolution: {integrity: sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.0 + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + dev: true + /http-signature@1.3.6: resolution: {integrity: sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==} engines: {node: '>=0.10'} @@ -20251,6 +20085,16 @@ packages: transitivePeerDependencies: - supports-color + /https-proxy-agent@7.0.0: + resolution: {integrity: sha512-0euwPCRyAPSgGdzD1IVN9nJYHtBhJwb6XPfbpQcYbPCwrBidX6GzxmchnaF4sfF/jPb74Ojx5g4yTg3sixlyPw==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.0 + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + dev: true + /human-id@1.0.2: resolution: {integrity: sha512-UNopramDEhHJD+VR+ehk8rOslwSfByxPIZyJRfV739NDhN5LF1fa1MqnzKm2lGTQRjNrjK19Q5fhkgIfjlVUKw==} dev: true @@ -21091,15 +20935,6 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} - /isomorphic-fetch@3.0.0: - resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} - dependencies: - node-fetch: 2.6.7 - whatwg-fetch: 3.6.2 - transitivePeerDependencies: - - encoding - dev: true - /isomorphic-unfetch@3.1.0: resolution: {integrity: sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==} dependencies: @@ -21109,14 +20944,6 @@ packages: - encoding dev: true - /isomorphic-ws@5.0.0(ws@8.11.0): - resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} - peerDependencies: - ws: '*' - dependencies: - ws: 8.11.0 - dev: true - /isomorphic-ws@5.0.0(ws@8.13.0): resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} peerDependencies: @@ -21265,6 +21092,7 @@ packages: /jiti@1.17.1: resolution: {integrity: sha512-NZIITw8uZQFuzQimqjUxIrIcEdxYDFIe/0xYfIlVXTkiBjjyBEvgasj5bb0/cHtPRD/NziPbT312sFrkI5ALpw==} hasBin: true + dev: false /jiti@1.18.2: resolution: {integrity: sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==} @@ -21288,7 +21116,6 @@ packages: /jose@4.12.0: resolution: {integrity: sha512-wW1u3cK81b+SFcHjGC8zw87yuyUweEFe0UJirrXEw1NasW00eF7sZjeG3SLBGz001ozxQ46Y9sofDvhBmWFtXQ==} - dev: false /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} @@ -21501,6 +21328,7 @@ packages: lodash: 4.17.21 ms: 2.1.3 semver: 7.5.1 + dev: false /jsprim@2.0.2: resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} @@ -21548,6 +21376,7 @@ packages: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + dev: false /jwks-rsa@2.1.5: resolution: {integrity: sha512-IODtn1SwEm7n6GQZnQLY0oxKDrMh7n/jRH1MzE8mlxWMrh2NnMyOsXTebu8vJ1qCpmuTJcL4DdiE0E4h8jnwsA==} @@ -21582,6 +21411,7 @@ packages: dependencies: jwa: 1.4.1 safe-buffer: 5.2.1 + dev: false /kafkajs@2.2.4: resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==} @@ -22170,6 +22000,14 @@ packages: /lru_map@0.3.3: resolution: {integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==} + /lucide-react@0.236.0(react@18.2.0): + resolution: {integrity: sha512-himeKF7nVgOQ1BNcyBgk41E4/rcbmI6Zw8Q4o57nlynsFvIBA/DMacFpzKYdcyBReUj8jf08xTnCGyn/niLvwQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /luxon@3.0.4: resolution: {integrity: sha512-aV48rGUwP/Vydn8HT+5cdr26YYQiUZ42NM6ToMoaGKwYfWbfLeRkEu1wXWMHBZT6+KyLfcbbtVcoQFCbbPjKlw==} engines: {node: '>=12'} @@ -24122,11 +23960,6 @@ packages: minimatch: 3.1.2 dev: true - /node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - dev: true - /node-fetch-native@1.0.2: resolution: {integrity: sha512-KIkvH1jl6b3O7es/0ShyCgWLcfXxlBrLBbP3rOr23WArC66IMcU4DeZEeYEOwnopYhawLTn7/y+YtmASe8DFVQ==} dev: true @@ -28874,6 +28707,18 @@ packages: resolution: {integrity: sha512-qCN98uP7i9z0fIS4amQ5zbGBOq+OSigYeGvPy7NDk8Y9yncqDZ9pRPgfsc2PJIVM9RrJj7GIfuRgmjoUU9zTHQ==} dev: false + /tailwind-merge@1.13.1: + resolution: {integrity: sha512-tRtRN22TDokGi2TuYSvuHQuuW6BJ/zlUEG+iYpAQ9i66msc/0eU/+HPccbPnNNH0mCPp0Ob8thaC8Uy9CxHitQ==} + dev: false + + /tailwindcss-animate@1.0.6(tailwindcss@3.3.2): + resolution: {integrity: sha512-4WigSGMvbl3gCCact62ZvOngA+PRqhAn7si3TQ3/ZuPuQZcIEtVap+ENSXbzWhpojKB8CpvnIsrwBu8/RnHtuw==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + dependencies: + tailwindcss: 3.3.2(ts-node@10.9.1) + dev: true + /tailwindcss-radix@2.8.0: resolution: {integrity: sha512-1k1UfoIYgVyBl13FKwwoKavjnJ5VEaUClCTAsgz3VLquN4ay/lyaMPzkbqD71sACDs2fRGImytAUlMb4TzOt1A==} dev: true @@ -29773,13 +29618,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /undici@5.12.0: - resolution: {integrity: sha512-zMLamCG62PGjd9HHMpo05bSLvvwWOZgGeiWlN/vlqu3+lRo3elxktVGEyLMX+IO7c2eflLjcW74AlkhEZm15mg==} - engines: {node: '>=12.18'} - dependencies: - busboy: 1.6.0 - dev: true - /unfetch@4.2.0: resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==} dev: true @@ -30297,11 +30135,6 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false - /value-or-promise@1.0.11: - resolution: {integrity: sha512-41BrgH+dIbCFXClcSapVs5M6GkENd3gQOJpEfPDNa71LsUGMXDL0jMWpI/Rh7WhX+Aalfz2TTS3Zt5pUsbnhLg==} - engines: {node: '>=12'} - dev: true - /value-or-promise@1.0.12: resolution: {integrity: sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==} engines: {node: '>=12'} @@ -30584,11 +30417,6 @@ packages: resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} engines: {node: '>= 8'} - /web-streams-polyfill@4.0.0-beta.3: - resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} - engines: {node: '>= 14'} - dev: true - /web-worker@1.2.0: resolution: {integrity: sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==} dev: false @@ -30747,10 +30575,6 @@ packages: engines: {node: '>=6'} dev: true - /whatwg-fetch@3.6.2: - resolution: {integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==} - dev: true - /whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -30950,19 +30774,6 @@ packages: utf-8-validate: optional: true - /ws@8.11.0: - resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: true - /ws@8.13.0: resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} engines: {node: '>=10.0.0'}