In this article, we describe our approach to creating an Angular library for CRUD UI components. These CRUD components can then be used in an Angular app to read and modify data in our backend called Knora. Knora is a CMS that lets clients read, create, and modify data via its RESTful API. Knora supports different data types such as numeric values, texts with and without markup, dates etc. Each data type has its specifics, but all data types share some commonalities.

Therefore, we create one Angular component per data type, and derive all components from an abstract base class that contains logic shared by all data types. This approach has the advantage of shared logic that can be moved to the abstract base class, making the data type specific components less complex.

Component inheritance is a well-known concept in Angular and won't be covered in detail in this article. If you want to learn more about component inheritance, the following two sources could be helpful:

As for the CRUD operations, we identified two approaches:

  1. Handling all CRUD operations in one component, see Building a CRUD application with Angular and Angular Tutorial: Create a CRUD App with Angular CLI and TypeScript
  2. Create one component per operation, see Angular 8 CRUD Web Application

We think that the first approach is more promising than the second because we want to avoid redundant UI code, e.g., create and update are very similar in the UI, the difference being that update starts from an existing value.

Creating UI Components for CRUD Operations

Problem Definition

For each data type, the following operations should be supported in the UI:

  • read (display existing data)
  • update / edit (make a new version of existing data)
  • create (add new data)
  • delete (remove existing data)

Before creating or updating data, it has to be validated:

  • the user input has to meet the restrictions defined for the data type (e.g, an integer cannot have fractions and a text cannot be an empty string)
  • when updating / editing existing data, the new version has to be different from the existing version (no redundant versions are allowed)

The CMS enforces these rules and would reject an operation violating any of the rules. However, the UI components should validate user input before sending a request to the server and prevent the user from performing any illegal action. Proper validation involves some complexity, but a lot of this is shared among the different data types.  

Proper state management is another requirement: After each operation, the UI component should be in a consistent state. When the user cancels the editing of a value, for instance, the existing value should be correctly re-initialised, restoring the original state. Another example could be that the user creates a new value, and this new value gets read back from the CMS, allowing for further update operations.

Our Approach

We decided to create an Angular component per data type and to make each of these components a subclass of an abstract base class that contains common logic and defines the public API shared by all components. We refer to these components as value components, e.g., IntValueComponent for an integer value. A value component knows how to display a value of a given data type in the UI and how to update it or create a new value interacting with a user.

Based on the public API shared by all value components, we are able to create components that actually communicate with the backend: read, update, create, and delete values. We refer to these as operation components. Since all data types share some commonalities and all value components have a common public API, there is no need to develop data type specific components for CRUD operations. This means that we can create an operation component to update existing data that works for all data types.

Abstract Base Class and Value Components

The abstract base class defines abstract members and methods that have to be implemented by the value components. The abstract base class also provides some implementations that can be used in the value components.

@Directive()
export abstract class BaseValueComponent {
   
   /** 
    * Value to be displayed, if any. 
    */ 
   @Input() abstract displayValue?: ReadValue; 
  
   /** 
    * Sets the mode of the component. 
    */ 
   @Input() mode: 'read' | 'update' | 'create';
 
   ...
}   
@Inputs defined by the Abstract Base Class

Each value component inherits the @Input mode that defines whether the value component is in read, update, create mode. Each value component has to provide a data type specific implementation of displayValue, e.g. a ReadIntValue (see the docs for an explanation of these classes) in the case of an integer data type. Since all value components have these @Inputs, it becomes easy to write components that use value components in their template and can deal with all data types.

To make data editable, we rely on Angular Material components.  To communicate between the component's class and the template, we use FormControl. One of the most complex things we have to deal with is validation (see section Problem Definition). Since it is complex, we would like to handle most of it in the abstract base class so that the derived classes do not have to implement that logic.

In the base class we have implemented a method resetFormControl(): void (see source code) that sets the values and validators of a FormControl depending on the mode the value component is in (e.g., read, update, or create):

// set validators depending on mode
if (this.mode === 'update') {       
    this.valueFormControl.setValidators(
        [Validators.required, this.standardValidatorFunc(...)]
        .concat(this.customValidators)
    );
} else {          
    this.valueFormControl.setValidators(
       [Validators.required].concat(this.customValidators)
    );                                                                                                                                                                                   
}
Centralised Logic for Validators

To make the validation configurable for different data types, we added an abstract member customValidators: ValidatorFn[] to the base class. This acts as a type checker where types offered by TypeScript or JavaScript are not restrictive enough, e.g., when a URI is expected (not just a string) or an integer (in TypeScript or JavaScript numbers can have fractions). If such a check is needed, a validator can simply be added in the derived class.

