Skip to content
This repository has been archived by the owner on Jul 23, 2024. It is now read-only.

Commit

Permalink
Integrate members API
Browse files Browse the repository at this point in the history
Signed-off-by: Dmitriy Lazarev <[email protected]>
  • Loading branch information
wKich committed Aug 8, 2023
1 parent 39b2f15 commit cbbcca7
Show file tree
Hide file tree
Showing 7 changed files with 215 additions and 100 deletions.
21 changes: 21 additions & 0 deletions plugins/parodos/src/api/fetchAccessRequests.ts
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 20 additions & 0 deletions plugins/parodos/src/api/fetchProjectMembers.ts
Original file line number Diff line number Diff line change
@@ -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);
}
22 changes: 22 additions & 0 deletions plugins/parodos/src/api/removeUserFromProject.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
23 changes: 23 additions & 0 deletions plugins/parodos/src/api/responseOnAccessRequest.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
23 changes: 23 additions & 0 deletions plugins/parodos/src/api/updateUserRole.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
196 changes: 96 additions & 100 deletions plugins/parodos/src/components/projects/ProjectAccessTable.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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<AccessRole, 'Owner'>) =>
setMembers(prevMembers =>
prevMembers.map(member =>
member.name === name ? { ...member, role } : member,
),
),
[],
);

return { members, addMember, removeMember, transferOwnership, changeRole };
}

const useStyles = makeStyles<BackstageTheme>(theme => ({
root: {
padding: theme.spacing(0),
Expand Down Expand Up @@ -110,17 +69,25 @@ const useStyles = makeStyles<BackstageTheme>(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<string>();
const [undoRemove, setUndoRemove] = useState<() => void>();
const [selectedMembers, setSelectedMembers] = useState<
{ name: string; role: AccessRole }[]
>([]);

const {
error: getMembersError,
loading,
value: members = [],
} = useAsync(
async () => fetchProjectMembers(fetch, baseUrl, project.id),
[fetch, baseUrl, project.id],
);

const columns: TableColumn<AccessTableData>[] = [
{
title: `PROJECT MEMBERS (${members.length})`,
Expand All @@ -131,18 +98,66 @@ 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<AccessRole, 'Owner'>) => {
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 =
event.target instanceof HTMLInputElement ? event.target.name : '';
if (selected) {
setSelectedMembers(prevSelected => [
...prevSelected,
members.find(member => member.name === name)!,
members.find(member => member.username === name)!,
]);
} else {
setSelectedMembers(prevSelected =>
Expand All @@ -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<AccessRole, 'Owner'>) => {
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 }) => {
Expand Down Expand Up @@ -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);
}}
/>
</Grid>
Expand All @@ -222,30 +237,9 @@ export function ProjectAccessTable({
}
return <TableCell>{rowData[columnDef.field]}</TableCell>;
},
[
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 (
<>
<Snackbar
Expand Down Expand Up @@ -299,13 +293,15 @@ export function ProjectAccessTable({
data={tableData}
components={{
Cell,
// Row: ({ rowData, ...props }) => (),
}}
/>
{loading && <Progress />}
<Button
variant="text"
color="primary"
disabled={selectedMembers.length === 0}
onClick={handleRemoveSelected}
onClick={removeMembers}
>
Remove selected
</Button>
Expand Down
Loading

0 comments on commit cbbcca7

Please sign in to comment.