Env swap #21
@ -279,34 +279,46 @@ module Profile =
|
||||
.RunResultAsync<ProfileSearchResult list> conn)
|
||||
|
||||
// Search profiles (public)
|
||||
let publicSearch (srch : PublicSearch) conn = task {
|
||||
let results =
|
||||
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 reg ->
|
||||
yield (fun q ->
|
||||
q.Filter(ReqlFunction1(fun it ->
|
||||
upcast it.G("region").Downcase().Match(reg.ToLowerInvariant ()))) :> ReqlExpr)
|
||||
| None -> ()
|
||||
match srch.remoteWork with
|
||||
| "" -> ()
|
||||
| _ -> yield (fun q -> q.Filter(r.HashMap(nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
||||
match srch.skill with
|
||||
| Some skl ->
|
||||
yield (fun q -> q.Filter(ReqlFunction1(fun it ->
|
||||
upcast it.G("skills.description").Downcase().Match(skl.ToLowerInvariant ()))) :> ReqlExpr)
|
||||
| None -> ()
|
||||
}
|
||||
|> Seq.toList
|
||||
|> List.fold (fun q f -> f q) (r.Table(Table.Profile) :> ReqlExpr)
|
||||
// TODO: pluck fields, compile skills
|
||||
return! results.RunResultAsync<PublicSearchResult list> conn
|
||||
}
|
||||
let publicSearch (srch : PublicSearch) 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 reg ->
|
||||
yield (fun q ->
|
||||
q.Filter(ReqlFunction1(fun it ->
|
||||
upcast it.G("region").Downcase().Match(reg.ToLowerInvariant ()))) :> ReqlExpr)
|
||||
| None -> ()
|
||||
match srch.remoteWork with
|
||||
| "" -> ()
|
||||
| _ -> yield (fun q -> q.Filter(r.HashMap(nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
||||
match srch.skill with
|
||||
| Some skl ->
|
||||
yield (fun q -> q.Filter(ReqlFunction1(fun it ->
|
||||
upcast it.G("skills.description").Downcase().Match(skl.ToLowerInvariant ()))) :> ReqlExpr)
|
||||
| None -> ()
|
||||
}
|
||||
|> Seq.toList
|
||||
|> List.fold
|
||||
(fun q f -> f q)
|
||||
(r.Table(Table.Profile)
|
||||
.EqJoin("continentId", r.Table(Table.Continent))
|
||||
.Without(r.HashMap("right", "id"))
|
||||
.Zip()
|
||||
.Filter(r.HashMap("isPublic", true)) :> ReqlExpr))
|
||||
.Merge(ReqlFunction1(fun it ->
|
||||
upcast r
|
||||
.HashMap("skills",
|
||||
it.G("skills").Map(ReqlFunction1(fun skill ->
|
||||
upcast r.Branch(skill.G("notes").Default_("").Eq(""), skill.G("description"),
|
||||
sprintf "%O (%O)" (skill.G("description")) (skill.G("notes"))))))
|
||||
.With("continent", it.G("name"))))
|
||||
.Pluck("continent", "region", "skills", "remoteWork")
|
||||
.RunResultAsync<PublicSearchResult list> conn)
|
||||
|
||||
|
||||
/// Citizen data access functions
|
||||
|
@ -20,7 +20,6 @@ module Vue =
|
||||
module Error =
|
||||
|
||||
open System.Threading.Tasks
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
/// Handler that will return a status code 404 and the text "Not Found"
|
||||
let notFound : HttpHandler =
|
||||
@ -49,7 +48,6 @@ module Helpers =
|
||||
|
||||
open NodaTime
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.Logging
|
||||
open RethinkDb.Driver.Net
|
||||
open System.Security.Claims
|
||||
|
||||
|
@ -35,6 +35,16 @@ export default defineComponent({
|
||||
TitleBar
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Return "Yes" for true and "No" for false
|
||||
*
|
||||
* @param cond The condition to be checked
|
||||
* @returns "Yes" for true, "No" for false
|
||||
*/
|
||||
export function yesOrNo (cond : boolean) : string {
|
||||
return cond ? 'Yes' : 'No'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
|
@ -8,6 +8,8 @@ import {
|
||||
ProfileForView,
|
||||
ProfileSearch,
|
||||
ProfileSearchResult,
|
||||
PublicSearch,
|
||||
PublicSearchResult,
|
||||
StoryEntry,
|
||||
Success
|
||||
} from './types'
|
||||
@ -113,6 +115,23 @@ export default {
|
||||
/** API functions for profiles */
|
||||
profile: {
|
||||
|
||||
/**
|
||||
* Search for public profile data using the given parameters
|
||||
*
|
||||
* @param query The public profile search parameters
|
||||
* @returns The matching public profiles (if found), undefined (if API returns 404), or an error string
|
||||
*/
|
||||
publicSearch: async (query : PublicSearch) : Promise<PublicSearchResult[] | string | undefined> => {
|
||||
const params = new URLSearchParams()
|
||||
if (query.continentId) params.append('continentId', query.continentId)
|
||||
if (query.region) params.append('bioExperience', query.region)
|
||||
if (query.skill) params.append('skill', query.skill)
|
||||
params.append('remoteWork', query.remoteWork)
|
||||
return apiResult<PublicSearchResult[]>(
|
||||
await fetch(apiUrl(`profile/public-search?${params.toString()}`), { method: 'GET' }),
|
||||
'searching public profile data')
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a profile
|
||||
*
|
||||
|
@ -25,6 +25,12 @@ export interface Continent {
|
||||
name : string
|
||||
}
|
||||
|
||||
/** A count */
|
||||
export interface Count {
|
||||
/** The count being returned */
|
||||
count : number
|
||||
}
|
||||
|
||||
/** A successful logon */
|
||||
export interface LogOnSuccess {
|
||||
/** The JSON Web Token (JWT) to use for API access */
|
||||
@ -109,10 +115,28 @@ export interface ProfileSearchResult {
|
||||
lastUpdatedOn : string
|
||||
}
|
||||
|
||||
/** A count */
|
||||
export interface Count {
|
||||
/** The count being returned */
|
||||
count : number
|
||||
/** The parameters for a public job search */
|
||||
export interface PublicSearch {
|
||||
/** Retrieve citizens from this continent */
|
||||
continentId : string | undefined
|
||||
/** Retrieve citizens from this region */
|
||||
region : string | undefined
|
||||
/** Text for a search within a citizen's skills */
|
||||
skill : string | undefined
|
||||
/** Whether to retrieve citizens who do or do not want remote work */
|
||||
remoteWork : string
|
||||
}
|
||||
|
||||
/** A public profile search result */
|
||||
export interface PublicSearchResult {
|
||||
/** The name of the continent on which the citizen resides */
|
||||
continent : string
|
||||
/** The region in which the citizen resides */
|
||||
region : string
|
||||
/** Whether this citizen is seeking remote work */
|
||||
remoteWork : boolean
|
||||
/** The skills this citizen has identified */
|
||||
skills : string[]
|
||||
}
|
||||
|
||||
/** An entry in the list of success stories */
|
||||
|
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<form>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" lg="3">
|
||||
<label for="continentId" class="jjj-label">Continent</label>
|
||||
<select id="continentId" class="form-control form-control-sm"
|
||||
:value="criteria.continentId" @change="updateValue('continentId', $event.target.value)">
|
||||
<option value="">– Any –</option>
|
||||
<option v-for="c in continents" :key="c.id" :value="c.id">{{c.name}}</option>
|
||||
</select>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4" lg="3">
|
||||
<label for="region" class="jjj-label">Region</label>
|
||||
<input type="text" id="region" class="form-control form-control-sm" placeholder="(free-form text)"
|
||||
:value="criteria.region" @input="updateValue('region', $event.target.value)">
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" offset-md="2" lg="3" offset-lg="0">
|
||||
<label class="jjj-label">Seeking Remote Work?</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>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" lg="3">
|
||||
<label for="skillSearch" class="jjj-label">Skill</label>
|
||||
<input type="text" id="skillSearch" class="form-control form-control-sm" placeholder="(free-form text)"
|
||||
:value="criteria.skill" @input="updateValue('skill', $event.target.value)">
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<br>
|
||||
<v-btn type="submit" color="primary" variant="outlined" @click.prevent="$emit('search')">Search</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted } from 'vue'
|
||||
import { PublicSearch } from '@/api'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ProfilePublicSearchForm',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['search', 'update:modelValue'],
|
||||
setup (props, { emit }) {
|
||||
const store = useStore()
|
||||
|
||||
/** The initial search criteria passed; this is what we'll update and emit when data changes */
|
||||
const criteria : PublicSearch = { ...props.modelValue as PublicSearch }
|
||||
|
||||
/** Make sure we have continents */
|
||||
onMounted(async () => await store.dispatch('ensureContinents'))
|
||||
|
||||
return {
|
||||
criteria,
|
||||
continents: computed(() => store.state.continents),
|
||||
updateValue: (key : string, value : string) => emit('update:modelValue', { ...criteria, [key]: value })
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<form Model=@Criteria OnValidSubmit=@OnSearch>
|
||||
<form>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6" md="4" lg="3">
|
||||
@ -39,7 +39,7 @@
|
||||
:value="criteria.bioExperience" @input="updateValue('bioExperience', $event.target.value)">
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="form-row">
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<br>
|
||||
<v-btn type="submit" color="primary" variant="outlined" @click.prevent="$emit('search')">Search</v-btn>
|
||||
|
@ -13,6 +13,18 @@ import LogOn from '@/views/citizen/LogOn.vue'
|
||||
/** The URL to which the user should be pointed once they have authorized with NAS */
|
||||
export const AFTER_LOG_ON_URL = 'jjj-after-log-on-url'
|
||||
|
||||
/**
|
||||
* Get a value from the query string
|
||||
*
|
||||
* @param route The current route
|
||||
* @param key The key of the query string value to obtain
|
||||
* @returns The string value, the first of many (if included multiple times), or `undefined` if not present
|
||||
*/
|
||||
export function queryValue (route: RouteLocationNormalizedLoaded, key : string) : string | undefined {
|
||||
const value = route.query[key]
|
||||
if (value) return Array.isArray(value) && value.length > 0 ? value[0]?.toString() : value.toString()
|
||||
}
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/',
|
||||
|
@ -42,9 +42,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, Ref, ref, watch } from 'vue'
|
||||
import { defineComponent, Ref, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { yesOrNo } from '@/App.vue'
|
||||
import api, { LogOnSuccess, ProfileSearch, ProfileSearchResult } from '@/api'
|
||||
import { queryValue } from '@/router'
|
||||
import { useStore } from '@/store'
|
||||
|
||||
import CollapsePanel from '@/components/CollapsePanel.vue'
|
||||
@ -88,25 +90,17 @@ export default defineComponent({
|
||||
/** The current search results */
|
||||
const results : Ref<ProfileSearchResult[]> = ref([])
|
||||
|
||||
/** Return "Yes" for true and "No" for false */
|
||||
const yesOrNo = (cond : boolean) => cond ? 'Yes' : 'No'
|
||||
|
||||
/** Get a value from the query string */
|
||||
const queryValue = (key : string) : string | undefined => {
|
||||
const value = route.query[key]
|
||||
if (value) return Array.isArray(value) && value.length > 0 ? value[0]?.toString() : value.toString()
|
||||
}
|
||||
|
||||
/** Set up the page to match its requested state */
|
||||
const setUpPage = async () => {
|
||||
if (queryValue('searched') === 'true') {
|
||||
if (queryValue(route, 'searched') === 'true') {
|
||||
searched.value = true
|
||||
try {
|
||||
searching.value = true
|
||||
const searchParams : ProfileSearch = { // eslint-disable-line
|
||||
continentId: queryValue('continentId'),
|
||||
skill: queryValue('skill'),
|
||||
bioExperience: queryValue('bioExperience'),
|
||||
remoteWork: queryValue('remoteWork') || ''
|
||||
const searchParams : ProfileSearch = {
|
||||
continentId: queryValue(route, 'continentId'),
|
||||
skill: queryValue(route, 'skill'),
|
||||
bioExperience: queryValue(route, 'bioExperience'),
|
||||
remoteWork: queryValue(route, 'remoteWork') || ''
|
||||
}
|
||||
const searchResult = await api.profile.search(searchParams, store.state.user as LogOnSuccess)
|
||||
if (typeof searchResult === 'string') {
|
||||
@ -124,6 +118,7 @@ export default defineComponent({
|
||||
searched.value = false
|
||||
criteria.value = emptyCriteria
|
||||
errors.value = []
|
||||
results.value = []
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,9 +130,8 @@ export default defineComponent({
|
||||
doSearch: () => router.push({ query: { searched: 'true', ...criteria.value } }),
|
||||
searching,
|
||||
searched,
|
||||
yesOrNo,
|
||||
results,
|
||||
continents: computed(() => store.state.continents)
|
||||
yesOrNo
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -1,3 +1,139 @@
|
||||
<template>
|
||||
<p>TODO: convert this view</p>
|
||||
<article>
|
||||
<page-title title="People Seeking Work" />
|
||||
<h3>People Seeking Work</h3>
|
||||
|
||||
<error-list :errors="errors">
|
||||
<p v-if="searching">Searching profiles...</p>
|
||||
<template v-else>
|
||||
<p v-if="!searched">
|
||||
Enter one or more criteria to filter results, or just click “Search” to list all profiles.
|
||||
</p>
|
||||
<collapse-panel headerText="Search Criteria" :collapsed="searched && results.length > 0">
|
||||
<profile-public-search-form v-model="criteria" @search="doSearch" />
|
||||
</collapse-panel>
|
||||
<br>
|
||||
<template v-if="results.length > 0">
|
||||
<p>
|
||||
These profiles match your search criteria. To learn more about these people, join the merry band of human
|
||||
resources in the <a href="https://noagendashow.net" target="_blank">No Agenda</a> tribe!
|
||||
</p>
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Continent</th>
|
||||
<th scope="col" class="text-center">Region</th>
|
||||
<th scope="col" class="text-center">Remote?</th>
|
||||
<th scope="col" class="text-center">Skills</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(profile, idx) in results" :key="idx">
|
||||
<td>{{profile.continent}}</td>
|
||||
<td>{{profile.region}}</td>
|
||||
<td class="text-center">{{yesOrNo(profile.remoteWork)}}</td>
|
||||
<td>
|
||||
<template v-for="(skill, idx) in profile.skills" :key="idx">{{skill}}<br></template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p v-if="searched">No results found for the specified criteria</p>
|
||||
</template>
|
||||
</template>
|
||||
</error-list>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, Ref, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { yesOrNo } from '@/App.vue'
|
||||
import api, { PublicSearch, PublicSearchResult } from '@/api'
|
||||
import { queryValue } from '@/router'
|
||||
|
||||
import CollapsePanel from '@/components/CollapsePanel.vue'
|
||||
import ErrorList from '@/components/ErrorList.vue'
|
||||
import ProfilePublicSearchForm from '@/components/profile/PublicSearchForm.vue'
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
CollapsePanel,
|
||||
ErrorList,
|
||||
ProfilePublicSearchForm
|
||||
},
|
||||
setup () {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** Whether a search has been performed */
|
||||
const searched = ref(false)
|
||||
|
||||
/** Indicates whether a request for matching profiles is in progress */
|
||||
const searching = ref(false)
|
||||
|
||||
/** Error messages encountered while searching for profiles */
|
||||
const errors : Ref<string[]> = ref([])
|
||||
|
||||
/** An empty set of search criteria */
|
||||
const emptyCriteria = {
|
||||
continentId: undefined,
|
||||
region: undefined,
|
||||
skill: undefined,
|
||||
remoteWork: ''
|
||||
}
|
||||
|
||||
/** The search criteria being built from the page */
|
||||
const criteria : Ref<PublicSearch> = ref(emptyCriteria)
|
||||
|
||||
/** The search results */
|
||||
const results : Ref<PublicSearchResult[]> = ref([])
|
||||
|
||||
/** Set up the page to match its requested state */
|
||||
const setUpPage = async () => {
|
||||
if (queryValue(route, 'searched') === 'true') {
|
||||
searched.value = true
|
||||
try {
|
||||
searching.value = true
|
||||
const searchParams : PublicSearch = {
|
||||
continentId: queryValue(route, 'continentId'),
|
||||
region: queryValue(route, 'region'),
|
||||
skill: queryValue(route, 'skill'),
|
||||
remoteWork: queryValue(route, 'remoteWork') || ''
|
||||
}
|
||||
const searchResult = await api.profile.publicSearch(searchParams)
|
||||
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
|
||||
criteria.value = searchParams
|
||||
}
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
} else {
|
||||
searched.value = false
|
||||
criteria.value = emptyCriteria
|
||||
errors.value = []
|
||||
results.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.query, setUpPage, { immediate: true })
|
||||
|
||||
return {
|
||||
errors,
|
||||
criteria,
|
||||
doSearch: () => router.push({ query: { searched: 'true', ...criteria.value } }),
|
||||
searching,
|
||||
searched,
|
||||
results,
|
||||
yesOrNo
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
@ -135,6 +135,7 @@ type ProfileForView = {
|
||||
|
||||
|
||||
/// The parameters for a public job search
|
||||
[<CLIMutable>]
|
||||
type PublicSearch = {
|
||||
/// Retrieve citizens from this continent
|
||||
continentId : string option
|
||||
|
Loading…
Reference in New Issue
Block a user