> For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt.

# Dialog Validation

## 11 – Dialog Validation

> **Target:** 3–4 years experienced AEM Developer **Syllabus point covered (20):** _"How do you write validation JS for a component dialog?"_ **Project domain:** a global energy technology company's marketing site.

***

## Before we start — two things that reframe this question

Your syllabus asks how to write validation JavaScript. That's the right question, but there are two things worth knowing before you write a line of it, and both are good things to say in an interview.

**First: try not to write any.** Granite UI gives you `required`, and the field types themselves constrain a lot — a pathfield restricted with `rootPath` cannot produce a bad path, and a select cannot produce a value outside its options. A good `fieldDescription` prevents more errors than a validator catches, because it stops the author making the mistake rather than telling them off afterwards. Reaching for custom JavaScript first is a mild code smell.

**Second, and more important: dialog validation is UX, not enforcement.**

It runs in the author's browser. It can be bypassed — by disabling JavaScript, by posting to the node directly, by a package install, by a content migration script, by MSM rolling a value in from a blueprint. So if a constraint genuinely matters for correctness or security, **it cannot only live in the dialog**.

That distinction is the thing that separates a good answer from a merely correct one. Section 2.8 covers where the real enforcement goes.

***

## 1. Introduction

### 1.1 What dialog validation actually is

Recall from file 02: a dialog is an **HTML form**, and saving it is a POST handled by the **Sling Post Servlet**. There is no special "dialog save" API.

So dialog validation is exactly what form validation is anywhere on the web — **client-side JavaScript that inspects field values and blocks submission if something is wrong.**

AEM provides a framework for it called **foundation-validation**, part of Granite UI. It is jQuery-based, and it works by letting you **register a validator against a CSS selector**. Any field matching that selector gets validated by your function.

That selector-based design is worth noticing, because it means a validator is not attached to a specific field — it is attached to a _pattern_, and any field that matches picks it up. One validator can serve twenty fields across ten components.

### 1.2 What you get for free

Before any JavaScript:

| Mechanism                      | What it does                                 |
| ------------------------------ | -------------------------------------------- |
| `required="{Boolean}true"`     | Field cannot be empty; submission is blocked |
| `rootPath` on a pathfield      | Author can only browse within that subtree   |
| A `select` field               | Value must be one of the options             |
| `min` / `max` on a numberfield | Numeric bounds                               |
| Field type itself              | A datepicker cannot produce a non-date       |

**The point worth making:** most validation requirements are better solved by **choosing the right field and constraining it** than by validating free text afterwards. If an author must pick a product page, a pathfield with `rootPath` is better than a textfield plus a validator — it makes the wrong answer unreachable rather than rejected.

### 1.3 A real project example to adapt

> "We have three custom validators. One checks that our product code fields match the expected format, because the codes feed an external system and a malformed one fails silently downstream. One enforces a character limit on SEO fields — technically it's a soft limit, so it warns rather than blocks. And one is a cross-field validator on a date range, making sure the end date is after the start.
>
> They live in a clientlib with category `cq.authoring.dialog`, which is what AEM loads in the dialog context. And for anything that genuinely matters we don't rely on the dialog alone — the product code is also validated in the Sling Model, because dialog validation is client-side and can be bypassed by a package install or an MSM rollout."

That covers the mechanism, the clientlib, the soft-versus-hard distinction, and the enforcement point.

***

## 2. Core Concepts

### 2.1 The foundation-validation registry

**This is the API your syllabus is asking about.**

```javascript
(function ($, $document) {
    "use strict";

    $.validator.register({
        selector: "[data-validation='energy.productcode']",

        validate: function (el) {
            var value = el.val();

            if (value && !/^[A-Z]{2}-\d{4}$/.test(value)) {
                // Returning a STRING means INVALID.
                // The string is the message shown to the author.
                return "Product code must be two letters, a hyphen, then four digits — e.g. TX-4000";
            }

            // Returning nothing means VALID.
        }
    });

})(jQuery, jQuery(document));
```

**Three things to understand about that:**

**`selector`** is a CSS selector. Any field in any dialog matching it gets this validator. That is why validators are reusable across components.

