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 = {