Migration now at feature parity (plus)

This commit is contained in:
Daniel J. Summers 2021-08-09 22:22:58 -04:00
parent 2922ced971
commit f8ea4f4e8d
13 changed files with 286 additions and 46 deletions

View File

@ -54,7 +54,7 @@ html
a:link, a:link,
a:visited a:visited
text-decoration: none text-decoration: none
a:hover a:not(.btn):hover
text-decoration: underline text-decoration: underline
label.jjj-required::after label.jjj-required::after
color: red color: red

View File

@ -3,6 +3,7 @@ import {
Citizen, Citizen,
Continent, Continent,
Count, Count,
Listing,
LogOnSuccess, LogOnSuccess,
Profile, Profile,
ProfileForm, ProfileForm,
@ -135,6 +136,19 @@ export default {
apiResult<Continent[]>(await fetch(apiUrl('continent/all'), { method: 'GET' }), 'retrieving continents') apiResult<Continent[]>(await fetch(apiUrl('continent/all'), { method: 'GET' }), 'retrieving continents')
}, },
/** API functions for job listings */
listings: {
/**
* Retrieve the job listings posted by the current citizen
*
* @param user The currently logged-on user
* @returns The job listings the user has posted, or an error string
*/
mine: async (user : LogOnSuccess) : Promise<Listing[] | string | undefined> =>
apiResult<Listing[]>(await fetch(apiUrl('listings/mine'), reqInit('GET', user)), 'retrieving your job listings')
},
/** API functions for profiles */ /** API functions for profiles */
profile: { profile: {

View File

@ -31,6 +31,34 @@ export interface Count {
count : number count : number
} }
/** A job listing */
export interface Listing {
/** The ID of the job listing */
id : string
/** The ID of the citizen who posted the job listing */
citizenId : string
/** When this job listing was created (date) */
createdOn : string
/** The short title of the job listing */
title : string
/** The ID of the continent on which the job is located */
continentId : string
/** The region in which the job is located */
region : string
/** Whether this listing is for remote work */
remoteWork : boolean
/** Whether this listing has expired */
isExpired : boolean
/** When this listing was last updated (date) */
updatedOn : string
/** The details of this job */
text : string
/** When this job needs to be filled (date) */
neededBy : string | undefined
/** Was this job filled as part of its appearance on Jobs, Jobs, Jobs? */
wasFilledHere : boolean | undefined
}
/** A successful logon */ /** A successful logon */
export interface LogOnSuccess { export interface LogOnSuccess {
/** The JSON Web Token (JWT) to use for API access */ /** The JSON Web Token (JWT) to use for API access */

View File

@ -0,0 +1,92 @@
<template>
<div class="modal fade" id="maybeSaveModal" tabindex="-1" aria-labelledby="maybeSaveLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="maybeSaveLabel">Unsaved Changes</h5>
</div>
<div class="modal-body">
You have modified the data on this page since it was last saved. What would you like to do?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click.prevent="onStay">Stay on This Page</button>
<button type="button" class="btn btn-primary" @click.prevent="onSave">Save Changes</button>
<button type="button" class="btn btn-danger" @click.prevent="onDiscard">Discard Changes</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, ref, Ref, watch } from 'vue'
import { RouteLocationNormalized, useRouter } from 'vue-router'
import { Validation } from '@vuelidate/core'
import { Modal } from 'bootstrap'
export default defineComponent({
name: 'MaybeSave',
props: {
isShown: {
type: Boolean,
required: true
},
toRoute: {
// Can't type this because it's not filled until just before the modal is shown
required: true
},
saveAction: {
type: Function
},
validator: {
type: Object
}
},
emits: ['close', 'discard', 'cancel'],
setup (props, { emit }) {
const router = useRouter()
/** The route where we tried to go */
const newRoute = computed(() => props.toRoute as RouteLocationNormalized)
/** Reference to the modal dialog (we can't get it until the component is rendered) */
const modal : Ref<Modal | undefined> = ref(undefined)
/** Save changes (if required) and go to the next route */
const onSave = async () => {
if (props.saveAction) await Promise.resolve(props.saveAction())
emit('close')
router.push(newRoute.value)
}
/** Discard changes (if required) and go to the next route */
const onDiscard = () => {
if (props.validator) (props.validator as Validation).$reset()
emit('close')
router.push(newRoute.value)
}
onMounted(() => {
modal.value = new Modal(document.getElementById('maybeSaveModal') as HTMLElement,
{ backdrop: 'static', keyboard: false })
})
/** Show or hide the modal based on the property value changing */
watch(() => props.isShown, (toShow) => {
if (modal.value) {
if (toShow) {
modal.value.show()
} else {
modal.value.hide()
}
}
})
return {
onStay: () => emit('close'),
onSave,
onDiscard
}
}
})
</script>

View File

@ -72,9 +72,20 @@ const routes: Array<RouteRecordRaw> = [
name: 'LogOff', name: 'LogOff',
component: () => import(/* webpackChunkName: "logoff" */ '../views/citizen/LogOff.vue') component: () => import(/* webpackChunkName: "logoff" */ '../views/citizen/LogOff.vue')
}, },
// Job Listing URLs
{
path: '/listing/:id/edit',
name: 'EditListing',
component: () => import(/* webpackChunkName: "jobedit" */ '../views/listing/ListingEdit.vue')
},
{
path: '/listings/mine',
name: 'MyListings',
component: () => import(/* webpackChunkName: "joblist" */ '../views/listing/MyListings.vue')
},
// Profile URLs // Profile URLs
{ {
path: '/profile/view/:id', path: '/profile/:id/view',
name: 'ViewProfile', name: 'ViewProfile',
component: () => import(/* webpackChunkName: "profview" */ '../views/profile/ProfileView.vue') component: () => import(/* webpackChunkName: "profview" */ '../views/profile/ProfileView.vue')
}, },
@ -106,12 +117,12 @@ const routes: Array<RouteRecordRaw> = [
component: () => import(/* webpackChunkName: "success" */ '../views/success-story/StoryList.vue') component: () => import(/* webpackChunkName: "success" */ '../views/success-story/StoryList.vue')
}, },
{ {
path: '/success-story/edit/:id', path: '/success-story/:id/edit',
name: 'EditStory', name: 'EditStory',
component: () => import(/* webpackChunkName: "succedit" */ '../views/success-story/StoryEdit.vue') component: () => import(/* webpackChunkName: "succedit" */ '../views/success-story/StoryEdit.vue')
}, },
{ {
path: '/success-story/view/:id', path: '/success-story/:id/view',
name: 'ViewStory', name: 'ViewStory',
component: () => import(/* webpackChunkName: "success" */ '../views/success-story/StoryView.vue') component: () => import(/* webpackChunkName: "success" */ '../views/success-story/StoryView.vue')
} }

