Migrate / finish account profile page

This commit is contained in:
Daniel J. Summers 2023-01-16 20:20:51 -05:00
parent ccd7311e74
commit fa03e361e1
10 changed files with 334 additions and 141 deletions

View File

@ -23,7 +23,6 @@ let options =
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString) WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
WrappedJsonConverter (ListingId.ofString, ListingId.toString) WrappedJsonConverter (ListingId.ofString, ListingId.toString)
WrappedJsonConverter (Text, MarkdownString.toString) WrappedJsonConverter (Text, MarkdownString.toString)
WrappedJsonConverter (OtherContactId.ofString, OtherContactId.toString)
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString) WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
JsonFSharpConverter () JsonFSharpConverter ()
] ]

View File

@ -4,47 +4,6 @@ module JobsJobsJobs.Domain.SharedTypes
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open NodaTime open NodaTime
/// The data to add or update an other contact
type OtherContactForm =
{ /// The ID of the contact
Id : string
/// The type of the contact
ContactType : string
/// The name of the contact
Name : string option
/// The value of the contact (URL, e-mail address, phone, etc.)
Value : string
/// Whether this contact is displayed for public employment profiles and job listings
IsPublic : bool
}
/// The data available to update an account profile
type AccountProfileForm =
{ /// The first name of the citizen
FirstName : string
/// The last name of the citizen
LastName : string
/// The display name for the citizen
DisplayName : string option
/// The citizen's new password
NewPassword : string option
/// Confirmation of the citizen's new password
NewPasswordConfirm : string option
/// The contacts for this profile
Contacts : OtherContactForm list
}
/// The data needed to display a listing /// The data needed to display a listing
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type ListingForView = type ListingForView =

View File

