Implement functionality for adding new domain in project settings (#48)

* Add mutation to create new domain

* Update mutation to create two domains for www and non-www variant

* Add gql-client method and frontend to create domain

* Display fetched domains in domains tab panel using project id

* Fix graphql type for fetching domains

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
This commit is contained in:
Nabarun Gogoi 2024-01-29 18:18:43 +05:30 committed by Ashwin Phatak
parent 8c38d4788e
commit a34e2286a6
15 changed files with 239 additions and 40 deletions

View File

@ -11,6 +11,7 @@ import { Project } from './entity/Project';
import { Deployment, Environment } from './entity/Deployment'; import { Deployment, Environment } from './entity/Deployment';
import { ProjectMember } from './entity/ProjectMember'; import { ProjectMember } from './entity/ProjectMember';
import { EnvironmentVariable } from './entity/EnvironmentVariable'; import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { Domain } from './entity/Domain';
const log = debug('snowball:database'); const log = debug('snowball:database');
@ -261,7 +262,7 @@ export class Database {
} }
} }
async redeployToProdById (deploymentId: string): Promise<boolean> { async redeployToProdById (deploymentId: string): Promise<Deployment> {
const deploymentRepository = this.dataSource.getRepository(Deployment); const deploymentRepository = this.dataSource.getRepository(Deployment);
const deployment = await deploymentRepository.findOne({ const deployment = await deploymentRepository.findOne({
relations: { relations: {
@ -284,13 +285,8 @@ export class Database {
} }
await deploymentRepository.update({ id: Number(deploymentId) }, { domain: null, isCurrent: false }); await deploymentRepository.update({ id: Number(deploymentId) }, { domain: null, isCurrent: false });
const savedUpdatedDeployment = await deploymentRepository.save(updatedDeployment);
if (savedUpdatedDeployment) { return deploymentRepository.save(updatedDeployment);
return true;
} else {
return false;
}
} }
async deleteProjectById (projectId: string): Promise<boolean> { async deleteProjectById (projectId: string): Promise<boolean> {
@ -317,4 +313,56 @@ export class Database {
return false; return false;
} }
} }
async addDomainByProjectId (projectId: string, domainDetails: { name: string }): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain);
const projectRepository = this.dataSource.getRepository(Project);
const currentProject = await projectRepository.findOneBy({
id: projectId
});
if (currentProject === null) {
throw new Error(`Project with ${projectId} not found`);
}
const primaryDomainDetails = {
...domainDetails,
isRedirected: false,
branch: currentProject.prodBranch,
project: currentProject
};
const primaryDomain = domainRepository.create(primaryDomainDetails as DeepPartial<Domain>);
const domainArr = domainDetails.name.split('www.');
const redirectedDomainDetails = {
name: domainArr.length > 1 ? domainArr[1] : `www.${domainArr[0]}`,
isRedirected: true,
branch: currentProject.prodBranch,
project: currentProject
};
const savedPrimaryDomain = await domainRepository.save(primaryDomain);
const redirectedDomain = domainRepository.create(redirectedDomainDetails as DeepPartial<Domain>);
const savedRedirectedDomain = await domainRepository.save(redirectedDomain);
return [savedPrimaryDomain, savedRedirectedDomain];
}
async getDomainsByProjectId (projectId: string): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain);
const domains = await domainRepository.find({
where: {
project: {
id: projectId
}
}
});
return domains;
}
} }

View File

