diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index 2a9c5a4d2..98c58d13b 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -30,6 +30,7 @@ views: fieldsetExample: Form Generation Example fieldsetAnnotations: Fieldset Annotations fieldsetFetcher: Radio, Checkbox and Option Fetcher + customFormElement: Custom Form Elements turbo: title: Turbo turboFrameView: TurboFrameView annotation diff --git a/src/main/docs/guide/views/fieldset/customFormElement.adoc b/src/main/docs/guide/views/fieldset/customFormElement.adoc new file mode 100644 index 000000000..b6a1ad14e --- /dev/null +++ b/src/main/docs/guide/views/fieldset/customFormElement.adoc @@ -0,0 +1,3 @@ +You many need a custom implementation of api:views.fields.FormElement[]. If you do, create a bean of type api:views.fields.formelementresolvers.FormElementResolver[] to resolve your custom `FormElement` class for a certain bean property's type. + +Providing a bean of type api:views.fields.formelementresolvers.FormElementResolver[] allows the custom form element to be part of the api:views.fields.FieldsetGenerator[] API. diff --git a/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/fieldset.html b/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/fieldset.html index 8dc3dd234..5eb8e64f0 100644 --- a/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/fieldset.html +++ b/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/fieldset.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file diff --git a/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/inputfile.html b/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/inputfile.html new file mode 100644 index 000000000..78064a75b --- /dev/null +++ b/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/inputfile.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/views-fieldset-tck/build.gradle.kts b/views-fieldset-tck/build.gradle.kts index a55c8fc85..ce0a9982d 100644 --- a/views-fieldset-tck/build.gradle.kts +++ b/views-fieldset-tck/build.gradle.kts @@ -5,6 +5,7 @@ dependencies { api(mnTest.micronaut.test.junit5) api(projects.micronautViewsFieldset) api(projects.micronautViewsCore) + api(mn.micronaut.http) annotationProcessor(mnValidation.micronaut.validation.processor) api(mnValidation.micronaut.validation) } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormCompletedFileUploadRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormCompletedFileUploadRenderTest.java new file mode 100644 index 000000000..e76a332e7 --- /dev/null +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormCompletedFileUploadRenderTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.views.fields.tck; + +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.beans.BeanIntrospection; +import io.micronaut.http.multipart.CompletedFileUpload; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.micronaut.views.ViewsRenderer; +import io.micronaut.views.fields.Form; +import io.micronaut.views.fields.FormElement; +import io.micronaut.views.fields.FormGenerator; +import io.micronaut.views.fields.annotations.InputHidden; +import io.micronaut.views.fields.elements.InputSubmitFormElement; +import io.micronaut.views.fields.messages.Message; +import jakarta.inject.Singleton; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Map; +import java.util.function.BiConsumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SuppressWarnings({"java:S5960"}) // Assertions are fine, these are tests +@Property(name = "spec.name", value = "FormCompletedFileUploadRenderTest") +@MicronautTest(startApplication = false) +class FormCompletedFileUploadRenderTest { + + @Test + void render(ViewsRenderer, ?> viewsRenderer, + FormGenerator formGenerator, + EventImageSaveValidator validator) throws IOException { + String viewName = "fieldset/form.html"; + BiConsumer> builderConsumer = (propertyName, builder) -> { + if (propertyName.equals("file")) { + builder.with("accept", "image/png, image/jpeg"); + } + if (propertyName.equals("alt")) { + builder.with("label", Message.of("Alternative description of the image")); + } + }; + String expectedClass = """ +
\ + \ +
\ + \ + \ +
"""; + Form form = formGenerator.generate("/foo/bar", "post", EventImageSave.class, builderConsumer); + assertEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", "post", EventImageSave.class, FormGenerator.SUBMIT, builderConsumer); + assertEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", "post", EventImageSave.class, new InputSubmitFormElement(FormGenerator.SUBMIT), builderConsumer); + assertEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", EventImageSave.class, builderConsumer); + assertEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", EventImageSave.class, FormGenerator.SUBMIT, builderConsumer); + assertEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", EventImageSave.class, new InputSubmitFormElement(FormGenerator.SUBMIT), builderConsumer); + assertEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + EventImageSave invalid = new EventImageSave("xxx", "", null); + ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> validator.validate(invalid)); + String expectedInvalid = """ +
\ + \ +
\ + \ +
must not be blank
\ +
\ + \ + \ +
must not be null
\ + \ +
"""; + form = formGenerator.generate("/foo/bar", "post", invalid, ex, builderConsumer); + assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", "post", invalid, ex, FormGenerator.SUBMIT, builderConsumer); + assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", "post", invalid, ex, new InputSubmitFormElement(FormGenerator.SUBMIT), builderConsumer); + assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", invalid, ex, new InputSubmitFormElement(FormGenerator.SUBMIT), builderConsumer); + assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", invalid, ex, builderConsumer); + assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", invalid, ex, FormGenerator.SUBMIT, builderConsumer); + assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", invalid, ex, new InputSubmitFormElement(FormGenerator.SUBMIT), builderConsumer); + assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + EventImageSave valid = new EventImageSave("xxx", "Micronaut Logo", null); + String expectedValid = """ +
\ + \ +
\ + \ + \ +
"""; + form = formGenerator.generate("/foo/bar", "post", valid, builderConsumer); + assertEquals(expectedValid, + TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", valid, builderConsumer); + assertEquals(expectedValid, + TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", valid, FormGenerator.SUBMIT, builderConsumer); + assertEquals(expectedValid, + TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + + form = formGenerator.generate("/foo/bar", valid, new InputSubmitFormElement(FormGenerator.SUBMIT), builderConsumer); + assertEquals(expectedValid, + TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + } + + @Introspected + record EventImageSave(@NotBlank @InputHidden String id, + @NotBlank String alt, + @NotNull CompletedFileUpload file) { + } + + @Requires(property = "spec.name", value = "FormCompletedFileUploadRenderTest") + @Singleton + static class EventImageSaveValidator { + void validate(@Valid EventImageSave eventImageSave) { + } + } + +} diff --git a/views-fieldset/build.gradle.kts b/views-fieldset/build.gradle.kts index fa16e9d13..1a5373c37 100644 --- a/views-fieldset/build.gradle.kts +++ b/views-fieldset/build.gradle.kts @@ -5,13 +5,13 @@ plugins { dependencies { annotationProcessor(mnValidation.micronaut.validation.processor) implementation(mnValidation.micronaut.validation) + compileOnly(mn.micronaut.http) testAnnotationProcessor(mnValidation.micronaut.validation.processor) testImplementation(mnValidation.micronaut.validation) compileOnly(projects.micronautViewsCore) - testImplementation(mnData.micronaut.data.model) - + testImplementation(mn.micronaut.http) testAnnotationProcessor(mn.micronaut.inject.java) testImplementation(libs.junit.jupiter.api) testImplementation(mnTest.micronaut.test.junit5) diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/DefaultFieldGenerator.java b/views-fieldset/src/main/java/io/micronaut/views/fields/DefaultFieldGenerator.java index 1cb19bc7d..8dfbd3006 100644 --- a/views-fieldset/src/main/java/io/micronaut/views/fields/DefaultFieldGenerator.java +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/DefaultFieldGenerator.java @@ -25,17 +25,11 @@ import io.micronaut.core.beans.BeanWrapper; import io.micronaut.core.util.StringUtils; import io.micronaut.views.fields.annotations.InputCheckbox; -import io.micronaut.views.fields.annotations.InputEmail; -import io.micronaut.views.fields.annotations.InputHidden; -import io.micronaut.views.fields.annotations.InputPassword; import io.micronaut.views.fields.annotations.InputRadio; -import io.micronaut.views.fields.annotations.InputTel; -import io.micronaut.views.fields.annotations.InputUrl; import io.micronaut.views.fields.annotations.Select; -import io.micronaut.views.fields.annotations.Textarea; -import io.micronaut.views.fields.annotations.TrixEditor; import io.micronaut.views.fields.elements.*; import io.micronaut.views.fields.fetchers.*; +import io.micronaut.views.fields.formelementresolvers.FormElementResolver; import io.micronaut.views.fields.messages.ConstraintViolationUtils; import io.micronaut.views.fields.messages.Message; import jakarta.annotation.Nonnull; @@ -44,9 +38,6 @@ import jakarta.validation.constraints.*; import java.lang.annotation.Annotation; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiConsumer; @@ -75,23 +66,8 @@ public class DefaultFieldGenerator implements FieldsetGenerator { private static final String BUILDER_METHOD_MAX = "max"; private static final String BUILDER_METHOD_MAX_LENGTH = "maxLength"; private static final String BUILDER_METHOD_MIN_LENGTH = "minLength"; - - private static final Map, Class> ANNOTATION_MAPPING = Map.ofEntries( - Map.entry(InputHidden.class, InputHiddenFormElement.class), - Map.entry(InputRadio.class, InputRadioFormElement.class), - Map.entry(InputCheckbox.class, InputCheckboxFormElement.class), - Map.entry(InputPassword.class, InputPasswordFormElement.class), - Map.entry(InputEmail.class, InputEmailFormElement.class), - Map.entry(Email.class, InputEmailFormElement.class), - Map.entry(InputUrl.class, InputUrlFormElement.class), - Map.entry(InputTel.class, InputTelFormElement.class), - Map.entry(Select.class, SelectFormElement.class), - Map.entry(Textarea.class, TextareaFormElement.class), - Map.entry(TrixEditor.class, TrixEditorFormElement.class) - ); private static final String MEMBER_MIN = "min"; private static final String MEMBER_MAX = "max"; - private static final String CLASS_IO_MICRONAUT_DATA_ANNOTATION_AUTO_POPULATED = "io.micronaut.data.annotation.AutoPopulated"; private final EnumOptionFetcher enumOptionFetcher; @@ -100,6 +76,8 @@ public class DefaultFieldGenerator implements FieldsetGenerator { private final EnumCheckboxFetcher enumCheckboxFetcher; private final BeanContext beanContext; + private final FormElementResolver formElementResolver; + private final ConcurrentHashMap, OptionFetcher> optionFetcherCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap, RadioFetcher> radioFetcherCache = new ConcurrentHashMap<>(); @@ -110,15 +88,18 @@ public class DefaultFieldGenerator implements FieldsetGenerator { * @param enumRadioFetcher Enum fetcher for {@link Radio}. * @param enumCheckboxFetcher Enum fetcher for {@link Checkbox}. * @param beanContext Bean Context + * @param formElementResolver Primary Form Element Resolver. {@link io.micronaut.views.fields.formelementresolvers.CompositeFormElementResolver}. */ public DefaultFieldGenerator(EnumOptionFetcher enumOptionFetcher, EnumRadioFetcher enumRadioFetcher, EnumCheckboxFetcher enumCheckboxFetcher, - BeanContext beanContext) { + BeanContext beanContext, + FormElementResolver formElementResolver) { this.enumOptionFetcher = enumOptionFetcher; this.enumRadioFetcher = enumRadioFetcher; this.enumCheckboxFetcher = enumCheckboxFetcher; this.beanContext = beanContext; + this.formElementResolver = formElementResolver; } @Override @@ -173,7 +154,7 @@ private List generateOfBeanWrapper(@NonNull BeanWrapp @Nullable BiConsumer> builderConsumer) { List result = new ArrayList<>(beanWrapper.getBeanProperties().size()); for (BeanProperty beanProperty : beanWrapper.getBeanProperties()) { - formElementClassForBeanProperty(beanProperty).ifPresent(formElementClazz -> { + formElementResolver.resolve(beanProperty).ifPresent(formElementClazz -> { BeanIntrospection.Builder builder = formElementBuilderForBeanProperty(beanProperty, formElementClazz, beanWrapper, ex, builderConsumer); result.add(builder.build()); }); @@ -181,41 +162,6 @@ private List generateOfBeanWrapper(@NonNull BeanWrapp return result; } - @NonNull - private Optional> formElementClassForBeanProperty(@NonNull BeanProperty beanProperty) { - if (beanProperty.hasStereotype(CLASS_IO_MICRONAUT_DATA_ANNOTATION_AUTO_POPULATED)) { - return Optional.empty(); - } - for (var mapping : ANNOTATION_MAPPING.entrySet()) { - if (beanProperty.hasAnnotation(mapping.getKey())) { - return Optional.of(mapping.getValue()); - } - } - if (beanProperty.getType() == LocalDate.class) { - return Optional.of(InputDateFormElement.class); - } - if (beanProperty.getType() == LocalDateTime.class) { - return Optional.of(InputDateTimeLocalFormElement.class); - } - if (beanProperty.getType() == LocalTime.class) { - return Optional.of(InputTimeFormElement.class); - } - if (Number.class.isAssignableFrom(beanProperty.getType())) { - return Optional.of(InputNumberFormElement.class); - } - if (beanProperty.getType() == boolean.class) { - return Optional.of(InputCheckboxFormElement.class); - } - if (beanProperty.getType().isEnum()) { - return Optional.of(SelectFormElement.class); - } - if (CharSequence.class.isAssignableFrom(beanProperty.getType())) { - return Optional.of(InputTextFormElement.class); - } - - return Optional.empty(); - } - @NonNull private Optional radioFetcherForBeanProperty(@NonNull BeanProperty beanProperty) { if (beanProperty.hasAnnotation(InputRadio.class)) { @@ -409,7 +355,7 @@ private List formElements(Collection formElementOfBeanProperty(@NonNull BeanProperty beanProperty, @Nullable BiConsumer> builderConsumer) { - return formElementClassForBeanProperty(beanProperty) + return formElementResolver.resolve(beanProperty) .map(formElementClass -> formElementBuilderForBeanProperty(beanProperty, formElementClass, null, null, builderConsumer).build()); } diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/DefaultFormGenerator.java b/views-fieldset/src/main/java/io/micronaut/views/fields/DefaultFormGenerator.java index a92288f4f..073255909 100644 --- a/views-fieldset/src/main/java/io/micronaut/views/fields/DefaultFormGenerator.java +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/DefaultFormGenerator.java @@ -17,6 +17,8 @@ import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.beans.BeanIntrospection; +import io.micronaut.views.fields.elements.InputFileFormElement; import io.micronaut.views.fields.elements.InputSubmitFormElement; import jakarta.inject.Singleton; import jakarta.validation.ConstraintViolationException; @@ -25,6 +27,8 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; +import java.util.function.BiConsumer; /** * {@link io.micronaut.context.annotation.DefaultImplementation} of {@link FormGenerator}. @@ -54,11 +58,24 @@ public Form generateWithFieldset(@NonNull String action, } @Override - public Form generate(String action, String method, Object instance, InputSubmitFormElement inputSubmitFormElement) { + public Form generate(@NonNull @NotBlank String action, + @NonNull @NotBlank String method, + @NonNull Object instance, + @NonNull @NotNull InputSubmitFormElement inputSubmitFormElement) { Fieldset fieldset = fieldsetGenerator.generate(instance); return generate(action, method, fieldset, inputSubmitFormElement); } + @Override + public Form generate(@NonNull @NotBlank String action, + @NonNull @NotBlank String method, + @NonNull Object instance, + @NonNull @NotNull InputSubmitFormElement inputSubmitFormElement, + @NonNull BiConsumer> builderConsumer) { + Fieldset fieldset = fieldsetGenerator.generate(instance, builderConsumer); + return generate(action, method, fieldset, inputSubmitFormElement); + } + @Override public Form generate(@NonNull String action, @NonNull String method, @@ -69,6 +86,17 @@ public Form generate(@NonNull String action, return generate(action, method, fieldset, inputSubmitFormElement); } + @Override + public Form generate(@NonNull @NotBlank String action, + @NonNull @NotBlank String method, + @NonNull Object instance, + @NonNull ConstraintViolationException ex, + @NonNull @NotNull InputSubmitFormElement inputSubmitFormElement, + @NonNull BiConsumer> builderConsumer) { + Fieldset fieldset = fieldsetGenerator.generate(instance, ex, builderConsumer); + return generate(action, method, fieldset, inputSubmitFormElement); + } + @Override public Form generate(@NonNull @NotBlank String action, @NonNull @NotBlank String method, @@ -78,12 +106,31 @@ public Form generate(@NonNull @NotBlank String action, return generate(action, method, fieldset, inputSubmitFormElement); } - private Form generate(@NonNull @NotBlank String action, + @Override + public Form generate(@NonNull @NotBlank String action, @NonNull @NotBlank String method, - @NonNull @NotNull Fieldset fieldset, - @NonNull @NotNull InputSubmitFormElement inputSubmitFormElement) { + @NonNull @NotNull Class type, + @NonNull @NotNull InputSubmitFormElement inputSubmitFormElement, + @NonNull BiConsumer> builderConsumer) { + Fieldset fieldset = fieldsetGenerator.generate(type, builderConsumer); + return generate(action, method, fieldset, inputSubmitFormElement); + } + + private Form generate(@NonNull @NotBlank String action, + @NonNull @NotBlank String method, + @NonNull @NotNull Fieldset fieldset, + @NonNull @NotNull InputSubmitFormElement inputSubmitFormElement) { List fields = new ArrayList<>(fieldset.fields()); fields.add(inputSubmitFormElement); - return new Form(action, method, new Fieldset(fields, fieldset.errors())); + return enctype(fieldset) + .map(enctype -> new Form(action, method, new Fieldset(fields, fieldset.errors()), enctype)) + .orElseGet(() -> new Form(action, method, new Fieldset(fields, fieldset.errors()))); + } + + @NonNull + private Optional enctype(@NonNull Fieldset fieldset) { + return fieldset.fields().stream().anyMatch(InputFileFormElement.class::isInstance) + ? Optional.of("multipart/form-data") + : Optional.empty(); } } diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/FormGenerator.java b/views-fieldset/src/main/java/io/micronaut/views/fields/FormGenerator.java index e0bd023ff..ad11a433c 100644 --- a/views-fieldset/src/main/java/io/micronaut/views/fields/FormGenerator.java +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/FormGenerator.java @@ -17,10 +17,13 @@ import io.micronaut.core.annotation.Experimental; import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.beans.BeanIntrospection; import io.micronaut.views.fields.elements.InputSubmitFormElement; import io.micronaut.views.fields.messages.Message; import jakarta.validation.ConstraintViolationException; +import java.util.function.BiConsumer; + /** * Generates a {@link Form} for a given type representing a form class. * @author Sergio del Amo @@ -358,4 +361,323 @@ default Form generate(@NonNull String action, @NonNull InputSubmitFormElement inputSubmitFormElement) { return generate(action, POST, type, inputSubmitFormElement); } + + + /** + * @param action Form action attribute + * @param method Form method attribute + * @param instance The Object instance which should be {@link io.micronaut.core.annotation.Introspected}. + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull String method, + @NonNull Object instance, + @NonNull BiConsumer> builderConsumer) { + return generate(action, method, instance, SUBMIT, builderConsumer); + } + + /** + * Generate FORM Post. + * @param action Form action attribute + * @param instance The Object instance which should be {@link io.micronaut.core.annotation.Introspected}. + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull Object instance, + @NonNull BiConsumer> builderConsumer) { + return generate(action, instance, SUBMIT, builderConsumer); + } + + /** + * @param action Form action attribute + * @param method Form method attribute + * @param instance The Object instance which should be {@link io.micronaut.core.annotation.Introspected}. + * @param submitValue input submit + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull String method, + @NonNull Object instance, + @NonNull Message submitValue, + @NonNull BiConsumer> builderConsumer) { + return generate(action, method, instance, new InputSubmitFormElement(submitValue), builderConsumer); + } + + /** + * Generate FORM Post. + * @param action Form action attribute + * @param instance The Object instance which should be {@link io.micronaut.core.annotation.Introspected}. + * @param submitValue input submit + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull Object instance, + @NonNull Message submitValue, + @NonNull BiConsumer> builderConsumer) { + return generate(action, instance, new InputSubmitFormElement(submitValue), builderConsumer); + } + + /** + * @param action Form action attribute + * @param method Form method attribute + * @param instance The Object instance which should be {@link io.micronaut.core.annotation.Introspected}. + * @param inputSubmitFormElement input submit + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + Form generate(@NonNull String action, + @NonNull String method, + @NonNull Object instance, + @NonNull InputSubmitFormElement inputSubmitFormElement, + @NonNull BiConsumer> builderConsumer); + + /** + * Generate FORM Post. + * @param action Form action attribute + * @param instance The Object instance which should be {@link io.micronaut.core.annotation.Introspected}. + * @param inputSubmitFormElement input submit + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull Object instance, + @NonNull InputSubmitFormElement inputSubmitFormElement, + @NonNull BiConsumer> builderConsumer) { + return generate(action, POST, instance, inputSubmitFormElement, builderConsumer); + } + + /** + * @param action Form action attribute + * @param method Form method attribute + * @param instance The Object instance which should be {@link io.micronaut.core.annotation.Introspected}. + * @param ex A Validation exception + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull String method, + @NonNull Object instance, + @NonNull ConstraintViolationException ex, + @NonNull BiConsumer> builderConsumer) { + return generate(action, method, instance, ex, SUBMIT, builderConsumer); + } + + /** + * Generate POST form. + * @param action Form action attribute + * @param instance The Object instance which should be {@link io.micronaut.core.annotation.Introspected}. + * @param ex A Validation exception + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull Object instance, + @NonNull ConstraintViolationException ex, + @NonNull BiConsumer> builderConsumer) { + return generate(action, instance, ex, SUBMIT, builderConsumer); + } + + /** + * @param action Form action attribute + * @param method Form method attribute + * @param instance The Object instance which should be {@link io.micronaut.core.annotation.Introspected}. + * @param ex A Validation exception + * @param submitValue input submit + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull String method, + @NonNull Object instance, + @NonNull ConstraintViolationException ex, + @NonNull Message submitValue, + @NonNull BiConsumer> builderConsumer) { + return generate(action, method, instance, ex, new InputSubmitFormElement(submitValue), builderConsumer); + } + + /** + * Generate POST form. + * @param action Form action attribute + * @param instance The Object instance which should be {@link io.micronaut.core.annotation.Introspected}. + * @param ex A Validation exception + * @param submitValue input submit + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull Object instance, + @NonNull ConstraintViolationException ex, + @NonNull Message submitValue, + @NonNull BiConsumer> builderConsumer) { + return generate(action, instance, ex, new InputSubmitFormElement(submitValue), builderConsumer); + } + + /** + * @param action Form action attribute + * @param method Form method attribute + * @param instance The Object instance which should be {@link io.micronaut.core.annotation.Introspected}. + * @param ex A Validation exception + * @param inputSubmitFormElement input submit + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + Form generate(@NonNull String action, + @NonNull String method, + @NonNull Object instance, + @NonNull ConstraintViolationException ex, + @NonNull InputSubmitFormElement inputSubmitFormElement, + @NonNull BiConsumer> builderConsumer); + + /** + * Generate POST form. + * @param action Form action attribute + * @param instance The Object instance which should be {@link io.micronaut.core.annotation.Introspected}. + * @param ex A Validation exception + * @param inputSubmitFormElement input submit + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull Object instance, + @NonNull ConstraintViolationException ex, + @NonNull InputSubmitFormElement inputSubmitFormElement, + @NonNull BiConsumer> builderConsumer) { + return generate(action, POST, instance, ex, inputSubmitFormElement, builderConsumer); + } + + /** + * + * @param action Form action attribute + * @param method Form method attribute + * @param type A class which should be {@link io.micronaut.core.annotation.Introspected}. + * @param type + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull String method, + @NonNull Class type, + @NonNull BiConsumer> builderConsumer) { + return generate(action, method, type, SUBMIT, builderConsumer); + } + + /** + * Generate POST Form. + * @param action Form action attribute + * @param type A class which should be {@link io.micronaut.core.annotation.Introspected}. + * @param type + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull Class type, + @NonNull BiConsumer> builderConsumer) { + return generate(action, type, SUBMIT, builderConsumer); + } + + /** + * + * @param action Form action attribute + * @param method Form method attribute + * @param type A class which should be {@link io.micronaut.core.annotation.Introspected}. + * @param submitValue input submit + * @param type + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull String method, + @NonNull Class type, + @NonNull Message submitValue, + @NonNull BiConsumer> builderConsumer) { + return generate(action, method, type, new InputSubmitFormElement(submitValue), builderConsumer); + } + + /** + * Generate a POST Form. + * @param action Form action attribute + * @param type A class which should be {@link io.micronaut.core.annotation.Introspected}. + * @param submitValue input submit + * @param type + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull Class type, + @NonNull Message submitValue, + @NonNull BiConsumer> builderConsumer) { + return generate(action, type, new InputSubmitFormElement(submitValue), builderConsumer); + } + + /** + * + * @param action Form action attribute + * @param method Form method attribute + * @param type A class which should be {@link io.micronaut.core.annotation.Introspected}. + * @param inputSubmitFormElement input submit + * @param type + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + Form generate(@NonNull String action, + @NonNull String method, + @NonNull Class type, + @NonNull InputSubmitFormElement inputSubmitFormElement, + @NonNull BiConsumer> builderConsumer); + + /** + * Generate a POST form. + * @param action Form action attribute + * @param type A class which should be {@link io.micronaut.core.annotation.Introspected}. + * @param inputSubmitFormElement input submit + * @param type + * @param builderConsumer A BiConsumer with the property name and the builder. It allows to consume a form element builder while the form fieldset is being generated. + * @return A Form + * @since 5.1.0 + */ + @NonNull + default Form generate(@NonNull String action, + @NonNull Class type, + @NonNull InputSubmitFormElement inputSubmitFormElement, + @NonNull BiConsumer> builderConsumer) { + return generate(action, POST, type, inputSubmitFormElement, builderConsumer); + } } diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/InputType.java b/views-fieldset/src/main/java/io/micronaut/views/fields/InputType.java index 6cea3fd62..454e42544 100644 --- a/views-fieldset/src/main/java/io/micronaut/views/fields/InputType.java +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/InputType.java @@ -69,6 +69,11 @@ public enum InputType { */ EMAIL("email"), + /** + * HTML Input type file. + */ + FILE("file"), + /** * HTML Input type password. */ diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/constraints/package-info.java b/views-fieldset/src/main/java/io/micronaut/views/fields/constraints/package-info.java index d938ff311..5139c8eb8 100644 --- a/views-fieldset/src/main/java/io/micronaut/views/fields/constraints/package-info.java +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/constraints/package-info.java @@ -18,4 +18,4 @@ * @author Sergio del Amo * @since 5.1.0 */ -package io.micronaut.views.fields.constraints; \ No newline at end of file +package io.micronaut.views.fields.constraints; diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/elements/InputFileFormElement.java b/views-fieldset/src/main/java/io/micronaut/views/fields/elements/InputFileFormElement.java new file mode 100644 index 000000000..b3c6363ea --- /dev/null +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/elements/InputFileFormElement.java @@ -0,0 +1,181 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.views.fields.elements; + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.views.fields.InputType; +import io.micronaut.views.fields.messages.Message; + +import java.util.Collections; +import java.util.List; + +/** + * Input File. + * @see Input File + * @param name Name of the form control. Submitted with the form as part of a name/value pair + * @param id It defines an identifier (ID) which must be unique in the whole document + * @param accept It defines the file types the file input should accept. + * @param required If true indicates that the user must specify a value for the input before the owning form can be submitted. + * @param label the input label + * @param errors errors associated with this input + * + * @author Sergio del Amo + * @since 4.1.0 + */ +@Experimental +@Introspected(builder = @Introspected.IntrospectionBuilder(builderClass = InputFileFormElement.Builder.class)) +public record InputFileFormElement(@NonNull String name, + @Nullable String id, + @Nullable String accept, + boolean required, + @Nullable Message label, + @NonNull List errors) implements InputFormElement, GlobalAttributes, FormElementAttributes { + @Override + @NonNull + public InputType getType() { + return InputType.FILE; + } + + /** + * + * @return Input Text builder + */ + @NonNull + public static Builder builder() { + return new Builder(); + } + + /** + * Input Text Builder. + */ + public static final class Builder { + /** + * + * Name of the form control. Submitted with the form as part of a name/value pair. + */ + private String name; + + /** + * + * It defines an identifier (ID) which must be unique in the whole document. + */ + private String id; + + /** + * + * The accept attribute defines the file types the file input should accept. + */ + private String accept; + + /** + * + * If true indicates that the user must specify a value for the input before the owning form can be submitted. + */ + private boolean required; + + /** + * + * Input Errors. + */ + private List errors; + + /** + * + * message for an HTML Label element. + */ + private Message label; + + /** + * + * @param accept The accept attribute defines the file types the file input should accept. + * @return The Builder + */ + @NonNull + public Builder accept(@NonNull String accept) { + this.accept = accept; + return this; + } + + /** + * + * @param required If true indicates that the user must specify a value for the input before the owning form can be submitted. + * @return the Builder + */ + @NonNull + public Builder required(boolean required) { + this.required = required; + return this; + } + + /** + * + * @param name Name of the form control. Submitted with the form as part of a name/value pair + * @return the Builder + */ + @NonNull + public Builder name(@NonNull String name) { + this.name = name; + return this; + } + + /** + * + * @param id It defines an identifier (ID) which must be unique in the whole document + * @return The Builder + */ + @NonNull + public Builder id(@NonNull String id) { + this.id = id; + return this; + } + + /** + * + * @param label represents a caption for an item in a user interface + * @return the Builder + */ + @NonNull + public Builder label(Message label) { + this.label = label; + return this; + } + + /** + * + * @param errors Form element validation Errors. + * @return The builder + */ + @NonNull + public Builder errors(@NonNull List errors) { + this.errors = errors; + return this; + } + + /** + + /** + * + * @return Creates a {@link InputFileFormElement}. + */ + @NonNull + public InputFileFormElement build() { + return new InputFileFormElement(name, id, accept, required, label, errors == null ? Collections.emptyList() : errors); + } + } +} diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/CompletedFileUploadFormElementResolver.java b/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/CompletedFileUploadFormElementResolver.java new file mode 100644 index 000000000..70899a40c --- /dev/null +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/CompletedFileUploadFormElementResolver.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.views.fields.formelementresolvers; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.beans.BeanProperty; +import io.micronaut.http.multipart.CompletedFileUpload; +import io.micronaut.views.fields.FormElement; +import io.micronaut.views.fields.elements.InputFileFormElement; +import jakarta.inject.Singleton; + +import java.util.Optional; + +/** + * Resolves a form element of type {@link InputFileFormElement} if the bean property is {@link CompletedFileUpload}. + * @author Sergio del Amo + * @since 5.1.0 + */ +@Requires(classes = CompletedFileUpload.class) +@Singleton +public class CompletedFileUploadFormElementResolver implements FormElementResolver { + @Override + public Optional> resolve(BeanProperty beanProperty) { + return beanProperty.getType() == CompletedFileUpload.class + ? Optional.of(InputFileFormElement.class) + : Optional.empty(); + } + + @Override + public int getOrder() { + return DefaultFormElementResolver.ORDER + 10; + } +} diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/CompositeFormElementResolver.java b/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/CompositeFormElementResolver.java new file mode 100644 index 000000000..8c7a5f1a1 --- /dev/null +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/CompositeFormElementResolver.java @@ -0,0 +1,63 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.views.fields.formelementresolvers; + +import io.micronaut.context.annotation.Primary; +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.beans.BeanProperty; +import io.micronaut.core.order.Ordered; +import io.micronaut.views.fields.FormElement; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.Optional; + +/** + * Composite Pattern implementaiton for the {@link FormElementResolver} API. + * This instance is injected when injecting a single bean of type {@link FormElementResolver} because of the {@link Primary} annotation. + * This implementation iterates through every bean of type {@link FormElementResolver} in order and returns the first resolved {@link FormElement} class if any. + * @author Sergio del Amo + * @since 5.1.0 + */ +@Experimental +@Primary +@Singleton +@Internal +public class CompositeFormElementResolver implements FormElementResolver { + + private final List formElementResolvers; + + /** + * + * @param formElementResolvers Beans of type {@link FormElementResolver} in order. + */ + public CompositeFormElementResolver(List formElementResolvers) { + this.formElementResolvers = formElementResolvers; + } + + @Override + public Optional> resolve(BeanProperty beanProperty) { + return formElementResolvers.stream() + .map(resolver -> resolver.resolve(beanProperty)) + .flatMap(Optional::stream) + .findFirst(); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } +} diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/DefaultFormElementResolver.java b/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/DefaultFormElementResolver.java new file mode 100644 index 000000000..c2eebe732 --- /dev/null +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/DefaultFormElementResolver.java @@ -0,0 +1,94 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.views.fields.formelementresolvers; + +import io.micronaut.core.beans.BeanProperty; +import io.micronaut.views.fields.FormElement; +import io.micronaut.views.fields.annotations.*; +import io.micronaut.views.fields.elements.*; +import jakarta.inject.Singleton; +import jakarta.validation.constraints.Email; + +import java.lang.annotation.Annotation; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Map; +import java.util.Optional; + +/** + * Default implementation of the {@link FormElementResolver} API. + * @author Sergio del Amo + * @since 5.1.0 + */ +@Singleton +public class DefaultFormElementResolver implements FormElementResolver { + public static final int ORDER = 0; + private static final String CLASS_IO_MICRONAUT_DATA_ANNOTATION_AUTO_POPULATED = "io.micronaut.data.annotation.AutoPopulated"; + private static final Map, Class> ANNOTATION_MAPPING = Map.ofEntries( + Map.entry(InputHidden.class, InputHiddenFormElement.class), + Map.entry(InputRadio.class, InputRadioFormElement.class), + Map.entry(InputCheckbox.class, InputCheckboxFormElement.class), + Map.entry(InputPassword.class, InputPasswordFormElement.class), + Map.entry(InputEmail.class, InputEmailFormElement.class), + Map.entry(Email.class, InputEmailFormElement.class), + Map.entry(InputUrl.class, InputUrlFormElement.class), + Map.entry(InputTel.class, InputTelFormElement.class), + Map.entry(Select.class, SelectFormElement.class), + Map.entry(Textarea.class, TextareaFormElement.class), + Map.entry(TrixEditor.class, TrixEditorFormElement.class) + ); + + @Override + public Optional> resolve(BeanProperty beanProperty) { + if (beanProperty.hasStereotype(CLASS_IO_MICRONAUT_DATA_ANNOTATION_AUTO_POPULATED)) { + return Optional.empty(); + } + for (var mapping : ANNOTATION_MAPPING.entrySet()) { + if (beanProperty.hasAnnotation(mapping.getKey())) { + return Optional.of(mapping.getValue()); + } + } + if (beanProperty.getType() == LocalDate.class) { + return Optional.of(InputDateFormElement.class); + } + if (beanProperty.getType() == LocalDateTime.class) { + return Optional.of(InputDateTimeLocalFormElement.class); + } + if (beanProperty.getType() == LocalTime.class) { + return Optional.of(InputTimeFormElement.class); + } + if (Number.class.isAssignableFrom(beanProperty.getType())) { + return Optional.of(InputNumberFormElement.class); + } + if (beanProperty.getType() == boolean.class) { + return Optional.of(InputCheckboxFormElement.class); + } + if (beanProperty.getType().isEnum()) { + return Optional.of(SelectFormElement.class); + } + if (CharSequence.class.isAssignableFrom(beanProperty.getType())) { + return Optional.of(InputTextFormElement.class); + } + + return Optional.empty(); + } + + @Override + public int getOrder() { + return ORDER; + } +} diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/FormElementResolver.java b/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/FormElementResolver.java new file mode 100644 index 000000000..66a400d09 --- /dev/null +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/FormElementResolver.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.views.fields.formelementresolvers; + +import io.micronaut.core.annotation.Indexed; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.beans.BeanProperty; +import io.micronaut.core.order.Ordered; +import io.micronaut.views.fields.FormElement; + +import java.util.Optional; + +/** + * Resolves given a bean property the {@link FormElement} class which should be used to be build the element. + * @author Sergio del Amo + * @since 5.1.0 + */ +@FunctionalInterface +@Indexed(FormElementResolver.class) +public interface FormElementResolver extends Ordered { + /** + * Resolves given a bean property the {@link FormElement} class which should be used to be build the element. + * + * @param beanProperty Bean Property + * @return The best Form Element for the bean property. + * @param The bean type + * @param The bean property type + */ + Optional> resolve(@NonNull BeanProperty beanProperty); +} diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/package-info.java b/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/package-info.java new file mode 100644 index 000000000..4bca2479c --- /dev/null +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/formelementresolvers/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Defines an API to resolve a Form Element class given a bean property. + * @author Sergio del Amo + * @since 5.1.0 + */ +package io.micronaut.views.fields.formelementresolvers; diff --git a/views-fieldset/src/test/java/io/micronaut/views/fields/InputFileFormElementTest.java b/views-fieldset/src/test/java/io/micronaut/views/fields/InputFileFormElementTest.java new file mode 100644 index 000000000..65b6af55d --- /dev/null +++ b/views-fieldset/src/test/java/io/micronaut/views/fields/InputFileFormElementTest.java @@ -0,0 +1,50 @@ +package io.micronaut.views.fields; + +import io.micronaut.core.beans.BeanIntrospection; +import io.micronaut.views.fields.elements.InputFileFormElement; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class InputFileFormElementTest { + private final String ID = "numid"; + private static final String NAME = "num"; + private static final boolean REQUIRED = true; + private static final String ACCEPT = ".doc,.docx,.xml,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + + @Test + void testTagAndType() { + InputFileFormElement formElement = InputFileFormElement.builder().build(); + assertEquals(HtmlTag.INPUT, formElement.getTag()); + assertEquals(InputType.FILE, formElement.getType()); + } + + @Test + void builder() { + InputFileFormElement formElement = InputFileFormElement.builder() + .name(NAME) + .id(ID) + .accept(ACCEPT) + .required(REQUIRED) + .build(); + assertFormElement(formElement); + BeanIntrospection introspection = BeanIntrospection.getIntrospection(InputFileFormElement.class); + BeanIntrospection.Builder builder = introspection.builder(); + formElement = builder + .with("name", NAME) + .with("id", ID) + .with("accept", ACCEPT) + .with("required", REQUIRED) + .build(); + assertFormElement(formElement); + assertFalse(formElement.hasErrors()); + } + + private void assertFormElement(InputFileFormElement formElement) { + assertNotNull(formElement); + assertEquals(ID, formElement.id()); + assertEquals(NAME, formElement.name()); + assertEquals(REQUIRED, formElement.required()); + assertEquals(ACCEPT, formElement.accept()); + } +} \ No newline at end of file diff --git a/views-fieldset/src/test/java/io/micronaut/views/fields/formelementresolvers/FormElementResolverTest.java b/views-fieldset/src/test/java/io/micronaut/views/fields/formelementresolvers/FormElementResolverTest.java new file mode 100644 index 000000000..4ed1afe61 --- /dev/null +++ b/views-fieldset/src/test/java/io/micronaut/views/fields/formelementresolvers/FormElementResolverTest.java @@ -0,0 +1,27 @@ +package io.micronaut.views.fields.formelementresolvers; + +import io.micronaut.context.BeanContext; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@MicronautTest(startApplication = false) +class FormElementResolverTest { + + @Inject + BeanContext beanContext; + + @Test + void defaultFormElementResolverFirst() { + List resolvers = new ArrayList<>(beanContext.getBeansOfType(FormElementResolver.class)); + assertEquals(3, resolvers.size()); + assertTrue(resolvers.get(0) instanceof CompositeFormElementResolver); + assertTrue(resolvers.get(1) instanceof DefaultFormElementResolver); + assertTrue(resolvers.get(2) instanceof CompletedFileUploadFormElementResolver); + } +}