feat(15): phone validation htmx inline

making phone input as a fragment that accepts value and error
initial value from overall state, initial error empty string

some contitional classes and hidden error message
wowy,
endpoint that takes POST request, extracts value and resends the
rendered fragment. cool
This commit is contained in:
efim 2023-07-16 15:07:46 +00:00
parent 8c0318c4e2
commit 77bcf87cd1
5 changed files with 162 additions and 64 deletions

View File

@ -11,6 +11,7 @@ lazy val multiStepForm = (project in file("."))
libraryDependencies ++= Seq(
"com.lihaoyi" %% "cask" % "0.9.1",
"com.lihaoyi" %% "mainargs" % "0.5.0",
"org.thymeleaf" % "thymeleaf" % "3.1.1.RELEASE"
"org.thymeleaf" % "thymeleaf" % "3.1.1.RELEASE",
"com.googlecode.libphonenumber" % "libphonenumber" % "8.13.16"
)
)

View File

@ -572,10 +572,6 @@ video {
grid-row: span 2 / span 2;
}
.row-span-full {
grid-row: 1 / -1;
}
.row-start-1 {
grid-row-start: 1;
}
@ -655,6 +651,10 @@ video {
display: inline-grid;
}
.contents {
display: contents;
}
.hidden {
display: none;
}
@ -777,14 +777,6 @@ video {
grid-template-columns: repeat(4, auto);
}
.grid-rows-5 {
grid-template-rows: repeat(5, minmax(0, 1fr));
}
.grid-rows-3 {
grid-template-rows: repeat(3, minmax(0, 1fr));
}
.flex-row {
flex-direction: row;
}
@ -879,6 +871,11 @@ video {
border-color: rgb(255 255 255 / var(--tw-border-opacity));
}
.border-strawberry-red {
--tw-border-opacity: 1;
border-color: hsl(354 84% 57% / var(--tw-border-opacity));
}
.\!bg-light-blue {
--tw-bg-opacity: 1 !important;
background-color: hsl(206 94% 87% / var(--tw-bg-opacity)) !important;
@ -1063,6 +1060,11 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.text-strawberry-red {
--tw-text-opacity: 1;
color: hsl(354 84% 57% / var(--tw-text-opacity));
}
.underline {
text-decoration-line: underline;
}

View File

@ -33,13 +33,14 @@
</head>
<body>
<main class="grid place-content-center w-screen h-screen bg-magnolia">
<section
hx-get="/get-form"
hx-trigger="load"
hx-swap="outerHTML"
>
<section hx-get="/get-form" hx-trigger="load" hx-swap="outerHTML">
<!-- here be immediate hx-get for the form. to subscitute the body -->
<img class="w-14 text-green-500 fill-current" th:src="'public/images/tail-spin.svg'" src="../public/images/tail-spin.svg" alt="loading..." />
<img
class="w-14 text-green-500 fill-current"
th:src="'public/images/tail-spin.svg'"
src="../public/images/tail-spin.svg"
alt="loading..."
/>
</section>
<section class="absolute top-0 end-0" id="new-session-control">

View File

@ -126,42 +126,81 @@
<p class="py-3 text-cool-gray">
Please provide your name, email address, and phone number.
</p>
<label for="name" class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue"
>Name</label
<div class="contents">
<label
for="name"
class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue"
>Name</label
>
<input
id="name"
th:value="${formData.userAnswers.step1.name}"
name="name"
type="text"
required
placeholder="e.g. Stephen King"
class="p-1 px-4 h-10 text-sm font-semibold rounded border md:p-6 md:px-4 md:text-base md:rounded-lg focus:outline-none placeholder:text-cool-gray invalid:border-strawberry-red focus:border-marine-blue"
/>
</div>
<div class="contents">
<label
for="email"
class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue"
>Email Address</label
>
<input
id="email"
th:value="${formData.userAnswers.step1.email}"
name="email"
type="email"
required
placeholder="e.g. stephenking@lorem.com"
class="p-1 px-4 h-10 text-sm font-semibold rounded border md:p-6 md:px-4 md:text-base md:rounded-lg focus:outline-none placeholder:text-cool-gray invalid:border-strawberry-red focus:border-marine-blue"
/>
</div>
<!-- Following is email input field
it has outer div that sets separate 'value' variable
and innder div which denotes fragment, parametrized by this only variable
now i should be able to render only this input field as fragment -->
<div
th:with="value=${formData.userAnswers.step1.phone},error=''"
class="contents relative"
>
<input
id="name"
th:value="${formData.userAnswers.step1.name}"
name="name"
type="text"
required
placeholder="e.g. Stephen King"
class="p-1 px-4 h-10 text-sm font-semibold rounded border md:p-6 md:px-4 md:text-base md:rounded-lg focus:outline-none placeholder:text-cool-gray invalid:border-strawberry-red focus:border-marine-blue"
/>
<label for="email" class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue"
>Email Address</label
>
<input
id="email"
th:value="${formData.userAnswers.step1.email}"
name="email"
type="email"
required
placeholder="e.g. stephenking@lorem.com"
class="p-1 px-4 h-10 text-sm font-semibold rounded border md:p-6 md:px-4 md:text-base md:rounded-lg focus:outline-none placeholder:text-cool-gray invalid:border-strawberry-red focus:border-marine-blue"
/>
<label for="phone" class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue"
>Phone Number</label
>
<input
id="phone"
th:value="${formData.userAnswers.step1.phone}"
name="phone"
type="tel"
required
placeholder="e.g. +1 234 567 890"
class="p-1 px-4 h-10 text-sm font-semibold rounded border md:p-6 md:px-4 md:text-base md:rounded-lg focus:outline-none placeholder:text-cool-gray invalid:border-strawberry-red focus:border-marine-blue"
/>
<div
class="contents"
hx-target="this"
hx-swap="outerHTML"
th:fragment="email-input (value,error)"
>
<label
for="phone"
class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue"
>Phone Number</label
>
<input
id="phone"
th:value="${value}"
name="phone"
type="tel"
required
placeholder="e.g. +1 234 567 890"
class="p-1 px-4 h-10 text-sm font-semibold rounded border md:p-6 md:px-4 md:text-base md:rounded-lg focus:outline-none placeholder:text-cool-gray invalid:border-strawberry-red focus:border-marine-blue"
th:classappend="${!error.isEmpty} ? 'border-strawberry-red' : ''"
hx-post="/step1/phonenumber"
hx-indicator="#ind"
/>
<div
th:if="${error} != null"
th:text="${error}"
class="text-sm text-strawberry-red">Please enter valid phone number</div>
<img
id="ind"
src="../public/images/tail-spin.svg"
th:src="'public/images/tail-spin.svg'"
class="absolute w-14 h-14 htmx-indicator"
/>
</div>
</div>
<!-- Step 1 end -->
</section>

View File

@ -8,7 +8,11 @@ import java.util.UUID
import scala.jdk.CollectionConverters._
import multistepform.Models.Answers
import scala.annotation.internal.requiresCapability
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber.CountryCodeSource
import com.google.i18n.phonenumbers.PhoneNumberUtil
import java.net.URLDecoder
import scala.util.Try
case class Routes()(implicit cc: castor.Context, log: cask.Logger)
extends cask.Routes {
@ -103,9 +107,10 @@ case class Routes()(implicit cc: castor.Context, log: cask.Logger)
// and that is not nice
val userAnswers = Sessions.sessionReplies.getOrElse(id, Answers(id))
val submittedData = URLDecoder.decode(request.text() , "UTF-8")
val submittedData = URLDecoder.decode(request.text(), "UTF-8")
val updatedAnswers = userAnswers.updateStep(stepNum, submittedData, nextStep)
val updatedAnswers =
userAnswers.updateStep(stepNum, submittedData, nextStep)
Sessions.sessionReplies.update(id, updatedAnswers)
@ -124,25 +129,75 @@ case class Routes()(implicit cc: castor.Context, log: cask.Logger)
)
}
/**
* This endpoint re-renders 'plan type inputs'
* so that togglng monthly\yearly could redraw their html
*/
@cask.post("/step1/phonenumber")
def validateStep1PhoneNumber(request: cask.Request) = {
val submittedData = URLDecoder.decode(request.text(), "UTF-8")
println(
s"getting data ${request.data} or ${request.text()} or $submittedData"
)
val fieldValues = submittedData
.split("&")
.filterNot(_.isEmpty())
.map { field =>
println(s"looking at field $field")
val (name, value) = field.split("=").toList match {
case List(name, value) => name -> value
case name :: tail => name -> tail.headOption.getOrElse("")
case Nil => ???
}
name -> value
}
.toMap
val name = fieldValues.getOrElse("name", "")
val email = fieldValues.getOrElse("email", "")
val phone = fieldValues.getOrElse("phone", "")
println(s"after parsing name=$name | email=$email | phone=$phone")
val phoneUtils = PhoneNumberUtil.getInstance()
val phoneNum = Try(
phoneUtils.parse(phone, CountryCodeSource.UNSPECIFIED.name())
).toOption
val isPhoneValid = phoneNum.map(phoneUtils.isValidNumber(_)).getOrElse(false)
val error = if (isPhoneValid) "" else "Please input valid phone number"
val context = new Context()
context.setVariable("value", phone)
context.setVariable("error", error)
val inputDiv =
templateEngine.process("step1", Set("email-input").asJava, context)
cask.Response(
inputDiv,
headers = Seq("Content-Type" -> "text/html;charset=UTF-8")
)
}
/** This endpoint re-renders 'plan type inputs' so that togglng monthly\yearly
* could redraw their html
*/
@cask.post("/step2/planTypeInputs")
def getPlanTypeInputs(sessionId: cask.Cookie, request: cask.Request) = {
val id = sessionId.value
val submittedData = URLDecoder.decode(request.text() , "UTF-8")
val submittedData = URLDecoder.decode(request.text(), "UTF-8")
println(s"requesting plan type inputs for $id and $request")
Sessions.sessionReplies.get(id) match {
case None =>
cask.Response("Can't find answers for your session, please reload the page", 404)
cask.Response(
"Can't find answers for your session, please reload the page",
404
)
case Some(answers) => {
// here changing yearly/monthly part of state based on passed checkbox value
// and selected plan
// only for purposes of rendering the fragment
// not persisting, unless next or previous buttons are pressed
val withYearlyParamSetAndSelectedPlan = answers.step2.fromFormData(submittedData)
val updatedState = answers.copy(step2 = withYearlyParamSetAndSelectedPlan)
val withYearlyParamSetAndSelectedPlan =
answers.step2.fromFormData(submittedData)
val updatedState =
answers.copy(step2 = withYearlyParamSetAndSelectedPlan)
val formData = Models.FormData(updatedState)
val context = new Context()
context.setVariable(formDataContextVarName, formData)