Finish profile edit; add profile view
This commit is contained in:
parent
e4a86ab701
commit
1a4a8d1bba
@ -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",
|
||||
|
@ -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
|
||||
<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’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" /> Save
|
||||
</button>
|
||||
<template v-if="!isNew">
|
||||
|
||||
<router-link class="btn btn-outline-secondary" :to="`/profile/${user.citizenId}/view`">
|
||||
<icon color="#6c757d" :icon="mdiFileAccountOutline" /> 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>
|
@ -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">
|
||||
<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"> ({{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" /> 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(" • ")
|
||||
})
|
||||
|
||||
/** 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>
|
@ -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 ()
|
||||
]
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
@ -141,20 +141,14 @@ type OtherContact =
|
||||
}
|
||||
|
||||
|
||||
/// The ID of a skill
|
||||
type SkillId = SkillId of Guid
|
||||
|
||||
/// 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
|
||||
/// A skill the job seeker possesses
|
||||
type Skill =
|
||||
{ /// A description of the skill
|
||||
Description : string
|
||||
|
||||
/// Notes regarding this skill (level, duration, etc.)
|
||||
Notes : string option
|
||||
}
|
||||
|
||||
|
||||
/// The ID of a success report
|
||||
|
@ -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 =
|
||||
|
@ -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
|
||||
|
@ -69,6 +69,7 @@ module Error =
|
||||
clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message
|
||||
|
||||
|
||||
open System
|
||||
open NodaTime
|
||||
|
||||
/// Helper functions
|
||||
@ -170,6 +171,12 @@ module Helpers =
|
||||
let addError msg ctx = task {
|
||||
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 {
|
||||
@ -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 [
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
@ -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 " "
|
||||
a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ rawText "Edit Profile" ]
|
||||
|
@ -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 " − "
|
||||
]
|
||||
]
|
||||
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 "
|
||||
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 " "; 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 " • "
|
||||
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 " ("; 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 " Edit Your Profile"
|
||||
]
|
||||
]
|
||||
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user