Handle remove member operation in frontend (#32)

* Add remove member gql client method

* Handle remove member UI operation

* Refactor fetching of project members

* Rename type MemberPermission to ProjectMember

* Add types to gql client response

* Remove circular dependency in gql client types

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
This commit is contained in:
prathamesh0 2024-01-23 17:17:02 +05:30 committed by Ashwin Phatak
parent 02f7ebb9bd
commit cbc394f9f8
15 changed files with 286 additions and 56 deletions

View File

@ -12,12 +12,12 @@ import {
Checkbox, Checkbox,
} from '@material-tailwind/react'; } from '@material-tailwind/react';
import { Member, Permission } from '../../../../types/project'; import { ProjectMember, Permission } from '../../../../types/project';
interface AddMemberDialogProp { interface AddMemberDialogProp {
open: boolean; open: boolean;
handleOpen: () => void; handleOpen: () => void;
handleAddMember: (member: Member) => void; handleAddMember: (projectMember: ProjectMember) => void;
} }
interface formData { interface formData {
@ -52,13 +52,17 @@ const AddMemberDialog = ({
reset(); reset();
handleOpen(); handleOpen();
const member: Member = { const projectMember: ProjectMember = {
email: data.emailAddress, id: Math.random().toString(),
id: Math.random(), permissions: [],
member: {
name: '', name: '',
email: data.emailAddress,
id: Math.random().toString(),
},
}; };
handleAddMember(member); handleAddMember(projectMember);
}, []); }, []);
return ( return (

View File

@ -6,13 +6,13 @@ import { Button, Typography } from '@material-tailwind/react';
import DomainCard from './DomainCard'; import DomainCard from './DomainCard';
import { import {
DomainDetails, DomainDetails,
ProjectsOutletContext, ProjectSearchOutletContext,
} from '../../../../types/project'; } from '../../../../types/project';
const Domains = () => { const Domains = () => {
const { id } = useParams(); const { id } = useParams();
const { projects } = useOutletContext<ProjectsOutletContext>(); const { projects } = useOutletContext<ProjectSearchOutletContext>();
const currentProject = useMemo(() => { const currentProject = useMemo(() => {
return projects.find((project) => { return projects.find((project) => {

View File

@ -17,7 +17,7 @@ import DisplayEnvironmentVariables from './DisplayEnvironmentVariables';
import { import {
EnvironmentVariable, EnvironmentVariable,
Environments, Environments,
ProjectsOutletContext, ProjectSearchOutletContext,
} from '../../../../types/project'; } from '../../../../types/project';
import HorizontalLine from '../../../HorizontalLine'; import HorizontalLine from '../../../HorizontalLine';
@ -36,7 +36,7 @@ export type EnvironmentVariablesFormValues = {
export const EnvironmentVariablesTabPanel = () => { export const EnvironmentVariablesTabPanel = () => {
const { id } = useParams(); const { id } = useParams();
const { projects } = useOutletContext<ProjectsOutletContext>(); const { projects } = useOutletContext<ProjectSearchOutletContext>();
const currentProject = useMemo(() => { const currentProject = useMemo(() => {
return projects.find((project) => { return projects.find((project) => {

View File

@ -13,7 +13,7 @@ import {
import DeleteProjectDialog from './DeleteProjectDialog'; import DeleteProjectDialog from './DeleteProjectDialog';
import ConfirmDialog from '../../../shared/ConfirmDialog'; import ConfirmDialog from '../../../shared/ConfirmDialog';
import { ProjectsOutletContext } from '../../../../types/project'; import { ProjectSearchOutletContext } from '../../../../types/project';
const TEAMS = ['Airfoil']; const TEAMS = ['Airfoil'];
const DEFAULT_SELECT_TEAM = undefined; const DEFAULT_SELECT_TEAM = undefined;
@ -34,7 +34,7 @@ const CopyIcon = ({ value }: { value: string }) => {
const GeneralTabPanel = () => { const GeneralTabPanel = () => {
const { id } = useParams(); const { id } = useParams();
const { projects } = useOutletContext<ProjectsOutletContext>(); const { projects } = useOutletContext<ProjectSearchOutletContext>();
const currentProject = useMemo(() => { const currentProject = useMemo(() => {
return projects.find((project: any) => project.id === id); return projects.find((project: any) => project.id === id);

View File

@ -1,5 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import toast from 'react-hot-toast';
import { import {
Select, Select,
@ -34,7 +33,8 @@ interface MemberCardProps {
isOwner: boolean; isOwner: boolean;
isPending: boolean; isPending: boolean;
permissions: string[]; permissions: string[];
handleDeletePendingMember: (id: number) => void; handleDeletePendingMember: (id: string) => void;
removeMemberHandler: () => Promise<void>;
} }
const MemberCard = ({ const MemberCard = ({
@ -44,6 +44,7 @@ const MemberCard = ({
isPending, isPending,
permissions, permissions,
handleDeletePendingMember, handleDeletePendingMember,
removeMemberHandler,
}: MemberCardProps) => { }: MemberCardProps) => {
const [selectedPermission, setSelectedPermission] = useState( const [selectedPermission, setSelectedPermission] = useState(
permissions.join('+'), permissions.join('+'),
@ -127,7 +128,7 @@ const MemberCard = ({
confirmButtonTitle="Yes, Remove member" confirmButtonTitle="Yes, Remove member"
handleConfirm={() => { handleConfirm={() => {
setRemoveMemberDialogOpen((preVal) => !preVal); setRemoveMemberDialogOpen((preVal) => !preVal);
toast.success('Member removed from project'); removeMemberHandler();
}} }}
color="red" color="red"
> >

View File

@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useOutletContext, useParams } from 'react-router-dom'; import { useOutletContext, useParams } from 'react-router-dom';
import toast, { Toaster } from 'react-hot-toast'; import toast, { Toaster } from 'react-hot-toast';
@ -6,31 +6,60 @@ import { Chip, Button, Typography } from '@material-tailwind/react';
import MemberCard from './MemberCard'; import MemberCard from './MemberCard';
import { Member } from '../../../../types/project'; import {
ProjectMember,
ProjectSearchOutletContext,
} from '../../../../types/project';
import AddMemberDialog from './AddMemberDialog'; import AddMemberDialog from './AddMemberDialog';
import { useGQLClient } from '../../../../context/GQLClientContext';
const FIRST_MEMBER_CARD = 0; const FIRST_MEMBER_CARD = 0;
const MembersTabPanel = () => { const MembersTabPanel = () => {
const { id } = useParams(); const { id } = useParams();
const client = useGQLClient();
const [addmemberDialogOpen, setAddMemberDialogOpen] = useState(false); const [addmemberDialogOpen, setAddMemberDialogOpen] = useState(false);
// @ts-expect-error create context type for projects const { projects } = useOutletContext<ProjectSearchOutletContext>();
const { projects } = useOutletContext();
const currentProject = useMemo(() => { const currentProject = useMemo(() => {
return projects.find((project: any) => project.id === id); return projects.find((project) => project.id === id);
}, [id]); }, [id]);
const [updatedMembers, setUpdatedMembers] = useState([ const [projectMembers, setProjectMembers] = useState<ProjectMember[]>([]);
...currentProject?.members,
]);
const addMemberHandler = useCallback((member: Member) => { const addMemberHandler = useCallback((projectMember: ProjectMember) => {
setUpdatedMembers((val) => [...val, member]); setProjectMembers((val) => [...val, projectMember]);
toast.success('Invitation sent'); toast.success('Invitation sent');
}, []); }, []);
const fetchProjectMembers = useCallback(async () => {
if (currentProject) {
const { projectMembers } = await client.getProjectMembers(
currentProject.id,
);
setProjectMembers(projectMembers);
}
}, [currentProject]);
const removeMemberHandler = async (projectMemberId: string) => {
const { removeMember: isMemberRemoved } =
await client.removeMember(projectMemberId);
if (isMemberRemoved) {
toast.success('Member removed from project');
await fetchProjectMembers();
} else {
toast.error('Not able to remove member');
}
};
useEffect(() => {
fetchProjectMembers();
}, []);
return ( return (
<div className="p-2 mb-20"> <div className="p-2 mb-20">
<div className="flex justify-between mb-2"> <div className="flex justify-between mb-2">
@ -40,7 +69,7 @@ const MembersTabPanel = () => {
<Chip <Chip
className="normal-case ml-3 font-normal" className="normal-case ml-3 font-normal"
size="sm" size="sm"
value={updatedMembers.length} value={projectMembers.length}
/> />
</div> </div>
</div> </div>
@ -53,20 +82,23 @@ const MembersTabPanel = () => {
</Button> </Button>
</div> </div>
</div> </div>
{updatedMembers.map((member, index) => { {projectMembers.map((projectMember, index) => {
return ( return (
<MemberCard <MemberCard
member={member.member} member={projectMember.member}
key={member.id} key={projectMember.id}
isFirstCard={index === FIRST_MEMBER_CARD} isFirstCard={index === FIRST_MEMBER_CARD}
isOwner={member.member.id === currentProject?.owner.id} isOwner={projectMember.member.id === currentProject?.owner.id}
isPending={member.name === ''} isPending={projectMember.member.name === ''}
permissions={member.permissions} permissions={projectMember.permissions}
handleDeletePendingMember={(id: number) => { handleDeletePendingMember={(id: string) => {
setUpdatedMembers( setProjectMembers(
updatedMembers.filter((member) => member.id !== id), projectMembers.filter(
(projectMember) => projectMember.member.id !== id,
),
); );
}} }}
removeMemberHandler={() => removeMemberHandler(projectMember.id)}
/> />
); );
})} })}

View File

@ -13,11 +13,11 @@ const ProjectSearch = () => {
useEffect(() => { useEffect(() => {
const fetch = async () => { const fetch = async () => {
const res = await client.getOrganizations(); const { organizations } = await client.getOrganizations();
// Note: select first organization as organization switching not yet implemented // Note: select first organization as organization switching not yet implemented
const projects = res.organizations[0]?.projects || []; const projects = organizations[0].projects || [];
const orgName = res.organizations[0]?.name || ''; const orgName = organizations[0].name || '';
const updatedProjectsPromises = projects.map(async (project: any) => { const updatedProjectsPromises = projects.map(async (project: any) => {
const { deployments } = await client.getDeployments(String(project.id)); const { deployments } = await client.getDeployments(String(project.id));

View File

@ -5,10 +5,10 @@ import { Link } from 'react-router-dom';
import { Button, Typography, Chip } from '@material-tailwind/react'; import { Button, Typography, Chip } from '@material-tailwind/react';
import ProjectCard from '../components/projects/ProjectCard'; import ProjectCard from '../components/projects/ProjectCard';
import { ProjectsOutletContext } from '../types/project'; import { ProjectSearchOutletContext } from '../types/project';
const Projects = () => { const Projects = () => {
const { projects } = useOutletContext<ProjectsOutletContext>(); const { projects } = useOutletContext<ProjectSearchOutletContext>();
return ( return (
<div> <div>

View File

@ -5,13 +5,13 @@ import { Button, Typography } from '@material-tailwind/react';
import HorizontalLine from '../../components/HorizontalLine'; import HorizontalLine from '../../components/HorizontalLine';
import ProjectTabs from '../../components/projects/project/ProjectTabs'; import ProjectTabs from '../../components/projects/project/ProjectTabs';
import { ProjectsOutletContext } from '../../types/project'; import { ProjectSearchOutletContext } from '../../types/project';
const Project = () => { const Project = () => {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { projects } = useOutletContext<ProjectsOutletContext>(); const { projects } = useOutletContext<ProjectSearchOutletContext>();
const project = useMemo(() => { const project = useMemo(() => {
return projects.find((project) => { return projects.find((project) => {

View File

@ -4,10 +4,10 @@ import { Link, useOutletContext } from 'react-router-dom';
import { Button, Typography, Chip } from '@material-tailwind/react'; import { Button, Typography, Chip } from '@material-tailwind/react';
import ProjectCard from '../../components/projects/ProjectCard'; import ProjectCard from '../../components/projects/ProjectCard';
import { ProjectsOutletContext } from '../../types/project'; import { ProjectSearchOutletContext } from '../../types/project';
const Projects = () => { const Projects = () => {
const { projects } = useOutletContext<ProjectsOutletContext>(); const { projects } = useOutletContext<ProjectSearchOutletContext>();
return ( return (
<div> <div>

View File

@ -2,6 +2,7 @@ export interface ProjectDetails {
icon: string; icon: string;
name: string; name: string;
title: string; title: string;
owner: Member;
organization: string; organization: string;
description: string; description: string;
url: string; url: string;
@ -18,13 +19,14 @@ export interface ProjectDetails {
}; };
repositoryId: number; repositoryId: number;
repositories: RepositoryDetails[]; repositories: RepositoryDetails[];
members: MemberPermission[]; members: ProjectMember[];
ownerId: number; ownerId: number;
environmentVariables: EnvironmentVariable[]; environmentVariables: EnvironmentVariable[];
} }
export interface MemberPermission { export interface ProjectMember {
id: number; id: string;
member: Member;
permissions: string[]; permissions: string[];
} }
@ -103,9 +105,9 @@ export enum Permission {
export interface Member { export interface Member {
name: string; name: string;
email: string; email: string;
id: number; id: string;
} }
export interface ProjectsOutletContext { export interface ProjectSearchOutletContext {
projects: ProjectDetails[]; projects: ProjectDetails[];
} }

View File

@ -1,22 +1,37 @@
import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client'; import { ApolloClient, DefaultOptions, InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { getUser, getOrganizations, getDeployments } from './queries'; import { getUser, getOrganizations, getDeployments, getProjectMembers } from './queries';
import { GetDeploymentsResponse, GetOrganizationsResponse, GetProjectMembersResponse, GetUserResponse, RemoveMemberResponse } from './types';
import { removeMember } from './mutations';
export interface GraphQLConfig { export interface GraphQLConfig {
gqlEndpoint: string; gqlEndpoint: string;
} }
// TODO: check options
const defaultOptions: DefaultOptions = {
watchQuery: {
fetchPolicy: 'no-cache',
errorPolicy: 'ignore'
},
query: {
fetchPolicy: 'no-cache',
errorPolicy: 'all'
}
};
export class GQLClient { export class GQLClient {
private client: ApolloClient<NormalizedCacheObject>; private client: ApolloClient<NormalizedCacheObject>;
constructor (config: GraphQLConfig) { constructor (config: GraphQLConfig) {
this.client = new ApolloClient({ this.client = new ApolloClient({
uri: config.gqlEndpoint, uri: config.gqlEndpoint,
cache: new InMemoryCache() cache: new InMemoryCache(),
defaultOptions
}); });
} }
async getUser () : Promise<any> { async getUser () : Promise<GetUserResponse> {
const { data } = await this.client.query({ const { data } = await this.client.query({
query: getUser query: getUser
}); });
@ -24,7 +39,7 @@ export class GQLClient {
return data; return data;
} }
async getOrganizations () : Promise<any> { async getOrganizations () : Promise<GetOrganizationsResponse> {
const { data } = await this.client.query({ const { data } = await this.client.query({
query: getOrganizations query: getOrganizations
}); });
@ -32,7 +47,7 @@ export class GQLClient {
return data; return data;
} }
async getDeployments (projectId: string) : Promise<any> { async getDeployments (projectId: string) : Promise<GetDeploymentsResponse> {
const { data } = await this.client.query({ const { data } = await this.client.query({
query: getDeployments, query: getDeployments,
variables: { variables: {
@ -42,4 +57,26 @@ export class GQLClient {
return data; return data;
} }
async removeMember (memberId: string): Promise<RemoveMemberResponse> {
const { data } = await this.client.mutate({
mutation: removeMember,
variables: {
memberId
}
});
return data;
}
async getProjectMembers (projectId: string) : Promise<GetProjectMembersResponse> {
const { data } = await this.client.query({
query: getProjectMembers,
variables: {
projectId
}
});
return data;
}
} }

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const removeMember = gql`
mutation ($memberId: String!) {
removeMember(memberId: $memberId)
}
`;

View File

@ -81,3 +81,19 @@ query ($projectId: String!) {
} }
} }
`; `;
export const getProjectMembers = gql`
query ($projectId: String!) {
projectMembers(projectId: $projectId) {
id
member {
id
name
email
}
createdAt
updatedAt
permissions
}
}
`;

View File

@ -0,0 +1,131 @@
// Note: equivalent to types present in GQL schema
export enum Role {
Owner = 'Owner',
Maintainer = 'Maintainer',
Reader = 'Reader',
}
export enum Permission {
View = 'View',
Edit = 'Edit',
}
export enum Environment {
Production = 'Production',
Preview = 'Preview',
Development = 'Development',
}
export enum DeploymentStatus {
Building = 'Building',
Ready = 'Ready',
Error = 'Error',
}
export enum DomainStatus {
Live = 'Live',
Pending = 'Pending',
}
export type EnvironmentVariable = {
id: string
environments: Environment[]
key: string
value: string
createdAt: string
updatedAt: string
}
export type Domain = {
id: string
branch: string
name: string
isRedirected: boolean
status: DomainStatus
createdAt: string
updatedAt: string
}
export type Deployment = {
id: string
domain: Domain
branch: string
commitHash: string
title: string
environment: Environment
isCurrent: boolean
status: DeploymentStatus
createdAt: string
updatedAt: string
}
export type User = {
id: string
name: string
email: string
createdAt: string
updatedAt: string
}
export type OrganizationMember = {
id: string
member: User
role: Role
createdAt: string
updatedAt: string
}
export type ProjectMember = {
id: string
member: User
permissions: Permission[]
createdAt: string
updatedAt: string
}
export type Project = {
id: string
owner: User
deployments: Deployment[]
name: string
repository: string
prodBranch: string
description: string
template: string
framework: string
webhooks: string[]
members: ProjectMember[]
environmentVariables: EnvironmentVariable[]
createdAt: string
updatedAt: string
}
export type Organization = {
id: string
name: string
projects: Project[]
createdAt: string
updatedAt: string
members: OrganizationMember[]
}
export type GetProjectMembersResponse = {
projectMembers: ProjectMember[]
}
export type RemoveMemberResponse = {
removeMember: boolean;
}
export type GetDeploymentsResponse = {
deployments: Deployment[]
}
export type GetOrganizationsResponse = {
organizations: Organization[]
}
export type GetUserResponse = {
user: User
}