**`validate`** receives the element and returns either **a string** (invalid — and the string is the message shown) or **nothing** (valid). That inverted convention catches people: you return a value to indicate _failure_, not success.

**The empty check.** Notice `if (value && ...)`. An empty field is not this validator's problem — that is what `required` is for. Without that guard, an optional field would be flagged invalid when left blank, which is wrong and infuriating for authors.

### 2.2 Connecting the dialog field to the validator

The link is the **`validation` property** on the field, which Granite renders as a **`data-validation` attribute** on the element.

```xml
<productCode
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
    fieldLabel="Product Code"
    fieldDescription="Format: two letters, hyphen, four digits — for example TX-4000"
    name="./productCode"
    required="{Boolean}true"
    validation="energy.productcode"/>
```

That renders roughly as:

```html
<input type="text" name="./productCode" data-validation="energy.productcode">
```

Which is what `selector: "[data-validation='energy.productcode']"` matches.

**Why this indirection is good:** the field names a validator; the validator declares what it matches. Neither knows about the other's implementation, so you can change the rule without touching any dialog.

**You can select by anything, though.** The `data-validation` attribute is the conventional route, but the selector is a plain CSS selector:

```javascript
selector: "[name='./productCode']"                    // by field name
selector: ".energy-product-code"                      // by granite:class
selector: "coral-multifield [data-validation='x']"    // scoped to multifields
```

**Namespace your validator names.** `energy.productcode`, not `productcode`. These are global across every dialog in the instance — including Adobe's — so a generic name risks colliding with something you didn't write.

### 2.3 Where the clientlib goes

**This is the part that most often causes "my validation isn't running."**

```text
/apps/energy/clientlibs/clientlib-authoring/
    ├── .content.xml
    ├── js.txt
    └── js/
        └── validators.js
```

`.content.xml`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:cq="http://www.day.com/jcr/cq/1.0"
          xmlns:jcr="http://www.jcp.org/jcr/1.0"
    jcr:primaryType="cq:ClientLibraryFolder"
    categories="[cq.authoring.dialog]"/>
```

**`cq.authoring.dialog` is the category AEM loads in the dialog context.** Get it wrong and your JavaScript never loads, the validator is never registered, and the field simply saves — with no error anywhere, because nothing failed. It just isn't there.

**Related categories worth knowing:**

| Category                      | Loaded in                                          |
| ----------------------------- | -------------------------------------------------- |
| `cq.authoring.dialog`         | Component dialogs — **the one you want**           |
| `cq.authoring.editor.hook`    | The page editor itself                             |
| `granite.ui.coral.foundation` | The general Granite UI (Adobe's — don't add to it) |

**And a point that connects to file 04:** this is an **author-only** clientlib. It must never be embedded into the site bundle that visitors download. Authoring JavaScript shipped to the public is pure waste — and occasionally an information leak, since validators often encode business rules.

### 2.4 Hard versus soft validation

A distinction worth raising, because it shows you think about authors rather than just rules.

**Hard validation blocks the save.** Return a message from `validate` and the field is marked invalid and submission is prevented. Right when the value would actually break something.

**Soft validation warns but allows.** Sometimes the rule is a guideline — an SEO title _should_ be under 60 characters, but an author may have a good reason to exceed it, and blocking them is presumptuous.

For soft rules, don't return a message. Show a hint instead:

```javascript
$.validator.register({
    selector: "[data-validation='energy.seolength']",
    validate: function (el) {
        var $el = $(el);
        var max = parseInt($el.data("maxLength"), 10) || 60;
        var length = ($el.val() || "").length;

        // A WARNING, not an error -- we show it and allow the save.
        var $hint = $el.closest(".coral-Form-fieldwrapper").find(".energy-length-hint");
        if (length > max) {
            $hint.text(length + " characters — search engines typically show about " + max)
                 .addClass("is-warning");
        } else {
            $hint.text("").removeClass("is-warning");
        }

        // Return NOTHING: the field stays valid and the author can save.
    }
});
```

**The interview point:**

> "I'd distinguish between rules that must block the save and rules that are guidance. A malformed product code breaks a downstream integration, so that blocks. An SEO title over sixty characters is a recommendation — the author might have a reason, and blocking them is presumptuous. For those I show a hint and let them save. Over-blocking is how authors end up hating a component."

### 2.5 Cross-field validation — and the trap in it

The genuinely tricky case: validating one field against another.

```javascript
$.validator.register({
    selector: "[data-validation='energy.enddate']",
    validate: function (el) {
        var $el = $(el);
        var $form = $el.closest("form");

        var start = $form.find("[name='./startDate']").val();
        var end = $el.val();

        if (start && end && new Date(end) < new Date(start)) {
            return "End date must be after the start date";
        }
    }
});
```

**That works — and it has a bug that catches nearly everyone.**

**The validator only runs when _its own_ field changes.** So:

1. Author sets start = 1 June, end = 1 July. Valid. ✓
2. Author then changes start to 1 August.
3. **The end-date validator does not re-run**, because the end date didn't change.
4. The dialog saves with an invalid range.

**The fix is to trigger revalidation of the dependent field when the other one changes:**

```javascript
$document.on("change", "[name='./startDate']", function () {
    var $form = $(this).closest("form");
    // Poke the dependent field so its validator runs again
    $form.find("[name='./endDate']").trigger("change");
});
```

**This is an excellent thing to raise unprompted**, because it demonstrates you've actually built one rather than read about it:

> "The trap with cross-field validation is that a validator only fires when its own field changes. So if someone validates the end date against the start date, and the author then goes back and changes the _start_ date, the validator never re-runs and an invalid range saves. You have to explicitly trigger a change event on the dependent field when the other one changes."

### 2.6 Passing configuration into a validator with granite:data

Rather than hardcoding limits in JavaScript, put them on the field:

```xml
<seoTitle
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
    fieldLabel="SEO Title"
    name="./seoTitle"
    validation="energy.maxlength">
    <granite:data
        jcr:primaryType="nt:unstructured"
        maxLength="60"/>
