From 2aef50400d7535d5a47aeca57be9f88ee140d21a Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 29 Jan 2023 21:40:22 -0500 Subject: [PATCH] Unify public/private profile search (#39) --- src/JobsJobsJobs/Common/Data.fs | 2 +- src/JobsJobsJobs/Common/Handlers.fs | 2 +- src/JobsJobsJobs/Common/Views.fs | 6 +- src/JobsJobsJobs/Profiles/Data.fs | 79 +++------ src/JobsJobsJobs/Profiles/Handlers.fs | 24 +-- src/JobsJobsJobs/Profiles/Views.fs | 244 +++++++++++--------------- 6 files changed, 136 insertions(+), 221 deletions(-) diff --git a/src/JobsJobsJobs/Common/Data.fs b/src/JobsJobsJobs/Common/Data.fs index 476fcc3..b67b1e6 100644 --- a/src/JobsJobsJobs/Common/Data.fs +++ b/src/JobsJobsJobs/Common/Data.fs @@ -45,7 +45,7 @@ module DataConnection = let dataSource () = match theDataSource with | Some ds -> Sql.fromDataSource ds - | None -> invalidOp "Connection.setUp() must be called before accessing the database" + | None -> invalidOp "DataConnection.setUp() must be called before accessing the database" /// Create tables let private createTables () = backgroundTask { diff --git a/src/JobsJobsJobs/Common/Handlers.fs b/src/JobsJobsJobs/Common/Handlers.fs index 1701923..9e5e3dc 100644 --- a/src/JobsJobsJobs/Common/Handlers.fs +++ b/src/JobsJobsJobs/Common/Handlers.fs @@ -195,7 +195,7 @@ let isLocal = Regex """^/[^\/\\].*""" let redirectToGet (url : string) next ctx = task { do! saveSession ctx let action = - if Option.isSome (noneIfEmpty url) && isLocal.IsMatch url then + if Option.isSome (noneIfEmpty url) && (url = "/" || isLocal.IsMatch url) then if isHtmx ctx then withHxRedirect url else redirectTo false url else RequestErrors.BAD_REQUEST "Invalid redirect URL" return! action next ctx diff --git a/src/JobsJobsJobs/Common/Views.fs b/src/JobsJobsJobs/Common/Views.fs index 693e9ab..670d1aa 100644 --- a/src/JobsJobsJobs/Common/Views.fs +++ b/src/JobsJobsJobs/Common/Views.fs @@ -251,9 +251,9 @@ module Layout = div [ _class "separator" ] [] navLink "/citizen/log-off" "logout-variant" "Log Off" else - navLink "/" "home" "Home" - navLink "/profile/seeking" "view-list-outline" "Job Seekers" - navLink "/citizen/log-on" "login-variant" "Log On" + navLink "/" "home" "Home" + navLink "/profile/search" "view-list-outline" "Employment Profiles" + navLink "/citizen/log-on" "login-variant" "Log On" navLink "/how-it-works" "help-circle-outline" "How It Works" ] diff --git a/src/JobsJobsJobs/Profiles/Data.fs b/src/JobsJobsJobs/Profiles/Data.fs index 1ad9fd6..ebbc158 100644 --- a/src/JobsJobsJobs/Profiles/Data.fs +++ b/src/JobsJobsJobs/Profiles/Data.fs @@ -29,6 +29,13 @@ let findById citizenId = backgroundTask { | None -> return None } +/// Convert a data row to a profile for viewing +let private toProfileForView row = + { Profile = toDocument row + Citizen = toDocumentFrom "cit_data" row + Continent = toDocumentFrom "cont_data" row + } + /// Find a profile by citizen ID for viewing (includes citizen and continent information) let findByIdForView citizenId = backgroundTask { let! tryCitizen = @@ -41,11 +48,7 @@ let findByIdForView citizenId = backgroundTask { WHERE p.id = @id AND p.data ->> 'isLegacy' = 'false'" |> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ] - |> Sql.executeAsync (fun row -> - { Profile = toDocument row - Citizen = toDocumentFrom "cit_data" row - Continent = toDocumentFrom "cont_data" row - }) + |> Sql.executeAsync toProfileForView return List.tryHead tryCitizen } @@ -53,8 +56,8 @@ let findByIdForView citizenId = backgroundTask { let save (profile : Profile) = dataSource () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile -/// Search profiles (logged-on users) -let search (search : ProfileSearchForm) = backgroundTask { +/// Search profiles +let search (search : ProfileSearchForm) isPublic = backgroundTask { let searches = [ if search.ContinentId <> "" then "p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ] @@ -74,61 +77,21 @@ let search (search : ProfileSearchForm) = backgroundTask { OR x ->> 'description' ILIKE @text)", [ "@text", like search.Text ] ] + let vizSql = + if isPublic then + sprintf "IN ('%s', '%s')" (ProfileVisibility.toString Public) (ProfileVisibility.toString Anonymous) + else sprintf "<> '%s'" (ProfileVisibility.toString Hidden) let! results = dataSource () |> Sql.query $" - SELECT p.*, c.data AS cit_data + SELECT p.*, c.data AS cit_data, o.data AS cont_data FROM {Table.Profile} p - INNER JOIN {Table.Citizen} c ON c.id = p.id - WHERE p.data ->> 'isLegacy' = 'false' - AND p.data ->> 'visibility' <> '{ProfileVisibility.toString Hidden}' + INNER JOIN {Table.Citizen} c ON c.id = p.id + INNER JOIN {Table.Continent} o ON o.id = p.data ->> 'continentId' + WHERE p.data ->> 'isLegacy' = 'false' + AND p.data ->> 'visibility' {vizSql} {searchSql searches}" |> Sql.parameters (searches |> List.collect snd) - |> Sql.executeAsync (fun row -> - let profile = toDocument row - let citizen = toDocumentFrom "cit_data" row - { CitizenId = profile.Id - DisplayName = Citizen.name citizen - SeekingEmployment = profile.IsSeekingEmployment - RemoteWork = profile.IsRemote - FullTime = profile.IsFullTime - LastUpdatedOn = profile.LastUpdatedOn - }) - return results |> List.sortBy (fun psr -> psr.DisplayName.ToLowerInvariant ()) + |> Sql.executeAsync toProfileForView + return results |> List.sortBy (fun pfv -> (Citizen.name pfv.Citizen).ToLowerInvariant ()) } - -// Search profiles (public) -let publicSearch (search : PublicSearchForm) = - let searches = [ - if search.ContinentId <> "" then - "p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ] - if search.Region <> "" then - "p.data ->> 'region' ILIKE @region", [ "@region", like search.Region ] - if search.RemoteWork <> "" then - "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 ] - ] - dataSource () - |> Sql.query $" - SELECT p.*, c.data AS cont_data - FROM {Table.Profile} p - INNER JOIN {Table.Continent} c ON c.id = p.data ->> 'continentId' - WHERE p.data ->> 'isPubliclySearchable' = 'true' - AND p.data ->> 'isLegacy' = 'false' - {searchSql searches}" - |> Sql.parameters (searches |> List.collect snd) - |> Sql.executeAsync (fun row -> - let profile = toDocument row - let continent = toDocumentFrom "cont_data" row - { Continent = continent.Name - Region = profile.Region - RemoteWork = profile.IsRemote - Skills = profile.Skills - |> List.map (fun s -> - let notes = match s.Notes with Some n -> $" ({n})" | None -> "" - $"{s.Description}{notes}") - }) diff --git a/src/JobsJobsJobs/Profiles/Handlers.fs b/src/JobsJobsJobs/Profiles/Handlers.fs index 10f69ba..3c51d67 100644 --- a/src/JobsJobsJobs/Profiles/Handlers.fs +++ b/src/JobsJobsJobs/Profiles/Handlers.fs @@ -71,35 +71,20 @@ let saveGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task { } // GET: /profile/search -let search : HttpHandler = requireUser >=> fun next ctx -> task { +let search : HttpHandler = fun next ctx -> task { let! continents = Common.Data.Continents.all () let form = match ctx.TryBindQueryString () with | Ok f -> f | Error _ -> { ContinentId = ""; RemoteWork = ""; Text = "" } + let isPublic = tryUser ctx |> Option.isNone let! results = task { if string ctx.Request.Query["searched"] = "true" then - let! it = Data.search form + let! it = Data.search form isPublic return Some it else return None } - return! Views.search form continents (timeZone ctx) results |> render "Profile Search" next ctx -} - -// GET: /profile/seeking -let seeking : HttpHandler = fun next ctx -> task { - let! continents = Common.Data.Continents.all () - let form = - match ctx.TryBindQueryString () with - | Ok f -> f - | Error _ -> { ContinentId = ""; Region = ""; RemoteWork = ""; Skill = "" } - let! results = task { - if string ctx.Request.Query["searched"] = "true" then - let! it = Data.publicSearch form - return Some it - else return None - } - return! Views.publicSearch form continents results |> render "Profile Search" next ctx + return! Views.search form continents (timeZone ctx) results isPublic |> render "Profile Search" next ctx } // GET: /profile/edit/skills @@ -262,7 +247,6 @@ let endpoints = route "/edit/skills" skills route "/edit/skills/list" skillList route "/search" search - route "/seeking" seeking ] POST [ route "/delete" delete diff --git a/src/JobsJobsJobs/Profiles/Views.fs b/src/JobsJobsJobs/Profiles/Views.fs index f39f980..d8bb6ea 100644 --- a/src/JobsJobsJobs/Profiles/Views.fs +++ b/src/JobsJobsJobs/Profiles/Views.fs @@ -353,163 +353,131 @@ let editHistory (history : EmploymentHistory list) idx csrf = // ~~~ PROFILE SEARCH ~~~ // -/// The public search page -let publicSearch (m : PublicSearchForm) continents (results : PublicSearchResult list option) = - pageWithTitle "People Seeking Work" [ - if Option.isNone results then - p [] [ - txt "Enter one or more criteria to filter results, or just click “Search” to list all " - txt "publicly searchable profiles." - ] - collapsePanel "Search Criteria" [ - form [ _class "container"; _method "GET"; _action "/profile/seeking" ] [ - 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 +/// The search form +let private searchForm (m : ProfileSearchForm) continents = + 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 mb-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 mb-3" ] [ + label [ _class "jjj-label" ] [ txt "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" ] [ txt "No Selection" ] ] - div [ _class "col-12 col-sm-6 col-md-4 col-lg-3" ] [ - textBox [ _maxlength "1000" ] (nameof m.Region) m.Region "Region" false - div [ _class "form-text" ] [ txt "(free-form text)" ] + 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" ] [ txt "Yes" ] ] - div [ _class "col-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0" ] [ - label [ _class "jjj-label" ] [ txt "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" ] [ txt "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" ] [ txt "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" ] [ txt "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" ] [ txt "(free-form text)" ] + 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" ] [ txt "No" ] ] ] - div [ _class "row" ] [ - div [ _class "col" ] [ - br [] - button [ _type "submit"; _class "btn btn-outline-primary" ] [ txt "Search" ] + div [ _class "col-12 col-sm-12 col-lg-6 mb-3" ] [ + textBox [ _maxlength "1000" ] (nameof m.Text) m.Text "Text Search" false + div [ _class "form-text" ] [ + txt "searches Region, Professional Biography, Skills, Employment History, and Experience" ] ] ] + div [ _class "row" ] [ + div [ _class "col" ] [ + br [] + button [ _type "submit"; _class "btn btn-outline-primary" ] [ txt "Search" ] + ] + ] ] - match results with - | Some r when List.isEmpty r -> p [ _class "pt-3" ] [ txt "No results found for the specified criteria" ] - | Some r -> - p [ _class "py-3" ] [ - txt "These profiles match your search criteria. To learn more about these people, join the merry band " - txt "of human resources in the " - a [ _href "https://noagendashow.net"; _target "_blank"; _rel "noopener" ] [ txt "No Agenda" ] - txt " tribe!" - ] - table [ _class "table table-sm table-hover" ] [ - thead [] [ - tr [] [ - th [ _scope "col" ] [ txt "Continent" ] - th [ _scope "col"; _class "text-center" ] [ txt "Region" ] - th [ _scope "col"; _class "text-center" ] [ txt "Remote?" ] - th [ _scope "col"; _class "text-center" ] [ txt "Skills" ] - ] - ] - r |> List.map (fun profile -> - tr [] [ - td [] [ str profile.Continent ] - td [] [ str profile.Region ] - td [ _class "text-center" ] [ txt (yesOrNo profile.RemoteWork) ] - profile.Skills - |> List.collect (fun skill -> [ str skill; br [] ]) - |> td [] - ]) - |> tbody [] - ] - | None -> () ] +/// Display search results for public users +let private publicResults (results : ProfileForView list) = + [ p [ _class "py-3" ] [ + txt "These profiles match your search criteria. To learn more about these people, join the merry band " + txt "of human resources in the " + a [ _href "https://noagendashow.net"; _target "_blank"; _rel "noopener" ] [ txt "No Agenda" ] + txt " tribe!" + ] + table [ _class "table table-sm table-hover" ] [ + thead [] [ + tr [] [ + th [ _scope "col" ] [ txt "Profile" ] + th [ _scope "col" ] [ txt "Continent / Region" ] + th [ _scope "col"; _class "text-center" ] [ txt "Remote?" ] + th [ _scope "col"; _class "text-center" ] [ txt "Skills" ] + ] + ] + results + |> List.map (fun it -> + tr [] [ + td [] [ + match it.Profile.Visibility with + | Public -> a [ _href $"/profile/{CitizenId.toString it.Profile.Id}/view" ] [ txt "View" ] + | _ -> txt " " + ] + td [] [ txt $"{it.Continent.Name} / "; str it.Profile.Region ] + td [ _class "text-center" ] [ txt (yesOrNo it.Profile.IsRemote) ] + match it.Profile.Visibility with + | Public -> td [ _class "text-muted fst-italic" ] [ txt "See Profile" ] + | _ when List.isEmpty it.Profile.Skills -> + td [ _class "text-muted fst-italic" ] [ txt "None Listed" ] + | _ -> + it.Profile.Skills + |> List.collect (fun skill -> + let notes = match skill.Notes with Some n -> $" ({n})" | None -> "" + [ str $"{skill.Description}{notes}"; br [] ]) + |> td [] + ]) + |> tbody [] + ] + ] + +/// Display search results for logged-on users +let private privateResults (results : ProfileForView list) tz = + // 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" ] [ txt "Profile" ] + th [ _scope "col" ] [ txt "Name" ] + th [ _scope "col"; _class $"{isWide} text-center" ] [ txt "Seeking?" ] + th [ _scope "col"; _class "text-center" ] [ txt "Remote?" ] + th [ _scope "col"; _class $"{isWide} text-center" ] [ txt "Full-Time?" ] + th [ _scope "col"; _class isWide ] [ txt "Last Updated" ] + ] + ] + results + |> List.map (fun it -> + tr [] [ + td [] [ a [ _href $"/profile/{CitizenId.toString it.Profile.Id}/view" ] [ txt "View" ] ] + td [ if it.Profile.IsSeekingEmployment then _class "fw-bold" ] [ str (Citizen.name it.Citizen) ] + td [ _class $"{isWide} text-center" ] [ txt (yesOrNo it.Profile.IsSeekingEmployment) ] + td [ _class "text-center" ] [ txt (yesOrNo it.Profile.IsRemote) ] + td [ _class $"{isWide} text-center" ] [ txt (yesOrNo it.Profile.IsFullTime) ] + td [ _class isWide ] [ str (fullDate it.Profile.LastUpdatedOn tz) ] + ]) + |> tbody [] + ] /// Logged-on search page -let search (m : ProfileSearchForm) continents tz (results : ProfileSearchResult list option) = +let search m continents tz (results : ProfileForView list option) isPublic = [ if Option.isNone results then p [] [ txt "Enter one or more criteria to filter results, or just click “Search” to list all " + if isPublic then txt "publicly searchable or viewable " txt "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 mb-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 mb-3" ] [ - label [ _class "jjj-label" ] [ txt "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" ] [ txt "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" ] [ txt "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" ] [ txt "No" ] - ] - ] - div [ _class "col-12 col-sm-12 col-lg-6 mb-3" ] [ - textBox [ _maxlength "1000" ] (nameof m.Text) m.Text "Text Search" false - div [ _class "form-text" ] [ - txt "searches Region, Professional Biography, Skills, Employment History, and Experience" - ] - ] - ] - div [ _class "row" ] [ - div [ _class "col" ] [ - br [] - button [ _type "submit"; _class "btn btn-outline-primary" ] [ txt "Search" ] - ] - ] - ] - ] + searchForm m continents match results with | Some r when List.isEmpty r -> p [ _class "pt-3" ] [ txt "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" ] [ txt "Profile" ] - th [ _scope "col" ] [ txt "Name" ] - th [ _scope "col"; _class $"{isWide} text-center" ] [ txt "Seeking?" ] - th [ _scope "col"; _class "text-center" ] [ txt "Remote?" ] - th [ _scope "col"; _class $"{isWide} text-center" ] [ txt "Full-Time?" ] - th [ _scope "col"; _class isWide ] [ txt "Last Updated" ] - ] - ] - r |> List.map (fun profile -> - tr [] [ - td [] [ a [ _href $"/profile/{CitizenId.toString profile.CitizenId}/view" ] [ txt "View" ] ] - td [ if profile.SeekingEmployment then _class "fw-bold" ] [ str profile.DisplayName ] - td [ _class $"{isWide} text-center" ] [ txt (yesOrNo profile.SeekingEmployment) ] - td [ _class "text-center" ] [ txt (yesOrNo profile.RemoteWork) ] - td [ _class $"{isWide} text-center" ] [ txt (yesOrNo profile.FullTime) ] - td [ _class isWide ] [ str (fullDate profile.LastUpdatedOn tz) ] - ]) - |> tbody [] - ] + | Some r -> if isPublic then yield! publicResults r else privateResults r tz | None -> () ] |> pageWithTitle "Search Profiles"