Version 3 (#40)

Code for version 3
This commit was merged in pull request #40.
This commit is contained in:
2023-02-02 18:47:28 -05:00
committed by GitHub
parent 323ea83594
commit f3a7b9ea93
126 changed files with 7136 additions and 29577 deletions

View 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 ())
}

View 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 }

View 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
]
]

View 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>

View 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 "&nbsp; 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 "&laquo; 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 " &ndash; 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 " &ndash; 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 " &ndash; 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 " &ndash; 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 " &nbsp; &nbsp; "
a [ _href "/profile/edit/skills/list"; _hxGet "/profile/edit/skills/list"; _hxTarget "#skillList"
_class "btn btn-secondary" ] [ i [ _class "mdi mdi-cancel"] []; txt "&nbsp; 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 " &nbsp; &nbsp; "
a [ _href "/profile/edit/history/list"; _hxGet "/profile/edit/history/list"; _hxTarget "#historyList"
_class "btn btn-secondary" ] [ i [ _class "mdi mdi-cancel"] []; txt "&nbsp; 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 "&nbsp;"
]
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 &ldquo;Search&rdquo; 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 "&nbsp; &nbsp;"; 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 &bull; "
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 " &nbsp;("; 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 "&nbsp; Edit Your Profile"
]
txt " &nbsp; &nbsp; "
a [ _href $"/profile/{CitizenId.toString it.Profile.Id}/print"; _target "_blank"
_class "btn btn-outline-secondary" ] [
i [ _class "mdi mdi-printer-outline" ] []; txt "&nbsp; 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 "&nbsp; Print"
]
]