WIP on job listing search (#17)
This commit is contained in:
parent
227713fa26
commit
69e386d91b
@ -193,6 +193,7 @@ let regexContains (it : string) =
|
||||
System.Text.RegularExpressions.Regex.Escape it
|
||||
|> sprintf "(?i).*%s.*"
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Domain.SharedTypes
|
||||
open RethinkDb.Driver.Ast
|
||||
|
||||
@ -200,8 +201,6 @@ open RethinkDb.Driver.Ast
|
||||
[<RequireQualifiedAccess>]
|
||||
module Profile =
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
let count conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Profile)
|
||||
@ -430,25 +429,15 @@ module Continent =
|
||||
[<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 () -> 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})
|
||||
})
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
r.Table(Table.Listing)
|
||||
.GetAll(citizenId).OptArg("index", nameof citizenId)
|
||||
.EqJoin("continentId", r.Table(Table.Continent))
|
||||
.Merge(ReqlFunction1(fun it -> upcast r.HashMap("listing", it.G("left")).With("continent", it.G("right"))))
|
||||
.Pluck("listing", "continent")
|
||||
.RunResultAsync<ListingForView list> conn)
|
||||
|
||||
/// Find a listing by its ID
|
||||
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
|
||||
[<RequireQualifiedAccess>]
|
||||
|
@ -239,6 +239,16 @@ module Listing =
|
||||
| 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
|
||||
[<RequireQualifiedAccess>]
|
||||
module Profile =
|
||||
@ -441,8 +451,9 @@ let allEndpoints = [
|
||||
GET_HEAD [ route "/continent/all" Continent.all ]
|
||||
subRoute "/listing" [
|
||||
GET_HEAD [
|
||||
routef "/%O" Listing.get
|
||||
route "s/mine" Listing.mine
|
||||
routef "/%O" Listing.get
|
||||
route "/search" Listing.search
|
||||
route "s/mine" Listing.mine
|
||||
]
|
||||
POST [
|
||||
route "s" Listing.add
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
Listing,
|
||||
ListingForm,
|
||||
ListingForView,
|
||||
ListingSearch,
|
||||
LogOnSuccess,
|
||||
Profile,
|
||||
ProfileForm,
|
||||
@ -171,6 +172,23 @@ export default {
|
||||
retreive: async (id : string, user : LogOnSuccess) : Promise<Listing | undefined | string> =>
|
||||
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
|
||||
*
|
||||
|
@ -85,6 +85,18 @@ export interface ListingForView {
|
||||
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 */
|
||||
export interface LogOnSuccess {
|
||||
/** The JSON Web Token (JWT) to use for API access */
|
||||
|
80
src/JobsJobsJobs/App/src/components/ListingSearchForm.vue
Normal file
80
src/JobsJobsJobs/App/src/components/ListingSearchForm.vue
Normal 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>
|
@ -9,10 +9,10 @@
|
||||
<router-link to="/profile/search" class="separator">
|
||||
<icon icon="view-list-outline" /> View Profiles
|
||||
</router-link>
|
||||
<router-link to="/listings/mine"><icon icon="sign-text" /> My Job Listings</router-link>
|
||||
<router-link to="/listings/search" class="separator">
|
||||
<icon icon="newspaper-variant-multiple-outline" /> View Listings
|
||||
<router-link to="/help-wanted">
|
||||
<icon icon="newspaper-variant-multiple-outline" /> Help Wanted!
|
||||
</router-link>
|
||||
<router-link to="/listings/mine" class="separator"><icon icon="sign-text" /> My Job Listings</router-link>
|
||||
<router-link to="/success-story/list"><icon icon="thumb-up" /> Success Stories</router-link>
|
||||
<router-link to="/citizen/log-off"><icon icon="logout-variant" /> Log Off</router-link>
|
||||
</template>
|
||||
|
@ -73,21 +73,26 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import(/* webpackChunkName: "logoff" */ '../views/citizen/LogOff.vue')
|
||||
},
|
||||
// Job Listing URLs
|
||||
{
|
||||
path: '/help-wanted',
|
||||
name: 'HelpWanted',
|
||||
component: () => import(/* webpackChunkName: "joblist" */ '../views/listing/HelpWanted.vue')
|
||||
},
|
||||
{
|
||||
path: '/listing/:id/edit',
|
||||
name: 'EditListing',
|
||||
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',
|
||||
name: 'MyListings',
|
||||
component: () => import(/* webpackChunkName: "joblist" */ '../views/listing/MyListings.vue')
|
||||
},
|
||||
{
|
||||
path: '/listings/search',
|
||||
name: 'SearchListings',
|
||||
component: () => import(/* webpackChunkName: "joblist" */ '../views/listing/ListingSearch.vue')
|
||||
},
|
||||
// Profile URLs
|
||||
{
|
||||
path: '/profile/:id/view',
|
||||
|
145
src/JobsJobsJobs/App/src/views/listing/HelpWanted.vue
Normal file
145
src/JobsJobsJobs/App/src/views/listing/HelpWanted.vue
Normal 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 “Search” 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>
|
@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<p>TODO: write this</p>
|
||||
</template>
|
3
src/JobsJobsJobs/App/src/views/listing/ListingView.vue
Normal file
3
src/JobsJobsJobs/App/src/views/listing/ListingView.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<p>TODO: write</p>
|
||||
</template>
|
@ -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
|
||||
type LogOnSuccess = {
|
||||
/// The JSON Web Token (JWT) to use for API access
|
||||
@ -122,17 +136,6 @@ type ProfileSearch = {
|
||||
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
|
||||
type ProfileSearchResult = {
|
||||
|
Loading…
Reference in New Issue
Block a user