Skip to content

Commit a26a325

Browse files
authored
Implement custom bean validation annotations #3 (#8)
1 parent aea6f70 commit a26a325

29 files changed

Lines changed: 1346 additions & 36 deletions

.DS_Store

6 KB
Binary file not shown.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1010

1111
- Support sorting `LocalizedString` values by the user's current locale.
1212
- Configuration properties to disable the add-on sorting customizations.
13+
- Bean Validation annotations for `LocalizedString` values, including locale-aware not-null validation.
1314

1415
### Changed
1516

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,26 @@ If the target `ValuePicker` of `LocalizedStringEditAction` is bound to a require
134134

135135
![Required Field](/doc/img/required-field.png)
136136

137+
The add-on provides Bean Validation annotations for `LocalizedString` attributes:
138+
139+
* `@LocalizedStringSize`
140+
* `@LocalizedStringLength`
141+
* `@LocalizedStringPattern`
142+
* `@LocalizedStringNotNull`
143+
* `@LocalizedStringNotEmpty`
144+
* `@LocalizedStringNotBlank`
145+
146+
For example:
147+
148+
```java
149+
@LocalizedStringNotBlank
150+
@LocalizedStringSize(max = 100)
151+
@Column(name = "NAME")
152+
private LocalizedString name;
153+
```
154+
155+
Every stored localized value is validated. `@LocalizedStringNotNull` also checks that a value is stored for every configured application locale. When the edit dialog is used for an entity attribute, these constraints are applied to each locale field separately. Standard `@NotNull` can still be used for the attribute itself when only the `LocalizedString` object reference should be checked.
156+
137157
Additionally, you can add a validator that checks the value of every field in the edit dialog. For example:
138158

