Skip to content

Commit 31430da

Browse files
committed
feat(users): extend search to include external ID in user tables
Update the search placeholder and filter logic in ManageUsersTable and UserInvitationsTable to match against external ID in addition to name and email. The existing shouldInclude in ManageUsersTable was refactored to pre-compute the query string and add the externalId check. UserInvitationsTable gains a search prop with a localised placeholder, plus sets searchable: true on the conditionally-rendered externalId column so TanStack's column-level filter also covers it. Add a searchText translation key to user-invitations/translations.ts mirroring the wording used in the users bundle.
1 parent d6e1808 commit 31430da

9 files changed

Lines changed: 236 additions & 13 deletions

File tree

client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ const UserInvitationsTable: FC<Props> = (props) => {
168168
of: 'externalId',
169169
title: t(tableTranslations.externalId ?? null),
170170
sortable: false,
171-
searchable: false,
171+
searchable: true,
172172
cell: (datum) => datum.externalId ?? null,
173173
} satisfies ColumnTemplate<InvitationRowData>,
174174
]
@@ -278,6 +278,9 @@ const UserInvitationsTable: FC<Props> = (props) => {
278278
data={processedInvitations}
279279
getRowId={(datum) => datum.id.toString()}
280280
indexing={{ indices: true }}
281+
search={{
282+
searchPlaceholder: t(translations.searchText),
283+
}}
281284
sort={{
282285
initially: { by: 'status', order: 'asc' },
283286
}}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import userEvent from '@testing-library/user-event';
2+
import { render, screen, waitForElementToBeRemoved } from 'test-utils';
3+
import { InvitationMiniEntity } from 'types/course/userInvitations';
4+
5+
import UserInvitationsTable from '../UserInvitationsTable';
6+
7+
const baseInvitation: InvitationMiniEntity = {
8+
id: 1,
9+
name: 'Alice Lim',
10+
email: 'alice@example.com',
11+
externalId: null,
12+
role: 'student',
13+
phantom: false,
14+
confirmed: false,
15+
isRetryable: true,
16+
invitationKey: 'KEY001',
17+
sentAt: '2024-01-01T00:00:00Z',
18+
confirmedAt: null,
19+
};
20+
21+
const SEARCH_PLACEHOLDER = 'Search by name, email or external ID';
22+
23+
describe('<UserInvitationsTable />', () => {
24+
describe('search', () => {
25+
it('filters invitations by external ID', async () => {
26+
const user = userEvent.setup();
27+
const invitations: InvitationMiniEntity[] = [
28+
{
29+
...baseInvitation,
30+
id: 1,
31+
name: 'Alice Lim',
32+
externalId: 'EXT-ALICE',
33+
},
34+
{
35+
...baseInvitation,
36+
id: 2,
37+
name: 'Bob Tan',
38+
email: 'bob@example.com',
39+
externalId: 'EXT-BOB',
40+
},
41+
];
42+
43+
render(<UserInvitationsTable invitations={invitations} />);
44+
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
45+
46+
await user.type(
47+
screen.getByPlaceholderText(SEARCH_PLACEHOLDER),
48+
'EXT-ALICE',
49+
);
50+
51+
expect(screen.getByText('Alice Lim')).toBeInTheDocument();
52+
expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument();
53+
});
54+
55+
it('searches external ID case-insensitively', async () => {
56+
const user = userEvent.setup();
57+
const invitations: InvitationMiniEntity[] = [
58+
{
59+
...baseInvitation,
60+
id: 1,
61+
name: 'Alice Lim',
62+
externalId: 'ext-alice',
63+
},
64+
{
65+
...baseInvitation,
66+
id: 2,
67+
name: 'Bob Tan',
68+
email: 'bob@example.com',
69+
externalId: 'EXT-BOB',
70+
},
71+
];
72+
73+
render(<UserInvitationsTable invitations={invitations} />);
74+
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
75+
76+
await user.type(
77+
screen.getByPlaceholderText(SEARCH_PLACEHOLDER),
78+
'EXT-ALICE',
79+
);
80+
81+
expect(screen.getByText('Alice Lim')).toBeInTheDocument();
82+
expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument();
83+
});
84+
85+
it('shows all invitations when search is cleared', async () => {
86+
const user = userEvent.setup();
87+
const invitations: InvitationMiniEntity[] = [
88+
{
89+
...baseInvitation,
90+
id: 1,
91+
name: 'Alice Lim',
92+
externalId: 'EXT-ALICE',
93+
},
94+
{
95+
...baseInvitation,
96+
id: 2,
97+
name: 'Bob Tan',
98+
email: 'bob@example.com',
99+
externalId: 'EXT-BOB',
100+
},
101+
];
102+
103+
render(<UserInvitationsTable invitations={invitations} />);
104+
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
105+
106+
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
107+
await user.type(searchInput, 'EXT-ALICE');
108+
expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument();
109+
110+
await user.clear(searchInput);
111+
expect(screen.getByText('Alice Lim')).toBeInTheDocument();
112+
expect(screen.getByText('Bob Tan')).toBeInTheDocument();
113+
});
114+
});
115+
});

client/app/bundles/course/user-invitations/translations.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ const translations = defineMessages({
2222
id: 'course.userInvitations.UserInvitationsTable.noInvitations',
2323
defaultMessage: 'There are no invitations.',
2424
},
25+
searchText: {
26+
id: 'course.userInvitations.UserInvitationsTable.searchText',
27+
defaultMessage: 'Search by name, email or external ID',
28+
},
2529
pending: {
2630
id: 'course.userInvitations.UserInvitationsTable.pending',
2731
defaultMessage: 'Pending',
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import userEvent from '@testing-library/user-event';
2+
import { render, screen, waitForElementToBeRemoved } from 'test-utils';
3+
import { CourseUserMiniEntity } from 'types/course/courseUsers';
4+
5+
import ManageUsersTable from '../index';
6+
7+
const baseUser: CourseUserMiniEntity = {
8+
id: 1,
9+
name: 'Alice Lim',
10+
email: 'alice@example.com',
11+
role: 'student',
12+
};
13+
14+
const SEARCH_PLACEHOLDER = 'Search by name, email or external ID';
15+
16+
describe('<ManageUsersTable />', () => {
17+
describe('search', () => {
18+
it('filters users by external ID', async () => {
19+
const user = userEvent.setup();
20+
const users: CourseUserMiniEntity[] = [
21+
{ ...baseUser, id: 1, name: 'Alice Lim', externalId: 'EXT-ALICE' },
22+
{
23+
...baseUser,
24+
id: 2,
25+
name: 'Bob Tan',
26+
email: 'bob@example.com',
27+
externalId: 'EXT-BOB',
28+
},
29+
];
30+
31+
render(
32+
<ManageUsersTable csvDownloadFilename="users.csv" users={users} />,
33+
);
34+
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
35+
36+
await user.type(
37+
screen.getByPlaceholderText(SEARCH_PLACEHOLDER),
38+
'EXT-ALICE',
39+
);
40+
41+
expect(screen.getByText('Alice Lim')).toBeInTheDocument();
42+
expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument();
43+
});
44+
45+
it('searches external ID case-insensitively', async () => {
46+
const user = userEvent.setup();
47+
const users: CourseUserMiniEntity[] = [
48+
{ ...baseUser, id: 1, name: 'Alice Lim', externalId: 'ext-alice' },
49+
{
50+
...baseUser,
51+
id: 2,
52+
name: 'Bob Tan',
53+
email: 'bob@example.com',
54+
externalId: 'EXT-BOB',
55+
},
56+
];
57+
58+
render(
59+
<ManageUsersTable csvDownloadFilename="users.csv" users={users} />,
60+
);
61+
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
62+
63+
await user.type(
64+
screen.getByPlaceholderText(SEARCH_PLACEHOLDER),
65+
'EXT-ALICE',
66+
);
67+
68+
expect(screen.getByText('Alice Lim')).toBeInTheDocument();
69+
expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument();
70+
});
71+
72+
it('shows all users when search is cleared', async () => {
73+
const user = userEvent.setup();
74+
const users: CourseUserMiniEntity[] = [
75+
{ ...baseUser, id: 1, name: 'Alice Lim', externalId: 'EXT-ALICE' },
76+
{
77+
...baseUser,
78+
id: 2,
79+
name: 'Bob Tan',
80+
email: 'bob@example.com',
81+
externalId: 'EXT-BOB',
82+
},
83+
];
84+
85+
render(
86+
<ManageUsersTable csvDownloadFilename="users.csv" users={users} />,
87+
);
88+
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
89+
90+
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
91+
await user.type(searchInput, 'EXT-ALICE');
92+
expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument();
93+
94+
await user.clear(searchInput);
95+
expect(screen.getByText('Alice Lim')).toBeInTheDocument();
96+
expect(screen.getByText('Bob Tan')).toBeInTheDocument();
97+
});
98+
});
99+
});

client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,11 @@ const ManageUsersTable = (props: ManageUsersTableProps): JSX.Element => {
195195
if (!user.name && !user.email) return false;
196196
if (!filterValue?.length) return true;
197197

198+
const query = filterValue.toLowerCase().trim();
198199
return (
199-
user.name
200-
.toLowerCase()
201-
.trim()
202-
.includes(filterValue.toLowerCase().trim()) ||
203-
user.email
204-
.toLowerCase()
205-
.trim()
206-
.includes(filterValue.toLowerCase().trim())
200+
user.name.toLowerCase().trim().includes(query) ||
201+
user.email.toLowerCase().trim().includes(query) ||
202+
(user.externalId?.toLowerCase().trim().includes(query) ?? false)
207203
);
208204
},
209205
},

client/app/bundles/course/users/translations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const translations = defineMessages({
77
},
88
searchText: {
99
id: 'course.users.ManageUsersTable.ManageUsersTable.searchText',
10-
defaultMessage: 'Search by name or email',
10+
defaultMessage: 'Search by name, email or external ID',
1111
},
1212
renameSuccess: {
1313
id: 'course.users.ManageUsersTable.renameSuccess',

client/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7096,7 +7096,7 @@
70967096
"defaultMessage": "There are no users"
70977097
},
70987098
"course.users.ManageUsersTable.ManageUsersTable.searchText": {
7099-
"defaultMessage": "Search by name or email"
7099+
"defaultMessage": "Search by name, email or external ID"
71007100
},
71017101
"course.users.ManageUsersTable.assignToTimeline": {
71027102
"defaultMessage": "Assign to timeline"

client/locales/ko.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7040,6 +7040,9 @@
70407040
"course.userInvitations.UserInvitationsTable.noInvitations": {
70417041
"defaultMessage": "초대가 없습니다."
70427042
},
7043+
"course.userInvitations.UserInvitationsTable.searchText": {
7044+
"defaultMessage": "이름, 이메일 또는 외부 ID로 검색"
7045+
},
70437046
"course.userInvitations.UserInvitationsTable.pending": {
70447047
"defaultMessage": "대기 중"
70457048
},
@@ -7083,7 +7086,7 @@
70837086
"defaultMessage": "사용자가 없습니다."
70847087
},
70857088
"course.users.ManageUsersTable.ManageUsersTable.searchText": {
7086-
"defaultMessage": "이름 또는 이메일로 검색"
7089+
"defaultMessage": "이름, 이메일 또는 외부 ID로 검색"
70877090
},
70887091
"course.users.ManageUsersTable.assignToTimeline": {
70897092
"defaultMessage": "타임라인에 할당"

client/locales/zh.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7034,6 +7034,9 @@
70347034
"course.userInvitations.UserInvitationsTable.noInvitations": {
70357035
"defaultMessage": "没有邀请。"
70367036
},
7037+
"course.userInvitations.UserInvitationsTable.searchText": {
7038+
"defaultMessage": "按姓名、电子邮件或外部ID搜索"
7039+
},
70377040
"course.userInvitations.UserInvitationsTable.pending": {
70387041
"defaultMessage": "待处理"
70397042
},
@@ -7077,7 +7080,7 @@
70777080
"defaultMessage": "没有用户"
70787081
},
70797082
"course.users.ManageUsersTable.ManageUsersTable.searchText": {
7080-
"defaultMessage": "按姓名、电子邮件、角色等搜索。"
7083+
"defaultMessage": "按姓名、电子邮件或外部ID搜索"
70817084
},
70827085
"course.users.ManageUsersTable.assignToTimeline": {
70837086
"defaultMessage": "分配到时间线"

0 commit comments

Comments
 (0)