Migrate job listing search
This commit is contained in:
parent
9de255d25a
commit
df7299ccb1
|
@ -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>
|
|
@ -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 “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 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>
|
|
@ -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")
|
||||
}
|
|
@ -373,19 +373,16 @@ module Listings =
|
|||
connection () |> saveDocument Table.Listing (ListingId.toString listing.Id) <| mkDoc listing
|
||||
|
||||
/// Search job listings
|
||||
let search (search : ListingSearch) =
|
||||
let search (search : ListingSearchForm) =
|
||||
let searches = [
|
||||
match search.ContinentId with
|
||||
| Some contId -> "l.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string contId ]
|
||||
| None -> ()
|
||||
match search.Region with
|
||||
| Some region -> "l.data ->> 'region' ILIKE @region", [ "@region", like region ]
|
||||
| None -> ()
|
||||
if search.ContinentId <> "" then
|
||||
"l.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ]
|
||||
if search.Region <> "" then
|
||||
"l.data ->> 'region' ILIKE @region", [ "@region", like search.Region ]
|
||||
if search.RemoteWork <> "" then
|
||||
"l.data ->> 'isRemote' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ]
|
||||
match search.Text with
|
||||
| Some text -> "l.data ->> 'text' ILIKE @text", [ "@text", like text ]
|
||||
| None -> ()
|
||||
if search.Text <> "" then
|
||||
"l.data ->> 'text' ILIKE @text", [ "@text", like search.Text ]
|
||||
]
|
||||
connection ()
|
||||
|> Sql.query $"
|
||||
|
|
|
@ -82,19 +82,19 @@ type ListingForView =
|
|||
|
||||
|
||||
/// The various ways job listings can be searched
|
||||
[<CLIMutable>]
|
||||
type ListingSearch =
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type ListingSearchForm =
|
||||
{ /// Retrieve job listings for this continent
|
||||
ContinentId : string option
|
||||
ContinentId : string
|
||||
|
||||
/// Text for a search within a region
|
||||
Region : string option
|
||||
Region : string
|
||||
|
||||
/// Whether to retrieve job listings for remote work
|
||||
RemoteWork : string
|
||||
|
||||
/// Text for a search with the job listing description
|
||||
Text : string option
|
||||
Text : string
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -457,7 +457,7 @@ module Home =
|
|||
renderHandler "Terms of Service" Home.termsOfService
|
||||
|
||||
|
||||
/// Handlers for /listing[s] routes
|
||||
/// Handlers for /listing[s] routes (and /help-wanted)
|
||||
[<RequireQualifiedAccess>]
|
||||
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
|
||||
let view listingId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
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
|
||||
[<RequireQualifiedAccess>]
|
||||
module Profile =
|
||||
|
@ -780,7 +784,13 @@ open Giraffe.EndpointRouting
|
|||
|
||||
/// All available endpoints for the application
|
||||
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" [
|
||||
GET_HEAD [
|
||||
routef "/confirm/%s" Citizen.confirm
|
||||
|
@ -797,7 +807,6 @@ let allEndpoints = [
|
|||
route "/register" Citizen.doRegistration
|
||||
]
|
||||
]
|
||||
GET_HEAD [ route "/how-it-works" Home.howItWorks ]
|
||||
subRoute "/listing" [
|
||||
GET_HEAD [
|
||||
route "s/mine" Listing.mine
|
||||
|
@ -810,7 +819,6 @@ let allEndpoints = [
|
|||
route "/save" Listing.save
|
||||
]
|
||||
]
|
||||
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
|
||||
subRoute "/profile" [
|
||||
GET_HEAD [
|
||||
routef "/%O/view" Profile.view
|
||||
|
@ -823,7 +831,6 @@ let allEndpoints = [
|
|||
route "/save" Profile.save
|
||||
]
|
||||
]
|
||||
GET_HEAD [ route "/terms-of-service" Home.termsOfService ]
|
||||
|
||||
subRoute "/api" [
|
||||
subRoute "/citizen" [
|
||||
|
@ -832,11 +839,6 @@ let allEndpoints = [
|
|||
]
|
||||
]
|
||||
GET_HEAD [ route "/continents" Continent.all ]
|
||||
subRoute "/listing" [
|
||||
GET_HEAD [
|
||||
route "/search" ListingApi.search
|
||||
]
|
||||
]
|
||||
POST [ route "/markdown-preview" Api.markdownPreview ]
|
||||
subRoute "/profile" [
|
||||
PATCH [ route "/employment-found" ProfileApi.employmentFound ]
|
||||
|
|
|
@ -133,6 +133,89 @@ let mine (listings : ListingForView list) tz =
|
|||
|
||||
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 “Search” 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
|
||||
let view (it : ListingForView) =
|
||||
article [] [
|
||||
|
@ -150,9 +233,8 @@ let view (it : ListingForView) =
|
|||
p [] [
|
||||
match it.Listing.NeededBy with
|
||||
| Some needed ->
|
||||
let format dt =
|
||||
(LocalDatePattern.CreateWithCurrentCulture "MMMM d, yyyy").Format(dt).ToUpperInvariant ()
|
||||
strong [] [ em [] [ rawText "NEEDED BY "; str (format needed) ] ]; rawText " • "
|
||||
strong [] [ em [] [ rawText "NEEDED BY "; str ((neededBy needed).ToUpperInvariant ()) ] ]
|
||||
rawText " • "
|
||||
| None -> ()
|
||||
rawText "Listed by "; str it.ListedBy //<!-- a :href="profileUrl" target="_blank" -->{{citizenName(citizen)}}<!-- /a -->
|
||||
]
|
||||
|
|
Loading…
Reference in New Issue
Block a user