Add listing edit page, refine my listings (#16)
This commit is contained in:
parent
a0d079c67a
commit
837ab35da5
|
@ -201,7 +201,6 @@ open RethinkDb.Driver.Ast
|
|||
module Profile =
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
open RethinkDb.Driver.Ast
|
||||
|
||||
let count conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
|
@ -210,14 +209,14 @@ module Profile =
|
|||
.RunResultAsync<int64> conn)
|
||||
|
||||
/// Find a profile by citizen ID
|
||||
let findById (citizenId : CitizenId) conn = task {
|
||||
let! profile =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
let findById (citizenId : CitizenId) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! profile =
|
||||
r.Table(Table.Profile)
|
||||
.Get(citizenId)
|
||||
.RunResultAsync<Profile> conn)
|
||||
return toOption profile
|
||||
}
|
||||
.RunResultAsync<Profile> conn
|
||||
return toOption profile
|
||||
})
|
||||
|
||||
/// Insert or update a profile
|
||||
let save (profile : Profile) conn =
|
||||
|
@ -331,24 +330,24 @@ module Profile =
|
|||
module Citizen =
|
||||
|
||||
/// Find a citizen by their ID
|
||||
let findById (citizenId : CitizenId) conn = task {
|
||||
let! citizen =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
let findById (citizenId : CitizenId) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! citizen =
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizenId)
|
||||
.RunResultAsync<Citizen> conn)
|
||||
return toOption citizen
|
||||
}
|
||||
.RunResultAsync<Citizen> conn
|
||||
return toOption citizen
|
||||
})
|
||||
|
||||
/// Find a citizen by their No Agenda Social username
|
||||
let findByNaUser (naUser : string) conn = task {
|
||||
let! citizen =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
let findByNaUser (naUser : string) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! citizen =
|
||||
r.Table(Table.Citizen)
|
||||
.GetAll(naUser).OptArg("index", "naUser").Nth(0)
|
||||
.RunResultAsync<Citizen> conn)
|
||||
return toOption citizen
|
||||
}
|
||||
.RunResultAsync<Citizen> conn
|
||||
return toOption citizen
|
||||
})
|
||||
|
||||
/// Add a citizen
|
||||
let add (citizen : Citizen) conn =
|
||||
|
@ -381,6 +380,11 @@ module Citizen =
|
|||
.GetAll(citizenId).OptArg("index", "citizenId")
|
||||
.Delete()
|
||||
.RunWriteAsync conn
|
||||
let! _ =
|
||||
r.Table(Table.Listing)
|
||||
.GetAll(citizenId).OptArg("index", "citizenId")
|
||||
.Delete()
|
||||
.RunWriteAsync conn
|
||||
let! _ =
|
||||
r.Table(Table.Citizen)
|
||||
.Get(citizenId)
|
||||
|
@ -412,26 +416,70 @@ module Continent =
|
|||
.RunResultAsync<Continent list> conn)
|
||||
|
||||
/// Get a continent by its ID
|
||||
let findById (contId : ContinentId) conn = task {
|
||||
let! continent =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
let findById (contId : ContinentId) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! continent =
|
||||
r.Table(Table.Continent)
|
||||
.Get(contId)
|
||||
.RunResultAsync<Continent> conn)
|
||||
return toOption continent
|
||||
}
|
||||
.RunResultAsync<Continent> conn
|
||||
return toOption continent
|
||||
})
|
||||
|
||||
|
||||
/// Job listing data access functions
|
||||
[<RequireQualifiedAccess>]
|
||||
module Listing =
|
||||
|
||||
/// This is how RethinkDB is going to return our listing/continent combo
|
||||
// fsharplint:disable RecordFieldNames
|
||||
[<CLIMutable; NoEquality; NoComparison>]
|
||||
type ListingAndContinent = {
|
||||
left : Listing
|
||||
right : Continent
|
||||
}
|
||||
// fsharplint:enable
|
||||
|
||||
/// Find all job listings posted by the given citizen
|
||||
let findByCitizen (citizenId : CitizenId) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Listing)
|
||||
.GetAll(citizenId).OptArg("index", nameof citizenId)
|
||||
.RunResultAsync<Listing list> conn)
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! both =
|
||||
r.Table(Table.Listing)
|
||||
.GetAll(citizenId).OptArg("index", nameof citizenId)
|
||||
.EqJoin("continentId", r.Table(Table.Continent))
|
||||
.RunResultAsync<ListingAndContinent list> conn
|
||||
return both |> List.map (fun b -> { listing = b.left; continent = b.right})
|
||||
})
|
||||
|
||||
/// Find a listing by its ID
|
||||
let findById (listingId : ListingId) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! listing =
|
||||
r.Table(Table.Listing)
|
||||
.Get(listingId)
|
||||
.RunResultAsync<Listing> conn
|
||||
return toOption listing
|
||||
})
|
||||
|
||||
/// Add a listing
|
||||
let add (listing : Listing) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Listing)
|
||||
.Insert(listing)
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
/// Update a listing
|
||||
let update (listing : Listing) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Listing)
|
||||
.Get(listing.id)
|
||||
.Replace(listing)
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
|
||||
/// Success story data access functions
|
||||
|
@ -439,25 +487,25 @@ module Listing =
|
|||
module Success =
|
||||
|
||||
/// Find a success report by its ID
|
||||
let findById (successId : SuccessId) conn = task {
|
||||
let! success =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
let findById (successId : SuccessId) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! success =
|
||||
r.Table(Table.Success)
|
||||
.Get(successId)
|
||||
.RunResultAsync<Success> conn)
|
||||
return toOption success
|
||||
}
|
||||
.RunResultAsync<Success> conn
|
||||
return toOption success
|
||||
})
|
||||
|
||||
/// Insert or update a success story
|
||||
let save (success : Success) conn = task {
|
||||
let! _ =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
let save (success : Success) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! _ =
|
||||
r.Table(Table.Success)
|
||||
.Get(success.id)
|
||||
.Replace(success)
|
||||
.RunWriteAsync conn)
|
||||
()
|
||||
}
|
||||
.RunWriteAsync conn
|
||||
()
|
||||
})
|
||||
|
||||
// Retrieve all success stories
|
||||
let all conn =
|
||||
|
|
|
@ -170,7 +170,13 @@ module Continent =
|
|||
[<RequireQualifiedAccess>]
|
||||
module Listing =
|
||||
|
||||
// GET: /api/listing/mine
|
||||
open NodaTime
|
||||
open System
|
||||
|
||||
/// Parse the string we receive from JSON into a NodaTime local date
|
||||
let private parseDate = DateTime.Parse >> LocalDate.FromDateTime
|
||||
|
||||
// GET: /api/listings/mine
|
||||
let mine : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
|
@ -178,6 +184,61 @@ module Listing =
|
|||
return! json listings next ctx
|
||||
}
|
||||
|
||||
// GET: /api/listing/[id]
|
||||
let get listingId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
match! Data.Listing.findById (ListingId listingId) (conn ctx) with
|
||||
| Some listing -> return! json listing next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST: /listings
|
||||
let add : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let! form = ctx.BindJsonAsync<ListingForm> ()
|
||||
let now = (clock ctx).GetCurrentInstant ()
|
||||
do! Data.Listing.add
|
||||
{ id = ListingId.create ()
|
||||
citizenId = currentCitizenId ctx
|
||||
createdOn = now
|
||||
title = form.title
|
||||
continentId = ContinentId.ofString form.continentId
|
||||
region = form.region
|
||||
remoteWork = form.remoteWork
|
||||
isExpired = false
|
||||
updatedOn = now
|
||||
text = Text form.text
|
||||
neededBy = (form.neededBy |> Option.map parseDate)
|
||||
wasFilledHere = None
|
||||
} (conn ctx)
|
||||
return! ok next ctx
|
||||
}
|
||||
|
||||
// PUT: /api/listing/[id]
|
||||
let update listingId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let dbConn = conn ctx
|
||||
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<ListingForm> ()
|
||||
do! Data.Listing.update
|
||||
{ listing with
|
||||
title = form.title
|
||||
continentId = ContinentId.ofString form.continentId
|
||||
region = form.region
|
||||
remoteWork = form.remoteWork
|
||||
text = Text form.text
|
||||
neededBy = form.neededBy |> Option.map parseDate
|
||||
updatedOn = (clock ctx).GetCurrentInstant ()
|
||||
} dbConn
|
||||
return! ok next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// Handlers for /api/profile routes
|
||||
[<RequireQualifiedAccess>]
|
||||
module Profile =
|
||||
|
@ -380,7 +441,14 @@ let allEndpoints = [
|
|||
GET_HEAD [ route "/continent/all" Continent.all ]
|
||||
subRoute "/listing" [
|
||||
GET_HEAD [
|
||||
route "s/mine" Listing.mine
|
||||
routef "/%O" Listing.get
|
||||
route "s/mine" Listing.mine
|
||||
]
|
||||
POST [
|
||||
route "s" Listing.add
|
||||
]
|
||||
PUT [
|
||||
routef "/%O" Listing.update
|
||||
]
|
||||
]
|
||||
subRoute "/profile" [
|
||||
|
|
|
@ -4,6 +4,8 @@ import {
|
|||
Continent,
|
||||
Count,
|
||||
Listing,
|
||||
ListingForm,
|
||||
ListingForView,
|
||||
LogOnSuccess,
|
||||
Profile,
|
||||
ProfileForm,
|
||||
|
@ -139,14 +141,45 @@ export default {
|
|||
/** API functions for job listings */
|
||||
listings: {
|
||||
|
||||
/**
|
||||
* Add a new job listing
|
||||
*
|
||||
* @param listing The profile data to be saved
|
||||
* @param user The currently logged-on user
|
||||
* @returns True if the addition was successful, an error string if not
|
||||
*/
|
||||
add: async (listing : ListingForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
||||
apiSend(await fetch(apiUrl('listings'), reqInit('POST', user, listing)), 'adding job listing'),
|
||||
|
||||
/**
|
||||
* Retrieve the job listings posted by the current citizen
|
||||
*
|
||||
* @param user The currently logged-on user
|
||||
* @returns The job listings the user has posted, or an error string
|
||||
*/
|
||||
mine: async (user : LogOnSuccess) : Promise<Listing[] | string | undefined> =>
|
||||
apiResult<Listing[]>(await fetch(apiUrl('listings/mine'), reqInit('GET', user)), 'retrieving your job listings')
|
||||
mine: async (user : LogOnSuccess) : Promise<ListingForView[] | string | undefined> =>
|
||||
apiResult<ListingForView[]>(await fetch(apiUrl('listings/mine'), reqInit('GET', user)),
|
||||
'retrieving your job listings'),
|
||||
|
||||
/**
|
||||
* Retrieve a job listing
|
||||
*
|
||||
* @param id The ID of the job listing to retrieve
|
||||
* @param user The currently logged-on user
|
||||
* @returns The job listing (if found), undefined (if not found), or an error string
|
||||
*/
|
||||
retreive: async (id : string, user : LogOnSuccess) : Promise<Listing | undefined | string> =>
|
||||
apiResult<Listing>(await fetch(apiUrl(`listing/${id}`), reqInit('GET', user)), 'retrieving job listing'),
|
||||
|
||||
/**
|
||||
* Update an existing job listing
|
||||
*
|
||||
* @param listing The profile data to be saved
|
||||
* @param user The currently logged-on user
|
||||
* @returns True if the update was successful, an error string if not
|
||||
*/
|
||||
update: async (listing : ListingForm, user : LogOnSuccess) : Promise<boolean | string> =>
|
||||
apiSend(await fetch(apiUrl(`listing/${listing.id}`), reqInit('PUT', user, listing)), 'updating job listing')
|
||||
},
|
||||
|
||||
/** API functions for profiles */
|
||||
|
|
|
@ -59,6 +59,32 @@ export interface Listing {
|
|||
wasFilledHere : boolean | undefined
|
||||
}
|
||||
|
||||
/** The data required to add or edit a job listing */
|
||||
export class ListingForm {
|
||||
/** The ID of the listing */
|
||||
id = ''
|
||||
/** The listing title */
|
||||
title = ''
|
||||
/** The ID of the continent on which this opportunity exists */
|
||||
continentId = ''
|
||||
/** The region in which this opportunity exists */
|
||||
region = ''
|
||||
/** Whether this is a remote work opportunity */
|
||||
remoteWork = false
|
||||
/** The text of the job listing */
|
||||
text = ''
|
||||
/** The date by which this job listing is needed */
|
||||
neededBy : string | undefined
|
||||
}
|
||||
|
||||
/** The data required to view a listing */
|
||||
export interface ListingForView {
|
||||
/** The listing itself */
|
||||
listing : Listing
|
||||
/** The continent for the listing */
|
||||
continent : Continent
|
||||
}
|
||||
|
||||
/** A successful logon */
|
||||
export interface LogOnSuccess {
|
||||
/** The JSON Web Token (JWT) to use for API access */
|
||||
|
|
59
src/JobsJobsJobs/App/src/components/ContinentList.vue
Normal file
59
src/JobsJobsJobs/App/src/components/ContinentList.vue
Normal file
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<div class="form-floating">
|
||||
<select id="continentId" :class="{ 'form-select': true, 'is-invalid': isInvalid}"
|
||||
:value="continentId" @change="continentChanged">
|
||||
<option value="">– {{emptyLabel}} –</option>
|
||||
<option v-for="c in continents" :key="c.id" :value="c.id">{{c.name}}</option>
|
||||
</select>
|
||||
<label for="continentId" class="jjj-required">Continent</label>
|
||||
</div>
|
||||
<div class="invalid-feedback">Please select a continent</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useStore } from '@/store'
|
||||
import { computed, defineComponent, onMounted, ref } from 'vue'
|
||||
export default defineComponent({
|
||||
name: 'ContinentList',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
topLabel: { type: String },
|
||||
isInvalid: { type: Boolean }
|
||||
},
|
||||
emits: ['update:modelValue', 'touch'],
|
||||
setup (props, { emit }) {
|
||||
const store = useStore()
|
||||
|
||||
/** The continent ID, which this component can change */
|
||||
const continentId = ref(props.modelValue)
|
||||
|
||||
/**
|
||||
* Mark the continent field as changed
|
||||
*
|
||||
* (This works around a really strange sequence where, if the "touch" call is directly wired up to the onChange
|
||||
* event, the first time a value is selected, it doesn't stick (although the field is marked as touched). On second
|
||||
* and subsequent times, it worked. The solution here is to grab the value and update the reactive source for the
|
||||
* form, then manually set the field to touched; this restores the expected behavior. This is probably why the
|
||||
* library doesn't hook into the onChange event to begin with...)
|
||||
*/
|
||||
const continentChanged = (e : Event) : boolean => {
|
||||
continentId.value = (e.target as HTMLSelectElement).value
|
||||
emit('touch')
|
||||
emit('update:modelValue', continentId.value)
|
||||
return true
|
||||
}
|
||||
|
||||
onMounted(async () => await store.dispatch('ensureContinents'))
|
||||
|
||||
return {
|
||||
continentId,
|
||||
continents: computed(() => store.state.continents),
|
||||
emptyLabel: props.topLabel || 'Select',
|
||||
continentChanged
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
|
@ -17,7 +17,7 @@ export default defineComponent({
|
|||
},
|
||||
setup (props) {
|
||||
return {
|
||||
formatted: format(parseToUtc(props.date), 'PPPppp')
|
||||
formatted: format(parseToUtc(props.date), 'PPPp')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
<h5 class="card-header">Your Profile</h5>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-3 text-muted fst-italic">
|
||||
Last updated <full-date :date="profile.lastUpdatedOn" />
|
||||
Last updated <full-date-time :date="profile.lastUpdatedOn" />
|
||||
</h6>
|
||||
<p v-if="profile" class="card-text">
|
||||
Your profile currently lists {{profile.skills.length}}
|
||||
|
@ -73,13 +73,13 @@ import { defineComponent, Ref, ref } from 'vue'
|
|||
import api, { LogOnSuccess, Profile } from '@/api'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
import FullDate from '@/components/FullDate.vue'
|
||||
import FullDateTime from '@/components/FullDateTime.vue'
|
||||
import LoadData from '@/components/LoadData.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Dashboard',
|
||||
components: {
|
||||
FullDate,
|
||||
FullDateTime,
|
||||
LoadData
|
||||
},
|
||||
setup () {
|
||||
|
|
|
@ -1,3 +1,190 @@
|
|||
<template>
|
||||
<p>TODO: placeholder</p>
|
||||
<article>
|
||||
<page-title :title="isNew ? 'Add a Job Listing' : 'Edit Job Listing'" />
|
||||
<h3 v-if="isNew" class="pb-3">Add a Job Listing</h3>
|
||||
<h3 v-else class="pb-3">Edit Job Listing</h3>
|
||||
<load-data :load="retrieveData">
|
||||
<form class="row g-3">
|
||||
<div class="col-12 col-sm-10 col-md-8 col-lg-6">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="title" :class="{ 'form-control': true, 'is-invalid': v$.title.$error }"
|
||||
v-model="v$.title.$model" maxlength="255" placeholder="The title for the job listing">
|
||||
<div id="titleFeedback" class="invalid-feedback">Please enter a title for the job listing</div>
|
||||
<label class="jjj-required" for="title">Title</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
No need to put location here; it will always be show to seekers with continent and region
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-md-4">
|
||||
<continent-list v-model="v$.continentId.$model" :isInvalid="v$.continentId.$error"
|
||||
@touch="v$.continentId.$touch() || true" />
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-md-8">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="region" :class="{ 'form-control': true, 'is-invalid': v$.region.$error }"
|
||||
v-model="v$.region.$model" maxlength="255" placeholder="Country, state, geographic area, etc.">
|
||||
<div id="regionFeedback" class="invalid-feedback">Please enter a region</div>
|
||||
<label for="region" class="jjj-required">Region</label>
|
||||
</div>
|
||||
<div class="form-text">Country, state, geographic area, etc.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="isRemote" class="form-check-input" v-model="v$.remoteWork.$model">
|
||||
<label class="form-check-label" for="isRemote">This opportunity is for remote work</label>
|
||||
</div>
|
||||
</div>
|
||||
<markdown-editor id="description" label="Job Description" v-model:text="v$.text.$model"
|
||||
:isInvalid="v$.text.$error" />
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="form-floating">
|
||||
<input type="date" id="neededBy" class="form-control" v-model="v$.neededBy.$model"
|
||||
placeholder="Date by which this position needs to be filled">
|
||||
<label for="neededBy">Needed By</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<p v-if="v$.$error" class="text-danger">Please correct the errors above</p>
|
||||
<button class="btn btn-primary" @click.prevent="saveListing(true)">
|
||||
<icon icon="content-save-outline" /> Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</load-data>
|
||||
<maybe-save :isShown="confirmNavShown" :toRoute="nextRoute" :saveAction="doSave" :validator="v$"
|
||||
@close="confirmClose" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, ref, Ref } from 'vue'
|
||||
import { onBeforeRouteLeave, RouteLocationNormalized, useRoute, useRouter } from 'vue-router'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { required } from '@vuelidate/validators'
|
||||
|
||||
import api, { Listing, ListingForm, LogOnSuccess } from '@/api'
|
||||
import { toastError, toastSuccess } from '@/components/layout/AppToaster.vue'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
import ContinentList from '@/components/ContinentList.vue'
|
||||
import LoadData from '@/components/LoadData.vue'
|
||||
import MarkdownEditor from '@/components/MarkdownEditor.vue'
|
||||
import MaybeSave from '@/components/MaybeSave.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ListingEdit',
|
||||
components: {
|
||||
ContinentList,
|
||||
LoadData,
|
||||
MarkdownEditor,
|
||||
MaybeSave
|
||||
},
|
||||
setup () {
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** The currently logged-on user */
|
||||
const user = store.state.user as LogOnSuccess
|
||||
|
||||
/** A new job listing */
|
||||
const newListing : Listing = {
|
||||
id: '',
|
||||
citizenId: user.citizenId,
|
||||
createdOn: '',
|
||||
title: '',
|
||||
continentId: '',
|
||||
region: '',
|
||||
remoteWork: false,
|
||||
isExpired: false,
|
||||
updatedOn: '',
|
||||
text: '',
|
||||
neededBy: undefined,
|
||||
wasFilledHere: undefined
|
||||
}
|
||||
|
||||
/** The backing object for the form */
|
||||
const listing = reactive(new ListingForm())
|
||||
|
||||
/** The ID of the listing requested */
|
||||
const id = route.params.id as string
|
||||
|
||||
/** Is this a new job listing? */
|
||||
const isNew = computed(() => id === 'new')
|
||||
|
||||
/** Validation rules for the form */
|
||||
const rules = computed(() => ({
|
||||
id: { },
|
||||
title: { required },
|
||||
continentId: { required },
|
||||
region: { required },
|
||||
remoteWork: { },
|
||||
text: { required },
|
||||
neededBy: { }
|
||||
}))
|
||||
|
||||
/** Initialize form validation */
|
||||
const v$ = useVuelidate(rules, listing, { $lazy: true })
|
||||
|
||||
/** Retrieve the listing being edited (or set up the form for a new listing) */
|
||||
const retrieveData = async (errors : string[]) => {
|
||||
const listResult = isNew.value ? newListing : await api.listings.retreive(id, user)
|
||||
if (typeof listResult === 'string') {
|
||||
errors.push(listResult)
|
||||
} else if (typeof listResult === 'undefined') {
|
||||
errors.push('Job listing not found')
|
||||
} else {
|
||||
listing.id = listResult.id
|
||||
listing.title = listResult.title
|
||||
listing.continentId = listResult.continentId
|
||||
listing.region = listResult.region
|
||||
listing.remoteWork = listResult.remoteWork
|
||||
listing.text = listResult.text
|
||||
listing.neededBy = listResult.neededBy
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the job listing */
|
||||
const saveListing = async (navigate : boolean) => {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$error) return
|
||||
const apiFunc = isNew.value ? api.listings.add : api.listings.update
|
||||
if (listing.neededBy === '') listing.neededBy = undefined
|
||||
const result = await apiFunc(listing, user)
|
||||
if (typeof result === 'string') {
|
||||
toastError(result, 'saving job listing')
|
||||
} else {
|
||||
toastSuccess(`Job Listing ${isNew.value ? 'Add' : 'Updat'}ed 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)
|
||||
|
||||
/** If the user has unsaved changes, give them an opportunity to save before moving on */
|
||||
onBeforeRouteLeave(async (to, from) => { // eslint-disable-line
|
||||
if (!v$.value.$anyDirty) return true
|
||||
nextRoute.value = to
|
||||
confirmNavShown.value = true
|
||||
return false
|
||||
})
|
||||
|
||||
return {
|
||||
isNew,
|
||||
v$,
|
||||
retrieveData,
|
||||
saveListing,
|
||||
confirmNavShown,
|
||||
nextRoute,
|
||||
doSave: async () => await saveListing(false),
|
||||
confirmClose: () => { confirmNavShown.value = false }
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -6,21 +6,23 @@
|
|||
<router-link class="btn btn-outline-primary" to="/listing/new/edit">Add a New Job Listing</router-link>
|
||||
</p>
|
||||
<load-data :load="getListings">
|
||||
<table v-if="listings.length > 0">
|
||||
<table v-if="listings.length > 0" class="table table-sm table-hover pt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Title</th>
|
||||
<th>Continent / Region</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="listing in listings" :key="listing.id">
|
||||
<td><router-link :to="`/listing/${listing.Id}/edit`">Edit</router-link></td>
|
||||
<td>{{listing.Title}}</td>
|
||||
<td><full-date-time :date="listing.createdOn" /></td>
|
||||
<td><full-date-time :date="listing.updatedOn" /></td>
|
||||
<tr v-for="it in listings" :key="it.listing.id">
|
||||
<td><router-link :to="`/listing/${it.listing.id}/edit`">Edit</router-link></td>
|
||||
<td>{{it.listing.title}}</td>
|
||||
<td>{{it.continent.name}} / {{it.listing.region}}</td>
|
||||
<td><full-date-time :date="it.listing.createdOn" /></td>
|
||||
<td><full-date-time :date="it.listing.updatedOn" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -31,7 +33,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent, Ref, ref } from 'vue'
|
||||
import api, { Listing, LogOnSuccess } from '@/api'
|
||||
import api, { ListingForView, LogOnSuccess } from '@/api'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
import FullDateTime from '@/components/FullDateTime.vue'
|
||||
|
@ -47,7 +49,7 @@ export default defineComponent({
|
|||
const store = useStore()
|
||||
|
||||
/** The listings for the user */
|
||||
const listings : Ref<Listing[]> = ref([])
|
||||
const listings : Ref<ListingForView[]> = ref([])
|
||||
|
||||
/** Retrieve the job listing posted by the current citizen */
|
||||
const getListings = async (errors : string[]) => {
|
||||
|
|
|
@ -6,6 +6,34 @@ open NodaTime
|
|||
|
||||
// fsharplint:disable FieldNames
|
||||
|
||||
/// The data required to add or edit a job listing
|
||||
type ListingForm = {
|
||||
/// The ID of the listing
|
||||
id : string
|
||||
/// The listing title
|
||||
title : string
|
||||
/// The ID of the continent on which this opportunity exists
|
||||
continentId : string
|
||||
/// The region in which this opportunity exists
|
||||
region : string
|
||||
/// Whether this is a remote work opportunity
|
||||
remoteWork : bool
|
||||
/// The text of the job listing
|
||||
text : string
|
||||
/// The date by which this job listing is needed
|
||||
neededBy : string option
|
||||
}
|
||||
|
||||
|
||||
/// The data needed to display a listing
|
||||
type ListingForView = {
|
||||
/// The listing itself
|
||||
listing : Listing
|
||||
/// The continent for that listing
|
||||
continent : Continent
|
||||
}
|
||||
|
||||
|
||||
/// A successful logon
|
||||
type LogOnSuccess = {
|
||||
/// The JSON Web Token (JWT) to use for API access
|
||||
|
|
Loading…
Reference in New Issue
Block a user