From f2caf9194f9eea9766a25692ee2f5042efa19dec Mon Sep 17 00:00:00 2001 From: Robert Knight Date: Mon, 9 Dec 2024 09:40:11 +0000 Subject: [PATCH] Add "Joined" column to group members table 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. --- .../components/EditGroupMembersForm.tsx | 175 +++++++++++------- .../test/EditGroupMembersForm-test.js | 27 ++- h/static/scripts/group-forms/utils/api.ts | 9 + 3 files changed, 139 insertions(+), 72 deletions(-) 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 = {