diff --git a/h/static/scripts/group-forms/components/EditGroupMembersForm.tsx b/h/static/scripts/group-forms/components/EditGroupMembersForm.tsx
index 5aeaaa9d732..65a3d92438c 100644
--- a/h/static/scripts/group-forms/components/EditGroupMembersForm.tsx
+++ b/h/static/scripts/group-forms/components/EditGroupMembersForm.tsx
@@ -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';
@@ -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;
};
/**
@@ -71,6 +74,7 @@ function memberToRow(member: GroupMember, currentUserid: string): MemberRow {
role,
availableRoles,
busy: false,
+ joined: member.created ? new Date(member.created) : undefined,
};
}
@@ -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;
@@ -216,6 +230,12 @@ export default function EditGroupMembersForm({
{
field: 'role',
label: 'Role',
+ classes: 'w-40',
+ },
+ {
+ field: 'joined',
+ label: 'Joined',
+ classes: 'w-36',
},
{
field: 'showDeleteAction',
@@ -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 (
-
-
- @{user.username}
-
- {user.displayName && (
-
- {
- // 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.
-
- }
- {user.displayName}
+ const renderRow = useCallback(
+ (user: MemberRow, field: keyof MemberRow) => {
+ switch (field) {
+ case 'username':
+ return (
+
+
+ @{user.username}
- )}
-
- );
- case 'role':
- if (user.availableRoles.length <= 1) {
+ {user.displayName && (
+
+ {
+ // 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.
+
+ }
+ {user.displayName}
+
+ )}
+
+ );
+ 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.
+
+ {roleStrings[user.role]}
+
+ );
+ }
return (
- // Left padding here aligns the static role label in this row with
- // the current role in dropdowns in other rows.
-
- {roleStrings[user.role]}
+ changeRole(user, role)}
+ disabled={user.busy}
+ />
+ );
+ case 'joined':
+ return (
+
+ {user.joined && dateFormatter.format(user.joined)}
+ {!user.joined && 'Before Dec 2024'}
);
- }
- return (
- changeRole(user, role)}
- disabled={user.busy}
- />
- );
- case 'showDeleteAction':
- return user.showDeleteAction ? (
- setPendingRemoval(user.username)}
- />
- ) : null;
- // istanbul ignore next
- default:
- return null;
- }
- };
+ case 'showDeleteAction':
+ return user.showDeleteAction ? (
+ setPendingRemoval(user.username)}
+ />
+ ) : null;
+ // istanbul ignore next
+ default:
+ return null;
+ }
+ },
+ [changeRole, dateFormatter],
+ );
return (
<>
diff --git a/h/static/scripts/group-forms/components/test/EditGroupMembersForm-test.js b/h/static/scripts/group-forms/components/test/EditGroupMembersForm-test.js
index 7f0af0466c0..01879da854e 100644
--- a/h/static/scripts/group-forms/components/test/EditGroupMembersForm-test.js
+++ b/h/static/scripts/group-forms/components/test/EditGroupMembersForm-test.js
@@ -11,6 +11,7 @@ import {
describe('EditGroupMembersForm', () => {
let config;
+ let dateFormatter;
let fakeCallAPI;
const defaultMembers = [
@@ -25,6 +26,9 @@ describe('EditGroupMembersForm', () => {
'updates.roles.member',
],
roles: ['admin'],
+
+ // User who joined before Dec 2024
+ created: null,
},
{
userid: 'acct:johnsmith@localhost',
@@ -32,6 +36,7 @@ describe('EditGroupMembersForm', () => {
display_name: 'John Smith',
actions: [],
roles: ['owner'],
+ created: '2024-01-01T01:02:03+00:00',
},
{
userid: 'acct:jane@localhost',
@@ -43,6 +48,7 @@ describe('EditGroupMembersForm', () => {
'updates.roles.member',
],
roles: ['admin'],
+ created: '2024-01-02T01:02:03+00:00',
},
];
@@ -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
@@ -127,7 +140,11 @@ describe('EditGroupMembersForm', () => {
const createForm = (props = {}) => {
return mount(
-
+
,
{ connected: true },
);
@@ -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}"]`);
};
@@ -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');
});
[
diff --git a/h/static/scripts/group-forms/utils/api.ts b/h/static/scripts/group-forms/utils/api.ts
index f095a943dbb..94092d25e6c 100644
--- a/h/static/scripts/group-forms/utils/api.ts
+++ b/h/static/scripts/group-forms/utils/api.ts
@@ -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.
*
@@ -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- = {