Show Navigation

Grails Fields Plugin: Custom Widgets, Wrappers, and Tables

Drive every CRUD page with f:all, f:display, and f:table while customising widgets, wrappers, and table columns once for the whole app

Authors: James Fredley

Grails Version: 8

1 Getting Started

In this guide you will build a small library application with two domain classes - Book and Author - and use the Grails Fields plugin to drive every CRUD page (create, edit, show, list) with a single line of GSP each.

By the end of the guide your create.gsp, edit.gsp, show.gsp, and list.gsp will contain almost no field-by-field markup. All of the formatting, validation feedback, and per-property layout will live once in grails-app/views/_fields/ as custom widgets, wrappers, and table templates - reused across every domain class in the app.

This is the first guide that targets Apache Grails 8.

1.1 What You Will Build

You will start with the default scaffolded views that Grails generates for two domain classes (Book and Author), then progressively replace them with three Fields-plugin tags:

  • <f:all bean="book"/> - renders every editable property of the bean as a labelled, validated form field. Used in create.gsp and edit.gsp.

  • <f:display bean="book"/> - renders every property of the bean as a read-only labelled value. Used in show.gsp.

  • <f:table collection="${bookList}"/> - renders a collection as an HTML table with one column per property. Used in list.gsp.

The interesting part of the guide is what you do alongside those one-liners: customise the templates the plugin uses to look up how each field renders. When you finish the guide you will have:

  • A custom _wrapper.gsp that gives every field a consistent label, required marker, error message, and help text.

  • Custom _widget.gsp templates per property type (String, Date, Boolean) that respect Grails constraints (inList, email, url, widget: 'textarea').

  • A per-property widget for Book.isbn that adds an HTML pattern attribute and a help line.

  • A per-property wrapper for Book.description that adds a live character counter.

  • A per-domain _table.gsp for Book with custom column cells, action buttons, and link-out behaviour.

  • A <f:display>-driven show.gsp that automatically picks up every customisation you wrote for the form side.

The result is a CRUD UI where adding a new domain class is a 5-line change, and changing how every date in the application renders is a single-file edit.

1.2 What You Will Need

To follow this guide you will need:

  • JDK 21 or later. JDK 21 LTS is the minimum required by Apache Grails 8; JDK 25 LTS also works.

  • Approximately 30 minutes of your time.

  • A text editor or IDE - IntelliJ IDEA is recommended; the Community Edition is free.

  • Familiarity with the basics of GSP, GORM domain classes, and the Grails dynamic scaffolding plugin.

This guide targets Apache Grails 8. The Fields plugin is bundled with grails-core in Grails 7 and later, so no additional plugin dependency is required.

1.3 How to Complete the Guide

You can either type the code in this guide as you read, or skip ahead and clone the finished sample app from the upstream repository:

git clone -b grails8 https://github.com/grails-guides/grails-fields-custom-widgets-and-wrappers.git
cd grails-fields-custom-widgets-and-wrappers/complete
./gradlew bootRun

The repository contains two top-level directories:

  • initial/ - the starting Grails 8 project, generated from start.grails.org with no customisations applied.

  • complete/ - the same project with all the widgets, wrappers, and table customisations from this guide already in place.

Each chapter ends with the file paths it touches relative to the project root, so you can match what you typed against the complete/ reference.

2 Creating the Application

Generate a fresh Apache Grails 8 project from start.grails.org.

2.1 Download a Grails 8 Starter

Open start.grails.org, pick the web profile, name the application library, set the package to example, and download the generated zip. Unzip it and cd into the project directory.

Verify it boots before you go further:

./gradlew bootRun

Open http://localhost:8080/ in a browser and you should see the default Grails landing page. Stop the application with Ctrl+C once you have confirmed it runs.

2.2 The Fields Plugin in Grails 8

As of Apache Grails 7 the Fields plugin is bundled with grails-core as the grails-fields module. You do not need to add a dependency for it. The taglib namespace f is registered automatically by grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy (inside grails-fields.jar) and the default templates live on the runtime classpath.

You can confirm the plugin is on the classpath by listing the resolved configurations:

./gradlew dependencies --configuration runtimeClasspath | grep -i fields

You should see org.apache.grails:grails-fields:<version> matching the Grails version your project pins. There is no separate plugin descriptor to register and no BuildConfig.groovy to edit.

The official 8.0.x reference for the plugin lives at grails-doc/src/en/guide/theWebLayer/fields.adoc (and the fields/ subdirectory). That chapter covers the high-level f: taglib API; this guide complements it with worked examples of customising the per-property and per-class templates that the plugin’s FormFieldsTemplateService looks up.

3 The Book and Author Domain Model

A small library is the right size to exercise every interesting customisation hook in the Fields plugin: two domain classes, a belongsTo association, a few different property types (String, Date, Boolean, BigDecimal), and a handful of constraints (inList, email, url, matches, widget: 'textarea') that drive widget selection.

3.1 Author Domain Class

Create the Author domain class first. Authors have a name, an email address, a free-form bio, and an optional homepage URL.

./grailsw create-domain-class example.Author

Replace the contents of the generated file with:

grails-app/domain/example/Author.groovy
package example

class Author {

    String name
    String email
    String bio
    String website

    static hasOne = [contactInfo: ContactInfo]
    static hasMany = [books: Book]

    static constraints = {
        name blank: false, maxSize: 200
        email email: true, blank: false
        bio blank: false, maxSize: 4000, widget: 'textarea'
        website url: true, nullable: true
        contactInfo nullable: true
    }

    static mapping = {
        sort name: 'asc'
    }

    String toString() { name }
}

Notable constraints:

  • email: true - Grails validates the value as an email address; the Fields plugin renders an <input type="email"> widget.

  • url: true - Grails validates the value as a URL; the Fields plugin renders an <input type="url"> widget.

  • widget: 'textarea' on bio - tells the Fields plugin to render a <textarea> instead of the default <input type="text">.

  • nullable: true on website - makes the field optional in the form.

  • static hasOne = [contactInfo: ContactInfo] - one-to-one association where Author owns the FK column on ContactInfo. We will customise this in the association chapter.

  • static hasMany = [books: Book] - one-to-many back-reference; the books own the FK on their side via belongsTo: Author.

The ContactInfo class is a thin owned domain object:

grails-app/domain/example/ContactInfo.groovy
package example

class ContactInfo {

    String phone
    String mailingAddress

    static belongsTo = [author: Author]

    static constraints = {
        phone blank: false, maxSize: 32
        mailingAddress blank: false, maxSize: 500, widget: 'textarea'
    }

    String toString() { "Contact for ${author?.name}" }
}

3.2 Book Domain Class

Create the Book domain class. A book belongs to one Author, has an ISBN that follows a strict pattern, a published date, a price, an in-stock flag, and a genre selected from a fixed list.

./grailsw create-domain-class example.Book

Replace the contents of the generated file with:

