diff --git a/annotations/build.gradle.kts b/annotations/build.gradle.kts index ad90b739..eafc5b36 100644 --- a/annotations/build.gradle.kts +++ b/annotations/build.gradle.kts @@ -3,4 +3,5 @@ plugins { } dependencies { api("io.micronaut:micronaut-http") + api("io.micronaut.views:micronaut-views-core:5.3.0") } \ No newline at end of file diff --git a/annotations/src/main/java/org/projectcheckins/annotations/GetHtml.java b/annotations/src/main/java/org/projectcheckins/annotations/GetHtml.java index a43563e6..20ba7978 100644 --- a/annotations/src/main/java/org/projectcheckins/annotations/GetHtml.java +++ b/annotations/src/main/java/org/projectcheckins/annotations/GetHtml.java @@ -4,6 +4,7 @@ import io.micronaut.scheduling.TaskExecutors; import java.lang.annotation.*; import static java.lang.annotation.RetentionPolicy.RUNTIME; +import io.micronaut.views.turbo.TurboStreamAction; @Documented @Retention(RUNTIME) @@ -19,4 +20,6 @@ boolean hidden() default true; String executesOn() default TaskExecutors.BLOCKING; + + String turboView() default ""; } diff --git a/bootstrap/build.gradle.kts b/bootstrap/build.gradle.kts index 69afe25e..adb08367 100644 --- a/bootstrap/build.gradle.kts +++ b/bootstrap/build.gradle.kts @@ -2,6 +2,6 @@ plugins { id("org.projectcheckins.micronaut-modules-conventions") } dependencies { - implementation("io.micronaut.views:micronaut-views-fieldset") + implementation("io.micronaut.views:micronaut-views-fieldset:5.3.0") testImplementation(project(":test-utils")) } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/org.projectcheckins.micronaut-http-modules-conventions.gradle.kts b/buildSrc/src/main/kotlin/org.projectcheckins.micronaut-http-modules-conventions.gradle.kts index f7973aac..5f508eae 100644 --- a/buildSrc/src/main/kotlin/org.projectcheckins.micronaut-http-modules-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/org.projectcheckins.micronaut-http-modules-conventions.gradle.kts @@ -11,9 +11,9 @@ dependencies { implementation("io.micronaut:micronaut-http-server") // Views - implementation("io.micronaut.views:micronaut-views-fieldset") - implementation("io.micronaut.views:micronaut-views-core") - runtimeOnly("io.micronaut.views:micronaut-views-thymeleaf") + implementation("io.micronaut.views:micronaut-views-fieldset:5.3.0") + implementation("io.micronaut.views:micronaut-views-core:5.3.0") + runtimeOnly("io.micronaut.views:micronaut-views-thymeleaf:5.3.0") // Test Server testImplementation("io.micronaut:micronaut-http-server-netty") diff --git a/buildSrc/src/main/kotlin/org.projectcheckins.micronaut-modules-conventions.gradle.kts b/buildSrc/src/main/kotlin/org.projectcheckins.micronaut-modules-conventions.gradle.kts index 11a3e605..8dd25dfd 100644 --- a/buildSrc/src/main/kotlin/org.projectcheckins.micronaut-modules-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/org.projectcheckins.micronaut-modules-conventions.gradle.kts @@ -43,3 +43,11 @@ dependencies { tasks.withType { useJUnitPlatform() } + +configurations.all { + resolutionStrategy.eachDependency { + if (requested.group == "io.micronaut.views") { + useVersion("5.3.0") + } + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index a0477b6d..78fcca5d 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -5,8 +5,8 @@ dependencies { api(project(":multitenancy")) api(project(":security")) api("io.micronaut.security:micronaut-security") - api("io.micronaut.views:micronaut-views-core") - api("io.micronaut.views:micronaut-views-fieldset") + api("io.micronaut.views:micronaut-views-core:5.3.0") + api("io.micronaut.views:micronaut-views-fieldset:5.3.0") implementation("com.vladsch.flexmark:flexmark:${project.properties["flexmarkVersion"]}") diff --git a/http/build.gradle.kts b/http/build.gradle.kts index 16c42fdf..c708b382 100644 --- a/http/build.gradle.kts +++ b/http/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("org.projectcheckins.micronaut-http-modules-conventions") } dependencies { + api(project(":security-http")) api(project(":core")) api(project(":bootstrap")) implementation("io.micronaut:micronaut-management") diff --git a/http/src/main/java/org/projectcheckins/http/controllers/AnswerController.java b/http/src/main/java/org/projectcheckins/http/controllers/AnswerController.java index 1249fff4..5e07d072 100644 --- a/http/src/main/java/org/projectcheckins/http/controllers/AnswerController.java +++ b/http/src/main/java/org/projectcheckins/http/controllers/AnswerController.java @@ -15,6 +15,7 @@ import io.micronaut.views.fields.Form; import io.micronaut.views.fields.FormGenerator; import io.micronaut.views.fields.messages.Message; +import io.micronaut.views.turbo.http.TurboMediaType; import jakarta.validation.ConstraintViolationException; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -28,6 +29,8 @@ import org.projectcheckins.core.forms.*; import org.projectcheckins.core.services.AnswerService; import org.projectcheckins.core.services.QuestionService; +import org.projectcheckins.security.http.TurboFrameUtils; +import org.projectcheckins.security.http.TurboStreamUtils; import java.net.URI; import java.util.List; @@ -39,6 +42,8 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static org.projectcheckins.http.controllers.ApiConstants.SLASH; + @Controller class AnswerController { private static final String ANSWER = "answer"; @@ -77,24 +82,30 @@ class AnswerController { public static final BiFunction PATH_SHOW_URI_BUILDER = (questionId, id) -> UriBuilder.of(QuestionController.PATH).path(questionId).path(ANSWER).path(id).path(ApiConstants.ACTION_SHOW).build(); public static final Function PATH_SHOW_BUILDER = answer -> PATH_SHOW_URI_BUILDER.apply(answer.questionId(), answer.id()); private static final String VIEW_SHOW = ANSWER + ApiConstants.VIEW_SHOW; + public static final String VIEW_SHOW_FRAGMENT = ANSWER + SLASH + ApiConstants.FRAGMENT_SHOW; // EDIT private static final String PATH_EDIT = PATH + ApiConstants.PATH_EDIT; private static final String VIEW_EDIT = ANSWER + ApiConstants.VIEW_EDIT; + public static final String VIEW_EDIT_FRAGMENT = ANSWER + SLASH + ApiConstants.FRAGMENT_EDIT; private static final Breadcrumb BREADCRUMB_EDIT = new Breadcrumb(Message.of("Edit Answer", ANSWER + ApiConstants.DOT + ApiConstants.ACTION_EDIT)); private final QuestionService questionService; private final AnswerService answerService; private final FormGenerator formGenerator; + private final AnswerSaveFormGenerator answerSaveFormGenerator; + private final HttpLocaleResolver httpLocaleResolver; AnswerController(QuestionService questionService, AnswerService answerService, FormGenerator formGenerator, + AnswerSaveFormGenerator answerSaveFormGenerator, HttpLocaleResolver httpLocaleResolver) { this.questionService = questionService; this.answerService = answerService; this.formGenerator = formGenerator; + this.answerSaveFormGenerator = answerSaveFormGenerator; this.httpLocaleResolver = httpLocaleResolver; } @@ -124,13 +135,16 @@ HttpResponse answerShow(HttpRequest request, @Nullable Tenant tenant) { Locale locale = httpLocaleResolver.resolveOrDefault(request); return answerShowModel(questionId, id, authentication, locale, tenant) - .map(model -> new ModelAndView<>(VIEW_SHOW, model)) + .map(model -> TurboFrameUtils.turboFrame(request) + .map(frame -> (Object) TurboFrameUtils.turboFrame(frame, VIEW_SHOW_FRAGMENT, model)) + .orElseGet(() -> new ModelAndView<>(VIEW_SHOW, model))) .map(HttpResponse::ok) .orElseGet(NotFoundController::notFoundRedirect); } @GetHtml(uri = PATH_EDIT, rolesAllowed = SecurityRule.IS_AUTHENTICATED) - HttpResponse answerEdit(@NonNull @NotNull HttpRequest request, @PathVariable @NotBlank String questionId, + HttpResponse answerEdit(@NonNull @NotNull HttpRequest request, + @PathVariable @NotBlank String questionId, @PathVariable @NotBlank String id, @NonNull Authentication authentication, @Nullable Locale locale, @@ -138,7 +152,9 @@ HttpResponse answerEdit(@NonNull @NotNull HttpRequest request, @PathVariab return questionService.findById(questionId, tenant) .flatMap(question -> answerService.findById(id, authentication, tenant) .map(answer -> updateAnswerModel(question, answer, locale))) - .map(model -> new ModelAndView<>(VIEW_EDIT, model)) + .map(model -> TurboFrameUtils.turboFrame(request) + .map(frame -> (Object) TurboFrameUtils.turboFrame(frame, VIEW_EDIT_FRAGMENT, model)) + .orElseGet(() -> new ModelAndView<>(VIEW_EDIT, model))) .map(HttpResponse::ok) .orElseGet(NotFoundController::notFoundRedirect); } @@ -151,6 +167,12 @@ HttpResponse answerUpdate(@NonNull @NotNull HttpRequest request, @Nullable Tenant tenant, @Body @NonNull @NotNull @Valid AnswerUpdateRecord answerUpdate) { answerService.update(authentication, questionId, id, answerUpdate, tenant); + if (TurboMediaType.acceptsTurboStream(request)) { + return answerShowModel(questionId, id, authentication, httpLocaleResolver.resolveOrDefault(request), tenant) + .flatMap(model -> TurboStreamUtils.turboStream(request, VIEW_SHOW_FRAGMENT, model)) + .map(HttpResponse::ok) + .orElseGet(NotFoundController::notFoundRedirect); + } return HttpResponse.seeOther(PATH_SHOW_URI_BUILDER.apply(questionId, id)); } @@ -169,9 +191,10 @@ private Optional> retrySave(HttpRequest request, Authenticati return answerForm(request) .map(f -> questionService.findById(f.questionId(), tenant) .map(q -> QuestionController.showModel(answerService, q, generateForm(f, ex), auth, tenant)) - .map(model -> new ModelAndView<>(QuestionController.VIEW_SHOW, model)) + .map(model -> TurboMediaType.acceptsTurboStream(request) ? TurboStreamUtils.turboStream(request, VIEW_SHOW_FRAGMENT, model) : new ModelAndView<>(QuestionController.VIEW_SHOW, model)) .map(b -> HttpResponse.unprocessableEntity().body(b)) - .orElseGet(NotFoundController::notFoundRedirect)); + .orElseGet(NotFoundController::notFoundRedirect)); + } private Optional answerForm(HttpRequest request) { @@ -268,6 +291,12 @@ private HttpResponse answerSave(@NonNull HttpRequest request, return HttpResponse.unprocessableEntity(); } answerService.save(authentication, new AnswerSave(form.questionId(), form.answerDate(), format, text), tenant); + if (TurboMediaType.acceptsTurboStream(request)) { + return QuestionController.showModel(answerService, questionService, answerSaveFormGenerator, questionId, authentication, tenant) + .flatMap(model -> TurboStreamUtils.turboStream(request, QuestionController.VIEW_SHOW_FRAGMENT, model)) + .map(HttpResponse::ok) + .orElseGet(NotFoundController::notFoundRedirect); + } return HttpResponse.seeOther(QuestionController.PATH_SHOW_BUILDER.apply(questionId)); } diff --git a/http/src/main/java/org/projectcheckins/http/controllers/ApiConstants.java b/http/src/main/java/org/projectcheckins/http/controllers/ApiConstants.java index f8974dc3..98663c74 100644 --- a/http/src/main/java/org/projectcheckins/http/controllers/ApiConstants.java +++ b/http/src/main/java/org/projectcheckins/http/controllers/ApiConstants.java @@ -10,6 +10,8 @@ ) ) public interface ApiConstants { + String FRAME_ID_MAIN = "main"; + String DATA_TURBO_ACTION = "advance"; String PATH_VARIABLE_ID = "{id}"; String SLASH = "/"; @@ -19,6 +21,10 @@ public interface ApiConstants { String ACTION_SHOW = "show"; String ACTION_CREATE = "create"; + String FRAGMENT_SHOW = "_show.html"; + String FRAGMENT_CREATE = "_create.html"; + String FRAGMENT_EDIT = "_edit.html"; + String FRAGMENT_LIST = "_list.html"; String ACTION_EDIT = "edit"; String ACTION_SAVE = "save"; String ACTION_UPDATE = "update"; diff --git a/http/src/main/java/org/projectcheckins/http/controllers/NotFoundController.java b/http/src/main/java/org/projectcheckins/http/controllers/NotFoundController.java index 7040b0a4..7f02d3d4 100644 --- a/http/src/main/java/org/projectcheckins/http/controllers/NotFoundController.java +++ b/http/src/main/java/org/projectcheckins/http/controllers/NotFoundController.java @@ -19,7 +19,6 @@ Map index() { return Collections.emptyMap(); } - public static MutableHttpResponse notFoundRedirect() { return HttpResponse.seeOther(URI.create(PATH)); } diff --git a/http/src/main/java/org/projectcheckins/http/controllers/ProfileController.java b/http/src/main/java/org/projectcheckins/http/controllers/ProfileController.java index 877f6ee9..cdb94a1a 100644 --- a/http/src/main/java/org/projectcheckins/http/controllers/ProfileController.java +++ b/http/src/main/java/org/projectcheckins/http/controllers/ProfileController.java @@ -12,6 +12,7 @@ import io.micronaut.views.fields.Form; import io.micronaut.views.fields.FormGenerator; import io.micronaut.views.fields.messages.Message; +import io.micronaut.views.turbo.http.TurboMediaType; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import java.net.URI; @@ -25,6 +26,9 @@ import org.projectcheckins.core.api.Profile; import org.projectcheckins.core.forms.ProfileUpdate; import org.projectcheckins.core.repositories.ProfileRepository; + +import org.projectcheckins.security.http.TurboFrameUtils; +import org.projectcheckins.security.http.TurboStreamUtils; import static org.projectcheckins.http.controllers.ApiConstants.*; @Controller @@ -36,11 +40,15 @@ class ProfileController { private static final String MODEL_PROFILE = "profile"; // SHOW + public static final String VIEW_SHOW_FRAGMENT = MODEL_PROFILE + SLASH + ApiConstants.FRAGMENT_SHOW; + private static final Message MESSAGE_SHOW = Message.of("Profile", PROFILE + ApiConstants.DOT + ApiConstants.ACTION_SHOW); private static final String PATH_SHOW = PATH + SLASH + ApiConstants.ACTION_SHOW; private static final String VIEW_SHOW = PATH + ApiConstants.VIEW_SHOW; // EDIT + private static final String VIEW_EDIT_FRAGMENT = PATH + SLASH + FRAGMENT_EDIT; + private static final Message MESSAGE_BREADCRUMB_EDIT = Message.of("Edit", PROFILE + ApiConstants.DOT + ApiConstants.ACTION_EDIT); private static final Breadcrumb BREADCRUMB_EDIT = new Breadcrumb(MESSAGE_BREADCRUMB_EDIT); private static final String PATH_EDIT = PATH + SLASH + ApiConstants.ACTION_EDIT; @@ -62,7 +70,9 @@ HttpResponse profileShow(@NonNull @NotNull HttpRequest request, @NonNull @NotNull Authentication authentication, @Nullable Tenant tenant) { return showModel(authentication, tenant) - .map(model -> new ModelAndView<>(VIEW_SHOW, model)) + .map(model -> TurboFrameUtils.turboFrame(request) + .map(frame -> (Object) TurboFrameUtils.turboFrame(frame, VIEW_SHOW_FRAGMENT, model)) + .orElseGet(() -> new ModelAndView<>(VIEW_SHOW, model))) .map(HttpResponse::ok) .orElseGet(NotFoundController::notFoundRedirect); } @@ -73,7 +83,9 @@ HttpResponse profileEdit(@NonNull @NotNull HttpRequest request, @Nullable Tenant tenant) { return profileRepository.findByAuthentication(authentication, tenant) .map(this::updateModel) - .map(model -> new ModelAndView<>(VIEW_EDIT, model)) + .map(model -> TurboFrameUtils.turboFrame(request) + .map(frame -> (Object) TurboFrameUtils.turboFrame(frame, VIEW_EDIT_FRAGMENT, model)) + .orElseGet(() -> new ModelAndView<>(VIEW_EDIT, model))) .map(HttpResponse::ok) .orElseGet(NotFoundController::notFoundRedirect); } @@ -85,6 +97,12 @@ HttpResponse profileUpdate( @NonNull @NotNull @Valid @Body ProfileUpdate profileUpdate, @Nullable Tenant tenant) { profileRepository.update(authentication, profileUpdate, tenant); + if (TurboMediaType.acceptsTurboStream(request)) { + return showModel(authentication, tenant) + .flatMap(model -> TurboStreamUtils.turboStream(request, VIEW_SHOW_FRAGMENT, model)) + .map(HttpResponse::ok) + .orElseGet(HttpResponse::notFound); + } return HttpResponse.seeOther(URI.create(PATH_SHOW)); } diff --git a/http/src/main/java/org/projectcheckins/http/controllers/QuestionController.java b/http/src/main/java/org/projectcheckins/http/controllers/QuestionController.java index 10e8b727..df287394 100644 --- a/http/src/main/java/org/projectcheckins/http/controllers/QuestionController.java +++ b/http/src/main/java/org/projectcheckins/http/controllers/QuestionController.java @@ -16,6 +16,9 @@ import io.micronaut.views.ModelAndView; import io.micronaut.views.fields.Form; import io.micronaut.views.fields.messages.Message; +import io.micronaut.views.turbo.TurboStream; +import io.micronaut.views.turbo.http.TurboHttpHeaders; +import io.micronaut.views.turbo.http.TurboMediaType; import jakarta.validation.ConstraintViolationException; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -27,6 +30,8 @@ import org.projectcheckins.core.forms.*; import org.projectcheckins.core.services.AnswerService; import org.projectcheckins.core.services.QuestionService; +import org.projectcheckins.security.http.TurboFrameUtils; +import org.projectcheckins.security.http.TurboStreamUtils; import java.net.URI; import java.time.DayOfWeek; @@ -37,6 +42,8 @@ import java.util.regex.Pattern; import static org.projectcheckins.http.controllers.ApiConstants.*; +import static org.projectcheckins.http.controllers.ApiConstants.FRAME_ID_MAIN; +import static org.projectcheckins.http.controllers.ApiConstants.SLASH; @Controller class QuestionController { @@ -50,6 +57,7 @@ class QuestionController { public static final String MODEL_ANSWERS = "answers"; // LIST + private static final String VIEW_LIST_FRAGMENT = PATH + SLASH + FRAGMENT_LIST; public static final String PATH_LIST = PATH + ApiConstants.PATH_LIST; private static final String VIEW_LIST = PATH + ApiConstants.VIEW_LIST; public static final Message MESSAGE_QUESTIONS = Message.of("Questions", QUESTION + DOT + ACTION_LIST); @@ -58,6 +66,7 @@ class QuestionController { // CREATE private static final String PATH_CREATE = PATH + ApiConstants.PATH_CREATE; private static final String VIEW_CREATE = PATH + ApiConstants.VIEW_CREATE; + private static final String VIEW_CREATE_FRAGMENT = PATH + SLASH + FRAGMENT_CREATE; private static final Breadcrumb BREADCRUMB_CREATE = new Breadcrumb(Message.of("New Question", QUESTION + DOT + ACTION_CREATE)); // SAVE @@ -67,11 +76,13 @@ class QuestionController { private static final String PATH_SHOW = PATH + ApiConstants.PATH_SHOW; public static final Function PATH_SHOW_BUILDER = id -> UriBuilder.of(PATH).path(id).path(ACTION_SHOW).build(); public static final String VIEW_SHOW = PATH + ApiConstants.VIEW_SHOW; + public static final String VIEW_SHOW_FRAGMENT = PATH + SLASH + FRAGMENT_SHOW; public static final Function BREADCRUMB_SHOW = question -> new Breadcrumb(Message.of(question.title()), PATH_SHOW_BUILDER.andThen(URI::toString).apply(question.id())); // EDIT private static final String PATH_EDIT = PATH + ApiConstants.PATH_EDIT; private static final String VIEW_EDIT = PATH + ApiConstants.VIEW_EDIT; + private static final String VIEW_EDIT_FRAGMENT = PATH + SLASH + FRAGMENT_EDIT; private static final Breadcrumb BREADCRUMB_EDIT = new Breadcrumb(Message.of("Edit Question", QUESTION + DOT + ACTION_EDIT)); // UPDATE @@ -98,14 +109,16 @@ class QuestionController { @GetHtml(uri = PATH_LIST, rolesAllowed = SecurityRule.IS_AUTHENTICATED, - view = VIEW_LIST) + view = VIEW_LIST, + turboView = VIEW_LIST_FRAGMENT) Map questionList(@Nullable Tenant tenant) { return listModel(tenant); } @GetHtml(uri = PATH_CREATE, rolesAllowed = SecurityRule.IS_AUTHENTICATED, - view = VIEW_CREATE) + view = VIEW_CREATE, + turboView = VIEW_CREATE_FRAGMENT) Map questionCreate(@Nullable Tenant tenant) { final QuestionForm form = QuestionFormRecord.of(new QuestionRecord( null, @@ -120,12 +133,14 @@ Map questionCreate(@Nullable Tenant tenant) { } @PostForm(uri = PATH_SAVE, rolesAllowed = SecurityRule.IS_AUTHENTICATED) - HttpResponse questionSave(HttpRequest request, + HttpResponse questionSave(@NonNull @NotNull HttpRequest request, @NonNull @NotNull @Valid @Body QuestionFormRecord form, @NonNull Authentication authentication, @Nullable Tenant tenant) { String id = questionService.save(form, tenant); - return HttpResponse.seeOther(PATH_SHOW_BUILDER.apply(id)); + return TurboMediaType.acceptsTurboStream(request) + ? showTurboStream(request, id, authentication, tenant).map(HttpResponse::ok).orElseGet(HttpResponse::notFound) + : HttpResponse.seeOther(PATH_SHOW_BUILDER.apply(id)); } @GetHtml(uri = PATH_SHOW, rolesAllowed = SecurityRule.IS_AUTHENTICATED) @@ -134,29 +149,39 @@ HttpResponse questionShow(HttpRequest request, @NonNull Authentication authentication, @Nullable Tenant tenant) { return showModel(answerService, questionService, answerSaveFormGenerator, id, authentication, tenant) - .map(model -> new ModelAndView<>(VIEW_SHOW, model)) + .map(model -> TurboFrameUtils.turboFrame(request, VIEW_SHOW_FRAGMENT, model) + .orElseGet(() -> new ModelAndView<>(VIEW_SHOW, model))) .map(HttpResponse::ok) .orElseGet(NotFoundController::notFoundRedirect); } + @GetHtml(uri = PATH_EDIT, rolesAllowed = SecurityRule.IS_AUTHENTICATED) HttpResponse questionEdit(HttpRequest request, @PathVariable @NotBlank String id, @Nullable Tenant tenant) { return questionService.findById(id, tenant) .map(question -> updateModel(question, QuestionFormRecord.of(question), tenant)) - .map(model -> new ModelAndView<>(VIEW_EDIT, model)) + .map(model -> TurboFrameUtils.turboFrame(request) + .map(frame -> (Object) TurboFrameUtils.turboFrame(frame, VIEW_EDIT_FRAGMENT, model)) + .orElseGet(() -> new ModelAndView<>(VIEW_EDIT, model))) .map(HttpResponse::ok) .orElseGet(NotFoundController::notFoundRedirect); } @PostForm(uri = PATH_UPDATE, rolesAllowed = SecurityRule.IS_AUTHENTICATED) + HttpResponse questionUpdate(@NonNull @NotNull HttpRequest request, @NonNull Authentication authentication, @PathVariable @NotBlank String id, @NonNull @NotNull @Valid @Body QuestionFormRecord form, @Nullable Tenant tenant) { questionService.update(id, form, tenant); + if (TurboMediaType.acceptsTurboStream(request)) { + return showTurboStream(request, id, authentication, tenant) + .map(HttpResponse::ok) + .orElseGet(HttpResponse::notFound); + } return HttpResponse.seeOther(PATH_SHOW_BUILDER.apply(id)); } @@ -165,19 +190,24 @@ HttpResponse questionDelete(HttpRequest request, @PathVariable @NotBlank String id, @Nullable Tenant tenant) { questionService.deleteById(id, tenant); - return HttpResponse.seeOther(URI.create(PATH_LIST)); + return TurboMediaType.acceptsTurboStream(request) + ? HttpResponse.ok(TurboStreamUtils.turboStream(request, VIEW_LIST_FRAGMENT, listModel(tenant))) + : HttpResponse.seeOther(URI.create(PATH_LIST)); } @Error(exception = ConstraintViolationException.class) public HttpResponse onConstraintViolationException(HttpRequest request, @Nullable Tenant tenant, ConstraintViolationException ex) { - String contentType = MediaType.TEXT_HTML; + String turboFrame = request.getHeaders().get(TurboHttpHeaders.TURBO_FRAME, String.class, FRAME_ID_MAIN); + boolean turboRequest = TurboMediaType.acceptsTurboStream(request); + String contentType = turboRequest ? TurboMediaType.TURBO_STREAM : MediaType.TEXT_HTML; + final Matcher matcher = REGEX_UPDATE.matcher(request.getPath()); if (request.getPath().equals(PATH_SAVE)) { return request.getBody(QuestionForm.class) .map(form -> saveModel(QuestionFormRecord.of(form, ex), tenant)) - .map(model -> unprocessableEntity(request, model, contentType, VIEW_CREATE)) + .map(model -> unprocessableEntity(model, contentType, turboRequest, VIEW_CREATE, VIEW_CREATE_FRAGMENT, turboFrame)) .orElseGet(HttpResponse::serverError); } else if (matcher.find()) { final String id = matcher.group(1); @@ -188,19 +218,22 @@ public HttpResponse onConstraintViolationException(HttpRequest request, QuestionForm form = updateFormOptional.get(); return questionService.findById(id, tenant) .map(question -> updateModel(question, QuestionFormRecord.of(form, ex), tenant)) - .map(model -> unprocessableEntity(request, model, contentType, VIEW_EDIT)) + .map(model -> unprocessableEntity(model, contentType, turboRequest, VIEW_EDIT, VIEW_EDIT_FRAGMENT, turboFrame)) .orElseGet(NotFoundController::notFoundRedirect); } return HttpResponse.serverError(); } @NonNull - private HttpResponse unprocessableEntity(@NonNull @NotNull HttpRequest request, - Object model, + private HttpResponse unprocessableEntity(Object model, String contentType, - String view) { - return HttpResponse.unprocessableEntity().body(new ModelAndView<>(view, model)) - .contentType(contentType); + boolean turboRequest, + String view, + String turboView, + @Nullable String turboFrame) { + return HttpResponse.unprocessableEntity().body(turboRequest + ? TurboStream.builder().targetDomId(turboFrame).template(turboView, model).update() + : new ModelAndView<>(view, model)); } @NonNull @@ -248,6 +281,14 @@ public static Optional> showModel(@NonNull AnswerService ans .map(question -> showModel(answerService, question, answerFormSave, authentication, tenant)); } + private Optional showTurboStream(HttpRequest request, + @NonNull String questionid, + @NonNull Authentication authentication, + @Nullable Tenant tenant) { + return showModel(answerService, questionService, answerSaveFormGenerator, questionid, authentication, tenant) + .flatMap(model -> TurboStreamUtils.turboStream(request, VIEW_SHOW_FRAGMENT, model)); + } + @NonNull private Map listModel(@Nullable Tenant tenant) { return Map.of(MODEL_QUESTIONS, questionService.findAll(tenant), diff --git a/http/src/main/java/org/projectcheckins/http/filters/LoggingHeadersFilter.java b/http/src/main/java/org/projectcheckins/http/filters/LoggingHeadersFilter.java new file mode 100644 index 00000000..2773d1ce --- /dev/null +++ b/http/src/main/java/org/projectcheckins/http/filters/LoggingHeadersFilter.java @@ -0,0 +1,27 @@ +package org.projectcheckins.http.filters; + +import io.micronaut.core.order.Ordered; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.annotation.RequestFilter; +import io.micronaut.http.annotation.ServerFilter; +import io.micronaut.http.filter.ServerFilterPhase; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import io.micronaut.http.util.HttpHeadersUtil; +import static io.micronaut.http.annotation.Filter.MATCH_ALL_PATTERN; + +@ServerFilter(MATCH_ALL_PATTERN) +class LoggingHeadersFilter implements Ordered { + + private static final Logger LOG = LoggerFactory.getLogger(LoggingHeadersFilter.class); + + @RequestFilter + void filterRequest(HttpRequest request) { + HttpHeadersUtil.trace(LOG, request.getHeaders()); + } + + @Override + public int getOrder() { + return ServerFilterPhase.FIRST.order(); + } +} \ No newline at end of file diff --git a/http/src/main/resources/views/layout.html b/http/src/main/resources/views/layout.html index ae979021..f7c91f8c 100644 --- a/http/src/main/resources/views/layout.html +++ b/http/src/main/resources/views/layout.html @@ -9,6 +9,9 @@ + diff --git a/http/src/main/resources/views/question/edit.html b/http/src/main/resources/views/question/edit.html index ebfc361b..8dfd482d 100644 --- a/http/src/main/resources/views/question/edit.html +++ b/http/src/main/resources/views/question/edit.html @@ -6,7 +6,7 @@
-
+
\ No newline at end of file diff --git a/http/src/main/resources/views/question/list.html b/http/src/main/resources/views/question/list.html index 497000cb..3ce89477 100644 --- a/http/src/main/resources/views/question/list.html +++ b/http/src/main/resources/views/question/list.html @@ -7,7 +7,6 @@
-
\ No newline at end of file diff --git a/http/src/test/resources/logback.xml b/http/src/test/resources/logback.xml index 5c7860f2..4600b54a 100644 --- a/http/src/test/resources/logback.xml +++ b/http/src/test/resources/logback.xml @@ -7,4 +7,5 @@ + diff --git a/netty/src/main/resources/logback.xml b/netty/src/main/resources/logback.xml index 5c7860f2..5fd23d46 100644 --- a/netty/src/main/resources/logback.xml +++ b/netty/src/main/resources/logback.xml @@ -7,4 +7,5 @@ + diff --git a/processor/build.gradle.kts b/processor/build.gradle.kts index 8255d0e7..b31cedaf 100644 --- a/processor/build.gradle.kts +++ b/processor/build.gradle.kts @@ -7,7 +7,7 @@ dependencies { implementation(project(":annotations")) implementation("io.swagger.core.v3:swagger-annotations") - implementation("io.micronaut.views:micronaut-views-core") + implementation("io.micronaut.views:micronaut-views-core:5.3.0") implementation("io.micronaut.security:micronaut-security-annotations") // JUnit Testing diff --git a/processor/src/main/java/org/projectcheckins/processor/GetHtmlAnnotationMapper.java b/processor/src/main/java/org/projectcheckins/processor/GetHtmlAnnotationMapper.java index 80d2c2e5..c9c6964b 100644 --- a/processor/src/main/java/org/projectcheckins/processor/GetHtmlAnnotationMapper.java +++ b/processor/src/main/java/org/projectcheckins/processor/GetHtmlAnnotationMapper.java @@ -1,7 +1,7 @@ package org.projectcheckins.processor; -import org.projectcheckins.annotations.GetHtml; import io.micronaut.core.annotation.AnnotationValue; +import io.micronaut.core.annotation.AnnotationValueBuilder; import io.micronaut.core.util.StringUtils; import io.micronaut.http.MediaType; import io.micronaut.http.annotation.Get; @@ -12,7 +12,9 @@ import io.micronaut.scheduling.annotation.ExecuteOn; import io.micronaut.security.annotation.Secured; import io.micronaut.views.View; +import io.micronaut.views.turbo.TurboFrameView; import io.swagger.v3.oas.annotations.Hidden; +import org.projectcheckins.annotations.GetHtml; import java.util.ArrayList; import java.util.List; @@ -22,10 +24,11 @@ public class GetHtmlAnnotationMapper implements TypedAnnotationMapper { public static final String MEMBER_VALUE = "value"; public static final String MEMBER_VIEW = "view"; public static final String MEMBER_URI = "uri"; - + private static final String MEMBER_TURBO_VIEW = "turboView"; public static final String MEMBER_ROLESALLOWED = "rolesAllowed"; public static final String MEMBER_EXECUTES_ON = "executesOn"; public static final String MEMBER_HIDDEN = "hidden"; + public static final String MEMBER_ACTION = "action"; @Override public Class annotationType() { @@ -43,6 +46,14 @@ public List> map(AnnotationValue annotation, Visitor .ifPresent(view -> result.add(AnnotationValue.builder(View.class).member(MEMBER_VALUE, view).build())); + annotation.stringValue(MEMBER_TURBO_VIEW) + .filter(StringUtils::isNotEmpty) + .ifPresent(view -> { + AnnotationValueBuilder b = AnnotationValue.builder(TurboFrameView.class) + .member(MEMBER_VALUE, view); + result.add(b.build()); + }); + annotation.stringValue(MEMBER_URI) .filter(StringUtils::isNotEmpty) .ifPresent(uri -> diff --git a/processor/src/main/java/org/projectcheckins/processor/PostFormAnnotationMapper.java b/processor/src/main/java/org/projectcheckins/processor/PostFormAnnotationMapper.java index 631dc602..2198874d 100644 --- a/processor/src/main/java/org/projectcheckins/processor/PostFormAnnotationMapper.java +++ b/processor/src/main/java/org/projectcheckins/processor/PostFormAnnotationMapper.java @@ -1,5 +1,6 @@ package org.projectcheckins.processor; +import io.micronaut.views.turbo.http.TurboMediaType; import org.projectcheckins.annotations.PostForm; import io.micronaut.core.annotation.AnnotationValue; import io.micronaut.http.MediaType; @@ -32,7 +33,9 @@ public Class annotationType() { public List> map(AnnotationValue annotation, VisitorContext visitorContext) { List> result = new ArrayList<>(); - result.add(AnnotationValue.builder(Produces.class).member(MEMBER_VALUE, MediaType.TEXT_HTML).build()); + result.add(AnnotationValue.builder(Produces.class) + .member(MEMBER_VALUE, MediaType.TEXT_HTML, TurboMediaType.TURBO_STREAM) + .build()); result.add(AnnotationValue.builder(Consumes.class).member(MEMBER_VALUE, MediaType.APPLICATION_FORM_URLENCODED).build()); diff --git a/processor/src/test/java/org/projectcheckins/processor/PostFormAnnotationMapperTest.java b/processor/src/test/java/org/projectcheckins/processor/PostFormAnnotationMapperTest.java index 6018701e..ce28ae06 100644 --- a/processor/src/test/java/org/projectcheckins/processor/PostFormAnnotationMapperTest.java +++ b/processor/src/test/java/org/projectcheckins/processor/PostFormAnnotationMapperTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import io.micronaut.views.turbo.http.TurboMediaType; import org.projectcheckins.annotations.PostForm; import io.micronaut.core.annotation.AnnotationValue; import io.micronaut.http.MediaType; @@ -32,7 +33,7 @@ void storeParamsIsMappedToStore() { .isNotNull() .hasSize(6) .containsExactlyInAnyOrder( - AnnotationValue.builder(Produces.class).member("value", MediaType.TEXT_HTML).build(), + AnnotationValue.builder(Produces.class).member("value", MediaType.TEXT_HTML, TurboMediaType.TURBO_STREAM).build(), AnnotationValue.builder(ExecuteOn.class).member("value", TaskExecutors.BLOCKING).build(), AnnotationValue.builder(Post.class).member("uri", "/{id}/update").build(), AnnotationValue.builder(Consumes.class).member("value", MediaType.APPLICATION_FORM_URLENCODED).build(), diff --git a/security-http/src/main/java/org/projectcheckins/security/http/LogoutFormViewModelProcessor.java b/security-http/src/main/java/org/projectcheckins/security/http/LogoutFormViewModelProcessor.java index 28fed1eb..ee0f1193 100644 --- a/security-http/src/main/java/org/projectcheckins/security/http/LogoutFormViewModelProcessor.java +++ b/security-http/src/main/java/org/projectcheckins/security/http/LogoutFormViewModelProcessor.java @@ -7,6 +7,7 @@ import io.micronaut.security.utils.SecurityService; import io.micronaut.views.ModelAndView; import io.micronaut.views.fields.Fieldset; +import io.micronaut.views.fields.FieldsetGenerator; import io.micronaut.views.fields.Form; import io.micronaut.views.fields.FormGenerator; import io.micronaut.views.fields.elements.InputSubmitFormElement; @@ -23,19 +24,13 @@ class LogoutFormViewModelProcessor extends MapViewModelProcessor { private static final String MODEL_KEY = "logoutForm"; - - private final SecurityService securityService; private final Form logoutForm; - LogoutFormViewModelProcessor(FormGenerator formGenerator, - SecurityService securityService, - LogoutControllerConfiguration logoutControllerConfiguration) { - this.securityService = securityService; - this.logoutForm = formGenerator.generate(logoutControllerConfiguration.getPath(), - new Fieldset(Collections.emptyList(), Collections.emptyList()), InputSubmitFormElement - .builder() - .value(Message.of("Logout", "logout.submit")) - .build()); + LogoutFormViewModelProcessor(LogoutControllerConfiguration logoutControllerConfiguration) { + this.logoutForm = new Form(logoutControllerConfiguration.getPath(), "post", new Fieldset(Collections.singletonList(InputSubmitFormElement + .builder() + .value(Message.of("Logout", "logout.submit")) + .build()), Collections.emptyList()), false); } diff --git a/security-http/src/main/java/org/projectcheckins/security/http/SecurityController.java b/security-http/src/main/java/org/projectcheckins/security/http/SecurityController.java index 1488446a..04a8ebcb 100644 --- a/security-http/src/main/java/org/projectcheckins/security/http/SecurityController.java +++ b/security-http/src/main/java/org/projectcheckins/security/http/SecurityController.java @@ -17,6 +17,7 @@ import io.micronaut.security.endpoints.LoginControllerConfiguration; import io.micronaut.security.rules.SecurityRule; import io.micronaut.views.ModelAndView; +import io.micronaut.views.fields.*; import io.micronaut.views.fields.Form; import io.micronaut.views.fields.FormGenerator; import io.micronaut.views.fields.elements.InputSubmitFormElement; @@ -34,6 +35,7 @@ import org.projectcheckins.security.constraints.ValidToken; import java.net.URI; +import java.util.*; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -99,6 +101,7 @@ class SecurityController { private final HttpLocaleResolver httpLocaleResolver; SecurityController(FormGenerator formGenerator, + FieldsetGenerator fieldsetGenerator, LoginControllerConfiguration loginControllerConfiguration, RegisterService registerService, PasswordService passwordService, @@ -109,7 +112,10 @@ class SecurityController { this.passwordService = passwordService; this.httpHostResolver = httpHostResolver; this.httpLocaleResolver = httpLocaleResolver; - loginForm = formGenerator.generate(loginControllerConfiguration.getPath(), LoginForm.class, INPUT_SUBMIT_LOGIN); + Fieldset fieldset = fieldsetGenerator.generate(LoginForm.class); + List fields = new ArrayList<>(fieldset.fields()); + fields.add(INPUT_SUBMIT_LOGIN); + loginForm = new Form(loginControllerConfiguration.getPath(), "post", new Fieldset(fields, fieldset.errors()), Boolean.FALSE); signUpForm = formGenerator.generate(PATH_SIGN_UP, SignUpForm.class); forgotPasswordForm = formGenerator.generate(PATH_PASSWORD_FORGOT, ForgotPasswordForm.class, MESSAGE_FORGOT_SUBMIT); } @@ -120,7 +126,8 @@ Map signUpCreate() { } @PostForm(uri = PATH_SIGN_UP, rolesAllowed = SecurityRule.IS_ANONYMOUS) - HttpResponse signUp(@NonNull @NotNull @Valid @Body SignUpForm form, + HttpResponse signUp(HttpRequest request, + @NonNull @NotNull @Valid @Body SignUpForm form, @Nullable Tenant tenant) { try { registerService.register(form.email(), form.password(), tenant); diff --git a/security-http/src/main/java/org/projectcheckins/security/http/TeamController.java b/security-http/src/main/java/org/projectcheckins/security/http/TeamController.java index a159647d..1a58e4cd 100644 --- a/security-http/src/main/java/org/projectcheckins/security/http/TeamController.java +++ b/security-http/src/main/java/org/projectcheckins/security/http/TeamController.java @@ -7,6 +7,7 @@ import io.micronaut.http.annotation.Body; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Error; +import io.micronaut.http.annotation.Header; import io.micronaut.http.server.util.HttpHostResolver; import io.micronaut.http.server.util.locale.HttpLocaleResolver; import io.micronaut.http.uri.UriBuilder; @@ -16,6 +17,9 @@ import io.micronaut.views.fields.Form; import io.micronaut.views.fields.FormGenerator; import io.micronaut.views.fields.messages.Message; +import io.micronaut.views.turbo.TurboStream; +import io.micronaut.views.turbo.http.TurboHttpHeaders; +import io.micronaut.views.turbo.http.TurboMediaType; import jakarta.validation.ConstraintViolationException; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -36,10 +40,13 @@ @Controller class TeamController { + private static final String FRAME_ID_MAIN = "main"; public static final String SLASH = "/"; public static final String DOT = "."; public static final String DOT_HTML = DOT + "html"; public static final String ACTION_LIST = "list"; + public static final String FRAGMENT_LIST = "_list.html"; + public static final String FRAGMENT_CREATE = "_create.html"; public static final String ACTION_CREATE = "create"; public static final String ACTION_SAVE = "save"; public static final String ACTION_DELETE = "delete"; @@ -58,6 +65,7 @@ class TeamController { // LIST public static final String PATH_LIST = PATH + SLASH + ACTION_LIST; private static final String VIEW_LIST = PATH + SLASH + ACTION_LIST + DOT_HTML; + private static final String VIEW_LIST_FRAGMENT = PATH + SLASH + FRAGMENT_LIST; private static final Message MESSAGE_LIST = Message.of("Team members", TEAM + DOT + ACTION_LIST); public static final Breadcrumb BREADCRUMB_LIST = new Breadcrumb(MESSAGE_LIST, PATH_LIST); private static final List BREADCRUMBS_LIST = List.of(BREADCRUMB_HOME, new Breadcrumb(MESSAGE_LIST)); @@ -65,6 +73,7 @@ class TeamController { // CREATE private static final String PATH_CREATE = PATH + SLASH + ACTION_CREATE; private static final String VIEW_CREATE = PATH + SLASH + ACTION_CREATE + DOT_HTML; + private static final String VIEW_CREATE_FRAGMENT = PATH + SLASH + FRAGMENT_CREATE; private static final Breadcrumb BREADCRUMB_CREATE = new Breadcrumb(Message.of("Add team member", TEAM + DOT + ACTION_CREATE)); // SAVE @@ -88,7 +97,7 @@ class TeamController { this.httpLocaleResolver = httpLocaleResolver; } - @GetHtml(uri = PATH_LIST, rolesAllowed = SecurityRule.IS_AUTHENTICATED, view = VIEW_LIST) + @GetHtml(uri = PATH_LIST, rolesAllowed = SecurityRule.IS_AUTHENTICATED, view = VIEW_LIST, turboView = VIEW_LIST_FRAGMENT) Map memberList(@Nullable Tenant tenant) { return listModel(tenant); } @@ -103,7 +112,7 @@ private Form deleteMemberForm(@NonNull PublicProfile member) { return formGenerator.generate(PATH_DELETE, new TeamMemberDelete(member.email()), MESSAGE_DELETE); } - @GetHtml(uri = PATH_CREATE, rolesAllowed = SecurityRule.IS_AUTHENTICATED, view = VIEW_CREATE) + @GetHtml(uri = PATH_CREATE, rolesAllowed = SecurityRule.IS_AUTHENTICATED, view = VIEW_CREATE, turboView = VIEW_CREATE_FRAGMENT) Map memberCreate() { return createModel(); } @@ -111,8 +120,16 @@ Map memberCreate() { @PostForm(uri = PATH_SAVE, rolesAllowed = SecurityRule.IS_AUTHENTICATED) HttpResponse memberSave(@NonNull @NotNull HttpRequest request, @NonNull @NotNull @Valid @Body TeamMemberSave form, + @Nullable @Header(value = TurboHttpHeaders.TURBO_FRAME) String turboFrame, @Nullable Tenant tenant) { teamService.save(form, tenant, getLocale(request), getSignUpUri(request).toString()); + if (TurboMediaType.acceptsTurboStream(request)) { + return HttpResponse.ok() + .body(TurboStream.builder() + .targetDomId(turboFrame) + .template(VIEW_LIST_FRAGMENT, listModel(tenant)) + .update()); + } return HttpResponse.seeOther(URI.create(PATH_LIST)); } diff --git a/security-http/src/main/java/org/projectcheckins/security/http/TurboFrameRendererReplacement.java b/security-http/src/main/java/org/projectcheckins/security/http/TurboFrameRendererReplacement.java new file mode 100644 index 00000000..d1c6971b --- /dev/null +++ b/security-http/src/main/java/org/projectcheckins/security/http/TurboFrameRendererReplacement.java @@ -0,0 +1,51 @@ +package org.projectcheckins.security.http; + +import io.micronaut.context.annotation.Replaces; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.io.Writable; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.MediaType; +import io.micronaut.views.ModelAndView; +import io.micronaut.views.ViewsModelDecorator; +import io.micronaut.views.ViewsRendererLocator; +import io.micronaut.views.turbo.TurboFrame; +import io.micronaut.views.turbo.TurboFrameRenderer; +import jakarta.inject.Singleton; + +import java.util.Optional; + +@Replaces(TurboFrameRenderer.class) +@Singleton +class TurboFrameRendererReplacement implements TurboFrameRenderer { + private final ViewsModelDecorator viewsModelDecorator; + private final ViewsRendererLocator viewsRendererLocator; + private final String mediaType; + + TurboFrameRendererReplacement(ViewsModelDecorator viewsModelDecorator, + ViewsRendererLocator viewsRendererLocator) { + this.viewsModelDecorator = viewsModelDecorator; + this.viewsRendererLocator = viewsRendererLocator; + this.mediaType = MediaType.TEXT_HTML; + } + + @SuppressWarnings("unchecked") + @Override + public @NonNull Optional render(TurboFrame.Builder builder, @Nullable HttpRequest request) { + return builder.getTemplateView() + .map(viewName -> { + Object model = builder.getTemplateModel().orElse(null); + ModelAndView modelAndView = new ModelAndView<>(viewName, model); + if (request != null && viewsModelDecorator != null) { + viewsModelDecorator.decorate(request, modelAndView); + } + Object decoratedModel = modelAndView.getModel().orElse(null); + return viewsRendererLocator.resolveViewsRenderer(viewName, mediaType, decoratedModel) + .flatMap(renderer -> builder.template(renderer.render(viewName, decoratedModel, request)) + .build() + .render()); + }) + .orElseGet(() -> builder.build().render()); + } + +} diff --git a/security-http/src/main/java/org/projectcheckins/security/http/TurboFrameUtils.java b/security-http/src/main/java/org/projectcheckins/security/http/TurboFrameUtils.java new file mode 100644 index 00000000..10fc6956 --- /dev/null +++ b/security-http/src/main/java/org/projectcheckins/security/http/TurboFrameUtils.java @@ -0,0 +1,32 @@ +package org.projectcheckins.security.http; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.HttpRequest; +import io.micronaut.views.TemplatedBuilder; +import io.micronaut.views.turbo.TurboFrame; +import io.micronaut.views.turbo.http.TurboHttpHeaders; + +import java.util.Map; +import java.util.Optional; + +public final class TurboFrameUtils { + private TurboFrameUtils() { + + } + + public static Optional turboFrame(@NonNull HttpRequest request) { + return request.getHeaders().get(TurboHttpHeaders.TURBO_FRAME, String.class); + } + + public static Optional turboFrame(@NonNull HttpRequest request, + @NonNull String viewName, + @NonNull Map model) { + return turboFrame(request).map(id -> turboFrame(id, viewName, model)); + } + + public static TemplatedBuilder turboFrame(@NonNull String id, + @NonNull String viewName, + @NonNull Map model) { + return TurboFrame.builder().id(id).template(viewName, model); + } +} diff --git a/security-http/src/main/java/org/projectcheckins/security/http/TurboStreamUtils.java b/security-http/src/main/java/org/projectcheckins/security/http/TurboStreamUtils.java new file mode 100644 index 00000000..750b7844 --- /dev/null +++ b/security-http/src/main/java/org/projectcheckins/security/http/TurboStreamUtils.java @@ -0,0 +1,33 @@ +package org.projectcheckins.security.http; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.HttpRequest; +import io.micronaut.views.turbo.TurboStream; +import io.micronaut.views.turbo.http.TurboHttpHeaders; +import io.micronaut.views.turbo.http.TurboMediaType; + +import java.util.Map; +import java.util.Optional; + +public final class TurboStreamUtils { + private TurboStreamUtils() { + + } + + @NonNull + public static Optional turboStream(@NonNull HttpRequest request, + @NonNull String viewName, + @NonNull Map model) { + if (!TurboMediaType.acceptsTurboStream(request)) { + return Optional.empty(); + } + String turboFrame = request.getHeaders().get(TurboHttpHeaders.TURBO_FRAME); + if (turboFrame == null) { + return Optional.empty(); + } + return Optional.of(TurboStream.builder() + .targetDomId(turboFrame) + .template(viewName, model) + .update()); + } +} diff --git a/security-http/src/test/java/org/projectcheckins/security/http/SecurityControllerTest.java b/security-http/src/test/java/org/projectcheckins/security/http/SecurityControllerTest.java index 149895a0..c2b0b4fa 100644 --- a/security-http/src/test/java/org/projectcheckins/security/http/SecurityControllerTest.java +++ b/security-http/src/test/java/org/projectcheckins/security/http/SecurityControllerTest.java @@ -95,6 +95,7 @@ void login(@Client("/") HttpClient httpClient) { .containsOnlyOnce(ACTION_SECURITY_LOGIN) .containsOnlyOnce(TYPE_EMAIL) .containsOnlyOnce("Log in") + .containsOnlyOnce("data-turbo=\"false\"") .containsOnlyOnce(TYPE_PASSWORD); assertThat(client.retrieve(HttpRequest.POST("/login", Map.of("username", "sherlock@example.com", "password", "password")))) @@ -148,11 +149,11 @@ void changePassword(@Client("/") HttpClient httpClient) { assertThat(client.retrieve(BrowserRequest.GET("/security/changePassword"))) .satisfies(containsManyTimes(3, TYPE_PASSWORD)) .containsOnlyOnce(""" -