Migrate job listing search

This commit is contained in:
Daniel J. Summers 2023-01-15 22:17:33 -05:00
parent 9de255d25a
commit df7299ccb1
7 changed files with 121 additions and 256 deletions

View File

@ -1,77 +0,0 @@
<template>
<form class="container">
<div class="row">
<div class="col-12 col-sm-6 col-md-4 col-lg-3">
<continent-list v-model="criteria.continentId" topLabel="Any" @update:modelValue="updateContinent" />
</div>
<div class="col-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">Region</label>
</div>
<div class="form-text">(free-form text)</div>
</div>
<div class="col-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" class="form-check-input" name="remoteWork"
:checked="criteria.remoteWork === ''" @click="updateValue('remoteWork', '')">
<label class="form-check-label" for="remoteNull">No Selection</label>
</div>
<div class="form-check form-check-inline">
<input type="radio" id="remoteYes" class="form-check-input" name="remoteWork"
:checked="criteria.remoteWork === 'yes'" @click="updateValue('remoteWork', 'yes')">
<label class="form-check-label" for="remoteYes">Yes</label>
</div>
<div class="form-check form-check-inline">
<input type="radio" id="remoteNo" class="form-check-input" name="remoteWork"
:checked="criteria.remoteWork === 'no'" @click="updateValue('remoteWork', 'no')">
<label class="form-check-label" for="remoteNo">No</label>
</div>
</div>
<div class="col-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">Job Listing Text</label>
</div>
<div class="form-text">(free-form text)</div>
</div>
</div>
<div class="row">
<div class="col">
<br>
<button class="btn btn-outline-primary" type="submit" @click.prevent="$emit('search')">Search</button>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import { ListingSearch } from "@/api"
import { ref } from "vue"
import ContinentList from "./ContinentList.vue"
const props = defineProps<{
modelValue: ListingSearch
}>()
const emit = defineEmits<{
(e: "search") : void
(e: "update:modelValue", value : ListingSearch) : void
}>()
/** The initial search criteria passed; this is what we'll update and emit when data changes */
const criteria = ref({ ...props.modelValue })
/** Emit a value update */
const updateValue = (key : string, value : string) => {
criteria.value = { ...criteria.value, [key]: value }
emit("update:modelValue", criteria.value)
}
/** Update the continent ID */
const updateContinent = (c : string) => updateValue("continentId", c)
</script>

View File

@ -1,128 +0,0 @@
<template>
<article>
<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&hellip;</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 class="text-center" scope="col">Remote?</th>
<th class="text-center" scope="col">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 class="text-center" v-if="it.listing.neededBy">{{formatNeededBy(it.listing.neededBy)}}</td>
<td class="text-center" v-else>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 setup lang="ts">
import { ref, Ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { formatNeededBy } from "./"
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"
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 = []
}
}
/** Refresh the page when the query string changes */
watch(() => route.query, setUpPage, { immediate: true })
/** Show or hide the search parameter panel */
const toggleCollapse = (it : boolean) => { isCollapsed.value = it }
/** Execute a search */
const doSearch = () => router.push({ query: { searched: "true", ...criteria.value } })
</script>

View File

@ -1,11 +0,0 @@
import { format } from "date-fns"
/**
* Format the needed by date for display
*
* @param neededBy The defined needed by date
* @returns The date to display
*/
export function formatNeededBy (neededBy : string) : string {
return format(Date.parse(`${neededBy}T00:00:00`), "PPP")
}

View File

@ -373,19 +373,16 @@ module Listings =
connection () |> saveDocument Table.Listing (ListingId.toString listing.Id) <| mkDoc listing connection () |> saveDocument Table.Listing (ListingId.toString listing.Id) <| mkDoc listing
/// Search job listings /// Search job listings
let search (search : ListingSearch) = let search (search : ListingSearchForm) =
let searches = [ let searches = [
match search.ContinentId with if search.ContinentId <> "" then
| Some contId -> "l.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string contId ] "l.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ]
| None -> () if search.Region <> "" then
match search.Region with "l.data ->> 'region' ILIKE @region", [ "@region", like search.Region ]
| Some region -> "l.data ->> 'region' ILIKE @region", [ "@region", like region ]
| None -> ()
if search.RemoteWork <> "" then if search.RemoteWork <> "" then
"l.data ->> 'isRemote' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ] "l.data ->> 'isRemote' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ]
match search.Text with if search.Text <> "" then
| Some text -> "l.data ->> 'text' ILIKE @text", [ "@text", like text ] "l.data ->> 'text' ILIKE @text", [ "@text", like search.Text ]
| None -> ()
] ]
connection () connection ()
|> Sql.query $" |> Sql.query $"

View File

