blob: ca787cbf6f972f9823585461f7d72eeddf8fa929 [file]
/*
* 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, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DataTableComponent, ColumnDef } from './data-table.component';
import { CellTemplateDirective } from './cell-template.directive';
import { PageEvent, SortEvent } from '../../models/table.model';
import { provideIonicTesting } from '../../../testing/ionic-testing';
import { provideTestConfig } from '../../../testing/config';
import { AuthService } from '../../../core/services/auth.service';
interface TestData {
id: number;
name: string;
status: { value: string; code: string };
}
const COLUMNS: ColumnDef[] = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'name', label: 'Name', sortable: true },
{ key: 'status', label: 'Status', sortable: false },
];
const ROWS: TestData[] = [
{ id: 1, name: 'Alice', status: { value: 'Active', code: 'active' } },
{ id: 2, name: 'Bob', status: { value: 'Inactive', code: 'inactive' } },
];
const CUSTOM_CELL = '.custom-cell';
const SORT_BUTTON = 'button.sort-button';
describe('DataTableComponent', () => {
let fixture: ComponentFixture<DataTableComponent<TestData>>;
let component: DataTableComponent<TestData>;
/** Names in the second column of each rendered row, in render order. */
const renderedNames = (): string[] =>
Array.from(fixture.nativeElement.querySelectorAll('tr[cdk-row]')).map((row) =>
(row as HTMLElement).children[1].textContent!.trim(),
);
const setInputs = (inputs: Record<string, unknown>): void => {
Object.entries(inputs).forEach(([key, value]) => fixture.componentRef.setInput(key, value));
fixture.detectChanges();
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DataTableComponent, TranslateModule.forRoot()],
providers: [
provideIonicTesting(),
// The create button is now gated by `*appHasPermission`, which reads both the session
// and the deployment's `rbacEnabled`; neither has a usable default in a bare TestBed.
provideTestConfig(),
provideHttpClient(),
provideHttpClientTesting(),
],
}).compileComponents();
fixture = TestBed.createComponent<DataTableComponent<TestData>>(DataTableComponent);
component = fixture.componentInstance;
setInputs({ columns: COLUMNS, data: ROWS, localLogic: true });
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('renders a row per record', () => {
expect(component.rows()).toHaveLength(2);
expect(renderedNames()).toEqual(['Alice', 'Bob']);
});
it('renders the no-data row when there are no records', () => {
setInputs({ data: [] });
expect(fixture.nativeElement.querySelector('.no-data-row')).toBeTruthy();
});
describe('cell values', () => {
it('unwraps Fineract { value, code } option objects', () => {
expect(component.getCellValue(ROWS[0], 'status')).toBe('Active');
});
it('resolves nested values using dot notation', () => {
const nested = { user: { profile: { name: 'John Doe' } } };
expect(component.getCellValue(nested as unknown as TestData, 'user.profile.name')).toBe(
'John Doe',
);
});
it('returns undefined rather than throwing on a missing branch', () => {
expect(component.getCellValue({} as TestData, 'user.profile.name')).toBeUndefined();
});
});
describe('local logic', () => {
it('filters rows on search', () => {
component.onSearch('alice');
fixture.detectChanges();
expect(renderedNames()).toEqual(['Alice']);
});
it('matches case-insensitively across any displayed column', () => {
component.onSearch('INACTIVE');
fixture.detectChanges();
expect(renderedNames()).toEqual(['Bob']);
});
it('sorts ascending, then descending, then clears', () => {
const nameColumn = COLUMNS[1];
component.onSortHeaderClick(nameColumn);
fixture.detectChanges();
expect(renderedNames()).toEqual(['Alice', 'Bob']);
component.onSortHeaderClick(nameColumn);
fixture.detectChanges();
expect(renderedNames()).toEqual(['Bob', 'Alice']);
component.onSortHeaderClick(nameColumn);
fixture.detectChanges();
expect(component.sort()).toEqual({ active: '', direction: '' });
});
it('ignores clicks on non-sortable columns', () => {
let emitted: SortEvent | undefined;
component.sortChange.subscribe((event) => (emitted = event));
component.onSortHeaderClick(COLUMNS[2]);
expect(component.sort().direction).toBe('');
expect(emitted).toBeUndefined();
});
it('sorts numerically rather than lexically', () => {
setInputs({
data: [
{ id: 10, name: 'Ten', status: { value: 'A', code: 'a' } },
{ id: 9, name: 'Nine', status: { value: 'B', code: 'b' } },
],
});
component.onSortHeaderClick(COLUMNS[0]);
fixture.detectChanges();
expect(renderedNames()).toEqual(['Nine', 'Ten']);
});
it('sorts rows with a missing value last, in both directions', () => {
setInputs({
data: [
{ id: 1, name: 'Zoe', status: { value: 'A', code: 'a' } },
{ id: 2, name: null as unknown as string, status: { value: 'B', code: 'b' } },
{ id: 3, name: 'Amy', status: { value: 'C', code: 'c' } },
],
});
component.onSortHeaderClick(COLUMNS[1]);
fixture.detectChanges();
expect(renderedNames()).toEqual(['Amy', 'Zoe', '']);
component.onSortHeaderClick(COLUMNS[1]);
fixture.detectChanges();
expect(renderedNames()).toEqual(['Zoe', 'Amy', '']);
});
it('paginates and reports the filtered total', () => {
setInputs({ pageSize: 1 });
expect(component.rows()).toHaveLength(1);
expect(component.displayedTotal()).toBe(2);
component.onPage({ pageIndex: 1, pageSize: 1, length: 2 });
fixture.detectChanges();
expect(renderedNames()).toEqual(['Bob']);
});
it('returns to the first page when a search narrows the results', () => {
setInputs({ pageSize: 1 });
component.onPage({ pageIndex: 1, pageSize: 1, length: 2 });
component.onSearch('alice');
fixture.detectChanges();
expect(renderedNames()).toEqual(['Alice']);
});
});
describe('server-side mode', () => {
beforeEach(() => {
setInputs({ localLogic: false, totalRecords: 250 });
});
it('passes data straight through without slicing', () => {
expect(renderedNames()).toEqual(['Alice', 'Bob']);
expect(component.displayedTotal()).toBe(250);
});
it('does not filter locally, leaving the parent to refetch', () => {
component.onSearch('alice');
fixture.detectChanges();
expect(renderedNames()).toEqual(['Alice', 'Bob']);
});
it('emits sort changes for the parent to act on', () => {
let emitted: SortEvent | undefined;
component.sortChange.subscribe((event) => (emitted = event));
component.onSortHeaderClick(COLUMNS[1]);
expect(emitted).toEqual({ active: 'name', direction: 'asc' });
});
/*
* These pin the bug that made pagination unusable. effectivePageIndex read
* the raw @Input, which server-side parents never bind back, so it stayed 0:
* the range label never moved off "1 - 10", "previous" stayed disabled, and
* goTo() — which computes from the current index — resolved "next" to page 1
* every time, making page 2 the only page reachable.
*/
it('advances its own page index without the parent binding it back', () => {
expect(component.effectivePageIndex).toBe(0);
component.onPage({ pageIndex: 1, pageSize: 10, length: 250 });
expect(component.effectivePageIndex).toBe(1);
component.onPage({ pageIndex: 2, pageSize: 10, length: 250 });
expect(component.effectivePageIndex).toBe(2);
});
it('still lets a parent drive the page index through the input', () => {
// Parents mirror the index back from (pageChange) — see onPage() in clients-list and
// centers-list — so the bound value tracks the table while the user pages.
component.onPage({ pageIndex: 3, pageSize: 10, length: 250 });
setInputs({ pageIndex: 3 });
expect(component.effectivePageIndex).toBe(3);
// What a parent does when a filter changes and it refetches from offset 0.
setInputs({ pageIndex: 0 });
expect(component.effectivePageIndex).toBe(0);
});
it('returns to the first page on search, matching the parent refetch', () => {
component.onPage({ pageIndex: 4, pageSize: 10, length: 250 });
component.onSearch('alice');
expect(component.effectivePageIndex).toBe(0);
});
it('returns to the first page on sort, since the order changed', () => {
component.onPage({ pageIndex: 4, pageSize: 10, length: 250 });
component.onSortHeaderClick(COLUMNS[1]);
expect(component.effectivePageIndex).toBe(0);
});
it('tracks page size changes too', () => {
component.onPage({ pageIndex: 1, pageSize: 25, length: 250 });
expect(component.effectivePageSize).toBe(25);
});
});
describe('create button permission', () => {
const createButton = (): HTMLElement | null =>
fixture.nativeElement.querySelector('[data-testid="data-table-create"]');
/** Signs a permission set in, so `*appHasPermission` has something to evaluate. */
const signIn = (permissions: string[]): void => {
TestBed.inject(AuthService).currentUser.set({
username: 'tester',
base64EncodedAuthenticationKey: 'key',
authenticated: true,
officeId: 1,
officeName: 'Head Office',
userId: 1,
permissions,
});
fixture.detectChanges();
};
it('shows the button to everyone when no permission is named', () => {
signIn([]);
setInputs({ createButtonLabel: 'CREATE' });
expect(createButton()).toBeTruthy();
});
it('shows the button to a user who holds the permission', () => {
signIn(['CREATE_CLIENT']);
setInputs({ createButtonLabel: 'CREATE', createPermission: 'CREATE_CLIENT' });
expect(createButton()).toBeTruthy();
});
it('withholds the button from a user who does not', () => {
signIn(['READ_CLIENT']);
setInputs({ createButtonLabel: 'CREATE', createPermission: 'CREATE_CLIENT' });
expect(createButton()).toBeNull();
});
it('withholds it from a read-only user, whose create route would refuse them anyway', () => {
signIn(['ALL_FUNCTIONS_READ']);
setInputs({ createButtonLabel: 'CREATE', createPermission: 'CREATE_CLIENT' });
expect(createButton()).toBeNull();
});
it('shows it to a superuser', () => {
signIn(['ALL_FUNCTIONS']);
setInputs({ createButtonLabel: 'CREATE', createPermission: 'CREATE_CLIENT' });
expect(createButton()).toBeTruthy();
});
});
describe('outputs', () => {
it('emits create', () => {
vi.spyOn(component.create, 'emit');
component.onCreate();
expect(component.create.emit).toHaveBeenCalled();
});
it('emits searchChange', () => {
vi.spyOn(component.searchChange, 'emit');
component.onSearch('test');
expect(component.searchChange.emit).toHaveBeenCalledWith('test');
});
it('emits pageChange', () => {
let emitted: PageEvent | undefined;
component.pageChange.subscribe((event) => (emitted = event));
component.onPage({ pageIndex: 1, pageSize: 10, length: 2 });
expect(emitted).toEqual({ pageIndex: 1, pageSize: 10, length: 2 });
});
});
describe('accessibility', () => {
it('renders native buttons only for sortable headers', () => {
const headers = fixture.nativeElement.querySelectorAll('th[cdk-header-cell]');
const sortButtons = fixture.nativeElement.querySelectorAll(SORT_BUTTON);
expect(sortButtons).toHaveLength(2);
expect(sortButtons[0].getAttribute('type')).toBe('button');
expect(headers[0].querySelector(SORT_BUTTON)).toBe(sortButtons[0]);
expect(headers[1].querySelector(SORT_BUTTON)).toBe(sortButtons[1]);
expect(headers[2].querySelector(SORT_BUTTON)).toBeNull();
});
it('sorts through the rendered keyboard-operable control', () => {
const nameSortButton: HTMLButtonElement =
fixture.nativeElement.querySelectorAll(SORT_BUTTON)[1];
nameSortButton.focus();
nameSortButton.click();
fixture.detectChanges();
expect(document.activeElement).toBe(nameSortButton);
expect(component.sort()).toEqual({ active: 'name', direction: 'asc' });
});
it('exposes the sorted column via aria-sort', () => {
component.onSortHeaderClick(COLUMNS[1]);
fixture.detectChanges();
const headers = fixture.nativeElement.querySelectorAll('th[cdk-header-cell]');
expect(headers[1].getAttribute('aria-sort')).toBe('ascending');
expect(headers[0].getAttribute('aria-sort')).toBeNull();
});
});
describe('error state', () => {
const errorPanel = () =>
fixture.nativeElement.querySelector('[data-testid="data-table-error"]');
it('should not show anything about errors on a normal load', () => {
expect(errorPanel()).toBeNull();
expect(renderedNames()).toEqual(['Alice', 'Bob']);
});
it('should replace the table rather than showing an empty one', () => {
setInputs({ hasError: true });
// "No records found" under a failed request tells the user the data does not exist,
// when in fact nobody knows.
expect(errorPanel()).not.toBeNull();
expect(fixture.nativeElement.querySelector('table.data-table')).toBeNull();
});
it('should ask the parent to load again when the user retries', () => {
setInputs({ hasError: true });
let retries = 0;
component.retry.subscribe(() => retries++);
fixture.nativeElement.querySelector('[data-testid="data-table-retry"]').click();
expect(retries).toBe(1);
});
it('should restore the table once a retry succeeds', () => {
setInputs({ hasError: true });
setInputs({ hasError: false });
expect(errorPanel()).toBeNull();
expect(renderedNames()).toEqual(['Alice', 'Bob']);
});
});
describe('projected cell templates', () => {
/**
* The template for `name` sits inside an `@if`, so it registers after the table's first
* content pass. Under `@ContentChildren` + `QueryList` this is the case that silently
* failed: `QueryList.changes` does not mark an OnPush view dirty, so the late template
* was never picked up and the column kept rendering its plain value.
*/
@Component({
standalone: true,
imports: [DataTableComponent, CellTemplateDirective],
template: `
<app-data-table [columns]="columns" [data]="data" [localLogic]="true">
@if (showCustom()) {
<ng-template appCellTemplate="name" let-row>
<span class="custom-cell">custom:{{ row.name }}</span>
</ng-template>
}
</app-data-table>
`,
})
class HostComponent {
readonly columns = COLUMNS;
readonly data = ROWS;
readonly showCustom = signal(false);
}
it('picks up a template that registers after the first pass', () => {
const host = TestBed.createComponent(HostComponent);
host.detectChanges();
expect(host.nativeElement.querySelectorAll(CUSTOM_CELL)).toHaveLength(0);
host.componentInstance.showCustom.set(true);
host.detectChanges();
expect(host.nativeElement.querySelectorAll(CUSTOM_CELL)).toHaveLength(2);
expect(host.nativeElement.querySelector(CUSTOM_CELL).textContent.trim()).toBe('custom:Alice');
});
});
});