From cbbcca78498823cf628acdc8b8a8dde14a2bb2ab Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Tue, 8 Aug 2023 16:36:09 +0400 Subject: [PATCH] Integrate members API Signed-off-by: Dmitriy Lazarev --- .../parodos/src/api/fetchAccessRequests.ts | 21 ++ .../parodos/src/api/fetchProjectMembers.ts | 20 ++ .../parodos/src/api/removeUserFromProject.ts | 22 ++ .../src/api/responseOnAccessRequest.ts | 23 ++ plugins/parodos/src/api/updateUserRole.ts | 23 ++ .../projects/ProjectAccessTable.tsx | 196 +++++++++--------- plugins/parodos/src/models/project.ts | 10 + 7 files changed, 215 insertions(+), 100 deletions(-) create mode 100644 plugins/parodos/src/api/fetchAccessRequests.ts create mode 100644 plugins/parodos/src/api/fetchProjectMembers.ts create mode 100644 plugins/parodos/src/api/removeUserFromProject.ts create mode 100644 plugins/parodos/src/api/responseOnAccessRequest.ts create mode 100644 plugins/parodos/src/api/updateUserRole.ts diff --git a/plugins/parodos/src/api/fetchAccessRequests.ts b/plugins/parodos/src/api/fetchAccessRequests.ts new file mode 100644 index 0000000..907ad76 --- /dev/null +++ b/plugins/parodos/src/api/fetchAccessRequests.ts @@ -0,0 +1,21 @@ +import { FetchApi } from '@backstage/core-plugin-api'; +import { AccessRole } from '../models/project'; +import * as urls from '../urls'; + +// TODO There has been no API for this yet +export async function fetchAccessRequests( + fetch: FetchApi['fetch'], + baseUrl: string, + projectId: string, +): Promise<{ username: string; requestId: string; role: AccessRole }[]> { + const response = await fetch( + `${baseUrl}${urls.Projects}/${projectId}/access`, + ); + const data = await response.json(); + + if ('error' in data) { + throw new Error(`${data.error}: ${data.path}`); + } + + return data; +} diff --git a/plugins/parodos/src/api/fetchProjectMembers.ts b/plugins/parodos/src/api/fetchProjectMembers.ts new file mode 100644 index 0000000..2df14e3 --- /dev/null +++ b/plugins/parodos/src/api/fetchProjectMembers.ts @@ -0,0 +1,20 @@ +import { FetchApi } from '@backstage/core-plugin-api'; +import { projectMembers } from '../models/project'; +import * as urls from '../urls'; + +export async function fetchProjectMembers( + fetch: FetchApi['fetch'], + baseUrl: string, + projectId: string, +) { + const response = await fetch( + `${baseUrl}${urls.Projects}/${projectId}/members`, + ); + const data = await response.json(); + + if ('error' in data) { + throw new Error(`${data.error}: ${data.path}`); + } + + return projectMembers.parse(data); +} diff --git a/plugins/parodos/src/api/removeUserFromProject.ts b/plugins/parodos/src/api/removeUserFromProject.ts new file mode 100644 index 0000000..d12a2ab --- /dev/null +++ b/plugins/parodos/src/api/removeUserFromProject.ts @@ -0,0 +1,22 @@ +import { FetchApi } from '@backstage/core-plugin-api'; +import * as urls from '../urls'; + +export async function removeUserFromProject( + fetch: FetchApi['fetch'], + baseUrl: string, + projectId: string, + users: string[], +) { + const response = await fetch( + `${baseUrl}${urls.Projects}/${projectId}/users`, + { + method: 'POST', + body: JSON.stringify(users), + }, + ); + const data = await response.json(); + + if ('error' in data) { + throw new Error(`${data.error}: ${data.path}`); + } +} diff --git a/plugins/parodos/src/api/responseOnAccessRequest.ts b/plugins/parodos/src/api/responseOnAccessRequest.ts new file mode 100644 index 0000000..bf77202 --- /dev/null +++ b/plugins/parodos/src/api/responseOnAccessRequest.ts @@ -0,0 +1,23 @@ +import { FetchApi } from '@backstage/core-plugin-api'; +import { AccessRole } from '../models/project'; +import * as urls from '../urls'; + +export async function responseOnAccessRequest( + fetch: FetchApi['fetch'], + baseUrl: string, + requestId: string, + payload: { comment?: string; status: AccessRole }, +) { + const response = await fetch( + `${baseUrl}${urls.Projects}/access/${requestId}/status`, + { + method: 'POST', + body: JSON.stringify({ ...payload, comment: payload.comment || '' }), + }, + ); + const data = await response.json(); + + if ('error' in data) { + throw new Error(`${data.error}: ${data.path}`); + } +} diff --git a/plugins/parodos/src/api/updateUserRole.ts b/plugins/parodos/src/api/updateUserRole.ts new file mode 100644 index 0000000..c9341df --- /dev/null +++ b/plugins/parodos/src/api/updateUserRole.ts @@ -0,0 +1,23 @@ +import { FetchApi } from '@backstage/core-plugin-api'; +import { AccessRole } from '../models/project'; +import * as urls from '../urls'; + +export async function updateUserRole( + fetch: FetchApi['fetch'], + baseUrl: string, + projectId: string, + payload: { username: string; role: AccessRole }[], +) { + const response = await fetch( + `${baseUrl}${urls.Projects}/${projectId}/users`, + { + method: 'POST', + body: JSON.stringify(payload), + }, + ); + const data = await response.json(); + + if ('error' in data) { + throw new Error(`${data.error}: ${data.path}`); + } +} diff --git a/plugins/parodos/src/components/projects/ProjectAccessTable.tsx b/plugins/parodos/src/components/projects/ProjectAccessTable.tsx index b3ddfc2..1680817 100644 --- a/plugins/parodos/src/components/projects/ProjectAccessTable.tsx +++ b/plugins/parodos/src/components/projects/ProjectAccessTable.tsx @@ -1,4 +1,5 @@ -import { Table, TableColumn } from '@backstage/core-components'; +import { Progress, Table, TableColumn } from '@backstage/core-components'; +import { errorApiRef, fetchApiRef, useApi } from '@backstage/core-plugin-api'; import { BackstageTheme } from '@backstage/theme'; import { Button, @@ -13,8 +14,14 @@ import { } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; import classNames from 'classnames'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; import { AccessRole, Project } from '../../models/project'; +import { useStore } from '../../stores/workflowStore/workflowStore'; +import useAsync from 'react-use/lib/useAsync'; +import { fetchProjectMembers } from '../../api/fetchProjectMembers'; +import { updateUserRole } from '../../api/updateUserRole'; +import { removeUserFromProject } from '../../api/removeUserFromProject'; export interface ProjectAccessTableProps { project: Project; @@ -27,54 +34,6 @@ interface AccessTableData { const roles: AccessRole[] = ['Owner', 'Admin', 'Developer']; -const mockMembers: { name: string; role: AccessRole }[] = [ - { name: 'Luke Shannon', role: 'Owner' }, - { name: 'Piotr Kliczewski', role: 'Admin' }, - { name: 'Richard Wang', role: 'Developer' }, - { name: 'Moti Asayag', role: 'Developer' }, - { name: 'Paul Cowan', role: 'Developer' }, - { name: 'Dmitriy Lazarev', role: 'Developer' }, -]; - -function useMockMembers() { - const [members, setMembers] = useState(mockMembers); - - const addMember = useCallback( - (name: string, role: AccessRole) => - setMembers(prevMembers => [...prevMembers, { name, role }]), - [], - ); - const removeMember = useCallback( - (name: string) => - setMembers(prevMembers => - prevMembers.filter(member => member.name !== name), - ), - [], - ); - const transferOwnership = useCallback( - (name: string) => - setMembers(prevMembers => - prevMembers.map(member => { - if (member.name === name) return { ...member, role: 'Owner' }; - if (member.role === 'Owner') return { ...member, role: 'Developer' }; - return member; - }), - ), - [], - ); - const changeRole = useCallback( - (name: string, role: Exclude) => - setMembers(prevMembers => - prevMembers.map(member => - member.name === name ? { ...member, role } : member, - ), - ), - [], - ); - - return { members, addMember, removeMember, transferOwnership, changeRole }; -} - const useStyles = makeStyles(theme => ({ root: { padding: theme.spacing(0), @@ -110,10 +69,9 @@ const useStyles = makeStyles(theme => ({ export function ProjectAccessTable({ project, }: ProjectAccessTableProps): JSX.Element { - // TODO Use real data when it will be available - const { members, addMember, removeMember, transferOwnership, changeRole } = - useMockMembers(); - + const { fetch } = useApi(fetchApiRef); + const errorApi = useApi(errorApiRef); + const baseUrl = useStore(state => state.baseUrl); const classes = useStyles(); const [snackbarMessage, setSnackbarMessage] = useState(); const [undoRemove, setUndoRemove] = useState<() => void>(); @@ -121,6 +79,15 @@ export function ProjectAccessTable({ { name: string; role: AccessRole }[] >([]); + const { + error: getMembersError, + loading, + value: members = [], + } = useAsync( + async () => fetchProjectMembers(fetch, baseUrl, project.id), + [fetch, baseUrl, project.id], + ); + const columns: TableColumn[] = [ { title: `PROJECT MEMBERS (${members.length})`, @@ -131,10 +98,58 @@ export function ProjectAccessTable({ ]; const tableData: AccessTableData[] = members.map(member => ({ - member: member.name, - roles: roles.map(role => [role, role === member.role]), + member: `${member.firstName} ${member.lastName}`, + roles: roles.map(role => [role, member.roles.includes(role)]), })); + const [{ error: changeRoleError }, changeRole] = useAsyncFn( + async (username: string, role: Exclude) => { + await updateUserRole(fetch, baseUrl, project.id, [{ username, role }]); + setSnackbarMessage( + `User role for ${username} has been successfully changed to ${role}.`, + ); + }, + [fetch, baseUrl, project.id], + ); + + const [{ error: transferOwnershipError }, transferOwnership] = useAsyncFn( + async (username: string) => { + const { username: ownerUsername } = + members.find(member => member.roles.includes('Owner')) ?? {}; + await updateUserRole(fetch, baseUrl, project.id, [ + { username, role: 'Owner' }, + ...(ownerUsername + ? [{ username: ownerUsername, role: 'Admin' as AccessRole }] + : []), + ]); + setSnackbarMessage( + `User ownership has been successfully transferred to ${username}.`, + ); + }, + [fetch, baseUrl, project.id, members], + ); + + const [{ error: removeMembersError }, removeMembers] = + useAsyncFn(async () => { + const users = selectedMembers.map(({ name }) => name); + await removeUserFromProject(fetch, baseUrl, project.id, users); + setSelectedMembers([]); + setSnackbarMessage( + `You have successfully removed ${selectedMembers.length} contributor${ + selectedMembers.length > 1 ? 's' : '' + } from this project.`, + ); + setUndoRemove( + () => () => + updateUserRole( + fetch, + baseUrl, + project.id, + selectedMembers.map(({ name, role }) => ({ username: name, role })), + ), + ); + }, [fetch, baseUrl, project.id, selectedMembers]); + const handleSelectMember = useCallback( (event: React.ChangeEvent<{}>, selected: boolean) => { const name = @@ -142,7 +157,7 @@ export function ProjectAccessTable({ if (selected) { setSelectedMembers(prevSelected => [ ...prevSelected, - members.find(member => member.name === name)!, + members.find(member => member.username === name)!, ]); } else { setSelectedMembers(prevSelected => @@ -153,25 +168,25 @@ export function ProjectAccessTable({ [members], ); - const handleTransferOwnership = useCallback( - (name: string) => { - transferOwnership(name); - setSnackbarMessage( - `User ownership has been successfully transferred to ${name}.`, - ); - }, - [transferOwnership], - ); + useEffect(() => { + const error = + getMembersError || + changeRoleError || + transferOwnershipError || + removeMembersError; + if (error) { + // eslint-disable-next-line no-console + console.error(error); - const handleChangeRole = useCallback( - (name: string, role: Exclude) => { - changeRole(name, role); - setSnackbarMessage( - `User role for ${name} has been successfully changed to ${role}.`, - ); - }, - [changeRole], - ); + errorApi.post(new Error('Failed to update project access')); + } + }, [ + errorApi, + getMembersError, + changeRoleError, + transferOwnershipError, + removeMembersError, + ]); const Cell = useCallback( ({ columnDef, rowData }) => { @@ -209,8 +224,8 @@ export function ProjectAccessTable({ onChange={() => { if (selected) return; if (roleName === 'Owner') - handleTransferOwnership(rowData.member); - else handleChangeRole(rowData.member, roleName); + transferOwnership(rowData.member); + else changeRole(rowData.member, roleName); }} /> @@ -222,30 +237,9 @@ export function ProjectAccessTable({ } return {rowData[columnDef.field]}; }, - [ - project.accessRole, - handleSelectMember, - handleTransferOwnership, - handleChangeRole, - ], + [project.accessRole, handleSelectMember, transferOwnership, changeRole], ); - const handleRemoveSelected = () => { - selectedMembers.forEach(member => removeMember(member.name)); - setSelectedMembers([]); - setSnackbarMessage( - `You have successfully removed ${selectedMembers.length} contributor${ - selectedMembers.length > 1 ? 's' : '' - } from this project.`, - ); - setUndoRemove( - () => () => - [...selectedMembers] - .reverse() - .forEach(member => addMember(member.name, member.role)), - ); - }; - return ( <> (), }} /> + {loading && } diff --git a/plugins/parodos/src/models/project.ts b/plugins/parodos/src/models/project.ts index 7da4f79..e1fb33f 100644 --- a/plugins/parodos/src/models/project.ts +++ b/plugins/parodos/src/models/project.ts @@ -18,6 +18,16 @@ export type AccessRole = z.infer; export type ProjectStatus = z.infer; +export const projectMember = z.object({ + firstName: z.string(), + lastName: z.string(), + roles: z.array(accessRole), +}); + +export const projectMembers = z.array(projectMember); + +export type ProjectMember = z.infer; + export const projectSchema = z.object({ id: z.string(), name: z.string(),