Version 3 #40

Merged
danieljsummers merged 67 commits from version-2-3 into main 2023-02-02 23:47:28 +00:00
4 changed files with 257 additions and 40 deletions
Showing only changes of commit 79eb3c8bfa - Show all commits

View File

@ -114,7 +114,7 @@ module EmploymentHistory =
let empty = let empty =
{ Employer = "" { Employer = ""
StartDate = LocalDate.MinIsoValue StartDate = LocalDate.FromDateTime DateTime.Today
EndDate = None EndDate = None
Position = None Position = None
Description = None Description = None

View File

@ -3,27 +3,6 @@ module JobsJobsJobs.Profiles.Domain
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open NodaTime 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 /// The data required to update a profile
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type EditProfileForm = type EditProfileForm =
@ -80,6 +59,48 @@ module EditProfileForm =
} }
/// 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 /// The various ways profiles can be searched
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type ProfileSearchForm = type ProfileSearchForm =
@ -166,3 +187,23 @@ type PublicSearchResult =
Skills : string list 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

@ -108,6 +108,13 @@ let skills : HttpHandler = requireUser >=> fun next ctx -> task {
| None -> return! notFound 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] // GET: /profile/edit/skill/[idx]
let editSkill idx : HttpHandler = requireUser >=> fun next ctx -> task { let editSkill idx : HttpHandler = requireUser >=> fun next ctx -> task {
match! Data.findById (currentCitizenId ctx) with match! Data.findById (currentCitizenId ctx) with
@ -146,6 +153,59 @@ let deleteSkill idx : HttpHandler = requireUser >=> validateCsrf >=> fun next ct
| None -> return! notFound 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 (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 (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 (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 (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 (csrf ctx) |> renderBare next ctx
| None -> return! notFound ctx
}
// GET: /profile/[id]/view // GET: /profile/[id]/view
let view citizenId : HttpHandler = fun next ctx -> task { let view citizenId : HttpHandler = fun next ctx -> task {
let citId = CitizenId citizenId let citId = CitizenId citizenId
@ -167,18 +227,24 @@ open Giraffe.EndpointRouting
let endpoints = let endpoints =
subRoute "/profile" [ subRoute "/profile" [
GET_HEAD [ GET_HEAD [
routef "/%O/view" view routef "/%O/view" view
route "/edit" edit route "/edit" edit
route "/edit/general" editGeneralInfo route "/edit/general" editGeneralInfo
routef "/edit/skill/%i" editSkill routef "/edit/history/%i" editHistory
route "/edit/skills" skills route "/edit/history" history
route "/search" search route "/edit/history/list" historyList
route "/seeking" seeking routef "/edit/skill/%i" editSkill
route "/edit/skills" skills
route "/edit/skills/list" skillList
route "/search" search
route "/seeking" seeking
] ]
POST [ POST [
route "/delete" delete route "/delete" delete
routef "/edit/skill/%i" saveSkill routef "/edit/history/%i" saveHistory
routef "/edit/skill/%i/delete" deleteSkill routef "/edit/history/%i/delete" deleteHistory
route "/save" saveGeneralInfo routef "/edit/skill/%i" saveSkill
routef "/edit/skill/%i/delete" deleteSkill
route "/save" saveGeneralInfo
] ]
] ]

View File

@ -134,19 +134,15 @@ let editGeneralInfo (m : EditProfileForm) continents csrf =
] ]
/// Render the skill edit template and existing skills /// Render the skill edit template
let skillForm (m : SkillForm) isNew = let skillForm (m : SkillForm) isNew =
[ h4 [] [ txt $"""{if isNew then "Add a" else "Edit"} Skill""" ] [ h4 [] [ txt $"""{if isNew then "Add a" else "Edit"} Skill""" ]
div [ _class "col-12 col-md-6" ] [ div [ _class "col-12 col-md-6" ] [
div [ _class "form-floating" ] [ textBox [ _type "text"; _maxlength "200"; _autofocus ] (nameof m.Description) m.Description "Skill" true
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 "form-text" ] [ txt "A skill (language, design technique, process, etc.)" ]
] ]
div [ _class "col-12 col-md-6" ] [ div [ _class "col-12 col-md-6" ] [
div [ _class "form-floating" ] [ textBox [ _type "text"; _maxlength "1000" ] (nameof m.Notes) m.Notes "Notes" false
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 "form-text" ] [ txt "A further description of the skill" ]
] ]
div [ _class "col-12" ] [ div [ _class "col-12" ] [
@ -231,6 +227,120 @@ let skills (skills : Skill list) csrf =
let editSkill (skills : Skill list) idx csrf = let editSkill (skills : Skill list) idx csrf =
skillTable skills (Some idx) csrf skillTable skills (Some idx) csrf
open NodaTime
/// Render the employment history edit template
let historyForm (m : HistoryForm) isNew =
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" ] [
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"
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" ]
]
]
/// List the employment history entries for an employment profile
let historyTable (history : EmploymentHistory list) editIdx csrf =
let editingIdx = defaultArg editIdx -2
let isEditing = editingIdx >= -1
let monthAndYear = Text.LocalDatePattern.CreateWithInvariantCulture "MMMM yyyy"
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)
]
]
]
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
]
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) 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 csrf
]
/// The employment history edit component
let editHistory (history : EmploymentHistory list) idx csrf =
historyTable history (Some idx) csrf
// ~~~ PROFILE SEARCH ~~~ // // ~~~ PROFILE SEARCH ~~~ //