139159
```java

jmix-localized-string-datatype-demo/src/main/java/com/glebfox/jmix/locstr/demo/entity/Product.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
package com.glebfox.jmix.locstr.demo.entity;
1818

1919
import com.glebfox.jmix.locstr.datatype.LocalizedString;
20+
import com.glebfox.jmix.locstr.validation.constraints.LocalizedStringNotBlank;
21+
import com.glebfox.jmix.locstr.validation.constraints.LocalizedStringNotNull;
22+
import com.glebfox.jmix.locstr.validation.constraints.LocalizedStringPattern;
2023
import io.jmix.core.entity.annotation.JmixGeneratedValue;
2124
import io.jmix.core.metamodel.annotation.InstanceName;
2225
import io.jmix.core.metamodel.annotation.JmixEntity;
@@ -34,11 +37,14 @@ public class Product {
3437
@Id
3538
private UUID id;
3639

40+
@LocalizedStringPattern(regexp = "^[a-zA-Z0-9а-яА-Я \\t\\r\\f-]+$")
3741
@InstanceName
3842
@Column(name = "NAME", nullable = false)
3943
@NotNull
44+
@LocalizedStringNotNull
4045
private LocalizedString name;
4146

47+
@LocalizedStringNotBlank
4248
@Lob
4349
@Column(name = "DESCRIPTION")
4450
private LocalizedString description;

jmix-localized-string-datatype/src/main/java/com/glebfox/jmix/locstr/action/LocalizedStringEditAction.java

Lines changed: 62 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,11 @@
1717
package com.glebfox.jmix.locstr.action;
1818

1919
import com.glebfox.jmix.locstr.datatype.LocalizedString;
20+
import com.glebfox.jmix.locstr.validation.BeanValidatorAdapter;
2021
import com.glebfox.jmix.locstr.validation.Validator;
2122
import com.glebfox.jmix.locstr.validation.ValidatorAdapter;
2223
import com.google.common.base.Preconditions;
2324
import com.google.common.base.Strings;
24-
import com.google.common.cache.Cache;
25-
import com.google.common.cache.CacheBuilder;
2625
import com.vaadin.flow.component.*;
2726
import com.vaadin.flow.component.button.Button;
2827
import com.vaadin.flow.component.button.ButtonVariant;
@@ -37,7 +36,10 @@
3736
import com.vaadin.flow.shared.Registration;
3837
import io.jmix.core.CoreProperties;
3938
import io.jmix.core.MessageTools;
39+
import io.jmix.core.MetadataTools;
4040
import io.jmix.core.Messages;
41+
import io.jmix.core.entity.KeyValueEntity;
42+
import io.jmix.core.metamodel.model.MetaClass;
4143
import io.jmix.core.metamodel.model.MetaProperty;
4244
import io.jmix.core.metamodel.model.MetaPropertyPath;
4345
import io.jmix.flowui.Dialogs;
@@ -49,6 +51,7 @@
4951
import io.jmix.flowui.component.PickerComponent;
5052
import io.jmix.flowui.component.SupportsValidation;
5153
import io.jmix.flowui.component.UiComponentUtils;
54+
import io.jmix.flowui.component.validation.bean.BeanPropertyValidator;
5255
import io.jmix.flowui.data.EntityValueSource;
5356
import io.jmix.flowui.data.ValueSource;
5457
import io.jmix.flowui.kit.action.ActionVariant;
@@ -78,13 +81,14 @@ public class LocalizedStringEditAction
7881
protected Messages messages;
7982
protected UiComponents uiComponents;
8083
protected MessageTools messageTools;
84+
protected MetadataTools metadataTools;
8185

8286
protected Dialog dialog;
8387
protected Button saveButton;
8488
protected Button cancelButton;
8589

8690
protected LinkedHashMap<Locale, String> availableLocales;
87-
protected Cache<Locale, HasValueAndElement<?, String>> fieldCache;
91+
protected Map<Locale, HasValueAndElement<?, String>> fields = new LinkedHashMap<>();
8892
protected List<Validator> validators;
8993

9094
protected Boolean multiline;
@@ -145,6 +149,11 @@ public void setMessageTools(MessageTools messageTools) {
145149
this.messageTools = messageTools;
146150
}
147151

152+
@Autowired
153+
public void setMetadataTools(MetadataTools metadataTools) {
154+
this.metadataTools = metadataTools;
155+
}
156+
148157
@Autowired
149158
public void setDialogs(Dialogs dialogs) {
150159
this.dialogs = dialogs;
@@ -177,9 +186,9 @@ public boolean isMultiline() {
177186
*
178187
* @param multiline {@code true} to use a multi-line text
179188
* input component, {@code false} otherwise
180-
* @apiNote this setting is applied before the first time the edit
181-
* dialog is opened, as fields are cached. {@link TextArea} is used
182-
* for multi-line text input, {@link TextField} otherwise
189+
* @apiNote this setting is applied when the edit dialog is opened.
190+
* {@link TextArea} is requested from {@link UiComponents} for
191+
* multi-line text input, {@link TextField} otherwise
183192
*/
184193
public void setMultiline(boolean multiline) {
185194
this.multiline = multiline;
@@ -192,9 +201,9 @@ public void setMultiline(boolean multiline) {
192201
* @param multiline {@code true} to use a multi-line text
193202
* input component, {@code false} otherwise
194203
* @return this object
195-
* @apiNote this setting is applied before the first time the edit
196-
* dialog is opened, as fields are cached. {@link TextArea} is used
197-
* for multi-line text input, {@link TextField} otherwise
204+
* @apiNote this setting is applied when the edit dialog is opened.
205+
* {@link TextArea} is requested from {@link UiComponents} for
206+
* multi-line text input, {@link TextField} otherwise
198207
*/
199208
public LocalizedStringEditAction withMultiline(boolean multiline) {
200209
setMultiline(multiline);
@@ -786,10 +795,9 @@ public LocalizedStringEditAction withFieldProvider(
786795
public void execute() {
787796
checkTarget();
788797

789-
// Fields are not recreated because they are cached, but
790-
// they are correctly initialized with new values
791798
dialog.removeAll();
792799
dialog.add(createContent());
800+
updateSaveButtonState();
793801

794802
// Clear flag after content is created because fields are
795803
// initialized with a default value
@@ -846,8 +854,7 @@ protected void addButtonClickShortcut(Button button, Key key, KeyModifier... key
846854
}
847855

848856
protected void doSave(ClickEvent<Button> event) {
849-
Map<Locale, String> localizedValues = getFields().asMap()
850-
.entrySet().stream()
857+
Map<Locale, String> localizedValues = getFields().entrySet().stream()
851858
.collect(Collectors.toMap(Map.Entry::getKey,
852859
entry -> entry.getValue().getValue())
853860
);
@@ -892,6 +899,7 @@ protected Component createContent() {
892899
layout.setAlignItems(FlexComponent.Alignment.STRETCH);
893900
layout.setClassName("localized-string-editor-content");
894901

902+
fields = new LinkedHashMap<>();
895903
availableLocales.keySet().stream()
896904
.map(locale -> ((Component) getField(locale)))
897905
.forEach(layout::add);
@@ -900,12 +908,8 @@ protected Component createContent() {
900908
}
901909

902910
protected HasValueAndElement<?, String> getField(Locale locale) {
903-
HasValueAndElement<?, String> field = getFields().getIfPresent(locale);
904-
if (field == null) {
905-
field = createField(locale);
906-
getFields().put(locale, field);
907-
}
908-
911+
HasValueAndElement<?, String> field = createField(locale);
912+
getFields().put(locale, field);
909913
field.setValue(getInitialValue(locale));
910914
return field;
911915
}
@@ -944,20 +948,21 @@ protected void initField(HasValueAndElement<?, String> field,
944948
}
945949

946950
initRequired(field, metaPropertyPath);
947-
initValidators(field, locale);
951+
initValidators(field, locale, metaPropertyPath);
948952
}
949953

950954
protected void onFieldInvalidChanged(PropertyChangeEvent propertyChangeEvent) {
951-
saveButton.setEnabled(!hasInvalidFields());
955+
updateSaveButtonState();
952956
}
953957

954-
protected boolean hasInvalidFields() {
955-
return getFields().asMap().entrySet()
958+
protected void updateSaveButtonState() {
959+
boolean hasInvalidFields = getFields().entrySet()
956960
.stream()
957961
.anyMatch(entry ->
958962
entry.getValue() instanceof HasValidation hasValidation
959963
&& hasValidation.isInvalid()
960964
);
965+
saveButton.setEnabled(!hasInvalidFields);
961966
}
962967

963968
protected void initMultilineField(HasValueAndElement<?, String> field) {
@@ -971,7 +976,6 @@ protected void initMultilineField(HasValueAndElement<?, String> field) {
971976
@Nullable
972977
protected MetaPropertyPath findMetaPropertyPath() {
973978
ValueSource<LocalizedString> valueSource = target.getValueSource();
974-
MetaPropertyPath metaPropertyPath = null;
975979
return valueSource instanceof EntityValueSource<?, ?> entityValueSource
976980
? entityValueSource.getMetaPropertyPath()
977981
: null;
@@ -1024,28 +1028,52 @@ protected void initRequired(HasValueAndElement<?, String> field,
10241028
}
10251029

10261030
@SuppressWarnings("unchecked")
1027-
protected void initValidators(HasValueAndElement<?, String> field, Locale locale) {
1031+
protected void initValidators(HasValueAndElement<?, String> field,
1032+
Locale locale,
1033+
@Nullable MetaPropertyPath metaPropertyPath) {
1034+
if (field instanceof SupportsValidation<?>) {
1035+
initBeanValidator((SupportsValidation<String>) field, metaPropertyPath);
1036+
}
1037+
10281038
if (validators != null
1029-
&& field instanceof SupportsValidation) {
1039+
&& field instanceof SupportsValidation<?>) {
10301040
validators.forEach(validator ->
10311041
((SupportsValidation<String>) field).addValidator(new ValidatorAdapter(validator, locale)));
10321042
}
10331043
}
10341044

1045+
protected void initBeanValidator(SupportsValidation<String> field,
1046+
@Nullable MetaPropertyPath metaPropertyPath) {
1047+
if (metaPropertyPath == null) {
1048+
return;
1049+
}
1050+
1051+
MetaClass enclosingMetaClass = metadataTools.getPropertyEnclosingMetaClass(metaPropertyPath);
1052+
Class<?> enclosingJavaClass = enclosingMetaClass.getJavaClass();
1053+
if (enclosingJavaClass == KeyValueEntity.class) {
1054+
return;
1055+
}
1056+
1057+
MetaProperty metaProperty = metaPropertyPath.getMetaProperty();
1058+
if (!LocalizedString.class.isAssignableFrom(metaProperty.getJavaType())) {
1059+
return;
1060+
}
1061+
1062+
BeanPropertyValidator beanPropertyValidator = applicationContext.getBean(
1063+
BeanPropertyValidator.class,
1064+
enclosingJavaClass,
1065+
metaProperty.getName());
1066+
field.addValidator(new BeanValidatorAdapter(beanPropertyValidator, availableLocales.keySet()));
1067+
}
1068+
10351069
@SuppressWarnings("unchecked")
10361070
protected String getInitialValue(Locale locale) {
10371071
LocalizedString localizedString = ((HasValue<?, LocalizedString>) target).getValue();
10381072
return localizedString != null ? localizedString.getValue(locale) : "";
10391073
}
10401074

1041-
protected Cache<Locale, HasValueAndElement<?, String>> getFields() {
1042-
if (fieldCache == null) {
1043-
fieldCache = CacheBuilder.newBuilder()
1044-
.maximumSize(availableLocales.size())
1045-
.build();
1046-
}
1047-
1048-
return fieldCache;
1075+
protected Map<Locale, HasValueAndElement<?, String>> getFields() {
1076+
return fields;
10491077
}
10501078

10511079
protected boolean isMac() {

jmix-localized-string-datatype/src/main/java/com/glebfox/jmix/locstr/datatype/LocalizedString.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.fasterxml.jackson.databind.ObjectMapper;
2222

2323
import java.io.Serializable;
24+
import java.util.Collections;
2425
import java.util.HashMap;
2526
import java.util.Locale;
2627
import java.util.Map;
@@ -40,6 +41,15 @@ public String getValue(Locale locale) {
4041
return values.getOrDefault(locale, "");
4142
}
4243

44+
/**
45+
* Returns all stored localized values.
46+
*
47+
* @return unmodifiable map of locale to localized value
48+
*/
49+
public Map<Locale, String> getValues() {
50+
return Collections.unmodifiableMap(values);
51+
}
52+
4353
public String toJson() {
4454
try {
4555
return new ObjectMapper().writeValueAsString(values);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright 2024 Gleb Gorelov.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.glebfox.jmix.locstr.validation;
18+
19+
import com.glebfox.jmix.locstr.datatype.LocalizedString;
20+
import io.jmix.flowui.component.validation.bean.BeanPropertyValidator;
21+
import org.springframework.lang.Nullable;
22+
23+
import java.util.Collection;
24+
import java.util.LinkedHashMap;
25+
import java.util.Locale;
26+
import java.util.Map;
27+
28+
public record BeanValidatorAdapter(BeanPropertyValidator validator, Collection<Locale> locales)
29+
implements io.jmix.flowui.component.validation.Validator<String> {
30+
31+
@Override
32+
public void accept(@Nullable String value) {
33+
Map<Locale, String> values = new LinkedHashMap<>();
34+
locales.forEach(locale -> values.put(locale, value));
35+
36+
validator.accept(new LocalizedString(values));
37+
}
38+
}

0 commit comments

Comments
 (0)