Finish profile edit; add profile view

This commit is contained in:
Daniel J. Summers 2023-01-14 17:41:34 -05:00
parent e4a86ab701
commit 1a4a8d1bba
13 changed files with 170 additions and 427 deletions

View File

@ -129,18 +129,6 @@ const routes: Array<RouteRecordRaw> = [
meta: { auth: true, title: "My Job Listings" }
},
// 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",
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 (Text, MarkdownString.toString)
WrappedJsonConverter (OtherContactId.ofString, OtherContactId.toString)
WrappedJsonConverter (SkillId.ofString, SkillId.toString)
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
JsonFSharpConverter ()
]

View File

@ -163,18 +163,6 @@ type AuthOptions () =
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
[<CLIMutable; NoComparison; NoEquality>]
type ProfileForm =
@ -203,7 +191,7 @@ type ProfileForm =
Experience : string option
/// The skills for the user
Skills : SkillForm list
Skills : Skill list
}
/// Support functions for the ProfileForm type
@ -220,11 +208,6 @@ module ProfileForm =
Biography = MarkdownString.toString profile.Biography
Experience = profile.Experience |> Option.map MarkdownString.toString
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
type SkillId = SkillId of Guid
/// A skill the job seeker possesses
type Skill =
{ /// A description of the skill
Description : string
/// Support functions for skill IDs
module SkillId =
/// 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
/// Notes regarding this skill (level, duration, etc.)
Notes : string option
}
/// 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
[<CLIMutable; NoComparison; NoEquality>]
type Profile =

View File

@ -141,8 +141,7 @@ task {
Skills = p["skills"].Children()
|> Seq.map (fun s ->
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
})
|> List.ofSeq

View File

@ -69,6 +69,7 @@ module Error =
clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message
open System
open NodaTime
/// Helper functions
@ -171,6 +172,12 @@ module Helpers =
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
let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task {
let! messages = popMessages ctx
@ -213,7 +220,6 @@ module Helpers =
}
open System
open JobsJobsJobs.Data
open JobsJobsJobs.ViewModels
@ -387,8 +393,7 @@ module Citizen =
do! addError "There is already an account registered to the e-mail address provided" ctx
return! refreshPage () next ctx
else
let errMsg = String.Join ("</li><li>", errors)
do! addError $"Please correct the following errors:<ul><li>{errMsg}</li></ul>" ctx
do! addErrors errors ctx
return! refreshPage () next ctx
}
@ -581,7 +586,7 @@ module Listing =
module Profile =
// 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! continents = Continents.all ()
let isNew = Option.isNone profile
@ -590,6 +595,72 @@ module Profile =
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
[<RequireQualifiedAccess>]
@ -624,37 +695,6 @@ module ProfileApi =
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
let employmentFound : HttpHandler = authorize >=> fun next ctx -> task {
match! Profiles.findById (currentCitizenId ctx) with
@ -756,8 +796,10 @@ let allEndpoints = [
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
subRoute "/profile" [
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 ]
@ -794,7 +836,6 @@ let allEndpoints = [
route "/search" ProfileApi.search
]
PATCH [ route "/employment-found" ProfileApi.employmentFound ]
POST [ route "" ProfileApi.save ]
]
subRoute "/success" [
GET_HEAD [

View File

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

View File

@ -54,7 +54,8 @@ let dashboard (citizen : Citizen) (profile : Profile option) profileCount =
div [ _class "card-footer" ] [
match profile with
| 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 "&nbsp; &nbsp;"
a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ rawText "Edit Profile" ]

View File

@ -2,7 +2,6 @@
[<RequireQualifiedAccess>]
module JobsJobsJobs.Views.Profile
open Giraffe
open Giraffe.ViewEngine
open Giraffe.ViewEngine.Htmx
open JobsJobsJobs.ViewModels
@ -10,45 +9,42 @@ open JobsJobsJobs.ViewModels
/// Render the skill edit template and existing skills
let skillEdit (skills : SkillForm array) =
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" ] [
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;"
]
]
div [ _class "col-10 col-md-6" ] [
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.)"
_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
div [ _class "form-text" ] [ rawText "A skill (language, design technique, process, etc.)" ]
]
div [ _class "col-12 col-md-5" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _id $"skillNotes{skill.Id}"; _name $"Skills[{idx}].Notes";
_class "form-control"; _maxlength "1000"
_placeholder "A further description of the skill (1,000 characters max)"
input [ _type "text"; _id $"skillNotes{idx}"; _name $"Skills[{idx}].Notes"; _class "form-control"
_maxlength "1000"; _placeholder "A further description of the skill (1,000 characters max)"
_value (defaultArg skill.Notes "") ]
label [ _class "jjj-label"; _for $"skillNotes{skill.Id}" ] [ rawText "Notes" ]
label [ _class "jjj-label"; _for $"skillNotes{idx}" ] [ rawText "Notes" ]
]
if idx < 1 then
div [ _class "form-text" ] [ rawText "A further description of the skill" ]
]
]
template [ _id "newSkill" ] [ mapToInputs -1 { Id = ""; Description = ""; Notes = None } ]
:: (skills
|> Array.mapi mapToInputs
|> List.ofArray)
template [ _id "newSkill" ] [ mapToInputs -1 { Description = ""; Notes = None } ]
:: (skills |> Array.mapi mapToInputs |> List.ofArray)
/// The profile edit page
let edit (m : EditProfileViewModel) continents isNew csrf =
article [] [
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
div [ _class "col-12" ] [
div [ _class "form-check" ] [
@ -100,7 +96,8 @@ let edit (m : EditProfileViewModel) continents isNew csrf =
hr []
h4 [ _class "pb-2" ] [
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"
]
]
@ -149,3 +146,49 @@ let edit (m : EditProfileViewModel) continents isNew csrf =
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
*/
addSkill() {
const newId = `new${this.nextIndex}`
const next = this.nextIndex
/** @type {HTMLTemplateElement} */
const newSkillTemplate = document.getElementById("newSkill")
/** @type {HTMLDivElement} */
const newSkill = newSkillTemplate.content.firstElementChild.cloneNode(true)
newSkill.setAttribute("id", `skillRow${newId}`)
newSkill.setAttribute("id", `skillRow${next}`)
const cols = newSkill.children
// Button column
cols[0].querySelector("button").setAttribute("onclick", `jjj.profile.removeSkill('${newId}')`)
cols[0].querySelector("button").setAttribute("onclick", `jjj.profile.removeSkill('${next}')`)
// Skill column
const skillField = cols[1].querySelector("input")
skillField.setAttribute("id", `skillDesc${newId}`)
skillField.setAttribute("id", `skillDesc${next}`)
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()
// Notes column
const notesField = cols[2].querySelector("input")
notesField.setAttribute("id", `skillNotes${newId}`)
notesField.setAttribute("id", `skillNotes${next}`)
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()
// Add the row
@ -179,7 +179,7 @@ this.jjj = {
/**
* 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) {
document.getElementById(`skillRow${id}`).remove()