</seoTitle>
```

`granite:data` renders as `data-*` attributes, so in the validator:

```javascript
var max = parseInt($(el).data("maxLength"), 10) || 60;
```

**Why this matters:** one validator now serves every length-limited field in the project, with the limit configured per field in the dialog. Without it you'd need a validator per limit, or the limit hardcoded somewhere a template author can't see.

### 2.7 Show/hide — adjacent, and frequently asked together

Not validation, but it comes up in the same conversation and AEM gives it to you built in.

**Show a group of fields based on a dropdown:**

```xml
<linkType
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/select"
    fieldLabel="Link Type"
    name="./linkType"
    granite:class="cq-dialog-dropdown-showhide">
    <granite:data
        jcr:primaryType="nt:unstructured"
        cq-dialog-dropdown-showhide-target=".link-type-target"/>
    <items jcr:primaryType="nt:unstructured">
        <internal jcr:primaryType="nt:unstructured" text="Internal page" value="internal"/>
        <external jcr:primaryType="nt:unstructured" text="External URL" value="external"/>
        <document jcr:primaryType="nt:unstructured" text="Document download" value="document"/>
    </items>
</linkType>

<internalGroup
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/container"
    granite:class="hide link-type-target">
    <granite:data jcr:primaryType="nt:unstructured" showhidetargetvalue="internal"/>
    <items jcr:primaryType="nt:unstructured">
        <internalPath
            jcr:primaryType="nt:unstructured"
            sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
            fieldLabel="Page"
            name="./internalPath"
            rootPath="/content/energy"/>
    </items>
</internalGroup>
```

**The mechanism:** `cq-dialog-dropdown-showhide` on the select, a `cq-dialog-dropdown-showhide-target` selector pointing at the groups, and each group carrying `showhidetargetvalue` for the option that reveals it. There is an equivalent `cq-dialog-checkbox-showhide`.

**Worth mentioning because it's free.** Candidates often write custom JavaScript for this, and AEM already does it.

### 2.8 Where real enforcement lives

**The most important section in this file.**

Dialog validation runs in the author's browser. It can be bypassed:

- JavaScript disabled, or an error earlier in the clientlib preventing registration
- POSTing to the node directly
- Installing a content package
- A content migration script
- **An MSM rollout pushing a value in from a blueprint** — a real one on a multi-country site
- Content copied between environments

**So for anything that genuinely matters, validate again where it counts:**

**In the Sling Model**, treating the value as untrusted:

```java
@PostConstruct
protected void init() {
    // The dialog validator SHOULD have caught this, but a package
    // install or MSM rollout can write anything.
    this.validCode = productCode != null && PRODUCT_CODE.matcher(productCode).matches();
}

