diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentStatisticsTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentStatisticsTable.tsx index 5f22c48ce17..038540e0c4b 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentStatisticsTable.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentStatisticsTable.tsx @@ -61,7 +61,7 @@ const translations = defineMessages({ }, searchBar: { id: 'course.statistics.StatisticsIndex.students.searchBar', - defaultMessage: 'Search by Student Name or Student Type', + defaultMessage: 'Search by Student Name, Student Type or External ID', }, }); diff --git a/client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx b/client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx index c24b976f1f8..89c4c7694a0 100644 --- a/client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx +++ b/client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx @@ -170,8 +170,8 @@ const UserInvitationsTable: FC = (props) => { of: 'externalId', title: t(tableTranslations.externalId), sortable: false, - searchable: false, - cell: (datum) => datum.externalId ?? '', + searchable: true, + cell: (datum) => datum.externalId ?? null, } satisfies ColumnTemplate, ] : []), @@ -280,6 +280,9 @@ const UserInvitationsTable: FC = (props) => { data={processedInvitations} getRowId={(datum) => datum.id.toString()} indexing={{ indices: true }} + search={{ + searchPlaceholder: t(translations.searchText), + }} sort={{ initially: { by: 'status', order: 'asc' }, }} diff --git a/client/app/bundles/course/user-invitations/components/tables/__test__/UserInvitationsTable.test.tsx b/client/app/bundles/course/user-invitations/components/tables/__test__/UserInvitationsTable.test.tsx new file mode 100644 index 00000000000..62bde5be532 --- /dev/null +++ b/client/app/bundles/course/user-invitations/components/tables/__test__/UserInvitationsTable.test.tsx @@ -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('', () => { + 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(); + 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(); + 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(); + 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(); + }); + }); +}); diff --git a/client/app/bundles/course/user-invitations/translations.ts b/client/app/bundles/course/user-invitations/translations.ts index c3c249f6b4c..0569076b635 100644 --- a/client/app/bundles/course/user-invitations/translations.ts +++ b/client/app/bundles/course/user-invitations/translations.ts @@ -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', diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable/__test__/index.test.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable/__test__/index.test.tsx new file mode 100644 index 00000000000..fdbaedfc26c --- /dev/null +++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/__test__/index.test.tsx @@ -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('', () => { + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + }); + }); +}); diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx index 31da6ed8c16..72bf3413222 100644 --- a/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx +++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx @@ -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) ); }, }, diff --git a/client/app/bundles/course/users/translations.ts b/client/app/bundles/course/users/translations.ts index cb7aec0635d..c4e66a140b5 100644 --- a/client/app/bundles/course/users/translations.ts +++ b/client/app/bundles/course/users/translations.ts @@ -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', diff --git a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx index dc648e2f28e..b8544e8f937 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx +++ b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx @@ -175,6 +175,14 @@ const useTanStackTableBuilder = ( props.pagination.onPaginationChange(newValue, pagination); } }, + // TanStack's default getColumnCanGlobalFilter sniffs the first row's value + // type (string|number) to decide whether a column participates in global + // filter. When the first row has a nullable column (e.g. externalId=null), + // typeof null === 'object' → false, silently excluding that column from + // search even when the column has searchable:true / enableGlobalFilter:true. + // We already express intent via enableGlobalFilter, so bypass the sniff. + // See: https://github.com/TanStack/table/pull/6252 + getColumnCanGlobalFilter: () => true, autoResetPageIndex: false, state: { rowSelection, diff --git a/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx b/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx index b8899dbf676..94f6031da08 100644 --- a/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx +++ b/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx @@ -768,6 +768,58 @@ describe('localStorage persistence', () => { }); }); +// ---------- global search — nullable searchable column (regression) ---------- +// +// Root cause: TanStack's default getColumnCanGlobalFilter sniffs the first row's +// value type (string|number). When the first row has externalId=null, typeof null +// === 'object', so TanStack silently excludes the column from global filter for +// the entire table — even rows with a real string value are never matched. +// Fix: override getColumnCanGlobalFilter in useReactTable to return true always, +// relying on enableGlobalFilter (set by searchable:true/false) instead of sniffing. + +describe('global search — searchable column whose first row is null', () => { + interface StudentRow { + id: number; + name: string; + externalId: string | null; + } + + const nullFirstData: StudentRow[] = [ + { id: 1, name: 'Alice', externalId: null }, + { id: 2, name: 'Bob', externalId: 'EXT001' }, + ]; + + const searchColumns: ColumnTemplate[] = [ + { of: 'name', title: 'Name', cell: (r) => r.name, searchable: true }, + { + of: 'externalId', + title: 'External ID', + cell: (r) => r.externalId ?? '', + searchable: true, + }, + ]; + + it('finds rows by a searchable column value even when the first row has null for that column', () => { + const { result } = renderHook( + () => + useTanStackTableBuilder({ + data: nullFirstData, + columns: searchColumns, + getRowId: (r) => r.id.toString(), + search: { searchPlaceholder: 'Search' }, + }), + { wrapper: withStore() }, + ); + + act(() => result.current.toolbar!.onSearchKeywordChange?.('EXT001')); + + expect(result.current.body.rows).toHaveLength(1); + expect( + (result.current.body.rows[0] as { original: StudentRow }).original.name, + ).toBe('Bob'); + }); +}); + describe('useTanStackTableBuilder onDirectExport', () => { beforeEach(() => { mockedDownloadFile.mockClear(); diff --git a/client/locales/en.json b/client/locales/en.json index 2e1646a2018..c985d1e1030 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6289,7 +6289,7 @@ "defaultMessage": "Student Statistics" }, "course.statistics.StatisticsIndex.students.searchBar": { - "defaultMessage": "Search by Student Name or Student Type" + "defaultMessage": "Search by Student Name, Student Type or External ID" }, "course.statistics.StatisticsIndex.studentsFailure": { "defaultMessage": "Failed to fetch student data!" @@ -7231,7 +7231,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" diff --git a/client/locales/ko.json b/client/locales/ko.json index 14d3f6eff9a..4e5335475ca 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6270,7 +6270,7 @@ "defaultMessage": "학생 통계" }, "course.statistics.StatisticsIndex.students.searchBar": { - "defaultMessage": "학생 이름 또는 학생 유형으로 검색" + "defaultMessage": "학생 이름, 학생 유형 또는 외부 ID로 검색" }, "course.statistics.StatisticsIndex.studentsFailure": { "defaultMessage": "학생 데이터를 가져오는 데 실패했습니다!" @@ -7172,6 +7172,9 @@ "course.userInvitations.UserInvitationsTable.noInvitations": { "defaultMessage": "초대가 없습니다." }, + "course.userInvitations.UserInvitationsTable.searchText": { + "defaultMessage": "이름, 이메일 또는 외부 ID로 검색" + }, "course.userInvitations.UserInvitationsTable.pending": { "defaultMessage": "대기 중" }, @@ -7215,7 +7218,7 @@ "defaultMessage": "사용자가 없습니다." }, "course.users.ManageUsersTable.ManageUsersTable.searchText": { - "defaultMessage": "이름 또는 이메일로 검색" + "defaultMessage": "이름, 이메일 또는 외부 ID로 검색" }, "course.users.ManageUsersTable.assignToTimeline": { "defaultMessage": "타임라인에 할당" diff --git a/client/locales/zh.json b/client/locales/zh.json index 6dac3121633..4595d3f0cb6 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6264,7 +6264,7 @@ "defaultMessage": "学生统计" }, "course.statistics.StatisticsIndex.students.searchBar": { - "defaultMessage": "按学生姓名或学生类型搜索" + "defaultMessage": "按学生姓名、学生类型或外部编号搜索" }, "course.statistics.StatisticsIndex.studentsFailure": { "defaultMessage": "获取学生数据失败!" @@ -7166,6 +7166,9 @@ "course.userInvitations.UserInvitationsTable.noInvitations": { "defaultMessage": "没有邀请。" }, + "course.userInvitations.UserInvitationsTable.searchText": { + "defaultMessage": "按姓名、电子邮件或外部ID搜索" + }, "course.userInvitations.UserInvitationsTable.pending": { "defaultMessage": "待处理" }, @@ -7209,7 +7212,7 @@ "defaultMessage": "没有用户" }, "course.users.ManageUsersTable.ManageUsersTable.searchText": { - "defaultMessage": "按姓名、电子邮件、角色等搜索。" + "defaultMessage": "按姓名、电子邮件或外部ID搜索" }, "course.users.ManageUsersTable.assignToTimeline": { "defaultMessage": "分配到时间线"