Skip to content

Commit

Permalink
Add "Joined" column to group members table
Browse files Browse the repository at this point in the history
This the date the user joined the group if known, or placeholder text for users
who joined before Dec 2024, when we started recording this information.
  • Loading branch information
robertknight committed Dec 9, 2024
1 parent 7a43ca4 commit f2caf91
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 72 deletions.
175 changes: 104 additions & 71 deletions h/static/scripts/group-forms/components/EditGroupMembersForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
Pagination,
Select,
} from '@hypothesis/frontend-shared';
import { useContext, useEffect, useState } from 'preact/hooks';
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';

import { Config } from '../config';
import type { APIConfig, Group } from '../config';
Expand Down Expand Up @@ -40,6 +40,9 @@ type MemberRow = {

/** True if an operation is currently being performed against this member. */
busy: boolean;

/** Date when user joined group, if known. */
joined?: Date;
};

/**
Expand Down Expand Up @@ -71,6 +74,7 @@ function memberToRow(member: GroupMember, currentUserid: string): MemberRow {
role,
availableRoles,
busy: false,
joined: member.created ? new Date(member.created) : undefined,
};
}

Expand Down Expand Up @@ -163,15 +167,25 @@ function RoleSelect({
);
}

const defaultDateFormatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
});

export const pageSize = 20;

export type EditGroupMembersFormProps = {
/** The saved group details. */
group: Group;

/** Test seam. Formatter used to format the "Joined" date. */
dateFormatter?: Intl.DateTimeFormat;
};

export default function EditGroupMembersForm({
group,
dateFormatter = defaultDateFormatter,
}: EditGroupMembersFormProps) {
const config = useContext(Config)!;
const currentUserid = config.context.user.userid;
Expand Down Expand Up @@ -216,6 +230,12 @@ export default function EditGroupMembersForm({
{
field: 'role',
label: 'Role',
classes: 'w-40',
},
{
field: 'joined',
label: 'Joined',
classes: 'w-36',
},
{
field: 'showDeleteAction',
Expand Down Expand Up @@ -260,81 +280,94 @@ export default function EditGroupMembersForm({
}
};

const changeRole = async (member: MemberRow, role: Role) => {
updateMember(member.userid, { role, busy: true });
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, busy: false });
setErrorMessage(err.message);
}
};
const changeRole = useCallback(
async (member: MemberRow, role: Role) => {
updateMember(member.userid, { role, busy: true });
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, busy: false });
setErrorMessage(err.message);
}
},
[currentUserid, config.api.editGroupMember],
);

const renderRow = (user: MemberRow, field: keyof MemberRow) => {
switch (field) {
case 'username':
return (
<div className="truncate" title={user.username}>
<span data-testid="username" className="font-bold text-grey-7">
@{user.username}
</span>
{user.displayName && (
<span data-testid="display-name">
{
// Create space using a separate element, rather than eg.
// `inline-block ml-3` on the display name container because
// that would cause the entire display name to be hidden if
// truncated.
<span className="inline-block w-3" />
}
{user.displayName}
const renderRow = useCallback(
(user: MemberRow, field: keyof MemberRow) => {
switch (field) {
case 'username':
return (
<div className="truncate" title={user.username}>
<span data-testid="username" className="font-bold text-grey-7">
@{user.username}
</span>
)}
</div>
);
case 'role':
if (user.availableRoles.length <= 1) {
{user.displayName && (
<span data-testid="display-name">
{
// Create space using a separate element, rather than eg.
// `inline-block ml-3` on the display name container because
// that would cause the entire display name to be hidden if
// truncated.
<span className="inline-block w-3" />
}
{user.displayName}
</span>
)}
</div>
);
case 'role':
if (user.availableRoles.length <= 1) {
return (
// Left padding here aligns the static role label in this row with
// the current role in dropdowns in other rows.
<span className="pl-2" data-testid={`role-${user.username}`}>
{roleStrings[user.role]}
</span>
);
}
return (
// Left padding here aligns the static role label in this row with
// the current role in dropdowns in other rows.
<span className="pl-2" data-testid={`role-${user.username}`}>
{roleStrings[user.role]}
<RoleSelect
username={user.username}
current={user.role}
available={user.availableRoles}
onChange={role => changeRole(user, role)}
disabled={user.busy}
/>
);
case 'joined':
return (
<span data-testid={`joined-${user.username}`}>
{user.joined && dateFormatter.format(user.joined)}
{!user.joined && 'Before Dec 2024'}
</span>
);
}
return (
<RoleSelect
username={user.username}
current={user.role}
available={user.availableRoles}
onChange={role => changeRole(user, role)}
disabled={user.busy}
/>
);
case 'showDeleteAction':
return user.showDeleteAction ? (
<IconButton
classes={user.busy ? 'opacity-50' : ''}
disabled={user.busy}
icon={TrashIcon}
title="Remove member"
data-testid={`remove-${user.username}`}
onClick={() => setPendingRemoval(user.username)}
/>
) : null;
// istanbul ignore next
default:
return null;
}
};
case 'showDeleteAction':
return user.showDeleteAction ? (
<IconButton
classes={user.busy ? 'opacity-50' : ''}
disabled={user.busy}
icon={TrashIcon}
title="Remove member"
data-testid={`remove-${user.username}`}
onClick={() => setPendingRemoval(user.username)}
/>
) : null;
// istanbul ignore next
default:
return null;
}
},
[changeRole, dateFormatter],
);

