From 396bd145b54e58c250129964447829498688febe Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:19:40 +0800 Subject: [PATCH 1/5] fix(i18n): rename advanced client search The page exposes one client-scoped free-text query, so align its navigation label and title with the capability it actually provides. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- src/assets/i18n/en.json | 4 ++-- src/assets/i18n/hi.json | 2 +- src/assets/i18n/ko.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index ec1fda549..f2f16c1ab 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -16,7 +16,7 @@ "main": "Main Navigation", "dashboard": "Dashboard", "clients": "Clients", - "clientSearchV2": "Advanced Client Search", + "clientSearchV2": "Client Search", "groups": "Groups", "centers": "Centers", "loans": "Loans", @@ -2711,7 +2711,7 @@ "SUCCESS": "Operation successful." }, "CLIENT_SEARCH_V2": { - "TITLE": "Advanced Client Search", + "TITLE": "Client Search", "QUERY": "Search Query", "SEARCH": "Search", "NAME": "Client Name", diff --git a/src/assets/i18n/hi.json b/src/assets/i18n/hi.json index 7eb3fc8e2..bce8beef9 100644 --- a/src/assets/i18n/hi.json +++ b/src/assets/i18n/hi.json @@ -15,7 +15,7 @@ "main": "मुख्य नेविगेशन", "dashboard": "डैशबोर्ड", "clients": "ग्राहक", - "clientSearchV2": "उन्नत ग्राहक खोज", + "clientSearchV2": "ग्राहक खोज", "groups": "समूह", "centers": "केंद्र", "loans": "ऋण", diff --git a/src/assets/i18n/ko.json b/src/assets/i18n/ko.json index 3859660a1..79cfe5aa2 100644 --- a/src/assets/i18n/ko.json +++ b/src/assets/i18n/ko.json @@ -15,7 +15,7 @@ "main": "메인 내비게이션", "dashboard": "대시보드", "clients": "고객", - "clientSearchV2": "고급 고객 검색", + "clientSearchV2": "고객 검색", "groups": "그룹", "centers": "센터", "loans": "대출", From 3ff52700807d38325fcac226fe4a7837097ad96a Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:54:56 +0800 Subject: [PATCH 2/5] feat(clients): consolidate paginated client search Unify the client list and text search, preserve status filtering and supported sorting, and cover desktop and mobile pagination. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- CHANGELOG.md | 8 + e2e/accessibility.spec.ts | 3 + e2e/all-functions-read-shortcut.spec.ts | 3 + e2e/client.spec.ts | 3 + e2e/e2e-journey.spec.ts | 4 + e2e/functional-coverage.spec.ts | 3 + e2e/list-pagination.spec.ts | 60 ++++- e2e/mobile-shell.spec.ts | 20 +- e2e/utils/client-search-mock.ts | 48 ++++ eslint-suppressions.json | 10 - scripts/check-nav-ids.mjs | 5 +- .../services/navigation-config.service.ts | 7 - .../clients/client-search-v2.component.ts | 232 ------------------ .../clients/clients-list.component.test.ts | 181 ++++++++++++++ .../clients/clients-list.component.ts | 189 +++++++++----- src/app/features/clients/clients.routes.ts | 7 +- .../data-table/data-table.component.test.ts | 11 + .../data-table/data-table.component.ts | 4 +- src/assets/i18n/en.json | 16 +- 19 files changed, 474 insertions(+), 340 deletions(-) create mode 100644 e2e/utils/client-search-mock.ts delete mode 100644 src/app/features/clients/client-search-v2.component.ts create mode 100644 src/app/features/clients/clients-list.component.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9faebb2c7..964a1b3d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ Notable changes to the Apache Fineract Backoffice UI. Format follows ## [Unreleased] +### Changed + +- Client search and the client list share one paginated screen. The separate `clients.search` + navigation id is retired; deployments should move any navigation overrides to `clients`. + Existing `/clients/search` links redirect to `/clients`. With a status filter selected, + search continues to match client names; without it, text search also matches account numbers, + external identifiers, mobile numbers and identification documents. + ## [1.0.0-rc.1] The first release candidate. Everything below is the initial release content rather than a diff diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index 9544c17d9..fe6fb4e70 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -17,6 +17,7 @@ * under the License. */ +import { mockClientTextSearch } from './utils/client-search-mock'; import AxeBuilder from '@axe-core/playwright'; import { test, expect, Page } from './fixtures'; @@ -121,6 +122,8 @@ async function mockSession(page: Page): Promise { }); }); + await mockClientTextSearch(page); + await page.route(/\/api\/v1\/clients(\?|$)/, async (route) => { await route.fulfill({ status: 200, diff --git a/e2e/all-functions-read-shortcut.spec.ts b/e2e/all-functions-read-shortcut.spec.ts index 222dc6a99..245393378 100644 --- a/e2e/all-functions-read-shortcut.spec.ts +++ b/e2e/all-functions-read-shortcut.spec.ts @@ -30,6 +30,7 @@ * which is hidden or shown purely by `AuthService.hasPermission()`. */ +import { mockClientTextSearch } from './utils/client-search-mock'; import { test, expect, Page } from './fixtures'; const API_BASE = '/api/v1'; @@ -65,6 +66,8 @@ async function login(page: Page, permissions: string[]): Promise { }); }); + await mockClientTextSearch(page); + await page.route('**/api/v1/clients*', async (route) => { await route.fulfill({ status: 200, diff --git a/e2e/client.spec.ts b/e2e/client.spec.ts index e2f136915..173d089a7 100644 --- a/e2e/client.spec.ts +++ b/e2e/client.spec.ts @@ -17,6 +17,7 @@ * under the License. */ +import { mockClientTextSearch } from './utils/client-search-mock'; import { test, expect } from './fixtures'; const HEAD_OFFICE = 'Head Office'; @@ -102,6 +103,8 @@ test.describe('Client Management', () => { } }); + await mockClientTextSearch(page, createdClients); + // Intercept Clients List GET await page.route('**/api/v1/clients?**', async (route) => { await route.fulfill({ diff --git a/e2e/e2e-journey.spec.ts b/e2e/e2e-journey.spec.ts index b20779557..a8c84a546 100644 --- a/e2e/e2e-journey.spec.ts +++ b/e2e/e2e-journey.spec.ts @@ -19,6 +19,7 @@ /* eslint-disable sonarjs/no-duplicate-string -- Playwright test patterns inherently repeat locator strings */ +import { mockClientTextSearch } from './utils/client-search-mock'; import { test, expect, Page } from './fixtures'; /* ─────────── Constants ─────────── */ @@ -106,6 +107,7 @@ async function mockOffices(page: Page, offices = [OFFICE_HEAD]) { } async function mockClients(page: Page, clients: unknown[] = []) { + await mockClientTextSearch(page, clients); const body = clients.length > 0 ? JSON.stringify({ totalFilteredRecords: clients.length, pageItems: clients }) @@ -668,6 +670,7 @@ test.describe('E2E: Client Creation', () => { }); test('should fill client form and enable save on the last step', async ({ page }) => { + await mockClientTextSearch(page); await page.route('**/api/v1/clients?**', async (route) => { await route.fulfill(okJsonResponse(EMPTY_LIST)); }); @@ -689,6 +692,7 @@ test.describe('E2E: Client Creation', () => { }); test('should submit client and redirect to clients list', async ({ page }) => { + await mockClientTextSearch(page); await page.route('**/api/v1/clients?**', async (route) => { await route.fulfill(okJsonResponse(EMPTY_LIST)); }); diff --git a/e2e/functional-coverage.spec.ts b/e2e/functional-coverage.spec.ts index 74077504e..1c0e34459 100644 --- a/e2e/functional-coverage.spec.ts +++ b/e2e/functional-coverage.spec.ts @@ -17,6 +17,7 @@ * under the License. */ +import { mockClientTextSearch } from './utils/client-search-mock'; import { test, expect, Page } from './fixtures'; const TEST_USER = 'mifos'; @@ -128,6 +129,7 @@ async function loginAndGoToDashboard(page: Page) { } async function setupClientMocks(page: Page, clients: unknown[] = []) { + await mockClientTextSearch(page, clients); const body = clients.length > 0 ? JSON.stringify({ totalFilteredRecords: clients.length, pageItems: clients }) @@ -1172,6 +1174,7 @@ test.describe('Create Office Dialog from Client Form', () => { test('should open create office dialog from client create form', async ({ page }) => { await loginAndGoToDashboard(page); await mockOffices(page); + await mockClientTextSearch(page); await page.route('**/api/v1/clients?**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: EMPTY_RESPONSE }); }); diff --git a/e2e/list-pagination.spec.ts b/e2e/list-pagination.spec.ts index 798ef0ae5..44194278f 100644 --- a/e2e/list-pagination.spec.ts +++ b/e2e/list-pagination.spec.ts @@ -26,6 +26,7 @@ * against page.route() mocks, so it needs no backend. */ +import { mockClientTextSearch } from './utils/client-search-mock'; import { test, expect, Page } from './fixtures'; const TENANT = 'default'; @@ -80,7 +81,9 @@ async function mockSession(page: Page) { }); }); - // Serve the slice the app asks for, so the rows on screen reflect the offset. + await mockClientTextSearch(page, clientsPage(0, TOTAL_CLIENTS).pageItems); + + // Status filtering keeps using the v1 endpoint. await page.route(/\/api\/v1\/clients(\?|$)/, async (route) => { const url = new URL(route.request().url()); const offset = Number(url.searchParams.get('offset') ?? 0); @@ -164,6 +167,46 @@ test.describe('List pagination', () => { // paginator still reading "51 - 54" would be lying about what is on screen. await expect(range(page)).toContainText(`1 - 10 of ${TOTAL_CLIENTS}`); await expect(firstAccountNo(page)).toHaveText('000000001'); + await expect( + page.getByText('When a status is selected, search matches client names.'), + ).toBeVisible(); + }); + + test('keeps search text when paging and shows the v2 account number', async ({ page }) => { + await page.getByPlaceholder('Type to search...').fill('Client'); + await expect(range(page)).toContainText(`1 - 10 of ${TOTAL_CLIENTS}`); + const requested = page.waitForRequest( + (request) => + request.url().endsWith('/api/v2/clients/search') && request.postDataJSON().page === 1, + ); + await pagerButton(page, NEXT).click(); + expect((await requested).postDataJSON()).toMatchObject({ + request: { text: 'Client' }, + page: 1, + size: 10, + }); + await expect(firstAccountNo(page)).toHaveText('000000011'); + }); + + test('redirects the old search page and exposes only one client navigation item', async ({ + page, + }) => { + await page.goto('/clients/search'); + await expect(page).toHaveURL('/clients'); + await expect(page.locator('a[href="/clients/search"]')).toHaveCount(0); + await expect(firstAccountNo(page)).toHaveText('000000001'); + }); + + test('keeps an account-number search broad and disables unsupported Office sorting', async ({ + page, + }) => { + await page.getByRole('button', { name: 'Office', exact: true }).click(); + await expect(page.locator('th[aria-sort="ascending"]')).toContainText('Office'); + await page.getByPlaceholder('Search by client name...').fill('000000011'); + await expect(firstAccountNo(page)).toHaveText('000000011'); + await expect(range(page)).toContainText('1 - 1 of 1'); + await expect(page.getByRole('button', { name: 'Office', exact: true })).toHaveCount(0); + await expect(page.locator('th[aria-sort]')).toHaveCount(0); }); test('returns to the first page when searching', async ({ page }) => { @@ -225,19 +268,24 @@ test.describe('List load failure', () => { // Fail the first request, serve the second. The retry has to be what fixes it, or this // passes whether or not the button is wired to anything. let attempts = 0; - await page.route(/\/api\/v1\/clients(\?|$)/, async (route) => { + await page.route('**/api/v2/clients/search', async (route) => { attempts += 1; if (attempts === 1) { await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); return; } - const url = new URL(route.request().url()); + const { page: pageIndex, size } = route.request().postDataJSON(); + const clients = clientsPage(pageIndex * size, size); await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify( - clientsPage(Number(url.searchParams.get('offset') ?? 0), PAGE_SIZE, undefined), - ), + body: JSON.stringify({ + content: clients.pageItems.map((client) => ({ + ...client, + accountNumber: client.accountNo, + })), + totalElements: clients.totalFilteredRecords, + }), }); }); diff --git a/e2e/mobile-shell.spec.ts b/e2e/mobile-shell.spec.ts index a3c280a45..7f2808611 100644 --- a/e2e/mobile-shell.spec.ts +++ b/e2e/mobile-shell.spec.ts @@ -30,6 +30,7 @@ */ import { test, expect, type Page } from './fixtures'; +import { mockClientTextSearch } from './utils/client-search-mock'; const TENANT = 'default'; const USER = 'mifos'; @@ -42,6 +43,7 @@ const MOBILE_BREAKPOINT_PX = 768; const MIN_TAP_TARGET_PX = 44; async function mockBackend(page: Page): Promise { + await mockClientTextSearch(page); // Registration order is load-bearing: Playwright matches routes in *reverse* order, so the // catch-all has to be registered first or it shadows every specific handler below it. With it // last, the authentication call returns `{}`, the session carries no permissions, and RBAC @@ -140,16 +142,16 @@ test.describe('the shell at a phone viewport', () => { }); test('keeps the paginator label intact on a phone viewport', async ({ page }) => { - await page.route(/\/api\/v1\/clients/, (route) => + await page.route('**/api/v2/clients/search', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ - totalFilteredRecords: 21, - pageItems: [ + totalElements: 21, + content: [ { id: 1, - accountNo: '000000001', + accountNumber: '000000001', displayName: 'Aisha Rahman', status: { value: 'Active' }, }, @@ -179,23 +181,23 @@ test.describe('the shell at a phone viewport', () => { test('renders tables as cards instead of a sideways scroll', async ({ page }) => { // The generic `/api/v1/` mock returns `{}`, which renders an empty state rather than a // table — so this case has to supply rows before it can assert on how they are laid out. - await page.route(/\/api\/v1\/clients/, (route) => + await page.route('**/api/v2/clients/search', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ - totalFilteredRecords: 2, - pageItems: [ + totalElements: 2, + content: [ { id: 1, - accountNo: '000000001', + accountNumber: '000000001', displayName: 'Aisha Rahman', status: { value: 'Active' }, officeName: 'Head Office', }, { id: 2, - accountNo: '000000002', + accountNumber: '000000002', displayName: 'Boubacar Diallo', status: { value: 'Pending' }, officeName: 'Head Office', diff --git a/e2e/utils/client-search-mock.ts b/e2e/utils/client-search-mock.ts new file mode 100644 index 000000000..f7a5e7c96 --- /dev/null +++ b/e2e/utils/client-search-mock.ts @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Page } from '../fixtures'; + +/** Serve the v2 API shape from the same client fixtures used by v1 form mocks. */ +export async function mockClientTextSearch(page: Page, clients: readonly unknown[] = []) { + await page.route('**/api/v2/clients/search', async (route) => { + const { request, page: pageIndex = 0, size = 10 } = route.request().postDataJSON(); + const text = String(request?.text ?? '').toLowerCase(); + const matches = clients + .map((value) => { + const client = value as Record; + return { ...client, accountNumber: client['accountNo'] }; + }) + .filter((client) => + ['displayName', 'accountNumber', 'externalId', 'mobileNo'].some((key) => + String((client as Record)[key] ?? '') + .toLowerCase() + .includes(text), + ), + ); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + content: matches.slice(pageIndex * size, (pageIndex + 1) * size), + totalElements: matches.length, + }), + }); + }); +} diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 084327fc8..fed17657d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -234,11 +234,6 @@ "count": 1 } }, - "src/app/features/clients/client-search-v2.component.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/features/clients/client-view.component.test.ts": { "no-restricted-imports": { "count": 1 @@ -249,11 +244,6 @@ "count": 1 } }, - "src/app/features/clients/clients-list.component.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/features/clients/collateral/client-collateral-form.component.test.ts": { "no-restricted-imports": { "count": 1 diff --git a/scripts/check-nav-ids.mjs b/scripts/check-nav-ids.mjs index 60ee91d17..0aaa5e69e 100644 --- a/scripts/check-nav-ids.mjs +++ b/scripts/check-nav-ids.mjs @@ -52,7 +52,10 @@ const SNAPSHOT = join(HERE, 'nav-ids.json'); * stops matching, and so the removal appears in a changelog someone reads. */ const DEPRECATED = { - // 'products.share': { removedIn: '1.1.0', note: 'Folded into products.shares.' }, + 'clients.search': { + removedIn: 'unreleased', + note: 'Folded into clients; /clients/search redirects to /clients.', + }, }; /** Ids as they appear in NAV_CONFIG, in source order. */ diff --git a/src/app/core/services/navigation-config.service.ts b/src/app/core/services/navigation-config.service.ts index ba566d857..5c19c526c 100644 --- a/src/app/core/services/navigation-config.service.ts +++ b/src/app/core/services/navigation-config.service.ts @@ -166,13 +166,6 @@ const NAV_CONFIG: readonly NavItemConfig[] = [ labelKey: 'nav.clients', icon: ICON_PEOPLE_OUTLINE, }, - { - id: 'clients.search', - route: '/clients/search', - requiredPermissions: 'READ_CLIENT', - labelKey: 'nav.clientSearchV2', - icon: 'search-circle-outline', - }, { id: 'groups', route: '/groups', diff --git a/src/app/features/clients/client-search-v2.component.ts b/src/app/features/clients/client-search-v2.component.ts deleted file mode 100644 index 14b15211b..000000000 --- a/src/app/features/clients/client-search-v2.component.ts +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { Component, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; -import { ClientSearchV2Service, PageClientSearchData, ClientSearchData } from '../../api'; -import { CdkTableModule } from '@angular/cdk/table'; -import { PaginatorComponent } from '../../shared/components/paginator/paginator.component'; -import { PageEvent } from '../../shared/models/table.model'; -import { - IonButton, - IonCard, - IonCardContent, - IonCardHeader, - IonCardTitle, - IonIcon, - IonInput, - IonItem, - IonLabel, - IonSpinner, -} from '@ionic/angular/standalone'; - -@Component({ - selector: 'app-client-search-v2', - standalone: true, - imports: [ - FormsModule, - CdkTableModule, - TranslateModule, - IonIcon, - IonButton, - IonSpinner, - IonInput, - IonItem, - IonLabel, - IonCardContent, - IonCardHeader, - IonCardTitle, - IonCard, - PaginatorComponent, - ], - template: ` - - - {{ 'CLIENT_SEARCH_V2.TITLE' | translate }} - - -
- - {{ 'CLIENT_SEARCH_V2.QUERY' | translate }} - - - - - {{ 'CLIENT_SEARCH_V2.SEARCH' | translate }} - -
- - @if (isLoading()) { -
- -
- } - - @if (results().length > 0) { - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ 'CLIENT_SEARCH_V2.NAME' | translate }}{{ row.displayName }} - {{ 'CLIENT_SEARCH_V2.ACCOUNT_NO' | translate }} - {{ row.accountNo }}{{ 'CLIENT_SEARCH_V2.STATUS' | translate }}{{ row.status?.value }}{{ 'CLIENT_SEARCH_V2.OFFICE' | translate }}{{ row.officeName }} - - - -
- - - } - - @if (searched() && results().length === 0 && !isLoading()) { -

{{ 'CLIENT_SEARCH_V2.NO_RESULTS' | translate }}

- } -
-
- `, - styles: [ - ` - .search-row { - display: flex; - gap: 16px; - align-items: center; - margin-bottom: 8px; - } - .search-field { - flex: 1; - } - .spinner-row { - display: flex; - justify-content: center; - padding: 24px; - } - .full-width { - width: 100%; - } - .clickable-row { - cursor: pointer; - } - .clickable-row:hover { - background: rgba(0, 0, 0, 0.04); - } - .no-results { - text-align: center; - color: rgba(0, 0, 0, 0.5); - padding: 24px 0; - } - `, - ], -}) -export class ClientSearchV2Component { - private readonly clientSearchService = inject(ClientSearchV2Service); - private readonly router = inject(Router); - - query = ''; - pageSize = 10; - pageIndex = 0; - pageNumber = 0; - readonly isLoading = signal(false); - readonly searched = signal(false); - - readonly results = signal([]); - readonly totalElements = signal(0); - - displayedColumns = ['displayName', 'accountNo', 'status', 'officeName', 'actions']; - - search(page = 0): void { - if (!this.query.trim()) return; - this.isLoading.set(true); - this.pageNumber = page; - - this.clientSearchService - .postClientsSearch({ - request: { text: this.query }, - page: this.pageNumber, - size: this.pageSize, - }) - .subscribe({ - next: (data: PageClientSearchData) => { - this.results.set(data?.content ?? []); - this.totalElements.set(data?.totalElements ?? 0); - this.isLoading.set(false); - this.searched.set(true); - }, - error: () => { - this.isLoading.set(false); - this.searched.set(true); - }, - }); - } - - onPage(event: PageEvent): void { - this.pageSize = event.pageSize; - this.pageIndex = event.pageIndex; - this.search(event.pageIndex); - } - - viewClient(id: number): void { - this.router.navigate(['/clients/view', id]); - } -} diff --git a/src/app/features/clients/clients-list.component.test.ts b/src/app/features/clients/clients-list.component.test.ts new file mode 100644 index 000000000..0c72efe3d --- /dev/null +++ b/src/app/features/clients/clients-list.component.test.ts @@ -0,0 +1,181 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { BASE_PATH } from '../../api'; +import { ClientsListComponent } from './clients-list.component'; +import { CLIENTS_ROUTES } from './clients.routes'; + +const SEARCH_URL = '/api/v2/clients/search'; +const CLIENTS_URL = '/api/v1/clients'; +const EMPTY_PAGE = { content: [], totalElements: 0 }; + +describe('ClientsListComponent search', () => { + let component: ClientsListComponent; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter([]), + { provide: BASE_PATH, useValue: '/api' }, + ], + }); + http = TestBed.inject(HttpTestingController); + component = TestBed.runInInjectionContext(() => new ClientsListComponent()); + }); + + afterEach(() => http.verify()); + + it('browses all clients with an empty v2 query and displays accountNumber', () => { + const req = http.expectOne(SEARCH_URL); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ request: { text: '' }, page: 0, size: 10 }); + expect(component.isLoading()).toBe(true); + req.flush({ + content: [{ id: 4, accountNumber: '0004', displayName: 'Jane', officeName: 'HQ' }], + totalElements: 37, + }); + expect(component.clients()[0]).toMatchObject({ accountNo: '0004', displayName: 'Jane' }); + expect(component.totalRecords()).toBe(37); + expect(component.isLoading()).toBe(false); + }); + + it('keeps the text and page size across pages, then resets a new search to page zero', () => { + http.expectOne(SEARCH_URL).flush(EMPTY_PAGE); + component.onSearch(' external-id-42 '); + expect(http.expectOne(SEARCH_URL).request.body.request.text).toBe('external-id-42'); + component.onPage({ pageIndex: 2, pageSize: 25, length: 90 }); + const page = http.expectOne(SEARCH_URL); + expect(page.request.body).toEqual({ request: { text: 'external-id-42' }, page: 2, size: 25 }); + page.flush(EMPTY_PAGE); + expect(component.pageIndex()).toBe(2); + expect(component.pageSize()).toBe(25); + component.onSearch('mobile'); + const search = http.expectOne(SEARCH_URL); + expect(search.request.body).toEqual({ request: { text: 'mobile' }, page: 0, size: 25 }); + search.flush(EMPTY_PAGE); + expect(component.pageIndex()).toBe(0); + }); + + it.each([ + ['accountNo', 'accountNumber'], + ['fullname', 'displayName'], + ['status', 'status'], + ])('maps the %s column to the supported v2 sort property', (active, property) => { + http.expectOne(SEARCH_URL).flush(EMPTY_PAGE); + component.onSort({ active, direction: 'desc' }); + const req = http.expectOne(SEARCH_URL); + expect(req.request.body.sorts).toEqual([{ property, direction: 'DESC' }]); + req.flush(EMPTY_PAGE); + }); + + it('preserves status filtering and name matching with v1, then returns to v2 for All', () => { + http.expectOne(SEARCH_URL).flush(EMPTY_PAGE); + component.activeFilters.status = 'closed'; + component.onFilterChange(); + const filtered = http.expectOne((req) => req.url === CLIENTS_URL); + expect(filtered.request.params.get('status')).toBe('closed'); + filtered.flush({ pageItems: [], totalFilteredRecords: 0 }); + component.onSearch('Jane'); + const named = http.expectOne((req) => req.url === CLIENTS_URL); + expect( + new URL(named.request.urlWithParams, 'https://localhost').searchParams.get('displayName'), + ).toBe('%Jane%'); + expect(named.request.params.get('status')).toBe('closed'); + named.flush({ pageItems: [], totalFilteredRecords: 0 }); + expect(component.searchPlaceholder()).toBe('CLIENTS.SEARCH_BY_NAME'); + component.activeFilters.status = ''; + component.onFilterChange(); + const all = http.expectOne(SEARCH_URL); + expect(all.request.body).toEqual({ request: { text: 'Jane' }, page: 0, size: 10 }); + all.flush(EMPTY_PAGE); + expect(component.searchPlaceholder()).toBe('COMMON.SEARCH_PLACEHOLDER'); + }); + + it('preserves office sorting without sending unsupported officeName to v2', () => { + http.expectOne(SEARCH_URL).flush(EMPTY_PAGE); + component.onSort({ active: 'officeName', direction: 'asc' }); + const req = http.expectOne((request) => request.url === CLIENTS_URL); + expect(req.request.params.get('orderBy')).toBe('officeName'); + expect(req.request.params.get('sortOrder')).toBe('ASC'); + expect(req.request.params.has('status')).toBe(false); + req.flush({ pageItems: [], totalFilteredRecords: 0 }); + component.onSort({ active: 'officeName', direction: '' }); + http.expectOne(SEARCH_URL).flush(EMPTY_PAGE); + }); + + it('keeps account and external-ID searches broad when Office sorting is requested', () => { + http.expectOne(SEARCH_URL).flush(EMPTY_PAGE); + component.onSort({ active: 'officeName', direction: 'asc' }); + http + .expectOne((request) => request.url === CLIENTS_URL) + .flush({ pageItems: [], totalFilteredRecords: 0 }); + component.onSearch('external-id-42'); + const broad = http.expectOne(SEARCH_URL); + expect(broad.request.body.request.text).toBe('external-id-42'); + expect(broad.request.body.sorts).toBeUndefined(); + broad.flush(EMPTY_PAGE); + expect(component.currentSort()).toEqual({ active: '', direction: '' }); + expect(component.columns().find((column) => column.key === 'officeName')?.sortable).toBe(false); + component.onSort({ active: 'officeName', direction: 'asc' }); + const sorted = http.expectOne(SEARCH_URL); + expect(sorted.request.body.request.text).toBe('external-id-42'); + expect(sorted.request.body.sorts).toBeUndefined(); + sorted.flush(EMPTY_PAGE); + }); + + it('clears stale rows and totals on errors, and retries the same search', () => { + http.expectOne(SEARCH_URL).flush({ content: [{ id: 1 }], totalElements: 15 }); + component.onSearch('missing'); + http.expectOne(SEARCH_URL).flush({}, { status: 500, statusText: 'Server Error' }); + expect(component.hasError()).toBe(true); + expect(component.clients()).toEqual([]); + expect(component.totalRecords()).toBe(0); + expect(component.isLoading()).toBe(false); + component.onRetry(); + const retry = http.expectOne(SEARCH_URL); + expect(retry.request.body.request.text).toBe('missing'); + retry.flush(EMPTY_PAGE); + expect(component.hasError()).toBe(false); + }); + + it('cancels an obsolete search so it cannot replace newer results', () => { + const old = http.expectOne(SEARCH_URL); + component.onSearch('new'); + expect(old.cancelled).toBe(true); + http + .expectOne(SEARCH_URL) + .flush({ content: [{ id: 2, displayName: 'New' }], totalElements: 1 }); + expect(component.clients()[0].id).toBe(2); + }); + + it('redirects the old standalone search URL to the single client list', () => { + http.expectOne(SEARCH_URL).flush(EMPTY_PAGE); + expect(CLIENTS_ROUTES.find((route) => route.path === 'search')).toMatchObject({ + redirectTo: '/clients', + pathMatch: 'full', + }); + }); +}); diff --git a/src/app/features/clients/clients-list.component.ts b/src/app/features/clients/clients-list.component.ts index 506261479..e12d304d7 100644 --- a/src/app/features/clients/clients-list.component.ts +++ b/src/app/features/clients/clients-list.component.ts @@ -17,13 +17,14 @@ * under the License. */ -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '../../core/adapters'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router, RouterModule } from '@angular/router'; import { Subject, merge, of } from 'rxjs'; -import { catchError, map, startWith, switchMap, tap } from 'rxjs/operators'; +import { catchError, finalize, map, startWith, switchMap, tap } from 'rxjs/operators'; import { StatusBadgeComponent, DataTableComponent, @@ -31,7 +32,12 @@ import { ColumnDef, HasPermissionDirective, } from '../../shared'; -import { ClientService, GetClientsPageItemsResponse } from '../../api'; +import { + ClientService, + ClientSearchV2Service, + GetClientsPageItemsResponse, + SortOrder, +} from '../../api'; import { PageEvent, SortEvent } from '../../shared/models/table.model'; import { IonButton, @@ -48,7 +54,7 @@ import { imports: [ RouterModule, FormsModule, - TranslateModule, + TranslatePipe, StatusBadgeComponent, DataTableComponent, CellTemplateDirective, @@ -63,10 +69,14 @@ import { template: ` - {{ 'COMMON.STATUS' | translate }} + {{ 'COMMON.STATUS' | appTranslate }} - {{ 'COMMON.ALL' | translate }} - {{ 'COMMON.ACTIVE' | translate }} + {{ 'COMMON.ALL' | appTranslate }} + {{ + 'COMMON.ACTIVE' | appTranslate + }} {{ - 'COMMON.PENDING' | translate + 'COMMON.PENDING' | appTranslate + }} + {{ + 'COMMON.CLOSED' | appTranslate }} - {{ 'COMMON.CLOSED' | translate }} + @if (activeFilters.status) { +

{{ 'CLIENTS.NAME_SEARCH_HINT' | appTranslate }}

+ } @@ -123,7 +140,7 @@ import { (); private readonly clientService = inject(ClientService); + private readonly clientSearchService = inject(ClientSearchV2Service); private readonly router = inject(Router); - columns: ColumnDef[] = [ + private readonly broadTextSearch = signal(false); + readonly columns = computed(() => [ { key: 'accountNo', label: 'CLIENTS.ACCOUNT_NO', sortable: true }, { key: 'fullname', label: 'COMMON.NAME', sortable: true }, { key: 'status', label: 'COMMON.STATUS', sortable: true }, - { key: 'officeName', label: 'COMMON.OFFICE', sortable: true }, + { key: 'officeName', label: 'COMMON.OFFICE', sortable: !this.broadTextSearch() }, { key: 'actions', label: 'COMMON.ACTIONS', sortable: false }, - ]; + ]); readonly clients = signal([]); readonly totalRecords = signal(0); @@ -187,7 +215,7 @@ export class ClientsListComponent { private filterSubject = new Subject(); private currentFilter = ''; - private currentSort: SortEvent = { active: '', direction: '' }; + readonly currentSort = signal({ active: '', direction: '' }); private currentPage: PageEvent = { pageIndex: 0, pageSize: 10, length: 0 }; /** Mirrors currentPage.pageIndex for the data-table, so resetting to the first page on search/sort/filter actually moves the paginator. */ @@ -204,63 +232,110 @@ export class ClientsListComponent { .pipe( startWith({}), switchMap(() => { - const offset = this.currentPage.pageIndex * this.currentPage.pageSize; - const limit = this.currentPage.pageSize; - const orderBy = this.currentSort.active || undefined; - const sortOrder = this.currentSort.direction - ? this.currentSort.direction.toUpperCase() - : undefined; - - const displayName = this.currentFilter ? `%${this.currentFilter}%` : undefined; - // Fineract's /clients endpoint rejects `status=` and `status=All` outright (a 400, - // "The Status value '...' is not supported") — the param must be omitted entirely - // to mean "any status", so the "All" sentinel is never forwarded as-is. - const status = this.activeFilters.status || undefined; - - return this.clientService - .getClients( - undefined, - undefined, - displayName, - undefined, - undefined, - status, - undefined, - offset, - limit, - orderBy, - sortOrder, - false, - 1, - ) - .pipe( - tap(() => this.hasError.set(false)), - catchError(() => { - this.hasError.set(true); - return of(null); - }), - ); + this.isLoading.set(true); + return this.loadClients().pipe( + tap(() => this.hasError.set(false)), + catchError(() => { + this.hasError.set(true); + return of({ totalFilteredRecords: 0, pageItems: [] }); + }), + finalize(() => this.isLoading.set(false)), + ); }), map((response) => { - if (response === null) return []; this.totalRecords.set(response.totalFilteredRecords || 0); return response.pageItems || []; }), + takeUntilDestroyed(), ) .subscribe((data) => { this.clients.set(data); }); } + private usesNameSearch(): boolean { + // The v2 endpoint searches multiple fields but cannot filter by status or sort + // by the joined office name. Keep these existing v1 capabilities in this table. + return ( + !!this.activeFilters.status || + (!this.currentFilter && + this.currentSort().active === 'officeName' && + !!this.currentSort().direction) + ); + } + + private updateSearchPlaceholder(): void { + this.broadTextSearch.set(!!this.currentFilter && !this.activeFilters.status); + if (this.broadTextSearch() && this.currentSort().active === 'officeName') { + this.currentSort.set({ active: '', direction: '' }); + } + this.searchPlaceholder.set( + this.usesNameSearch() ? 'CLIENTS.SEARCH_BY_NAME' : 'COMMON.SEARCH_PLACEHOLDER', + ); + } + + private loadClients() { + if (this.usesNameSearch()) { + return this.clientService.getClients( + undefined, + undefined, + this.currentFilter ? `%${this.currentFilter}%` : undefined, + undefined, + undefined, + this.activeFilters.status || undefined, + undefined, + this.currentPage.pageIndex * this.currentPage.pageSize, + this.currentPage.pageSize, + this.currentSort().active || undefined, + this.currentSort().direction ? this.currentSort().direction.toUpperCase() : undefined, + false, + 1, + ); + } + + const sortProperties: Record = { + accountNo: 'accountNumber', + fullname: 'displayName', + status: 'status', + }; + const property = sortProperties[this.currentSort().active]; + const sorts: SortOrder[] = + property && this.currentSort().direction + ? [{ property, direction: this.currentSort().direction === 'asc' ? 'ASC' : 'DESC' }] + : []; + return this.clientSearchService + .postClientsSearch({ + request: { text: this.currentFilter }, + page: this.currentPage.pageIndex, + size: this.currentPage.pageSize, + ...(sorts.length ? { sorts } : {}), + }) + .pipe( + map((response) => ({ + totalFilteredRecords: response.totalElements ?? 0, + pageItems: (response.content ?? []).map((client): GetClientsPageItemsResponse => ({ + id: client.id, + accountNo: client.accountNumber, + displayName: client.displayName, + officeId: client.officeId, + officeName: client.officeName, + status: client.status, + })), + })), + ); + } + onSearch(filterValue: string) { - this.currentFilter = filterValue; + this.currentFilter = filterValue.trim(); + this.updateSearchPlaceholder(); this.currentPage.pageIndex = 0; this.pageIndex.set(0); this.searchSubject.next(filterValue); } onSort(sort: SortEvent) { - this.currentSort = sort; + this.currentSort.set(sort); + this.updateSearchPlaceholder(); this.currentPage.pageIndex = 0; this.pageIndex.set(0); this.sortSubject.next(sort); @@ -268,11 +343,13 @@ export class ClientsListComponent { onPage(event: PageEvent) { this.currentPage = event; + this.pageSize.set(event.pageSize); this.pageIndex.set(event.pageIndex); this.pageSubject.next(event); } onFilterChange() { + this.updateSearchPlaceholder(); this.currentPage.pageIndex = 0; this.pageIndex.set(0); this.filterSubject.next(); diff --git a/src/app/features/clients/clients.routes.ts b/src/app/features/clients/clients.routes.ts index 6c311d489..492ca8e03 100644 --- a/src/app/features/clients/clients.routes.ts +++ b/src/app/features/clients/clients.routes.ts @@ -31,11 +31,8 @@ export const CLIENTS_ROUTES: Routes = [ }, { path: 'search', - canActivate: [authGuard, permissionGuard], - data: { permissions: 'READ_CLIENT' }, - title: 'CLIENT_SEARCH_V2.TITLE', - loadComponent: () => - import('./client-search-v2.component').then((m) => m.ClientSearchV2Component), + pathMatch: 'full', + redirectTo: '/clients', }, { path: 'create', diff --git a/src/app/shared/components/data-table/data-table.component.test.ts b/src/app/shared/components/data-table/data-table.component.test.ts index ca787cbf6..17c793e66 100644 --- a/src/app/shared/components/data-table/data-table.component.test.ts +++ b/src/app/shared/components/data-table/data-table.component.test.ts @@ -86,6 +86,17 @@ describe('DataTableComponent', () => { expect(component).toBeTruthy(); }); + it('clears the visible sort indicator when the parent resets the query sort', () => { + setInputs({ sortState: { active: 'name', direction: 'desc' } }); + expect(component.ariaSortFor(COLUMNS.find((column) => column.key === 'name')!)).toBe( + 'descending', + ); + expect(renderedNames()).toEqual(['Bob', 'Alice']); + setInputs({ sortState: { active: '', direction: '' } }); + expect(component.ariaSortFor(COLUMNS.find((column) => column.key === 'name')!)).toBeNull(); + expect(renderedNames()).toEqual(['Alice', 'Bob']); + }); + it('renders a row per record', () => { expect(component.rows()).toHaveLength(2); expect(renderedNames()).toEqual(['Alice', 'Bob']); diff --git a/src/app/shared/components/data-table/data-table.component.ts b/src/app/shared/components/data-table/data-table.component.ts index 0ea72383d..6767f87a0 100644 --- a/src/app/shared/components/data-table/data-table.component.ts +++ b/src/app/shared/components/data-table/data-table.component.ts @@ -384,7 +384,9 @@ export class DataTableComponent { */ readonly cellTemplates = contentChildren(CellTemplateDirective); - readonly sort = signal({ active: '', direction: '' }); + /** Lets a server-backed table visibly reset sorting when its query mode changes. */ + readonly sortState = input(); + readonly sort = linkedSignal(() => this.sortState() ?? { active: '', direction: '' }); protected readonly columnTemplates = computed>>(() => { const map: Record> = {}; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index f2f16c1ab..189ac16ed 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -16,7 +16,6 @@ "main": "Main Navigation", "dashboard": "Dashboard", "clients": "Clients", - "clientSearchV2": "Client Search", "groups": "Groups", "centers": "Centers", "loans": "Loans", @@ -855,7 +854,9 @@ "RECURRING_DEPOSITS": "Recurring Deposits", "NO_FIXED_DEPOSITS": "This client holds no fixed deposits.", "NO_RECURRING_DEPOSITS": "This client holds no recurring deposits.", - "NO_SHARE_ACCOUNTS": "This client holds no share accounts." + "NO_SHARE_ACCOUNTS": "This client holds no share accounts.", + "SEARCH_BY_NAME": "Search by client name...", + "NAME_SEARCH_HINT": "When a status is selected, search matches client names." }, "SAVINGS": { "CREATE_ACCOUNT": "Create Savings Account", @@ -2710,17 +2711,6 @@ "DELETE": "Delete", "SUCCESS": "Operation successful." }, - "CLIENT_SEARCH_V2": { - "TITLE": "Client Search", - "QUERY": "Search Query", - "SEARCH": "Search", - "NAME": "Client Name", - "ACCOUNT_NO": "Account No.", - "STATUS": "Status", - "OFFICE": "Office", - "VIEW": "View Client", - "NO_RESULTS": "No clients found matching your search." - }, "LOAN_SCHEDULE_MODIFY": { "TITLE": "Loan Schedule Modification", "LOAN_ID": "Loan ID", From 38e60b418fe40323b4c4b39a0cb547c34cae027f Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:23:07 +0800 Subject: [PATCH 3/5] fix(clients): preserve exact search totals Preserve exact v2 search totals of 1, 11, and 21 without changing the v1 unknown-total behavior. Wait for debounced search responses before the pagination regression advances. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- e2e/list-pagination.spec.ts | 6 ++++++ src/app/features/clients/clients-list.component.test.ts | 3 +++ src/app/features/clients/clients-list.component.ts | 6 +++++- .../components/data-table/data-table.component.test.ts | 8 ++++++++ .../shared/components/data-table/data-table.component.ts | 4 +++- 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/e2e/list-pagination.spec.ts b/e2e/list-pagination.spec.ts index 44194278f..71c750984 100644 --- a/e2e/list-pagination.spec.ts +++ b/e2e/list-pagination.spec.ts @@ -173,7 +173,13 @@ test.describe('List pagination', () => { }); test('keeps search text when paging and shows the v2 account number', async ({ page }) => { + const searchLoaded = page.waitForResponse( + (response) => + response.url().endsWith('/api/v2/clients/search') && + response.request().postDataJSON().request.text === 'Client', + ); await page.getByPlaceholder('Type to search...').fill('Client'); + await searchLoaded; await expect(range(page)).toContainText(`1 - 10 of ${TOTAL_CLIENTS}`); const requested = page.waitForRequest( (request) => diff --git a/src/app/features/clients/clients-list.component.test.ts b/src/app/features/clients/clients-list.component.test.ts index 0c72efe3d..556a60a31 100644 --- a/src/app/features/clients/clients-list.component.test.ts +++ b/src/app/features/clients/clients-list.component.test.ts @@ -59,6 +59,7 @@ describe('ClientsListComponent search', () => { }); expect(component.clients()[0]).toMatchObject({ accountNo: '0004', displayName: 'Jane' }); expect(component.totalRecords()).toBe(37); + expect(component.exactTotal()).toBe(true); expect(component.isLoading()).toBe(false); }); @@ -97,6 +98,7 @@ describe('ClientsListComponent search', () => { component.onFilterChange(); const filtered = http.expectOne((req) => req.url === CLIENTS_URL); expect(filtered.request.params.get('status')).toBe('closed'); + expect(component.exactTotal()).toBe(false); filtered.flush({ pageItems: [], totalFilteredRecords: 0 }); component.onSearch('Jane'); const named = http.expectOne((req) => req.url === CLIENTS_URL); @@ -112,6 +114,7 @@ describe('ClientsListComponent search', () => { expect(all.request.body).toEqual({ request: { text: 'Jane' }, page: 0, size: 10 }); all.flush(EMPTY_PAGE); expect(component.searchPlaceholder()).toBe('COMMON.SEARCH_PLACEHOLDER'); + expect(component.exactTotal()).toBe(true); }); it('preserves office sorting without sending unsupported officeName to v2', () => { diff --git a/src/app/features/clients/clients-list.component.ts b/src/app/features/clients/clients-list.component.ts index e12d304d7..0eed34e0e 100644 --- a/src/app/features/clients/clients-list.component.ts +++ b/src/app/features/clients/clients-list.component.ts @@ -79,6 +79,7 @@ import { [sortState]="currentSort()" [data]="clients()" [totalRecords]="totalRecords()" + [exactTotal]="exactTotal()" (searchChange)="onSearch($event)" (sortChange)="onSort($event)" [pageIndex]="pageIndex()" @@ -203,6 +204,7 @@ export class ClientsListComponent { readonly clients = signal([]); readonly totalRecords = signal(0); + readonly exactTotal = signal(true); // Empty string, not `undefined`, so it round-trips through ``'s ngModel // binding as a real match for the "All" option's own `value=""` rather than leaving the @@ -275,7 +277,9 @@ export class ClientsListComponent { } private loadClients() { - if (this.usesNameSearch()) { + const nameSearch = this.usesNameSearch(); + this.exactTotal.set(!nameSearch); + if (nameSearch) { return this.clientService.getClients( undefined, undefined, diff --git a/src/app/shared/components/data-table/data-table.component.test.ts b/src/app/shared/components/data-table/data-table.component.test.ts index 17c793e66..cd01af74c 100644 --- a/src/app/shared/components/data-table/data-table.component.test.ts +++ b/src/app/shared/components/data-table/data-table.component.test.ts @@ -97,6 +97,14 @@ describe('DataTableComponent', () => { expect(renderedNames()).toEqual(['Alice', 'Bob']); }); + it.each([1, 11, 21])('preserves an exact server total of %i records', (totalRecords) => { + setInputs({ localLogic: false, totalRecords, exactTotal: true, pageSize: 10 }); + const range = fixture.nativeElement.querySelector('[data-testid="paginator-range-label"]'); + expect(range.textContent.trim()).toBe(`1 - ${Math.min(totalRecords, 10)} of ${totalRecords}`); + setInputs({ exactTotal: false }); + expect(range.textContent.trim()).toContain('of many'); + }); + it('renders a row per record', () => { expect(component.rows()).toHaveLength(2); expect(renderedNames()).toEqual(['Alice', 'Bob']); diff --git a/src/app/shared/components/data-table/data-table.component.ts b/src/app/shared/components/data-table/data-table.component.ts index 6767f87a0..242bf5448 100644 --- a/src/app/shared/components/data-table/data-table.component.ts +++ b/src/app/shared/components/data-table/data-table.component.ts @@ -221,7 +221,7 @@ const NEXT_DIRECTION: Record = { [pageSize]="effectivePageSize" [pageIndex]="effectivePageIndex" [pageSizeOptions]="pageSizeOptions()" - [exactTotal]="localLogic()" + [exactTotal]="localLogic() || exactTotal()" (page)="onPage($event)" > @@ -350,6 +350,8 @@ export class DataTableComponent { readonly data = input([]); /** Total number of records. If server-side, this comes from API response. */ readonly totalRecords = input(0); + /** Set when a server endpoint returns an exact count rather than an unknown-total sentinel. */ + readonly exactTotal = input(false); readonly pageSize = input(10); readonly pageIndex = input(0); readonly pageSizeOptions = input([5, 10, 25, 100]); From 09ceff6f5b28c52b5729321848b7623a1b96831e Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:20:03 +0800 Subject: [PATCH 4/5] test(clients): preserve redirect routes in title coverage Require titles for rendered pages and preserve redirect configuration in the RouterTestingHarness. Verify that the legacy client-search URL navigates to the client list and uses its translated title. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- .../features/clients/clients.routes.test.ts | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/app/features/clients/clients.routes.test.ts b/src/app/features/clients/clients.routes.test.ts index ed2228b63..d04b0e8b6 100644 --- a/src/app/features/clients/clients.routes.test.ts +++ b/src/app/features/clients/clients.routes.test.ts @@ -20,7 +20,7 @@ import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Title } from '@angular/platform-browser'; -import { TitleStrategy, provideRouter } from '@angular/router'; +import { Router, TitleStrategy, provideRouter } from '@angular/router'; import { RouterTestingHarness } from '@angular/router/testing'; import { CLIENTS_ROUTES } from './clients.routes'; import { TranslatedTitleStrategy } from '../../core/router/translated-title.strategy'; @@ -57,11 +57,11 @@ describe('CLIENTS_ROUTES', () => { beforeEach(() => { // The guards are not under test here, and permissionGuard would reject every // navigation without an authenticated user. - const routes = CLIENTS_ROUTES.map(({ path, title }) => ({ - path, - title, - component: RouteStub, - })); + const routes = CLIENTS_ROUTES.map(({ path, title, redirectTo, pathMatch }) => + redirectTo === undefined + ? { path, title, component: RouteStub } + : { path, redirectTo, pathMatch }, + ); const adapters = provideFakeAdapters(); i18n = adapters.i18n; @@ -83,20 +83,29 @@ describe('CLIENTS_ROUTES', () => { * screen and is only wrong in the one place nobody is looking. Every page under `clients` * would silently read "Clients". */ - it('gives every route its own title', () => { - const untitled = CLIENTS_ROUTES.filter((route) => !route.title).map((route) => route.path); + it('gives every rendered page its own title', () => { + const untitled = CLIENTS_ROUTES.filter( + (route) => route.redirectTo === undefined && !route.title, + ).map((route) => route.path); expect(untitled).toEqual([]); }); it('titles routes with translation keys rather than phrases', () => { - const notKeys = CLIENTS_ROUTES.filter((route) => !isTranslationKey(route.title)).map( - (route) => route.path, - ); + const notKeys = CLIENTS_ROUTES.filter( + (route) => route.redirectTo === undefined && !isTranslationKey(route.title), + ).map((route) => route.path); expect(notKeys).toEqual([]); }); + it('uses the destination title when the old search URL redirects to the client list', async () => { + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/clients/search'); + expect(TestBed.inject(Router).url).toBe('/clients'); + expect(TestBed.inject(Title).getTitle()).toBe(`${SECTION_NAME} \u00b7 ${APP_NAME}`); + }); + /** * The shapes the convention has to answer, and the reason `clients` was the file to settle * it on: an empty path, a record-scoped page, and a two-level nested sub-resource. The last From 6b8656359d9df9a9c0fa5dbcbed9a0cba1604c83 Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:28:35 +0800 Subject: [PATCH 5/5] test(clients): use the literal title separator Require titles for rendered pages and preserve redirect configuration in the RouterTestingHarness. Verify that the legacy client-search URL navigates to the client list and uses its translated title. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- src/app/features/clients/clients.routes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/clients/clients.routes.test.ts b/src/app/features/clients/clients.routes.test.ts index d04b0e8b6..2ae5d3b30 100644 --- a/src/app/features/clients/clients.routes.test.ts +++ b/src/app/features/clients/clients.routes.test.ts @@ -103,7 +103,7 @@ describe('CLIENTS_ROUTES', () => { const harness = await RouterTestingHarness.create(); await harness.navigateByUrl('/clients/search'); expect(TestBed.inject(Router).url).toBe('/clients'); - expect(TestBed.inject(Title).getTitle()).toBe(`${SECTION_NAME} \u00b7 ${APP_NAME}`); + expect(TestBed.inject(Title).getTitle()).toBe(`${SECTION_NAME} · ${APP_NAME}`); }); /**