public boolean isReady() {
    return validCode;      // render nothing rather than something broken
}
```

**Or in a `SlingPostProcessor`**, which runs after the Sling Post Servlet has processed a POST — server-side, so it applies regardless of how the POST arrived.

**The interview answer:**

> "Dialog validation is UX, not enforcement. It runs in the author's browser, so it can be bypassed by disabling JavaScript, by POSTing directly, by a package install, by a content migration, or — the one people forget — by an MSM rollout pushing a value in from a blueprint.
>
> So for anything that actually matters I validate again server-side. Usually in the Sling Model, treating the value as untrusted and exposing an `isReady()` that renders nothing rather than something broken. For a hard constraint I'd use a `SlingPostProcessor`, which runs after the Sling Post Servlet regardless of how the POST arrived.
>
> The dialog validator is still worth having — it gives the author immediate, specific feedback at the point they can fix it, which is a much better experience than discovering a broken page later. But it's the first line, not the only one."

***

## 3. Internal Working

### 3.1 The validation lifecycle

sequenceDiagram participant A as Author participant D as Dialog (browser) participant V as foundation-validation participant P as Sling Post Servlet participant J as JCR Note over D,V: On dialog open, the cq.authoring.dialog\<br/>clientlib registers all validators A->>D: types in a field D->>V: change / blur event V->>V: find validators whose SELECTOR matches V->>V: run validate(el) alt returns a string V->>D: mark invalid, show the message A->>D: clicks Save D->>D: BLOCKED else returns nothing V->>D: field is valid A->>D: clicks Save D->>P: POST./field=value P->>J: write the property end

**Three things to draw out:**

**Validators register when the clientlib loads, not per field.** If the clientlib isn't in the dialog context, nothing registers and every field silently passes.

**Validation runs on change and blur, and again on submit.** So a field the author never touched still gets checked at submit time — which is how `required` catches an untouched empty field.

**Once it reaches the Sling Post Servlet, no client-side validation exists any more.** That is the whole argument in 2.8, expressed as a diagram.

### 3.2 Why "my validation isn't running" is nearly always one of three things

graph TD A\["The validator never fires"] --> B\{"Is the clientlib loading?\<br/>network tab, dialog context"} B -->|No| C\["WRONG CATEGORY\<br/>needs cq.authoring.dialog"] B -->|Yes| D\{"Does the selector match?\<br/>inspect the field element"} D -->|No| E\["The data-validation attribute\<br/>isn't rendering, or the\<br/>selector is wrong"] D -->|Yes| F\{"Any JS error in the console\<br/>BEFORE registration?"} F -->|Yes| G\["An earlier error stopped\<br/>the file executing"] F -->|No| H\["Check the validate function —\<br/>is it returning a string?"]

**The third branch is the sneaky one.** A JavaScript error anywhere earlier in the same file — or in another file loaded before it in the same clientlib — stops execution, so `$.validator.register` never runs. The symptom is a validator that simply doesn't exist, with the real error sitting further up the console.

***

## 8. Coding Examples

### 8.1 The complete validator clientlib

`/apps/energy/clientlibs/clientlib-authoring/.content.xml`

```xml
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:cq="http://www.day.com/jcr/cq/1.0"
          xmlns:jcr="http://www.jcp.org/jcr/1.0"
    jcr:primaryType="cq:ClientLibraryFolder"

    <!-- THE CATEGORY THAT MATTERS.
         cq.authoring.dialog is what AEM loads in the dialog context.
         Get this wrong and NOTHING registers -- every field silently
         passes, with no error anywhere, because nothing failed. -->
    categories="[cq.authoring.dialog]"/>
```

`js.txt`

```text
#base=js