grails-app/domain/example/Book.groovy
package example

class Book {

    String title
    String isbn
    String genre
    String description
    Date publishedDate
    BigDecimal priceUSD
    Boolean inStock = true

    static belongsTo = [author: Author]
    static hasMany = [tags: Tag]

    static constraints = {
        title blank: false, maxSize: 255
        isbn blank: false, matches: /^(?:\d{10}|\d{13}|\d{3}-\d-\d{2}-\d{6}-\d)$/
        genre inList: ['Fiction', 'Non-Fiction', 'Biography', 'Science', 'History', 'Poetry']
        description blank: false, maxSize: 2000, widget: 'textarea'
        publishedDate nullable: false
        priceUSD min: 0.00G, scale: 2
        inStock nullable: false
    }

    static mapping = {
        sort title: 'asc'
    }

    String toString() { title }
}

Each constraint here will drive a customisation later in the guide:

  • inList: on genre - the Fields plugin will render a <select> populated from the list. We will keep that behaviour but restyle it.

  • matches: on isbn - we will reach down to a per-property _widget.gsp and add an HTML pattern attribute that mirrors the Groovy regex.

  • widget: 'textarea' on description - we will swap in a per-property _wrapper.gsp that adds a live character counter.

  • belongsTo: Author - many-to-one association: the Fields plugin renders this as a <select> populated with all Author instances. We will customise the option label and the display widget.

  • static hasMany = [tags: Tag] - many-to-many association via the Tag domain class. The default plugin widget is a multi-select; we will swap it for a checkbox group in the association chapter.

Tag itself is the join-side domain:

grails-app/domain/example/Tag.groovy
package example

class Tag {

    String name

    static hasMany = [books: Book]
    static belongsTo = Book

    static constraints = {
        name blank: false, maxSize: 60, unique: true
    }

    static mapping = {
        sort name: 'asc'
    }

    String toString() { name }
}

The static mapping block on Book sorts list views by title ascending, which we will exploit when we customise the table template.

3.3 Bootstrap Sample Data

Replace grails-app/init/example/BootStrap.groovy with the following so that the application starts with a few authors and books to look at:

grails-app/init/example/BootStrap.groovy
package example

class BootStrap {

    def init = { servletContext ->
        Tag fantasy = new Tag(name: 'Fantasy').save(failOnError: true)
        Tag classic = new Tag(name: 'Classic').save(failOnError: true)
        Tag romance = new Tag(name: 'Romance').save(failOnError: true)
        Tag adventure = new Tag(name: 'Adventure').save(failOnError: true)

        Author tolkien = new Author(
                name: 'J.R.R. Tolkien',
                email: 'jrr@example.com',
                bio: 'English writer and philologist, best known for The Hobbit and The Lord of the Rings.',
                website: 'https://www.tolkienestate.com/'
        )
        tolkien.contactInfo = new ContactInfo(
                phone: '+44 20 7946 0958',
                mailingAddress: '1 Oxford Way, Oxford, England'
        )
        tolkien.save(failOnError: true)

        Author austen = new Author(
                name: 'Jane Austen',
                email: 'jane@example.com',
                bio: 'English novelist known primarily for her six major novels of the early 19th century.',
                website: null
        )
        austen.contactInfo = new ContactInfo(
                phone: '+44 1256 462100',
                mailingAddress: 'Steventon Rectory, Hampshire, England'
        )
        austen.save(failOnError: true)

        Book hobbit = new Book(
                title: 'The Hobbit',
                isbn: '9780547928227',
                genre: 'Fiction',
                description: 'A reluctant hobbit, Bilbo Baggins, sets out to the Lonely Mountain with a spirited group of dwarves to reclaim their mountain home.',
                publishedDate: Date.parse('yyyy-MM-dd', '1937-09-21'),
                priceUSD: 14.99G,
                inStock: true,
                author: tolkien
        )
        hobbit.addToTags(fantasy).addToTags(adventure).addToTags(classic).save(failOnError: true)

        Book pride = new Book(
                title: 'Pride and Prejudice',
                isbn: '9780141439518',
                genre: 'Fiction',
                description: 'The story of Mr Bennet of Longbourn estate and his five daughters on the lookout for marriage.',
                publishedDate: Date.parse('yyyy-MM-dd', '1813-01-28'),
                priceUSD: 9.99G,
                inStock: true,
                author: austen
        )
        pride.addToTags(romance).addToTags(classic).save(failOnError: true)
    }

    def destroy = {}
}

Restart the application with ./gradlew bootRun. The H2 database is in-memory by default, so the seed data is recreated on every startup.

4 Scaffolding the CRUD Controllers

Grails dynamic scaffolding generates a default index/list/show/create/edit/delete action set and matching GSPs at runtime, just by setting static scaffold = SomeDomainClass on a controller. This is the starting point we will improve on - the goal is replacing the default views, not removing scaffolding.

4.1 Generate Controllers

Generate two controllers, one per domain class:

./grailsw create-controller example.Book
./grailsw create-controller example.Author

Replace each generated controller with the scaffolded version below. The static scaffold property is the only line you need:

grails-app/controllers/example/BookController.groovy
package example

class BookController {

    static scaffold = Book
}
grails-app/controllers/example/AuthorController.groovy
package example

class AuthorController {

    static scaffold = Author
}

Restart the app, browse to http://localhost:8080/book/index, and you should see a working CRUD UI rendered entirely from runtime-generated GSPs.

4.2 Why the Default Views Are Not Enough

The default scaffolded _form.gsp is generated from a static template inside grails-scaffolding.jar and produces verbose markup like this for every property:

<div class="fieldcontain ${hasErrors(bean: book, field: 'title', 'error')} required">
    <label for="title">
        <g:message code="book.title.label" default="Title" />
        <span class="required-indicator">*</span>
    </label>
    <g:textField name="title" required="" value="${book?.title}"/>
</div>

That is twelve properties times that block per form, copy-pasted into both create.gsp and edit.gsp, plus a similar block per property in show.gsp, plus a hand-built <table> in list.gsp. Multiply by every domain class.

There are three concrete problems with this code that the Fields plugin solves:

  1. No central place to change form styling. Switching the <div class="fieldcontain"> to a Bootstrap 5 <div class="mb-3"> requires editing every domain’s GSPs.

  2. Constraints are not honoured for free. inList does become a <select> because the scaffolding template knows about it, but if you change a domain class to use widget: 'textarea' you have to hand-edit its _form.gsp to swap the input type.

  3. Per-property special cases sprawl. If Book.isbn needs a pattern= attribute, that one-line change goes into book/_form.gsp, mixed in with all the unrelated fields.

The Fields plugin lets you replace the verbose blocks with <f:all> / <f:display> / <f:table>, then customise rendering in one well-known location: grails-app/views/_fields/.

5 Replacing the GSPs with f:all and f:display

