From 8caee867699db3f93955ca2761b500c471f4edf7 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 blank for users who joined before Dec 2024, when we started recording this information. --- .../components/EditGroupMembersForm.tsx | 174 +++++++++++------- .../test/EditGroupMembersForm-test.js | 27 ++- h/static/scripts/group-forms/utils/api.ts | 9 + 3 files changed, 138 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..f959e4a3c01 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,93 @@ 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)} ); - } - 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..11378a00bec 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'), ''); + 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 = {