javavalidationjavafxmvvmcontrolsfx

ControlsFX ValidationSupport: Show validation errors on load and keep OK button disabled until valid input


I'm working on a JavaFX + Spring Boot application using the MVVM pattern. We use ControlsFX ValidationSupport to validate fields in a login form. Our goal is to:

Example:

We have a dialog to create a new user with these fields:

We register Validators like this:

@Component
public class ValidationHelper {

    public void registerUserRegistrationValidations(ValidationSupport validationSupport, TextField userName,
                                                    PasswordField password, PasswordField repeatPassword) {
        registerFocusLostValidation(userName, getEnteredUserNameDataLengthValidator(), validationSupport);
        registerFocusLostValidation(password, getEnteredPasswdDataLengthValidator(), validationSupport);
        registerFocusLostValidation(repeatPassword, getEnteredPasswdDataLengthValidator(), validationSupport);
        registerFocusLostValidation(repeatPassword, getEnteredPasswordsEqualValidator(repeatPassword), validationSupport);
    }

    private Validator<Object> getEnteredUserNameDataLengthValidator() {
        return Validator.createPredicateValidator(
                userName -> userName != null && ((String) userName).length() > 2,
                "user name too short");
    }

    private Validator<Object> getEnteredPasswdDataLengthValidator() {
        return Validator.createPredicateValidator(
                pin -> pin != null && ((String) pin).length() > 2,
                "Password too short");
    }

    private Validator<Object> getEnteredPasswordsEqualValidator(PasswordField passwdField) {
        return Validator.createPredicateValidator(
                password -> password != null && password.equals(passwdField.getText()),
                "Passwords do not match");
    }

    private <T> void registerFocusLostValidation(Control control, Validator<T> validator, ValidationSupport validationSupport) {
        validationSupport.registerValidator(control, false, validator);
    }
}

We also do this to bind a global flag:

BooleanBinding isInvalid = Bindings.createBooleanBinding(
    () -> !validationSupport.getValidationResult().getErrors().isEmpty(),
    validationSupport.validationResultProperty()
);
validationState.formInvalidProperty().bind(isInvalid);

Then in the footer controller:

okButton.disableProperty().bind(validationState.formInvalidProperty());

The Problem

This mostly works, but only after the user starts typing.

Initially:

We want the validation to appear as soon as the form is displayed, without user interaction. Attempts

Any ideas on how to make the validation trigger immediately and ensure the OK button behaves correctly from the start?

