How to Build a Metadata-Driven UI
A metadata-driven UI provides project teams with element alignment by invoking a single endpoint for data. Here's how to build your own metadata-driven UI.
Join the DZone community and get the full member experience.
Join For FreeA metadata-driven UI approach is especially useful in project teams with a high back-end or DBA competence rather than UI. In general, it provides element alignment by invocation of a single endpoint that provides all data required such as cardinality, language, font size, and the font itself.
The library itself aims to provide a configurable metadata engine and a set of endpoints. At the same time, UI should be written from scratch, taking into account corresponding use case specifics to be able to properly handle metadata and construct itself based on it.
Getting Started
To get started with the usage of a metadata provider, just add the following maven dependency to the main application:
<dependency>
<groupId>io.github.sergeivisotsky.metadata</groupId>
<artifactId>metadata-selector</artifactId>
</dependency>
And the following dependency to a deployment application:
<dependency>
<groupId>io.github.sergeivisotsky.metadata</groupId>
<artifactId>metadata-deploy</artifactId>
</dependency>
To have a compatible version of both dependencies, it is also highly recommended to add a library starter to your dependencyManagement
section of parent POM:
<dependency>
<groupId>io.github.sergeivisotsky.metadata</groupId>
<artifactId>metadata-provider-bom</artifactId>
<version>0.0.7</version>
<scope>import</scope>
<type>pom</type>
</dependency>
It will be loaded from Maven central.
Extension
Let's imagine we have the following preconfigured form metadata provider, which was crafted from the following preconfigured template.
@Component
public class FormMetadataMapper implements MetadataMapper<FormMetadata> {
@Override
public String getSql() {
return "SELECT fm.id,\n" +
" fm.form_name,\n" +
" fm.cardinality,\n" +
" fm.language,\n" +
" fm.offset,\n" +
" fm.padding,\n" +
" fm.font,\n" +
" fm.font_size,\n" +
" fm.description,\n" +
" fm.facet,\n" +
" vf.enabled_by_default,\n" +
" vf.ui_control\n" +
"FROM form_metadata fm\n" +
" LEFT JOIN view_field vf on fm.id = vf.form_metadata_id\n" +
"WHERE fm.form_name = :formName\n" +
" AND fm.language = :lang";
}
@Override
public ExtendedFormMetadata map(ResultSet rs) {
try
ExtendedFormMetadata metadata = new ExtendedFormMetadata();
metadata.setFormName(rs.getString("form_name"));
metadata.setCardinality(rs.getString("cardinality"));
metadata.setLang(Language.valueOf(rs.getString("language")
.toUpperCase(Locale.ROOT)));
metadata.setOffset(rs.getInt("offset"));
metadata.setPadding(rs.getInt("padding"));
metadata.setFont(rs.getString("font"));
metadata.setFontSize(rs.getInt("font_size"));
metadata.setDescription(rs.getString("description"));
ViewField viewField = new ViewField();
viewField.setEnabledByDefault(rs.getInt("enabled_by_default"));
viewField.setUiControl(rs.getString("ui_control"));
metadata.setViewField(viewField);
metadata.setFacet(rs.getString("facet"));
return metadata;
} catch (SQLException e) {
throw new RuntimeException("Unable to get value from ResultSet for Mapper: {}" +
FormMetadataMapper.class.getSimpleName(), e);
}
}
}
From the first glance, this is more than enough. However, for a delivery project's specific needs it is necessary to add an additional structure that will represent some mysterious footer data.
This requires the following steps:
- Create a corresponding database table/new fields by means of adjusting deployment Liquibase scripts.
- Add a new structure in a preconfigured domain model like `ExtendedFormMetadata` or create a completely new one that will be a part of the form metadata.
- Adjust `FormMetadataMapper` or create a completely new mapper in case of the new requirements.
However, let's move to our example of a mysterious footer.
We have a requirement that:
- Web page footer should be generated from metadata.
- It should be bumped up in the response of the OOTBS metadata endpoint.
Step One
Create a new deployment Liquibase script.
In our case, it is just called db.changelog-12-09-2021.xml
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.0.xsd">
<changeSet id="1" author="svisockis">
<createTable tableName="footer">
<column name="id" type="java.sql.Types.BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="resizable" type="java.sql.Types.BOOLEAN"/>
<column name="displayable" type="java.sql.Types.BOOLEAN"/>
<column name="defaultText" type="java.sql.Types.VARCHAR(150)"/>
<column name="form_metadata_id" type="java.sql.Types.BIGINT"/>
</createTable>
<addForeignKeyConstraint baseTableName="footer" baseColumnNames="form_metadata_id"
constraintName="footer_form_view_metadata_fk"
referencedTableName="form_metadata"
referencedColumnNames="id"/>
</changeSet>
</databaseChangeLog>
Our footer metadata should hold information on whether the footer will be resizable and displayable, as well as default text that the user will see after the page is generated and a foreign key to the metadata base table.
Step Two
Create a corresponding POJO class.
public class Footer {
private Long id;
private Boolean displayable;
private Boolean resizable;
private String defaultText;
// Constructor, getter and setters omitted
}
Add a reference to parent POJO like this:
public class ExtendedFormMetadata extends FormMetadata {
private String facet;
private Footer footer;
// Constructor, getters and setters omitted
}
Step Three
Adjust a corresponding mapper — `FormMetadataMapper`
in our case.
- SQL should be adjusted.
- Result set extraction should be adjusted.
@Component
public class FormMetadataMapper implements MetadataMapper<FormMetadata> {
@Override
public String getSql() {
return "SELECT fm.id,\n" +
" fm.form_name,\n" +
" fm.cardinality,\n" +
" fm.language,\n" +
" fm.offset,\n" +
" fm.padding,\n" +
" fm.font,\n" +
" fm.font_size,\n" +
" fm.description,\n" +
" fm.facet,\n" +
" vf.enabled_by_default,\n" +
" vf.ui_control,\n" +
" ft.displayable,\n" + // new
" ft.resizable,\n" + // new
" ft.default_Text\n" + // new
"FROM form_metadata fm\n" +
" LEFT JOIN view_field vf on fm.id = vf.form_metadata_id\n" +
" LEFT JOIN footer ft on fm.id = ft.form_metadata_id\n" + // new
"WHERE fm.form_name = :formName\n" +
" AND fm.language = :lang";
}
@Override
public ExtendedFormMetadata map(ResultSet rs) {
try {
ExtendedFormMetadata metadata = new ExtendedFormMetadata();
metadata.setFormName(rs.getString("form_name"));
metadata.setCardinality(rs.getString("cardinality"));
metadata.setLang(Language.valueOf(rs.getString("language")
.toUpperCase(Locale.ROOT)));
metadata.setOffset(rs.getInt("offset"));
metadata.setPadding(rs.getInt("padding"));
metadata.setFont(rs.getString("font"));
metadata.setFontSize(rs.getInt("font_size"));
metadata.setDescription(rs.getString("description"));
ViewField viewField = new ViewField();
viewField.setEnabledByDefault(rs.getInt("enabled_by_default"));
viewField.setUiControl(rs.getString("ui_control"));
metadata.setViewField(viewField);
metadata.setFacet(rs.getString("facet"));
// --- New block ---
Footer footer = new Footer();
footer.setResizable(rs.getBoolean("resizable"));
footer.setDisplayable(rs.getBoolean("displayable"));
footer.setDefaultText(rs.getString("default_text"));
metadata.setFooter(footer);
// --- End new block ---
return metadata;
} catch (SQLException e) {
throw new RuntimeException("Unable to get value from ResultSet for Mapper: {}" +
FormMetadataMapper.class.getSimpleName(), e);
}
}
}
Step Four
Run the deployer application to update the database schema and the application itself.
Result
In the result, you can see the following new section in the metadata endpoint.
}
// ...
"footer": {
"id": null,
"displayable": true,
"resizable": false,
"defaultText": "This is some footer needed to fulfill our business requirements"
}
// ...
}
A source code of this demo can be found in the following repository.
Sample OOTB (Out-of-the-Box) Usage
The following page describes an OOTB (Out-of-the-Box) combo box metadata feature.
For a combo box style and values, metadata is used as well. As an example:
[
{
"id": 1,
"codifier": "CD_001",
"font": "Times New Roman",
"fontSize": 12,
"weight": 300,
"height": 20,
"displayable": true,
"immutable": false,
"comboContent": [
{
"key": "initial",
"defaultValue": "Some initial value",
"comboId": 1
},
{
"key": "secondary",
"defaultValue": "Some secondary value",
"comboId": 1
},
{
"key": "someThird",
"defaultValue": "Some third value",
"comboId": 1
}
]
}
]
The main section contains general properties of the combo box like weight, height, font, and font size.
A comboContent
sub-section contains the content of the combo box, including all possible default values.
When UI invokes a metadata endpoint, it first should construct the page itself and then it should parse an example combobox.
Sample in React:
class SampleCombo extends Component {
state = {
metadata: null,
}
// process metadata
componentDidMount() {
const viewName = 'main';
const self = this;
axios.all([getMetadata(viewName), getMessageHeader(viewName)])
.then(axios.spread((metadata, header) => {
let formattedMetadata = formatMetadata(metadata);
formattedMetadata = populateFields(header, formattedMetadata);
self.setState({metadata: formattedMetadata, activeTab: formattedMetadata.sections.get('comboContent')});
}));
}
// renders component
render() {
const {metadata, activeTab} = this.state;
if (!metadata) return <Loader/>;
const {
codifier,
font,
fontSize,
weight,
height,
displayable,
immutable,
} = metadata;
return (
<div id={uiName} className="klp-page">
<select id="sample" name="sample" style="font={font};fontSize={fontSize};weight={weight};height={height}">
<option value="{key}">{defaultValue}</option>
</select>
</div>
);
}
}
NOTE: This example is not ideal, but it shows the main idea.
Database Schema
Library provides OOTB database schema tables whose goal is to provide base metadata common for all possible UIs. It consists of the following tables:
- form_metadata
- layout
- view_field
- lookup_holder
- lookup_metadata
- combo_box
- combo_box_content
- combo_box_and_content_relation
Database Extension
It is possible to extend a database schema. For extension purposes and database version management purposes, a Liquibase is used. An out-of-the-box solution is written in XML representation, however, YAML representation is also acceptable per wish/requirements in each particular case.
Opinions expressed by DZone contributors are their own.
Comments