diff --git a/codegen.yml b/codegen.yml index 6d8e098197..3a02517d5a 100644 --- a/codegen.yml +++ b/codegen.yml @@ -60,6 +60,7 @@ generates: BillingPaymentMethod: 'StripeTypes.PaymentMethod.Card' BillingDetails: 'StripeTypes.PaymentMethod.BillingDetails' BillingInvoice: 'StripeTypes.Invoice' + OrganizationGetStarted: ../shared/entities#OrganizationGetStarted as OrganizationGetStartedMapper plugins: - add: content: "import { StripeTypes } from '@hive/stripe-billing';" diff --git a/integration-tests/testkit/flow.ts b/integration-tests/testkit/flow.ts index 6668e5d9a7..dd871a6a03 100644 --- a/integration-tests/testkit/flow.ts +++ b/integration-tests/testkit/flow.ts @@ -65,6 +65,31 @@ export function createOrganization(input: CreateOrganizationInput, authToken: st }); } +export function getOrganizationGetStartedProgress(organizationId: string, authToken: string) { + return execute({ + document: gql(/* GraphQL */ ` + query getOrganizationGetStartedProgress($organizationId: ID!) { + organization(selector: { organization: $organizationId }) { + organization { + getStarted { + creatingProject + publishingSchema + checkingSchema + invitingMembers + reportingOperations + enablingUsageBasedBreakingChanges + } + } + } + } + `), + authToken, + variables: { + organizationId, + }, + }); +} + export function renameOrganization(input: UpdateOrganizationNameInput, authToken: string) { return execute({ document: gql(/* GraphQL */ ` diff --git a/integration-tests/tests/api/organization/get-started.spec.ts b/integration-tests/tests/api/organization/get-started.spec.ts new file mode 100644 index 0000000000..da2c575ede --- /dev/null +++ b/integration-tests/tests/api/organization/get-started.spec.ts @@ -0,0 +1,229 @@ +import { + createOrganization, + getOrganizationGetStartedProgress, + createProject, + createToken, + publishSchema, + checkSchema, + joinOrganization, + waitFor, + setTargetValidation, +} from '../../../testkit/flow'; +import { authenticate } from '../../../testkit/auth'; +import { collect } from '../../../testkit/usage'; +import { TargetAccessScope, ProjectType, ProjectAccessScope, OrganizationAccessScope } from '@app/gql/graphql'; + +async function getSteps({ organization, token }: { organization: string; token: string }) { + const result = await getOrganizationGetStartedProgress(organization, token); + + expect(result.body.errors).not.toBeDefined(); + + return result.body.data?.organization?.organization.getStarted; +} + +test('freshly created organization has Get Started progress at 0%', async () => { + const { access_token } = await authenticate('main'); + const orgResult = await createOrganization( + { + name: 'foo', + }, + access_token + ); + const org = orgResult.body.data!.createOrganization.ok!.createdOrganizationPayload.organization; + + const steps = await getSteps({ + organization: org.cleanId, + token: access_token, + }); + + expect(steps?.creatingProject).toBe(false); + expect(steps?.publishingSchema).toBe(false); + expect(steps?.checkingSchema).toBe(false); + expect(steps?.invitingMembers).toBe(false); + expect(steps?.reportingOperations).toBe(false); + expect(steps?.enablingUsageBasedBreakingChanges).toBe(false); +}); + +test('completing each step should result in updated Get Started progress', async () => { + const { access_token } = await authenticate('main'); + const orgResult = await createOrganization( + { + name: 'foo', + }, + access_token + ); + const org = orgResult.body.data!.createOrganization.ok!.createdOrganizationPayload.organization; + + // Step: creating project + + const projectResult = await createProject( + { + organization: org.cleanId, + name: 'foo', + type: ProjectType.Single, + }, + access_token + ); + + let steps = await getSteps({ + organization: org.cleanId, + token: access_token, + }); + + expect(steps?.creatingProject).toBe(true); // modified + expect(steps?.publishingSchema).toBe(false); + expect(steps?.checkingSchema).toBe(false); + expect(steps?.invitingMembers).toBe(false); + expect(steps?.reportingOperations).toBe(false); + expect(steps?.enablingUsageBasedBreakingChanges).toBe(false); + + expect(projectResult.body.errors).not.toBeDefined(); + + const target = projectResult.body.data?.createProject.ok?.createdTargets.find(t => t.name === 'production'); + const project = projectResult.body.data?.createProject.ok?.createdProject; + + if (!target || !project) { + throw new Error('Failed to create project'); + } + + const tokenResult = await createToken( + { + name: 'test', + organization: org.cleanId, + project: project.cleanId, + target: target.cleanId, + organizationScopes: [OrganizationAccessScope.Read], + projectScopes: [ProjectAccessScope.Read], + targetScopes: [ + TargetAccessScope.Read, + TargetAccessScope.RegistryRead, + TargetAccessScope.RegistryWrite, + TargetAccessScope.Settings, + ], + }, + access_token + ); + + expect(tokenResult.body.errors).not.toBeDefined(); + + const token = tokenResult.body.data!.createToken.ok!.secret; + + // Step: publishing schema + + await publishSchema( + { + author: 'test', + commit: 'test', + sdl: 'type Query { foo: String }', + }, + token + ); + + steps = await getSteps({ + organization: org.cleanId, + token: access_token, + }); + + expect(steps?.creatingProject).toBe(true); + expect(steps?.publishingSchema).toBe(true); // modified + expect(steps?.checkingSchema).toBe(false); + expect(steps?.invitingMembers).toBe(false); + expect(steps?.reportingOperations).toBe(false); + expect(steps?.enablingUsageBasedBreakingChanges).toBe(false); + + // Step: checking schema + + await checkSchema( + { + sdl: 'type Query { foo: String bar: String }', + }, + token + ); + + steps = await getSteps({ + organization: org.cleanId, + token: access_token, + }); + + expect(steps?.creatingProject).toBe(true); + expect(steps?.publishingSchema).toBe(true); + expect(steps?.checkingSchema).toBe(true); // modified + expect(steps?.invitingMembers).toBe(false); + expect(steps?.reportingOperations).toBe(false); + expect(steps?.enablingUsageBasedBreakingChanges).toBe(false); + + // Step: inviting members + + const { access_token: member_access_token } = await authenticate('extra'); + await joinOrganization(org.inviteCode, member_access_token); + + steps = await getSteps({ + organization: org.cleanId, + token: access_token, + }); + + expect(steps?.creatingProject).toBe(true); + expect(steps?.publishingSchema).toBe(true); + expect(steps?.checkingSchema).toBe(true); + expect(steps?.invitingMembers).toBe(true); // modified + expect(steps?.reportingOperations).toBe(false); + expect(steps?.enablingUsageBasedBreakingChanges).toBe(false); + + // Step: reporting operations + + await collect({ + operations: [ + { + operationName: 'foo', + operation: 'query foo { foo }', + fields: ['Query', 'Query.foo'], + execution: { + duration: 2_000_000, + ok: true, + errorsTotal: 0, + }, + }, + ], + token, + authorizationHeader: 'authorization', + }); + await waitFor(10_000); + + steps = await getSteps({ + organization: org.cleanId, + token: access_token, + }); + + expect(steps?.creatingProject).toBe(true); + expect(steps?.publishingSchema).toBe(true); + expect(steps?.checkingSchema).toBe(true); + expect(steps?.invitingMembers).toBe(true); + expect(steps?.reportingOperations).toBe(true); // modified + expect(steps?.enablingUsageBasedBreakingChanges).toBe(false); + + // Step: reporting operations + + await setTargetValidation( + { + enabled: true, + target: target.cleanId, + project: project.cleanId, + organization: org.cleanId, + }, + { + token, + } + ); + + steps = await getSteps({ + organization: org.cleanId, + token: access_token, + }); + + expect(steps?.creatingProject).toBe(true); + expect(steps?.publishingSchema).toBe(true); + expect(steps?.checkingSchema).toBe(true); + expect(steps?.invitingMembers).toBe(true); + expect(steps?.reportingOperations).toBe(true); + expect(steps?.enablingUsageBasedBreakingChanges).toBe(true); // modified +}); diff --git a/packages/libraries/client/src/version.ts b/packages/libraries/client/src/version.ts index b7190d27ef..2c3072cd9e 100644 --- a/packages/libraries/client/src/version.ts +++ b/packages/libraries/client/src/version.ts @@ -1 +1 @@ -export const version = '0.16.0'; +export const version = '0.17.0'; diff --git a/packages/services/api/src/modules/operations/module.graphql.ts b/packages/services/api/src/modules/operations/module.graphql.ts index 381e34e372..4769254777 100644 --- a/packages/services/api/src/modules/operations/module.graphql.ts +++ b/packages/services/api/src/modules/operations/module.graphql.ts @@ -140,4 +140,8 @@ export default gql` duration: Int! count: SafeInt! } + + extend type OrganizationGetStarted { + reportingOperations: Boolean! + } `; 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 5285896b3b..ad495e26e8 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -6,7 +6,8 @@ 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 { TargetSelector } from '../../shared/providers/storage'; +import type { TargetSelector, OrganizationSelector } from '../../shared/providers/storage'; +import { Storage } from '../../shared/providers/storage'; import { OperationsReader } from './operations-reader'; const DAY_IN_MS = 86_400_000; @@ -57,7 +58,12 @@ interface ReadFieldStatsOutput { export class OperationsManager { private logger: Logger; - constructor(logger: Logger, private authManager: AuthManager, private reader: OperationsReader) { + constructor( + logger: Logger, + private authManager: AuthManager, + private reader: OperationsReader, + private storage: Storage + ) { this.logger = logger.child({ source: 'OperationsManager' }); } @@ -435,4 +441,26 @@ export class OperationsManager { operations, }); } + + async hasOperationsForOrganization(selector: OrganizationSelector): Promise { + const targets = await this.storage.getTargetIdsOfOrganization(selector); + + if (targets.length === 0) { + return false; + } + + const total = await this.reader.countOperationsForTargets({ + targets, + }); + + if (total > 0) { + await this.storage.completeGetStartedStep({ + organization: selector.organization, + step: 'reportingOperations', + }); + return true; + } + + return false; + } } 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 b4ff3e31ef..ffdeccd8df 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -835,6 +835,26 @@ export class OperationsReader { }); } + async countOperationsForTargets({ targets }: { targets: readonly string[] }): Promise { + const result = await this.clickHouse.query<{ + total: string; + }>({ + query: `SELECT sum(total) as total from operations_new_hourly_mv WHERE target IN ('${targets.join(`', '`)}')`, + queryId: 'count_operations_for_targets', + timeout: 15_000, + }); + + if (result.data.length === 0) { + return 0; + } + + if (result.data.length > 1) { + throw new Error('Too many rows returned, expected 1'); + } + + return ensureNumber(result.data[0].total); + } + async adminCountOperationsPerTarget({ daysLimit }: { daysLimit: number }) { const result = await this.clickHouse.query<{ total: string; diff --git a/packages/services/api/src/modules/operations/resolvers.ts b/packages/services/api/src/modules/operations/resolvers.ts index c337c94406..2c9ca31440 100644 --- a/packages/services/api/src/modules/operations/resolvers.ts +++ b/packages/services/api/src/modules/operations/resolvers.ts @@ -246,6 +246,17 @@ export const resolvers: OperationsModule.Resolvers = { }, OperationStatsConnection: createConnection(), ClientStatsConnection: createConnection(), + OrganizationGetStarted: { + reportingOperations(organization, _, { injector }) { + if (organization.reportingOperations === true) { + return organization.reportingOperations; + } + + return injector.get(OperationsManager).hasOperationsForOrganization({ + organization: organization.id, + }); + }, + }, }; function transformPercentile(value: number | null): number { diff --git a/packages/services/api/src/modules/organization/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index 3cd9cab9f8..f94ffab46d 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -91,6 +91,7 @@ export default gql` me: Member! members: MemberConnection! inviteCode: String! + getStarted: OrganizationGetStarted! } type OrganizationConnection { @@ -114,4 +115,12 @@ export default gql` selector: OrganizationSelector! organization: Organization! } + + type OrganizationGetStarted { + creatingProject: Boolean! + publishingSchema: Boolean! + checkingSchema: Boolean! + invitingMembers: Boolean! + enablingUsageBasedBreakingChanges: Boolean! + } `; diff --git a/packages/services/api/src/modules/organization/providers/organization-manager.ts b/packages/services/api/src/modules/organization/providers/organization-manager.ts index 9a27d8ef6a..3384c2acfe 100644 --- a/packages/services/api/src/modules/organization/providers/organization-manager.ts +++ b/packages/services/api/src/modules/organization/providers/organization-manager.ts @@ -374,13 +374,19 @@ export class OrganizationManager { // Because we checked the access before, it's stale by now this.authManager.resetAccessCache(); - await this.activityManager.create({ - type: 'MEMBER_ADDED', - selector: { + await Promise.all([ + this.storage.completeGetStartedStep({ organization: organization.id, - user: user.id, - }, - }); + step: 'invitingMembers', + }), + this.activityManager.create({ + type: 'MEMBER_ADDED', + selector: { + organization: organization.id, + user: user.id, + }, + }), + ]); return this.storage.getOrganization({ organization: organization.id, diff --git a/packages/services/api/src/modules/project/providers/project-manager.ts b/packages/services/api/src/modules/project/providers/project-manager.ts index 1a814af16e..6051359ac4 100644 --- a/packages/services/api/src/modules/project/providers/project-manager.ts +++ b/packages/services/api/src/modules/project/providers/project-manager.ts @@ -63,16 +63,22 @@ export class ProjectManager { validationUrl, }); - await this.activityManager.create({ - type: 'PROJECT_CREATED', - selector: { + await Promise.all([ + this.storage.completeGetStartedStep({ organization, - project: project.id, - }, - meta: { - projectType: type, - }, - }); + step: 'creatingProject', + }), + this.activityManager.create({ + type: 'PROJECT_CREATED', + selector: { + organization, + project: project.id, + }, + meta: { + projectType: type, + }, + }), + ]); return project; } 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 41f8d124ab..d3d410d2fd 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -6,7 +6,7 @@ import { atomic, stringifySelector } from '../../../shared/helpers'; import { HiveError } from '../../../shared/errors'; import { AuthManager } from '../../auth/providers/auth-manager'; import { Logger } from '../../shared/providers/logger'; -import { Storage, TargetSelector } from '../../shared/providers/storage'; +import { Storage, TargetSelector, OrganizationSelector } from '../../shared/providers/storage'; import { CustomOrchestrator } from './orchestrators/custom'; import { FederationOrchestrator } from './orchestrators/federation'; import { SingleOrchestrator } from './orchestrators/single'; @@ -416,4 +416,12 @@ export class SchemaManager { name: input.newName, }); } + + completeGetStartedCheck( + selector: OrganizationSelector & { + step: 'publishingSchema' | 'checkingSchema'; + } + ): Promise { + return this.storage.completeGetStartedStep(selector); + } } diff --git a/packages/services/api/src/modules/schema/providers/schema-publisher.ts b/packages/services/api/src/modules/schema/providers/schema-publisher.ts index 21063cf95d..a124af504b 100644 --- a/packages/services/api/src/modules/schema/providers/schema-publisher.ts +++ b/packages/services/api/src/modules/schema/providers/schema-publisher.ts @@ -96,15 +96,21 @@ export class SchemaPublisher { const schemas = latest.schemas; const isInitialSchema = schemas.length === 0; - await this.tracking.track({ - event: 'SCHEMA_CHECK', - data: { - organization: input.organization, - project: input.project, - target: input.target, - projectType: project.type, - }, - }); + await Promise.all([ + this.schemaManager.completeGetStartedCheck({ + organization: project.orgId, + step: 'checkingSchema', + }), + this.tracking.track({ + event: 'SCHEMA_CHECK', + data: { + organization: input.organization, + project: input.project, + target: input.target, + projectType: project.type, + }, + }), + ]); if (input.github) { await this.tracking.track({ @@ -379,15 +385,21 @@ export class SchemaPublisher { const schemas = latest.schemas; - await this.tracking.track({ - event: 'SCHEMA_PUBLISH', - data: { - organization: organizationId, - project: projectId, - target: targetId, - projectType: project.type, - }, - }); + await Promise.all([ + this.schemaManager.completeGetStartedCheck({ + organization: project.orgId, + step: 'publishingSchema', + }), + this.tracking.track({ + event: 'SCHEMA_PUBLISH', + data: { + organization: organizationId, + project: projectId, + target: targetId, + projectType: project.type, + }, + }), + ]); this.logger.debug(`Found ${schemas.length} most recent schemas`); diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 76cffe101d..a6352b0d75 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -123,6 +123,7 @@ export interface Storage { deleteTarget(_: TargetSelector): Promise; getTarget(_: TargetSelector): Promise; getTargets(_: ProjectSelector): Promise; + getTargetIdsOfOrganization(_: OrganizationSelector): Promise; getTargetSettings(_: TargetSelector): Promise; setTargetValidation(_: TargetSelector & { enabled: boolean }): Promise; updateTargetValidationSettings( @@ -310,6 +311,12 @@ export interface Storage { getBaseSchema(_: TargetSelector): Promise; updateBaseSchema(_: TargetSelector, base: string | null): Promise; + + completeGetStartedStep( + _: OrganizationSelector & { + step: Exclude; + } + ): Promise; } @Injectable() diff --git a/packages/services/api/src/modules/target/providers/target-manager.ts b/packages/services/api/src/modules/target/providers/target-manager.ts index 24973e4321..5a17aeb466 100644 --- a/packages/services/api/src/modules/target/providers/target-manager.ts +++ b/packages/services/api/src/modules/target/providers/target-manager.ts @@ -167,7 +167,7 @@ export class TargetManager { return this.storage.getTargetSettings(selector); } - async setTargetValidaton( + async setTargetValidation( input: { enabled: boolean; } & TargetSelector @@ -178,17 +178,23 @@ export class TargetManager { scope: TargetAccessScope.SETTINGS, }); - await this.tracking.track({ - event: input.enabled ? 'TARGET_VALIDATION_ENABLED' : 'TARGET_VALIDATION_DISABLED', - data: { - ...input, - }, - }); + await Promise.all([ + this.storage.completeGetStartedStep({ + organization: input.organization, + step: 'enablingUsageBasedBreakingChanges', + }), + this.tracking.track({ + event: input.enabled ? 'TARGET_VALIDATION_ENABLED' : 'TARGET_VALIDATION_DISABLED', + data: { + ...input, + }, + }), + ]); return this.storage.setTargetValidation(input); } - async updateTargetValidatonSettings( + async updateTargetValidationSettings( input: Omit & TargetSelector ): Promise { this.logger.debug('Updating target validation settings (input=%o)', input); diff --git a/packages/services/api/src/modules/target/resolvers.ts b/packages/services/api/src/modules/target/resolvers.ts index 9d2d50fe09..ca25fd1681 100644 --- a/packages/services/api/src/modules/target/resolvers.ts +++ b/packages/services/api/src/modules/target/resolvers.ts @@ -206,7 +206,7 @@ export const resolvers: TargetModule.Resolvers = { ]); const targetManager = injector.get(TargetManager); - const settings = await targetManager.setTargetValidaton({ + const settings = await targetManager.setTargetValidation({ organization, project, target, @@ -257,7 +257,7 @@ export const resolvers: TargetModule.Resolvers = { } const targetManager = injector.get(TargetManager); - const settings = await targetManager.updateTargetValidatonSettings({ + const settings = await targetManager.updateTargetValidationSettings({ period: input.period, percentage: input.percentage, target, diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index f2907b10df..a91c9d372c 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -93,6 +93,16 @@ export enum OrganizationType { REGULAR = 'REGULAR', } +export interface OrganizationGetStarted { + id: string; + creatingProject: boolean; + publishingSchema: boolean; + checkingSchema: boolean; + invitingMembers: boolean; + reportingOperations: boolean; + enablingUsageBasedBreakingChanges: boolean; +} + export interface Organization { id: string; cleanId: string; @@ -105,6 +115,7 @@ export interface Organization { operations: number; schemaPush: number; }; + getStarted: OrganizationGetStarted; } export interface OrganizationBilling { diff --git a/packages/services/storage/migrations/actions/2022.07.11T10.09.41.get-started-wizard.sql b/packages/services/storage/migrations/actions/2022.07.11T10.09.41.get-started-wizard.sql new file mode 100644 index 0000000000..908a761386 --- /dev/null +++ b/packages/services/storage/migrations/actions/2022.07.11T10.09.41.get-started-wizard.sql @@ -0,0 +1,40 @@ +-- Tracks feature discovery progress + +ALTER table public.organizations + ADD COLUMN get_started_creating_project BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN get_started_publishing_schema BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN get_started_checking_schema BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN get_started_inviting_members BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN get_started_reporting_operations BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN get_started_usage_breaking BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE public.organizations + SET get_started_creating_project = TRUE + WHERE id IN (SELECT org_id FROM public.projects GROUP BY org_id); + +UPDATE public.organizations +SET get_started_publishing_schema = TRUE + WHERE id IN ( + SELECT p.org_id + FROM public.commits as c + INNER JOIN public.projects as p ON p.id = c.project_id + GROUP BY p.org_id +); + +UPDATE public.organizations +SET get_started_inviting_members = TRUE + WHERE id IN ( + SELECT organization_id + FROM public.organization_member + GROUP BY organization_id + HAVING COUNT(user_id) > 1 + ); + +UPDATE public.organizations +SET get_started_usage_breaking = TRUE + WHERE id IN ( + SELECT p.org_id + FROM public.targets as t + INNER JOIN public.projects as p ON p.id = t.project_id + WHERE t.validation_enabled IS TRUE + ); diff --git a/packages/services/storage/migrations/actions/down/2022.07.11T10.09.41.get-started-wizard.sql b/packages/services/storage/migrations/actions/down/2022.07.11T10.09.41.get-started-wizard.sql new file mode 100644 index 0000000000..8672889c03 --- /dev/null +++ b/packages/services/storage/migrations/actions/down/2022.07.11T10.09.41.get-started-wizard.sql @@ -0,0 +1,7 @@ +ALTER TABLE public.commits + DROP COLUMN get_started_creating_project, + DROP COLUMN get_started_publishing_schema, + DROP COLUMN get_started_checking_schema, + DROP COLUMN get_started_inviting_members, + DROP COLUMN get_started_reporting_operations, + DROP COLUMN get_started_usage_breaking; diff --git a/packages/services/storage/package.json b/packages/services/storage/package.json index 2180cde737..4331329479 100644 --- a/packages/services/storage/package.json +++ b/packages/services/storage/package.json @@ -25,7 +25,8 @@ "dotenv": "10.0.0", "got": "12.0.4", "slonik": "24.1.2", - "slonik-interceptor-query-logging": "1.3.9" + "slonik-interceptor-query-logging": "1.3.9", + "slonik-utilities": "1.9.2" }, "devDependencies": { "@tgriesser/schemats": "7.0.0", diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index e3af6bcfbf..09c8763ceb 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -72,6 +72,12 @@ export interface organization_member { export interface organizations { clean_id: string; created_at: Date; + get_started_checking_schema: boolean; + get_started_creating_project: boolean; + get_started_inviting_members: boolean; + get_started_publishing_schema: boolean; + get_started_reporting_operations: boolean; + get_started_usage_breaking: boolean; github_app_installation_id: string | null; id: string; invite_code: string; diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index a0f43d1535..df0e6ce844 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -18,6 +18,7 @@ import type { OrganizationType, } from '@hive/api'; import { sql, TaggedTemplateLiteralInvocationType } from 'slonik'; +import { update } from 'slonik-utilities'; import { commits, getPool, @@ -48,6 +49,15 @@ export type WithMaybeMetadata = T & { metadata?: string | null; }; +const organizationGetStartedMapping: Record, keyof organizations> = { + creatingProject: 'get_started_creating_project', + publishingSchema: 'get_started_publishing_schema', + checkingSchema: 'get_started_checking_schema', + invitingMembers: 'get_started_inviting_members', + reportingOperations: 'get_started_reporting_operations', + enablingUsageBasedBreakingChanges: 'get_started_usage_breaking', +}; + function getProviderBasedOnExternalId(externalId: string): AuthProvider { if (externalId.startsWith('github')) { return 'GITHUB'; @@ -100,6 +110,15 @@ export async function createStorage(connection: string): Promise { }, billingPlan: organization.plan_name, type: (organization.type === 'PERSONAL' ? 'PERSONAL' : 'REGULAR') as OrganizationType, + getStarted: { + id: organization.id, + creatingProject: organization.get_started_creating_project, + publishingSchema: organization.get_started_publishing_schema, + checkingSchema: organization.get_started_checking_schema, + invitingMembers: organization.get_started_inviting_members, + reportingOperations: organization.get_started_reporting_operations, + enablingUsageBasedBreakingChanges: organization.get_started_usage_breaking, + }, }; } @@ -753,6 +772,18 @@ export async function createStorage(connection: string): Promise { return results.rows.map(r => transformTarget(r, organization)); }, + async getTargetIdsOfOrganization({ organization }) { + const results = await pool.query>>( + sql` + SELECT t.id as id FROM public.targets as t + LEFT JOIN public.projects as p ON (p.id = t.project_id) + WHERE p.org_id = ${organization} + GROUP BY t.id + ` + ); + + return results.rows.map(r => r.id); + }, async getTargetSettings({ target, project }) { const row = await pool.one< Pick & { @@ -1777,6 +1808,18 @@ export async function createStorage(connection: string): Promise { ) ); }, + async completeGetStartedStep({ organization, step }) { + await update( + pool, + 'organizations', + { + [organizationGetStartedMapping[step]]: true, + }, + { + id: organization, + } + ); + }, }; return storage; diff --git a/packages/web/app/src/components/get-started/wizard.tsx b/packages/web/app/src/components/get-started/wizard.tsx new file mode 100644 index 0000000000..788a55ac16 --- /dev/null +++ b/packages/web/app/src/components/get-started/wizard.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { VscIssues, VscError } from 'react-icons/vsc'; +import { + useDisclosure, + Drawer, + DrawerBody, + DrawerHeader, + DrawerOverlay, + DrawerContent, + DrawerCloseButton, +} from '@chakra-ui/react'; +import clsx from 'clsx'; +import { OrganizationType } from '@/graphql'; +import { gql, DocumentType } from 'urql'; + +const GetStartedWizard_GetStartedProgress = gql(/* GraphQL */ ` + fragment GetStartedWizard_GetStartedProgress on OrganizationGetStarted { + creatingProject + publishingSchema + checkingSchema + invitingMembers + reportingOperations + enablingUsageBasedBreakingChanges + } +`); + +export function GetStartedProgress({ + tasks, + organizationType, +}: { + tasks: DocumentType; + organizationType: OrganizationType; +}) { + const { isOpen, onOpen, onClose } = useDisclosure(); + const triggerRef = React.useRef(null); + + if (!tasks) { + return null; + } + + const processedTasks = + organizationType === OrganizationType.Personal + ? { + ...tasks, + invitingMembers: undefined, + } + : tasks; + const values = Object.values(processedTasks).filter(v => typeof v === 'boolean'); + const total = values.length; + const completed = values.filter(t => t === true).length; + const remaining = total - completed; + + if (remaining === 0) { + return null; + } + + return ( + <> + + + + ); +} + +function GetStartedWizard({ + isOpen, + onClose, + triggerRef, + tasks, +}: { + isOpen: boolean; + onClose(): void; + triggerRef: React.RefObject; + tasks: + | DocumentType + | Omit, 'invitingMembers'>; +}) { + return ( + + + + + Get Started + +

