Version 3 #40
|
@ -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",
|
||||||
|
|
|
@ -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 (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 ()
|
||||||
]
|
]
|
||||||
|
|
|
@ -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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 =
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 [
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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 " "
|
]; rawText " "
|
||||||
a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ rawText "Edit Profile" ]
|
a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ rawText "Edit Profile" ]
|
||||||
|
|
|
@ -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 " − "
|
rawText " − "
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
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 "
|
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"
|
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 " "; 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
|
* 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()
|
||||||
|
|
Loading…
Reference in New Issue
Block a user