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

@ -18,13 +18,12 @@ open NodaTime.Serialization.SystemTextJson
/// JsonSerializer options that use the custom converters /// JsonSerializer options that use the custom converters
let options = let options =
let opts = JsonSerializerOptions () let opts = JsonSerializerOptions ()
[ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter [ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter
WrappedJsonConverter (ContactType.parse, ContactType.toString) WrappedJsonConverter (ContactType.parse, ContactType.toString)
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 ()
] ]
|> List.iter opts.Converters.Add |> List.iter opts.Converters.Add

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

@ -251,7 +251,16 @@ module Citizen =
|> Array.ofSeq |> Array.ofSeq
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,44 +396,52 @@ module Citizen =
return! refreshPage () next ctx return! refreshPage () next ctx
} }
// POST: /citizen/save-account
let saveAccount : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let! theForm = ctx.BindFormAsync<AccountProfileForm> ()
let form = { theForm with Contacts = theForm.Contacts |> Array.filter (box >> isNull >> not) }
let errors = [
if form.FirstName = "" then "First Name is required"
if form.LastName = "" then "Last Name is required"
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"
if form.Contacts |> Array.exists (fun c -> c.Value = "") then "All Contacts are required"
]
if List.isEmpty errors then
match! Citizens.findById (currentCitizenId ctx) with
| Some citizen ->
let password =
if form.NewPassword = "" then citizen.PasswordHash
else Auth.Passwords.hash citizen form.NewPassword
do! Citizens.save
{ citizen with
FirstName = form.FirstName
LastName = form.LastName
DisplayName = noneIfEmpty form.DisplayName
PasswordHash = password
OtherContacts = form.Contacts
|> Array.map (fun c ->
{ OtherContact.Name = noneIfEmpty c.Name
ContactType = ContactType.parse c.ContactType
Value = c.Value
IsPublic = c.IsPublic
})
|> List.ofArray
}
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
else
do! addErrors errors ctx
return! Citizen.account form (csrf ctx) |> render "Account Profile" next ctx
}
// GET: /citizen/so-long // GET: /citizen/so-long
let soLong : HttpHandler = requireUser >=> fun next ctx -> let soLong : HttpHandler = requireUser >=> fun next ctx ->
Citizen.deletionOptions (csrf ctx) |> render "Account Deletion Options" next ctx Citizen.deletionOptions (csrf ctx) |> render "Account Deletion Options" next ctx
/// Handlers for /api/citizen routes
[<RequireQualifiedAccess>]
module CitizenApi =
// PATCH: /api/citizen/account
let account : HttpHandler = authorize >=> fun next ctx -> task {
let! form = ctx.BindJsonAsync<AccountProfileForm> ()
match! Citizens.findById (currentCitizenId ctx) with
| Some citizen ->
let password =
if defaultArg form.NewPassword "" = "" then citizen.PasswordHash
else Auth.Passwords.hash citizen form.NewPassword.Value
do! Citizens.save
{ citizen with
FirstName = form.FirstName
LastName = form.LastName
DisplayName = noneIfBlank form.DisplayName
PasswordHash = password
OtherContacts = form.Contacts
|> List.map (fun c -> {
Id = if c.Id.StartsWith "new" then OtherContactId.create ()
else OtherContactId.ofString c.Id
ContactType = ContactType.parse c.ContactType
Name = noneIfBlank c.Name
Value = c.Value
IsPublic = c.IsPublic
})
}
return! ok next ctx
| None -> return! Error.notFound next ctx
}
/// Handlers for the home page, legal stuff, and help /// Handlers for the home page, legal stuff, and help
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Home = module Home =
@ -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
@ -801,9 +819,10 @@ let allEndpoints = [
route "/so-long" Citizen.soLong route "/so-long" Citizen.soLong
] ]
POST [ POST [
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()
} }
} }
} }