SpringBot add related entities to Data table list view

By default the Data table tiles list view shows the attributes for the entity that it is attached to. Sometimes you may with to add context by showing information from related entities.


Model

For this article we will be using our LMS entity model from our Learning Management System (LMS) - Example project as our example. Specifically our Course entity.

LMS Course Entity Model

Specifically, our ‘Course’ entity and our ‘Course Category’ entity.

Task

For this article we will be adding the name column from our Course Category entity to our Course Data table list to show what category a course has been assigned.

Article Data table list

Currently, the course Data table only describes direct attributes of the Course entity. This is available at http://localhost:4200/course.

Course Unedited here

To add a column displaying the CourseCategory relation of each course, we will need to add some custom code.

The primary file we will be working with for this task will be the course-crud-list.component.ts which can be found at clientside/src/app/components/crud/course/list/course-crud-list.component.ts.

Expands

The first thing we need to update is our expands. Expands allow us to select what data we fetch from our related entities.

  1. Imports: Find the protected region in the course-crud-list.component.ts file called Add any additional imports here, activate it and add the following:
// % protected region % [Add any additional imports here] on begin

import { getCourseCategoryModelWithId } from 'src/app/models/courseCategory/course_category.model.selector';
import { map }  from  'rxjs/operators';
import {ModelPropertyType} from 'src/app/lib/models/abstract.model';

// % protected region % [Add any additional imports here] end
  1. In the same file, find the protected region called Change your default expands if required here and activate it. This is where we dictate which related data to return to the Data table list. By default we expand on no entities to improve performance.
  2. Add the following to the protected region activated above:
// % protected region % [Change your default expands if required here] on begin
/**
 * Default references to expand
 * In CRUD data table, default to expand all the references
 */
private get defaultExpands(): Expand[] {
    return [{ name: 'courseCategory', fields: ['id', 'name'] }];
}
// % protected region % [Change your default expands if required here] end

Given we wish to display the name, we need to include it in our query. Additionally, for the purposes of retrieving the CourseCategory entity from our store, we also must have its ID. Items that can be used in expands are found within the getRelations() method with our model. In this case the clientside/src/app/models/course/course.model.ts file.

For example:

/**
 * The relations of the entity
 */
static getRelations(): { [name: string]: ModelRelation } {
    return {
        ...super.getRelations(),
        courseLessons: {
        type: ModelRelationType.MANY,
            name: 'courseLessonsIds',
            // % protected region % [Customise your 1-1 or 1-M label for Course Lessons here] off begin
            label: 'Course Lessons',
            // % protected region % [Customise your 1-1 or 1-M label for Course Lessons here] end
            // % protected region % [Customise your display name for Course Lessons here] off begin
            displayName: 'order',
            // % protected region % [Customise your display name for Course Lessons here] end
            validators: [
                // % protected region % [Add other validators for Course Lessons here] off begin
                // % protected region % [Add other validators for Course Lessons here] end
            ],
            // % protected region % [Add any additional field for relation Course Lessons here] off begin
            // % protected region % [Add any additional field for relation Course Lessons here] end
        },
        courseCategory: {
            type: ModelRelationType.ONE,
            name: 'courseCategoryId',
            // % protected region % [Customise your label for Course Category here] off begin
            label: 'Course Category',
            // % protected region % [Customise your label for Course Category here] end
            // % protected region % [Customise your display name for Course Category here] off begin
            // TODO change implementation to use OrderBy or create new metamodel property DisplayBy
            displayName: 'name',
            // % protected region % [Customise your display name for Course Category here] end
            validators: [
                // % protected region % [Add other validators for Course Category here] off begin
                // % protected region % [Add other validators for Course Category here] end
            ],
            // % protected region % [Add any additional field for relation Course Category here] off begin
            // % protected region % [Add any additional field for relation Course Category here] end
        },
    };
}

We can expand on both courseCategory and courseLessons from our Course entity Data table. Fields which can be filtered are the model properties on the each of the Course and Course Categories models.

Header Options
  1. In course-crud-list.component.ts create a new method called sortedHeaderOptions. Find the protected region called Add any additional class methods here and add our method stub as follows:
