diff --git a/packages/frontend/src/components/projects/project/settings/AddMemberDialog.tsx b/packages/frontend/src/components/projects/project/settings/AddMemberDialog.tsx index 9f79331b..df40e025 100644 --- a/packages/frontend/src/components/projects/project/settings/AddMemberDialog.tsx +++ b/packages/frontend/src/components/projects/project/settings/AddMemberDialog.tsx @@ -12,12 +12,12 @@ import { Checkbox, } from '@material-tailwind/react'; -import { Member, Permission } from '../../../../types/project'; +import { ProjectMember, Permission } from '../../../../types/project'; interface AddMemberDialogProp { open: boolean; handleOpen: () => void; - handleAddMember: (member: Member) => void; + handleAddMember: (projectMember: ProjectMember) => void; } interface formData { @@ -52,13 +52,17 @@ const AddMemberDialog = ({ reset(); handleOpen(); - const member: Member = { - email: data.emailAddress, - id: Math.random(), - name: '', + const projectMember: ProjectMember = { + id: Math.random().toString(), + permissions: [], + member: { + name: '', + email: data.emailAddress, + id: Math.random().toString(), + }, }; - handleAddMember(member); + handleAddMember(projectMember); }, []); return ( diff --git a/packages/frontend/src/components/projects/project/settings/Domains.tsx b/packages/frontend/src/components/projects/project/settings/Domains.tsx index d8e8f9ec..c2ed9525 100644 --- a/packages/frontend/src/components/projects/project/settings/Domains.tsx +++ b/packages/frontend/src/components/projects/project/settings/Domains.tsx @@ -6,13 +6,13 @@ import { Button, Typography } from '@material-tailwind/react'; import DomainCard from './DomainCard'; import { DomainDetails, - ProjectsOutletContext, + ProjectSearchOutletContext, } from '../../../../types/project'; const Domains = () => { const { id } = useParams(); - const { projects } = useOutletContext(); + const { projects } = useOutletContext(); const currentProject = useMemo(() => { return projects.find((project) => { diff --git a/packages/frontend/src/components/projects/project/settings/EnvironmentVariablesTabPanel.tsx b/packages/frontend/src/components/projects/project/settings/EnvironmentVariablesTabPanel.tsx index 7aba4622..ce627570 100644 --- a/packages/frontend/src/components/projects/project/settings/EnvironmentVariablesTabPanel.tsx +++ b/packages/frontend/src/components/projects/project/settings/EnvironmentVariablesTabPanel.tsx @@ -17,7 +17,7 @@ import DisplayEnvironmentVariables from './DisplayEnvironmentVariables'; import { EnvironmentVariable, Environments, - ProjectsOutletContext, + ProjectSearchOutletContext, } from '../../../../types/project'; import HorizontalLine from '../../../HorizontalLine'; @@ -36,7 +36,7 @@ export type EnvironmentVariablesFormValues = { export const EnvironmentVariablesTabPanel = () => { const { id } = useParams(); - const { projects } = useOutletContext(); + const { projects } = useOutletContext(); const currentProject = useMemo(() => { return projects.find((project) => { diff --git a/packages/frontend/src/components/projects/project/settings/GeneralTabPanel.tsx b/packages/frontend/src/components/projects/project/settings/GeneralTabPanel.tsx index 13549862..4013724b 100644 --- a/packages/frontend/src/components/projects/project/settings/GeneralTabPanel.tsx +++ b/packages/frontend/src/components/projects/project/settings/GeneralTabPanel.tsx @@ -13,7 +13,7 @@ import { import DeleteProjectDialog from './DeleteProjectDialog'; import ConfirmDialog from '../../../shared/ConfirmDialog'; -import { ProjectsOutletContext } from '../../../../types/project'; +import { ProjectSearchOutletContext } from '../../../../types/project'; const TEAMS = ['Airfoil']; const DEFAULT_SELECT_TEAM = undefined; @@ -34,7 +34,7 @@ const CopyIcon = ({ value }: { value: string }) => { const GeneralTabPanel = () => { const { id } = useParams(); - const { projects } = useOutletContext(); + const { projects } = useOutletContext(); const currentProject = useMemo(() => { return projects.find((project: any) => project.id === id); diff --git a/packages/frontend/src/components/projects/project/settings/MemberCard.tsx b/packages/frontend/src/components/projects/project/settings/MemberCard.tsx index b6b9d14e..fe6a2fef 100644 --- a/packages/frontend/src/components/projects/project/settings/MemberCard.tsx +++ b/packages/frontend/src/components/projects/project/settings/MemberCard.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useState } from 'react'; -import toast from 'react-hot-toast'; import { Select, @@ -34,7 +33,8 @@ interface MemberCardProps { isOwner: boolean; isPending: boolean; permissions: string[]; - handleDeletePendingMember: (id: number) => void; + handleDeletePendingMember: (id: string) => void; + removeMemberHandler: () => Promise; } const MemberCard = ({ @@ -44,6 +44,7 @@ const MemberCard = ({ isPending, permissions, handleDeletePendingMember, + removeMemberHandler, }: MemberCardProps) => { const [selectedPermission, setSelectedPermission] = useState( permissions.join('+'), @@ -127,7 +128,7 @@ const MemberCard = ({ confirmButtonTitle="Yes, Remove member" handleConfirm={() => { setRemoveMemberDialogOpen((preVal) => !preVal); - toast.success('Member removed from project'); + removeMemberHandler(); }} color="red" > diff --git a/packages/frontend/src/components/projects/project/settings/MembersTabPanel.tsx b/packages/frontend/src/components/projects/project/settings/MembersTabPanel.tsx index 8436088d..68d14675 100644 --- a/packages/frontend/src/components/projects/project/settings/MembersTabPanel.tsx +++ b/packages/frontend/src/components/projects/project/settings/MembersTabPanel.tsx @@ -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 toast, { Toaster } from 'react-hot-toast'; @@ -6,31 +6,60 @@ import { Chip, Button, Typography } from '@material-tailwind/react'; import MemberCard from './MemberCard'; -import { Member } from '../../../../types/project'; +import { + ProjectMember, + ProjectSearchOutletContext, +} from '../../../../types/project'; import AddMemberDialog from './AddMemberDialog'; +import { useGQLClient } from '../../../../context/GQLClientContext'; const FIRST_MEMBER_CARD = 0; const MembersTabPanel = () => { const { id } = useParams(); + const client = useGQLClient(); + const [addmemberDialogOpen, setAddMemberDialogOpen] = useState(false); - // @ts-expect-error create context type for projects - const { projects } = useOutletContext(); + const { projects } = useOutletContext(); const currentProject = useMemo(() => { - return projects.find((project: any) => project.id === id); + return projects.find((project) => project.id === id); }, [id]); - const [updatedMembers, setUpdatedMembers] = useState([ - ...currentProject?.members, - ]); + const [projectMembers, setProjectMembers] = useState([]); - const addMemberHandler = useCallback((member: Member) => { - setUpdatedMembers((val) => [...val, member]); + const addMemberHandler = useCallback((projectMember: ProjectMember) => { + setProjectMembers((val) => [...val, projectMember]); 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 (
@@ -40,7 +69,7 @@ const MembersTabPanel = () => {
@@ -53,20 +82,23 @@ const MembersTabPanel = () => { - {updatedMembers.map((member, index) => { + {projectMembers.map((projectMember, index) => { return ( { - setUpdatedMembers( - updatedMembers.filter((member) => member.id !== id), + isOwner={projectMember.member.id === currentProject?.owner.id} + isPending={projectMember.member.name === ''} + permissions={projectMember.permissions} + handleDeletePendingMember={(id: string) => { + setProjectMembers( + projectMembers.filter( + (projectMember) => projectMember.member.id !== id, + ), ); }} + removeMemberHandler={() => removeMemberHandler(projectMember.id)} /> ); })} diff --git a/packages/frontend/src/layouts/ProjectSearch.tsx b/packages/frontend/src/layouts/ProjectSearch.tsx index 481fb508..aa234905 100644 --- a/packages/frontend/src/layouts/ProjectSearch.tsx +++ b/packages/frontend/src/layouts/ProjectSearch.tsx @@ -13,11 +13,11 @@ const ProjectSearch = () => { useEffect(() => { const fetch = async () => { - const res = await client.getOrganizations(); + const { organizations } = await client.getOrganizations(); // Note: select first organization as organization switching not yet implemented - const projects = res.organizations[0]?.projects || []; - const orgName = res.organizations[0]?.name || ''; + 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)); diff --git a/packages/frontend/src/pages/index.tsx b/packages/frontend/src/pages/index.tsx index 99dcb7ad..a7ff7fa3 100644 --- a/packages/frontend/src/pages/index.tsx +++ b/packages/frontend/src/pages/index.tsx @@ -5,10 +5,10 @@ import { Link } from 'react-router-dom'; import { Button, Typography, Chip } from '@material-tailwind/react'; import ProjectCard from '../components/projects/ProjectCard'; -import { ProjectsOutletContext } from '../types/project'; +import { ProjectSearchOutletContext } from '../types/project'; const Projects = () => { - const { projects } = useOutletContext(); + const { projects } = useOutletContext(); return (
diff --git a/packages/frontend/src/pages/projects/Project.tsx b/packages/frontend/src/pages/projects/Project.tsx index e8cf9b40..76edb514 100644 --- a/packages/frontend/src/pages/projects/Project.tsx +++ b/packages/frontend/src/pages/projects/Project.tsx @@ -5,13 +5,13 @@ import { Button, Typography } from '@material-tailwind/react'; import HorizontalLine from '../../components/HorizontalLine'; import ProjectTabs from '../../components/projects/project/ProjectTabs'; -import { ProjectsOutletContext } from '../../types/project'; +import { ProjectSearchOutletContext } from '../../types/project'; const Project = () => { const { id } = useParams(); const navigate = useNavigate(); - const { projects } = useOutletContext(); + const { projects } = useOutletContext(); const project = useMemo(() => { return projects.find((project) => { diff --git a/packages/frontend/src/pages/projects/index.tsx b/packages/frontend/src/pages/projects/index.tsx index 4ecfa6f1..31dc1c78 100644 --- a/packages/frontend/src/pages/projects/index.tsx +++ b/packages/frontend/src/pages/projects/index.tsx @@ -4,10 +4,10 @@ import { Link, useOutletContext } from 'react-router-dom'; import { Button, Typography, Chip } from '@material-tailwind/react'; import ProjectCard from '../../components/projects/ProjectCard'; -import { ProjectsOutletContext } from '../../types/project'; +import { ProjectSearchOutletContext } from '../../types/project'; const Projects = () => { - const { projects } = useOutletContext(); + const { projects } = useOutletContext(); return (
diff --git a/packages/frontend/src/types/project.ts b/packages/frontend/src/types/project.ts index f08fc070..1db328a7 100644 --- a/packages/frontend/src/types/project.ts +++ b/packages/frontend/src/types/project.ts @@ -2,6 +2,7 @@ export interface ProjectDetails { icon: string; name: string; title: string; + owner: Member; organization: string; description: string; url: string; @@ -18,13 +19,14 @@ export interface ProjectDetails { }; repositoryId: number; repositories: RepositoryDetails[]; - members: MemberPermission[]; + members: ProjectMember[]; ownerId: number; environmentVariables: EnvironmentVariable[]; } -export interface MemberPermission { - id: number; +export interface ProjectMember { + id: string; + member: Member; permissions: string[]; } @@ -103,9 +105,9 @@ export enum Permission { export interface Member { name: string; email: string; - id: number; + id: string; } -export interface ProjectsOutletContext { +export interface ProjectSearchOutletContext { projects: ProjectDetails[]; } diff --git a/packages/gql-client/src/client.ts b/packages/gql-client/src/client.ts index 8f196818..a53ab459 100644 --- a/packages/gql-client/src/client.ts +++ b/packages/gql-client/src/client.ts @@ -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 { gqlEndpoint: string; } +// TODO: check options +const defaultOptions: DefaultOptions = { + watchQuery: { + fetchPolicy: 'no-cache', + errorPolicy: 'ignore' + }, + query: { + fetchPolicy: 'no-cache', + errorPolicy: 'all' + } +}; + export class GQLClient { private client: ApolloClient; constructor (config: GraphQLConfig) { this.client = new ApolloClient({ uri: config.gqlEndpoint, - cache: new InMemoryCache() + cache: new InMemoryCache(), + defaultOptions }); } - async getUser () : Promise { + async getUser () : Promise { const { data } = await this.client.query({ query: getUser }); @@ -24,7 +39,7 @@ export class GQLClient { return data; } - async getOrganizations () : Promise { + async getOrganizations () : Promise { const { data } = await this.client.query({ query: getOrganizations }); @@ -32,7 +47,7 @@ export class GQLClient { return data; } - async getDeployments (projectId: string) : Promise { + async getDeployments (projectId: string) : Promise { const { data } = await this.client.query({ query: getDeployments, variables: { @@ -42,4 +57,26 @@ export class GQLClient { return data; } + + async removeMember (memberId: string): Promise { + const { data } = await this.client.mutate({ + mutation: removeMember, + variables: { + memberId + } + }); + + return data; + } + + async getProjectMembers (projectId: string) : Promise { + const { data } = await this.client.query({ + query: getProjectMembers, + variables: { + projectId + } + }); + + return data; + } } diff --git a/packages/gql-client/src/mutations.ts b/packages/gql-client/src/mutations.ts new file mode 100644 index 00000000..4cca83c4 --- /dev/null +++ b/packages/gql-client/src/mutations.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const removeMember = gql` +mutation ($memberId: String!) { + removeMember(memberId: $memberId) +} +`; diff --git a/packages/gql-client/src/queries.ts b/packages/gql-client/src/queries.ts index 4e4f7b5c..95ec5637 100644 --- a/packages/gql-client/src/queries.ts +++ b/packages/gql-client/src/queries.ts @@ -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 + } +} +`; diff --git a/packages/gql-client/src/types.ts b/packages/gql-client/src/types.ts new file mode 100644 index 00000000..04cf9d30 --- /dev/null +++ b/packages/gql-client/src/types.ts @@ -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 +}