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==",
"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": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
@ -12853,6 +12869,11 @@
"@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": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-7.8.0.tgz",

View File

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

View File

@ -5,6 +5,7 @@ import {
Count,
LogOnSuccess,
Profile,
ProfileForm,
ProfileForView,
ProfileSearch,
ProfileSearchResult,
@ -28,13 +29,22 @@ const apiUrl = (url : string) : string => `http://localhost:5000/api/${url}`
* @param user The currently logged-on user
* @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()
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 {
headers,
method
// mode: 'cors'
}
}
@ -51,6 +61,18 @@ async function apiResult<T> (resp : Response, action : string) : Promise<T | und
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
*
@ -156,6 +178,15 @@ export default {
retreiveForView: async (id : string, user : LogOnSuccess) : Promise<ProfileForView | string | undefined> =>
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
*

View File

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

View File

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

View File

@ -3,97 +3,94 @@
<page-title title="Edit Profile" />
<h3>Employment Profile</h3>
<load-data :load="retrieveData">
<form>
<div class="row pb-3">
<div class="col col-xs-12 col-sm-10 col-md-8 col-lg-6">
<div class="form-floating">
<input type="text" id="realName" class="form-control" v-model="profile.realName" maxlength="255"
placeholder="Leave blank to use your NAS display name">
<label for="realName">Real Name</label>
</div>
<div class="form-text">Leave blank to use your NAS display name</div>
<form class="row g-3">
<div class="col-12 col-sm-10 col-md-8 col-lg-6">
<div class="form-floating">
<input type="text" id="realName" class="form-control" v-model="profile.realName" maxlength="255"
placeholder="Leave blank to use your NAS display name">
<label for="realName">Real Name</label>
</div>
<div class="form-text">Leave blank to use your NAS display name</div>
</div>
<div class="col-12">
<div class="form-check">
<input type="checkbox" class="form-check-input" v-model="profile.seekingEmployment">
<label class="form-check-label">I am currently seeking employment</label>
</div>
<p v-if="profile?.seekingEmployment">
<em>If you have found employment, consider <router-link to="/success-story/add">telling your fellow
citizens about it!</router-link></em>
</p>
</div>
<div class="col-12 col-sm-6 col-md-4">
<div class="form-floating">
<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>
</select>
<label for="continentId" class="jjj-required">Continent</label>
</div>
<div class="invalid-feedback">Please select a continent</div>
</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 id="regionFeedback" class="invalid-feedback">Please enter a region</div>
<label for="region" class="jjj-required">Region</label>
</div>
<div class="form-text">Country, state, geographic area, etc.</div>
</div>
<markdown-editor id="bio" label="Professional Biography" v-model:text="profile.biography"
: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="profile.remoteWork">
<label class="form-check-label" for="isRemote">I am looking for remote work</label>
</div>
</div>
<div class="row pb-3">
<div class="col col-xs-12">
<div class="form-check">
<input type="checkbox" class="form-check-input" v-model="profile.seekingEmployment">
<label class="form-check-label">I am currently seeking employment</label>
</div>
<p v-if="profile?.seekingEmployment">
<em>If you have found employment, consider <router-link to="/success-story/add">telling your fellow
citizens about it!</router-link></em>
</p>
<div class="col-12 col-md-4">
<div class="form-check">
<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>
</div>
</div>
<div class="row pb-3">
<div class="col col-xs-12 col-sm-6 col-md-4">
<div class="form-floating">
<select id="continentId" class="form-select" :value="profile.continentId">
<option v-for="c in continents" :key="c.id" :value="c.id">{{c.name}}</option>
</select>
<label for="continentId" class="jjj-required">Continent</label>
</div>
</div>
<div class="col col-xs-12 col-sm-6 col-md-8">
<div class="form-floating">
<input type="text" id="region" class="form-control" v-model="profile.region" maxlength="255"
placeholder="Country, state, geographic area, etc.">
<label for="region" class="jjj-required">Region</label>
</div>
<div class="form-text">Country, state, geographic area, etc.</div>
</div>
<div class="col-12">
<hr>
<h4 class="pb-2">
Skills &nbsp;
<button class="btn btn-sm btn-outline-primary rounded-pill" @click.prevent="addSkill">Add a Skill</button>
</h4>
</div>
<markdown-editor id="bio" label="Professional Biography" v-model:text="profile.biography" />
<div class="row pb-3">
<div class="col col-xs-12 col-offset-md-2 col-md-4">
<div class="form-check">
<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>
</div>
</div>
<div class="col col-xs-12 col-md-4">
<div class="form-check">
<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>
</div>
</div>
</div>
<hr>
<h4 class="pb-2">
Skills &nbsp;
<button class="btn btn-sm btn-outline-primary rounded-pill" @click.prevent="addSkill">Add a Skill</button>
</h4>
<profile-skill-edit v-for="(skill, idx) in profile.skills" :key="skill.id" v-model="profile.skills[idx]"
@remove="removeSkill(skill.id)" />
<hr>
<h4>Experience</h4>
<p>
This application does not have a place to individually list your chronological job history; however, you can
use this area to list prior jobs, their dates, and anything else you want to include that&rsquo;s not
already a part of your Professional Biography above.
</p>
<div class="col-12">
<hr>
<h4>Experience</h4>
<p>
This application does not have a place to individually list your chronological job history; however, you can
use this area to list prior jobs, their dates, and anything else you want to include that&rsquo;s not
already a part of your Professional Biography above.
</p>
</div>
<markdown-editor id="experience" label="Experience" v-model:text="profile.experience" />
<div class="row pb-3">
<div class="col col-xs-12">
<div class="form-check">
<input type="checkbox" id="isPublic" class="form-check-input" v-model="profile.isPublic">
<label class="form-check-label" for="isPublic">
Allow my profile to be searched publicly (outside NA Social)
</label>
</div>
<div class="col-12">
<div class="form-check">
<input type="checkbox" id="isPublic" class="form-check-input" v-model="profile.isPublic">
<label class="form-check-label" for="isPublic">
Allow my profile to be searched publicly (outside NA Social)
</label>
</div>
</div>
<div class="row pt-3">
<div class="col col-xs-12">
<button class="btn btn-primary">Save</button>
<template v-if="!isNew">
&nbsp; &nbsp;
<button class="btn btn-outline-secondary" @click.prevent="viewProfile">
<icon icon="file-account-outline" />&nbsp; View Your User Profile
</button>
</template>
</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">Save</button>
<template v-if="!isNew">
&nbsp; &nbsp;
<button class="btn btn-outline-secondary" @click.prevent="viewProfile">
<icon icon="file-account-outline" />&nbsp; View Your User Profile
</button>
</template>
</div>
</form>
</load-data>
@ -106,13 +103,16 @@
</template>
<script lang="ts">
import { computed, defineComponent, Ref, ref } from 'vue'
import { computed, defineComponent, ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import useVuelidate from '@vuelidate/core'
import { required } from '@vuelidate/validators'
import api, { Citizen, LogOnSuccess, Profile, ProfileForm } from '@/api'
import { useStore } from '@/store'
import LoadData from '@/components/LoadData.vue'
import MarkdownEditor from '@/components/MarkdownEditor.vue'
import { toastError, toastSuccess } from '@/components/layout/AppToaster.vue'
import ProfileSkillEdit from '@/components/profile/SkillEdit.vue'
export default defineComponent({
@ -148,7 +148,17 @@ export default defineComponent({
}
/** 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 */
const retrieveData = async (errors : string[]) => {
@ -164,45 +174,55 @@ export default defineComponent({
errors.push(nameResult)
}
if (errors.length > 0) return
// Update the empty form with appropriate values
const p = isNew.value ? newProfile : profileResult as Profile
profile.value = {
isSeekingEmployment: p.seekingEmployment,
isPublic: p.isPublic,
continentId: p.continentId,
region: p.region,
remoteWork: p.remoteWork,
fullTime: p.fullTime,
biography: p.biography,
experience: p.experience,
skills: p.skills,
realName: typeof nameResult !== 'undefined' ? (nameResult as Citizen).realName || '' : ''
}
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
profile.realName = typeof nameResult !== 'undefined' ? (nameResult as Citizen).realName || '' : ''
}
/** The ID for new skills */
let newSkillId = 0
/** Add a skill to the profile */
const addSkill = () => {
const form = profile.value as ProfileForm
form.skills.push({ id: `new${newSkillId}`, description: '', notes: undefined })
newSkillId++
profile.value = form
}
const addSkill = () => { profile.skills.push({ id: `new${newSkillId++}`, description: '', notes: undefined }) }
/** Remove the given skill from the profile */
const removeSkill = (skillId : string) => {
const form = profile.value as ProfileForm
form.skills = form.skills.filter(s => s.id !== skillId)
profile.value = form
}
const removeSkill = (skillId : string) => { profile.skills = profile.skills.filter(s => s.id !== skillId) }
/** Save the current profile values */
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 {
v$,
retrieveData,
user,
isNew,
@ -211,7 +231,7 @@ export default defineComponent({
addSkill,
removeSkill,
saveProfile,
viewProfile: () => router.push(`/profile/view/${user.citizenId}`)
viewProfile
}
}
})