Feature repo reorg, phase 2

Each feature in its own project
This commit is contained in:
2023-01-19 21:49:13 -05:00
parent 6eaea09f31
commit c7a535626d
33 changed files with 177 additions and 50 deletions

View File

@@ -0,0 +1,128 @@
module JobsJobsJobs.Profiles.Data
open JobsJobsJobs.Common.Data
open JobsJobsJobs.Domain
open JobsJobsJobs.Profiles.Domain
open Npgsql.FSharp
/// Count the current profiles
let count () =
dataSource ()
|> Sql.query $"SELECT COUNT(id) AS the_count FROM {Table.Profile} WHERE data ->> 'isLegacy' = 'false'"
|> Sql.executeRowAsync (fun row -> row.int64 "the_count")
/// Delete a profile by its ID
let deleteById citizenId = backgroundTask {
let! _ =
dataSource ()
|> Sql.query $"DELETE FROM {Table.Profile} WHERE id = @id"
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|> Sql.executeNonQueryAsync
()
}
/// Find a profile by citizen ID
let findById citizenId = backgroundTask {
match! dataSource () |> getDocument<Profile> Table.Profile (CitizenId.toString citizenId) with
| Some profile when not profile.IsLegacy -> return Some profile
| Some _
| None -> return None
}
/// Find a profile by citizen ID for viewing (includes citizen and continent information)
let findByIdForView citizenId = backgroundTask {
let! tryCitizen =
dataSource ()
|> Sql.query $"
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.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
})
return List.tryHead tryCitizen
}
/// Save a profile
let save (profile : Profile) =
dataSource () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile
/// Search profiles (logged-on users)
let search (search : ProfileSearchForm) = backgroundTask {
let searches = [
if search.ContinentId <> "" then
"p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ]
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 ]
if search.BioExperience <> "" then
"(p.data ->> 'biography' ILIKE @text OR p.data ->> 'experience' ILIKE @text)",
[ "@text", like search.BioExperience ]
]
let! results =
dataSource ()
|> Sql.query $"
SELECT p.*, c.data AS cit_data
FROM {Table.Profile} p
INNER JOIN {Table.Citizen} c ON c.id = p.id
WHERE p.data ->> 'isLegacy' = 'false'
{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 ())
}
// 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}")
})

View File

@@ -0,0 +1,178 @@
module JobsJobsJobs.Profiles.Domain
open JobsJobsJobs.Domain
open NodaTime
/// The fields required for a skill
[<CLIMutable; NoComparison; NoEquality>]
type SkillForm =
{ Description : string
/// Notes regarding the skill
Notes : string
}
/// Functions to support skill forms
module SkillForm =
/// Create a skill form from a skill
let fromSkill (skill : Skill) =
{ SkillForm.Description = skill.Description; Notes = defaultArg skill.Notes "" }
/// Create a skill from a skill form
let toSkill (form : SkillForm) =
{ Skill.Description = form.Description; Notes = if form.Notes = "" then None else Some form.Notes }
/// The data required to update a profile
[<CLIMutable; NoComparison; NoEquality>]
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
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 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
}
/// Support functions for the ProfileForm type
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
}
/// 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
}
/// The various ways profiles can be searched
[<CLIMutable; NoComparison; NoEquality>]
type ProfileSearchForm =
{ /// Retrieve citizens from this continent
ContinentId : string
/// Text for a search within a citizen's skills
Skill : string
/// Text for a search with a citizen's professional biography and experience fields
BioExperience : string
/// Whether to retrieve citizens who do or do not want remote work
RemoteWork : string
}
/// A user matching the profile search
[<NoComparison; NoEquality>]
type ProfileSearchResult =
{ /// The ID of the citizen
CitizenId : CitizenId
/// The citizen's display name
DisplayName : string
/// Whether this citizen is currently seeking employment
SeekingEmployment : bool
/// Whether this citizen is looking for remote work
RemoteWork : bool
/// Whether this citizen is looking for full-time work
FullTime : bool
/// When this profile was last updated
LastUpdatedOn : Instant
}
/// The data required to show a viewable profile
type ProfileForView =
{ /// The profile itself
Profile : Profile
/// The citizen to whom the profile belongs
Citizen : Citizen
/// The continent for the profile
Continent : Continent
}
/// The parameters for a public job search
[<CLIMutable; NoComparison; NoEquality>]
type PublicSearchForm =
{ /// Retrieve citizens from this continent
ContinentId : string
/// Retrieve citizens from this region
Region : string
/// Text for a search within a citizen's skills
Skill : string
/// Whether to retrieve citizens who do or do not want remote work
RemoteWork : string
}
/// A public profile search result
[<NoComparison; NoEquality>]
type PublicSearchResult =
{ /// The name of the continent on which the citizen resides
Continent : string
/// The region in which the citizen resides
Region : string
/// Whether this citizen is seeking remote work
RemoteWork : bool
/// The skills this citizen has identified
Skills : string list
}

