Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ const UserInvitationsTable: FC<Props> = (props) => {
of: 'externalId',
title: t(tableTranslations.externalId ?? null),
sortable: false,
searchable: false,
searchable: true,
cell: (datum) => datum.externalId ?? null,
} satisfies ColumnTemplate<InvitationRowData>,
]
Expand Down Expand Up @@ -278,6 +278,9 @@ const UserInvitationsTable: FC<Props> = (props) => {
data={processedInvitations}
getRowId={(datum) => datum.id.toString()}
indexing={{ indices: true }}
search={{
searchPlaceholder: t(translations.searchText),
}}
sort={{
initially: { by: 'status', order: 'asc' },
}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import userEvent from '@testing-library/user-event';
import { render, screen, waitForElementToBeRemoved } from 'test-utils';
import { InvitationMiniEntity } from 'types/course/userInvitations';

import UserInvitationsTable from '../UserInvitationsTable';

const baseInvitation: InvitationMiniEntity = {
id: 1,
name: 'Alice Lim',
email: 'alice@example.com',
externalId: null,
role: 'student',
phantom: false,
confirmed: false,
isRetryable: true,
invitationKey: 'KEY001',
sentAt: '2024-01-01T00:00:00Z',
confirmedAt: null,
};

const SEARCH_PLACEHOLDER = 'Search by name, email or external ID';

describe('<UserInvitationsTable />', () => {
describe('search', () => {
it('filters invitations by external ID', async () => {
const user = userEvent.setup();
const invitations: InvitationMiniEntity[] = [
{
...baseInvitation,
id: 1,
name: 'Alice Lim',
externalId: 'EXT-ALICE',
},
{
...baseInvitation,
id: 2,
name: 'Bob Tan',
email: 'bob@example.com',
externalId: 'EXT-BOB',
},
];

render(<UserInvitationsTable invitations={invitations} />);
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));

await user.type(
screen.getByPlaceholderText(SEARCH_PLACEHOLDER),
'EXT-ALICE',
);

expect(screen.getByText('Alice Lim')).toBeInTheDocument();
expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument();
});

it('searches external ID case-insensitively', async () => {
const user = userEvent.setup();
const invitations: InvitationMiniEntity[] = [
{
...baseInvitation,
id: 1,
name: 'Alice Lim',
externalId: 'ext-alice',
},
{
...baseInvitation,
id: 2,
name: 'Bob Tan',
email: 'bob@example.com',
externalId: 'EXT-BOB',
},
];

render(<UserInvitationsTable invitations={invitations} />);
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));

await user.type(
screen.getByPlaceholderText(SEARCH_PLACEHOLDER),
'EXT-ALICE',
);

expect(screen.getByText('Alice Lim')).toBeInTheDocument();
expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument();
});

it('shows all invitations when search is cleared', async () => {
const user = userEvent.setup();
const invitations: InvitationMiniEntity[] = [
{
...baseInvitation,
id: 1,
name: 'Alice Lim',
externalId: 'EXT-ALICE',
},
{
...baseInvitation,
id: 2,
name: 'Bob Tan',
email: 'bob@example.com',
externalId: 'EXT-BOB',
},
];

render(<UserInvitationsTable invitations={invitations} />);
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));

const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
await user.type(searchInput, 'EXT-ALICE');
expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument();

await user.clear(searchInput);
expect(screen.getByText('Alice Lim')).toBeInTheDocument();
expect(screen.getByText('Bob Tan')).toBeInTheDocument();
});
});
});
4 changes: 4 additions & 0 deletions client/app/bundles/course/user-invitations/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const translations = defineMessages({
id: 'course.userInvitations.UserInvitationsTable.noInvitations',
defaultMessage: 'There are no invitations.',
},
searchText: {
id: 'course.userInvitations.UserInvitationsTable.searchText',
defaultMessage: 'Search by name, email or external ID',
},
pending: {
id: 'course.userInvitations.UserInvitationsTable.pending',
defaultMessage: 'Pending',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import userEvent from '@testing-library/user-event';
import { render, screen, waitForElementToBeRemoved } from 'test-utils';
import { CourseUserMiniEntity } from 'types/course/courseUsers';

import ManageUsersTable from '../index';

const baseUser: CourseUserMiniEntity = {
id: 1,
name: 'Alice Lim',
email: 'alice@example.com',
role: 'student',
};

const SEARCH_PLACEHOLDER = 'Search by name, email or external ID';

describe('<ManageUsersTable />', () => {
describe('search', () => {
it('filters users by external ID', async () => {
const user = userEvent.setup();
const users: CourseUserMiniEntity[] = [
{ ...baseUser, id: 1, name: 'Alice Lim', externalId: 'EXT-ALICE' },
{
...baseUser,
id: 2,
name: 'Bob Tan',
email: 'bob@example.com',
externalId: 'EXT-BOB',
},
];

render(
<ManageUsersTable csvDownloadFilename="users.csv" users={users} />,
);
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));

await user.type(
screen.getByPlaceholderText(SEARCH_PLACEHOLDER),
'EXT-ALICE',
);

expect(screen.getByText('Alice Lim')).toBeInTheDocument();
expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument();
});

it('searches external ID case-insensitively', async () => {
const user = userEvent.setup();
const users: CourseUserMiniEntity[] = [
{ ...baseUser, id: 1, name: 'Alice Lim', externalId: 'ext-alice' },
{
...baseUser,
id: 2,
name: 'Bob Tan',
email: 'bob@example.com',
externalId: 'EXT-BOB',
},
];

render(
<ManageUsersTable csvDownloadFilename="users.csv" users={users} />,
);
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));

await user.type(
screen.getByPlaceholderText(SEARCH_PLACEHOLDER),
'EXT-ALICE',
);

expect(screen.getByText('Alice Lim')).toBeInTheDocument();
expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument();
});

it('shows all users when search is cleared', async () => {
const user = userEvent.setup();
const users: CourseUserMiniEntity[] = [
{ ...baseUser, id: 1, name: 'Alice Lim', externalId: 'EXT-ALICE' },
{
...baseUser,
id: 2,
name: 'Bob Tan',
email: 'bob@example.com',
externalId: 'EXT-BOB',
},
];

render(
<ManageUsersTable csvDownloadFilename="users.csv" users={users} />,
);
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));

const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
await user.type(searchInput, 'EXT-ALICE');
expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument();

await user.clear(searchInput);
expect(screen.getByText('Alice Lim')).toBeInTheDocument();
expect(screen.getByText('Bob Tan')).toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -195,15 +195,11 @@ const ManageUsersTable = (props: ManageUsersTableProps): JSX.Element => {
if (!user.name && !user.email) return false;
if (!filterValue?.length) return true;

const query = filterValue.toLowerCase().trim();
return (
user.name
.toLowerCase()
.trim()
.includes(filterValue.toLowerCase().trim()) ||
user.email
.toLowerCase()
.trim()
.includes(filterValue.toLowerCase().trim())
user.name.toLowerCase().trim().includes(query) ||
user.email.toLowerCase().trim().includes(query) ||
(user.externalId?.toLowerCase().trim().includes(query) ?? false)
);
},
},
Expand Down
2 changes: 1 addition & 1 deletion client/app/bundles/course/users/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const translations = defineMessages({
},
searchText: {
id: 'course.users.ManageUsersTable.ManageUsersTable.searchText',
defaultMessage: 'Search by name or email',
defaultMessage: 'Search by name, email or external ID',
},
renameSuccess: {
id: 'course.users.ManageUsersTable.renameSuccess',
Expand Down
2 changes: 1 addition & 1 deletion client/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7096,7 +7096,7 @@
"defaultMessage": "There are no users"
},
"course.users.ManageUsersTable.ManageUsersTable.searchText": {
"defaultMessage": "Search by name or email"
"defaultMessage": "Search by name, email or external ID"
},
"course.users.ManageUsersTable.assignToTimeline": {
"defaultMessage": "Assign to timeline"
Expand Down
5 changes: 4 additions & 1 deletion client/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -7040,6 +7040,9 @@
"course.userInvitations.UserInvitationsTable.noInvitations": {
"defaultMessage": "초대가 없습니다."
},
"course.userInvitations.UserInvitationsTable.searchText": {
"defaultMessage": "이름, 이메일 또는 외부 ID로 검색"
},
"course.userInvitations.UserInvitationsTable.pending": {
"defaultMessage": "대기 중"
},
Expand Down Expand Up @@ -7083,7 +7086,7 @@
"defaultMessage": "사용자가 없습니다."
},
"course.users.ManageUsersTable.ManageUsersTable.searchText": {
"defaultMessage": "이름 또는 이메일로 검색"
"defaultMessage": "이름, 이메일 또는 외부 ID로 검색"
},
"course.users.ManageUsersTable.assignToTimeline": {
"defaultMessage": "타임라인에 할당"
Expand Down
5 changes: 4 additions & 1 deletion client/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -7034,6 +7034,9 @@
"course.userInvitations.UserInvitationsTable.noInvitations": {
"defaultMessage": "没有邀请。"
},
"course.userInvitations.UserInvitationsTable.searchText": {
"defaultMessage": "按姓名、电子邮件或外部ID搜索"
},
"course.userInvitations.UserInvitationsTable.pending": {
"defaultMessage": "待处理"
},
Expand Down Expand Up @@ -7077,7 +7080,7 @@
"defaultMessage": "没有用户"
},
"course.users.ManageUsersTable.ManageUsersTable.searchText": {
"defaultMessage": "按姓名、电子邮件、角色等搜索。"
"defaultMessage": "按姓名、电子邮件或外部ID搜索"
},
"course.users.ManageUsersTable.assignToTimeline": {
"defaultMessage": "分配到时间线"
Expand Down