View File

@ -27,10 +27,11 @@
</div> </div>
<div class="card-footer"> <div class="card-footer">
<template v-if="profile"> <template v-if="profile">
<button class="btn btn-outline-secondary" @click="viewProfile">View Profile</button> &nbsp; &nbsp; <router-link class="btn btn-outline-secondary"
<button class="btn btn-outline-secondary" @click="editProfile">Edit Profile</button> :to="`/profile/${user.citizenId}/view`">View Profile</router-link> &nbsp; &nbsp;
<router-link class="btn btn-outline-secondary" to="/citizen/profile">Edit Profile</router-link>
</template> </template>
<button v-else class="btn btn-primary" @click="editProfile">Create Profile</button> <router-link v-else class="btn btn-primary" to="/citizen/profile">Create Profile</router-link>
</div> </div>
</div> </div>
</div> </div>
@ -53,7 +54,7 @@
</p> </p>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<button class="btn btn-outline-secondary" @click="searchProfiles">Search Profiles</button> <router-link class="btn btn-outline-secondary" to="/profile/search">Search Profiles</router-link>
</div> </div>
</div> </div>
</div> </div>
@ -69,7 +70,6 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, Ref, ref } from 'vue' import { defineComponent, Ref, ref } from 'vue'
import { useRouter } from 'vue-router'
import api, { LogOnSuccess, Profile } from '@/api' import api, { LogOnSuccess, Profile } from '@/api'
import { useStore } from '@/store' import { useStore } from '@/store'
@ -84,7 +84,6 @@ export default defineComponent({
}, },
setup () { setup () {
const store = useStore() const store = useStore()
const router = useRouter()
/** The currently logged-in user */ /** The currently logged-in user */
const user = store.state.user as LogOnSuccess const user = store.state.user as LogOnSuccess
@ -114,10 +113,7 @@ export default defineComponent({
retrieveData, retrieveData,
user, user,
profile, profile,
profileCount, profileCount
viewProfile: () => router.push(`/profile/view/${user.citizenId}`),
editProfile: () => router.push('/citizen/profile'),
searchProfiles: () => router.push('/profile/search')
} }
} }
}) })

View File

