Version 3 #40

Merged
danieljsummers merged 67 commits from version-2-3 into main 2023-02-02 23:47:28 +00:00
10 changed files with 189 additions and 339 deletions
Showing only changes of commit ccd7311e74 - Show all commits

View File

@ -1,120 +0,0 @@
<template>
<article>
<h3 class="pb-3">{{title}}</h3>
<load-data :load="retrieveStory">
<p v-if="isNew">
Congratulations on your employment! Your fellow citizens would enjoy hearing how it all came about; tell us
about it below! <em>(These will be visible to other users, but not to the general public.)</em>
</p>
<form class="row g-3">
<div class="col-12">
<div class="form-check">
<input type="checkbox" id="fromHere" class="form-check-input" v-model="v$.fromHere.$model">
<label class="form-check-label" for="fromHere">I found my employment here</label>
</div>
</div>
<markdown-editor id="story" label="The Success Story" v-model:text="v$.story.$model" />
<div class="col-12">
<button class="btn btn-primary" type="submit" @click.prevent="saveStory(true)">
<icon :icon="mdiContentSaveOutline" />&nbsp; Save
</button>
<p v-if="isNew">
<em>(Saving this will set &ldquo;Seeking Employment&rdquo; to &ldquo;No&rdquo; on your profile.)</em>
</p>
</div>
</form>
</load-data>
<maybe-save :saveAction="doSave" :validator="v$" />
</article>
</template>
<script setup lang="ts">
import { computed, reactive } from "vue"
import { useRoute, useRouter } from "vue-router"
import { mdiContentSaveOutline } from "@mdi/js"
import useVuelidate from "@vuelidate/core"
import api, { LogOnSuccess, StoryForm } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { Mutations, useStore } from "@/store"
import LoadData from "@/components/LoadData.vue"
import MarkdownEditor from "@/components/MarkdownEditor.vue"
import MaybeSave from "@/components/MaybeSave.vue"
const store = useStore()
const route = useRoute()
const router = useRouter()
/** The currently logged-on user */
const user = store.state.user as LogOnSuccess
/** The ID of the story being edited */
const id = route.params.id as string
/** Whether this is a new story */
const isNew = computed(() => id === "new")
/** The form for editing the story */
const story = reactive(new StoryForm())
/** Validator rules */
const rules = computed(() => ({
fromHere: { },
story: { }
}))
/** The validator */
const v$ = useVuelidate(rules, story, { $lazy: true })
/** Retrieve the specified story */
const retrieveStory = async (errors : string[]) => {
if (isNew.value) {
story.id = "new"
store.commit(Mutations.SetTitle, "Tell Your Success Story")
} else {
const storyResult = await api.success.retrieve(id, user)
if (typeof storyResult === "string") {
errors.push(storyResult)
} else if (typeof storyResult === "undefined") {
errors.push("Story not found")
} else if (storyResult.citizenId !== user.citizenId) {
errors.push("Quit messing around")
} else {
story.id = storyResult.id
story.fromHere = storyResult.fromHere
story.story = storyResult.story ?? ""
}
}
}
/** Save the success story */
const saveStory = async (navigate : boolean) => {
const saveResult = await api.success.save(story, user)
if (typeof saveResult === "string") {
toastError(saveResult, "saving success story")
} else {
if (isNew.value) {
const foundResult = await api.profile.markEmploymentFound(user)
if (typeof foundResult === "string") {
toastError(foundResult, "clearing employment flag")
} else {
toastSuccess("Success Story saved and Seeking Employment flag cleared successfully")
v$.value.$reset()
if (navigate) {
await router.push("/success-story/list")
}
}
} else {
toastSuccess("Success Story saved successfully")
v$.value.$reset()
if (navigate) {
await router.push("/success-story/list")
}
}
}
}
/** No-parameter save function (used for save-on-navigate) */
const doSave = async () => await saveStory(false)
</script>

View File

@ -1,61 +0,0 @@
<template>
<article>
<h3 class="pb-3">Success Stories</h3>
<load-data :load="retrieveStories">
<table v-if="stories?.length > 0" class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">Story</th>
<th scope="col">From</th>
<th scope="col">Found Here?</th>
<th scope="col">Recorded On</th>
</tr>
</thead>
<tbody>
<tr v-for="story in stories" :key="story.id">
<td>
<router-link v-if="story.hasStory" :to="`/success-story/${story.id}/view`">View</router-link>
<em v-else>None</em>
<template v-if="story.citizenId === user.citizenId">
~ <router-link :to="`/success-story/${story.id}/edit`">Edit</router-link>
</template>
</td>
<td>{{story.citizenName}}</td>
<td><strong v-if="story.fromHere">Yes</strong><template v-else>No</template></td>
<td><full-date :date="story.recordedOn" /></td>
</tr>
</tbody>
</table>
<p v-else>There are no success stories recorded <em>(yet)</em></p>
</load-data>
</article>
</template>
<script setup lang="ts">
import { ref, Ref } from "vue"
import api, { LogOnSuccess, StoryEntry } from "@/api"
import { useStore } from "@/store"
import FullDate from "@/components/FullDate.vue"
import LoadData from "@/components/LoadData.vue"
const store = useStore()
/** The currently logged-on user */
const user = store.state.user as LogOnSuccess
/** The success stories to be displayed */
const stories : Ref<StoryEntry[] | undefined> = ref(undefined)
/** Get all currently recorded stories */
const retrieveStories = async (errors : string[]) => {
const listResult = await api.success.list(user)
if (typeof listResult === "string") {
errors.push(listResult)
} else if (typeof listResult === "undefined") {
stories.value = []
} else {
stories.value = listResult
}
}
</script>