The first three tags you will use are <f:all>, <f:field>, and <f:display>. <f:all> walks every editable property of a bean and emits one <f:field> per property. <f:display> does the same in read-only mode using the display templates instead of the form templates.

Switching from the scaffolded form to <f:all> is the move that lets you delete the verbose markup and keep all per-property concerns in _fields/ instead.

5.1 f:all in create.gsp

Override the runtime-generated create.gsp for Book by creating the file under grails-app/views/book/:

grails-app/views/book/create.gsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main"/>
    <title>Create Book</title>
</head>
<body>
<div class="container my-4">
    <h1>Create Book</h1>
    <g:hasErrors bean="${book}">
        <div class="alert alert-danger" role="alert">
            <g:eachError bean="${book}" var="error">
                <div><g:message error="${error}"/></div>
            </g:eachError>
        </div>
    </g:hasErrors>
    <g:form resource="${book}" method="POST">
        <f:all bean="book" except="dateCreated,lastUpdated"/>
        <button type="submit" class="btn btn-primary">Create</button>
        <g:link action="index" class="btn btn-link">Cancel</g:link>
    </g:form>
</div>
</body>
</html>

Three things to notice:

  1. The body of the form is a single <f:all bean="book"/> line.

  2. except="dateCreated,lastUpdated" skips audit columns the user has no business editing - the Fields plugin already excludes id, version, errors, and Hibernate’s belongsTo back-references by default.

  3. Validation feedback is handled by <g:hasErrors> once at the top of the form - the per-field error display is the wrapper template’s responsibility, which we will customise later.

You can do exactly the same thing for Author:

grails-app/views/author/create.gsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main"/>
    <title>Create Author</title>
</head>
<body>
<div class="container my-4">
    <h1>Create Author</h1>
    <g:hasErrors bean="${author}">
        <div class="alert alert-danger" role="alert">
            <g:eachError bean="${author}" var="error">
                <div><g:message error="${error}"/></div>
            </g:eachError>
        </div>
    </g:hasErrors>
    <g:form resource="${author}" method="POST">
        <f:all bean="author" except="dateCreated,lastUpdated,books"/>
        <button type="submit" class="btn btn-primary">Create</button>
        <g:link action="index" class="btn btn-link">Cancel</g:link>
    </g:form>
</div>
</body>
</html>

Restart the app and visit http://localhost:8080/book/create. The form renders with the Fields plugin’s default wrapper and widget templates - which produce sensible but plain markup. We will customise it to match the rest of the application later.

5.2 f:all in edit.gsp

edit.gsp is identical to create.gsp except the form action and the optimistic-locking version field:

grails-app/views/book/edit.gsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main"/>
    <title>Edit Book</title>
</head>
<body>
<div class="container my-4">
    <h1>Edit ${book?.title}</h1>
    <g:hasErrors bean="${book}">
        <div class="alert alert-danger" role="alert">
            <g:eachError bean="${book}" var="error">
                <div><g:message error="${error}"/></div>
            </g:eachError>
        </div>
    </g:hasErrors>
    <g:form resource="${book}" method="PUT">
        <g:hiddenField name="version" value="${book?.version}"/>
        <f:all bean="book" except="dateCreated,lastUpdated"/>
        <button type="submit" class="btn btn-primary">Update</button>
        <g:link resource="${book}" action="show" class="btn btn-link">Cancel</g:link>
    </g:form>
</div>
</body>
</html>

<g:hiddenField name="version" value="${book?.version}"/> is what makes the update action throw a stale-object exception when two browsers edit the same record - keep it.

grails-app/views/author/edit.gsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main"/>
    <title>Edit Author</title>
</head>
<body>
<div class="container my-4">
    <h1>Edit ${author?.name}</h1>
    <g:hasErrors bean="${author}">
        <div class="alert alert-danger" role="alert">
            <g:eachError bean="${author}" var="error">
                <div><g:message error="${error}"/></div>
            </g:eachError>
        </div>
    </g:hasErrors>
    <g:form resource="${author}" method="PUT">
        <g:hiddenField name="version" value="${author?.version}"/>
        <f:all bean="author" except="dateCreated,lastUpdated,books"/>
        <button type="submit" class="btn btn-primary">Update</button>
        <g:link resource="${author}" action="show" class="btn btn-link">Cancel</g:link>
    </g:form>
</div>
</body>
</html>

The <f:all> line is identical to create.gsp. Any customisation you make to a wrapper or widget template applies to both views automatically.

5.3 f:display in show.gsp

<f:display> is the read-only counterpart of <f:all>. It uses the _displayWrapper.gsp and _displayWidget.gsp templates rather than _wrapper.gsp and _widget.gsp.

grails-app/views/book/show.gsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main"/>
    <title>${book?.title}</title>
</head>
<body>
<div class="container my-4">
    <h1>${book?.title}</h1>
    <f:display bean="book" except="dateCreated,lastUpdated"/>
    <div class="mt-3">
        <g:link resource="${book}" action="edit" class="btn btn-warning">Edit</g:link>
        <g:link action="index" class="btn btn-link">Back</g:link>
    </div>
</div>
</body>
</html>
grails-app/views/author/show.gsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main"/>
    <title>${author?.name}</title>
</head>
<body>
<div class="container my-4">
    <h1>${author?.name}</h1>
    <f:display bean="author" except="dateCreated,lastUpdated,books"/>
    <div class="mt-3">
        <g:link resource="${author}" action="edit" class="btn btn-warning">Edit</g:link>
        <g:link action="index" class="btn btn-link">Back</g:link>
    </div>
</div>
</body>
</html>

<f:display bean="book"/> is one line and you never have to revisit it. The display formatting (date format, currency, association labels) lives in _fields/…​/_displayWidget.gsp templates that we will customise later.

6 Replacing list.gsp with f:table

<f:table> renders a collection as an HTML table. Out of the box it picks the first seven non-trivial properties of the domain class, looks each one up in _displayWidget.gsp for the cell rendering, and emits a clickable header row.

6.1 f:table for Books

Override the scaffolded list view for books with this single-line replacement:

grails-app/views/book/index.gsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main"/>
    <title>Books</title>
</head>
<body>
<div class="container my-4">
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h1>Books</h1>
        <g:link action="create" class="btn btn-primary">New Book</g:link>
    </div>
    <f:table collection="${bookList}" properties="title,author,genre,priceUSD,inStock"/>
</div>
</body>
</html>

The two attributes you will use most often:

  • properties="title,author,genre,priceUSD,inStock" - explicitly pick which columns to render and in which order. Without this, <f:table> picks the first seven properties from Book in declaration order.

  • except="id,version,dateCreated,lastUpdated" - drop columns you almost never want in a list view.

Visit http://localhost:8080/book/index and you should see a basic table. The cell formatting (the priceUSD decimal format, the Author association label, the Boolean checkmark) is whatever the default display widgets produce - we will replace those next.

6.2 f:table for Authors

Do the same for authors:

grails-app/views/author/index.gsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main"/>
    <title>Authors</title>
