Feature repo reorg, phase 2
Each feature in its own project
This commit is contained in:
128
src/JobsJobsJobs/Profiles/Data.fs
Normal file
128
src/JobsJobsJobs/Profiles/Data.fs
Normal 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}")
|
||||
})
|
||||
178
src/JobsJobsJobs/Profiles/Domain.fs
Normal file
178
src/JobsJobsJobs/Profiles/Domain.fs
Normal 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
|
||||
}
|
||||
|
||||
135
src/JobsJobsJobs/Profiles/Handlers.fs
Normal file
135
src/JobsJobsJobs/Profiles/Handlers.fs
Normal 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
|
||||
]
|
||||
]
|
||||
19
src/JobsJobsJobs/Profiles/JobsJobsJobs.Profiles.fsproj
Normal file
19
src/JobsJobsJobs/Profiles/JobsJobsJobs.Profiles.fsproj
Normal 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>
|
||||
314
src/JobsJobsJobs/Profiles/Views.fs
Normal file
314
src/JobsJobsJobs/Profiles/Views.fs
Normal 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 " − " ]
|
||||
]
|
||||
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 "
|
||||
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’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 " "
|
||||
a [ _class "btn btn-outline-secondary"; _href $"/profile/{CitizenId.toString citizenId}/view" ] [
|
||||
i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] []
|
||||
txt " View Your User Profile"
|
||||
]
|
||||
]
|
||||
]
|
||||
hr []
|
||||
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 “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) =
|
||||
pageWithTitle "Search Profiles" [
|
||||
if Option.isNone results then
|
||||
p [] [
|
||||
txt "Enter one or more criteria to filter results, or just click “Search” 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 " "; 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 • "
|
||||
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 " ("; 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 " Edit Your Profile"
|
||||
]
|
||||
]
|
||||
Reference in New Issue
Block a user