Version 3 #40
|
@ -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" }
|
meta: { auth: false, title: "Terms of Service" }
|
||||||
},
|
},
|
||||||
// Citizen URLs
|
// 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",
|
path: "/citizen/account",
|
||||||
name: "AccountProfile",
|
name: "AccountProfile",
|
||||||
component: () => import(/* webpackChunkName: "account" */ "../views/citizen/AccountProfile.vue"),
|
component: () => import(/* webpackChunkName: "account" */ "../views/citizen/AccountProfile.vue"),
|
||||||
meta: { auth: true, title: "Account Profile" }
|
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
|
// Job Listing URLs
|
||||||
{
|
{
|
||||||
path: "/help-wanted",
|
path: "/help-wanted",
|
||||||
|
@ -129,12 +87,6 @@ const routes: Array<RouteRecordRaw> = [
|
||||||
meta: { auth: true, title: "My Job Listings" }
|
meta: { auth: true, title: "My Job Listings" }
|
||||||
},
|
},
|
||||||
// Profile URLs
|
// Profile URLs
|
||||||
{
|
|
||||||
path: "/profile/search",
|
|
||||||
name: "SearchProfiles",
|
|
||||||
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileSearch.vue"),
|
|
||||||
meta: { auth: true, title: "Search Profiles" }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/profile/seeking",
|
path: "/profile/seeking",
|
||||||
name: "PublicSearchProfiles",
|
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
|
connection () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile
|
||||||
|
|
||||||
/// Search profiles (logged-on users)
|
/// Search profiles (logged-on users)
|
||||||
let search (search : ProfileSearch) = backgroundTask {
|
let search (search : ProfileSearchForm) = backgroundTask {
|
||||||
let searches = [
|
let searches = [
|
||||||
match search.ContinentId with
|
if search.ContinentId <> "" then
|
||||||
| Some contId -> "p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string contId ]
|
"p.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ]
|
||||||
| None -> ()
|
|
||||||
if search.RemoteWork <> "" then
|
if search.RemoteWork <> "" then
|
||||||
"p.data ->> 'remoteWork' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ]
|
"p.data ->> 'isRemote' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ]
|
||||||
match search.Skill with
|
if search.Skill <> "" then
|
||||||
| Some skl -> "p.data -> 'skills' ->> 'description' ILIKE @description", [ "@description", like skl ]
|
"EXISTS (
|
||||||
| None -> ()
|
SELECT 1 FROM jsonb_array_elements(p.data['skills']) x(elt)
|
||||||
match search.BioExperience with
|
WHERE x ->> 'description' ILIKE @description)",
|
||||||
| Some text ->
|
[ "@description", like search.Skill ]
|
||||||
|
if search.BioExperience <> "" then
|
||||||
"(p.data ->> 'biography' ILIKE @text OR p.data ->> 'experience' ILIKE @text)",
|
"(p.data ->> 'biography' ILIKE @text OR p.data ->> 'experience' ILIKE @text)",
|
||||||
[ "@text", Sql.string text ]
|
[ "@text", like search.BioExperience ]
|
||||||
| None -> ()
|
|
||||||
]
|
]
|
||||||
let! results =
|
let! results =
|
||||||
connection ()
|
connection ()
|
||||||
|
|
|
@ -163,65 +163,17 @@ type AuthOptions () =
|
||||||
override this.Value = this
|
override this.Value = this
|
||||||
|
|
||||||
|
|
||||||
/// The data required to update a profile
|
/// The various ways profiles can be searched
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type ProfileForm =
|
type ProfileSearchForm =
|
||||||
{ /// Whether the citizen to whom this profile belongs is actively seeking employment
|
{ /// Retrieve citizens from this continent
|
||||||
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
|
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
|
/// 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
|
/// 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
|
/// Whether to retrieve citizens who do or do not want remote work
|
||||||
RemoteWork : string
|
RemoteWork : string
|
||||||
|
@ -229,6 +181,7 @@ type ProfileSearch =
|
||||||
|
|
||||||
|
|
||||||
/// A user matching the profile search
|
/// A user matching the profile search
|
||||||
|
[<NoComparison; NoEquality>]
|
||||||
type ProfileSearchResult =
|
type ProfileSearchResult =
|
||||||
{ /// The ID of the citizen
|
{ /// The ID of the citizen
|
||||||
CitizenId : CitizenId
|
CitizenId : CitizenId
|
||||||
|
|
|
@ -129,6 +129,11 @@ module Helpers =
|
||||||
let csrf ctx =
|
let csrf ctx =
|
||||||
(antiForgery ctx).GetAndStoreTokens 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
|
/// The key to use to indicate if we have loaded the session
|
||||||
let private sessionLoadedKey = "session-loaded"
|
let private sessionLoadedKey = "session-loaded"
|
||||||
|
|
||||||
|
@ -587,12 +592,13 @@ module Profile =
|
||||||
|
|
||||||
// GET: /profile/edit
|
// GET: /profile/edit
|
||||||
let edit : HttpHandler = requireUser >=> fun next ctx -> task {
|
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! continents = Continents.all ()
|
||||||
let isNew = Option.isNone profile
|
let isNew = Option.isNone profile
|
||||||
let form = if isNew then EditProfileViewModel.empty else EditProfileViewModel.fromProfile profile.Value
|
let form = if isNew then EditProfileViewModel.empty else EditProfileViewModel.fromProfile profile.Value
|
||||||
let title = $"""{if isNew then "Create" else "Edit"} Profile"""
|
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
|
// POST: /profile/save
|
||||||
|
@ -616,18 +622,19 @@ module Profile =
|
||||||
do! Profiles.save
|
do! Profiles.save
|
||||||
{ profile with
|
{ profile with
|
||||||
IsSeekingEmployment = form.IsSeekingEmployment
|
IsSeekingEmployment = form.IsSeekingEmployment
|
||||||
IsPubliclySearchable = form.IsPublic
|
|
||||||
ContinentId = ContinentId.ofString form.ContinentId
|
ContinentId = ContinentId.ofString form.ContinentId
|
||||||
Region = form.Region
|
Region = form.Region
|
||||||
IsRemote = form.RemoteWork
|
IsRemote = form.RemoteWork
|
||||||
IsFullTime = form.FullTime
|
IsFullTime = form.FullTime
|
||||||
Biography = Text form.Biography
|
Biography = Text form.Biography
|
||||||
LastUpdatedOn = now ctx
|
LastUpdatedOn = now ctx
|
||||||
Experience = noneIfBlank form.Experience |> Option.map Text
|
|
||||||
Skills = form.Skills
|
Skills = form.Skills
|
||||||
|> Array.filter (fun s -> (box >> isNull >> not) s)
|
|> Array.filter (fun s -> (box >> isNull >> not) s)
|
||||||
|> Array.map SkillForm.toSkill
|
|> Array.map SkillForm.toSkill
|
||||||
|> List.ofArray
|
|> List.ofArray
|
||||||
|
Experience = noneIfBlank form.Experience |> Option.map Text
|
||||||
|
IsPubliclySearchable = form.IsPubliclySearchable
|
||||||
|
IsPubliclyLinkable = form.IsPubliclyLinkable
|
||||||
}
|
}
|
||||||
let action = if isNew then "cre" else "upd"
|
let action = if isNew then "cre" else "upd"
|
||||||
do! addSuccess $"Employment Profile {action}ated successfully" ctx
|
do! addSuccess $"Employment Profile {action}ated successfully" ctx
|
||||||
|
@ -636,10 +643,26 @@ module Profile =
|
||||||
do! addErrors errors ctx
|
do! addErrors errors ctx
|
||||||
let! continents = Continents.all ()
|
let! continents = Continents.all ()
|
||||||
return!
|
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
|
|> 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
|
// GET: /profile/[id]/view
|
||||||
let view citizenId : HttpHandler = fun next ctx -> task {
|
let view citizenId : HttpHandler = fun next ctx -> task {
|
||||||
let citId = CitizenId.ofString citizenId
|
let citId = CitizenId.ofString citizenId
|
||||||
|
@ -710,13 +733,6 @@ module ProfileApi =
|
||||||
return! ok next ctx
|
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
|
// GET: /api/profile/public-search
|
||||||
let publicSearch : HttpHandler = fun next ctx -> task {
|
let publicSearch : HttpHandler = fun next ctx -> task {
|
||||||
let search = ctx.BindQueryString<PublicSearch> ()
|
let search = ctx.BindQueryString<PublicSearch> ()
|
||||||
|
@ -798,6 +814,7 @@ let allEndpoints = [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
routef "/%s/view" Profile.view
|
routef "/%s/view" Profile.view
|
||||||
route "/edit" Profile.edit
|
route "/edit" Profile.edit
|
||||||
|
route "/search" Profile.search
|
||||||
]
|
]
|
||||||
POST [ route "/save" Profile.save ]
|
POST [ route "/save" Profile.save ]
|
||||||
]
|
]
|
||||||
|
@ -833,7 +850,6 @@ let allEndpoints = [
|
||||||
routef "/%O" ProfileApi.get
|
routef "/%O" ProfileApi.get
|
||||||
routef "/%O/view" ProfileApi.view
|
routef "/%O/view" ProfileApi.view
|
||||||
route "/public-search" ProfileApi.publicSearch
|
route "/public-search" ProfileApi.publicSearch
|
||||||
route "/search" ProfileApi.search
|
|
||||||
]
|
]
|
||||||
PATCH [ route "/employment-found" ProfileApi.employmentFound ]
|
PATCH [ route "/employment-found" ProfileApi.employmentFound ]
|
||||||
]
|
]
|
||||||
|
|
|
@ -9,7 +9,7 @@ type SkillForm =
|
||||||
{ Description : string
|
{ Description : string
|
||||||
|
|
||||||
/// Notes regarding the skill
|
/// Notes regarding the skill
|
||||||
Notes : string option
|
Notes : string
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Functions to support skill forms
|
/// Functions to support skill forms
|
||||||
|
@ -17,11 +17,11 @@ module SkillForm =
|
||||||
|
|
||||||
/// Create a skill form from a skill
|
/// Create a skill form from a skill
|
||||||
let fromSkill (skill : 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
|
/// Create a skill from a skill form
|
||||||
let toSkill (form : SkillForm) =
|
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
|
/// 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
|
{ /// Whether the citizen to whom this profile belongs is actively seeking employment
|
||||||
IsSeekingEmployment : bool
|
IsSeekingEmployment : bool
|
||||||
|
|
||||||
/// Whether this profile should appear in the public search
|
|
||||||
IsPublic : bool
|
|
||||||
|
|
||||||
/// The ID of the continent on which the citizen is located
|
/// The ID of the continent on which the citizen is located
|
||||||
ContinentId : string
|
ContinentId : string
|
||||||
|
|
||||||
|
@ -48,11 +45,17 @@ type EditProfileViewModel =
|
||||||
/// The user's professional biography
|
/// The user's professional biography
|
||||||
Biography : string
|
Biography : string
|
||||||
|
|
||||||
|
/// The skills for the user
|
||||||
|
Skills : SkillForm array
|
||||||
|
|
||||||
/// The user's past experience
|
/// The user's past experience
|
||||||
Experience : string option
|
Experience : string option
|
||||||
|
|
||||||
/// The skills for the user
|
/// Whether this profile should appear in the public search
|
||||||
Skills : SkillForm array
|
IsPubliclySearchable : bool
|
||||||
|
|
||||||
|
/// Whether this profile should be shown publicly
|
||||||
|
IsPubliclyLinkable : bool
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support functions for the ProfileForm type
|
/// Support functions for the ProfileForm type
|
||||||
|
@ -61,27 +64,29 @@ module EditProfileViewModel =
|
||||||
/// An empty view model (used for new profiles)
|
/// An empty view model (used for new profiles)
|
||||||
let empty =
|
let empty =
|
||||||
{ IsSeekingEmployment = false
|
{ IsSeekingEmployment = false
|
||||||
IsPublic = false
|
|
||||||
ContinentId = ""
|
ContinentId = ""
|
||||||
Region = ""
|
Region = ""
|
||||||
RemoteWork = false
|
RemoteWork = false
|
||||||
FullTime = false
|
FullTime = false
|
||||||
Biography = ""
|
Biography = ""
|
||||||
Experience = None
|
|
||||||
Skills = [||]
|
Skills = [||]
|
||||||
|
Experience = None
|
||||||
|
IsPubliclySearchable = false
|
||||||
|
IsPubliclyLinkable = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an instance of this form from the given profile
|
/// Create an instance of this form from the given profile
|
||||||
let fromProfile (profile : Profile) =
|
let fromProfile (profile : Profile) =
|
||||||
{ IsSeekingEmployment = profile.IsSeekingEmployment
|
{ IsSeekingEmployment = profile.IsSeekingEmployment
|
||||||
IsPublic = profile.IsPubliclySearchable
|
ContinentId = ContinentId.toString profile.ContinentId
|
||||||
ContinentId = string profile.ContinentId
|
|
||||||
Region = profile.Region
|
Region = profile.Region
|
||||||
RemoteWork = profile.IsRemote
|
RemoteWork = profile.IsRemote
|
||||||
FullTime = profile.IsFullTime
|
FullTime = profile.IsFullTime
|
||||||
Biography = MarkdownString.toString profile.Biography
|
Biography = MarkdownString.toString profile.Biography
|
||||||
Experience = profile.Experience |> Option.map MarkdownString.toString
|
|
||||||
Skills = profile.Skills |> List.map SkillForm.fromSkill |> Array.ofList
|
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 ]
|
| Some returnTo -> input [ _type "hidden"; _name (nameof m.ReturnTo); _value returnTo ]
|
||||||
| None -> ()
|
| None -> ()
|
||||||
div [ _class "col-12 col-md-6" ] [
|
div [ _class "col-12 col-md-6" ] [
|
||||||
div [ _class "form-floating" ] [
|
textBox [ _type "email"; _autofocus ] (nameof m.Email) m.Email "E-mail Address" true
|
||||||
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" ]
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
div [ _class "col-12 col-md-6" ] [
|
div [ _class "col-12 col-md-6" ] [
|
||||||
div [ _class "form-floating" ] [
|
textBox [ _type "password" ] (nameof m.Password) "" "Password" true
|
||||||
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" ]
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
div [ _class "col-12" ] [
|
div [ _class "col-12" ] [
|
||||||
button [ _class "btn btn-primary"; _type "submit" ] [
|
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" ] [
|
form [ _class "row g-3"; _hxPost "/citizen/register" ] [
|
||||||
antiForgery csrf
|
antiForgery csrf
|
||||||
div [ _class "col-6 col-xl-4" ] [
|
div [ _class "col-6 col-xl-4" ] [
|
||||||
div [ _class "form-floating" ] [
|
textBox [ _type "text"; _autofocus ] (nameof m.FirstName) m.FirstName "First Name" true
|
||||||
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" ]
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
div [ _class "col-6 col-xl-4" ] [
|
div [ _class "col-6 col-xl-4" ] [
|
||||||
div [ _class "form-floating" ] [
|
textBox [ _type "text" ] (nameof m.LastName) m.LastName "Last Name" true
|
||||||
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" ]
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
div [ _class "col-6 col-xl-4" ] [
|
div [ _class "col-6 col-xl-4" ] [
|
||||||
div [ _class "form-floating" ] [
|
textBox [ _type "text" ] (nameof m.DisplayName) (defaultArg m.DisplayName "") "Display Name" false
|
||||||
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" ]
|
|
||||||
div [ _class "form-text" ] [ em [] [ rawText "Optional; overrides first/last for display" ] ]
|
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 "col-6 col-xl-4" ] [
|
||||||
div [ _class "form-floating" ] [
|
textBox [ _type "password"; _minlength "8" ] (nameof m.Password) "" "Password" true
|
||||||
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" ]
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
div [ _class "col-6 col-xl-4" ] [
|
div [ _class "col-6 col-xl-4" ] [
|
||||||
div [ _class "form-floating" ] [
|
textBox [ _type "password"; _minlength "8" ] "ConfirmPassword" "" "Confirm Password" true
|
||||||
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" ]
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
div [ _class "col-12" ] [
|
div [ _class "col-12" ] [
|
||||||
hr []
|
hr []
|
||||||
|
@ -222,21 +179,11 @@ let register q1 q2 (m : RegisterViewModel) csrf =
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
div [ _class "col-12 col-xl-6" ] [
|
div [ _class "col-12 col-xl-6" ] [
|
||||||
div [ _class "form-floating" ] [
|
textBox [ _type "text"; _maxlength "30" ] (nameof m.Question1Answer) m.Question1Answer "Question 1" true
|
||||||
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 ]
|
|
||||||
]
|
|
||||||
input [ _type "hidden"; _name (nameof m.Question1Index); _value (string m.Question1Index ) ]
|
input [ _type "hidden"; _name (nameof m.Question1Index); _value (string m.Question1Index ) ]
|
||||||
]
|
]
|
||||||
div [ _class "col-12 col-xl-6" ] [
|
div [ _class "col-12 col-xl-6" ] [
|
||||||
div [ _class "form-floating" ] [
|
textBox [ _type "text"; _maxlength "30" ] (nameof m.Question2Answer) m.Question2Answer "Question 2" true
|
||||||
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 ]
|
|
||||||
]
|
|
||||||
input [ _type "hidden"; _name (nameof m.Question2Index); _value (string m.Question2Index ) ]
|
input [ _type "hidden"; _name (nameof m.Question2Index); _value (string m.Question2Index ) ]
|
||||||
]
|
]
|
||||||
div [ _class "col-12" ] [
|
div [ _class "col-12" ] [
|
||||||
|
|
|
@ -16,17 +16,35 @@ let audioClip clip text =
|
||||||
let antiForgery (csrf : AntiforgeryTokenSet) =
|
let antiForgery (csrf : AntiforgeryTokenSet) =
|
||||||
input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ]
|
input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ]
|
||||||
|
|
||||||
/// Create a select list of continents
|
/// Create a floating-label text input box
|
||||||
let continentList attrs name (continents : Continent list) emptyLabel selectedValue =
|
let textBox attrs name value fieldLabel isRequired =
|
||||||
div [ _class "form-floating" ] [
|
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 ] [
|
option [ _value ""; if selectedValue = "" then _selected ] [
|
||||||
rawText $"""– {defaultArg emptyLabel "Select"} –""" ]
|
rawText $"""– {defaultArg emptyLabel "Select"} –""" ]
|
||||||
:: (continents
|
:: (continents
|
||||||
|> List.map (fun c ->
|
|> List.map (fun c ->
|
||||||
let theId = ContinentId.toString c.Id
|
let theId = ContinentId.toString c.Id
|
||||||
option [ _value theId; if theId = selectedValue then _selected ] [ str c.Name ])))
|
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
|
/// Create a Markdown editor
|
||||||
|
@ -57,3 +75,28 @@ let markdownEditor attrs name value editorLabel =
|
||||||
rawText "})"
|
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
|
||||||
open Giraffe.ViewEngine.Htmx
|
open Giraffe.ViewEngine.Htmx
|
||||||
|
open JobsJobsJobs.Domain
|
||||||
|
open JobsJobsJobs.Domain.SharedTypes
|
||||||
open JobsJobsJobs.ViewModels
|
open JobsJobsJobs.ViewModels
|
||||||
|
|
||||||
/// Render the skill edit template and existing skills
|
/// 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"
|
input [ _type "text"; _id $"skillDesc{idx}"; _name $"Skills[{idx}].Description"
|
||||||
_class "form-control"; _placeholder "A skill (language, design technique, process, etc.)"
|
_class "form-control"; _placeholder "A skill (language, design technique, process, etc.)"
|
||||||
_maxlength "200"; _value skill.Description; _required ]
|
_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
|
if idx < 1 then
|
||||||
div [ _class "form-text" ] [ rawText "A skill (language, design technique, process, etc.)" ]
|
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" ] [
|
div [ _class "form-floating" ] [
|
||||||
input [ _type "text"; _id $"skillNotes{idx}"; _name $"Skills[{idx}].Notes"; _class "form-control"
|
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)"
|
_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" ]
|
label [ _class "jjj-label"; _for $"skillNotes{idx}" ] [ rawText "Notes" ]
|
||||||
]
|
]
|
||||||
if idx < 1 then
|
if idx < 1 then
|
||||||
div [ _class "form-text" ] [ rawText "A further description of the skill" ]
|
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)
|
:: (skills |> Array.mapi mapToInputs |> List.ofArray)
|
||||||
|
|
||||||
/// The profile edit page
|
/// The profile edit page
|
||||||
let edit (m : EditProfileViewModel) continents isNew csrf =
|
let edit (m : EditProfileViewModel) continents isNew citizenId csrf =
|
||||||
article [] [
|
article [] [
|
||||||
h3 [ _class "pb-3" ] [ rawText "My Employment Profile" ]
|
h3 [ _class "pb-3" ] [ rawText "My Employment Profile" ]
|
||||||
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
|
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
|
||||||
antiForgery csrf
|
antiForgery csrf
|
||||||
div [ _class "col-12" ] [
|
div [ _class "col-12" ] [
|
||||||
div [ _class "form-check" ] [
|
checkBox (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment"
|
||||||
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"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
if m.IsSeekingEmployment then
|
if m.IsSeekingEmployment then
|
||||||
p [] [
|
p [] [
|
||||||
em [] [
|
em [] [
|
||||||
|
@ -63,34 +59,18 @@ let edit (m : EditProfileViewModel) continents isNew csrf =
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
div [ _class "col-12 col-sm-6 col-md-4" ] [
|
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 "col-12 col-sm-6 col-md-8" ] [
|
||||||
div [ _class "form-floating" ] [
|
textBox [ _type "text"; _maxlength "255" ] (nameof m.Region) m.Region "Region" true
|
||||||
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" ]
|
|
||||||
]
|
|
||||||
div [ _class "form-text" ] [ rawText "Country, state, geographic area, etc." ]
|
div [ _class "form-text" ] [ rawText "Country, state, geographic area, etc." ]
|
||||||
]
|
]
|
||||||
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
|
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
|
||||||
div [ _class "col-12 col-offset-md-2 col-md-4" ] [
|
div [ _class "col-12 col-offset-md-2 col-md-4" ] [
|
||||||
div [ _class "form-check" ] [
|
checkBox (nameof m.RemoteWork) m.RemoteWork "I am looking for remote work"
|
||||||
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"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
div [ _class "col-12 col-md-4" ] [
|
div [ _class "col-12 col-md-4" ] [
|
||||||
div [ _class "form-check" ] [
|
checkBox (nameof m.FullTime) m.FullTime "I am looking for full-time work"
|
||||||
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"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
div [ _class "col-12" ] [
|
div [ _class "col-12" ] [
|
||||||
hr []
|
hr []
|
||||||
|
@ -114,14 +94,13 @@ let edit (m : EditProfileViewModel) continents isNew csrf =
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience"
|
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience"
|
||||||
div [ _class "col-12" ] [
|
div [ _class "col-12 col-xl-6" ] [
|
||||||
div [ _class "form-check" ] [
|
checkBox (nameof m.IsPubliclySearchable) m.IsPubliclySearchable
|
||||||
input [ _type "checkbox"; _id (nameof m.IsPublic); _name (nameof m.IsPublic)
|
"Allow my profile to be searched publicly"
|
||||||
_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.IsPubliclyLinkable) m.IsPubliclyLinkable
|
||||||
|
"Show my profile to anyone who has the direct link to it"
|
||||||
]
|
]
|
||||||
div [ _class "col-12" ] [
|
div [ _class "col-12" ] [
|
||||||
button [ _type "submit"; _class "btn btn-primary" ] [
|
button [ _type "submit"; _class "btn btn-primary" ] [
|
||||||
|
@ -129,7 +108,7 @@ let edit (m : EditProfileViewModel) continents isNew csrf =
|
||||||
]
|
]
|
||||||
if not isNew then
|
if not isNew then
|
||||||
rawText " "
|
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" ] []
|
i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] []
|
||||||
rawText " View Your User Profile"
|
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 =
|
let view (citizen : Citizen) (profile : Profile) (continentName : string) pageTitle currentId =
|
||||||
article [] [
|
article [] [
|
||||||
h3 [ _class "pb-3" ] [ str pageTitle ]
|
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
|
if not (List.isEmpty profile.Skills) then
|
||||||
hr []
|
hr []
|
||||||
h4 [ _class "pb-3" ] [ rawText "Skills" ]
|
h4 [ _class "pb-3" ] [ rawText "Skills" ]
|
||||||
profile.Skills |> List.map (fun skill ->
|
profile.Skills
|
||||||
|
|> List.map (fun skill ->
|
||||||
li [] [
|
li [] [
|
||||||
str skill.Description
|
str skill.Description
|
||||||
match skill.Notes with
|
match skill.Notes with
|
||||||
|
|
Loading…
Reference in New Issue
Block a user