Skip to content

Commit

Permalink
feat: input file (#677)
Browse files Browse the repository at this point in the history
  • Loading branch information
sdelamo authored Dec 19, 2023
1 parent 2e01053 commit 16cdae4
Show file tree
Hide file tree
Showing 20 changed files with 1,084 additions and 72 deletions.
1 change: 1 addition & 0 deletions src/main/docs/guide/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/main/docs/guide/views/fieldset/customFormElement.adoc
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<th:block th:each="field : ${el.fields()}" th:fragment="fieldset(el)" xmlns:th="http://www.thymeleaf.org"><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'hidden'}"><input th:replace="~{fieldset/inputhidden :: inputhidden(${field})}"/></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'password'}"><div class="mb-3" th:insert="~{fieldset/inputstring :: inputstring('password',${field})}"/></th:block><th:block th:if="${field.tag.toString() == 'textarea'}"><div class="mb-3" th:insert="~{fieldset/textarea :: textarea(${field})}"/></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'number'}"><div class="mb-3" th:insert="~{fieldset/inputnumber :: inputnumber(${field})}"/></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'submit'}"><div th:replace="~{fieldset/inputsubmit :: inputsubmit(${field})}"/></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'datetime-local'}"><div class="mb-3" th:insert="~{fieldset/inputdatetimelocal :: inputdatetimelocal(${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'date'}"><div class="mb-3" th:insert="~{fieldset/inputdate :: inputdate(${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'time'}"><div class="mb-3" th:insert="~{fieldset/inputtime :: inputtime(${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'text'}"><div class="mb-3" th:insert="~{fieldset/inputstring :: inputstring('text', ${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'email'}"><div class="mb-3" th:insert="~{fieldset/inputstring :: inputstring('email', ${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'tel'}"><div class="mb-3" th:insert="~{fieldset/inputstring :: inputstring('tel', ${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'url'}"><div class="mb-3" th:insert="~{fieldset/inputstring :: inputstring('url', ${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'radio'}"><div class="mb-3" th:insert="~{fieldset/inputradios :: inputradios(${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'checkbox'}"><div class="mb-3" th:insert="~{fieldset/inputcheckbox :: inputcheckbox(${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'select'}"><div class="mb-3" th:insert="~{fieldset/select :: select(${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'trix-editor'}"><div class="mb-3" th:if="${el.hasErrors()}" th:insert="~{fieldset/errors :: errors(${el.errors()})}"></div><div class="mb-3" th:insert="~{fieldset/trixeditor :: trixeditor(${field})}"></div></th:block></th:block>
<th:block th:each="field : ${el.fields()}" th:fragment="fieldset(el)" xmlns:th="http://www.thymeleaf.org"><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'file'}"><input th:replace="~{fieldset/inputfile :: inputfile(${field})}"/></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'hidden'}"><input th:replace="~{fieldset/inputhidden :: inputhidden(${field})}"/></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'password'}"><div class="mb-3" th:insert="~{fieldset/inputstring :: inputstring('password',${field})}"/></th:block><th:block th:if="${field.tag.toString() == 'textarea'}"><div class="mb-3" th:insert="~{fieldset/textarea :: textarea(${field})}"/></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'number'}"><div class="mb-3" th:insert="~{fieldset/inputnumber :: inputnumber(${field})}"/></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'submit'}"><div th:replace="~{fieldset/inputsubmit :: inputsubmit(${field})}"/></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'datetime-local'}"><div class="mb-3" th:insert="~{fieldset/inputdatetimelocal :: inputdatetimelocal(${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'date'}"><div class="mb-3" th:insert="~{fieldset/inputdate :: inputdate(${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'time'}"><div class="mb-3" th:insert="~{fieldset/inputtime :: inputtime(${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'text'}"><div class="mb-3" th:insert="~{fieldset/inputstring :: inputstring('text', ${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'email'}"><div class="mb-3" th:insert="~{fieldset/inputstring :: inputstring('email', ${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'tel'}"><div class="mb-3" th:insert="~{fieldset/inputstring :: inputstring('tel', ${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'url'}"><div class="mb-3" th:insert="~{fieldset/inputstring :: inputstring('url', ${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'radio'}"><div class="mb-3" th:insert="~{fieldset/inputradios :: inputradios(${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'input' && field.type.toString() == 'checkbox'}"><div class="mb-3" th:insert="~{fieldset/inputcheckbox :: inputcheckbox(${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'select'}"><div class="mb-3" th:insert="~{fieldset/select :: select(${field})}"></div></th:block><th:block th:if="${field.tag.toString() == 'trix-editor'}"><div class="mb-3" th:if="${el.hasErrors()}" th:insert="~{fieldset/errors :: errors(${el.errors()})}"></div><div class="mb-3" th:insert="~{fieldset/trixeditor :: trixeditor(${field})}"></div></th:block></th:block>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<th:block th:fragment="inputfile(el)" xmlns:th="http://www.thymeleaf.org"><label th:replace="~{fieldset/label :: label(${el.id()}, ${el.label()})}"></label><input th:type="file" th:name="${el.name()}" th:id="${el.id()}" th:accept="${el.accept()}" th:required="${el.required()}" class="form-control" th:classappend="${el.hasErrors()} ? is-invalid" th:attr="aria-describedby=${el.hasErrors}?|${el.name}ValidationServerFeedback|"/><div th:if="${el.hasErrors()}" th:replace="~{fieldset/errors :: errors(${el.name()}, ${el.errors()})}"></div></th:block>
1 change: 1 addition & 0 deletions views-fieldset-tck/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Map<String, Object>, ?> viewsRenderer,
FormGenerator formGenerator,
EventImageSaveValidator validator) throws IOException {
String viewName = "fieldset/form.html";
BiConsumer<String, BeanIntrospection.Builder<? extends FormElement>> 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 action="/foo/bar" method="post" enctype="multipart/form-data">\
<input type="hidden" name="id" value=""/>\
<div class="mb-3"><label for="alt" class="form-label">Alternative description of the image</label><input type="text" name="alt" value="" id="alt" class="form-control" required="required"/></div>\
<label for="file" class="form-label">File</label><input type="file" name="file" id="file" accept="image/png, image/jpeg" class="form-control" required="required"/>\
<input type="submit" value="Submit" class="btn btn-primary"/>\
</form>""";
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 = """
<form action="/foo/bar" method="post" enctype="multipart/form-data">\
<input type="hidden" name="id" value="xxx"/>\
<div class="mb-3"><label for="alt" class="form-label">Alternative description of the image</label>\
<input type="text" name="alt" value="" id="alt" class="form-control is-invalid" aria-describedby="altValidationServerFeedback" required="required"/>\
<div id="altValidationServerFeedback" class="invalid-feedback">must not be blank</div>\
</div>\
<label for="file" class="form-label">File</label>\
<input type="file" name="file" id="file" accept="image/png, image/jpeg" class="form-control is-invalid" aria-describedby="fileValidationServerFeedback" required="required"/>\
<div id="fileValidationServerFeedback" class="invalid-feedback">must not be null</div>\
<input type="submit" value="Submit" class="btn btn-primary"/>\
</form>""";
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 action="/foo/bar" method="post" enctype="multipart/form-data">\
<input type="hidden" name="id" value="xxx"/>\
<div class="mb-3"><label for="alt" class="form-label">Alternative description of the image</label><input type="text" name="alt" value="Micronaut Logo" id="alt" class="form-control" required="required"/></div>\
<label for="file" class="form-label">File</label><input type="file" name="file" id="file" accept="image/png, image/jpeg" class="form-control" required="required"/>\
<input type="submit" value="Submit" class="btn btn-primary"/>\
</form>""";
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) {
}
}

}
4 changes: 2 additions & 2 deletions views-fieldset/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 16cdae4

Please sign in to comment.