From 0443b4656883fd39c658a514e64ffe5bce5bb5cf Mon Sep 17 00:00:00 2001 From: lws49 Date: Tue, 26 May 2026 15:59:42 +0800 Subject: [PATCH 1/2] feat(users): extend search to include external ID in user tables Update the search placeholder and filter logic in ManageUsersTable, StudentStatisticsTable 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. --- .../students/StudentStatisticsTable.tsx | 2 +- .../tables/UserInvitationsTable.tsx | 7 +- .../__test__/UserInvitationsTable.test.tsx | 115 ++++++++++++++++++ .../course/user-invitations/translations.ts | 4 + .../ManageUsersTable/__test__/index.test.tsx | 99 +++++++++++++++ .../tables/ManageUsersTable/index.tsx | 12 +- .../app/bundles/course/users/translations.ts | 2 +- client/locales/en.json | 4 +- client/locales/ko.json | 7 +- client/locales/zh.json | 7 +- 10 files changed, 241 insertions(+), 18 deletions(-) create mode 100644 client/app/bundles/course/user-invitations/components/tables/__test__/UserInvitationsTable.test.tsx create mode 100644 client/app/bundles/course/users/components/tables/ManageUsersTable/__test__/index.test.tsx 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/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": "分配到时间线" From c52f9d55bd918ee03f2f1989b9f028fbf567a1e9 Mon Sep 17 00:00:00 2001 From: lws49 Date: Mon, 8 Jun 2026 23:37:03 +0800 Subject: [PATCH 2/2] fix(search): fix bug where external ID not included in search when first entry has external ID being null --- .../useTanStackTableBuilder.tsx | 8 +++ .../useTanStackTableBuilder.test.tsx | 52 +++++++++++++++++++ 2 files changed, 60 insertions(+) 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();