Implement functionality for editing domain (#57)

* Make use of domain id for redirecting domains

* Add checks in backend to prevent chain redirecting and editing redirected domain

* Add self relation to domain entity and use it to create and edit domains

* Add self referencing relation to initialize db script

* Add redirectToId column in domain entity

* Remove isRedirected flag from domain entity

* Refactor edit domain dialog box

* Use dummy data for repository

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
This commit is contained in:
Nabarun Gogoi 2024-01-31 17:55:19 +05:30 committed by Ashwin Phatak
parent ace27c7eae
commit 0dd6c7702a
13 changed files with 177 additions and 94 deletions

View File

@ -378,24 +378,22 @@ export class Database {
const primaryDomainDetails = { const primaryDomainDetails = {
...domainDetails, ...domainDetails,
isRedirected: false,
branch: currentProject.prodBranch, branch: currentProject.prodBranch,
project: currentProject project: currentProject
}; };
const primaryDomain = domainRepository.create(primaryDomainDetails as DeepPartial<Domain>); const primaryDomain = domainRepository.create(primaryDomainDetails as DeepPartial<Domain>);
const savedPrimaryDomain = await domainRepository.save(primaryDomain);
const domainArr = domainDetails.name.split('www.'); const domainArr = domainDetails.name.split('www.');
const redirectedDomainDetails = { const redirectedDomainDetails = {
name: domainArr.length > 1 ? domainArr[1] : `www.${domainArr[0]}`, name: domainArr.length > 1 ? domainArr[1] : `www.${domainArr[0]}`,
isRedirected: true,
branch: currentProject.prodBranch, branch: currentProject.prodBranch,
project: currentProject project: currentProject,
redirectTo: savedPrimaryDomain
}; };
const savedPrimaryDomain = await domainRepository.save(primaryDomain);
const redirectedDomain = domainRepository.create(redirectedDomainDetails as DeepPartial<Domain>); const redirectedDomain = domainRepository.create(redirectedDomainDetails as DeepPartial<Domain>);
const savedRedirectedDomain = await domainRepository.save(redirectedDomain); const savedRedirectedDomain = await domainRepository.save(redirectedDomain);
@ -406,6 +404,9 @@ export class Database {
const domainRepository = this.dataSource.getRepository(Domain); const domainRepository = this.dataSource.getRepository(Domain);
const domains = await domainRepository.find({ const domains = await domainRepository.find({
relations: {
redirectTo: true
},
where: { where: {
project: { project: {
id: projectId id: projectId
@ -416,10 +417,56 @@ export class Database {
return domains; return domains;
} }
async updateDomainById (domainId: string, updates: DeepPartial<Domain>): Promise<boolean> { async updateDomainById (domainId: string, data: DeepPartial<Domain>): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain); const domainRepository = this.dataSource.getRepository(Domain);
const updateResult = await domainRepository.update({ id: Number(domainId) }, updates); const domain = await domainRepository.findOne({
where: {
id: Number(domainId)
}
});
const newDomain: DeepPartial<Domain> = {
...data
};
if (domain === null) {
throw new Error(`Error finding domain with id ${domainId}`);
}
const domainsRedirectedFrom = await domainRepository.find({
where: {
project: {
id: domain.projectId
},
redirectToId: domain.id
}
});
// If there are domains redirecting to current domain, only branch of current domain can be updated
if (domainsRedirectedFrom.length > 0 && data.branch === domain.branch) {
throw new Error('Remove all redirects to this domain before updating');
}
if (data.redirectToId) {
const redirectedDomain = await domainRepository.findOne({
where: {
id: Number(data.redirectToId)
}
});
if (redirectedDomain === null) {
throw new Error('Could not find Domain to redirect to');
}
if (redirectedDomain.redirectToId) {
throw new Error('Unable to redirect to the domain because it is already redirecting elsewhere. Redirects cannot be chained.');
}
newDomain.redirectTo = redirectedDomain;
}
const updateResult = await domainRepository.update({ id: Number(domainId) }, newDomain);
if (updateResult.affected) { if (updateResult.affected) {
return updateResult.affected > 0; return updateResult.affected > 0;

View File

@ -20,6 +20,9 @@ export class Domain {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@Column('varchar')
projectId!: string;
@ManyToOne(() => Project, { onDelete: 'CASCADE' }) @ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' }) @JoinColumn({ name: 'projectId' })
project!: Project; project!: Project;
@ -30,8 +33,13 @@ export class Domain {
@Column('varchar', { length: 255 }) @Column('varchar', { length: 255 })
name!: string; name!: string;
@Column('boolean', { default: false }) @Column('int', { nullable: true })
isRedirected!: boolean; redirectToId!: number;
@ManyToOne(() => Domain)
@JoinColumn({ name: 'redirectToId' })
// eslint-disable-next-line no-use-before-define
redirectTo!: Domain | null;
@Column({ @Column({
enum: Status, enum: Status,

View File

@ -1,5 +1,6 @@
import debug from 'debug'; import debug from 'debug';
import assert from 'assert'; import assert from 'assert';
import { DeepPartial } from 'typeorm';
import { OAuthApp } from '@octokit/oauth-app'; import { OAuthApp } from '@octokit/oauth-app';
@ -7,6 +8,7 @@ import { Database } from './database';
import { deploymentToGqlType, projectMemberToGqlType, projectToGqlType, environmentVariableToGqlType, isUserOwner } from './utils'; import { deploymentToGqlType, projectMemberToGqlType, projectToGqlType, environmentVariableToGqlType, isUserOwner } from './utils';
import { Environment } from './entity/Deployment'; import { Environment } from './entity/Deployment';
import { Permission } from './entity/ProjectMember'; import { Permission } from './entity/ProjectMember';
import { Domain } from './entity/Domain';
const log = debug('snowball:database'); const log = debug('snowball:database');
@ -233,7 +235,7 @@ export const createResolvers = async (db: Database, app: OAuthApp): Promise<any>
} }
}, },
updateDomain: async (_: any, { domainId, domainDetails }: { domainId: string, domainDetails: {name?: string, isRedirected?: boolean, branch?: string }}) => { updateDomain: async (_: any, { domainId, domainDetails }: { domainId: string, domainDetails: DeepPartial<Domain>}) => {
try { try {
await db.updateDomainById(domainId, domainDetails); await db.updateDomainById(domainId, domainDetails);
return true; return true;

View File

@ -98,7 +98,7 @@ type Domain {
id: String! id: String!
branch: String! branch: String!
name: String! name: String!
isRedirected: Boolean! redirectTo: Domain
status: DomainStatus! status: DomainStatus!
createdAt: String! createdAt: String!
updatedAt: String! updatedAt: String!
@ -163,8 +163,8 @@ input AddDomainInput {
input UpdateDomainInput { input UpdateDomainInput {
name: String name: String
isRedirected: Boolean
branch: String branch: String
redirectToId: String
} }
input UpdateEnvironmentVariableInput { input UpdateEnvironmentVariableInput {

View File

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

View File

@ -0,0 +1,23 @@
[
{
"projectIndex": 0,
"name": "www.saugatt.com",
"status": "Pending",
"redirectToIndex": 1,
"branch": "test"
},
{
"projectIndex": 1,
"name": "www.saugatt.com",
"status": "Pending",
"redirectToIndex": 3,
"branch": "test"
},
{
"projectIndex": 2,
"name": "www.saugatt.com",
"status": "Pending",
"redirectToIndex": 5,
"branch": "test"
}
]

View File

@ -22,9 +22,10 @@ const PROJECT_DATA_PATH = './fixtures/projects.json';
const ORGANIZATION_DATA_PATH = './fixtures/organizations.json'; const ORGANIZATION_DATA_PATH = './fixtures/organizations.json';
const USER_ORGANIZATION_DATA_PATH = './fixtures/user-orgnizations.json'; const USER_ORGANIZATION_DATA_PATH = './fixtures/user-orgnizations.json';
const PROJECT_MEMBER_DATA_PATH = './fixtures/project-members.json'; const PROJECT_MEMBER_DATA_PATH = './fixtures/project-members.json';
const DOMAIN_DATA_PATH = './fixtures/domains.json'; const PRIMARY_DOMAIN_DATA_PATH = './fixtures/primary-domains.json';
const DEPLOYMENT_DATA_PATH = './fixtures/deployments.json'; const DEPLOYMENT_DATA_PATH = './fixtures/deployments.json';
const ENVIRONMENT_VARIABLE_DATA_PATH = './fixtures/environment-variables.json'; const ENVIRONMENT_VARIABLE_DATA_PATH = './fixtures/environment-variables.json';
const REDIRECTED_DOMAIN_DATA_PATH = './fixtures/redirected-domains.json';
const loadAndSaveData = async <Entity extends ObjectLiteral>(entityType: EntityTarget<Entity>, dataSource: DataSource, filePath: string, relations?: any | undefined) => { const loadAndSaveData = async <Entity extends ObjectLiteral>(entityType: EntityTarget<Entity>, dataSource: DataSource, filePath: string, relations?: any | undefined) => {
const entitiesData = await fs.readFile(filePath, 'utf-8'); const entitiesData = await fs.readFile(filePath, 'utf-8');
@ -54,6 +55,8 @@ const loadAndSaveData = async <Entity extends ObjectLiteral>(entityType: EntityT
}; };
const generateTestData = async (dataSource: DataSource) => { const generateTestData = async (dataSource: DataSource) => {
const domainRepository = await dataSource.getRepository(Domain);
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));
@ -64,11 +67,20 @@ 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 = { const domainPrimaryRelations = {
project: savedProjects project: savedProjects
}; };
const savedDomains = await loadAndSaveData(Domain, dataSource, path.resolve(__dirname, DOMAIN_DATA_PATH), domainRelations); const savedPrimaryDomains = await loadAndSaveData(Domain, dataSource, path.resolve(__dirname, PRIMARY_DOMAIN_DATA_PATH), domainPrimaryRelations);
const domainRedirectedRelations = {
project: savedProjects,
redirectTo: savedPrimaryDomains
};
await loadAndSaveData(Domain, dataSource, path.resolve(__dirname, REDIRECTED_DOMAIN_DATA_PATH), domainRedirectedRelations);
const savedDomains = await domainRepository.find();
const userOrganizationRelations = { const userOrganizationRelations = {
member: savedUsers, member: savedUsers,

View File

@ -22,11 +22,15 @@ const Domains = () => {
}); });
}, [id, projects]); }, [id, projects]);
const linkedRepo = useMemo(() => { // TODO: Use github API for getting linked repository
return currentProject?.repositories?.find( const linkedRepo = {
(repo: any) => repo.id === Number(currentProject?.repositoryId), id: 3,
); title: 'project-103',
}, [currentProject]); updatedAt: '2023-12-21T04:20:00',
user: 'charlie',
private: false,
branch: ['main', 'prod', 'test'],
};
const fetchDomains = async () => { const fetchDomains = async () => {
if (currentProject === undefined) { if (currentProject === undefined) {

View File

@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Domain } from 'gql-client'; import { Domain } from 'gql-client';
@ -40,37 +40,57 @@ const EditDomainDialog = ({
const client = useGQLClient(); const client = useGQLClient();
const getRedirectUrl = (domain: Domain) => { const getRedirectUrl = (domain: Domain) => {
const domainArr = domain.name.split('www.'); const redirectDomain = domain.redirectTo;
let redirectUrl = '';
if (domain.name.startsWith('www.')) { if (redirectDomain !== null) {
redirectUrl = domainArr[1]; return redirectDomain?.name;
} else { } else {
redirectUrl = `www.${domainArr[0]}`; return 'none';
} }
return redirectUrl;
}; };
const redirectOptions = useMemo(() => { const redirectOptions = useMemo(() => {
const redirectUrl = getRedirectUrl(domain); const domainNames = domains
return [...DEFAULT_REDIRECT_OPTIONS, redirectUrl]; .filter((domainData) => domainData.id !== domain.id)
}, [domain]); .map((domain) => domain.name);
return ['none', ...domainNames];
}, [domain, domains]);
const domainRedirectedFrom = useMemo(() => {
return domains.find(
(domainData) => domainData.redirectTo?.id === domain.id,
);
}, [domains, domain]);
const isDisableDropdown = useMemo(() => { const isDisableDropdown = useMemo(() => {
const redirectUrl = getRedirectUrl(domain); return domainRedirectedFrom?.redirectTo?.id !== undefined;
}, [domain, domains]);
const domainRedirected = domains.find( const {
(domain) => domain.name === redirectUrl, handleSubmit,
); register,
control,
watch,
reset,
formState: { isValid, isDirty },
} = useForm({
defaultValues: {
name: domain.name,
branch: domain.branch,
redirectedTo: getRedirectUrl(domain),
},
});
return domainRedirected?.isRedirected; const updateDomainHandler = useCallback(
}, [domain]);
const onSubmit = useCallback(
async (data: any) => { async (data: any) => {
const domainRedirectTo = domains.find(
(domainData) => data.redirectedTo === domainData.name,
);
const updates = { const updates = {
name: data.name, name: data.name ? data.name : domain.name,
branch: data.branch, branch: data.branch ? data.branch : domain.branch,
isRedirected: data.redirectedTo !== 'none', redirectToId: domainRedirectTo ? domainRedirectTo.id : null,
}; };
const { updateDomain } = await client.updateDomain(domain.id, updates); const { updateDomain } = await client.updateDomain(domain.id, updates);
@ -79,29 +99,22 @@ const EditDomainDialog = ({
await onUpdate(); await onUpdate();
toast.success(`Domain ${domain.name} has been updated`); toast.success(`Domain ${domain.name} has been updated`);
} else { } else {
reset();
toast.error(`Error updating domain ${domain.name}`); toast.error(`Error updating domain ${domain.name}`);
} }
handleOpen(); handleOpen();
}, },
[client], [client, domains, domain],
); );
const { useEffect(() => {
handleSubmit, reset({
register,
control,
watch,
formState: { isValid, isDirty },
} = useForm({
defaultValues: {
name: domain.name, name: domain.name,
branch: repo.branch[0], branch: domain.branch,
redirectedTo: !domain.isRedirected redirectedTo: getRedirectUrl(domain),
? redirectOptions[0] });
: redirectOptions[1], }, [domain, repo]);
},
});
return ( return (
<Dialog open={open} handler={handleOpen}> <Dialog open={open} handler={handleOpen}>
@ -115,7 +128,7 @@ const EditDomainDialog = ({
X X
</Button> </Button>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(updateDomainHandler)}>
<DialogBody className="flex flex-col gap-2 p-4"> <DialogBody className="flex flex-col gap-2 p-4">
<Typography variant="small">Domain name</Typography> <Typography variant="small">Domain name</Typography>
<Input crossOrigin={undefined} {...register('name')} /> <Input crossOrigin={undefined} {...register('name')} />
@ -137,8 +150,9 @@ const EditDomainDialog = ({
<div className="flex p-2 gap-2 text-black bg-gray-300 rounded-lg"> <div className="flex p-2 gap-2 text-black bg-gray-300 rounded-lg">
<div>^</div> <div>^</div>
<Typography variant="small"> <Typography variant="small">
Domain {redirectOptions[1]} redirects to this domain so you Domain {domainRedirectedFrom ? domainRedirectedFrom.name : ''}
can not redirect this doman further. redirects to this domain so you can not redirect this doman
further.
</Typography> </Typography>
</div> </div>
)} )}

View File

@ -41,7 +41,6 @@ const ProjectSearch = () => {
name: '', name: '',
value: '', value: '',
}, },
isRedirectedto: deployment.domain.isRedirected,
} }
: null, : null,
}; };

View File

@ -70,7 +70,6 @@ export interface DomainDetails {
name: string; name: string;
value: string; value: string;
}; };
isRedirectedto: boolean;
} }
export enum Permission { export enum Permission {

View File

@ -48,7 +48,6 @@ query ($projectId: String!) {
updatedAt updatedAt
id id
name name
isRedirected
} }
} }
} }
@ -85,7 +84,6 @@ query ($organizationId: String!) {
updatedAt updatedAt
id id
name name
isRedirected
} }
} }
} }
@ -136,7 +134,6 @@ query ($projectId: String!) {
domain{ domain{
branch branch
createdAt createdAt
isRedirected
id id
name name
status status
@ -218,7 +215,12 @@ query ($projectId: String!) {
domains(projectId: $projectId) { domains(projectId: $projectId) {
branch branch
createdAt createdAt
isRedirected redirectTo {
id
name
branch
status
}
id id
name name
status status

View File

@ -41,8 +41,8 @@ export type Domain = {
id: string id: string
branch: string branch: string
name: string name: string
isRedirected: boolean
status: DomainStatus status: DomainStatus
redirectTo?: Domain
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
@ -224,8 +224,8 @@ export type UpdateProjectInput = {
export type UpdateDomainInput = { export type UpdateDomainInput = {
name?: string; name?: string;
isRedirected?: boolean;
branch?: string; branch?: string;
redirectToId?: string | null;
} }
export type RedeployToProdResponse = { export type RedeployToProdResponse = {