From 8111d34d860f88a0af0f5cda3df1e5942925b442 Mon Sep 17 00:00:00 2001 From: Nabarun Gogoi Date: Fri, 2 Feb 2024 14:04:26 +0530 Subject: [PATCH] Implement functionality to transfer project to different organization (#50) * Add GQL mutation for transfer project * Integrate transfer project GQL client method * Use update project GQL method for transfer project --------- Co-authored-by: neeraj --- packages/backend/src/database.ts | 19 ++-- packages/backend/src/resolvers.ts | 34 +------ packages/backend/src/schema.gql | 1 + .../projects/project/settings/DomainCard.tsx | 6 +- .../projects/project/settings/Domains.tsx | 25 ++--- .../project/settings/GeneralTabPanel.tsx | 92 +++++++++++++------ .../src/components/shared/AsyncSelect.tsx | 15 +++ .../frontend/src/layouts/ProjectSearch.tsx | 78 +--------------- packages/gql-client/src/queries.ts | 34 ++----- packages/gql-client/src/types.ts | 1 + 10 files changed, 109 insertions(+), 196 deletions(-) create mode 100644 packages/frontend/src/components/shared/AsyncSelect.tsx diff --git a/packages/backend/src/database.ts b/packages/backend/src/database.ts index 5a9a4bfd..f8dc062c 100644 --- a/packages/backend/src/database.ts +++ b/packages/backend/src/database.ts @@ -6,7 +6,6 @@ import assert from 'assert'; import { DatabaseConfig } from './config'; import { User } from './entity/User'; import { Organization } from './entity/Organization'; -import { UserOrganization } from './entity/UserOrganization'; import { Project } from './entity/Project'; import { Deployment, Environment } from './entity/Deployment'; import { Permission, ProjectMember } from './entity/ProjectMember'; @@ -52,22 +51,19 @@ export class Database { } async getOrganizationsByUserId (userId: number): Promise { - const userOrganizationRepository = this.dataSource.getRepository(UserOrganization); + const organizationRepository = this.dataSource.getRepository(Organization); - const userOrgs = await userOrganizationRepository.find({ - relations: { - member: true, - organization: true - }, + const userOrgs = await organizationRepository.find({ where: { - member: { - id: userId + userOrganizations: { + member: { + id: userId + } } } }); - const organizations = userOrgs.map(userOrg => userOrg.organization); - return organizations; + return userOrgs; } async getProjectsByOrganizationId (organizationId: number): Promise { @@ -96,6 +92,7 @@ export class Database { .leftJoinAndSelect('project.deployments', 'deployments', 'deployments.isCurrent = true') .leftJoinAndSelect('deployments.domain', 'domain') .leftJoinAndSelect('project.owner', 'owner') + .leftJoinAndSelect('project.organization', 'organization') .where('project.id = :projectId', { projectId }) diff --git a/packages/backend/src/resolvers.ts b/packages/backend/src/resolvers.ts index 1ee02292..a6d0de31 100644 --- a/packages/backend/src/resolvers.ts +++ b/packages/backend/src/resolvers.ts @@ -5,7 +5,7 @@ import { DeepPartial } from 'typeorm'; import { OAuthApp } from '@octokit/oauth-app'; import { Database } from './database'; -import { deploymentToGqlType, projectMemberToGqlType, projectToGqlType, environmentVariableToGqlType, isUserOwner } from './utils'; +import { deploymentToGqlType, projectMemberToGqlType, projectToGqlType, isUserOwner } from './utils'; import { Environment } from './entity/Deployment'; import { Permission } from './entity/ProjectMember'; import { Domain } from './entity/Domain'; @@ -22,37 +22,7 @@ export const createResolvers = async (db: Database, app: OAuthApp): Promise }, organizations: async (_:any, __: any, context: any) => { - const organizations = await db.getOrganizationsByUserId(context.userId); - - const orgsWithProjectsPromises = organizations.map(async (org) => { - const dbProjects = await db.getProjectsByOrganizationId(org.id); - - const projectsPromises = dbProjects.map(async (dbProject) => { - const dbProjectMembers = await db.getProjectMembersByProjectId(dbProject.id); - const dbEnvironmentVariables = await db.getEnvironmentVariablesByProjectId(dbProject.id); - - const projectMembers = dbProjectMembers.map(dbProjectMember => { - return projectMemberToGqlType(dbProjectMember); - }); - - const environmentVariables = dbEnvironmentVariables.map(dbEnvironmentVariable => { - return environmentVariableToGqlType(dbEnvironmentVariable); - }); - - return projectToGqlType(dbProject, projectMembers, environmentVariables); - }); - - const projects = await Promise.all(projectsPromises); - - return { - ...org, - projects - }; - }); - - // TODO: Add organizationMembers field when / if required - const orgsWithProjects = await Promise.all(orgsWithProjectsPromises); - return orgsWithProjects; + return db.getOrganizationsByUserId(context.userId); }, project: async (_: any, { projectId }: { projectId: string }) => { diff --git a/packages/backend/src/schema.gql b/packages/backend/src/schema.gql index de379699..4015d293 100644 --- a/packages/backend/src/schema.gql +++ b/packages/backend/src/schema.gql @@ -137,6 +137,7 @@ input UpdateProjectInput { name: String description: String prodBranch: String + organizationId: String webhooks: [String!] } diff --git a/packages/frontend/src/components/projects/project/settings/DomainCard.tsx b/packages/frontend/src/components/projects/project/settings/DomainCard.tsx index 21ce9a33..3680dff7 100644 --- a/packages/frontend/src/components/projects/project/settings/DomainCard.tsx +++ b/packages/frontend/src/components/projects/project/settings/DomainCard.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import toast from 'react-hot-toast'; -import { Domain, DomainStatus } from 'gql-client'; +import { Domain, DomainStatus, Project } from 'gql-client'; import { Chip, @@ -12,7 +12,7 @@ import { Card, } from '@material-tailwind/react'; -import { ProjectDetails, RepositoryDetails } from '../../../../types/project'; +import { RepositoryDetails } from '../../../../types/project'; import ConfirmDialog from '../../../shared/ConfirmDialog'; import EditDomainDialog from './EditDomainDialog'; import { useGQLClient } from '../../../../context/GQLClientContext'; @@ -28,7 +28,7 @@ interface DomainCardProps { domains: Domain[]; domain: Domain; repo: RepositoryDetails; - project: ProjectDetails; + project: Project; onUpdate: () => Promise; } diff --git a/packages/frontend/src/components/projects/project/settings/Domains.tsx b/packages/frontend/src/components/projects/project/settings/Domains.tsx index 7869a868..b95cc885 100644 --- a/packages/frontend/src/components/projects/project/settings/Domains.tsx +++ b/packages/frontend/src/components/projects/project/settings/Domains.tsx @@ -1,34 +1,23 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { useParams, Link, useOutletContext } from 'react-router-dom'; -import { Domain } from 'gql-client'; +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Domain, Project } from 'gql-client'; import { Button, Typography } from '@material-tailwind/react'; import DomainCard from './DomainCard'; -import { ProjectSearchOutletContext } from '../../../../types/project'; import { useGQLClient } from '../../../../context/GQLClientContext'; import repositories from '../../../../assets/repositories.json'; -const Domains = () => { - const { id } = useParams(); +const Domains = ({ project }: { project: Project }) => { const client = useGQLClient(); - const [domains, setDomains] = useState([]); - const { projects } = useOutletContext(); - - const currentProject = useMemo(() => { - return projects.find((project) => { - return project.id === id; - }); - }, [id, projects]); - const fetchDomains = async () => { - if (currentProject === undefined) { + if (project === undefined) { return; } - const fetchedDomains = await client.getDomains(currentProject.id); + const fetchedDomains = await client.getDomains(project.id); setDomains(fetchedDomains.domains); }; @@ -55,7 +44,7 @@ const Domains = () => { key={domain.id} // TODO: Use github API for getting linked repository repo={repositories[0]!} - project={currentProject!} + project={project} onUpdate={fetchDomains} /> ); diff --git a/packages/frontend/src/components/projects/project/settings/GeneralTabPanel.tsx b/packages/frontend/src/components/projects/project/settings/GeneralTabPanel.tsx index 69f381fc..377d77f5 100644 --- a/packages/frontend/src/components/projects/project/settings/GeneralTabPanel.tsx +++ b/packages/frontend/src/components/projects/project/settings/GeneralTabPanel.tsx @@ -1,23 +1,15 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { useForm, Controller } from 'react-hook-form'; import toast from 'react-hot-toast'; -import { Project } from 'gql-client'; +import { Organization, Project } from 'gql-client'; -import { - Button, - Typography, - Input, - Select, - Option, -} from '@material-tailwind/react'; +import { Button, Typography, Input, Option } from '@material-tailwind/react'; import DeleteProjectDialog from './DeleteProjectDialog'; import ConfirmDialog from '../../../shared/ConfirmDialog'; import { useGQLClient } from '../../../../context/GQLClientContext'; - -const TEAMS = ['Airfoil']; -const DEFAULT_SELECT_TEAM = undefined; +import AsyncSelect from '../../../shared/AsyncSelect'; const CopyIcon = ({ value }: { value: string }) => { return ( @@ -41,22 +33,26 @@ const GeneralTabPanel = ({ onUpdate: () => Promise; }) => { const client = useGQLClient(); + const [transferOrganizations, setTransferOrganizations] = useState< + Organization[] + >([]); + const [selectedTransferOrganization, setSelectedTransferOrganization] = + useState(''); const { handleSubmit: handleTransfer, control, formState, + reset: transferFormReset, } = useForm({ defaultValues: { - team: DEFAULT_SELECT_TEAM, + orgId: '', }, }); const [openTransferDialog, setOpenTransferDialog] = useState(false); - const handleTransferProjectDialog = () => - setOpenTransferDialog(!openTransferDialog); - const [openDeleteDialog, setOpenDeleteDialog] = useState(false); + const handleDeleteProjectDialog = () => setOpenDeleteDialog(!openDeleteDialog); @@ -72,6 +68,45 @@ const GeneralTabPanel = ({ }, }); + const fetchUserOrganizations = useCallback(async () => { + const { organizations } = await client.getOrganizations(); + const orgsToTransfer = organizations.filter( + (org) => org.id !== project.organization.id, + ); + setTransferOrganizations(orgsToTransfer); + }, [project]); + + const handleTransferProject = useCallback(async () => { + const { updateProject: isTransferred } = await client.updateProject( + project.id, + { + organizationId: selectedTransferOrganization, + }, + ); + setOpenTransferDialog(!openTransferDialog); + + if (isTransferred) { + toast.success('Project transferred'); + await fetchUserOrganizations(); + await onUpdate(); + transferFormReset(); + } else { + toast.error('Project not transrfered'); + } + }, [project, selectedTransferOrganization]); + + const selectedUserOrgName = useMemo(() => { + return ( + transferOrganizations.find( + (org) => org.id === selectedTransferOrganization, + )?.name || '' + ); + }, [transferOrganizations, selectedTransferOrganization]); + + useEffect(() => { + fetchUserOrganizations(); + }, [project]); + useEffect(() => { reset({ appName: project.name, description: project.description }); }, [project]); @@ -140,29 +175,30 @@ const GeneralTabPanel = ({
{ - handleTransferProjectDialog(); + onSubmit={handleTransfer(({ orgId }) => { + setSelectedTransferOrganization(orgId); + setOpenTransferDialog(!openTransferDialog); })} > Choose team ( - + )} />