View File

@ -1,67 +0,0 @@
<template>
<article>
<load-data :load="retrieveStory">
<h3>
{{citizenName}}&rsquo;s Success Story
<span v-if="story.fromHere" class="jjj-heading-label">
&nbsp; &nbsp;<span class="badge bg-success">Via {{profileOrListing}} on Jobs, Jobs, Jobs</span>
</span>
</h3>
<h4 class="pb-3 text-muted"><full-date-time :date="story.recordedOn" /></h4>
<div v-if="story.story" v-html="successStory" />
</load-data>
</article>
</template>
<script setup lang="ts">
import { computed, Ref, ref } from "vue"
import { useRoute } from "vue-router"
import api, { LogOnSuccess, Success } from "@/api"
import { citizenName as citName } from "@/App.vue"
import { toHtml } from "@/markdown"
import { useStore } from "@/store"
import FullDateTime from "@/components/FullDateTime.vue"
import LoadData from "@/components/LoadData.vue"
const store = useStore()
const route = useRoute()
/** The currently logged-on user */
const user = store.state.user as LogOnSuccess
/** The story to be displayed */
const story : Ref<Success | undefined> = ref(undefined)
/** The citizen's name */
const citizenName = ref("")
/** Retrieve the success story */
const retrieveStory = async (errors : string []) => {
const storyResponse = await api.success.retrieve(route.params.id as string, user)
if (typeof storyResponse === "string") {
errors.push(storyResponse)
return
}
if (typeof storyResponse === "undefined") {
errors.push("Success story not found")
return
}
story.value = storyResponse
const citResponse = await api.citizen.retrieve(story.value.citizenId, user)
if (typeof citResponse === "string") {
errors.push(citResponse)
} else if (typeof citResponse === "undefined") {
errors.push("Citizen not found")
} else {
citizenName.value = citName(citResponse)
}
}
/** Whether this success is from an employment profile or a job listing */
const profileOrListing = computed(() => story.value?.source === "profile" ? "employment profile" : "job listing")
/** The HTML success story */
const successStory = computed(() => toHtml(story.value?.story ?? ""))
</script>

View File

@ -130,7 +130,7 @@ type ProfileForView =
/// The parameters for a public job search /// The parameters for a public job search
[<CLIMutable>] [<CLIMutable; NoComparison; NoEquality>]
type PublicSearchForm = type PublicSearchForm =
{ /// Retrieve citizens from this continent { /// Retrieve citizens from this continent
ContinentId : string 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 /// An entry in the list of success stories
[<NoComparison; NoEquality>]
type StoryEntry = type StoryEntry =
{ /// The ID of this success story { /// The ID of this success story
Id : SuccessId Id : SuccessId

View File

@ -264,7 +264,7 @@ module Citizen =
let! citizen = Citizens.findById citizenId let! citizen = Citizens.findById citizenId
let! profile = Profiles.findById citizenId let! profile = Profiles.findById citizenId
let! prfCount = Profiles.count () 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 // POST: /citizen/delete
@ -425,17 +425,6 @@ module CitizenApi =
} }
/// Handlers for /api/continent routes
[<RequireQualifiedAccess>]
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 /// Handlers for the home page, legal stuff, and help
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Home = module Home =
@ -718,64 +707,74 @@ module Profile =
} }
/// Handlers for /api/profile routes /// Handlers for /success-stor[y|ies] routes
[<RequireQualifiedAccess>]
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
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Success = module Success =
// GET: /api/success/[id] // GET: /success-story/[id]/edit
let get successId : HttpHandler = authorize >=> fun next ctx -> task { let edit successId : HttpHandler = requireUser >=> fun next ctx -> task {
match! Successes.findById (SuccessId successId) with let citizenId = currentCitizenId ctx
| Some story -> return! json story next 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 | None -> return! Error.notFound next ctx
} }
// GET: /api/success/list // GET: /success-stories
let all : HttpHandler = authorize >=> fun next ctx -> task { let list : HttpHandler = requireUser >=> fun next ctx -> task {
let! stories = Successes.all () 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 // GET: /success-story/[id]/view
let save : HttpHandler = authorize >=> fun next ctx -> task { 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 citizenId = currentCitizenId ctx
let! form = ctx.BindJsonAsync<StoryForm> () let! form = ctx.BindFormAsync<EditSuccessForm> ()
let! success = task { let isNew = form.Id = ShortGuid.fromGuid Guid.Empty
match form.Id with let! theSuccess = task {
| "new" -> if isNew then
return Some { Id = SuccessId.create () return Some
{ Success.empty with
Id = SuccessId.create ()
CitizenId = citizenId CitizenId = citizenId
RecordedOn = now ctx RecordedOn = now ctx
IsFromHere = form.FromHere
Source = "profile" Source = "profile"
Story = noneIfEmpty form.Story |> Option.map Text
} }
| successId -> else return! Successes.findById (SuccessId.ofString form.Id)
match! Successes.findById (SuccessId.ofString successId) with }
match theSuccess with
| Some story when story.CitizenId = citizenId -> | Some story when story.CitizenId = citizenId ->
return Some { story with do! Successes.save
IsFromHere = form.FromHere { story with IsFromHere = form.FromHere; Story = noneIfEmpty form.Story |> Option.map Text }
Story = noneIfEmpty form.Story |> Option.map Text if isNew then
} match! Profiles.findById citizenId with
| Some _ | None -> return None | Some profile -> do! Profiles.save { profile with IsSeekingEmployment = false }
} | None -> ()
match success with let extraMsg = if isNew then " and seeking employment flag cleared" else ""
| Some story -> do! addSuccess $"Success story saved{extraMsg} successfully" ctx
do! Successes.save story return! redirectToGet "/success-stories" next ctx
return! ok next ctx | Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -831,6 +830,14 @@ let allEndpoints = [
route "/save" Profile.save 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 "/api" [
subRoute "/citizen" [ subRoute "/citizen" [
@ -838,17 +845,6 @@ let allEndpoints = [
route "/account" CitizenApi.account route "/account" CitizenApi.account
] ]
] ]
GET_HEAD [ route "/continents" Continent.all ]
POST [ route "/markdown-preview" Api.markdownPreview ] 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 ]
]
] ]
] ]

