Version 3 #40
|
@ -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 ()
|
||||||
]
|
]
|
||||||
|
|
|
@ -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 =
|
||||||
|
|
|
@ -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.)
|
||||||
|
|
|
@ -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 ]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
|
@ -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 =
|
||||||
|
|
|
@ -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 " − "
|
||||||
|
]
|
||||||
|
]
|
||||||
|
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 “Display Name” 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 "
|
||||||
|
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 " 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 " Save"
|
i [ _class "mdi mdi-content-save-outline" ] []; rawText " 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("")
|
|
||||||
}
|
|
||||||
})"""
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -72,9 +72,7 @@ let expire (m : ExpireListingForm) (listing : Listing) csrf =
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
script [] [
|
jsOnLoad "jjj.listing.toggleFromHere()"
|
||||||
rawText """document.addEventListener("DOMContentLoaded", function () { jjj.listing.toggleFromHere() })"""
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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 "})"
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user