From e5be1cb85d474a7e8c796bd7450c2b6fb09f1aeb Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 10 Jan 2023 09:02:00 -0500 Subject: [PATCH] WIP on profile edit page - Delete migrated citizen views --- .../src/views/citizen/ConfirmRegistration.vue | 38 ------ .../App/src/views/citizen/Dashboard.vue | 105 ----------------- .../src/views/citizen/DenyRegistration.vue | 37 ------ .../App/src/views/citizen/LogOff.vue | 22 ---- .../App/src/views/citizen/LogOn.vue | 92 --------------- .../App/src/views/citizen/Register.vue | 105 ----------------- .../App/src/views/citizen/Registered.vue | 15 --- src/JobsJobsJobs/Server/Handlers.fs | 43 +++++-- .../Server/JobsJobsJobs.Server.fsproj | 1 + src/JobsJobsJobs/Server/ViewModels.fs | 81 +++++++++++++ src/JobsJobsJobs/Server/Views/Common.fs | 33 ++++++ src/JobsJobsJobs/Server/Views/Profile.fs | 110 ++++++++++++++++++ 12 files changed, 257 insertions(+), 425 deletions(-) delete mode 100644 src/JobsJobsJobs/App/src/views/citizen/ConfirmRegistration.vue delete mode 100644 src/JobsJobsJobs/App/src/views/citizen/Dashboard.vue delete mode 100644 src/JobsJobsJobs/App/src/views/citizen/DenyRegistration.vue delete mode 100644 src/JobsJobsJobs/App/src/views/citizen/LogOff.vue delete mode 100644 src/JobsJobsJobs/App/src/views/citizen/LogOn.vue delete mode 100644 src/JobsJobsJobs/App/src/views/citizen/Register.vue delete mode 100644 src/JobsJobsJobs/App/src/views/citizen/Registered.vue create mode 100644 src/JobsJobsJobs/Server/Views/Profile.fs diff --git a/src/JobsJobsJobs/App/src/views/citizen/ConfirmRegistration.vue b/src/JobsJobsJobs/App/src/views/citizen/ConfirmRegistration.vue deleted file mode 100644 index b846957..0000000 --- a/src/JobsJobsJobs/App/src/views/citizen/ConfirmRegistration.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/views/citizen/Dashboard.vue b/src/JobsJobsJobs/App/src/views/citizen/Dashboard.vue deleted file mode 100644 index dbbc6e7..0000000 --- a/src/JobsJobsJobs/App/src/views/citizen/Dashboard.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/views/citizen/DenyRegistration.vue b/src/JobsJobsJobs/App/src/views/citizen/DenyRegistration.vue deleted file mode 100644 index 6cc243b..0000000 --- a/src/JobsJobsJobs/App/src/views/citizen/DenyRegistration.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/views/citizen/LogOff.vue b/src/JobsJobsJobs/App/src/views/citizen/LogOff.vue deleted file mode 100644 index a8f3879..0000000 --- a/src/JobsJobsJobs/App/src/views/citizen/LogOff.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/views/citizen/LogOn.vue b/src/JobsJobsJobs/App/src/views/citizen/LogOn.vue deleted file mode 100644 index 85a2fcb..0000000 --- a/src/JobsJobsJobs/App/src/views/citizen/LogOn.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/views/citizen/Register.vue b/src/JobsJobsJobs/App/src/views/citizen/Register.vue deleted file mode 100644 index 2706002..0000000 --- a/src/JobsJobsJobs/App/src/views/citizen/Register.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/views/citizen/Registered.vue b/src/JobsJobsJobs/App/src/views/citizen/Registered.vue deleted file mode 100644 index 1262bd8..0000000 --- a/src/JobsJobsJobs/App/src/views/citizen/Registered.vue +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index 3a6e63c..7739a9c 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -54,7 +54,7 @@ module Error = /// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response let notAuthorized : HttpHandler = fun next ctx -> if ctx.Request.Method = "GET" then - let redirectUrl = $"/user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}" + let redirectUrl = $"/citizen/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}" if isHtmx ctx then (withHxRedirect redirectUrl >=> redirectTo false redirectUrl) next ctx else redirectTo false redirectUrl next ctx else @@ -256,7 +256,7 @@ module Citizen = // GET: /citizen/dashboard let dashboard = requireUser >=> fun next ctx -> task { - let citizenId = CitizenId.ofString (tryUser ctx).Value + let citizenId = currentCitizenId ctx let! citizen = Citizens.findById citizenId let! profile = Profiles.findById citizenId let! prfCount = Profiles.count () @@ -561,10 +561,25 @@ module Listing = } -/// Handlers for /api/profile routes +/// Handlers for /profile routes [] module Profile = + // GET: /profile/edit + let edit = requireUser >=> fun next ctx -> task { + let! profile = Profiles.findById (currentCitizenId ctx) + let! continents = Continents.all () + let isNew = Option.isNone profile + let form = if isNew then EditProfileViewModel.empty else EditProfileViewModel.fromProfile profile.Value + let title = $"""{if isNew then "Create" else "Edit"} Profile""" + return! Profile.edit form continents isNew (csrf ctx) |> render title next ctx + } + + +/// Handlers for /api/profile routes +[] +module ProfileApi = + // GET: /api/profile // This returns the current citizen's profile, or a 204 if it is not found (a citizen not having a profile yet // is not an error). The "get" handler returns a 404 if a profile is not found. @@ -724,7 +739,13 @@ let allEndpoints = [ ] GET_HEAD [ route "/how-it-works" Home.howItWorks ] GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ] + subRoute "/profile" [ + GET_HEAD [ + route "/edit" Profile.edit + ] + ] GET_HEAD [ route "/terms-of-service" Home.termsOfService ] + subRoute "/api" [ subRoute "/citizen" [ GET_HEAD [ routef "/%O" CitizenApi.get ] @@ -749,15 +770,15 @@ let allEndpoints = [ ] subRoute "/profile" [ GET_HEAD [ - route "" Profile.current - route "/count" Profile.count - routef "/%O" Profile.get - routef "/%O/view" Profile.view - route "/public-search" Profile.publicSearch - route "/search" Profile.search + route "" ProfileApi.current + route "/count" ProfileApi.count + routef "/%O" ProfileApi.get + routef "/%O/view" ProfileApi.view + route "/public-search" ProfileApi.publicSearch + route "/search" ProfileApi.search ] - PATCH [ route "/employment-found" Profile.employmentFound ] - POST [ route "" Profile.save ] + PATCH [ route "/employment-found" ProfileApi.employmentFound ] + POST [ route "" ProfileApi.save ] ] subRoute "/success" [ GET_HEAD [ diff --git a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj index 705864d..b3facc3 100644 --- a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj +++ b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj @@ -15,6 +15,7 @@ + diff --git a/src/JobsJobsJobs/Server/ViewModels.fs b/src/JobsJobsJobs/Server/ViewModels.fs index 4a32d57..dcf4fe1 100644 --- a/src/JobsJobsJobs/Server/ViewModels.fs +++ b/src/JobsJobsJobs/Server/ViewModels.fs @@ -1,6 +1,87 @@ /// View models for Jobs, Jobs, Jobs module JobsJobsJobs.ViewModels +open JobsJobsJobs.Domain + +/// The fields required for a skill +[] +type SkillForm = + { /// The ID of this skill + Id : string + + /// The description of the skill + Description : string + + /// Notes regarding the skill + Notes : string option + } + +/// The data required to update a profile +[] +type EditProfileViewModel = + { /// Whether the citizen to whom this profile belongs is actively seeking employment + IsSeekingEmployment : bool + + /// Whether this profile should appear in the public search + IsPublic : bool + + /// 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 + + /// 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 skills for the user + Skills : SkillForm list + } + +/// Support functions for the ProfileForm type +module EditProfileViewModel = + + /// An empty view model (used for new profiles) + let empty = + { IsSeekingEmployment = false + IsPublic = false + ContinentId = "" + Region = "" + RemoteWork = false + FullTime = false + Biography = "" + Experience = None + Skills = [ { Id = ""; Description = ""; Notes = None } ] + } + + /// Create an instance of this form from the given profile + let fromProfile (profile : Profile) = + { IsSeekingEmployment = profile.IsSeekingEmployment + IsPublic = profile.IsPubliclySearchable + ContinentId = string profile.ContinentId + Region = profile.Region + RemoteWork = profile.IsRemote + FullTime = profile.IsFullTime + Biography = MarkdownString.toString profile.Biography + Experience = profile.Experience |> Option.map MarkdownString.toString + Skills = profile.Skills + |> List.map (fun s -> + { Id = string s.Id + Description = s.Description + Notes = s.Notes + }) + } + + /// View model for the log on page [] type LogOnViewModel = diff --git a/src/JobsJobsJobs/Server/Views/Common.fs b/src/JobsJobsJobs/Server/Views/Common.fs index a64bbd6..755879c 100644 --- a/src/JobsJobsJobs/Server/Views/Common.fs +++ b/src/JobsJobsJobs/Server/Views/Common.fs @@ -2,7 +2,9 @@ module JobsJobsJobs.Views.Common open Giraffe.ViewEngine +open Giraffe.ViewEngine.Accessibility open Microsoft.AspNetCore.Antiforgery +open JobsJobsJobs.Domain /// Create an audio clip with the specified text node let audioClip clip text = @@ -13,3 +15,34 @@ let audioClip clip text = /// Create an anti-forgery hidden input let antiForgery (csrf : AntiforgeryTokenSet) = input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ] + +/// Create a select list of continents +let continentList attrs name (continents : Continent list) emptyLabel selectedValue = + div [ _class "form-floating" ] [ + select (List.append attrs [ _id name; _name name; _class "form-select" ]) ( + option [ _value ""; if selectedValue = "" then _selected ] [ + rawText $"""– {defaultArg emptyLabel "Select"} –""" ] + :: (continents + |> List.map (fun c -> + let theId = ContinentId.toString c.Id + option [ _value theId; if theId = selectedValue then _selected ] [ str c.Name ]))) + label [ _class "jjj-required"; _for name ] [ rawText "Continent" ] + ] + +/// Create a Markdown editor +let markdownEditor attrs name value editorLabel = + div [ _class "col-12" ] [ + nav [ _class "nav nav-pills pb-1" ] [ + button [ _type "button"; _class "jjj-md-source"; _onclick "jjj.showMarkdown('TODO')" ] [ + rawText "Markdown" + ]; rawText "   " + button [ _type "button"; _class "jjj-md-preview"; _onclick "jjj.showPreview('TODO')" ] [ rawText "Preview" ] + ] + section [ _id $"{name}Preview"; _class "jjj-preview"; _ariaLabel "Rendered Markdown preview" ] [] + div [ _class "form-floating" ] [ + textarea (List.append attrs [ _id name; _name name; _class "form-control md-edit"; _rows "10" ]) [ + rawText value + ] + label [ _for name ] [ rawText editorLabel ] + ] + ] diff --git a/src/JobsJobsJobs/Server/Views/Profile.fs b/src/JobsJobsJobs/Server/Views/Profile.fs new file mode 100644 index 0000000..29e998e --- /dev/null +++ b/src/JobsJobsJobs/Server/Views/Profile.fs @@ -0,0 +1,110 @@ +/// Views for /profile URLs +[] +module JobsJobsJobs.Views.Profile + +open Giraffe +open Giraffe.ViewEngine +open Giraffe.ViewEngine.Htmx +open JobsJobsJobs.ViewModels + +/// The profile edit page +let edit (m : EditProfileViewModel) continents isNew csrf = + article [] [ + h3 [ _class "pb-3" ] [ rawText "My Employment Profile" ] + form [ _class "row g-3"; _action "/profile/edit"; _hxPost "/profile/edit" ] [ + antiForgery csrf + div [ _class "col-12" ] [ + div [ _class "form-check" ] [ + input [ _type "checkbox"; _id (nameof m.IsSeekingEmployment); _name (nameof m.IsSeekingEmployment) + _class "form-check-input"; if m.IsSeekingEmployment then _checked ] + label [ _class "form-check-label"; _for (nameof m.IsSeekingEmployment) ] [ + rawText "I am currently seeking employment" + ] + ] + if m.IsSeekingEmployment then + p [] [ + em [] [ + rawText "If you have found employment, consider " + a [ _href "/success-story/new/edit" ] [ rawText "telling your fellow citizens about it!" ] + ] + ] + ] + div [ _class "col-12 col-sm-6 col-md-4" ] [ + continentList [ _required ] (nameof m.ContinentId) continents None m.ContinentId + ] + div [ _class "col-12 col-sm-6 col-md-8" ] [ + div [ _class "form-floating" ] [ + input [ _type "text"; _id (nameof m.Region); _name (nameof m.Region); _class "form-control" + _maxlength "255"; _placeholder "Country, state, geographic area, etc."; _required ] + label [ _class "jjj-required"; _for (m.Region) ] [ rawText "Region" ] + ] + div [ _class "form-text" ] [ rawText "Country, state, geographic area, etc." ] + ] + markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography" + div [ _class "col-12 col-offset-md-2 col-md-4" ] [ + div [ _class "form-check" ] [ + input [ _type "checkbox"; _id (nameof m.RemoteWork); _name (nameof m.RemoteWork) + _class "form-check-input"; if m.RemoteWork then _checked ] + label [ _class "form-check-label"; _for (nameof m.RemoteWork) ] [ + rawText "I am looking for remote work" + ] + ] + ] + div [ _class "col-12 col-md-4" ] [ + div [ _class "form-check" ] [ + input [ _type "checkbox"; _id (nameof m.FullTime); _name (nameof m.FullTime) + _class "form-check-input"; if m.FullTime then _checked ] + label [ _class "form-check-label"; _for (nameof m.FullTime) ] [ + rawText "I am looking for full-time work" + ] + ] + ] + div [ _class "col-12" ] [ + hr [] + h4 [ _class "pb-2" ] [ + rawText "Skills   " + button [ _class "btn btn-sm btn-outline-primary rounded-pill"; _onclick "jjj.addSkill" ] [ + rawText "Add a Skill" + ] + ] + ] + // + div [ _class "col-12" ] [ + hr [] + h4 [] [ rawText "Experience" ] + p [] [ + rawText "This application does not have a place to individually list your chronological job " + rawText "history; however, you can use this area to list prior jobs, their dates, and anything " + rawText "else you want to include that’s not already a part of your Professional Biography " + rawText "above." + ] + ] + markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience" + div [ _class "col-12" ] [ + div [ _class "form-check" ] [ + input [ _type "checkbox"; _id (nameof m.IsPublic); _name (nameof m.IsPublic) + _class "form-check-input"; if m.IsPublic then _checked ] + label [ _class "form-check-label"; _for (nameof m.IsPublic) ] [ + rawText "Allow my profile to be searched publicly" + ] + ] + ] + div [ _class "col-12" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ + i [ _class "mdi mdi-content-save-outline" ] []; rawText "  Save" + ] + if not isNew then + rawText "    " + a [ _class "btn btn-outline-secondary"; _href "`/profile/${user.citizenId}/view`" ] [ + i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] [] + rawText "  View Your User Profile" + ] + ] + ] + hr [] + p [ _class "text-muted fst-italic" ] [ + rawText "(If you want to delete your profile, or your entire account, " + a [ _href "/so-long/options" ] [ rawText "see your deletion options here" ]; rawText ".)" + ] + ]