First cut of profile search migrated

This commit is contained in:
Daniel J. Summers 2021-08-01 23:09:45 -04:00
parent 9c9bb16cf2
commit 8f5ccd7338
11 changed files with 423 additions and 48 deletions

View File

@ -237,35 +237,46 @@ module Profile =
}) })
/// Search profiles (logged-on users) /// Search profiles (logged-on users)
let search (srch : ProfileSearch) conn = task { let search (srch : ProfileSearch) 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) ->
q.Filter(r.HashMap(nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr) q.Filter(r.HashMap(nameof srch.continentId, ContinentId.ofString conId)) :> ReqlExpr)
| None -> () | None -> ()
match srch.remoteWork with match srch.remoteWork with
| "" -> () | "" -> ()
| _ -> yield (fun q -> q.Filter(r.HashMap(nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr) | _ -> yield (fun q -> q.Filter(r.HashMap(nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
match srch.skill with match srch.skill with
| Some skl -> | Some skl ->
yield (fun q -> q.Filter(ReqlFunction1(fun it -> yield (fun q -> q.Filter(ReqlFunction1(fun it ->
upcast it.G("skills.description").Downcase().Match(skl.ToLowerInvariant ()))) :> ReqlExpr) upcast it.G("skills.description").Downcase().Match(skl.ToLowerInvariant ()))) :> ReqlExpr)
| None -> () | None -> ()
match srch.bioExperience with match srch.bioExperience with
| Some text -> | Some text ->
let txt = text.ToLowerInvariant () let txt = text.ToLowerInvariant ()
yield (fun q -> q.Filter(ReqlFunction1(fun it -> yield (fun q -> q.Filter(ReqlFunction1(fun it ->
upcast it.G("biography" ).Downcase().Match(txt) upcast it.G("biography" ).Downcase().Match(txt)
.Or(it.G("experience").Downcase().Match(txt)))) :> ReqlExpr) .Or(it.G("experience").Downcase().Match(txt)))) :> ReqlExpr)
| None -> () | None -> ()
} }
|> Seq.toList |> Seq.toList
|> List.fold (fun q f -> f q) (r.Table(Table.Profile) :> ReqlExpr) |> List.fold
// TODO: pluck fields, include display name (fun q f -> f q)
return! results.RunResultAsync<ProfileSearchResult list> conn (r.Table(Table.Profile)
} .EqJoin("id", r.Table(Table.Citizen))
.Without(r.HashMap("right", "id"))
.Zip() :> ReqlExpr))
.Merge(ReqlFunction1(fun it ->
upcast r
.HashMap("displayName",
r.Branch(it.G("realName" ).Default_("").Ne(""), it.G("realName"),
it.G("displayName").Default_("").Ne(""), it.G("displayName"),
it.G("naUser")))
.With("citizenId", it.G("id"))))
.Pluck("citizenId", "displayName", "seekingEmployment", "remoteWork", "fullTime", "lastUpdatedOn")
.RunResultAsync<ProfileSearchResult list> conn)
// Search profiles (public) // Search profiles (public)
let publicSearch (srch : PublicSearch) conn = task { let publicSearch (srch : PublicSearch) conn = task {
@ -438,6 +449,11 @@ module Success =
.EqJoin("citizenId", r.Table(Table.Citizen)) .EqJoin("citizenId", r.Table(Table.Citizen))
.Without(r.HashMap("right", "id")) .Without(r.HashMap("right", "id"))
.Zip() .Zip()
.Merge(Javascript "function (s) { return { citizenName: s.realName || s.displayName || s.naUser } }") .Merge(ReqlFunction1(fun it ->
upcast r
.HashMap("displayName",
r.Branch(it.G("realName" ).Default_("").Ne(""), it.G("realName"),
it.G("displayName").Default_("").Ne(""), it.G("displayName"),
it.G("naUser")))))
.Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory") .Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory")
.RunResultAsync<StoryEntry list> conn) .RunResultAsync<StoryEntry list> conn)

View File

