Version 3 #40
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -252,7 +252,7 @@ module Layout =
|
|||
navLink "/citizen/log-off" "logout-variant" "Log Off"
|
||||
else
|
||||
navLink "/" "home" "Home"
|
||||
navLink "/profile/seeking" "view-list-outline" "Job Seekers"
|
||||
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"
|
||||
]
|
||||
|
|
|
@ -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<Profile> row
|
||||
Citizen = toDocumentFrom<Citizen> "cit_data" row
|
||||
Continent = toDocumentFrom<Continent> "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<Profile> row
|
||||
Citizen = toDocumentFrom<Citizen> "cit_data" row
|
||||
Continent = toDocumentFrom<Continent> "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
|
||||
INNER JOIN {Table.Continent} o ON o.id = p.data ->> 'continentId'
|
||||
WHERE p.data ->> 'isLegacy' = 'false'
|
||||
AND p.data ->> 'visibility' <> '{ProfileVisibility.toString Hidden}'
|
||||
AND p.data ->> 'visibility' {vizSql}
|
||||
{searchSql searches}"
|
||||
|> Sql.parameters (searches |> List.collect snd)
|
||||
|> Sql.executeAsync (fun row ->
|
||||
let profile = toDocument<Profile> row
|
||||
let citizen = toDocumentFrom<Citizen> "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<Profile> row
|
||||
let continent = toDocumentFrom<Continent> "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}")
|
||||
})
|
||||
|
|
|
@ -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<ProfileSearchForm> () 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<PublicSearchForm> () 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
|
||||
|
|
|
@ -353,96 +353,8 @@ 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
|
||||
]
|
||||
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 "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 "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 -> ()
|
||||
]
|
||||
|
||||
|
||||
/// Logged-on search page
|
||||
let search (m : ProfileSearchForm) continents tz (results : ProfileSearchResult list option) =
|
||||
[ if Option.isNone results then
|
||||
p [] [
|
||||
txt "Enter one or more criteria to filter results, or just click “Search” to list all "
|
||||
txt "profiles."
|
||||
]
|
||||
/// 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" ]
|
||||
|
@ -483,9 +395,51 @@ let search (m : ProfileSearchForm) continents tz (results : ProfileSearchResult
|
|||
]
|
||||
]
|
||||
]
|
||||
match results with
|
||||
| Some r when List.isEmpty r -> p [ _class "pt-3" ] [ txt "No results found for the specified criteria" ]
|
||||
| Some r ->
|
||||
|
||||
/// 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" ] [
|
||||
|
@ -499,17 +453,31 @@ let search (m : ProfileSearchForm) continents tz (results : ProfileSearchResult
|
|||
th [ _scope "col"; _class isWide ] [ txt "Last Updated" ]
|
||||
]
|
||||
]
|
||||
r |> List.map (fun profile ->
|
||||
results
|
||||
|> List.map (fun it ->
|
||||
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) ]
|
||||
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 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."
|
||||
]
|
||||
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 -> if isPublic then yield! publicResults r else privateResults r tz
|
||||
| None -> ()
|
||||
]
|
||||
|> pageWithTitle "Search Profiles"
|
||||
|
|
Loading…
Reference in New Issue
Block a user