Active request page (#16)

Also:
- refactored snoozed and answered list pages to use a common list format
- reworked the URLs to make them more consistent
- eliminated current "full" API endpoint, and renamed existing "complete" endpoint to "full"
This commit is contained in:
Daniel J. Summers 2018-08-17 22:12:14 -05:00
parent d3aff4a110
commit e351fe5b56
16 changed files with 261 additions and 245 deletions

View File

@ -267,7 +267,7 @@ type AppDbContext (opts : DbContextOptions<AppDbContext>) =
} }
/// Retrieve a request, including its history and notes, by its ID and user ID /// Retrieve a request, including its history and notes, by its ID and user ID
member this.TryCompleteRequestById requestId userId = member this.TryFullRequestById requestId userId =
task { task {
match! this.TryJournalById requestId userId with match! this.TryJournalById requestId userId with
| Some req -> | Some req ->
@ -281,18 +281,3 @@ type AppDbContext (opts : DbContextOptions<AppDbContext>) =
| None -> return None | None -> return None
| None -> return None | None -> return None
} }
/// Retrieve a request, including its history, by its ID and user ID
member this.TryFullRequestById requestId userId =
task {
match! this.TryJournalById requestId userId with
| Some req ->
let! fullReq =
this.Requests.AsNoTracking()
.Include(fun r -> r.history)
.FirstOrDefaultAsync(fun r -> r.requestId = requestId && r.userId = userId)
match toOption fullReq with
| Some _ -> return Some { req with history = List.ofSeq fullReq.history }
| None -> return None
| None -> return None
}

View File

@ -26,7 +26,7 @@ module Error =
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
let notFound : HttpHandler = let notFound : HttpHandler =
fun next ctx -> fun next ctx ->
[ "/answered"; "/journal"; "/legal"; "/request"; "/snoozed"; "/user" ] [ "/journal"; "/legal"; "/request"; "/user" ]
|> List.filter ctx.Request.Path.Value.StartsWith |> List.filter ctx.Request.Path.Value.StartsWith
|> List.length |> List.length
|> function |> function
@ -236,16 +236,6 @@ module Request =
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
/// GET /api/request/[req-id]/complete
let getComplete reqId : HttpHandler =
authorize
>=> fun next ctx ->
task {
match! (db ctx).TryCompleteRequestById reqId (userId ctx) with
| Some req -> return! json req next ctx
| None -> return! Error.notFound next ctx
}
/// GET /api/request/[req-id]/full /// GET /api/request/[req-id]/full
let getFull reqId : HttpHandler = let getFull reqId : HttpHandler =
authorize authorize

View File