@ -1,5 +1,16 @@
import { MarkedOptions } from 'marked' import { MarkedOptions } from 'marked'
import { Citizen, Continent, Count, LogOnSuccess, Profile, ProfileForView, StoryEntry, Success } from './types' import {
Citizen,
Continent,
Count,
LogOnSuccess,
Profile,
ProfileForView,
ProfileSearch,
ProfileSearchResult,
StoryEntry,
Success
} from './types'
/** /**
* Create a URL that will access the API * Create a URL that will access the API
@ -126,6 +137,23 @@ export default {
retreiveForView: async (id : string, user : LogOnSuccess) : Promise<ProfileForView | string | undefined> => retreiveForView: async (id : string, user : LogOnSuccess) : Promise<ProfileForView | string | undefined> =>
apiResult<ProfileForView>(await fetch(apiUrl(`profile/view/${id}`), reqInit('GET', user)), 'retrieving profile'), apiResult<ProfileForView>(await fetch(apiUrl(`profile/view/${id}`), reqInit('GET', user)), 'retrieving profile'),
/**
* Search for profiles using the given parameters
*
* @param query The profile search parameters
* @param user The currently logged-on user
* @returns The matching profiles (if found), undefined (if API returns 404), or an error string
*/
search: async (query : ProfileSearch, user : LogOnSuccess) : Promise<ProfileSearchResult[] | string | undefined> => {
const params = new URLSearchParams()
if (query.continentId) params.append('continentId', query.continentId)
if (query.skill) params.append('skill', query.skill)
if (query.bioExperience) params.append('bioExperience', query.bioExperience)
params.append('remoteWork', query.remoteWork)
return apiResult<ProfileSearchResult[]>(await fetch(apiUrl(`profile/search?${params.toString()}`),
reqInit('GET', user)), 'searching profiles')
},
/** /**
* Count profiles in the system * Count profiles in the system
* *

View File

@ -81,6 +81,34 @@ export interface ProfileForView {
continent : Continent continent : Continent
} }
/** The various ways profiles can be searched */
export interface ProfileSearch {
/** Retrieve citizens from this continent */
continentId : string | undefined
/** Text for a search within a citizen's skills */
skill : string | undefined
/** Text for a search with a citizen's professional biography and experience fields */
bioExperience : string | undefined
/** Whether to retrieve citizens who do or do not want remote work */
remoteWork : string
}
/** A user matching the profile search */
export interface ProfileSearchResult {
/** The ID of the citizen */
citizenId : string
/** The citizen's display name */
displayName : string
/** Whether this citizen is currently seeking employment */
seekingEmployment : boolean
/** Whether this citizen is looking for remote work */
remoteWork : boolean
/** Whether this citizen is looking for full-time work */
fullTime : boolean
/** When this profile was last updated (date) */
lastUpdatedOn : string
}
/** A count */ /** A count */
export interface Count { export interface Count {
/** The count being returned */ /** The count being returned */

View File

@ -0,0 +1,55 @@
<template>
<v-card>
<v-card-header>
<v-card-header-text>
<v-card-title>
<a href="#" :class="{ 'cp-c': isCollapsed, 'cp-o': !isCollapsed }" @click.prevent="toggle">{{headerText}}</a>
</v-card-title>
</v-card-header-text>
</v-card-header>
<v-card-text v-if="!isCollapsed">
<slot></slot>
</v-card-text>
</v-card>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
name: 'CollapsePanel',
props: {
headerText: {
type: String,
default: 'Toggle'
},
collapsed: {
type: Boolean,
default: false
}
},
setup (props) {
/** Whether the panel is collapsed or not */
const isCollapsed = ref(props.collapsed)
return {
isCollapsed,
toggle: () => { isCollapsed.value = !isCollapsed.value }
}
}
})
</script>
<style lang="sass" scoped>
a.cp-c,
a.cp-o
text-decoration: none
font-weight: bold
color: black
a.cp-c:hover,
a.cp-o:hover
cursor: pointer
.cp-c::before
content: '\2b9e \00a0'
.cp-o::before
content: '\2b9f \00a0'
</style>

View File

@ -0,0 +1,23 @@
<template>
<template v-if="errors.length > 0">
<p>The following error<template v-if="errors.length !== 1">s</template> occurred:</p>
<ul>
<li v-for="(error, idx) in errors" :key="idx"><pre>{{error}}</pre></li>
</ul>
</template>
<slot v-else></slot>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'ErrorList',
props: {
errors: {
type: Array,
required: true
}
}
})
</script>

View File

