WIP on listing expire/success story (#18)
This commit is contained in:
parent
dd549cf5f1
commit
8c2ff4c84d
|
@ -429,6 +429,8 @@ module Continent =
|
|||
[<RequireQualifiedAccess>]
|
||||
module Listing =
|
||||
|
||||
open NodaTime
|
||||
|
||||
/// Find all job listings posted by the given citizen
|
||||
let findByCitizen (citizenId : CitizenId) conn =
|
||||
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
|
||||
let search (srch : ListingSearch) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
|
|
|
@ -257,6 +257,33 @@ module Listing =
|
|||
return! ok 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
|
||||
let search : HttpHandler =
|
||||
|
@ -475,6 +502,9 @@ let allEndpoints = [
|
|||
routef "/%O/view" Listing.view
|
||||
route "s/mine" Listing.mine
|
||||
]
|
||||
PATCH [
|
||||
routef "/%O" Listing.expire
|
||||
]
|
||||
POST [
|
||||
route "s" Listing.add
|
||||
]
|
||||
|
|
|
@ -3,6 +3,7 @@ import {
|
|||
Continent,
|
||||
Count,
|
||||
Listing,
|
||||
ListingExpireForm,
|
||||
ListingForm,
|
||||
ListingForView,
|
||||
ListingSearch,
|
||||
|
@ -153,6 +154,17 @@ export default {
|
|||
add: async (listing : ListingForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
||||
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
|
||||
*
|
||||
|
|
|
@ -77,6 +77,14 @@ export class ListingForm {
|
|||
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 */
|
||||
export interface ListingForView {
|
||||
/** The listing itself */
|
||||
|
|
|
@ -83,6 +83,11 @@ const routes: Array<RouteRecordRaw> = [
|
|||
name: "EditListing",
|
||||
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",
|
||||
name: "ViewListing",
|
||||
|
|
110
src/JobsJobsJobs/App/src/views/listing/ListingExpire.vue
Normal file
110
src/JobsJobsJobs/App/src/views/listing/ListingExpire.vue
Normal 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 “My Job
|
||||
Listings” page, but you will not be able to “un-expire” 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")] 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>
|
|
@ -7,5 +7,5 @@ import { format } from "date-fns"
|
|||
* @returns The date to display
|
||||
*/
|
||||
export function formatNeededBy (neededBy : string) : string {
|
||||
return format(Date.parse(neededBy), "PPP")
|
||||
return format(Date.parse(`${neededBy}T00:00:00`), "PPP")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
[<CLIMutable>]
|
||||
type ListingSearch = {
|
||||
|
|
Loading…
Reference in New Issue
Block a user