private sortedHeaderOptions(): HeaderOption[] {
    const headerOpts = this.modelProperties.map(prop => {
        return {
            ...prop,
            sortable: true,
            sourceDirectFromModel: true,
            valueSource: prop.name
        } as HeaderOption;
    }).filter(opt => opt.name !== 'id' && !opt.doHide);

    return headerOpts;
}

At this stage, we have just reproduced the default behaviour, however, we have also completed the ground work required to customise it further. For example, you may notice the applied filter. This can be used to remove any attribute from the list view by matching the attribute name as it appears in the respective model. In this case we would be referring to clientside/src/app/models/course/course.model.ts.

  1. Now to update our header options to use this new method; find the protected region called Change your header options required here, activate it and replace its contents with the following:
// % protected region % [Change your header options required here] on begin
readonly headerOptions: HeaderOption[] = this.sortedHeaderOptions();
// % protected region % [Change your header options required here] end

Our new method now controls our header options.

  1. We now need to customise it by adding our own custom column. We will be adding the following before our return statement:
headerOpts.push({
            name: 'courseCategory',
            displayName: 'Course Category',
            sortable: true,
            sourceDirectFromModel: false,
            type: ModelPropertyType.OBSERVABLE,
            valueFunction: (model) => {
                let courseModel = model as CourseModel;
                if (courseModel.courseCategoryId){
                    return this.store.select(getCourseCategoryModelWithId, courseModel.courseCategoryId).pipe(
                        map(res => res.name)
                    );
                } else {
                    return '';
                }
            }
        } as HeaderOption);

A couple of important things to notice with addition:

  • displayName This defines what appears in the column header
  • sourceDirectFromModel We set this to false, as Course Category name is not part of the Course model
  • type This is set to ModelPropertyType.OBSERVABLE as we need to fetch the value from our store.
  • valueFunction Allows us to define how we retrieve the value which as you can see, is simply a retrieval from our store.

Now we have added our custom column, our method now looks as follows:

// % protected region % [Add any additional class methods here] on begin
    private sortedHeaderOptions(): HeaderOption[] {
        const headerOpts = this.modelProperties.map(prop => {
            return {
                ...prop,
                sortable: true,
                sourceDirectFromModel: true,
                valueSource: prop.name
            } as HeaderOption;
        }).filter(opt => opt.name !== 'id' && !opt.doHide);

        headerOpts.push({
            name: 'courseCategory',
            displayName: 'Course Category',
            sortable: true,
            sourceDirectFromModel: false,
            type: ModelPropertyType.OBSERVABLE,
            valueFunction: (model) => {
                let courseModel = model as CourseModel;
                if (courseModel.courseCategoryId){
                    return this.store.select(getCourseCategoryModelWithId, courseModel.courseCategoryId).pipe(
                        map(res => res.name)
                    );
                } else {
                    return '';
                }
            }
        } as HeaderOption);

        return headerOpts;
    }

    // % protected region % [Add any additional class methods here] end

We can now see our Course Category name in our Data table.

Course with CourseCategory Table

One to many reference

The previous example demonstrated how to add a reference if the target is a single entity. What if the target is many entities?

To handle this we will need to make some adjustments to the above.

For this example, we will be using the Book entity as it has an outgoing many-to-one relationship with the Article entity. What we will do is provide a count of the number of articles which are associated with a given book.

Primary file we will be working with is the book-tile-crud-list.component.ts found in clientside/src/app/components/crud/book/list/book-crud-list.component.ts.

Default expands
  1. Find the protected region called Change your default expands if required here, activate it and add the following:
// % protected region % [Change your default expands if required here] on begin
/**
 * Default references to expand
 * In CRUD tile, default to expand all the references
 */
private get defaultExpands(): Expand[] {
    return [{ name: 'articles', fields: ['id'] }];
}
// % protected region % [Change your default expands if required here] end

Again referring to the model, which for this example can be found in clientside/src/app/models/book/book.model.ts.

Header options
  1. Same as shown in the previous example, create a new method called sortedHeaderOptions within the protected region called Add any additional class methods here as shown here:
