From 77ea839b9c6348a5a9ca29e9459e29dce947e8a9 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 22 Jan 2023 21:28:51 -0500 Subject: [PATCH] Finish employment history / view / print (#39) - Add isHtmx flag for page load JS --- .../Application/wwwroot/style.css | 5 ++ src/JobsJobsJobs/Citizens/Handlers.fs | 15 ++-- src/JobsJobsJobs/Citizens/Views.fs | 12 +-- src/JobsJobsJobs/Common/Handlers.fs | 31 ++++--- src/JobsJobsJobs/Common/Views.fs | 46 ++++++++-- src/JobsJobsJobs/Listings/Handlers.fs | 4 +- src/JobsJobsJobs/Listings/Views.fs | 10 +-- src/JobsJobsJobs/Profiles/Handlers.fs | 32 +++++-- src/JobsJobsJobs/Profiles/Views.fs | 87 ++++++++++++++----- src/JobsJobsJobs/SuccessStories/Handlers.fs | 3 +- src/JobsJobsJobs/SuccessStories/Views.fs | 4 +- 11 files changed, 176 insertions(+), 73 deletions(-) diff --git a/src/JobsJobsJobs/Application/wwwroot/style.css b/src/JobsJobsJobs/Application/wwwroot/style.css index 4e4c277..8fec34e 100644 --- a/src/JobsJobsJobs/Application/wwwroot/style.css +++ b/src/JobsJobsJobs/Application/wwwroot/style.css @@ -24,6 +24,11 @@ label[for]:hover { .material-icons { vertical-align: bottom; } +@media print { + .jjj-hide-from-printer { + display: none; + } +} /* Material Design Icon / Bootstrap styling */ .mdi::before { font-size: 24px; diff --git a/src/JobsJobsJobs/Citizens/Handlers.fs b/src/JobsJobsJobs/Citizens/Handlers.fs index bc892b0..75e367e 100644 --- a/src/JobsJobsJobs/Citizens/Handlers.fs +++ b/src/JobsJobsJobs/Citizens/Handlers.fs @@ -64,7 +64,9 @@ module private Auth = let account : HttpHandler = fun next ctx -> task { match! Data.findById (currentCitizenId ctx) with | Some citizen -> - return! Views.account (AccountProfileForm.fromCitizen citizen) (csrf ctx) |> render "Account Profile" next ctx + return! + Views.account (AccountProfileForm.fromCitizen citizen) (isHtmx ctx) (csrf ctx) + |> render "Account Profile" next ctx | None -> return! Error.notFound next ctx } @@ -176,7 +178,7 @@ let register next ctx = q2Index <- System.Random.Shared.Next(0, 5) let qAndA = Auth.questions ctx Views.register (fst qAndA[q1Index]) (fst qAndA[q2Index]) - { RegisterForm.empty with Question1Index = q1Index; Question2Index = q2Index } (csrf ctx) + { RegisterForm.empty with Question1Index = q1Index; Question2Index = q2Index } (isHtmx ctx) (csrf ctx) |> render "Register" next ctx // POST: /citizen/register @@ -199,7 +201,7 @@ let doRegistration : HttpHandler = validateCsrf >=> fun next ctx -> task { ] let refreshPage () = Views.register (fst qAndA[form.Question1Index]) (fst qAndA[form.Question2Index]) { form with Password = "" } - (csrf ctx) + (isHtmx ctx) (csrf ctx) |> renderHandler "Register" if badForm then @@ -246,7 +248,8 @@ let resetPassword token : HttpHandler = fun next ctx -> task { match! Data.trySecurityByToken token with | Some security -> return! - Views.resetPassword { Id = CitizenId.toString security.Id; Token = token; Password = "" } (csrf ctx) + Views.resetPassword { Id = CitizenId.toString security.Id; Token = token; Password = "" } (isHtmx ctx) + (csrf ctx) |> render "Reset Password" next ctx | None -> return! Error.notFound next ctx } @@ -273,7 +276,7 @@ let doResetPassword : HttpHandler = validateCsrf >=> fun next ctx -> task { | None -> return! Error.notFound next ctx else do! addErrors errors ctx - return! Views.resetPassword form (csrf ctx) |> render "Reset Password" next ctx + return! Views.resetPassword form (isHtmx ctx) (csrf ctx) |> render "Reset Password" next ctx } // POST: /citizen/save-account @@ -314,7 +317,7 @@ let saveAccount : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> | None -> return! Error.notFound next ctx else do! addErrors errors ctx - return! Views.account form (csrf ctx) |> render "Account Profile" next ctx + return! Views.account form (isHtmx ctx) (csrf ctx) |> render "Account Profile" next ctx } // GET: /citizen/so-long diff --git a/src/JobsJobsJobs/Citizens/Views.fs b/src/JobsJobsJobs/Citizens/Views.fs index 8330ac5..dffb5b2 100644 --- a/src/JobsJobsJobs/Citizens/Views.fs +++ b/src/JobsJobsJobs/Citizens/Views.fs @@ -61,7 +61,7 @@ let contactEdit (contacts : OtherContactForm array) = :: (contacts |> Array.mapi mapToInputs |> List.ofArray) /// The account edit page -let account (m : AccountProfileForm) csrf = +let account (m : AccountProfileForm) isHtmx csrf = pageWithTitle "Account Profile" [ p [] [ txt "This information is visible to all fellow logged-on citizens. For publicly-visible employment " @@ -107,7 +107,7 @@ let account (m : AccountProfileForm) csrf = ] jsOnLoad $" jjj.citizen.nextIndex = {m.Contacts.Length} - jjj.citizen.validatePasswords('{nameof m.NewPassword}', '{nameof m.NewPasswordConfirm}', false)" + jjj.citizen.validatePasswords('{nameof m.NewPassword}', '{nameof m.NewPasswordConfirm}', false)" isHtmx ] @@ -305,7 +305,7 @@ let logOn (m : LogOnForm) csrf = ] /// The registration page -let register q1 q2 (m : RegisterForm) csrf = +let register q1 q2 (m : RegisterForm) isHtmx csrf = pageWithTitle "Register" [ form [ _class "row g-3"; _hxPost "/citizen/register" ] [ antiForgery csrf @@ -343,7 +343,7 @@ let register q1 q2 (m : RegisterForm) csrf = input [ _type "hidden"; _name (nameof m.Question2Index); _value (string m.Question2Index ) ] ] div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ] - jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" + jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" isHtmx ] ] @@ -376,7 +376,7 @@ let resetCanceled wasCanceled = /// The password reset page -let resetPassword (m : ResetPasswordForm) csrf = +let resetPassword (m : ResetPasswordForm) isHtmx csrf = pageWithTitle "Reset Password" [ p [] [ txt "Enter your new password in the fields below" ] form [ _class "row g-3"; _method "POST"; _action "/citizen/reset-password" ] [ @@ -390,6 +390,6 @@ let resetPassword (m : ResetPasswordForm) csrf = textBox [ _type "password"; _minlength "8" ] "ConfirmPassword" "" "Confirm New Password" true ] div [ _class "col-12" ] [ submitButton "lock-reset" "Reset Password" ] - jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" + jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" isHtmx ] ] diff --git a/src/JobsJobsJobs/Common/Handlers.fs b/src/JobsJobsJobs/Common/Handlers.fs index d699379..1701923 100644 --- a/src/JobsJobsJobs/Common/Handlers.fs +++ b/src/JobsJobsJobs/Common/Handlers.fs @@ -7,7 +7,7 @@ open Microsoft.AspNetCore.Http open Microsoft.Extensions.Logging [] -module private HtmxHelpers = +module HtmxHelpers = /// Is the request from htmx? let isHtmx (ctx : HttpContext) = @@ -145,27 +145,32 @@ let addErrors (errors : string list) ctx = task { open JobsJobsJobs.Common.Views -/// Render a page-level view -let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task { - let! messages = popMessages ctx - let renderCtx : Layout.PageRenderContext = { - IsLoggedOn = Option.isSome (tryUser ctx) +/// Create the render context for an HTML response +let private createContext (ctx : HttpContext) pageTitle content messages : Layout.PageRenderContext = + { IsLoggedOn = Option.isSome (tryUser ctx) CurrentUrl = ctx.Request.Path.Value PageTitle = pageTitle Content = content Messages = messages } - let renderFunc = if isHtmx ctx then Layout.partial else Layout.full + +/// Render a page-level view +let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task { + let! messages = popMessages ctx + let renderCtx = createContext ctx pageTitle content messages + let renderFunc = if isHtmx ctx then Layout.partial else Layout.full return! ctx.WriteHtmlViewAsync (renderFunc renderCtx) } +/// Render a printable view (content with styles, but no layout) +let renderPrint pageTitle (_ : HttpFunc) (ctx : HttpContext) content = + createContext ctx pageTitle content [] + |> Layout.print + |> ctx.WriteHtmlViewAsync + +/// Render a bare (component) view let renderBare (_ : HttpFunc) (ctx : HttpContext) content = - ({ IsLoggedOn = Option.isSome (tryUser ctx) - CurrentUrl = ctx.Request.Path.Value - PageTitle = "" - Content = content - Messages = [] - } : Layout.PageRenderContext) + createContext ctx "" content [] |> Layout.bare |> ctx.WriteHtmlViewAsync diff --git a/src/JobsJobsJobs/Common/Views.fs b/src/JobsJobsJobs/Common/Views.fs index 170ffa4..693e9ab 100644 --- a/src/JobsJobsJobs/Common/Views.fs +++ b/src/JobsJobsJobs/Common/Views.fs @@ -68,11 +68,14 @@ let emptyP = p [] [ txt " " ] /// Register JavaScript code to run in the DOMContentLoaded event on the page -let jsOnLoad js = - script [] [ txt """document.addEventListener("DOMContentLoaded", function () { """; txt js; txt " })" ] +let jsOnLoad js isHtmx = + script [] [ + let (target, event) = if isHtmx then "document.body", "htmx:afterSettle" else "document", "DOMContentLoaded" + txt (sprintf """%s.addEventListener("%s", () => { %s }, { once: true })""" target event js) + ] /// Create a Markdown editor -let markdownEditor attrs name value editorLabel = +let markdownEditor attrs name value editorLabel isHtmx = div [ _class "col-12"; _id $"{name}EditRow" ] [ nav [ _class "nav nav-pills pb-1" ] [ button [ _type "button"; _id $"{name}EditButton"; _class "btn btn-primary btn-sm rounded-pill" ] [ @@ -93,7 +96,7 @@ let markdownEditor attrs name value editorLabel = ] label [ _for name ] [ txt editorLabel ] ] - jsOnLoad $"jjj.markdownOnLoad('{name}')" + jsOnLoad $"jjj.markdownOnLoad('{name}')" isHtmx ] /// Wrap content in a collapsing panel @@ -124,22 +127,44 @@ let contactInfo citizen isPublic = |> List.collect (fun contact -> match contact.ContactType with | Website -> - [ i [ _class "mdi mdi-sm mdi-web" ] []; rawText " " + [ i [ _class "mdi mdi-sm mdi-web" ] []; txt " " a [ _href contact.Value; _target "_blank"; _rel "noopener"; _class "me-4" ] [ str (defaultArg contact.Name "Website") ] ] | Email -> - [ i [ _class "mdi mdi-sm mdi-email-outline" ] []; rawText " " + [ i [ _class "mdi mdi-sm mdi-email-outline" ] []; txt " " a [ _href $"mailto:{contact.Value}"; _class "me-4" ] [ str (defaultArg contact.Name "E-mail") ] ] | Phone -> [ span [ _class "me-4" ] [ - i [ _class "mdi mdi-sm mdi-phone" ] []; rawText " "; str contact.Value + i [ _class "mdi mdi-sm mdi-phone" ] []; txt " "; str contact.Value match contact.Name with Some name -> str $" ({name})" | None -> () ] ]) +/// Display a citizen's contact information +let contactInfoPrint citizen isPublic = + citizen.OtherContacts + |> List.filter (fun it -> (isPublic && it.IsPublic) || not isPublic) + |> List.collect (fun contact -> + match contact.ContactType with + | Website -> + [ i [ _class "mdi mdi-sm mdi-web" ] []; txt " "; str (defaultArg contact.Name "Website"); txt " – " + str contact.Value; br [] + ] + | Email -> + [ i [ _class "mdi mdi-sm mdi-email-outline" ] []; txt " "; str (defaultArg contact.Name "E-mail") + txt " – "; str contact.Value; br [] + ] + | Phone -> + [ span [ _class "me-4" ] [ + i [ _class "mdi mdi-sm mdi-phone" ] []; rawText " " + match contact.Name with Some name -> str name; txt " – " | None -> () + str contact.Value; br [] + ] + ]) + open NodaTime open NodaTime.Text @@ -353,6 +378,13 @@ module Layout = ] ] + /// Render a print view (styles, but no other layout) + let print ctx = + html [ _lang "en" ] [ + htmlHead ctx + body [ _class "m-1" ] [ ctx.Content ] + ] + /// Render a bare view (used for components) let bare ctx = html [ _lang "en" ] [ diff --git a/src/JobsJobsJobs/Listings/Handlers.fs b/src/JobsJobsJobs/Listings/Handlers.fs index 03b3c68..5ed3422 100644 --- a/src/JobsJobsJobs/Listings/Handlers.fs +++ b/src/JobsJobsJobs/Listings/Handlers.fs @@ -23,7 +23,7 @@ let edit listId : HttpHandler = requireUser >=> fun next ctx -> task { | Some listing when listing.CitizenId = citizenId -> let! continents = Common.Data.Continents.all () return! - Views.edit (EditListingForm.fromListing listing listId) continents (listId = "new") (csrf ctx) + Views.edit (EditListingForm.fromListing listing listId) continents (listId = "new") (isHtmx ctx) (csrf ctx) |> render $"""{if listId = "new" then "Add a" else "Edit"} Job Listing""" next ctx | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx @@ -38,7 +38,7 @@ let expire listingId : HttpHandler = requireUser >=> fun next ctx -> task { return! redirectToGet "/listings/mine" next ctx else let form = { Id = ListingId.toString listing.Id; FromHere = false; SuccessStory = "" } - return! Views.expire form listing (csrf ctx) |> render "Expire Job Listing" next ctx + return! Views.expire form listing (isHtmx ctx) (csrf ctx) |> render "Expire Job Listing" next ctx | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } diff --git a/src/JobsJobsJobs/Listings/Views.fs b/src/JobsJobsJobs/Listings/Views.fs index 5f602bc..b485593 100644 --- a/src/JobsJobsJobs/Listings/Views.fs +++ b/src/JobsJobsJobs/Listings/Views.fs @@ -8,7 +8,7 @@ open JobsJobsJobs.Listings.Domain /// Job listing edit page -let edit (m : EditListingForm) continents isNew csrf = +let edit (m : EditListingForm) continents isNew isHtmx csrf = pageWithTitle $"""{if isNew then "Add a" else "Edit"} Job Listing""" [ form [ _class "row g-3"; _method "POST"; _action "/listing/save" ] [ antiForgery csrf @@ -29,7 +29,7 @@ let edit (m : EditListingForm) continents isNew csrf = div [ _class "col-12" ] [ checkBox [] (nameof m.RemoteWork) m.RemoteWork "This opportunity is for remote work" ] - markdownEditor [ _required ] (nameof m.Text) m.Text "Job Description" + markdownEditor [ _required ] (nameof m.Text) m.Text "Job Description" isHtmx div [ _class "col-12 col-md-4" ] [ textBox [ _type "date" ] (nameof m.NeededBy) m.NeededBy "Needed By" false ] @@ -41,7 +41,7 @@ let edit (m : EditListingForm) continents isNew csrf = open System.Net /// Page to expire a job listing -let expire (m : ExpireListingForm) (listing : Listing) csrf = +let expire (m : ExpireListingForm) (listing : Listing) isHtmx csrf = pageWithTitle $"Expire Job Listing ({WebUtility.HtmlEncode listing.Title})" [ p [ _class "fst-italic" ] [ txt "Expiring this listing will remove it from search results. You will be able to see it via your " @@ -60,10 +60,10 @@ let expire (m : ExpireListingForm) (listing : Listing) csrf = txt "visible to logged-on users here, but not to the general public." ] ] - markdownEditor [] (nameof m.SuccessStory) m.SuccessStory "Your Success Story" + markdownEditor [] (nameof m.SuccessStory) m.SuccessStory "Your Success Story" isHtmx div [ _class "col-12" ] [ submitButton "text-box-remove-outline" "Expire Listing" ] ] - jsOnLoad "jjj.listing.toggleFromHere()" + jsOnLoad "jjj.listing.toggleFromHere()" isHtmx ] diff --git a/src/JobsJobsJobs/Profiles/Handlers.fs b/src/JobsJobsJobs/Profiles/Handlers.fs index c18695e..6bce6c8 100644 --- a/src/JobsJobsJobs/Profiles/Handlers.fs +++ b/src/JobsJobsJobs/Profiles/Handlers.fs @@ -27,7 +27,8 @@ let editGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task { 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 (csrf ctx) |> render "General Information | Employment Profile" next ctx + Views.editGeneralInfo form continents (isHtmx ctx) (csrf ctx) + |> render "General Information | Employment Profile" next ctx } // POST: /profile/save @@ -65,7 +66,7 @@ let saveGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task { do! addErrors errors ctx let! continents = Common.Data.Continents.all () return! - Views.editGeneralInfo form continents (csrf ctx) + Views.editGeneralInfo form continents (isHtmx ctx) (csrf ctx) |> render "General Information | Employment Profile" next ctx } @@ -157,14 +158,16 @@ let deleteSkill idx : HttpHandler = requireUser >=> validateCsrf >=> fun next ct 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 + 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 (csrf ctx) |> renderBare next ctx + | Some profile -> return! Views.historyTable profile.History None (isHtmx ctx) (csrf ctx) |> renderBare next ctx | None -> return! notFound ctx } @@ -173,7 +176,7 @@ 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 + else return! Views.editHistory profile.History idx (isHtmx ctx) (csrf ctx) |> renderBare next ctx | None -> return! notFound ctx } @@ -190,7 +193,7 @@ let saveHistory idx : HttpHandler = requireUser >=> validateCsrf >=> fun next ct 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 + return! Views.historyTable history None (isHtmx ctx) (csrf ctx) |> renderBare next ctx | None -> return! notFound ctx } @@ -202,7 +205,7 @@ let deleteHistory idx : HttpHandler = requireUser >=> validateCsrf >=> fun next 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 + return! Views.historyTable history None (isHtmx ctx) (csrf ctx) |> renderBare next ctx | None -> return! notFound ctx } @@ -220,6 +223,20 @@ let view citizenId : HttpHandler = fun next ctx -> task { | None -> return! notFound ctx } +// GET: /profile/[id]/print +let print citizenId : HttpHandler = fun next ctx -> task { + let citId = CitizenId citizenId + match! Data.findByIdForView citId with + | Some profile -> + let currentCitizen = tryUser ctx |> Option.map CitizenId.ofString + if not (profile.Profile.Visibility = Public) && Option.isNone currentCitizen then + return! Error.notAuthorized next ctx + else + 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 @@ -228,6 +245,7 @@ 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 diff --git a/src/JobsJobsJobs/Profiles/Views.fs b/src/JobsJobsJobs/Profiles/Views.fs index 9279a99..0ad9dfa 100644 --- a/src/JobsJobsJobs/Profiles/Views.fs +++ b/src/JobsJobsJobs/Profiles/Views.fs @@ -62,7 +62,7 @@ let backToEdit = /// The profile edit page -let editGeneralInfo (m : EditProfileForm) continents csrf = +let editGeneralInfo (m : EditProfileForm) continents isHtmx csrf = pageWithTitle "Employment Profile: General Information" [ backToEdit form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [ @@ -88,7 +88,7 @@ let editGeneralInfo (m : EditProfileForm) continents csrf = 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" + markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography" isHtmx div [ _class "col-12" ] [ hr [] h4 [] [ txt "Experience" ] @@ -98,7 +98,7 @@ let editGeneralInfo (m : EditProfileForm) continents csrf = txt "entries, provide closing notes, etc." ] ] - markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience" + markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience" isHtmx div [ _class "col-12" ] [ hr [] h4 [] [ txt "Visibility" ] @@ -230,7 +230,7 @@ let editSkill (skills : Skill list) idx csrf = open NodaTime /// Render the employment history edit template -let historyForm (m : HistoryForm) isNew = +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" ] [ @@ -239,7 +239,7 @@ let historyForm (m : HistoryForm) isNew = 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" ] [ + 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" ] [ @@ -248,7 +248,7 @@ let historyForm (m : HistoryForm) isNew = 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" + 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" @@ -256,19 +256,19 @@ let historyForm (m : HistoryForm) isNew = ] ] +let private monthAndYear = Text.LocalDatePattern.CreateWithInvariantCulture "MMMM yyyy" /// 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 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) + yield! historyForm (HistoryForm.fromHistory entry) (idx = -1) isHtmx ] ] ] @@ -314,7 +314,7 @@ let historyTable (history : EmploymentHistory list) editIdx csrf = 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 + yield! historyForm (HistoryForm.fromHistory EmploymentHistory.empty) true isHtmx ] else if isEditing then div [ _id "historyList" ] [ renderTable () ] else // not editing, there is history to show @@ -325,7 +325,7 @@ let historyTable (history : EmploymentHistory list) editIdx csrf = /// The employment history maintenance page -let history (history : EmploymentHistory list) csrf = +let history (history : EmploymentHistory list) isHtmx csrf = pageWithTitle "Employment Profile: Employment History" [ backToEdit p [] [ @@ -334,7 +334,7 @@ let history (history : EmploymentHistory list) csrf = txt "Add an Employment History Entry" ] ] - historyTable history None csrf + historyTable history None isHtmx csrf ] @@ -507,11 +507,9 @@ let search (m : ProfileSearchForm) continents tz (results : ProfileSearchResult | None -> () ] - -/// Profile view template -let view (it : ProfileForView) currentId = - article [] [ - h2 [] [ +/// 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" ] [ @@ -519,17 +517,15 @@ let view (it : ProfileForView) currentId = ] ] h4 [] [ str $"{it.Continent.Name}, {it.Profile.Region}" ] - contactInfo it.Citizen (Option.isNone currentId) + (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" ] - hr [] div [] [ md2html it.Profile.Biography ] if not (List.isEmpty it.Profile.Skills) then - hr [] - h4 [ _class "pb-3" ] [ txt "Skills" ] + h4 [ _class "pb-3 border-top border-3" ] [ txt "Skills" ] it.Profile.Skills |> List.map (fun skill -> li [] [ @@ -537,12 +533,55 @@ let view (it : ProfileForView) currentId = 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) ]; txt " to " + match entry.EndDate with + | Some dt -> span [ _class "text-nowrap" ] [ str (monthAndYear.Format dt) ] + | None -> txt "Present" + ] + ] + match entry.Description with Some d -> div [] [ md2html d ] | None -> () + ]) match it.Profile.Experience with - | Some exp -> hr []; h4 [ _class "pb-3" ] [ txt "Experience / Employment History" ]; div [] [ md2html exp ] + | 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" + ] ] diff --git a/src/JobsJobsJobs/SuccessStories/Handlers.fs b/src/JobsJobsJobs/SuccessStories/Handlers.fs index 54618e9..f556a17 100644 --- a/src/JobsJobsJobs/SuccessStories/Handlers.fs +++ b/src/JobsJobsJobs/SuccessStories/Handlers.fs @@ -19,7 +19,8 @@ let edit successId : HttpHandler = requireUser >=> fun next ctx -> task { | Some success when success.CitizenId = citizenId -> let pgTitle = $"""{if isNew then "Tell Your" else "Edit"} Success Story""" return! - Views.edit (EditSuccessForm.fromSuccess success) (success.Id = SuccessId Guid.Empty) pgTitle (csrf ctx) + Views.edit (EditSuccessForm.fromSuccess success) (success.Id = SuccessId Guid.Empty) pgTitle (isHtmx ctx) + (csrf ctx) |> render pgTitle next ctx | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx diff --git a/src/JobsJobsJobs/SuccessStories/Views.fs b/src/JobsJobsJobs/SuccessStories/Views.fs index 03b115a..6ddf38e 100644 --- a/src/JobsJobsJobs/SuccessStories/Views.fs +++ b/src/JobsJobsJobs/SuccessStories/Views.fs @@ -7,7 +7,7 @@ open JobsJobsJobs.Domain open JobsJobsJobs.SuccessStories.Domain /// The add/edit success story page -let edit (m : EditSuccessForm) isNew pgTitle csrf = +let edit (m : EditSuccessForm) isNew pgTitle isHtmx csrf = pageWithTitle pgTitle [ if isNew then p [] [ @@ -21,7 +21,7 @@ let edit (m : EditSuccessForm) isNew pgTitle csrf = div [ _class "col-12" ] [ checkBox [] (nameof m.FromHere) m.FromHere "I found my employment here" ] - markdownEditor [] (nameof m.Story) m.Story "The Success Story" + markdownEditor [] (nameof m.Story) m.Story "The Success Story" isHtmx div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" if isNew then