From 79eb3c8bfa0fade92ef78e7ef625d2d6166b9837 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 21 Jan 2023 08:48:01 -0500 Subject: [PATCH] WIP on employment history --- src/JobsJobsJobs/Common/Domain.fs | 2 +- src/JobsJobsJobs/Profiles/Domain.fs | 83 ++++++++++++----- src/JobsJobsJobs/Profiles/Handlers.fs | 88 +++++++++++++++--- src/JobsJobsJobs/Profiles/Views.fs | 124 ++++++++++++++++++++++++-- 4 files changed, 257 insertions(+), 40 deletions(-) diff --git a/src/JobsJobsJobs/Common/Domain.fs b/src/JobsJobsJobs/Common/Domain.fs index 083794d..409aa72 100644 --- a/src/JobsJobsJobs/Common/Domain.fs +++ b/src/JobsJobsJobs/Common/Domain.fs @@ -114,7 +114,7 @@ module EmploymentHistory = let empty = { Employer = "" - StartDate = LocalDate.MinIsoValue + StartDate = LocalDate.FromDateTime DateTime.Today EndDate = None Position = None Description = None diff --git a/src/JobsJobsJobs/Profiles/Domain.fs b/src/JobsJobsJobs/Profiles/Domain.fs index c817537..3c352d4 100644 --- a/src/JobsJobsJobs/Profiles/Domain.fs +++ b/src/JobsJobsJobs/Profiles/Domain.fs @@ -3,27 +3,6 @@ module JobsJobsJobs.Profiles.Domain open JobsJobsJobs.Domain open NodaTime -/// The fields required for a skill -[] -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 [] type EditProfileForm = @@ -80,6 +59,48 @@ module EditProfileForm = } +/// The form used to add or edit employment history entries +[] +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 [] type ProfileSearchForm = @@ -166,3 +187,23 @@ type PublicSearchResult = Skills : string list } + +/// The fields required for a skill +[] +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 } diff --git a/src/JobsJobsJobs/Profiles/Handlers.fs b/src/JobsJobsJobs/Profiles/Handlers.fs index 5b305b3..c18695e 100644 --- a/src/JobsJobsJobs/Profiles/Handlers.fs +++ b/src/JobsJobsJobs/Profiles/Handlers.fs @@ -108,6 +108,13 @@ let skills : HttpHandler = requireUser >=> fun next ctx -> task { | 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 @@ -146,6 +153,59 @@ let deleteSkill idx : HttpHandler = requireUser >=> validateCsrf >=> fun next ct | 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 () + 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 let view citizenId : HttpHandler = fun next ctx -> task { let citId = CitizenId citizenId @@ -167,18 +227,24 @@ open Giraffe.EndpointRouting let endpoints = subRoute "/profile" [ GET_HEAD [ - routef "/%O/view" view - route "/edit" edit - route "/edit/general" editGeneralInfo - routef "/edit/skill/%i" editSkill - route "/edit/skills" skills - route "/search" search - route "/seeking" seeking + routef "/%O/view" view + 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 + route "/seeking" seeking ] POST [ - route "/delete" delete - routef "/edit/skill/%i" saveSkill - routef "/edit/skill/%i/delete" deleteSkill - route "/save" saveGeneralInfo + 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 ] ] diff --git a/src/JobsJobsJobs/Profiles/Views.fs b/src/JobsJobsJobs/Profiles/Views.fs index 18bf197..9279a99 100644 --- a/src/JobsJobsJobs/Profiles/Views.fs +++ b/src/JobsJobsJobs/Profiles/Views.fs @@ -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 = [ h4 [] [ txt $"""{if isNew then "Add a" else "Edit"} Skill""" ] 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 "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 "col-12" ] [ @@ -231,6 +227,120 @@ let skills (skills : Skill list) csrf = 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 = + 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 "     " + a [ _href "/profile/edit/history/list"; _hxGet "/profile/edit/history/list"; _hxTarget "#historyList" + _class "btn btn-secondary" ] [ i [ _class "mdi mdi-cancel"] []; txt "  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 ~~~ //