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 =
|
module Profile =
|
||||||
|
|
||||||
open JobsJobsJobs.Domain
|
open JobsJobsJobs.Domain
|
||||||
open RethinkDb.Driver.Ast
|
|
||||||
|
|
||||||
let count conn =
|
let count conn =
|
||||||
withReconn(conn).ExecuteAsync(fun () ->
|
withReconn(conn).ExecuteAsync(fun () ->
|
||||||
|
@ -210,14 +209,14 @@ module Profile =
|
||||||
.RunResultAsync<int64> conn)
|
.RunResultAsync<int64> conn)
|
||||||
|
|
||||||
/// Find a profile by citizen ID
|
/// Find a profile by citizen ID
|
||||||
let findById (citizenId : CitizenId) conn = task {
|
let findById (citizenId : CitizenId) conn =
|
||||||
|
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||||
let! profile =
|
let! profile =
|
||||||
withReconn(conn).ExecuteAsync(fun () ->
|
|
||||||
r.Table(Table.Profile)
|
r.Table(Table.Profile)
|
||||||
.Get(citizenId)
|
.Get(citizenId)
|
||||||
.RunResultAsync<Profile> conn)
|
.RunResultAsync<Profile> conn
|
||||||
return toOption profile
|
return toOption profile
|
||||||
}
|
})
|
||||||
|
|
||||||
/// Insert or update a profile
|
/// Insert or update a profile
|
||||||
let save (profile : Profile) conn =
|
let save (profile : Profile) conn =
|
||||||
|
@ -331,24 +330,24 @@ module Profile =
|
||||||
module Citizen =
|
module Citizen =
|
||||||
|
|
||||||
/// Find a citizen by their ID
|
/// Find a citizen by their ID
|
||||||
let findById (citizenId : CitizenId) conn = task {
|
let findById (citizenId : CitizenId) conn =
|
||||||
|
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||||
let! citizen =
|
let! citizen =
|
||||||
withReconn(conn).ExecuteAsync(fun () ->
|
|
||||||
r.Table(Table.Citizen)
|
r.Table(Table.Citizen)
|
||||||
.Get(citizenId)
|
.Get(citizenId)
|
||||||
.RunResultAsync<Citizen> conn)
|
.RunResultAsync<Citizen> conn
|
||||||
return toOption citizen
|
return toOption citizen
|
||||||
}
|
})
|
||||||
|
|
||||||
/// Find a citizen by their No Agenda Social username
|
/// Find a citizen by their No Agenda Social username
|
||||||
let findByNaUser (naUser : string) conn = task {
|
let findByNaUser (naUser : string) conn =
|
||||||
|
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||||
let! citizen =
|
let! citizen =
|
||||||
withReconn(conn).ExecuteAsync(fun () ->
|
|
||||||
r.Table(Table.Citizen)
|
r.Table(Table.Citizen)
|
||||||
.GetAll(naUser).OptArg("index", "naUser").Nth(0)
|
.GetAll(naUser).OptArg("index", "naUser").Nth(0)
|
||||||
.RunResultAsync<Citizen> conn)
|
.RunResultAsync<Citizen> conn
|
||||||
return toOption citizen
|
return toOption citizen
|
||||||
}
|
})
|
||||||
|
|
||||||
/// Add a citizen
|
/// Add a citizen
|
||||||
let add (citizen : Citizen) conn =
|
let add (citizen : Citizen) conn =
|
||||||
|
@ -381,6 +380,11 @@ module Citizen =
|
||||||
.GetAll(citizenId).OptArg("index", "citizenId")
|
.GetAll(citizenId).OptArg("index", "citizenId")
|
||||||
.Delete()
|
.Delete()
|
||||||
.RunWriteAsync conn
|
.RunWriteAsync conn
|
||||||
|
let! _ =
|
||||||
|
r.Table(Table.Listing)
|
||||||
|
.GetAll(citizenId).OptArg("index", "citizenId")
|
||||||
|
.Delete()
|
||||||
|
.RunWriteAsync conn
|
||||||
let! _ =
|
let! _ =
|
||||||
r.Table(Table.Citizen)
|
r.Table(Table.Citizen)
|
||||||
.Get(citizenId)
|
.Get(citizenId)
|
||||||
|
@ -412,26 +416,70 @@ module Continent =
|
||||||
.RunResultAsync<Continent list> conn)
|
.RunResultAsync<Continent list> conn)
|
||||||
|
|
||||||
/// Get a continent by its ID
|
/// Get a continent by its ID
|
||||||
let findById (contId : ContinentId) conn = task {
|
let findById (contId : ContinentId) conn =
|
||||||
|
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||||
let! continent =
|
let! continent =
|
||||||
withReconn(conn).ExecuteAsync(fun () ->
|
|
||||||
r.Table(Table.Continent)
|
r.Table(Table.Continent)
|
||||||
.Get(contId)
|
.Get(contId)
|
||||||
.RunResultAsync<Continent> conn)
|
.RunResultAsync<Continent> conn
|
||||||
return toOption continent
|
return toOption continent
|
||||||
}
|
})
|
||||||
|
|
||||||
|
|
||||||
/// Job listing data access functions
|
/// Job listing data access functions
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module Listing =
|
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
|
/// 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 () -> task {
|
||||||
|
let! both =
|
||||||
r.Table(Table.Listing)
|
r.Table(Table.Listing)
|
||||||
.GetAll(citizenId).OptArg("index", nameof citizenId)
|
.GetAll(citizenId).OptArg("index", nameof citizenId)
|
||||||
.RunResultAsync<Listing list> conn)
|
.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
|
/// Success story data access functions
|
||||||
|
@ -439,25 +487,25 @@ module Listing =
|
||||||
module Success =
|
module Success =
|
||||||
|
|
||||||
/// Find a success report by its ID
|
/// Find a success report by its ID
|
||||||
let findById (successId : SuccessId) conn = task {
|
let findById (successId : SuccessId) conn =
|
||||||
|
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||||
let! success =
|
let! success =
|
||||||
withReconn(conn).ExecuteAsync(fun () ->
|
|
||||||
r.Table(Table.Success)
|
r.Table(Table.Success)
|
||||||
.Get(successId)
|
.Get(successId)
|
||||||
.RunResultAsync<Success> conn)
|
.RunResultAsync<Success> conn
|
||||||
return toOption success
|
return toOption success
|
||||||
}
|
})
|
||||||
|
|
||||||
/// Insert or update a success story
|
/// Insert or update a success story
|
||||||
let save (success : Success) conn = task {
|
let save (success : Success) conn =
|
||||||
|
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||||
let! _ =
|
let! _ =
|
||||||
withReconn(conn).ExecuteAsync(fun () ->
|
|
||||||
r.Table(Table.Success)
|
r.Table(Table.Success)
|
||||||
.Get(success.id)
|
.Get(success.id)
|
||||||
.Replace(success)
|
.Replace(success)
|
||||||
.RunWriteAsync conn)
|
.RunWriteAsync conn
|
||||||
()
|
()
|
||||||
}
|
})
|
||||||
|
|
||||||
// Retrieve all success stories
|
// Retrieve all success stories
|
||||||
let all conn =
|
let all conn =
|
||||||
|
|
|
@ -170,7 +170,13 @@ module Continent =
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module Listing =
|
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 =
|
let mine : HttpHandler =
|
||||||
authorize
|
authorize
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
|
@ -178,6 +184,61 @@ module Listing =
|
||||||
return! json listings next ctx
|
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
|
/// Handlers for /api/profile routes
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module Profile =
|
module Profile =
|
||||||
|
@ -380,8 +441,15 @@ let allEndpoints = [
|
||||||
GET_HEAD [ route "/continent/all" Continent.all ]
|
GET_HEAD [ route "/continent/all" Continent.all ]
|
||||||
subRoute "/listing" [
|
subRoute "/listing" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
|
routef "/%O" Listing.get
|
||||||
route "s/mine" Listing.mine
|
route "s/mine" Listing.mine
|
||||||
]
|
]
|
||||||
|
POST [
|
||||||
|
route "s" Listing.add
|
||||||
|
]
|
||||||
|
PUT [
|
||||||
|
routef "/%O" Listing.update
|
||||||
|
]
|
||||||
]
|
]
|
||||||
subRoute "/profile" [
|
subRoute "/profile" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
|
|
|
@ -4,6 +4,8 @@ import {
|
||||||
Continent,
|
Continent,
|
||||||
Count,
|
Count,
|
||||||
Listing,
|
Listing,
|
||||||
|
ListingForm,
|
||||||
|
ListingForView,
|
||||||
LogOnSuccess,
|
LogOnSuccess,
|
||||||
Profile,
|
Profile,
|
||||||
ProfileForm,
|
ProfileForm,
|
||||||
|
@ -139,14 +141,45 @@ export default {
|
||||||
/** API functions for job listings */
|
/** API functions for job listings */
|
||||||
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
|
* Retrieve the job listings posted by the current citizen
|
||||||
*
|
*
|
||||||
* @param user The currently logged-on user
|
* @param user The currently logged-on user
|
||||||
* @returns The job listings the user has posted, or an error string
|
* @returns The job listings the user has posted, or an error string
|
||||||
*/
|
*/
|
||||||
mine: async (user : LogOnSuccess) : Promise<Listing[] | string | undefined> =>
|
mine: async (user : LogOnSuccess) : Promise<ListingForView[] | string | undefined> =>
|
||||||
apiResult<Listing[]>(await fetch(apiUrl('listings/mine'), reqInit('GET', user)), 'retrieving your job listings')
|
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 */
|
/** API functions for profiles */
|
||||||
|
|
|
@ -59,6 +59,32 @@ export interface Listing {
|
||||||
wasFilledHere : boolean | undefined
|
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 */
|
/** A successful logon */
|
||||||
export interface LogOnSuccess {
|
export interface LogOnSuccess {
|
||||||
/** The JSON Web Token (JWT) to use for API access */
|
/** 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) {
|
setup (props) {
|
||||||
return {
|
return {
|
||||||
formatted: format(parseToUtc(props.date), 'PPPppp')
|
formatted: format(parseToUtc(props.date), 'PPPp')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<h5 class="card-header">Your Profile</h5>
|
<h5 class="card-header">Your Profile</h5>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="card-subtitle mb-3 text-muted fst-italic">
|
<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>
|
</h6>
|
||||||
<p v-if="profile" class="card-text">
|
<p v-if="profile" class="card-text">
|
||||||
Your profile currently lists {{profile.skills.length}}
|
Your profile currently lists {{profile.skills.length}}
|
||||||
|
@ -73,13 +73,13 @@ import { defineComponent, Ref, ref } from 'vue'
|
||||||
import api, { LogOnSuccess, Profile } from '@/api'
|
import api, { LogOnSuccess, Profile } from '@/api'
|
||||||
import { useStore } from '@/store'
|
import { useStore } from '@/store'
|
||||||
|
|
||||||
import FullDate from '@/components/FullDate.vue'
|
import FullDateTime from '@/components/FullDateTime.vue'
|
||||||
import LoadData from '@/components/LoadData.vue'
|
import LoadData from '@/components/LoadData.vue'
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'Dashboard',
|
name: 'Dashboard',
|
||||||
components: {
|
components: {
|
||||||
FullDate,
|
FullDateTime,
|
||||||
LoadData
|
LoadData
|
||||||
},
|
},
|
||||||
setup () {
|
setup () {
|
||||||
|
|
|
@ -1,3 +1,190 @@
|
||||||
<template>
|
<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>
|
</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>
|
<router-link class="btn btn-outline-primary" to="/listing/new/edit">Add a New Job Listing</router-link>
|
||||||
</p>
|
</p>
|
||||||
<load-data :load="getListings">
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
|
<th>Continent / Region</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Updated</th>
|
<th>Updated</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="listing in listings" :key="listing.id">
|
<tr v-for="it in listings" :key="it.listing.id">
|
||||||
<td><router-link :to="`/listing/${listing.Id}/edit`">Edit</router-link></td>
|
<td><router-link :to="`/listing/${it.listing.id}/edit`">Edit</router-link></td>
|
||||||
<td>{{listing.Title}}</td>
|
<td>{{it.listing.title}}</td>
|
||||||
<td><full-date-time :date="listing.createdOn" /></td>
|
<td>{{it.continent.name}} / {{it.listing.region}}</td>
|
||||||
<td><full-date-time :date="listing.updatedOn" /></td>
|
<td><full-date-time :date="it.listing.createdOn" /></td>
|
||||||
|
<td><full-date-time :date="it.listing.updatedOn" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -31,7 +33,7 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, Ref, ref } from 'vue'
|
import { defineComponent, Ref, ref } from 'vue'
|
||||||
import api, { Listing, LogOnSuccess } from '@/api'
|
import api, { ListingForView, LogOnSuccess } from '@/api'
|
||||||
import { useStore } from '@/store'
|
import { useStore } from '@/store'
|
||||||
|
|
||||||
import FullDateTime from '@/components/FullDateTime.vue'
|
import FullDateTime from '@/components/FullDateTime.vue'
|
||||||
|
@ -47,7 +49,7 @@ export default defineComponent({
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
|
||||||
/** The listings for the user */
|
/** The listings for the user */
|
||||||
const listings : Ref<Listing[]> = ref([])
|
const listings : Ref<ListingForView[]> = ref([])
|
||||||
|
|
||||||
/** Retrieve the job listing posted by the current citizen */
|
/** Retrieve the job listing posted by the current citizen */
|
||||||
const getListings = async (errors : string[]) => {
|
const getListings = async (errors : string[]) => {
|
||||||
|
|
|
@ -6,6 +6,34 @@ open NodaTime
|
||||||
|
|
||||||
// fsharplint:disable FieldNames
|
// 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
|
/// A successful logon
|
||||||
type LogOnSuccess = {
|
type LogOnSuccess = {
|
||||||
/// The JSON Web Token (JWT) to use for API access
|
/// The JSON Web Token (JWT) to use for API access
|
||||||
|
|
Loading…
Reference in New Issue
Block a user