Version 3 #40

Merged
danieljsummers merged 67 commits from version-2-3 into main 2023-02-02 23:47:28 +00:00
13 changed files with 170 additions and 427 deletions
Showing only changes of commit 1a4a8d1bba - Show all commits

View File

@ -129,18 +129,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/:id/view",
name: "ViewProfile",
component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileView.vue"),
meta: { auth: true, title: "Loading Profile..." }
},
{
path: "/profile/edit",
name: "EditProfile",
component: () => import(/* webpackChunkName: "profedit" */ "../views/profile/EditProfile.vue"),
meta: { auth: true, title: "Edit Profile" }
},
{ {
path: "/profile/search", path: "/profile/search",
name: "SearchProfiles", name: "SearchProfiles",

View File

@ -1,206 +0,0 @@
<template>
<article>
<h3 class="pb-3">My Employment Profile</h3>
<load-data :load="retrieveData">
<form class="row g-3">
<div class="col-12">
<div class="form-check">
<input type="checkbox" id="isSeeking" class="form-check-input" v-model="v$.isSeekingEmployment.$model">
<label class="form-check-label" for="isSeeking">I am currently seeking employment</label>
</div>
<p v-if="profile.isSeekingEmployment">
<em>
If you have found employment, consider
<router-link to="/success-story/new/edit">telling your fellow citizens about it!</router-link>
</em>
</p>
</div>
<div class="col-12 col-sm-6 col-md-4">
<continent-list v-model="v$.continentId.$model" :isInvalid="v$.continentId.$error"
@touch="v$.continentId.$touch() || true" />
</div>
<div class="col-12 col-sm-6 col-md-8">
<div class="form-floating">
<input type="text" id="region" :class="{'form-control': true, 'is-invalid': v$.region.$error }"
v-model="v$.region.$model" maxlength="255" placeholder="Country, state, geographic area, etc.">
<div class="invalid-feedback">Please enter a region</div>
<label class="jjj-required" for="region">Region</label>
</div>
<div class="form-text">Country, state, geographic area, etc.</div>
</div>
<markdown-editor id="bio" label="Professional Biography" v-model:text="v$.biography.$model"
:isInvalid="v$.biography.$error" />
<div class="col-12 col-offset-md-2 col-md-4">
<div class="form-check">
<input type="checkbox" id="isRemote" class="form-check-input" v-model="v$.remoteWork.$model">
<label class="form-check-label" for="isRemote">I am looking for remote work</label>
</div>
</div>
<div class="col-12 col-md-4">
<div class="form-check">
<input type="checkbox" id="isFullTime" class="form-check-input" v-model="v$.fullTime.$model">
<label class="form-check-label" for="isFullTime">I am looking for full-time work</label>
</div>
</div>
<div class="col-12">
<hr>
<h4 class="pb-2">
Skills &nbsp;
<button class="btn btn-sm btn-outline-primary rounded-pill" @click.prevent="addSkill">Add a Skill</button>
</h4>
</div>
<profile-skill-edit v-for="(skill, idx) in profile.skills" :key="skill.id" v-model="profile.skills[idx]"
@remove="removeSkill(skill.id)" @input="v$.skills.$touch" />
<div class="col-12">
<hr>
<h4>Experience</h4>
<p>
This application does not have a place to individually list your chronological job history; however, you
can use this area to list prior jobs, their dates, and anything else you want to include that&rsquo;s not
already a part of your Professional Biography above.
</p>
</div>
<markdown-editor id="experience" label="Experience" v-model:text="v$.experience.$model" />
<div class="col-12">
<div class="form-check">
<input type="checkbox" id="isPublic" class="form-check-input" v-model="v$.isPublic.$model">
<label class="form-check-label" for="isPublic">Allow my profile to be searched publicly</label>
</div>
</div>
<div class="col-12">
<p v-if="v$.$error" class="text-danger">Please correct the errors above</p>
<button class="btn btn-primary" @click.prevent="saveProfile">
<icon :icon="mdiContentSaveOutline" />&nbsp; Save
</button>
<template v-if="!isNew">
&nbsp; &nbsp;
<router-link class="btn btn-outline-secondary" :to="`/profile/${user.citizenId}/view`">
<icon color="#6c757d" :icon="mdiFileAccountOutline" />&nbsp; View Your User Profile
</router-link>
</template>
</div>
</form>
</load-data>
<hr>
<p class="text-muted fst-italic">
(If you want to delete your profile, or your entire account,
<router-link to="/so-long/options">see your deletion options here</router-link>.)
</p>
<maybe-save :saveAction="saveProfile" :validator="v$" />
</article>
</template>
<script setup lang="ts">
import { computed, ref, reactive } from "vue"
import { mdiContentSaveOutline, mdiFileAccountOutline } from "@mdi/js"
import useVuelidate from "@vuelidate/core"
import { required } from "@vuelidate/validators"
import api, { LogOnSuccess, Profile, ProfileForm } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore } from "@/store"
import ContinentList from "@/components/ContinentList.vue"
import LoadData from "@/components/LoadData.vue"
import MarkdownEditor from "@/components/MarkdownEditor.vue"
import MaybeSave from "@/components/MaybeSave.vue"
import ProfileSkillEdit from "@/components/profile/SkillEdit.vue"
const store = useStore()
/** The currently logged-on user */
const user = store.state.user as LogOnSuccess
/** Whether this is a new profile */
const isNew = ref(false)
/** The starting values for a new employment profile */
const newProfile : Profile = {
id: user.citizenId,
seekingEmployment: false,
isPublic: false,
continentId: "",
region: "",
remoteWork: false,
fullTime: false,
biography: "",
lastUpdatedOn: "",
experience: undefined,
skills: []
}
/** The user's current profile (plus a few items, adapted for editing) */
const profile = reactive(new ProfileForm())
/** The validation rules for the form */
const rules = computed(() => ({
isSeekingEmployment: { },
isPublic: { },
continentId: { required },
region: { required },
remoteWork: { },
fullTime: { },
biography: { required },
experience: { },
skills: { }
}))
/** Initialize form validation */
const v$ = useVuelidate(rules, profile, { $lazy: true })
/** Retrieve the user's profile and their real name */
const retrieveData = async (errors : string[]) => {
const profileResult = await api.profile.retreive(undefined, user)
if (typeof profileResult === "string") {
errors.push(profileResult)
} else if (typeof profileResult === "undefined") {
isNew.value = true
}
const nameResult = await api.citizen.retrieve(user.citizenId, user)
if (typeof nameResult === "string") {
errors.push(nameResult)
}
if (errors.length > 0) return
// Update the empty form with appropriate values
const p = isNew.value ? newProfile : profileResult as Profile
profile.isSeekingEmployment = p.seekingEmployment
profile.isPublic = p.isPublic
profile.continentId = p.continentId
profile.region = p.region
profile.remoteWork = p.remoteWork
profile.fullTime = p.fullTime
profile.biography = p.biography
profile.experience = p.experience
profile.skills = p.skills
}
/** The ID for new skills */
let newSkillId = 0
/** Add a skill to the profile */
const addSkill = () => {
profile.skills.push({ id: `new${newSkillId++}`, description: "", notes: undefined })
v$.value.skills.$touch()
}
/** Remove the given skill from the profile */
const removeSkill = (skillId : string) => {
profile.skills = profile.skills.filter(s => s.id !== skillId)
v$.value.skills.$touch()
}
/** Save the current profile values */
const saveProfile = async () => {
v$.value.$touch()
if (v$.value.$error) return
// Remove any blank skills before submitting
profile.skills = profile.skills.filter(s => !(s.description.trim() === "" && (s.notes ?? "").trim() === ""))
const saveResult = await api.profile.save(profile, user)
if (typeof saveResult === "string") {
toastError(saveResult, "saving profile")
} else {
toastSuccess("Profile Saved Successfully")
v$.value.$reset()
}
}
</script>

View File

@ -1,88 +0,0 @@
<template>
<article>
<h3 class="pb-3">{{title}}</h3>
<load-data :load="retrieveProfile">
<h2>
<a :href="it.citizen.profileUrl" target="_blank" rel="noopener">{{citizenName(it.citizen)}}</a>
<span v-if="it.profile.seekingEmployment" class="jjj-heading-label">
&nbsp; &nbsp;<span class="badge bg-dark">Currently Seeking Employment</span>
</span>
</h2>
<h4 class="pb-3">{{it.continent.name}}, {{it.profile.region}}</h4>
<p v-html="workTypes" />
<hr>
<div v-html="bioHtml" />
<template v-if="it.profile.skills.length > 0">
<hr>
<h4 class="pb-3">Skills</h4>
<ul>
<li v-for="(skill, idx) in it.profile.skills" :key="idx">
{{skill.description}}<template v-if="skill.notes"> &nbsp;({{skill.notes}})</template>
</li>
</ul>
</template>
<template v-if="it.profile.experience">
<hr>
<h4 class="pb-3">Experience / Employment History</h4>
<div v-html="expHtml" />
</template>
<template v-if="user.citizenId === it.citizen.id">
<br><br>
<router-link class="btn btn-primary" to="/profile/edit">
<icon :icon="mdiPencil" />&nbsp; Edit Your Profile
</router-link>
</template>
</load-data>
</article>
</template>
<script setup lang="ts">
import { computed, ref, Ref } from "vue"
import { useRoute } from "vue-router"
import { mdiPencil } from "@mdi/js"
import api, { LogOnSuccess, ProfileForView } from "@/api"
import { citizenName } from "@/App.vue"
import { toHtml } from "@/markdown"
import { Mutations, useStore } from "@/store"
import LoadData from "@/components/LoadData.vue"
const store = useStore()
const route = useRoute()
/** The currently logged-on user */
const user = store.state.user as LogOnSuccess
/** The requested profile */
const it : Ref<ProfileForView | undefined> = ref(undefined)
/** The work types for the top of the page */
const workTypes = computed(() => {
const parts : string[] = []
if (it.value) {
const p = it.value.profile
parts.push(`${p.fullTime ? "I" : "Not i"}nterested in full-time employment`)
parts.push(`${p.remoteWork ? "I" : "Not i"}nterested in remote opportunities`)
}
return parts.join(" &bull; ")
})
/** Retrieve the profile and supporting data */
const retrieveProfile = async (errors : string[]) => {
const profileResp = await api.profile.retreiveForView(route.params.id as string, user)
if (typeof profileResp === "string") {
errors.push(profileResp)
} else if (typeof profileResp === "undefined") {
errors.push("Profile not found")
} else {
it.value = profileResp
store.commit(Mutations.SetTitle, `Employment profile for ${citizenName(profileResp.citizen)}`)
}
}
/** The HTML version of the citizen's professional biography */
const bioHtml = computed(() => toHtml(it.value?.profile.biography ?? ""))
/** The HTML version of the citizens Experience section */
const expHtml = computed(() => toHtml(it.value?.profile.experience ?? ""))
</script>

View File

@ -24,7 +24,6 @@ let options =
WrappedJsonConverter (ListingId.ofString, ListingId.toString) WrappedJsonConverter (ListingId.ofString, ListingId.toString)
WrappedJsonConverter (Text, MarkdownString.toString) WrappedJsonConverter (Text, MarkdownString.toString)
WrappedJsonConverter (OtherContactId.ofString, OtherContactId.toString) WrappedJsonConverter (OtherContactId.ofString, OtherContactId.toString)
WrappedJsonConverter (SkillId.ofString, SkillId.toString)
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString) WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
JsonFSharpConverter () JsonFSharpConverter ()
] ]