View File

@@ -0,0 +1,135 @@
module JobsJobsJobs.Profiles.Handlers
open Giraffe
open JobsJobsJobs
open JobsJobsJobs.Common.Handlers
open JobsJobsJobs.Domain
open JobsJobsJobs.Profiles.Domain
// POST: /profile/delete
let delete : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
do! Data.deleteById (currentCitizenId ctx)
do! addSuccess "Profile deleted successfully" ctx
return! redirectToGet "/citizen/dashboard" next ctx
}
// GET: /profile/edit
let edit : HttpHandler = requireUser >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx
let! profile = Data.findById citizenId
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.edit form continents isNew citizenId (csrf ctx) |> render title next ctx
}
// POST: /profile/save
let save : HttpHandler = requireUser >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx
let! theForm = ctx.BindFormAsync<EditProfileForm> ()
let form = { theForm with Skills = theForm.Skills |> Array.filter (box >> isNull >> not) }
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
| Some p -> return p
| None -> return { Profile.empty with Id = citizenId }
}
let isNew = profile.Region = ""
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
}
let action = if isNew then "cre" else "upd"
do! addSuccess $"Employment Profile {action}ated successfully" ctx
return! redirectToGet "/profile/edit" next ctx
else
do! addErrors errors ctx
let! continents = Common.Data.Continents.all ()
return!
Views.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 = Common.Data.Continents.all ()
let form =
match ctx.TryBindQueryString<ProfileSearchForm> () with
| Ok f -> f
| Error _ -> { ContinentId = ""; RemoteWork = ""; Skill = ""; BioExperience = "" }
let! results = task {
if string ctx.Request.Query["searched"] = "true" then
let! it = Data.search form
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
}
// 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
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
}
open Giraffe.EndpointRouting
/// All endpoints for this feature
let endpoints =
subRoute "/profile" [
GET_HEAD [
routef "/%O/view" view
route "/edit" edit
route "/search" search
route "/seeking" seeking
]
POST [
route "/delete" delete
route "/save" save
]
]

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Data.fs" />
<Compile Include="Views.fs" />
<Compile Include="Handlers.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,314 @@
/// Views for /profile URLs
module JobsJobsJobs.Profiles.Views
open Giraffe.ViewEngine
open Giraffe.ViewEngine.Htmx
open JobsJobsJobs.Common.Views
open JobsJobsJobs.Domain
open JobsJobsJobs.Profiles.Domain
/// 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 "&nbsp;&minus;&nbsp;" ]
]
div [ _class "col-10 col-md-6" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _id $"skillDesc{idx}"; _name $"Skills[{idx}].Description"
_class "form-control"; _placeholder "A skill (language, design technique, process, etc.)"
_maxlength "200"; _value skill.Description; _required ]
label [ _class "jjj-required"; _for $"skillDesc{idx}" ] [ txt "Skill" ]
]
if idx < 1 then div [ _class "form-text" ] [ txt "A skill (language, design technique, process, etc.)" ]
]
div [ _class "col-12 col-md-5" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _id $"skillNotes{idx}"; _name $"Skills[{idx}].Notes"; _class "form-control"
_maxlength "1000"; _placeholder "A further description of the skill (1,000 characters max)"
_value skill.Notes ]
label [ _class "jjj-label"; _for $"skillNotes{idx}" ] [ txt "Notes" ]
]
if idx < 1 then div [ _class "form-text" ] [ txt "A further description of the skill" ]
]
]
template [ _id "newSkill" ] [ mapToInputs -1 { Description = ""; Notes = "" } ]
:: (skills |> Array.mapi mapToInputs |> List.ofArray)
/// The profile edit page
let edit (m : EditProfileForm) continents isNew citizenId csrf =
pageWithTitle "My Employment Profile" [
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
antiForgery csrf
div [ _class "col-12" ] [
checkBox [] (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment"
if m.IsSeekingEmployment then
p [ _class "fst-italic " ] [
txt "If you have found employment, consider "
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"
]
div [ _class "col-12 col-md-4" ] [
checkBox [] (nameof m.FullTime) m.FullTime "I am looking for full-time work"
]
div [ _class "col-12" ] [
hr []
h4 [ _class "pb-2" ] [
txt "Skills &nbsp; "
button [ _type "button"; _class "btn btn-sm btn-outline-primary rounded-pill"
_onclick "jjj.profile.addSkill()" ] [ txt "Add a Skill" ]
]
]
yield! skillEdit m.Skills
div [ _class "col-12" ] [
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&rsquo;s not already a part of your Professional Biography above."
]
]
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 "&nbsp; &nbsp; "
a [ _class "btn btn-outline-secondary"; _href $"/profile/{CitizenId.toString citizenId}/view" ] [
i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] []
txt "&nbsp; View Your User Profile"
]
]
]
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}"
]
/// 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 &ldquo;Search&rdquo; 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) =
pageWithTitle "Search Profiles" [
if Option.isNone results then
p [] [
txt "Enter one or more criteria to filter results, or just click &ldquo;Search&rdquo; to list all "
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" ] [
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" ] [ 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 "col-12 col-sm-6 col-lg-3" ] [
textBox [ _maxlength "1000" ] (nameof m.BioExperience) m.BioExperience "Bio / Experience" 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 ->
// 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 []
]
| None -> ()
]
/// Profile view template
let view (it : ProfileForView) currentId =
article [] [
h2 [] [
str (Citizen.name it.Citizen)
if it.Profile.IsSeekingEmployment then
span [ _class "jjj-heading-label" ] [
txt "&nbsp; &nbsp;"; span [ _class "badge bg-dark" ] [ txt "Currently Seeking Employment" ]
]
]
h4 [] [ str $"{it.Continent.Name}, {it.Profile.Region}" ]
contactInfo it.Citizen (Option.isNone currentId)
|> div [ _class "pb-3" ]
p [] [
txt (if it.Profile.IsFullTime then "I" else "Not i"); txt "nterested in full-time employment &bull; "
txt (if it.Profile.IsRemote then "I" else "Not i"); txt "nterested in remote opportunities"
]
hr []
div [] [ md2html it.Profile.Biography ]
if not (List.isEmpty it.Profile.Skills) then
hr []
h4 [ _class "pb-3" ] [ txt "Skills" ]
it.Profile.Skills
|> List.map (fun skill ->
li [] [
str skill.Description
match skill.Notes with Some notes -> txt " &nbsp;("; str notes; txt ")" | None -> ()
])
|> ul []
match it.Profile.Experience with
| Some exp -> hr []; h4 [ _class "pb-3" ] [ txt "Experience / Employment History" ]; div [] [ md2html exp ]
| None -> ()
if Option.isSome currentId && currentId.Value = it.Citizen.Id then
br []; br []
a [ _href "/profile/edit"; _class "btn btn-primary" ] [
i [ _class "mdi mdi-pencil" ] []; txt "&nbsp; Edit Your Profile"
]
]