diff --git a/build-webapp.sh b/build-webapp.sh new file mode 100755 index 00000000..e3e8db99 --- /dev/null +++ b/build-webapp.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +PKG_DIR="./packages/frontend" +OUTPUT_DIR="${PKG_DIR}/build" +DEST_DIR=${1:-/data} + +if [[ -d "$DEST_DIR" ]]; then + echo "${DEST_DIR} already exists." 1>&2 + exit 1 +fi + +cat > $PKG_DIR/.env <&2 + exit 1 +fi + +mv "$OUTPUT_DIR" "$DEST_DIR" diff --git a/packages/backend/.eslintrc.json b/packages/backend/.eslintrc.json index da3b01c6..c7012cab 100644 --- a/packages/backend/.eslintrc.json +++ b/packages/backend/.eslintrc.json @@ -25,6 +25,9 @@ "allowArgumentsExplicitlyTypedAsAny": true } ], - "@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }] + "@typescript-eslint/no-unused-vars": [ + "error", + { "ignoreRestSiblings": true } + ] } } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 1a98a657..55c4f773 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -13,7 +13,7 @@ export interface GitHubConfig { oAuth: { clientId: string; clientSecret: string; - } + }; } export interface RegistryConfig { @@ -27,7 +27,7 @@ export interface RegistryConfig { amount: string; denom: string; gas: string; - } + }; } export interface Config { diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 7803a7b1..916d2004 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -1,8 +1,14 @@ -export const DEFAULT_CONFIG_FILE_PATH = 'environments/local.toml'; +import process from 'process'; + +export const DEFAULT_CONFIG_FILE_PATH = + process.env.SNOWBALL_BACKEND_CONFIG_FILE_PATH || 'environments/local.toml'; export const DEFAULT_GQL_PATH = '/graphql'; // Note: temporary hardcoded user, later to be derived from auth token -export const USER_ID = '59f4355d-9549-4aac-9b54-eeefceeabef0'; +export const USER_ID = + process.env.SNOWBALL_BACKEND_USER_ID || + '59f4355d-9549-4aac-9b54-eeefceeabef0'; -export const PROJECT_DOMAIN = 'snowball.xyz'; +export const PROJECT_DOMAIN = + process.env.SNOWBALL_BACKEND_PROJECT_DOMAIN || 'snowball.xyz'; diff --git a/packages/backend/src/database.ts b/packages/backend/src/database.ts index a45c354d..324051a7 100644 --- a/packages/backend/src/database.ts +++ b/packages/backend/src/database.ts @@ -1,4 +1,10 @@ -import { DataSource, DeepPartial, FindManyOptions, FindOneOptions, FindOptionsWhere } from 'typeorm'; +import { + DataSource, + DeepPartial, + FindManyOptions, + FindOneOptions, + FindOptionsWhere +} from 'typeorm'; import path from 'path'; import debug from 'debug'; import assert from 'assert'; @@ -14,6 +20,12 @@ import { ProjectMember } from './entity/ProjectMember'; import { EnvironmentVariable } from './entity/EnvironmentVariable'; import { Domain } from './entity/Domain'; import { PROJECT_DOMAIN } from './constants'; +import { getEntities, loadAndSaveData } from './utils'; +import { UserOrganization } from './entity/UserOrganization'; + +const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json'; +const USER_DATA_PATH = '../test/fixtures/users.json'; +const USER_ORGANIZATION_DATA_PATH = '../test/fixtures/user-organizations.json'; const log = debug('snowball:database'); @@ -36,6 +48,40 @@ export class Database { async init (): Promise { await this.dataSource.initialize(); log('database initialized'); + + const organizations = await this.getOrganizations({}); + + if (!organizations.length) { + const orgEntities = await getEntities( + path.resolve(__dirname, ORGANIZATION_DATA_PATH) + ); + const savedOrgs = await loadAndSaveData(Organization, this.dataSource, [ + orgEntities[0] + ]); + + // TODO: Remove user once authenticated + const userEntities = await getEntities( + path.resolve(__dirname, USER_DATA_PATH) + ); + const savedUsers = await loadAndSaveData(User, this.dataSource, [ + userEntities[0] + ]); + + const userOrganizationRelations = { + member: savedUsers, + organization: savedOrgs + }; + + const userOrgEntities = await getEntities( + path.resolve(__dirname, USER_ORGANIZATION_DATA_PATH) + ); + await loadAndSaveData( + UserOrganization, + this.dataSource, + [userOrgEntities[0]], + userOrganizationRelations + ); + } } async getUser (options: FindOneOptions): Promise { @@ -60,7 +106,18 @@ export class Database { return updateResult.affected > 0; } - async getOrganization (options: FindOneOptions): Promise { + async getOrganizations ( + options: FindManyOptions + ): Promise { + const organizationRepository = this.dataSource.getRepository(Organization); + const organizations = await organizationRepository.find(options); + + return organizations; + } + + async getOrganization ( + options: FindOneOptions + ): Promise { const organizationRepository = this.dataSource.getRepository(Organization); const organization = await organizationRepository.findOne(options); @@ -95,7 +152,11 @@ export class Database { const project = await projectRepository .createQueryBuilder('project') - .leftJoinAndSelect('project.deployments', 'deployments', 'deployments.isCurrent = true') + .leftJoinAndSelect( + 'project.deployments', + 'deployments', + 'deployments.isCurrent = true' + ) .leftJoinAndSelect('deployments.createdBy', 'user') .leftJoinAndSelect('deployments.domain', 'domain') .leftJoinAndSelect('project.owner', 'owner') @@ -108,19 +169,29 @@ export class Database { return project; } - async getProjectsInOrganization (userId: string, organizationSlug: string): Promise { + async getProjectsInOrganization ( + userId: string, + organizationSlug: string + ): Promise { const projectRepository = this.dataSource.getRepository(Project); const projects = await projectRepository .createQueryBuilder('project') - .leftJoinAndSelect('project.deployments', 'deployments', 'deployments.isCurrent = true') + .leftJoinAndSelect( + 'project.deployments', + 'deployments', + 'deployments.isCurrent = true' + ) .leftJoinAndSelect('deployments.domain', 'domain') .leftJoin('project.projectMembers', 'projectMembers') .leftJoin('project.organization', 'organization') - .where('(project.ownerId = :userId OR projectMembers.userId = :userId) AND organization.slug = :organizationSlug', { - userId, - organizationSlug - }) + .where( + '(project.ownerId = :userId OR projectMembers.userId = :userId) AND organization.slug = :organizationSlug', + { + userId, + organizationSlug + } + ) .getMany(); return projects; @@ -129,7 +200,9 @@ export class Database { /** * Get deployments with specified filter */ - async getDeployments (options: FindManyOptions): Promise { + async getDeployments ( + options: FindManyOptions + ): Promise { const deploymentRepository = this.dataSource.getRepository(Deployment); const deployments = await deploymentRepository.find(options); @@ -154,7 +227,9 @@ export class Database { }); } - async getDeployment (options: FindOneOptions): Promise { + async getDeployment ( + options: FindOneOptions + ): Promise { const deploymentRepository = this.dataSource.getRepository(Deployment); const deployment = await deploymentRepository.findOne(options); @@ -182,8 +257,11 @@ export class Database { return deployment; } - async getProjectMembersByProjectId (projectId: string): Promise { - const projectMemberRepository = this.dataSource.getRepository(ProjectMember); + async getProjectMembersByProjectId ( + projectId: string + ): Promise { + const projectMemberRepository = + this.dataSource.getRepository(ProjectMember); const projectMembers = await projectMemberRepository.find({ relations: { @@ -200,8 +278,12 @@ export class Database { return projectMembers; } - async getEnvironmentVariablesByProjectId (projectId: string, filter?: FindOptionsWhere): Promise { - const environmentVariableRepository = this.dataSource.getRepository(EnvironmentVariable); + async getEnvironmentVariablesByProjectId ( + projectId: string, + filter?: FindOptionsWhere + ): Promise { + const environmentVariableRepository = + this.dataSource.getRepository(EnvironmentVariable); const environmentVariables = await environmentVariableRepository.find({ where: { @@ -216,9 +298,12 @@ export class Database { } async removeProjectMemberById (projectMemberId: string): Promise { - const projectMemberRepository = this.dataSource.getRepository(ProjectMember); + const projectMemberRepository = + this.dataSource.getRepository(ProjectMember); - const deleteResult = await projectMemberRepository.delete({ id: projectMemberId }); + const deleteResult = await projectMemberRepository.delete({ + id: projectMemberId + }); if (deleteResult.affected) { return deleteResult.affected > 0; @@ -227,37 +312,63 @@ export class Database { } } - async updateProjectMemberById (projectMemberId: string, data: DeepPartial): Promise { - const projectMemberRepository = this.dataSource.getRepository(ProjectMember); - const updateResult = await projectMemberRepository.update({ id: projectMemberId }, data); + async updateProjectMemberById ( + projectMemberId: string, + data: DeepPartial + ): Promise { + const projectMemberRepository = + this.dataSource.getRepository(ProjectMember); + const updateResult = await projectMemberRepository.update( + { id: projectMemberId }, + data + ); return Boolean(updateResult.affected); } - async addProjectMember (data: DeepPartial): Promise { - const projectMemberRepository = this.dataSource.getRepository(ProjectMember); + async addProjectMember ( + data: DeepPartial + ): Promise { + const projectMemberRepository = + this.dataSource.getRepository(ProjectMember); const newProjectMember = await projectMemberRepository.save(data); return newProjectMember; } - async addEnvironmentVariables (data: DeepPartial[]): Promise { - const environmentVariableRepository = this.dataSource.getRepository(EnvironmentVariable); - const savedEnvironmentVariables = await environmentVariableRepository.save(data); + async addEnvironmentVariables ( + data: DeepPartial[] + ): Promise { + const environmentVariableRepository = + this.dataSource.getRepository(EnvironmentVariable); + const savedEnvironmentVariables = + await environmentVariableRepository.save(data); return savedEnvironmentVariables; } - async updateEnvironmentVariable (environmentVariableId: string, data: DeepPartial): Promise { - const environmentVariableRepository = this.dataSource.getRepository(EnvironmentVariable); - const updateResult = await environmentVariableRepository.update({ id: environmentVariableId }, data); + async updateEnvironmentVariable ( + environmentVariableId: string, + data: DeepPartial + ): Promise { + const environmentVariableRepository = + this.dataSource.getRepository(EnvironmentVariable); + const updateResult = await environmentVariableRepository.update( + { id: environmentVariableId }, + data + ); return Boolean(updateResult.affected); } - async deleteEnvironmentVariable (environmentVariableId: string): Promise { - const environmentVariableRepository = this.dataSource.getRepository(EnvironmentVariable); - const deleteResult = await environmentVariableRepository.delete({ id: environmentVariableId }); + async deleteEnvironmentVariable ( + environmentVariableId: string + ): Promise { + const environmentVariableRepository = + this.dataSource.getRepository(EnvironmentVariable); + const deleteResult = await environmentVariableRepository.delete({ + id: environmentVariableId + }); if (deleteResult.affected) { return deleteResult.affected > 0; @@ -267,7 +378,8 @@ export class Database { } async getProjectMemberById (projectMemberId: string): Promise { - const projectMemberRepository = this.dataSource.getRepository(ProjectMember); + const projectMemberRepository = + this.dataSource.getRepository(ProjectMember); const projectMemberWithProject = await projectMemberRepository.find({ relations: { @@ -279,8 +391,7 @@ export class Database { where: { id: projectMemberId } - } - ); + }); if (projectMemberWithProject.length === 0) { throw new Error('Member does not exist'); @@ -289,34 +400,49 @@ export class Database { return projectMemberWithProject[0]; } - async getProjectsBySearchText (userId: string, searchText: string): Promise { + async getProjectsBySearchText ( + userId: string, + searchText: string + ): Promise { const projectRepository = this.dataSource.getRepository(Project); const projects = await projectRepository .createQueryBuilder('project') .leftJoinAndSelect('project.organization', 'organization') .leftJoin('project.projectMembers', 'projectMembers') - .where('(project.owner = :userId OR projectMembers.member.id = :userId) AND project.name LIKE :searchText', { - userId, - searchText: `%${searchText}%` - }) + .where( + '(project.owner = :userId OR projectMembers.member.id = :userId) AND project.name LIKE :searchText', + { + userId, + searchText: `%${searchText}%` + } + ) .getMany(); return projects; } - async updateDeploymentById (deploymentId: string, data: DeepPartial): Promise { + async updateDeploymentById ( + deploymentId: string, + data: DeepPartial + ): Promise { return this.updateDeployment({ id: deploymentId }, data); } - async updateDeployment (criteria: FindOptionsWhere, data: DeepPartial): Promise { + async updateDeployment ( + criteria: FindOptionsWhere, + data: DeepPartial + ): Promise { const deploymentRepository = this.dataSource.getRepository(Deployment); const updateResult = await deploymentRepository.update(criteria, data); return Boolean(updateResult.affected); } - async updateDeploymentsByProjectIds (projectIds: string[], data: DeepPartial): Promise { + async updateDeploymentsByProjectIds ( + projectIds: string[], + data: DeepPartial + ): Promise { const deploymentRepository = this.dataSource.getRepository(Deployment); const updateResult = await deploymentRepository @@ -329,7 +455,11 @@ export class Database { return Boolean(updateResult.affected); } - async addProject (userId: string, organizationId: string, data: DeepPartial): Promise { + async addProject ( + userId: string, + organizationId: string, + data: DeepPartial + ): Promise { const projectRepository = this.dataSource.getRepository(Project); // TODO: Check if organization exists @@ -352,9 +482,15 @@ export class Database { return projectRepository.save(newProject); } - async updateProjectById (projectId: string, data: DeepPartial): Promise { + async updateProjectById ( + projectId: string, + data: DeepPartial + ): Promise { const projectRepository = this.dataSource.getRepository(Project); - const updateResult = await projectRepository.update({ id: projectId }, data); + const updateResult = await projectRepository.update( + { id: projectId }, + data + ); return Boolean(updateResult.affected); } @@ -401,14 +537,20 @@ export class Database { return domain; } - async updateDomainById (domainId: string, data: DeepPartial): Promise { + async updateDomainById ( + domainId: string, + data: DeepPartial + ): Promise { const domainRepository = this.dataSource.getRepository(Domain); const updateResult = await domainRepository.update({ id: domainId }, data); return Boolean(updateResult.affected); } - async getDomainsByProjectId (projectId: string, filter?: FindOptionsWhere): Promise { + async getDomainsByProjectId ( + projectId: string, + filter?: FindOptionsWhere + ): Promise { const domainRepository = this.dataSource.getRepository(Domain); const domains = await domainRepository.find({ diff --git a/packages/backend/src/entity/Deployment.ts b/packages/backend/src/entity/Deployment.ts index 62d3f38f..b8397a31 100644 --- a/packages/backend/src/entity/Deployment.ts +++ b/packages/backend/src/entity/Deployment.ts @@ -26,18 +26,27 @@ export enum DeploymentStatus { Error = 'Error', } +export interface ApplicationDeploymentRequest { + type: string; + version: string; + name: string; + application: string; + config: string; + meta: string; +} + export interface ApplicationRecord { type: string; - version:string - name: string - description?: string - homepage?: string - license?: string - author?: string - repository?: string[], - app_version?: string - repository_ref: string - app_type: string + version: string; + name: string; + description?: string; + homepage?: string; + license?: string; + author?: string; + repository?: string[]; + app_version?: string; + repository_ref: string; + app_type: string; } @Entity() @@ -78,6 +87,12 @@ export class Deployment { @Column('simple-json') applicationRecordData!: ApplicationRecord; + @Column('varchar') + applicationDeploymentRequestId!: string; + + @Column('simple-json') + applicationDeploymentRequestData!: ApplicationDeploymentRequest; + @Column('varchar', { nullable: true }) applicationDeploymentRecordId!: string | null; diff --git a/packages/backend/src/entity/Domain.ts b/packages/backend/src/entity/Domain.ts index 29f189ff..c662daff 100644 --- a/packages/backend/src/entity/Domain.ts +++ b/packages/backend/src/entity/Domain.ts @@ -39,7 +39,7 @@ export class Domain { @ManyToOne(() => Domain) @JoinColumn({ name: 'redirectToId' }) - // eslint-disable-next-line no-use-before-define + // eslint-disable-next-line no-use-before-define redirectTo!: Domain | null; @Column({ diff --git a/packages/backend/src/entity/Organization.ts b/packages/backend/src/entity/Organization.ts index 15ff1011..3b15522d 100644 --- a/packages/backend/src/entity/Organization.ts +++ b/packages/backend/src/entity/Organization.ts @@ -27,8 +27,12 @@ export class Organization { @UpdateDateColumn() updatedAt!: Date; - @OneToMany(() => UserOrganization, userOrganization => userOrganization.organization, { - cascade: ['soft-remove'] - }) + @OneToMany( + () => UserOrganization, + (userOrganization) => userOrganization.organization, + { + cascade: ['soft-remove'] + } + ) userOrganizations!: UserOrganization[]; } diff --git a/packages/backend/src/entity/Project.ts b/packages/backend/src/entity/Project.ts index 86fa7b3d..b46c219a 100644 --- a/packages/backend/src/entity/Project.ts +++ b/packages/backend/src/entity/Project.ts @@ -15,15 +15,6 @@ import { Organization } from './Organization'; import { ProjectMember } from './ProjectMember'; import { Deployment } from './Deployment'; -export interface ApplicationDeploymentRequest { - type: string - version: string - name: string - application: string - config: string, - meta: string -} - @Entity() export class Project { @PrimaryGeneratedColumn('uuid') @@ -52,12 +43,6 @@ export class Project { @Column('varchar', { length: 255, default: 'main' }) prodBranch!: string; - @Column('varchar', { nullable: true }) - applicationDeploymentRequestId!: string | null; - - @Column('simple-json', { nullable: true }) - applicationDeploymentRequestData!: ApplicationDeploymentRequest | null; - @Column('text', { default: '' }) description!: string; @@ -91,7 +76,7 @@ export class Project { @OneToMany(() => Deployment, (deployment) => deployment.project) deployments!: Deployment[]; - @OneToMany(() => ProjectMember, projectMember => projectMember.project, { + @OneToMany(() => ProjectMember, (projectMember) => projectMember.project, { cascade: ['soft-remove'] }) projectMembers!: ProjectMember[]; diff --git a/packages/backend/src/entity/ProjectMember.ts b/packages/backend/src/entity/ProjectMember.ts index d4e77edc..545a64db 100644 --- a/packages/backend/src/entity/ProjectMember.ts +++ b/packages/backend/src/entity/ProjectMember.ts @@ -15,7 +15,7 @@ import { User } from './User'; export enum Permission { View = 'View', - Edit = 'Edit' + Edit = 'Edit', } @Entity() diff --git a/packages/backend/src/entity/User.ts b/packages/backend/src/entity/User.ts index d280edc7..8f8a8c54 100644 --- a/packages/backend/src/entity/User.ts +++ b/packages/backend/src/entity/User.ts @@ -34,13 +34,17 @@ export class User { @CreateDateColumn() updatedAt!: Date; - @OneToMany(() => ProjectMember, projectMember => projectMember.project, { + @OneToMany(() => ProjectMember, (projectMember) => projectMember.project, { cascade: ['soft-remove'] }) projectMembers!: ProjectMember[]; - @OneToMany(() => UserOrganization, UserOrganization => UserOrganization.member, { - cascade: ['soft-remove'] - }) + @OneToMany( + () => UserOrganization, + (UserOrganization) => UserOrganization.member, + { + cascade: ['soft-remove'] + } + ) userOrganizations!: UserOrganization[]; } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7a871a28..6fb7e812 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -19,7 +19,9 @@ const OAUTH_CLIENT_TYPE = 'oauth-app'; export const main = async (): Promise => { // TODO: get config path using cli - const { server, database, gitHub, registryConfig } = await getConfig(DEFAULT_CONFIG_FILE_PATH); + const { server, database, gitHub, registryConfig } = await getConfig( + DEFAULT_CONFIG_FILE_PATH + ); const app = new OAuthApp({ clientType: OAUTH_CLIENT_TYPE, @@ -31,9 +33,16 @@ export const main = async (): Promise => { await db.init(); const registry = new Registry(registryConfig); - const service = new Service({ gitHubConfig: gitHub, registryConfig }, db, app, registry); + const service = new Service( + { gitHubConfig: gitHub, registryConfig }, + db, + app, + registry + ); - const typeDefs = fs.readFileSync(path.join(__dirname, 'schema.gql')).toString(); + const typeDefs = fs + .readFileSync(path.join(__dirname, 'schema.gql')) + .toString(); const resolvers = await createResolvers(service); await createAndStartServer(server, typeDefs, resolvers, service); diff --git a/packages/backend/src/registry.ts b/packages/backend/src/registry.ts index 108318bb..0c4b3bf2 100644 --- a/packages/backend/src/registry.ts +++ b/packages/backend/src/registry.ts @@ -6,8 +6,11 @@ import { DateTime } from 'luxon'; import { Registry as LaconicRegistry } from '@cerc-io/laconic-sdk'; import { RegistryConfig } from './config'; -import { ApplicationDeploymentRequest } from './entity/Project'; -import { ApplicationRecord, Deployment } from './entity/Deployment'; +import { + ApplicationRecord, + Deployment, + ApplicationDeploymentRequest +} from './entity/Deployment'; import { AppDeploymentRecord, PackageJSON } from './types'; const log = debug('snowball:registry'); @@ -21,35 +24,54 @@ export class Registry { private registry: LaconicRegistry; private registryConfig: RegistryConfig; - constructor (registryConfig : RegistryConfig) { + constructor (registryConfig: RegistryConfig) { this.registryConfig = registryConfig; - this.registry = new LaconicRegistry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, registryConfig.chainId); + this.registry = new LaconicRegistry( + registryConfig.gqlEndpoint, + registryConfig.restEndpoint, + registryConfig.chainId + ); } async createApplicationRecord ({ + appName, packageJSON, commitHash, appType, repoUrl }: { - packageJSON: PackageJSON - commitHash: string, - appType: string, - repoUrl: string - }): Promise<{applicationRecordId: string, applicationRecordData: ApplicationRecord}> { - assert(packageJSON.name, "name field doesn't exist in package.json"); + appName: string; + packageJSON: PackageJSON; + commitHash: string; + appType: string; + repoUrl: string; + }): Promise<{ + applicationRecordId: string; + applicationRecordData: ApplicationRecord; + }> { // Use laconic-sdk to publish record // Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh // Fetch previous records - const records = await this.registry.queryRecords({ - type: APP_RECORD_TYPE, - name: packageJSON.name - }, true); + const records = await this.registry.queryRecords( + { + type: APP_RECORD_TYPE, + name: packageJSON.name + }, + true + ); // Get next version of record - const bondRecords = records.filter((record: any) => record.bondId === this.registryConfig.bondId); - const [latestBondRecord] = bondRecords.sort((a: any, b: any) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime()); - const nextVersion = semverInc(latestBondRecord?.attributes.version ?? '0.0.0', 'patch'); + const bondRecords = records.filter( + (record: any) => record.bondId === this.registryConfig.bondId + ); + const [latestBondRecord] = bondRecords.sort( + (a: any, b: any) => + new Date(b.createTime).getTime() - new Date(a.createTime).getTime() + ); + const nextVersion = semverInc( + latestBondRecord?.attributes.version ?? '0.0.0', + 'patch' + ); assert(nextVersion, 'Application record version not valid'); @@ -60,11 +82,16 @@ export class Registry { repository_ref: commitHash, repository: [repoUrl], app_type: appType, - name: packageJSON.name, + name: appName, ...(packageJSON.description && { description: packageJSON.description }), ...(packageJSON.homepage && { homepage: packageJSON.homepage }), ...(packageJSON.license && { license: packageJSON.license }), - ...(packageJSON.author && { author: typeof packageJSON.author === 'object' ? JSON.stringify(packageJSON.author) : packageJSON.author }), + ...(packageJSON.author && { + author: + typeof packageJSON.author === 'object' + ? JSON.stringify(packageJSON.author) + : packageJSON.author + }), ...(packageJSON.version && { app_version: packageJSON.version }) }; @@ -81,26 +108,45 @@ export class Registry { log('Application record data:', applicationRecord); // TODO: Discuss computation of CRN - const crn = this.getCrn(packageJSON.name); + const crn = this.getCrn(packageJSON.name, appName); log(`Setting name: ${crn} for record ID: ${result.data.id}`); - await this.registry.setName({ cid: result.data.id, crn }, this.registryConfig.privateKey, this.registryConfig.fee); - await this.registry.setName({ cid: result.data.id, crn: `${crn}@${applicationRecord.app_version}` }, this.registryConfig.privateKey, this.registryConfig.fee); - await this.registry.setName({ cid: result.data.id, crn: `${crn}@${applicationRecord.repository_ref}` }, this.registryConfig.privateKey, this.registryConfig.fee); + await this.registry.setName( + { cid: result.data.id, crn }, + this.registryConfig.privateKey, + this.registryConfig.fee + ); + await this.registry.setName( + { cid: result.data.id, crn: `${crn}@${applicationRecord.app_version}` }, + this.registryConfig.privateKey, + this.registryConfig.fee + ); + await this.registry.setName( + { + cid: result.data.id, + crn: `${crn}@${applicationRecord.repository_ref}` + }, + this.registryConfig.privateKey, + this.registryConfig.fee + ); - return { applicationRecordId: result.data.id, applicationRecordData: applicationRecord }; + return { + applicationRecordId: result.data.id, + applicationRecordData: applicationRecord + }; } async createApplicationDeploymentRequest (data: { - appName: string, - commitHash: string, - repository: string, - environmentVariables: { [key: string]: string } + appName: string; + packageJsonName: string; + commitHash: string; + repository: string; + environmentVariables: { [key: string]: string }; }): Promise<{ - applicationDeploymentRequestId: string, - applicationDeploymentRequestData: ApplicationDeploymentRequest + applicationDeploymentRequestId: string; + applicationDeploymentRequestData: ApplicationDeploymentRequest; }> { - const crn = this.getCrn(data.appName); + const crn = this.getCrn(data.packageJsonName, data.appName); const records = await this.registry.resolveNames([crn]); const applicationRecord = records[0]; @@ -124,7 +170,9 @@ export class Registry { env: data.environmentVariables }), meta: JSON.stringify({ - note: `Added by Snowball @ ${DateTime.utc().toFormat('EEE LLL dd HH:mm:ss \'UTC\' yyyy')}`, + note: `Added by Snowball @ ${DateTime.utc().toFormat( + "EEE LLL dd HH:mm:ss 'UTC' yyyy" + )}`, repository: data.repository, repository_ref: data.commitHash }) @@ -142,31 +190,40 @@ export class Registry { log(`Application deployment request record published: ${result.data.id}`); log('Application deployment request data:', applicationDeploymentRequest); - return { applicationDeploymentRequestId: result.data.id, applicationDeploymentRequestData: applicationDeploymentRequest }; + return { + applicationDeploymentRequestId: result.data.id, + applicationDeploymentRequestData: applicationDeploymentRequest + }; } /** * Fetch ApplicationDeploymentRecords for deployments */ - async getDeploymentRecords (deployments: Deployment[]): Promise { + async getDeploymentRecords ( + deployments: Deployment[] + ): Promise { // Fetch ApplicationDeploymentRecords for corresponding ApplicationRecord set in deployments // TODO: Implement Laconicd GQL query to filter records by multiple values for an attribute - const records = await this.registry.queryRecords({ - type: APP_DEPLOYMENT_RECORD_TYPE - }, true); + const records = await this.registry.queryRecords( + { + type: APP_DEPLOYMENT_RECORD_TYPE + }, + true + ); // Filter records with ApplicationRecord ids - return records.filter((record: AppDeploymentRecord) => deployments.some(deployment => deployment.applicationRecordId === record.attributes.application)); + return records.filter((record: AppDeploymentRecord) => + deployments.some( + (deployment) => + deployment.applicationRecordId === record.attributes.application + ) + ); } - getCrn (packageJsonName: string): string { - const [arg1, arg2] = packageJsonName.split('/'); + getCrn (packageJsonName: string, appName: string): string { + const [arg1] = packageJsonName.split('/'); + const authority = arg1.replace('@', ''); - if (arg2) { - const authority = arg1.replace('@', ''); - return `crn://${authority}/applications/${arg2}`; - } - - return `crn://${arg1}/applications/${arg1}`; + return `crn://${authority}/applications/${appName}`; } } diff --git a/packages/backend/src/resolvers.ts b/packages/backend/src/resolvers.ts index f583bdab..a1c544af 100644 --- a/packages/backend/src/resolvers.ts +++ b/packages/backend/src/resolvers.ts @@ -17,7 +17,7 @@ export const createResolvers = async (service: Service): Promise => { return service.getUser(context.userId); }, - organizations: async (_:any, __: any, context: any) => { + organizations: async (_: any, __: any, context: any) => { return service.getOrganizationsByUserId(context.userId); }, @@ -25,15 +25,25 @@ export const createResolvers = async (service: Service): Promise => { return service.getProjectById(projectId); }, - projectsInOrganization: async (_: any, { organizationSlug }: {organizationSlug: string }, context: any) => { - return service.getProjectsInOrganization(context.userId, organizationSlug); + projectsInOrganization: async ( + _: any, + { organizationSlug }: { organizationSlug: string }, + context: any + ) => { + return service.getProjectsInOrganization( + context.userId, + organizationSlug + ); }, deployments: async (_: any, { projectId }: { projectId: string }) => { return service.getDeploymentsByProjectId(projectId); }, - environmentVariables: async (_: any, { projectId }: { projectId: string }) => { + environmentVariables: async ( + _: any, + { projectId }: { projectId: string } + ) => { return service.getEnvironmentVariablesByProjectId(projectId); }, @@ -41,32 +51,55 @@ export const createResolvers = async (service: Service): Promise => { return service.getProjectMembersByProjectId(projectId); }, - searchProjects: async (_: any, { searchText }: { searchText: string }, context: any) => { + searchProjects: async ( + _: any, + { searchText }: { searchText: string }, + context: any + ) => { return service.searchProjects(context.userId, searchText); }, - domains: async (_:any, { projectId, filter }: { projectId: string, filter?: FindOptionsWhere }) => { + domains: async ( + _: any, + { + projectId, + filter + }: { projectId: string; filter?: FindOptionsWhere } + ) => { return service.getDomainsByProjectId(projectId, filter); } }, // TODO: Return error in GQL response Mutation: { - removeProjectMember: async (_: any, { projectMemberId }: { projectMemberId: string }, context: any) => { + removeProjectMember: async ( + _: any, + { projectMemberId }: { projectMemberId: string }, + context: any + ) => { try { - return await service.removeProjectMember(context.userId, projectMemberId); + return await service.removeProjectMember( + context.userId, + projectMemberId + ); } catch (err) { log(err); return false; } }, - updateProjectMember: async (_: any, { projectMemberId, data }: { - projectMemberId: string, - data: { - permissions: Permission[] + updateProjectMember: async ( + _: any, + { + projectMemberId, + data + }: { + projectMemberId: string; + data: { + permissions: Permission[]; + }; } - }) => { + ) => { try { return await service.updateProjectMember(projectMemberId, data); } catch (err) { @@ -75,13 +108,19 @@ export const createResolvers = async (service: Service): Promise => { } }, - addProjectMember: async (_: any, { projectId, data }: { - projectId: string, - data: { - email: string, - permissions: Permission[] + addProjectMember: async ( + _: any, + { + projectId, + data + }: { + projectId: string; + data: { + email: string; + permissions: Permission[]; + }; } - }) => { + ) => { try { return Boolean(await service.addProjectMember(projectId, data)); } catch (err) { @@ -90,25 +129,51 @@ export const createResolvers = async (service: Service): Promise => { } }, - addEnvironmentVariables: async (_: any, { projectId, data }: { projectId: string, data: { environments: string[], key: string, value: string}[] }) => { + addEnvironmentVariables: async ( + _: any, + { + projectId, + data + }: { + projectId: string; + data: { environments: string[]; key: string; value: string }[]; + } + ) => { try { - return Boolean(await service.addEnvironmentVariables(projectId, data)); + return Boolean( + await service.addEnvironmentVariables(projectId, data) + ); } catch (err) { log(err); return false; } }, - updateEnvironmentVariable: async (_: any, { environmentVariableId, data }: { environmentVariableId: string, data : DeepPartial}) => { + updateEnvironmentVariable: async ( + _: any, + { + environmentVariableId, + data + }: { + environmentVariableId: string; + data: DeepPartial; + } + ) => { try { - return await service.updateEnvironmentVariable(environmentVariableId, data); + return await service.updateEnvironmentVariable( + environmentVariableId, + data + ); } catch (err) { log(err); return false; } }, - removeEnvironmentVariable: async (_: any, { environmentVariableId }: { environmentVariableId: string}) => { + removeEnvironmentVariable: async ( + _: any, + { environmentVariableId }: { environmentVariableId: string } + ) => { try { return await service.removeEnvironmentVariable(environmentVariableId); } catch (err) { @@ -117,25 +182,45 @@ export const createResolvers = async (service: Service): Promise => { } }, - updateDeploymentToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => { + updateDeploymentToProd: async ( + _: any, + { deploymentId }: { deploymentId: string }, + context: any + ) => { try { - return Boolean(await service.updateDeploymentToProd(context.userId, deploymentId)); + return Boolean( + await service.updateDeploymentToProd(context.userId, deploymentId) + ); } catch (err) { log(err); return false; } }, - addProject: async (_: any, { organizationSlug, data }: { organizationSlug: string, data: DeepPartial }, context: any) => { + addProject: async ( + _: any, + { + organizationSlug, + data + }: { organizationSlug: string; data: DeepPartial }, + context: any + ) => { try { - return await service.addProject(context.userId, organizationSlug, data); + return await service.addProject( + context.userId, + organizationSlug, + data + ); } catch (err) { log(err); throw err; } }, - updateProject: async (_: any, { projectId, data }: { projectId: string, data: DeepPartial }) => { + updateProject: async ( + _: any, + { projectId, data }: { projectId: string; data: DeepPartial } + ) => { try { return await service.updateProject(projectId, data); } catch (err) { @@ -144,9 +229,15 @@ export const createResolvers = async (service: Service): Promise => { } }, - redeployToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => { + redeployToProd: async ( + _: any, + { deploymentId }: { deploymentId: string }, + context: any + ) => { try { - return Boolean(await service.redeployToProd(context.userId, deploymentId)); + return Boolean( + await service.redeployToProd(context.userId, deploymentId) + ); } catch (err) { log(err); return false; @@ -157,7 +248,8 @@ export const createResolvers = async (service: Service): Promise => { try { return await service.deleteProject(projectId); } catch (err) { - log(err); return false; + log(err); + return false; } }, @@ -170,7 +262,13 @@ export const createResolvers = async (service: Service): Promise => { } }, - rollbackDeployment: async (_: any, { projectId, deploymentId }: {deploymentId: string, projectId: string }) => { + rollbackDeployment: async ( + _: any, + { + projectId, + deploymentId + }: { deploymentId: string; projectId: string } + ) => { try { return await service.rollbackDeployment(projectId, deploymentId); } catch (err) { @@ -179,7 +277,10 @@ export const createResolvers = async (service: Service): Promise => { } }, - addDomain: async (_: any, { projectId, data }: { projectId: string, data: { name: string } }) => { + addDomain: async ( + _: any, + { projectId, data }: { projectId: string; data: { name: string } } + ) => { try { return Boolean(await service.addDomain(projectId, data)); } catch (err) { @@ -188,7 +289,10 @@ export const createResolvers = async (service: Service): Promise => { } }, - updateDomain: async (_: any, { domainId, data }: { domainId: string, data: DeepPartial}) => { + updateDomain: async ( + _: any, + { domainId, data }: { domainId: string; data: DeepPartial } + ) => { try { return await service.updateDomain(domainId, data); } catch (err) { @@ -197,7 +301,11 @@ export const createResolvers = async (service: Service): Promise => { } }, - authenticateGitHub: async (_: any, { code }: { code: string }, context: any) => { + authenticateGitHub: async ( + _: any, + { code }: { code: string }, + context: any + ) => { try { return await service.authenticateGitHub(code, context.userId); } catch (err) { @@ -208,7 +316,9 @@ export const createResolvers = async (service: Service): Promise => { unauthenticateGitHub: async (_: any, __: object, context: any) => { try { - return service.unauthenticateGitHub(context.userId, { gitHubToken: null }); + return service.unauthenticateGitHub(context.userId, { + gitHubToken: null + }); } catch (err) { log(err); return false; diff --git a/packages/backend/src/schema.gql b/packages/backend/src/schema.gql index 620dec72..c22db8db 100644 --- a/packages/backend/src/schema.gql +++ b/packages/backend/src/schema.gql @@ -188,10 +188,19 @@ type Query { type Mutation { addProjectMember(projectId: String!, data: AddProjectMemberInput): Boolean! - updateProjectMember(projectMemberId: String!, data: UpdateProjectMemberInput): Boolean! + updateProjectMember( + projectMemberId: String! + data: UpdateProjectMemberInput + ): Boolean! removeProjectMember(projectMemberId: String!): Boolean! - addEnvironmentVariables(projectId: String!, data: [AddEnvironmentVariableInput!]): Boolean! - updateEnvironmentVariable(environmentVariableId: String!, data: UpdateEnvironmentVariableInput!): Boolean! + addEnvironmentVariables( + projectId: String! + data: [AddEnvironmentVariableInput!] + ): Boolean! + updateEnvironmentVariable( + environmentVariableId: String! + data: UpdateEnvironmentVariableInput! + ): Boolean! removeEnvironmentVariable(environmentVariableId: String!): Boolean! updateDeploymentToProd(deploymentId: String!): Boolean! addProject(organizationSlug: String!, data: AddProjectInput): Project! diff --git a/packages/backend/src/service.ts b/packages/backend/src/service.ts index 0ce326ff..77f8d73b 100644 --- a/packages/backend/src/service.ts +++ b/packages/backend/src/service.ts @@ -15,15 +15,15 @@ import { Permission, ProjectMember } from './entity/ProjectMember'; import { User } from './entity/User'; import { Registry } from './registry'; import { GitHubConfig, RegistryConfig } from './config'; -import { AppDeploymentRecord, GitPushEventPayload } from './types'; +import { AppDeploymentRecord, GitPushEventPayload, PackageJSON } from './types'; const log = debug('snowball:service'); const GITHUB_UNIQUE_WEBHOOK_ERROR = 'Hook already exists on this repository'; interface Config { - gitHubConfig: GitHubConfig - registryConfig: RegistryConfig + gitHubConfig: GitHubConfig; + registryConfig: RegistryConfig; } export class Service { @@ -71,7 +71,9 @@ export class Service { }); if (deployments.length) { - log(`Found ${deployments.length} deployments in ${DeploymentStatus.Building} state`); + log( + `Found ${deployments.length} deployments in ${DeploymentStatus.Building} state` + ); // Fetch ApplicationDeploymentRecord for deployments const records = await this.registry.getDeploymentRecords(deployments); @@ -91,10 +93,12 @@ export class Service { /** * Update deployments with ApplicationDeploymentRecord data */ - async updateDeploymentsWithRecordData (records: AppDeploymentRecord[]): Promise { + async updateDeploymentsWithRecordData ( + records: AppDeploymentRecord[] + ): Promise { // Get deployments for ApplicationDeploymentRecords const deployments = await this.db.getDeployments({ - where: records.map(record => ({ + where: records.map((record) => ({ applicationRecordId: record.attributes.application })), order: { @@ -103,38 +107,46 @@ export class Service { }); // Get project IDs of deployments that are in production environment - const productionDeploymentProjectIds = deployments.reduce((acc, deployment): Set => { - if (deployment.environment === Environment.Production) { - acc.add(deployment.projectId); - } + const productionDeploymentProjectIds = deployments.reduce( + (acc, deployment): Set => { + if (deployment.environment === Environment.Production) { + acc.add(deployment.projectId); + } - return acc; - }, new Set()); + return acc; + }, + new Set() + ); // Set old deployments isCurrent to false - await this.db.updateDeploymentsByProjectIds(Array.from(productionDeploymentProjectIds), { isCurrent: false }); + await this.db.updateDeploymentsByProjectIds( + Array.from(productionDeploymentProjectIds), + { isCurrent: false } + ); - const recordToDeploymentsMap = deployments.reduce((acc: {[key: string]: Deployment}, deployment) => { - acc[deployment.applicationRecordId] = deployment; - return acc; - }, {}); + const recordToDeploymentsMap = deployments.reduce( + (acc: { [key: string]: Deployment }, deployment) => { + acc[deployment.applicationRecordId] = deployment; + return acc; + }, + {} + ); // Update deployment data for ApplicationDeploymentRecords const deploymentUpdatePromises = records.map(async (record) => { const deployment = recordToDeploymentsMap[record.attributes.application]; - await this.db.updateDeploymentById( - deployment.id, - { - applicationDeploymentRecordId: record.id, - applicationDeploymentRecordData: record.attributes, - url: record.attributes.url, - status: DeploymentStatus.Ready, - isCurrent: deployment.environment === Environment.Production - } - ); + await this.db.updateDeploymentById(deployment.id, { + applicationDeploymentRecordId: record.id, + applicationDeploymentRecordData: record.attributes, + url: record.attributes.url, + status: DeploymentStatus.Ready, + isCurrent: deployment.environment === Environment.Production + }); - log(`Updated deployment ${deployment.id} with URL ${record.attributes.url}`); + log( + `Updated deployment ${deployment.id} with URL ${record.attributes.url}` + ); }); await Promise.all(deploymentUpdatePromises); @@ -150,7 +162,10 @@ export class Service { async getOctokit (userId: string): Promise { const user = await this.db.getUser({ where: { id: userId } }); - assert(user && user.gitHubToken, 'User needs to be authenticated with GitHub token'); + assert( + user && user.gitHubToken, + 'User needs to be authenticated with GitHub token' + ); return new Octokit({ auth: user.gitHubToken }); } @@ -165,8 +180,14 @@ export class Service { return dbProject; } - async getProjectsInOrganization (userId:string, organizationSlug: string): Promise { - const dbProjects = await this.db.getProjectsInOrganization(userId, organizationSlug); + async getProjectsInOrganization ( + userId: string, + organizationSlug: string + ): Promise { + const dbProjects = await this.db.getProjectsInOrganization( + userId, + organizationSlug + ); return dbProjects; } @@ -175,35 +196,52 @@ export class Service { return dbDeployments; } - async getEnvironmentVariablesByProjectId (projectId: string): Promise { - const dbEnvironmentVariables = await this.db.getEnvironmentVariablesByProjectId(projectId); + async getEnvironmentVariablesByProjectId ( + projectId: string + ): Promise { + const dbEnvironmentVariables = + await this.db.getEnvironmentVariablesByProjectId(projectId); return dbEnvironmentVariables; } - async getProjectMembersByProjectId (projectId: string): Promise { - const dbProjectMembers = await this.db.getProjectMembersByProjectId(projectId); + async getProjectMembersByProjectId ( + projectId: string + ): Promise { + const dbProjectMembers = + await this.db.getProjectMembersByProjectId(projectId); return dbProjectMembers; } async searchProjects (userId: string, searchText: string): Promise { - const dbProjects = await this.db.getProjectsBySearchText(userId, searchText); + const dbProjects = await this.db.getProjectsBySearchText( + userId, + searchText + ); return dbProjects; } - async getDomainsByProjectId (projectId: string, filter?: FindOptionsWhere): Promise { + async getDomainsByProjectId ( + projectId: string, + filter?: FindOptionsWhere + ): Promise { const dbDomains = await this.db.getDomainsByProjectId(projectId, filter); return dbDomains; } - async updateProjectMember (projectMemberId: string, data: {permissions: Permission[]}): Promise { + async updateProjectMember ( + projectMemberId: string, + data: { permissions: Permission[] } + ): Promise { return this.db.updateProjectMemberById(projectMemberId, data); } - async addProjectMember (projectId: string, + async addProjectMember ( + projectId: string, data: { - email: string, - permissions: Permission[] - }): Promise { + email: string; + permissions: Permission[]; + } + ): Promise { // TODO: Send invitation let user = await this.db.getUser({ where: { @@ -231,7 +269,10 @@ export class Service { return newProjectMember; } - async removeProjectMember (userId: string, projectMemberId: string): Promise { + async removeProjectMember ( + userId: string, + projectMemberId: string + ): Promise { const member = await this.db.getProjectMemberById(projectMemberId); if (String(member.member.id) === userId) { @@ -248,33 +289,48 @@ export class Service { } } - async addEnvironmentVariables (projectId: string, data: { environments: string[], key: string, value: string}[]): Promise { - const formattedEnvironmentVariables = data.map((environmentVariable) => { - return environmentVariable.environments.map((environment) => { - return ({ - key: environmentVariable.key, - value: environmentVariable.value, - environment: environment as Environment, - project: Object.assign(new Project(), { - id: projectId - }) + async addEnvironmentVariables ( + projectId: string, + data: { environments: string[]; key: string; value: string }[] + ): Promise { + const formattedEnvironmentVariables = data + .map((environmentVariable) => { + return environmentVariable.environments.map((environment) => { + return { + key: environmentVariable.key, + value: environmentVariable.value, + environment: environment as Environment, + project: Object.assign(new Project(), { + id: projectId + }) + }; }); - }); - }).flat(); + }) + .flat(); - const savedEnvironmentVariables = await this.db.addEnvironmentVariables(formattedEnvironmentVariables); + const savedEnvironmentVariables = await this.db.addEnvironmentVariables( + formattedEnvironmentVariables + ); return savedEnvironmentVariables; } - async updateEnvironmentVariable (environmentVariableId: string, data : DeepPartial): Promise { + async updateEnvironmentVariable ( + environmentVariableId: string, + data: DeepPartial + ): Promise { return this.db.updateEnvironmentVariable(environmentVariableId, data); } - async removeEnvironmentVariable (environmentVariableId: string): Promise { + async removeEnvironmentVariable ( + environmentVariableId: string + ): Promise { return this.db.deleteEnvironmentVariable(environmentVariableId); } - async updateDeploymentToProd (userId: string, deploymentId: string): Promise { + async updateDeploymentToProd ( + userId: string, + deploymentId: string + ): Promise { const oldDeployment = await this.db.getDeployment({ where: { id: deploymentId }, relations: { @@ -286,20 +342,21 @@ export class Service { throw new Error('Deployment does not exist'); } - const prodBranchDomains = await this.db.getDomainsByProjectId(oldDeployment.project.id, { branch: oldDeployment.project.prodBranch }); + const prodBranchDomains = await this.db.getDomainsByProjectId( + oldDeployment.project.id, + { branch: oldDeployment.project.prodBranch } + ); const octokit = await this.getOctokit(userId); - const newDeployment = await this.createDeployment(userId, - octokit, - { - project: oldDeployment.project, - branch: oldDeployment.branch, - environment: Environment.Production, - domain: prodBranchDomains[0], - commitHash: oldDeployment.commitHash, - commitMessage: oldDeployment.commitMessage - }); + const newDeployment = await this.createDeployment(userId, octokit, { + project: oldDeployment.project, + branch: oldDeployment.branch, + environment: Environment.Production, + domain: prodBranchDomains[0], + commitHash: oldDeployment.commitHash, + commitMessage: oldDeployment.commitMessage + }); return newDeployment; } @@ -311,7 +368,9 @@ export class Service { recordData: { repoUrl?: string } = {} ): Promise { assert(data.project?.repository, 'Project repository not found'); - log(`Creating deployment in project ${data.project.name} from branch ${data.branch}`); + log( + `Creating deployment in project ${data.project.name} from branch ${data.branch}` + ); const [owner, repo] = data.project.repository.split('/'); const { data: packageJSONData } = await octokit.rest.repos.getContent({ @@ -326,28 +385,61 @@ export class Service { } assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file'); - const packageJSON = JSON.parse(atob(packageJSONData.content)); + const packageJSON: PackageJSON = JSON.parse(atob(packageJSONData.content)); + + assert(packageJSON.name, "name field doesn't exist in package.json"); if (!recordData.repoUrl) { - const { data: repoDetails } = await octokit.rest.repos.get({ owner, repo }); + const { data: repoDetails } = await octokit.rest.repos.get({ + owner, + repo + }); recordData.repoUrl = repoDetails.html_url; } // TODO: Set environment variables for each deployment (environment variables can`t be set in application record) - const { applicationRecordId, applicationRecordData } = await this.registry.createApplicationRecord({ - packageJSON, - appType: data.project!.template!, - commitHash: data.commitHash!, - repoUrl: recordData.repoUrl - }); + const { applicationRecordId, applicationRecordData } = + await this.registry.createApplicationRecord({ + appName: repo, + packageJSON, + appType: data.project!.template!, + commitHash: data.commitHash!, + repoUrl: recordData.repoUrl + }); + + const environmentVariables = + await this.db.getEnvironmentVariablesByProjectId(data.project.id!, { + environment: Environment.Production + }); + + const environmentVariablesObj = environmentVariables.reduce( + (acc, env) => { + acc[env.key] = env.value; + + return acc; + }, + {} as { [key: string]: string } + ); + + const { applicationDeploymentRequestId, applicationDeploymentRequestData } = + await this.registry.createApplicationDeploymentRequest({ + appName: repo, + packageJsonName: packageJSON.name, + commitHash: data.commitHash!, + repository: recordData.repoUrl, + environmentVariables: environmentVariablesObj + }); // Update previous deployment with prod branch domain // TODO: Fix unique constraint error for domain - await this.db.updateDeployment({ - domainId: data.domain?.id - }, { - domain: null - }); + await this.db.updateDeployment( + { + domainId: data.domain?.id + }, + { + domain: null + } + ); const newDeployment = await this.db.addDeployment({ project: data.project, @@ -358,17 +450,25 @@ export class Service { status: DeploymentStatus.Building, applicationRecordId, applicationRecordData, + applicationDeploymentRequestId, + applicationDeploymentRequestData, domain: data.domain, createdBy: Object.assign(new User(), { id: userId }) }); - log(`Created deployment ${newDeployment.id} and published application record ${applicationRecordId}`); + log( + `Created deployment ${newDeployment.id} and published application record ${applicationRecordId}` + ); return newDeployment; } - async addProject (userId: string, organizationSlug: string, data: DeepPartial): Promise { + async addProject ( + userId: string, + organizationSlug: string, + data: DeepPartial + ): Promise { const organization = await this.db.getOrganization({ where: { slug: organizationSlug @@ -383,7 +483,9 @@ export class Service { const octokit = await this.getOctokit(userId); const [owner, repo] = project.repository.split('/'); - const { data: [latestCommit] } = await octokit.rest.repos.listCommits({ + const { + data: [latestCommit] + } = await octokit.rest.repos.listCommits({ owner, repo, sha: project.prodBranch, @@ -393,7 +495,8 @@ export class Service { const { data: repoDetails } = await octokit.rest.repos.get({ owner, repo }); // Create deployment with prod branch and latest commit - const newDeployment = await this.createDeployment(userId, + await this.createDeployment( + userId, octokit, { project, @@ -408,27 +511,6 @@ export class Service { } ); - const environmentVariables = await this.db.getEnvironmentVariablesByProjectId(project.id, { environment: Environment.Production }); - - const environmentVariablesObj = environmentVariables.reduce((acc, env) => { - acc[env.key] = env.value; - - return acc; - }, {} as { [key: string]: string }); - - const { applicationDeploymentRequestId, applicationDeploymentRequestData } = await this.registry.createApplicationDeploymentRequest( - { - appName: newDeployment.applicationRecordData.name!, - commitHash: latestCommit.sha, - repository: repoDetails.html_url, - environmentVariables: environmentVariablesObj - }); - - await this.db.updateProjectById(project.id, { - applicationDeploymentRequestId, - applicationDeploymentRequestData - }); - await this.createRepoHook(octokit, project); return project; @@ -441,7 +523,10 @@ export class Service { owner, repo, config: { - url: new URL('api/github/webhook', this.config.gitHubConfig.webhookUrl).href, + url: new URL( + 'api/github/webhook', + this.config.gitHubConfig.webhookUrl + ).href, content_type: 'json' }, events: ['push'] @@ -449,9 +534,13 @@ export class Service { } catch (err) { // https://docs.github.com/en/rest/repos/webhooks?apiVersion=2022-11-28#create-a-repository-webhook--status-codes if ( - !(err instanceof RequestError && - err.status === 422 && - (err.response?.data as any).errors.some((err: any) => err.message === GITHUB_UNIQUE_WEBHOOK_ERROR)) + !( + err instanceof RequestError && + err.status === 422 && + (err.response?.data as any).errors.some( + (err: any) => err.message === GITHUB_UNIQUE_WEBHOOK_ERROR + ) + ) ) { throw err; } @@ -463,7 +552,9 @@ export class Service { async handleGitHubPush (data: GitPushEventPayload): Promise { const { repository, ref, head_commit: headCommit } = data; log(`Handling GitHub push event from repository: ${repository.full_name}`); - const projects = await this.db.getProjects({ where: { repository: repository.full_name } }); + const projects = await this.db.getProjects({ + where: { repository: repository.full_name } + }); if (!projects.length) { log(`No projects found for repository ${repository.full_name}`); @@ -475,23 +566,29 @@ export class Service { for await (const project of projects) { const octokit = await this.getOctokit(project.ownerId); - const [domain] = await this.db.getDomainsByProjectId(project.id, { branch }); + const [domain] = await this.db.getDomainsByProjectId(project.id, { + branch + }); // Create deployment with branch and latest commit in GitHub data - await this.createDeployment(project.ownerId, - octokit, - { - project, - branch, - environment: project.prodBranch === branch ? Environment.Production : Environment.Preview, - domain, - commitHash: headCommit.id, - commitMessage: headCommit.message - }); + await this.createDeployment(project.ownerId, octokit, { + project, + branch, + environment: + project.prodBranch === branch + ? Environment.Production + : Environment.Preview, + domain, + commitHash: headCommit.id, + commitMessage: headCommit.message + }); } } - async updateProject (projectId: string, data: DeepPartial): Promise { + async updateProject ( + projectId: string, + data: DeepPartial + ): Promise { return this.db.updateProjectById(projectId, data); } @@ -508,13 +605,18 @@ export class Service { }); if (domainsRedirectedFrom.length > 0) { - throw new Error('Cannot delete domain since it has redirects from other domains'); + throw new Error( + 'Cannot delete domain since it has redirects from other domains' + ); } return this.db.deleteDomainById(domainId); } - async redeployToProd (userId: string, deploymentId: string): Promise { + async redeployToProd ( + userId: string, + deploymentId: string + ): Promise { const oldDeployment = await this.db.getDeployment({ relations: { project: true, @@ -532,22 +634,23 @@ export class Service { const octokit = await this.getOctokit(userId); - const newDeployment = await this.createDeployment(userId, - octokit, - { - project: oldDeployment.project, - // TODO: Put isCurrent field in project - branch: oldDeployment.branch, - environment: Environment.Production, - domain: oldDeployment.domain, - commitHash: oldDeployment.commitHash, - commitMessage: oldDeployment.commitMessage - }); + const newDeployment = await this.createDeployment(userId, octokit, { + project: oldDeployment.project, + // TODO: Put isCurrent field in project + branch: oldDeployment.branch, + environment: Environment.Production, + domain: oldDeployment.domain, + commitHash: oldDeployment.commitHash, + commitMessage: oldDeployment.commitMessage + }); return newDeployment; } - async rollbackDeployment (projectId: string, deploymentId: string): Promise { + async rollbackDeployment ( + projectId: string, + deploymentId: string + ): Promise { // TODO: Implement transactions const oldCurrentDeployment = await this.db.getDeployment({ relations: { @@ -565,16 +668,25 @@ export class Service { throw new Error('Current deployment doesnot exist'); } - const oldCurrentDeploymentUpdate = await this.db.updateDeploymentById(oldCurrentDeployment.id, { isCurrent: false, domain: null }); + const oldCurrentDeploymentUpdate = await this.db.updateDeploymentById( + oldCurrentDeployment.id, + { isCurrent: false, domain: null } + ); - const newCurrentDeploymentUpdate = await this.db.updateDeploymentById(deploymentId, { isCurrent: true, domain: oldCurrentDeployment?.domain }); + const newCurrentDeploymentUpdate = await this.db.updateDeploymentById( + deploymentId, + { isCurrent: true, domain: oldCurrentDeployment?.domain } + ); return newCurrentDeploymentUpdate && oldCurrentDeploymentUpdate; } - async addDomain (projectId: string, data: { name: string }): Promise<{ - primaryDomain: Domain, - redirectedDomain: Domain + async addDomain ( + projectId: string, + data: { name: string } + ): Promise<{ + primaryDomain: Domain; + redirectedDomain: Domain; }> { const currentProject = await this.db.getProjectById(projectId); @@ -599,12 +711,20 @@ export class Service { redirectTo: savedPrimaryDomain }; - const savedRedirectedDomain = await this.db.addDomain(redirectedDomainDetails); + const savedRedirectedDomain = await this.db.addDomain( + redirectedDomainDetails + ); - return { primaryDomain: savedPrimaryDomain, redirectedDomain: savedRedirectedDomain }; + return { + primaryDomain: savedPrimaryDomain, + redirectedDomain: savedRedirectedDomain + }; } - async updateDomain (domainId: string, data: DeepPartial): Promise { + async updateDomain ( + domainId: string, + data: DeepPartial + ): Promise { const domain = await this.db.getDomain({ where: { id: domainId @@ -645,7 +765,9 @@ export class Service { } if (redirectedDomain.redirectToId) { - throw new Error('Unable to redirect to the domain because it is already redirecting elsewhere. Redirects cannot be chained.'); + throw new Error( + 'Unable to redirect to the domain because it is already redirecting elsewhere. Redirects cannot be chained.' + ); } newDomain.redirectTo = redirectedDomain; @@ -656,8 +778,13 @@ export class Service { return updateResult; } - async authenticateGitHub (code:string, userId: string): Promise<{token: string}> { - const { authentication: { token } } = await this.oauthApp.createToken({ + async authenticateGitHub ( + code: string, + userId: string + ): Promise<{ token: string }> { + const { + authentication: { token } + } = await this.oauthApp.createToken({ code }); @@ -666,7 +793,10 @@ export class Service { return { token }; } - async unauthenticateGitHub (userId: string, data: DeepPartial): Promise { + async unauthenticateGitHub ( + userId: string, + data: DeepPartial + ): Promise { return this.db.updateUser(userId, data); } } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 71626645..eae6929f 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -1,6 +1,6 @@ export interface PackageJSON { - name?: string; - version?: string; + name: string; + version: string; author?: string; description?: string; homepage?: string; @@ -47,5 +47,5 @@ interface RegistryRecord { } export interface AppDeploymentRecord extends RegistryRecord { - attributes: AppDeploymentRecordAttributes + attributes: AppDeploymentRecordAttributes; } diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index f33963f9..693223bb 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -2,6 +2,7 @@ import fs from 'fs-extra'; import path from 'path'; import toml from 'toml'; import debug from 'debug'; +import { DataSource, DeepPartial, EntityTarget, ObjectLiteral } from 'typeorm'; const log = debug('snowball:utils'); @@ -19,3 +20,49 @@ export const getConfig = async ( return config; }; + +export const checkFileExists = async (filePath: string): Promise => { + try { + await fs.access(filePath, fs.constants.F_OK); + return true; + } catch (err) { + log(err); + return false; + } +}; + +export const getEntities = async (filePath: string): Promise => { + const entitiesData = await fs.readFile(filePath, 'utf-8'); + const entities = JSON.parse(entitiesData); + return entities; +}; + +export const loadAndSaveData = async ( + entityType: EntityTarget, + dataSource: DataSource, + entities: any, + relations?: any | undefined +): Promise => { + const entityRepository = dataSource.getRepository(entityType); + + const savedEntity: Entity[] = []; + + for (const entityData of entities) { + let entity = entityRepository.create(entityData as DeepPartial); + + if (relations) { + for (const field in relations) { + const valueIndex = String(field + 'Index'); + + entity = { + ...entity, + [field]: relations[field][entityData[valueIndex]] + }; + } + } + const dbEntity = await entityRepository.save(entity); + savedEntity.push(dbEntity); + } + + return savedEntity; +}; diff --git a/packages/backend/test/delete-db.ts b/packages/backend/test/delete-db.ts index 9fd6a9ab..d984fc8c 100644 --- a/packages/backend/test/delete-db.ts +++ b/packages/backend/test/delete-db.ts @@ -18,4 +18,4 @@ const main = async () => { deleteFile(config.database.dbPath); }; -main().catch(err => log(err)); +main().catch((err) => log(err)); diff --git a/packages/backend/test/fixtures/deployments.json b/packages/backend/test/fixtures/deployments.json index 5eb682a8..b73956f8 100644 --- a/packages/backend/test/fixtures/deployments.json +++ b/packages/backend/test/fixtures/deployments.json @@ -1,14 +1,16 @@ [ { "projectIndex": 0, - "domainIndex":0, + "domainIndex": 0, "createdByIndex": 0, - "id":"ffhae3zq", + "id": "ffhae3zq", "status": "Ready", "environment": "Production", "isCurrent": true, "applicationRecordId": "qbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", "applicationRecordData": {}, + "applicationDeploymentRequestId": "xqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, "branch": "main", "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", "commitMessage": "subscription added", @@ -16,14 +18,16 @@ }, { "projectIndex": 0, - "domainIndex":1, + "domainIndex": 1, "createdByIndex": 0, - "id":"vehagei8", + "id": "vehagei8", "status": "Ready", "environment": "Preview", "isCurrent": false, "applicationRecordId": "wbafyreihvzya6ovp4yfpkqnddkui2iw7thbhwq74lbqs7bhobvmfhrowoi", "applicationRecordData": {}, + "applicationDeploymentRequestId": "wqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, "branch": "test", "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", "commitMessage": "subscription added", @@ -31,14 +35,16 @@ }, { "projectIndex": 0, - "domainIndex":2, + "domainIndex": 2, "createdByIndex": 0, - "id":"qmgekyte", + "id": "qmgekyte", "status": "Ready", "environment": "Development", "isCurrent": false, "applicationRecordId": "ebafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi", "applicationRecordData": {}, + "applicationDeploymentRequestId": "kqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, "branch": "test", "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", "commitMessage": "subscription added", @@ -48,12 +54,14 @@ "projectIndex": 0, "domainIndex": null, "createdByIndex": 0, - "id":"f8wsyim6", + "id": "f8wsyim6", "status": "Ready", "environment": "Production", "isCurrent": false, "applicationRecordId": "rbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhw74lbqs7bhobvmfhrowoi", "applicationRecordData": {}, + "applicationDeploymentRequestId": "yqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, "branch": "prod", "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", "commitMessage": "subscription added", @@ -61,14 +69,16 @@ }, { "projectIndex": 1, - "domainIndex":3, + "domainIndex": 3, "createdByIndex": 1, - "id":"eO8cckxk", + "id": "eO8cckxk", "status": "Ready", "environment": "Production", "isCurrent": true, "applicationRecordId": "tbafyreihvzya6ovp4yfpqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", "applicationRecordData": {}, + "applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, "branch": "main", "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", "commitMessage": "subscription added", @@ -76,14 +86,16 @@ }, { "projectIndex": 1, - "domainIndex":4, + "domainIndex": 4, "createdByIndex": 1, - "id":"yaq0t5yw", + "id": "yaq0t5yw", "status": "Ready", "environment": "Preview", "isCurrent": false, "applicationRecordId": "ybafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi", "applicationRecordData": {}, + "applicationDeploymentRequestId": "tqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, "branch": "test", "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", "commitMessage": "subscription added", @@ -91,14 +103,16 @@ }, { "projectIndex": 1, - "domainIndex":5, + "domainIndex": 5, "createdByIndex": 1, - "id":"hwwr6sbx", + "id": "hwwr6sbx", "status": "Ready", "environment": "Development", "isCurrent": false, "applicationRecordId": "ubafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvfhrowoi", "applicationRecordData": {}, + "applicationDeploymentRequestId": "eqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, "branch": "test", "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", "commitMessage": "subscription added", @@ -106,14 +120,16 @@ }, { "projectIndex": 2, - "domainIndex":9, + "domainIndex": 9, "createdByIndex": 2, - "id":"ndxje48a", + "id": "ndxje48a", "status": "Ready", "environment": "Production", "isCurrent": true, "applicationRecordId": "ibayreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", "applicationRecordData": {}, + "applicationDeploymentRequestId": "dqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, "branch": "main", "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", "commitMessage": "subscription added", @@ -121,14 +137,16 @@ }, { "projectIndex": 2, - "domainIndex":7, + "domainIndex": 7, "createdByIndex": 2, - "id":"gtgpgvei", + "id": "gtgpgvei", "status": "Ready", "environment": "Preview", "isCurrent": false, "applicationRecordId": "obafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", "applicationRecordData": {}, + "applicationDeploymentRequestId": "aqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, "branch": "test", "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", "commitMessage": "subscription added", @@ -136,14 +154,16 @@ }, { "projectIndex": 2, - "domainIndex":8, + "domainIndex": 8, "createdByIndex": 2, - "id":"b4bpthjr", + "id": "b4bpthjr", "status": "Ready", "environment": "Development", "isCurrent": false, "applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo", "applicationRecordData": {}, + "applicationDeploymentRequestId": "uqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, "branch": "test", "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", "commitMessage": "subscription added", @@ -153,12 +173,14 @@ "projectIndex": 3, "domainIndex": 6, "createdByIndex": 2, - "id":"b4bpthjr", + "id": "b4bpthjr", "status": "Ready", "environment": "Production", "isCurrent": true, "applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo", "applicationRecordData": {}, + "applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, "branch": "test", "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", "commitMessage": "subscription added", diff --git a/packages/backend/test/fixtures/project-members.json b/packages/backend/test/fixtures/project-members.json index 7b2661ea..84029c27 100644 --- a/packages/backend/test/fixtures/project-members.json +++ b/packages/backend/test/fixtures/project-members.json @@ -2,77 +2,55 @@ { "memberIndex": 1, "projectIndex": 0, - "permissions": [ - "View" - ], + "permissions": ["View"], "isPending": false }, { "memberIndex": 2, "projectIndex": 0, - "permissions": [ - "View", - "Edit" - ], + "permissions": ["View", "Edit"], "isPending": false }, { "memberIndex": 2, "projectIndex": 1, - "permissions": [ - "View" - ], + "permissions": ["View"], "isPending": false }, { "memberIndex": 0, "projectIndex": 2, - "permissions": [ - "View" - ], + "permissions": ["View"], "isPending": false }, { "memberIndex": 1, "projectIndex": 2, - "permissions": [ - "View", - "Edit" - ], + "permissions": ["View", "Edit"], "isPending": false }, { "memberIndex": 0, "projectIndex": 3, - "permissions": [ - "View" - ], + "permissions": ["View"], "isPending": false }, { "memberIndex": 2, "projectIndex": 3, - "permissions": [ - "View", - "Edit" - ], + "permissions": ["View", "Edit"], "isPending": false }, { "memberIndex": 1, "projectIndex": 4, - "permissions": [ - "View" - ], + "permissions": ["View"], "isPending": false }, { "memberIndex": 2, "projectIndex": 4, - "permissions": [ - "View", - "Edit" - ], + "permissions": ["View", "Edit"], "isPending": false } ] diff --git a/packages/backend/test/fixtures/projects.json b/packages/backend/test/fixtures/projects.json index 55c5ee23..f4564643 100644 --- a/packages/backend/test/fixtures/projects.json +++ b/packages/backend/test/fixtures/projects.json @@ -10,8 +10,6 @@ "framework": "test", "webhooks": [], "icon": "", - "applicationDeploymentRequestId": "hbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", - "applicationDeploymentRequestData": {}, "subDomain": "testProject.snowball.xyz" }, { @@ -25,8 +23,6 @@ "framework": "test-2", "webhooks": [], "icon": "", - "applicationDeploymentRequestId": "gbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", - "applicationDeploymentRequestData": {}, "subDomain": "testProject-2.snowball.xyz" }, { @@ -40,8 +36,6 @@ "framework": "test-3", "webhooks": [], "icon": "", - "applicationDeploymentRequestId": "ebafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", - "applicationDeploymentRequestData": {}, "subDomain": "iglootools.snowball.xyz" }, { @@ -55,8 +49,6 @@ "framework": "test-4", "webhooks": [], "icon": "", - "applicationDeploymentRequestId": "qbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", - "applicationDeploymentRequestData": {}, "subDomain": "iglootools-2.snowball.xyz" }, { @@ -70,8 +62,6 @@ "framework": "test-5", "webhooks": [], "icon": "", - "applicationDeploymentRequestId": "xbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", - "applicationDeploymentRequestData": {}, "subDomain": "snowball-2.snowball.xyz" } ] diff --git a/packages/backend/test/initialize-db.ts b/packages/backend/test/initialize-db.ts index 0aade587..c4384294 100644 --- a/packages/backend/test/initialize-db.ts +++ b/packages/backend/test/initialize-db.ts @@ -1,5 +1,4 @@ -import { DataSource, DeepPartial, EntityTarget, ObjectLiteral } from 'typeorm'; -import * as fs from 'fs/promises'; +import { DataSource } from 'typeorm'; import debug from 'debug'; import path from 'path'; @@ -11,7 +10,12 @@ import { EnvironmentVariable } from '../src/entity/EnvironmentVariable'; import { Domain } from '../src/entity/Domain'; import { ProjectMember } from '../src/entity/ProjectMember'; import { Deployment } from '../src/entity/Deployment'; -import { getConfig } from '../src/utils'; +import { + checkFileExists, + getConfig, + getEntities, + loadAndSaveData +} from '../src/utils'; import { Config } from '../src/config'; import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants'; @@ -27,43 +31,35 @@ const DEPLOYMENT_DATA_PATH = './fixtures/deployments.json'; const ENVIRONMENT_VARIABLE_DATA_PATH = './fixtures/environment-variables.json'; const REDIRECTED_DOMAIN_DATA_PATH = './fixtures/redirected-domains.json'; -const loadAndSaveData = async (entityType: EntityTarget, dataSource: DataSource, filePath: string, relations?: any | undefined) => { - const entitiesData = await fs.readFile(filePath, 'utf-8'); - const entities = JSON.parse(entitiesData); - const entityRepository = dataSource.getRepository(entityType); - - const savedEntity:Entity[] = []; - - for (const entityData of entities) { - let entity = entityRepository.create(entityData as DeepPartial); - - if (relations) { - for (const field in relations) { - const valueIndex = String(field + 'Index'); - - entity = { - ...entity, - [field]: relations[field][entityData[valueIndex]] - }; - } - } - const dbEntity = await entityRepository.save(entity); - savedEntity.push(dbEntity); - } - - return savedEntity; -}; - const generateTestData = async (dataSource: DataSource) => { - const savedUsers = await loadAndSaveData(User, dataSource, path.resolve(__dirname, USER_DATA_PATH)); - const savedOrgs = await loadAndSaveData(Organization, dataSource, path.resolve(__dirname, ORGANIZATION_DATA_PATH)); + const userEntities = await getEntities( + path.resolve(__dirname, USER_DATA_PATH) + ); + const savedUsers = await loadAndSaveData(User, dataSource, userEntities); + + const orgEntities = await getEntities( + path.resolve(__dirname, ORGANIZATION_DATA_PATH) + ); + const savedOrgs = await loadAndSaveData( + Organization, + dataSource, + orgEntities + ); const projectRelations = { owner: savedUsers, organization: savedOrgs }; - const savedProjects = await loadAndSaveData(Project, dataSource, path.resolve(__dirname, PROJECT_DATA_PATH), projectRelations); + const projectEntities = await getEntities( + path.resolve(__dirname, PROJECT_DATA_PATH) + ); + const savedProjects = await loadAndSaveData( + Project, + dataSource, + projectEntities, + projectRelations + ); const domainRepository = dataSource.getRepository(Domain); @@ -71,14 +67,30 @@ const generateTestData = async (dataSource: DataSource) => { project: savedProjects }; - const savedPrimaryDomains = await loadAndSaveData(Domain, dataSource, path.resolve(__dirname, PRIMARY_DOMAIN_DATA_PATH), domainPrimaryRelations); + const primaryDomainsEntities = await getEntities( + path.resolve(__dirname, PRIMARY_DOMAIN_DATA_PATH) + ); + const savedPrimaryDomains = await loadAndSaveData( + Domain, + dataSource, + primaryDomainsEntities, + domainPrimaryRelations + ); const domainRedirectedRelations = { project: savedProjects, redirectTo: savedPrimaryDomains }; - await loadAndSaveData(Domain, dataSource, path.resolve(__dirname, REDIRECTED_DOMAIN_DATA_PATH), domainRedirectedRelations); + const redirectDomainsEntities = await getEntities( + path.resolve(__dirname, REDIRECTED_DOMAIN_DATA_PATH) + ); + await loadAndSaveData( + Domain, + dataSource, + redirectDomainsEntities, + domainRedirectedRelations + ); const savedDomains = await domainRepository.find(); @@ -87,14 +99,30 @@ const generateTestData = async (dataSource: DataSource) => { organization: savedOrgs }; - await loadAndSaveData(UserOrganization, dataSource, path.resolve(__dirname, USER_ORGANIZATION_DATA_PATH), userOrganizationRelations); + const userOrganizationsEntities = await getEntities( + path.resolve(__dirname, USER_ORGANIZATION_DATA_PATH) + ); + await loadAndSaveData( + UserOrganization, + dataSource, + userOrganizationsEntities, + userOrganizationRelations + ); const projectMemberRelations = { member: savedUsers, project: savedProjects }; - await loadAndSaveData(ProjectMember, dataSource, path.resolve(__dirname, PROJECT_MEMBER_DATA_PATH), projectMemberRelations); + const projectMembersEntities = await getEntities( + path.resolve(__dirname, PROJECT_MEMBER_DATA_PATH) + ); + await loadAndSaveData( + ProjectMember, + dataSource, + projectMembersEntities, + projectMemberRelations + ); const deploymentRelations = { project: savedProjects, @@ -102,23 +130,29 @@ const generateTestData = async (dataSource: DataSource) => { createdBy: savedUsers }; - await loadAndSaveData(Deployment, dataSource, path.resolve(__dirname, DEPLOYMENT_DATA_PATH), deploymentRelations); + const deploymentsEntities = await getEntities( + path.resolve(__dirname, DEPLOYMENT_DATA_PATH) + ); + await loadAndSaveData( + Deployment, + dataSource, + deploymentsEntities, + deploymentRelations + ); const environmentVariableRelations = { project: savedProjects }; - await loadAndSaveData(EnvironmentVariable, dataSource, path.resolve(__dirname, ENVIRONMENT_VARIABLE_DATA_PATH), environmentVariableRelations); -}; - -const checkFileExists = async (filePath: string) => { - try { - await fs.access(filePath, fs.constants.F_OK); - return true; - } catch (err) { - log(err); - return false; - } + const environmentVariablesEntities = await getEntities( + path.resolve(__dirname, ENVIRONMENT_VARIABLE_DATA_PATH) + ); + await loadAndSaveData( + EnvironmentVariable, + dataSource, + environmentVariablesEntities, + environmentVariableRelations + ); }; const main = async () => { diff --git a/packages/backend/test/initialize-registry.ts b/packages/backend/test/initialize-registry.ts index 9162f718..8a91bb53 100644 --- a/packages/backend/test/initialize-registry.ts +++ b/packages/backend/test/initialize-registry.ts @@ -17,16 +17,32 @@ const AUTHORITY_NAMES = ['snowballtools', 'cerc-io']; async function main () { const { registryConfig } = await getConfig(DEFAULT_CONFIG_FILE_PATH); - const registry = new Registry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, registryConfig.chainId); + const registry = new Registry( + registryConfig.gqlEndpoint, + registryConfig.restEndpoint, + registryConfig.chainId + ); const bondId = await registry.getNextBondId(registryConfig.privateKey); log('bondId:', bondId); - await registry.createBond({ denom: DENOM, amount: BOND_AMOUNT }, registryConfig.privateKey, registryConfig.fee); + await registry.createBond( + { denom: DENOM, amount: BOND_AMOUNT }, + registryConfig.privateKey, + registryConfig.fee + ); for await (const name of AUTHORITY_NAMES) { - await registry.reserveAuthority({ name }, registryConfig.privateKey, registryConfig.fee); + await registry.reserveAuthority( + { name }, + registryConfig.privateKey, + registryConfig.fee + ); log('Reserved authority name:', name); - await registry.setAuthorityBond({ name, bondId }, registryConfig.privateKey, registryConfig.fee); + await registry.setAuthorityBond( + { name, bondId }, + registryConfig.privateKey, + registryConfig.fee + ); log(`Bond ${bondId} set for authority ${name}`); } } diff --git a/packages/backend/test/publish-deploy-records.ts b/packages/backend/test/publish-deploy-records.ts index f5636c4e..e963e317 100644 --- a/packages/backend/test/publish-deploy-records.ts +++ b/packages/backend/test/publish-deploy-records.ts @@ -12,9 +12,15 @@ import { Deployment, DeploymentStatus } from '../src/entity/Deployment'; const log = debug('snowball:publish-deploy-records'); async function main () { - const { registryConfig, database } = await getConfig(DEFAULT_CONFIG_FILE_PATH); + const { registryConfig, database } = await getConfig( + DEFAULT_CONFIG_FILE_PATH + ); - const registry = new Registry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, registryConfig.chainId); + const registry = new Registry( + registryConfig.gqlEndpoint, + registryConfig.restEndpoint, + registryConfig.chainId + ); const dataSource = new DataSource({ type: 'better-sqlite3', @@ -53,7 +59,7 @@ async function main () { so: '66fcfa49a1664d4cb4ce4f72c1c0e151' }), - request: deployment.project.applicationDeploymentRequestId, + request: deployment.applicationDeploymentRequestId, url }; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 7caa5ab7..cbb0b2e4 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -7,8 +7,11 @@ "@material-tailwind/react": "^2.1.7", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-tooltip": "^1.0.7", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", diff --git a/packages/frontend/src/components/shared/Button/Button.tsx b/packages/frontend/src/components/shared/Button/Button.tsx index 6033a440..90d9085c 100644 --- a/packages/frontend/src/components/shared/Button/Button.tsx +++ b/packages/frontend/src/components/shared/Button/Button.tsx @@ -98,19 +98,29 @@ const Button = forwardRef< href, ...baseLinkProps, }; - return {_children}; + return ( + // @ts-expect-error - ref + + {_children} + + ); } // Internal link return ( - + // @ts-expect-error - ref + {_children} ); } else { const { ...buttonProps } = _props; - // @ts-expect-error - as prop is not a valid prop for button elements - return ; + return ( + // @ts-expect-error - as prop is not a valid prop for button elements + + ); } }, [], @@ -161,8 +171,6 @@ const Button = forwardRef< return ( {cloneIcon(leftIcon, { ...iconSize })} diff --git a/packages/frontend/src/components/shared/Input/Input.theme.ts b/packages/frontend/src/components/shared/Input/Input.theme.ts index 8def6a0d..bf5ce915 100644 --- a/packages/frontend/src/components/shared/Input/Input.theme.ts +++ b/packages/frontend/src/components/shared/Input/Input.theme.ts @@ -50,6 +50,7 @@ export const inputTheme = tv( 'outline-offset-0', 'outline-border-danger', 'shadow-none', + 'focus:outline-border-danger', ], helperText: 'text-elements-danger', }, diff --git a/packages/frontend/src/components/shared/Radio/Radio.theme.ts b/packages/frontend/src/components/shared/Radio/Radio.theme.ts new file mode 100644 index 00000000..0b84601e --- /dev/null +++ b/packages/frontend/src/components/shared/Radio/Radio.theme.ts @@ -0,0 +1,54 @@ +import { VariantProps, tv } from 'tailwind-variants'; + +export const radioTheme = tv({ + slots: { + root: ['flex', 'gap-3', 'flex-wrap'], + wrapper: ['flex', 'items-center', 'gap-2', 'group'], + label: ['text-sm', 'tracking-[-0.006em]', 'text-elements-high-em'], + radio: [ + 'w-5', + 'h-5', + 'rounded-full', + 'border', + 'group', + 'border-border-interactive/10', + 'shadow-button', + 'group-hover:border-border-interactive/[0.14]', + 'focus-ring', + // Checked + 'data-[state=checked]:bg-controls-primary', + 'data-[state=checked]:group-hover:bg-controls-primary-hovered', + ], + indicator: [ + 'flex', + 'items-center', + 'justify-center', + 'relative', + 'w-full', + 'h-full', + 'after:content-[""]', + 'after:block', + 'after:w-2.5', + 'after:h-2.5', + 'after:rounded-full', + 'after:bg-transparent', + 'after:group-hover:bg-controls-disabled', + 'after:group-focus-visible:bg-controls-disabled', + // Checked + 'after:data-[state=checked]:bg-elements-on-primary', + 'after:data-[state=checked]:group-hover:bg-elements-on-primary', + 'after:data-[state=checked]:group-focus-visible:bg-elements-on-primary', + ], + }, + variants: { + orientation: { + vertical: { root: ['flex-col'] }, + horizontal: { root: ['flex-row'] }, + }, + }, + defaultVariants: { + orientation: 'vertical', + }, +}); + +export type RadioTheme = VariantProps; diff --git a/packages/frontend/src/components/shared/Radio/Radio.tsx b/packages/frontend/src/components/shared/Radio/Radio.tsx new file mode 100644 index 00000000..96542493 --- /dev/null +++ b/packages/frontend/src/components/shared/Radio/Radio.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { + Root as RadixRoot, + RadioGroupProps, +} from '@radix-ui/react-radio-group'; +import { RadioTheme, radioTheme } from './Radio.theme'; +import { RadioItem, RadioItemProps } from './RadioItem'; + +export interface RadioOption extends RadioItemProps { + /** + * The label of the radio option. + */ + label: string; + /** + * The value of the radio option. + */ + value: string; +} + +export interface RadioProps extends RadioGroupProps, RadioTheme { + /** + * The options of the radio. + * @default [] + * @example + * ```tsx + * const options = [ + * { + * label: 'Label 1', + * value: '1', + * }, + * { + * label: 'Label 2', + * value: '2', + * }, + * { + * label: 'Label 3', + * value: '3', + * }, + * ]; + * ``` + */ + options: RadioOption[]; +} + +/** + * The Radio component is used to select one option from a list of options. + */ +export const Radio = ({ + className, + options, + orientation, + ...props +}: RadioProps) => { + const { root } = radioTheme({ orientation }); + + return ( + + {options.map((option) => ( + + ))} + + ); +}; diff --git a/packages/frontend/src/components/shared/Radio/RadioItem.tsx b/packages/frontend/src/components/shared/Radio/RadioItem.tsx new file mode 100644 index 00000000..177af9db --- /dev/null +++ b/packages/frontend/src/components/shared/Radio/RadioItem.tsx @@ -0,0 +1,74 @@ +import React, { ComponentPropsWithoutRef } from 'react'; +import { + Item as RadixRadio, + Indicator as RadixIndicator, + RadioGroupItemProps, + RadioGroupIndicatorProps, +} from '@radix-ui/react-radio-group'; +import { radioTheme } from './Radio.theme'; + +export interface RadioItemProps extends RadioGroupItemProps { + /** + * The wrapper props of the radio item. + * You can use this prop to customize the wrapper props. + */ + wrapperProps?: ComponentPropsWithoutRef<'div'>; + /** + * The label props of the radio item. + * You can use this prop to customize the label props. + */ + labelProps?: ComponentPropsWithoutRef<'label'>; + /** + * The indicator props of the radio item. + * You can use this prop to customize the indicator props. + */ + indicatorProps?: RadioGroupIndicatorProps; + /** + * The id of the radio item. + */ + id?: string; + /** + * The label of the radio item. + */ + label?: string; +} + +/** + * The RadioItem component is used to render a radio item. + */ +export const RadioItem = ({ + className, + wrapperProps, + labelProps, + indicatorProps, + label, + id, + ...props +}: RadioItemProps) => { + const { wrapper, label: labelClass, radio, indicator } = radioTheme(); + + // Generate a unique id for the radio item from the label if the id is not provided + const kebabCaseLabel = label?.toLowerCase().replace(/\s+/g, '-'); + const componentId = id ?? kebabCaseLabel; + + return ( +
+ + + + {label && ( + + )} +
+ ); +}; diff --git a/packages/frontend/src/components/shared/Radio/index.ts b/packages/frontend/src/components/shared/Radio/index.ts new file mode 100644 index 00000000..6d49f1a8 --- /dev/null +++ b/packages/frontend/src/components/shared/Radio/index.ts @@ -0,0 +1,2 @@ +export * from './Radio'; +export * from './RadioItem'; diff --git a/packages/frontend/src/components/shared/Switch/Switch.theme.ts b/packages/frontend/src/components/shared/Switch/Switch.theme.ts new file mode 100644 index 00000000..87fbd092 --- /dev/null +++ b/packages/frontend/src/components/shared/Switch/Switch.theme.ts @@ -0,0 +1,84 @@ +import { tv, type VariantProps } from 'tailwind-variants'; + +export const switchTheme = tv({ + slots: { + wrapper: ['flex', 'items-start', 'gap-4', 'w-[375px]'], + switch: [ + 'h-6', + 'w-12', + 'rounded-full', + 'transition-all', + 'duration-500', + 'relative', + 'cursor-default', + 'shadow-inset', + 'focus-ring', + 'outline-none', + ], + thumb: [ + 'block', + 'h-4', + 'w-4', + 'translate-x-1', + 'transition-transform', + 'duration-100', + 'will-change-transform', + 'rounded-full', + 'shadow-button', + 'data-[state=checked]:translate-x-7', + 'bg-controls-elevated', + ], + label: [ + 'flex', + 'flex-1', + 'flex-col', + 'px-1', + 'gap-1', + 'text-sm', + 'text-elements-high-em', + 'tracking-[-0.006em]', + ], + description: ['text-xs', 'text-elements-low-em'], + }, + variants: { + checked: { + true: { + switch: [ + 'bg-controls-primary', + 'hover:bg-controls-primary-hovered', + 'focus-visible:bg-controls-primary-hovered', + ], + }, + false: { + switch: [ + 'bg-controls-inset', + 'hover:bg-controls-inset-hovered', + 'focus-visible:bg-controls-inset-hovered', + ], + }, + }, + disabled: { + true: { + switch: ['bg-controls-disabled', 'cursor-not-allowed'], + thumb: ['bg-elements-on-disabled'], + }, + }, + fullWidth: { + true: { + wrapper: ['w-full', 'justify-between'], + }, + }, + }, + compoundVariants: [ + { + checked: true, + disabled: true, + class: { + switch: ['bg-controls-disabled-active'], + thumb: ['bg-snowball-900'], + }, + }, + ], +}); + +export type SwitchVariants = VariantProps; diff --git a/packages/frontend/src/components/shared/Switch/Switch.tsx b/packages/frontend/src/components/shared/Switch/Switch.tsx new file mode 100644 index 00000000..32d35957 --- /dev/null +++ b/packages/frontend/src/components/shared/Switch/Switch.tsx @@ -0,0 +1,85 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; +import { type SwitchProps as SwitchRadixProps } from '@radix-ui/react-switch'; +import * as SwitchRadix from '@radix-ui/react-switch'; + +import { switchTheme, type SwitchVariants } from './Switch.theme'; + +interface SwitchProps + extends Omit, + SwitchVariants { + /** + * The label of the switch. + */ + label?: string; + /** + * The description of the switch. + */ + description?: string; + /** + * Custom wrapper props for the switch. + */ + wrapperProps?: ComponentPropsWithoutRef<'div'>; + /** + * Function that is called when the checked state of the switch changes. + * @param checked The new checked state of the switch. + */ + onCheckedChange?(checked: boolean): void; +} + +/** + * A switch is a component used for toggling between two states. + */ +export const Switch = ({ + className, + checked, + label, + description, + disabled, + name, + wrapperProps, + fullWidth, + ...props +}: SwitchProps) => { + const { + wrapper, + switch: switchClass, + thumb, + label: labelClass, + description: descriptionClass, + } = switchTheme({ + checked, + disabled, + fullWidth, + }); + + const switchComponent = ( + + + + ); + + // If a label is provided, wrap the switch in a label element. + if (label) { + return ( +
+ + {switchComponent} +
+ ); + } + + return switchComponent; +}; diff --git a/packages/frontend/src/components/shared/Switch/index.ts b/packages/frontend/src/components/shared/Switch/index.ts new file mode 100644 index 00000000..1b19c1d3 --- /dev/null +++ b/packages/frontend/src/components/shared/Switch/index.ts @@ -0,0 +1 @@ +export * from './Switch'; diff --git a/packages/frontend/src/components/shared/Tabs/Tabs.theme.ts b/packages/frontend/src/components/shared/Tabs/Tabs.theme.ts index 4667fe83..7c39b077 100644 --- a/packages/frontend/src/components/shared/Tabs/Tabs.theme.ts +++ b/packages/frontend/src/components/shared/Tabs/Tabs.theme.ts @@ -9,6 +9,8 @@ export const tabsTheme = tv({ // Horizontal – default 'px-1', 'pb-5', + 'cursor-default', + 'select-none', 'text-elements-low-em', 'border-b-2', 'border-transparent', diff --git a/packages/frontend/src/components/shared/Tooltip/Tooltip.theme.ts b/packages/frontend/src/components/shared/Tooltip/Tooltip.theme.ts new file mode 100644 index 00000000..c4c1c5e0 --- /dev/null +++ b/packages/frontend/src/components/shared/Tooltip/Tooltip.theme.ts @@ -0,0 +1,14 @@ +import { tv } from 'tailwind-variants'; + +export const tooltipTheme = tv({ + slots: { + content: [ + 'z-tooltip', + 'rounded-md', + 'bg-surface-high-contrast', + 'p-2', + 'text-elements-on-high-contrast', + ], + arrow: ['fill-surface-high-contrast'], + }, +}); diff --git a/packages/frontend/src/components/shared/Tooltip/Tooltip.tsx b/packages/frontend/src/components/shared/Tooltip/Tooltip.tsx new file mode 100644 index 00000000..75d73c78 --- /dev/null +++ b/packages/frontend/src/components/shared/Tooltip/Tooltip.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import type { + TooltipContentProps, + TooltipTriggerProps, +} from '@radix-ui/react-tooltip'; +import { ReactNode, useState } from 'react'; + +import { TooltipBase, type TooltipBaseProps } from './TooltipBase'; + +export interface TooltipProps extends TooltipBaseProps { + triggerProps?: TooltipTriggerProps; + contentProps?: TooltipContentProps; + content?: ReactNode; +} + +// https://github.com/radix-ui/primitives/issues/955#issuecomment-1798201143 +// Wrap on top of Tooltip base to make tooltip open on mobile via click +export const Tooltip = ({ + children, + triggerProps, + contentProps, + content, + ...props +}: TooltipProps) => { + const [isTooltipVisible, setIsTooltipVisible] = useState(false); + + return ( + + setIsTooltipVisible(false)} + onClick={() => setIsTooltipVisible((prevOpen) => !prevOpen)} + onFocus={() => setTimeout(() => setIsTooltipVisible(true), 0)} + {...triggerProps} + > + {triggerProps?.children ?? children} + + + {content ?? contentProps?.children ?? 'Coming soon'} + + + ); +}; diff --git a/packages/frontend/src/components/shared/Tooltip/TooltipBase.tsx b/packages/frontend/src/components/shared/Tooltip/TooltipBase.tsx new file mode 100644 index 00000000..273a9cbe --- /dev/null +++ b/packages/frontend/src/components/shared/Tooltip/TooltipBase.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { + Provider, + TooltipProps as RadixTooltipProps, + Root, + type TooltipProviderProps, +} from '@radix-ui/react-tooltip'; +import { useState, type PropsWithChildren } from 'react'; + +import { TooltipContent } from './TooltipContent'; +import { TooltipTrigger } from './TooltipTrigger'; + +export interface TooltipBaseProps extends RadixTooltipProps { + providerProps?: TooltipProviderProps; +} + +export const TooltipBase = ({ + children, + providerProps, + ...props +}: PropsWithChildren) => { + const [isTooltipVisible, setIsTooltipVisible] = useState(false); + + return ( + + + {children} + + + ); +}; + +TooltipBase.Trigger = TooltipTrigger; +TooltipBase.Content = TooltipContent; diff --git a/packages/frontend/src/components/shared/Tooltip/TooltipContent/TooltipContent.tsx b/packages/frontend/src/components/shared/Tooltip/TooltipContent/TooltipContent.tsx new file mode 100644 index 00000000..57678206 --- /dev/null +++ b/packages/frontend/src/components/shared/Tooltip/TooltipContent/TooltipContent.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { + Arrow, + Content, + Portal, + type TooltipArrowProps, + type TooltipContentProps, +} from '@radix-ui/react-tooltip'; + +import { tooltipTheme } from '../Tooltip.theme'; + +export interface ContentProps extends TooltipContentProps { + hasArrow?: boolean; + arrowProps?: TooltipArrowProps; +} + +export const TooltipContent = ({ + children, + arrowProps, + className, + hasArrow = true, + ...props +}: ContentProps) => { + const { content, arrow } = tooltipTheme(); + return ( + + + {hasArrow && ( + + )} + {children} + + + ); +}; diff --git a/packages/frontend/src/components/shared/Tooltip/TooltipContent/index.ts b/packages/frontend/src/components/shared/Tooltip/TooltipContent/index.ts new file mode 100644 index 00000000..1675248c --- /dev/null +++ b/packages/frontend/src/components/shared/Tooltip/TooltipContent/index.ts @@ -0,0 +1 @@ +export * from './TooltipContent'; diff --git a/packages/frontend/src/components/shared/Tooltip/TooltipTrigger/TooltipTrigger.tsx b/packages/frontend/src/components/shared/Tooltip/TooltipTrigger/TooltipTrigger.tsx new file mode 100644 index 00000000..c27993ed --- /dev/null +++ b/packages/frontend/src/components/shared/Tooltip/TooltipTrigger/TooltipTrigger.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Trigger, type TooltipTriggerProps } from '@radix-ui/react-tooltip'; + +export type TriggerProps = TooltipTriggerProps; + +export const TooltipTrigger = ({ children, ...props }: TriggerProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/frontend/src/components/shared/Tooltip/TooltipTrigger/index.ts b/packages/frontend/src/components/shared/Tooltip/TooltipTrigger/index.ts new file mode 100644 index 00000000..e73d66c6 --- /dev/null +++ b/packages/frontend/src/components/shared/Tooltip/TooltipTrigger/index.ts @@ -0,0 +1 @@ +export * from './TooltipTrigger'; diff --git a/packages/frontend/src/components/shared/Tooltip/index.ts b/packages/frontend/src/components/shared/Tooltip/index.ts new file mode 100644 index 00000000..a51f2a31 --- /dev/null +++ b/packages/frontend/src/components/shared/Tooltip/index.ts @@ -0,0 +1,2 @@ +export * from './Tooltip'; +export * from './TooltipBase'; diff --git a/packages/frontend/src/pages/components/index.tsx b/packages/frontend/src/pages/components/index.tsx index 89403520..fc6acbec 100644 --- a/packages/frontend/src/pages/components/index.tsx +++ b/packages/frontend/src/pages/components/index.tsx @@ -10,6 +10,7 @@ import { renderBadges } from './renders/badge'; import { renderButtonIcons, renderButtons, + renderDisabledButtons, renderLinks, } from './renders/button'; import { @@ -17,16 +18,22 @@ import { renderTabs, renderVerticalTabs, } from './renders/tabs'; +import { Switch } from 'components/shared/Switch'; +import { RADIO_OPTIONS } from './renders/radio'; +import { Radio } from 'components/shared/Radio'; import { renderInlineNotificationWithDescriptions, renderInlineNotifications, } from './renders/inlineNotifications'; import { renderInputs } from './renders/input'; import { renderToast, renderToastsWithCta } from './renders/toast'; +import { renderTooltips } from './renders/tooltip'; const Page = () => { const [singleDate, setSingleDate] = useState(); const [dateRange, setDateRange] = useState(); + const [switchValue, setSwitchValue] = useState(false); + const [selectedRadio, setSelectedRadio] = useState(''); return (
@@ -52,17 +59,47 @@ const Page = () => { {/* Button */}
+

Tooltip

+
+ {renderTooltips()} +
+ +
+ + {/* Input */}

Input

{renderInputs()}
+ {/* Button */}

Button

{renderButtons()} {renderButtonIcons()}
+ {/* Link */} +
+

Link

+
+ {renderLinks()} +
+
+ + {/* Disabled button, icon only, and link */} +
+
+

Disabled

+

+ Button – icon only – link +

+
+
+ {renderDisabledButtons()} +
+
+
{/* Badge */} @@ -139,6 +176,49 @@ const Page = () => {
+ {/* Switch */} +
+

Switch

+
+ + + + +
+
+ +
+ + {/* Radio */} +
+

Radio

+
+ + +
+
+ +
+ {/* Inline notification */}

Inline Notification

@@ -149,16 +229,6 @@ const Page = () => { {renderInlineNotificationWithDescriptions()}
- -
- - {/* Link */} -
-

Link

-
- {renderLinks()} -
-
diff --git a/packages/frontend/src/pages/components/renders/button.tsx b/packages/frontend/src/pages/components/renders/button.tsx index f5272d3e..0b86aaaf 100644 --- a/packages/frontend/src/pages/components/renders/button.tsx +++ b/packages/frontend/src/pages/components/renders/button.tsx @@ -2,44 +2,45 @@ import { Button, ButtonTheme } from 'components/shared/Button'; import { PlusIcon } from 'components/shared/CustomIcon'; import React from 'react'; +const buttonVariants = ['primary', 'secondary', 'tertiary', 'danger'] as const; +const buttonSizes = ['lg', 'md', 'sm', 'xs'] as const; +const iconOnlyVariants = [ + 'primary', + 'secondary', + 'tertiary', + 'ghost', + 'danger', + 'danger-ghost', +] as const; +const linkVariants = ['link', 'link-emphasized'] as const; + export const renderButtons = () => { - return ['primary', 'secondary', 'tertiary', 'danger'].map( - (variant, index) => ( -
- {['lg', 'md', 'sm', 'xs', 'disabled'].map((size) => ( - - ))} -
- ), - ); + return buttonVariants.map((variant, index) => ( +
+ {buttonSizes.map((size) => ( + + ))} +
+ )); }; export const renderButtonIcons = () => { - return [ - 'primary', - 'secondary', - 'tertiary', - 'ghost', - 'danger', - 'danger-ghost', - ].map((variant, index) => ( + return iconOnlyVariants.map((variant, index) => (
- {['lg', 'md', 'sm', 'xs', 'disabled'].map((size) => ( + {buttonSizes.map((size) => ( @@ -49,17 +50,38 @@ export const renderButtonIcons = () => { }; export const renderLinks = () => { - return ['link', 'link-emphasized', 'disabled'].map((variant) => ( + return linkVariants.map((variant) => ( )); }; + +export const renderDisabledButtons = () => { + return ( + <> + {/* Button variants */} + + {/* Icon only variants */} + + {/* Link variantws */} + + + ); +}; diff --git a/packages/frontend/src/pages/components/renders/radio.ts b/packages/frontend/src/pages/components/renders/radio.ts new file mode 100644 index 00000000..ff262f66 --- /dev/null +++ b/packages/frontend/src/pages/components/renders/radio.ts @@ -0,0 +1,16 @@ +import { RadioOption } from 'components/shared/Radio'; + +export const RADIO_OPTIONS: RadioOption[] = [ + { + label: 'Label 1', + value: '1', + }, + { + label: 'Label 2', + value: '2', + }, + { + label: 'Label 3', + value: '3', + }, +]; diff --git a/packages/frontend/src/pages/components/renders/tooltip.tsx b/packages/frontend/src/pages/components/renders/tooltip.tsx new file mode 100644 index 00000000..7114f4db --- /dev/null +++ b/packages/frontend/src/pages/components/renders/tooltip.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Button } from 'components/shared/Button'; +import { Tooltip } from 'components/shared/Tooltip'; +import { ContentProps } from 'components/shared/Tooltip/TooltipContent'; + +const alignments: ContentProps['align'][] = ['start', 'center', 'end']; +const sides: ContentProps['side'][] = ['left', 'top', 'bottom', 'right']; + +export const renderTooltips = () => { + const tooltips = sides.map((side) => { + return alignments.map((align) => { + return ( + + + + ); + }); + }); + return tooltips; +}; diff --git a/packages/frontend/src/pages/org-slug/projects/id/Deployments.tsx b/packages/frontend/src/pages/org-slug/projects/id/Deployments.tsx index 958382ac..1d9e4e2d 100644 --- a/packages/frontend/src/pages/org-slug/projects/id/Deployments.tsx +++ b/packages/frontend/src/pages/org-slug/projects/id/Deployments.tsx @@ -16,6 +16,7 @@ const DEFAULT_FILTER_VALUE: FilterValue = { searchedBranch: '', status: StatusOptions.ALL_STATUS, }; +const FETCH_DEPLOYMENTS_INTERVAL = 5000; const DeploymentsTabPanel = () => { const client = useGQLClient(); @@ -26,22 +27,30 @@ const DeploymentsTabPanel = () => { const [deployments, setDeployments] = useState([]); const [prodBranchDomains, setProdBranchDomains] = useState([]); - const fetchDeployments = async () => { + const fetchDeployments = useCallback(async () => { const { deployments } = await client.getDeployments(project.id); setDeployments(deployments); - }; + }, [client, project]); const fetchProductionBranchDomains = useCallback(async () => { const { domains } = await client.getDomains(project.id, { branch: project.prodBranch, }); setProdBranchDomains(domains); - }, []); + }, [client, project]); useEffect(() => { - fetchDeployments(); fetchProductionBranchDomains(); - }, []); + fetchDeployments(); + + const interval = setInterval(() => { + fetchDeployments(); + }, FETCH_DEPLOYMENTS_INTERVAL); + + return () => { + clearInterval(interval); + }; + }, [fetchDeployments, fetchProductionBranchDomains]); const currentDeployment = useMemo(() => { return deployments.find((deployment) => { diff --git a/packages/frontend/tailwind.config.js b/packages/frontend/tailwind.config.js index 8279056b..ffd17636 100644 --- a/packages/frontend/tailwind.config.js +++ b/packages/frontend/tailwind.config.js @@ -8,10 +8,13 @@ export default withMT({ '../../node_modules/@material-tailwind/react/theme/components/**/*.{js,ts,jsx,tsx}', ], theme: { - fontFamily: { - sans: ['Inter', 'sans-serif'], - }, extend: { + zIndex: { + tooltip: '52', + }, + fontFamily: { + sans: ['Inter', 'sans-serif'], + }, fontSize: { '2xs': '0.625rem', '3xs': '0.5rem', @@ -151,6 +154,7 @@ export default withMT({ calendar: '0px 3px 20px rgba(8, 47, 86, 0.1), 0px 0px 4px rgba(8, 47, 86, 0.14)', field: '0px 1px 2px rgba(0, 0, 0, 0.04)', + inset: 'inset 0px 1px 0px rgba(8, 47, 86, 0.06)', }, spacing: { 2.5: '0.625rem', diff --git a/yarn.lock b/yarn.lock index aa52ebdc..476291c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2032,6 +2032,13 @@ link-module-alias "^1.2.0" shx "^0.3.4" +"@floating-ui/core@^1.0.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" + integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g== + dependencies: + "@floating-ui/utils" "^0.2.1" + "@floating-ui/core@^1.4.2": version "1.5.2" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.2.tgz#53a0f7a98c550e63134d504f26804f6b83dbc071" @@ -2047,6 +2054,14 @@ "@floating-ui/core" "^1.4.2" "@floating-ui/utils" "^0.1.3" +"@floating-ui/dom@^1.6.1": + version "1.6.3" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.3.tgz#954e46c1dd3ad48e49db9ada7218b0985cee75ef" + integrity sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw== + dependencies: + "@floating-ui/core" "^1.0.0" + "@floating-ui/utils" "^0.2.0" + "@floating-ui/react-dom@^1.2.2": version "1.3.0" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-1.3.0.tgz#4d35d416eb19811c2b0e9271100a6aa18c1579b3" @@ -2054,6 +2069,13 @@ dependencies: "@floating-ui/dom" "^1.2.1" +"@floating-ui/react-dom@^2.0.0": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.8.tgz#afc24f9756d1b433e1fe0d047c24bd4d9cefaa5d" + integrity sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw== + dependencies: + "@floating-ui/dom" "^1.6.1" + "@floating-ui/react@0.19.0": version "0.19.0" resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.19.0.tgz#d8e19a3fcfaa0684d5ec3f335232b4e0ac0c87e1" @@ -2068,6 +2090,11 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9" integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A== +"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" + integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== + "@fontsource/inter@^5.0.16": version "5.0.16" resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-5.0.16.tgz#b858508cdb56dcbbf3166903122851e2fbd16b50" @@ -3284,6 +3311,14 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-arrow@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz#c24f7968996ed934d57fe6cde5d6ec7266e1d25d" + integrity sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-avatar@^1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-avatar/-/react-avatar-1.0.4.tgz#de9a5349d9e3de7bbe990334c4d2011acbbb9623" @@ -3362,6 +3397,23 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-use-layout-effect" "1.0.1" +"@radix-ui/react-popper@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz#24c03f527e7ac348fabf18c89795d85d21b00b42" + integrity sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w== + dependencies: + "@babel/runtime" "^7.13.10" + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-layout-effect" "1.0.1" + "@radix-ui/react-use-rect" "1.0.1" + "@radix-ui/react-use-size" "1.0.1" + "@radix-ui/rect" "1.0.1" + "@radix-ui/react-portal@1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15" @@ -3387,6 +3439,23 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-slot" "1.0.2" +"@radix-ui/react-radio-group@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-radio-group/-/react-radio-group-1.1.3.tgz#3197f5dcce143bcbf961471bf89320735c0212d3" + integrity sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-roving-focus" "1.0.4" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-previous" "1.0.1" + "@radix-ui/react-use-size" "1.0.1" + "@radix-ui/react-roving-focus@1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974" @@ -3411,6 +3480,20 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-compose-refs" "1.0.1" +"@radix-ui/react-switch@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.0.3.tgz#6119f16656a9eafb4424c600fdb36efa5ec5837e" + integrity sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-previous" "1.0.1" + "@radix-ui/react-use-size" "1.0.1" + "@radix-ui/react-tabs@^1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2" @@ -3443,6 +3526,23 @@ "@radix-ui/react-use-callback-ref" "1.0.1" "@radix-ui/react-use-controllable-state" "1.0.1" "@radix-ui/react-use-layout-effect" "1.0.1" +"@radix-ui/react-tooltip@^1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz#8f55070f852e7e7450cc1d9210b793d2e5a7686e" + integrity sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-popper" "1.1.3" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-use-controllable-state" "1.0.1" "@radix-ui/react-visually-hidden" "1.0.3" "@radix-ui/react-use-callback-ref@1.0.1": @@ -3482,6 +3582,14 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-use-rect@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz#fde50b3bb9fd08f4a1cd204572e5943c244fcec2" + integrity sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/rect" "1.0.1" + "@radix-ui/react-use-size@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz#1c5f5fea940a7d7ade77694bb98116fb49f870b2" @@ -3498,6 +3606,13 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/rect@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.0.1.tgz#bf8e7d947671996da2e30f4904ece343bc4a883f" + integrity sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@remix-run/router@1.13.1": version "1.13.1" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.13.1.tgz#07e2a8006f23a3bc898b3f317e0a58cc8076b86e"