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( libraryDependencies ++= Seq(
"com.lihaoyi" %% "cask" % "0.9.1", "com.lihaoyi" %% "cask" % "0.9.1",
"com.lihaoyi" %% "mainargs" % "0.5.0", "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; grid-row: span 2 / span 2;
} }
.row-span-full {
grid-row: 1 / -1;
}
.row-start-1 { .row-start-1 {
grid-row-start: 1; grid-row-start: 1;
} }
@ -655,6 +651,10 @@ video {
display: inline-grid; display: inline-grid;
} }
.contents {
display: contents;
}
.hidden { .hidden {
display: none; display: none;
} }
@ -777,14 +777,6 @@ video {
grid-template-columns: repeat(4, auto); 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-row {
flex-direction: row; flex-direction: row;
} }
@ -879,6 +871,11 @@ video {
border-color: rgb(255 255 255 / var(--tw-border-opacity)); 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 { .\!bg-light-blue {
--tw-bg-opacity: 1 !important; --tw-bg-opacity: 1 !important;
background-color: hsl(206 94% 87% / var(--tw-bg-opacity)) !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)); 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 { .underline {
text-decoration-line: underline; text-decoration-line: underline;
} }

View File

@ -33,13 +33,14 @@
</head> </head>
<body> <body>
<main class="grid place-content-center w-screen h-screen bg-magnolia"> <main class="grid place-content-center w-screen h-screen bg-magnolia">
<section <section hx-get="/get-form" hx-trigger="load" hx-swap="outerHTML">
hx-get="/get-form"
hx-trigger="load"
hx-swap="outerHTML"
>
<!-- here be immediate hx-get for the form. to subscitute the body --> <!-- 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>
<section class="absolute top-0 end-0" id="new-session-control"> <section class="absolute top-0 end-0" id="new-session-control">

View File

@ -126,7 +126,10 @@
<p class="py-3 text-cool-gray"> <p class="py-3 text-cool-gray">
Please provide your name, email address, and phone number. Please provide your name, email address, and phone number.
</p> </p>
<label for="name" class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue" <div class="contents">
<label
for="name"
class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue"
>Name</label >Name</label
> >
<input <input
@ -138,7 +141,11 @@
placeholder="e.g. Stephen King" 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" 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" </div>
<div class="contents">
<label
for="email"
class="pt-3 text-sm md:pt-5 md:pb-2 text-marine-blue"
>Email Address</label >Email Address</label
> >
<input <input
@ -150,18 +157,50 @@
placeholder="e.g. stephenking@lorem.com" 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" 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" </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"
>
<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 >Phone Number</label
> >
<input <input
id="phone" id="phone"
th:value="${formData.userAnswers.step1.phone}" th:value="${value}"
name="phone" name="phone"
type="tel" type="tel"
required required
placeholder="e.g. +1 234 567 890" 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" 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 --> <!-- Step 1 end -->
</section> </section>

View File

@ -8,7 +8,11 @@ import java.util.UUID
import scala.jdk.CollectionConverters._ import scala.jdk.CollectionConverters._
import multistepform.Models.Answers import multistepform.Models.Answers
import scala.annotation.internal.requiresCapability import scala.annotation.internal.requiresCapability
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber.CountryCodeSource
import com.google.i18n.phonenumbers.PhoneNumberUtil
import java.net.URLDecoder import java.net.URLDecoder
import scala.util.Try
case class Routes()(implicit cc: castor.Context, log: cask.Logger) case class Routes()(implicit cc: castor.Context, log: cask.Logger)
extends cask.Routes { extends cask.Routes {
@ -105,7 +109,8 @@ case class Routes()(implicit cc: castor.Context, log: cask.Logger)
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) Sessions.sessionReplies.update(id, updatedAnswers)
@ -124,9 +129,54 @@ case class Routes()(implicit cc: castor.Context, log: cask.Logger)
) )
} }
/** @cask.post("/step1/phonenumber")
* This endpoint re-renders 'plan type inputs' def validateStep1PhoneNumber(request: cask.Request) = {
* so that togglng monthly\yearly could redraw their html 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") @cask.post("/step2/planTypeInputs")
def getPlanTypeInputs(sessionId: cask.Cookie, request: cask.Request) = { def getPlanTypeInputs(sessionId: cask.Cookie, request: cask.Request) = {
@ -135,14 +185,19 @@ case class Routes()(implicit cc: castor.Context, log: cask.Logger)
println(s"requesting plan type inputs for $id and $request") println(s"requesting plan type inputs for $id and $request")
Sessions.sessionReplies.get(id) match { Sessions.sessionReplies.get(id) match {
case None => 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) => { case Some(answers) => {
// here changing yearly/monthly part of state based on passed checkbox value // here changing yearly/monthly part of state based on passed checkbox value
// and selected plan // and selected plan
// only for purposes of rendering the fragment // only for purposes of rendering the fragment
// not persisting, unless next or previous buttons are pressed // not persisting, unless next or previous buttons are pressed
val withYearlyParamSetAndSelectedPlan = answers.step2.fromFormData(submittedData) val withYearlyParamSetAndSelectedPlan =
val updatedState = answers.copy(step2 = withYearlyParamSetAndSelectedPlan) answers.step2.fromFormData(submittedData)
val updatedState =
answers.copy(step2 = withYearlyParamSetAndSelectedPlan)
val formData = Models.FormData(updatedState) val formData = Models.FormData(updatedState)
val context = new Context() val context = new Context()
context.setVariable(formDataContextVarName, formData) context.setVariable(formDataContextVarName, formData)