@ -59,7 +59,6 @@ module Configure =
route "journal" Handlers.Journal.journal route "journal" Handlers.Journal.journal
subRoute "request" [ subRoute "request" [
route "s/answered" Handlers.Request.answered route "s/answered" Handlers.Request.answered
routef "/%s/complete" Handlers.Request.getComplete
routef "/%s/full" Handlers.Request.getFull routef "/%s/full" Handlers.Request.getFull
routef "/%s/notes" Handlers.Request.getNotes routef "/%s/notes" Handlers.Request.getNotes
routef "/%s" Handlers.Request.get routef "/%s" Handlers.Request.get

View File

@ -106,6 +106,12 @@ a:hover {
.mpj-request-text { .mpj-request-text {
white-space: pre-line; white-space: pre-line;
} }
.mpj-request-list p {
border-top: solid 1px lightgray;
}
.mpj-request-list p:first-child {
border-top: none;
}
.mpj-request-log { .mpj-request-log {
width: 100%; width: 100%;
} }

View File

@ -1,3 +1,5 @@
'use strict'
import axios from 'axios' import axios from 'axios'
const http = axios.create({ const http = axios.create({
@ -39,7 +41,7 @@ export default {
getAnsweredRequests: () => http.get('requests/answered'), getAnsweredRequests: () => http.get('requests/answered'),
/** /**
* Get a prayer request (full; includes all history) * Get a prayer request (full; includes all history and notes)
* @param {string} requestId The Id of the request to retrieve * @param {string} requestId The Id of the request to retrieve
*/ */
getFullRequest: requestId => http.get(`request/${requestId}/full`), getFullRequest: requestId => http.get(`request/${requestId}/full`),
@ -56,11 +58,6 @@ export default {
*/ */
getRequest: requestId => http.get(`request/${requestId}`), getRequest: requestId => http.get(`request/${requestId}`),
/**
* Get a complete request; equivalent of "full" and "notes" combined
*/
getRequestComplete: requestId => http.get(`request/${requestId}/complete`),
/** /**
* Get all prayer requests and their most recent updates * Get all prayer requests and their most recent updates
*/ */

View File

@ -1,86 +0,0 @@
<template lang="pug">
article.mpj-main-content(role='main')
page-title(title='Answered Request')
template(v-if='request')
p.
Answered {{ formatDate(answered) }} (#[date-from-now(:value='answered')]) &nbsp;
#[small: em.mpj-muted-text prayed {{ prayedCount }} times, open {{ openDays }} days]
p.mpj-request-text {{ lastText }}
br
table.mpj-request-log
thead
tr
th Action
th Update / Notes
tbody
tr(v-for='item in log' :key='item.asOf')
td {{ item.status }} on #[span.mpj-text-nowrap {{ formatDate(item.asOf) }}]
td(v-if='item.text').mpj-request-text {{ item.text.fields[0] }}
td(v-else) &nbsp;
p(v-else) Loading request...
</template>
<script>
'use strict'
import moment from 'moment'
import api from '@/api'
const asOfDesc = (a, b) => b.asOf - a.asOf
export default {
name: 'answer-detail',
props: {
id: {
type: String,
required: true
}
},
data () {
return {
request: null
}
},
computed: {
answered () {
return this.request.history.find(hist => hist.status === 'Answered').asOf
},
lastText () {
return this.request.history
.filter(hist => hist.text)
.sort(asOfDesc)[0].text.fields[0]
},
log () {
return (this.request.notes || [])
.map(note => ({ asOf: note.asOf, text: { case: 'Some', fields: [ note.notes ] }, status: 'Notes' }))
.concat(this.request.history)
.sort(asOfDesc)
.slice(1)
},
openDays () {
return Math.floor(
(this.answered - this.request.history.find(hist => hist.status === 'Created').asOf) / 1000 / 60 / 60 / 24)
},
prayedCount () {
return this.request.history.filter(hist => hist.status === 'Prayed').length
}
},
async mounted () {
this.$Progress.start()
try {
const req = await api.getRequestComplete(this.id)
this.request = req.data
this.$Progress.finish()
} catch (e) {
console.log(e)
this.$Progress.fail()
}
},
methods: {
formatDate (asOf) {
return moment(asOf).format('LL')
}
}
}
</script>

View File

@ -67,5 +67,6 @@ export default {
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
justify-content: center; justify-content: center;
align-items: flex-start;
} }
</style> </style>

View File

@ -8,11 +8,14 @@ nav.mpj-top-nav.mpj-bg(role='menubar')
router-link(v-if='isAuthenticated' router-link(v-if='isAuthenticated'
:to="{ name: 'Journal' }" :to="{ name: 'Journal' }"
role='menuitem') Journal role='menuitem') Journal
router-link(v-if='isAuthenticated'
:to="{ name: 'ActiveRequests' }"
role='menuitem') Active
router-link(v-if='hasSnoozed' router-link(v-if='hasSnoozed'
:to="{ name: 'Snoozed' }" :to="{ name: 'SnoozedRequests' }"
role='menuitem') Snoozed role='menuitem') Snoozed
router-link(v-if='isAuthenticated' router-link(v-if='isAuthenticated'
:to="{ name: 'Answered' }" :to="{ name: 'AnsweredRequests' }"
role='menuitem') Answered role='menuitem') Answered
a(v-if='isAuthenticated' a(v-if='isAuthenticated'
href='#' href='#'

View File

@ -0,0 +1,63 @@
<template lang="pug">
article.mpj-main-content(role='main')
page-title(title='Active Requests')
div(v-if='loaded').mpj-request-list
p.mpj-text-center(v-if='requests.length === 0'): em.
No active requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal]
request-list-item(v-for='req in requests'
:key='req.requestId'
:request='req')
p(v-else) Loading journal...
</template>
<script>
'use strict'
import { mapState } from 'vuex'
import RequestListItem from '@/components/request/RequestListItem'
import actions from '@/store/action-types'
export default {
name: 'active-requests',
components: {
RequestListItem
},
data () {
return {
requests: [],
loaded: false
}
},
computed: {
toast () {
return this.$parent.$refs.toast
},
...mapState(['journal', 'isLoadingJournal'])
},
methods: {
async ensureJournal () {
if (!Array.isArray(this.journal)) {
this.loaded = false
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
}
this.requests = this.journal
.sort((a, b) => a.showAfter - b.showAfter)
this.loaded = true
},
async cancelSnooze (requestId) {
await this.$store.dispatch(actions.SNOOZE_REQUEST, {
progress: this.$Progress,
requestId: requestId,
until: 0
})
this.toast.showToast('Request un-snoozed', { theme: 'success' })
this.ensureJournal()
}
},
async mounted () {
await this.ensureJournal()
}
}
</script>

View File

@ -1,30 +1,27 @@
<template lang="pug"> <template lang="pug">
article.mpj-main-content(role='main') article.mpj-main-content(role='main')
page-title(title='Answered Requests') page-title(title='Answered Requests')
div(v-if='loaded').mpj-answered-list div(v-if='loaded').mpj-request-list
p.text-center(v-if='requests.length === 0'): em. p.text-center(v-if='requests.length === 0'): em.
No answered requests found; once you have marked one as &ldquo;Answered&rdquo;, it will appear here No answered requests found; once you have marked one as &ldquo;Answered&rdquo;, it will appear here
p.mpj-request-text(v-for='req in requests' :key='req.requestId') request-list-item(v-for='req in requests'
| {{ req.text }} :key='req.requestId'
br :request='req')
br
router-link(:to='{ name: "AnsweredDetail", params: { id: req.requestId }}'
role='button'
title='View Full Request')
md-icon(icon='description')
= ' View Full Request'
small.mpj-muted-text: em.
&nbsp; Answered #[date-from-now(:value='req.asOf')]
p(v-else) Loading answered requests... p(v-else) Loading answered requests...
</template> </template>
<script> <script>
'use static' 'use strict'
import api from '@/api' import api from '@/api'
import RequestListItem from '@/components/request/RequestListItem'
export default { export default {
name: 'answered', name: 'answered-requests',
components: {
RequestListItem
},
data () { data () {
return { return {
requests: [], requests: [],
@ -52,12 +49,3 @@ export default {
} }
} }
</script> </script>
<style>
.mpj-answered-list p {
border-top: solid 1px lightgray;
}
.mpj-answered-list p:first-child {
border-top: none;
}
</style>

View File

@ -1,56 +1,89 @@
<template lang="pug"> <template lang="pug">
span article.mpj-main-content(role='main')
b-modal(v-model='historyVisible' page-title(title='Full Prayer Request')
header-bg-variant='mpj' template(v-if='request')
header-text-variant='light' p
size='lg' span(v-if='isAnswered') Answered {{ formatDate(answered) }} (#[date-from-now(:value='answered')]) &nbsp;
title='Prayer Request History' small: em.mpj-muted-text prayed {{ prayedCount }} times, open {{ openDays }} days
@shows='focusRequestText') p.mpj-request-text {{ lastText }}
b-list-group(v-if='null !== full' br
flush) table.mpj-request-log
full-request-history(v-for='item in full.history' thead
:key='item.asOf' tr
:history='item') th Action
div.w-100.text-right(slot='modal-footer') th Update / Notes
b-btn(variant='primary' tbody
@click='closeDialog()') Close tr(v-for='item in log' :key='item.asOf')
td {{ item.status }} on #[span.mpj-text-nowrap {{ formatDate(item.asOf) }}]
td(v-if='item.text').mpj-request-text {{ item.text.fields[0] }}
td(v-else) &nbsp;
p(v-else) Loading request...
</template> </template>
<script> <script>
'use strict' 'use strict'
import FullRequestHistory from './FullRequestHistory' import moment from 'moment'
import api from '@/api' import api from '@/api'
const asOfDesc = (a, b) => b.asOf - a.asOf
export default { export default {
name: 'full-request', name: 'full-request',
components: {
FullRequestHistory
},
props: { props: {
events: { required: true } id: {
type: String,
required: true
}
}, },
data () { data () {
return { return {
historyVisible: false, request: null
full: null
} }
}, },
created () { computed: {
this.events.$on('full', this.openDialog) answered () {
return this.request.history.find(hist => hist.status === 'Answered').asOf
},
isAnswered () {
return this.request.history.filter(hist => hist.status === 'Answered').length > 0
},
lastText () {
return this.request.history
.filter(hist => hist.text)
.sort(asOfDesc)[0].text.fields[0]
},
log () {
return (this.request.notes || [])
.map(note => ({ asOf: note.asOf, text: { case: 'Some', fields: [ note.notes ] }, status: 'Notes' }))
.concat(this.request.history)
.sort(asOfDesc)
.slice(1)
},
openDays () {
const asOf = this.isAnswered ? this.answered : Date.now()
return Math.floor(
(asOf - this.request.history.find(hist => hist.status === 'Created').asOf) / 1000 / 60 / 60 / 24)
},
prayedCount () {
return this.request.history.filter(hist => hist.status === 'Prayed').length
}
},
async mounted () {
this.$Progress.start()
try {
const req = await api.getFullRequest(this.id)
this.request = req.data
this.$Progress.finish()
} catch (e) {
console.log(e)
this.$Progress.fail()
}
}, },
methods: { methods: {
closeDialog () { formatDate (asOf) {
this.full = null return moment(asOf).format('LL')
this.historyVisible = false
},
async openDialog (requestId) {
this.historyVisible = true
this.$Progress.start()
const req = await api.getFullRequest(requestId)
this.full = req.data
this.$Progress.finish()
} }
} }
} }

View File

@ -1,28 +0,0 @@
<template lang="pug">
b-list-group-item
| {{ history.status }}
|
small.text-muted(:title='actualDate') {{ asOf }}
div(v-if='history.text').mpj-request-text {{ history.text.fields[0] }}
</template>
<script>
'use strict'
import moment from 'moment'
export default {
name: 'full-request-history',
props: {
history: { required: true }
},
computed: {
asOf () {
return moment(this.history.asOf).fromNow()
},
actualDate () {
return moment(this.history.asOf).format('LLLL')
}
}
}
</script>

View File

@ -0,0 +1,65 @@
<template lang="pug">
p.mpj-request-text
| {{ request.text }}
br
br
router-link(:to="{ name: 'FullRequest', params: { id: request.requestId } }"
role='button'
title='View Full Request')
md-icon(icon='description')
= ' View Full Request'
| &nbsp;
router-link(v-if='!isAnswered'
:to="{ name: 'EditRequest', params: { id: request.requestId } }"
role='button'
title='Edit Request')
md-icon(icon='edit')
= ' Edit Request'
| &nbsp;
button(v-if='isSnoozed' @click='cancelSnooze()')
md-icon(icon='restore')
= ' Cancel Snooze'
br(v-if='isSnoozed || isAnswered')
small(v-if='isSnoozed').mpj-muted-text: em.
&nbsp; Snooze expires #[date-from-now(:value='request.snoozedUntil')]
small(v-if='isAnswered').mpj-muted-text: em.
&nbsp; Answered #[date-from-now(:value='request.asOf')]
</template>
<script>
'use strict'
import actions from '@/store/action-types'
export default {
name: 'request-list-item',
props: {
request: { required: true }
},
data () {
return {}
},
computed: {
answered () {
return this.request.history.find(hist => hist.status === 'Answered').asOf
},
isAnswered () {
return this.request.lastStatus === 'Answered'
},
isSnoozed () {
return this.request.snoozedUntil > Date.now()
}
},
methods: {
async cancelSnooze () {
await this.$store.dispatch(actions.SNOOZE_REQUEST, {
progress: this.$Progress,
requestId: this.request.requestId,
until: 0
})
this.toast.showToast('Request un-snoozed', { theme: 'success' })
// FIXME: communicate with the parent to refresh // this.ensureJournal()
}
}
}
</script>

View File

@ -1,30 +1,29 @@
<template lang="pug"> <template lang="pug">
article.mpj-main-content(role='main') article.mpj-main-content(role='main')
page-title(title='Snoozed Requests') page-title(title='Snoozed Requests')
div(v-if='loaded').mpj-snoozed-list div(v-if='loaded').mpj-request-list
p.mpj-text-center(v-if='requests.length === 0'): em. p.mpj-text-center(v-if='requests.length === 0'): em.
No snoozed requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal] No snoozed requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal]
p.mpj-request-text(v-for='req in requests' :key='req.requestId') request-list-item(v-for='req in requests'
| {{ req.text }} :key='req.requestId'
br :request='req')
br
button(@click='cancelSnooze(req.requestId)')
md-icon(icon='restore')
= ' Cancel Snooze'
small.mpj-muted-text: em.
&nbsp; Snooze expires #[date-from-now(:value='req.snoozedUntil')]
p(v-else) Loading journal... p(v-else) Loading journal...
</template> </template>
<script> <script>
'use static' 'use strict'
import { mapState } from 'vuex' import { mapState } from 'vuex'
import actions from '@/store/action-types' import actions from '@/store/action-types'
import RequestListItem from '@/components/request/RequestListItem'
export default { export default {
name: 'answered', name: 'snoozed-requests',
components: {
RequestListItem
},
data () { data () {
return { return {
requests: [], requests: [],
@ -63,12 +62,3 @@ export default {
} }
} }
</script> </script>
<style>
.mpj-snoozed-list p {
border-top: solid 1px lightgray;
}
.mpj-snoozed-list p:first-child {
border-top: none;
}
</style>

View File

@ -1,14 +1,17 @@
'use strict'
import Vue from 'vue' import Vue from 'vue'
import Router from 'vue-router' import Router from 'vue-router'
import Answered from '@/components/Answered' import ActiveRequests from '@/components/request/ActiveRequests'
import AnsweredDetail from '@/components/AnsweredDetail' import AnsweredRequests from '@/components/request/AnsweredRequests'
import EditRequest from '@/components/request/EditRequest' import EditRequest from '@/components/request/EditRequest'
import FullRequest from '@/components/request/FullRequest'
import Home from '@/components/Home' import Home from '@/components/Home'
import Journal from '@/components/Journal' import Journal from '@/components/Journal'
import LogOn from '@/components/user/LogOn' import LogOn from '@/components/user/LogOn'
import PrivacyPolicy from '@/components/legal/PrivacyPolicy' import PrivacyPolicy from '@/components/legal/PrivacyPolicy'
import Snoozed from '@/components/Snoozed' import SnoozedRequests from '@/components/request/SnoozedRequests'
import TermsOfService from '@/components/legal/TermsOfService' import TermsOfService from '@/components/legal/TermsOfService'
Vue.use(Router) Vue.use(Router)
@ -21,17 +24,6 @@ export default new Router({
name: 'Home', name: 'Home',
component: Home component: Home
}, },
{
path: '/answered/:id',
name: 'AnsweredDetail',
component: AnsweredDetail,
props: true
},
{
path: '/answered',
name: 'Answered',
component: Answered
},
{ {
path: '/journal', path: '/journal',
name: 'Journal', name: 'Journal',
@ -54,9 +46,25 @@ export default new Router({
props: true props: true
}, },
{ {
path: '/snoozed', path: '/request/:id/full',
name: 'Snoozed', name: 'FullRequest',
component: Snoozed component: FullRequest,
props: true
},
{
path: '/requests/active',
name: 'ActiveRequests',
component: ActiveRequests
},
{
path: '/requests/answered',
name: 'AnsweredRequests',
component: AnsweredRequests
},
{
path: '/requests/snoozed',
name: 'SnoozedRequests',
component: SnoozedRequests
}, },
{ {
path: '/user/log-on', path: '/user/log-on',

View File

@ -1,3 +1,5 @@
'use strict'
import Vue from 'vue' import Vue from 'vue'
import Vuex from 'vuex' import Vuex from 'vuex'