From ccd7311e7455d19f385db583b35a2db5d37153fd Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 16 Jan 2023 08:44:09 -0500 Subject: [PATCH] Migrate success story pages --- .../App/src/views/success-story/StoryEdit.vue | 120 --------------- .../App/src/views/success-story/StoryList.vue | 61 -------- .../App/src/views/success-story/StoryView.vue | 67 --------- src/JobsJobsJobs/Domain/SharedTypes.fs | 16 +- src/JobsJobsJobs/Server/Handlers.fs | 138 +++++++++--------- .../Server/JobsJobsJobs.Server.fsproj | 1 + src/JobsJobsJobs/Server/ViewModels.fs | 24 +++ src/JobsJobsJobs/Server/Views/Citizen.fs | 4 +- src/JobsJobsJobs/Server/Views/Layout.fs | 8 +- src/JobsJobsJobs/Server/Views/Success.fs | 89 +++++++++++ 10 files changed, 189 insertions(+), 339 deletions(-) delete mode 100644 src/JobsJobsJobs/App/src/views/success-story/StoryEdit.vue delete mode 100644 src/JobsJobsJobs/App/src/views/success-story/StoryList.vue delete mode 100644 src/JobsJobsJobs/App/src/views/success-story/StoryView.vue create mode 100644 src/JobsJobsJobs/Server/Views/Success.fs diff --git a/src/JobsJobsJobs/App/src/views/success-story/StoryEdit.vue b/src/JobsJobsJobs/App/src/views/success-story/StoryEdit.vue deleted file mode 100644 index 170ee66..0000000 --- a/src/JobsJobsJobs/App/src/views/success-story/StoryEdit.vue +++ /dev/null @@ -1,120 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/views/success-story/StoryList.vue b/src/JobsJobsJobs/App/src/views/success-story/StoryList.vue deleted file mode 100644 index 8e1993f..0000000 --- a/src/JobsJobsJobs/App/src/views/success-story/StoryList.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/views/success-story/StoryView.vue b/src/JobsJobsJobs/App/src/views/success-story/StoryView.vue deleted file mode 100644 index 8b2ae99..0000000 --- a/src/JobsJobsJobs/App/src/views/success-story/StoryView.vue +++ /dev/null @@ -1,67 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs index dd5d344..0ac70b3 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -130,7 +130,7 @@ type ProfileForView = /// The parameters for a public job search -[] +[] type PublicSearchForm = { /// Retrieve citizens from this continent ContinentId : string @@ -163,20 +163,8 @@ type PublicSearchResult = } -/// The data required to provide a success story -type StoryForm = - { /// The ID of this story - Id : string - - /// Whether the employment was obtained from Jobs, Jobs, Jobs - FromHere : bool - - /// The success story - Story : string - } - - /// An entry in the list of success stories +[] type StoryEntry = { /// The ID of this success story Id : SuccessId diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index 73bd375..6f35a42 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -264,7 +264,7 @@ module Citizen = let! citizen = Citizens.findById citizenId let! profile = Profiles.findById citizenId let! prfCount = Profiles.count () - return! Citizen.dashboard citizen.Value profile prfCount |> render "Dashboard" next ctx + return! Citizen.dashboard citizen.Value profile prfCount (timeZone ctx) |> render "Dashboard" next ctx } // POST: /citizen/delete @@ -425,17 +425,6 @@ module CitizenApi = } -/// Handlers for /api/continent routes -[] -module Continent = - - // GET: /api/continent/all - let all : HttpHandler = fun next ctx -> task { - let! continents = Continents.all () - return! json continents next ctx - } - - /// Handlers for the home page, legal stuff, and help [] module Home = @@ -718,64 +707,74 @@ module Profile = } -/// Handlers for /api/profile routes -[] -module ProfileApi = - - // PATCH: /api/profile/employment-found - let employmentFound : HttpHandler = authorize >=> fun next ctx -> task { - match! Profiles.findById (currentCitizenId ctx) with - | Some profile -> - do! Profiles.save { profile with IsSeekingEmployment = false } - return! ok next ctx - | None -> return! Error.notFound next ctx - } - - -/// Handlers for /api/success routes +/// Handlers for /success-stor[y|ies] routes [] module Success = - // GET: /api/success/[id] - let get successId : HttpHandler = authorize >=> fun next ctx -> task { - match! Successes.findById (SuccessId successId) with - | Some story -> return! json story next ctx + // GET: /success-story/[id]/edit + let edit successId : HttpHandler = requireUser >=> fun next ctx -> task { + let citizenId = currentCitizenId ctx + let isNew = successId = "new" + let! theSuccess = task { + if isNew then return Some { Success.empty with CitizenId = citizenId } + else return! Successes.findById (SuccessId.ofString successId) + } + match theSuccess with + | Some success when success.CitizenId = citizenId -> + let pgTitle = $"""{if isNew then "Tell Your" else "Edit"} Success Story""" + return! + Success.edit (EditSuccessForm.fromSuccess success) (success.Id = SuccessId Guid.Empty) pgTitle + (csrf ctx) + |> render pgTitle next ctx + | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } - // GET: /api/success/list - let all : HttpHandler = authorize >=> fun next ctx -> task { + // GET: /success-stories + let list : HttpHandler = requireUser >=> fun next ctx -> task { let! stories = Successes.all () - return! json stories next ctx + return! Success.list stories (currentCitizenId ctx) (timeZone ctx) |> render "Success Stories" next ctx } - // POST: /api/success/save - let save : HttpHandler = authorize >=> fun next ctx -> task { - let citizenId = currentCitizenId ctx - let! form = ctx.BindJsonAsync () - let! success = task { - match form.Id with - | "new" -> - return Some { Id = SuccessId.create () - CitizenId = citizenId - RecordedOn = now ctx - IsFromHere = form.FromHere - Source = "profile" - Story = noneIfEmpty form.Story |> Option.map Text - } - | successId -> - match! Successes.findById (SuccessId.ofString successId) with - | Some story when story.CitizenId = citizenId -> - return Some { story with - IsFromHere = form.FromHere - Story = noneIfEmpty form.Story |> Option.map Text - } - | Some _ | None -> return None + // GET: /success-story/[id]/view + let view successId : HttpHandler = requireUser >=> fun next ctx -> task { + match! Successes.findById (SuccessId successId) with + | Some success -> + match! Citizens.findById success.CitizenId with + | Some citizen -> + return! Success.view success (Citizen.name citizen) (timeZone ctx) |> render "Success Story" next ctx + | None -> return! Error.notFound next ctx + | None -> return! Error.notFound next ctx + } + + // POST: /success-story/save + let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let citizenId = currentCitizenId ctx + let! form = ctx.BindFormAsync () + let isNew = form.Id = ShortGuid.fromGuid Guid.Empty + let! theSuccess = task { + if isNew then + return Some + { Success.empty with + Id = SuccessId.create () + CitizenId = citizenId + RecordedOn = now ctx + Source = "profile" + } + else return! Successes.findById (SuccessId.ofString form.Id) } - match success with - | Some story -> - do! Successes.save story - return! ok next ctx + match theSuccess with + | Some story when story.CitizenId = citizenId -> + do! Successes.save + { story with IsFromHere = form.FromHere; Story = noneIfEmpty form.Story |> Option.map Text } + if isNew then + match! Profiles.findById citizenId with + | Some profile -> do! Profiles.save { profile with IsSeekingEmployment = false } + | None -> () + let extraMsg = if isNew then " and seeking employment flag cleared" else "" + do! addSuccess $"Success story saved{extraMsg} successfully" ctx + return! redirectToGet "/success-stories" next ctx + | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx } @@ -831,6 +830,14 @@ let allEndpoints = [ route "/save" Profile.save ] ] + subRoute "/success-stor" [ + GET_HEAD [ + route "ies" Success.list + routef "y/%s/edit" Success.edit + routef "y/%O/view" Success.view + ] + POST [ route "y/save" Success.save ] + ] subRoute "/api" [ subRoute "/citizen" [ @@ -838,17 +845,6 @@ let allEndpoints = [ route "/account" CitizenApi.account ] ] - GET_HEAD [ route "/continents" Continent.all ] POST [ route "/markdown-preview" Api.markdownPreview ] - subRoute "/profile" [ - PATCH [ route "/employment-found" ProfileApi.employmentFound ] - ] - subRoute "/success" [ - GET_HEAD [ - routef "/%O" Success.get - route "es" Success.all - ] - POST [ route "" Success.save ] - ] ] ] diff --git a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj index 285b8a9..9fa79e0 100644 --- a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj +++ b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj @@ -17,6 +17,7 @@ + diff --git a/src/JobsJobsJobs/Server/ViewModels.fs b/src/JobsJobsJobs/Server/ViewModels.fs index 1d16504..e595c75 100644 --- a/src/JobsJobsJobs/Server/ViewModels.fs +++ b/src/JobsJobsJobs/Server/ViewModels.fs @@ -136,6 +136,30 @@ module EditProfileViewModel = } +/// The data required to provide a success story +[] +type EditSuccessForm = + { /// The ID of this success story + Id : string + + /// Whether the employment was obtained from Jobs, Jobs, Jobs + FromHere : bool + + /// The success story + Story : string + } + +/// Support functions for success edit forms +module EditSuccessForm = + + /// Create an edit form from a success story + let fromSuccess (success : Success) = + { Id = SuccessId.toString success.Id + FromHere = success.IsFromHere + Story = success.Story |> Option.map MarkdownString.toString |> Option.defaultValue "" + } + + /// The form submitted to expire a listing [] type ExpireListingForm = diff --git a/src/JobsJobsJobs/Server/Views/Citizen.fs b/src/JobsJobsJobs/Server/Views/Citizen.fs index 22fdb4c..ca7b46c 100644 --- a/src/JobsJobsJobs/Server/Views/Citizen.fs +++ b/src/JobsJobsJobs/Server/Views/Citizen.fs @@ -23,7 +23,7 @@ let confirmAccount isConfirmed = ] /// The citizen's dashboard page -let dashboard (citizen : Citizen) (profile : Profile option) profileCount = +let dashboard (citizen : Citizen) (profile : Profile option) profileCount tz = article [ _class "container" ] [ h3 [ _class "pb-4" ] [ rawText "ITM, "; str citizen.FirstName; rawText "!" ] div [ _class "row row-cols-1 row-cols-md-2" ] [ @@ -34,7 +34,7 @@ let dashboard (citizen : Citizen) (profile : Profile option) profileCount = match profile with | Some prfl -> h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [ - rawText "Last updated "; (* full-date-time :date="profile.lastUpdatedOn" *) + rawText "Last updated "; str (fullDateTime prfl.LastUpdatedOn tz) ] p [ _class "card-text" ] [ rawText "Your profile currently lists "; str $"{List.length prfl.Skills}" diff --git a/src/JobsJobsJobs/Server/Views/Layout.fs b/src/JobsJobsJobs/Server/Views/Layout.fs index 55a92ab..4715b6a 100644 --- a/src/JobsJobsJobs/Server/Views/Layout.fs +++ b/src/JobsJobsJobs/Server/Views/Layout.fs @@ -57,10 +57,10 @@ let private links ctx = ] [ i [ _class $"mdi mdi-{icon}"; _ariaHidden "true" ] []; rawText text ] nav [ _class "jjj-nav" ] [ if ctx.IsLoggedOn then - navLink "/citizen/dashboard" "view-dashboard-variant" "Dashboard" - navLink "/help-wanted" "newspaper-variant-multiple-outline" "Help Wanted!" - navLink "/profile/search" "view-list-outline" "Employment Profiles" - navLink "/success-story/list" "thumb-up" "Success Stories" + navLink "/citizen/dashboard" "view-dashboard-variant" "Dashboard" + navLink "/help-wanted" "newspaper-variant-multiple-outline" "Help Wanted!" + navLink "/profile/search" "view-list-outline" "Employment Profiles" + navLink "/success-stories" "thumb-up" "Success Stories" div [ _class "separator" ] [] navLink "/citizen/account" "account-edit" "My Account" navLink "/listings/mine" "sign-text" "My Job Listings" diff --git a/src/JobsJobsJobs/Server/Views/Success.fs b/src/JobsJobsJobs/Server/Views/Success.fs new file mode 100644 index 0000000..8fce1ae --- /dev/null +++ b/src/JobsJobsJobs/Server/Views/Success.fs @@ -0,0 +1,89 @@ +/// Views for /success-stor[y|ies] URLs +[] +module JobsJobsJobs.Views.Success + +open Giraffe.ViewEngine +open JobsJobsJobs.Domain +open JobsJobsJobs.Domain.SharedTypes +open JobsJobsJobs.ViewModels + +/// The add/edit success story page +let edit (m : EditSuccessForm) isNew pgTitle csrf = + article [] [ + h3 [ _class "pb-3" ] [ rawText pgTitle ] + if isNew then + p [] [ + rawText "Congratulations on your employment! Your fellow citizens would enjoy hearing how it all came " + rawText "about; tell us about it below! " + em [] [ rawText "(These will be visible to other users, but not to the general public.)" ] + ] + form [ _class "row g-3"; _method "POST"; _action "/success-story/save" ] [ + antiForgery csrf + input [ _type "hidden"; _name (nameof m.Id); _value m.Id ] + div [ _class "col-12" ] [ + checkBox [] (nameof m.FromHere) m.FromHere "I found my employment here" + ] + markdownEditor [] (nameof m.Story) m.Story "The Success Story" + div [ _class "col-12" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ + i [ _class "mdi mdi-content-save-outline" ] []; rawText "  Save" + ] + if isNew then + p [ _class "fst-italic" ] [ + rawText "(Saving this will set “Seeking Employment” to “No” on your " + rawText "profile.)" + ] + ] + ] + ] + + +/// The list of success stories +let list (m : StoryEntry list) citizenId tz = + article [] [ + h3 [ _class "pb-3" ] [ rawText "Success Stories" ] + if List.isEmpty m then + p [] [ rawText "There are no success stories recorded "; em [] [ rawText "(yet)" ] ] + else + table [ _class "table table-sm table-hover" ] [ + thead [] [ + [ "Story"; "From"; "Found Here?"; "Recorded On" ] + |> List.map (fun it -> th [ _scope "col" ] [ rawText it ]) + |> tr [] + ] + m |> List.map (fun story -> + tr [] [ + td [] [ + let theId = SuccessId.toString story.Id + if story.HasStory then a [ _href $"/success-story/{theId}/view" ] [ rawText "View" ] + else em [] [ rawText "None" ] + if story.CitizenId = citizenId then + rawText " ~ "; a [ _href $"/success-story/{theId}/edit" ] [ rawText "Edit" ] + ] + td [] [ str story.CitizenName ] + td [] [ if story.FromHere then strong [] [ rawText "Yes" ] else rawText "No" ] + td [] [ str (fullDate story.RecordedOn tz) ] + ]) + |> tbody [] + ] + ] + + +/// The page to view a success story +let view (it : Success) citizenName tz = + article [] [ + h3 [] [ + str citizenName; rawText "’s Success Story" + if it.IsFromHere then + span [ _class "jjj-heading-label" ] [ + rawText "     " + span [ _class "badge bg-success" ] [ + rawText "Via " + rawText (if it.Source = "profile" then "employment profile" else "job listing") + rawText " on Jobs, Jobs, Jobs" + ] + ] + ] + h4 [ _class "pb-3 text-muted" ] [ str (fullDateTime it.RecordedOn tz) ] + match it.Story with Some text -> div [] [ md2html text ] | None -> () + ]