</head>
<body>
<div class="container my-4">
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h1>Authors</h1>
        <g:link action="create" class="btn btn-primary">New Author</g:link>
    </div>
    <f:table collection="${authorList}" properties="name,email,website"/>
</div>
</body>
</html>

<f:table> works for any domain class without per-class boilerplate. If you add a third domain class tomorrow, its list view is a one-line change.

7 Template Lookup Precedence

Before customising anything, you need to understand how the Fields plugin looks up which template to use. Every time <f:field bean="book" property="isbn"/> is rendered, the plugin walks an ordered list of candidate paths and uses the first match it finds.

The lookup order, from most specific to least specific, is roughly:

  1. Per-class, per-property: grails-app/views/_fields/book/isbn/_widget.gsp

  2. Inherited per-class, per-property: the same path traversed up the superclass chain.

  3. Association type: _fields/oneToOne/_widget.gsp, _fields/manyToOne/_widget.gsp, _fields/manyToMany/_widget.gsp, _fields/oneToMany/_widget.gsp.

  4. widget: constraint value: if the domain says widget: 'textarea', the plugin looks for _fields/textarea/_widget.gsp.

  5. Property type: _fields/string/_widget.gsp, _fields/date/_widget.gsp, _fields/boolean/_widget.gsp, _fields/bigDecimal/_widget.gsp, etc. The type name is decapitalised and includes superclasses.

  6. Default fallback: _fields/default/_widget.gsp.

The same precedence applies to:

  • _wrapper.gsp (used by <f:field> and <f:all>)

  • _displayWidget.gsp (used by <f:display> and the cells of <f:table>)

  • _displayWrapper.gsp (used by <f:display>)

  • _table.gsp (used by <f:table>)

The practical consequence: you customise the highest-impact template at the level you want the change to apply.

  • Want every form in the app to use Bootstrap 5 markup? Customise _fields/default/_wrapper.gsp once.

  • Want every Date field to use a date picker? Customise _fields/date/_widget.gsp once.

  • Want only Book.isbn to have an HTML pattern attribute? Customise _fields/book/isbn/_widget.gsp.

The rest of this guide walks each of those layers in turn.

If you want to see exactly which path the plugin chose, set the log level on org.grails.plugin.formfields.FormFieldsTemplateService to DEBUG. Each lookup logs every path it tried in order. The lookup logic itself lives in grails-fields/src/main/groovy/grails/plugin/formfields/FormFieldsTemplateService.groovy if you want to read the source. The plugin also supports a themes/<theme>/ lookup level (controlled by grails.plugin.fields.theme) that comes _before the per-class layer; see grails-doc/src/en/guide/theWebLayer/fields.adoc for the theme story.

8 Customising the Default Wrapper

The wrapper is the markup around a widget: the label, the required-marker, the validation feedback, and the column or grid container. Customising _fields/default/_wrapper.gsp is the single highest-impact change you can make - every form in the app picks it up automatically.

Create the file:

grails-app/views/_fields/default/_wrapper.gsp
<div class="mb-3 ${invalid ? 'has-error' : ''}">
    <label for="${prefix}${property}" class="form-label">
        ${label}<g:if test="${required}"> <span class="text-danger" aria-hidden="true">*</span></g:if>
    </label>

    ${widget}

    <g:if test="${invalid}">
        <div class="invalid-feedback d-block">
            <g:each in="${errors}" var="error">
                <div><g:message error="${error}"/></div>
            </g:each>
        </div>
    </g:if>
</div>

The wrapper template receives a fixed set of model variables from the plugin:

Variable Type Description

bean

Object

The bean being rendered (book, author, etc.).

property

String

The property path (e.g. "title", "author", "address.city").

value

Object

The current property value.

label

String

The label text, already i18n-resolved if a book.title.label message exists.

required

boolean

true if the constraints declare the field non-nullable and non-blank.

invalid

boolean

true if the bean has validation errors on this property.

errors

List<String>

The validation error messages for this property.

widget

String

The HTML produced by the widget template - splice it into the wrapper with ${widget}.

The wrapper above wraps ${widget} in a Bootstrap 5 <div class="mb-3">, adds the required asterisk, and renders the error list. Every form field in the app now uses this layout without any per-domain or per-property markup.

9 Customising Widgets by Type

A widget is the markup inside the wrapper - the actual <input>, <select>, or <textarea> element. The Fields plugin ships defaults that handle every Java type Grails knows about, but the defaults are deliberately plain. Customising widgets per type is what turns the form from "functional" to "fits the rest of the application".

Three type-level customisations cover most of the surface area.

9.1 Custom String Widget With Constraints

The most-used widget is the one for String. The default renders a plain <input type="text">, but Grails constraints can change what the right widget actually is.

Customise _fields/string/_widget.gsp to dispatch on the constraints:

grails-app/views/_fields/string/_widget.gsp
<%-- String widget dispatcher: routes to the right input based on constraints. --%>
<g:set var="cssClass" value="${invalid ? 'form-control is-invalid' : 'form-control'}"/>

<g:if test="${constraints?.inList}">
    <g:select name="${prefix}${property}"
              from="${constraints.inList}"
              value="${value}"
              class="${invalid ? 'form-select is-invalid' : 'form-select'}"
              noSelection="${required ? null : ['': '-- choose --']}"
              required="${required}"/>
</g:if>
<g:elseif test="${constraints?.email}">
    <g:field type="email"
             name="${prefix}${property}"
             value="${value}"
             class="${cssClass}"
             required="${required}"/>
</g:elseif>
<g:elseif test="${constraints?.url}">
    <g:field type="url"
             name="${prefix}${property}"
             value="${value}"
             class="${cssClass}"
             required="${required}"/>
</g:elseif>
<g:elseif test="${constraints?.password}">
    <g:passwordField name="${prefix}${property}"
                     value="${value}"
                     class="${cssClass}"
                     required="${required}"/>
</g:elseif>
<g:elseif test="${constraints?.widget == 'textarea'}">
    <g:textArea name="${prefix}${property}"
                class="${cssClass}"
                rows="4"
                required="${required}">${value}</g:textArea>
</g:elseif>
<g:else>
    <g:textField name="${prefix}${property}"
                 value="${value}"
                 class="${cssClass}"
                 maxlength="${constraints?.maxSize ?: ''}"
                 required="${required}"/>
</g:else>

The dispatcher reads from the constraints model variable (a ConstrainedProperty) and picks the right widget:

  • inList - render a <select> populated from the constraint.

  • email - render <input type="email"> for HTML5 keyboard hints.

  • url - render <input type="url">.

  • password - render <input type="password">.

  • widget == 'textarea' - render a <textarea>.

  • Anything else - render <input type="text">.

Every customisation lives in one file. Adding a new constraint-driven widget (say, inList rendered as radio buttons instead of a select) is a single-file edit that applies to every String property in every domain class in the app.

9.2 Custom Date Widget

