Skip to content

Commit

Permalink
feat/qg-146: реализовано отображение модального окна с инфой об упраж…
Browse files Browse the repository at this point in the history
…нении на странице создания программы (#223)

Co-authored-by: Dmitriy <golenko_dmitriy@mail.ru>
  • Loading branch information
d-r-q and DaemonFoen authored Aug 10, 2024
1 parent 93f540c commit b240a14
Show file tree
Hide file tree
Showing 10 changed files with 248 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import pro.qyoga.app.platform.notFound
import pro.qyoga.core.therapy.exercises.ExercisesService
import pro.qyoga.core.therapy.exercises.dtos.ExerciseSummaryDto


@Controller
@RequestMapping("/therapist/exercises/{exerciseId}")
class EditExercisePageController(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package pro.qyoga.app.therapist.therapy.exercises.compenents

import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.servlet.ModelAndView
import pro.azhidkov.platform.spring.mvc.modelAndView
import pro.qyoga.app.platform.notFound
import pro.qyoga.app.therapist.therapy.exercises.compenents.ExerciseModalController.Companion.PATH
import pro.qyoga.core.therapy.exercises.ExercisesService


@Controller
@RequestMapping(PATH)
class ExerciseModalController(
private val exercisesService: ExercisesService
) {

@GetMapping
fun getEditExerciseModal(@PathVariable exerciseId: Long): ModelAndView {
val exercise = exercisesService.findById(exerciseId)
?: return notFound

return modelAndView("therapist/therapy/exercises/exercise-modal") {
"exercise" bindTo exercise
}
}

companion object {
const val PATH = "/therapist/exercises/{exerciseId}/modal"
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,9 @@ <h1 class="mt-4 pb-2 mb-3 border-bottom" th:text="${exercise?.title ?: 'Ново
reader.onload = e => callback(e.target.result)
}


function exerciseStep(step, idx) {
let exerciseId = /*[[${exercise?.id}]]*/ 0;
let imageUrl = step?.imageId != null ? `/therapist/exercises/${exerciseId}/step-images/${idx}` : noImage;
console.log(imageUrl)
return {
description: step?.description,
imageUrl: step ? imageUrl : noImage,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<div class="modal-content" id="exercise-modal">
<div class="modal-header">
<h3 class="modal-title me-2" id="exercise-title" th:text="${exercise?.title}"></h3>
<button aria-label="Закрыть" class="btn-close" data-bs-dismiss="modal" type="button"></button>
</div>
<div class="modal-body" id="modal-body">
<section class="container px-2">

<div class="row">

<div class="col-6 mb-3 col-sm-5" id="exercise-type" th:text="${exercise.exerciseType.label}">
</div>

<div class="col-6 mb-3 col-sm-2"
th:text="${T(pro.azhidkov.platform.java.time.DurationExtKt).toDecimalMinutes(exercise?.duration) + ' мин.'}"
>
</div>
</div>

<div th:if="${exercise.description != null && exercise.description.length> 0}">
<div class="row mb-5">
<div class="col" id="exercise-description" th:text="${exercise.description ?: ''}">
</div>
</div>

<h4>Шаги</h4>
</div>

<div class="row step-row" th:each="step, iterStat : ${exercise.steps}">
<div class="row align-items-center px-3 mb-3">
<div class="col-sm-2 mb-3 text-center mx-auto" th:if="${step.imageId != null}">
<img class="mw-100 d-block"
th:src="${'/therapist/exercises/' + exercise.id + '/step-images/' +iterStat.index}">
</div>
<div class="col-sm-10 step-description">
<strong class="step-description" th:text="'Шаг ' + ${iterStat.index + 1} + ': '"></strong>[[${step.description}]]
</div>
</div>
</div>

</section>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ <h1 class="mt-4 pb-2 mb-3 border-bottom" th:text="${program?.title ?: 'Нова
document.addEventListener('alpine:init', () => {
Alpine.data('programForm', () => ({
exercises: initialExercises,

remove(idx) {
this.exercises.splice(idx, 1);
},
Expand Down Expand Up @@ -114,13 +113,18 @@ <h1 class="mt-4 pb-2 mb-3 border-bottom" th:text="${program?.title ?: 'Нова

}

function exerciseTagId(ex) {
return `exerciseTag${ex.id}TagId`
}

</script>

<form class="mt-4"
id="programForm"
th:attr="hx-post=${pageMode == 'CREATE' ? '/therapist/programs/create' : null},hx-put=${pageMode == 'EDIT' ? '/therapist/programs/' + program.id : null}"
hx-on:htmx:validation:validate="validate(event)"
x-data="programForm"
x-init="$watch('exercises', new_value => {htmx.process(htmx.find('#' + exerciseTagId(new_value.slice(-1)[0])))})"
th:fragment="programForm"
>

Expand Down Expand Up @@ -230,18 +234,22 @@ <h1 class="mt-4 pb-2 mb-3 border-bottom" th:text="${program?.title ?: 'Нова

<ul class="list-group list-group-flush g-0">
<template :key="exercise.id" x-for="(exercise, idx) in exercises">
<li class="list-group-item d-flex justify-content-between align-items-center">
<li
class="list-group-item d-flex justify-content-between align-items-center"
:id="exerciseTagId(exercise)">
<input :value="exercise.id"
name="exerciseIds"
type="hidden">
<div class="col-8">
<div class="text-truncate" x-text="exercise.title">
Разминка для шеи
</div>
<div class="link link-primary"
role="button"
:hx-get="`/therapist/exercises/${exercise.id}/modal`"
data-bs-target="#exercise-modal"
data-bs-toggle="modal"
hx-target="#modal-content"
x-text="exercise.title"></div>
<small class="text-muted"
x-text="`${exercise.type.label}, ${exercise.duration} мин.`">
Разминка, 10 мин
</small>
x-text="`${exercise.type.label}, ${exercise.duration} мин.`"></small>
</div>
<div class="btn-group h-fit-content ">
<button @click="moveUp(idx)" class="btn btn-sm btn-outline-secondary"
Expand All @@ -261,6 +269,12 @@ <h1 class="mt-4 pb-2 mb-3 border-bottom" th:text="${program?.title ?: 'Нова
</template>
</ul>

<div class="modal modal-blur fade" id="exercise-modal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content" id="modal-content"></div>
</div>
</div>

<div class="row g-2 justify-content-end">
<div class="col-6 col-sm-auto text-center">
<a class="btn btn-outline-danger" href="/therapist/programs"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package pro.qyoga.tests.cases.app.therapist.therapy.exercises.components

import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.http.HttpStatus
import pro.qyoga.tests.assertions.shouldBe
import pro.qyoga.tests.assertions.shouldBePage
import pro.qyoga.tests.clients.TherapistClient
import pro.qyoga.tests.fixture.object_mothers.therapy.exercises.ExercisesObjectMother.createExerciseRequests
import pro.qyoga.tests.infra.web.QYogaAppIntegrationBaseTest
import pro.qyoga.tests.pages.publc.NotFoundErrorPage
import pro.qyoga.tests.pages.therapist.therapy.exercises.ExerciseModal


@DisplayName("Диалоговое окно просмотра упражнения")
class ExerciseModalControllerTest : QYogaAppIntegrationBaseTest() {

@Test
fun `должно отображаться корректно`() {
// Дано
val exercise = backgrounds.exercises.createExercises(createExerciseRequests(1)).single()
val therapist = TherapistClient.loginAsTheTherapist()

// Когда
val document = therapist.exercises.getExerciseModal(exercise.id)

// Тогда
document shouldBe ExerciseModal.forExercise(exercise)
}

@Test
fun `должно возвращать страницу ошибки 404 при запросе несуществующего упражнения`() {
// Дано
val therapist = TherapistClient.loginAsTheTherapist()

// Когда
val document = therapist.exercises.getExerciseModal(404, expectedStatus = HttpStatus.NOT_FOUND)

// Тогда
document shouldBePage NotFoundErrorPage
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,17 @@ class TherapistExercisesApi(override val authCookie: Cookie) : AuthorizedApi {
}
}

fun getExerciseModal(exerciseId: Long, expectedStatus: HttpStatus = HttpStatus.OK): Document {
return Given {
authorized()
pathParam("exerciseId", exerciseId)
} When {
get(ExerciseModal.PATH)
} Then {
statusCode(expectedStatus.value())
} Extract {
Jsoup.parse(body().asString())
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package pro.qyoga.tests.pages.therapist.therapy.exercises

import io.kotest.matchers.collections.shouldBeSameSizeAs
import pro.qyoga.app.therapist.therapy.exercises.compenents.ExerciseModalController
import pro.qyoga.core.therapy.exercises.model.Exercise
import pro.qyoga.tests.assertions.PageMatcher
import pro.qyoga.tests.assertions.shouldHaveComponent
import pro.qyoga.tests.platform.html.div
import pro.qyoga.tests.platform.html.h3


object ExerciseModal {

const val PATH = ExerciseModalController.PATH

fun selector(): String = "#exercise-modal"

fun forExercise(exercise: Exercise): PageMatcher = PageMatcher { element ->
element shouldHaveComponent h3("exercise-title", exercise.title)
element shouldHaveComponent div("exercise-type", text = exercise.exerciseType.label)
element shouldHaveComponent div("exercise-description", text = exercise.description)

val stepElements = element.getElementsByClass("step-row")
stepElements shouldBeSameSizeAs exercise.steps
stepElements.zip(exercise.steps).forEachIndexed { idx, (stepElement, step) ->
stepElement shouldHaveComponent div(
clazz = "step-description",
text = "Шаг ${idx + 1}: ${step.description}"
)
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package pro.qyoga.tests.platform.html


fun selector(
id: String?,
clazz: String?,
tag: String?
): String {
val selector = buildString {
if (tag != null) {
append(tag)
}
if (id != null) {
append("#$id")
}
if (clazz != null) {
append(".$clazz")
}
}
check(selector.isNotBlank()) { "Selector must not be blank" }
return selector
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package pro.qyoga.tests.platform.html

import io.kotest.matchers.Matcher
import org.jsoup.nodes.Element
import pro.qyoga.tests.assertions.haveText
import pro.qyoga.tests.assertions.isTag
import pro.qyoga.tests.platform.kotest.buildAllOfMatcher

fun h3(id: String, text: String) = TextElement(
"h3",
"#$id",
haveText(text)
)

fun div(id: String? = null, clazz: String? = null, text: String) = TextElement(
"div",
selector(id, clazz, "div"),
haveText(text)
)

data class TextElement(
val tag: String,
val selector: String,
val textMatcher: Matcher<Element>?
) : Component {

override fun selector() = selector

override fun matcher(): Matcher<Element> = buildAllOfMatcher {
add(isTag(tag))
if (textMatcher != null) {
add(textMatcher)
}
}

}

0 comments on commit b240a14

Please sign in to comment.