@ -18,7 +18,7 @@
<label for="isSeeking" class="form-check-label">I am currently seeking employment</label> <label for="isSeeking" class="form-check-label">I am currently seeking employment</label>
</div> </div>
<p v-if="profile.isSeekingEmployment"> <p v-if="profile.isSeekingEmployment">
<em>If you have found employment, consider <router-link to="/success-story/edit/new">telling your fellow <em>If you have found employment, consider <router-link to="/success-story/new/edit">telling your fellow
citizens about it!</router-link></em> citizens about it!</router-link></em>
</p> </p>
</div> </div>
@ -84,12 +84,14 @@
</div> </div>
<div class="col-12"> <div class="col-12">
<p v-if="v$.$error" class="text-danger">Please correct the errors above</p> <p v-if="v$.$error" class="text-danger">Please correct the errors above</p>
<button class="btn btn-primary" @click.prevent="saveProfile">Save</button> <button class="btn btn-primary" @click.prevent="saveProfile">
<icon icon="content-save-outline" />&nbsp; Save
</button>
<template v-if="!isNew"> <template v-if="!isNew">
&nbsp; &nbsp; &nbsp; &nbsp;
<button class="btn btn-outline-secondary" @click.prevent="viewProfile"> <router-link class="btn btn-outline-secondary" :to="`/profile/${user.citizenId}/view`">
<icon icon="file-account-outline" />&nbsp; View Your User Profile <icon icon="file-account-outline" />&nbsp; View Your User Profile
</button> </router-link>
</template> </template>
</div> </div>
</form> </form>
@ -99,20 +101,24 @@
(If you want to delete your profile, or your entire account, <router-link to="/so-long/options">see your deletion (If you want to delete your profile, or your entire account, <router-link to="/so-long/options">see your deletion
options here</router-link>.) options here</router-link>.)
</p> </p>
<maybe-save :isShown="confirmNavShown" :toRoute="nextRoute" :saveAction="saveProfile" :validator="v$"
@close="confirmClose" />
</article> </article>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, reactive } from 'vue' import { computed, defineComponent, ref, reactive, Ref } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router' import { onBeforeRouteLeave, RouteLocationNormalized } from 'vue-router'
import useVuelidate from '@vuelidate/core' import useVuelidate from '@vuelidate/core'
import { required } from '@vuelidate/validators' import { required } from '@vuelidate/validators'
import api, { Citizen, LogOnSuccess, Profile, ProfileForm } from '@/api' import api, { Citizen, LogOnSuccess, Profile, ProfileForm } from '@/api'
import { toastError, toastSuccess } from '@/components/layout/AppToaster.vue'
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 MaybeSave from '@/components/MaybeSave.vue'
import ProfileSkillEdit from '@/components/profile/SkillEdit.vue' import ProfileSkillEdit from '@/components/profile/SkillEdit.vue'
export default defineComponent({ export default defineComponent({
@ -120,11 +126,11 @@ export default defineComponent({
components: { components: {
LoadData, LoadData,
MarkdownEditor, MarkdownEditor,
MaybeSave,
ProfileSkillEdit ProfileSkillEdit
}, },
setup () { setup () {
const store = useStore() const store = useStore()
const router = useRouter()
/** The currently logged-on user */ /** The currently logged-on user */
const user = store.state.user as LogOnSuccess const user = store.state.user as LogOnSuccess
@ -240,13 +246,17 @@ export default defineComponent({
} }
} }
/** Whether the navigation confirmation is shown */
const confirmNavShown = ref(false)
/** The "next" route (will be navigated or cleared) */
const nextRoute : Ref<RouteLocationNormalized | undefined> = ref(undefined)
/** If the user has unsaved changes, give them an opportunity to save before moving on */ /** If the user has unsaved changes, give them an opportunity to save before moving on */
onBeforeRouteLeave(async (to, from) => { // eslint-disable-line onBeforeRouteLeave(async (to, from) => { // eslint-disable-line
if (!v$.value.$anyDirty) return true if (!v$.value.$anyDirty) return true
if (confirm('There are unsaved changes; save before viewing?')) { nextRoute.value = to
await saveProfile() confirmNavShown.value = true
return true
}
return false return false
}) })
@ -261,7 +271,9 @@ export default defineComponent({
addSkill, addSkill,
removeSkill, removeSkill,
saveProfile, saveProfile,
viewProfile: () => router.push(`/profile/view/${user.citizenId}`) confirmNavShown,
nextRoute,
confirmClose: () => { confirmNavShown.value = false }
} }
} }
}) })

View File

@ -0,0 +1,3 @@
<template>
<p>TODO: placeholder</p>
</template>

View File

