Migrate profile search
Add helper functions for common UI elements
This commit is contained in:
parent
1a4a8d1bba
commit
6e2a40e436
|
@ -1,76 +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-offset-md-2 col-lg-3 col-offset-lg-0">
|
||||
<label class="jjj-label">Seeking Remote Work?</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="skillSearch" class="form-control" maxlength="1000" placeholder="(free-form text)"
|
||||
:value="criteria.skill" @input="updateValue('skill', $event.target.value)">
|
||||
<label for="skillSearch">Skill</label>
|
||||
</div>
|
||||
<div class="form-text">(free-form text)</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="bioSearch" class="form-control" maxlength="1000" placeholder="(free-form text)"
|
||||
:value="criteria.bioExperience" @input="updateValue('bioExperience', $event.target.value)">
|
||||
<label for="bioSearch">Bio / Experience</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 { ref } from "vue"
|
||||
import { ProfileSearch } from "@/api"
|
||||
import ContinentList from "../ContinentList.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ProfileSearch
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "search") : void
|
||||
(e: "update:modelValue", value : ProfileSearch) : 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,51 +0,0 @@
|
|||
<template>
|
||||
<div class="row pb-3">
|
||||
<div class="col-2 col-md-1 align-self-center">
|
||||
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete"
|
||||
@click.prevent="$emit('remove')"> − </button>
|
||||
</div>
|
||||
<div class="col-10 col-md-6">
|
||||
<div class="form-floating">
|
||||
<input type="text" :id="`skillDesc${skill.id}`" class="form-control" maxlength="200"
|
||||
placeholder="A skill (language, design technique, process, etc.)" :value="skill.description"
|
||||
@input="updateValue('description', $event.target.value)">
|
||||
<label class="jjj-label" :for="`skillDesc${skill.id}`">Skill</label>
|
||||
</div>
|
||||
<div class="form-text">A skill (language, design technique, process, etc.)</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-5">
|
||||
<div class="form-floating">
|
||||
<input class="form-control" type="text" :id="`skillNotes${skill.id}`" maxlength="1000"
|
||||
placeholder="A further description of the skill (100 characters max)" :value="skill.notes"
|
||||
@input="updateValue('notes', $event.target.value)">
|
||||
<label class="jjj-label" :for="`skillNotes${skill.id}`">Notes</label>
|
||||
</div>
|
||||
<div class="form-text">A further description of the skill</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Ref, ref } from "vue"
|
||||
import { Skill } from "@/api"
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Skill
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "input") : void
|
||||
(e: "remove") : void
|
||||
(e: "update:modelValue", value: Skill) : void
|
||||
}>()
|
||||
|
||||
/** The skill being edited */
|
||||
const skill : Ref<Skill> = ref({ ...props.modelValue as Skill })
|
||||
|
||||
/** Update a value in the model */
|
||||
const updateValue = (key : string, value : string) => {
|
||||
skill.value = { ...skill.value, [key]: value }
|
||||
emit("update:modelValue", skill.value)
|
||||
emit("input")
|
||||
}
|
||||
</script>
|
|
@ -49,54 +49,12 @@ const routes: Array<RouteRecordRaw> = [
|
|||
meta: { auth: false, title: "Terms of Service" }
|
||||
},
|
||||
// Citizen URLs
|
||||
{
|
||||
path: "/citizen/register",
|
||||
name: "CitizenRegistration",
|
||||
component: () => import(/* webpackChunkName: "register" */ "../views/citizen/Register.vue"),
|
||||
meta: { auth: false, title: "Register" }
|
||||
},
|
||||
{
|
||||
path: "/citizen/registered",
|
||||
name: "CitizenRegistered",
|
||||
component: () => import(/* webpackChunkName: "register" */ "../views/citizen/Registered.vue"),
|
||||
meta: { auth: false, title: "Registration Successful" }
|
||||
},
|
||||
{
|
||||
path: "/citizen/confirm/:token",
|
||||
name: "ConfirmRegistration",
|
||||
component: () => import(/* webpackChunkName: "logon" */ "../views/citizen/ConfirmRegistration.vue"),
|
||||
meta: { auth: false, title: "Account Confirmation" }
|
||||
},
|
||||
{
|
||||
path: "/citizen/deny/:token",
|
||||
name: "DenyRegistration",
|
||||
component: () => import(/* webpackChunkName: "deny" */ "../views/citizen/DenyRegistration.vue"),
|
||||
meta: { auth: false, title: "Account Deletion" }
|
||||
},
|
||||
{
|
||||
path: "/citizen/log-on",
|
||||
name: "LogOn",
|
||||
component: () => import(/* webpackChunkName: "logon" */ "../views/citizen/LogOn.vue"),
|
||||
meta: { auth: false, title: "Log On" }
|
||||
},
|
||||
{
|
||||
path: "/citizen/dashboard",
|
||||
name: "Dashboard",
|
||||
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Dashboard.vue"),
|
||||
meta: { auth: true, title: "Dashboard" }
|
||||
},
|
||||
{
|
||||
path: "/citizen/account",
|
||||
name: "AccountProfile",
|
||||
component: () => import(/* webpackChunkName: "account" */ "../views/citizen/AccountProfile.vue"),
|
||||
meta: { auth: true, title: "Account Profile" }
|
||||
},
|
||||
{
|
||||
path: "/citizen/log-off",
|
||||
name: "LogOff",
|
||||
component: () => import(/* webpackChunkName: "logoff" */ "../views/citizen/LogOff.vue"),
|
||||
meta: { auth: true, title: "Logging Off" }
|
||||
},
|
||||
// Job Listing URLs
|
||||
{
|
||||
path: "/help-wanted",
|
||||
|
@ -129,12 +87,6 @@ const routes: Array<RouteRecordRaw> = [
|
|||
meta: { auth: true, title: "My Job Listings" }
|
||||
},
|
||||
// Profile URLs
|
||||
{
|
||||
path: "/profile/search",
|
||||
name: "SearchProfiles",
|
||||
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileSearch.vue"),
|
||||
meta: { auth: true, title: "Search Profiles" }
|
||||
},
|
||||
{
|
||||
path: "/profile/seeking",
|
||||
name: "PublicSearchProfiles",
|
||||
|
|
|
@ -1,134 +0,0 @@
|
|||
<template>
|
||||
<article>
|
||||
<h3 class="pb-3">Search Profiles</h3>
|
||||
<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="isCollapsed" @toggle="toggleCollapse">
|
||||
<profile-search-form v-model="criteria" @search="doSearch" />
|
||||
</collapse-panel>
|
||||
<error-list :errors="errors">
|
||||
<p v-if="searching" class="pt-3">Searching profiles…</p>
|
||||
<template v-else>
|
||||
<table v-if="results.length > 0" class="table table-sm table-hover pt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Profile</th>
|
||||
<th scope="col">Name</th>
|
||||
<th v-if="wideDisplay" class="text-center" scope="col">Seeking?</th>
|
||||
<th class="text-center" scope="col">Remote?</th>
|
||||
<th v-if="wideDisplay" class="text-center" scope="col">Full-Time?</th>
|
||||
<th v-if="wideDisplay" scope="col">Last Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="profile in results" :key="profile.citzenId">
|
||||
<td><router-link :to="`/profile/${profile.citizenId}/view`">View</router-link></td>
|
||||
<td :class="{ 'fw-bold' : profile.seekingEmployment }">{{profile.displayName}}</td>
|
||||
<td v-if="wideDisplay" class="text-center">{{yesOrNo(profile.seekingEmployment)}}</td>
|
||||
<td class="text-center">{{yesOrNo(profile.remoteWork)}}</td>
|
||||
<td v-if="wideDisplay" class="text-center">{{yesOrNo(profile.fullTime)}}</td>
|
||||
<td v-if="wideDisplay"><full-date :date="profile.lastUpdatedOn" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else-if="searched" class="pt-3">No results 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 { useBreakpoints, breakpointsBootstrapV5 } from "@vueuse/core"
|
||||
|
||||
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"
|
||||
import ErrorList from "@/components/ErrorList.vue"
|
||||
import FullDate from "@/components/FullDate.vue"
|
||||
import ProfileSearchForm from "@/components/profile/SearchForm.vue"
|
||||
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const breakpoints = useBreakpoints(breakpointsBootstrapV5)
|
||||
|
||||
/** 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: "",
|
||||
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([])
|
||||
|
||||
/** Whether the search criteria should be collapsed */
|
||||
const isCollapsed = ref(searched.value && results.value.length > 0)
|
||||
|
||||
/** Hide certain columns if the display is too narrow */
|
||||
const wideDisplay = breakpoints.greater("sm")
|
||||
|
||||
/** 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 : ProfileSearch = {
|
||||
continentId: contId === "" ? undefined : contId,
|
||||
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") {
|
||||
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 and 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>
|
|
@ -445,21 +445,20 @@ module Profiles =
|
|||
connection () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile
|
||||
|
||||
/// Search profiles (logged-on users)
|
||||
let search (search : ProfileSearch) = backgroundTask {
|
||||
let search (search : ProfileSearchForm) = backgroundTask {
|
||||
let searches = [
|
||||
match search.ContinentId with
|
||||
| Some contId -> "p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string contId ]
|
||||
| None -> ()
|
||||
if search.ContinentId <> "" then
|
||||
"p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ]
|
||||
if search.RemoteWork <> "" then
|
||||
"p.data ->> 'remoteWork' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ]
|
||||
match search.Skill with
|
||||
| Some skl -> "p.data -> 'skills' ->> 'description' ILIKE @description", [ "@description", like skl ]
|
||||
| None -> ()
|
||||
match search.BioExperience with
|
||||
| Some text ->
|
||||
"p.data ->> 'isRemote' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ]
|
||||
if search.Skill <> "" then
|
||||
"EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(p.data['skills']) x(elt)
|
||||
WHERE x ->> 'description' ILIKE @description)",
|
||||
[ "@description", like search.Skill ]
|
||||
if search.BioExperience <> "" then
|
||||
"(p.data ->> 'biography' ILIKE @text OR p.data ->> 'experience' ILIKE @text)",
|
||||
[ "@text", Sql.string text ]
|
||||
| None -> ()
|
||||
[ "@text", like search.BioExperience ]
|
||||
]
|
||||
let! results =
|
||||
connection ()
|
||||
|
|
|
@ -163,65 +163,17 @@ type AuthOptions () =
|
|||
override this.Value = this
|
||||
|
||||
|
||||
/// The data required to update a profile
|
||||
/// The various ways profiles can be searched
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type ProfileForm =
|
||||
{ /// Whether the citizen to whom this profile belongs is actively seeking employment
|
||||
IsSeekingEmployment : bool
|
||||
|
||||
/// Whether this profile should appear in the public search
|
||||
IsPublic : bool
|
||||
|
||||
/// The ID of the continent on which the citizen is located
|
||||
type ProfileSearchForm =
|
||||
{ /// Retrieve citizens from this continent
|
||||
ContinentId : string
|
||||
|
||||
/// The area within that continent where the citizen is located
|
||||
Region : string
|
||||
|
||||
/// If the citizen is available for remote work
|
||||
RemoteWork : bool
|
||||
|
||||
/// If the citizen is seeking full-time employment
|
||||
FullTime : bool
|
||||
|
||||
/// The user's professional biography
|
||||
Biography : string
|
||||
|
||||
/// The user's past experience
|
||||
Experience : string option
|
||||
|
||||
/// The skills for the user
|
||||
Skills : Skill list
|
||||
}
|
||||
|
||||
/// Support functions for the ProfileForm type
|
||||
module ProfileForm =
|
||||
|
||||
/// Create an instance of this form from the given profile
|
||||
let fromProfile (profile : Profile) =
|
||||
{ IsSeekingEmployment = profile.IsSeekingEmployment
|
||||
IsPublic = profile.IsPubliclySearchable
|
||||
ContinentId = string profile.ContinentId
|
||||
Region = profile.Region
|
||||
RemoteWork = profile.IsRemote
|
||||
FullTime = profile.IsFullTime
|
||||
Biography = MarkdownString.toString profile.Biography
|
||||
Experience = profile.Experience |> Option.map MarkdownString.toString
|
||||
Skills = profile.Skills
|
||||
}
|
||||
|
||||
|
||||
/// The various ways profiles can be searched
|
||||
[<CLIMutable>]
|
||||
type ProfileSearch =
|
||||
{ /// Retrieve citizens from this continent
|
||||
ContinentId : string option
|
||||
|
||||
/// Text for a search within a citizen's skills
|
||||
Skill : string option
|
||||
Skill : string
|
||||
|
||||
/// Text for a search with a citizen's professional biography and experience fields
|
||||
BioExperience : string option
|
||||
BioExperience : string
|
||||
|
||||
/// Whether to retrieve citizens who do or do not want remote work
|
||||
RemoteWork : string
|
||||
|
@ -229,6 +181,7 @@ type ProfileSearch =
|
|||
|
||||
|
||||
/// A user matching the profile search
|
||||
[<NoComparison; NoEquality>]
|
||||
type ProfileSearchResult =
|
||||
{ /// The ID of the citizen
|
||||
CitizenId : CitizenId
|
||||
|
|
|
@ -129,6 +129,11 @@ module Helpers =
|
|||
let csrf ctx =
|
||||
(antiForgery ctx).GetAndStoreTokens ctx
|
||||
|
||||
/// Get the time zone from the citizen's browser
|
||||
let timeZone (ctx : HttpContext) =
|
||||
let tz = string ctx.Request.Headers["X-Time-Zone"]
|
||||
defaultArg (noneIfEmpty tz) "Etc/UTC"
|
||||
|
||||
/// The key to use to indicate if we have loaded the session
|
||||
let private sessionLoadedKey = "session-loaded"
|
||||
|
||||
|
@ -587,12 +592,13 @@ module Profile =
|
|||
|
||||
// GET: /profile/edit
|
||||
let edit : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! profile = Profiles.findById (currentCitizenId ctx)
|
||||
let citizenId = currentCitizenId ctx
|
||||
let! profile = Profiles.findById citizenId
|
||||
let! continents = Continents.all ()
|
||||
let isNew = Option.isNone profile
|
||||
let form = if isNew then EditProfileViewModel.empty else EditProfileViewModel.fromProfile profile.Value
|
||||
let title = $"""{if isNew then "Create" else "Edit"} Profile"""
|
||||
return! Profile.edit form continents isNew (csrf ctx) |> render title next ctx
|
||||
return! Profile.edit form continents isNew citizenId (csrf ctx) |> render title next ctx
|
||||
}
|
||||
|
||||
// POST: /profile/save
|
||||
|
@ -616,18 +622,19 @@ module Profile =
|
|||
do! Profiles.save
|
||||
{ profile with
|
||||
IsSeekingEmployment = form.IsSeekingEmployment
|
||||
IsPubliclySearchable = form.IsPublic
|
||||
ContinentId = ContinentId.ofString form.ContinentId
|
||||
Region = form.Region
|
||||
IsRemote = form.RemoteWork
|
||||
IsFullTime = form.FullTime
|
||||
Biography = Text form.Biography
|
||||
LastUpdatedOn = now ctx
|
||||
Experience = noneIfBlank form.Experience |> Option.map Text
|
||||
Skills = form.Skills
|
||||
|> Array.filter (fun s -> (box >> isNull >> not) s)
|
||||
|> Array.map SkillForm.toSkill
|
||||
|> List.ofArray
|
||||
Experience = noneIfBlank form.Experience |> Option.map Text
|
||||
IsPubliclySearchable = form.IsPubliclySearchable
|
||||
IsPubliclyLinkable = form.IsPubliclyLinkable
|
||||
}
|
||||
let action = if isNew then "cre" else "upd"
|
||||
do! addSuccess $"Employment Profile {action}ated successfully" ctx
|
||||
|
@ -636,10 +643,26 @@ module Profile =
|
|||
do! addErrors errors ctx
|
||||
let! continents = Continents.all ()
|
||||
return!
|
||||
Profile.edit form continents isNew (csrf ctx)
|
||||
Profile.edit form continents isNew citizenId (csrf ctx)
|
||||
|> render $"""{if isNew then "Create" else "Edit"} Profile""" next ctx
|
||||
}
|
||||
|
||||
// GET: /profile/search
|
||||
let search : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! continents = Continents.all ()
|
||||
let form =
|
||||
match ctx.TryBindQueryString<ProfileSearchForm> () with
|
||||
| Ok f -> f
|
||||
| Error _ -> { ContinentId = ""; RemoteWork = ""; Skill = ""; BioExperience = "" }
|
||||
let! results = task {
|
||||
if string ctx.Request.Query["searched"] = "true" then
|
||||
let! it = Profiles.search form
|
||||
return Some it
|
||||
else return None
|
||||
}
|
||||
return! Profile.search form continents (timeZone ctx) results |> render "Profile Search" next ctx
|
||||
}
|
||||
|
||||
// GET: /profile/[id]/view
|
||||
let view citizenId : HttpHandler = fun next ctx -> task {
|
||||
let citId = CitizenId.ofString citizenId
|
||||
|
@ -710,13 +733,6 @@ module ProfileApi =
|
|||
return! ok next ctx
|
||||
}
|
||||
|
||||
// GET: /api/profile/search
|
||||
let search : HttpHandler = authorize >=> fun next ctx -> task {
|
||||
let search = ctx.BindQueryString<ProfileSearch> ()
|
||||
let! results = Profiles.search search
|
||||
return! json results next ctx
|
||||
}
|
||||
|
||||
// GET: /api/profile/public-search
|
||||
let publicSearch : HttpHandler = fun next ctx -> task {
|
||||
let search = ctx.BindQueryString<PublicSearch> ()
|
||||
|
@ -798,6 +814,7 @@ let allEndpoints = [
|
|||
GET_HEAD [
|
||||
routef "/%s/view" Profile.view
|
||||
route "/edit" Profile.edit
|
||||
route "/search" Profile.search
|
||||
]
|
||||
POST [ route "/save" Profile.save ]
|
||||
]
|
||||
|
@ -833,7 +850,6 @@ let allEndpoints = [
|
|||
routef "/%O" ProfileApi.get
|
||||
routef "/%O/view" ProfileApi.view
|
||||
route "/public-search" ProfileApi.publicSearch
|
||||
route "/search" ProfileApi.search
|
||||
]
|
||||
PATCH [ route "/employment-found" ProfileApi.employmentFound ]
|
||||
]
|
||||
|
|
|
@ -9,7 +9,7 @@ type SkillForm =
|
|||
{ Description : string
|
||||
|
||||
/// Notes regarding the skill
|
||||
Notes : string option
|
||||
Notes : string
|
||||
}
|
||||
|
||||
/// Functions to support skill forms
|
||||
|
@ -17,11 +17,11 @@ module SkillForm =
|
|||
|
||||
/// Create a skill form from a skill
|
||||
let fromSkill (skill : Skill) =
|
||||
{ SkillForm.Description = skill.Description; Notes = skill.Notes }
|
||||
{ SkillForm.Description = skill.Description; Notes = defaultArg skill.Notes "" }
|
||||
|
||||
/// Create a skill from a skill form
|
||||
let toSkill (form : SkillForm) =
|
||||
{ Skill.Description = form.Description; Notes = form.Notes }
|
||||
{ Skill.Description = form.Description; Notes = if form.Notes = "" then None else Some form.Notes }
|
||||
|
||||
|
||||
/// The data required to update a profile
|
||||
|
@ -30,9 +30,6 @@ type EditProfileViewModel =
|
|||
{ /// Whether the citizen to whom this profile belongs is actively seeking employment
|
||||
IsSeekingEmployment : bool
|
||||
|
||||
/// Whether this profile should appear in the public search
|
||||
IsPublic : bool
|
||||
|
||||
/// The ID of the continent on which the citizen is located
|
||||
ContinentId : string
|
||||
|
||||
|
@ -48,11 +45,17 @@ type EditProfileViewModel =
|
|||
/// The user's professional biography
|
||||
Biography : string
|
||||
|
||||
/// The skills for the user
|
||||
Skills : SkillForm array
|
||||
|
||||
/// The user's past experience
|
||||
Experience : string option
|
||||
|
||||
/// The skills for the user
|
||||
Skills : SkillForm array
|
||||
/// Whether this profile should appear in the public search
|
||||
IsPubliclySearchable : bool
|
||||
|
||||
/// Whether this profile should be shown publicly
|
||||
IsPubliclyLinkable : bool
|
||||
}
|
||||
|
||||
/// Support functions for the ProfileForm type
|
||||
|
@ -61,27 +64,29 @@ module EditProfileViewModel =
|
|||
/// An empty view model (used for new profiles)
|
||||
let empty =
|
||||
{ IsSeekingEmployment = false
|
||||
IsPublic = false
|
||||
ContinentId = ""
|
||||
Region = ""
|
||||
RemoteWork = false
|
||||
FullTime = false
|
||||
Biography = ""
|
||||
Experience = None
|
||||
Skills = [||]
|
||||
Experience = None
|
||||
IsPubliclySearchable = false
|
||||
IsPubliclyLinkable = false
|
||||
}
|
||||
|
||||
/// Create an instance of this form from the given profile
|
||||
let fromProfile (profile : Profile) =
|
||||
{ IsSeekingEmployment = profile.IsSeekingEmployment
|
||||
IsPublic = profile.IsPubliclySearchable
|
||||
ContinentId = string profile.ContinentId
|
||||
ContinentId = ContinentId.toString profile.ContinentId
|
||||
Region = profile.Region
|
||||
RemoteWork = profile.IsRemote
|
||||
FullTime = profile.IsFullTime
|
||||
Biography = MarkdownString.toString profile.Biography
|
||||
Experience = profile.Experience |> Option.map MarkdownString.toString
|
||||
Skills = profile.Skills |> List.map SkillForm.fromSkill |> Array.ofList
|
||||
Experience = profile.Experience |> Option.map MarkdownString.toString
|
||||
IsPubliclySearchable = profile.IsPubliclySearchable
|
||||
IsPubliclyLinkable = profile.IsPubliclyLinkable
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -128,28 +128,10 @@ let logOn (m : LogOnViewModel) csrf =
|
|||
| Some returnTo -> input [ _type "hidden"; _name (nameof m.ReturnTo); _value returnTo ]
|
||||
| None -> ()
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "email"
|
||||
_class "form-control"
|
||||
_id (nameof m.Email)
|
||||
_name (nameof m.Email)
|
||||
_placeholder "E-mail Address"
|
||||
_value m.Email
|
||||
_required
|
||||
_autofocus ]
|
||||
label [ _class "jjj-required"; _for (nameof m.Email) ] [ rawText "E-mail Address" ]
|
||||
]
|
||||
textBox [ _type "email"; _autofocus ] (nameof m.Email) m.Email "E-mail Address" true
|
||||
]
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "password"
|
||||
_class "form-control"
|
||||
_id (nameof m.Password)
|
||||
_name (nameof m.Password)
|
||||
_placeholder "Password"
|
||||
_required ]
|
||||
label [ _class "jjj-required"; _for (nameof m.Password) ] [ rawText "Password" ]
|
||||
]
|
||||
textBox [ _type "password" ] (nameof m.Password) "" "Password" true
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
button [ _class "btn btn-primary"; _type "submit" ] [
|
||||
|
@ -172,48 +154,23 @@ let register q1 q2 (m : RegisterViewModel) csrf =
|
|||
form [ _class "row g-3"; _hxPost "/citizen/register" ] [
|
||||
antiForgery csrf
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _class "form-control"; _id (nameof m.FirstName); _name (nameof m.FirstName)
|
||||
_value m.FirstName; _placeholder "First Name"; _required; _autofocus ]
|
||||
label [ _class "jjj-required"; _for (nameof m.FirstName) ] [ rawText "First Name" ]
|
||||
]
|
||||
textBox [ _type "text"; _autofocus ] (nameof m.FirstName) m.FirstName "First Name" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _class "form-control"; _id (nameof m.LastName); _name (nameof m.LastName)
|
||||
_value m.LastName; _placeholder "Last Name"; _required ]
|
||||
label [ _class "jjj-required"; _for (nameof m.LastName) ] [ rawText "Last Name" ]
|
||||
]
|
||||
textBox [ _type "text" ] (nameof m.LastName) m.LastName "Last Name" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _class "form-control"; _id (nameof m.DisplayName)
|
||||
_name (nameof m.DisplayName); _value (defaultArg m.DisplayName "")
|
||||
_placeholder "Display Name" ]
|
||||
label [ _for (nameof m.DisplayName) ] [ rawText "Display Name" ]
|
||||
textBox [ _type "text" ] (nameof m.DisplayName) (defaultArg m.DisplayName "") "Display Name" false
|
||||
div [ _class "form-text" ] [ em [] [ rawText "Optional; overrides first/last for display" ] ]
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
textBox [ _type "text" ] (nameof m.Email) m.Email "E-mail Address" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "email"; _class "form-control"; _id (nameof m.Email); _name (nameof m.Email)
|
||||
_value m.Email; _placeholder "E-mail Address"; _required ]
|
||||
label [ _class "jjj-required"; _for (nameof m.Email) ] [ rawText "E-mail Address" ]
|
||||
]
|
||||
textBox [ _type "password"; _minlength "8" ] (nameof m.Password) "" "Password" true
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "password"; _class "form-control"; _id (nameof m.Password); _name (nameof m.Password)
|
||||
_placeholder "Password"; _minlength "8"; _required ]
|
||||
label [ _class "jjj-required"; _for (nameof m.Password) ] [ rawText "Password" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-6 col-xl-4" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "password"; _class "form-control"; _id "ConfirmPassword"
|
||||
_placeholder "Confirm Password"; _minlength "8"; _required ]
|
||||
label [ _class "jjj-required"; _for "ConfirmPassword" ] [ rawText "Confirm Password" ]
|
||||
]
|
||||
textBox [ _type "password"; _minlength "8" ] "ConfirmPassword" "" "Confirm Password" true
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
hr []
|
||||
|
@ -222,21 +179,11 @@ let register q1 q2 (m : RegisterViewModel) csrf =
|
|||
]
|
||||
]
|
||||
div [ _class "col-12 col-xl-6" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _class "form-control"; _id (nameof m.Question1Answer)
|
||||
_name (nameof m.Question1Answer); _value m.Question1Answer; _placeholder "Question 1"
|
||||
_maxlength "30"; _required ]
|
||||
label [ _class "jjj-required"; _for (nameof m.Question1Answer) ] [ str q1 ]
|
||||
]
|
||||
textBox [ _type "text"; _maxlength "30" ] (nameof m.Question1Answer) m.Question1Answer "Question 1" true
|
||||
input [ _type "hidden"; _name (nameof m.Question1Index); _value (string m.Question1Index ) ]
|
||||
]
|
||||
div [ _class "col-12 col-xl-6" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _class "form-control"; _id (nameof m.Question2Answer)
|
||||
_name (nameof m.Question2Answer); _value m.Question2Answer; _placeholder "Question 2"
|
||||
_maxlength "30"; _required ]
|
||||
label [ _class "jjj-required"; _for (nameof m.Question2Answer) ] [ str q2 ]
|
||||
]
|
||||
textBox [ _type "text"; _maxlength "30" ] (nameof m.Question2Answer) m.Question2Answer "Question 2" true
|
||||
input [ _type "hidden"; _name (nameof m.Question2Index); _value (string m.Question2Index ) ]
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
|
|
|
@ -16,17 +16,35 @@ let audioClip clip text =
|
|||
let antiForgery (csrf : AntiforgeryTokenSet) =
|
||||
input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ]
|
||||
|
||||
/// Create a select list of continents
|
||||
let continentList attrs name (continents : Continent list) emptyLabel selectedValue =
|
||||
/// Create a floating-label text input box
|
||||
let textBox attrs name value fieldLabel isRequired =
|
||||
div [ _class "form-floating" ] [
|
||||
select (List.append attrs [ _id name; _name name; _class "form-select" ]) (
|
||||
List.append attrs [
|
||||
_id name; _name name; _class "form-control"; _placeholder fieldLabel; _value value
|
||||
if isRequired then _required
|
||||
] |> input
|
||||
label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ rawText fieldLabel ]
|
||||
]
|
||||
|
||||
/// Create a checkbox that will post "true" if checked
|
||||
let checkBox name isChecked checkLabel =
|
||||
div [ _class "form-check" ] [
|
||||
input [ _type "checkbox"; _id name; _name name; _class "form-check-input"; _value "true"
|
||||
if isChecked then _checked ]
|
||||
label [ _class "form-check-label"; _for name ] [ str checkLabel ]
|
||||
]
|
||||
|
||||
/// Create a select list of continents
|
||||
let continentList attrs name (continents : Continent list) emptyLabel selectedValue isRequired =
|
||||
div [ _class "form-floating" ] [
|
||||
select (List.append attrs [ _id name; _name name; _class "form-select"; if isRequired then _required ]) (
|
||||
option [ _value ""; if selectedValue = "" then _selected ] [
|
||||
rawText $"""– {defaultArg emptyLabel "Select"} –""" ]
|
||||
:: (continents
|
||||
|> List.map (fun c ->
|
||||
let theId = ContinentId.toString c.Id
|
||||
option [ _value theId; if theId = selectedValue then _selected ] [ str c.Name ])))
|
||||
label [ _class "jjj-required"; _for name ] [ rawText "Continent" ]
|
||||
label [ _class (if isRequired then "jjj-required" else "jjj-label"); _for name ] [ rawText "Continent" ]
|
||||
]
|
||||
|
||||
/// Create a Markdown editor
|
||||
|
@ -57,3 +75,28 @@ let markdownEditor attrs name value editorLabel =
|
|||
rawText "})"
|
||||
]
|
||||
]
|
||||
|
||||
/// Wrap content in a collapsing panel
|
||||
let collapsePanel header content =
|
||||
div [ _class "card" ] [
|
||||
div [ _class "card-body" ] [
|
||||
h6 [ _class "card-title" ] [
|
||||
// TODO: toggle collapse
|
||||
//a [ _href "#"; _class "{ 'cp-c': collapsed, 'cp-o': !collapsed }"; @click.prevent="toggle">{{headerText}} ]
|
||||
rawText header
|
||||
]
|
||||
yield! content
|
||||
]
|
||||
]
|
||||
|
||||
/// "Yes" or "No" based on a boolean value
|
||||
let yesOrNo value =
|
||||
if value then "Yes" else "No"
|
||||
|
||||
open NodaTime
|
||||
open NodaTime.Text
|
||||
|
||||
/// Generate a full date from an instant in the citizen's local time zone
|
||||
let fullDate (value : Instant) tz =
|
||||
(ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy", DateTimeZoneProviders.Tzdb))
|
||||
.Format(value.InZone(DateTimeZoneProviders.Tzdb[tz]))
|
||||
|
|
|
@ -4,6 +4,8 @@ module JobsJobsJobs.Views.Profile
|
|||
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Domain.SharedTypes
|
||||
open JobsJobsJobs.ViewModels
|
||||
|
||||
/// Render the skill edit template and existing skills
|
||||
|
@ -21,7 +23,7 @@ let skillEdit (skills : SkillForm array) =
|
|||
input [ _type "text"; _id $"skillDesc{idx}"; _name $"Skills[{idx}].Description"
|
||||
_class "form-control"; _placeholder "A skill (language, design technique, process, etc.)"
|
||||
_maxlength "200"; _value skill.Description; _required ]
|
||||
label [ _class "jjj-label"; _for $"skillDesc{idx}" ] [ rawText "Skill" ]
|
||||
label [ _class "jjj-required"; _for $"skillDesc{idx}" ] [ rawText "Skill" ]
|
||||
]
|
||||
if idx < 1 then
|
||||
div [ _class "form-text" ] [ rawText "A skill (language, design technique, process, etc.)" ]
|
||||
|
@ -30,30 +32,24 @@ let skillEdit (skills : SkillForm array) =
|
|||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id $"skillNotes{idx}"; _name $"Skills[{idx}].Notes"; _class "form-control"
|
||||
_maxlength "1000"; _placeholder "A further description of the skill (1,000 characters max)"
|
||||
_value (defaultArg skill.Notes "") ]
|
||||
_value skill.Notes ]
|
||||
label [ _class "jjj-label"; _for $"skillNotes{idx}" ] [ rawText "Notes" ]
|
||||
]
|
||||
if idx < 1 then
|
||||
div [ _class "form-text" ] [ rawText "A further description of the skill" ]
|
||||
]
|
||||
]
|
||||
template [ _id "newSkill" ] [ mapToInputs -1 { Description = ""; Notes = None } ]
|
||||
template [ _id "newSkill" ] [ mapToInputs -1 { Description = ""; Notes = "" } ]
|
||||
:: (skills |> Array.mapi mapToInputs |> List.ofArray)
|
||||
|
||||
/// The profile edit page
|
||||
let edit (m : EditProfileViewModel) continents isNew csrf =
|
||||
let edit (m : EditProfileViewModel) continents isNew citizenId csrf =
|
||||
article [] [
|
||||
h3 [ _class "pb-3" ] [ rawText "My Employment Profile" ]
|
||||
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
|
||||
antiForgery csrf
|
||||
div [ _class "col-12" ] [
|
||||
div [ _class "form-check" ] [
|
||||
input [ _type "checkbox"; _id (nameof m.IsSeekingEmployment); _name (nameof m.IsSeekingEmployment)
|
||||
_class "form-check-input"; if m.IsSeekingEmployment then _checked ]
|
||||
label [ _class "form-check-label"; _for (nameof m.IsSeekingEmployment) ] [
|
||||
rawText "I am currently seeking employment"
|
||||
]
|
||||
]
|
||||
checkBox (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment"
|
||||
if m.IsSeekingEmployment then
|
||||
p [] [
|
||||
em [] [
|
||||
|
@ -63,34 +59,18 @@ let edit (m : EditProfileViewModel) continents isNew csrf =
|
|||
]
|
||||
]
|
||||
div [ _class "col-12 col-sm-6 col-md-4" ] [
|
||||
continentList [ _required ] (nameof m.ContinentId) continents None m.ContinentId
|
||||
continentList [] (nameof m.ContinentId) continents None m.ContinentId true
|
||||
]
|
||||
div [ _class "col-12 col-sm-6 col-md-8" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id (nameof m.Region); _name (nameof m.Region); _class "form-control"
|
||||
_maxlength "255"; _placeholder "Country, state, geographic area, etc."; _required ]
|
||||
label [ _class "jjj-required"; _for (m.Region) ] [ rawText "Region" ]
|
||||
]
|
||||
textBox [ _type "text"; _maxlength "255" ] (nameof m.Region) m.Region "Region" true
|
||||
div [ _class "form-text" ] [ rawText "Country, state, geographic area, etc." ]
|
||||
]
|
||||
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
|
||||
div [ _class "col-12 col-offset-md-2 col-md-4" ] [
|
||||
div [ _class "form-check" ] [
|
||||
input [ _type "checkbox"; _id (nameof m.RemoteWork); _name (nameof m.RemoteWork)
|
||||
_class "form-check-input"; if m.RemoteWork then _checked ]
|
||||
label [ _class "form-check-label"; _for (nameof m.RemoteWork) ] [
|
||||
rawText "I am looking for remote work"
|
||||
]
|
||||
]
|
||||
checkBox (nameof m.RemoteWork) m.RemoteWork "I am looking for remote work"
|
||||
]
|
||||
div [ _class "col-12 col-md-4" ] [
|
||||
div [ _class "form-check" ] [
|
||||
input [ _type "checkbox"; _id (nameof m.FullTime); _name (nameof m.FullTime)
|
||||
_class "form-check-input"; if m.FullTime then _checked ]
|
||||
label [ _class "form-check-label"; _for (nameof m.FullTime) ] [
|
||||
rawText "I am looking for full-time work"
|
||||
]
|
||||
]
|
||||
checkBox (nameof m.FullTime) m.FullTime "I am looking for full-time work"
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
hr []
|
||||
|
@ -114,14 +94,13 @@ let edit (m : EditProfileViewModel) continents isNew csrf =
|
|||
]
|
||||
]
|
||||
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience"
|
||||
div [ _class "col-12" ] [
|
||||
div [ _class "form-check" ] [
|
||||
input [ _type "checkbox"; _id (nameof m.IsPublic); _name (nameof m.IsPublic)
|
||||
_class "form-check-input"; if m.IsPublic then _checked ]
|
||||
label [ _class "form-check-label"; _for (nameof m.IsPublic) ] [
|
||||
rawText "Allow my profile to be searched publicly"
|
||||
]
|
||||
div [ _class "col-12 col-xl-6" ] [
|
||||
checkBox (nameof m.IsPubliclySearchable) m.IsPubliclySearchable
|
||||
"Allow my profile to be searched publicly"
|
||||
]
|
||||
div [ _class "col-12 col-xl-6" ] [
|
||||
checkBox (nameof m.IsPubliclyLinkable) m.IsPubliclyLinkable
|
||||
"Show my profile to anyone who has the direct link to it"
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
button [ _type "submit"; _class "btn btn-primary" ] [
|
||||
|
@ -129,7 +108,7 @@ let edit (m : EditProfileViewModel) continents isNew csrf =
|
|||
]
|
||||
if not isNew then
|
||||
rawText " "
|
||||
a [ _class "btn btn-outline-secondary"; _href "`/profile/${user.citizenId}/view`" ] [
|
||||
a [ _class "btn btn-outline-secondary"; _href $"/profile/{CitizenId.toString citizenId}/view" ] [
|
||||
i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] []
|
||||
rawText " View Your User Profile"
|
||||
]
|
||||
|
@ -147,8 +126,90 @@ let edit (m : EditProfileViewModel) continents isNew csrf =
|
|||
]
|
||||
]
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
|
||||
/// Logged-on search page
|
||||
let search (m : ProfileSearchForm) continents tz (results : ProfileSearchResult list option) =
|
||||
article [] [
|
||||
h3 [ _class "pb-3" ] [ rawText "Search Profiles" ]
|
||||
if Option.isNone results then
|
||||
p [] [
|
||||
rawText "Enter one or more criteria to filter results, or just click “Search” to list all "
|
||||
rawText "profiles."
|
||||
]
|
||||
collapsePanel "Search Criteria" [
|
||||
form [ _class "container"; _method "GET"; _action "/profile/search" ] [
|
||||
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-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.Skill) m.Skill "Skill" false
|
||||
div [ _class "form-text" ] [ rawText "(free-form text)" ]
|
||||
]
|
||||
div [ _class "col-12 col-sm-6 col-lg-3" ] [
|
||||
textBox [ _maxlength "1000" ] (nameof m.BioExperience) m.BioExperience "Bio / Experience" 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 results with
|
||||
| Some r when List.isEmpty r -> p [ _class "pt-3" ] [ rawText "No results found for the specified criteria" ]
|
||||
| Some r ->
|
||||
// Bootstrap utility classes to only show at medium or above
|
||||
let isWide = "d-none d-md-table-cell"
|
||||
table [ _class "table table-sm table-hover pt-3" ] [
|
||||
thead [] [
|
||||
tr [] [
|
||||
th [ _scope "col" ] [ rawText "Profile" ]
|
||||
th [ _scope "col" ] [ rawText "Name" ]
|
||||
th [ _scope "col"; _class $"{isWide} text-center" ] [ rawText "Seeking?" ]
|
||||
th [ _scope "col"; _class "text-center" ] [ rawText "Remote?" ]
|
||||
th [ _scope "col"; _class $"{isWide} text-center" ] [ rawText "Full-Time?" ]
|
||||
th [ _scope "col"; _class isWide ] [ rawText "Last Updated" ]
|
||||
]
|
||||
]
|
||||
r |> List.map (fun profile ->
|
||||
tr [] [
|
||||
td [] [ a [ _href $"/profile/{CitizenId.toString profile.CitizenId}/view" ] [ rawText "View" ] ]
|
||||
td [ if profile.SeekingEmployment then _class "fw-bold" ] [ str profile.DisplayName ]
|
||||
td [ _class $"{isWide} text-center" ] [ rawText (yesOrNo profile.SeekingEmployment) ]
|
||||
td [ _class "text-center" ] [ rawText (yesOrNo profile.RemoteWork) ]
|
||||
td [ _class $"{isWide} text-center" ] [ rawText (yesOrNo profile.FullTime) ]
|
||||
td [ _class isWide ] [ str (fullDate profile.LastUpdatedOn tz) ]
|
||||
])
|
||||
|> tbody []
|
||||
]
|
||||
| None -> ()
|
||||
]
|
||||
|
||||
|
||||
/// Profile view template
|
||||
let view (citizen : Citizen) (profile : Profile) (continentName : string) pageTitle currentId =
|
||||
article [] [
|
||||
h3 [ _class "pb-3" ] [ str pageTitle ]
|
||||
|
@ -171,7 +232,8 @@ let view (citizen : Citizen) (profile : Profile) (continentName : string) pageTi
|
|||
if not (List.isEmpty profile.Skills) then
|
||||
hr []
|
||||
h4 [ _class "pb-3" ] [ rawText "Skills" ]
|
||||
profile.Skills |> List.map (fun skill ->
|
||||
profile.Skills
|
||||
|> List.map (fun skill ->
|
||||
li [] [
|
||||
str skill.Description
|
||||
match skill.Notes with
|
||||
|
|
Loading…
Reference in New Issue
Block a user