// % protected region % [Add any additional class methods here] on begin
private sortedHeaderOptions(): HeaderOption[] {
    const headerOpts = this.modelProperties.map(prop => {
        return {
            ...prop,
            sortable: true,
            sourceDirectFromModel: true,
            valueSource: prop.name
        } as HeaderOption;
    }).filter(opt => opt.name !== 'id' && !opt.doHide);

    return headerOpts;
}
// % protected region % [Add any additional class methods here] end
  1. Update the header options attribute to use this method by updating the protected region called Change your header options required here as follows:
// % protected region % [Change your header options required here] on begin
readonly headerOptions: HeaderOption[] = this.sortedHeaderOptions();
// % protected region % [Change your header options required here] end

Again this is as shown in the previous example.

  1. Now we add our custom column within our method sortedHeaderOptions as follows:
headerOpts.push({
    name: 'article',
    displayName: 'Articles',
    sortable: true,
    sourceDirectFromModel: false,
    type: ModelPropertyType.NUMBER,
    valueFunction: (model) => {
        return model.articlesIds.length;
    }
} as HeaderOption);

The key differences here are we now refer to our articlesIds and set the length, and our ModelPropertyType is now a number as we no longer fetch from our store.

  1. The entire method should appear as follows:
// % protected region % [Add any additional class methods here] on begin
private sortedHeaderOptions(): HeaderOption[] {
    const headerOpts = this.modelProperties.map(prop => {
        return {
            ...prop,
            sortable: true,
            sourceDirectFromModel: true,
            valueSource: prop.name
        } as HeaderOption;
    }).filter(opt => opt.name !== 'id' && !opt.doHide);

    headerOpts.push({
        name: 'article',
        displayName: 'Articles',
        sortable: true,
        sourceDirectFromModel: false,
        type: ModelPropertyType.NUMBER,
        valueFunction: (model) => {
            return model.articlesIds.length;
        }
    } as HeaderOption);

    return headerOpts;
}
// % protected region % [Add any additional class methods here] end
  1. Finally, update our imports:
// % protected region % \[Add any additional imports here\] on begin
import { ModelPropertyType } from 'src/app/lib/models/abstract.model';
import { getArticleModels } from 'src/app/models/article/article.model.selector';
import { map } from 'rxjs/operators';
// % protected region % \[Add any additional imports here\] end

We will now see our count for our articles in the Article column as shown here:

Book with Article count column
  1. Now, if we want to show the actual names in the Article column, we have to make some more changes. Firstly we want to update our defaultExpands method to include the Article names as follows:
// % protected region % [Change your default expands if required here] on begin
/**
 * Default references to expand
 * In CRUD tile, default to expand all the references
 */
private get defaultExpands(): Expand[] {
    return [{ name: 'articles', fields: ['id', 'title'] }];
}
// % protected region % [Change your default expands if required here] end -->
  1. Now we need to update our custom column, namely updating the type and the value function as follows:
// % protected region % [Add any additional class methods here] on begin
private sortedHeaderOptions(): HeaderOption[] {
    const headerOpts = this.modelProperties.map(prop => {
        return {
            ...prop,
            sortable: true,
            sourceDirectFromModel: true,
            valueSource: prop.name
        } as HeaderOption;
    }).filter(opt => opt.name !== 'id' && !opt.doHide);

    headerOpts.push({
        name: 'article',
        displayName: 'Articles',
        sortable: true,
        sourceDirectFromModel: false,
        type: ModelPropertyType.OBSERVABLE,
        valueFunction: (model) => {
            let bookModel = model as BookModel;
            return this.store.select(getArticleModels).pipe(
                map((res => {
                    return res
                        .filter(article => bookModel.articlesIds.indexOf(article.id) > -1)
                        .map(article => article.title).join(", ");
                }))
            )
        }
    } as HeaderOption);

    return headerOpts;
}
// % protected region % [Add any additional class methods here] end

As can be seen above, we have updated our type to be an observable, as we are now fetching from the store again. Additionally, our value function fetches all articles from the store and filters them based on their association with the current book. This method will show all associated articles so it may be good for practical implementations to place a limit to avoid excessive overflow.

Our Book Data table now looks like the following.

Book with listed articles

Solution

Have a look at the add-related-entities branch to see the solution code.