diff --git a/h/static/scripts/group-forms/components/EditGroupMembersForm.tsx b/h/static/scripts/group-forms/components/EditGroupMembersForm.tsx index 77365eab095..dc18f171b96 100644 --- a/h/static/scripts/group-forms/components/EditGroupMembersForm.tsx +++ b/h/static/scripts/group-forms/components/EditGroupMembersForm.tsx @@ -1,5 +1,5 @@ -import { DataTable, Scroll } from '@hypothesis/frontend-shared'; -import { useEffect, useState } from 'preact/hooks'; +import { DataTable, Input, Scroll } from '@hypothesis/frontend-shared'; +import { useEffect, useMemo, useState } from 'preact/hooks'; import { routes } from '../routes'; import type { Group } from '../config'; @@ -56,6 +56,8 @@ export default function EditGroupMembersForm({ }, ]; + const [filter, setFilter] = useState(''); + const renderRow = (user: MemberRow, field: keyof MemberRow) => { switch (field) { case 'username': @@ -76,11 +78,46 @@ export default function EditGroupMembersForm({ const memberText = pluralize(members?.length ?? 0, 'member', 'members'); + const filteredMembers = useMemo(() => { + if (!filter || !members) { + return members; + } + + const normalizedFilter = filter.toLowerCase(); + + // nb. We can get away with lower-casing name and filter to do + // case-insensitive search because of the character set restrictions on + // usernames. This would be incorrect for Unicode text. + return members.filter(m => + m.username.toLowerCase().includes(normalizedFilter), + ); + }, [filter, members]); + + let emptyMessage; + if (members !== null && members.length > 0 && filter) { + emptyMessage = ( +
+ No members match the filter {'"'} + {filter} + {'"'} +
+ ); + } + return (
-
+
+ {/* Input has `w-full` style. Wrap in container to stop it stretching to full width of row. */} +
+ setFilter((e.target as HTMLInputElement).value)} + /> +
{members?.length ?? '...'} {memberText} @@ -91,9 +128,10 @@ export default function EditGroupMembersForm({
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 d95725ad576..66bc420559a 100644 --- a/h/static/scripts/group-forms/components/test/EditGroupMembersForm-test.js +++ b/h/static/scripts/group-forms/components/test/EditGroupMembersForm-test.js @@ -26,6 +26,9 @@ describe('EditGroupMembersForm', () => { { username: 'bob', }, + { + username: 'johnsmith', + }, ]); $imports.$mock({ @@ -52,24 +55,37 @@ describe('EditGroupMembersForm', () => { return wrapper.find('ErrorNotice').prop('message') !== null; }); + const getRenderedUsers = wrapper => { + return wrapper.find('[data-testid="username"]').map(node => node.text()); + }; + + const enterFilterValue = (wrapper, value) => { + const filter = wrapper.find('input[data-testid="search-input"]'); + filter.getDOMNode().value = value; + filter.simulate('input'); + }; + it('fetches and displays members', async () => { const wrapper = createForm(); assert.calledWith(fakeCallAPI, '/api/groups/1234/members'); await waitForTable(wrapper); - const users = wrapper.find('[data-testid="username"]'); - assert.equal(users.length, 1); - assert.equal(users.at(0).text(), 'bob'); + const users = getRenderedUsers(wrapper); + assert.deepEqual(users, ['bob', 'johnsmith']); }); it('displays member count', async () => { + fakeCallAPI.withArgs('/api/groups/1234/members').resolves([ + { + username: 'bob', + }, + ]); const wrapper = createForm(); const memberCount = wrapper.find('[data-testid="member-count"]'); assert.equal(memberCount.text(), '... members'); await waitForTable(wrapper); - assert.equal(memberCount.text(), '1 member'); }); @@ -94,4 +110,31 @@ describe('EditGroupMembersForm', () => { // Unmount while fetching. This should abort the request. wrapper.unmount(); }); + + it('filters members', async () => { + const wrapper = createForm(); + await waitForTable(wrapper); + + // Filter should remove non-matching users + enterFilterValue(wrapper, 'john'); + const users = getRenderedUsers(wrapper); + assert.deepEqual(users, ['johnsmith']); + + // Filter should match anywhere in username + enterFilterValue(wrapper, 'smith'); + const users2 = getRenderedUsers(wrapper); + assert.deepEqual(users2, ['johnsmith']); + + // Filter should be case-insensitive + enterFilterValue(wrapper, 'BOB'); + const users3 = getRenderedUsers(wrapper); + assert.deepEqual(users3, ['bob']); + }); + + it('displays message if filter does not match any members', async () => { + const wrapper = createForm(); + await waitForTable(wrapper); + enterFilterValue(wrapper, 'no-match'); + assert.isTrue(wrapper.exists('[data-testid="no-filter-match"]')); + }); });