Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce Oxpecker.ModelValidation #33

Merged
merged 9 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions examples/CRUD/Backend/Handlers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ type OperationEnv(env: Env) =
interface IGetProducts with
member this.GetProducts() = ProductRepository.getProducts env



let getOrders env (ctx: HttpContext) =
task {
let operationEnv = OperationEnv(env)
Expand Down
1 change: 1 addition & 0 deletions examples/ContactApp/ContactApp.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<Compile Include="Models.fs" />
<Compile Include="Tools.fs" />
<Compile Include="templates\shared\layout.fs" />
<Compile Include="templates\shared\errors.fs" />
<Compile Include="templates\shared\contactFields.fs" />
<Compile Include="templates\index.fs" />
<Compile Include="templates\show.fs" />
Expand Down
47 changes: 26 additions & 21 deletions examples/ContactApp/Handlers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ open ContactApp.Tools
open Microsoft.AspNetCore.Http
open Oxpecker
open Oxpecker.Htmx
open Oxpecker.ModelValidation

let mutable archiver = Archiver(ResizeArray())

Expand Down Expand Up @@ -34,52 +35,56 @@ let getContactsCount: EndpointHandler =
ctx.WriteText $"({count} total Contacts)"

let getNewContact: EndpointHandler =
let newContact = {
id = 0
first = ""
last = ""
email = ""
phone = ""
errors = dict []
}
writeHtml (new'.html newContact)
writeHtml (new'.html ModelState.Empty)

let insertContact: EndpointHandler =
fun ctx ->
task {
let! contact = ctx.BindForm<ContactDTO>()
let validatedContact = contact.Validate()
if validatedContact.errors.Count > 0 then
return! ctx |> writeHtml (new'.html validatedContact)
else
match! ctx.BindAndValidateForm<ContactDTO>() with
| ValidationResult.Valid validatedContact ->
validatedContact.ToDomain()
|> ContactService.add
|> ignore
flash "Created new Contact!" ctx
return ctx.Response.Redirect("/contacts")
| ValidationResult.Invalid invalidModel ->
return!
invalidModel
|> ModelState.Invalid
|> new'.html
|> writeHtml
<| ctx
}

let updateContact id: EndpointHandler =
fun ctx ->
task {
let! contact = ctx.BindForm<ContactDTO>()
let validatedContact = contact.Validate()
if validatedContact.errors.Count > 0 then
return! ctx |> writeHtml (edit.html { validatedContact with id = id })
else
match! ctx.BindAndValidateForm<ContactDTO>() with
| ValidationResult.Valid validatedContact ->
let domainContact = validatedContact.ToDomain()
ContactService.update({domainContact with Id = id})
flash "Updated Contact!" ctx
return ctx.Response.Redirect($"/contacts/{id}")
| ValidationResult.Invalid (contactDto, errors) ->
return!
({ contactDto with id = id }, errors)
|> ModelState.Invalid
|> edit.html
|> writeHtml
<| ctx
}

let viewContact id: EndpointHandler =
let contact = ContactService.find id
writeHtml <| show.html contact

let getEditContact id: EndpointHandler =
let contact = ContactService.find id |> ContactDTO.FromDomain
writeHtml <| edit.html contact
id
|> ContactService.find
|> ContactDTO.FromDomain
|> ModelState.Valid
|> edit.html
|> writeHtml

let deleteContact id: EndpointHandler =
fun ctx ->
Expand Down
22 changes: 7 additions & 15 deletions examples/ContactApp/Models.fs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
module ContactApp.Models
open System
open System.Collections.Generic
open System.ComponentModel.DataAnnotations


type Contact = {
Expand All @@ -14,24 +13,17 @@ type Contact = {
[<CLIMutable>]
type ContactDTO = {
id: int
[<Required>]
first: string
[<Required>]
last: string
[<Required>]
phone: string
[<Required>]
[<EmailAddress>]
email: string
errors: IDictionary<string, string>
} with
member this.GetError key =
match this.errors.TryGetValue key with
| true, value -> value
| _ -> ""
member this.Validate() =
let errors = Dictionary<string, string>()
if String.IsNullOrEmpty(this.first) then errors.Add("first", "First name is required")
if String.IsNullOrEmpty(this.last) then errors.Add("last", "Last name is required")
if String.IsNullOrEmpty(this.phone) then errors.Add("phone", "Phone is required")
if String.IsNullOrEmpty(this.email) then errors.Add("email", "Email is required")
{ this with errors = errors }
member this.ToDomain() =
{ Id = this.id; First = this.first; Last = this.last; Phone = this.phone; Email = this.email }
static member FromDomain(contact: Contact) =
{ id = contact.Id; first = contact.First; last = contact.Last; phone = contact.Phone; email = contact.Email; errors = dict [] }
{ id = contact.Id; first = contact.First; last = contact.Last; phone = contact.Phone; email = contact.Email }
2 changes: 2 additions & 0 deletions examples/ContactApp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@

Here you can find an F# version of the contact app presented in [Hypermedia Systems](https://hypermedia.systems/) book.

It is also an example of `Oxpecker.ModelValidation` usage.

![image](https://github.com/Lanayx/Oxpecker/assets/3329606/888dc44f-3fa5-43e1-9da7-5df1d255b584)
8 changes: 5 additions & 3 deletions examples/ContactApp/templates/edit.fs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
module ContactApp.templates.edit

open Oxpecker.ModelValidation
open Oxpecker.ViewEngine
open Oxpecker.Htmx
open ContactApp.Models
open ContactApp.templates.shared

let html (contact: ContactDTO) =
let html (contact: ModelState<ContactDTO>) =
let contactId = contact.Value(_.id >> string)
Fragment() {
form(action= $"/contacts/{contact.id}/edit", method="post") {
form(action= $"/contacts/{contactId}/edit", method="post") {
fieldset() {
legend() { "Contact Values" }
contactFields.html contact
Expand All @@ -16,7 +18,7 @@ let html (contact: ContactDTO) =
}

button(id="delete-btn",
hxDelete= $"/contacts/{contact.id}",
hxDelete= $"/contacts/{contactId}",
hxPushUrl="true",
hxConfirm="Are you sure you want to delete this contact?",
hxTarget="body") { "Delete Contact" }
Expand Down
3 changes: 2 additions & 1 deletion examples/ContactApp/templates/new.fs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
module ContactApp.templates.new'
open ContactApp.Models
open Oxpecker.ModelValidation
open Oxpecker.ViewEngine
open ContactApp.templates.shared

let html (contact: ContactDTO) =
let html (contact: ModelState<ContactDTO>) =
Fragment() {
form(action="/contacts/new", method="post") {
fieldset() {
Expand Down
24 changes: 14 additions & 10 deletions examples/ContactApp/templates/shared/contactFields.fs
Original file line number Diff line number Diff line change
@@ -1,34 +1,38 @@
namespace ContactApp.templates.shared

open ContactApp.templates.shared.errors
open Oxpecker.Htmx
open Oxpecker.ModelValidation

module contactFields =

open ContactApp.Models
open Oxpecker.ViewEngine

let html (contact: ContactDTO) =
let html (contact: ModelState<ContactDTO>) =
let x = Unchecked.defaultof<ContactDTO>
let showErrors = showErrors contact
div() {
p() {
label(for'="email") { "Email" }
input(name="email", id="email", type'="email", placeholder="Email", value=contact.email,
input(name=nameof x.email, id="email", type'="email", placeholder="Email", value=contact.Value(_.email),
hxTrigger="change, keyup delay:200ms changed",
hxGet= $"/contacts/{contact.id}/email", hxTarget="next .error")
span(class'="error") { contact.GetError("email") }
hxGet= $"/contacts/{contact.Value(_.id >> string)}/email", hxTarget="next .error")
showErrors <| nameof x.email
}
p() {
label(for'="first") { "First Name" }
input(name="first", id="firs", type'="text", placeholder="First Name", value=contact.first)
span(class'="error") { contact.GetError("first") }
input(name=nameof x.first, id="first", type'="text", placeholder="First Name", value=contact.Value(_.first))
showErrors <| nameof x.first
}
p() {
label(for'="last") { "Last Name" }
input(name="last", id="last", type'="text", placeholder="Last Name", value=contact.last)
span(class'="error") { contact.GetError("last") }
input(name=nameof x.last, id="last", type'="text", placeholder="Last Name", value=contact.Value(_.last))
showErrors <| nameof x.last
}
p() {
label(for'="phone") { "Phone" }
input(name="phone", id="phone", type'="text", placeholder="Phone", value=contact.phone)
span(class'="error") { contact.GetError("phone") }
input(name=nameof x.phone, id="phone", type'="text", placeholder="Phone", value=contact.Value(_.phone))
showErrors <| nameof x.phone
}
}
13 changes: 13 additions & 0 deletions examples/ContactApp/templates/shared/errors.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module ContactApp.templates.shared.errors

open Oxpecker.ViewEngine
open Oxpecker.ModelValidation

let showErrors (modelState: ModelState<_>) (fieldName: string) =
match modelState with
| ModelState.Invalid (_, modelErrors) ->
span(class'="error"){
System.String.Join(", ", modelErrors.ErrorMessagesFor(fieldName))
} :> HtmlElement
| _ ->
Fragment()
Loading
Loading