@ -3,10 +3,14 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Column, Column,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn UpdateDateColumn,
ManyToOne,
JoinColumn
} from 'typeorm'; } from 'typeorm';
enum Status { import { Project } from './Project';
export enum Status {
Live = 'Live', Live = 'Live',
Pending = 'Pending', Pending = 'Pending',
} }
@ -16,6 +20,10 @@ export class Domain {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project!: Project;
@Column('varchar', { length: 255, default: 'main' }) @Column('varchar', { length: 255, default: 'main' })
branch!: string; branch!: string;
@ -26,7 +34,8 @@ export class Domain {
isRedirected!: boolean; isRedirected!: boolean;
@Column({ @Column({
enum: Status enum: Status,
default: Status.Pending
}) })
status!: Status; status!: Status;

View File

@ -98,6 +98,15 @@ export const createResolvers = async (db: Database): Promise<any> => {
}); });
return projects; return projects;
},
domains: async (_:any, { projectId }: { projectId: string }) => {
try {
return db.getDomainsByProjectId(projectId);
} catch (err) {
log(err);
return false;
}
} }
}, },
@ -155,7 +164,8 @@ export const createResolvers = async (db: Database): Promise<any> => {
redeployToProd: async (_: any, { deploymentId }: { deploymentId: string }) => { redeployToProd: async (_: any, { deploymentId }: { deploymentId: string }) => {
try { try {
return db.redeployToProdById(deploymentId); await db.redeployToProdById(deploymentId);
return true;
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;
@ -178,6 +188,16 @@ export const createResolvers = async (db: Database): Promise<any> => {
log(err); log(err);
return false; return false;
} }
},
addDomain: async (_: any, { projectId, domainDetails }: { projectId: string, domainDetails: { name: string } }) => {
try {
await db.addDomainByProjectId(projectId, domainDetails);
return true;
} catch (err) {
log(err);
return false;
}
} }
} }
}; };

View File

@ -122,6 +122,7 @@ type Query {
environmentVariables(projectId: String!): [EnvironmentVariable!] environmentVariables(projectId: String!): [EnvironmentVariable!]
projectMembers(projectId: String!): [ProjectMember!] projectMembers(projectId: String!): [ProjectMember!]
searchProjects(searchText: String!): [Project!] searchProjects(searchText: String!): [Project!]
domains(projectId: String!): [Domain!]
} }
type Mutation { type Mutation {
@ -132,6 +133,7 @@ type Mutation {
redeployToProd(deploymentId: String!): Boolean! redeployToProd(deploymentId: String!): Boolean!
deleteProject(projectId: String!): Boolean! deleteProject(projectId: String!): Boolean!
rollbackDeployment(projectId: String!, deploymentId: String!): Boolean! rollbackDeployment(projectId: String!, deploymentId: String!): Boolean!
addDomain(projectId: String!, domainDetails: AddDomainInput!): Boolean!
} }
input AddEnvironmentVariableInput { input AddEnvironmentVariableInput {
@ -144,3 +146,7 @@ input UpdateProjectInput {
name: String name: String
description: String description: String
} }
input AddDomainInput {
name: String!
}

View File

@ -6,7 +6,7 @@
"status": "Building", "status": "Building",
"environment": "Production", "environment": "Production",
"isCurrent": true, "isCurrent": true,
"branch": "prod", "branch": "main",
"commitHash": "testXyz" "commitHash": "testXyz"
}, },
{ {
@ -16,7 +16,7 @@
"status": "Ready", "status": "Ready",
"environment": "Preview", "environment": "Preview",
"isCurrent": false, "isCurrent": false,
"branch": "prod", "branch": "test",
"commitHash": "testXyz" "commitHash": "testXyz"
}, },
{ {
@ -26,7 +26,7 @@
"status": "Error", "status": "Error",
"environment": "Development", "environment": "Development",
"isCurrent": false, "isCurrent": false,
"branch": "prod", "branch": "test",
"commitHash": "testXyz" "commitHash": "testXyz"
}, },
{ {
@ -46,7 +46,7 @@
"status": "Building", "status": "Building",
"environment": "Production", "environment": "Production",
"isCurrent": true, "isCurrent": true,
"branch": "prod", "branch": "main",
"commitHash": "testXyz" "commitHash": "testXyz"
}, },
{ {
@ -56,7 +56,7 @@
"status": "Ready", "status": "Ready",
"environment": "Preview", "environment": "Preview",
"isCurrent": false, "isCurrent": false,
"branch": "prod", "branch": "test",
"commitHash": "testXyz" "commitHash": "testXyz"
}, },
{ {
@ -66,7 +66,7 @@
"status": "Error", "status": "Error",
"environment": "Development", "environment": "Development",
"isCurrent": false, "isCurrent": false,
"branch": "prod", "branch": "test",
"commitHash": "testXyz" "commitHash": "testXyz"
}, },
{ {
@ -76,7 +76,7 @@
"status": "Building", "status": "Building",
"environment": "Production", "environment": "Production",
"isCurrent": true, "isCurrent": true,
"branch": "prod", "branch": "main",
"commitHash": "testXyz" "commitHash": "testXyz"
}, },
{ {
@ -86,7 +86,7 @@
"status": "Ready", "status": "Ready",
"environment": "Preview", "environment": "Preview",
"isCurrent": false, "isCurrent": false,
"branch": "prod", "branch": "test",
"commitHash": "testXyz" "commitHash": "testXyz"
}, },
{ {
@ -96,7 +96,7 @@
"status": "Error", "status": "Error",
"environment": "Development", "environment": "Development",
"isCurrent": false, "isCurrent": false,
"branch": "prod", "branch": "test",
"commitHash": "testXyz" "commitHash": "testXyz"
} }
] ]

