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:
parent
ef89d69577
commit
8111d34d86
@ -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
|
||||
})
|
||||
|
@ -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 }) => {
|
||||
|
@ -137,6 +137,7 @@ input UpdateProjectInput {
|
||||
name: String
|
||||
description: String
|
||||
prodBranch: String
|
||||
organizationId: String
|
||||
webhooks: [String!]
|
||||
}
|
||||
|
||||
|
@ -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>;
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
@ -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>
|
||||
|
15
packages/frontend/src/components/shared/AsyncSelect.tsx
Normal file
15
packages/frontend/src/components/shared/AsyncSelect.tsx
Normal 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;
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -249,6 +249,7 @@ export type UpdateProjectInput = {
|
||||
description?: string
|
||||
prodBranch?: string
|
||||
webhooks?: string[]
|
||||
organizationId?: string
|
||||
}
|
||||
|
||||
export type UpdateDomainInput = {
|
||||
|
Loading…
Reference in New Issue
Block a user