Add profile visibility, skill edit (#39)

- Add hover style for labels
This commit is contained in:
Daniel J. Summers 2023-01-20 20:44:10 -05:00
parent 93da2831cb
commit 2e0bfa5524
9 changed files with 381 additions and 228 deletions

View File

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

View File

@ -25,6 +25,34 @@ module CitizenId =
let value = function CitizenId guid -> guid 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 /// The ID of a continent
type ContinentId = ContinentId of Guid type ContinentId = ContinentId of Guid
@ -112,34 +140,6 @@ module ListingId =
let value = function ListingId guid -> guid 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 /// Another way to contact a citizen from this site
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type OtherContact = 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 /// A skill the job seeker possesses
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type Skill = type Skill =
@ -370,25 +398,19 @@ type Profile =
{ /// The ID of the citizen to whom this profile belongs { /// The ID of the citizen to whom this profile belongs
Id : CitizenId 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 /// The ID of the continent on which the citizen resides
ContinentId : ContinentId ContinentId : ContinentId
/// The region in which the citizen resides /// The region in which the citizen resides
Region : string 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 IsRemote : bool
/// Whether the citizen is looking for full-time work /// Whether the citizen is interested in full-time work
IsFullTime : bool IsFullTime : bool
/// The citizen's professional biography /// The citizen's professional biography
@ -403,6 +425,9 @@ type Profile =
/// The citizen's experience (topical / chronological) /// The citizen's experience (topical / chronological)
Experience : MarkdownString option Experience : MarkdownString option
/// The visibility of this profile
Visibility : ProfileVisibility
/// When the citizen last updated their profile /// When the citizen last updated their profile
LastUpdatedOn : Instant LastUpdatedOn : Instant
@ -415,20 +440,19 @@ module Profile =
// An empty profile // An empty profile
let empty = { let empty = {
Id = CitizenId Guid.Empty Id = CitizenId Guid.Empty
IsSeekingEmployment = false ContinentId = ContinentId Guid.Empty
IsPubliclySearchable = false Region = ""
IsPubliclyLinkable = false IsSeekingEmployment = false
ContinentId = ContinentId Guid.Empty IsRemote = false
Region = "" IsFullTime = false
IsRemote = false Biography = Text ""
IsFullTime = false Skills = []
Biography = Text "" History = []
Skills = [] Experience = None
History = [] Visibility = Private
Experience = None LastUpdatedOn = Instant.MinValue
LastUpdatedOn = Instant.MinValue IsLegacy = false
IsLegacy = false
} }

View File

@ -20,12 +20,12 @@ module Error =
open System.Net open System.Net
/// Handler that will return a status code 404 and the text "Not Found" /// 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 fac = ctx.GetService<ILoggerFactory> ()
let log = fac.CreateLogger "Handler" let log = fac.CreateLogger "Handler"
let path = string ctx.Request.Path let path = string ctx.Request.Path
log.LogInformation "Returning 404" 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 /// 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.ofObj
|> Option.map (fun x -> x.Value) |> 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 /// Get the ID of the currently logged in citizen
// NOTE: if no one is logged in, this will raise an exception // NOTE: if no one is logged in, this will raise an exception
let currentCitizenId ctx = (tryUser >> Option.get >> CitizenId.ofString) ctx let currentCitizenId ctx = (tryUser >> Option.get >> CitizenId.ofString) ctx
/// Return an empty OK response
let ok : HttpHandler = Successful.OK ""
// -- NEW --
let antiForgerySvc (ctx : HttpContext) = let antiForgerySvc (ctx : HttpContext) =
ctx.RequestServices.GetRequiredService<IAntiforgery> () ctx.RequestServices.GetRequiredService<IAntiforgery> ()
@ -168,6 +159,16 @@ let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task {
return! ctx.WriteHtmlViewAsync (renderFunc renderCtx) 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 /// Render as a composable HttpHandler
let renderHandler pageTitle content : HttpHandler = fun next ctx -> let renderHandler pageTitle content : HttpHandler = fun next ctx ->
render pageTitle next ctx content render pageTitle next ctx content
@ -194,3 +195,7 @@ let redirectToGet (url : string) next ctx = task {
else RequestErrors.BAD_REQUEST "Invalid redirect URL" else RequestErrors.BAD_REQUEST "Invalid redirect URL"
return! action next ctx 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 /// 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 (SuccessId.ofString, SuccessId.toString) WrappedJsonConverter (ProfileVisibility.parse, ProfileVisibility.toString)
JsonFSharpConverter () WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
JsonFSharpConverter ()
] ]
|> List.iter opts.Converters.Add |> List.iter opts.Converters.Add
let _ = opts.ConfigureForNodaTime DateTimeZoneProviders.Tzdb 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 -> |> List.map (fun p ->
let experience = p["experience"].Value<string> () let experience = p["experience"].Value<string> ()
{ Profile.empty with { Profile.empty with
Id = CitizenId.ofString (p["id"].Value<string> ()) Id = CitizenId.ofString (p["id"].Value<string> ())
IsSeekingEmployment = p["seekingEmployment"].Value<bool> () ContinentId = ContinentId.ofString (p["continentId"].Value<string> ())
IsPubliclySearchable = p["isPublic"].Value<bool> () Region = p["region"].Value<string> ()
ContinentId = ContinentId.ofString (p["continentId"].Value<string> ()) IsSeekingEmployment = p["seekingEmployment"].Value<bool> ()
Region = p["region"].Value<string> () IsRemote = p["remoteWork"].Value<bool> ()
IsRemote = p["remoteWork"].Value<bool> () IsFullTime = p["fullTime"].Value<bool> ()
IsFullTime = p["fullTime"].Value<bool> () Biography = Text (p["biography"].Value<string> ())
Biography = Text (p["biography"].Value<string> ()) Experience = if isNull experience then None else Some (Text experience)
LastUpdatedOn = getInstant p "lastUpdatedOn" Skills = p["skills"].Children()
Experience = if isNull experience then None else Some (Text experience) |> Seq.map (fun s ->
Skills = p["skills"].Children() let notes = s["notes"].Value<string> ()
|> Seq.map (fun s -> { Description = s["description"].Value<string> ()
let notes = s["notes"].Value<string> () Notes = if isNull notes then None else Some notes
{ Description = s["description"].Value<string> () })
Notes = if isNull notes then None else Some notes
})
|> List.ofSeq |> 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 for profile in newProfiles do
do! Profiles.Data.save profile do! Profiles.Data.save profile

View File

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

View File

@ -23,25 +23,21 @@ let edit : HttpHandler = requireUser >=> fun next ctx -> task {
// GET: /profile/edit/general // GET: /profile/edit/general
let editGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task { let editGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx let! profile = Data.findById (currentCitizenId ctx)
let! profile = Data.findById citizenId
let! continents = Common.Data.Continents.all () let! continents = Common.Data.Continents.all ()
let isNew = Option.isNone profile let form = if Option.isNone profile then EditProfileForm.empty else EditProfileForm.fromProfile profile.Value
let form = if isNew then EditProfileForm.empty else EditProfileForm.fromProfile profile.Value return!
let title = $"""{if isNew then "Create" else "Edit"} Profile""" Views.editGeneralInfo form continents (csrf ctx) |> render "General Information | Employment Profile" next ctx
return! Views.editGeneralInfo form continents isNew citizenId (csrf ctx) |> render title next ctx
} }
// POST: /profile/save // POST: /profile/save
let save : HttpHandler = requireUser >=> fun next ctx -> task { let saveGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx let citizenId = currentCitizenId ctx
let! theForm = ctx.BindFormAsync<EditProfileForm> () let! form = ctx.BindFormAsync<EditProfileForm> ()
let form = { theForm with Skills = theForm.Skills |> Array.filter (box >> isNull >> not) }
let errors = [ let errors = [
if form.ContinentId = "" then "Continent is required" if form.ContinentId = "" then "Continent is required"
if form.Region = "" then "Region is required" if form.Region = "" then "Region is required"
if form.Biography = "" then "Professional Biography 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 { let! profile = task {
match! Data.findById citizenId with match! Data.findById citizenId with
@ -52,20 +48,15 @@ let save : HttpHandler = requireUser >=> fun next ctx -> task {
if List.isEmpty errors then if List.isEmpty errors then
do! Data.save do! Data.save
{ profile with { profile with
IsSeekingEmployment = form.IsSeekingEmployment ContinentId = ContinentId.ofString form.ContinentId
ContinentId = ContinentId.ofString form.ContinentId Region = form.Region
Region = form.Region IsSeekingEmployment = form.IsSeekingEmployment
IsRemote = form.RemoteWork IsRemote = form.RemoteWork
IsFullTime = form.FullTime IsFullTime = form.FullTime
Biography = Text form.Biography Biography = Text form.Biography
LastUpdatedOn = now ctx LastUpdatedOn = now ctx
Skills = form.Skills Experience = noneIfBlank form.Experience |> Option.map Text
|> Array.filter (fun s -> (box >> isNull >> not) s) Visibility = ProfileVisibility.parse form.Visibility
|> Array.map SkillForm.toSkill
|> List.ofArray
Experience = noneIfBlank form.Experience |> Option.map Text
IsPubliclySearchable = form.IsPubliclySearchable
IsPubliclyLinkable = form.IsPubliclyLinkable
} }
let action = if isNew then "cre" else "upd" let action = if isNew then "cre" else "upd"
do! addSuccess $"Employment Profile {action}ated successfully" ctx do! addSuccess $"Employment Profile {action}ated successfully" ctx
@ -74,8 +65,8 @@ let save : HttpHandler = requireUser >=> fun next ctx -> task {
do! addErrors errors ctx do! addErrors errors ctx
let! continents = Common.Data.Continents.all () let! continents = Common.Data.Continents.all ()
return! return!
Views.editGeneralInfo form continents isNew citizenId (csrf ctx) Views.editGeneralInfo form continents (csrf ctx)
|> render $"""{if isNew then "Create" else "Edit"} Profile""" next ctx |> render "General Information | Employment Profile" next ctx
} }
// GET: /profile/search // 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 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 // GET: /profile/[id]/view
let view citizenId : HttpHandler = fun next ctx -> task { let view citizenId : HttpHandler = fun next ctx -> task {
let citId = CitizenId citizenId let citId = CitizenId citizenId
match! Data.findByIdForView citId with match! Data.findByIdForView citId with
| Some profile -> | Some profile ->
let currentCitizen = tryUser ctx |> Option.map CitizenId.ofString 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 return! Error.notAuthorized next ctx
else else
let title = $"Employment Profile for {Citizen.name profile.Citizen}" let title = $"Employment Profile for {Citizen.name profile.Citizen}"
return! Views.view profile currentCitizen |> render title next ctx 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 = let endpoints =
subRoute "/profile" [ subRoute "/profile" [
GET_HEAD [ GET_HEAD [
routef "/%O/view" view routef "/%O/view" view
route "/edit" edit route "/edit" edit
route "/edit/general" editGeneralInfo route "/edit/general" editGeneralInfo
route "/search" search routef "/edit/skill/%i" editSkill
route "/seeking" seeking route "/edit/skills" skills
route "/search" search
route "/seeking" seeking
] ]
POST [ POST [
route "/delete" delete route "/delete" delete
route "/save" save routef "/edit/skill/%i" saveSkill
routef "/edit/skill/%i/delete" deleteSkill
route "/save" saveGeneralInfo
] ]
] ]

View File

@ -1,12 +1,15 @@
/// Views for /profile URLs /// Views for /profile URLs
module JobsJobsJobs.Profiles.Views module JobsJobsJobs.Profiles.Views
open Giraffe.Htmx.Common
open Giraffe.ViewEngine open Giraffe.ViewEngine
open Giraffe.ViewEngine.Htmx open Giraffe.ViewEngine.Htmx
open JobsJobsJobs.Common.Views open JobsJobsJobs.Common.Views
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open JobsJobsJobs.Profiles.Domain open JobsJobsJobs.Profiles.Domain
// ~~~ PROFILE EDIT ~~~ //
/// The profile edit menu page /// The profile edit menu page
let edit (profile : Profile) = let edit (profile : Profile) =
let hasProfile = profile.Region <> "" 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" 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 /// A link to allow the user to return to the profile edit menu page
let skillEdit (skills : SkillForm array) = let backToEdit =
let mapToInputs (idx : int) (skill : SkillForm) = p [ _class "mx-3" ] [ a [ _href "/profile/edit" ] [ txt "&laquo; Back to Profile Edit Menu" ] ]
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)
/// The profile edit page /// The profile edit page
let editGeneralInfo (m : EditProfileForm) continents isNew citizenId csrf = let editGeneralInfo (m : EditProfileForm) continents csrf =
pageWithTitle "My Employment Profile" [ pageWithTitle "Employment Profile: General Information" [
backToEdit
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [ form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
antiForgery csrf 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" ] [ div [ _class "col-12" ] [
checkBox [] (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment" checkBox [] (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment"
if m.IsSeekingEmployment then 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!" ] 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" ] [ 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" ] [ 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" ] [ markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
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
div [ _class "col-12" ] [ div [ _class "col-12" ] [
hr [] hr []
h4 [] [ txt "Experience" ] h4 [] [ txt "Experience" ]
p [] [ p [] [
txt "This application does not have a place to individually list your chronological job history; " txt "The information in this box is displayed after the list of skills and chronological job "
txt "however, you can use this area to list prior jobs, their dates, and anything else you want to " txt "history, with no heading; it can be used to highlight your experiences apart from the history "
txt "include that&rsquo;s not already a part of your Professional Biography above." txt "entries, provide closing notes, etc."
] ]
] ]
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience" 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" ] [ div [ _class "col-12" ] [
submitButton "content-save-outline" "Save" hr []
if not isNew then h4 [] [ txt "Visibility" ]
txt "&nbsp; &nbsp; " div [ _class "form-check" ] [
a [ _class "btn btn-outline-secondary"; _href $"/profile/{CitizenId.toString citizenId}/view" ] [ let pvt = ProfileVisibility.toString Private
i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] [] input [ _type "radio"; _id $"{nameof m.Visibility}Private"; _name (nameof m.Visibility)
txt "&nbsp; View Your User Profile" _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 /// The public search page
let publicSearch (m : PublicSearchForm) continents (results : PublicSearchResult list option) = let publicSearch (m : PublicSearchForm) continents (results : PublicSearchResult list option) =
pageWithTitle "People Seeking Work" [ pageWithTitle "People Seeking Work" [