From 6e2a40e4369beaf27b77beabf2271b8ad9bf7d6c Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 14 Jan 2023 22:02:34 -0500 Subject: [PATCH] Migrate profile search Add helper functions for common UI elements --- .../App/src/components/profile/SearchForm.vue | 76 --------- .../App/src/components/profile/SkillEdit.vue | 51 ------ src/JobsJobsJobs/App/src/router/index.ts | 48 ------ .../App/src/views/profile/ProfileSearch.vue | 134 ---------------- src/JobsJobsJobs/Data/Data.fs | 23 ++- src/JobsJobsJobs/Domain/SharedTypes.fs | 59 +------ src/JobsJobsJobs/Server/Handlers.fs | 42 +++-- src/JobsJobsJobs/Server/ViewModels.fs | 57 +++---- src/JobsJobsJobs/Server/Views/Citizen.fs | 75 ++------- src/JobsJobsJobs/Server/Views/Common.fs | 51 +++++- src/JobsJobsJobs/Server/Views/Profile.fs | 146 +++++++++++++----- 11 files changed, 239 insertions(+), 523 deletions(-) delete mode 100644 src/JobsJobsJobs/App/src/components/profile/SearchForm.vue delete mode 100644 src/JobsJobsJobs/App/src/components/profile/SkillEdit.vue delete mode 100644 src/JobsJobsJobs/App/src/views/profile/ProfileSearch.vue diff --git a/src/JobsJobsJobs/App/src/components/profile/SearchForm.vue b/src/JobsJobsJobs/App/src/components/profile/SearchForm.vue deleted file mode 100644 index 58bcd02..0000000 --- a/src/JobsJobsJobs/App/src/components/profile/SearchForm.vue +++ /dev/null @@ -1,76 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/components/profile/SkillEdit.vue b/src/JobsJobsJobs/App/src/components/profile/SkillEdit.vue deleted file mode 100644 index c59d73e..0000000 --- a/src/JobsJobsJobs/App/src/components/profile/SkillEdit.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/router/index.ts b/src/JobsJobsJobs/App/src/router/index.ts index 615970f..49fe403 100644 --- a/src/JobsJobsJobs/App/src/router/index.ts +++ b/src/JobsJobsJobs/App/src/router/index.ts @@ -49,54 +49,12 @@ const routes: Array = [ meta: { auth: false, title: "Terms of Service" } }, // Citizen URLs - { - path: "/citizen/register", - name: "CitizenRegistration", - component: () => import(/* webpackChunkName: "register" */ "../views/citizen/Register.vue"), - meta: { auth: false, title: "Register" } - }, - { - path: "/citizen/registered", - name: "CitizenRegistered", - component: () => import(/* webpackChunkName: "register" */ "../views/citizen/Registered.vue"), - meta: { auth: false, title: "Registration Successful" } - }, - { - path: "/citizen/confirm/:token", - name: "ConfirmRegistration", - component: () => import(/* webpackChunkName: "logon" */ "../views/citizen/ConfirmRegistration.vue"), - meta: { auth: false, title: "Account Confirmation" } - }, - { - path: "/citizen/deny/:token", - name: "DenyRegistration", - component: () => import(/* webpackChunkName: "deny" */ "../views/citizen/DenyRegistration.vue"), - meta: { auth: false, title: "Account Deletion" } - }, - { - path: "/citizen/log-on", - name: "LogOn", - component: () => import(/* webpackChunkName: "logon" */ "../views/citizen/LogOn.vue"), - meta: { auth: false, title: "Log On" } - }, - { - path: "/citizen/dashboard", - name: "Dashboard", - component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue"), - meta: { auth: true, title: "Dashboard" } - }, { path: "/citizen/account", name: "AccountProfile", component: () => import(/* webpackChunkName: "account" */ "../views/citizen/AccountProfile.vue"), meta: { auth: true, title: "Account Profile" } }, - { - path: "/citizen/log-off", - name: "LogOff", - component: () => import(/* webpackChunkName: "logoff" */ "../views/citizen/LogOff.vue"), - meta: { auth: true, title: "Logging Off" } - }, // Job Listing URLs { path: "/help-wanted", @@ -129,12 +87,6 @@ const routes: Array = [ meta: { auth: true, title: "My Job Listings" } }, // Profile URLs - { - path: "/profile/search", - name: "SearchProfiles", - component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileSearch.vue"), - meta: { auth: true, title: "Search Profiles" } - }, { path: "/profile/seeking", name: "PublicSearchProfiles", diff --git a/src/JobsJobsJobs/App/src/views/profile/ProfileSearch.vue b/src/JobsJobsJobs/App/src/views/profile/ProfileSearch.vue deleted file mode 100644 index 06457be..0000000 --- a/src/JobsJobsJobs/App/src/views/profile/ProfileSearch.vue +++ /dev/null @@ -1,134 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/Data/Data.fs b/src/JobsJobsJobs/Data/Data.fs index 47f4ec8..302e954 100644 --- a/src/JobsJobsJobs/Data/Data.fs +++ b/src/JobsJobsJobs/Data/Data.fs @@ -445,21 +445,20 @@ module Profiles = connection () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile /// Search profiles (logged-on users) - let search (search : ProfileSearch) = backgroundTask { + let search (search : ProfileSearchForm) = backgroundTask { let searches = [ - match search.ContinentId with - | Some contId -> "p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string contId ] - | None -> () + if search.ContinentId <> "" then + "p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ] if search.RemoteWork <> "" then - "p.data ->> 'remoteWork' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ] - match search.Skill with - | Some skl -> "p.data -> 'skills' ->> 'description' ILIKE @description", [ "@description", like skl ] - | None -> () - match search.BioExperience with - | Some text -> + "p.data ->> 'isRemote' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ] + if search.Skill <> "" then + "EXISTS ( + SELECT 1 FROM jsonb_array_elements(p.data['skills']) x(elt) + WHERE x ->> 'description' ILIKE @description)", + [ "@description", like search.Skill ] + if search.BioExperience <> "" then "(p.data ->> 'biography' ILIKE @text OR p.data ->> 'experience' ILIKE @text)", - [ "@text", Sql.string text ] - | None -> () + [ "@text", like search.BioExperience ] ] let! results = connection () diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs index b84f0fb..1255e45 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -163,65 +163,17 @@ type AuthOptions () = override this.Value = this -/// The data required to update a profile +/// The various ways profiles can be searched [] -type ProfileForm = - { /// Whether the citizen to whom this profile belongs is actively seeking employment - IsSeekingEmployment : bool - - /// Whether this profile should appear in the public search - IsPublic : bool - - /// The ID of the continent on which the citizen is located +type ProfileSearchForm = + { /// Retrieve citizens from this continent ContinentId : string - /// The area within that continent where the citizen is located - Region : string - - /// If the citizen is available for remote work - RemoteWork : bool - - /// If the citizen is seeking full-time employment - FullTime : bool - - /// The user's professional biography - Biography : string - - /// The user's past experience - Experience : string option - - /// The skills for the user - Skills : Skill list - } - -/// Support functions for the ProfileForm type -module ProfileForm = - - /// Create an instance of this form from the given profile - let fromProfile (profile : Profile) = - { IsSeekingEmployment = profile.IsSeekingEmployment - IsPublic = profile.IsPubliclySearchable - ContinentId = string profile.ContinentId - Region = profile.Region - RemoteWork = profile.IsRemote - FullTime = profile.IsFullTime - Biography = MarkdownString.toString profile.Biography - Experience = profile.Experience |> Option.map MarkdownString.toString - Skills = profile.Skills - } - - -/// The various ways profiles can be searched -[] -type ProfileSearch = - { /// Retrieve citizens from this continent - ContinentId : string option - /// Text for a search within a citizen's skills - Skill : string option + Skill : string /// Text for a search with a citizen's professional biography and experience fields - BioExperience : string option + BioExperience : string /// Whether to retrieve citizens who do or do not want remote work RemoteWork : string @@ -229,6 +181,7 @@ type ProfileSearch = /// A user matching the profile search +[] type ProfileSearchResult = { /// The ID of the citizen CitizenId : CitizenId diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index e329ac6..7d5fb66 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -129,6 +129,11 @@ module Helpers = let csrf ctx = (antiForgery ctx).GetAndStoreTokens ctx + /// Get the time zone from the citizen's browser + let timeZone (ctx : HttpContext) = + let tz = string ctx.Request.Headers["X-Time-Zone"] + defaultArg (noneIfEmpty tz) "Etc/UTC" + /// The key to use to indicate if we have loaded the session let private sessionLoadedKey = "session-loaded" @@ -587,12 +592,13 @@ module Profile = // GET: /profile/edit let edit : HttpHandler = requireUser >=> fun next ctx -> task { - let! profile = Profiles.findById (currentCitizenId ctx) + let citizenId = currentCitizenId ctx + let! profile = Profiles.findById citizenId let! continents = Continents.all () let isNew = Option.isNone profile let form = if isNew then EditProfileViewModel.empty else EditProfileViewModel.fromProfile profile.Value let title = $"""{if isNew then "Create" else "Edit"} Profile""" - return! Profile.edit form continents isNew (csrf ctx) |> render title next ctx + return! Profile.edit form continents isNew citizenId (csrf ctx) |> render title next ctx } // POST: /profile/save @@ -616,18 +622,19 @@ module Profile = do! Profiles.save { profile with IsSeekingEmployment = form.IsSeekingEmployment - IsPubliclySearchable = form.IsPublic ContinentId = ContinentId.ofString form.ContinentId Region = form.Region IsRemote = form.RemoteWork IsFullTime = form.FullTime Biography = Text form.Biography LastUpdatedOn = now ctx - Experience = noneIfBlank form.Experience |> Option.map Text 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 } let action = if isNew then "cre" else "upd" do! addSuccess $"Employment Profile {action}ated successfully" ctx @@ -636,10 +643,26 @@ module Profile = do! addErrors errors ctx let! continents = Continents.all () return! - Profile.edit form continents isNew (csrf ctx) + Profile.edit form continents isNew citizenId (csrf ctx) |> render $"""{if isNew then "Create" else "Edit"} Profile""" next ctx } + // GET: /profile/search + let search : HttpHandler = requireUser >=> fun next ctx -> task { + let! continents = Continents.all () + let form = + match ctx.TryBindQueryString () with + | Ok f -> f + | Error _ -> { ContinentId = ""; RemoteWork = ""; Skill = ""; BioExperience = "" } + let! results = task { + if string ctx.Request.Query["searched"] = "true" then + let! it = Profiles.search form + return Some it + else return None + } + return! Profile.search form continents (timeZone ctx) results |> render "Profile Search" next ctx + } + // GET: /profile/[id]/view let view citizenId : HttpHandler = fun next ctx -> task { let citId = CitizenId.ofString citizenId @@ -710,13 +733,6 @@ module ProfileApi = return! ok next ctx } - // GET: /api/profile/search - let search : HttpHandler = authorize >=> fun next ctx -> task { - let search = ctx.BindQueryString () - let! results = Profiles.search search - return! json results next ctx - } - // GET: /api/profile/public-search let publicSearch : HttpHandler = fun next ctx -> task { let search = ctx.BindQueryString () @@ -798,6 +814,7 @@ let allEndpoints = [ GET_HEAD [ routef "/%s/view" Profile.view route "/edit" Profile.edit + route "/search" Profile.search ] POST [ route "/save" Profile.save ] ] @@ -833,7 +850,6 @@ let allEndpoints = [ routef "/%O" ProfileApi.get routef "/%O/view" ProfileApi.view route "/public-search" ProfileApi.publicSearch - route "/search" ProfileApi.search ] PATCH [ route "/employment-found" ProfileApi.employmentFound ] ] diff --git a/src/JobsJobsJobs/Server/ViewModels.fs b/src/JobsJobsJobs/Server/ViewModels.fs index 9d5f593..6d6c794 100644 --- a/src/JobsJobsJobs/Server/ViewModels.fs +++ b/src/JobsJobsJobs/Server/ViewModels.fs @@ -9,7 +9,7 @@ type SkillForm = { Description : string /// Notes regarding the skill - Notes : string option + Notes : string } /// Functions to support skill forms @@ -17,11 +17,11 @@ module SkillForm = /// Create a skill form from a skill let fromSkill (skill : Skill) = - { SkillForm.Description = skill.Description; Notes = skill.Notes } + { SkillForm.Description = skill.Description; Notes = defaultArg skill.Notes "" } /// Create a skill from a skill form let toSkill (form : SkillForm) = - { Skill.Description = form.Description; Notes = form.Notes } + { Skill.Description = form.Description; Notes = if form.Notes = "" then None else Some form.Notes } /// The data required to update a profile @@ -30,9 +30,6 @@ type EditProfileViewModel = { /// Whether the citizen to whom this profile belongs is actively seeking employment IsSeekingEmployment : bool - /// Whether this profile should appear in the public search - IsPublic : bool - /// The ID of the continent on which the citizen is located ContinentId : string @@ -48,11 +45,17 @@ type EditProfileViewModel = /// The user's professional biography Biography : string + /// The skills for the user + Skills : SkillForm array + /// The user's past experience Experience : string option - /// The skills for the user - Skills : SkillForm array + /// Whether this profile should appear in the public search + IsPubliclySearchable : bool + + /// Whether this profile should be shown publicly + IsPubliclyLinkable : bool } /// Support functions for the ProfileForm type @@ -60,28 +63,30 @@ module EditProfileViewModel = /// An empty view model (used for new profiles) let empty = - { IsSeekingEmployment = false - IsPublic = false - ContinentId = "" - Region = "" - RemoteWork = false - FullTime = false - Biography = "" - Experience = None - Skills = [||] + { IsSeekingEmployment = false + ContinentId = "" + Region = "" + RemoteWork = false + FullTime = false + Biography = "" + Skills = [||] + Experience = None + IsPubliclySearchable = false + IsPubliclyLinkable = false } /// Create an instance of this form from the given profile let fromProfile (profile : Profile) = - { IsSeekingEmployment = profile.IsSeekingEmployment - IsPublic = profile.IsPubliclySearchable - ContinentId = string profile.ContinentId - Region = profile.Region - RemoteWork = profile.IsRemote - FullTime = profile.IsFullTime - Biography = MarkdownString.toString profile.Biography - Experience = profile.Experience |> Option.map MarkdownString.toString - Skills = profile.Skills |> List.map SkillForm.fromSkill |> Array.ofList + { 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 } diff --git a/src/JobsJobsJobs/Server/Views/Citizen.fs b/src/JobsJobsJobs/Server/Views/Citizen.fs index 349617e..ef1a8f7 100644 --- a/src/JobsJobsJobs/Server/Views/Citizen.fs +++ b/src/JobsJobsJobs/Server/Views/Citizen.fs @@ -128,28 +128,10 @@ let logOn (m : LogOnViewModel) csrf = | Some returnTo -> input [ _type "hidden"; _name (nameof m.ReturnTo); _value returnTo ] | None -> () div [ _class "col-12 col-md-6" ] [ - div [ _class "form-floating" ] [ - input [ _type "email" - _class "form-control" - _id (nameof m.Email) - _name (nameof m.Email) - _placeholder "E-mail Address" - _value m.Email - _required - _autofocus ] - label [ _class "jjj-required"; _for (nameof m.Email) ] [ rawText "E-mail Address" ] - ] + textBox [ _type "email"; _autofocus ] (nameof m.Email) m.Email "E-mail Address" true ] div [ _class "col-12 col-md-6" ] [ - div [ _class "form-floating" ] [ - input [ _type "password" - _class "form-control" - _id (nameof m.Password) - _name (nameof m.Password) - _placeholder "Password" - _required ] - label [ _class "jjj-required"; _for (nameof m.Password) ] [ rawText "Password" ] - ] + textBox [ _type "password" ] (nameof m.Password) "" "Password" true ] div [ _class "col-12" ] [ button [ _class "btn btn-primary"; _type "submit" ] [ @@ -172,48 +154,23 @@ let register q1 q2 (m : RegisterViewModel) csrf = form [ _class "row g-3"; _hxPost "/citizen/register" ] [ antiForgery csrf div [ _class "col-6 col-xl-4" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _class "form-control"; _id (nameof m.FirstName); _name (nameof m.FirstName) - _value m.FirstName; _placeholder "First Name"; _required; _autofocus ] - label [ _class "jjj-required"; _for (nameof m.FirstName) ] [ rawText "First Name" ] - ] + textBox [ _type "text"; _autofocus ] (nameof m.FirstName) m.FirstName "First Name" true ] div [ _class "col-6 col-xl-4" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _class "form-control"; _id (nameof m.LastName); _name (nameof m.LastName) - _value m.LastName; _placeholder "Last Name"; _required ] - label [ _class "jjj-required"; _for (nameof m.LastName) ] [ rawText "Last Name" ] - ] + textBox [ _type "text" ] (nameof m.LastName) m.LastName "Last Name" true ] div [ _class "col-6 col-xl-4" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _class "form-control"; _id (nameof m.DisplayName) - _name (nameof m.DisplayName); _value (defaultArg m.DisplayName "") - _placeholder "Display Name" ] - label [ _for (nameof m.DisplayName) ] [ rawText "Display Name" ] - div [ _class "form-text" ] [ em [] [ rawText "Optional; overrides first/last for display" ] ] - ] + textBox [ _type "text" ] (nameof m.DisplayName) (defaultArg m.DisplayName "") "Display Name" false + div [ _class "form-text" ] [ em [] [ rawText "Optional; overrides first/last for display" ] ] ] div [ _class "col-6 col-xl-4" ] [ - div [ _class "form-floating" ] [ - input [ _type "email"; _class "form-control"; _id (nameof m.Email); _name (nameof m.Email) - _value m.Email; _placeholder "E-mail Address"; _required ] - label [ _class "jjj-required"; _for (nameof m.Email) ] [ rawText "E-mail Address" ] - ] + textBox [ _type "text" ] (nameof m.Email) m.Email "E-mail Address" true ] div [ _class "col-6 col-xl-4" ] [ - div [ _class "form-floating" ] [ - input [ _type "password"; _class "form-control"; _id (nameof m.Password); _name (nameof m.Password) - _placeholder "Password"; _minlength "8"; _required ] - label [ _class "jjj-required"; _for (nameof m.Password) ] [ rawText "Password" ] - ] + textBox [ _type "password"; _minlength "8" ] (nameof m.Password) "" "Password" true ] div [ _class "col-6 col-xl-4" ] [ - div [ _class "form-floating" ] [ - input [ _type "password"; _class "form-control"; _id "ConfirmPassword" - _placeholder "Confirm Password"; _minlength "8"; _required ] - label [ _class "jjj-required"; _for "ConfirmPassword" ] [ rawText "Confirm Password" ] - ] + textBox [ _type "password"; _minlength "8" ] "ConfirmPassword" "" "Confirm Password" true ] div [ _class "col-12" ] [ hr [] @@ -222,21 +179,11 @@ let register q1 q2 (m : RegisterViewModel) csrf = ] ] div [ _class "col-12 col-xl-6" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _class "form-control"; _id (nameof m.Question1Answer) - _name (nameof m.Question1Answer); _value m.Question1Answer; _placeholder "Question 1" - _maxlength "30"; _required ] - label [ _class "jjj-required"; _for (nameof m.Question1Answer) ] [ str q1 ] - ] + textBox [ _type "text"; _maxlength "30" ] (nameof m.Question1Answer) m.Question1Answer "Question 1" true input [ _type "hidden"; _name (nameof m.Question1Index); _value (string m.Question1Index ) ] ] div [ _class "col-12 col-xl-6" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _class "form-control"; _id (nameof m.Question2Answer) - _name (nameof m.Question2Answer); _value m.Question2Answer; _placeholder "Question 2" - _maxlength "30"; _required ] - label [ _class "jjj-required"; _for (nameof m.Question2Answer) ] [ str q2 ] - ] + textBox [ _type "text"; _maxlength "30" ] (nameof m.Question2Answer) m.Question2Answer "Question 2" true input [ _type "hidden"; _name (nameof m.Question2Index); _value (string m.Question2Index ) ] ] div [ _class "col-12" ] [ diff --git a/src/JobsJobsJobs/Server/Views/Common.fs b/src/JobsJobsJobs/Server/Views/Common.fs index 5760bb0..c5cd512 100644 --- a/src/JobsJobsJobs/Server/Views/Common.fs +++ b/src/JobsJobsJobs/Server/Views/Common.fs @@ -16,17 +16,35 @@ let audioClip clip text = let antiForgery (csrf : AntiforgeryTokenSet) = input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ] -/// Create a select list of continents -let continentList attrs name (continents : Continent list) emptyLabel selectedValue = +/// Create a floating-label text input box +let textBox attrs name value fieldLabel isRequired = div [ _class "form-floating" ] [ - select (List.append attrs [ _id name; _name name; _class "form-select" ]) ( + List.append attrs [ + _id name; _name name; _class "form-control"; _placeholder fieldLabel; _value value + if isRequired then _required + ] |> input + label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ rawText fieldLabel ] + ] + +/// Create a checkbox that will post "true" if checked +let checkBox name isChecked checkLabel = + div [ _class "form-check" ] [ + input [ _type "checkbox"; _id name; _name name; _class "form-check-input"; _value "true" + if isChecked then _checked ] + label [ _class "form-check-label"; _for name ] [ str checkLabel ] + ] + +/// Create a select list of continents +let continentList attrs name (continents : Continent list) emptyLabel selectedValue isRequired = + div [ _class "form-floating" ] [ + select (List.append attrs [ _id name; _name name; _class "form-select"; if isRequired then _required ]) ( option [ _value ""; if selectedValue = "" then _selected ] [ rawText $"""– {defaultArg emptyLabel "Select"} –""" ] :: (continents |> List.map (fun c -> let theId = ContinentId.toString c.Id option [ _value theId; if theId = selectedValue then _selected ] [ str c.Name ]))) - label [ _class "jjj-required"; _for name ] [ rawText "Continent" ] + label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ rawText "Continent" ] ] /// Create a Markdown editor @@ -57,3 +75,28 @@ let markdownEditor attrs name value editorLabel = rawText "})" ] ] + +/// Wrap content in a collapsing panel +let collapsePanel header content = + div [ _class "card" ] [ + div [ _class "card-body" ] [ + h6 [ _class "card-title" ] [ + // TODO: toggle collapse + //a [ _href "#"; _class "{ 'cp-c': collapsed, 'cp-o': !collapsed }"; @click.prevent="toggle">{{headerText}} ] + rawText header + ] + yield! content + ] + ] + +/// "Yes" or "No" based on a boolean value +let yesOrNo value = + if value then "Yes" else "No" + +open NodaTime +open NodaTime.Text + +/// Generate a full date from an instant in the citizen's local time zone +let fullDate (value : Instant) tz = + (ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy", DateTimeZoneProviders.Tzdb)) + .Format(value.InZone(DateTimeZoneProviders.Tzdb[tz])) diff --git a/src/JobsJobsJobs/Server/Views/Profile.fs b/src/JobsJobsJobs/Server/Views/Profile.fs index 4198f96..88c8709 100644 --- a/src/JobsJobsJobs/Server/Views/Profile.fs +++ b/src/JobsJobsJobs/Server/Views/Profile.fs @@ -4,6 +4,8 @@ module JobsJobsJobs.Views.Profile open Giraffe.ViewEngine open Giraffe.ViewEngine.Htmx +open JobsJobsJobs.Domain +open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.ViewModels /// Render the skill edit template and existing skills @@ -21,7 +23,7 @@ let skillEdit (skills : SkillForm array) = 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-label"; _for $"skillDesc{idx}" ] [ rawText "Skill" ] + label [ _class "jjj-required"; _for $"skillDesc{idx}" ] [ rawText "Skill" ] ] if idx < 1 then div [ _class "form-text" ] [ rawText "A skill (language, design technique, process, etc.)" ] @@ -30,30 +32,24 @@ let skillEdit (skills : SkillForm array) = 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 (defaultArg skill.Notes "") ] + _value skill.Notes ] label [ _class "jjj-label"; _for $"skillNotes{idx}" ] [ rawText "Notes" ] ] if idx < 1 then div [ _class "form-text" ] [ rawText "A further description of the skill" ] ] ] - template [ _id "newSkill" ] [ mapToInputs -1 { Description = ""; Notes = None } ] + template [ _id "newSkill" ] [ mapToInputs -1 { Description = ""; Notes = "" } ] :: (skills |> Array.mapi mapToInputs |> List.ofArray) /// The profile edit page -let edit (m : EditProfileViewModel) continents isNew csrf = +let edit (m : EditProfileViewModel) continents isNew citizenId csrf = article [] [ h3 [ _class "pb-3" ] [ rawText "My Employment Profile" ] form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [ antiForgery csrf div [ _class "col-12" ] [ - div [ _class "form-check" ] [ - input [ _type "checkbox"; _id (nameof m.IsSeekingEmployment); _name (nameof m.IsSeekingEmployment) - _class "form-check-input"; if m.IsSeekingEmployment then _checked ] - label [ _class "form-check-label"; _for (nameof m.IsSeekingEmployment) ] [ - rawText "I am currently seeking employment" - ] - ] + checkBox (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment" if m.IsSeekingEmployment then p [] [ em [] [ @@ -63,34 +59,18 @@ let edit (m : EditProfileViewModel) continents isNew csrf = ] ] div [ _class "col-12 col-sm-6 col-md-4" ] [ - continentList [ _required ] (nameof m.ContinentId) continents None m.ContinentId + continentList [] (nameof m.ContinentId) continents None m.ContinentId true ] div [ _class "col-12 col-sm-6 col-md-8" ] [ - div [ _class "form-floating" ] [ - input [ _type "text"; _id (nameof m.Region); _name (nameof m.Region); _class "form-control" - _maxlength "255"; _placeholder "Country, state, geographic area, etc."; _required ] - label [ _class "jjj-required"; _for (m.Region) ] [ rawText "Region" ] - ] + textBox [ _type "text"; _maxlength "255" ] (nameof m.Region) m.Region "Region" true div [ _class "form-text" ] [ rawText "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 "form-check" ] [ - input [ _type "checkbox"; _id (nameof m.RemoteWork); _name (nameof m.RemoteWork) - _class "form-check-input"; if m.RemoteWork then _checked ] - label [ _class "form-check-label"; _for (nameof m.RemoteWork) ] [ - rawText "I am looking for remote work" - ] - ] + checkBox (nameof m.RemoteWork) m.RemoteWork "I am looking for remote work" ] div [ _class "col-12 col-md-4" ] [ - div [ _class "form-check" ] [ - input [ _type "checkbox"; _id (nameof m.FullTime); _name (nameof m.FullTime) - _class "form-check-input"; if m.FullTime then _checked ] - label [ _class "form-check-label"; _for (nameof m.FullTime) ] [ - rawText "I am looking for full-time work" - ] - ] + checkBox (nameof m.FullTime) m.FullTime "I am looking for full-time work" ] div [ _class "col-12" ] [ hr [] @@ -114,14 +94,13 @@ let edit (m : EditProfileViewModel) continents isNew csrf = ] ] markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience" - div [ _class "col-12" ] [ - div [ _class "form-check" ] [ - input [ _type "checkbox"; _id (nameof m.IsPublic); _name (nameof m.IsPublic) - _class "form-check-input"; if m.IsPublic then _checked ] - label [ _class "form-check-label"; _for (nameof m.IsPublic) ] [ - rawText "Allow my profile to be searched publicly" - ] - ] + 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" ] [ button [ _type "submit"; _class "btn btn-primary" ] [ @@ -129,7 +108,7 @@ let edit (m : EditProfileViewModel) continents isNew csrf = ] if not isNew then rawText "    " - a [ _class "btn btn-outline-secondary"; _href "`/profile/${user.citizenId}/view`" ] [ + a [ _class "btn btn-outline-secondary"; _href $"/profile/{CitizenId.toString citizenId}/view" ] [ i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] [] rawText "  View Your User Profile" ] @@ -147,8 +126,90 @@ let edit (m : EditProfileViewModel) continents isNew csrf = ] ] -open JobsJobsJobs.Domain +/// Logged-on search page +let search (m : ProfileSearchForm) continents tz (results : ProfileSearchResult list option) = + article [] [ + h3 [ _class "pb-3" ] [ rawText "Search Profiles" ] + if Option.isNone results then + p [] [ + rawText "Enter one or more criteria to filter results, or just click “Search” to list all " + rawText "profiles." + ] + collapsePanel "Search Criteria" [ + form [ _class "container"; _method "GET"; _action "/profile/search" ] [ + input [ _type "hidden"; _name "searched"; _value "true" ] + div [ _class "row" ] [ + div [ _class "col-12 col-sm-6 col-md-4 col-lg-3" ] [ + continentList [] "ContinentId" continents (Some "Any") m.ContinentId false + ] + div [ _class "col-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0" ] [ + label [ _class "jjj-label" ] [ rawText "Seeking Remote Work?" ]; br [] + div [ _class "form-check form-check-inline" ] [ + input [ _type "radio"; _id "remoteNull"; _name (nameof m.RemoteWork); _value "" + _class "form-check-input"; if m.RemoteWork = "" then _checked ] + label [ _class "form-check-label"; _for "remoteNull" ] [ rawText "No Selection" ] + ] + div [ _class "form-check form-check-inline" ] [ + input [ _type "radio"; _id "remoteYes"; _name (nameof m.RemoteWork); _value "yes" + _class "form-check-input"; if m.RemoteWork = "yes" then _checked ] + label [ _class "form-check-label"; _for "remoteYes" ] [ rawText "Yes" ] + ] + div [ _class "form-check form-check-inline" ] [ + input [ _type "radio"; _id "remoteNo"; _name (nameof m.RemoteWork); _value "no" + _class "form-check-input"; if m.RemoteWork = "no" then _checked ] + label [ _class "form-check-label"; _for "remoteNo" ] [ rawText "No" ] + ] + ] + div [ _class "col-12 col-sm-6 col-lg-3" ] [ + textBox [ _maxlength "1000" ] (nameof m.Skill) m.Skill "Skill" false + div [ _class "form-text" ] [ rawText "(free-form text)" ] + ] + div [ _class "col-12 col-sm-6 col-lg-3" ] [ + textBox [ _maxlength "1000" ] (nameof m.BioExperience) m.BioExperience "Bio / Experience" false + div [ _class "form-text" ] [ rawText "(free-form text)" ] + ] + ] + div [ _class "row" ] [ + div [ _class "col" ] [ + br [] + button [ _type "submit"; _class "btn btn-outline-primary" ] [ rawText "Search" ] + ] + ] + ] + ] + match results with + | Some r when List.isEmpty r -> p [ _class "pt-3" ] [ rawText "No results found for the specified criteria" ] + | Some r -> + // Bootstrap utility classes to only show at medium or above + let isWide = "d-none d-md-table-cell" + table [ _class "table table-sm table-hover pt-3" ] [ + thead [] [ + tr [] [ + th [ _scope "col" ] [ rawText "Profile" ] + th [ _scope "col" ] [ rawText "Name" ] + th [ _scope "col"; _class $"{isWide} text-center" ] [ rawText "Seeking?" ] + th [ _scope "col"; _class "text-center" ] [ rawText "Remote?" ] + th [ _scope "col"; _class $"{isWide} text-center" ] [ rawText "Full-Time?" ] + th [ _scope "col"; _class isWide ] [ rawText "Last Updated" ] + ] + ] + r |> List.map (fun profile -> + tr [] [ + td [] [ a [ _href $"/profile/{CitizenId.toString profile.CitizenId}/view" ] [ rawText "View" ] ] + td [ if profile.SeekingEmployment then _class "fw-bold" ] [ str profile.DisplayName ] + td [ _class $"{isWide} text-center" ] [ rawText (yesOrNo profile.SeekingEmployment) ] + td [ _class "text-center" ] [ rawText (yesOrNo profile.RemoteWork) ] + td [ _class $"{isWide} text-center" ] [ rawText (yesOrNo profile.FullTime) ] + td [ _class isWide ] [ str (fullDate profile.LastUpdatedOn tz) ] + ]) + |> tbody [] + ] + | None -> () + ] + + +/// Profile view template let view (citizen : Citizen) (profile : Profile) (continentName : string) pageTitle currentId = article [] [ h3 [ _class "pb-3" ] [ str pageTitle ] @@ -171,7 +232,8 @@ let view (citizen : Citizen) (profile : Profile) (continentName : string) pageTi if not (List.isEmpty profile.Skills) then hr [] h4 [ _class "pb-3" ] [ rawText "Skills" ] - profile.Skills |> List.map (fun skill -> + profile.Skills + |> List.map (fun skill -> li [] [ str skill.Description match skill.Notes with