Version 3 (#40)
Code for version 3
This commit was merged in pull request #40.
This commit is contained in:
86
src/JobsJobsJobs/Profiles/Data.fs
Normal file
86
src/JobsJobsJobs/Profiles/Data.fs
Normal file
@@ -0,0 +1,86 @@
|
||||
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
|
||||
}
|
||||
|
||||
/// 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 =
|
||||
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 toProfileForView
|
||||
return List.tryHead tryCitizen
|
||||
}
|
||||
|
||||
/// Save a profile
|
||||
let save (profile : Profile) =
|
||||
dataSource () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile
|
||||
|
||||
/// Search profiles
|
||||
let search (search : ProfileSearchForm) isPublic = 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.Text <> "" then
|
||||
"p.text_search @@ plainto_tsquery(@text_search)", [ "@text_search", Sql.string 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, 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' {vizSql}
|
||||
{searchSql searches}"
|
||||
|> Sql.parameters (searches |> List.collect snd)
|
||||
|> Sql.executeAsync toProfileForView
|
||||
return results |> List.sortBy (fun pfv -> (Citizen.name pfv.Citizen).ToLowerInvariant ())
|
||||
}
|
||||
206
src/JobsJobsJobs/Profiles/Domain.fs
Normal file
206
src/JobsJobsJobs/Profiles/Domain.fs
Normal file
@@ -0,0 +1,206 @@
|
||||
module JobsJobsJobs.Profiles.Domain
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
open NodaTime
|
||||
|
||||
/// The data required to update a profile
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type EditProfileForm =
|
||||
{ /// 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
|
||||
|
||||
/// Whether the citizen to whom this profile belongs is actively seeking employment
|
||||
IsSeekingEmployment : bool
|
||||
|
||||
/// If the citizen is available for remote work
|
||||
RemoteWork : bool
|
||||
|
||||
/// If the citizen is seeking full-time employment
|
||||
FullTime : bool
|
||||
|
||||
/// The user's professional biography
|
||||
Biography : string
|
||||
|
||||
/// The user's past experience
|
||||
Experience : string option
|
||||
|
||||
/// The visibility for this profile
|
||||
Visibility : string
|
||||
}
|
||||
|
||||
/// Support functions for the ProfileForm type
|
||||
module EditProfileForm =
|
||||
|
||||
/// An empty view model (used for new profiles)
|
||||
let empty =
|
||||
{ ContinentId = ""
|
||||
Region = ""
|
||||
IsSeekingEmployment = false
|
||||
RemoteWork = false
|
||||
FullTime = false
|
||||
Biography = ""
|
||||
Experience = None
|
||||
Visibility = ProfileVisibility.toString Private
|
||||
}
|
||||
|
||||
/// Create an instance of this form from the given profile
|
||||
let fromProfile (profile : Profile) =
|
||||
{ ContinentId = ContinentId.toString profile.ContinentId
|
||||
Region = profile.Region
|
||||
IsSeekingEmployment = profile.IsSeekingEmployment
|
||||
RemoteWork = profile.IsRemote
|
||||
FullTime = profile.IsFullTime
|
||||
Biography = MarkdownString.toString profile.Biography
|
||||
Experience = profile.Experience |> Option.map MarkdownString.toString
|
||||
Visibility = ProfileVisibility.toString profile.Visibility
|
||||
}
|
||||
|
||||
|
||||
/// The form used to add or edit employment history entries
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type HistoryForm =
|
||||
{ /// The name of the employer
|
||||
Employer : string
|
||||
|
||||
StartDate : string
|
||||
|
||||
EndDate : string
|
||||
|
||||
Position : string
|
||||
|
||||
Description : string
|
||||
}
|
||||
|
||||
/// Support functions for the employment history form
|
||||
module HistoryForm =
|
||||
|
||||
open System
|
||||
|
||||
/// The date format expected by the browser's date input field
|
||||
let dateFormat = Text.LocalDatePattern.CreateWithInvariantCulture "yyyy-MM-dd"
|
||||
|
||||
/// Create an employment history form from an employment history entry
|
||||
let fromHistory (history : EmploymentHistory) =
|
||||
{ Employer = history.Employer
|
||||
StartDate = dateFormat.Format history.StartDate
|
||||
EndDate = match history.EndDate with Some dt -> dateFormat.Format dt | None -> ""
|
||||
Position = defaultArg history.Position ""
|
||||
Description = match history.Description with Some d -> MarkdownString.toString d | None -> ""
|
||||
}
|
||||
|
||||
/// Create an employment history entry from an employment history form
|
||||
let toHistory (history : HistoryForm) : EmploymentHistory =
|
||||
{ Employer = history.Employer
|
||||
StartDate = (dateFormat.Parse history.StartDate).Value
|
||||
EndDate = if history.EndDate = "" then None else Some (dateFormat.Parse history.EndDate).Value
|
||||
Position = if history.Position = "" then None else Some history.Position
|
||||
Description = if history.Description = "" then None else Some (Text history.Description)
|
||||
}
|
||||
|
||||
|
||||
/// The various ways profiles can be searched
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type ProfileSearchForm =
|
||||
{ /// Retrieve citizens from this continent
|
||||
ContinentId : string
|
||||
|
||||
/// Whether to retrieve citizens who do or do not want remote work
|
||||
RemoteWork : string
|
||||
|
||||
/// Text for a search within a citizen's region, professional bio, skills, experience, and employment history
|
||||
Text : 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
|
||||
}
|
||||
|
||||
|
||||
/// 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 }
|
||||
259
src/JobsJobsJobs/Profiles/Handlers.fs
Normal file
259
src/JobsJobsJobs/Profiles/Handlers.fs
Normal file
@@ -0,0 +1,259 @@
|
||||
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 display = match profile with Some p -> p | None -> { Profile.empty with Id = citizenId }
|
||||
return! Views.edit display |> render "Employment Profile" next ctx
|
||||
}
|
||||
|
||||
// GET: /profile/edit/general
|
||||
let editGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! profile = Data.findById (currentCitizenId ctx)
|
||||
let! continents = Common.Data.Continents.all ()
|
||||
let form = if Option.isNone profile then EditProfileForm.empty else EditProfileForm.fromProfile profile.Value
|
||||
return!
|
||||
Views.editGeneralInfo form continents (isHtmx ctx) (csrf ctx)
|
||||
|> render "General Information | Employment Profile" next ctx
|
||||
}
|
||||
|
||||
// POST: /profile/save
|
||||
let saveGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let citizenId = currentCitizenId ctx
|
||||
let! form = ctx.BindFormAsync<EditProfileForm> ()
|
||||
let errors = [
|
||||
if form.ContinentId = "" then "Continent is required"
|
||||
if form.Region = "" then "Region is required"
|
||||
if form.Biography = "" then "Professional Biography is 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
|
||||
ContinentId = ContinentId.ofString form.ContinentId
|
||||
Region = form.Region
|
||||
IsSeekingEmployment = form.IsSeekingEmployment
|
||||
IsRemote = form.RemoteWork
|
||||
IsFullTime = form.FullTime
|
||||
Biography = Text form.Biography
|
||||
LastUpdatedOn = now ctx
|
||||
Experience = noneIfBlank form.Experience |> Option.map Text
|
||||
Visibility = ProfileVisibility.parse form.Visibility
|
||||
}
|
||||
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.editGeneralInfo form continents (isHtmx ctx) (csrf ctx)
|
||||
|> render "General Information | Employment Profile" next ctx
|
||||
}
|
||||
|
||||
// GET: /profile/search
|
||||
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 isPublic
|
||||
return Some it
|
||||
else return None
|
||||
}
|
||||
return! Views.search form continents (timeZone ctx) results isPublic |> render "Profile Search" next ctx
|
||||
}
|
||||
|
||||
// GET: /profile/edit/skills
|
||||
let skills : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile -> return! Views.skills profile.Skills (csrf ctx) |> render "Skills | Employment Profile" next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// GET: /profile/edit/skills/list
|
||||
let skillList : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile -> return! Views.skillTable profile.Skills None (csrf ctx) |> renderBare next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// GET: /profile/edit/skill/[idx]
|
||||
let editSkill idx : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile ->
|
||||
if idx < -1 || idx >= List.length profile.Skills then return! notFound ctx
|
||||
else return! Views.editSkill profile.Skills idx (csrf ctx) |> renderBare next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// POST: /profile/edit/skill/[idx]
|
||||
let saveSkill idx : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile ->
|
||||
if idx < -1 || idx >= List.length profile.Skills then return! notFound ctx
|
||||
else
|
||||
let! form = ctx.BindFormAsync<SkillForm> ()
|
||||
let skill = SkillForm.toSkill form
|
||||
let skills =
|
||||
if idx = -1 then skill :: profile.Skills
|
||||
else profile.Skills |> List.mapi (fun skillIdx it -> if skillIdx = idx then skill else it)
|
||||
|> List.sortBy (fun it -> it.Description.ToLowerInvariant ())
|
||||
do! Data.save { profile with Skills = skills }
|
||||
return! Views.skillTable skills None (csrf ctx) |> renderBare next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// POST: /profile/edit/skill/[idx]/delete
|
||||
let deleteSkill idx : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile ->
|
||||
if idx < 0 || idx >= List.length profile.Skills then return! notFound ctx
|
||||
else
|
||||
let skills = profile.Skills |> List.indexed |> List.filter (fun it -> fst it <> idx) |> List.map snd
|
||||
do! Data.save { profile with Skills = skills }
|
||||
return! Views.skillTable skills None (csrf ctx) |> renderBare next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// GET: /profile/edit/history
|
||||
let history : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile ->
|
||||
return!
|
||||
Views.history profile.History (isHtmx ctx) (csrf ctx)
|
||||
|> render "Employment History | Employment Profile" next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// GET: /profile/edit/history/list
|
||||
let historyList : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile -> return! Views.historyTable profile.History None (isHtmx ctx) (csrf ctx) |> renderBare next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// GET: /profile/edit/history/[idx]
|
||||
let editHistory idx : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile ->
|
||||
if idx < -1 || idx >= List.length profile.History then return! notFound ctx
|
||||
else return! Views.editHistory profile.History idx (isHtmx ctx) (csrf ctx) |> renderBare next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// POST: /profile/edit/history/[idx]
|
||||
let saveHistory idx : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile ->
|
||||
if idx < -1 || idx >= List.length profile.History then return! notFound ctx
|
||||
else
|
||||
let! form = ctx.BindFormAsync<HistoryForm> ()
|
||||
let entry = HistoryForm.toHistory form
|
||||
let history =
|
||||
if idx = -1 then entry :: profile.History
|
||||
else profile.History |> List.mapi (fun histIdx it -> if histIdx = idx then entry else it)
|
||||
|> List.sortByDescending (fun it -> defaultArg it.EndDate NodaTime.LocalDate.MaxIsoValue)
|
||||
do! Data.save { profile with History = history }
|
||||
return! Views.historyTable history None (isHtmx ctx) (csrf ctx) |> renderBare next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// POST: /profile/edit/history/[idx]/delete
|
||||
let deleteHistory idx : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile ->
|
||||
if idx < 0 || idx >= List.length profile.History then return! notFound ctx
|
||||
else
|
||||
let history = profile.History |> List.indexed |> List.filter (fun it -> fst it <> idx) |> List.map snd
|
||||
do! Data.save { profile with History = history }
|
||||
return! Views.historyTable history None (isHtmx ctx) (csrf ctx) |> renderBare next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
/// Get a profile for view, and enforce visibility restrictions against the current user
|
||||
let private getProfileForView citizenId ctx = task {
|
||||
let citId = CitizenId citizenId
|
||||
match! Data.findByIdForView citId with
|
||||
| Some profile ->
|
||||
let currentCitizen = tryUser ctx |> Option.map CitizenId.ofString
|
||||
let canView =
|
||||
match profile.Profile.Visibility, currentCitizen with
|
||||
| Private, Some _
|
||||
| Anonymous, Some _
|
||||
| Public, _ -> true
|
||||
| Hidden, Some citizenId when profile.Citizen.Id = citizenId -> true
|
||||
| _ -> false
|
||||
return if canView then Some (profile, currentCitizen) else None
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
// GET: /profile/[id]/view
|
||||
let view citizenId : HttpHandler = fun next ctx -> task {
|
||||
match! getProfileForView citizenId ctx with
|
||||
| Some (profile, currentCitizen) ->
|
||||
let title = $"Employment Profile for {Citizen.name profile.Citizen}"
|
||||
return! Views.view profile currentCitizen |> render title next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// GET: /profile/[id]/print
|
||||
let print citizenId : HttpHandler = fun next ctx -> task {
|
||||
match! getProfileForView citizenId ctx with
|
||||
| Some (profile, currentCitizen) ->
|
||||
let pageTitle = $"Employment Profile for {Citizen.name profile.Citizen}"
|
||||
return! Views.print profile (Option.isNone currentCitizen) |> renderPrint pageTitle next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
|
||||
open Giraffe.EndpointRouting
|
||||
|
||||
/// All endpoints for this feature
|
||||
let endpoints =
|
||||
subRoute "/profile" [
|
||||
GET_HEAD [
|
||||
routef "/%O/view" view
|
||||
routef "/%O/print" print
|
||||
route "/edit" edit
|
||||
route "/edit/general" editGeneralInfo
|
||||
routef "/edit/history/%i" editHistory
|
||||
route "/edit/history" history
|
||||
route "/edit/history/list" historyList
|
||||
routef "/edit/skill/%i" editSkill
|
||||
route "/edit/skills" skills
|
||||
route "/edit/skills/list" skillList
|
||||
route "/search" search
|
||||
]
|
||||
POST [
|
||||
route "/delete" delete
|
||||
routef "/edit/history/%i" saveHistory
|
||||
routef "/edit/history/%i/delete" deleteHistory
|
||||
routef "/edit/skill/%i" saveSkill
|
||||
routef "/edit/skill/%i/delete" deleteSkill
|
||||
route "/save" saveGeneralInfo
|
||||
]
|
||||
]
|
||||
18
src/JobsJobsJobs/Profiles/JobsJobsJobs.Profiles.fsproj
Normal file
18
src/JobsJobsJobs/Profiles/JobsJobsJobs.Profiles.fsproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</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>
|
||||
566
src/JobsJobsJobs/Profiles/Views.fs
Normal file
566
src/JobsJobsJobs/Profiles/Views.fs
Normal file
@@ -0,0 +1,566 @@
|
||||
/// Views for /profile URLs
|
||||
module JobsJobsJobs.Profiles.Views
|
||||
|
||||
open Giraffe.Htmx.Common
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open JobsJobsJobs.Common.Views
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Profiles.Domain
|
||||
|
||||
// ~~~ PROFILE EDIT ~~~ //
|
||||
|
||||
/// The profile edit menu page
|
||||
let edit (profile : Profile) =
|
||||
let hasProfile = profile.Region <> ""
|
||||
pageWithTitle "Employment Profile" [
|
||||
p [] [ txt "There are three different sections to the employment profile." ]
|
||||
ul [] [
|
||||
li [ _class "mb-2" ] [
|
||||
a [ _href $"/profile/edit/general" ] [ strong [] [ txt "General Information" ] ]; br []
|
||||
txt "contains your location, professional biography, and information about the type of employment you "
|
||||
txt "may be seeking."
|
||||
if not hasProfile then txt " Entering information here will create your profile."
|
||||
]
|
||||
if hasProfile then
|
||||
li [ _class "mb-2" ] [
|
||||
let skillCount = List.length profile.Skills
|
||||
a [ _href $"/profile/edit/skills" ] [ strong [] [ txt "Skills" ] ]; br []
|
||||
txt "is where you can list skills you have acquired through education or experience."
|
||||
em [] [
|
||||
txt $" (Your profile currently lists {skillCount} skill"; if skillCount <> 1 then txt "s"
|
||||
txt ".)"
|
||||
]
|
||||
]
|
||||
li [ _class "mb-2" ] [
|
||||
let historyCount = List.length profile.History
|
||||
a [ _href $"/profile/edit/history" ] [ strong [] [ txt "Employment History" ] ]; br []
|
||||
txt "is where you can record a chronological history of your employment."
|
||||
em [] [
|
||||
txt $" (Your profile contains {historyCount} employment history entr"
|
||||
txt (if historyCount <> 1 then "ies" else "y"); txt ".)"
|
||||
]
|
||||
]
|
||||
]
|
||||
if hasProfile then
|
||||
p [] [
|
||||
a [ _class "btn btn-primary"; _href $"/profile/{CitizenId.toString profile.Id}/view" ] [
|
||||
i [ _class "mdi mdi-file-account-outline" ] []; txt " View Your Employment Profile"
|
||||
]
|
||||
]
|
||||
hr []
|
||||
p [ _class "text-muted" ] [
|
||||
txt "If you want to delete your profile, or your entire account, "
|
||||
a [ _href "/citizen/so-long" ] [ txt "see your deletion options here" ]; txt "."
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// A link to allow the user to return to the profile edit menu page
|
||||
let backToEdit =
|
||||
p [ _class "mx-3" ] [ a [ _href "/profile/edit" ] [ txt "« Back to Profile Edit Menu" ] ]
|
||||
|
||||
|
||||
/// The profile edit page
|
||||
let editGeneralInfo (m : EditProfileForm) continents isHtmx csrf =
|
||||
pageWithTitle "Employment Profile: General Information" [
|
||||
backToEdit
|
||||
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
|
||||
antiForgery csrf
|
||||
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." ]
|
||||
]
|
||||
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-offset-md-2 col-md-4" ] [
|
||||
checkBox [] (nameof m.RemoteWork) m.RemoteWork "I am interested in remote work"
|
||||
]
|
||||
div [ _class "col-12 col-md-4" ] [
|
||||
checkBox [] (nameof m.FullTime) m.FullTime "I am interested in full-time work"
|
||||
]
|
||||
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography" isHtmx
|
||||
div [ _class "col-12" ] [
|
||||
hr []
|
||||
h4 [] [ txt "Experience" ]
|
||||
p [] [
|
||||
txt "The information in this box is displayed after the list of skills and chronological job "
|
||||
txt "history, with no heading; it can be used to highlight your experiences apart from the history "
|
||||
txt "entries, provide closing notes, etc."
|
||||
]
|
||||
]
|
||||
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience" isHtmx
|
||||
div [ _class "col-12" ] [
|
||||
hr []
|
||||
h4 [] [ txt "Visibility" ]
|
||||
div [ _class "form-check" ] [
|
||||
let hid = ProfileVisibility.toString Hidden
|
||||
input [ _type "radio"; _id $"{nameof m.Visibility}Hidden"; _name (nameof m.Visibility)
|
||||
_class "form-check-input"; _value hid; if m.Visibility = hid then _checked ]
|
||||
label [ _class "form-check-label"; _for $"{nameof m.Visibility}Hidden" ] [
|
||||
strong [] [ txt "Hidden" ]
|
||||
txt " – do not show my employment profile to anyone else"
|
||||
]
|
||||
]
|
||||
div [ _class "form-check" ] [
|
||||
let pvt = ProfileVisibility.toString Private
|
||||
input [ _type "radio"; _id $"{nameof m.Visibility}Private"; _name (nameof m.Visibility)
|
||||
_class "form-check-input"; _value pvt; if m.Visibility = pvt then _checked ]
|
||||
label [ _class "form-check-label"; _for $"{nameof m.Visibility}Private" ] [
|
||||
strong [] [ txt "Private" ]
|
||||
txt " – only show my employment profile to other authenticated users"
|
||||
]
|
||||
]
|
||||
div [ _class "form-check" ] [
|
||||
let anon = ProfileVisibility.toString Anonymous
|
||||
input [ _type "radio"; _id $"{nameof m.Visibility}Anonymous"; _name (nameof m.Visibility)
|
||||
_class "form-check-input"; _value anon; if m.Visibility = anon then _checked ]
|
||||
label [ _class "form-check-label"; _for $"{nameof m.Visibility}Anonymous" ] [
|
||||
strong [] [ txt "Anonymous" ]
|
||||
txt " – show my location and skills to public users anonymously"
|
||||
]
|
||||
]
|
||||
div [ _class "form-check" ] [
|
||||
let pub = ProfileVisibility.toString Public
|
||||
input [ _type "radio"; _id $"{nameof m.Visibility}Public"; _name (nameof m.Visibility)
|
||||
_class "form-check-input"; _value pub; if m.Visibility = pub then _checked ]
|
||||
label [ _class "form-check-label"; _for $"{nameof m.Visibility}Public" ] [
|
||||
strong [] [ txt "Public" ]; txt " – show my full profile to public users"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Render the skill edit template
|
||||
let skillForm (m : SkillForm) isNew =
|
||||
[ h4 [] [ txt $"""{if isNew then "Add a" else "Edit"} Skill""" ]
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
textBox [ _type "text"; _maxlength "200"; _autofocus ] (nameof m.Description) m.Description "Skill" true
|
||||
div [ _class "form-text" ] [ txt "A skill (language, design technique, process, etc.)" ]
|
||||
]
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
textBox [ _type "text"; _maxlength "1000" ] (nameof m.Notes) m.Notes "Notes" false
|
||||
div [ _class "form-text" ] [ txt "A further description of the skill" ]
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
submitButton "content-save-outline" "Save"; txt " "
|
||||
a [ _href "/profile/edit/skills/list"; _hxGet "/profile/edit/skills/list"; _hxTarget "#skillList"
|
||||
_class "btn btn-secondary" ] [ i [ _class "mdi mdi-cancel"] []; txt " Cancel" ]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// List the skills for an employment profile
|
||||
let skillTable (skills : Skill list) editIdx csrf =
|
||||
let editingIdx = defaultArg editIdx -2
|
||||
let isEditing = editingIdx >= -1
|
||||
let renderTable () =
|
||||
let editSkillForm skill idx =
|
||||
tr [] [
|
||||
td [ _colspan "3" ] [
|
||||
form [ _class "row g-3"; _hxPost $"/profile/edit/skill/{idx}"; _hxTarget "#skillList" ] [
|
||||
antiForgery csrf
|
||||
yield! skillForm (SkillForm.fromSkill skill) (idx = -1)
|
||||
]
|
||||
]
|
||||
]
|
||||
table [ _class "table table-sm table-hover pt-3" ] [
|
||||
thead [] [
|
||||
[ "Action"; "Skill"; "Notes" ]
|
||||
|> List.map (fun it -> th [ _scope "col" ] [ txt it ])
|
||||
|> tr []
|
||||
]
|
||||
tbody [] [
|
||||
if isEditing && editingIdx = -1 then editSkillForm { Skill.Description = ""; Notes = None } -1
|
||||
yield! skills |> List.mapi (fun idx skill ->
|
||||
if isEditing && editingIdx = idx then editSkillForm skill idx
|
||||
else
|
||||
tr [] [
|
||||
td [ if isEditing then _class "text-muted" ] [
|
||||
if isEditing then txt "Edit ~ Delete"
|
||||
else
|
||||
let link = $"/profile/edit/skill/{idx}"
|
||||
a [ _href link; _hxGet link ] [ txt "Edit" ]; txt " ~ "
|
||||
a [ _href $"{link}/delete"; _hxPost $"{link}/delete"; _class "text-danger"
|
||||
_hxConfirm "Are you sure you want to delete this skill?" ] [ txt "Delete" ]
|
||||
]
|
||||
td [] [ str skill.Description ]
|
||||
td [ if Option.isNone skill.Notes then _class "text-muted fst-italic" ] [
|
||||
str (defaultArg skill.Notes "None")
|
||||
]
|
||||
])
|
||||
]
|
||||
]
|
||||
|
||||
if List.isEmpty skills && not isEditing then
|
||||
p [ _id "skillList"; _class "text-muted fst-italic pt-3" ] [ txt "Your profile has no skills defined" ]
|
||||
else if List.isEmpty skills then
|
||||
form [ _id "skillList"; _hxTarget "this"; _hxPost "/profile/edit/skill/-1"; _hxSwap HxSwap.OuterHtml
|
||||
_class "row g-3" ] [
|
||||
antiForgery csrf
|
||||
yield! skillForm { Description = ""; Notes = "" } true
|
||||
]
|
||||
else if isEditing then div [ _id "skillList" ] [ renderTable () ]
|
||||
else // not editing, there are skills to show
|
||||
form [ _id "skillList"; _hxTarget "this"; _hxSwap HxSwap.OuterHtml ] [
|
||||
antiForgery csrf
|
||||
renderTable ()
|
||||
]
|
||||
|
||||
|
||||
/// The profile skills maintenance page
|
||||
let skills (skills : Skill list) csrf =
|
||||
pageWithTitle "Employment Profile: Skills" [
|
||||
backToEdit
|
||||
p [] [
|
||||
a [ _href "/profile/edit/skill/-1"; _hxGet "/profile/edit/skill/-1"; _hxTarget "#skillList"
|
||||
_hxSwap HxSwap.OuterHtml; _class "btn btn-sm btn-outline-primary rounded-pill" ] [ txt "Add a Skill" ]
|
||||
]
|
||||
skillTable skills None csrf
|
||||
]
|
||||
|
||||
|
||||
/// The skill edit component
|
||||
let editSkill (skills : Skill list) idx csrf =
|
||||
skillTable skills (Some idx) csrf
|
||||
|
||||
open NodaTime
|
||||
|
||||
/// Render the employment history edit template
|
||||
let historyForm (m : HistoryForm) isNew isHtmx =
|
||||
let maxDate = HistoryForm.dateFormat.Format (LocalDate.FromDateTime System.DateTime.Today)
|
||||
[ h4 [] [ txt $"""{if isNew then "Add" else "Edit"} Employment History""" ]
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
textBox [ _type "text"; _maxlength "200"; _autofocus ] (nameof m.Employer) m.Employer "Employer" true
|
||||
]
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
textBox [ _type "text"; _maxlength "200" ] (nameof m.Position) m.Position "Title or Position" true
|
||||
]
|
||||
p [ _class "col-12 text-center mb-0" ] [
|
||||
txt "Select any date within the month; only the month and year will be displayed"
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-xl-4 offset-xl-1" ] [
|
||||
textBox [ _type "date"; _max maxDate ] (nameof m.StartDate) m.StartDate "Start Date" true
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-xl-4 offset-xl-2" ] [
|
||||
textBox [ _type "date"; _max maxDate ] (nameof m.EndDate) m.EndDate "End Date" false
|
||||
]
|
||||
markdownEditor [] (nameof m.Description) m.Description "Description" isHtmx
|
||||
div [ _class "col-12" ] [
|
||||
submitButton "content-save-outline" "Save"; txt " "
|
||||
a [ _href "/profile/edit/history/list"; _hxGet "/profile/edit/history/list"; _hxTarget "#historyList"
|
||||
_class "btn btn-secondary" ] [ i [ _class "mdi mdi-cancel"] []; txt " Cancel" ]
|
||||
]
|
||||
]
|
||||
|
||||
let private monthAndYear = Text.LocalDatePattern.CreateWithInvariantCulture "MMMM yyyy"
|
||||
|
||||
/// List the employment history entries for an employment profile
|
||||
let historyTable (history : EmploymentHistory list) editIdx isHtmx csrf =
|
||||
let editingIdx = defaultArg editIdx -2
|
||||
let isEditing = editingIdx >= -1
|
||||
let renderTable () =
|
||||
let editHistoryForm entry idx =
|
||||
tr [] [
|
||||
td [ _colspan "4" ] [
|
||||
form [ _class "row g-3"; _hxPost $"/profile/edit/history/{idx}"; _hxTarget "#historyList" ] [
|
||||
antiForgery csrf
|
||||
yield! historyForm (HistoryForm.fromHistory entry) (idx = -1) isHtmx
|
||||
]
|
||||
]
|
||||
]
|
||||
table [ _class "table table-sm table-hover pt-3" ] [
|
||||
thead [] [
|
||||
[ "Action"; "Employer"; "Dates"; "Position" ]
|
||||
|> List.map (fun it -> th [ _scope "col" ] [ txt it ])
|
||||
|> tr []
|
||||
]
|
||||
tbody [] [
|
||||
if isEditing && editingIdx = -1 then editHistoryForm EmploymentHistory.empty -1
|
||||
yield! history |> List.mapi (fun idx entry ->
|
||||
if isEditing && editingIdx = idx then editHistoryForm entry idx
|
||||
else
|
||||
tr [] [
|
||||
td [ if isEditing then _class "text-muted" ] [
|
||||
if isEditing then txt "Edit ~ Delete"
|
||||
else
|
||||
let link = $"/profile/edit/history/{idx}"
|
||||
a [ _href link; _hxGet link ] [ txt "Edit" ]; txt " ~ "
|
||||
a [ _href $"{link}/delete"; _hxPost $"{link}/delete"; _class "text-danger"
|
||||
_hxConfirm "Are you sure you want to delete this employment history entry?" ] [
|
||||
txt "Delete"
|
||||
]
|
||||
]
|
||||
td [] [ str entry.Employer ]
|
||||
td [] [
|
||||
str (monthAndYear.Format entry.StartDate); txt " to "
|
||||
match entry.EndDate with Some dt -> str (monthAndYear.Format dt) | None -> txt "Present"
|
||||
]
|
||||
td [ if Option.isNone entry.Position then _class "text-muted fst-italic" ] [
|
||||
str (defaultArg entry.Position "None")
|
||||
]
|
||||
])
|
||||
]
|
||||
]
|
||||
|
||||
if List.isEmpty history && not isEditing then
|
||||
p [ _id "historyList"; _class "text-muted fst-italic pt-3" ] [
|
||||
txt "Your profile has no employment history defined"
|
||||
]
|
||||
else if List.isEmpty history then
|
||||
form [ _id "historyList"; _hxTarget "this"; _hxPost "/profile/edit/history/-1"; _hxSwap HxSwap.OuterHtml
|
||||
_class "row g-3" ] [
|
||||
antiForgery csrf
|
||||
yield! historyForm (HistoryForm.fromHistory EmploymentHistory.empty) true isHtmx
|
||||
]
|
||||
else if isEditing then div [ _id "historyList" ] [ renderTable () ]
|
||||
else // not editing, there is history to show
|
||||
form [ _id "historyList"; _hxTarget "this"; _hxSwap HxSwap.OuterHtml ] [
|
||||
antiForgery csrf
|
||||
renderTable ()
|
||||
]
|
||||
|
||||
|
||||
/// The employment history maintenance page
|
||||
let history (history : EmploymentHistory list) isHtmx csrf =
|
||||
pageWithTitle "Employment Profile: Employment History" [
|
||||
backToEdit
|
||||
p [] [
|
||||
a [ _href "/profile/edit/history/-1"; _hxGet "/profile/edit/history/-1"; _hxTarget "#historyList"
|
||||
_hxSwap HxSwap.OuterHtml; _class "btn btn-sm btn-outline-primary rounded-pill" ] [
|
||||
txt "Add an Employment History Entry"
|
||||
]
|
||||
]
|
||||
historyTable history None isHtmx csrf
|
||||
]
|
||||
|
||||
|
||||
/// The employment history edit component
|
||||
let editHistory (history : EmploymentHistory list) idx csrf =
|
||||
historyTable history (Some idx) csrf
|
||||
|
||||
// ~~~ PROFILE SEARCH ~~~ //
|
||||
|
||||
/// The search form
|
||||
let private searchForm (m : ProfileSearchForm) continents isShown =
|
||||
collapsePanel "Search Criteria" isShown [
|
||||
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" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// 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 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 (List.isEmpty (defaultArg results []))
|
||||
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"
|
||||
|
||||
|
||||
/// Display a profile
|
||||
let private displayProfile (it : ProfileForView) isPublic isPrint =
|
||||
[ 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}" ]
|
||||
(if isPrint then contactInfoPrint else contactInfo) it.Citizen isPublic
|
||||
|> 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"
|
||||
]
|
||||
div [] [ md2html it.Profile.Biography ]
|
||||
if not (List.isEmpty it.Profile.Skills) then
|
||||
h4 [ _class "pb-3 border-top border-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 []
|
||||
if not (List.isEmpty it.Profile.History) then
|
||||
h4 [ _class "mb-3 border-top border-3" ] [ txt "Employment History" ]
|
||||
yield!
|
||||
it.Profile.History
|
||||
|> List.indexed
|
||||
|> List.collect (fun (idx, entry) -> [
|
||||
let maybeBorder = if idx > 0 then " border-top" else ""
|
||||
div [ _class $"d-flex flex-row flex-wrap justify-content-between align-items-start mt-4 mb-2{maybeBorder}" ] [
|
||||
div [] [
|
||||
strong [] [ str entry.Employer ]
|
||||
match entry.Position with Some pos -> br []; str pos | None -> ()
|
||||
]
|
||||
div [ _class "text-end" ] [
|
||||
span [ _class "text-nowrap" ] [ str (monthAndYear.Format entry.StartDate) ]
|
||||
span [ _class "text-nowrap" ] [
|
||||
txt " to "
|
||||
match entry.EndDate with
|
||||
| Some dt -> str (monthAndYear.Format dt)
|
||||
| None -> txt "Present"
|
||||
]
|
||||
]
|
||||
]
|
||||
match entry.Description with Some d -> div [] [ md2html d ] | None -> ()
|
||||
])
|
||||
match it.Profile.Experience with
|
||||
| Some exp -> div [ _class "border-top border-3" ] [ md2html exp ]
|
||||
| None -> ()
|
||||
]
|
||||
|
||||
|
||||
/// Profile view template
|
||||
let view (it : ProfileForView) currentId =
|
||||
article [] [
|
||||
yield! displayProfile it (Option.isNone currentId) false
|
||||
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"
|
||||
]
|
||||
txt " "
|
||||
a [ _href $"/profile/{CitizenId.toString it.Profile.Id}/print"; _target "_blank"
|
||||
_class "btn btn-outline-secondary" ] [
|
||||
i [ _class "mdi mdi-printer-outline" ] []; txt " View Print Version"
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Printable profile view template
|
||||
let print (it : ProfileForView) isPublic =
|
||||
article [] [
|
||||
yield! displayProfile it isPublic true
|
||||
button [ _type "button"; _class "btn btn-sm btn-secondary jjj-hide-from-printer"; _onclick "window.print()" ] [
|
||||
i [ _class "mdi mdi-printer-outline" ] []; txt " Print"
|
||||
]
|
||||
]
|
||||
Reference in New Issue
Block a user