Env swap #21
|
@ -237,9 +237,9 @@ module Profile =
|
|||
})
|
||||
|
||||
/// Search profiles (logged-on users)
|
||||
let search (srch : ProfileSearch) conn = task {
|
||||
let results =
|
||||
seq {
|
||||
let search (srch : ProfileSearch) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () ->
|
||||
(seq {
|
||||
match srch.continentId with
|
||||
| Some conId ->
|
||||
yield (fun (q : ReqlExpr) ->
|
||||
|
@ -262,10 +262,21 @@ module Profile =
|
|||
| None -> ()
|
||||
}
|
||||
|> Seq.toList
|
||||
|> List.fold (fun q f -> f q) (r.Table(Table.Profile) :> ReqlExpr)
|
||||
// TODO: pluck fields, include display name
|
||||
return! results.RunResultAsync<ProfileSearchResult list> conn
|
||||
}
|
||||
|> List.fold
|
||||
(fun q f -> f q)
|
||||
(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)
|
||||
let publicSearch (srch : PublicSearch) conn = task {
|
||||
|
@ -438,6 +449,11 @@ module Success =
|
|||
.EqJoin("citizenId", r.Table(Table.Citizen))
|
||||
.Without(r.HashMap("right", "id"))
|
||||
.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")
|
||||
.RunResultAsync<StoryEntry list> conn)
|
||||
|
|
|
@ -1,5 +1,16 @@
|
|||
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
|
||||
|
@ -126,6 +137,23 @@ export default {
|
|||
retreiveForView: async (id : string, user : LogOnSuccess) : Promise<ProfileForView | string | undefined> =>
|
||||
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
|
||||
*
|
||||
|
|
|
@ -81,6 +81,34 @@ export interface ProfileForView {
|
|||
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 */
|
||||
export interface Count {
|
||||
/** The count being returned */
|
||||
|
|
55
src/JobsJobsJobs/App/src/components/CollapsePanel.vue
Normal file
55
src/JobsJobsJobs/App/src/components/CollapsePanel.vue
Normal 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>
|
23
src/JobsJobsJobs/App/src/components/ErrorList.vue
Normal file
23
src/JobsJobsJobs/App/src/components/ErrorList.vue
Normal 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>
|
|
@ -1,17 +1,17 @@
|
|||
<template>
|
||||
<div v-if="loading">Loading…</div>
|
||||
<template v-else>
|
||||
<div v-if="errors.length > 0">
|
||||
<p v-for="(error, idx) in errors" :key="idx">{{error}}</p>
|
||||
</div>
|
||||
<slot v-else></slot>
|
||||
</template>
|
||||
<error-list v-else :errors="errors">
|
||||
<slot></slot>
|
||||
</error-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import ErrorList from './ErrorList.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'LoadData',
|
||||
components: { ErrorList },
|
||||
props: {
|
||||
load: {
|
||||
type: Function,
|
||||
|
|
82
src/JobsJobsJobs/App/src/components/profile/SearchForm.vue
Normal file
82
src/JobsJobsJobs/App/src/components/profile/SearchForm.vue
Normal 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="">– 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" 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>
|
|
@ -24,7 +24,7 @@ export default createStore({
|
|||
state: () : State => {
|
||||
return {
|
||||
user: undefined,
|
||||
logOnState: 'Logging you on with No Agenda Social...',
|
||||
logOnState: '<em>Welcome back! Verifying your No Agenda Social account…</em>',
|
||||
continents: []
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<article>
|
||||
<page-title title="Logging on..." />
|
||||
<p>{{message}}</p>
|
||||
<p v-html="message"></p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
|
@ -32,7 +32,8 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
} 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 “Cancel”?)')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,144 @@
|
|||
<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 “Search” 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>
|
||||
|
||||
<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>
|
||||
|
|
|
@ -82,6 +82,7 @@ module ProfileForm =
|
|||
|
||||
|
||||
/// The various ways profiles can be searched
|
||||
[<CLIMutable>]
|
||||
type ProfileSearch = {
|
||||
/// Retrieve citizens from this continent
|
||||
continentId : string option
|
||||
|
@ -107,18 +108,18 @@ module ProfileSearch =
|
|||
|
||||
/// A user matching the profile search
|
||||
type ProfileSearchResult = {
|
||||
// The ID of the citizen
|
||||
/// The ID of the citizen
|
||||
citizenId : CitizenId
|
||||
// The citizen's display name
|
||||
/// The citizen's display name
|
||||
displayName : string
|
||||
// Whether this citizen is currently seeking employment
|
||||
/// Whether this citizen is currently seeking employment
|
||||
seekingEmployment : bool
|
||||
// Whether this citizen is looking for remote work
|
||||
/// Whether this citizen is looking for remote work
|
||||
remoteWork : bool
|
||||
// Whether this citizen is looking for full-time work
|
||||
/// Whether this citizen is looking for full-time work
|
||||
fullTime : bool
|
||||
// When this profile was last updated
|
||||
lastUpdated : Instant
|
||||
/// When this profile was last updated
|
||||
lastUpdatedOn : Instant
|
||||
}
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user