Skip to content

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 });
    }
}
export class CountryTableFilter extends FilterableTable<Country> {
    matches(country: Country, input: string): boolean {
        return (
            country.name.common.toLowerCase().includes(input.toLowerCase()) ||
            country.region.toLowerCase().includes(input.toLowerCase())
        );
    }
}
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 });
    }
}
export class CountryTableFilter extends FilterableTable<Country> {
    matches(country: Country, input: string): boolean {
        return (
            country.name.common.toLowerCase().includes(input.toLowerCase()) ||
            country.region.toLowerCase().includes(input.toLowerCase())
        );
    }
}
<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 Changes to component
<!-- Using *ngIf (1) -->
<div *ngIf="selectedOption === 'countries'">
    <div *ngIf="countryResults$ | async as countryResults">
        <app-country-table [apiResults]="countryResults"></app-country-table>
    </div>
</div>
<div *ngIf="selectedOption === 'journals'">
    <div *ngIf="journals$ | async as journals">
        <app-journal-table [apiResults]="journals"></app-journal-table>
    </div>
</div>

<!-- Using *ngSwitch (2) -->
<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>
  1. Simple methodology of selectively showing child components with ngIf statement.
  2. 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>
  1. Instead of an infinite ngSwitchCase, we can choose to instead take a dynamic approach with ngComponentOutlet within an ng-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;
    }
  }
}
Inputs changed to setters to explicitly invoke dynamic updates
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;
  }
}
Component variable instead of getter
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,
    };
  }
}
Added changes
+ public updateValues(values: T[]){
+     this.allValues = values;
+     this._search$.next();
+ }

+ set loading(loading: boolean){
+     this._loading$.next(loading);
+ }

Comments