Grails ships a <g:datePicker> taglib that emits three separate <select> elements (year, month, day). Modern HTML has <input type="date"> for the same job in one element. Replace the default with the HTML5 control:

grails-app/views/_fields/date/_widget.gsp
<%-- Date widget: HTML5 date picker, ISO-8601 value. --%>
<g:set var="iso" value="${value ? value.format('yyyy-MM-dd') : ''}"/>
<g:field type="date"
         name="${prefix}${property}"
         value="${iso}"
         class="${invalid ? 'form-control is-invalid' : 'form-control'}"
         required="${required}"/>

A few subtleties:

  • The value model variable is a java.util.Date (or java.time.LocalDate if your domain uses the JSR-310 type). Convert to ISO-8601 (yyyy-MM-dd) which is the only format <input type="date"> accepts.

  • <g:formatDate> produces the localised display string in _displayWidget.gsp - we will write that next.

grails-app/views/_fields/date/_displayWidget.gsp
<g:if test="${value}"><g:formatDate date="${value}" format="d MMMM yyyy"/></g:if><g:else><span class="text-muted">&mdash;</span></g:else>

Now every Date property in the app renders as an HTML5 date picker on edit and a localised long-form date on display. Adding a Date startDate to any domain class needs zero per-property GSP work.

9.3 Custom Boolean Widget

Booleans want a checkbox on edit and a check or cross on display. The default plugin widget is almost right but does not include a label-aligned wrapper.

grails-app/views/_fields/boolean/_widget.gsp
<div class="form-check">
    <g:checkBox name="${prefix}${property}"
                value="${value}"
                class="form-check-input ${invalid ? 'is-invalid' : ''}"/>
    <label class="form-check-label" for="${prefix}${property}">${label}</label>
</div>
grails-app/views/_fields/boolean/_displayWidget.gsp
<g:if test="${value}"><i class="bi bi-check-circle-fill text-success" aria-label="yes"></i></g:if><g:else><i class="bi bi-x-circle text-muted" aria-label="no"></i></g:else>

The display widget here uses Bootstrap 5 icon classes (bi-check-circle-fill, bi-x-circle) but you can swap in any icon set or plain text. The point is that every boolean column in every list view, and every boolean field on every show page, now renders the same way without per-property work.

10 Customising Association Widgets

Associations are the part of the Fields plugin’s customisation surface with the thinnest documentation. The plugin exposes four type aliases that you can drop a template under in _fields/ - manyToOne, oneToOne, oneToMany, and manyToMany - and the lookup precedence will pick the most-specific available template at runtime.

The four templates each have a different shape because the underlying GORM relationship has different rendering semantics:

Type alias Owning side Default widget Recommended customisation

manyToOne

Has the FK column on this side

<select> of all candidates

Restyled <select> with deterministic option order

oneToOne

Has the FK column on this side, but only one row

<select> of all candidates

Inline child fieldset when child is owned (belongsTo); otherwise <select>

oneToMany

FK lives on the other side

List of links to children

Read-only list with quick-add link, no editing on parent form

manyToMany

Both sides via a join table

<select multiple>

Checkbox group, plain HTML form-binding semantics

The model variables in the templates are the same as for any other widget (bean, property, value, label, required, invalid, errors) plus one extra:

  • persistentProperty - a org.grails.datastore.mapping.model.PersistentProperty (specifically a ToOne or ToMany subtype). Its associatedEntity.javaClass is the FQN of the other side; its referencedPropertyName is the back-reference name on the other side. You will use those two fields in every association template.

The rest of this chapter walks each of the four type aliases in turn.

10.1 many-to-one

A many-to-one association on Book.author reads belongsTo = [author: Author] and stores author_id on the book table. The default Fields widget renders all candidates as <select> options labelled by Author.toString().

Customise _fields/manyToOne/_widget.gsp once and every many-to-one in the application picks it up:

grails-app/views/_fields/manyToOne/_widget.gsp
<%-- many-to-one: render the owning side as a select of all candidates. --%>
<g:set var="referencedClass" value="${persistentProperty.associatedEntity.javaClass}"/>
<g:set var="candidates" value="${referencedClass.list([sort: 'id', order: 'asc'])}"/>
<g:set var="cssClass" value="${invalid ? 'form-select is-invalid' : 'form-select'}"/>

<g:select name="${prefix}${property}.id"
          from="${candidates}"
          optionKey="id"
          optionValue="${{ it.toString() }}"
          value="${value?.id}"
          class="${cssClass}"
          noSelection="${required ? null : ['null': '-- none --']}"
          required="${required}"/>

Two points worth highlighting:

  • persistentProperty.associatedEntity.javaClass is how you reach the other side without hard-coding Author in the template. The same template now serves every many-to-one in the app - Book.author, OrderItem.product, whatever you add tomorrow.

  • The name attribute uses ${prefix}${property}.id, not just ${property}. Grails data binding writes the looked-up Author instance into book.author when the form posts; the trailing .id is what tells the binder to look up the row, not assign the literal value.

The matching display widget renders the association as a link to the show page of the referenced entity:

grails-app/views/_fields/manyToOne/_displayWidget.gsp
<g:if test="${value}">
    <g:link controller="${persistentProperty.associatedEntity.javaClass.simpleName.toLowerCase()}"
            action="show"
            id="${value.id}">${value.toString().encodeAsHTML()}</g:link>
</g:if>
<g:else><span class="text-muted">&mdash;</span></g:else>

Now both the show page (<f:display bean="book"/>) and the list view (<f:table collection="${bookList}"/>) render book.author as a clickable link to the author show page, with one template instead of one piece of markup per consumer.

10.2 one-to-one

A one-to-one association comes in two shapes:

  1. Owned - the child is part of the parent’s identity (Author.contactInfo with ContactInfo belongsTo: Author). Editing the parent should also edit the child inline.

  2. Independent - the two sides exist separately and are linked by a foreign-key column. Editing the parent should pick an existing instance via a select.

The default Fields widget treats both as a select - which is correct for shape 2 and wrong for shape 1. Customise _fields/oneToOne/_widget.gsp to dispatch on whether the child side is owned:

grails-app/views/_fields/oneToOne/_widget.gsp
<%--
    one-to-one: identical wire format to many-to-one (a single foreign-key
    select), but rendered as a list-group of read-only fields when the
    target side is owned by the parent (belongsTo).
--%>
<g:set var="referencedClass" value="${persistentProperty.associatedEntity.javaClass}"/>
<g:set var="ownedSide" value="${persistentProperty.bidirectional && persistentProperty.inverseSide?.owningSide}"/>

<g:if test="${ownedSide}">
    <%-- The associated record is owned by this bean. Render its editable
         fields inline with the parent prefix so binding flows through. --%>
    <fieldset class="border rounded p-3 mb-2">
        <legend class="float-none w-auto fs-6 px-2 text-muted">
            <g:message code="${referencedClass.simpleName.toLowerCase()}.label"
                       default="${referencedClass.simpleName}"/>
        </legend>
        <f:with bean="${value ?: referencedClass.newInstance()}" prefix="${prefix}${property}.">
            <f:all/>
        </f:with>
    </fieldset>