Since a new version of the data must be different from the current version, we implemented standardValidatorFunc() (see source code), a ValidatorFn generator that works for primitive types. The generated validator makes use of standardValueComparisonFunc(): boolean (see source code) to compare two versions of a value. In the case of an integer data type, it would compare two numbers. If the numbers are the same, then the validation fails. As an additional rule, existing data is considered equivalent to a new version if only the comment changes. A comment is an optional string that can be attached to each version of a value. This logic is the same for all data types and therefore handled in the base class. Whenever a value is complex, e.g., an object, then only standardValueComparisonFunc() has to be overridden in the subclass. This is necessary for an interval with a start and end.

All the value components have to do is call resetFormControl()  in ngOnInit (when the value component is initialised) and ngOnChanges (when the value component's mode changes and/or a new displayValue is assigned, e.g. after a successful update).

In addition to the members and methods explained above, each value component must implement methods to get the updated or created value from the UI, so that it can be used by an operation component.

Performing CRUD Actions with Operation Components

So far, we have covered the value components that display existing data and make it editable in the UI.  Now we will introduce operation components that handle logic needed to switch between read and update mode and communicate with the CMS. So far, we have implemented one operation component with the name DisplayEditComponent (see source code). It can deal with any value component because it just needs to know about the base class's interface. The code snippet below shows a reference to the value component used in its template:

@ViewChild('valComp') valueComponent: BaseValueComponent;
Generic access to any Value Component from DisplayEditComponent

Since all value components are derived from the same base class, it's sufficient to know about its public API to perform all necessary actions. The graphic below shows the IntValueComponent inside the DisplayEditComponent. IntValueComponent handles all the logic needed for the integer data type, and DisplayEditComponent takes care of the CRUD operations (save and cancel button etc.).

Operation Component containing a Value Component 

The IntValueComponent can be replaced by any other value component. Depending on the type of a given value, DisplayEditComponent's template chooses the apt value component to display a value in the GUI and make it editable. Depending on valueType, the selector of the value component is chosen. Note that both components IntValueComponent and TimeValueComponent have the same inputs mode and displayValue defined in the base class.

<span [ngSwitch]="valueType">
  <dsp-int-value #valueComponent *ngSwitchCase="'IntValue'" [mode]="mode" [displayValue]="displayValue"></dsp-int-value>
  <dsp-time-value #valueComponent *ngSwitchCase="'TimeValue'" [mode]="mode" [displayValue]="displayValue"></dsp-time-value>
  ...
</span>
Template of DisplayEditComponent

The same operation component can deal with various value components because all value components are derived from the same class defining the interface. To display and update a timestamp instead of an integer, it is enough to use TimeValueComponent's selector from DisplayEditComponent's template.

Editing a timestamp: cancel and save operations

For each data type for which we have implemented a value component, we now have all the logic necessary to:

  • show an existing value with comment in read mode
Text Value with comment in Read Mode
  • edit an existing value in update mode
Boolean Value in Edit Mode
  • cancel an update and restore the initially given value
Cancelled Update of a Decimal Value
  • perform an update and write the new value back (updated metadata provided by the CMS)
Updating a Color Value (using ngx-color-picker)

In the operation components, we do not communicate directly with Knora's API but make use of @knora/api, a library written in TypeScript. This library facilitates the communication with Knora because the client does not have to know about URL path segments etc. and does not have to care about serialisation and deserialisation of data (from and to JSON). The classes ReadValue and ReadIntValue that have been mentioned above are provided by this library.

As a next step, we are going to implement an operation component to create values, also making use of the value components as in the case of editing a value. We are also going to add a button to delete a value, provided the user has the necessary permissions. The necessary API operations in Knora are already supported by @knora/api.

Testing

Each component has to be tested in its own spec file. Also if a component solely uses logic that is already implemented in the abstract base class (as in the case of value components like IntValueComponent), it is still the component's responsibility to implement the lifecycle hooks and call methods from the base class from there. But since a lot of the logic is shared among components, it is enough to write very thorough tests for complex base class logic once. For the other cases, it is enough to make sure the base class's method calls are performed at the right time (on init, on change etc.). It is also helpful to define a set of cases that have to be tested in each component and stick to this set consistently. It then becomes easier to read and change specs written by another developer.

Background

The CRUD UI components presented here are part of the DaSCH Service Platform (DSP). The Data and Service Center for the Humanities (DaSCH) provides long term access to qualitative research data. Since research data not only has to be presented to the public but also be made further useable by researchers in a convenient way, CRUD UI components are an essential part of the DSP.

Acknowledgements

The work presented in this article is a group effort undertaken by the members of the Research and Development Team of the Data and Service Center for the Humanities (DaSCH).