vaadin / flow-components

Java counterpart of Vaadin Web Components
100 stars 66 forks source link

Highlighting specific sub-fields as invalid when using CustomField + Binder #4653

Open vursen opened 1 year ago

vursen commented 1 year ago

By default, when invalid, CustomField doesn't visually indicate that, except for showing an error message. The recent UX testing showed that it can be difficult to spot an invalid CustomField among other fields in the form, therefore. You can solve that by overriding setInvalid() for a CustomField to make it propagate the invalid state to the child fields.

That said, sometimes there is a need to only highlight a specific sub-field as invalid based on the validation error.

Let's say you have a PhoneField that consists of a country code and phone number sub-fields. When the CustomField value doesn't have a country code, you may expect Binder to show an error message like "Must have a country code" under the whole field but highlight only the country code sub-field as invalid.

This isn't possible at the moment unless with some dirty workarounds, especially when using Binder where you can set up errors only for the whole field.

PhoneField example

public class Phone {
    private final String code;
    private final String number;

    public Phone(String code, String number) {
        this.code = code;
        this.number = number;
    }

    public String getCode() {
        return this.code;
    }

    public String getNumber() {
        return this.number;
    }
}
public class PhoneField extends CustomField<Phone> {
    private Select<String> codeField;
    private TextField numberField;

    private List<String> COUNTRY_CODES = List.of("+358", "+49", "+1");

    public PhoneField(String label) {
        this();
        setLabel(label);
    }

    public PhoneField() {
        HorizontalLayout layout = new HorizontalLayout();

        codeField = new Select<>();
        codeField.setItems(COUNTRY_CODES);
        codeField.setWidth("25%");
        codeField.setPlaceholder("Code");
        layout.add(codeField);

        numberField = new TextField();
        numberField.setWidth("75%");
        layout.add(numberField);

        add(layout);
    }

    @Override
    protected Phone generateModelValue() {
        String code = codeField.getValue();
        String number = numberField.getValue();

        if (code == null && number.isEmpty()) {
            return null;
        }

        return new Phone(code, number.isEmpty() ? null : number);
    }

    @Override
    protected void setPresentationValue(Phone phone) {
        if (phone != null) {
            codeField.setValue(phone.getCode());
            numberField.setValue(phone.getNumber());
        }
    }
}
public class UserForm extends FormLayout {
    public UserForm(Binder<Order> binder) {
        PhoneField phone = new PhoneField("Phone");
        phone.setHelperText("Select a country code and enter a number using digits");
        binder.forField(phone)
                .asRequired("The field is required")
                // This validator actually checks the country code sub-field.
                .withValidator(
                        value -> value == null || value.getNumber() == null
                                || value.getCode() != null,
                        "The country code is required")
                // This validator actually checks the phone number sub-field.
                .withValidator(
                        value -> value == null || value.getNumber() == null
                                || Pattern.matches("^\\d+$", value.getNumber()),
                        "The number should consist of digits")
                .bind("user.phone");
    }
}

Additional context

Related to https://github.com/vaadin/platform/issues/3066

jcgueriaud1 commented 1 year ago

I would probably implement this usecase with an internal binder in the custom field. So the validation would be displayed properly. But if you are using an internal binder in the custom field then the custom field will return only a valid value. So the main binder will always have the valid value.

So you need to create your own validator:

binder.forField(phone)
                .asRequired("The field is required")
                // This validator actually checks the country code sub-field.
                .withValidator(
                        value -> phone.checkValidity(value),
                        "Custom field is invalid")
                .bind("user.phone");

I'm wondering, as it's an internal behavior of the custom-field, if we should use the HasValidator interface for this. Something like:

    public Validator<String> getDefaultValidator() {
        return (value, context) -> {
            return this.checkValidity(value);
        };
    }

The documentation seems to describe this as a usecase: https://github.com/vaadin/flow/blob/main/flow-data/src/main/java/com/vaadin/flow/data/binder/HasValidator.java#L44

In short use the same mechanism for the datetimepicker and a custom field.

TatuLund commented 1 year ago

@jcgueriaud1 I have similar experiment here https://github.com/TatuLund/ProtoTools/blob/master/src/main/java/org/vaadin/addons/tatu/prototools/Form.java I.e. sub form as a field.