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 { 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<Organization[]> {
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<Project[]> {
@ -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
})

View File

@ -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<any>
},
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 }) => {

View File

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

View File

@ -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<void>;
}

View File

@ -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<Domain[]>([]);
const { projects } = useOutletContext<ProjectSearchOutletContext>();
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}
/>
);

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 { 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<void>;
}) => {
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 = ({
</Link>
</Typography>
<form
onSubmit={handleTransfer(() => {
handleTransferProjectDialog();
onSubmit={handleTransfer(({ orgId }) => {
setSelectedTransferOrganization(orgId);
setOpenTransferDialog(!openTransferDialog);
})}
>
<Typography variant="small" className="font-medium text-gray-800">
Choose team
</Typography>
<Controller
name="team"
name="orgId"
rules={{ required: 'This field is required' }}
control={control}
render={({ field }) => (
<Select
<AsyncSelect
{...field}
// TODO: Implement placeholder for select
label={!field.value ? 'Select an account / team' : ''}
>
{TEAMS.map((team, key) => (
<Option key={key} value={team}>
^ {team}
{transferOrganizations.map((org, key) => (
<Option key={key} value={org.id}>
^ {org.name}
</Option>
))}
</Select>
</AsyncSelect>
)}
/>
<Button
@ -177,15 +213,15 @@ const GeneralTabPanel = ({
</form>
<ConfirmDialog
dialogTitle="Transfer project"
handleOpen={handleTransferProjectDialog}
handleOpen={() => setOpenTransferDialog(!openTransferDialog)}
open={openTransferDialog}
confirmButtonTitle="Yes, Confirm transfer"
handleConfirm={handleTransferProjectDialog}
handleConfirm={handleTransferProject}
color="blue"
>
<Typography variant="small">
Upon confirmation, your project nextjs-boilerplate will be
transferred from saugat to Airfoil.
Upon confirmation, your project {project.name} will be transferred
from {project.organization.name} to {selectedUserOrgName}.
</Typography>
</ConfirmDialog>
</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 { Environment } from 'gql-client';
import { IconButton, Typography } from '@material-tailwind/react';
import HorizontalLine from '../components/HorizontalLine';
import ProjectSearchBar from '../components/projects/ProjectSearchBar';
import { useGQLClient } from '../context/GQLClientContext';
import { ProjectDetails } from '../types/project';
const ProjectSearch = () => {
const client = useGQLClient();
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 (
<div>
@ -107,7 +33,7 @@ const ProjectSearch = () => {
<HorizontalLine />
</div>
<div className="z-0">
<Outlet context={{ projects }} />
<Outlet />
</div>
</div>
);

View File

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

View File

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