@ -82,19 +82,19 @@ type ListingForView =
/// The various ways job listings can be searched /// The various ways job listings can be searched
[<CLIMutable>] [<CLIMutable; NoComparison; NoEquality>]
type ListingSearch = type ListingSearchForm =
{ /// Retrieve job listings for this continent { /// Retrieve job listings for this continent
ContinentId : string option ContinentId : string
/// Text for a search within a region /// Text for a search within a region
Region : string option Region : string
/// Whether to retrieve job listings for remote work /// Whether to retrieve job listings for remote work
RemoteWork : string RemoteWork : string
/// Text for a search with the job listing description /// Text for a search with the job listing description
Text : string option Text : string
} }

View File

@ -457,7 +457,7 @@ module Home =
renderHandler "Terms of Service" Home.termsOfService renderHandler "Terms of Service" Home.termsOfService
/// Handlers for /listing[s] routes /// Handlers for /listing[s] routes (and /help-wanted)
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Listing = module Listing =
@ -572,6 +572,22 @@ module Listing =
} }
// GET: /help-wanted
let search : HttpHandler = requireUser >=> fun next ctx -> task {
let! continents = Continents.all ()
let form =
match ctx.TryBindQueryString<ListingSearchForm> () with
| Ok f -> f
| Error _ -> { ContinentId = ""; Region = ""; RemoteWork = ""; Text = "" }
let! results = task {
if string ctx.Request.Query["searched"] = "true" then
let! it = Listings.search form
return Some it
else return None
}
return! Listing.search form continents results |> render "Help Wanted" next ctx
}
// GET: /listing/[id]/view // GET: /listing/[id]/view
let view listingId : HttpHandler = requireUser >=> fun next ctx -> task { let view listingId : HttpHandler = requireUser >=> fun next ctx -> task {
match! Listings.findByIdForView (ListingId listingId) with match! Listings.findByIdForView (ListingId listingId) with
@ -580,18 +596,6 @@ module Listing =
} }
/// Handlers for /api/listing[s] routes
[<RequireQualifiedAccess>]
module ListingApi =
// GET: /api/listing/search
let search : HttpHandler = authorize >=> fun next ctx -> task {
let search = ctx.BindQueryString<ListingSearch> ()
let! results = Listings.search search
return! json results next ctx
}
/// Handlers for /profile routes /// Handlers for /profile routes
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Profile = module Profile =
@ -780,7 +784,13 @@ open Giraffe.EndpointRouting
/// All available endpoints for the application /// All available endpoints for the application
let allEndpoints = [ let allEndpoints = [
GET_HEAD [ route "/" Home.home ] GET_HEAD [
route "/" Home.home
route "/help-wanted" Listing.search
route "/how-it-works" Home.howItWorks
route "/privacy-policy" Home.privacyPolicy
route "/terms-of-service" Home.termsOfService
]
subRoute "/citizen" [ subRoute "/citizen" [
GET_HEAD [ GET_HEAD [
routef "/confirm/%s" Citizen.confirm routef "/confirm/%s" Citizen.confirm
@ -797,7 +807,6 @@ let allEndpoints = [
route "/register" Citizen.doRegistration route "/register" Citizen.doRegistration
] ]
] ]
GET_HEAD [ route "/how-it-works" Home.howItWorks ]
subRoute "/listing" [ subRoute "/listing" [
GET_HEAD [ GET_HEAD [
route "s/mine" Listing.mine route "s/mine" Listing.mine
@ -810,7 +819,6 @@ let allEndpoints = [
route "/save" Listing.save route "/save" Listing.save
] ]
] ]
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
subRoute "/profile" [ subRoute "/profile" [
GET_HEAD [ GET_HEAD [
routef "/%O/view" Profile.view routef "/%O/view" Profile.view
@ -823,7 +831,6 @@ let allEndpoints = [
route "/save" Profile.save route "/save" Profile.save
] ]
] ]
GET_HEAD [ route "/terms-of-service" Home.termsOfService ]
subRoute "/api" [ subRoute "/api" [
subRoute "/citizen" [ subRoute "/citizen" [
@ -832,11 +839,6 @@ let allEndpoints = [
] ]
] ]
GET_HEAD [ route "/continents" Continent.all ] GET_HEAD [ route "/continents" Continent.all ]
subRoute "/listing" [
GET_HEAD [
route "/search" ListingApi.search
]
]
POST [ route "/markdown-preview" Api.markdownPreview ] POST [ route "/markdown-preview" Api.markdownPreview ]
subRoute "/profile" [ subRoute "/profile" [
PATCH [ route "/employment-found" ProfileApi.employmentFound ] PATCH [ route "/employment-found" ProfileApi.employmentFound ]

View File

@ -133,6 +133,89 @@ let mine (listings : ListingForView list) tz =
open NodaTime.Text open NodaTime.Text
/// Format the needed by date
let private neededBy dt =
(LocalDatePattern.CreateWithCurrentCulture "MMMM d, yyyy").Format dt
let search (m : ListingSearchForm) continents (listings : ListingForView list option) =
article [] [
h3 [ _class "pb-3" ] [ rawText "Help Wanted" ]
if Option.isNone listings then
p [] [
rawText "Enter relevant criteria to find results, or just click &ldquo;Search&rdquo; to see all "
rawText "current job listings."
]
collapsePanel "Search Criteria" [
form [ _class "container"; _method "GET"; _action "/help-wanted" ] [
input [ _type "hidden"; _name "searched"; _value "true" ]
div [ _class "row" ] [
div [ _class "col-12 col-sm-6 col-md-4 col-lg-3" ] [
continentList [] "ContinentId" continents (Some "Any") m.ContinentId false
]
div [ _class "col-12 col-sm-6 col-md-4 col-lg-3" ] [
textBox [ _maxlength "1000" ] (nameof m.Region) m.Region "Region" false
div [ _class "form-text" ] [ rawText "(free-form text)" ]
]
div [ _class "col-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0" ] [
label [ _class "jjj-label" ] [ rawText "Seeking Remote Work?" ]; br []
div [ _class "form-check form-check-inline" ] [
input [ _type "radio"; _id "remoteNull"; _name (nameof m.RemoteWork); _value ""
_class "form-check-input"; if m.RemoteWork = "" then _checked ]
label [ _class "form-check-label"; _for "remoteNull" ] [ rawText "No Selection" ]
]
div [ _class "form-check form-check-inline" ] [
input [ _type "radio"; _id "remoteYes"; _name (nameof m.RemoteWork); _value "yes"
_class "form-check-input"; if m.RemoteWork = "yes" then _checked ]
label [ _class "form-check-label"; _for "remoteYes" ] [ rawText "Yes" ]
]
div [ _class "form-check form-check-inline" ] [
input [ _type "radio"; _id "remoteNo"; _name (nameof m.RemoteWork); _value "no"
_class "form-check-input"; if m.RemoteWork = "no" then _checked ]
label [ _class "form-check-label"; _for "remoteNo" ] [ rawText "No" ]
]
]
div [ _class "col-12 col-sm-6 col-lg-3" ] [
textBox [ _maxlength "1000" ] (nameof m.Text) m.Text "Job Listing Text" false
div [ _class "form-text" ] [ rawText "(free-form text)" ]
]
]
div [ _class "row" ] [
div [ _class "col" ] [
br []
button [ _type "submit"; _class "btn btn-outline-primary" ] [ rawText "Search" ]
]
]
]
]
match listings with
| Some r when List.isEmpty r ->
p [ _class "pt-3" ] [ rawText "No job listings found for the specified criteria" ]
| Some r ->
table [ _class "table table-sm table-hover pt-3" ] [
thead [] [
tr [] [
th [ _scope "col" ] [ rawText "Listing" ]
th [ _scope "col" ] [ rawText "Title" ]
th [ _scope "col" ] [ rawText "Location" ]
th [ _scope "col"; _class "text-center" ] [ rawText "Remote?" ]
th [ _scope "col"; _class "text-center" ] [ rawText "Needed By" ]
]
]
r |> List.map (fun it ->
tr [] [
td [] [ a [ _href $"/listing/{ListingId.toString it.Listing.Id}/view" ] [ rawText "View" ] ]
td [] [ str it.Listing.Title ]
td [] [ str it.ContinentName; rawText " / "; str it.Listing.Region ]
td [ _class "text-center" ] [ str (yesOrNo it.Listing.IsRemote) ]
td [ _class "text-center" ] [
match it.Listing.NeededBy with Some needed -> str (neededBy needed) | None -> rawText "N/A"
]
])
|> tbody []
]
| None -> ()
]
/// The job listing view page /// The job listing view page
let view (it : ListingForView) = let view (it : ListingForView) =
article [] [ article [] [
@ -150,9 +233,8 @@ let view (it : ListingForView) =
p [] [ p [] [
match it.Listing.NeededBy with match it.Listing.NeededBy with
| Some needed -> | Some needed ->
let format dt = strong [] [ em [] [ rawText "NEEDED BY "; str ((neededBy needed).ToUpperInvariant ()) ] ]
(LocalDatePattern.CreateWithCurrentCulture "MMMM d, yyyy").Format(dt).ToUpperInvariant () rawText " &bull; "
strong [] [ em [] [ rawText "NEEDED BY "; str (format needed) ] ]; rawText " &bull; "
| None -> () | None -> ()
rawText "Listed by "; str it.ListedBy //<!-- a :href="profileUrl" target="_blank" -->{{citizenName(citizen)}}<!-- /a --> rawText "Listed by "; str it.ListedBy //<!-- a :href="profileUrl" target="_blank" -->{{citizenName(citizen)}}<!-- /a -->
] ]