WIP on profile edit page

- Delete migrated citizen views
This commit is contained in:
Daniel J. Summers 2023-01-10 09:02:00 -05:00
parent b74b871c2b
commit e5be1cb85d
12 changed files with 257 additions and 425 deletions

View File

@ -1,38 +0,0 @@
<template>
<article>
<h3 class="pb-3">Account Confirmation</h3>
<load-data :load="confirmToken">
<p v-if="isConfirmed">
Your account was confirmed successfully! You may <router-link to="/citizen/log-on">log on here</router-link>.
</p>
<p v-else>
The confirmation token did not match any pending accounts. Confirmation tokens are only valid for 3 days; if
the token expired, you will need to re-register,
which <router-link to="/citizen/register">you can do here</router-link>.
</p>
</load-data>
</article>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { useRoute } from "vue-router"
import api from "@/api"
import LoadData from "@/components/LoadData.vue"
const route = useRoute()
/** Whether the account was confirmed */
const isConfirmed = ref(false)
/** Confirm the account via the token */
const confirmToken = async (errors: string[]) => {
const resp = await api.citizen.confirmToken(route.params.token as string)
if (typeof resp === "string") {
errors.push(resp)
} else {
isConfirmed.value = resp
}
}
</script>

View File

@ -1,105 +0,0 @@
<template>
<article class="container">
<h3 class="pb-4">ITM, {{user.name}}!</h3>
<load-data :load="retrieveData">
<div class="row row-cols-1 row-cols-md-2">
<div class="col">
<div class="card h-100">
<h5 class="card-header">Your Profile</h5>
<div class="card-body">
<h6 v-if="profile" class="card-subtitle mb-3 text-muted fst-italic">
Last updated <full-date-time :date="profile.lastUpdatedOn" />
</h6>
<p v-if="profile" class="card-text">
Your profile currently lists {{profile.skills.length}}
skill<template v-if="profile.skills.length !== 1">s</template>.
<span v-if="profile.seekingEmployment">
<br><br>
Your profile indicates that you are seeking employment. Once you find it,
<router-link to="/success-story/add">tell your fellow citizens about it!</router-link>
</span>
</p>
<p class="card-text" v-else>
You do not have an employment profile established; click below (or &ldquo;Edit Profile&rdquo; in the
menu) to get started!
</p>
</div>
<div class="card-footer">
<template v-if="profile">
<router-link class="btn btn-outline-secondary"
:to="`/profile/${user.citizenId}/view`">View Profile</router-link>
&nbsp; &nbsp;
<router-link class="btn btn-outline-secondary" to="/profile/edit">Edit Profile</router-link>
</template>
<router-link class="btn btn-primary" v-else to="/profile/edit">Create Profile</router-link>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<h5 class="card-header">Other Citizens</h5>
<div class="card-body">
<h6 class="card-subtitle mb-3 text-muted fst-italic">
<template v-if="profileCount === 0">No&nbsp;</template>
<template v-else>{{profileCount}} Total&nbsp;</template>
Employment Profile<template v-if="profileCount !== 1">s</template>
</h6>
<p v-if="profileCount === 1 && profile" class="card-text">
It looks like, for now, it&rsquo;s just you&hellip;
</p>
<p v-else-if="profileCount > 0" class="card-text">
Take a look around and see if you can help them find work!
</p>
<p v-else class="card-text">
You can click below, but you will not find anything&hellip;
</p>
</div>
<div class="card-footer">
<router-link class="btn btn-outline-secondary" to="/profile/search">Search Profiles</router-link>
</div>
</div>
</div>
</div>
</load-data>
<p>&nbsp;</p>
<p>
To see how this application works, check out &ldquo;How It Works&rdquo; in the sidebar (last updated August
29<sup>th</sup>, 2021).
</p>
</article>
</template>
<script setup lang="ts">
import { Ref, ref } from "vue"
import api, { LogOnSuccess, Profile } from "@/api"
import { useStore } from "@/store"
import FullDateTime from "@/components/FullDateTime.vue"
import LoadData from "@/components/LoadData.vue"
const store = useStore()
/** The currently logged-in user */
const user = store.state.user as LogOnSuccess
/** The user's profile */
const profile : Ref<Profile | undefined> = ref(undefined)
/** A count of profiles in the system */
const profileCount = ref(0)
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") {
profile.value = profileResult
}
const count = await api.profile.count(user)
if (typeof count === "string") {
errors.push(count)
} else {
profileCount.value = count
}
}
</script>

