Version 3 #40

Merged
danieljsummers merged 67 commits from version-2-3 into main 2023-02-02 23:47:28 +00:00
11 changed files with 239 additions and 523 deletions
Showing only changes of commit 6e2a40e436 - Show all commits

View File

@ -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>

View File

@ -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')">&nbsp;&minus;&nbsp;</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>

View File

@ -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",

View File

@ -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 &ldquo;Search&rdquo; 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&hellip;</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>

View File

@ -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 ()

View File

@ -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

View File

@ -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 ]
] ]

View File

@ -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
@ -60,28 +63,30 @@ 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 = "" Skills = [||]
Experience = None Experience = None
Skills = [||] 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 Skills = profile.Skills |> List.map SkillForm.fromSkill |> Array.ofList
Experience = profile.Experience |> Option.map MarkdownString.toString Experience = profile.Experience |> Option.map MarkdownString.toString
Skills = profile.Skills |> List.map SkillForm.fromSkill |> Array.ofList IsPubliclySearchable = profile.IsPubliclySearchable
IsPubliclyLinkable = profile.IsPubliclyLinkable
} }

View File

@ -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) div [ _class "form-text" ] [ em [] [ rawText "Optional; overrides first/last for display" ] ]
_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 "col-6 col-xl-4" ] [ div [ _class "col-6 col-xl-4" ] [
div [ _class "form-floating" ] [ textBox [ _type "text" ] (nameof m.Email) m.Email "E-mail Address" 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" ] (nameof m.Password) "" "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 "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 "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" ] [

View File

@ -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 $"""&ndash; {defaultArg emptyLabel "Select"} &ndash;""" ] rawText $"""&ndash; {defaultArg emptyLabel "Select"} &ndash;""" ]
:: (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]))

View File

@ -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) ] [ div [ _class "col-12 col-xl-6" ] [
rawText "Allow my profile to be searched publicly" 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 "&nbsp; &nbsp; " rawText "&nbsp; &nbsp; "
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 "&nbsp; View Your User Profile" rawText "&nbsp; 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 &ldquo;Search&rdquo; 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