From 1ae15648782e81a61147ec10c0ab84d9a75354f3 Mon Sep 17 00:00:00 2001 From: Nabarun Gogoi Date: Mon, 29 Jan 2024 16:31:03 +0530 Subject: [PATCH] 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 --- packages/backend/src/database.ts | 48 +++++++++++---- packages/backend/src/entity/Project.ts | 13 ++++- packages/backend/src/resolvers.ts | 18 +++++- packages/backend/src/schema.gql | 3 + packages/backend/test/fixtures/projects.json | 35 ++++++----- .../src/components/projects/ProjectCard.tsx | 4 +- .../projects/project/OverviewTabPanel.tsx | 4 +- .../project/settings/DeleteProjectDialog.tsx | 29 ++++++---- .../projects/project/settings/DomainCard.tsx | 29 +++++----- .../projects/project/settings/Domains.tsx | 9 +-- .../project/settings/EditDomainDialog.tsx | 13 ++--- .../project/settings/GeneralTabPanel.tsx | 2 +- packages/frontend/src/pages/index.tsx | 37 ++++++++++-- packages/frontend/src/types/project.ts | 30 ++++------ packages/gql-client/src/client.ts | 28 ++++++++- packages/gql-client/src/mutations.ts | 6 ++ packages/gql-client/src/queries.ts | 58 +++++++++++++++++++ packages/gql-client/src/types.ts | 9 +++ 18 files changed, 279 insertions(+), 96 deletions(-) diff --git a/packages/backend/src/database.ts b/packages/backend/src/database.ts index db08672b..4c0f39e7 100644 --- a/packages/backend/src/database.ts +++ b/packages/backend/src/database.ts @@ -78,22 +78,39 @@ export class Database { return projects; } - async getProjectByProjectId (projectId: string): Promise { + async getProjectById (projectId: string): Promise { 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 { + 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 { const deploymentRepository = this.dataSource.getRepository(Deployment); @@ -275,4 +292,15 @@ export class Database { return false; } } + + async deleteProjectById (projectId: string): Promise { + const projectRepository = this.dataSource.getRepository(Project); + const deleteResult = await projectRepository.softDelete({ id: projectId }); + + if (deleteResult.affected) { + return deleteResult.affected > 0; + } else { + return false; + } + } } diff --git a/packages/backend/src/entity/Project.ts b/packages/backend/src/entity/Project.ts index 889d29bd..8a9736d8 100644 --- a/packages/backend/src/entity/Project.ts +++ b/packages/backend/src/entity/Project.ts @@ -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[]; } diff --git a/packages/backend/src/resolvers.ts b/packages/backend/src/resolvers.ts index 7d049296..d28dbae4 100644 --- a/packages/backend/src/resolvers.ts +++ b/packages/backend/src/resolvers.ts @@ -50,9 +50,14 @@ export const createResolvers = async (db: Database): Promise => { }, 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 => { log(err); return false; } + }, + + deleteProject: async (_: any, { projectId }: { projectId: string }) => { + try { + return db.deleteProjectById(projectId); + } catch (err) { + log(err); + return false; + } } } }; diff --git a/packages/backend/src/schema.gql b/packages/backend/src/schema.gql index aaf678e3..2c9814eb 100644 --- a/packages/backend/src/schema.gql +++ b/packages/backend/src/schema.gql @@ -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 { diff --git a/packages/backend/test/fixtures/projects.json b/packages/backend/test/fixtures/projects.json index b6ae8dc3..0447b4c7 100644 --- a/packages/backend/test/fixtures/projects.json +++ b/packages/backend/test/fixtures/projects.json @@ -1,57 +1,62 @@ [ { - "ownerIndex":0, - "organizationIndex":0, + "ownerIndex": 0, + "organizationIndex": 0, "name": "testProject", "repository": "test", "prodBranch": "main", "description": "test", "template": "test", "framework": "test", - "webhooks": [] + "webhooks": [], + "icon": "" }, { - "ownerIndex":1, - "organizationIndex":0, + "ownerIndex": 1, + "organizationIndex": 0, "name": "testProject-2", "repository": "test-2", "prodBranch": "main", "description": "test-2", "template": "test-2", "framework": "test-2", - "webhooks": [] + "webhooks": [], + "icon": "" }, { - "ownerIndex":2, - "organizationIndex":0, + "ownerIndex": 2, + "organizationIndex": 0, "name": "iglootools", "repository": "test-3", "prodBranch": "main", "description": "test-3", "template": "test-3", "framework": "test-3", - "webhooks": [] + "webhooks": [], + "icon": "" }, { - "ownerIndex":1, - "organizationIndex":0, + "ownerIndex": 1, + "organizationIndex": 0, "name": "iglootools-2", "repository": "test-4", "prodBranch": "main", "description": "test-4", "template": "test-4", "framework": "test-4", - "webhooks": [] + "webhooks": [], + "icon": "" }, { - "ownerIndex":0, - "organizationIndex":1, + "ownerIndex": 0, + "organizationIndex": 1, "name": "snowball-2", "repository": "test-5", "prodBranch": "main", "description": "test-5", "template": "test-5", "framework": "test-5", - "webhooks": [] + "webhooks": [], + "icon": "" } ] diff --git a/packages/frontend/src/components/projects/ProjectCard.tsx b/packages/frontend/src/components/projects/ProjectCard.tsx index 338fa764..f9ec6970 100644 --- a/packages/frontend/src/components/projects/ProjectCard.tsx +++ b/packages/frontend/src/components/projects/ProjectCard.tsx @@ -25,7 +25,9 @@ const ProjectCard: React.FC = ({ project }) => { {project.name} - {project.url} + {project.deployments[0]?.domain.name + ? project.deployments[0]?.domain.name + : ''} diff --git a/packages/frontend/src/components/projects/project/OverviewTabPanel.tsx b/packages/frontend/src/components/projects/project/OverviewTabPanel.tsx index efbc495b..a638d6e4 100644 --- a/packages/frontend/src/components/projects/project/OverviewTabPanel.tsx +++ b/packages/frontend/src/components/projects/project/OverviewTabPanel.tsx @@ -31,7 +31,9 @@ const OverviewTabPanel = ({ project, organizationProject }: OverviewProps) => {
{project.name} - {organizationProject.url} + {project.deployments[0]?.domain.name + ? project.deployments[0]?.domain.name + : ''}
diff --git a/packages/frontend/src/components/projects/project/settings/DeleteProjectDialog.tsx b/packages/frontend/src/components/projects/project/settings/DeleteProjectDialog.tsx index 4fdf6e34..99958282 100644 --- a/packages/frontend/src/components/projects/project/settings/DeleteProjectDialog.tsx +++ b/packages/frontend/src/components/projects/project/settings/DeleteProjectDialog.tsx @@ -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; + 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 ( @@ -49,12 +63,7 @@ const DeleteProjectDialog = ({ X -
{ - handleOpen(); - navigate('/'); - })} - > + Deleting your project is irreversible. Enter your project’s diff --git a/packages/frontend/src/components/projects/project/settings/DomainCard.tsx b/packages/frontend/src/components/projects/project/settings/DomainCard.tsx index 9064f121..088312ee 100644 --- a/packages/frontend/src/components/projects/project/settings/DomainCard.tsx +++ b/packages/frontend/src/components/projects/project/settings/DomainCard.tsx @@ -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) => { ^} /> @@ -106,7 +109,7 @@ const DomainCard = ({ domain, repo, project }: DomainCardProps) => { Once deleted, the project{' '} - {project.title} + {project.name} {' '} will not be accessible from the domain{' '} @@ -117,7 +120,7 @@ const DomainCard = ({ domain, repo, project }: DomainCardProps) => { Production - {domain.status === DomainStatus.PENDING && ( + {domain.status === DomainStatus.Pending && ( {refreshStatus === RefreshStatus.IDLE ? ( @@ -147,9 +150,9 @@ const DomainCard = ({ domain, repo, project }: DomainCardProps) => { - {domain.record.type} - {domain.record.name} - {domain.record.value} + {DOMAIN_RECORD.type} + {DOMAIN_RECORD.name} + {DOMAIN_RECORD.value} diff --git a/packages/frontend/src/components/projects/project/settings/Domains.tsx b/packages/frontend/src/components/projects/project/settings/Domains.tsx index c2ed9525..79a58114 100644 --- a/packages/frontend/src/components/projects/project/settings/Domains.tsx +++ b/packages/frontend/src/components/projects/project/settings/Domains.tsx @@ -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 = () => { - {(domains as DomainDetails[]).map((domain) => { + {domains?.map((domain) => { return ( 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], }, diff --git a/packages/frontend/src/components/projects/project/settings/GeneralTabPanel.tsx b/packages/frontend/src/components/projects/project/settings/GeneralTabPanel.tsx index 9ea09718..69f381fc 100644 --- a/packages/frontend/src/components/projects/project/settings/GeneralTabPanel.tsx +++ b/packages/frontend/src/components/projects/project/settings/GeneralTabPanel.tsx @@ -206,7 +206,7 @@ const GeneralTabPanel = ({ diff --git a/packages/frontend/src/pages/index.tsx b/packages/frontend/src/pages/index.tsx index a7ff7fa3..09cc0e67 100644 --- a/packages/frontend/src/pages/index.tsx +++ b/packages/frontend/src/pages/index.tsx @@ -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(); + const client = useGQLClient(); + const [projects, setProjects] = useState([]); + + 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 (
@@ -33,7 +60,7 @@ const Projects = () => {
{projects.length !== 0 && - projects.map((project: any, key: number) => { + projects.map((project, key) => { return ; })}
diff --git a/packages/frontend/src/types/project.ts b/packages/frontend/src/types/project.ts index 8fb4dbf3..2472f0d7 100644 --- a/packages/frontend/src/types/project.ts +++ b/packages/frontend/src/types/project.ts @@ -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 { diff --git a/packages/gql-client/src/client.ts b/packages/gql-client/src/client.ts index 39e6406b..585ea944 100644 --- a/packages/gql-client/src/client.ts +++ b/packages/gql-client/src/client.ts @@ -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 { + const { data } = await this.client.query({ + query: getProjectsInOrganization, + variables: { + organizationId + } + }); + + return data; + } + async getOrganizations () : Promise { const { data } = await this.client.query({ query: getOrganizations @@ -158,4 +169,15 @@ export class GQLClient { return data; } + + async deleteProject (projectId: string): Promise { + const { data } = await this.client.mutate({ + mutation: deleteProject, + variables: { + projectId + } + }); + + return data; + } } diff --git a/packages/gql-client/src/mutations.ts b/packages/gql-client/src/mutations.ts index 242b17b9..e4eae6a7 100644 --- a/packages/gql-client/src/mutations.ts +++ b/packages/gql-client/src/mutations.ts @@ -28,3 +28,9 @@ mutation ($deploymentId: String!) { redeployToProd(deploymentId: $deploymentId) } `; + +export const deleteProject = gql` +mutation ($projectId: String!) { + deleteProject(projectId: $projectId) +} +`; diff --git a/packages/gql-client/src/queries.ts b/packages/gql-client/src/queries.ts index 507a5a45..6df622fd 100644 --- a/packages/gql-client/src/queries.ts +++ b/packages/gql-client/src/queries.ts @@ -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 + } + } } } `; diff --git a/packages/gql-client/src/types.ts b/packages/gql-client/src/types.ts index a55fea2b..098728d4 100644 --- a/packages/gql-client/src/types.ts +++ b/packages/gql-client/src/types.ts @@ -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