Help wanted #23
|
@ -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 () ->
|
||||||
|
|
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
|
@ -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
|
||||||
*
|
*
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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",
|
||||||
|
|
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
|
* @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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user