@ -78,22 +78,6 @@ module MarkdownString =
let toString = function Text text -> text let toString = function Text text -> text
/// The ID of an other contact
type OtherContactId = OtherContactId of Guid
/// Support functions for other contact IDs
module OtherContactId =
/// Create a new job listing ID
let create () = (Guid.NewGuid >> OtherContactId) ()
/// A string representation of a listing ID
let toString = function OtherContactId it -> ShortGuid.fromGuid it
/// Parse a string into a listing ID
let ofString = ShortGuid.toGuid >> OtherContactId
/// Types of contacts supported by Jobs, Jobs, Jobs /// Types of contacts supported by Jobs, Jobs, Jobs
type ContactType = type ContactType =
/// E-mail addresses /// E-mail addresses
@ -124,10 +108,7 @@ module ContactType =
/// Another way to contact a citizen from this site /// Another way to contact a citizen from this site
type OtherContact = type OtherContact =
{ /// The ID of the contact { /// The type of contact
Id : OtherContactId
/// The type of contact
ContactType : ContactType ContactType : ContactType
/// The name of the contact (Email, No Agenda Social, LinkedIn, etc.) /// The name of the contact (Email, No Agenda Social, LinkedIn, etc.)

View File

@ -252,6 +252,15 @@ module Citizen =
challenges <- Some qAndA challenges <- Some qAndA
qAndA qAndA
// GET: /citizen/account
let account : HttpHandler = fun next ctx -> task {
match! Citizens.findById (currentCitizenId ctx) with
| Some citizen ->
return!
Citizen.account (AccountProfileForm.fromCitizen citizen) (csrf ctx) |> render "Account Profile" next ctx
| None -> return! Error.notFound next ctx
}
// GET: /citizen/confirm/[token] // GET: /citizen/confirm/[token]
let confirm token next ctx = task { let confirm token next ctx = task {
let! isConfirmed = Citizens.confirmAccount token let! isConfirmed = Citizens.confirmAccount token
@ -387,43 +396,51 @@ module Citizen =
return! refreshPage () next ctx return! refreshPage () next ctx
} }
// GET: /citizen/so-long // POST: /citizen/save-account
let soLong : HttpHandler = requireUser >=> fun next ctx -> let saveAccount : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
Citizen.deletionOptions (csrf ctx) |> render "Account Deletion Options" next ctx let! theForm = ctx.BindFormAsync<AccountProfileForm> ()
let form = { theForm with Contacts = theForm.Contacts |> Array.filter (box >> isNull >> not) }
let errors = [
/// Handlers for /api/citizen routes if form.FirstName = "" then "First Name is required"
[<RequireQualifiedAccess>] if form.LastName = "" then "Last Name is required"
module CitizenApi = if form.NewPassword <> form.NewPassword then "New passwords do not match"
if form.Contacts |> Array.exists (fun c -> c.ContactType = "") then "All Contact Types are required"
// PATCH: /api/citizen/account if form.Contacts |> Array.exists (fun c -> c.Value = "") then "All Contacts are required"
let account : HttpHandler = authorize >=> fun next ctx -> task { ]
let! form = ctx.BindJsonAsync<AccountProfileForm> () if List.isEmpty errors then
match! Citizens.findById (currentCitizenId ctx) with match! Citizens.findById (currentCitizenId ctx) with
| Some citizen -> | Some citizen ->
let password = let password =
if defaultArg form.NewPassword "" = "" then citizen.PasswordHash if form.NewPassword = "" then citizen.PasswordHash
else Auth.Passwords.hash citizen form.NewPassword.Value else Auth.Passwords.hash citizen form.NewPassword
do! Citizens.save do! Citizens.save
{ citizen with { citizen with
FirstName = form.FirstName FirstName = form.FirstName
LastName = form.LastName LastName = form.LastName
DisplayName = noneIfBlank form.DisplayName DisplayName = noneIfEmpty form.DisplayName
PasswordHash = password PasswordHash = password
OtherContacts = form.Contacts OtherContacts = form.Contacts
|> List.map (fun c -> { |> Array.map (fun c ->
Id = if c.Id.StartsWith "new" then OtherContactId.create () { OtherContact.Name = noneIfEmpty c.Name
else OtherContactId.ofString c.Id
ContactType = ContactType.parse c.ContactType ContactType = ContactType.parse c.ContactType
Name = noneIfBlank c.Name
Value = c.Value Value = c.Value
IsPublic = c.IsPublic IsPublic = c.IsPublic
}) })
|> List.ofArray
} }
return! ok next ctx let extraMsg = if form.NewPassword = "" then "" else " and password changed"
do! addSuccess $"Account profile updated{extraMsg} successfully" ctx
return! redirectToGet "/citizen/account" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
else
do! addErrors errors ctx
return! Citizen.account form (csrf ctx) |> render "Account Profile" next ctx
} }
// GET: /citizen/so-long
let soLong : HttpHandler = requireUser >=> fun next ctx ->
Citizen.deletionOptions (csrf ctx) |> render "Account Deletion Options" next ctx
/// Handlers for the home page, legal stuff, and help /// Handlers for the home page, legal stuff, and help
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
@ -792,6 +809,7 @@ let allEndpoints = [
] ]
subRoute "/citizen" [ subRoute "/citizen" [
GET_HEAD [ GET_HEAD [
route "/account" Citizen.account
routef "/confirm/%s" Citizen.confirm routef "/confirm/%s" Citizen.confirm
route "/dashboard" Citizen.dashboard route "/dashboard" Citizen.dashboard
routef "/deny/%s" Citizen.deny routef "/deny/%s" Citizen.deny
@ -804,6 +822,7 @@ let allEndpoints = [
route "/delete" Citizen.delete route "/delete" Citizen.delete
route "/log-on" Citizen.doLogOn route "/log-on" Citizen.doLogOn
route "/register" Citizen.doRegistration route "/register" Citizen.doRegistration
route "/save-account" Citizen.saveAccount
] ]
] ]
subRoute "/listing" [ subRoute "/listing" [
@ -838,13 +857,7 @@ let allEndpoints = [
] ]
POST [ route "y/save" Success.save ] POST [ route "y/save" Success.save ]
] ]
subRoute "/api" [ subRoute "/api" [
subRoute "/citizen" [
PATCH [
route "/account" CitizenApi.account
]
]
POST [ route "/markdown-preview" Api.markdownPreview ] POST [ route "/markdown-preview" Api.markdownPreview ]
] ]
] ]

View File