return (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {

describe('EditGroupMembersForm', () => {
let config;
let dateFormatter;
let fakeCallAPI;

const defaultMembers = [
Expand All @@ -25,13 +26,17 @@ describe('EditGroupMembersForm', () => {
'updates.roles.member',
],
roles: ['admin'],

// User who joined before Dec 2024
created: null,
},
{
userid: 'acct:johnsmith@localhost',
username: 'johnsmith',
display_name: 'John Smith',
actions: [],
roles: ['owner'],
created: '2024-01-01T01:02:03+00:00',
},
{
userid: 'acct:jane@localhost',
Expand All @@ -43,6 +48,7 @@ describe('EditGroupMembersForm', () => {
'updates.roles.member',
],
roles: ['admin'],
created: '2024-01-02T01:02:03+00:00',
},
];

Expand Down Expand Up @@ -88,6 +94,13 @@ describe('EditGroupMembersForm', () => {
},
};

dateFormatter = {
format(date) {
// Return date in YYYY-MM-DD format.
return date.toISOString().match(/[0-9-]+/)[0];
},
};

fakeCallAPI = sinon.stub();
fakeCallAPI.rejects(new Error('Unknown API call'));
fakeCallAPI
Expand Down Expand Up @@ -127,7 +140,11 @@ describe('EditGroupMembersForm', () => {
const createForm = (props = {}) => {
return mount(
<Config.Provider value={config}>
<EditGroupMembersForm group={config.context.group} {...props} />
<EditGroupMembersForm
group={config.context.group}
dateFormatter={dateFormatter}
{...props}
/>
</Config.Provider>,
{ connected: true },
);
Expand All @@ -152,6 +169,10 @@ describe('EditGroupMembersForm', () => {
.map(node => node.text());
};

const getRenderedJoinDate = (wrapper, username) => {
return wrapper.find(`[data-testid="joined-${username}"]`).text();
};

const getRemoveUserButton = (wrapper, username) => {
return wrapper.find(`IconButton[data-testid="remove-${username}"]`);
};
Expand Down Expand Up @@ -221,6 +242,10 @@ describe('EditGroupMembersForm', () => {

const displayNames = getRenderedDisplayNames(wrapper);
assert.deepEqual(displayNames, ['Bob Jones', 'John Smith']);

assert.equal(getRenderedJoinDate(wrapper, 'bob'), 'Before Dec 2024');
assert.equal(getRenderedJoinDate(wrapper, 'johnsmith'), '2024-01-01');
assert.equal(getRenderedJoinDate(wrapper, 'jane'), '2024-01-02');
});

[
Expand Down
9 changes: 9 additions & 0 deletions h/static/scripts/group-forms/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export type GroupType = 'private' | 'restricted' | 'open';
/** Member role within a group. */
export type Role = 'owner' | 'admin' | 'moderator' | 'member';

/** A date and time in ISO format (eg. "2024-12-09T07:17:52+00:00") */
export type ISODateTime = string;

/**
* Request to create or update a group.
*
Expand Down Expand Up @@ -36,6 +39,12 @@ export type GroupMember = {
username: string;
actions: string[];
roles: Role[];

/** Timestamp when user joined group. `null` if before Dec 2024. */
created: ISODateTime | null;

/** Timestamp when membership was last updated. `null` if before Dec 2024. */
updated: ISODateTime | null;
};

export type PaginatedResponse<Item> = {
Expand Down

0 comments on commit f2caf91

Please sign in to comment.