</g:if>
<g:else>
    <%-- Independent record: behave like many-to-one and select an existing one. --%>
    <g:select name="${prefix}${property}.id"
              from="${referencedClass.list()}"
              optionKey="id"
              optionValue="${{ it.toString() }}"
              value="${value?.id}"
              class="${invalid ? 'form-select is-invalid' : 'form-select'}"
              noSelection="${required ? null : ['null': '-- none --']}"
              required="${required}"/>
</g:else>

How the dispatch works:

  • persistentProperty.bidirectional && persistentProperty.inverseSide?.owningSide is true when the other side declares belongsTo: ParentClass (or its bidirectional equivalent). The owning side’s row is created and destroyed with the parent.

  • When owned, the template uses <f:with bean="${value ?: referencedClass.newInstance()}" prefix="${prefix}${property}."> to recursively render <f:all> for the child. The prefix ensures the child’s input names are like author.contactInfo.phone so Grails data binding cascades into the child object.

  • When not owned, the template falls through to a <select> of existing rows, exactly like many-to-one.

This single template handles both Author.contactInfo (owned, inline edit) and any future Book.cover style relationship (independent, select an existing image) without per-property special-casing.

10.3 one-to-many

A one-to-many association is the read-only side of the relationship. Author.books is implemented by a foreign-key column on the book table - editing it from Author.create.gsp is almost always wrong because it mixes parent and child lifecycles. The right behaviour on the parent form is to render the existing children as a list of links, plus a quick-add button that opens the child controller pre-populated with the parent reference.

grails-app/views/_fields/oneToMany/_widget.gsp
<%--
    one-to-many: usually NOT editable on the parent side. Children are
    edited from their own controller (the side that owns the foreign key).
    Render the existing children as a navigable list with quick-add link.
--%>
<g:set var="childClass" value="${persistentProperty.associatedEntity.javaClass}"/>
<g:set var="childController" value="${childClass.simpleName.toLowerCase()}"/>
<g:set var="children" value="${value ?: []}"/>

<div class="border rounded p-2 bg-light">
    <g:if test="${children}">
        <ul class="list-unstyled mb-2">
            <g:each in="${children}" var="child">
                <li>
                    <g:link controller="${childController}"
                            action="show"
                            id="${child.id}">${child.toString().encodeAsHTML()}</g:link>
                </li>
            </g:each>
        </ul>
    </g:if>
    <g:else>
        <p class="text-muted small mb-2">No ${property} yet.</p>
    </g:else>
    <g:link controller="${childController}"
            action="create"
            params="['${persistentProperty.referencedPropertyName}.id': bean?.id]"
            class="btn btn-sm btn-outline-primary">Add ${childClass.simpleName}</g:link>
</div>

The template:

  • Reads persistentProperty.referencedPropertyName to find the back-reference name on the other side. For Author.books, the back-reference on Book is author, so the quick-add link passes author.id=<parent> to BookController.create() - which Spring binds straight onto the new Book.author field.

  • Renders zero work on the parent form. Submitting the parent form does not save children.

  • Includes a tiny "no children yet" placeholder so the field is not visually empty.

The display side is a comma-separated inline list:

grails-app/views/_fields/oneToMany/_displayWidget.gsp
<g:set var="childClass" value="${persistentProperty.associatedEntity.javaClass}"/>
<g:set var="childController" value="${childClass.simpleName.toLowerCase()}"/>
<g:set var="children" value="${value ?: []}"/>

<g:if test="${children}">
    <ul class="list-inline mb-0">
        <g:each in="${children}" var="child" status="i">
            <li class="list-inline-item">
                <g:link controller="${childController}" action="show" id="${child.id}">
                    ${child.toString().encodeAsHTML()}</g:link><g:if test="${i < children.size() - 1}">,</g:if>
            </li>
        </g:each>
    </ul>
</g:if>
<g:else><span class="text-muted">&mdash;</span></g:else>

<f:display bean="author"/> will now render Author.books as a list of clickable book titles, and <f:table collection="${authorList}"/> will get the same list inside the books column if you include it in properties=.

Avoid putting one-to-many on a <f:table> for very large collections - rendering 50 link badges per row is slow and unhelpful. Either skip the property in properties= or write a per-property _displayWidget.gsp that emits a count link instead (12 books → /author/12/books).

10.4 many-to-many

A many-to-many association is bound through a join table. Book.tags (with matching Tag belongsTo: Book and Tag hasMany: [books: Book]) is rendered by the default Fields plugin as a <select multiple>, which works but is hostile on touch devices and confusing for users who do not know they need to hold Ctrl/Cmd.

A checkbox group is almost always a better default:

grails-app/views/_fields/manyToMany/_widget.gsp
<%--
    many-to-many: render every candidate as a checkbox so the user can
    toggle membership without leaving the form. Spring/Grails data binding
    accepts a multi-valued <input name="<prop>" value="<id>"> form, which is
    what `<g:select multiple>` produces - we use checkboxes for clarity.
--%>
<g:set var="referencedClass" value="${persistentProperty.associatedEntity.javaClass}"/>
<g:set var="candidates" value="${referencedClass.list([sort: 'id', order: 'asc'])}"/>
<g:set var="selectedIds" value="${(value ?: []).collect { it.id } as Set}"/>

<div class="d-flex flex-wrap gap-2 ${invalid ? 'border border-danger rounded p-2' : ''}">
    <g:each in="${candidates}" var="candidate">
        <div class="form-check">
            <input type="checkbox"
                   class="form-check-input"
                   name="${prefix}${property}"
                   id="${prefix}${property}-${candidate.id}"
                   value="${candidate.id}"
                   ${selectedIds.contains(candidate.id) ? 'checked="checked"' : ''}/>
            <label class="form-check-label"
                   for="${prefix}${property}-${candidate.id}">${candidate.toString().encodeAsHTML()}</label>
        </div>
    </g:each>
</div>

The wire format Grails data binding wants for many-to-many is multiple inputs with the same name and different values:

<input type="checkbox" name="tags" value="1"/>
<input type="checkbox" name="tags" value="2"/>

That is what this template emits. When the form posts, Grails binds book.tags = Tag.findAllByIdInList([1, 2]) and the Book save replaces the join-table rows accordingly. There is no need to call addTo/removeFrom manually in the controller - dynamic scaffolding handles that for you because the binder maps the multi-valued parameter back to a Set<Tag>.

The display widget renders the collection as a list of badges:

grails-app/views/_fields/manyToMany/_displayWidget.gsp
<g:set var="childClass" value="${persistentProperty.associatedEntity.javaClass}"/>
<g:set var="childController" value="${childClass.simpleName.toLowerCase()}"/>
<g:set var="children" value="${(value ?: []) as List}"/>