Complete these steps to experience the full power of GraphQL Hive

+
+ + Create a project + + + Publish a schema + + + Check a schema + + {'invitingMembers' in tasks && typeof tasks.invitingMembers === 'boolean' ? ( + + Invite members + + ) : null} + + Report operations + + + Enable usage-based breaking changes + +
+
+
+
+ ); +} + +function Task({ + completed, + children, + link, +}: React.PropsWithChildren<{ + completed: boolean; + link: string; +}>) { + return ( + + {completed ? ( + + ) : ( + + )} + {children} + + ); +} diff --git a/packages/web/app/src/components/v2/header.tsx b/packages/web/app/src/components/v2/header.tsx index de6521ded2..61c16bfbe2 100644 --- a/packages/web/app/src/components/v2/header.tsx +++ b/packages/web/app/src/components/v2/header.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx'; import { useQuery } from 'urql'; import { useUser } from '@/components/auth/AuthProvider'; +import { GetStartedProgress } from '@/components/get-started/wizard'; import { Avatar, Button, DropdownMenu, HiveLink } from '@/components/v2'; import { AlertTriangleIcon, @@ -19,6 +20,7 @@ import { } from '@/components/v2/icon'; import { CreateOrganizationModal } from '@/components/v2/modals'; import { MeDocument, OrganizationsDocument, OrganizationsQuery, OrganizationType } from '@/graphql'; +import { useRouteSelector } from '@/lib/hooks/use-route-selector'; import { ManagerRoleGuard } from '../auth/ManagerRoleGuard'; type DropdownOrganization = OrganizationsQuery['organizations']['nodes']; @@ -32,6 +34,7 @@ const OrganizationLink = (props: { children: string; href: string }): ReactEleme }; export const Header = (): ReactElement => { + const router = useRouteSelector(); const [meQuery] = useQuery({ query: MeDocument }); const { user } = useUser(); const [organizationsQuery] = useQuery({ query: OrganizationsDocument }); @@ -42,7 +45,8 @@ export const Header = (): ReactElement => { }, []); const me = meQuery.data?.me; - const { personal, organizations } = (organizationsQuery.data?.organizations.nodes || []).reduce<{ + const allOrgs = organizationsQuery.data?.organizations.nodes || []; + const { personal, organizations } = allOrgs.reduce<{ personal: DropdownOrganization; organizations: DropdownOrganization; }>( @@ -57,6 +61,9 @@ export const Header = (): ReactElement => { { personal: [], organizations: [] } ); + const currentOrg = + typeof router.organizationId === 'string' ? allOrgs.find(org => org.cleanId === router.organizationId) : null; + // Copied from tailwindcss website // https://github.com/tailwindlabs/tailwindcss.com/blob/94971856747c159b4896621c3308bcfa629bb736/src/components/Header.js#L149 useEffect(() => { @@ -85,106 +92,111 @@ export const Header = (): ReactElement => { >
- - - - +
+ {currentOrg ? : null} + + + + - - {me?.displayName} - - - - Switch organization - - - - PERSONAL - {personal.map(org => ( - - {org.name} - - ))} - - OUTERS ORGANIZATIONS - - {organizations.map(org => ( - - {org.name} + + + {me?.displayName} + + + + + Switch organization + + + + PERSONAL + {personal.map(org => ( + + {org.name} + + ))} + + OUTERS ORGANIZATIONS + + {organizations.map(org => ( + + {org.name} + + ))} + + + + Create an organization - ))} - - - - Create an organization - - - - - - - Schedule a meeting - - + + + + + + Schedule a meeting + + - - - - - Profile settings - - - - - - - Documentation - - - - - - Status page - - - {/* TODO: Light mode will be available after releasing */} - {/**/} - {/* */} - {/* Switch Light Theme*/} - {/**/} - {user?.metadata?.admin && ( - - + + + + + Profile settings + + + + + + + Documentation + + + + + + Status page + + + {/* TODO: Light mode will be available after releasing */} + {/**/} + {/* */} + {/* Switch Light Theme*/} + {/**/} + {user?.metadata?.admin && ( + + + + + + Manage Instance + + + + + )} + {process.env.NODE_ENV === 'development' && ( + - - Manage Instance + + Dev GraphiQL - - )} - {process.env.NODE_ENV === 'development' && ( - - - - - Dev GraphiQL - + )} + + + + Log out - - )} - - - - Log out - - - - + + + +
diff --git a/packages/web/app/src/graphql/fragments.graphql b/packages/web/app/src/graphql/fragments.graphql index 3cb7c91d60..1fd4a262a4 100644 --- a/packages/web/app/src/graphql/fragments.graphql +++ b/packages/web/app/src/graphql/fragments.graphql @@ -7,6 +7,9 @@ fragment OrganizationFields on Organization { me { ...MemberFields } + getStarted { + ...GetStartedWizard_GetStartedProgress + } } fragment OrganizationEssentials on Organization { diff --git a/yarn.lock b/yarn.lock index 9f60ce9340..dd5ac23541 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7341,6 +7341,11 @@ core-js-pure@^3.20.2: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.22.5.tgz#bdee0ed2f9b78f2862cda4338a07b13a49b6c9a9" integrity sha512-8xo9R00iYD7TcV7OrC98GwxiUEAabVWO3dix+uyWjnYrx9fyASLlIX+f/3p5dW5qByaP2bcZ8X/T47s55et/tA== +core-js@^3.20.0: + version "3.23.4" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.23.4.tgz#92d640faa7f48b90bbd5da239986602cfc402aa6" + integrity sha512-vjsKqRc1RyAJC3Ye2kYqgfdThb3zYnx9CrqoCcjMOENMtQPC7ZViBvlDxwYU/2z2NI/IPuiXw5mT4hWhddqjzQ== + core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -7811,6 +7816,11 @@ degenerator@^3.0.1: esprima "^4.0.0" vm2 "^3.9.3" +delay@^4.3.0: + version "4.4.1" + resolved "https://registry.yarnpkg.com/delay/-/delay-4.4.1.tgz#6e02d02946a1b6ab98b39262ced965acba2ac4d1" + integrity sha512-aL3AhqtfhOlT/3ai6sWXeqwnw63ATNpnUiN4HL7x9q+My5QtHlO3OIkasmug9LKzpheLdmUKGRKnYXYAS7FQkQ== + delay@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/delay/-/delay-5.0.0.tgz#137045ef1b96e5071060dd5be60bf9334436bd1d" @@ -8850,7 +8860,7 @@ fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-sta resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== -fast-json-stringify@^2.5.2, fast-json-stringify@^2.7.9: +fast-json-stringify@^2.5.2, fast-json-stringify@^2.7.10, fast-json-stringify@^2.7.9: version "2.7.13" resolved "https://registry.yarnpkg.com/fast-json-stringify/-/fast-json-stringify-2.7.13.tgz#277aa86c2acba4d9851bd6108ed657aa327ed8c0" integrity sha512-ar+hQ4+OIurUGjSJD1anvYSDcUflywhKjfxnsW4TBTD7+u0tJufv6DKRWoQk3vI6YBOWMoz0TQtfbe7dxbQmvA== @@ -8889,7 +8899,7 @@ fast-redact@^3.0.0: resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.1.1.tgz#790fcff8f808c2e12fabbfb2be5cb2deda448fa0" integrity sha512-odVmjC8x8jNeMZ3C+rPMESzXVSEU8tSWSHv9HFxP2mm89G/1WwqhrerJDQm9Zus8X6aoRgQDThKqptdNA6bt+A== -fast-safe-stringify@^2.0.7, fast-safe-stringify@^2.0.8: +fast-safe-stringify@^2.0.7, fast-safe-stringify@^2.0.8, fast-safe-stringify@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== @@ -15855,6 +15865,18 @@ roarr@^7.0.3: json-stringify-safe "^5.0.1" semver-compare "^1.0.0" +roarr@^7.11.0: + version "7.11.0" + resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.11.0.tgz#010a0ef39bbb8317fd28fcfb337cb8a3e56f3de2" + integrity sha512-DKiMaEYHoOZ0JyD4Ohr5KRnqybQ162s3ZL/WNO9oy6EUszYvpp0eLYJErc/U4NI96HYnHsbROhFaH4LYuJPnDg== + dependencies: + boolean "^3.1.4" + fast-json-stringify "^2.7.10" + fast-printf "^1.6.9" + fast-safe-stringify "^2.1.1" + globalthis "^1.0.2" + semver-compare "^1.0.0" + rollup-plugin-generate-package-json@3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/rollup-plugin-generate-package-json/-/rollup-plugin-generate-package-json-3.2.0.tgz#e9c1d358f2be6c58b49853af58205292d45a33ff" @@ -16224,6 +16246,18 @@ slonik-interceptor-query-logging@1.3.9: pretty-ms "^6.0.0" serialize-error "^5.0.0" +slonik-utilities@1.9.2: + version "1.9.2" + resolved "https://registry.yarnpkg.com/slonik-utilities/-/slonik-utilities-1.9.2.tgz#224336a723913c5bd4cf208c9f1c5b54816f3684" + integrity sha512-u9DiHRqrZViqfmtgm3YPZIPa69JXTN24NXkxHARQ94O2F7OrG2HMfFZrzjz0u9CT1Q8gtHULyG09Y1Hqz/xzJA== + dependencies: + core-js "^3.20.0" + delay "^4.3.0" + es6-error "^4.1.1" + lodash "^4.17.21" + roarr "^7.11.0" + serialize-error "^5.0.0" + slonik@23.9.0: version "23.9.0" resolved "https://registry.yarnpkg.com/slonik/-/slonik-23.9.0.tgz#5fb5b04d714758f360c6a0cf184400ba49d0f845"