View File

@ -163,18 +163,6 @@ type AuthOptions () =
override this.Value = this override this.Value = this
/// The fields required for a skill
type SkillForm =
{ /// The ID of this skill
Id : string
/// The description of the skill
Description : string
/// Notes regarding the skill
Notes : string option
}
/// The data required to update a profile /// The data required to update a profile
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type ProfileForm = type ProfileForm =
@ -203,7 +191,7 @@ type ProfileForm =
Experience : string option Experience : string option
/// The skills for the user /// The skills for the user
Skills : SkillForm list Skills : Skill list
} }
/// Support functions for the ProfileForm type /// Support functions for the ProfileForm type
@ -220,11 +208,6 @@ module ProfileForm =
Biography = MarkdownString.toString profile.Biography Biography = MarkdownString.toString profile.Biography
Experience = profile.Experience |> Option.map MarkdownString.toString Experience = profile.Experience |> Option.map MarkdownString.toString
Skills = profile.Skills Skills = profile.Skills
|> List.map (fun s ->
{ Id = string s.Id
Description = s.Description
Notes = s.Notes
})
} }

View File

@ -141,20 +141,14 @@ type OtherContact =
} }
/// The ID of a skill /// A skill the job seeker possesses
type SkillId = SkillId of Guid type Skill =
{ /// A description of the skill
Description : string
/// Support functions for skill IDs /// Notes regarding this skill (level, duration, etc.)
module SkillId = Notes : string option
}
/// Create a new skill ID
let create () = (Guid.NewGuid >> SkillId) ()
/// A string representation of a skill ID
let toString = function SkillId it -> ShortGuid.fromGuid it
/// Parse a string into a skill ID
let ofString = ShortGuid.toGuid >> SkillId
/// The ID of a success report /// The ID of a success report