View File

@ -1,37 +0,0 @@
<template>
<article>
<h3 class="pb-3">Account Deletion</h3>
<load-data :load="denyAccount">
<p v-if="isDeleted">
The account was deleted successfully; sorry for the trouble.
</p>
<p v-else>
The confirmation token did not match any pending accounts; if this was an inadvertently created account, it has
likely already been deleted.
</p>
</load-data>
</article>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { useRoute } from "vue-router"
import api from "@/api"
import LoadData from "@/components/LoadData.vue"
const route = useRoute()
/** Whether the account was deleted */
const isDeleted = ref(false)
/** Deny the account after confirming the token */
const denyAccount = async (errors: string[]) => {
const resp = await api.citizen.denyAccount(route.params.token as string)
if (typeof resp === "string") {
errors.push(resp)
} else {
isDeleted.value = resp
}
}
</script>

View File

@ -1,22 +0,0 @@
<template>
<article>
<p>&nbsp;</p>
<p class="fst-italic">Logging off&hellip;</p>
</article>
</template>
<script setup lang="ts">
import { onMounted } from "vue"
import { useRouter } from "vue-router"
import { toastSuccess } from "@/components/layout/AppToaster.vue"
import { useStore, Mutations } from "@/store"
const store = useStore()
const router = useRouter()
onMounted(async () => {
store.commit(Mutations.ClearUser)
toastSuccess("Log Off Successful &nbsp; | &nbsp; <strong>Have a Nice Day!</strong>")
await router.push("/")
})
</script>

View File

@ -1,92 +0,0 @@
<template>
<article>
<h3 class="pb-3">Log On</h3>
<p v-if="message !== ''" class="pb-3 text-center">
<span class="text-danger">{{message}}</span><br>
<template v-if="message.indexOf('ocked') > -1">
If this is a new account, it must be confirmed before it can be used; otherwise, you need to
<router-link to="/citizen/forgot-password">request an unlock code</router-link> before you may log on.
</template>
</p>
<form class="row g-3 pb-3">
<div class="col-12 col-md-6">
<div class="form-floating">
<div class="form-floating">
<input type="email" id="email" :class="{ 'form-control': true, 'is-invalid': v$.email.$error }"
v-model="v$.email.$model" placeholder="E-mail Address" autofocus>
<div class="invalid-feedback">Please enter a valid e-mail address</div>
<label class="jjj-required" for="email">E-mail Address</label>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="form-floating">
<input type="password" id="password" :class="{ 'form-control': true, 'is-invalid': v$.password.$error }"
v-model="v$.password.$model" placeholder="Password">
<div class="invalid-feedback">Please enter a password</div>
<label class="jjj-required" for="password">Password</label>
</div>
</div>
<div class="col-12">
<p class="text-danger" v-if="v$.$error">Please correct the errors above</p>
<button class="btn btn-primary" @click.prevent="logOn" :disabled="!logOnEnabled">
<icon :icon="mdiLogin" />&nbsp; Log On
</button>
</div>
</form>
<p class="text-center">Need an account? <router-link to="/citizen/register">Register for one!</router-link></p>
<p class="text-center">
Forgot your password? <router-link to="/citizen/forgot-password">Request a reset.</router-link>
</p>
</article>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "vue"
import { useRouter } from "vue-router"
import { mdiLogin } from "@mdi/js"
import useVuelidate from "@vuelidate/core"
import { email, required } from "@vuelidate/validators"
import { LogOnForm } from "@/api"
import { toastSuccess } from "@/components/layout/AppToaster.vue"
import { AFTER_LOG_ON_URL } from "@/router"
import { useStore, Actions } from "@/store"
const store = useStore()
const router = useRouter()
/** The form to log on to Jobs, Jobs, Jobs */
const logOnForm = reactive(new LogOnForm())
/** Whether the log on button is enabled */
const logOnEnabled = ref(true)
/** The message returned from the log on attempt */
const message = computed(() => store.state.logOnState)
/** Validation rules for the log on form */
const rules = computed(() => ({
email: { required, email },
password: { required }
}))
/** Form and validation */
const v$ = useVuelidate(rules, logOnForm, { $lazy: true })
/** Log the citizen on */
const logOn = async () => {
v$.value.$touch()
if (v$.value.$error) return
logOnEnabled.value = false
await store.dispatch(Actions.LogOn, { form: logOnForm })
logOnEnabled.value = true
if (store.state.user !== undefined) {
toastSuccess("Log On Successful")
v$.value.$reset()
const nextUrl = window.localStorage.getItem(AFTER_LOG_ON_URL) ?? "/citizen/dashboard"
window.localStorage.removeItem(AFTER_LOG_ON_URL)
await router.push(nextUrl)
}
}
</script>

