Quick Start
Goals
With Dueuno Elements you can develop backoffice web applications writing code in a single programming language: Apache Groovy. No need to know HTML, CSS o Javascript.
With Dueuno Elements you can develop and maintain web applications with requirements such as:
-
Authenticate & authorize users (who can access and what they can do)
-
Implement several business features with a coherent user interface
-
Display and edit data in table format (CRUD)
-
Display charts, dashboards, etc. to build Business Intelligence applications
-
Let users customize their localization preferences with country specific formats (languages, dates, numbers, currencies, quantities, etc.)
-
Develop multi-device applications. Each application will automatically work on a desktop pc with a mouse as well as on a mobile device with a finger.
-
Develop multi-tenant applications. The same application can serve different clients with separate databases.
The main purpose of Dueuno Elements is to decouple the business logic from the User Interface (GUI). This lowers the costs of maintaining the application: just one language instead of 5 (HTML, CSS, JavaScript, Java, SQL), less skilled people can join the team and no need to upgrade the GUI when new web technologies/standards gets available. |
Non-Goals
Dueuno Elements is NOT a solution for:
-
Creating classical websites (text + images + basic user interaction)
-
Creating graphical/animated web applications
-
Developing applications where the client retains strict control on how the user interface will look like
For such cases you can just use the beautiful Grails.
Try it out
To develop Dueuno Elements applications you need to learn the basics of the Apache Groovy programming language and the basics of the Grails framework.
Learning Groovy
The Groovy programming language has been around for more than 20 years now, it’s been the second language to be developed for the JVM. You can read the documentation and access the Groovy learning resources
Learning Grails
Grails has been helping web developers for 20 years now, it still is a growing and evolving technology so what you want to do is reading the latest documentation on the Grails website
Run
We are going to run our first Dueuno Elements application, ready?
-
Download, install and run IntelliJ IDEA Community Edition.
-
Click on
File → New → Project From Version Control…
and paste the following link https://github.com/dueuno-projects/dueuno-app-template -
Run the application from the Gradle sidebar (on the right) clicking on
Tasks → application → bootRun
| Running application…
Configuring Spring Security Core …
… finished configuring Spring Security Core
Grails application running at https://localhost:8080 in environment: development
The first run will take some time since it has to download all the needed dependencies. |
We can now navigate to https://localhost:8080 to see the login screen. Login with the credentials super/super
to play around with the basic features of a plain Dueuno Elements application.
Basics
In Dueuno Elements everything is a component. All visual objects of the application are derived from the same base class Component
and they can be assembled together like we do with a LEGO set.
Some of these components are automatically created and managed by the org.dueuno:elements-core
Grails plugin for each application instance. Let’s give them a quick look.
The Shell
The Shell
component is the Dueuno Elements GUI. Each Dueuno Elements application share a common user experience and content structure.
Login
Where you can log in.
Home
Where you can find the application most relevant Features (favourits).
Application Menu
Where you can find the complete Features list.
User Menu
Where you can find the user options.
Navigation Bar
The user can access (from left to right) (1) the Main Menu, (2) the Home - where users can find their favourite features - and (3) the User Menu.
Content
Each Feature will display as an interactive screen into the main area surrounded by the shell. We call this area the Shell Content.
Contents can be displayed as modals. A modal content is rendered inside a dialog window. This lets the user focus on a specific part of the Feature to accomplish subtasks like editing a specific object.
Modals can be displayed in three sizes: normal
(default), wide
and fullscreen
.
User Messages
The application can display messages to the user to send alerts or confirm actions.
Responsiveness
All Dueuno Elements applications work both on desktop computers and on mobile devices by design and without the developer having to cope with it. Here is how an application looks like on a Desktop, on a Tablet and on a Mobile Phone.
Project Structure
Dueuno Elements applications are Grails applications. The project structure, follows the conventions over configuration design paradigm so each folder contains specific source file types.
/myapp (1) /grails-app (2) /controllers (3) /services (4) /domain (5) /i18n (6) /init (7) /conf (8) /src /main /groovy (9)
1 | Project root |
2 | Web Application root |
3 | User Interface. Each class name under this directory must end with Controller (Eg. PersonController ) |
4 | Business Logic. Each class name under this directory must end with Service (Eg. PersonService ) |
5 | Database. Each class name under this directory must begin with T (Eg. TPerson ) |
6 | Translations |
7 | Initialization |
8 | Configuration files |
9 | Other application source files |
Each folder contains the package structure, so for example if your application main package is myapp
the source file structure will look like this:
/myapp /grails-app /controllers /myapp MyController.groovy /services /myapp MyService.groovy /domain /myapp MyDomainClass.groovy /init /myapp BootStrap.groovy /src /main /groovy /myapp MyClass.groovy
Features
A Dueuno Elements application is a container for a set of Features.
Each Feature consists of a set of visual objects the user can interact with to accomplish specific tasks. You can identify each Feature as an item in the application menu on the left. Clicking a menu item will display the content of the selected Feature.
To configure the application Features we register them in the BootStrap.groovy
file.
class BootStrap {
ApplicationService applicationService (1)
def init = { servletContext ->
applicationService.init { (2)
registerFeature( (3)
controller: 'person',
icon: 'fa-user',
)
}
}
}
1 | ApplicationService is the object in charge of the application setup |
2 | The init = { servletContext → … } Grails closure is executed each time the application starts up |
3 | Within the applicationService.init { … } closure you can call any of the applicationService methods. In this case the method registerFeature() |
Controllers
A Feature, in the end, is just a link to a controller. A controller is a container for a set of actions.
All actions that a user can take on the application (eg. a click on a button) are coded as methods of a controller class. Each action corresponds to a URL that will be submitted from the browser to the server. The URL follows this structure:
http://my.company.com/${controllerName}/${actionName}
For example the following Controller contains two actions that can be called like this:
http://my.company.com/person/index (1) http://my.company.com/person/edit/1 (2)
class PersonController implements ElementsController {
def index() { (1)
dispaly ...
}
def edit() { (2)
def id = params.id (3)
display ... (4)
}
}
1 | The index action. It’s the default one, it can also be called omitting the action name, eg. http://my.company.com/person |
2 | The edit action, we are also passing 1 as the id parameter |
3 | The params implicit variable is a Map containing all the submitted parameters, in this case the id passed by the edit URL |
4 | The display method ends each action and tells the browser what component to display |
Services
We don’t implement business logic in Controllers. We do it in Services. Each Service is a class implementing several methods we can call from a Controller.
For example the following Service implements the method sayHello()
.
@Slf4j
@CurrentTenant
class PersonService {
String sayHello() {
log.info "Saying hello to the folks!"
return "Hi folks!"
}
}
We can call it from a Controller like this:
class PersonController implements ElementsController {
PersonService personService (1)
def index() {
def hello = personService.sayHello()
display message: hello (2)
}
}
1 | Service injection, the variable name must be the camelCase version of the PascalCase class name |
2 | the display method renders objects on the browser, in this case a message |
Domain
To design the database for our applications we can use GORM, the Grails Object Relational Mapper. This means we can map database tables to domain classes like this:
class TPerson implements GormEntity, MultiTenant<TPerson> { (1)
String firstname
String lastname
String address
static constraints = {
address nullable: true
}
}
1 | Dueuno Elements domain class names must start with T . This way we immediately know we are dealing with a domain class in our code. |
Database Connections
Each application has a DEFAULT
database connection defined in the grails-app/conf/application.yml
file. This DEFAULT
connection cannot be changed at runtime and it is used by Dueuno Elements to store its own database.
-
You can configure multiple databases per environment (DEV, TEST, PRODUCTION, ect) in the
application.yml
, see: https://docs.grails.org/latest/guide/single.html#environments -
You can edit/create database connections at runtime from the Dueuno Elements GUI accessing with the
super
user from the menuSystem Configuration → Connection Sources
-
You can programmatically create database connections at runtime with the
ConnectionSourceService
as follows:
class BootStrap {
ApplicationService applicationService (1)
ConnectionSourceService connectionSourceService (2)
def init = { servletContext ->
applicationService.onInstall { String tenantId -> (3)
connectionSourceService.create( (4)
name: 'runtimeDatasource',
driverClassName: 'org.h2.Driver',
dbCreate: 'update',
username: 'sa',
password: '',
url: 'jdbc:h2:mem:DYNAMIC_CONNECTION;LOCK_TIMEOUT=10000 DB_CLOSE_ON_EXIT=TRUE',
)
}
}
}
1 | ApplicationService is the object in charge of the application setup |
2 | ConnectionSourceService service injection |
3 | The onInstall { … } closure is called only the first time the application runs for the DEFAULT Tenant and each time a new Tenant is created |
4 | The create() method creates a new connection and connects to it. Once created the application will automatically connect to it each time it boots up. Connection details can be changed via GUI accessing as super from the menu System Configuration → Connection Sources |
Tenants
Multi-Tenants applications share the code while connecting to different databases, usually one for each different company. This way data is kept separated with no risk of disclosing data from one company to the other.
Application users can belong only to one Tenant. If a person needs to access different Tenants, then two different accounts must be created. To configure and manage users for a Tenant you have to access the application as the admin user. For each Tenant a default admin user is created with the same name as the Tenant (E.g. the Tenant called TEST
is going to have a test
user which is the Tenant administrator.
The default password for such users corresponds to their names. To change the password you need to log in with the admin user and change it from the User Profile . Go to User Menu (top right) → Profile .
|
New Tenants can be created from the Dueuno Elements GUI accessing as super
from the menu System Configuration → Tenants
. If multi-tenancy is not a requirement to your application you will be using the DEFAULT
Tenant which is automatically created.
User Management
Users can access a Dueuno Elements application with credentials made of a username and a secret password. Each user must be configured by the Tenant’s admin
user from the menu System Administration → Users
and System Administration → Groups
.
CRUD Applications
One of the most useful GUI pattern is the CRUD (Create, Read, Update, and Delete). It is based on the four basic operations available to work with persistent data and databases.
Applications are made of features, we register one to work with movies (See Features).
class BootStrap {
ApplicationService applicationService (1)
def init = { servletContext ->
applicationService.init {
registerFeature( (2)
controller: 'movie',
icon: 'fa-film',
favourite: true,
)
}
}
}
1 | See Application |
2 | See registerFeature() |
We are going to implement a simple database with GORM for Hibernate on top of which we can build our GUI.
class TMovie implements MultiTenant<TMovie> {
LocalDateTime dateCreated
String title
Integer released
static hasMany = [actors: TActor]
static constraints = {
}
}
class TActor implements MultiTenant<TActor> {
LocalDateTime dateCreated
String firstname
String lastname
static constraints = {
}
}
To create a CRUD user interface we are going to implement a controller with the following actions. The business logic will be implemented into a service to keep it decoupled from the GUI.
@Secured(['ROLE_CAN_EDIT_MOVIES']) (1)
class MovieController implements ElementsController { (2)
def index() {
// will display a list of movies
}
def create() { (3)
// will display a form with the movie title
}
def onCreate() { (3)
// will create the movie record on the database
}
def edit() {
// will display the details of a movie
}
def onEdit() {
// will update the movie record on the database
}
def onDelete() {
// will delete a movie record from the database
}
}
1 | Only users with the ROLE_CAN_EDIT_MOVIES authority can access the actions in this controller. |
2 | Implementing ElementsController the Dueuno Elements API will become available |
3 | As a convention, all actions building and displaying a GUI are named after a verb or a name while all actions that execute a business logic are identified by a name starting with on . |
We are going to use the ContentList
content to list the records, the ContentCreate
and ContentEdit
contents to create a new record and edit an existing one (See Contents).
@Secured(['ROLE_CAN_EDIT_MOVIES'])
class MovieController implements ElementsController {
MovieService movieService (1)
def index() {
def c = createContent(ContentList)
c.table.with {
filters.with {
addField(
class: TextField,
id: 'find',
label: TextDefault.FIND,
cols: 12,
)
}
sortable = [
title: 'asc',
]
columns = [
'title',
'released',
]
body.eachRow { TableRow row, Map values ->
// Do not execute slow operations here to avoid slowing down the table rendering
}
}
c.table.body = movieService.list(c.table.filtersParams, c.table.fetchParams)
c.table.paginate = movieService.count(c.table.filtersParams)
display content: c
}
private buildForm(TMovie obj = null) {
def c = obj
? createContent(ContentEdit)
: createContent(ContentCreate)
c.form.with {
validate = TMovie
addField(
class: TextField,
id: 'title',
)
addField(
class: NumberField,
id: 'released',
)
}
if (obj) {
c.form.values = obj
}
return c
}
def create() {
def c = buildForm()
display content: c, modal: true
}
def onCreate() {
def obj = movieService.create(params)
if (obj.hasErrors()) {
display errors: obj
return
}
display action: 'index'
}
def edit() {
def obj = movieService.get(params.id)
def c = buildForm(obj)
display content: c, modal: true
}
def onEdit() {
def obj = movieService.update(params)
if (obj.hasErrors()) {
display errors: obj
return
}
display action: 'index'
}
def onDelete() {
try {
movieService.delete(params.id)
display action: 'index'
} catch (e) {
display exception: e
}
}
}
1 | Service injection, see the implementation below |
We will implement the database operations using GORM for Hibernate, the default Object Relational Mapper used by Grails.
@Slf4j
@CurrentTenant
class MovieService {
private DetachedCriteria<TMovie> buildQuery(Map filters) {
def query = TMovie.where {}
if (filters.containsKey('id')) query = query.where { id == filters.id }
if (filters.find) {
query = query.where {
title =~ "%${search}%"
}
}
// Add additional filters here
return query
}
TMovie get(Serializable id) {
// Add any relationships here (Eg. references to other DomainObjects or hasMany)
Map fetch = [
actors: 'join',
]
return buildQuery(id: id).get(fetch: fetch)
}
List<TMovie> list(Map filterParams = [:], Map fetchParams = [:]) {
if (!params.sort) params.sort = [dateCreated: 'asc']
// Add single-sided relationships here (Eg. references to other Domain Objects)
// DO NOT add hasMany relationships, you are going to have troubles with pagination
// params.fetch = [
// actors: 'join',
// ]
def query = buildQuery(filterParams)
return query.list(fetchParams)
}
Integer count(Map filterParams = [:]) {
def query = buildQuery(filterParams)
return query.count()
}
TMovie create(Map args = [:]) {
if (args.failOnError == null) args.failOnError = false
TMovie obj = new TMovie(args)
obj.save(flush: true, failOnError: args.failOnError)
return obj
}
TMovie update(Map args = [:]) {
Serializable id = ArgsException.requireArgument(args, 'id')
if (args.failOnError == null) args.failOnError = false
TMovie obj = get(id)
obj.properties = args
obj.save(flush: true, failOnError: args.failOnError)
return obj
}
void delete(Serializable id) {
TMovie obj = get(id)
obj.delete(flush: true, failOnError: true)
}
}
Run the application with gradle bootRun
, you should be able to create, list, edit and delete movies.
What’s next?
Read the The Dueuno Elements Book or the Dueuno Elements API
---
The Dueuno Elements Book
My name is Gianluca Sartori, I was born in Italy. I studied Computer Science and started my career as a Software Developer, but quickly moved to Project Management.
In the strangest twist of events of my life, I ended up leading a team of just two people working to deliver three separate web applications at the same time. I had to get my hands dirty again.
After more than 15 years managing people, projects and clients, I was a developer. Again.
The 90s
I clearly remember what it meant developing Desktop Applications with Visual Basic or Borland Delphi. IDEs were User Interface builders, not only code editors. SQL was the only way to interact with a databases. Most of the features were visually designed with drag-and-drop, in a kind of WYSIWYG manner. It was somehting a single developer could have managed.
But that was in the 90s. Fast forwarding to the present day, everyboy is developing Web Applications. And with good reasons.
Web applications are not installed, they are just accessed from your web browser. When you fix a bug or add a new feature to a web application, you don’t need to update all the installed applications. Ask SAP about this.
But a web application is far more complex than a desktop one. To develop one, we need to know a couple of things:
-
Some protocols like TCP/IP, HTTP and what a Web Server is;
-
Be proficient with at least five languages: HTML, CSS, JavaScript, a Backend language (PHP, Java, C#, etc) to write your Buiness Logic and SQL to design and query databases;
-
We need to know the Business Domain, the Business Processes and design the best User Experience we can;
-
Security. The world has changed a lot from the 90s, our applications must be updated each time a breach is found on one of the software components we use. Let alone the security responsibilities we have in charge.
-
Applications today can be accessed from both a Desktop PC and a Mobile Phone. We need to make sure they work on any screen resolution (Responsive User Interface);
-
Applications today must speak to the users in different languages, at least your country’s language and English. So they must be translated and we have to take care of the Internationalization (i18n);
-
Since we are providing the same application to potentially hundreds of thousands users, we need to know how to deploy it in a way it can serve all of them with decent performances. This opens up a Pandora’s box I don’t even try talking about it here (google On-Premises, SaaS, IaaS, PaaS, FaaS, Edge Computing and enjoy).
I am happy though that we at least survived the Browsers War. Today we virtually have one browser to develop applications for.
Full-Stack Developers
All of the above leads us to the mythological figure of the Full-Stack Developer, a super hero that can do all the stuff required to develop a web application.
Yes, you can find a full-stack developer in the wild. It usually is the brightest one, the one that goes Burnout sooner than the others. If you are a developer and you are still working helthy, you are not a full-stack developer.
In history, waves of full-stack developers had quit their jobs, ending up in some remote exotic island doing manual work and investing in their Surfing Skills.
The rest of us
We normal people avoid developing web applications as much as we can. We love Web Services though. Same Shit, No User Interface.
Because the problem is with the UI. We keep reinventing the wheel each and every time. “Hey, but each time gets better!” you would say. No. Each time gets worse.
For backoffice web applications most of the costs lies in the UI development. The Front End, code executed by the browser. Something invented to display text and links is nowadays used to render a GUI whose development requires us to draw lines and align pixels.
We can’t just say “Hey, browser, I need a modal dialog with a title and three buttons”. We need to code an HTML page to structure the content, tell the browser how to display it with CSS, write JavaScript code so that the button “click” event can send an HTTP request to a server that executes a Java function that returns a JSON or HTML we then use to update the page again with JavaScript code.
This is hell. For years big companies like Google or Facebook have been trying to make it less hellish building frameworks to help us remain, if not healthy, at least safe.
Add to this that — usually, people who have no clue about what they are talking about, are in charge of giving the UI requirements.
Developers don’t hesitate adding buttons everywhere with no real logical connection. Developers don’t know what Ergonomics means and those who know, they just don’t care: “The client wants that button? Let’s give’em the fu***ng button”.
Developers don’t like writing web applications.
I don’t like writing web applications
-
I don’t like writing HTML.
-
I don’t like writing CSS.
-
I don’t like writing JavaScript.
-
I don’t like building User Interfaces.
But I had to.
So I jumped into this adventure of creating something to hide away all the low level programming and use just one single programming language: Apache Groovy.
I want to code the “what” and let the computer handle the “how”. Cause I am not one of those folks who just like doing stuff. I want stuff to be done. |
So, we had to build what is called a Backoffice Application. One of the most basic type of applications (I mean, it’s not a videogame, right?) We needed to develop the usual suspects, something the IT world have been doing for the last 50 years:
-
Authenticate and Authorise users
-
Show them a Menu to access the main features
-
Implement CRUD Views
-
Implement Dashboards
-
Other more exotic stuff, let’s call it Custom Views
-
Translate the application to English, Italian, Polish and Chinese
Dueuno Elements
That’s the name of the technology we’ve created.
We decided to go with Java because Java is everywhere. We have the most comprehensive set of libraries and frameworks, it is actively developed, mainteined and evolved, and many industries already adopted it.
So, Dueuno Elements runs on the JVM. It builds on top of open source technologies like the Grails Framework (Spring), Bootstrap and jQuery but lets you develop web applications with just one single programming language: the beautiful Apache Groovy.
-
No need to know HTML, CSS, JavaScript or any frotend framework;
-
No need to know the screen resolution of the device your app will be consumed;
-
No need to be a super hero (A.k.a. full-stack developer), you can just be a normal person.
We took decisions, of course. We created our standard UI and UX. Is it perfect? No. Does it do the job. Yes. And we can only make it better from now on. At least we were able to develop our back office web applications that One-Person can handle. |
BEWARE: If you like doing things, Dueuno Elements is not for you. If you like things to be done, follow me down the rabbit hole, you may find something useful. |
In the next chapters we’ll be going through the Quirks and Quarks of building backoffice web applications with Dueuno Elements.
Application Basics
I love Philosophy. I could write thousands of words about Dueuno Elements but there’s another thing I love: Practice.
So let’s write our first Dueuno Elements application, shall we?
If you are a seasoned developer, or you’re in a hurry, install Java 17, go download the Dueuno Elements Template Application, run it with ./gradlew bootRun and these are your three steps.
|
Grails
Dueuno Elements is built on top of the Grails Framework. The Grails website gives us what we need to quickly start up with a “blank” application project.
-
Go here: https://start.grails.org
-
Select “Java 17” — SELECT JAVA 17 (We only support Grails 6.2.2 with Java 17.
-
Click “Generate Project”
-
Download the
demo.zip
file and uncompress it on your home folder
Dueuno Elements
Find the dependencies
section of the build.gradle
file and add the following line:
...
dependencies {
...
implementation 'org.dueuno:elements-core:{version-elements}'
}
Copy the following code in BootStrap.groovy
:
package com.example
import dueuno.elements.core.ApplicationService
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.init {
// It's fine to leave this empty at the moment
}
}
def destroy = {
}
}
Delete ~/demo/grails-app/controllers/UrlMappings.groovy
First Execution
$ ./gradlew bootRun
The first time you run the application a lot of dependencies will be downloaded so go get something to drink or play Tetris. I love playing Tetris. |
Open the displayed URL: http://localhost:8080
With a bit of luck, at this point you should be able to see something on the screen. You can login with the following username/password
:
-
super/super
This is the Superuser, it has full power on the system. You can create new tenants, configure the system properties and monitor the whole application. -
admin/admin
This is the DEFAULT Tenant Administrator. It can configure the tenant properties, manage groups, users and watch audit logs.
We can now play around with the basics of a Dueuno Elements application. When done, we can terminate the application pressing CTRL + C
.
Application Structure
Okay, we have an empty box now. Let’s fill it with some fruits (fruit is good for our health).
Features
An application is a set of Features. A Feature is made of a Menu Item — so that our users can access it — and a Content, a bidimensional area we can fill with buttons and stuff.
The dark area on the left is the Main Menu. The light area is where the Content is rendered.
We create features in the application initialization method.
package com.example
import dueuno.elements.core.ApplicationService
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.init {
registerFeature(
controller: 'person',
icon: 'fa-user',
favourite: true,
)
}
}
def destroy = {
}
}
The object in charge of creating a feature, and other application-wide stuff, is the ApplicationService
.
To register a Feature we need to specify the name of the controller that implemets the feature. Eg. in our case, the feature will be available at the following URL:
http://localhost:8080/person
We can couple it with an optional icon, from the free set provided by Font Awesome, to decorate the Menu Item.
Last but not least, we can register the feature as a favourite one. Favourite features are immediately available clicking the Home buttom (top-left).
$ ./gradlew bootRun
Click on the feature to get a (404) Not Found! message. That’s fine, we still haven’t implemented anything.
Controllers and Actions
Look at the URL. It says:
http://localhost:8080/person/index
We have three things here:
-
http://localhost:8080
This is the address and port where we can find our application. -
person
This is the Controller name. A Controller is just a container. It contains Actions. -
index
This is an Action name. An Action is a piece of code that implements some logic.
Even if a Feature is accessed from a specific URL (controller/action
pair), a Feature may be composed by more than one Controller and many, many… many Actions.
-
Controllers are Groovy classes located under the folder:
~/demo/grails-app/controllers/
-
Actions are methods of a Controller class.
Controller Structure
It’s time to feed our newborn creature. We are going to implement a CRUD view, writing the PersonController
, with different actions in order to display a list of people and give the user the ability to create, edit and delete one or more persons.
We create a skeleton first, a controller with just the action firms, so we can focus on the structure.
~/demo/grails-app/controllers/com/example/PersonController.groovy
package com.example
import dueuno.elements.core.ElementsController
import grails.validation.Validateable
class PersonController implements ElementsController {
def index() {
// Displays a list of people
}
def create() {
// Displays a form to input person data
}
def onCreate(PersonValidator val) {
// Creates a new person
}
def edit() {
// Displays a form to edit person data
}
def onEdit(PersonValidator val) {
// Updates a person record
}
def onDelete() {
// Deletes a person
}
}
class PersonValidator implements Validateable {
// Will validate user input
}
Please focus your attention to:
-
The controller class name must be suffixed by the word
Controller
. That’s why our person controller is calledPersonController
(this is a convention of the Grails Framework). -
The person controller implements
ElementsController
. This makes the Dueuno Elements API available to our actions (NOTE: If you use IntelliJ IDEA Ultimate with the Grails plugin you can avoid implementingElementsController
and everything will magically work as expected. Yay!). -
We use a convention to name the actions. When they start with the
on
prefix, they execute some logic in the background. When they don’t, they render a user interface. We are also using a naming standard here, we may change the action names, but for now let’s not add too much complications.
Controller Implementation
~/demo/grails-app/controllers/com/example/PersonController.groovy
package com.example
import dueuno.elements.contents.*
import dueuno.elements.controls.*
import dueuno.elements.core.ElementsController
import grails.validation.Validateable
import java.time.LocalDate
class PersonController implements ElementsController {
static final List personRegistry = [
[id: 1, firstname: 'Gianluca', lastname: 'Sartori', birthdate: LocalDate.of(1979, 6, 24)],
[id: 2, firstname: 'John Luke', lastname: 'Taylor', birthdate: LocalDate.of(1921, 6, 24)],
[id: 3, firstname: 'Juan Lucas', lastname: 'Sastre', birthdate: LocalDate.of(1942, 6, 24)],
]
def index() {
def c = createContent(ContentList)
c.table.with {
columns = [
'firstname',
'lastname',
'birthdate',
]
}
c.table.body = personRegistry
c.table.paginate = personRegistry.size()
display content: c
}
private ContentForm buildForm(Map obj = null) {
def c = obj
? createContent(ContentEdit)
: createContent(ContentCreate)
c.form.with {
validate = PersonValidator
addField(
class: TextField,
id: 'firstname',
)
addField(
class: TextField,
id: 'lastname',
)
addField(
class: DateField,
id: 'birthdate',
)
}
if (obj) {
c.form.values = obj
}
return c
}
def create() {
def c = buildForm()
display content: c, modal: true
}
def onCreate(PersonValidator val) {
if (val.hasErrors()) {
display errors: val
return
}
def last = personRegistry.max { it.id }
personRegistry << [
id: last ? last.id + 1 : 1,
firstname: params.firstname,
lastname: params.lastname,
birthdate: params.birthdate,
]
display action: 'index'
}
def edit() {
def obj = personRegistry.find { it.id == params.id }
def c = buildForm(obj)
display content: c, modal: true
}
def onEdit(PersonValidator val) {
if (val.hasErrors()) {
display errors: val
return
}
def obj = personRegistry.find { it.id == params.id }
obj.firstname = params.firstname
obj.lastname = params.lastname
obj.birthdate = params.birthdate
display action: 'index'
}
def onDelete() {
try {
personRegistry.removeIf { it.id == params.id }
display action: 'index'
} catch (e) {
display exception: e
}
}
}
class PersonValidator implements Validateable {
String firstname
String lastname
LocalDate birthdate
}
There’s a lot of stuff here. The most important things now are:
-
Contents. A Content is the canvas on which we design the UI. To do it we add
Components
andControls
. You can’t see it in the example because we are using preconfigured contents for tables (ContentList
) and forms (ContentCreate
&ContentEdit
) -
The
display()
method. Each action terminates its execution with thedisplay()
method. This is the way we display the UI or route from one action to the other.
For the sake of the demo we’ve implemented the Business Logic within the controller class. This is not something we do. Don’t do it. Ever. Don’t. |
$ ./gradlew bootRun
In the next chapter we are going to see how and where to implement the Business Logic adding a database to this Supa-Dupa-Cool-And-Fool application.
Project Setup
Before we get into the topic of adding Business Logic to our UI, there are some twirls I’d like to address.
Spring Dev Tools
We don’t like executing ./gradlew bootRun
each time we need to launch the application.
We prefer to just save our code and let the system automatically take care of the compilation and application restart so we can just refresh the browser and test our code.
To achieve this we need to add the Spring Dev Tools to our project along with some configuration.
~/demo/build.gradle
and add the following dependency:dependencies {
...
developmentOnly 'org.springframework.boot:spring-boot-devtools'
}
Edit ~/demo/grails-app/conf/application.yml
and add the following properties:
spring:
main:
banner-mode: 'log'
devtools:
restart:
additional-exclude:
- '*.gsp'
- '**/*.gsp'
- '*.gson'
- '**/*.gson'
- 'logback.groovy'
- '*.properties'
From now on when you make a change to one of the application classes it will be automatically reflected in the application.
The application restart is not immediate, it will take some seconds. |
Logging
Logs are important. We want to configure them so we can have logs in our terminal while developing the application. At the same time we want them to be written on a file and archived when they reach a certain size so the disk of our server won’t get filled up.
The following is a default configuration that may make sense in many production environments, but not in all of them.
The following configuration is not a silver bullet. Please take a look at the Logback documentation to configure logging to fit your needs. |
~/demo/grails-app/conf/logback.xml
and copy the following:<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<conversionRule conversionWord="highlightLogLevel" converterClass="dueuno.commons.logs.HighlightLogLevel" />
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %highlightLogLevel(%-5level) [%thread] %-40.40logger{36} : %highlightLogLevel(%msg%n)</pattern>
</encoder>
</appender>
<property resource="application.yml" />
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${info.app.name}/logs/${info.app.name}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${info.app.name}/logs/archive/${info.app.name}.%d{yyyy-MM-dd}.%i.log.zip</fileNamePattern>
<cleanHistoryOnStart>true</cleanHistoryOnStart>
<maxHistory>10</maxHistory>
<maxFileSize>10MB</maxFileSize>
<totalSizeCap>100GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %-40.40logger{36} : %msg%n</pattern>
</encoder>
</appender>
<root level="ERROR">
<appender-ref ref="STDOUT" />
<appender-ref ref="FILE" />
</root>
<logger name="org.springframework.boot.SpringApplication" level="INFO" />
<logger name="dueuno" level="INFO" />
<logger name="demo" level="DEBUG" />
</configuration>
From now on, when you run the application you should see something like this:
10:53:29.113 INFO [restartedMain] o.s.boot.SpringApplication :
> <
> _ <
> | | ELEMENTS <
> __| |_ _ ___ _ _ _ __ ___ <
> / _` | | | |/ _ \ | | | '_ \ / _ \ <
> | (_| | |_| | __/ |_| | | | | (_) | <
> \__,_|\__,_|\___|\__,_|_| |_|\___/ <
> 2024 (c) https://dueuno.com <
> <
> <
Configuring Spring Security Core ...
... finished configuring Spring Security Core
10:53:31.227 INFO [restartedMain] d.e.core.ConnectionSourceService : Installed datasource connection 'DEFAULT: jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE'
10:53:31.265 INFO [restartedMain] d.elements.tenants.TenantService : Creating new tenant 'DEFAULT'...
10:53:31.300 INFO [restartedMain] d.elements.security.SecurityService : Created authority 'ROLE_SECURITY'
10:53:31.306 INFO [restartedMain] d.elements.security.SecurityService : Created authority 'ROLE_USER'
10:53:31.308 INFO [restartedMain] d.elements.security.SecurityService : DEFAULT: Created group 'USERS' with authorities: [ROLE_USER]
10:53:31.313 INFO [restartedMain] d.elements.security.SecurityService : Created authority 'ROLE_ADMIN'
10:53:31.314 INFO [restartedMain] d.elements.security.SecurityService : DEFAULT: Created group 'ADMINS' with authorities: [ROLE_ADMIN]
10:53:31.318 INFO [restartedMain] d.elements.security.SecurityService : Created authority 'ROLE_SUPERADMIN'
10:53:31.319 INFO [restartedMain] d.elements.security.SecurityService : DEFAULT: Created group 'SUPERADMINS' with authorities: [ROLE_SUPERADMIN]
10:53:31.417 INFO [restartedMain] d.elements.security.SecurityService : DEFAULT: Created user 'super' in groups: [SUPERADMINS, USERS]
10:53:31.511 INFO [restartedMain] d.elements.security.SecurityService : DEFAULT: Created user 'admin' in groups: [USERS, ADMINS]
10:53:31.528 INFO [restartedMain] d.elements.tenants.TenantService : --------------------------------------------------------------------------------
10:53:31.528 INFO [restartedMain] d.elements.tenants.TenantService : DEFAULT: INSTALLING PLUGINS...
10:53:31.528 INFO [restartedMain] d.elements.tenants.TenantService : --------------------------------------------------------------------------------
10:53:31.529 INFO [restartedMain] d.elements.core.ApplicationService : DEFAULT: Executing 'dueuno.elements.core.onPluginInstall'...
10:53:31.532 INFO [restartedMain] d.elements.core.ApplicationService : ...done.
10:53:31.532 INFO [restartedMain] d.elements.core.ApplicationService :
10:53:31.544 INFO [restartedMain] d.elements.core.ApplicationService : Available languages [cs, da, de, en, es, fr, it, ja, nb, nl, pl, pt_br, pt_pt, ru, sk, sv, th, zh_cn]
10:53:31.544 INFO [restartedMain] d.elements.core.ApplicationService :
10:53:31.544 INFO [restartedMain] d.elements.core.ApplicationService : --------------------------------------------------------------------------------
10:53:31.544 INFO [restartedMain] d.elements.core.ApplicationService : APPLICATION: STARTING UP...
10:53:31.544 INFO [restartedMain] d.elements.core.ApplicationService : --------------------------------------------------------------------------------
10:53:31.545 INFO [restartedMain] d.elements.core.ApplicationService : Executing 'dueuno.elements.core.beforeInit'...
10:53:31.586 INFO [restartedMain] d.elements.core.ApplicationService : Executing 'com.example.init'...
10:53:31.586 INFO [restartedMain] d.elements.core.ApplicationService : Executing 'dueuno.elements.core.afterInit'...
10:53:31.592 INFO [restartedMain] d.elements.core.ApplicationService : --------------------------------------------------------------------------------
10:53:31.592 INFO [restartedMain] d.elements.core.ApplicationService : APPLICATION: STARTED.
10:53:31.593 INFO [restartedMain] d.elements.core.ApplicationService : --------------------------------------------------------------------------------
10:53:31.593 INFO [restartedMain] d.elements.core.ApplicationService :
Grails application running at http://localhost:8080 in environment: development
Git
Dueuno Elements applications will create a working directory when they first start. It is good practice not to share its content in the Git repository. Also, it’s good practice not to share the IDE’s own project configuration since that is handled by Gradle.
The following configuration can help keeping your Git repository clean.
~/demo/.gitignore
and copy the following:HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Set the following to the application name
/demo/
This is it. We are now ready to move on implementing the Business Logic on a real Database. Are you ready?
First Real Application
Now we are going to finish our first Dueuno Elements application.
Database Setup
We need a real database to store our people data. For the sake of this demo application we are going to just configure an H2 database to persist our data in a file.
~/demo/grails-app/conf/application.yml
dataSource:
url: jdbc:h2:file:./demo/demo;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
For this demo we are going to use GORM, the Object Relational Mapper provided by the Grails Framework, so we need to create a domain class. GORM will map this class to a table in the generated database schema. Querying this class with the GORM API will give us access to the data we are storing.
~/demo/grails-app/domain/com/example/TPerson.groovy
and copy the following code:package com.example
import grails.gorm.MultiTenant
import org.grails.datastore.gorm.GormEntity
import java.time.LocalDate
import java.time.LocalDateTime
class TPerson implements GormEntity, MultiTenant<TPerson> {
LocalDateTime dateCreated
String firstname
String lastname
LocalDate birthdate
}
A couple of things to note:
-
The class name starts with
T
. This is a Dueuno Elements convention to let us immediately realize when we are working on a domain object.T
stands for Table (I know, it’s such an original decision). -
The class
implements GormEntity, MultiTenant<TPerson>
. This means the domain object will be replicated in every new tenant created after the application gets deployed. TheGormEntity
is technically optional, we can omit it, but this way we gain a minimum of support from common IDEs with no need for the Grails Plugin (available only for IntelliJ IDEA).
We are going to mock-up some data to help us with the development of the application. We do it in the onDevInstall
method since it will only get executed while developing the application. It will not be executed when running the application in the production environment.
The mock-up code uses the PersonService
we are going to create in a minute.
~/demo/init/com/example/BootStrap.groovy
package com.example
import dueuno.elements.core.ApplicationService
import java.time.LocalDate
class BootStrap {
ApplicationService applicationService
PersonService personService
def init = { servletContext ->
applicationService.onDevInstall { String tenantId ->
personService.create(
firstname: 'Felicity',
lastname: 'Green',
birthdate: LocalDate.of(2021, 1, 2),
)
personService.create(
firstname: 'Grace',
lastname: 'Blue',
birthdate: LocalDate.of(2021, 2, 1),
)
personService.create(
firstname: 'Joy',
lastname: 'Red',
birthdate: LocalDate.of(2021, 12, 21),
)
}
applicationService.init {
registerFeature(
controller: 'person',
icon: 'fa-user',
favourite: true,
)
}
}
def destroy = {
}
}
GORM will generate a database table for us. Let’s see:
$ ./gradlew bootRun
Login as Superuser (super/super
).
Click on “Connection Sources” in the Main Menu (on the left) and copy the connection URL:
Click on the User Menu (top-right) and select "[DEV] H2 Console":
Copy the URL and click “Connect”. You should see the T_PERSON
table:
Table names starts with T_ to avoid conflicting with database keywords or other existent table names. Yes, it happens. Yes, there are common theoretical and technical ways to handle such cases, but they all give responsibility to the developer. Yes, we don´t want that responsibility to be on the developer hands.
|
Business Logic
We don’t like writing the Business Logic in controllers. It is an anti-pattern. Dueuno Elements uses controllers as a way to implement the User Interface.
Fortunately the Grails Framework comes into rescue giving us a convention to implement our logic. We just need to implement a Service.
Services are classes located into the /grails-app/services
folder whose name is suffixed by Service
. So we just need to create our PersonService
class.
~/demo/grails-app/services/com/example/PersonService.groovy
and copy the code below.package com.example
import dueuno.elements.exceptions.ArgsException
import grails.gorm.DetachedCriteria
import grails.gorm.multitenancy.CurrentTenant
import javax.annotation.PostConstruct
@CurrentTenant
class PersonService {
@PostConstruct
void init() {
// Executes only once when the application starts
}
private DetachedCriteria<TPerson> buildQuery(Map filterParams) {
def query = TPerson.where {}
if (filterParams.containsKey('id')) query = query.where { id == filterParams.id }
if (filterParams.containsKey('birthdate')) query = query.where { birthdate == filterParams.birthdate }
if (filterParams.find) {
String search = filterParams.find.replaceAll('\\*', '%')
query = query.where { 1 == 1
|| firstname =~ "%${search}%"
|| lastname =~ "%${search}%"
}
}
// Add additional filters here
return query
}
TPerson get(Serializable id) {
// Add any relationships here (Eg. references to other DomainObjects or hasMany)
Map fetch = [
relationshipName: 'join',
]
return buildQuery(id: id).get(fetch: fetch)
}
List<TPerson> list(Map filterParams = [:], Map fetchParams = [:]) {
if (!fetchParams.sort) fetchParams.sort = [dateCreated: 'asc']
// Add single-sided relationships here (Eg. references to other DomainObjects)
// DO NOT add hasMany relationships, you are going to have troubles with pagination
fetchParams.fetch = [
relationshipName: 'join',
]
def query = buildQuery(filterParams)
return query.list(fetchParams)
}
Integer count(Map filterParams = [:]) {
def query = buildQuery(filterParams)
return query.count()
}
TPerson create(Map args = [:]) {
if (args.failOnError == null) args.failOnError = false
TPerson obj = new TPerson(args)
obj.save(flush: true, failOnError: args.failOnError)
return obj
}
TPerson update(Map args = [:]) {
Serializable id = ArgsException.requireArgument(args, 'id')
if (args.failOnError == null) args.failOnError = false
TPerson obj = get(id)
obj.properties = args
obj.save(flush: true, failOnError: args.failOnError)
return obj
}
void delete(Serializable id) {
TPerson obj = get(id)
obj.delete(flush: true, failOnError: true)
}
}
As you can see we have implemented the methods we need to create a CRUD:
-
get()
returns a single record by its ID -
list()
returns a set of records accepting some filters and some fetch parameters to control sorting and pagination -
count()
returns the number of records depending on the used filters -
create()
inserts a new record in the database -
update()
updates and existing record in the database -
delete()
deletes a single record by its ID
In this case we are using GORM, the Object Relational Mapper provided by the Grails Framework, but we could have implemented our service in any other way. Plain SQL or Web Services calls would have been fine. |
As long as the methods return Objects, List of Objects or List of Maps we are fine. |
Now, let’s put this all together.
User Interface
We already have our PersonController
, we just need to adapt it so it can use the new PersonService
.
We are also adding some filters and sorting so the final user can search by name and birth date.
~/demo/grails-app/controllers/com/example/PersonController.groovy
package com.example
import dueuno.elements.contents.*
import dueuno.elements.controls.*
import dueuno.elements.core.ElementsController
import grails.validation.Validateable
import java.time.LocalDate
class PersonController implements ElementsController {
PersonService personService
def index() {
def c = createContent(ContentList)
c.table.with {
filters.with {
addField(
class: DateField,
id: 'birthdate',
cols: 3,
)
addField(
class: TextField,
id: 'find',
cols: 9,
)
}
sortable = [
lastname: 'asc',
]
columns = [
'firstname',
'lastname',
'birthdate',
]
}
c.table.body = personService.list(c.table.filterParams, c.table.fetchParams)
c.table.paginate = personService.count(c.table.filterParams)
display content: c
}
private ContentForm buildForm(TPerson obj = null) {
def c = obj
? createContent(ContentEdit)
: createContent(ContentCreate)
c.form.with {
validate = TPerson
addField(
class: TextField,
id: 'firstname',
)
addField(
class: TextField,
id: 'lastname',
)
addField(
class: DateField,
id: 'birthdate',
)
}
if (obj) {
c.form.values = obj
}
return c
}
def create() {
def c = buildForm()
display content: c, modal: true
}
def onCreate() {
def obj = personService.create(params)
if (obj.hasErrors()) {
display errors: obj
return
}
display action: 'index'
}
def edit() {
def obj = personService.get(params.id)
def c = buildForm(obj)
display content: c, modal: true
}
def onEdit() {
def obj = personService.update(params)
if (obj.hasErrors()) {
display errors: obj
return
}
display action: 'index'
}
def onDelete() {
try {
personService.delete(params.id)
display action: 'index'
} catch (e) {
display exception: e
}
}
}
To finish the UI we implement the English and Italian translations deleting all the others.
~/demo/grails/app/i18n/messages.properties
app.name=People Registry
shell.person=People
shell.person.help=Manage the People Registry
person.index.header.title=People
person.create.header.title=New Person
person.edit.header.title=Person
person.filters.birthdate=Birthdate
person.filters.find=Find
person.firstname=Firstname
person.lastname=Lastname
person.birthdate=Birth Date
~/demo/grails/app/i18n/messages_it.properties
app.name=Registro persone
shell.person=Persone
shell.person.help=Gestisci il registro persone
person.index.header.title=Persone
person.create.header.title=Nuova persona
person.edit.header.title=Persona
person.filters.birthdate=Nato il
person.filters.find=Trova
person.firstname=Nome
person.lastname=Cognome
person.birthdate=Nato il
Delete all the others .properties
files in ~/demo/grails-app/i18n/
Now, with a bit of luck, we should be able to run our first complete Dueuno Elements application:
$ ./gradlew bootRun
This chapter closes the first round on the Dueuno Elements basics.
In the next chapter we are going to explore the Tenant Properties to configure the application to reflect our customer’s brand.
Application Branding
Companies invest in Brand Identity. They also invest in Employer Branding, which basically means selling your brand to your employees. This is not just a single activity, but you can bet your client is going to ask you to have their logo on the application their employees use daily.
Logo
Every company has a logo. For the sake of this demo application we are asking Google to give us one.
It must be a .png image so we can have transparency. We don’t accept any other format.
|
We also need a background for the login screen. Let’s ask Google this one too.
It must be a .jpg image
|
~/demo/src/main/resources/deploy/public/brand
then copy and rename the files we just downloaded. The logo.png
file can be duplicated to create the other .png
files./demo/src/main/resources/deploy/public/brand
- login-logo.png
- login-background.jpg
- logo.png
- favicon.png
- appicon.png
From now on, each time we deploy the application for the first time, those files will be extracted and added into the following folder: ~/demo/demo/tenants/DEFAULT/public/brand
To see the results of our effort we need to remove the default branding already installed under the ~/demo/demo
folder.
Delete the ~/demo/demo folder
|
$ ./gradlew bootRun
We should see something like this. It looks cool but… we need to fix the colors.
Colors
Dueuno Elements applications can be configured from the Tenant Properties (System Administration → Settings). That means each tenant can have a different setup.
Login as admin/admin
, click on “System Administration → Settings” on the Main Menu (on the left) and search for “color”.
Each Dueuno Elements application can be configured with three different colors:
-
PRIMARY
The color used for primary actions. Primary actions are main buttons, buttons that “create” stuff or buttons that we want the user to see as different from the others because they do something relevant in the context they are displayed. -
SECONDARY
It’s the color used for all the other buttons, those buttons that do normal stuff. -
TERTIARY
It’s the color used for the Content and the Form Fields.
Each color has three values:
-
BACKGROUND_COLOR
The color used for the background of the element. -
BACKGROUND_COLOR_ALPHA
The index of transparency. This is used to shade the primary color when its flat version is not appropriate. -
TEXT_COLOR
The color used for texts.
To configure the main colors:
-
Set the
PRIMARY_BACKGROUND_COLOR
to#018B84
-
Set the
PRIMARY_BACKGROUND_COLOR_ALPHA
to0.25
Logout and login again |
The application should now look like this:
Since we don’t want to manually set the colors every time we install the application, we can use the onInstall
method to set the Tenant Properties like the follow.
We are also adding some copy under the login form with a link to our website to let the users know who we are.
~/demo/grails-app/init/BootStrap.groovy
adding the following lines:class BootStrap {
ApplicationService applicationService
TenantPropertyService tenantPropertyService
...
def init = { servletContext ->
applicationService.onInstall { String tenantId ->
tenantPropertyService.setString('PRIMARY_BACKGROUND_COLOR', '#018B84')
tenantPropertyService.setNumber('PRIMARY_BACKGROUND_COLOR_ALPHA', 0.25)
tenantPropertyService.setString('LOGIN_COPY', '2024 © <a href="https://my-company.com" target="_blank">My Company</a><br/>Made in Italy')
}
...
}
Logs
We want everybody, not just the end users, be aware of the brand! That’s why we want to add a banner to our logs.
This way, every time the application is restarted, we will see an ASCII Art representation of our company name. You can spend hours creating the perfect ASCII Art. I am lazy and will use an ASCII Text generator I found here: https://patorjk.com/software/taag
~/demo/src/resources/banner.txt
and copy the generated text leaving a blank line on the top __ __ ____
| \/ |_ _ / ___|___ _ __ ___ _ __ __ _ _ __ _ _
| |\/| | | | | | | / _ \| '_ ` _ \| '_ \ / _` | '_ \| | | |
| | | | |_| | | |__| (_) | | | | | | |_) | (_| | | | | |_| |
|_| |_|\__, | \____\___/|_| |_| |_| .__/ \__,_|_| |_|\__, |
|___/ |_| |___/
$ ./gradlew bootRun
18:47:30.769 INFO [restartedMain] o.s.boot.SpringApplication :
__ __ ____
| \/ |_ _ / ___|___ _ __ ___ _ __ __ _ _ __ _ _
| |\/| | | | | | | / _ \| '_ ` _ \| '_ \ / _` | '_ \| | | |
| | | | |_| | | |__| (_) | | | | | | |_) | (_| | | | | |_| |
|_| |_|\__, | \____\___/|_| |_| |_| .__/ \__,_|_| |_|\__, |
|___/ |_| |___/
Configuring Spring Security Core ...
... finished configuring Spring Security Core
18:47:36.979 INFO [restartedMain] d.elements.core.ApplicationService : Available languages [en, it]
18:47:36.982 INFO [restartedMain] d.elements.core.ApplicationService :
18:47:36.982 INFO [restartedMain] d.elements.core.ApplicationService : --------------------------------------------------------------------------------
18:47:36.982 INFO [restartedMain] d.elements.core.ApplicationService : APPLICATION: STARTING UP...
18:47:36.982 INFO [restartedMain] d.elements.core.ApplicationService : --------------------------------------------------------------------------------
18:47:36.987 INFO [restartedMain] d.elements.core.ApplicationService : Executing 'dueuno.elements.core.beforeInit'...
18:47:37.029 INFO [restartedMain] d.elements.core.ApplicationService : Executing 'com.example.init'...
18:47:37.030 INFO [restartedMain] d.elements.core.ApplicationService : Executing 'dueuno.elements.core.afterInit'...
18:47:37.042 INFO [restartedMain] d.elements.core.ApplicationService : --------------------------------------------------------------------------------
18:47:37.042 INFO [restartedMain] d.elements.core.ApplicationService : APPLICATION: STARTED.
18:47:37.042 INFO [restartedMain] d.elements.core.ApplicationService : --------------------------------------------------------------------------------
18:47:37.042 INFO [restartedMain] d.elements.core.ApplicationService :
Grails application running at http://localhost:8080 in environment: development
We made our client happy. That means we are happy too. Do we need anything more?
In the next chapter we are going to see what happens when we use a Dueuno Elements application from a Desktop Computer, from a Tablet and from a Mobile Phone.
Responsive Design
We are going to see what the User Experience of a Dueuno Elements application looks like on different devices.
Getting ready
Before we go ahead, let’s complicate a little bit the person form so we can see how it changes with different resolutions.
We first add some optional fields to the domain object.
~/demo/grails-app/domain/com/example/TPerson.groovy
package com.example
import grails.gorm.MultiTenant
import org.grails.datastore.gorm.GormEntity
import java.time.LocalDate
import java.time.LocalDateTime
class TPerson implements GormEntity, MultiTenant<TPerson> {
LocalDateTime dateCreated
String firstname
String lastname
LocalDate birthdate
String address
String city
String postCode
String state
String country
static constraints = {
address nullable: true
city nullable: true
postCode nullable: true
state nullable: true
country nullable: true
}
}
We then update the table view to display the new columns.
index()
in ~/demo/grails-app/controllers/com/example/PersonController.groovy
def index() {
def c = createContent(ContentList)
c.table.with {
filters.with {
addField(
class: DateField,
id: 'birthdate',
cols: 3,
)
addField(
class: TextField,
id: 'find',
cols: 9,
)
}
sortable = [
lastname: 'asc',
]
columns = [
'firstname',
'lastname',
'birthdate',
'address',
'city',
'postCode',
'state',
'country',
]
}
c.table.body = personService.list(c.table.filterParams, c.table.fetchParams)
c.table.paginate = personService.count(c.table.filterParams)
display content: c
}
To layout the fields in a Dueuno Elements Form
we use the cols
parameter. It represents the number of columns the field will occupy in a line. Since Dueuno Elements uses Bootstrap under the hood, we use its Grid System to decide how to layout fields in a form.
In short, we have 12 invisible columns for each row. Each field can occupy one single column or any number of columns up to 12. It’s up to us to decide what layout best fits our needs. |
buildForm()
in ~/demo/grails-app/controllers/com/example/PersonController.groovy
to add the new fields to the formprivate ContentForm buildForm(TPerson obj = null) {
def c = obj
? createContent(ContentEdit)
: createContent(ContentCreate)
c.form.with {
validate = TPerson
addField(
class: TextField,
id: 'firstname',
cols: 6,
)
addField(
class: TextField,
id: 'lastname',
cols: 6,
)
addField(
class: DateField,
id: 'birthdate',
cols: 6,
)
addField(
class: Separator,
id: 's1',
icon: 'fa-earth-americas',
cols: 12,
)
addField(
class: TextField,
id: 'address',
cols: 12,
)
addField(
class: TextField,
id: 'city',
cols: 6,
)
addField(
class: TextField,
id: 'postCode',
cols: 6,
)
addField(
class: TextField,
id: 'state',
cols: 6,
)
addField(
class: TextField,
id: 'country',
cols: 6,
)
}
if (obj) {
c.form.values = obj
}
return c
}
Finally, since we have confgiured the application to work with an H2 database on a file, we can just delete the application demo
folder and let the application reinstall from scratch and recreate the database.
Delete the ~/demo/demo folder
|
$ ./gradlew bootRun
We are now ready to watch some homemade videos. I know you like homemade videos…
12" Laptop
14" Touchscreen Laptop
Apple iPad
Apple iPhone
Meta Quest 2
Conclusions
As we have seen, Dueuno Elements applications work out of the box on different devices. They are not optimized for any one of them, but hey, they work without you having to worry about it.
To answer the question you have in your mind right now: yes, we can optimize them but that requires building specific components. It will cost more, of course.
In the next chapter we are going to create a One-To-Many relationship on our database and see how we can manage it on the screen.
One-to-Many Relationships
Let’s give a job to our people, linking them to a Company
. We are going to surf bottom-up from the Domain (data), to the Services (logic), the Controllers (GUI) ending up registering our new feature to the users.
The Domain
We start from the bottom, the Domain. In our demo application it’s implemented as a set of GORM entities: Groovy classes that represents, and are mapped to, database tables.
~/demo/grails-app/domain/com/example/TCompany.groovy
package com.example
import grails.gorm.MultiTenant
import org.grails.datastore.gorm.GormEntity
import java.time.LocalDateTime
class TCompany implements GormEntity, MultiTenant<TCompany> {
LocalDateTime dateCreated
String name
static hasMany = [
emplyees: TPerson,
]
}
~/demo/grails-app/domain/com/example/TPerson.groovy
package com.example
import grails.gorm.MultiTenant
import org.grails.datastore.gorm.GormEntity
import java.time.LocalDate
import java.time.LocalDateTime
class TPerson implements GormEntity, MultiTenant<TPerson> {
LocalDateTime dateCreated
String firstname
String lastname
LocalDate birthdate
String address
String city
String postCode
String state
String country
TCompany company
static belongsTo = [
company: TCompany,
]
static constraints = {
address nullable: true
city nullable: true
postCode nullable: true
state nullable: true
country nullable: true
}
}
We’ve associated the two domain classes as follow:
-
TPerson
belongs to aTCompany
It means that each record of a person will have a column (company_id
) referencing its company. -
TCompany
has manyTPerson
It means that each company will be able to reference its employees navigating through them
To have a better understanding of the code above please refer to the GORM Hibernate documentation.
The Services
The CompanyService
will hold the logic to query and operate with the TCompany
domain object. It is almost the same as the PersonService
, in fact I’ve just duplicated it replacing TPerson
with TCompany
and added some more filters to the main query.
~/demo/grails-app/services/com/example/CompanyService.groovy
package com.example
import dueuno.elements.exceptions.ArgsException
import grails.gorm.DetachedCriteria
import grails.gorm.multitenancy.CurrentTenant
import javax.annotation.PostConstruct
@CurrentTenant
class CompanyService {
@PostConstruct
void init() {
// Executes only once when the application starts
}
private DetachedCriteria<TCompany> buildQuery(Map filterParams) {
def query = TCompany.where {}
if (filterParams.containsKey('id')) query = query.where { id == filterParams.id }
if (filterParams.find) {
String search = filterParams.find.replaceAll('\\*', '%')
query = query.where { 1 == 1
|| name =~ "%${search}%"
}
}
// Add additional filters here
return query
}
TCompany get(Serializable id) {
// Add any relationships here (Eg. references to other DomainObjects or hasMany)
Map fetch = [
relationshipName: 'join',
]
return buildQuery(id: id).get(fetch: fetch)
}
List<TCompany> list(Map filterParams = [:], Map fetchParams = [:]) {
if (!fetchParams.sort) fetchParams.sort = [dateCreated: 'asc']
// Add single-sided relationships here (Eg. references to other DomainObjects)
// DO NOT add hasMany relationships, you are going to have troubles with pagination
fetchParams.fetch = [
relationshipName: 'join',
]
def query = buildQuery(filterParams)
return query.list(fetchParams)
}
Integer count(Map filterParams = [:]) {
def query = buildQuery(filterParams)
return query.count()
}
TCompany create(Map args = [:]) {
if (args.failOnError == null) args.failOnError = false
TCompany obj = new TCompany(args)
obj.save(flush: true, failOnError: args.failOnError)
return obj
}
TCompany update(Map args = [:]) {
Serializable id = ArgsException.requireArgument(args, 'id')
if (args.failOnError == null) args.failOnError = false
TCompany obj = get(id)
obj.properties = args
obj.save(flush: true, failOnError: args.failOnError)
return obj
}
void delete(Serializable id) {
TCompany obj = get(id)
obj.delete(flush: true, failOnError: true)
}
}
~/demo/grails-app/services/com/example/PersonService.groovy
package com.example
import dueuno.elements.exceptions.ArgsException
import grails.gorm.DetachedCriteria
import grails.gorm.multitenancy.CurrentTenant
import javax.annotation.PostConstruct
@CurrentTenant
class PersonService {
@PostConstruct
void init() {
// Executes only once when the application starts
}
private DetachedCriteria<TPerson> buildQuery(Map filterParams) {
def query = TPerson.where {}
if (filterParams.containsKey('id')) query = query.where { id == filterParams.id }
if (filterParams.containsKey('lastname')) query = query.where { lastname == filterParams.lastname }
if (filterParams.containsKey('birthdate')) query = query.where { birthdate == filterParams.birthdate }
if (filterParams.containsKey('company')) query = query.where { company.id == filterParams.company }
if (filterParams.find) {
String search = filterParams.find.replaceAll('\\*', '%')
query = query.where { 1 == 1
|| firstname =~ "%${search}%"
|| lastname =~ "%${search}%"
}
}
// Add additional filters here
return query
}
TPerson get(Serializable id) {
// Add any relationships here (Eg. references to other DomainObjects or hasMany)
Map fetch = [
company: 'join',
]
return buildQuery(id: id).get(fetch: fetch)
}
List<TPerson> list(Map filterParams = [:], Map fetchParams = [:]) {
if (!fetchParams.sort) fetchParams.sort = [dateCreated: 'asc']
// Add single-sided relationships here (Eg. references to other DomainObjects)
// DO NOT add hasMany relationships, you are going to have troubles with pagination
fetchParams.fetch = [
company: 'join',
]
def query = buildQuery(filterParams)
return query.list(fetchParams)
}
Integer count(Map filterParams = [:]) {
def query = buildQuery(filterParams)
return query.count()
}
TPerson create(Map args = [:]) {
if (args.failOnError == null) args.failOnError = false
TPerson obj = new TPerson(args)
obj.save(flush: true, failOnError: args.failOnError)
return obj
}
TPerson update(Map args = [:]) {
Serializable id = ArgsException.requireArgument(args, 'id')
if (args.failOnError == null) args.failOnError = false
TPerson obj = get(id)
obj.properties = args
obj.save(flush: true, failOnError: args.failOnError)
return obj
}
void delete(Serializable id) {
TPerson obj = get(id)
obj.delete(flush: true, failOnError: true)
}
}
The Controllers
The CompanyController
edit()
action will display the name of the company and a list of its employees. To do that we need to add a Table
component to the Content
.
The CompanyController
is basically the same as the PersonController
, in fact we’ve just duplicated it replacing TPerson
with TCompany
, adding a reference to the CompanyService
(injected by Grails) and changing the buildForm()
method to add the Table
.
~/demo/grails-app/controllers/com/example/CompanyController.groovy
package com.example
import dueuno.elements.components.Table
import dueuno.elements.contents.ContentCreate
import dueuno.elements.contents.ContentEdit
import dueuno.elements.contents.ContentList
import dueuno.elements.controls.TextField
import dueuno.elements.core.ElementsController
import dueuno.elements.style.TextDefault
class CompanyController implements ElementsController {
PersonService personService
CompanyService companyService
def index() {
def c = createContent(ContentList)
c.table.with {
filters.with {
addField(
class: TextField,
id: 'find',
label: TextDefault.FIND,
)
}
sortable = [
name: 'asc',
]
columns = [
'name',
]
}
c.table.body = companyService.list(c.table.filterParams, c.table.fetchParams)
c.table.paginate = companyService.count(c.table.filterParams)
display content: c
}
private ContentForm buildForm(TCompany obj = null) {
def c = obj
? createContent(ContentEdit)
: createContent(ContentCreate)
c.form.with {
validate = TCompany
addField(
class: TextField,
id: 'name',
)
}
if (obj) {
c.form.values = obj
def table = c.addComponent(Table)
table.with {
rowActions = false
rowHighlight = false
columns = [
'firstname',
'lastname',
'country',
]
body = personService.list(company: obj.id)
}
}
return c
}
def create() {
def c = buildForm()
display content: c, modal: true
}
def onCreate() {
def obj = companyService.create(params)
if (obj.hasErrors()) {
display errors: obj
return
}
display action: 'index'
}
def edit() {
def obj = companyService.get(params.id)
def c = buildForm(obj)
display content: c, modal: true
}
def onEdit() {
def obj = companyService.update(params)
if (obj.hasErrors()) {
display errors: obj
return
}
display action: 'index'
}
def onDelete() {
try {
companyService.delete(params.id)
display action: 'index'
} catch (e) {
display exception: e
}
}
}
We need to add the company
field to the PersonController
table and form as well.
To be able to actually see something meaningful in the Select
control listing all the companies, we need to register a PrettyPrinter
. This is a templating mechanism we use to render a domain object as a String
. We are going to register it in the next paragraph along with the new feature.
~/demo/grails-app/controllers/com.example/PersonController.groovy
package com.example
import dueuno.elements.components.Separator
import dueuno.elements.contents.ContentCreate
import dueuno.elements.contents.ContentEdit
import dueuno.elements.contents.ContentList
import dueuno.elements.controls.DateField
import dueuno.elements.controls.Select
import dueuno.elements.controls.TextField
import dueuno.elements.core.ElementsController
import dueuno.elements.style.TextDefault
class PersonController implements ElementsController {
PersonService personService
CompanyService companyService
def index() {
def c = createContent(ContentList)
c.table.with {
filters.with {
addField(
class: DateField,
id: 'birthdate',
cols: 3,
)
addField(
class: TextField,
id: 'find',
label: TextDefault.FIND,
cols: 9,
)
}
sortable = [
lastname: 'asc',
]
columns = [
'company',
'firstname',
'lastname',
'birthdate',
'address',
'city',
'postCode',
'state',
'country',
]
}
c.table.body = personService.list(c.table.filterParams, c.table.fetchParams)
c.table.paginate = personService.count(c.table.filterParams)
display content: c
}
private ContentForm buildForm(TPerson obj = null) {
def c = obj
? createContent(ContentEdit)
: createContent(ContentCreate)
c.form.with {
validate = TPerson
addField(
class: Select,
id: 'company',
optionsFromRecordset: companyService.list(),
cols: 12,
)
addField(
class: TextField,
id: 'firstname',
cols: 6,
)
addField(
class: TextField,
id: 'lastname',
cols: 6,
)
addField(
class: DateField,
id: 'birthdate',
cols: 6,
)
addField(
class: Separator,
id: 's1',
icon: 'fa-earth-americas',
cols: 12,
)
addField(
class: TextField,
id: 'address',
cols: 12,
)
addField(
class: TextField,
id: 'city',
cols: 6,
)
addField(
class: TextField,
id: 'postCode',
cols: 6,
)
addField(
class: TextField,
id: 'state',
cols: 6,
)
addField(
class: TextField,
id: 'country',
cols: 6,
)
}
if (obj) {
c.form.values = obj
}
return c
}
def create() {
def c = buildForm()
display content: c, modal: true
}
def onCreate() {
def obj = personService.create(params)
if (obj.hasErrors()) {
display errors: obj
return
}
display action: 'index'
}
def edit() {
def obj = personService.get(params.id)
def c = buildForm(obj)
display content: c, modal: true
}
def onEdit() {
def obj = personService.update(params)
if (obj.hasErrors()) {
display errors: obj
return
}
display action: 'index'
}
def onDelete() {
try {
personService.delete(params.id)
display action: 'index'
} catch (e) {
display exception: e
}
}
}
The Features
We need to let the users access the newly created CompanyController
. We do so by registering a new feature. Since we are here, we are going to mock-up a couple of companies too, so we can test the application.
~/demo/grails-app/init/com/example/BootStrap.groovy
package com.example
import dueuno.elements.core.ApplicationService
import dueuno.elements.tenants.TenantPropertyService
import java.time.LocalDate
class BootStrap {
ApplicationService applicationService
TenantPropertyService tenantPropertyService
PersonService personService
CompanyService companyService
def init = { servletContext ->
applicationService.onInstall { String tenantId ->
tenantPropertyService.setString('PRIMARY_BACKGROUND_COLOR', '#018B84')
tenantPropertyService.setNumber('PRIMARY_BACKGROUND_COLOR_ALPHA', 0.25)
tenantPropertyService.setString('LOGIN_COPY', '2024 © <a href="https://my-company.com" target="_blank">My Company</a><br/>Made in Italy')
}
applicationService.onDevInstall { String tenantId ->
def yourCompany = companyService.create(name: 'Your Company', failOnError: true)
def theirCompany = companyService.create(name: 'Their Company', failOnError: true)
personService.create(
company: yourCompany,
firstname: 'Felicity',
lastname: 'Green',
birthdate: LocalDate.of(2021, 1, 2),
failOnError: true,
)
personService.create(
company: yourCompany,
firstname: 'Grace',
lastname: 'Blue',
birthdate: LocalDate.of(2021, 2, 1),
failOnError: true,
)
personService.create(
company: theirCompany,
firstname: 'Joy',
lastname: 'Red',
birthdate: LocalDate.of(2021, 12, 21),
failOnError: true,
)
}
applicationService.init {
registerPrettyPrinter(TCompany, '${it.name}')
registerFeature(
controller: 'person',
icon: 'fa-user',
favourite: true,
)
registerFeature(
controller: 'company',
icon: 'fa-briefcase',
)
}
}
def destroy = {
}
}
The registerPrettyPrinter()
call configures a renderer for the TCompany
objects. In the string template (see Groovy String Template Engines) we can reference any TCompany
class property. The it
symbol will references an instance of a TCompany
object.
Delete the ~/demo/demo folder
|
$ ./gradlew bootRun
Table
One of the most important components in a Dueuno Elements application is the Table
component. Its main purpose is displaying data in columns and rows. Usually, each row displays a record on a database, but it can display anything from in-memory data to the results of a web service call.
The user can interact by clicking on one of the buttons displayed on the left side of each row. By default the Table
component creates the edit
and delete
buttons. We call them actions. Actions can be customized removing or adding as many as we need. Each row can have different actions depending on the logged-in user or a specific state of the record.
Sample CRUD
To talk about the Table
component we are going to create a new CRUD to manage our books. Let’s create what we need:
-
A
TBook
domain class to persist our books -
A
BookService
to implement the Business Logic -
A
BookController
to implement the User Interface
~/demo/grails-app/domain/com/example/TBook.groovy
package com.example
import grails.gorm.MultiTenant
import org.grails.datastore.gorm.GormEntity
import java.time.LocalDateTime
class TBook implements GormEntity, MultiTenant<TBook> {
String title
String author
String description
Boolean taken
static constraints = {
}
}
~/demo/grails-app/services/com/example/BookService.groovy
package com.example
import dueuno.elements.exceptions.ArgsException
import grails.gorm.DetachedCriteria
import grails.gorm.multitenancy.CurrentTenant
import javax.annotation.PostConstruct
@CurrentTenant
class BookService {
@PostConstruct
void init() {
// Executes only once when the application starts
}
private DetachedCriteria<TBook> buildQuery(Map filterParams) {
def query = TBook.where {}
if (filterParams.containsKey('id')) query = query.where { id == filterParams.id }
if (filterParams.find) {
String search = filterParams.find.replaceAll('\\*', '%')
query = query.where { 1 == 1
|| title =~ "%${search}%"
|| author =~ "%${search}%"
|| description =~ "%${search}%"
}
}
// Add additional filters here
return query
}
TBook get(Serializable id) {
// Add any relationships here (Eg. references to other DomainObjects or hasMany)
Map fetch = [
relationshipName: 'join',
]
return buildQuery(id: id).get(fetch: fetch)
}
List<TBook> list(Map filterParams = [:], Map fetchParams = [:]) {
if (!fetchParams.sort) fetchParams.sort = [dateCreated: 'asc']
// Add single-sided relationships here (Eg. references to other DomainObjects)
// DO NOT add hasMany relationships, you are going to have troubles with pagination
fetchParams.fetch = [
relationshipName: 'join',
]
def query = buildQuery(filterParams)
return query.list(fetchParams)
}
Integer count(Map filterParams = [:]) {
def query = buildQuery(filterParams)
return query.count()
}
TBook create(Map args = [:]) {
if (args.failOnError == null) args.failOnError = false
TBook obj = new TBook(args)
obj.save(flush: true, failOnError: args.failOnError)
return obj
}
TBook update(Map args = [:]) {
Serializable id = ArgsException.requireArgument(args, 'id')
if (args.failOnError == null) args.failOnError = false
TBook obj = get(id)
obj.properties = args
obj.save(flush: true, failOnError: args.failOnError)
return obj
}
void delete(Serializable id) {
TBook obj = get(id)
obj.delete(flush: true, failOnError: true)
}
void take(Serializable id) {
update(id: id, taken: true)
}
void giveBack(Serializable id) {
update(id: id, taken: false)
}
}
~/demo/grails-app/controllers/com/example/BookController.groovy
package com.example
import dueuno.elements.components.TableRow
import dueuno.elements.contents.ContentCreate
import dueuno.elements.contents.ContentEdit
import dueuno.elements.contents.ContentForm
import dueuno.elements.contents.ContentList
import dueuno.elements.controls.Checkbox
import dueuno.elements.controls.TextField
import dueuno.elements.core.ElementsController
import dueuno.elements.style.TextDefault
class BookController implements ElementsController {
BookService bookService
def index() {
def c = createContent(ContentList)
c.table.with {
filters.with {
fold = false
addField(
class: TextField,
id: 'find',
label: TextDefault.FIND,
)
}
sortable = [
title: 'asc',
author: 'asc',
]
columns = [
'title',
'author',
'description',
'taken',
]
groupActions.addAction(
action: 'onGiveBackAll',
submit: 'table',
icon: 'fa-regular fa-bookmark',
confirmMessage: 'book.index.confirm.give.back.all',
)
body.eachRow { TableRow row, Map values ->
// Do not execute slow operations here to avoid slowing down the table rendering
if (values.taken) {
row.actions.addAction(action: 'onGiveBack', icon: 'fa-regular fa-bookmark')
} else {
row.actions.addAction(action: 'onTake', icon: 'fa-solid fa-bookmark')
}
}
}
c.table.body = bookService.list(c.table.filterParams, c.table.fetchParams)
c.table.paginate = bookService.count(c.table.filterParams)
display content: c
}
private ContentForm buildForm(TBook obj = null) {
def c = obj
? createContent(ContentEdit)
: createContent(ContentCreate)
c.form.with {
validate = TBook
addField(
class: TextField,
id: 'title',
)
addField(
class: TextField,
id: 'author',
)
addField(
class: TextField,
id: 'description',
)
addField(
class: Checkbox,
id: 'taken',
)
}
if (obj) {
c.form.values = obj
}
return c
}
def onTake() {
bookService.take(params.id)
display action: 'index'
}
def onGiveBack() {
bookService.giveBack(params.id)
display action: 'index'
}
def onGiveBackAll() {
List<Long> ids = params.rows.findAll { it.selected }*.id
for (id in ids) {
bookService.giveBack(id)
}
display action: 'index'
}
def create() {
def c = buildForm()
display content: c, modal: true
}
def onCreate() {
def obj = bookService.create(params)
if (obj.hasErrors()) {
display errors: obj
return
}
display action: 'index'
}
def edit() {
def obj = bookService.get(params.id)
def c = buildForm(obj)
display content: c, modal: true
}
def onEdit() {
def obj = bookService.update(params)
if (obj.hasErrors()) {
display errors: obj
return
}
display action: 'index'
}
def onDelete() {
try {
bookService.delete(params.id)
display action: 'index'
} catch (e) {
display exception: e
}
}
}
~/demo/grails-app/init/com/example/BootStrap.groovy
def init = { servletContext ->
...
applicationService.onDevInstall { String tenantId ->
...
bookService.create(
title: 'The Teachings of Don Juan',
author: 'Carlos Castaneda',
description: 'This is a nice fictional book',
borrowed: false,
failOnError: true,
)
bookService.create(
title: 'The Antipodes of the Mind',
author: 'Benny Shanon',
description: 'This is a nice scientific book',
borrowed: false,
failOnError: true,
)
}
applicationService.init {
...
registerFeature(
controller: 'book',
icon: 'fa-book',
)
}
}
Delete the ~/demo/demo folder
|
$ ./gradlew bootRun
Loading Data
What can we load a table with?
List of Lists
Loading a table with a List of Lists is possible, the sequence will determine how each column will be mapped to each value. There is no hard relationship between the displayed column name and the value.
For this reason we suggest using List of Maps instead.
c.table.columns = [
'title',
'author',
'description',
]
c.table.body = [
['The Teachings of Don Juan', 'Carlos Castaneda', 'This is a nice fictional book'],
['The Antipodes of the Mind', 'Benny Shanon', 'This is a nice scientific book'],
]
List of Maps
We can load a table with a "recordset" style data structure like the List of Maps. This way each column will display exactly the value associated to the key of the record (Map
) having the same name of the column.
c.table.columns = [
'title',
'author',
'id',
]
c.table.body = [
[id: '1', title: 'The Teachings of Don Juan', author: 'Carlos Castaneda', description: 'This is a nice fictional book'],
[id: '2', title: 'The Antipodes of the Mind', author: 'Benny Shanon', description: 'This is a nice scientific book'],
]
List of POGOs
A List of Plain Old Groovy Objects can also be used to load a table.
Given this POGO:
class Book {
String id
String title
Strng author
String description
}
We can load our table:
c.table.columns = [
'title',
'author',
'id',
]
c.table.body = [
new Book(id: '1', title: 'The Teachings of Don Juan', author: 'Carlos Castaneda', description: 'This is a nice fictional book'),
new Book(id: '2', title: 'The Antipodes of the Mind', author: 'Benny Shanon', description: 'This is a nice scientific book'),
]
GORM Recordsets
Using a GORM Recordset is an easy way to load a table.
Given this domain class:
class TBook {
String title
Strng author
String description
}
We can load our table:
c.table.with {
columns = [
'title',
'author',
'id',
]
body = TBook.list()
paginate = TBook.count()
}
Row Actions
There are two ways to configure row actions. All at once and on a row basis. To set all rows to have the same actions we can set them up in the table namespace as follows:
c.table.with {
columns = [
'title',
'author',
]
actions.addAction(action: 'onTake')
actions.addAction(action: 'onGiveBack')
}
If we need to configure the row actions depending on the record values, or other logics, we can do it from the eachRow
closure.
c.table.with {
columns = [
'title',
'author',
]
body.eachRow { TableRow row, Map values ->
if (values.borrowed) {
row.actions.addAction(action: 'onGiveBack')
} else {
row.actions.addAction(action: 'onTake')
}
}
}
Group Actions
The table can be configured to select multiple rows ad apply to all of them the same action. In this case we need to explicitly configure the action to submit the Table
component so we can access its selected rows. The default id
of the Table
component embedded in the ContentList
is table
.
c.table.with {
columns = [
'title',
'author',
]
groupActions.addAction(action: 'onTake', submit: 'table')
groupActions.addAction(action: 'onGiveBack', submit: 'table')
}
Filters
Each table can have its own search Form
to filter results. When submitting the filters, the action where they have been defined will be reloaded and the filters values will be available in the Grails params
map.
c.table.with {
filters.with {
addField(
class: TextField,
id: 'search',
)
}
Map filters = c.table.filterParams (1)
c.table.body = bookService.list(filters)
}
1 | The submitted values of the filters fields. |
Pagination
The Table
component let us paginate the results with a single instruction assigning the total record count. Underneath it uses the same params that GORM Recordset uses to paginate and sort its results. They are stored in the variable c.table.fetchParams
and we can use it right away to instruct our GORM queries.
c.table.with {
columns = [
'title',
'author',
]
body = bookService.list(c.table.filterParams, c.table.fetchParams)
paginate = bookService.count(c.table.filterParams)
}
def results = TBook.list(c.table.fetchParams)
Buttons
A Button
is the main human-interaction point in a GUI. A button triggers an action, usually sending data that will be processed, in our case, by the server.
Anatomy of a Button
Buttons in Dueuno Elements are made up of three components:
-
mainAction
AButton
with just a main action will look like a common button. It will display just one text label, and it will trigger just one action. The main action will always take the first position from left to right in a button.
-
tailAction
The tail action, if present, will be displayed in the second position from left to right, just after the main action. It will trigger a second action.
-
actions
All other actions added to theButton
will be placed into a list. The list can be shown clicking the arrow button that will appear on the right side. A menu will be displayed with all the available actions.
Let’s create a controller to see how buttons are used in a form.
~/demo/grails-app/controller/com/example/ButtonController.groovy
package com.example
import dueuno.elements.components.Button
import dueuno.elements.contents.ContentForm
import dueuno.elements.controls.TextField
import dueuno.elements.core.ElementsController
class ButtonController implements ElementsController {
def index() {
def c = createContent(ContentForm)
c.header.removeNextButton()
c.form.with {
addField(
class: TextField,
id: 'text',
defaultValue: 'You are the journey',
)
addField(
class: Button,
id: 'show',
action: 'onShow',
submit: ['form'],
primary: true,
cols: 4,
)
Button caseBtn = addField(
class: Button,
id: 'lowercase',
icon: 'fa-circle-down',
action: 'onLowercase',
submit: ['form'],
cols: 4,
).component
caseBtn.addTailAction(
action: 'onUppercase',
submit: ['form'],
text: '',
icon: 'fa-circle-up',
)
Button capitalizeBtn = addField(
class: Button,
id: 'capitalize',
action: 'onCapitalize',
submit: ['form'],
cols: 4,
).component
capitalizeBtn.addAction(
action: 'onUncapitalize',
submit: ['form'],
)
capitalizeBtn.addAction(
action: 'onQuote',
submit: ['form'],
)
capitalizeBtn.addAction(
action: 'onHighlight',
submit: ['form'],
)
}
display content: c
}
def onShow() {
String text = params.text
display message: text
}
def onUppercase() {
String text = params.text
display message: text.toUpperCase()
}
def onLowercase() {
String text = params.text
display message: text.toLowerCase()
}
def onCapitalize() {
String text = params.text
display message: text.split(' ')*.capitalize().join(' ')
}
def onUncapitalize() {
String text = params.text
display message: text.split(' ')*.uncapitalize().join(' ')
}
def onQuote() {
String text = params.text
display message: "'${text}'"
}
def onHighlight() {
String text = params.text
display message: "- ${text} -"
}
}
$ ./gradlew bootRun
Actions
Each button action is implemented by a Link
component. See Link
---
API Reference
Dueuno Elements is a Grails plugin useful to develop backoffice applications.
Create an application
Use Grails Forge to generate a Web Application with Java 17 (we support only Java 17) then add the following repository and dependency to your newly created Grails application:
dependencies {
…
implementation "org.dueuno:elements-core:2.x-SNAPSHOT"
}
Add the following code to initialize the application:
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.init {
// no-op (1)
}
}
}
1 | It’s okay to leave the init closure empty, but the declaration must exist otherwise the application will not be initialized and you won’t be able to login. |
Just delete the autogenerated file grails-app/controllers/**/UrlMappings.groovy if you want to present a login screen to your users when they first reach your application.
|
Optional steps
Configure the application to display the Dueuno Elements logs and banner. Add the following code to the relative files:
spring:
main:
banner-mode: "log"
<logger name="org.springframework.boot.SpringApplication" level="INFO" />
<logger name="dueuno" level="INFO" />
Login
At this point you should be able to run the application, point your browser to http://localhost:8080
and login with the system administrator credentials (username: super
, password: super
).
URL Path
We can set up our application to be accessible from a specific URL path. For example http://localhost:8080/admin
.
class BootStrap {
ApplicationService applicationService
TenantPropertyService tenantPropertyService
def init = { servletContext ->
applicationService.onInstall { String tenantId ->
tenantPropertyService.setString('SHELL_URL_MAPPING', '/admin')
tenantPropertyService.setString('LOGIN_LANDING_URL', '/') (1)
tenantPropertyService.setString('LOGOUT_LANDING_URL', '/') (2)
}
}
}
1 | After the user logs in the shell will redirect to this path |
2 | When the user logs out the shell will redirect to this path |
We also need to configure the URL mappings to tell Grails what’s the new configuration. For example if the main controller of our website is WebsiteController.groovy
we can create the following file:
class WebsiteUrlMappings {
static mappings = {
"/"(controller: 'website')
"/admin"(controller: 'shell') (1)
}
}
1 | The configured path must match the value of the SHELL_URL_MAPPING tenant property. |
Application
ApplicationService
is the main component in charge of the application setup, mainly used in BootStrap.groovy
. The following are the methods it exposes.
init()
Initializes the application. This closure gets called each time the application is executed.
class BootStrap {
ApplicationService applicationService (1)
def init = { servletContext ->
applicationService.init { (2)
// ...
}
}
}
1 | Injects an instance of the ApplicationService |
2 | The init { … } closure is executed each time the application starts up |
beforeInit()
Gets executed before the application is initialized. The session is not available you can NOT set session variables from here.
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.beforeInit {
// ...
}
}
}
afterInit()
Gets executed after the application is initialized. The session is not available you can NOT set session variables from here.
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.afterInit {
// ...
}
}
}
afterLogin()
Gets executed after the user logged in. The session is active, you can set session variables from here.
class BootStrap {
SecurityService securityService (1)
def init = { servletContext ->
securityService.afterLogin {
// ...
}
}
}
1 | Injects an instance of the SecurityService |
afterLogout()
Gets executed after the user logged in. The session is NOT active, you can NOT manage session variables from here.
class BootStrap {
SecurityService securityService (1)
def init = { servletContext ->
securityService.afterLogout {
// ...
}
}
}
1 | Injects an instance of the SecurityService |
onInstall()
Installs the application. This closure gets called only once when the application is run for the first time. It is executed for the DEFAULT tenant and when a new tenant is created from the super admin GUI.
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.onInstall { String tenantId -> (1)
// ...
}
}
}
1 | The tenantId tells what tenant is being installed |
onSystemInstall()
Gets executed only the first time the application is run.
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.onSystemInstall {
// ...
}
}
}
onPluginInstall()
Gets executed only the first time the application is run. It is used to install plugins.
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.onPluginInstall { String tenantId ->
// ...
}
}
}
onDevInstall()
Gets executed only once if the application is run from the IDE (only when the development environment is active). You can use this to preload data to test the application.
This closure will NOT be executed when the application is run as JAR, WAR or when the test environment is active.
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.onDevInstall { String tenantId ->
// ...
}
}
}
onUpdate()
On application releases, may you need to update the database or any other component, you can programmatically do it adding an onUpdate
closure.
These closures get executed only once when the application starts up. The execution order is defined by the argument, in alphabetical order.
class BootStrap {
ApplicationService applicationService
def init = { servletContext -> (1)
applicationService.onUpdate('2021-01-03') { String tenantId ->
println "${tenantId}: UPDATE N.2"
}
applicationService.onUpdate('2021-01-02') { String tenantId ->
println "${tenantId}: UPDATE N.1"
}
applicationService.onUpdate('2021-01-05') { String tenantId ->
println "${tenantId}: UPDATE N.4"
}
applicationService.onUpdate('2021-01-04') { String tenantId ->
println "${tenantId}: UPDATE N.3"
}
}
}
1 | The closures will be executed in the following order based on the specified version string: 2021-01-02 , 2021-01-03 , 2021-01-04 , 2021-01-05 . |
registerPrettyPrinter()
Registers a string template to render an instance of a specific Class. A pretty printer can be registered with just a name, in this case it must be explicitly assigned to a Control when defining it.
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.init {
registerPrettyPrinter(TProject, '${it.name}') (1)
registerPrettyPrinter('PROJECT_ID', '${it.padLeft(4, "0")}') (2)
}
}
}
1 | Registers a pretty printer for the TProject domain class. The it variable will refer to an instance of a TProject in this case we will display the name property |
2 | Registers a pretty printer called PROJECT_ID . Since we know that the project id is going to be a String we can call the padLeft() method on it |
registerTransformer()
Registers a callback used to render an instance of a specific Class. To make it work it must be explicitly assigned to a Control when defining it.
The closure will receive the value that is being transformed and must return a String. |
class BootStrap {
ApplicationService applicationService
SecurityService securityService
def init = { servletContext ->
applicationService.init {
registerTransformer('USER_FULLNAME') { Object value ->
return securityService.getUserByUsername(value).fullname
}
}
}
}
registerCredits()
Registers a role along with the people who took that role during the development of the project. When a credit reference is registered a new menu item will appear in the User Menu.
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.init {
registerCredits('Application Development', 'Francesco Piceghello', 'Gianluca Sartori')
}
}
}
Features
A Dueuno Elements application is a container for a finite set of features that you want to expose to the users. Features are defined in the init
closure. The main menu on the right side of the GUI lists all the features accessible by a user depending on its privileges.
Once defined, features are than implemented in Controllers & Actions.
registerFeature()
Registers a Feature.
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.init {
registerFeature(
controller: 'book', (1)
action: 'index', (2)
icon: 'fa-book', (3)
authorities: ['ROLE_CAN_EDIT_BOOKS'] (4)
)
registerFeature(
controller: 'read',
icon: 'fa-glasses',
)
registerFeature(
controller: 'configuration', (5)
)
registerFeature(
parent: 'configuration', (6)
controller: 'authors',
icon: 'fa-user',
)
registerFeature(
parent: 'configuration',
controller: 'publishers',
icon: 'fa-user-shield',
)
}
}
}
1 | Name of the controller that implements the feature |
2 | Name of the action to execute when the feature is clicked (default: index ) |
3 | Menu item icon, you can choose one from Font Awesome |
4 | The feature will be displayed only to the users configured with the roles in the list (default: ROLE_USER ) |
5 | A feature with just a controller can be created to group features. This will become the parent feature. |
6 | Tells the feature which one is its parent |
Available options:
Name | Type | Default | Description |
---|---|---|---|
|
|
|
The name of the controller that implements the feature. If not specified it is automatically set to the current controller name. |
|
|
|
(OPTIONAL) The name of the action to execute |
|
|
(OPTIONAL) Parameters to add when calling the |
|
|
|
(OPTIONAL) List of the component names that will be processed to retrieve the values to be passed when calling the |
|
|
|
(OPTIONAL) Menu item icon, you can choose one from Font Awesome |
|
|
|
|
(OPTIONAL) The feature will be displayed only to the users configured with the roles in the list |
|
|
(OPTIONAL) If |
|
|
|
(OPTIONAL) An absolute URL. When specified it takes precedence so |
|
|
|
(OPTIONAL) Menu items are URLs managed by Dueuno Elements. When set to |
|
|
|
(OPTIONAL) The feature will be displayed in a new browser tab with the provided name |
|
|
|
(OPTIONAL) The feature will be displayed in a new browser tab ( |
|
|
|
(OPTIONAL) Message to display before the feature is displayed giving the option to cancel or confirm the operation |
|
|
|
(OPTIONAL) If set, the message will be displayed instead of the feature |
registerUserFeature()
Registers a Feature in the User Menu. For the available options see: registerFeature()
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.init {
registerUserFeature(
controller: 'manual',
icon: 'fa-book',
targetNew: true,
)
}
}
}
Controllers & Actions
Controllers
A controller is a container for a set of actions. When a user interacts with the GUI an Action could be called to execute some logic. Actions are grouped in controllers so we can split and organize the application to fit the business domain.
A Controller is a Groovy class and each method is an Action. In the following example we see the structure of a Dueuno Elements controller for a CRUD operation.
@Secured(['ROLE_CAN_EDIT_BOOKS']) (1)
class BookController implements ElementsController { (2)
def index() {
// will display a list of books
}
def create() { (3)
// will display a form with book title and author
}
def onCreate() { (3)
// will create the book record on the database
}
def edit() {
// will display the details of a book
}
def onEdit() {
// will update the book record on the database
}
def onDelete() {
// will delete a book from the database
}
}
1 | The @Secured annotation let all the actions from this controller be accessed only by users with the ROLE_CAN_EDIT_BOOKS authority. |
2 | Implementing ElementsController the Dueuno Elements API will become available |
3 | As a convention, all actions building and displaying a GUI are named after a verb or a name while all actions that execute a business logic are identified by a name starting with on . |
Actions
An Action can implement an interactive Graphic User Interface (GUI) or act as an entry point to do some business logic and, if needed, update the user interface.
We don’t implement the business logic directly into actions, we do it into Grails Services, following Grails conventions and best practices.
To display a GUI we need to build one using Contents and Components. In the following example we create a GUI to list, create and edit books:
@Secured(['ROLE_CAN_EDIT_BOOKS'])
class BookController implements ElementsController {
BookService bookService (1)
def index() {
def c = createContent(ContentList) (2)
c.table.with {
columns = [
'title',
'author',
]
body = bookService.list()
}
display content: c
}
private buildForm(Map obj = null) {
def c = obj (3)
? createContent(ContentEdit)
: createContent(ContentCreate)
c.form.with {
addField(
class: TextField,
id: 'title',
)
addField(
class: TextField,
id: 'author',
)
}
if (obj) {
c.form.values = obj
}
return c
}
def create() {
def c = buildForm()
display content: c, modal: true
}
def edit() {
def book = bookService.get(params.id)
def c = buildForm(book)
display content: c, modal: true
}
}
1 | The BookService service implements the business logic |
2 | createContent() instantiates one of the available Contents to display a list of records |
3 | Each action ends with a display statement that renders the composed GUI to the browser |
4 | The GUI we build for the create and edit actions is the same. We make sure to use the appropriate content for creating and editing (See Contents) |
We implement a BookService
service with CRUD operations to manage a simple in memory database.
class BookService {
private static final data = [
[id: 1, title: 'The Teachings of Don Juan', author: 'Carlos Castaneda', description: 'This is a nice fictional book'],
[id: 2, title: 'The Antipodes of the Mind', author: 'Benny Shanon', description: 'This is a nice scientific book'],
]
List<Map> list() {
return data
}
Map get(Serializable id) {
return data.find { it.id == id }
}
void create(Map record) {
record.id = data.size() + 1
data.add(record)
}
void update(Map record) {
if (!record.id) throw new Exception("'id' required to update a record!")
Map item = data.find { it.id == record.id }
if (item) {
item.title == record.title
item.author = record.author
}
}
void delete(Serializable id) {
data.removeAll { it.id == id }
}
}
Book listing:
Editing a book:
Validation
Input from the user must be validated before we can save it. We can use the standard Gails Validation to make this happen.
For the purpose of this document we are going to use the Validateable Trait to check that the fields are not null and the title is unique. Please refer to the Grails Validation documentation to see all possible options.
class BookValidator implements Validateable {
String title
String author
BookService bookService
static constraints = {
title validator: { Object val, BookValidator obj, Errors errors ->
if (obj.bookService.getByTitle(val)) {
errors.rejectValue('title', 'unique')
}
}
}
}
When rejecting values you can use the following default messages:
Code | Message |
---|---|
|
Value between {3} and {4} |
|
Value between {3} and {4} |
|
Does not match pattern [{3}] |
|
Cannot be {3} |
|
Choose one of {3} |
|
Maximum value {3} |
|
Maximum size {3} |
|
Minimum value {3} |
|
Minimum size {3} |
|
Not a valid URL |
|
Not a valid e-mail |
|
Not a valid card number |
|
Already exists |
|
Required |
|
Required |
We can now implement the whole CRUD interface:
class BookController implements ElementsController {
BookService bookService
def index() {
def c = createContent(ContentList)
c.table.with {
columns = [
'title',
'author',
]
body = bookService.list()
}
display content: c
}
private buildForm(Map obj = null) {
def c = obj
? createContent(ContentEdit)
: createContent(ContentCreate)
c.form.with {
addField(
class: TextField,
id: 'title',
)
addField(
class: TextField,
id: 'author',
)
}
if (obj) {
c.form.values = obj
}
return c
}
def create() {
def c = buildForm()
display content: c, modal: true
}
def onCreate(BookValidator obj) { (2)
if (obj.hasErrors()) {
display errors: obj (1)
return
}
bookService.create(params)
display action: 'index'
}
def edit() {
def book = bookService.get(params.id)
def c = buildForm(book)
display content: c, modal: true
}
def onEdit(BookValidator obj) { (2)
if (obj.hasErrors()) {
display errors: obj (1)
return
}
bookService.update(params)
display action: 'index'
}
def onDelete() { (2)
try {
bookService.delete(params.id)
display action: 'index'
} catch (Exception e) {
display exception: e
}
}
}
1 | We use our BookValidator class to make sure the fields are not null and the title is unique and, in case, highlight the invalid fields |
2 | The name of these methods is defined by the ContentList , ContentCreate and ContentEdit contents, you can see them in your browser hovering the mouse over the Delete , Create and Save buttons (look the bottom left of your browser to see which URL is going to be called when clicking the buttons) |
Internationalization (i18n)
When building the GUI, Dueuno Elements automatically suggests labels for any relevant component requiring a text. To translate those labels we just copy them to its corresponding grails-app/i18n/messages_*.properties
file giving them a translation.
For example to enable the English and Italian languages we can do as follows.
English:
shell.book=Books
shell.read=Read
book.index.header.title=Books
book.create.header.title=New Book
book.edit.header.title=Book
book.title=Title
book.author=Author
Italian:
shell.book=Libri
shell.read=Leggi
book.index.header.title=Libri
book.create.header.title=Nuovo libro
book.edit.header.title=Libro
book.title=Titolo
book.author=Autore
The User Menu will automatically display the available languages based on the presence of their language files.
display()
The most relevant feature of Dueuno Elements is the display
method. It renders the GUI on the server and sends is to the browser.
You can call display
with one or more of the following parameters:
Name | Type | Default | Description |
---|---|---|---|
|
|
The name of the controller to redirect to. If no |
|
|
|
The name of the action to redirect to. If no |
|
|
|
The params to pass when redirecting to a |
|
|
|
The content to display (See Contents) |
|
|
|
The transition to display (See Transitions) |
|
|
|
Whether to display the content in a modal dialog or not |
|
|
|
When displaying the content as |
|
|
|
When displaying the content as |
|
|
|
|
When displaying the content as |
|
|
Validation errors to display (See Validation) |
|
|
|
Message to display in a message box to the user |
|
|
|
Exception to display in a message box to the user |
|
|
|
Message to display in a message box to the user |
Transitions
A Transition is a set of instructions sent from the server to the client (browser) to alter the currently displayed content. For instance, when selecting a book from a list we want a text field to be populated with its description. To implement such behaviours we use transitions.
Please refer to Controls and Components to see what events are available to each component. |
Refer to Websockets to understand how to trigger events programmatically from sources other than the user input. |
class ReadController implements ElementsController {
BookService bookService
def index() {
def c = createContent(ContentForm)
c.header.removeNextButton()
c.form.with {
addField(
class: Select,
id: 'book',
optionsFromRecordset: bookService.list(),
onChange: 'onChangeBook', (1)
)
addField(
class: Textarea,
id: 'description',
)
}
display content: c
}
def onChangeBook() {
def t = createTransition() (2)
def book = bookService.get(params.book)
if (book) {
t.set('description', book.description) (3)
t.set('description', 'readonly', true) (4)
} else {
t.set('description', null)
t.set('description', 'readonly', false)
}
display transition: t
}
}
1 | We tell the Select field which action to execute when the change event occurs (See Events) |
2 | We create a new Transition |
3 | The set method sets the value of the description field |
4 | We also set the Textarea to a readonly state |
To finish it up we register a Pretty Printer for the book record and tell the 'Select' control to use it to display the items.
class BootStrap {
ApplicationService applicationService
def init = { servletContext ->
applicationService.init {
registerPrettyPrinter('BOOK', '${it.title} - ${it.author}') (1)
}
}
}
1 | A pretty printer called BOOK will display each book by title and author. The it variable refers to an instance of the book record (a Map in this case) |
class ReadController implements ElementsController {
...
addField(
class: Select,
id: 'book',
optionsFromRecordset: bookService.list(),
prettyPrinter: 'BOOK', (1)
onChange: 'onChangeBook',
)
...
}
1 | We configure the Select control to use the BOOK pretty printer to format the books |
Exceptions
When developing the application all unhandled exceptions will be rendered to the browser as follows.
In production, all the details will be hidden and just the sad face will be displayed.
|
To display a message box instead you can add an Exception handler to the controller:
class ReadController implements ElementsController {
def handleException(Exception e) {
display exception: e
}
def handleMyCustomException(MyCustomException e) {
display exception: e
}
}
Contents
Contents are the canvas to each feature. You can create a ContentBlank
, which is a plain empty canvas, and add Components to it. This is not something you will usually want to do since Dueuno Elements provides pre-assembled contents to be used right away.
Components are added to the content on a vertical stripe one after the other. We can not layout components, to create a layout we need to use the Form
component or we can create a custom component.
ContentBase
Embeds a Header
and a Confirm Button
that submits a component called form
(not provided) to an action called onConfirm
.
ContentForm
Extends ContentHeader
and embeds a Form
called form
.
ContentCreate
Extends ContentForm
and provides a Create Button
that submits the form
component to an action called onCreate
.
ContentEdit
Extends ContentForm
and provides a Save Button
that submits the form
component to an action called onEdit
.
ContentList
Extends ContentHeader
and embeds a Table
component. Provides a New Button
that redirects to an action called create
.
The Table
component is configured to present and Edit and a Delete Button
for each displayed row. The Edit Button
submits the raw id to an action called edit
while the Delete Button
asks for confirmation before redirecting to an action called onDelete
.
Components
Everything in dueuno_elements is a Component
. A component is itself a tiny web application. Each component is built with at least an HTML view, a CSS styling and a JavaScript logic. A Component can provide a supporting Service
or Controller
.
Unless we want to create a new component there is no need to know HTML, CSS or JavaScript to develop a Dueuno Elements application.
Each component extends the base class Component
so each component share the following properties and methods.
Properties
Property | Type | Default | Description |
---|---|---|---|
|
|
Id of the component instance. This is mandatory, it must be unique and provided in the constructor. |
|
|
|
|
Shows or hides the component without changing the layout |
|
|
|
Displays or hides the component, adding or removing it from the layout |
|
|
|
Readonly controls are disabled |
|
|
|
The component won’t participate in keyboard or mouse selection |
|
|
The component is sticky on top |
|
|
|
Contains instructions for the container. The container component may or may not respect them, see the documentation for the specific container component. |
|
|
|
The text color, CSS format |
|
|
|
Background color, CSS format |
|
|
|
Custom CSS class to apply. The CSS class must be a Bootstrap] CSS class or a cusom one declared into the |
|
|
|
Custom CSS inline style |
Methods
Method | Description |
---|---|
|
Adds a component as children. See Components. |
|
Adds a control as children. See Controls. |
|
Configures an event. See Events. |
Header
A Header
is a bar at the top of the Content
area. It can be sticky on top or it can scroll with the content. Its main purpose is to hold navigation buttons.
A Header
can have a backButton
on the left and a nextButton
on the right. In the middle we can find the title
.
Properties
Property | Type | Default | Description |
---|---|---|---|
|
|
When set to |
|
|
|
The title to display |
|
|
|
Args to be used when indexing an i18n message. Eg: in |
|
|
|
An icon to be displayed before the |
|
|
|
|
|
|
|
|
|
|
|
The back button object. See Button |
|
|
|
The next button object. See Button |
Table
A Table
is a convenient way to display a recordset.
Each table can implement some TableFilters and each row can have its own set of action buttons. For each row, depending on the logged in user and the status of the record we can define which actions are available.
Properties
Property | Type | Default | Description | ||
---|---|---|---|---|---|
|
|
A list of column names to display. Each column name must match the recordset column name to automatically display its values.
|
|||
|
|
List of key names. When specified, a new column will be created for each key. The keys will be automatically submitted when a row action is activated.
|
|||
|
|
Defines the sortable columns
|
|||
|
|
Defines the sorting of the recordset. It takes precedence over the
|
|||
|
|
||||
|
|
Programmatically change the label of the specified columns.
|
|||
|
|
Sets a transformer to a column. Each value of that column will be processed by the specified transformer (See registerTransformer())
|
|||
|
|
Sets a pretty printer to a column. Each value of that column will be processed by the specified pretty printer (See registerPrettyPrinter())
|
|||
|
|
Sets some pretty printer properties to a column. Each value of that column will be processed by the specified properties (See PrettyPrinterProperties)
|
|||
|
|
|
If |
||
|
|
To define table filters:
|
|||
|
|
|
Whether to display the row action buttons or not |
||
|
|
|
Whether to display the table header or not |
||
|
|
|
Whether to display the table footer or not |
||
|
|
|
Whether to display the table pagination or not |
||
|
|
|
Whether to render the table to host custom components on its cells or not. Enabling this feature slows down the rendering. |
||
|
|
|
Whether to highlight the rows on mouse pointer hover |
||
|
|
|
Whether to set the zebra style or not |
||
|
|
|
Whether to display a box with an icon and a text when the table has no results |
||
|
|
The icon ti display when the table has no results. Choose one from Font Awesome. |
|||
|
|
The message to display when the table has no results |
Methods
Method | Description | ||||||
---|---|---|---|---|---|---|---|
|
Assigns a recordset to the table body (See Recordsets)
|
||||||
|
Assigns a recordset to the table footer (See Recordsets)
|
||||||
|
If set the table will paginate the results. Must be set to the total count of the records to show.
|
||||||
|
This closure gets called for each row displayed by the table. Don’t execute slow code here since it will slow down the whole table rendering.
|
Recordsets
What can we load a table with?
List of Lists
Loading a table with a List of Lists is possible, the sequence will determine how each column will be mapped to each value. There is no hard relationship between the displayed column name and the value.
For this reason we suggest using List of Maps instead.
c.table.columns = [
'title',
'author',
'description',
]
c.table.body = [
['The Teachings of Don Juan', 'Carlos Castaneda', 'This is a nice fictional book'],
['The Antipodes of the Mind', 'Benny Shanon', 'This is a nice scientific book'],
]
List of Maps
We can load a table with a "recordset" style data structure like the List of Maps. This way each column will display exactly the value associated to the key of the record (Map
) having the same name of the column.
c.table.columns = [
'title',
'author',
'id',
]
c.table.body = [
[id: '1', title: 'The Teachings of Don Juan', author: 'Carlos Castaneda', description: 'This is a nice fictional book'],
[id: '2', title: 'The Antipodes of the Mind', author: 'Benny Shanon', description: 'This is a nice scientific book'],
]
List of POGOs
A List of Plain Old Groovy Objects can also be used to load a table.
Given this POGO:
class Book {
String id
String title
Strng author
String description
}
We can load our table:
c.table.columns = [
'title',
'author',
'id',
]
c.table.body = [
new Book(id: '1', title: 'The Teachings of Don Juan', author: 'Carlos Castaneda', description: 'This is a nice fictional book'),
new Book(id: '2', title: 'The Antipodes of the Mind', author: 'Benny Shanon', description: 'This is a nice scientific book'),
]
GORM Recordsets
Using a GORM Recordset is an easy way to load a table. See how to build a CRUD.
c.table.columns = [
'title',
'author',
]
c.table.body = TBook.list()
c.table.paginate = TBook.count()
Row Actions
There are two ways to configure row actions. All at once and on a row basis. To set all rows to have the same actions we can set them up in the table namespace as follows:
c.table.with {
columns = [
'title',
'author',
]
actions.addAction(action: 'borrow') (1)
actions.addAction(action: 'return')
}
1 | See Button for all the Button properties |
If we need to configure the row actions depending on the record values or other logics we can do it from the eachRow
closure.
c.table.with {
columns = [
'title',
'author',
]
body.eachRow {
if (values.borrowed) {
row.actions.addAction(action: 'return') (1)
} else {
row.actions.addAction(action: 'borrow')
}
}
}
1 | See Button for all the Button properties |
Group Actions
The table can be configured to select multiple rows ad apply to all of them the same action.
c.table.with {
columns = [
'title',
'author',
]
groupActions.addAction(action: 'return') (1)
groupActions.addAction(action: 'borrow')
}
1 | See Button for all the Button properties |
TableFilters
Each table can have its own search Form
to filter results. When submitting the filters, the action containing them will be reloaded and the filters values will be available in the Grails params
map.
c.table.with {
filters.with {
addField(
class: Select,
optionsFromRecordset: bookService.list(),
prettyPrinter: 'BOOK',
id: 'book',
cols: 4,
)
addField(
class: TextField,
id: 'search',
cols: 8,
)
}
Map filters = c.table.filters.values (1)
}
1 | The submitted values of the filters fields. |
Properties
Property | Type | Default | Description |
---|---|---|---|
|
|
|
|
|
|
|
Whether the filters form is displayed as folded or not at its first appearance. After that its folded state will be stored in the session. |
|
|
|
If set to |
Form
A form is the component we use to layout Components and Controls. Form
implements the grid system, once activated we have 12 columns we can use to arrange form fields horizontally.
When the application is accessed from a mobile phone all the fields will be displayed in a single column. This makes them usable when the available space is not enough to organise them in a meaningful way.
c.form.with {
grid = true
addField(
class: TextField,
id: 'title',
cols: 6,
)
addField(
class: TextField,
id: 'author',
cols: 6,
)
}
Properties
Property | Type | Default | Description |
---|---|---|---|
|
|
A |
|
|
|
|
Whether to activate the grid system or not |
|
|
|
Sets all the form fields readonly |
FormField
A form field wraps a Control
with a label and sets it into the grid system. A FormField
is automatically created each time we add a field to a Form
calling its addField()
method.
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
The contained component |
|
|
|
The field label |
|
|
|
A list of objects to pass to the localized message (Eg. when using |
|
|
|
A help message |
|
|
|
A list of objects to pass to the localized message (Eg. when using |
|
|
|
|
Whether to display the field as nullable or not. If set will override the form |
|
|
If set to |
|
|
|
Defines how many columns of the grid system will be used to span the |
|
|
|
If the |
Button
Buttons are key components of the GUI. We use buttons to let the user trigger actions. The Button
component can provide the user with multiple actions to be executed.
A single button can display two directly accessible actions, the defaultAction
and tailAction
and a menu with a list of links, the actionMenu
.
defaultAction |
tailAction |
actionMenu |
---|
A simple button will have just the defaultAction
.
c.form.with {
def addBookField = addField( (1)
class: Button,
id: 'addBook',
action: 'addBook',
submit: ['form'],
)
def button = addBookField.component
button.addAction(controller: 'addAuthor')
}
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
The default action |
|
|
|
The tail action |
|
|
|
The action menu |
|
|
|
|
When set to |
|
|
|
Set to |
|
|
|
If set to |
|
|
The max width in pixels that the button can reach |
Events
Event | Description |
---|---|
|
The event is triggered on mouse click or finger tab on touch devices |
Menu
A menu is the component we use to organize the Shell
and Button
menus. It can hold a tree of items with a parent-children structure but we use only one level to group items (See Features).
This component is meant for internal use only.
Link
Links are everywhere, they are in the Shell
menus, in Buttons
actions, TextField
or Select
actions, and they can be used as stand alone. Links and buttons share the same properties.
c.form.with {
addField( (1)
class: Link,
id: 'addBook',
action: 'addBook',
submit: ['form'],
icon: 'fa-book',
)
}
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
Icon that graphically represents the link. Choose one from Font Awesome. |
|
|
|
An SVG image that graphically represents the link. If specified a corresponding file must exist in the |
|
|
|
A label that describes the link, usually a code found in |
|
|
|
Point to a specific URL |
|
|
|
Whether to render the whole html page (or raw http body) or a Transition |
|
|
|
Set a target name to open the page into a new browser tab. All links with te same target will display in the same tab. |
|
|
|
If set to |
|
|
|
Whether to display the content in a modal dialog or not |
|
|
|
When displaying the content as |
|
|
|
When displaying the content as |
|
|
|
|
When displaying the content as |
|
|
|
If set to |
|
|
Can be set to |
|
|
|
If specified an info message will pop up, the link will never be executed |
|
|
|
If specified a confirmation message will pop up giving the user a chance to cancel the action |
Events
Event | Description |
---|---|
|
The event is triggered on mouse click or finger tap on touch devices |
Label
A Label
is a canvas for text and custom HTML.
c.form.with {
addField(
class: Label,
id: 'label',
html: '<b>This is a bold statement!</b>',
textAlign: TextAlign.END,
textWrap: TextWrap.LINE_WRAP,
textStyle: TextStyle.BOLD,
)
}
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
The text to display. If it’s a |
|
|
|
An html string, useful to format text or insert links |
|
|
|
If specified the |
|
|
|
An icon to display before the text, you can choose one from Font Awesome |
|
|
|
Determines the text horizontal alignment. It can be set to |
|
|
|
Determines how the text is wrapped:
|
|
|
|
Determines the text style:
|
|
|
|
Draws a coloured background. Useful when we want to display the label in a different color. |
|
|
|
|
If |
Separator
Wa can use separators to space between a set of fields and another one in a form.
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
Reduces the space the separator will introduce leaving just the space for the label |
KeyPress
We use the KeyPress
component to intercept key pressed by the user on the GUI. Its main use is to integrate barcode readers but it can be used for any other scenario.
def c = createContent(ContentList)
c.addComponent(
class: KeyPress,
id: 'keyPress',
action: 'onKeyPress', (1)
)
1 | See Events to configure the event |
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
|
Key pressed are stored into a buffer until a trigger key is pressed. When this happens the configured event is called. The trigger key can be any character or |
Controls
Controls are Components that can hold a value. Controls are the main way to interact with the application. We mainly use controls in forms to easily submit their values.
TextField
A text field.
c.form.addField(
class: TextField,
id: 'username',
icon: 'fa-user',
textStyle: TextStyle.LINE_THROUGH,
)
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
An icon to display within the control, you can choose one from Font Awesome |
|
|
|
A text to display before the edit area of the control |
|
|
|
Max number of characters the user can input |
|
|
|
A text to display when the text area is empty |
|
|
|
Transforms the input while typing. It may be one of the following:
|
|
|
|
Determines the text style:
|
|
|
|
A RegEx pattern to accept only specific input (Eg. |
Methods
Method | Description |
---|---|
|
Adds an action button at the end of the control. See Link. |
Events
Event | Description |
---|---|
|
Triggered once the content is loaded |
|
Triggered when the value changes |
Select
Displays a list of options to choose from.
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
Options will be set from the recordset |
|
|
|
Options will be set from the List items. The key of each item will match the value of the item itself. |
|
|
|
Options will be set from the Enum. The key of each item will match the value of the item itself. |
|
|
|
Options will be set from the Map items (key/value) |
|
|
|
|
List of column names to submit as the key for the selected option |
|
|
Use the specified pretty printer to display the options. See registerPrettyPrinter(). If the registered pretty printer |
|
|
|
Name of the transformer to use to display the options. See registerTransformer() |
|
|
|
Determines the text style:
|
|
|
|
Prefix to add to each item so it can be referred in |
|
|
|
|
Whether to display the |
|
|
Displays a text when no option is selected |
|
|
|
If |
|
|
|
|
When there is only one available option in the list it will be automatically selected |
|
|
|
Enables multiple selections |
|
|
Displays a search box to filter the available options. It works on the client side, to search on the server we need to user the |
|
|
|
|
Minimum number of characters to input before the search on the server can start. Works in combination with the |
Methods
Method | Description |
---|---|
|
Returns a |
|
Returns a |
|
Returns a |
|
Returns a |
Events
Event | Description |
---|---|
|
Triggered once the content is loaded |
|
Triggered when the value changes |
|
Triggered when |
Search on server
Example of setting up a server search.
c.form.with {
addField(
class: Select,
id: 'activity',
onLoad: 'onActivityLoad', (1)
onChange: 'onActivityChange',
onSearch: 'onActivitySearch', (2)
searchMinInputLength: 0, (3)
submit: ['form'],
allowClear: true,
)
}
1 | The load event must return a single option to display |
2 | The search event will return a list of matching options |
3 | If 0 then the search event will be triggered as soon as the user clicks on the control to open the options list. |
We need to create the following actions.
ActivityService activityService
def onActivityLoad() {
def t = createTransition()
def activities = activityService.list(id: params.activity) (1)
def options = Select.optionsFromRecordset(recordset: activities)
t.set('activity', 'options', options)
display transition: t
}
def onActivityChange() {
def t = createTransition()
// Do something...
display transition: t
}
def onActivitySearch() {
def t = createTransition()
def activities = activityService.list(find: params.activity) (2)
def options = Select.optionsFromRecordset(recordset: activities)
t.set('activity', 'options', options)
display transition: t
}
1 | params.activity will hold the selected id |
2 | params.activity will hold the search string |
Checkbox
A checkbox is a way to interact with Boolean
values.
c.form.with {
addField(
class: Checkbox,
id: 'fullscreen',
displayLabel: false,
cols: 3,
)
}
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
The text to display |
Events
Event | Description |
---|---|
|
Not implemented yet |
MultipleCheckbox
Manage multiple checkboxes as it was a Select control with many options. See Select.
Textarea
A text area who can span multiple lines of a form.
c.form.with {
addField(
class: Textarea,
id: 'textarea',
maxSize: 100,
cols: 12,
rows: 5,
)
}
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
Max number of characters the user can input |
Events
Event | Description |
---|---|
|
Triggered when the value changes |
QuantityField
A text field to input quantities.
c.form.with {
addField(
class: QuantityField,
id: 'quantity',
defaultUnit: QuantityUnit.KM,
availableUnits: quantityService.listAllUnits(),
)
}
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
|
Allowed decimal digits |
|
|
|
If negative values are allowed |
|
|
A list of units to select from |
|
|
|
The default unit to display |
Events
Event | Description |
---|---|
|
Triggered once the content is loaded |
|
Triggered when the value changes |
MoneyField
A text field to input currency values.
c.form.with {
addField(
class: MoneyField,
id: 'salary',
decimals: 0,
)
}
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
|
Allowed decimal digits |
|
|
|
If negative values are allowed |
Events
Event | Description |
---|---|
|
Triggered once the content is loaded |
|
Triggered when the value changes |
NumberField
A text field to manage number values.
c.form.with {
addField(
class: NumberField,
id: 'number',
min: -2,
max: 10,
)
}
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
|
Allowed decimal digits |
|
|
|
If negative values are allowed |
|
|
Minimum number the user can input |
|
|
|
Maximum number the user can input |
Events
Event | Description |
---|---|
|
Triggered once the content is loaded |
|
Triggered when the value changes |
DateField
A control to input a date.
c.form.with {
addField(
class: DateField,
id: 'dateStart',
min: LocalDate.now().minusDays(3),
max: LocalDate.now().plusDays(3),
)
}
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
Minimum date the user can input |
|
|
|
Maximum date the user can input |
Events
Event | Description |
---|---|
|
Triggered once the content is loaded |
|
Triggered when the value changes |
TimeField
A control to input a time.
c.form.with {
addField(
class: TimeField,
id: 'time',
min: LocalTime.now().minusHours(3),
timeStep: 10,
)
}
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
Minimum time the user can input |
|
|
|
Maximum time the user can input |
|
|
|
The amount of minutes the user can select. For example if set to |
Events
Event | Description |
---|---|
|
Triggered once the content is loaded |
|
Triggered when the value changes |
DateTimeField
A control to input a date and time.
c.form.with {
addField(
class: DateTimeField,
id: 'datetime',
min: LocalDate.now().minusDays(3),
)
}
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
Minimum date the user can input |
|
|
|
Maximum date the user can input |
|
|
|
The amount of minutes the user can select. For example if set to |
Events
Event | Description |
---|---|
|
Triggered once the content is loaded |
|
Triggered when the value changes |
EmailField
A control to input an email. See TextField.
c.form.with {
addField(
class: EmailField,
id: 'email',
)
}
TelephoneField
A control to input a telephone number. See TextField.
c.form.with {
addField(
class: TelephoneField,
id: 'telephone',
)
}
UrlField
A control to input a URL. See TextField.
c.form.with {
addField(
class: UrlField,
id: 'url',
)
}
PasswordField
A control to input a password. See TextField.
c.form.with {
addField(
class: PasswordField,
id: 'password',
)
}
HiddenField
A control to store a value without displaying it to the user.
c.form.with {
addField(
class: HiddenField,
id: 'hidden',
value: 'This is not visible but it will be submitted',
)
}
Events
Each Component
can trigger one or more events. Please see Components and Controls to see what events each specific component can trigger.
Each available event has a lowercase name. We can configure the event directly when creating a component as follows.
c.form.with {
addField(
class: Select,
id: 'book',
onChange: 'onChangeBook', (1)
submit: ['form'],
)
}
1 | The parameter name is composed by on followed by the capitalized name of the event (the event change in this case). The parameter value is the name of the action to be called. |
Multiple events can be configured as follows.
c.form.with {
def books = addField(
class: Select,
id: 'book',
).component (1)
books.with {
on( (2)
event: 'load',
action: 'onLoadBooks',
)
on( (3)
event: 'change',
action: 'onChangeBook',
submit: ['form'],
)
}
}
1 | We reference the component hold by the FormField , not the form field itself |
2 | Configuring the load event |
3 | Configuring the change event |
The following properties can be specified when configuring an event on a component.
Properties
Name | Type | Default | Description |
---|---|---|---|
|
|
The name of the controller to redirect to. If no |
|
|
|
The name of the action to redirect to. If no |
|
|
|
The params to pass when redirecting to a |
|
|
|
Name list of the components whose values we want to submit. Each component is responsible to define the data structure for the values it contains. The default behaviour will send the values of all the controls contained within the component. |
PrettyPrinterProperties
Every value in Dueuno Elements gets displayed by the PrettyPrinter
subsystem. Components and Controls can be configured to override the user settings and the system settings. Refer to the documentation of each component to see how those settings can be configured.
Name | Type | Default | Description |
---|---|---|---|
|
|
|
|
|
|
Transformer name |
|
|
|
- |
|
|
|
Default: |
|
|
|
Add or change the message prefix |
|
|
|
Add args for the i18n message |
|
|
|
|
If |
|
|
|
If the value is |
|
|
If the value is 0 render the specified string instead |
|
|
|
For |
|
|
|
Change the way the date is rendered (See DateTimeFormatter) |
|
|
|
For |
|
|
|
For |
|
|
|
|
For |
|
|
For |
|
|
|
|
For |
|
|
|
For |
|
|
|
For |
|
|
|
For |
|
|
|
For |
|
|
|
For |
|
|
|
Whether to display Sunday as the first day of the week ( |
Websockets
TODO
Tenant Properties
TODO
System Properties
TODO
Custom CSS
TODO
Custom JavaScript
TODO