View File

@ -178,19 +178,6 @@ module SecurityInfo =
} }
/// A skill the job seeker possesses
type Skill =
{ /// The ID of the skill
Id : SkillId
/// A description of the skill
Description : string
/// Notes regarding this skill (level, duration, etc.)
Notes : string option
}
/// A job seeker profile /// A job seeker profile
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Profile = type Profile =

View File

@ -141,8 +141,7 @@ task {
Skills = p["skills"].Children() Skills = p["skills"].Children()
|> Seq.map (fun s -> |> Seq.map (fun s ->
let notes = s["notes"].Value<string> () let notes = s["notes"].Value<string> ()
{ Skill.Id = SkillId.ofString (s["id"].Value<string> ()) { Description = s["description"].Value<string> ()
Description = s["description"].Value<string> ()
Notes = if isNull notes then None else Some notes Notes = if isNull notes then None else Some notes
}) })
|> List.ofSeq |> List.ofSeq

View File

@ -69,6 +69,7 @@ module Error =
clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message
open System
open NodaTime open NodaTime
/// Helper functions /// Helper functions
@ -171,6 +172,12 @@ module Helpers =
do! addMessage "error" msg ctx do! addMessage "error" msg ctx
} }
/// Add a list of errors to the response
let addErrors (errors : string list) ctx = task {
let errMsg = String.Join ("</li><li>", errors)
do! addError $"Please correct the following errors:<ul><li>{errMsg}</li></ul>" ctx
}
/// Render a page-level view /// Render a page-level view
let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task { let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task {
let! messages = popMessages ctx let! messages = popMessages ctx
@ -213,7 +220,6 @@ module Helpers =
} }
open System
open JobsJobsJobs.Data open JobsJobsJobs.Data
open JobsJobsJobs.ViewModels open JobsJobsJobs.ViewModels
@ -387,8 +393,7 @@ module Citizen =
do! addError "There is already an account registered to the e-mail address provided" ctx do! addError "There is already an account registered to the e-mail address provided" ctx
return! refreshPage () next ctx return! refreshPage () next ctx
else else
let errMsg = String.Join ("</li><li>", errors) do! addErrors errors ctx
do! addError $"Please correct the following errors:<ul><li>{errMsg}</li></ul>" ctx
return! refreshPage () next ctx return! refreshPage () next ctx
} }
@ -581,7 +586,7 @@ module Listing =
module Profile = module Profile =
// GET: /profile/edit // GET: /profile/edit
let edit = requireUser >=> fun next ctx -> task { let edit : HttpHandler = requireUser >=> fun next ctx -> task {
let! profile = Profiles.findById (currentCitizenId ctx) let! profile = Profiles.findById (currentCitizenId ctx)
let! continents = Continents.all () let! continents = Continents.all ()
let isNew = Option.isNone profile let isNew = Option.isNone profile
@ -590,6 +595,72 @@ module Profile =
return! Profile.edit form continents isNew (csrf ctx) |> render title next ctx return! Profile.edit form continents isNew (csrf ctx) |> render title next ctx
} }
// POST: /profile/save
let save : HttpHandler = requireUser >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx
let! theForm = ctx.BindFormAsync<EditProfileViewModel> ()
let form = { theForm with Skills = theForm.Skills |> Array.filter (box >> isNull >> not) }
let errors = [
if form.ContinentId = "" then "Continent is required"
if form.Region = "" then "Region is required"
if form.Biography = "" then "Professional Biography is required"
if form.Skills |> Array.exists (fun s -> s.Description = "") then "All skill Descriptions are required"
]
let! profile = task {
match! Profiles.findById citizenId with
| Some p -> return p
| None -> return { Profile.empty with Id = citizenId }
}
let isNew = profile.Region = ""
if List.isEmpty errors then
do! Profiles.save
{ profile with
IsSeekingEmployment = form.IsSeekingEmployment
IsPubliclySearchable = form.IsPublic
ContinentId = ContinentId.ofString form.ContinentId
Region = form.Region
IsRemote = form.RemoteWork
IsFullTime = form.FullTime
Biography = Text form.Biography
LastUpdatedOn = now ctx
Experience = noneIfBlank form.Experience |> Option.map Text
Skills = form.Skills
|> Array.filter (fun s -> (box >> isNull >> not) s)
|> Array.map SkillForm.toSkill
|> List.ofArray
}
let action = if isNew then "cre" else "upd"
do! addSuccess $"Employment Profile {action}ated successfully" ctx
return! redirectToGet "/profile/edit" next ctx
else
do! addErrors errors ctx
let! continents = Continents.all ()
return!
Profile.edit form continents isNew (csrf ctx)
|> render $"""{if isNew then "Create" else "Edit"} Profile""" next ctx
}
// GET: /profile/[id]/view
let view citizenId : HttpHandler = fun next ctx -> task {
let citId = CitizenId.ofString citizenId
match! Citizens.findById citId with
| Some citizen ->
match! Profiles.findById citId with
| Some profile ->
let currentCitizen = tryUser ctx |> Option.map CitizenId.ofString
if not profile.IsPubliclyLinkable && Option.isNone currentCitizen then
return! Error.notAuthorized next ctx
else
let! continent = Continents.findById profile.ContinentId
let continentName = match continent with Some c -> c.Name | None -> "not found"
let title = $"Employment Profile for {Citizen.name citizen}"
return!
Profile.view citizen profile continentName title currentCitizen
|> render title next ctx
| None -> return! Error.notFound next ctx
| None -> return! Error.notFound next ctx
}
/// Handlers for /api/profile routes /// Handlers for /api/profile routes
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
@ -624,37 +695,6 @@ module ProfileApi =
return! json {| Count = theCount |} next ctx return! json {| Count = theCount |} next ctx
} }
// POST: /api/profile/save
let save : HttpHandler = authorize >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx
let! form = ctx.BindJsonAsync<ProfileForm>()
let! profile = task {
match! Profiles.findById citizenId with
| Some p -> return p
| None -> return { Profile.empty with Id = citizenId }
}
do! Profiles.save
{ profile with
IsSeekingEmployment = form.IsSeekingEmployment
IsPubliclySearchable = form.IsPublic
ContinentId = ContinentId.ofString form.ContinentId
Region = form.Region
IsRemote = form.RemoteWork
IsFullTime = form.FullTime
Biography = Text form.Biography
LastUpdatedOn = now ctx
Experience = noneIfBlank form.Experience |> Option.map Text
Skills = form.Skills
|> List.map (fun s ->
{ Id = if s.Id.StartsWith "new" then SkillId.create ()
else SkillId.ofString s.Id
Description = s.Description
Notes = noneIfBlank s.Notes
})
}
return! ok next ctx
}
// PATCH: /api/profile/employment-found // PATCH: /api/profile/employment-found
let employmentFound : HttpHandler = authorize >=> fun next ctx -> task { let employmentFound : HttpHandler = authorize >=> fun next ctx -> task {
match! Profiles.findById (currentCitizenId ctx) with match! Profiles.findById (currentCitizenId ctx) with
@ -756,8 +796,10 @@ let allEndpoints = [
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ] GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
subRoute "/profile" [ subRoute "/profile" [
GET_HEAD [ GET_HEAD [
route "/edit" Profile.edit routef "/%s/view" Profile.view
route "/edit" Profile.edit
] ]
POST [ route "/save" Profile.save ]
] ]
GET_HEAD [ route "/terms-of-service" Home.termsOfService ] GET_HEAD [ route "/terms-of-service" Home.termsOfService ]
@ -794,7 +836,6 @@ let allEndpoints = [
route "/search" ProfileApi.search route "/search" ProfileApi.search
] ]
PATCH [ route "/employment-found" ProfileApi.employmentFound ] PATCH [ route "/employment-found" ProfileApi.employmentFound ]
POST [ route "" ProfileApi.save ]
] ]
subRoute "/success" [ subRoute "/success" [
GET_HEAD [ GET_HEAD [

View File

@ -6,16 +6,24 @@ open JobsJobsJobs.Domain
/// The fields required for a skill /// The fields required for a skill
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type SkillForm = type SkillForm =
{ /// The ID of this skill { Description : string
Id : string
/// The description of the skill
Description : string
/// Notes regarding the skill /// Notes regarding the skill
Notes : string option Notes : string option
} }
/// Functions to support skill forms
module SkillForm =
/// Create a skill form from a skill
let fromSkill (skill : Skill) =
{ SkillForm.Description = skill.Description; Notes = skill.Notes }
/// Create a skill from a skill form
let toSkill (form : SkillForm) =
{ Skill.Description = form.Description; Notes = form.Notes }
/// The data required to update a profile /// The data required to update a profile
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type EditProfileViewModel = type EditProfileViewModel =
@ -73,13 +81,7 @@ module EditProfileViewModel =
FullTime = profile.IsFullTime FullTime = profile.IsFullTime
Biography = MarkdownString.toString profile.Biography Biography = MarkdownString.toString profile.Biography
Experience = profile.Experience |> Option.map MarkdownString.toString Experience = profile.Experience |> Option.map MarkdownString.toString
Skills = profile.Skills Skills = profile.Skills |> List.map SkillForm.fromSkill |> Array.ofList
|> List.map (fun s ->
{ Id = string s.Id
Description = s.Description
Notes = s.Notes
})
|> Array.ofList
} }

View File

@ -54,7 +54,8 @@ let dashboard (citizen : Citizen) (profile : Profile option) profileCount =
div [ _class "card-footer" ] [ div [ _class "card-footer" ] [
match profile with match profile with
| Some p -> | Some p ->
a [ _href $"/profile/{citizen.Id}/view"; _class "btn btn-outline-secondary" ] [ a [ _href $"/profile/{CitizenId.toString citizen.Id}/view"
_class "btn btn-outline-secondary" ] [
rawText "View Profile" rawText "View Profile"
]; rawText "&nbsp; &nbsp;" ]; rawText "&nbsp; &nbsp;"
a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ rawText "Edit Profile" ] a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ rawText "Edit Profile" ]

View File

@ -2,7 +2,6 @@
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module JobsJobsJobs.Views.Profile module JobsJobsJobs.Views.Profile
open Giraffe
open Giraffe.ViewEngine open Giraffe.ViewEngine
open Giraffe.ViewEngine.Htmx open Giraffe.ViewEngine.Htmx
open JobsJobsJobs.ViewModels open JobsJobsJobs.ViewModels
@ -10,45 +9,42 @@ open JobsJobsJobs.ViewModels
/// Render the skill edit template and existing skills /// Render the skill edit template and existing skills
let skillEdit (skills : SkillForm array) = let skillEdit (skills : SkillForm array) =
let mapToInputs (idx : int) (skill : SkillForm) = let mapToInputs (idx : int) (skill : SkillForm) =
div [ _id $"skillRow{skill.Id}"; _class "row pb-3" ] [ div [ _id $"skillRow{idx}"; _class "row pb-3" ] [
div [ _class "col-2 col-md-1 align-self-center" ] [ div [ _class "col-2 col-md-1 align-self-center" ] [
button [ _class "btn btn-sm btn-outline-danger rounded-pill"; _title "Delete" button [ _class "btn btn-sm btn-outline-danger rounded-pill"; _title "Delete"
_onclick $"jjj.profile.removeSkill('{skill.Id}')" ] [ _onclick $"jjj.profile.removeSkill(idx)" ] [
rawText "&nbsp;&minus;&nbsp;" rawText "&nbsp;&minus;&nbsp;"
] ]
] ]
div [ _class "col-10 col-md-6" ] [ div [ _class "col-10 col-md-6" ] [
div [ _class "form-floating" ] [ div [ _class "form-floating" ] [
input [ _type "text"; _id $"skillDesc{skill.Id}"; _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{skill.Id}" ] [ rawText "Skill" ] label [ _class "jjj-label"; _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.)" ]
] ]
div [ _class "col-12 col-md-5" ] [ div [ _class "col-12 col-md-5" ] [
div [ _class "form-floating" ] [ div [ _class "form-floating" ] [
input [ _type "text"; _id $"skillNotes{skill.Id}"; _name $"Skills[{idx}].Notes"; input [ _type "text"; _id $"skillNotes{idx}"; _name $"Skills[{idx}].Notes"; _class "form-control"
_class "form-control"; _maxlength "1000" _maxlength "1000"; _placeholder "A further description of the skill (1,000 characters max)"
_placeholder "A further description of the skill (1,000 characters max)"
_value (defaultArg skill.Notes "") ] _value (defaultArg skill.Notes "") ]
label [ _class "jjj-label"; _for $"skillNotes{skill.Id}" ] [ 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 { Id = ""; Description = ""; Notes = None } ] template [ _id "newSkill" ] [ mapToInputs -1 { Description = ""; Notes = None } ]
:: (skills :: (skills |> Array.mapi mapToInputs |> List.ofArray)
|> 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 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/edit"; _hxPost "/profile/edit" ] [ 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" ] [ div [ _class "form-check" ] [
@ -100,7 +96,8 @@ let edit (m : EditProfileViewModel) continents isNew csrf =
hr [] hr []
h4 [ _class "pb-2" ] [ h4 [ _class "pb-2" ] [
rawText "Skills &nbsp; " rawText "Skills &nbsp; "
button [ _class "btn btn-sm btn-outline-primary rounded-pill"; _onclick "jjj.profile.addSkill()" ] [ button [ _type "button"; _class "btn btn-sm btn-outline-primary rounded-pill"
_onclick "jjj.profile.addSkill()" ] [
rawText "Add a Skill" rawText "Add a Skill"
] ]
] ]
@ -149,3 +146,49 @@ let edit (m : EditProfileViewModel) continents isNew csrf =
rawText "})" rawText "})"
] ]
] ]
open JobsJobsJobs.Domain
let view (citizen : Citizen) (profile : Profile) (continentName : string) pageTitle currentId =
article [] [
h3 [ _class "pb-3" ] [ str pageTitle ]
h2 [] [
// TODO: link to preferred profile
a [ _href "#"; _target "_blank"; _rel "noopener" ] [ str (Citizen.name citizen) ]
if profile.IsSeekingEmployment then
span [ _class "jjj-heading-label" ] [
rawText "&nbsp; &nbsp;"; span [ _class "badge bg-dark" ] [ rawText "Currently Seeking Employment" ]
]
]
h4 [ _class "pb-3" ] [ str $"{continentName}, {profile.Region}" ]
p [] [
rawText (if profile.IsFullTime then "I" else "Not i"); rawText "nterested in full-time employment"
rawText " &bull; "
rawText (if profile.IsRemote then "I" else "Not i"); rawText "nterested in remote opportunities"
]
hr []
div [] [ rawText (MarkdownString.toHtml profile.Biography) ]
if not (List.isEmpty profile.Skills) then
hr []
h4 [ _class "pb-3" ] [ rawText "Skills" ]
profile.Skills |> List.map (fun skill ->
li [] [
str skill.Description
match skill.Notes with
| Some notes ->
rawText " &nbsp;("; str notes; rawText ")"
| None -> ()
])
|> ul []
match profile.Experience with
| Some exp ->
hr []
h4 [ _class "pb-3" ] [ rawText "Experience / Employment History" ]
div [] [ rawText (MarkdownString.toHtml exp) ]
| None -> ()
if Option.isSome currentId && currentId.Value = citizen.Id then
br []; br []
a [ _href "/profile/edit"; _class "btn btn-primary" ] [
i [ _class "mdi mdi-pencil" ] []; rawText "&nbsp; Edit Your Profile"
]
]

View File

@ -145,28 +145,28 @@ this.jjj = {
* Add a skill to the profile form * Add a skill to the profile form
*/ */
addSkill() { addSkill() {
const newId = `new${this.nextIndex}` const next = this.nextIndex
/** @type {HTMLTemplateElement} */ /** @type {HTMLTemplateElement} */
const newSkillTemplate = document.getElementById("newSkill") const newSkillTemplate = document.getElementById("newSkill")
/** @type {HTMLDivElement} */ /** @type {HTMLDivElement} */
const newSkill = newSkillTemplate.content.firstElementChild.cloneNode(true) const newSkill = newSkillTemplate.content.firstElementChild.cloneNode(true)
newSkill.setAttribute("id", `skillRow${newId}`) newSkill.setAttribute("id", `skillRow${next}`)
const cols = newSkill.children const cols = newSkill.children
// Button column // Button column
cols[0].querySelector("button").setAttribute("onclick", `jjj.profile.removeSkill('${newId}')`) cols[0].querySelector("button").setAttribute("onclick", `jjj.profile.removeSkill('${next}')`)
// Skill column // Skill column
const skillField = cols[1].querySelector("input") const skillField = cols[1].querySelector("input")
skillField.setAttribute("id", `skillDesc${newId}`) skillField.setAttribute("id", `skillDesc${next}`)
skillField.setAttribute("name", `Skills[${this.nextIndex}].Description`) skillField.setAttribute("name", `Skills[${this.nextIndex}].Description`)
cols[1].querySelector("label").setAttribute("for", `skillDesc${newId}`) cols[1].querySelector("label").setAttribute("for", `skillDesc${next}`)
if (this.nextIndex > 0) cols[1].querySelector("div.form-text").remove() if (this.nextIndex > 0) cols[1].querySelector("div.form-text").remove()
// Notes column // Notes column
const notesField = cols[2].querySelector("input") const notesField = cols[2].querySelector("input")
notesField.setAttribute("id", `skillNotes${newId}`) notesField.setAttribute("id", `skillNotes${next}`)
notesField.setAttribute("name", `Skills[${this.nextIndex}].Notes`) notesField.setAttribute("name", `Skills[${this.nextIndex}].Notes`)
cols[2].querySelector("label").setAttribute("for", `skillNotes${newId}`) cols[2].querySelector("label").setAttribute("for", `skillNotes${next}`)
if (this.nextIndex > 0) cols[2].querySelector("div.form-text").remove() if (this.nextIndex > 0) cols[2].querySelector("div.form-text").remove()
// Add the row // Add the row
@ -179,7 +179,7 @@ this.jjj = {
/** /**
* Remove a skill row from the profile form * Remove a skill row from the profile form
* @param {string} id The ID of the skill row to remove * @param {number} id The ID of the skill row to remove
*/ */
removeSkill(id) { removeSkill(id) {
document.getElementById(`skillRow${id}`).remove() document.getElementById(`skillRow${id}`).remove()