Add listing edit page, refine my listings (#16)

This commit is contained in:
Daniel J. Summers 2021-08-14 17:05:37 -04:00
parent a0d079c67a
commit 837ab35da5
10 changed files with 509 additions and 58 deletions

View File

@ -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 =

View File

@ -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" [

View File

@ -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 */

View File

@ -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 */

View 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="">&ndash; {{emptyLabel}} &ndash;</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>

View File

@ -17,7 +17,7 @@ export default defineComponent({
},
setup (props) {
return {
formatted: format(parseToUtc(props.date), 'PPPppp')
formatted: format(parseToUtc(props.date), 'PPPp')
}
}
})

View File

@ -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 () {

View File

@ -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" />&nbsp; 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>

View File

@ -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[]) => {

View File

@ -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