View File

@ -1,53 +1,62 @@
[ [
{ {
"projectIndex": 0,
"name": "randomurl.snowballtools.xyz", "name": "randomurl.snowballtools.xyz",
"status": "Live", "status": "Live",
"isRedirected": false, "isRedirected": false,
"branch": "test" "branch": "main"
}, },
{ {
"projectIndex": 0,
"name": "saugatt.com", "name": "saugatt.com",
"status": "Pending", "status": "Pending",
"isRedirected": false, "isRedirected": false,
"branch": "test" "branch": "test"
}, },
{ {
"projectIndex": 0,
"name": "www.saugatt.com", "name": "www.saugatt.com",
"status": "Pending", "status": "Pending",
"isRedirected": true, "isRedirected": true,
"branch": "test" "branch": "test"
}, },
{ {
"projectIndex": 1,
"name": "randomurl.snowballtools.xyz", "name": "randomurl.snowballtools.xyz",
"status": "Live", "status": "Live",
"isRedirected": false, "isRedirected": false,
"branch": "test" "branch": "main"
}, },
{ {
"projectIndex": 1,
"name": "saugatt.com", "name": "saugatt.com",
"status": "Pending", "status": "Pending",
"isRedirected": false, "isRedirected": false,
"branch": "test" "branch": "test"
}, },
{ {
"projectIndex": 1,
"name": "www.saugatt.com", "name": "www.saugatt.com",
"status": "Pending", "status": "Pending",
"isRedirected": true, "isRedirected": true,
"branch": "test" "branch": "test"
}, },
{ {
"projectIndex": 2,
"name": "randomurl.snowballtools.xyz", "name": "randomurl.snowballtools.xyz",
"status": "Live", "status": "Live",
"isRedirected": false, "isRedirected": false,
"branch": "test" "branch": "main"
}, },
{ {
"projectIndex": 2,
"name": "saugatt.com", "name": "saugatt.com",
"status": "Pending", "status": "Pending",
"isRedirected": false, "isRedirected": false,
"branch": "test" "branch": "test"
}, },
{ {
"projectIndex": 2,
"name": "www.saugatt.com", "name": "www.saugatt.com",
"status": "Pending", "status": "Pending",
"isRedirected": true, "isRedirected": true,

View File

@ -56,7 +56,6 @@ const loadAndSaveData = async <Entity extends ObjectLiteral>(entityType: EntityT
const generateTestData = async (dataSource: DataSource) => { const generateTestData = async (dataSource: DataSource) => {
const savedUsers = await loadAndSaveData(User, dataSource, path.resolve(__dirname, USER_DATA_PATH)); 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 savedOrgs = await loadAndSaveData(Organization, dataSource, path.resolve(__dirname, ORGANIZATION_DATA_PATH));
const savedDomains = await loadAndSaveData(Domain, dataSource, path.resolve(__dirname, DOMAIN_DATA_PATH));
const projectRelations = { const projectRelations = {
owner: savedUsers, owner: savedUsers,
@ -65,6 +64,12 @@ const generateTestData = async (dataSource: DataSource) => {
const savedProjects = await loadAndSaveData(Project, dataSource, path.resolve(__dirname, PROJECT_DATA_PATH), projectRelations); const savedProjects = await loadAndSaveData(Project, dataSource, path.resolve(__dirname, PROJECT_DATA_PATH), projectRelations);
const domainRelations = {
project: savedProjects
};
const savedDomains = await loadAndSaveData(Domain, dataSource, path.resolve(__dirname, DOMAIN_DATA_PATH), domainRelations);
const userOrganizationRelations = { const userOrganizationRelations = {
member: savedUsers, member: savedUsers,
organization: savedOrgs organization: savedOrgs

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Domain, DomainStatus } from 'gql-client';
import { import {
Chip, Chip,
@ -14,7 +15,6 @@ import {
import { ProjectDetails, RepositoryDetails } from '../../../../types/project'; import { ProjectDetails, RepositoryDetails } from '../../../../types/project';
import ConfirmDialog from '../../../shared/ConfirmDialog'; import ConfirmDialog from '../../../shared/ConfirmDialog';
import EditDomainDialog from './EditDomainDialog'; import EditDomainDialog from './EditDomainDialog';
import { Domain, DomainStatus } from 'gql-client';
enum RefreshStatus { enum RefreshStatus {
IDLE, IDLE,

View File

@ -1,13 +1,18 @@
import React, { useMemo } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom'; import { useParams, Link, useOutletContext } from 'react-router-dom';
import { Button, Typography } from '@material-tailwind/react'; import { Button, Typography } from '@material-tailwind/react';
import DomainCard from './DomainCard'; import DomainCard from './DomainCard';
import { ProjectSearchOutletContext } from '../../../../types/project'; import { ProjectSearchOutletContext } from '../../../../types/project';
import { useGQLClient } from '../../../../context/GQLClientContext';
import { Domain } from 'gql-client';
const Domains = () => { const Domains = () => {
const { id } = useParams(); const { id } = useParams();
const client = useGQLClient();
const [domains, setDomains] = useState<Domain[]>([]);
const { projects } = useOutletContext<ProjectSearchOutletContext>(); const { projects } = useOutletContext<ProjectSearchOutletContext>();
@ -23,11 +28,18 @@ const Domains = () => {
); );
}, [currentProject]); }, [currentProject]);
const domains = currentProject?.deployments const fetchDomains = async () => {
.filter((deployment) => { if (currentProject === undefined) {
return deployment.domain != null; return;
}) }
.map((deployment) => deployment.domain);
const fetchedDomains = await client.getDomains(currentProject.id);
setDomains(fetchedDomains.domains);
};
useEffect(() => {
fetchDomains();
}, []);
return ( return (
<> <>
@ -40,7 +52,7 @@ const Domains = () => {
</Link> </Link>
</div> </div>
{domains?.map((domain) => { {domains.map((domain) => {
return ( return (
<DomainCard <DomainCard
domain={domain} domain={domain}

View File

@ -47,7 +47,9 @@ const SetupDomain = () => {
return ( return (
<form <form
onSubmit={handleSubmit(() => { onSubmit={handleSubmit(() => {
navigate('config'); watch('isWWW') === 'true'
? navigate(`config?name=www.${domainStr}`)
: navigate(`config?name=${domainStr}`);
})} })}
className="flex flex-col gap-6 w-full" className="flex flex-col gap-6 w-full"
> >

View File

@ -1,9 +1,38 @@
import React from 'react'; import React from 'react';
import { Link, useParams } from 'react-router-dom';
import toast from 'react-hot-toast';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { Typography, Alert, Button } from '@material-tailwind/react'; import { Typography, Alert, Button } from '@material-tailwind/react';
import { useGQLClient } from '../../../../../context/GQLClientContext';
const Config = () => { const Config = () => {
const { id } = useParams(); const { id } = useParams();
const client = useGQLClient();
const [searchParams] = useSearchParams();
const primaryDomainName = searchParams.get('name');
const handleSubmitDomain = async () => {
if (primaryDomainName === null) {
toast.error('Cannot resolve domain name');
return;
}
if (id === undefined) {
toast.error('Cannot find project');
return;
}
const isAdded = await client.addDomain(id, {
name: primaryDomainName,
});
if (isAdded) {
toast.success('Domain added successfully');
} else {
toast.error('Error adding domain');
}
};
return ( return (
<div className="flex flex-col gap-6 w-full"> <div className="flex flex-col gap-6 w-full">
@ -43,7 +72,11 @@ const Config = () => {
</Alert> </Alert>
<Link to={`/projects/${id}`}> <Link to={`/projects/${id}`}>
<Button className="w-fit" color="blue"> <Button
className="w-fit"
color="blue"
onClick={async () => await handleSubmitDomain()}
>
Finish <i>{'>'}</i> Finish <i>{'>'}</i>
</Button> </Button>
</Link> </Link>

View File

@ -1,8 +1,8 @@
import { ApolloClient, DefaultOptions, InMemoryCache, NormalizedCacheObject } from '@apollo/client'; import { ApolloClient, DefaultOptions, InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { getUser, getOrganizations, getDeployments, getProjectMembers, searchProjects, getEnvironmentVariables, getProject, getProjectsInOrganization } from './queries'; import { getUser, getOrganizations, getDeployments, getProjectMembers, searchProjects, getEnvironmentVariables, getProject, getDomains, getProjectsInOrganization } from './queries';
import { AddEnvironmentVariableInput, AddEnvironmentVariablesResponse, GetDeploymentsResponse, GetEnvironmentVariablesResponse, GetOrganizationsResponse, GetProjectMembersResponse, SearchProjectsResponse, GetUserResponse, RemoveMemberResponse, UpdateDeploymentToProdResponse, GetProjectResponse, UpdateProjectResponse, UpdateProjectInput, RedeployToProdResponse, DeleteProjectResponse, GetProjectsInOrganizationResponse, RollbackDeploymentResponse } from './types'; import { AddEnvironmentVariableInput, AddEnvironmentVariablesResponse, GetDeploymentsResponse, GetEnvironmentVariablesResponse, GetOrganizationsResponse, GetProjectMembersResponse, SearchProjectsResponse, GetUserResponse, RemoveMemberResponse, UpdateDeploymentToProdResponse, GetProjectResponse, UpdateProjectResponse, UpdateProjectInput, RedeployToProdResponse, DeleteProjectResponse, GetProjectsInOrganizationResponse, RollbackDeploymentResponse, AddDomainInput, AddDomainResponse, GetDomainsResponse } from './types';
import { removeMember, addEnvironmentVariables, updateDeploymentToProd, updateProjectMutation, redeployToProd, deleteProject, rollbackDeployment } from './mutations'; import { removeMember, addEnvironmentVariables, updateDeploymentToProd, updateProjectMutation, redeployToProd, deleteProject, addDomain, rollbackDeployment } from './mutations';
export interface GraphQLConfig { export interface GraphQLConfig {
gqlEndpoint: string; gqlEndpoint: string;
@ -192,4 +192,27 @@ export class GQLClient {
return data; return data;
} }
async addDomain (projectId: string, domainDetails: AddDomainInput): Promise<AddDomainResponse> {
const { data } = await this.client.mutate({
mutation: addDomain,
variables: {
projectId,
domainDetails
}
});
return data;
}
async getDomains (projectId: string): Promise<GetDomainsResponse> {
const { data } = await this.client.query({
query: getDomains,
variables: {
projectId
}
});
return data;
}
} }

View File

@ -40,3 +40,9 @@ mutation ($projectId: String! ,$deploymentId: String!) {
rollbackDeployment(proejctId: $projectId, deploymentId: $deploymentId) rollbackDeployment(proejctId: $projectId, deploymentId: $deploymentId)
} }
`; `;
export const addDomain = gql`
mutation ($projectId: String!, $domainDetails: AddDomainInput!) {
addDomain(projectId: $projectId, domainDetails: $domainDetails)
}
`;

View File

@ -215,3 +215,17 @@ query ($searchText: String!) {
} }
} }
`; `;
export const getDomains = gql`
query ($projectId: String!) {
domains(projectId: $projectId) {
branch
createdAt
isRedirected
id
name
status
updatedAt
}
}
`;

View File

@ -161,6 +161,10 @@ export type GetProjectsInOrganizationResponse = {
projectsInOrganization: Project[] projectsInOrganization: Project[]
} }
export type GetDomainsResponse = {
domains: Domain[]
}
export type SearchProjectsResponse = { export type SearchProjectsResponse = {
searchProjects: Project[] searchProjects: Project[]
} }
@ -199,3 +203,11 @@ export type RedeployToProdResponse = {
export type RollbackDeploymentResponse = { export type RollbackDeploymentResponse = {
rollbackDeployment: boolean rollbackDeployment: boolean
} }
export type AddDomainInput = {
name: string
}
export type AddDomainResponse = {
addDomain: true
}