Table
Table with API data¶
ng-bootstrap provides examples of how to create tables. However, the examples could be greatly improved by making their table service generic and provide examples of actually fetching data from an api.
Changes in the code include
- Convert the table service into a generic abstract utility class,
filterable-table.ts
, that takes an array of results in the constructor instead of having a hard coded array. - Extend the utility class when you need to implement a new table filter, i.e.
CountryTableFilter
. - Instantiate the table filter in your table component, i.e.
CountryTableComponent
, with the array being bound by an asynchronous observable.
Filterable Table Utility Class
interface SearchResult<T> {
displayedResults: T[];
total: number;
}
interface State {
page: number;
pageSize: number;
searchTerm: string;
sortColumn: string;
sortDirection: SortDirection;
}
function compare(v1: any, v2: any) {
return v1 < v2 ? -1 : v1 > v2 ? 1 : 0;
}
export abstract class FilterableTable<T> {
private _loading$ = new BehaviorSubject<boolean>(true);
private _search$ = new Subject<void>();
private _displayedResults$ = new BehaviorSubject<T[]>([]);
private _total$ = new BehaviorSubject<number>(0);
// public allValues: T[] = [];
private _state: State = {
page: 1,
pageSize: 4,
searchTerm: '',
sortColumn: '',
sortDirection: '',
};
constructor(public allValues: T[]) {
// this.allValues = allValues;
this._search$
.pipe(
tap(() => this._loading$.next(true)),
debounceTime(200),
switchMap(() => this._search()),
delay(200),
tap(() => this._loading$.next(false))
)
.subscribe((result) => {
this._displayedResults$.next(result.displayedResults);
this._total$.next(result.total);
});
this._search$.next();
}
get displayedResults$() {
return this._displayedResults$.asObservable();
}
get total$() {
return this._total$.asObservable();
}
get loading$() {
return this._loading$.asObservable();
}
get page() {
return this._state.page;
}
get pageSize() {
return this._state.pageSize;
}
get searchTerm() {
return this._state.searchTerm;
}
set page(page: number) {
this._set({ page });
}
set pageSize(pageSize: number) {
this._set({ pageSize });
}
set searchTerm(searchTerm: string) {
this._set({ searchTerm });
}
set sortColumn(sortColumn: string) {
this._set({ sortColumn });
}
set sortDirection(sortDirection: SortDirection) {
this._set({ sortDirection });
}
private _set(patch: Partial<State>) {
Object.assign(this._state, patch);
this._search$.next();
}
sort(apiResp: T[], column: string, direction: string): T[] {
console.log(`sourting on column ${column}`);
if (direction === '') {
return apiResp;
} else {
return [...apiResp].sort((a: T, b: T) => {
const res = compare(
this.getProperty(a, column),
this.getProperty(b, column)
);
return direction === 'asc' ? res : -res;
});
}
}
/**
* Ability to get nested properties, i.e. `name.common`
*/
getProperty(object: any, propertyName: string) {
var parts = propertyName.split('.'),
length = parts.length,
i,
property = object || this;
for (i = 0; i < length; i++) {
property = property[parts[i]];
}
return property;
}
abstract matches(resp: T, term: string): boolean;
private _search(): Observable<SearchResult<T>> {
const { sortColumn, sortDirection, pageSize, page, searchTerm } =
this._state;
// console.log(`allValues size ${this.allValues.length}`)
// console.log(`searchTerm: ${searchTerm}`)
// 1. sort
let displayResults: T[] = this.sort(
this.allValues,
sortColumn,
sortDirection
);
// 2. filter
displayResults = displayResults.filter((country) =>
this.matches(country, searchTerm)
);
const total = displayResults.length;
// 3. paginate
displayResults = displayResults.slice(
(page - 1) * pageSize,
(page - 1) * pageSize + pageSize
);
// console.log(`displayResults size ${displayResults.length}`)
return of({ displayedResults: displayResults, total });
}
}
Search Component
<div class="input-group mb-3">
<input
type="text"
class="form-control"
[(ngModel)]="searchTerm"
placeholder="Search country names"
(keydown.enter)="onSearch()"
/>
<div class="input-group-append">
<button class="btn btn-primary" (click)="onSearch()">Search</button>
</div>
</div>
<div *ngIf="apiResults$ | async as apiResults">
<app-country-table [apiResults]="apiResults"></app-country-table>
</div>
@Component({
selector: 'app-search-page',
templateUrl: './search-page.component.html',
standalone: true,
imports: [FormsModule, CountryTableComponent, NgIf, NgFor, AsyncPipe],
})
export class SearchPageComponent implements OnInit {
searchTerm = '';
apiResults$?: Observable<Country[]>;
constructor(private apiService: CountryApiService) {}
ngOnInit() {}
onSearch(): void {
console.log(`Searching ${this.searchTerm}`);
this.apiResults$ = this.apiService.findByName(this.searchTerm);
}
}
Table Component
<form>
<div class="mb-3 row">
<label
for="table-complete-search"
class="col-xs-3 col-sm-auto col-form-label"
>Full text search:</label
>
<div class="col-xs-3 col-sm-auto">
<input
id="table-complete-search"
type="text"
class="form-control"
name="searchTerm"
[(ngModel)]="tableHelper.searchTerm"
/>
</div>
<span class="col col-form-label" *ngIf="tableHelper.loading$ | async"
>Loading...</span
>
</div>
<table class="table table-striped">
<thead>
<tr>
<th sortable="name.common" scope="col" (sort)="onSort($event)">
Country
</th>
<th sortable="region" scope="col" (sort)="onSort($event)">Region</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let country of countries$ | async">
<td>
<ngb-highlight
[result]="country.name.common"
[term]="tableHelper.searchTerm"
></ngb-highlight>
</td>
<td>
<ngb-highlight
[result]="country.region"
[term]="tableHelper.searchTerm"
></ngb-highlight>
</td>
</tr>
</tbody>
</table>
<div class="d-flex justify-content-between p-2">
<ngb-pagination
[collectionSize]="(total$ | async)!"
[(page)]="tableHelper!.page"
[pageSize]="tableHelper!.pageSize"
>
</ngb-pagination>
<select
class="form-select"
style="width: auto"
name="pageSize"
[(ngModel)]="tableHelper!.pageSize"
>
<option [ngValue]="2">2 items per page</option>
<option [ngValue]="4">4 items per page</option>
<option [ngValue]="6">6 items per page</option>
</select>
</div>
</form>
@Component({
selector: 'app-country-table',
templateUrl: './country-table.component.html',
standalone: true,
imports: [ FormsModule, AsyncPipe, NgbHighlight, NgbdSortableHeader, NgbPaginationModule, NgIf, NgFor],
})
export class CountryTableComponent implements OnInit {
countries$!: Observable<Country[]>;
total$!: Observable<number>;
@ViewChildren(NgbdSortableHeader) headers!: QueryList<NgbdSortableHeader>;
@Input() apiResults!: Country[];
public tableHelper!: CountryTableFilter;
constructor() {}
ngOnInit(): void {
console.log(`part table initialize with results ${this.apiResults}`);
this.tableHelper = new CountryTableFilter(this.apiResults);
this.countries$ = this.tableHelper.displayedResults$;
this.total$ = this.tableHelper.total$;
}
onSort({ column, direction }: SortEvent) {
// resetting other headers
this.headers.forEach((header) => {
if (header.sortable !== column) {
header.direction = '';
}
});
this.tableHelper!.sortColumn = column.toString();
this.tableHelper!.sortDirection = direction;
}
identify(index: number, item: Country) {
return item.name.official;
}
}
Country API Service
const baseUrl = 'https://restcountries.com/v3.1';
@Injectable({
providedIn: 'root',
})
export class CountryApiService {
constructor(private http: HttpClient) {}
findByName(query: string): Observable<Country[]> {
const params = new HttpParams()
.set('fields', 'name,region');
const url = `${baseUrl}/name/${query}`;
return this.http.get<Country[]>(url, { params: params });
}
}
interface SearchResult<T> {
displayedResults: T[];
total: number;
}
interface State {
page: number;
pageSize: number;
searchTerm: string;
sortColumn: string;
sortDirection: SortDirection;
}
function compare(v1: any, v2: any) {
return v1 < v2 ? -1 : v1 > v2 ? 1 : 0;
}
export abstract class FilterableTable<T> {
private _loading$ = new BehaviorSubject<boolean>(true);
private _search$ = new Subject<void>();
private _displayedResults$ = new BehaviorSubject<T[]>([]);
private _total$ = new BehaviorSubject<number>(0);
// public allValues: T[] = [];
private _state: State = {
page: 1,
pageSize: 4,
searchTerm: '',
sortColumn: '',
sortDirection: '',
};
constructor(public allValues: T[]) {
// this.allValues = allValues;
this._search$
.pipe(
tap(() => this._loading$.next(true)),
debounceTime(200),
switchMap(() => this._search()),
delay(200),
tap(() => this._loading$.next(false))
)
.subscribe((result) => {
this._displayedResults$.next(result.displayedResults);
this._total$.next(result.total);
});
this._search$.next();
}
get displayedResults$() {
return this._displayedResults$.asObservable();
}
get total$() {
return this._total$.asObservable();
}
get loading$() {
return this._loading$.asObservable();
}
get page() {
return this._state.page;
}
get pageSize() {
return this._state.pageSize;
}
get searchTerm() {
return this._state.searchTerm;
}
set page(page: number) {
this._set({ page });
}
set pageSize(pageSize: number) {
this._set({ pageSize });
}
set searchTerm(searchTerm: string) {
this._set({ searchTerm });
}
set sortColumn(sortColumn: string) {
this._set({ sortColumn });
}
set sortDirection(sortDirection: SortDirection) {
this._set({ sortDirection });
}
private _set(patch: Partial<State>) {
Object.assign(this._state, patch);
this._search$.next();
}
sort(apiResp: T[], column: string, direction: string): T[] {
console.log(`sourting on column ${column}`);
if (direction === '') {
return apiResp;
} else {
return [...apiResp].sort((a: T, b: T) => {
const res = compare(
this.getProperty(a, column),
this.getProperty(b, column)
);
return direction === 'asc' ? res : -res;
});
}
}
/**
* Ability to get nested properties, i.e. `name.common`
*/
getProperty(object: any, propertyName: string) {
var parts = propertyName.split('.'),
length = parts.length,
i,
property = object || this;
for (i = 0; i < length; i++) {
property = property[parts[i]];
}
return property;
}
abstract matches(resp: T, term: string): boolean;
private _search(): Observable<SearchResult<T>> {
const { sortColumn, sortDirection, pageSize, page, searchTerm } =
this._state;
// console.log(`allValues size ${this.allValues.length}`)
// console.log(`searchTerm: ${searchTerm}`)
// 1. sort
let displayResults: T[] = this.sort(
this.allValues,
sortColumn,
sortDirection
);
// 2. filter
displayResults = displayResults.filter((country) =>
this.matches(country, searchTerm)
);
const total = displayResults.length;
// 3. paginate
displayResults = displayResults.slice(
(page - 1) * pageSize,
(page - 1) * pageSize + pageSize
);
// console.log(`displayResults size ${displayResults.length}`)
return of({ displayedResults: displayResults, total });
}
}
<div class="input-group mb-3">
<input
type="text"
class="form-control"
[(ngModel)]="searchTerm"
placeholder="Search country names"
(keydown.enter)="onSearch()"
/>
<div class="input-group-append">
<button class="btn btn-primary" (click)="onSearch()">Search</button>
</div>
</div>
<div *ngIf="apiResults$ | async as apiResults">
<app-country-table [apiResults]="apiResults"></app-country-table>
</div>
@Component({
selector: 'app-search-page',
templateUrl: './search-page.component.html',
standalone: true,
imports: [FormsModule, CountryTableComponent, NgIf, NgFor, AsyncPipe],
})
export class SearchPageComponent implements OnInit {
searchTerm = '';
apiResults$?: Observable<Country[]>;
constructor(private apiService: CountryApiService) {}
ngOnInit() {}
onSearch(): void {
console.log(`Searching ${this.searchTerm}`);
this.apiResults$ = this.apiService.findByName(this.searchTerm);
}
}
<form>
<div class="mb-3 row">
<label
for="table-complete-search"
class="col-xs-3 col-sm-auto col-form-label"
>Full text search:</label
>
<div class="col-xs-3 col-sm-auto">
<input
id="table-complete-search"
type="text"
class="form-control"
name="searchTerm"
[(ngModel)]="tableHelper.searchTerm"
/>
</div>
<span class="col col-form-label" *ngIf="tableHelper.loading$ | async"
>Loading...</span
>
</div>
<table class="table table-striped">
<thead>
<tr>
<th sortable="name.common" scope="col" (sort)="onSort($event)">
Country
</th>
<th sortable="region" scope="col" (sort)="onSort($event)">Region</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let country of countries$ | async">
<td>
<ngb-highlight
[result]="country.name.common"
[term]="tableHelper.searchTerm"
></ngb-highlight>
</td>
<td>
<ngb-highlight
[result]="country.region"
[term]="tableHelper.searchTerm"
></ngb-highlight>
</td>
</tr>
</tbody>
</table>
<div class="d-flex justify-content-between p-2">
<ngb-pagination
[collectionSize]="(total$ | async)!"
[(page)]="tableHelper!.page"
[pageSize]="tableHelper!.pageSize"
>
</ngb-pagination>
<select
class="form-select"
style="width: auto"
name="pageSize"
[(ngModel)]="tableHelper!.pageSize"
>
<option [ngValue]="2">2 items per page</option>
<option [ngValue]="4">4 items per page</option>
<option [ngValue]="6">6 items per page</option>
</select>
</div>
</form>
@Component({
selector: 'app-country-table',
templateUrl: './country-table.component.html',
standalone: true,
imports: [ FormsModule, AsyncPipe, NgbHighlight, NgbdSortableHeader, NgbPaginationModule, NgIf, NgFor],
})
export class CountryTableComponent implements OnInit {
countries$!: Observable<Country[]>;
total$!: Observable<number>;
@ViewChildren(NgbdSortableHeader) headers!: QueryList<NgbdSortableHeader>;
@Input() apiResults!: Country[];
public tableHelper!: CountryTableFilter;
constructor() {}
ngOnInit(): void {
console.log(`part table initialize with results ${this.apiResults}`);
this.tableHelper = new CountryTableFilter(this.apiResults);
this.countries$ = this.tableHelper.displayedResults$;
this.total$ = this.tableHelper.total$;
}
onSort({ column, direction }: SortEvent) {
// resetting other headers
this.headers.forEach((header) => {
if (header.sortable !== column) {
header.direction = '';
}
});
this.tableHelper!.sortColumn = column.toString();
this.tableHelper!.sortDirection = direction;
}
identify(index: number, item: Country) {
return item.name.official;
}
}
const baseUrl = 'https://restcountries.com/v3.1';
@Injectable({
providedIn: 'root',
})
export class CountryApiService {
constructor(private http: HttpClient) {}
findByName(query: string): Observable<Country[]> {
const params = new HttpParams()
.set('fields', 'name,region');
const url = `${baseUrl}/name/${query}`;
return this.http.get<Country[]>(url, { params: params });
}
}
View the demo on StackBlitz
Multiple APIs/Tables, One Parent Component¶
This example simply shows how you could use the same search input, but based on a repository selection, use a different api and populate a different table.
Try searching something like Tribology
after selecting Journals
.
View the demo on StackBlitz or the updated demo using ngSwitch
on StackBlitz
- Simple methodology of selectively showing child components with ngIf statement.
- More optimized solution using ngSwitch
@Component({
selector: 'app-search-page',
templateUrl: './search-page.component.html',
standalone: true,
imports: [
FormsModule,
CountryTableComponent,
WikiTableComponent,
JournalTableComponent,
NgIf,
NgFor,
AsyncPipe,
],
})
export class SearchPageComponent implements OnInit {
selectedOption = 'countries';
searchTerm = '';
countryResults$?: Observable<Country[]>;
journals$?: Observable<Journal[]>;
constructor(
private countryApiService: CountryApiService,
private journalApiService: JournalApiService
) {}
ngOnInit() {}
onSearch(): void {
console.log(`Searching ${this.searchTerm}`);
switch (this.selectedOption) {
case 'countries':
this.countryResults$ = this.countryApiService
.findByName(this.searchTerm)
.pipe(
tap((results) => {
console.log(`fetched: ${JSON.stringify(results)}`);
})
);
break;
case 'journals':
this.journals$ = this.journalApiService
.findByName(this.searchTerm)
.pipe(
map((journalApiResponse: JournalApiResponse) => {
return journalApiResponse.message.items;
})
);
break;
}
}
}
Multiple tables with Dynamic NgComponentOutlet
¶
Another consideration is to render our various result tables dynamically using NgComponentOutlet
within ng-content
.
View the demo on StackBlitz . Also check out how to use a property to denote the current component instead of the getter method on StackBlitz .
<!-- Replacing ngSwitch with ng-container -->
<div [ngSwitch]="selectedOption">
<div *ngSwitchCase="'countries'">
<div *ngIf="countryResults$ | async as countryResults">
<app-country-table [apiResults]="countryResults"></app-country-table>
</div>
</div>
<div *ngSwitchCase="'journals'">
<div *ngIf="journals$ | async as journals">
<app-journal-table [apiResults]="journals"></app-journal-table>
</div>
</div>
</div>
<!-- Click here! (1) -->
<ng-container
*ngComponentOutlet="selectedComponent; inputs: componentInput"
></ng-container>
- Instead of an infinite
ngSwitchCase
, we can choose to instead take a dynamic approach withngComponentOutlet
within anng-container
export class SearchPageComponent implements OnInit {
selectedOption = 'countries';
searchTerm = '';
results$: Observable<any[]> = of([]);
results: any[] = [];
loading: boolean = false;
constructor(
private countryApiService: CountryApiService,
private journalApiService: JournalApiService
) {}
ngOnInit() {}
onSearch(): void {
console.log(`Searching ${this.searchTerm}`);
switch (this.selectedOption) {
case 'countries':
this.searchCountries();
break;
case 'journals':
this.searchJournals();
break;
}
}
private searchCountries() {
this.results$ = this.countryApiService.findByName(this.searchTerm);
this.subscribe();
}
private searchJournals() {
this.results$ = this.journalApiService.findByName(this.searchTerm)
.pipe(
map((journalApiResponse: JournalApiResponse) => {
return journalApiResponse.message.items;
})
);
this.subscribe();
}
private subscribe(): void {
this.loading = true;
this.results$.pipe(take(1)).subscribe({
next: (data) => {
this.results = data;
},
complete: () => {
this.loading = false;
},
});
}
get componentInput() {
return {
apiResults: this.results,
loading: this.loading,
};
}
get selectedComponent(): Type<any> {
switch (this.selectedOption) {
case 'countries':
return CountryTableComponent;
case 'journals':
return JournalTableComponent;
default:
return CountryTableComponent;
}
}
}
export class CountryTableComponent implements OnInit {
countries$!: Observable<Country[]>;
total$!: Observable<number>;
@ViewChildren(NgbdSortableHeader) headers!: QueryList<NgbdSortableHeader>;
@Input() set apiResults(value: Country[]) {
if (this.tableHelper) {
console.log('setting value to tableHelper');
this.tableHelper.updateValues(value);
}
}
@Input() set loading(loading: boolean) {
if (this.tableHelper) {
this.tableHelper.loading = loading;
}
}
public tableHelper!: CountryTableFilter;
constructor() {}
ngOnInit(): void {
this.tableHelper = new CountryTableFilter([]);
this.countries$ = this.tableHelper.displayedResults$;
this.total$ = this.tableHelper.total$;
}
ngOnChanges(changes: SimpleChanges) {
if (this.tableHelper && changes['apiResults']) {
console.log('apiResults changed to:', changes['apiResults'].currentValue);
}
}
onSort({ column, direction }: SortEvent) {
// resetting other headers
this.headers.forEach((header) => {
if (header.sortable !== column) {
header.direction = '';
}
});
this.tableHelper!.sortColumn = column.toString();
this.tableHelper!.sortDirection = direction;
}
identify(index: number, item: Country) {
return item.name.official;
}
}
export class SearchPageComponent implements OnInit {
selectedOption = 'countries';
searchTerm = '';
results$: Observable<any[]> = of([]);
results: any[] = [];
loading: boolean = false;
selectedComponent?: Type<any>;
constructor(
private countryApiService: CountryApiService,
private journalApiService: JournalApiService
) {}
ngOnInit() {}
onSearch(): void {
console.log(`Searching ${this.searchTerm}`);
switch (this.selectedOption) {
case 'countries':
this.searchCountries();
this.selectedComponent = CountryTableComponent;
break;
case 'journals':
this.searchJournals();
this.selectedComponent = JournalTableComponent;
break;
}
}
private searchCountries() {
this.results$ = this.countryApiService.findByName(this.searchTerm);
this.subscribe();
}
private searchJournals() {
this.results$ = this.journalApiService.findByName(this.searchTerm).pipe(
map((journalApiResponse: JournalApiResponse) => {
return journalApiResponse.message.items;
})
);
this.subscribe();
}
private subscribe(): void {
this.loading = true;
this.results$.pipe(take(1)).subscribe({
next: (data) => {
this.results = data;
},
complete: () => {
this.loading = false;
},
});
}
get componentInput() {
return {
apiResults: this.results,
loading: this.loading,
};
}
}