Help wanted #23

Merged
danieljsummers merged 20 commits from help-wanted into main 2021-09-01 01:16:43 +00:00
8 changed files with 188 additions and 1 deletions
Showing only changes of commit 8c2ff4c84d - Show all commits

View File

@ -429,6 +429,8 @@ module Continent =
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Listing = module Listing =
open NodaTime
/// Find all job listings posted by the given citizen /// Find all job listings posted by the given citizen
let findByCitizen (citizenId : CitizenId) conn = let findByCitizen (citizenId : CitizenId) conn =
withReconn(conn).ExecuteAsync(fun () -> withReconn(conn).ExecuteAsync(fun () ->
@ -481,6 +483,17 @@ module Listing =
() ()
}) })
/// Expire a listing
let expire (listingId : ListingId) (fromHere : bool) (now : Instant) conn =
withReconn(conn).ExecuteAsync(fun () -> task {
let! _ =
r.Table(Table.Listing)
.Get(listingId)
.Update(r.HashMap("isExpired", true).With("fromHere", fromHere).With("updatedOn", now))
.RunWriteAsync conn
()
})
/// Search job listings /// Search job listings
let search (srch : ListingSearch) conn = let search (srch : ListingSearch) conn =
withReconn(conn).ExecuteAsync(fun () -> withReconn(conn).ExecuteAsync(fun () ->

View File

@ -258,6 +258,33 @@ module Listing =
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// PATCH: /api/listing/[id]
let expire listingId : HttpHandler =
authorize
>=> fun next ctx -> task {
let dbConn = conn ctx
let now = clock(ctx).GetCurrentInstant ()
match! Data.Listing.findById (ListingId listingId) dbConn with
| Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
| Some listing ->
let! form = ctx.BindJsonAsync<ListingExpireForm> ()
do! Data.Listing.expire listing.id form.fromHere now dbConn
match form.successStory with
| Some storyText ->
do! Data.Success.save
{ id = SuccessId.create()
citizenId = currentCitizenId ctx
recordedOn = now
fromHere = form.fromHere
source = "listing"
story = (Text >> Some) storyText
} dbConn
| None -> ()
return! ok next ctx
| None -> return! Error.notFound next ctx
}
// GET: /api/listing/search // GET: /api/listing/search
let search : HttpHandler = let search : HttpHandler =
authorize authorize
@ -475,6 +502,9 @@ let allEndpoints = [
routef "/%O/view" Listing.view routef "/%O/view" Listing.view
route "s/mine" Listing.mine route "s/mine" Listing.mine
] ]
PATCH [
routef "/%O" Listing.expire
]
POST [ POST [
route "s" Listing.add route "s" Listing.add
] ]

View File

@ -3,6 +3,7 @@ import {
Continent, Continent,
Count, Count,
Listing, Listing,
ListingExpireForm,
ListingForm, ListingForm,
ListingForView, ListingForView,
ListingSearch, ListingSearch,
@ -153,6 +154,17 @@ export default {
add: async (listing : ListingForm, user : LogOnSuccess) : Promise<boolean | string> => add: async (listing : ListingForm, user : LogOnSuccess) : Promise<boolean | string> =>
apiSend(await fetch(apiUrl("listings"), reqInit("POST", user, listing)), "adding job listing"), apiSend(await fetch(apiUrl("listings"), reqInit("POST", user, listing)), "adding job listing"),
/**
* Expire a job listing
*
* @param id The ID of the job listing to be expired
* @param form The information needed to expire the form
* @param user The currently logged-on user
* @returns True if the action was successful, an error string if not
*/
expire: async (id : string, listing : ListingExpireForm, user : LogOnSuccess) : Promise<boolean | string> =>
apiSend(await fetch(apiUrl(`listing/${id}`), reqInit("PATCH", user, listing)), "expiring job listing"),
/** /**
* Retrieve the job listings posted by the current citizen * Retrieve the job listings posted by the current citizen
* *

View File

@ -77,6 +77,14 @@ export class ListingForm {
neededBy : string | undefined neededBy : string | undefined
} }
/** The form submitted to expire a listing */
export class ListingExpireForm {
/** Whether the job was filled from here */
fromHere = false
/** The success story written by the user */
successStory : string | undefined
}
/** The data required to view a listing */ /** The data required to view a listing */
export interface ListingForView { export interface ListingForView {
/** The listing itself */ /** The listing itself */

View File

@ -83,6 +83,11 @@ const routes: Array<RouteRecordRaw> = [
name: "EditListing", name: "EditListing",
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingEdit.vue") component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingEdit.vue")
}, },
{
path: "/listing/:id/expire",
name: "ExpireListing",
component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingExpire.vue")
},
{ {
path: "/listing/:id/view", path: "/listing/:id/view",
name: "ViewListing", name: "ViewListing",

View File

@ -0,0 +1,110 @@
<template lang="pug">
article
page-title(title="Expire Job Listing")
load-data(:load="retrieveListing")
h3.pb-3 Expire Job Listing ({{listing.title}})
p: em.
Expiring this listing will remove it from search results. You will be able to see it via your &ldquo;My Job
Listings&rdquo; page, but you will not be able to &ldquo;un-expire&rdquo; it.
form.row.g-3
.col-12: .form-check
input.form-check-input(type="checkbox" id="fromHere" v-model="v$.fromHere.$model")
label.form-check-label(for="fromHere") This job was filled due to its listing here
template(v-if="expiration.fromHere")
.col-12: p.
Consider telling your fellow citizens about your experience! Comments entered here will be visible to
logged-on users here, but not to the general public.
markdown-editor(id="successStory" label="Your Success Story" v-model:text="v$.successStory.$model")
.col-12
button.btn.btn-primary(@click.prevent="expireListing").
#[icon(icon="text-box-remove-outline")]&nbsp; Expire Listing
maybe-save(:isShown="confirmNavShown" :toRoute="nextRoute" :saveAction="doSave" :validator="v$" @close="confirmClose")
</template>
<script setup lang="ts">
import { computed, reactive, Ref, ref } from "vue"
import { onBeforeRouteLeave, RouteLocationNormalized, useRoute, useRouter } from "vue-router"
import useVuelidate from "@vuelidate/core"
import api, { Listing, ListingExpireForm, LogOnSuccess } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { 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 listing being expired */
const listingId = route.params.id as string
/** The listing being expired */
const listing : Ref<Listing | undefined> = ref(undefined)
/** The data needed to expire a job listing */
const expiration = reactive(new ListingExpireForm())
/** The validation rules for the form */
const rules = computed(() => ({
fromHere: { },
successStory: { }
}))
/** Initialize form validation */
const v$ = useVuelidate(rules, expiration, { $lazy: true })
/** Retrieve the job listing being expired */
const retrieveListing = async (errors : string[]) => {
const listingResp = await api.listings.retreive(listingId, user)
if (typeof listingResp === "string") {
errors.push(listingResp)
} else if (typeof listingResp === "undefined") {
errors.push("Listing not found")
} else {
listing.value = listingResp
}
}
/** Expire the listing */
const expireListing = async (navigate : boolean) => {
v$.value.$touch()
if (v$.value.$error) return
if ((expiration.successStory ?? "").trim() === "") expiration.successStory = undefined
const expireResult = await api.listings.expire(listingId, expiration, user)
if (typeof expireResult === "string") {
toastError(expireResult, "expiring job listing")
} else {
toastSuccess(`Job Listing Expired${expiration.successStory ? " and Success Story Recorded" : ""} Successfully`)
v$.value.$reset()
if (navigate) {
router.push("/listings/mine")
}
}
}
/** Whether the navigation confirmation is shown */
const confirmNavShown = ref(false)
/** The "next" route (will be navigated or cleared) */
const nextRoute : Ref<RouteLocationNormalized | undefined> = ref(undefined)
/** Prompt for save if the user navigates away with unsaved changes */
onBeforeRouteLeave(async (to, from) => { // eslint-disable-line
if (!v$.value.$anyDirty) return true
nextRoute.value = to
confirmNavShown.value = true
return false
})
/** No-parameter save function (used for save-on-navigate) */
const doSave = async () => await expireListing(false)
/** Close the confirm navigation modal */
const confirmClose = () => { confirmNavShown.value = false }
</script>

View File

@ -7,5 +7,5 @@ import { format } from "date-fns"
* @returns The date to display * @returns The date to display
*/ */
export function formatNeededBy (neededBy : string) : string { export function formatNeededBy (neededBy : string) : string {
return format(Date.parse(neededBy), "PPP") return format(Date.parse(`${neededBy}T00:00:00`), "PPP")
} }

View File

@ -34,6 +34,15 @@ type ListingForView = {
} }
/// The form submitted to expire a listing
type ListingExpireForm = {
/// Whether the job was filled from here
fromHere : bool
/// The success story written by the user
successStory : string option
}
/// The various ways job listings can be searched /// The various ways job listings can be searched
[<CLIMutable>] [<CLIMutable>]
type ListingSearch = { type ListingSearch = {