View File

@ -1,105 +0,0 @@
<template>
<article>
<h3 class="pb-3">Register</h3>
<form class="row g-3" novalidate>
<div class="col-6 col-xl-4">
<div class="form-floating has-validation">
<input type="text" id="firstName" :class="{ 'form-control': true, 'is-invalid': v$.firstName.$error }"
v-model="v$.firstName.$model" placeholder="First Name">
<div class="invalid-feedback">Please enter your first name</div>
<label class="jjj-required" for="firstName">First Name</label>
</div>
</div>
<div class="col-6 col-xl-4">
<div class="form-floating">
<input type="text" id="lastName" :class="{ 'form-control': true, 'is-invalid': v$.lastName.$error }"
v-model="v$.lastName.$model" placeholder="Last Name">
<div class="invalid-feedback">Please enter your last name</div>
<label class="jjj-required" for="firstName">Last Name</label>
</div>
</div>
<div class="col-6 col-xl-4">
<div class="form-floating">
<input type="text" id="displayName" class="form-control" v-model="v$.displayName.$model"
placeholder="Display Name">
<label for="displayName">Display Name</label>
<div class="form-text"><em>Optional; overrides first/last for display</em></div>
</div>
</div>
<div class="col-6 col-xl-4">
<div class="form-floating">
<input type="email" id="email" :class="{ 'form-control': true, 'is-invalid': v$.email.$error }"
v-model="v$.email.$model" placeholder="E-mail Address">
<div class="invalid-feedback">Please enter a valid e-mail address</div>
<label class="jjj-required" for="email">E-mail Address</label>
</div>
</div>
<div class="col-6 col-xl-4">
<div class="form-floating">
<input type="password" id="password" :class="{ 'form-control': true, 'is-invalid': v$.password.$error }"
v-model="v$.password.$model" placeholder="Password">
<div class="invalid-feedback">Please enter a password at least 8 characters long</div>
<label class="jjj-required" for="password">Password</label>
</div>
</div>
<div class="col-6 col-xl-4">
<div class="form-floating">
<input type="password" id="confirmPassword"
:class="{ 'form-control': true, 'is-invalid': v$.confirmPassword.$error }"
v-model="v$.confirmPassword.$model" placeholder="Confirm Password">
<div class="invalid-feedback">The passwords do not match</div>
<label class="jjj-required" for="confirmPassword">Confirm Password</label>
</div>
</div>
<div class="col-12">
<p class="text-danger" v-if="v$.$error">Please correct the errors above</p>
<button class="btn btn-primary" @click.prevent="saveProfile">
<icon :icon="mdiContentSaveOutline" />&nbsp; Save
</button>
</div>
</form>
</article>
</template>
<script setup lang="ts">
import { computed, reactive } from "vue"
import { useRouter } from "vue-router"
import { mdiContentSaveOutline } from "@mdi/js"
import useVuelidate from "@vuelidate/core"
import { email, minLength, required, sameAs } from "@vuelidate/validators"
import api, { CitizenRegistrationForm } from "@/api"
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
const router = useRouter()
/** The information required to register a user */
const regForm = reactive(new CitizenRegistrationForm())
/** The validation rules for the form */
const rules = computed(() => ({
firstName: { required },
lastName: { required },
email: { required, email },
displayName: { },
password: { required, length: minLength(8) },
confirmPassword: { required, matchPassword: sameAs(regForm.password) }
}))
/** Initialize form validation */
const v$ = useVuelidate(rules, regForm, { $lazy: true })
/** Register the citizen */
const saveProfile = async () => {
v$.value.$touch()
if (v$.value.$error) return
const registerResult = await api.citizen.register(regForm)
if (typeof registerResult === "string") {
toastError(registerResult, "registering")
} else {
toastSuccess("Registered Successfully")
v$.value.$reset()
await router.push("/citizen/registered")
}
}
</script>

View File

