Add GQL mutation to delete project (#43)

* Implement delete project functionality

* Use delete project client method in UI

* Refetch projects information on deleting project

* Use project's current deployment domain name for url

* Handle review changes

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
This commit is contained in:
Nabarun Gogoi 2024-01-29 16:31:03 +05:30 committed by Ashwin Phatak
parent cfb4b4637c
commit 1ae1564878
18 changed files with 279 additions and 96 deletions

View File

@ -78,22 +78,39 @@ export class Database {
return projects;
}
async getProjectByProjectId (projectId: string): Promise<Project | null> {
async getProjectById (projectId: string): Promise<Project | null> {
const projectRepository = this.dataSource.getRepository(Project);
const project = await projectRepository.findOne({
relations: {
organization: true,
owner: true
},
where: {
id: projectId
}
});
const project = await projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect('project.deployments', 'deployments', 'deployments.isCurrent = true')
.leftJoinAndSelect('deployments.domain', 'domain')
.leftJoinAndSelect('project.owner', 'owner')
.where('project.id = :projectId', {
projectId
})
.getOne();
return project;
}
async getProjectsInOrganization (userId: string, organizationId: string): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project);
const projects = await projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect('project.deployments', 'deployments', 'deployments.isCurrent = true')
.leftJoinAndSelect('deployments.domain', 'domain')
.leftJoin('project.projectMembers', 'projectMembers')
.where('(project.ownerId = :userId OR projectMembers.userId = :userId) AND project.organizationId = :organizationId', {
userId,
organizationId
})
.getMany();
return projects;
}
async getDeploymentsByProjectId (projectId: string): Promise<Deployment[]> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
@ -275,4 +292,15 @@ export class Database {
return false;
}
}
async deleteProjectById (projectId: string): Promise<boolean> {
const projectRepository = this.dataSource.getRepository(Project);
const deleteResult = await projectRepository.softDelete({ id: projectId });
if (deleteResult.affected) {
return deleteResult.affected > 0;
} else {
return false;
}
}
}

View File

@ -6,12 +6,14 @@ import {
UpdateDateColumn,
ManyToOne,
JoinColumn,
OneToMany
OneToMany,
DeleteDateColumn
} from 'typeorm';
import { User } from './User';
import { Organization } from './Organization';
import { ProjectMember } from './ProjectMember';
import { Deployment } from './Deployment';
@Entity()
export class Project {
@ -49,12 +51,21 @@ export class Project {
})
webhooks!: string[];
@Column('varchar')
icon!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@DeleteDateColumn()
deletedAt?: Date;
@OneToMany(() => ProjectMember, projectMember => projectMember.project)
projectMembers!: ProjectMember[];
@OneToMany(() => Deployment, (deployment) => deployment.project)
deployments!: Deployment[];
}

View File

@ -50,9 +50,14 @@ export const createResolvers = async (db: Database): Promise<any> => {
},
project: async (_: any, { projectId }: { projectId: string }) => {
const dbProject = await db.getProjectByProjectId(projectId);
const dbProject = await db.getProjectById(projectId);
return dbProject ? projectToGqlType(dbProject, [], []) : null;
return dbProject || null;
},
projectsInOrganization: async (_: any, { organizationId }: {organizationId: string }, context: any) => {
const dbProject = await db.getProjectsInOrganization(context.userId, organizationId);
return dbProject;
},
deployments: async (_: any, { projectId }: { projectId: string }) => {
@ -155,6 +160,15 @@ export const createResolvers = async (db: Database): Promise<any> => {
log(err);
return false;
}
},
deleteProject: async (_: any, { projectId }: { projectId: string }) => {
try {
return db.deleteProjectById(projectId);
} catch (err) {
log(err);
return false;
}
}
}
};

View File