@ -3,6 +3,70 @@ module JobsJobsJobs.ViewModels
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
/// The data to add or update an other contact
[<CLIMutable; NoComparison; NoEquality>]
type OtherContactForm =
{ /// The type of the contact
ContactType : string
/// The name of the contact
Name : string
/// The value of the contact (URL, e-mail address, phone, etc.)
Value : string
/// Whether this contact is displayed for public employment profiles and job listings
IsPublic : bool
}
/// Support functions for the contact form
module OtherContactForm =
/// Create a contact form from a contact
let fromContact (contact : OtherContact) =
{ ContactType = ContactType.toString contact.ContactType
Name = defaultArg contact.Name ""
Value = contact.Value
IsPublic = contact.IsPublic
}
/// The data available to update an account profile
[<CLIMutable; NoComparison; NoEquality>]
type AccountProfileForm =
{ /// The first name of the citizen
FirstName : string
/// The last name of the citizen
LastName : string
/// The display name for the citizen
DisplayName : string
/// The citizen's new password
NewPassword : string
/// Confirmation of the citizen's new password
NewPasswordConfirm : string
/// The contacts for this profile
Contacts : OtherContactForm array
}
/// Support functions for the account profile form
module AccountProfileForm =
/// Create an account profile form from a citizen
let fromCitizen (citizen : Citizen) =
{ FirstName = citizen.FirstName
LastName = citizen.LastName
DisplayName = defaultArg citizen.DisplayName ""
NewPassword = ""
NewPasswordConfirm = ""
Contacts = citizen.OtherContacts |> List.map OtherContactForm.fromContact |> Array.ofList
}
/// The fields required for a skill /// The fields required for a skill
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type SkillForm = type SkillForm =

View File

@ -7,6 +7,116 @@ open Giraffe.ViewEngine.Htmx
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open JobsJobsJobs.ViewModels open JobsJobsJobs.ViewModels
/// The form to add or edit a means of contact
let contactEdit (contacts : OtherContactForm array) =
let mapToInputs (idx : int) (contact : OtherContactForm) =
div [ _id $"contactRow{idx}"; _class "row pb-3" ] [
div [ _class "col-2 col-md-1" ] [
button [ _type "button"; _class "btn btn-sm btn-outline-danger rounded-pill mt-3"; _title "Delete"
_onclick $"jjj.citizen.removeContact({idx})" ] [
rawText " &minus; "
]
]
div [ _class "col-10 col-md-4 col-xl-3" ] [
div [ _class "form-floating" ] [
select [ _id $"contactType{idx}"; _name $"Contacts[{idx}].ContactType"; _class "form-control"
_value contact.ContactType; _placeholder "Type"; _required ] [
option [ _value "Website" ] [ rawText "Website" ]
option [ _value "Email" ] [ rawText "E-mail Address" ]
option [ _value "Phone" ] [ rawText "Phone Number" ]
]
label [ _class "jjj-required"; _for $"contactType{idx}" ] [ rawText "Type" ]
]
]
div [ _class "col-12 col-md-4 col-xl-3" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _id $"contactName{idx}"; _name $"Contacts[{idx}].Name"; _class "form-control"
_maxlength "1000"; _value contact.Name; _placeholder "Name" ]
label [ _class "jjj-label"; _for $"contactName{idx}" ] [ rawText "Name" ]
]
if idx < 1 then
div [ _class "form-text" ] [ rawText "Optional; will link sites and e-mail, qualify phone numbers" ]
]
div [ _class "col-12 col-md-7 offset-md-1 col-xl-4 offset-xl-0" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _id $"contactValue{idx}"; _name $"Contacts[{idx}].Value"
_class "form-control"; _maxlength "1000"; _value contact.Value; _placeholder "Contact"
_required ]
label [ _class "jjj-required"; _for "contactValue{idx}" ] [ rawText "Contact" ]
]
if idx < 1 then div [ _class "form-text"] [ rawText "The URL, e-mail address, or phone number" ]
]
div [ _class "col-12 col-md-3 offset-md-1 col-xl-1 offset-xl-0" ] [
div [ _class "form-check mt-3" ] [
input [ _type "checkbox"; _id $"contactIsPublic{idx}"; _name $"Contacts[{idx}].IsPublic";
_class "form-check-input"; _value "true"; if contact.IsPublic then _checked ]
label [ _class "form-check-label"; _for $"contactIsPublic{idx}" ] [ rawText "Public" ]
]
]
]
template [ _id "newContact" ] [
mapToInputs -1 { ContactType = "Website"; Name = ""; Value = ""; IsPublic = false }
]
:: (contacts |> Array.mapi mapToInputs |> List.ofArray)
/// The account edit page
let account (m : AccountProfileForm) csrf =
article [] [
h3 [ _class "pb-3" ] [ rawText "Account Profile" ]
p [] [
rawText "This information is visible to all fellow logged-on citizens. For publicly-visible employment "
rawText "profiles and job listings, the &ldquo;Display Name&rdquo; fields and any public contacts will be "
rawText "displayed."
]
form [ _class "row g-3"; _method "POST"; _action "/citizen/save-account" ] [
antiForgery csrf
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text"; _autofocus ] (nameof m.FirstName) m.FirstName "First Name" true
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text" ] (nameof m.LastName) m.LastName "Last Name" true
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text" ] (nameof m.DisplayName) m.DisplayName "Display Name" false
div [ _class "form-text" ] [ em [] [ rawText "Optional; overrides first/last for display" ] ]
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "password"; _minlength "8" ] (nameof m.NewPassword) "" "New Password" false
div [ _class "form-text" ] [ rawText "Leave blank to keep your current password" ]
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "password"; _minlength "8" ] (nameof m.NewPasswordConfirm) "" "Confirm New Password"
false
div [ _class "form-text" ] [ rawText "Leave blank to keep your current password" ]
]
div [ _class "col-12" ] [
hr []
h4 [ _class "pb-2" ] [
rawText "Ways to Be Contacted &nbsp; "
button [ _type "button"; _class "btn btn-sm btn-outline-primary rounded-pill"
_onclick "jjj.citizen.addContact()" ] [
rawText "Add a Contact Method"
]
]
]
yield! contactEdit m.Contacts
div [ _class "col-12" ] [
button [ _type "submit"; _class "btn btn-primary" ] [
i [ _class "mdi mdi-content-save-outline" ] [ rawText "&nbsp; Save" ]
]
]
]
hr []
p [ _class "text-muted fst-italic" ] [
rawText "(If you want to delete your profile, or your entire account, "
a [ _href "/citizen/so-long" ] [ rawText "see your deletion options here" ]; rawText ".)"
]
jsOnLoad $"
jjj.citizen.nextIndex = {m.Contacts.Length}
jjj.citizen.validatePasswords('{nameof m.NewPassword}', '{nameof m.NewPasswordConfirm}', false)"
]
/// The account confirmation page /// The account confirmation page
let confirmAccount isConfirmed = let confirmAccount isConfirmed =
article [] [ article [] [
@ -231,19 +341,7 @@ let register q1 q2 (m : RegisterViewModel) csrf =
i [ _class "mdi mdi-content-save-outline" ] []; rawText "&nbsp; Save" i [ _class "mdi mdi-content-save-outline" ] []; rawText "&nbsp; Save"
] ]
] ]
script [] [ rawText """ jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)"
const pw = document.getElementById("Password")
const pwConfirm = document.getElementById("ConfirmPassword")
pwConfirm.addEventListener("input", () => {
if (!pw.validity.valid) {
pwConfirm.setCustomValidity("")
} else if (!pwConfirm.validity.valueMissing && pw.value !== pwConfirm.value) {
pwConfirm.setCustomValidity("Confirmation password does not match")
} else {
pwConfirm.setCustomValidity("")
}
})"""
]
] ]
] ]