@ -1,15 +0,0 @@
<template>
<article>
<h3 class="pb-3">Registration Successful</h3>
<p>
You have been successfully registered with Jobs, Jobs, Jobs. Check your e-mail for a confirmation link; it will
be valid for the next 72 hours (3 days). Once you confirm your account, you will be able to log on using the
e-mail address and password you provided.
</p>
<p>
If the account is not confirmed within the 72-hour window, it will be deleted, and you will need to register
again.
</p>
<p>If you encounter issues, feel free to reach out to @danieljsummers on No Agenda Social for assistance.</p>
</article>
</template>

View File

@ -54,7 +54,7 @@ module Error =
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response /// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
let notAuthorized : HttpHandler = fun next ctx -> let notAuthorized : HttpHandler = fun next ctx ->
if ctx.Request.Method = "GET" then if ctx.Request.Method = "GET" then
let redirectUrl = $"/user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}" let redirectUrl = $"/citizen/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}"
if isHtmx ctx then (withHxRedirect redirectUrl >=> redirectTo false redirectUrl) next ctx if isHtmx ctx then (withHxRedirect redirectUrl >=> redirectTo false redirectUrl) next ctx
else redirectTo false redirectUrl next ctx else redirectTo false redirectUrl next ctx
else else
@ -256,7 +256,7 @@ module Citizen =
// GET: /citizen/dashboard // GET: /citizen/dashboard
let dashboard = requireUser >=> fun next ctx -> task { let dashboard = requireUser >=> fun next ctx -> task {
let citizenId = CitizenId.ofString (tryUser ctx).Value let citizenId = currentCitizenId ctx
let! citizen = Citizens.findById citizenId let! citizen = Citizens.findById citizenId
let! profile = Profiles.findById citizenId let! profile = Profiles.findById citizenId
let! prfCount = Profiles.count () let! prfCount = Profiles.count ()
@ -561,10 +561,25 @@ module Listing =
} }
/// Handlers for /api/profile routes /// Handlers for /profile routes
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Profile = module Profile =
// GET: /profile/edit
let edit = requireUser >=> fun next ctx -> task {
let! profile = Profiles.findById (currentCitizenId ctx)
let! continents = Continents.all ()
let isNew = Option.isNone profile
let form = if isNew then EditProfileViewModel.empty else EditProfileViewModel.fromProfile profile.Value
let title = $"""{if isNew then "Create" else "Edit"} Profile"""
return! Profile.edit form continents isNew (csrf ctx) |> render title next ctx
}
/// Handlers for /api/profile routes
[<RequireQualifiedAccess>]
module ProfileApi =
// GET: /api/profile // GET: /api/profile
// This returns the current citizen's profile, or a 204 if it is not found (a citizen not having a profile yet // This returns the current citizen's profile, or a 204 if it is not found (a citizen not having a profile yet
// is not an error). The "get" handler returns a 404 if a profile is not found. // is not an error). The "get" handler returns a 404 if a profile is not found.
@ -724,7 +739,13 @@ let allEndpoints = [
] ]
GET_HEAD [ route "/how-it-works" Home.howItWorks ] GET_HEAD [ route "/how-it-works" Home.howItWorks ]
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ] GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
subRoute "/profile" [
GET_HEAD [
route "/edit" Profile.edit
]
]
GET_HEAD [ route "/terms-of-service" Home.termsOfService ] GET_HEAD [ route "/terms-of-service" Home.termsOfService ]
subRoute "/api" [ subRoute "/api" [
subRoute "/citizen" [ subRoute "/citizen" [
GET_HEAD [ routef "/%O" CitizenApi.get ] GET_HEAD [ routef "/%O" CitizenApi.get ]
@ -749,15 +770,15 @@ let allEndpoints = [
] ]
subRoute "/profile" [ subRoute "/profile" [
GET_HEAD [ GET_HEAD [
route "" Profile.current route "" ProfileApi.current
route "/count" Profile.count route "/count" ProfileApi.count
routef "/%O" Profile.get routef "/%O" ProfileApi.get
routef "/%O/view" Profile.view routef "/%O/view" ProfileApi.view
route "/public-search" Profile.publicSearch route "/public-search" ProfileApi.publicSearch
route "/search" Profile.search route "/search" ProfileApi.search
] ]
PATCH [ route "/employment-found" Profile.employmentFound ] PATCH [ route "/employment-found" ProfileApi.employmentFound ]
POST [ route "" Profile.save ] POST [ route "" ProfileApi.save ]
] ]
subRoute "/success" [ subRoute "/success" [
GET_HEAD [ GET_HEAD [

View File

@ -15,6 +15,7 @@
<Compile Include="Views\Layout.fs" /> <Compile Include="Views\Layout.fs" />
<Compile Include="Views\Citizen.fs" /> <Compile Include="Views\Citizen.fs" />
<Compile Include="Views\Home.fs" /> <Compile Include="Views\Home.fs" />
<Compile Include="Views\Profile.fs" />
<Compile Include="Handlers.fs" /> <Compile Include="Handlers.fs" />
<Compile Include="App.fs" /> <Compile Include="App.fs" />
</ItemGroup> </ItemGroup>

View File

@ -1,6 +1,87 @@
/// View models for Jobs, Jobs, Jobs /// View models for Jobs, Jobs, Jobs
module JobsJobsJobs.ViewModels module JobsJobsJobs.ViewModels
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
/// Notes regarding the skill
Notes : string option
}
/// The data required to update a profile
[<CLIMutable; NoComparison; NoEquality>]
type EditProfileViewModel =
{ /// Whether the citizen to whom this profile belongs is actively seeking employment
IsSeekingEmployment : bool
/// Whether this profile should appear in the public search
IsPublic : bool
/// The ID of the continent on which the citizen is located
ContinentId : string
/// The area within that continent where the citizen is located
Region : string
/// If the citizen is available for remote work
RemoteWork : bool
/// If the citizen is seeking full-time employment
FullTime : bool
/// The user's professional biography
Biography : string
/// The user's past experience
Experience : string option
/// The skills for the user
Skills : SkillForm list
}
/// Support functions for the ProfileForm type
module EditProfileViewModel =
/// An empty view model (used for new profiles)
let empty =
{ IsSeekingEmployment = false
IsPublic = false
ContinentId = ""
Region = ""
RemoteWork = false
FullTime = false
Biography = ""
Experience = None
Skills = [ { Id = ""; Description = ""; Notes = None } ]
}
/// Create an instance of this form from the given profile
let fromProfile (profile : Profile) =
{ IsSeekingEmployment = profile.IsSeekingEmployment
IsPublic = profile.IsPubliclySearchable
ContinentId = string profile.ContinentId
Region = profile.Region
RemoteWork = profile.IsRemote
FullTime = profile.IsFullTime
Biography = MarkdownString.toString profile.Biography
Experience = profile.Experience |> Option.map MarkdownString.toString
Skills = profile.Skills
|> List.map (fun s ->
{ Id = string s.Id
Description = s.Description
Notes = s.Notes
})
}
/// View model for the log on page /// View model for the log on page
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type LogOnViewModel = type LogOnViewModel =

View File

@ -2,7 +2,9 @@
module JobsJobsJobs.Views.Common module JobsJobsJobs.Views.Common
open Giraffe.ViewEngine open Giraffe.ViewEngine
open Giraffe.ViewEngine.Accessibility
open Microsoft.AspNetCore.Antiforgery open Microsoft.AspNetCore.Antiforgery
open JobsJobsJobs.Domain
/// Create an audio clip with the specified text node /// Create an audio clip with the specified text node
let audioClip clip text = let audioClip clip text =
@ -13,3 +15,34 @@ let audioClip clip text =
/// Create an anti-forgery hidden input /// Create an anti-forgery hidden input
let antiForgery (csrf : AntiforgeryTokenSet) = let antiForgery (csrf : AntiforgeryTokenSet) =
input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ] input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ]
/// Create a select list of continents
let continentList attrs name (continents : Continent list) emptyLabel selectedValue =
div [ _class "form-floating" ] [
select (List.append attrs [ _id name; _name name; _class "form-select" ]) (
option [ _value ""; if selectedValue = "" then _selected ] [
rawText $"""&ndash; {defaultArg emptyLabel "Select"} &ndash;""" ]
:: (continents
|> List.map (fun c ->
let theId = ContinentId.toString c.Id
option [ _value theId; if theId = selectedValue then _selected ] [ str c.Name ])))
label [ _class "jjj-required"; _for name ] [ rawText "Continent" ]
]
/// Create a Markdown editor
let markdownEditor attrs name value editorLabel =
div [ _class "col-12" ] [
nav [ _class "nav nav-pills pb-1" ] [
button [ _type "button"; _class "jjj-md-source"; _onclick "jjj.showMarkdown('TODO')" ] [
rawText "Markdown"
]; rawText " &nbsp; "
button [ _type "button"; _class "jjj-md-preview"; _onclick "jjj.showPreview('TODO')" ] [ rawText "Preview" ]
]
section [ _id $"{name}Preview"; _class "jjj-preview"; _ariaLabel "Rendered Markdown preview" ] []
div [ _class "form-floating" ] [
textarea (List.append attrs [ _id name; _name name; _class "form-control md-edit"; _rows "10" ]) [
rawText value
]
label [ _for name ] [ rawText editorLabel ]
]
]

View File

@ -0,0 +1,110 @@
/// Views for /profile URLs
[<RequireQualifiedAccess>]
module JobsJobsJobs.Views.Profile
open Giraffe
open Giraffe.ViewEngine
open Giraffe.ViewEngine.Htmx
open JobsJobsJobs.ViewModels
/// 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" ] [
antiForgery csrf
div [ _class "col-12" ] [
div [ _class "form-check" ] [
input [ _type "checkbox"; _id (nameof m.IsSeekingEmployment); _name (nameof m.IsSeekingEmployment)
_class "form-check-input"; if m.IsSeekingEmployment then _checked ]
label [ _class "form-check-label"; _for (nameof m.IsSeekingEmployment) ] [
rawText "I am currently seeking employment"
]
]
if m.IsSeekingEmployment then
p [] [
em [] [
rawText "If you have found employment, consider "
a [ _href "/success-story/new/edit" ] [ rawText "telling your fellow citizens about it!" ]
]
]
]
div [ _class "col-12 col-sm-6 col-md-4" ] [
continentList [ _required ] (nameof m.ContinentId) continents None m.ContinentId
]
div [ _class "col-12 col-sm-6 col-md-8" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _id (nameof m.Region); _name (nameof m.Region); _class "form-control"
_maxlength "255"; _placeholder "Country, state, geographic area, etc."; _required ]
label [ _class "jjj-required"; _for (m.Region) ] [ rawText "Region" ]
]
div [ _class "form-text" ] [ rawText "Country, state, geographic area, etc." ]
]
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
div [ _class "col-12 col-offset-md-2 col-md-4" ] [
div [ _class "form-check" ] [
input [ _type "checkbox"; _id (nameof m.RemoteWork); _name (nameof m.RemoteWork)
_class "form-check-input"; if m.RemoteWork then _checked ]
label [ _class "form-check-label"; _for (nameof m.RemoteWork) ] [
rawText "I am looking for remote work"
]
]
]
div [ _class "col-12 col-md-4" ] [
div [ _class "form-check" ] [
input [ _type "checkbox"; _id (nameof m.FullTime); _name (nameof m.FullTime)
_class "form-check-input"; if m.FullTime then _checked ]
label [ _class "form-check-label"; _for (nameof m.FullTime) ] [
rawText "I am looking for full-time work"
]
]
]
div [ _class "col-12" ] [
hr []
h4 [ _class "pb-2" ] [
rawText "Skills &nbsp; "
button [ _class "btn btn-sm btn-outline-primary rounded-pill"; _onclick "jjj.addSkill" ] [
rawText "Add a Skill"
]
]
]
//<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 [] [ rawText "Experience" ]
p [] [
rawText "This application does not have a place to individually list your chronological job "
rawText "history; however, you can use this area to list prior jobs, their dates, and anything "
rawText "else you want to include that&rsquo;s not already a part of your Professional Biography "
rawText "above."
]
]
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience"
div [ _class "col-12" ] [
div [ _class "form-check" ] [
input [ _type "checkbox"; _id (nameof m.IsPublic); _name (nameof m.IsPublic)
_class "form-check-input"; if m.IsPublic then _checked ]
label [ _class "form-check-label"; _for (nameof m.IsPublic) ] [
rawText "Allow my profile to be searched publicly"
]
]
]
div [ _class "col-12" ] [
button [ _type "submit"; _class "btn btn-primary" ] [
i [ _class "mdi mdi-content-save-outline" ] []; rawText "&nbsp; Save"
]
if not isNew then
rawText "&nbsp; &nbsp; "
a [ _class "btn btn-outline-secondary"; _href "`/profile/${user.citizenId}/view`" ] [
i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] []
rawText "&nbsp; View Your User Profile"
]
]
]
hr []
p [ _class "text-muted fst-italic" ] [
rawText "(If you want to delete your profile, or your entire account, "
a [ _href "/so-long/options" ] [ rawText "see your deletion options here" ]; rawText ".)"
]
]