Version 3 #40

Merged
danieljsummers merged 67 commits from version-2-3 into main 2023-02-02 23:47:28 +00:00
9 changed files with 381 additions and 228 deletions
Showing only changes of commit 2e0bfa5524 - Show all commits

View File

@ -13,6 +13,9 @@ label.jjj-required::after {
color: red;
content: ' *';
}
label[for]:hover {
cursor: pointer;
}
.jjj-heading-label {
display: inline-block;
font-size: 1rem;

View File

@ -25,6 +25,34 @@ module CitizenId =
let value = function CitizenId guid -> guid
/// Types of contacts supported by Jobs, Jobs, Jobs
type ContactType =
/// E-mail addresses
| Email
/// Phone numbers (home, work, cell, etc.)
| Phone
/// Websites (personal, social, etc.)
| Website
/// Functions to support contact types
module ContactType =
/// Parse a contact type from a string
let parse typ =
match typ with
| "Email" -> Email
| "Phone" -> Phone
| "Website" -> Website
| it -> invalidOp $"{it} is not a valid contact type"
/// Convert a contact type to its string representation
let toString =
function
| Email -> "Email"
| Phone -> "Phone"
| Website -> "Website"
/// The ID of a continent
type ContinentId = ContinentId of Guid
@ -112,34 +140,6 @@ module ListingId =
let value = function ListingId guid -> guid
/// Types of contacts supported by Jobs, Jobs, Jobs
type ContactType =
/// E-mail addresses
| Email
/// Phone numbers (home, work, cell, etc.)
| Phone
/// Websites (personal, social, etc.)
| Website
/// Functions to support contact types
module ContactType =
/// Parse a contact type from a string
let parse typ =
match typ with
| "Email" -> Email
| "Phone" -> Phone
| "Website" -> Website
| it -> invalidOp $"{it} is not a valid contact type"
/// Convert a contact type to its string representation
let toString =
function
| Email -> "Email"
| Phone -> "Phone"
| Website -> "Website"
/// Another way to contact a citizen from this site
[<NoComparison; NoEquality>]
type OtherContact =
@ -157,6 +157,34 @@ type OtherContact =
}
/// Visibility options for an employment profile
type ProfileVisibility =
/// Profile is only visible to authenticated users
| Private
/// Anonymous information is visible to public users
| Anonymous
/// The full employment profile is visible to public users
| Public
/// Support functions for profile visibility
module ProfileVisibility =
/// Parse a string into a profile visibility
let parse viz =
match viz with
| "Private" -> Private
| "Anonymous" -> Anonymous
| "Public" -> Public
| it -> invalidOp $"{it} is not a valid profile visibility value"
/// Convert a profile visibility to its string representation
let toString =
function
| Private -> "Private"
| Anonymous -> "Anonymous"
| Public -> "Public"
/// A skill the job seeker possesses
[<NoComparison; NoEquality>]
type Skill =
@ -370,25 +398,19 @@ type Profile =
{ /// The ID of the citizen to whom this profile belongs
Id : CitizenId
/// Whether this citizen is actively seeking employment
IsSeekingEmployment : bool
/// Whether this citizen allows their profile to be a part of the publicly-viewable, anonymous data
IsPubliclySearchable : bool
/// Whether this citizen allows their profile to be viewed via a public link
IsPubliclyLinkable : bool
/// The ID of the continent on which the citizen resides
ContinentId : ContinentId
/// The region in which the citizen resides
Region : string
/// Whether the citizen is looking for remote work
/// Whether this citizen is actively seeking employment
IsSeekingEmployment : bool
/// Whether the citizen is interested in remote work
IsRemote : bool
/// Whether the citizen is looking for full-time work
/// Whether the citizen is interested in full-time work
IsFullTime : bool
/// The citizen's professional biography
@ -403,6 +425,9 @@ type Profile =
/// The citizen's experience (topical / chronological)
Experience : MarkdownString option
/// The visibility of this profile
Visibility : ProfileVisibility
/// When the citizen last updated their profile
LastUpdatedOn : Instant
@ -415,20 +440,19 @@ module Profile =
// An empty profile
let empty = {
Id = CitizenId Guid.Empty
IsSeekingEmployment = false
IsPubliclySearchable = false
IsPubliclyLinkable = false
ContinentId = ContinentId Guid.Empty
Region = ""
IsRemote = false
IsFullTime = false
Biography = Text ""
Skills = []
History = []
Experience = None
LastUpdatedOn = Instant.MinValue
IsLegacy = false
Id = CitizenId Guid.Empty
ContinentId = ContinentId Guid.Empty
Region = ""
IsSeekingEmployment = false
IsRemote = false
IsFullTime = false
Biography = Text ""
Skills = []
History = []
Experience = None
Visibility = Private
LastUpdatedOn = Instant.MinValue
IsLegacy = false
}

View File

@ -20,12 +20,12 @@ module Error =
open System.Net
/// Handler that will return a status code 404 and the text "Not Found"
let notFound : HttpHandler = fun next ctx ->
let notFound : HttpHandler = fun _ ctx ->
let fac = ctx.GetService<ILoggerFactory> ()
let log = fac.CreateLogger "Handler"
let path = string ctx.Request.Path
log.LogInformation "Returning 404"
RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" earlyReturn ctx
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
@ -78,19 +78,10 @@ let tryUser (ctx : HttpContext) =
|> Option.ofObj
|> Option.map (fun x -> x.Value)
/// Require a user to be logged in
let authorize : HttpHandler =
fun next ctx -> match tryUser ctx with Some _ -> next ctx | None -> Error.notAuthorized next ctx
/// Get the ID of the currently logged in citizen
// NOTE: if no one is logged in, this will raise an exception
let currentCitizenId ctx = (tryUser >> Option.get >> CitizenId.ofString) ctx
/// Return an empty OK response
let ok : HttpHandler = Successful.OK ""
// -- NEW --
let antiForgerySvc (ctx : HttpContext) =
ctx.RequestServices.GetRequiredService<IAntiforgery> ()
@ -168,6 +159,16 @@ let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task {
return! ctx.WriteHtmlViewAsync (renderFunc renderCtx)
}
let renderBare (_ : HttpFunc) (ctx : HttpContext) content =
({ IsLoggedOn = Option.isSome (tryUser ctx)
CurrentUrl = ctx.Request.Path.Value
PageTitle = ""
Content = content
Messages = []
} : Layout.PageRenderContext)
|> Layout.bare
|> ctx.WriteHtmlViewAsync
/// Render as a composable HttpHandler
let renderHandler pageTitle content : HttpHandler = fun next ctx ->
render pageTitle next ctx content
@ -194,3 +195,7 @@ let redirectToGet (url : string) next ctx = task {
else RequestErrors.BAD_REQUEST "Invalid redirect URL"
return! action next ctx
}
/// Shorthand for Error.notFound for use in handler functions
let notFound ctx =
Error.notFound earlyReturn ctx

View File

@ -19,13 +19,14 @@ open NodaTime.Serialization.SystemTextJson
/// JsonSerializer options that use the custom converters
let options =
let opts = JsonSerializerOptions ()
[ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter
WrappedJsonConverter (ContactType.parse, ContactType.toString)
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
WrappedJsonConverter (ListingId.ofString, ListingId.toString)
WrappedJsonConverter (Text, MarkdownString.toString)
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
JsonFSharpConverter ()
[ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter
WrappedJsonConverter (ContactType.parse, ContactType.toString)
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
WrappedJsonConverter (ListingId.ofString, ListingId.toString)
WrappedJsonConverter (Text, MarkdownString.toString)
WrappedJsonConverter (ProfileVisibility.parse, ProfileVisibility.toString)
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
JsonFSharpConverter ()
]
|> List.iter opts.Converters.Add
let _ = opts.ConfigureForNodaTime DateTimeZoneProviders.Tzdb

View File

@ -352,3 +352,10 @@ module Layout =
]
]
]
/// Render a bare view (used for components)
let bare ctx =
html [ _lang "en" ] [
head [] [ title [] [] ]
body [] [ ctx.Content ]
]