validators.js
listeners.js
```

`js/validators.js`

```javascript
/**
 * Dialog validators for the energy site.
 *
 * Registered against the Granite UI foundation-validation framework.
 * Validators are selector-based, so one validator serves every field
 * whose data-validation attribute matches -- across all components.
 *
 * NAMING: always namespaced ("energy.*"). These are GLOBAL across
 * every dialog in the instance, including Adobe's own, so a generic
 * name risks colliding with something we didn't write.
 */
(function ($, $document) {
    "use strict";

    var PRODUCT_CODE = /^[A-Z]{2}-\d{4}$/;

    // ---------------------------------------------------------------
    // 1. FORMAT VALIDATION (hard -- blocks the save)
    // ---------------------------------------------------------------
    $.validator.register({
        selector: "[data-validation='energy.productcode']",

        validate: function (el) {
            var value = el.val();

            // Empty is NOT this validator's problem -- that's what
            // required is for. Without this guard an optional field
            // would be flagged when left blank, which is wrong and
            // infuriating for authors.
            if (!value) {
                return;
            }

            if (!PRODUCT_CODE.test(value)) {
                // Returning a STRING means INVALID, and the string is
                // the message shown. Include an EXAMPLE -- "invalid
                // format" tells the author nothing actionable.
                return "Product code must be two letters, a hyphen, then four digits — for example TX-4000";
            }

            // Returning NOTHING means valid.
        }
    });

    // ---------------------------------------------------------------
    // 2. INTERNAL LINK VALIDATION (hard)
    // ---------------------------------------------------------------
    $.validator.register({
        selector: "[data-validation='energy.internallink']",

        validate: function (el) {
            var value = el.val();
            if (!value) {
                return;
            }
            // External URLs are fine; internal paths must be in our tree
            if (value.indexOf("/") === 0 && value.indexOf("/content/energy") !== 0) {
                return "Internal links must point to a page under /content/energy";
            }
        }
    });

    // ---------------------------------------------------------------
    // 3. LENGTH GUIDANCE (SOFT -- warns but ALLOWS the save)
    // ---------------------------------------------------------------
    $.validator.register({
        selector: "[data-validation='energy.seolength']",

        validate: function (el) {
            var $el = $(el);

            // Configuration comes from granite:data on the FIELD, so one
            // validator serves every length-limited field with its own
            // limit set in its own dialog.
            var max = parseInt($el.data("maxLength"), 10) || 60;
            var length = ($el.val() || "").length;

            var $wrapper = $el.closest(".coral-Form-fieldwrapper");
            var $hint = $wrapper.find(".energy-length-hint");

            if ($hint.length === 0) {
                $hint = $("<span class='energy-length-hint'></span>").appendTo($wrapper);
            }

            if (length > max) {
                $hint.text(length + " characters — search engines typically show about " + max)
                     .addClass("is-warning");
            } else {
                $hint.text(length + " / " + max).removeClass("is-warning");
            }

            // RETURN NOTHING.
            //
            // This is guidance, not correctness. An author may have a
            // genuine reason to exceed it, and blocking them is
            // presumptuous. Over-blocking is how authors come to resent
            // a component.
        }
    });

    // ---------------------------------------------------------------
    // 4. CROSS-FIELD VALIDATION (hard)
    // ---------------------------------------------------------------
    $.validator.register({
        selector: "[data-validation='energy.enddate']",

        validate: function (el) {
            var $el = $(el);
            var $form = $el.closest("form");

            var start = $form.find("[name='./startDate']").val();
            var end = $el.val();

            if (!start || !end) {
                return;
            }

            if (new Date(end) < new Date(start)) {
                return "End date must be after the start date";
            }

            // NOTE: this validator only fires when THIS field changes.
            // If the author later edits the START date, it does NOT
            // re-run. See listeners.js for the fix.
        }
    });

})(jQuery, jQuery(document));
```

`js/listeners.js` — **the cross-field fix**

```javascript
/**
 * Triggers revalidation of dependent fields.
 *
 * THE PROBLEM THIS SOLVES:
 *
 *   A Granite validator only fires when ITS OWN field changes. So:
 *
 *     1. Author sets start = June, end = July     → valid
 *     2. Author goes back, changes start = August
 *     3. The END DATE validator does NOT re-run
 *     4. The dialog saves with an invalid range
 *
 *   It works perfectly if you fill the fields in order, which is
 *   exactly what every test does and no real author does.
 */