Tested with ControlsFX version 11.1.1 (or whatever you're using) and JavaFX 22.

Minimal Reproducible Example

ControlsFxApp.java

package controlsFx;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;

import java.io.IOException;
import java.net.URL;

@SpringBootApplication
public class ControlsFxApp extends Application {
    private static final String RESOURCE = "sample.fxml";
    private ConfigurableApplicationContext springContext;

    @Override
    public void init() {
        springContext = new SpringApplicationBuilder(ControlsFxApp.class).run();
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        Parent root = load(RESOURCE);
        Scene scene = new Scene(root);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public Parent load(String fxmlPath) throws IOException {
        URL location = getClass().getResource(fxmlPath);
        FXMLLoader fxmlLoader = new FXMLLoader(location);
        fxmlLoader.setControllerFactory(springContext::getBean);
        return fxmlLoader.load();
    }
}

FooterController.java

package controlsFx;

import javafx.fxml.FXML;
import javafx.scene.control.Button;
import org.springframework.stereotype.Controller;

@Controller
public class FooterController {
    private final ValidationState validationState;
    public Button register;

    public FooterController(ValidationState validationState) {
        this.validationState = validationState;
    }

    @FXML
    private void initialize() {
        register.setOnAction(event -> {
            System.out.println("Validation requested for the current step...");
        });
        register.disableProperty().bind(validationState.formInvalidProperty());
    }
}

RegistrationController.java

package controlsFx;

import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.fxml.FXML;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.GraphicValidationDecoration;
import org.springframework.stereotype.Controller;

@Controller
public class RegistrationController {
    private final ValidationHelper validationHelper;
    private final ValidationSupport validationSupport = new ValidationSupport();
    private final ValidationState validationState;
    public TextField userName;
    public PasswordField password;
    public PasswordField repeatPassword;

    public RegistrationController(ValidationHelper validationHelper, ValidationState validationState) {
        this.validationHelper = validationHelper;
        this.validationState = validationState;
    }

    @FXML
    public void initialize() {
        validationSupport.setValidationDecorator(new GraphicValidationDecoration());
        validationHelper.registerUserRegistrationValidations(validationSupport, userName, password, repeatPassword);
        Platform.runLater(() -> {
            validationSupport.revalidate();

            BooleanBinding isInvalid = Bindings.createBooleanBinding(
                    () -> !validationSupport.getValidationResult().getErrors().isEmpty(),
                    validationSupport.validationResultProperty()
            );
            validationState.formInvalidProperty().bind(isInvalid);
        });
    }
}

ValidationHelper.java

package controlsFx;

import javafx.scene.control.Control;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.springframework.stereotype.Component;

@Component
public class ValidationHelper {

    public void registerUserRegistrationValidations(ValidationSupport validationSupport, TextField userName,
                                                    PasswordField password, PasswordField repeatPassword) {
        registerFocusLostValidation(userName, getEnteredUserNameDataLengthValidator(), validationSupport);
        registerFocusLostValidation(password, getEnteredPasswdDataLengthValidator(), validationSupport);
        registerFocusLostValidation(repeatPassword, getEnteredPasswdDataLengthValidator(), validationSupport);
        registerFocusLostValidation(repeatPassword, getEnteredPasswordsEqualValidator(repeatPassword), validationSupport);
    }

    private Validator<Object> getEnteredUserNameDataLengthValidator() {
        return Validator.createPredicateValidator(
                userName -> userName != null && ((String) userName).length() > 2,
                "user name too short");
    }

    private Validator<Object> getEnteredPasswdDataLengthValidator() {
        return Validator.createPredicateValidator(
                pin -> pin != null && ((String) pin).length() > 2,
                "Password too short");
    }

    private Validator<Object> getEnteredPasswordsEqualValidator(PasswordField passwdField) {
        return Validator.createPredicateValidator(
                password -> password != null && password.equals(passwdField.getText()),
                "Passwords do not match");
    }

    private <T> void registerFocusLostValidation(Control control, Validator<T> validator, ValidationSupport validationSupport) {
        validationSupport.registerValidator(control, false, validator);
    }
}

ValidationState.java

package controlsFx;

import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.springframework.stereotype.Component;

@Component
public class ValidationState {
    private final BooleanProperty formInvalid = new SimpleBooleanProperty(true);

    public BooleanProperty formInvalidProperty() {
        return formInvalid;
    }
}

center.fxml

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<AnchorPane xmlns:fx="http://javafx.com/fxml"
            xmlns="http://javafx.com/javafx"
            fx:controller="controlsFx.RegistrationController"
            prefHeight="400.0" prefWidth="600.0">
    <VBox AnchorPane.bottomAnchor="30" AnchorPane.leftAnchor="30" AnchorPane.rightAnchor="30"
          AnchorPane.topAnchor="30">
        <Label>User Name:</Label>
        <TextField fx:id="userName" id="userName"/>
        <Label>Password:</Label>
        <PasswordField fx:id="password" id="password"/>
        <Label>Repeat Password:</Label>
        <PasswordField fx:id="repeatPassword" id="password"/>
    </VBox>
</AnchorPane>

footer.xml

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.Button?>
<?import javafx.scene.layout.AnchorPane?>
<AnchorPane xmlns:fx="http://javafx.com/fxml"
            xmlns="http://javafx.com/javafx" prefHeight="400.0" prefWidth="600.0"
            fx:controller="controlsFx.FooterController">
    <Button fx:id="register" layoutX="33.0" layoutY="187.0" prefHeight="25.0" prefWidth="534.0" text="Register"/>
</AnchorPane>

sample.fxml

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.AnchorPane?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.text.Font?>
<BorderPane xmlns:fx="http://javafx.com/fxml" xmlns="http://javafx.com/javafx" prefWidth="200" prefHeight="200"
            fx:id="borderPaneId">
    <top>
        <AnchorPane BorderPane.alignment="CENTER">
            <Label text="Registration">
                <font>
                    <Font size="24.0"/>
                </font>
            </Label>
            <BorderPane.margin>
                <Insets/>
            </BorderPane.margin>
        </AnchorPane>
    </top>
    <center>
        <fx:include source="center.fxml"/>
    </center>
    <bottom>
        <fx:include source="footer.fxml"/>
    </bottom>
</BorderPane>

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>controlsFx</groupId>
    <artifactId>validationdemo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>17</java.version>
        <javafx.version>22</javafx.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-fxml</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <dependency>
            <groupId>org.controlsfx</groupId>
            <artifactId>controlsfx</artifactId>
            <version>11.1.2</version>
        </dependency>
    </dependencies>
</project>

Solution

  • Answering my own question: Migrated to ValidatorFX – issue resolved

    After some research and feedback (see ControlsFX issue #1588), I migrated from ControlsFX ValidationSupport to ValidatorFX, which solved the issue completely.

    What I needed:

    The solution: use ValidatorFX

    1. ValidationHelper.java
    package controlsFx;
    
    import javafx.scene.control.PasswordField;
    import javafx.scene.control.TextField;
    import net.synedra.validatorfx.Validator;
    import org.springframework.stereotype.Component;
    
    @Component
    public class ValidationHelper {
    
        public void registerUserRegistrationValidations(Validator validator, TextField userNameField,
                                                        PasswordField passwordField, PasswordField repeatPasswordField) {
            String password = "password";
            String userName = "userName";
            String repeatPassword1 = "repeatPassword";
    
            validator.createCheck()
                    .withMethod(c -> {
                        String name = c.get(userName);
                        if (name == null || name.length() <= 2) {
                            c.error("Error");
                        }
                    })
                    .dependsOn(userName, userNameField.textProperty())
                    .decorates(userNameField)
                    .immediate();
    
            validator.createCheck()
                    .withMethod(c -> {
                        String pwd = c.get(password);
                        if (pwd == null || pwd.length() <= 2) {
                            c.error("Error");
                        }
                    })
                    .dependsOn(password, passwordField.textProperty())
                    .decorates(passwordField)
                    .immediate();
    
            validator.createCheck()
                    .withMethod(c -> {
                        String pwd = c.get(password);
                        String repeat = c.get(repeatPassword1);
                        if (repeat == null || !repeat.equals(pwd)) {
                            c.error("Error");
                        }
                    })
                    .dependsOn(password, passwordField.textProperty())
                    .dependsOn(repeatPassword1, repeatPasswordField.textProperty())
                    .decorates(repeatPasswordField)
                    .immediate();
        }
    }
    

    2. RegistrationController.java

    package controlsFx;
    
    import javafx.fxml.FXML;
    import javafx.scene.control.PasswordField;
    import javafx.scene.control.TextField;
    import net.synedra.validatorfx.Validator;
    import org.springframework.stereotype.Controller;
    
    @Controller
    public class RegistrationController {
        private final ValidationHelper validationHelper;
        private final Validator validator = new Validator();
        private final ValidationState validationState;
        public TextField userName;
        public PasswordField password;
        public PasswordField repeatPassword;
    
        public RegistrationController(ValidationHelper validationHelper, ValidationState validationState) {
            this.validationHelper = validationHelper;
            this.validationState = validationState;
        }
    
        @FXML
        public void initialize() {
            validationHelper.registerUserRegistrationValidations(validator, userName, password, repeatPassword);
            validationState.formInvalidProperty().bind(validator.containsErrorsProperty());
        }
    }
    
    3. Add this to your pom.xml 
    
    <dependency>
        <groupId>net.synedra</groupId>
        <artifactId>validatorfx</artifactId>
        <version>0.6.1</version>
    </dependency>
    
    And remove:
    
    <dependency>
        <groupId>org.controlsfx</groupId>
        <artifactId>controlsfx</artifactId>
    </dependency>