@ -0,0 +1,70 @@
<template>
<article>
<page-title title="My Job Listings" />
<h3 class="pb-3">My Job Listings</h3>
<p>
<router-link class="btn btn-primary-outline" to="/listing/new/edit">Add a New Job Listing</router-link>
</p>
<load-data :load="getListings">
<table v-if="listings.length > 0">
<thead>
<tr>
<th>Action</th>
<th>Title</th>
<th>Created</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
<tr v-for="listing in listings" :key="listing.id">
<td><router-link :to="`/listing/${listing.Id}/edit`">Edit</router-link></td>
<td>{{listing.Title}}</td>
<td><full-date-time :date="listing.createdOn" /></td>
<td><full-date-time :date="listing.updatedOn" /></td>
</tr>
</tbody>
</table>
<p v-else class="fst-italic">No job listings found</p>
</load-data>
</article>
</template>
<script lang="ts">
import { defineComponent, Ref, ref } from 'vue'
import api, { Listing, LogOnSuccess } from '@/api'
import { useStore } from '@/store'
import FullDateTime from '@/components/FullDateTime.vue'
import LoadData from '@/components/LoadData.vue'
export default defineComponent({
name: 'MyListings',
components: {
FullDateTime,
LoadData
},
setup () {
const store = useStore()
/** The listings for the user */
const listings : Ref<Listing[]> = ref([])
/** Retrieve the job listing posted by the current citizen */
const getListings = async (errors : string[]) => {
const listResult = await api.listings.mine(store.state.user as LogOnSuccess)
if (typeof listResult === 'string') {
errors.push(listResult)
} else if (typeof listResult === 'undefined') {
errors.push('API call returned 404 (this should not happen)')
} else {
listings.value = listResult
}
}
return {
getListings,
listings
}
}
})
</script>

View File

@ -25,7 +25,7 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="profile in results" :key="profile.citzenId"> <tr v-for="profile in results" :key="profile.citzenId">
<td><router-link :to="`/profile/view/${profile.citizenId}`">View</router-link></td> <td><router-link :to="`/profile/${profile.citizenId}/view`">View</router-link></td>
<td :class="{ 'font-weight-bold' : profile.seekingEmployment }">{{profile.displayName}}</td> <td :class="{ 'font-weight-bold' : profile.seekingEmployment }">{{profile.displayName}}</td>
<td class="text-center">{{yesOrNo(profile.seekingEmployment)}}</td> <td class="text-center">{{yesOrNo(profile.seekingEmployment)}}</td>
<td class="text-center">{{yesOrNo(profile.remoteWork)}}</td> <td class="text-center">{{yesOrNo(profile.remoteWork)}}</td>

View File

@ -26,7 +26,9 @@
<template v-if="user.citizenId === it.citizen.id"> <template v-if="user.citizenId === it.citizen.id">
<br><br> <br><br>
<button class="btn btn-primary" @click="editProfile"><icon icon="pencil" />&nbsp; Edit Your Profile</button> <router-link class="btn btn-primary" to="/citizen/profile">
<icon icon="pencil" />&nbsp; Edit Your Profile
</router-link>
</template> </template>
</load-data> </load-data>
</article> </article>
@ -34,7 +36,7 @@
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, Ref } from 'vue' import { computed, defineComponent, ref, Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import marked from 'marked' import marked from 'marked'
import api, { LogOnSuccess, markedOptions, ProfileForView } from '@/api' import api, { LogOnSuccess, markedOptions, ProfileForView } from '@/api'
import { useStore } from '@/store' import { useStore } from '@/store'
@ -46,7 +48,6 @@ export default defineComponent({
setup () { setup () {
const store = useStore() const store = useStore()
const route = useRoute() const route = useRoute()
const router = useRouter()
/** The currently logged-on user */ /** The currently logged-on user */
const user = store.state.user as LogOnSuccess const user = store.state.user as LogOnSuccess
@ -96,8 +97,7 @@ export default defineComponent({
workTypes, workTypes,
citizenName, citizenName,
bioHtml: computed(() => marked(it.value?.profile.biography || '', markedOptions)), bioHtml: computed(() => marked(it.value?.profile.biography || '', markedOptions)),
expHtml: computed(() => marked(it.value?.profile.experience || '', markedOptions)), expHtml: computed(() => marked(it.value?.profile.experience || '', markedOptions))
editProfile: () => router.push('/citizen/profile')
} }
} }
}) })

View File

