First cut of public profile search

Also tweak logged-on profile search
This commit is contained in:
Daniel J. Summers 2021-08-02 23:14:48 -04:00
parent 8f5ccd7338
commit db753699b8
11 changed files with 343 additions and 55 deletions

View File

@ -279,9 +279,9 @@ module Profile =
.RunResultAsync<ProfileSearchResult list> conn) .RunResultAsync<ProfileSearchResult list> conn)
// Search profiles (public) // Search profiles (public)
let publicSearch (srch : PublicSearch) conn = task { let publicSearch (srch : PublicSearch) conn =
let results = withReconn(conn).ExecuteAsync(fun () ->
seq { (seq {
match srch.continentId with match srch.continentId with
| Some conId -> | Some conId ->
yield (fun (q : ReqlExpr) -> yield (fun (q : ReqlExpr) ->
@ -303,10 +303,22 @@ module Profile =
| None -> () | None -> ()
} }
|> Seq.toList |> Seq.toList
|> List.fold (fun q f -> f q) (r.Table(Table.Profile) :> ReqlExpr) |> List.fold
// TODO: pluck fields, compile skills (fun q f -> f q)
return! results.RunResultAsync<PublicSearchResult list> conn (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 /// Citizen data access functions

View File

@ -20,7 +20,6 @@ module Vue =
module Error = module Error =
open System.Threading.Tasks open System.Threading.Tasks
open Microsoft.Extensions.Logging
/// Handler that will return a status code 404 and the text "Not Found" /// Handler that will return a status code 404 and the text "Not Found"
let notFound : HttpHandler = let notFound : HttpHandler =
@ -49,7 +48,6 @@ module Helpers =
open NodaTime open NodaTime
open Microsoft.Extensions.Configuration open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging
open RethinkDb.Driver.Net open RethinkDb.Driver.Net
open System.Security.Claims open System.Security.Claims

View File

@ -35,6 +35,16 @@ export default defineComponent({
TitleBar 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> </script>
<style lang="sass"> <style lang="sass">

View File

@ -8,6 +8,8 @@ import {
ProfileForView, ProfileForView,
ProfileSearch, ProfileSearch,
ProfileSearchResult, ProfileSearchResult,
PublicSearch,
PublicSearchResult,
StoryEntry, StoryEntry,
Success Success
} from './types' } from './types'
@ -113,6 +115,23 @@ export default {
/** API functions for profiles */ /** API functions for profiles */
profile: { 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 * Retrieve a profile
* *

View File

@ -25,6 +25,12 @@ export interface Continent {
name : string name : string
} }
/** A count */
export interface Count {
/** The count being returned */
count : number
}
/** 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 */
@ -109,10 +115,28 @@ export interface ProfileSearchResult {
lastUpdatedOn : string lastUpdatedOn : string
} }
/** A count */ /** The parameters for a public job search */
export interface Count { export interface PublicSearch {
/** The count being returned */ /** Retrieve citizens from this continent */
count : number 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 */ /** An entry in the list of success stories */

View File

@ -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="">&ndash; Any &ndash;</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>

View File

@ -1,5 +1,5 @@
<template> <template>
<form Model=@Criteria OnValidSubmit=@OnSearch> <form>
<v-container> <v-container>
<v-row> <v-row>
<v-col cols="12" sm="6" md="4" lg="3"> <v-col cols="12" sm="6" md="4" lg="3">
@ -39,7 +39,7 @@
:value="criteria.bioExperience" @input="updateValue('bioExperience', $event.target.value)"> :value="criteria.bioExperience" @input="updateValue('bioExperience', $event.target.value)">
</v-col> </v-col>
</v-row> </v-row>
<v-row class="form-row"> <v-row>
<v-col cols="12"> <v-col cols="12">
<br> <br>
<v-btn type="submit" color="primary" variant="outlined" @click.prevent="$emit('search')">Search</v-btn> <v-btn type="submit" color="primary" variant="outlined" @click.prevent="$emit('search')">Search</v-btn>

View File

@ -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 */ /** 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' 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> = [ const routes: Array<RouteRecordRaw> = [
{ {
path: '/', path: '/',

View File

@ -42,9 +42,11 @@
</template> </template>
<script lang="ts"> <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 { useRoute, useRouter } from 'vue-router'
import { yesOrNo } from '@/App.vue'
import api, { LogOnSuccess, ProfileSearch, ProfileSearchResult } from '@/api' import api, { LogOnSuccess, ProfileSearch, ProfileSearchResult } from '@/api'
import { queryValue } from '@/router'
import { useStore } from '@/store' import { useStore } from '@/store'
import CollapsePanel from '@/components/CollapsePanel.vue' import CollapsePanel from '@/components/CollapsePanel.vue'
@ -88,25 +90,17 @@ export default defineComponent({
/** The current search results */ /** The current search results */
const results : Ref<ProfileSearchResult[]> = ref([]) const results : Ref<ProfileSearchResult[]> = ref([])
/** Return "Yes" for true and "No" for false */ /** Set up the page to match its requested state */
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()
}
const setUpPage = async () => { const setUpPage = async () => {
if (queryValue('searched') === 'true') { if (queryValue(route, 'searched') === 'true') {
searched.value = true searched.value = true
try { try {
searching.value = true searching.value = true
const searchParams : ProfileSearch = { // eslint-disable-line const searchParams : ProfileSearch = {
continentId: queryValue('continentId'), continentId: queryValue(route, 'continentId'),
skill: queryValue('skill'), skill: queryValue(route, 'skill'),
bioExperience: queryValue('bioExperience'), bioExperience: queryValue(route, 'bioExperience'),
remoteWork: queryValue('remoteWork') || '' remoteWork: queryValue(route, 'remoteWork') || ''
} }
const searchResult = await api.profile.search(searchParams, store.state.user as LogOnSuccess) const searchResult = await api.profile.search(searchParams, store.state.user as LogOnSuccess)
if (typeof searchResult === 'string') { if (typeof searchResult === 'string') {
@ -124,6 +118,7 @@ export default defineComponent({
searched.value = false searched.value = false
criteria.value = emptyCriteria criteria.value = emptyCriteria
errors.value = [] errors.value = []
results.value = []
} }
} }
@ -135,9 +130,8 @@ export default defineComponent({
doSearch: () => router.push({ query: { searched: 'true', ...criteria.value } }), doSearch: () => router.push({ query: { searched: 'true', ...criteria.value } }),
searching, searching,
searched, searched,
yesOrNo,
results, results,
continents: computed(() => store.state.continents) yesOrNo
} }
} }
}) })

View File

@ -1,3 +1,139 @@
<template> <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 &ldquo;Search&rdquo; 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> </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>

View File

@ -135,6 +135,7 @@ type ProfileForView = {
/// The parameters for a public job search /// The parameters for a public job search
[<CLIMutable>]
type PublicSearch = { type PublicSearch = {
/// Retrieve citizens from this continent /// Retrieve citizens from this continent
continentId : string option continentId : string option