WIP on validate/save profile

This commit is contained in:
Daniel J. Summers 2021-08-07 22:56:37 -04:00
parent 058c5e5b77
commit bd97a3828f
6 changed files with 208 additions and 134 deletions

View File

@ -2589,6 +2589,22 @@
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==", "integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
"dev": true "dev": true
}, },
"@vuelidate/core": {
"version": "2.0.0-alpha.22",
"resolved": "https://registry.npmjs.org/@vuelidate/core/-/core-2.0.0-alpha.22.tgz",
"integrity": "sha512-HqRWY2c5pW6kxHNCupYyQn1+frjiFjEN6qgP0AwByvSu/JdKHDWlWqC2gJoxPiT3Oy23ulge43jVzHT3UO1r4A==",
"requires": {
"vue-demi": "^0.9.1"
}
},
"@vuelidate/validators": {
"version": "2.0.0-alpha.19",
"resolved": "https://registry.npmjs.org/@vuelidate/validators/-/validators-2.0.0-alpha.19.tgz",
"integrity": "sha512-5TQxi1Wa6jFPYZ4UIUI8RKR2nqN/yujPxRjUMh5vCOhVXoqXPMfoYdfs5DBtfABhmjj7LTtXrZ78WVMQkL9tNw==",
"requires": {
"vue-demi": "^0.9.1"
}
},
"@webassemblyjs/ast": { "@webassemblyjs/ast": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
@ -12853,6 +12869,11 @@
"@vue/shared": "3.1.4" "@vue/shared": "3.1.4"
} }
}, },
"vue-demi": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.9.1.tgz",
"integrity": "sha512-7s1lufRD2l369eFWPjgLvhqCRk0XzGWJsQc7K4q+0mZtixyGIvsK1Cg88P4NcaRIEiBuuN4q1NN4SZKFKwQswA=="
},
"vue-eslint-parser": { "vue-eslint-parser": {
"version": "7.8.0", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-7.8.0.tgz", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-7.8.0.tgz",

View File

@ -10,6 +10,8 @@
}, },
"dependencies": { "dependencies": {
"@mdi/font": "5.9.55", "@mdi/font": "5.9.55",
"@vuelidate/core": "^2.0.0-alpha.22",
"@vuelidate/validators": "^2.0.0-alpha.19",
"bootstrap": "^5.1.0", "bootstrap": "^5.1.0",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"date-fns": "^2.23.0", "date-fns": "^2.23.0",

View File

@ -5,6 +5,7 @@ import {
Count, Count,
LogOnSuccess, LogOnSuccess,
Profile, Profile,
ProfileForm,
ProfileForView, ProfileForView,
ProfileSearch, ProfileSearch,
ProfileSearchResult, ProfileSearchResult,
@ -28,13 +29,22 @@ const apiUrl = (url : string) : string => `http://localhost:5000/api/${url}`
* @param user The currently logged-on user * @param user The currently logged-on user
* @returns RequestInit parameters * @returns RequestInit parameters
*/ */
const reqInit = (method : string, user : LogOnSuccess) : RequestInit => { // eslint-disable-next-line
const reqInit = (method : string, user : LogOnSuccess, body : any | undefined = undefined) : RequestInit => {
const headers = new Headers() const headers = new Headers()
headers.append('Authorization', `Bearer ${user.jwt}`) headers.append('Authorization', `Bearer ${user.jwt}`)
if (body) {
headers.append('Content-Type', 'application/json')
return {
headers,
method,
cache: 'no-cache',
body: JSON.stringify(body)
}
}
return { return {
headers, headers,
method method
// mode: 'cors'
} }
} }
@ -51,6 +61,18 @@ async function apiResult<T> (resp : Response, action : string) : Promise<T | und
return `Error ${action} - ${await resp.text()}` return `Error ${action} - ${await resp.text()}`
} }
/**
* Send an update via the API
*
* @param resp The response received from the API
* @param action The action being performed (used in error messages)
* @returns True (if the response is a success) or an error string
*/
async function apiSend (resp : Response, action : string) : Promise<boolean | string> {
if (resp.status === 200) return true
return `Error ${action} - (${resp.status}) ${await resp.text()}`
}
/** /**
* Run an API action that does not return a result * Run an API action that does not return a result
* *
@ -156,6 +178,15 @@ export default {
retreiveForView: async (id : string, user : LogOnSuccess) : Promise<ProfileForView | string | undefined> => retreiveForView: async (id : string, user : LogOnSuccess) : Promise<ProfileForView | string | undefined> =>
apiResult<ProfileForView>(await fetch(apiUrl(`profile/view/${id}`), reqInit('GET', user)), 'retrieving profile'), apiResult<ProfileForView>(await fetch(apiUrl(`profile/view/${id}`), reqInit('GET', user)), 'retrieving profile'),
/**
* Save a user's profile data
*
* @param data The profile data to be saved
* @param user The currently logged-on user
*/
save: async (data : ProfileForm, user : LogOnSuccess) : Promise<boolean | string> =>
apiSend(await fetch(apiUrl('profile/save'), reqInit('POST', user, data)), 'saving profile'),
/** /**
* Search for profiles using the given parameters * Search for profiles using the given parameters
* *

View File

@ -78,27 +78,27 @@ export interface Profile {
} }
/** The data required to update a profile */ /** The data required to update a profile */
export interface ProfileForm { export class ProfileForm {
/** Whether the citizen to whom this profile belongs is actively seeking employment */ /** Whether the citizen to whom this profile belongs is actively seeking employment */
isSeekingEmployment : boolean isSeekingEmployment = false
/** Whether this profile should appear in the public search */ /** Whether this profile should appear in the public search */
isPublic : boolean isPublic = false
/** The user's real name */ /** The user's real name */
realName : string realName = ''
/** The ID of the continent on which the citizen is located */ /** The ID of the continent on which the citizen is located */
continentId : string continentId = ''
/** The area within that continent where the citizen is located */ /** The area within that continent where the citizen is located */
region : string region = ''
/** If the citizen is available for remote work */ /** If the citizen is available for remote work */
remoteWork : boolean remoteWork = false
/** If the citizen is seeking full-time employment */ /** If the citizen is seeking full-time employment */
fullTime : boolean fullTime = false
/** The user's professional biography */ /** The user's professional biography */
biography : string biography = ''
/** The user's past experience */ /** The user's past experience */
experience : string | undefined experience : string | undefined
/** The skills for the user */ /** The skills for the user */
skills : Skill[] skills : Skill[] = []
} }
/** The data required to show a viewable profile */ /** The data required to show a viewable profile */

View File

@ -1,6 +1,5 @@
<template> <template>
<div class="row pb-3"> <div class="col-12">
<div class="col col-xs-12">
<nav class="nav nav-pills pb-1"> <nav class="nav nav-pills pb-1">
<button :class="sourceClass" @click.prevent="showMarkdown">Markdown</button> &nbsp; <button :class="sourceClass" @click.prevent="showMarkdown">Markdown</button> &nbsp;
<button :class="previewClass" @click.prevent="showPreview">Preview</button> <button :class="previewClass" @click.prevent="showPreview">Preview</button>
@ -8,12 +7,12 @@
<section v-if="preview" class="preview" v-html="previewHtml"> <section v-if="preview" class="preview" v-html="previewHtml">
</section> </section>
<div v-else class="form-floating"> <div v-else class="form-floating">
<textarea :id="id" class="form-control md-edit" rows="10" v-text="text" <textarea :id="id" :class="{ 'form-control': true, 'md-edit': true, 'is-invalid': isInvalid }" rows="10"
@input="$emit('update:text', $event.target.value)"></textarea> v-text="text" @input="$emit('update:text', $event.target.value)"></textarea>
<div class="invalid-feedback">Please enter some text for {{label}}</div>
<label :for="id">{{label}}</label> <label :for="id">{{label}}</label>
</div> </div>
</div> </div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -35,7 +34,8 @@ export default defineComponent({
label: { label: {
type: String, type: String,
required: true required: true
} },
isInvalid: { type: Boolean }
}, },
emits: ['update:text'], emits: ['update:text'],
setup (props) { setup (props) {

View File

@ -3,9 +3,8 @@
<page-title title="Edit Profile" /> <page-title title="Edit Profile" />
<h3>Employment Profile</h3> <h3>Employment Profile</h3>
<load-data :load="retrieveData"> <load-data :load="retrieveData">
<form> <form class="row g-3">
<div class="row pb-3"> <div class="col-12 col-sm-10 col-md-8 col-lg-6">
<div class="col col-xs-12 col-sm-10 col-md-8 col-lg-6">
<div class="form-floating"> <div class="form-floating">
<input type="text" id="realName" class="form-control" v-model="profile.realName" maxlength="255" <input type="text" id="realName" class="form-control" v-model="profile.realName" maxlength="255"
placeholder="Leave blank to use your NAS display name"> placeholder="Leave blank to use your NAS display name">
@ -13,9 +12,7 @@
</div> </div>
<div class="form-text">Leave blank to use your NAS display name</div> <div class="form-text">Leave blank to use your NAS display name</div>
</div> </div>
</div> <div class="col-12">
<div class="row pb-3">
<div class="col col-xs-12">
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" v-model="profile.seekingEmployment"> <input type="checkbox" class="form-check-input" v-model="profile.seekingEmployment">
<label class="form-check-label">I am currently seeking employment</label> <label class="form-check-label">I am currently seeking employment</label>
@ -25,47 +22,49 @@
citizens about it!</router-link></em> citizens about it!</router-link></em>
</p> </p>
</div> </div>
</div> <div class="col-12 col-sm-6 col-md-4">
<div class="row pb-3">
<div class="col col-xs-12 col-sm-6 col-md-4">
<div class="form-floating"> <div class="form-floating">
<select id="continentId" class="form-select" :value="profile.continentId"> <select id="continentId" :class="{ 'form-select': true, 'is-invalid': v$.continentId.$error }"
:value="v$.continentId.$model">
<option v-for="c in continents" :key="c.id" :value="c.id">{{c.name}}</option> <option v-for="c in continents" :key="c.id" :value="c.id">{{c.name}}</option>
</select> </select>
<label for="continentId" class="jjj-required">Continent</label> <label for="continentId" class="jjj-required">Continent</label>
</div> </div>
<div class="invalid-feedback">Please select a continent</div>
</div> </div>
<div class="col col-xs-12 col-sm-6 col-md-8"> <div class="col-12 col-sm-6 col-md-8">
<div class="form-floating"> <div class="form-floating">
<input type="text" id="region" class="form-control" v-model="profile.region" maxlength="255" <input type="text" id="region" :class="{ 'form-control': true, 'is-invalid': v$.region.$error }"
placeholder="Country, state, geographic area, etc."> v-model="v$.region.$model" maxlength="255" placeholder="Country, state, geographic area, etc.">
<div id="regionFeedback" class="invalid-feedback">Please enter a region</div>
<label for="region" class="jjj-required">Region</label> <label for="region" class="jjj-required">Region</label>
</div> </div>
<div class="form-text">Country, state, geographic area, etc.</div> <div class="form-text">Country, state, geographic area, etc.</div>
</div> </div>
</div> <markdown-editor id="bio" label="Professional Biography" v-model:text="profile.biography"
<markdown-editor id="bio" label="Professional Biography" v-model:text="profile.biography" /> :isInvalid="v$.biography.$error" />
<div class="row pb-3"> <div class="col-12 col-offset-md-2 col-md-4">
<div class="col col-xs-12 col-offset-md-2 col-md-4">
<div class="form-check"> <div class="form-check">
<input type="checkbox" id="isRemote" class="form-check-input" v-model="profile.remoteWork"> <input type="checkbox" id="isRemote" class="form-check-input" v-model="profile.remoteWork">
<label class="form-check-label" for="isRemote">I am looking for remote work</label> <label class="form-check-label" for="isRemote">I am looking for remote work</label>
</div> </div>
</div> </div>
<div class="col col-xs-12 col-md-4"> <div class="col-12 col-md-4">
<div class="form-check"> <div class="form-check">
<input type="checkbox" id="isFullTime" class="form-check-input" v-model="profile.fullTime"> <input type="checkbox" id="isFullTime" class="form-check-input" v-model="profile.fullTime">
<label class="form-check-label" for="isFullTime">I am looking for full-time work</label> <label class="form-check-label" for="isFullTime">I am looking for full-time work</label>
</div> </div>
</div> </div>
</div> <div class="col-12">
<hr> <hr>
<h4 class="pb-2"> <h4 class="pb-2">
Skills &nbsp; Skills &nbsp;
<button class="btn btn-sm btn-outline-primary rounded-pill" @click.prevent="addSkill">Add a Skill</button> <button class="btn btn-sm btn-outline-primary rounded-pill" @click.prevent="addSkill">Add a Skill</button>
</h4> </h4>
</div>
<profile-skill-edit v-for="(skill, idx) in profile.skills" :key="skill.id" v-model="profile.skills[idx]" <profile-skill-edit v-for="(skill, idx) in profile.skills" :key="skill.id" v-model="profile.skills[idx]"
@remove="removeSkill(skill.id)" /> @remove="removeSkill(skill.id)" />
<div class="col-12">
<hr> <hr>
<h4>Experience</h4> <h4>Experience</h4>
<p> <p>
@ -73,9 +72,9 @@
use this area to list prior jobs, their dates, and anything else you want to include that&rsquo;s not use this area to list prior jobs, their dates, and anything else you want to include that&rsquo;s not
already a part of your Professional Biography above. already a part of your Professional Biography above.
</p> </p>
</div>
<markdown-editor id="experience" label="Experience" v-model:text="profile.experience" /> <markdown-editor id="experience" label="Experience" v-model:text="profile.experience" />
<div class="row pb-3"> <div class="col-12">
<div class="col col-xs-12">
<div class="form-check"> <div class="form-check">
<input type="checkbox" id="isPublic" class="form-check-input" v-model="profile.isPublic"> <input type="checkbox" id="isPublic" class="form-check-input" v-model="profile.isPublic">
<label class="form-check-label" for="isPublic"> <label class="form-check-label" for="isPublic">
@ -83,10 +82,9 @@
</label> </label>
</div> </div>
</div> </div>
</div> <div class="col-12">
<div class="row pt-3"> <p v-if="v$.$error" class="text-danger">Please correct the errors above</p>
<div class="col col-xs-12"> <button class="btn btn-primary" @click.prevent="saveProfile">Save</button>
<button class="btn btn-primary">Save</button>
<template v-if="!isNew"> <template v-if="!isNew">
&nbsp; &nbsp; &nbsp; &nbsp;
<button class="btn btn-outline-secondary" @click.prevent="viewProfile"> <button class="btn btn-outline-secondary" @click.prevent="viewProfile">
@ -94,7 +92,6 @@
</button> </button>
</template> </template>
</div> </div>
</div>
</form> </form>
</load-data> </load-data>
<hr> <hr>
@ -106,13 +103,16 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, Ref, ref } from 'vue' import { computed, defineComponent, ref, reactive } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import useVuelidate from '@vuelidate/core'
import { required } from '@vuelidate/validators'
import api, { Citizen, LogOnSuccess, Profile, ProfileForm } from '@/api' import api, { Citizen, LogOnSuccess, Profile, ProfileForm } from '@/api'
import { useStore } from '@/store' import { useStore } from '@/store'
import LoadData from '@/components/LoadData.vue' import LoadData from '@/components/LoadData.vue'
import MarkdownEditor from '@/components/MarkdownEditor.vue' import MarkdownEditor from '@/components/MarkdownEditor.vue'
import { toastError, toastSuccess } from '@/components/layout/AppToaster.vue'
import ProfileSkillEdit from '@/components/profile/SkillEdit.vue' import ProfileSkillEdit from '@/components/profile/SkillEdit.vue'
export default defineComponent({ export default defineComponent({
@ -148,7 +148,17 @@ export default defineComponent({
} }
/** The user's current profile (plus a few items, adapted for editing) */ /** The user's current profile (plus a few items, adapted for editing) */
const profile : Ref<ProfileForm | undefined> = ref(undefined) const profile = reactive(new ProfileForm())
/** The validation rules for the form */
const rules = {
continentId: { required },
region: { required },
biography: { required }
}
/** Initialize form validation */
const v$ = useVuelidate(rules, profile /*, { $lazy: true } */)
/** Retrieve the user's profile and their real name */ /** Retrieve the user's profile and their real name */
const retrieveData = async (errors : string[]) => { const retrieveData = async (errors : string[]) => {
@ -164,45 +174,55 @@ export default defineComponent({
errors.push(nameResult) errors.push(nameResult)
} }
if (errors.length > 0) return if (errors.length > 0) return
// Update the empty form with appropriate values
const p = isNew.value ? newProfile : profileResult as Profile const p = isNew.value ? newProfile : profileResult as Profile
profile.value = { profile.isSeekingEmployment = p.seekingEmployment
isSeekingEmployment: p.seekingEmployment, profile.isPublic = p.isPublic
isPublic: p.isPublic, profile.continentId = p.continentId
continentId: p.continentId, profile.region = p.region
region: p.region, profile.remoteWork = p.remoteWork
remoteWork: p.remoteWork, profile.fullTime = p.fullTime
fullTime: p.fullTime, profile.biography = p.biography
biography: p.biography, profile.experience = p.experience
experience: p.experience, profile.skills = p.skills
skills: p.skills, profile.realName = typeof nameResult !== 'undefined' ? (nameResult as Citizen).realName || '' : ''
realName: typeof nameResult !== 'undefined' ? (nameResult as Citizen).realName || '' : ''
}
} }
/** The ID for new skills */ /** The ID for new skills */
let newSkillId = 0 let newSkillId = 0
/** Add a skill to the profile */ /** Add a skill to the profile */
const addSkill = () => { const addSkill = () => { profile.skills.push({ id: `new${newSkillId++}`, description: '', notes: undefined }) }
const form = profile.value as ProfileForm
form.skills.push({ id: `new${newSkillId}`, description: '', notes: undefined })
newSkillId++
profile.value = form
}
/** Remove the given skill from the profile */ /** Remove the given skill from the profile */
const removeSkill = (skillId : string) => { const removeSkill = (skillId : string) => { profile.skills = profile.skills.filter(s => s.id !== skillId) }
const form = profile.value as ProfileForm
form.skills = form.skills.filter(s => s.id !== skillId)
profile.value = form
}
/** Save the current profile values */ /** Save the current profile values */
const saveProfile = async () => { const saveProfile = async () => {
// TODO 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 Successfuly')
v$.value.$reset()
}
}
/** View the profile, prompting for save if data has changed */
const viewProfile = async () => {
alert(v$.value.$dirty)
if (v$.value.$dirty && confirm('There are unsaved changes; save before viewing?')) {
await saveProfile()
router.push(`/profile/view/${user.citizenId}`)
}
} }
return { return {
v$,
retrieveData, retrieveData,
user, user,
isNew, isNew,
@ -211,7 +231,7 @@ export default defineComponent({
addSkill, addSkill,
removeSkill, removeSkill,
saveProfile, saveProfile,
viewProfile: () => router.push(`/profile/view/${user.citizenId}`) viewProfile
} }
} }
}) })