(function ($, $document) {
    "use strict";

    $document.on("change", "[name='./startDate']", function () {
        var $form = $(this).closest("form");
        // Poke the dependent field so its validator runs again
        $form.find("[name='./endDate']").trigger("change");
    });

    // Live character count as the author types, not just on blur
    $document.on("input", "[data-validation='energy.seolength']", function () {
        $(this).trigger("change");
    });

})(jQuery, jQuery(document));
```

### 8.2 The dialog fields

```xml
<productCode
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
    fieldLabel="Product Code"

    <!-- fieldDescription PREVENTS errors; the validator only CATCHES
         them. Showing the format up front is worth more than a good
         error message afterwards. -->
    fieldDescription="Two letters, a hyphen, then four digits — for example TX-4000"

    name="./productCode"
    required="{Boolean}true"

    <!-- Renders as data-validation="energy.productcode",
         which the validator's selector matches. -->
    validation="energy.productcode"/>

<seoTitle
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
    fieldLabel="SEO Title"
    fieldDescription="Shown in search results. Around 60 characters displays well."
    name="./seoTitle"
    validation="energy.seolength">
    <!-- Configuration for the validator, per field.
         Renders as data-max-length, read with $(el).data("maxLength"). -->
    <granite:data
        jcr:primaryType="nt:unstructured"
        maxLength="60"/>
</seoTitle>

<startDate
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/datepicker"
    fieldLabel="Campaign Start"
    name="./startDate"
    type="date"
    displayedFormat="DD MMMM YYYY"
    valueFormat="YYYY-MM-DD"/>

<endDate
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/datepicker"
    fieldLabel="Campaign End"
    name="./endDate"
    type="date"
    displayedFormat="DD MMMM YYYY"
    valueFormat="YYYY-MM-DD"
    validation="energy.enddate"/>

<!-- No validator needed: rootPath makes a wrong answer UNREACHABLE
     rather than rejected. Constraining the field beats validating it. -->
<productPage
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
    fieldLabel="Product Page"
    name="./productPage"
    rootPath="/content/energy/global/en/products"/>
```

### 8.3 The server-side counterpart

**The layer that actually holds.**

```java
package com.energy.core.models;

import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.SlingObject;
import org.apache.sling.models.annotations.injectorspecific.ValueMapValue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.PostConstruct;
import java.util.regex.Pattern;