View File

@ -17,6 +17,7 @@
<Compile Include="Views\Home.fs" /> <Compile Include="Views\Home.fs" />
<Compile Include="Views\Listing.fs" /> <Compile Include="Views\Listing.fs" />
<Compile Include="Views\Profile.fs" /> <Compile Include="Views\Profile.fs" />
<Compile Include="Views\Success.fs" />
<Compile Include="Handlers.fs" /> <Compile Include="Handlers.fs" />
<Compile Include="App.fs" /> <Compile Include="App.fs" />
</ItemGroup> </ItemGroup>

View File

@ -136,6 +136,30 @@ module EditProfileViewModel =
} }
/// The data required to provide a success story
[<CLIMutable; NoComparison; NoEquality>]
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 /// The form submitted to expire a listing
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type ExpireListingForm = type ExpireListingForm =

View File

@ -23,7 +23,7 @@ let confirmAccount isConfirmed =
] ]
/// The citizen's dashboard page /// 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" ] [ article [ _class "container" ] [
h3 [ _class "pb-4" ] [ rawText "ITM, "; str citizen.FirstName; rawText "!" ] h3 [ _class "pb-4" ] [ rawText "ITM, "; str citizen.FirstName; rawText "!" ]
div [ _class "row row-cols-1 row-cols-md-2" ] [ 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 match profile with
| Some prfl -> | Some prfl ->
h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [ 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" ] [ p [ _class "card-text" ] [
rawText "Your profile currently lists "; str $"{List.length prfl.Skills}" rawText "Your profile currently lists "; str $"{List.length prfl.Skills}"

View File

@ -60,7 +60,7 @@ let private links ctx =
navLink "/citizen/dashboard" "view-dashboard-variant" "Dashboard" navLink "/citizen/dashboard" "view-dashboard-variant" "Dashboard"
navLink "/help-wanted" "newspaper-variant-multiple-outline" "Help Wanted!" navLink "/help-wanted" "newspaper-variant-multiple-outline" "Help Wanted!"
navLink "/profile/search" "view-list-outline" "Employment Profiles" navLink "/profile/search" "view-list-outline" "Employment Profiles"
navLink "/success-story/list" "thumb-up" "Success Stories" navLink "/success-stories" "thumb-up" "Success Stories"
div [ _class "separator" ] [] div [ _class "separator" ] []
navLink "/citizen/account" "account-edit" "My Account" navLink "/citizen/account" "account-edit" "My Account"
navLink "/listings/mine" "sign-text" "My Job Listings" navLink "/listings/mine" "sign-text" "My Job Listings"

View File

@ -0,0 +1,89 @@
/// Views for /success-stor[y|ies] URLs
[<RequireQualifiedAccess>]
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 "&nbsp; Save"
]
if isNew then
p [ _class "fst-italic" ] [
rawText "(Saving this will set &ldquo;Seeking Employment&rdquo; to &ldquo;No&rdquo; 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 "&rsquo;s Success Story"
if it.IsFromHere then
span [ _class "jjj-heading-label" ] [
rawText " &nbsp; &nbsp; "
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 -> ()
]