@ -69,6 +69,7 @@ type Project {
createdAt: String!
updatedAt: String!
organization: Organization!
icon: String
}
type ProjectMember {
@ -115,6 +116,7 @@ type Query {
user: User!
organizations: [Organization!]
projects: [Project!]
projectsInOrganization(organizationId: String!): [Project!]
project(projectId: String!): Project
deployments(projectId: String!): [Deployment!]
environmentVariables(projectId: String!): [EnvironmentVariable!]
@ -128,6 +130,7 @@ type Mutation {
updateDeploymentToProd(deploymentId: String!): Boolean!
updateProject(projectId: String!, updateProject: UpdateProjectInput): Boolean!
redeployToProd(deploymentId: String!): Boolean!
deleteProject(projectId: String!): Boolean!
}
input AddEnvironmentVariableInput {

View File

@ -8,7 +8,8 @@
"description": "test",
"template": "test",
"framework": "test",
"webhooks": []
"webhooks": [],
"icon": ""
},
{
"ownerIndex": 1,
@ -19,7 +20,8 @@
"description": "test-2",
"template": "test-2",
"framework": "test-2",
"webhooks": []
"webhooks": [],
"icon": ""
},
{
"ownerIndex": 2,
@ -30,7 +32,8 @@
"description": "test-3",
"template": "test-3",
"framework": "test-3",
"webhooks": []
"webhooks": [],
"icon": ""
},
{
"ownerIndex": 1,
@ -41,7 +44,8 @@
"description": "test-4",
"template": "test-4",
"framework": "test-4",
"webhooks": []
"webhooks": [],
"icon": ""
},
{
"ownerIndex": 0,
@ -52,6 +56,7 @@
"description": "test-5",
"template": "test-5",
"framework": "test-5",
"webhooks": []
"webhooks": [],
"icon": ""
}
]

View File

@ -25,7 +25,9 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project }) => {
<Link to={`projects/${project.id}`}>
<Typography>{project.name}</Typography>
<Typography color="gray" variant="small">
{project.url}
{project.deployments[0]?.domain.name
? project.deployments[0]?.domain.name
: ''}
</Typography>
</Link>
</div>

View File

@ -31,7 +31,9 @@ const OverviewTabPanel = ({ project, organizationProject }: OverviewProps) => {
<div className="grow">
<Typography>{project.name}</Typography>
<Typography variant="small" color="gray">
{organizationProject.url}
{project.deployments[0]?.domain.name
? project.deployments[0]?.domain.name
: ''}
</Typography>
</div>
</div>

View File

@ -1,6 +1,8 @@
import React from 'react';
import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { Project } from 'gql-client';
import {
Button,
@ -11,13 +13,12 @@ import {
Input,
Typography,
} from '@material-tailwind/react';
import { ProjectDetails } from '../../../../types/project';
import { useGQLClient } from '../../../../context/GQLClientContext';
interface DeleteProjectDialogProp {
open: boolean;
handleOpen: () => void;
project: Partial<ProjectDetails>;
project: Project;
}
const DeleteProjectDialog = ({
@ -26,6 +27,7 @@ const DeleteProjectDialog = ({
project,
}: DeleteProjectDialogProp) => {
const navigate = useNavigate();
const client = useGQLClient();
const {
handleSubmit,
@ -37,6 +39,18 @@ const DeleteProjectDialog = ({
},
});
const deleteProjectHandler = useCallback(async () => {
const { deleteProject } = await client.deleteProject(project.id);
if (deleteProject) {
navigate('/');
} else {
toast.error('Project not deleted');
}
handleOpen();
}, [client, project, handleOpen]);
return (
<Dialog open={open} handler={handleOpen}>
<DialogHeader className="flex justify-between">
@ -49,12 +63,7 @@ const DeleteProjectDialog = ({
X
</Button>
</DialogHeader>
<form
onSubmit={handleSubmit(() => {
handleOpen();
navigate('/');
})}
>
<form onSubmit={handleSubmit(deleteProjectHandler)}>
<DialogBody className="flex flex-col gap-2">
<Typography variant="paragraph">
Deleting your project is irreversible. Enter your projects

View File

@ -11,14 +11,10 @@ import {
Card,
} from '@material-tailwind/react';
import {
DomainDetails,
DomainStatus,
ProjectDetails,
RepositoryDetails,
} from '../../../../types/project';
import { ProjectDetails, RepositoryDetails } from '../../../../types/project';
import ConfirmDialog from '../../../shared/ConfirmDialog';
import EditDomainDialog from './EditDomainDialog';
import { Domain, DomainStatus } from 'gql-client';
enum RefreshStatus {
IDLE,
@ -28,13 +24,20 @@ enum RefreshStatus {
}
interface DomainCardProps {
domain: DomainDetails;
domain: Domain;
repo: RepositoryDetails;
project: ProjectDetails;
}
const CHECK_FAIL_TIMEOUT = 5000; // In milliseconds
// TODO: Get domain record
const DOMAIN_RECORD = {
type: 'A',
name: '@',
value: '56.49.19.21',
};
const DomainCard = ({ domain, repo, project }: DomainCardProps) => {
const [refreshStatus, SetRefreshStatus] = useState(RefreshStatus.IDLE);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -50,7 +53,7 @@ const DomainCard = ({ domain, repo, project }: DomainCardProps) => {
<Chip
className="w-fit capitalize"
value={domain.status}
color={domain.status === DomainStatus.LIVE ? 'green' : 'orange'}
color={domain.status === DomainStatus.Live ? 'green' : 'orange'}
variant="ghost"
icon={<i>^</i>}
/>
@ -106,7 +109,7 @@ const DomainCard = ({ domain, repo, project }: DomainCardProps) => {
<Typography variant="small">
Once deleted, the project{' '}
<span className="bg-blue-100 rounded-sm p-0.5 text-blue-700">
{project.title}
{project.name}
</span>{' '}
will not be accessible from the domain{' '}
<span className="bg-blue-100 rounded-sm p-0.5 text-blue-700">
@ -117,7 +120,7 @@ const DomainCard = ({ domain, repo, project }: DomainCardProps) => {
</div>
<Typography variant="small">Production</Typography>
{domain.status === DomainStatus.PENDING && (
{domain.status === DomainStatus.Pending && (
<Card className="bg-gray-200 p-4 text-sm">
{refreshStatus === RefreshStatus.IDLE ? (
<Typography variant="small">
@ -147,9 +150,9 @@ const DomainCard = ({ domain, repo, project }: DomainCardProps) => {
</thead>
<tbody>
<tr>
<td>{domain.record.type}</td>
<td>{domain.record.name}</td>
<td>{domain.record.value}</td>
<td>{DOMAIN_RECORD.type}</td>
<td>{DOMAIN_RECORD.name}</td>
<td>{DOMAIN_RECORD.value}</td>
</tr>
</tbody>
</table>

View File

@ -4,10 +4,7 @@ import { useParams, Link, useOutletContext } from 'react-router-dom';
import { Button, Typography } from '@material-tailwind/react';
import DomainCard from './DomainCard';
import {
DomainDetails,
ProjectSearchOutletContext,
} from '../../../../types/project';
import { ProjectSearchOutletContext } from '../../../../types/project';
const Domains = () => {
const { id } = useParams();
@ -21,7 +18,7 @@ const Domains = () => {
}, [id, projects]);
const linkedRepo = useMemo(() => {
return currentProject?.repositories.find(
return currentProject?.repositories?.find(
(repo: any) => repo.id === Number(currentProject?.repositoryId),
);
}, [currentProject]);
@ -43,7 +40,7 @@ const Domains = () => {
</Link>
</div>
{(domains as DomainDetails[]).map((domain) => {
{domains?.map((domain) => {
return (
<DomainCard
domain={domain}

View File

@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { Domain } from 'gql-client';
import {
Button,
@ -14,18 +15,14 @@ import {
Option,
} from '@material-tailwind/react';
import {
DomainDetails,
ProjectDetails,
RepositoryDetails,
} from '../../../../types/project';
import { ProjectDetails, RepositoryDetails } from '../../../../types/project';
const DEFAULT_REDIRECT_OPTIONS = ['none'];
interface EditDomainDialogProp {
open: boolean;
handleOpen: () => void;
domain: DomainDetails;
domain: Domain;
repo: RepositoryDetails;
project: ProjectDetails;
}
@ -37,7 +34,7 @@ const EditDomainDialog = ({
repo,
project,
}: EditDomainDialogProp) => {
const getRedirectUrl = (domain: DomainDetails) => {
const getRedirectUrl = (domain: Domain) => {
const domainArr = domain.name.split('www.');
let redirectUrl = '';
if (domain.name.startsWith('www.')) {
@ -79,7 +76,7 @@ const EditDomainDialog = ({
defaultValues: {
name: domain.name,
branch: repo.branch[0],
redirectedTo: !domain.isRedirectedto
redirectedTo: !domain.isRedirected
? redirectOptions[0]
: redirectOptions[1],
},

View File

@ -206,7 +206,7 @@ const GeneralTabPanel = ({
<DeleteProjectDialog
handleOpen={handleDeleteProjectDialog}
open={openDeleteDialog}
project={{ name: 'Iglootools' }}
project={project}
/>
</div>
</>

View File

@ -1,14 +1,41 @@
import React from 'react';
import { useOutletContext } from 'react-router-dom';
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Typography, Chip } from '@material-tailwind/react';
import ProjectCard from '../components/projects/ProjectCard';
import { ProjectSearchOutletContext } from '../types/project';
import { useGQLClient } from '../context/GQLClientContext';
import { ProjectDetails } from '../types/project';
// TODO: Implement organization switcher
const USER_ORGANIZATION_ID = '1';
const Projects = () => {
const { projects } = useOutletContext<ProjectSearchOutletContext>();
const client = useGQLClient();
const [projects, setProjects] = useState<ProjectDetails[]>([]);
const fetchProjects = useCallback(async () => {
const { projectsInOrganization } =
await client.getProjectsInOrganization(USER_ORGANIZATION_ID);
const updatedProjects = projectsInOrganization.map((project) => {
return {
...project,
// TODO: Populate from github API
latestCommit: {
message: 'subscription added',
createdAt: '2023-12-11T04:20:00',
branch: 'main',
},
};
});
setProjects(updatedProjects);
}, []);
useEffect(() => {
fetchProjects();
}, []);
return (
<div>
@ -33,7 +60,7 @@ const Projects = () => {
</div>
<div className="grid grid-cols-3 gap-5 p-5">
{projects.length !== 0 &&
projects.map((project: any, key: number) => {
projects.map((project, key) => {
return <ProjectCard project={project} key={key} />;
})}
</div>

View File

@ -1,29 +1,19 @@
import { Environment, EnvironmentVariable } from 'gql-client';
import { Environment, Project } from 'gql-client';
export interface ProjectDetails {
icon: string;
name: string;
title: string;
owner: Member;
organization: string;
description: string;
url: string;
domain: string | null;
id: string;
createdAt: string;
createdBy: string;
deployments: DeploymentDetails[];
source: string;
export interface ProjectDetails extends Project {
// TODO: isDomain flag
domain?: string | null;
// TODO: Use deployment branch
source?: string;
latestCommit: {
message: string;
createdAt: string;
branch: string;
};
repositoryId: number;
repositories: RepositoryDetails[];
members: ProjectMember[];
ownerId: number;
environmentVariables: EnvironmentVariable[];
// TODO: Move out of project
repositories?: RepositoryDetails[];
repositoryId?: number;
}
export interface ProjectMember {

View File

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

View File

@ -28,3 +28,9 @@ mutation ($deploymentId: String!) {
redeployToProd(deploymentId: $deploymentId)
}
`;
export const deleteProject = gql`
mutation ($projectId: String!) {
deleteProject(projectId: $projectId)
}
`;

View File

@ -25,11 +25,69 @@ query ($projectId: String!) {
framework
repository
webhooks
icon
owner {
id
name
email
}
deployments {
id
branch
isCurrent
status
title
updatedAt
commitHash
createdAt
environment
domain {
status
branch
createdAt
updatedAt
id
name
isRedirected
}
}
}
}
`;
export const getProjectsInOrganization = gql`
query ($organizationId: String!) {
projectsInOrganization(organizationId: $organizationId) {
id
name
createdAt
description
framework
prodBranch
webhooks
repository
updatedAt
icon
deployments {
id
branch
isCurrent
status
title
updatedAt
commitHash
createdAt
environment
domain {
status
branch
createdAt
updatedAt
id
name
isRedirected
}
}
}
}
`;

View File

@ -126,6 +126,7 @@ export type Project = {
createdAt: string
updatedAt: string
organization: Organization
icon: string
}
export type GetProjectMembersResponse = {
@ -156,6 +157,10 @@ export type GetProjectResponse = {
project: Project | null
}
export type GetProjectsInOrganizationResponse = {
projectsInOrganization: Project[]
}
export type SearchProjectsResponse = {
searchProjects: Project[]
}
@ -178,6 +183,10 @@ export type UpdateProjectResponse = {
updateProject: boolean;
}
export type DeleteProjectResponse = {
deleteProject: boolean;
}
export type UpdateProjectInput = {
name: string
description: string