<g:if test="${children}">
    <g:each in="${children}" var="child">
        <span class="badge text-bg-secondary me-1">
            <g:link controller="${childController}" action="show" id="${child.id}" class="text-white text-decoration-none">
                ${child.toString().encodeAsHTML()}
            </g:link>
        </span>
    </g:each>
</g:if>
<g:else><span class="text-muted">&mdash;</span></g:else>
For very large candidate sets (hundreds of Tag rows), a checkbox group is unwieldy. Either fall back to <select multiple> for that one property by writing _fields/book/tags/_widget.gsp (per-property widget overrides the type-level template), or replace the widget with a typeahead control. The point of the type-level template is that it is a sensible default, not a hard rule.

11 Per-Property Overrides

Type-level customisation is enough for 90% of the form. The remaining 10% is the place where an individual property has a presentation requirement that does not generalise to its type. The Fields plugin handles this with a per-class, per-property template path: _fields/<class>/<property>/_widget.gsp (or _wrapper.gsp).

Two examples on the Book class show both axes.

11.1 Per-Property Widget for ISBN

Book.isbn already has a matches: constraint that validates a 10- or 13-digit ISBN on the server. We want the browser to enforce the same pattern client-side via the HTML5 pattern attribute, plus a help line below the input.

Create _fields/book/isbn/_widget.gsp:

grails-app/views/_fields/book/isbn/_widget.gsp
<g:textField name="${prefix}${property}"
             value="${value}"
             class="${invalid ? 'form-control is-invalid' : 'form-control'}"
             pattern="(?:\d{10}|\d{13}|\d{3}-\d-\d{2}-\d{6}-\d)"
             maxlength="20"
             placeholder="ISBN-10 or ISBN-13"
             required="${required}"/>
<small class="form-text text-muted">10 or 13 digits, with or without hyphens.</small>

This is the most-specific lookup the plugin will try, so it wins over _fields/string/_widget.gsp. The String dispatcher you wrote earlier still applies to every other String property on every domain class - only Book.isbn is special-cased.

Note how the per-property widget can still reuse the wrapper from _fields/default/_wrapper.gsp by emitting just the input and the help line - the wrapper supplies the label, required marker, and validation feedback.

11.2 Per-Property Wrapper for Description

Book.description uses widget: 'textarea', which already routes the widget lookup to a textarea. What it does not have is a character counter - a single-property feature that does not belong on every textarea in the app.

Create _fields/book/description/_wrapper.gsp (a per-property wrapper, not widget):

grails-app/views/_fields/book/description/_wrapper.gsp
<div class="mb-3 ${invalid ? 'has-error' : ''}">
    <label for="${prefix}${property}" class="form-label">
        ${label}<g:if test="${required}"> <span class="text-danger" aria-hidden="true">*</span></g:if>
    </label>

    <f:widget property="${property}"/>

    <small class="form-text text-muted">
        <span data-counter-for="${prefix}${property}">0</span> / 2000 characters
    </small>

    <g:if test="${invalid}">
        <div class="invalid-feedback d-block">
            <g:each in="${errors}" var="error">
                <div><g:message error="${error}"/></div>
            </g:each>
        </div>
    </g:if>
</div>

<script>
(function () {
    var input = document.getElementById('${prefix}${property}');
    var counter = document.querySelector('[data-counter-for="${prefix}${property}"]');
    if (!input || !counter) return;
    var update = function () { counter.textContent = (input.value || '').length; };
    input.addEventListener('input', update);
    update();
})();
</script>

A few things to notice:

  1. The wrapper still produces a label, the required marker, and the error list - the same shape as the default wrapper. This consistency is a soft requirement: if every wrapper in the app has the same skeleton, users feel the form is uniform.

  2. The wrapper renders <f:widget property="${property}"/> to ask the plugin to do the widget lookup inside a custom wrapper. The plugin will dispatch to _fields/string/_widget.gsp for the textarea (because of the widget: 'textarea' constraint), then this wrapper splices it inline.

  3. The <small> element with the live counter is per-property concern that does not leak to any other field.

If you want this behaviour on every textarea, move the same template to _fields/textarea/_wrapper.gsp and the precedence rules will pick it up app-wide.

12 Customising the Table Template

<f:table> reads its column list from the properties attribute (or the first seven properties of the bean), looks each cell up via <f:displayWidget>, and emits a <table> with a sortable header row.

Customising the table renders at three layers:

  • Whole-table layout, app-wide - replace the <table> skeleton (header row, body iteration, accessibility attributes) for every <f:table> invocation in the application. This is a single file under grails-app/views/templates/_fields/_table.gsp.

  • Whole-table layout, per-domain or per-page - opt one specific <f:table> invocation into a different template via the template= attribute, with the override file living next to the app-wide one under grails-app/views/templates/_fields/.

  • Per-column cells - each <td> is rendered by <f:displayWidget>, which DOES go through the standard _fields/<class>/<property>/_displayWidget.gsp lookup chain. So one-off cell formatting is a per-property override, not a table-template change.

Unlike the form templates (<f:field>, <f:all>, <f:display> with a property), <f:table> does NOT walk the _fields/<class>/_table.gsp lookup chain. Looking at FormFieldsTagLib.table in the plugin source, the template path is hardcoded to /templates/_fields/$template, where $template defaults to the literal string 'table'. A file at grails-app/views/_fields/book/_table.gsp is never picked up by the plugin, regardless of what domain class you pass.

12.1 App-Wide _table.gsp Override

Replace the default app-wide table template with one that renders as a Bootstrap 5 striped, hover-enabled table, uses <th scope="col"> for accessibility, and adds a trailing actions column for View / Edit / Delete buttons.

Create the file at the path the plugin actually looks up:

grails-app/views/templates/_fields/_table.gsp
<table class="table table-striped table-hover align-middle">
    <thead class="table-dark">
        <tr>
            <g:each in="${domainProperties}" var="prop">
                <th scope="col">
                    <g:message code="${domainClass.simpleName.toLowerCase()}.${prop.name}.label" default="${prop.naturalName}"/>
                </th>
            </g:each>
            <th scope="col" class="text-end">Actions</th>
        </tr>
    </thead>
    <tbody>
        <g:each in="${collection}" var="book">
            <tr>
                <g:each in="${domainProperties}" var="prop">
                    <td>
                        <f:displayWidget bean="${book}" property="${prop.name}"/>
                    </td>
                </g:each>
                <td class="text-end">
                    <g:link resource="${book}" action="show" class="btn btn-sm btn-outline-secondary">View</g:link>
                    <g:link resource="${book}" action="edit" class="btn btn-sm btn-outline-warning">Edit</g:link>
                    <g:form resource="${book}" method="DELETE" class="d-inline">
                        <button type="submit" class="btn btn-sm btn-outline-danger"
                                onclick="return confirm('Delete &quot;' + '${book.title.encodeAsJavaScript()}' + '&quot;?');">
                            Delete
                        </button>
                    </g:form>
                </td>
            </tr>
        </g:each>
    </tbody>