View File

@ -129,24 +129,24 @@ task {
|> List.map (fun p ->
let experience = p["experience"].Value<string> ()
{ Profile.empty with
Id = CitizenId.ofString (p["id"].Value<string> ())
IsSeekingEmployment = p["seekingEmployment"].Value<bool> ()
IsPubliclySearchable = p["isPublic"].Value<bool> ()
ContinentId = ContinentId.ofString (p["continentId"].Value<string> ())
Region = p["region"].Value<string> ()
IsRemote = p["remoteWork"].Value<bool> ()
IsFullTime = p["fullTime"].Value<bool> ()
Biography = Text (p["biography"].Value<string> ())
LastUpdatedOn = getInstant p "lastUpdatedOn"
Experience = if isNull experience then None else Some (Text experience)
Skills = p["skills"].Children()
|> Seq.map (fun s ->
let notes = s["notes"].Value<string> ()
{ Description = s["description"].Value<string> ()
Notes = if isNull notes then None else Some notes
})
Id = CitizenId.ofString (p["id"].Value<string> ())
ContinentId = ContinentId.ofString (p["continentId"].Value<string> ())
Region = p["region"].Value<string> ()
IsSeekingEmployment = p["seekingEmployment"].Value<bool> ()
IsRemote = p["remoteWork"].Value<bool> ()
IsFullTime = p["fullTime"].Value<bool> ()
Biography = Text (p["biography"].Value<string> ())
Experience = if isNull experience then None else Some (Text experience)
Skills = p["skills"].Children()
|> Seq.map (fun s ->
let notes = s["notes"].Value<string> ()
{ Description = s["description"].Value<string> ()
Notes = if isNull notes then None else Some notes
})
|> List.ofSeq
IsLegacy = true
Visibility = if p["isPublic"].Value<bool> () then Anonymous else Private
LastUpdatedOn = getInstant p "lastUpdatedOn"
IsLegacy = true
})
for profile in newProfiles do
do! Profiles.Data.save profile

View File

@ -27,15 +27,15 @@ module SkillForm =
/// The data required to update a profile
[<CLIMutable; NoComparison; NoEquality>]
type EditProfileForm =
{ /// Whether the citizen to whom this profile belongs is actively seeking employment
IsSeekingEmployment : bool
/// The ID of the continent on which the citizen is located
{ /// The ID of the continent on which the citizen is located
ContinentId : string
/// The area within that continent where the citizen is located
Region : string
/// Whether the citizen to whom this profile belongs is actively seeking employment
IsSeekingEmployment : bool
/// If the citizen is available for remote work
RemoteWork : bool
@ -45,17 +45,11 @@ type EditProfileForm =
/// The user's professional biography
Biography : string
/// The skills for the user
Skills : SkillForm array
/// The user's past experience
Experience : string option
/// Whether this profile should appear in the public search
IsPubliclySearchable : bool
/// Whether this profile should be shown publicly
IsPubliclyLinkable : bool
/// The visibility for this profile
Visibility : string
}
/// Support functions for the ProfileForm type
@ -63,30 +57,26 @@ module EditProfileForm =
/// An empty view model (used for new profiles)
let empty =
{ IsSeekingEmployment = false
ContinentId = ""
Region = ""
RemoteWork = false
FullTime = false
Biography = ""
Skills = [||]
Experience = None
IsPubliclySearchable = false
IsPubliclyLinkable = false
{ ContinentId = ""
Region = ""
IsSeekingEmployment = false
RemoteWork = false
FullTime = false
Biography = ""
Experience = None
Visibility = ProfileVisibility.toString Private
}
/// Create an instance of this form from the given profile
let fromProfile (profile : Profile) =
{ IsSeekingEmployment = profile.IsSeekingEmployment
ContinentId = ContinentId.toString profile.ContinentId
Region = profile.Region
RemoteWork = profile.IsRemote
FullTime = profile.IsFullTime
Biography = MarkdownString.toString profile.Biography
Skills = profile.Skills |> List.map SkillForm.fromSkill |> Array.ofList
Experience = profile.Experience |> Option.map MarkdownString.toString
IsPubliclySearchable = profile.IsPubliclySearchable
IsPubliclyLinkable = profile.IsPubliclyLinkable
{ ContinentId = ContinentId.toString profile.ContinentId
Region = profile.Region
IsSeekingEmployment = profile.IsSeekingEmployment
RemoteWork = profile.IsRemote
FullTime = profile.IsFullTime
Biography = MarkdownString.toString profile.Biography
Experience = profile.Experience |> Option.map MarkdownString.toString
Visibility = ProfileVisibility.toString profile.Visibility
}

View File

@ -23,25 +23,21 @@ let edit : HttpHandler = requireUser >=> fun next ctx -> task {
// GET: /profile/edit/general
let editGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx
let! profile = Data.findById citizenId
let! profile = Data.findById (currentCitizenId ctx)
let! continents = Common.Data.Continents.all ()
let isNew = Option.isNone profile
let form = if isNew then EditProfileForm.empty else EditProfileForm.fromProfile profile.Value
let title = $"""{if isNew then "Create" else "Edit"} Profile"""
return! Views.editGeneralInfo form continents isNew citizenId (csrf ctx) |> render title next ctx
let form = if Option.isNone profile then EditProfileForm.empty else EditProfileForm.fromProfile profile.Value
return!
Views.editGeneralInfo form continents (csrf ctx) |> render "General Information | Employment Profile" next ctx
}
// POST: /profile/save
let save : HttpHandler = requireUser >=> fun next ctx -> task {
let saveGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx
let! theForm = ctx.BindFormAsync<EditProfileForm> ()
let form = { theForm with Skills = theForm.Skills |> Array.filter (box >> isNull >> not) }
let! form = ctx.BindFormAsync<EditProfileForm> ()
let errors = [
if form.ContinentId = "" then "Continent is required"
if form.Region = "" then "Region is required"
if form.Biography = "" then "Professional Biography is required"
if form.Skills |> Array.exists (fun s -> s.Description = "") then "All skill Descriptions are required"
]
let! profile = task {
match! Data.findById citizenId with
@ -52,20 +48,15 @@ let save : HttpHandler = requireUser >=> fun next ctx -> task {
if List.isEmpty errors then
do! Data.save
{ profile with
IsSeekingEmployment = form.IsSeekingEmployment
ContinentId = ContinentId.ofString form.ContinentId
Region = form.Region
IsRemote = form.RemoteWork
IsFullTime = form.FullTime
Biography = Text form.Biography
LastUpdatedOn = now ctx
Skills = form.Skills
|> Array.filter (fun s -> (box >> isNull >> not) s)
|> Array.map SkillForm.toSkill
|> List.ofArray
Experience = noneIfBlank form.Experience |> Option.map Text
IsPubliclySearchable = form.IsPubliclySearchable
IsPubliclyLinkable = form.IsPubliclyLinkable
ContinentId = ContinentId.ofString form.ContinentId
Region = form.Region
IsSeekingEmployment = form.IsSeekingEmployment
IsRemote = form.RemoteWork
IsFullTime = form.FullTime
Biography = Text form.Biography
LastUpdatedOn = now ctx
Experience = noneIfBlank form.Experience |> Option.map Text
Visibility = ProfileVisibility.parse form.Visibility
}
let action = if isNew then "cre" else "upd"
do! addSuccess $"Employment Profile {action}ated successfully" ctx
@ -74,8 +65,8 @@ let save : HttpHandler = requireUser >=> fun next ctx -> task {
do! addErrors errors ctx
let! continents = Common.Data.Continents.all ()
return!
Views.editGeneralInfo form continents isNew citizenId (csrf ctx)
|> render $"""{if isNew then "Create" else "Edit"} Profile""" next ctx
Views.editGeneralInfo form continents (csrf ctx)
|> render "General Information | Employment Profile" next ctx
}
// GET: /profile/search
@ -110,18 +101,63 @@ let seeking : HttpHandler = fun next ctx -> task {
return! Views.publicSearch form continents results |> render "Profile Search" next ctx
}
// GET: /profile/edit/skills
let skills : HttpHandler = requireUser >=> fun next ctx -> task {
match! Data.findById (currentCitizenId ctx) with
| Some profile -> return! Views.skills profile.Skills (csrf ctx) |> render "Skills | Employment Profile" next ctx
| None -> return! notFound ctx
}
// GET: /profile/edit/skill/[idx]
let editSkill idx : HttpHandler = requireUser >=> fun next ctx -> task {
match! Data.findById (currentCitizenId ctx) with
| Some profile ->
if idx < -1 || idx >= List.length profile.Skills then return! notFound ctx
else return! Views.editSkill profile.Skills idx (csrf ctx) |> renderBare next ctx
| None -> return! notFound ctx
}
// POST: /profile/edit/skill/[idx]
let saveSkill idx : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
match! Data.findById (currentCitizenId ctx) with
| Some profile ->
if idx < -1 || idx >= List.length profile.Skills then return! notFound ctx
else
let! form = ctx.BindFormAsync<SkillForm> ()
let skill = SkillForm.toSkill form
let skills =
if idx = -1 then skill :: profile.Skills
else profile.Skills |> List.mapi (fun skillIdx it -> if skillIdx = idx then skill else it)
|> List.sortBy (fun it -> it.Description.ToLowerInvariant ())
do! Data.save { profile with Skills = skills }
return! Views.skillTable skills None (csrf ctx) |> renderBare next ctx
| None -> return! notFound ctx
}
// POST: /profile/edit/skill/[idx]/delete
let deleteSkill idx : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
match! Data.findById (currentCitizenId ctx) with
| Some profile ->
if idx < 0 || idx >= List.length profile.Skills then return! notFound ctx
else
let skills = profile.Skills |> List.indexed |> List.filter (fun it -> fst it <> idx) |> List.map snd
do! Data.save { profile with Skills = skills }
return! Views.skillTable skills None (csrf ctx) |> renderBare next ctx
| None -> return! notFound ctx
}
// GET: /profile/[id]/view
let view citizenId : HttpHandler = fun next ctx -> task {
let citId = CitizenId citizenId
match! Data.findByIdForView citId with
| Some profile ->
let currentCitizen = tryUser ctx |> Option.map CitizenId.ofString
if not profile.Profile.IsPubliclyLinkable && Option.isNone currentCitizen then
if not (profile.Profile.Visibility = Public) && Option.isNone currentCitizen then
return! Error.notAuthorized next ctx
else
let title = $"Employment Profile for {Citizen.name profile.Citizen}"
return! Views.view profile currentCitizen |> render title next ctx
| None -> return! Error.notFound next ctx
| None -> return! notFound ctx
}
@ -131,14 +167,18 @@ open Giraffe.EndpointRouting
let endpoints =
subRoute "/profile" [
GET_HEAD [
routef "/%O/view" view
route "/edit" edit
route "/edit/general" editGeneralInfo
route "/search" search
route "/seeking" seeking
routef "/%O/view" view
route "/edit" edit
route "/edit/general" editGeneralInfo
routef "/edit/skill/%i" editSkill
route "/edit/skills" skills
route "/search" search
route "/seeking" seeking
]
POST [
route "/delete" delete
route "/save" save
route "/delete" delete
routef "/edit/skill/%i" saveSkill
routef "/edit/skill/%i/delete" deleteSkill
route "/save" saveGeneralInfo
]
]

View File

@ -1,12 +1,15 @@
/// Views for /profile URLs
module JobsJobsJobs.Profiles.Views
open Giraffe.Htmx.Common
open Giraffe.ViewEngine
open Giraffe.ViewEngine.Htmx
open JobsJobsJobs.Common.Views
open JobsJobsJobs.Domain
open JobsJobsJobs.Profiles.Domain
// ~~~ PROFILE EDIT ~~~ //
/// The profile edit menu page
let edit (profile : Profile) =
let hasProfile = profile.Region <> ""
@ -45,44 +48,32 @@ let edit (profile : Profile) =
i [ _class "mdi mdi-file-account-outline" ] []; txt "&nbsp; View Your User Profile"
]
]
hr []
p [ _class "text-muted" ] [
txt "If you want to delete your profile, or your entire account, "
a [ _href "/citizen/so-long" ] [ txt "see your deletion options here" ]; txt "."
]
]
/// Render the skill edit template and existing skills
let skillEdit (skills : SkillForm array) =
let mapToInputs (idx : int) (skill : SkillForm) =
div [ _id $"skillRow{idx}"; _class "row pb-3" ] [
div [ _class "col-2 col-md-1 align-self-center" ] [
button [ _class "btn btn-sm btn-outline-danger rounded-pill"; _title "Delete"
_onclick $"jjj.profile.removeSkill(idx)" ] [ txt "&nbsp;&minus;&nbsp;" ]
]
div [ _class "col-10 col-md-6" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _id $"skillDesc{idx}"; _name $"Skills[{idx}].Description"
_class "form-control"; _placeholder "A skill (language, design technique, process, etc.)"
_maxlength "200"; _value skill.Description; _required ]
label [ _class "jjj-required"; _for $"skillDesc{idx}" ] [ txt "Skill" ]
]
if idx < 1 then div [ _class "form-text" ] [ txt "A skill (language, design technique, process, etc.)" ]
]
div [ _class "col-12 col-md-5" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _id $"skillNotes{idx}"; _name $"Skills[{idx}].Notes"; _class "form-control"
_maxlength "1000"; _placeholder "A further description of the skill (1,000 characters max)"
_value skill.Notes ]
label [ _class "jjj-label"; _for $"skillNotes{idx}" ] [ txt "Notes" ]
]
if idx < 1 then div [ _class "form-text" ] [ txt "A further description of the skill" ]
]
]
template [ _id "newSkill" ] [ mapToInputs -1 { Description = ""; Notes = "" } ]
:: (skills |> Array.mapi mapToInputs |> List.ofArray)
/// A link to allow the user to return to the profile edit menu page
let backToEdit =
p [ _class "mx-3" ] [ a [ _href "/profile/edit" ] [ txt "&laquo; Back to Profile Edit Menu" ] ]
/// The profile edit page
let editGeneralInfo (m : EditProfileForm) continents isNew citizenId csrf =
pageWithTitle "My Employment Profile" [
let editGeneralInfo (m : EditProfileForm) continents csrf =
pageWithTitle "Employment Profile: General Information" [
backToEdit
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
antiForgery csrf
div [ _class "col-12 col-sm-6 col-md-4" ] [
continentList [] (nameof m.ContinentId) continents None m.ContinentId true
]
div [ _class "col-12 col-sm-6 col-md-8" ] [
textBox [ _type "text"; _maxlength "255" ] (nameof m.Region) m.Region "Region" true
div [ _class "form-text" ] [ txt "Country, state, geographic area, etc." ]
]
div [ _class "col-12" ] [
checkBox [] (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment"
if m.IsSeekingEmployment then
@ -91,66 +82,158 @@ let editGeneralInfo (m : EditProfileForm) continents isNew citizenId csrf =
a [ _href "/success-story/new/edit" ] [ txt "telling your fellow citizens about it!" ]
]
]
div [ _class "col-12 col-sm-6 col-md-4" ] [
continentList [] (nameof m.ContinentId) continents None m.ContinentId true
]
div [ _class "col-12 col-sm-6 col-md-8" ] [
textBox [ _type "text"; _maxlength "255" ] (nameof m.Region) m.Region "Region" true
div [ _class "form-text" ] [ txt "Country, state, geographic area, etc." ]
]
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
div [ _class "col-12 col-offset-md-2 col-md-4" ] [
checkBox [] (nameof m.RemoteWork) m.RemoteWork "I am looking for remote work"
checkBox [] (nameof m.RemoteWork) m.RemoteWork "I am interested in remote work"
]
div [ _class "col-12 col-md-4" ] [
checkBox [] (nameof m.FullTime) m.FullTime "I am looking for full-time work"
checkBox [] (nameof m.FullTime) m.FullTime "I am interested in full-time work"
]
div [ _class "col-12" ] [
hr []
h4 [ _class "pb-2" ] [
txt "Skills &nbsp; "
button [ _type "button"; _class "btn btn-sm btn-outline-primary rounded-pill"
_onclick "jjj.profile.addSkill()" ] [ txt "Add a Skill" ]
]
]
yield! skillEdit m.Skills
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
div [ _class "col-12" ] [
hr []
h4 [] [ txt "Experience" ]
p [] [
txt "This application does not have a place to individually list your chronological job history; "
txt "however, you can use this area to list prior jobs, their dates, and anything else you want to "
txt "include that&rsquo;s not already a part of your Professional Biography above."
txt "The information in this box is displayed after the list of skills and chronological job "
txt "history, with no heading; it can be used to highlight your experiences apart from the history "
txt "entries, provide closing notes, etc."
]
]
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience"
div [ _class "col-12 col-xl-6" ] [
checkBox [] (nameof m.IsPubliclySearchable) m.IsPubliclySearchable
"Allow my profile to be searched publicly"
]
div [ _class "col-12 col-xl-6" ] [
checkBox [] (nameof m.IsPubliclyLinkable) m.IsPubliclyLinkable
"Show my profile to anyone who has the direct link to it"
]
div [ _class "col-12" ] [
submitButton "content-save-outline" "Save"
if not isNew then
txt "&nbsp; &nbsp; "
a [ _class "btn btn-outline-secondary"; _href $"/profile/{CitizenId.toString citizenId}/view" ] [
i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] []
txt "&nbsp; View Your User Profile"
hr []
h4 [] [ txt "Visibility" ]
div [ _class "form-check" ] [
let pvt = ProfileVisibility.toString Private
input [ _type "radio"; _id $"{nameof m.Visibility}Private"; _name (nameof m.Visibility)
_class "form-check-input"; _value pvt; if m.Visibility = pvt then _checked ]
label [ _class "form-check-label"; _for $"{nameof m.Visibility}Private" ] [
strong [] [ txt "Private" ]
txt " &ndash; only show my employment profile to other authenticated users"
]
]
div [ _class "form-check" ] [
let anon = ProfileVisibility.toString Anonymous
input [ _type "radio"; _id $"{nameof m.Visibility}Anonymous"; _name (nameof m.Visibility)
_class "form-check-input"; _value anon; if m.Visibility = anon then _checked ]
label [ _class "form-check-label"; _for $"{nameof m.Visibility}Anonymous" ] [
strong [] [ txt "Anonymous" ]
txt " &ndash; show my location and skills to public users anonymously"
]
]
div [ _class "form-check" ] [
let pub = ProfileVisibility.toString Public
input [ _type "radio"; _id $"{nameof m.Visibility}Public"; _name (nameof m.Visibility)
_class "form-check-input"; _value pub; if m.Visibility = pub then _checked ]
label [ _class "form-check-label"; _for $"{nameof m.Visibility}Public" ] [
strong [] [ txt "Public" ]; txt " &ndash; show my full profile to public users"
]
]
]
div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ]
]
hr []
p [ _class "text-muted fst-italic" ] [
txt "(If you want to delete your profile, or your entire account, "
a [ _href "/citizen/so-long" ] [ txt "see your deletion options here" ]; txt ".)"
]
jsOnLoad $"jjj.profile.nextIndex = {m.Skills.Length}"
]
/// Render the skill edit template and existing skills
let skillForm (m : SkillForm) isNew =
[ h4 [] [ txt $"""{if isNew then "Add a" else "Edit"} Skill""" ]
div [ _class "col-12 col-md-6" ] [
div [ _class "form-floating" ] [
textBox [ _type "text"; _maxlength "200"; _autofocus ] (nameof m.Description) m.Description "Skill" true
]
div [ _class "form-text" ] [ txt "A skill (language, design technique, process, etc.)" ]
]
div [ _class "col-12 col-md-6" ] [
div [ _class "form-floating" ] [
textBox [ _type "text"; _maxlength "1000" ] (nameof m.Notes) m.Notes "Notes" false
]
div [ _class "form-text" ] [ txt "A further description of the skill" ]
]
div [ _class "col-12" ] [
submitButton "content-save-outline" "Save"; txt " &nbsp; &nbsp; "
a [ _href "/profile/edit/skills/list"; _hxGet "/profile/edit/skills/list"; _hxTarget "#skillList"
_class "btn btn-secondary" ] [ i [ _class "mdi mdi-cancel"] []; txt "&nbsp; Cancel" ]
]
]
/// List the skills for an employment profile
let skillTable (skills : Skill list) editIdx csrf =
let editingIdx = defaultArg editIdx -2
let isEditing = editingIdx >= -1
let renderTable () =
let editSkillForm skill idx =
tr [] [
td [ _colspan "3" ] [
form [ _class "row g-3"; _hxPost $"/profile/edit/skill/{idx}"; _hxTarget "#skillList" ] [
antiForgery csrf
yield! skillForm (SkillForm.fromSkill skill) (idx = -1)
]
]
]
table [ _class "table table-sm table-hover pt-3" ] [
thead [] [
[ "Action"; "Skill"; "Notes" ]
|> List.map (fun it -> th [ _scope "col" ] [ txt it ])
|> tr []
]
tbody [] [
if isEditing && editingIdx = -1 then editSkillForm { Skill.Description = ""; Notes = None } -1
yield! skills |> List.mapi (fun idx skill ->
if isEditing && editingIdx = idx then editSkillForm skill idx
else
tr [] [
td [ if isEditing then _class "text-muted" ] [
if isEditing then txt "Edit ~ Delete"
else
let link = $"/profile/edit/skill/{idx}"
a [ _href link; _hxGet link ] [ txt "Edit" ]; txt " ~ "
a [ _href $"{link}/delete"; _hxPost $"{link}/delete"; _class "text-danger"
_hxConfirm "Are you sure you want to delete this skill?" ] [ txt "Delete" ]
]
td [] [ str skill.Description ]
td [ if Option.isNone skill.Notes then _class "text-muted fst-italic" ] [
str (defaultArg skill.Notes "None")
]
])
]
]
if List.isEmpty skills && not isEditing then
p [ _id "skillList"; _class "text-muted fst-italic pt-3" ] [ txt "Your profile has no skills defined" ]
else if List.isEmpty skills then
form [ _id "skillList"; _hxTarget "this"; _hxPost "/profile/edit/skill/-1"; _hxSwap HxSwap.OuterHtml
_class "row g-3" ] [
antiForgery csrf
yield! skillForm { Description = ""; Notes = "" } true
]
else if isEditing then div [ _id "skillList" ] [ renderTable () ]
else // not editing, there are skills to show
form [ _id "skillList"; _hxTarget "this"; _hxSwap HxSwap.OuterHtml ] [
antiForgery csrf
renderTable ()
]
/// The profile skills maintenance page
let skills (skills : Skill list) csrf =
pageWithTitle "Employment Profile: Skills" [
backToEdit
p [] [
a [ _href "/profile/edit/skill/-1"; _hxGet "/profile/edit/skill/-1"; _hxTarget "#skillList"
_hxSwap HxSwap.OuterHtml; _class "btn btn-sm btn-outline-primary rounded-pill" ] [ txt "Add a Skill" ]
]
skillTable skills None csrf
]
/// The skill edit component
let editSkill (skills : Skill list) idx csrf =
skillTable skills (Some idx) csrf
// ~~~ PROFILE SEARCH ~~~ //
/// The public search page
let publicSearch (m : PublicSearchForm) continents (results : PublicSearchResult list option) =
pageWithTitle "People Seeking Work" [