View File

@ -49,6 +49,12 @@ let continentList attrs name (continents : Continent list) emptyLabel selectedVa
label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ rawText "Continent" ] label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ rawText "Continent" ]
] ]
/// Register JavaScript code to run in the DOMContentLoaded event on the page
let jsOnLoad js =
script [] [
rawText """document.addEventListener("DOMContentLoaded", function () { """; rawText js; rawText " })"
]
/// Create a Markdown editor /// Create a Markdown editor
let markdownEditor attrs name value editorLabel = let markdownEditor attrs name value editorLabel =
div [ _class "col-12"; _id $"{name}EditRow" ] [ div [ _class "col-12"; _id $"{name}EditRow" ] [
@ -71,11 +77,7 @@ let markdownEditor attrs name value editorLabel =
] ]
label [ _for name ] [ rawText editorLabel ] label [ _for name ] [ rawText editorLabel ]
] ]
script [] [ jsOnLoad $"jjj.markdownOnLoad('{name}')"
rawText """document.addEventListener("DOMContentLoaded", function () {"""
rawText $" jjj.markdownOnLoad('{name}') "
rawText "})"
]
] ]
/// Wrap content in a collapsing panel /// Wrap content in a collapsing panel

View File

@ -72,9 +72,7 @@ let expire (m : ExpireListingForm) (listing : Listing) csrf =
] ]
] ]
] ]
script [] [ jsOnLoad "jjj.listing.toggleFromHere()"
rawText """document.addEventListener("DOMContentLoaded", function () { jjj.listing.toggleFromHere() })"""
]
] ]

View File

@ -119,11 +119,7 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf =
rawText "(If you want to delete your profile, or your entire account, " rawText "(If you want to delete your profile, or your entire account, "
a [ _href "/citizen/so-long" ] [ rawText "see your deletion options here" ]; rawText ".)" a [ _href "/citizen/so-long" ] [ rawText "see your deletion options here" ]; rawText ".)"
] ]
script [] [ jsOnLoad $"jjj.profile.nextIndex = {m.Skills.Length}"
rawText """addEventListener("DOMContentLoaded", function () {"""
rawText $" jjj.profile.nextIndex = {m.Skills.Length} "
rawText "})"
]
] ]