</table>

The model variables passed to the table template are:

Variable Type Description

collection

Collection<?>

The list of beans to render.

domainClass

org.grails.datastore.mapping.model.PersistentEntity

The persistent entity for the bean type. Use domainClass.javaClass.simpleName.toLowerCase() to compute a controller name for <g:link>.

domainProperties

List<Map>

The columns the plugin selected, already filtered by the properties=/except= attributes. Each map has property, label, and bean keys (plus class and theme if those attributes were passed).

displayStyle

String

The displayStyle attribute from <f:table>, defaulting to 'table'. Pass it through to per-cell <f:displayWidget> calls so cell templates can branch on style.

theme

String

The theme attribute from <f:table>. Same passthrough rule.

Inside the loop, render each cell with <f:displayWidget bean="${book}" property="${prop.property}"/>. That call DOES go through the standard lookup chain - it will pick up your per-property _displayWidget.gsp overrides (the Date formatter you wrote in chapter 9.2, the Boolean checkmark from 9.3, the per-column overrides we’ll write in chapter 12.3, etc.).

Because this template lives at templates/_fields/_table.gsp, every <f:table> in the application now uses this layout - books, authors, and any future domain class. Restart the app and visit http://localhost:8080/book/index and http://localhost:8080/author/index; both list pages now use the Bootstrap-styled table.

12.2 Per-Domain Override via template= Attribute

When you want a specific <f:table> invocation to use a different layout - for example, the books list page should show a card-grid instead of a table while every other list view keeps the standard table - opt into a per-page template via the template= attribute:

grails-app/views/book/index.gsp
<f:table collection="${bookList}"
         properties="title,author,genre,priceUSD,inStock"
         template="bookCards"/>

The plugin resolves template="bookCards" to the path /templates/_fields/bookCards, so create the override at:

grails-app/views/templates/_fields/_bookCards.gsp
<div class="row row-cols-1 row-cols-md-3 g-3">
    <g:each in="${collection}" var="book">
        <div class="col">
            <div class="card h-100">
                <div class="card-body">
                    <h5 class="card-title">${book.title.encodeAsHTML()}</h5>
                    <p class="card-subtitle text-muted mb-2">
                        <f:displayWidget bean="${book}" property="author"/>
                    </p>
                    <p class="card-text"><g:formatNumber number="${book.priceUSD}" type="currency" currencyCode="USD"/></p>
                    <g:link resource="${book}" action="show" class="btn btn-sm btn-outline-primary">View</g:link>
                </div>
            </div>
        </div>
    </g:each>
</div>

Inside this template, collection is the same model variable the app-wide _table.gsp receives. The template= attribute is only useful when you want a non-table layout for one specific page; if you want every list view to look like the new layout, change _table.gsp instead.

You can also use template= to swap layouts based on a request attribute (e.g. a "list" vs "grid" toggle), since the attribute value is just a string passed to render() at runtime.

12.3 Per-Column Cell Overrides

Sometimes a single column wants formatting that does not generalise to the property’s type. For example, the priceUSD column on the books table should render with a $ prefix and two decimal places, but you do not want every BigDecimal in the app to look like USD.

Per-class, per-property display widgets work the same way as form widgets. Create _fields/book/priceUSD/_displayWidget.gsp:

grails-app/views/_fields/book/priceUSD/_displayWidget.gsp
<g:formatNumber number="${value}" type="currency" currencyCode="USD"/>

The <f:displayWidget> call inside the table template dispatches to the most-specific path, so the price column picks up the currency formatting. The other BigDecimal columns in the app keep using whatever _fields/bigDecimal/_displayWidget.gsp (or the default) produces.

A second example: render Book.author as the author’s name rather than the default toString():

grails-app/views/_fields/book/author/_displayWidget.gsp
<g:if test="${value}"><g:link controller="author" action="show" id="${value.id}">${value.name}</g:link></g:if>

The same display widget is also picked up by <f:display bean="book" property="author"/> on the show page - one customisation, two consumers.

13 The Result - Clean CRUD GSPs

After all the customisation you have done, the CRUD GSPs for Book and Author are tiny. Compare the original scaffolded _form.gsp (typically 100+ lines per domain class) with what now lives in grails-app/views/book/:

grails-app/views/book/create.gsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main"/>
    <title>Create Book</title>
</head>
<body>
<div class="container my-4">
    <h1>Create Book</h1>
    <g:hasErrors bean="${book}">
        <div class="alert alert-danger" role="alert">
            <g:eachError bean="${book}" var="error">
                <div><g:message error="${error}"/></div>
            </g:eachError>
        </div>
    </g:hasErrors>
    <g:form resource="${book}" method="POST">
        <f:all bean="book" except="dateCreated,lastUpdated"/>
        <button type="submit" class="btn btn-primary">Create</button>
        <g:link action="index" class="btn btn-link">Cancel</g:link>
    </g:form>
</div>
</body>
</html>

Eight lines, including the <head> and the form action. Every per-property concern - the date picker, the ISBN pattern, the description character counter, the inList select for genre - is somewhere under grails-app/views/_fields/, written once, and applies everywhere.

The list view is even smaller:

grails-app/views/book/index.gsp
<%@ page contentType="text/html;charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main"/>
    <title>Books</title>
</head>
<body>
<div class="container my-4">
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h1>Books</h1>
        <g:link action="create" class="btn btn-primary">New Book</g:link>
    </div>
    <f:table collection="${bookList}" properties="title,author,genre,priceUSD,inStock"/>
</div>
</body>
</html>

Two attributes on a single <f:table> tag. The Bootstrap 5 striped layout, the action buttons, the currency formatting, and the boolean checkmark all come from _fields/book/_table.gsp and the cell-level _displayWidget.gsp templates.

The pattern that makes this work:

  1. Layer customisations from broad to narrow. _fields/default/_wrapper.gsp first (all forms), _fields/<type>/_widget.gsp next (all String/Date/Boolean), _fields/<class>/<property>/_widget.gsp last (specific cases).

  2. Keep the GSPs in views/<domain>/ thin. <f:all>, <f:display>, and <f:table> are the three tags you should see; anything else belongs in _fields/.

  3. Reuse the wrapper from custom widgets. When you write a per-property widget, render the input only - let the wrapper provide the label and the error feedback.

Adding a third domain class to this application now takes three steps:

  1. Write the domain class with its constraints.

  2. Set static scaffold = NewClass on a controller.

  3. Override views/newClass/{create,edit,show,index}.gsp with the four-line template that calls <f:all>, <f:display>, and <f:table>.

The new domain class inherits every type-level and constraint-level customisation you wrote for Book and Author. That is the payoff for keeping the formatting logic in _fields/.

14 Do you need help with Grails?

Help with Apache Grails

Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.