@ -1,17 +1,17 @@
<template> <template>
<div v-if="loading">Loading&hellip;</div> <div v-if="loading">Loading&hellip;</div>
<template v-else> <error-list v-else :errors="errors">
<div v-if="errors.length > 0"> <slot></slot>
<p v-for="(error, idx) in errors" :key="idx">{{error}}</p> </error-list>
</div>
<slot v-else></slot>
</template>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, onMounted, ref } from 'vue' import { defineComponent, onMounted, ref } from 'vue'
import ErrorList from './ErrorList.vue'
export default defineComponent({ export default defineComponent({
name: 'LoadData', name: 'LoadData',
components: { ErrorList },
props: { props: {
load: { load: {
type: Function, type: Function,

View File

@ -0,0 +1,82 @@
<template>
<form Model=@Criteria OnValidSubmit=@OnSearch>
<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" 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-col cols="12" sm="6" lg="3">
<label for="bioSearch" class="jjj-label">Bio / Experience</label>
<input type="text" id="bioSearch" class="form-control form-control-sm" placeholder="(free-form text)"
:value="criteria.bioExperience" @input="updateValue('bioExperience', $event.target.value)">
</v-col>
</v-row>
<v-row class="form-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 { ProfileSearch } from '@/api'
import { useStore } from '@/store'
export default defineComponent({
name: 'ProfileSearchForm',
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 : ProfileSearch = { ...props.modelValue as ProfileSearch }
/** 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

@ -24,7 +24,7 @@ export default createStore({
state: () : State => { state: () : State => {
return { return {
user: undefined, user: undefined,
logOnState: 'Logging you on with No Agenda Social...', logOnState: '<em>Welcome back! Verifying your No Agenda Social account&hellip;</em>',
continents: [] continents: []
} }
}, },

View File

@ -1,7 +1,7 @@
<template> <template>
<article> <article>
<page-title title="Logging on..." /> <page-title title="Logging on..." />
<p>{{message}}</p> <p v-html="message"></p>
</article> </article>
</template> </template>
@ -32,7 +32,8 @@ export default defineComponent({
} }
} }
} else { } else {
store.commit('setLogOnState', 'Did not receive a token from No Agenda Social (perhaps you clicked "Cancel"?)') store.commit('setLogOnState',
'Did not receive a token from No Agenda Social (perhaps you clicked &ldquo;Cancel&rdquo;?)')
} }
} }

View File

@ -1,3 +1,144 @@
<template> <template>
<p>TODO: convert this view</p> <article>
<page-title title="Search Profiles" />
<h3>Search Profiles</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-search-form v-model="criteria" @search="doSearch" />
</collapse-panel>
<br>
<table v-if="results.length > 0" class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">Profile</th>
<th scope="col">Name</th>
<th scope="col" class="text-center">Seeking?</th>
<th scope="col" class="text-center">Remote?</th>
<th scope="col" class="text-center">Full-Time?</th>
<th scope="col">Last Updated</th>
</tr>
</thead>
<tbody>
<tr v-for="profile in results" :key="profile.citzenId">
<td><router-link :to="`/profile/view/${profile.citizenId}`">View</router-link></td>
<td :class="{ 'font-weight-bold' : profile.seekingEmployment }">{{profile.displayName}}</td>
<td class="text-center">{{yesOrNo(profile.seekingEmployment)}}</td>
<td class="text-center">{{yesOrNo(profile.remoteWork)}}</td>
<td class="text-center">{{yesOrNo(profile.fullTime)}}</td>
<td><full-date :date="profile.lastUpdatedOn" /></td>
</tr>
</tbody>
</table>
<p v-else-if="searched">No results found for the specified criteria</p>
</template>
</error-list>
</article>
</template> </template>
<script lang="ts">
import { computed, defineComponent, Ref, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api, { LogOnSuccess, ProfileSearch, ProfileSearchResult } from '@/api'
import { useStore } from '@/store'
import CollapsePanel from '@/components/CollapsePanel.vue'
import ErrorList from '@/components/ErrorList.vue'
import FullDate from '@/components/FullDate.vue'
import ProfileSearchForm from '@/components/profile/SearchForm.vue'
export default defineComponent({
name: 'ProfileSearch',
components: {
CollapsePanel,
ErrorList,
FullDate,
ProfileSearchForm
},
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: undefined,
skill: undefined,
bioExperience: undefined,
remoteWork: ''
}
/** The search criteria being built from the page */
const criteria : Ref<ProfileSearch> = ref(emptyCriteria)
/** 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()
}
const setUpPage = async () => {
if (queryValue('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 searchResult = await api.profile.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
criteria.value = searchParams
}
} finally {
searching.value = false
}
} else {
searched.value = false
criteria.value = emptyCriteria
errors.value = []
}
}
watch(() => route.query, setUpPage, { immediate: true })
return {
errors,
criteria,
doSearch: () => router.push({ query: { searched: 'true', ...criteria.value } }),
searching,
searched,
yesOrNo,
results,
continents: computed(() => store.state.continents)
}
}
})
</script>

View File

@ -82,6 +82,7 @@ module ProfileForm =
/// The various ways profiles can be searched /// The various ways profiles can be searched
[<CLIMutable>]
type ProfileSearch = { type ProfileSearch = {
/// Retrieve citizens from this continent /// Retrieve citizens from this continent
continentId : string option continentId : string option
@ -107,18 +108,18 @@ module ProfileSearch =
/// A user matching the profile search /// A user matching the profile search
type ProfileSearchResult = { type ProfileSearchResult = {
// The ID of the citizen /// The ID of the citizen
citizenId : CitizenId citizenId : CitizenId
// The citizen's display name /// The citizen's display name
displayName : string displayName : string
// Whether this citizen is currently seeking employment /// Whether this citizen is currently seeking employment
seekingEmployment : bool seekingEmployment : bool
// Whether this citizen is looking for remote work /// Whether this citizen is looking for remote work
remoteWork : bool remoteWork : bool
// Whether this citizen is looking for full-time work /// Whether this citizen is looking for full-time work
fullTime : bool fullTime : bool
// When this profile was last updated /// When this profile was last updated
lastUpdated : Instant lastUpdatedOn : Instant
} }