Implementing shared logic for CRUD UI Components in Angular
In this article, we describe our approach to creating an Angular library for CRUD UI components. We derive the UI components from an abstract base class containing shared logic.
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:
- Angular Component Inheritance: Complete guide to create an Angular app with centralised logic for routing
- Component Inheritance in Angular: Detailed explanation of how component inheritance works and what is not inherited
As for the CRUD operations, we identified two approaches:
- 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
- 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
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.
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.
Each value component inherits the
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):
To make the validation configurable for different data types, we added an abstract member
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
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:
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
IntValueComponent handles all the logic needed for the integer data type, and
DisplayEditComponent takes care of the CRUD operations (save and cancel button etc.).
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
TimeValueComponent have the same inputs
displayValue defined in the base class.
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
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
- edit an existing value in update mode
- cancel an update and restore the initially given value
- perform an update and write the new value back (updated metadata provided by the CMS)
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
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
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.
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.
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).