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 <neeraj.rtly@gmail.com>
This commit is contained in:
Nabarun Gogoi 2024-02-02 14:04:26 +05:30 committed by GitHub
parent ef89d69577
commit 8111d34d86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 109 additions and 196 deletions

View File

@ -6,7 +6,6 @@ import assert from 'assert';
import { DatabaseConfig } from './config'; import { DatabaseConfig } from './config';
import { User } from './entity/User'; import { User } from './entity/User';
import { Organization } from './entity/Organization'; import { Organization } from './entity/Organization';
import { UserOrganization } from './entity/UserOrganization';
import { Project } from './entity/Project'; import { Project } from './entity/Project';
import { Deployment, Environment } from './entity/Deployment'; import { Deployment, Environment } from './entity/Deployment';
import { Permission, ProjectMember } from './entity/ProjectMember'; import { Permission, ProjectMember } from './entity/ProjectMember';
@ -52,22 +51,19 @@ export class Database {
} }
async getOrganizationsByUserId (userId: number): Promise<Organization[]> { async getOrganizationsByUserId (userId: number): Promise<Organization[]> {
const userOrganizationRepository = this.dataSource.getRepository(UserOrganization); const organizationRepository = this.dataSource.getRepository(Organization);
const userOrgs = await userOrganizationRepository.find({ const userOrgs = await organizationRepository.find({
relations: {
member: true,
organization: true
},
where: { where: {
userOrganizations: {
member: { member: {
id: userId id: userId
} }
} }
}
}); });
const organizations = userOrgs.map(userOrg => userOrg.organization); return userOrgs;
return organizations;
} }
async getProjectsByOrganizationId (organizationId: number): Promise<Project[]> { async getProjectsByOrganizationId (organizationId: number): Promise<Project[]> {
@ -96,6 +92,7 @@ export class Database {
.leftJoinAndSelect('project.deployments', 'deployments', 'deployments.isCurrent = true') .leftJoinAndSelect('project.deployments', 'deployments', 'deployments.isCurrent = true')
.leftJoinAndSelect('deployments.domain', 'domain') .leftJoinAndSelect('deployments.domain', 'domain')
.leftJoinAndSelect('project.owner', 'owner') .leftJoinAndSelect('project.owner', 'owner')
.leftJoinAndSelect('project.organization', 'organization')
.where('project.id = :projectId', { .where('project.id = :projectId', {
projectId projectId
}) })

View File

@ -5,7 +5,7 @@ import { DeepPartial } from 'typeorm';
import { OAuthApp } from '@octokit/oauth-app'; import { OAuthApp } from '@octokit/oauth-app';
import { Database } from './database'; 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 { Environment } from './entity/Deployment';
import { Permission } from './entity/ProjectMember'; import { Permission } from './entity/ProjectMember';
import { Domain } from './entity/Domain'; import { Domain } from './entity/Domain';
@ -22,37 +22,7 @@ export const createResolvers = async (db: Database, app: OAuthApp): Promise<any>
}, },
organizations: async (_:any, __: any, context: any) => { organizations: async (_:any, __: any, context: any) => {
const organizations = await db.getOrganizationsByUserId(context.userId); return 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;
}, },
project: async (_: any, { projectId }: { projectId: string }) => { project: async (_: any, { projectId }: { projectId: string }) => {

View File

@ -137,6 +137,7 @@ input UpdateProjectInput {
name: String name: String
description: String description: String
prodBranch: String prodBranch: String
organizationId: String
webhooks: [String!] webhooks: [String!]
} }

View File

@ -1,6 +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 { Domain, DomainStatus, Project } from 'gql-client';
import { import {
Chip, Chip,
@ -12,7 +12,7 @@ import {
Card, Card,
} from '@material-tailwind/react'; } from '@material-tailwind/react';
import { ProjectDetails, RepositoryDetails } from '../../../../types/project'; import { RepositoryDetails } from '../../../../types/project';
import ConfirmDialog from '../../../shared/ConfirmDialog'; import ConfirmDialog from '../../../shared/ConfirmDialog';
import EditDomainDialog from './EditDomainDialog'; import EditDomainDialog from './EditDomainDialog';
import { useGQLClient } from '../../../../context/GQLClientContext'; import { useGQLClient } from '../../../../context/GQLClientContext';
@ -28,7 +28,7 @@ interface DomainCardProps {
domains: Domain[]; domains: Domain[];
domain: Domain; domain: Domain;
repo: RepositoryDetails; repo: RepositoryDetails;
project: ProjectDetails; project: Project;
onUpdate: () => Promise<void>; onUpdate: () => Promise<void>;
} }

View File

@ -1,34 +1,23 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Domain } from 'gql-client'; import { Domain, Project } from 'gql-client';
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 { useGQLClient } from '../../../../context/GQLClientContext'; import { useGQLClient } from '../../../../context/GQLClientContext';
import repositories from '../../../../assets/repositories.json'; import repositories from '../../../../assets/repositories.json';
const Domains = () => { const Domains = ({ project }: { project: Project }) => {
const { id } = useParams();
const client = useGQLClient(); const client = useGQLClient();
const [domains, setDomains] = useState<Domain[]>([]); const [domains, setDomains] = useState<Domain[]>([]);
const { projects } = useOutletContext<ProjectSearchOutletContext>();
const currentProject = useMemo(() => {
return projects.find((project) => {
return project.id === id;
});
}, [id, projects]);
const fetchDomains = async () => { const fetchDomains = async () => {
if (currentProject === undefined) { if (project === undefined) {
return; return;
} }
const fetchedDomains = await client.getDomains(currentProject.id); const fetchedDomains = await client.getDomains(project.id);
setDomains(fetchedDomains.domains); setDomains(fetchedDomains.domains);
}; };
@ -55,7 +44,7 @@ const Domains = () => {
key={domain.id} key={domain.id}
// TODO: Use github API for getting linked repository // TODO: Use github API for getting linked repository
repo={repositories[0]!} repo={repositories[0]!}
project={currentProject!} project={project}
onUpdate={fetchDomains} onUpdate={fetchDomains}
/> />
); );

View File

@ -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 { Link } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Project } from 'gql-client'; import { Organization, Project } from 'gql-client';
import { import { Button, Typography, Input, Option } from '@material-tailwind/react';
Button,
Typography,
Input,
Select,
Option,
} from '@material-tailwind/react';
import DeleteProjectDialog from './DeleteProjectDialog'; import DeleteProjectDialog from './DeleteProjectDialog';
import ConfirmDialog from '../../../shared/ConfirmDialog'; import ConfirmDialog from '../../../shared/ConfirmDialog';
import { useGQLClient } from '../../../../context/GQLClientContext'; import { useGQLClient } from '../../../../context/GQLClientContext';
import AsyncSelect from '../../../shared/AsyncSelect';
const TEAMS = ['Airfoil'];
const DEFAULT_SELECT_TEAM = undefined;
const CopyIcon = ({ value }: { value: string }) => { const CopyIcon = ({ value }: { value: string }) => {
return ( return (
@ -41,22 +33,26 @@ const GeneralTabPanel = ({
onUpdate: () => Promise<void>; onUpdate: () => Promise<void>;
}) => { }) => {
const client = useGQLClient(); const client = useGQLClient();
const [transferOrganizations, setTransferOrganizations] = useState<
Organization[]
>([]);
const [selectedTransferOrganization, setSelectedTransferOrganization] =
useState('');
const { const {
handleSubmit: handleTransfer, handleSubmit: handleTransfer,
control, control,
formState, formState,
reset: transferFormReset,
} = useForm({ } = useForm({
defaultValues: { defaultValues: {
team: DEFAULT_SELECT_TEAM, orgId: '',
}, },
}); });
const [openTransferDialog, setOpenTransferDialog] = useState(false); const [openTransferDialog, setOpenTransferDialog] = useState(false);
const handleTransferProjectDialog = () =>
setOpenTransferDialog(!openTransferDialog);
const [openDeleteDialog, setOpenDeleteDialog] = useState(false); const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
const handleDeleteProjectDialog = () => const handleDeleteProjectDialog = () =>
setOpenDeleteDialog(!openDeleteDialog); 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(() => { useEffect(() => {
reset({ appName: project.name, description: project.description }); reset({ appName: project.name, description: project.description });
}, [project]); }, [project]);
@ -140,29 +175,30 @@ const GeneralTabPanel = ({
</Link> </Link>
</Typography> </Typography>
<form <form
onSubmit={handleTransfer(() => { onSubmit={handleTransfer(({ orgId }) => {
handleTransferProjectDialog(); setSelectedTransferOrganization(orgId);
setOpenTransferDialog(!openTransferDialog);
})} })}
> >
<Typography variant="small" className="font-medium text-gray-800"> <Typography variant="small" className="font-medium text-gray-800">
Choose team Choose team
</Typography> </Typography>
<Controller <Controller
name="team" name="orgId"
rules={{ required: 'This field is required' }} rules={{ required: 'This field is required' }}
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<Select <AsyncSelect
{...field} {...field}
// TODO: Implement placeholder for select // TODO: Implement placeholder for select
label={!field.value ? 'Select an account / team' : ''} label={!field.value ? 'Select an account / team' : ''}
> >
{TEAMS.map((team, key) => ( {transferOrganizations.map((org, key) => (
<Option key={key} value={team}> <Option key={key} value={org.id}>
^ {team} ^ {org.name}
</Option> </Option>
))} ))}
</Select> </AsyncSelect>
)} )}
/> />
<Button <Button
@ -177,15 +213,15 @@ const GeneralTabPanel = ({
</form> </form>
<ConfirmDialog <ConfirmDialog
dialogTitle="Transfer project" dialogTitle="Transfer project"
handleOpen={handleTransferProjectDialog} handleOpen={() => setOpenTransferDialog(!openTransferDialog)}
open={openTransferDialog} open={openTransferDialog}
confirmButtonTitle="Yes, Confirm transfer" confirmButtonTitle="Yes, Confirm transfer"
handleConfirm={handleTransferProjectDialog} handleConfirm={handleTransferProject}
color="blue" color="blue"
> >
<Typography variant="small"> <Typography variant="small">
Upon confirmation, your project nextjs-boilerplate will be Upon confirmation, your project {project.name} will be transferred
transferred from saugat to Airfoil. from {project.organization.name} to {selectedUserOrgName}.
</Typography> </Typography>
</ConfirmDialog> </ConfirmDialog>
</div> </div>

View File

@ -0,0 +1,15 @@
// https://github.com/creativetimofficial/material-tailwind/issues/419#issuecomment-1760474312
import React, { useEffect, useState } from 'react';
import { Select, SelectProps } from '@material-tailwind/react';
const AsyncSelect = React.forwardRef((props: SelectProps, ref: any) => {
const [key, setKey] = useState(0);
useEffect(() => setKey((preVal) => preVal + 1), [props]);
return <Select key={key} ref={ref} {...props} />;
});
AsyncSelect.displayName = 'AsyncSelect';
export default AsyncSelect;

View File

@ -1,87 +1,13 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import { Environment } from 'gql-client';
import { IconButton, Typography } from '@material-tailwind/react'; import { IconButton, Typography } from '@material-tailwind/react';
import HorizontalLine from '../components/HorizontalLine'; import HorizontalLine from '../components/HorizontalLine';
import ProjectSearchBar from '../components/projects/ProjectSearchBar'; import ProjectSearchBar from '../components/projects/ProjectSearchBar';
import { useGQLClient } from '../context/GQLClientContext';
import { ProjectDetails } from '../types/project';
const ProjectSearch = () => { const ProjectSearch = () => {
const client = useGQLClient();
const navigate = useNavigate(); const navigate = useNavigate();
const [projects, setProjects] = useState<ProjectDetails[]>([]);
useEffect(() => {
const fetch = async () => {
const { organizations } = await client.getOrganizations();
// Note: select first organization as organization switching not yet implemented
const projects = organizations[0].projects || [];
const orgName = organizations[0].name || '';
const updatedProjectsPromises = projects.map(async (project: any) => {
const { deployments } = await client.getDeployments(String(project.id));
const updatedDeployments = deployments.map((deployment: any) => {
return {
...deployment,
isProduction: deployment.environment === Environment.Production,
author: '',
commit: {
hash: '',
message: '',
},
domain: deployment.domain
? {
...deployment.domain,
record: {
type: '',
name: '',
value: '',
},
}
: null,
};
});
return {
...project,
// TODO: populate empty fields
icon: '',
organization: orgName,
deployments: updatedDeployments,
url: '',
domain: null,
createdBy: project.owner.name,
source: '',
repositoryId: 0,
repositories: [
{
id: 0,
title: project.repository,
updatedAt: '',
user: '',
private: false,
branch: [''],
},
],
// TODO: populate from github API
latestCommit: {
message: '',
createdAt: '',
branch: '',
},
};
});
const updatedProjects = await Promise.all(updatedProjectsPromises);
setProjects(updatedProjects);
};
fetch();
}, [client]);
return ( return (
<div> <div>
@ -107,7 +33,7 @@ const ProjectSearch = () => {
<HorizontalLine /> <HorizontalLine />
</div> </div>
<div className="z-0"> <div className="z-0">
<Outlet context={{ projects }} /> <Outlet />
</div> </div>
</div> </div>
); );

View File

@ -27,6 +27,10 @@ query ($projectId: String!) {
repository repository
webhooks webhooks
icon icon
organization {
id
name
}
owner { owner {
id id
name name
@ -96,35 +100,9 @@ query {
organizations { organizations {
id id
name name
projects {
id
owner {
id
name
}
deployments {
id
}
name
repository
prodBranch
description
template
framework
webhooks
members {
id
permissions
member{
id
name
email
}
}
createdAt createdAt
updatedAt updatedAt
} }
}
} }
`; `;

View File

@ -249,6 +249,7 @@ export type UpdateProjectInput = {
description?: string description?: string
prodBranch?: string prodBranch?: string
webhooks?: string[] webhooks?: string[]
organizationId?: string
} }
export type UpdateDomainInput = { export type UpdateDomainInput = {