WIP on job listing search (#17)

This commit is contained in:
Daniel J. Summers 2021-08-14 23:02:54 -04:00
parent 227713fa26
commit 69e386d91b
11 changed files with 338 additions and 43 deletions

View File

@ -193,6 +193,7 @@ let regexContains (it : string) =
System.Text.RegularExpressions.Regex.Escape it System.Text.RegularExpressions.Regex.Escape it
|> sprintf "(?i).*%s.*" |> sprintf "(?i).*%s.*"
open JobsJobsJobs.Domain
open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Domain.SharedTypes
open RethinkDb.Driver.Ast open RethinkDb.Driver.Ast
@ -200,8 +201,6 @@ open RethinkDb.Driver.Ast
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Profile = module Profile =
open JobsJobsJobs.Domain
let count conn = let count conn =
withReconn(conn).ExecuteAsync(fun () -> withReconn(conn).ExecuteAsync(fun () ->
r.Table(Table.Profile) r.Table(Table.Profile)
@ -430,25 +429,15 @@ module Continent =
[<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 () -> task { withReconn(conn).ExecuteAsync(fun () ->
let! both = r.Table(Table.Listing)
r.Table(Table.Listing) .GetAll(citizenId).OptArg("index", nameof citizenId)
.GetAll(citizenId).OptArg("index", nameof citizenId) .EqJoin("continentId", r.Table(Table.Continent))
.EqJoin("continentId", r.Table(Table.Continent)) .Merge(ReqlFunction1(fun it -> upcast r.HashMap("listing", it.G("left")).With("continent", it.G("right"))))
.RunResultAsync<ListingAndContinent list> conn .Pluck("listing", "continent")
return both |> List.map (fun b -> { listing = b.left; continent = b.right}) .RunResultAsync<ListingForView list> conn)
})
/// Find a listing by its ID /// Find a listing by its ID
let findById (listingId : ListingId) conn = let findById (listingId : ListingId) conn =
@ -481,6 +470,38 @@ module Listing =
() ()
}) })
/// Search job listings
let search (srch : ListingSearch) conn =
withReconn(conn).ExecuteAsync(fun () ->
(seq {
match srch.continentId with
| Some conId ->
yield (fun (q : ReqlExpr) ->
q.Filter(r.HashMap(nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
| None -> ()
match srch.region with
| Some rgn ->
yield (fun q ->
q.Filter(ReqlFunction1(fun s -> upcast s.G("region").Match(regexContains rgn))) :> ReqlExpr)
| None -> ()
match srch.remoteWork with
| "" -> ()
| _ -> yield (fun q -> q.Filter(r.HashMap(nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
match srch.text with
| Some text ->
yield (fun q ->
q.Filter(ReqlFunction1(fun it -> upcast it.G("text").Match(regexContains text))) :> ReqlExpr)
| None -> ()
}
|> Seq.toList
|> List.fold
(fun q f -> f q)
(r.Table(Table.Listing)
.EqJoin("continentId", r.Table(Table.Continent)) :> ReqlExpr))
.Merge(ReqlFunction1(fun it -> upcast r.HashMap("listing", it.G("left")).With("continent", it.G("right"))))
.Pluck("listing", "continent")
.RunResultAsync<ListingForView list> conn)
/// Success story data access functions /// Success story data access functions
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]

View File

@ -239,6 +239,16 @@ module Listing =
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// GET: /api/listing/search
let search : HttpHandler =
authorize
>=> fun next ctx -> task {
let search = ctx.BindQueryString<ListingSearch> ()
let! results = Data.Listing.search search (conn ctx)
return! json results next ctx
}
/// Handlers for /api/profile routes /// Handlers for /api/profile routes
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Profile = module Profile =
@ -441,8 +451,9 @@ 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 routef "/%O" Listing.get
route "s/mine" Listing.mine route "/search" Listing.search
route "s/mine" Listing.mine
] ]
POST [ POST [
route "s" Listing.add route "s" Listing.add

View File

@ -6,6 +6,7 @@ import {
Listing, Listing,
ListingForm, ListingForm,
ListingForView, ListingForView,
ListingSearch,
LogOnSuccess, LogOnSuccess,
Profile, Profile,
ProfileForm, ProfileForm,
@ -171,6 +172,23 @@ export default {
retreive: async (id : string, user : LogOnSuccess) : Promise<Listing | undefined | string> => retreive: async (id : string, user : LogOnSuccess) : Promise<Listing | undefined | string> =>
apiResult<Listing>(await fetch(apiUrl(`listing/${id}`), reqInit('GET', user)), 'retrieving job listing'), apiResult<Listing>(await fetch(apiUrl(`listing/${id}`), reqInit('GET', user)), 'retrieving job listing'),
/**
* Search for job listings using the given parameters
*
* @param query The listing search parameters
* @param user The currently logged-on user
* @returns The matching job listings (if found), undefined (if API returns 404), or an error string
*/
search: async (query : ListingSearch, user : LogOnSuccess) : Promise<ListingForView[] | string | undefined> => {
const params = new URLSearchParams()
if (query.continentId) params.append('continentId', query.continentId)
if (query.region) params.append('skill', query.region)
params.append('remoteWork', query.remoteWork)
if (query.text) params.append('text', query.text)
return apiResult<ListingForView[]>(await fetch(apiUrl(`listing/search?${params.toString()}`),
reqInit('GET', user)), 'searching job listings')
},
/** /**
* Update an existing job listing * Update an existing job listing
* *

View File

@ -85,6 +85,18 @@ export interface ListingForView {
continent : Continent continent : Continent
} }
/** The various ways job listings can be searched */
export interface ListingSearch {
/** Retrieve opportunities from this continent */
continentId : string | undefined
/** Text for a search for a specific region */
region : string | undefined
/** Whether to retrieve job listings for remote work */
remoteWork : string
/** Text to search with a job's full description */
text : string | undefined
}
/** 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 */

View File

@ -0,0 +1,80 @@
<template>
<form class="container">
<div class="row">
<div class="col col-xs-12 col-sm-6 col-md-4 col-lg-3">
<continent-list v-model="criteria.continentId" topLabel="Any"
@update:modelValue="(c) => updateValue('continentId', c)" />
</div>
<div class="col col-xs-12 col-sm-6 col-lg-3">
<div class="form-floating">
<input type="text" id="regionSearch" class="form-control" placeholder="(free-form text)"
:value="criteria.region" @input="updateValue('region', $event.target.value)">
<label for="regionSearch" class="jjj-label">Region</label>
</div>
<div class="form-text">(free-form text)</div>
</div>
<div class="col col-xs-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0">
<label class="jjj-label">Remote Work Opportunity?</label><br>
<div class="form-check form-check-inline">
<input type="radio" id="remoteNull" name="remoteWork" class="form-check-input"
:checked="criteria.remoteWork === ''" @click="updateValue('remoteWork', '')">
<label for="remoteNull" class="form-check-label">No Selection</label>
</div>
<div class="form-check form-check-inline">
<input type="radio" id="remoteYes" name="remoteWork" class="form-check-input"
:checked="criteria.remoteWork === 'yes'" @click="updateValue('remoteWork', 'yes')">
<label for="remoteYes" class="form-check-label">Yes</label>
</div>
<div class="form-check form-check-inline">
<input type="radio" id="remoteNo" name="remoteWork" class="form-check-input"
:checked="criteria.remoteWork === 'no'" @click="updateValue('remoteWork', 'no')">
<label for="remoteNo" class="form-check-label">No</label>
</div>
</div>
<div class="col col-xs-12 col-sm-6 col-lg-3">
<div class="form-floating">
<input type="text" id="textSearch" class="form-control" placeholder="(free-form text)"
:value="criteria.text" @input="updateValue('text', $event.target.value)">
<label for="textSearch" class="jjj-label">Job Listing Text</label>
</div>
<div class="form-text">(free-form text)</div>
</div>
</div>
<div class="row">
<div class="col col-xs-12">
<br>
<button type="submit" class="btn btn-outline-primary" @click.prevent="$emit('search')">Search</button>
</div>
</div>
</form>
</template>
<script lang="ts">
import { ListingSearch } from '@/api'
import { defineComponent, ref, Ref } from 'vue'
import ContinentList from './ContinentList.vue'
export default defineComponent({
name: 'ListingSearchForm',
components: { ContinentList },
props: {
modelValue: {
type: Object,
required: true
}
},
emits: ['search', 'update:modelValue'],
setup (props, { emit }) {
/** The initial search criteria passed; this is what we'll update and emit when data changes */
const criteria : Ref<ListingSearch> = ref({ ...props.modelValue as ListingSearch })
return {
criteria,
updateValue: (key : string, value : string) => {
criteria.value = { ...criteria.value, [key]: value }
emit('update:modelValue', criteria.value)
}
}
}
})
</script>

View File

@ -9,10 +9,10 @@
<router-link to="/profile/search" class="separator"> <router-link to="/profile/search" class="separator">
<icon icon="view-list-outline" />&nbsp; View Profiles <icon icon="view-list-outline" />&nbsp; View Profiles
</router-link> </router-link>
<router-link to="/listings/mine"><icon icon="sign-text" />&nbsp; My Job Listings</router-link> <router-link to="/help-wanted">
<router-link to="/listings/search" class="separator"> <icon icon="newspaper-variant-multiple-outline" />&nbsp; Help Wanted!
<icon icon="newspaper-variant-multiple-outline" />&nbsp; View Listings
</router-link> </router-link>
<router-link to="/listings/mine" class="separator"><icon icon="sign-text" />&nbsp; My Job Listings</router-link>
<router-link to="/success-story/list"><icon icon="thumb-up" />&nbsp; Success Stories</router-link> <router-link to="/success-story/list"><icon icon="thumb-up" />&nbsp; Success Stories</router-link>
<router-link to="/citizen/log-off"><icon icon="logout-variant" />&nbsp; Log Off</router-link> <router-link to="/citizen/log-off"><icon icon="logout-variant" />&nbsp; Log Off</router-link>
</template> </template>

View File

@ -73,21 +73,26 @@ const routes: Array<RouteRecordRaw> = [
component: () => import(/* webpackChunkName: "logoff" */ '../views/citizen/LogOff.vue') component: () => import(/* webpackChunkName: "logoff" */ '../views/citizen/LogOff.vue')
}, },
// Job Listing URLs // Job Listing URLs
{
path: '/help-wanted',
name: 'HelpWanted',
component: () => import(/* webpackChunkName: "joblist" */ '../views/listing/HelpWanted.vue')
},
{ {
path: '/listing/:id/edit', path: '/listing/:id/edit',
name: 'EditListing', name: 'EditListing',
component: () => import(/* webpackChunkName: "jobedit" */ '../views/listing/ListingEdit.vue') component: () => import(/* webpackChunkName: "jobedit" */ '../views/listing/ListingEdit.vue')
}, },
{
path: '/listing/:id/view',
name: 'ViewListing',
component: () => import(/* webpackChunkName: "joblist" */ '../views/listing/ListingView.vue')
},
{ {
path: '/listings/mine', path: '/listings/mine',
name: 'MyListings', name: 'MyListings',
component: () => import(/* webpackChunkName: "joblist" */ '../views/listing/MyListings.vue') component: () => import(/* webpackChunkName: "joblist" */ '../views/listing/MyListings.vue')
}, },
{
path: '/listings/search',
name: 'SearchListings',
component: () => import(/* webpackChunkName: "joblist" */ '../views/listing/ListingSearch.vue')
},
// Profile URLs // Profile URLs
{ {
path: '/profile/:id/view', path: '/profile/:id/view',

View File

@ -0,0 +1,145 @@
<template>
<article>
<page-title title="Help Wanted" />
<h3 class="pb-3">Help Wanted</h3>
<p v-if="!searched">
Enter relevant criteria to find results, or just click &ldquo;Search&rdquo; to see all current job listings.
</p>
<collapse-panel headerText="Search Criteria" :collapsed="isCollapsed" @toggle="toggleCollapse">
<listing-search-form v-model="criteria" @search="doSearch" />
</collapse-panel>
<error-list :errors="errors">
<p v-if="searching" class="pt-3">Searching job listings...</p>
<template v-else>
<table v-if="results.length > 0" class="table table-sm table-hover pt-3">
<thead>
<tr>
<th scope="col">Listing</th>
<th scope="col">Title</th>
<th scope="col">Location</th>
<th scope="col" class="text-center">Remote?</th>
<th scope="col" class="text-center">Needed By</th>
</tr>
</thead>
<tbody>
<tr v-for="it in results" :key="it.listing.id">
<td><router-link :to="`/listing/${it.listing.id}/view`">View</router-link></td>
<td>{{it.listing.title}}</td>
<td>{{it.continent.name}} / {{it.listing.region}}</td>
<td class="text-center">{{yesOrNo(it.listing.remoteWork)}}</td>
<td v-if="it.listing.neededBy" class="text-center">{{format(Date.parse(it.listing.neededBy), 'PPP')}}</td>
<td v-else class="text-center">N/A</td>
</tr>
</tbody>
</table>
<p v-else-if="searched" class="pt-3">No job listings found for the specified criteria</p>
</template>
</error-list>
</article>
</template>
<script lang="ts">
import { defineComponent, ref, Ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { format } from 'date-fns'
import { yesOrNo } from '@/App.vue'
import api, { ListingForView, ListingSearch, LogOnSuccess } from '@/api'
import { queryValue } from '@/router'
import { useStore } from '@/store'
import CollapsePanel from '@/components/CollapsePanel.vue'
import ErrorList from '@/components/ErrorList.vue'
import ListingSearchForm from '@/components/ListingSearchForm.vue'
export default defineComponent({
components: {
CollapsePanel,
ErrorList,
ListingSearchForm
},
setup () {
const store = useStore()
const route = useRoute()
const router = useRouter()
/** Any errors encountered while retrieving data */
const errors : Ref<string[]> = ref([])
/** Whether we are currently searching (retrieving data) */
const searching = ref(false)
/** Whether a search has been performed on this page since it has been loaded */
const searched = ref(false)
/** An empty set of search criteria */
const emptyCriteria = {
continentId: '',
region: undefined,
remoteWork: '',
text: undefined
}
/** The search criteria being built from the page */
const criteria : Ref<ListingSearch> = ref(emptyCriteria)
/** The current search results */
const results : Ref<ListingForView[]> = ref([])
/** Whether the search criteria should be collapsed */
const isCollapsed = ref(searched.value && results.value.length > 0)
/** Set up the page to match its requested state */
const setUpPage = async () => {
if (queryValue(route, 'searched') === 'true') {
searched.value = true
try {
searching.value = true
// Hold variable for ensuring continent ID is not undefined here, but excluded from search payload
const contId = queryValue(route, 'continentId')
const searchParams : ListingSearch = {
continentId: contId === '' ? undefined : contId,
region: queryValue(route, 'region'),
remoteWork: queryValue(route, 'remoteWork') || '',
text: queryValue(route, 'text')
}
const searchResult = await api.listings.search(searchParams, store.state.user as LogOnSuccess)
if (typeof searchResult === 'string') {
errors.value.push(searchResult)
} else if (searchResult === undefined) {
errors.value.push('The server returned a "Not Found" response (this should not happen)')
} else {
results.value = searchResult
searchParams.continentId = searchParams.continentId || ''
criteria.value = searchParams
}
} finally {
searching.value = false
}
isCollapsed.value = searched.value && results.value.length > 0
} else {
searched.value = false
criteria.value = emptyCriteria
errors.value = []
results.value = []
}
}
watch(() => route.query, setUpPage, { immediate: true })
return {
errors,
criteria,
isCollapsed,
toggleCollapse: (it : boolean) => { isCollapsed.value = it },
doSearch: () => router.push({ query: { searched: 'true', ...criteria.value } }),
searching,
searched,
results,
yesOrNo,
format
}
}
})
</script>

View File

@ -1,3 +0,0 @@
<template>
<p>TODO: write this</p>
</template>

View File

@ -0,0 +1,3 @@
<template>
<p>TODO: write</p>
</template>

View File

@ -34,6 +34,20 @@ type ListingForView = {
} }
/// The various ways job listings can be searched
[<CLIMutable>]
type ListingSearch = {
/// Retrieve job listings for this continent
continentId : string option
/// Text for a search within a region
region : string option
/// Whether to retrieve job listings for remote work
remoteWork : string
/// Text for a search with the job listing description
text : string option
}
/// 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
@ -122,17 +136,6 @@ type ProfileSearch = {
remoteWork : string remoteWork : string
} }
/// Support functions for profile searches
module ProfileSearch =
/// Is the search empty?
let isEmptySearch search =
[ search.continentId
search.skill
search.bioExperience
match search.remoteWork with "" -> Some search.remoteWork | _ -> None
]
|> List.exists Option.isSome
/// A user matching the profile search /// A user matching the profile search
type ProfileSearchResult = { type ProfileSearchResult = {