@Model(adaptables = Resource.class,
       defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class ProductCodeModel {

    private static final Logger LOG = LoggerFactory.getLogger(ProductCodeModel.class);

    // The SAME rule as the dialog validator. Kept here because the
    // dialog can be bypassed and this is the layer that has to hold.
    private static final Pattern PRODUCT_CODE = Pattern.compile("^[A-Z]{2}-\\d{4}$");

    @ValueMapValue
    private String productCode;

    @SlingObject
    private Resource resource;

    private boolean valid;

    @PostConstruct
    protected void init() {
        this.valid = StringUtils.isNotBlank(productCode)
                && PRODUCT_CODE.matcher(productCode).matches();

        if (StringUtils.isNotBlank(productCode) && !valid) {
            // LOG IT.
            //
            // The dialog validator SHOULD have caught this. If an invalid
            // value is here, it arrived some other way -- a package
            // install, a content migration, or an MSM rollout pushing a
            // value in from a blueprint. Nobody is going to report that
            // as a bug, so it has to be discoverable.
            LOG.warn("Invalid product code '{}' at {} — dialog validation was bypassed",
                    productCode, resource.getPath());
        }
    }

    public String getProductCode() {
        return valid ? productCode : null;
    }

    /**
     * Render NOTHING rather than something broken. A component that
     * renders nothing is diagnosable; one that renders and silently
     * fails downstream is not.
     */
    public boolean isReady() {
        return valid;
    }
}
```

### 8.4 Show/hide without custom JavaScript

```xml
<linkType
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/select"
    fieldLabel="Link Type"
    name="./linkType"
    granite:class="cq-dialog-dropdown-showhide">
    <granite:data
        jcr:primaryType="nt:unstructured"
        cq-dialog-dropdown-showhide-target=".link-type-target"/>
    <items jcr:primaryType="nt:unstructured">
        <internal jcr:primaryType="nt:unstructured" text="Internal page"     value="internal" selected="{Boolean}true"/>
        <external jcr:primaryType="nt:unstructured" text="External URL"      value="external"/>
        <document jcr:primaryType="nt:unstructured" text="Document download" value="document"/>
    </items>
</linkType>

<internalGroup
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/container"
    granite:class="hide link-type-target">
    <granite:data jcr:primaryType="nt:unstructured" showhidetargetvalue="internal"/>
    <items jcr:primaryType="nt:unstructured">
        <internalPath
            jcr:primaryType="nt:unstructured"
            sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
            fieldLabel="Page"
            name="./internalPath"
            rootPath="/content/energy"/>
    </items>
</internalGroup>

<externalGroup
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/container"
    granite:class="hide link-type-target">
    <granite:data jcr:primaryType="nt:unstructured" showhidetargetvalue="external"/>
    <items jcr:primaryType="nt:unstructured">
        <externalUrl
            jcr:primaryType="nt:unstructured"
            sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
            fieldLabel="URL"
            name="./externalUrl"
            validation="energy.externalurl"/>
    </items>
</externalGroup>
```

**Zero custom JavaScript.** This is built into AEM, and candidates frequently write their own. There is an equivalent `cq-dialog-checkbox-showhide`.

***

## 9. Common Mistakes

| The mistake                                                   | What actually happens                                            | The fix                                           |
| ------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------- |
| Wrong clientlib category                                      | **Nothing registers — silently.** Every field passes.            | `cq.authoring.dialog`                             |
| Validator name not namespaced                                 | Collides with another validator, including Adobe's               | `energy.something`                                |
| No empty-value guard                                          | Optional fields flagged invalid when blank                       | `if (!value) return;`                             |
| Returning `true` for valid                                    | Backwards — a truthy return means invalid                        | Return **nothing** for valid                      |
| Vague error messages                                          | Author can't tell what to change                                 | Include the expected format and an example        |
| Cross-field validator with no trigger                         | **Stale** — doesn't re-run when the other field changes          | Trigger a change on the dependent field           |
| Hard-blocking a guideline                                     | Authors resent the component and route around it                 | Warn and allow                                    |
| Relying only on dialog validation                             | Bypassed by package install, migration, **MSM rollout**          | Validate again in the model                       |
| Hardcoding limits in JavaScript                               | One validator per limit, invisible to template authors           | `granite:data` on the field                       |
| Authoring clientlib in the site bundle                        | Author-only JavaScript shipped to visitors                       | Separate category, never embedded                 |
| Custom JS for show/hide                                       | Reinventing something AEM provides                               | `cq-dialog-dropdown-showhide`                     |
| Free text plus a validator where a constrained field would do | Rejects bad input instead of preventing it                       | Pathfield with `rootPath`, or a select            |
| Calling an external API from a validator                      | Slow dialogs, and the author is blocked by someone else's outage | An explicit check button, or validate server-side |
| Slow validate function                                        | Runs on every change — the dialog feels laggy                    | Keep it cheap; no network calls                   |
| A JS error earlier in the file                                | Registration never runs; the validator doesn't exist             | Check the console **above** where you're looking  |

***

## 10. Best Practices

**Prefer prevention.** Constrain the field type before validating free text. A pathfield with `rootPath` beats a textfield plus a regex. A `fieldDescription` stating the format prevents more errors than any message catches.

**Distinguish rules from guidelines.** Hard-block what would actually break; warn on what's merely advisable. Over-blocking is how authors come to resent a component and start working around it.

**Write messages an author can act on.** Include the expected format and an example. "Invalid" is useless.

**Namespace validators**, and configure them with `granite:data` rather than hardcoding, so one validator serves many fields.

**Guard the empty case.** Emptiness is `required` 's job.

**Handle the cross-field trap** explicitly — a dependent validator needs a trigger from the field it depends on.

**Always validate server-side too** for anything that matters, and log at warn when the server rejects something, because a bypassed value arrived some way nobody will report.

**Keep the validate function cheap.** It runs on every change. No network calls.

**Keep authoring clientlibs out of the site bundle.**

***

## 11. Debugging Tips

**The three causes, in order**, from section 3.2:

**1. Is the clientlib loading?** Open a dialog, look at the network tab, and search for your category. If it isn't there, the category is wrong — and remember this failure is completely silent, because a missing validator doesn't error, it just doesn't exist.

**2. Does the selector match?** Inspect the field element in the DOM and confirm the `data-validation` attribute is actually rendered with the value you expect. If the field has no attribute, the `validation` property is missing or misspelled in the dialog XML.

**3. Is there a JavaScript error _earlier_ in the file?** This is the sneaky one. An error anywhere before `$.validator.register` stops execution, so registration never happens. The real error is further up the console than where you're looking.

**A quick way to confirm registration** — in the browser console with a dialog open:

```javascript
// Does the selector match anything in this dialog?
jQuery("[data-validation='energy.productcode']").length
```

Zero means the field isn't rendering the attribute. Non-zero means the field is fine and the problem is registration.

**When validation blocks but shows no message:** the validate function is returning something non-string-like, or the field wrapper structure isn't what your custom display code expects.

**When it works on one dialog and not another:** the selector is matching more or less than you intended. Test it in the console on both dialogs.

| Tool                                     | Answers                                    |
| ---------------------------------------- | ------------------------------------------ |
| Network tab, dialog open                 | Did the authoring clientlib load           |
| Element inspector                        | Is `data-validation` rendered              |
| Browser console                          | JS errors that stopped registration        |
| `jQuery("[data-validation='x']").length` | Does the selector match                    |
| `?debugClientLibs=true` on a public page | Did authoring JS leak into the site bundle |

***

## 12. Performance Notes

**The validate function runs on every change event.** A regex is fine. A repository lookup or an HTTP call is not — the dialog becomes laggy and the author feels it on every keystroke.

**Never call an external system from a validator.** Beyond the latency, it means the author is blocked when a system they've never heard of is down. If you genuinely need to check against an external source, make it an explicit action the author triggers, not something that runs while they type.

**Keep the authoring clientlib small and separate.** It only loads on author, so it doesn't affect site performance — but only if it isn't embedded in the site bundle.

**`input` events fire per keystroke.** If you bind to them for a live character count, that's fine for a string length check and wrong for anything heavier.

***

## 15. Comparison Tables

**Validation layers**

| Layer                   | Runs where | Bypassable | Purpose                                  |
| ----------------------- | ---------- | ---------- | ---------------------------------------- |
| Field type / `rootPath` | Browser    | Hard to    | **Prevention** — makes wrong unreachable |
| `required`              | Browser    | Yes        | Basic completeness                       |
| Custom validator JS     | Browser    | Yes        | Specific rules, good messages            |
| Sling Model check       | **Server** | **No**     | Correctness at render                    |
| `SlingPostProcessor`    | **Server** | **No**     | Hard constraint on write                 |

**Hard versus soft**

|         | Hard                           | Soft             |
| ------- | ------------------------------ | ---------------- |
| Returns | A message string               | Nothing          |
| Save    | **Blocked**                    | Allowed          |
| Use for | Would actually break something | Guidance         |
| Example | Malformed product code         | SEO title length |

**Clientlib categories**

| Category                   | Loaded in                                      |
| -------------------------- | ---------------------------------------------- |
| `cq.authoring.dialog`      | **Component dialogs**                          |
| `cq.authoring.editor.hook` | The page editor                                |
| Your site category         | Public pages — **never** put authoring JS here |

**Constrain versus validate**

| Requirement                   | Better approach                      |
| ----------------------------- | ------------------------------------ |
| Must be a page in our site    | **Pathfield with `rootPath`**        |
| One of three values           | **A select**                         |
| A date                        | **A datepicker**                     |
| Non-empty                     | **`required`**                       |
| A specific format             | A validator (nothing else fits)      |
| Consistent with another field | A validator, plus the change trigger |

***

## Next topic

**`12-MSM-and-Translation`** — your syllabus point 21 in full: what MSM is, blueprint versus live copy versus language copy, how rollout configurations work, inheritance and how it's cancelled, and how translation actually runs on a multi-country project.

***

_Topic 11 of the AEM Interview Preparation repository. Teaching style, energy-sector project domain._
