Skip to content

Commit

Permalink
Add UI for changing a member's role within a group
Browse files Browse the repository at this point in the history
Add a "Role" column to the Members tab of the group settings UI. This displays a
dropdown with the different roles that the current user can assign to the target
user within the group. If the current user cannot change the role, the column
displays the current role as plain text.
  • Loading branch information
robertknight committed Nov 28, 2024
1 parent 83c581f commit 4fb2246
Show file tree
Hide file tree
Showing 3 changed files with 295 additions and 38 deletions.
150 changes: 138 additions & 12 deletions h/static/scripts/group-forms/components/EditGroupMembersForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,58 @@ import {
Scroll,
TrashIcon,
IconButton,
Select,
} from '@hypothesis/frontend-shared';
import { useContext, useEffect, useMemo, useState } from 'preact/hooks';

import { Config } from '../config';
import type { APIConfig, Group } from '../config';
import ErrorNotice from './ErrorNotice';
import FormContainer from './forms/FormContainer';
import type { GroupMembersResponse } from '../utils/api';
import type { GroupMember, GroupMembersResponse, Role } from '../utils/api';
import { callAPI } from '../utils/api';
import FormContainer from './forms/FormContainer';
import { pluralize } from '../utils/pluralize';
import ErrorNotice from './ErrorNotice';
import GroupFormHeader from './GroupFormHeader';
import WarningDialog from './WarningDialog';

type MemberRow = {
username: string;
userid: string;
canDelete: boolean;
role: Role;
availableRoles: Role[];
};

/**
* Mappings between roles and labels. The keys are sorted in descending order
* of permissions.
*/
const roleStrings: Record<Role, string> = {
owner: 'Owner',
admin: 'Admin',
moderator: 'Moderator',
member: 'Member',
};
const possibleRoles: Role[] = Object.keys(roleStrings) as Role[];

function memberToRow(member: GroupMember, currentUserid: string): MemberRow {
const role = member.roles[0] ?? 'member';
const availableRoles =
member.userid !== currentUserid
? possibleRoles.filter(role =>
member.actions.includes(`updates.roles.${role}`),
)
: [role];
return {
userid: member.userid,
username: member.username,
canDelete:
member.actions.includes('delete') && member.userid !== currentUserid,
role,
availableRoles,
};
}

async function fetchMembers(
api: APIConfig,
currentUserid: string,
Expand All @@ -34,12 +67,7 @@ async function fetchMembers(
headers,
signal,
});
return members.map(member => ({
userid: member.userid,
username: member.username,
canDelete:
member.actions.includes('delete') && member.userid !== currentUserid,
}));
return members.map(m => memberToRow(m, currentUserid));
}

async function removeMember(api: APIConfig, userid: string) {
Expand All @@ -51,6 +79,57 @@ async function removeMember(api: APIConfig, userid: string) {
});
}

async function setMemberRoles(
api: APIConfig,
userid: string,
roles: Role[],
): Promise<GroupMember> {
const { url: urlTemplate, method, headers } = api;
const url = urlTemplate.replace(':userid', encodeURIComponent(userid));
return callAPI(url, {
method,
headers,
json: {
roles,
},
});
}

type RoleSelectProps = {
username: string;

/** The current role of the member. */
current: Role;

/** Ordered list of possible roles that the current user can assign to the member. */
available: Role[];

/** Callback for when the user requests to change the role of the member. */
onChange: (r: Role) => void;
};

function RoleSelect({
username,
current,
available,
onChange,
}: RoleSelectProps) {
return (
<Select
value={current}
onChange={onChange}
buttonContent={roleStrings[current]}
data-testid={`role-${username}`}
>
{available.map(role => (
<Select.Option key={role} value={role}>
{roleStrings[role]}
</Select.Option>
))}
</Select>
);
}

export type EditGroupMembersFormProps = {
/** The saved group details. */
group: Group;
Expand All @@ -60,7 +139,7 @@ export default function EditGroupMembersForm({
group,
}: EditGroupMembersFormProps) {
const config = useContext(Config)!;
const userid = config.context.user.userid;
const currentUserid = config.context.user.userid;

// Fetch group members when the form loads.
const [errorMessage, setErrorMessage] = useState<string | null>(null);
Expand All @@ -72,21 +151,25 @@ export default function EditGroupMembersForm({
}
const abort = new AbortController();
setErrorMessage(null);
fetchMembers(config.api.readGroupMembers, userid, abort.signal)
fetchMembers(config.api.readGroupMembers, currentUserid, abort.signal)
.then(setMembers)
.catch(err => {
setErrorMessage(`Failed to fetch group members: ${err.message}`);
});
return () => {
abort.abort();
};
}, [config.api.readGroupMembers, userid]);
}, [config.api.readGroupMembers, currentUserid]);

const columns = [
{
field: 'username' as keyof MemberRow,
label: 'Username',
},
{
field: 'role' as keyof MemberRow,
label: 'Role',
},
{
field: 'canDelete' as keyof MemberRow,
label: '',
Expand Down Expand Up @@ -119,6 +202,33 @@ export default function EditGroupMembersForm({
}
};

const updateMember = (userid: string, update: Partial<MemberRow>) => {
setMembers(
members =>
members?.map(m => {
return m.userid === userid ? { ...m, ...update } : m;
}) ?? null,
);
};

const changeRole = async (member: MemberRow, role: Role) => {
updateMember(member.userid, { role });
try {
const updatedMember = await setMemberRoles(
config.api.editGroupMember!,
member.userid,
[role],
);
// Update the member row in case the role change affected other columns
// (eg. whether we have permission to delete the user).
updateMember(member.userid, memberToRow(updatedMember, currentUserid));
} catch (err) {
const prevRole = member.role;
updateMember(member.userid, { role: prevRole });
setErrorMessage(err.message);
}
};

const renderRow = (user: MemberRow, field: keyof MemberRow) => {
switch (field) {
case 'username':
Expand All @@ -131,6 +241,22 @@ export default function EditGroupMembersForm({
{user.username}
</div>
);
case 'role':
if (user.availableRoles.length <= 1) {
return (
<span data-testid={`role-${user.username}`}>
{roleStrings[user.role]}
</span>
);
}
return (
<RoleSelect
username={user.username}
current={user.role}
available={user.availableRoles}
onChange={role => changeRole(user, role)}
/>
);
case 'canDelete':
return user.canDelete ? (
<IconButton
Expand Down
Loading

0 comments on commit 4fb2246

Please sign in to comment.