@ -17,19 +17,23 @@
</div> </div>
<markdown-editor id="story" label="The Success Story" v-model:text="v$.story.$model" /> <markdown-editor id="story" label="The Success Story" v-model:text="v$.story.$model" />
<div class="col-12"> <div class="col-12">
<button type="submit" class="btn btn-primary" @click.prevent="saveStory(true)"><icon icon="" /> Save</button> <button type="submit" class="btn btn-primary" @click.prevent="saveStory(true)">
<icon icon="content-save-outline" />&nbsp; Save
</button>
<p v-if="isNew"> <p v-if="isNew">
<em>(Saving this will set &ldquo;Seeking Employment&rdquo; to &ldquo;No&rdquo; on your profile.)</em> <em>(Saving this will set &ldquo;Seeking Employment&rdquo; to &ldquo;No&rdquo; on your profile.)</em>
</p> </p>
</div> </div>
</form> </form>
</load-data> </load-data>
<maybe-save :isShown="confirmNavShown" :toRoute="nextRoute" :saveAction="doSave" :validator="v$"
@close="confirmClose" />
</article> </article>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, reactive } from 'vue' import { computed, defineComponent, reactive, ref, Ref } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import { onBeforeRouteLeave, RouteLocationNormalized, useRoute, useRouter } from 'vue-router'
import useVuelidate from '@vuelidate/core' import useVuelidate from '@vuelidate/core'
import api, { LogOnSuccess, StoryForm } from '@/api' import api, { LogOnSuccess, StoryForm } from '@/api'
import { toastError, toastSuccess } from '@/components/layout/AppToaster.vue' import { toastError, toastSuccess } from '@/components/layout/AppToaster.vue'
@ -37,12 +41,14 @@ 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 MaybeSave from '@/components/MaybeSave.vue'
export default defineComponent({ export default defineComponent({
name: 'StoryEdit', name: 'StoryEdit',
components: { components: {
LoadData, LoadData,
MarkdownEditor MarkdownEditor,
MaybeSave
}, },
setup () { setup () {
const store = useStore() const store = useStore()
@ -105,28 +111,32 @@ export default defineComponent({
toastError(foundResult, 'clearing employment flag') toastError(foundResult, 'clearing employment flag')
} else { } else {
toastSuccess('Success Story saved and Seeking Employment flag cleared successfully') toastSuccess('Success Story saved and Seeking Employment flag cleared successfully')
v$.value.$reset()
if (navigate) { if (navigate) {
router.push('/success-story/list') router.push('/success-story/list')
v$.value.$reset()
} }
} }
} else { } else {
toastSuccess('Success Story saved successfully') toastSuccess('Success Story saved successfully')
v$.value.$reset()
if (navigate) { if (navigate) {
router.push('/success-story/list') router.push('/success-story/list')
v$.value.$reset()
} }
} }
} }
} }
/** Whether the navigation confirmation is shown */
const confirmNavShown = ref(false)
/** The "next" route (will be navigated or cleared) */
const nextRoute : Ref<RouteLocationNormalized | undefined> = ref(undefined)
/** Prompt for save if the user navigates away with unsaved changes */ /** Prompt for save if the user navigates away with unsaved changes */
onBeforeRouteLeave(async (to, from) => { // eslint-disable-line onBeforeRouteLeave(async (to, from) => { // eslint-disable-line
if (!v$.value.$anyDirty) return true if (!v$.value.$anyDirty) return true
if (confirm('There are unsaved changes; save before leaving?')) { nextRoute.value = to
await saveStory(false) confirmNavShown.value = true
return true
}
return false return false
}) })
@ -135,7 +145,11 @@ export default defineComponent({
isNew, isNew,
retrieveStory, retrieveStory,
v$, v$,
saveStory saveStory,
confirmNavShown,
nextRoute,
doSave: async () => await saveStory(false),
confirmClose: () => { confirmNavShown.value = false }
} }
} }
}) })

View File

@ -15,10 +15,10 @@
<tbody> <tbody>
<tr v-for="story in stories" :key="story.id"> <tr v-for="story in stories" :key="story.id">
<td> <td>
<router-link v-if="story.hasStory" :to="`/success-story/view/${story.id}`">View</router-link> <router-link v-if="story.hasStory" :to="`/success-story/${story.id}/view`">View</router-link>
<em v-else>None</em> <em v-else>None</em>
<template v-if="story.citizenId === user.citizenId"> <template v-if="story.citizenId === user.citizenId">
~ <router-link :to="`/success-story/edit/${story.id}`">Edit</router-link> ~ <router-link :to="`/success-story/${story.id}/edit`">Edit</router-link>
</template> </template>
</td> </td>
<td>{{story.citizenName}}</td> <td>{{story.citizenName}}</td>