diff --git a/src/JobsJobsJobs/Application/wwwroot/style.css b/src/JobsJobsJobs/Application/wwwroot/style.css index 63ce4f9..4e4c277 100644 --- a/src/JobsJobsJobs/Application/wwwroot/style.css +++ b/src/JobsJobsJobs/Application/wwwroot/style.css @@ -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; diff --git a/src/JobsJobsJobs/Common/Domain.fs b/src/JobsJobsJobs/Common/Domain.fs index 6bfa848..083794d 100644 --- a/src/JobsJobsJobs/Common/Domain.fs +++ b/src/JobsJobsJobs/Common/Domain.fs @@ -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 [] 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 [] 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 } diff --git a/src/JobsJobsJobs/Common/Handlers.fs b/src/JobsJobsJobs/Common/Handlers.fs index 1c7ee5e..d699379 100644 --- a/src/JobsJobsJobs/Common/Handlers.fs +++ b/src/JobsJobsJobs/Common/Handlers.fs @@ -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 () 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 () @@ -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 diff --git a/src/JobsJobsJobs/Common/Json.fs b/src/JobsJobsJobs/Common/Json.fs index b519c13..4593880 100644 --- a/src/JobsJobsJobs/Common/Json.fs +++ b/src/JobsJobsJobs/Common/Json.fs @@ -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 diff --git a/src/JobsJobsJobs/Common/Views.fs b/src/JobsJobsJobs/Common/Views.fs index cf99086..170ffa4 100644 --- a/src/JobsJobsJobs/Common/Views.fs +++ b/src/JobsJobsJobs/Common/Views.fs @@ -352,3 +352,10 @@ module Layout = ] ] ] + + /// Render a bare view (used for components) + let bare ctx = + html [ _lang "en" ] [ + head [] [ title [] [] ] + body [] [ ctx.Content ] + ] diff --git a/src/JobsJobsJobs/JobsJobsJobs.V3Migration/Program.fs b/src/JobsJobsJobs/JobsJobsJobs.V3Migration/Program.fs index ae3148b..5ee97c7 100644 --- a/src/JobsJobsJobs/JobsJobsJobs.V3Migration/Program.fs +++ b/src/JobsJobsJobs/JobsJobsJobs.V3Migration/Program.fs @@ -129,24 +129,24 @@ task { |> List.map (fun p -> let experience = p["experience"].Value () { Profile.empty with - Id = CitizenId.ofString (p["id"].Value ()) - IsSeekingEmployment = p["seekingEmployment"].Value () - IsPubliclySearchable = p["isPublic"].Value () - ContinentId = ContinentId.ofString (p["continentId"].Value ()) - Region = p["region"].Value () - IsRemote = p["remoteWork"].Value () - IsFullTime = p["fullTime"].Value () - Biography = Text (p["biography"].Value ()) - 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 () - { Description = s["description"].Value () - Notes = if isNull notes then None else Some notes - }) + Id = CitizenId.ofString (p["id"].Value ()) + ContinentId = ContinentId.ofString (p["continentId"].Value ()) + Region = p["region"].Value () + IsSeekingEmployment = p["seekingEmployment"].Value () + IsRemote = p["remoteWork"].Value () + IsFullTime = p["fullTime"].Value () + Biography = Text (p["biography"].Value ()) + Experience = if isNull experience then None else Some (Text experience) + Skills = p["skills"].Children() + |> Seq.map (fun s -> + let notes = s["notes"].Value () + { Description = s["description"].Value () + Notes = if isNull notes then None else Some notes + }) |> List.ofSeq - IsLegacy = true + Visibility = if p["isPublic"].Value () then Anonymous else Private + LastUpdatedOn = getInstant p "lastUpdatedOn" + IsLegacy = true }) for profile in newProfiles do do! Profiles.Data.save profile diff --git a/src/JobsJobsJobs/Profiles/Domain.fs b/src/JobsJobsJobs/Profiles/Domain.fs index 988bf27..c817537 100644 --- a/src/JobsJobsJobs/Profiles/Domain.fs +++ b/src/JobsJobsJobs/Profiles/Domain.fs @@ -27,15 +27,15 @@ module SkillForm = /// The data required to update a profile [] 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 } diff --git a/src/JobsJobsJobs/Profiles/Handlers.fs b/src/JobsJobsJobs/Profiles/Handlers.fs index 0518deb..5b305b3 100644 --- a/src/JobsJobsJobs/Profiles/Handlers.fs +++ b/src/JobsJobsJobs/Profiles/Handlers.fs @@ -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 () - let form = { theForm with Skills = theForm.Skills |> Array.filter (box >> isNull >> not) } + let! form = ctx.BindFormAsync () 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 () + 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 ] ] diff --git a/src/JobsJobsJobs/Profiles/Views.fs b/src/JobsJobsJobs/Profiles/Views.fs index 49c91c9..18bf197 100644 --- a/src/JobsJobsJobs/Profiles/Views.fs +++ b/src/JobsJobsJobs/Profiles/Views.fs @@ -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 "  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 " − " ] - ] - 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 "« 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   " - 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’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 "    " - a [ _class "btn btn-outline-secondary"; _href $"/profile/{CitizenId.toString citizenId}/view" ] [ - i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] [] - txt "  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 " – 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 " – 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 " – 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 "     " + a [ _href "/profile/edit/skills/list"; _hxGet "/profile/edit/skills/list"; _hxTarget "#skillList" + _class "btn btn-secondary" ] [ i [ _class "mdi mdi-cancel"] []; txt "  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" [