View File

@ -130,6 +130,89 @@ this.jjj = {
editDiv.classList.add("jjj-shown") editDiv.classList.add("jjj-shown")
}, },
citizen: {
/**
* The next index for a newly-added contact
* @type {number}
*/
nextIndex: 0,
/**
* Add a contact to the account form
*/
addContact() {
const next = this.nextIndex
/** @type {HTMLTemplateElement} */
const newContactTemplate = document.getElementById("newContact")
/** @type {HTMLDivElement} */
const newContact = newContactTemplate.content.firstElementChild.cloneNode(true)
newContact.setAttribute("id", `contactRow${next}`)
const cols = newContact.children
// Button column
cols[0].querySelector("button").setAttribute("onclick", `jjj.citizen.removeContact(${next})`)
// Contact Type column
const typeField = cols[1].querySelector("select")
typeField.setAttribute("id", `contactType${next}`)
typeField.setAttribute("name", `Contacts[${this.nextIndex}].ContactType`)
cols[1].querySelector("label").setAttribute("for", `contactType${next}`)
// Name column
const nameField = cols[2].querySelector("input")
nameField.setAttribute("id", `contactName${next}`)
nameField.setAttribute("name", `Contacts[${this.nextIndex}].Name`)
cols[2].querySelector("label").setAttribute("for", `contactName${next}`)
if (next > 0) cols[2].querySelector("div.form-text").remove()
// Value column
const valueField = cols[3].querySelector("input")
valueField.setAttribute("id", `contactValue${next}`)
valueField.setAttribute("name", `Contacts[${this.nextIndex}].Value`)
cols[3].querySelector("label").setAttribute("for", `contactName${next}`)
if (next > 0) cols[3].querySelector("div.form-text").remove()
// Is Public column
const isPublicField = cols[4].querySelector("input")
isPublicField.setAttribute("id", `contactIsPublic${next}`)
isPublicField.setAttribute("name", `Contacts[${this.nextIndex}].IsPublic`)
cols[4].querySelector("label").setAttribute("for", `contactIsPublic${next}`)
// Add the row
const contacts = document.querySelectorAll("div[id^=contactRow]")
const sibling = contacts.length > 0 ? contacts[contacts.length - 1] : newContactTemplate
sibling.insertAdjacentElement('afterend', newContact)
this.nextIndex++
},
/**
* Remove a contact row from the profile form
* @param {number} idx The index of the contact row to remove
*/
removeContact(idx) {
document.getElementById(`contactRow${idx}`).remove()
},
/**
* Register a comparison validation between a password and a "confirm password" field
* @param {string} pwId The ID for the password field
* @param {string} confirmId The ID for the "confirm password" field
* @param {boolean} isRequired Whether these fields are required
*/
validatePasswords(pwId, confirmId, isRequired) {
const pw = document.getElementById(pwId)
const pwConfirm = document.getElementById(confirmId)
pwConfirm.addEventListener("input", () => {
if (!pw.validity.valid) {
pwConfirm.setCustomValidity("")
} else if ((!pwConfirm.validity.valueMissing || !isRequired) && pw.value !== pwConfirm.value) {
pwConfirm.setCustomValidity("Confirmation password does not match")
} else {
pwConfirm.setCustomValidity("")
}
})
}
},
/** /**
* Script for listing pages * Script for listing pages
*/ */
@ -172,7 +255,7 @@ this.jjj = {
const cols = newSkill.children const cols = newSkill.children
// Button column // Button column
cols[0].querySelector("button").setAttribute("onclick", `jjj.profile.removeSkill('${next}')`) cols[0].querySelector("button").setAttribute("onclick", `jjj.profile.removeSkill(${next})`)
// Skill column // Skill column
const skillField = cols[1].querySelector("input") const skillField = cols[1].querySelector("input")
skillField.setAttribute("id", `skillDesc${next}`) skillField.setAttribute("id", `skillDesc${next}`)
@ -196,10 +279,10 @@ this.jjj = {
/** /**
* Remove a skill row from the profile form * Remove a skill row from the profile form
* @param {number} id The ID of the skill row to remove * @param {number} idx The index of the skill row to remove
*/ */
removeSkill(id) { removeSkill(idx) {
document.getElementById(`skillRow${id}`).remove() document.getElementById(`skillRow${idx}`).remove()
} }
} }
} }