Comp API - common / infra (#37)

Seems to work so far; journal is bombing now that App isn't providing progress / snackbar stuff via the $ properties
This commit is contained in:
Daniel J. Summers 2019-11-22 23:02:37 -06:00
parent 7604976b98
commit 23c6bc1f1f
16 changed files with 325 additions and 194 deletions

View File

@ -13,6 +13,7 @@
"vue": "vue-cli-service build --modern && cd ../MyPrayerJournal.Api && dotnet run"
},
"dependencies": {
"@vue/composition-api": "^0.3.2",
"auth0-js": "^9.7.3",
"axios": "^0.19.0",
"core-js": "^3.3.2",
@ -24,6 +25,7 @@
},
"devDependencies": {
"@types/auth0-js": "^9.10.6",
"@types/node": "^12.12.12",
"@typescript-eslint/eslint-plugin": "^2.8.0",
"@typescript-eslint/parser": "^2.8.0",
"@vue/cli-plugin-babel": "^4.0.5",

View File

@ -10,13 +10,13 @@
span(style='font-weight:700;') Journal
navigation
md-app-content
md-progress-bar(v-if='progress.visible'
:md-mode='progress.mode')
md-progress-bar(v-if='progress.visible.value'
:md-mode='progress.mode.value')
router-view
md-snackbar(:md-active.sync='snackbar.visible'
md-snackbar(:md-active.sync='snackbar.visible.value'
md-position='center'
:md-duration='snackbar.interval'
ref='snackbar') {{ snackbar.message }}
:md-duration='snackbar.interval.value'
ref='snackbar') {{ snackbar.message.value }}
footer
p.mpj-muted-text.mpj-text-right
| myPrayerJournal v{{ version }}
@ -28,82 +28,119 @@
#[a(href='https://bitbadger.solutions' target='_blank') Bit Badger Solutions]
</template>
<script>
'use strict'
<script lang="ts">
import Vue from 'vue'
import { computed, ref, onMounted, provide } from '@vue/composition-api'
import Navigation from '@/components/common/Navigation'
import Navigation from '@/components/common/Navigation.vue'
import { Actions } from '@/store/types'
import { version } from '../package.json'
import auth from './auth'
import router from './router'
import store from './store'
import { Actions } from './store/types'
import { provideAuth } from './plugins/auth'
import { provideRouter } from './plugins/router'
import { provideStore } from './plugins/store'
// import { version } = require('../package.json')
function useSnackbar () {
const events = new Vue()
const visible = ref(false)
const message = ref('')
const interval = ref(4000)
const showSnackbar = (msg: string) => {
message.value = msg
visible.value = true
}
const showInfo = (msg: string) => {
interval.value = 4000
showSnackbar(msg)
}
const showError = (msg: string) => {
interval.value = Infinity
showSnackbar(msg)
}
onMounted(() => {
events.$on('info', showInfo)
events.$on('error', showError)
})
return {
events,
visible,
message,
interval,
showSnackbar,
showInfo,
showError
}
}
function useProgress () {
const events = new Vue()
const visible = ref(false)
const mode = ref('query')
const showProgress = (mod: string) => {
mode.value = mod
visible.value = true
}
const hideProgress = () => { visible.value = false }
onMounted(() => {
events.$on('show', showProgress)
events.$on('done', hideProgress)
})
return {
events,
visible,
mode,
showProgress,
hideProgress
}
}
export default {
name: 'app',
components: {
Navigation
},
data () {
setup () {
const pkg = require('../package.json')
provideAuth(auth)
provideRouter(router)
provideStore(store)
const version = computed(() =>
pkg.version.endsWith('.0')
? pkg.version.endsWith('.0.0')
? pkg.version.substr(0, pkg.version.length - 4)
: pkg.version.substr(0, pkg.version.length - 2)
: pkg.version)
const progress = useProgress()
const snackbar = useSnackbar()
onMounted(async () => store.dispatch(Actions.CheckAuthentication))
const SnackbarSymbol = Symbol('Snackbar events')
provide(SnackbarSymbol, snackbar.events)
const ProgressSymbol = Symbol('Progress events')
provide(ProgressSymbol, progress.events)
return {
progress: {
events: new Vue(),
visible: false,
mode: 'query'
},
snackbar: {
events: new Vue(),
visible: false,
message: '',
interval: 4000
}
}
},
async mounted () {
this.progress.events.$on('show', this.showProgress)
this.progress.events.$on('done', this.hideProgress)
this.snackbar.events.$on('info', this.showInfo)
this.snackbar.events.$on('error', this.showError)
await this.$store.dispatch(Actions.CheckAuthentication)
},
computed: {
version () {
return version.endsWith('.0')
? version.endsWith('.0.0')
? version.substr(0, version.length - 4)
: version.substr(0, version.length - 2)
: version
}
},
methods: {
showSnackbar (message) {
this.snackbar.message = message
this.snackbar.visible = true
},
showInfo (message) {
this.snackbar.interval = 4000
this.showSnackbar(message)
},
showError (message) {
this.snackbar.interval = Infinity
this.showSnackbar(message)
},
showProgress (mode) {
this.progress.mode = mode
this.progress.visible = true
},
hideProgress () {
this.progress.visible = false
},
handleLoginEvent (data) {
if (!data.loggedIn) {
this.showInfo('Logged out successfully')
}
}
},
provide () {
return {
messages: this.snackbar.events,
progress: this.progress.events
version,
progress,
snackbar
}
}
}

View File

@ -20,7 +20,7 @@ const webAuth = new auth0.WebAuth({
/**
* A class to handle all authentication calls and determinations
*/
class AuthService extends EventEmitter {
export class AuthService extends EventEmitter {
// Local storage key for our session data
AUTH_SESSION = 'auth-session'
@ -34,8 +34,9 @@ class AuthService extends EventEmitter {
/**
* Starts the user log in flow
* @param customState Application state to be returned after user has authenticated
*/
login (customState: any) {
login (customState?: any) {
webAuth.authorize({
appState: customState
})
@ -43,6 +44,7 @@ class AuthService extends EventEmitter {
/**
* Promisified parseHash function
* @returns A promise that resolves with the parsed hash returned from Auth0
*/
parseHash (): Promise<Auth0DecodedHash> {
return new Promise((resolve, reject) => {
@ -103,6 +105,7 @@ class AuthService extends EventEmitter {
/**
* Renew authorzation tokens with Auth0
* @returns A promise with the parsed hash from the Auth0 response
*/
renewTokens (): Promise<Auth0DecodedHash> {
return new Promise((resolve, reject) => {
@ -141,22 +144,32 @@ class AuthService extends EventEmitter {
this.emit('loginEvent', { loggedIn: false })
}
/**
* Whether the given token is currently valid
* @param t The token to be validated
* @returns True if the token is valid
*/
isTokenValid = (t: Token) => t.token !== '' && t.expiry !== 0 && Date.now() < t.expiry
/**
* Is there a user authenticated?
* @returns True if a user is authenticated
*/
isAuthenticated () {
return this.session && this.session.id && this.session.id.isValid()
return this.session && this.session.id && this.isTokenValid(this.session.id)
}
/**
* Is the current access token valid?
* @returns True if the user's access token is valid
*/
isAccessTokenValid () {
return this.session && this.session.access && this.session.access.isValid()
return this.session && this.session.access && this.isTokenValid(this.session.access)
}
/**
* Get the user's access token, renewing it if required
* @returns A promise that resolves to the user's access token
*/
async getAccessToken (): Promise<string> {
if (this.isAccessTokenValid()) {

View File

@ -6,11 +6,6 @@ export class Token {
* @param expiry The expiration for the token
*/
constructor (public token: string, public expiry: number) { } // eslint-disable-line no-useless-constructor
/** Whether this token is currently valid */
isValid (): boolean {
return this.token !== '' && this.expiry !== 0 && Date.now() < this.expiry
}
}
/** A user's current session */

View File

@ -12,11 +12,3 @@ md-content(role='main').mpj-main-content
above, and log on with either a Microsoft or Google account. You can also learn more about the site at the
&ldquo;Docs&rdquo; link, also above.
</template>
<script>
'use strict'
export default {
name: 'home'
}
</script>

View File

@ -1,6 +1,5 @@
<script>
'use strict'
<script lang="ts">
import { computed, createElement, onBeforeUnmount, onMounted, ref } from '@vue/composition-api'
import moment from 'moment'
export default {
@ -19,35 +18,32 @@ export default {
default: 10000
}
},
data () {
return {
fromNow: moment(this.value).fromNow(),
intervalId: null
setup (props: any) {
/** Interval ID for updating relative time */
let intervalId: number = 0
/** The relative time string */
const fromNow = ref(moment(props.value).fromNow())
/** The actual date/time (used as the title for the relative time) */
const actual = computed(() => moment(props.value).format('LLLL'))
/** Update the relative time string if it is different */
const updateFromNow = () => {
const newFromNow = moment(props.value).fromNow()
if (newFromNow !== fromNow.value) fromNow.value = newFromNow
}
},
computed: {
actual () {
return moment(this.value).format('LLLL')
}
},
mounted () {
this.intervalId = setInterval(this.updateFromNow, this.interval)
this.$watch('value', this.updateFromNow)
},
beforeDestroy () {
clearInterval(this.intervalId)
},
methods: {
updateFromNow () {
const newFromNow = moment(this.value).fromNow()
if (newFromNow !== this.fromNow) this.fromNow = newFromNow
}
},
render (createElement) {
return createElement(this.tag, {
/** Refresh the relative time string to keep it accurate */
onMounted(() => { intervalId = setInterval(updateFromNow, props.interval) })
/** Cancel refreshing the time string represented with this component */
onBeforeUnmount(() => clearInterval(intervalId))
return () => createElement(props.tag, {
domProps: {
title: this.actual,
innerText: this.fromNow
title: actual.value,
innerText: fromNow.value
}
})
}

View File

@ -6,7 +6,7 @@
to='/journal')
md-tab(md-label='Active'
to='/requests/active')
md-tab(v-if='hasSnoozed'
md-tab(v-if='hasSnoozed.value'
md-label='Snoozed'
to='/requests/snoozed')
md-tab(md-label='Answered'
@ -26,33 +26,48 @@
@click.prevent='showHelp()')
</template>
<script>
'use strict'
<script lang="ts">
import { computed } from '@vue/composition-api'
import { Store } from 'vuex' // eslint-disable-line no-unused-vars
import { mapState } from 'vuex'
import { AppState } from '../../store/types' // eslint-disable-line no-unused-vars
import { AuthService } from '../../auth' // eslint-disable-line no-unused-vars
import { useAuth } from '../../plugins/auth'
import { useRouter } from '../../plugins/router'
import { useStore } from '../../plugins/store'
export default {
name: 'navigation',
data () {
return {}
},
computed: {
hasSnoozed () {
return this.isAuthenticated &&
Array.isArray(this.journal) &&
this.journal.filter(req => req.snoozedUntil > Date.now()).length > 0
},
...mapState(['isAuthenticated', 'journal'])
},
methods: {
logOn () {
this.$auth.login()
},
logOff () {
this.$auth.logout(this.$store, this.$router)
},
showHelp () {
window.open('https://docs.prayerjournal.me', '_blank')
setup () {
/** The Vuex store */
const store = useStore() as Store<AppState>
/** The auth service */
const auth = useAuth() as AuthService
/** Whether the user has any snoozed requests */
const hasSnoozed = computed(() =>
store.state.isAuthenticated &&
Array.isArray(store.state.journal) &&
store.state.journal.filter(req => req.snoozedUntil > Date.now()).length > 0)
/** Log a user on using Auth0 */
const logOn = () => auth.login()
/** Log a user off using Auth0 */
const logOff = () => {
auth.logout(store)
const router = useRouter()
router.push('/')
}
/** Open a new window/tab with help */
const showHelp = () => { window.open('https://docs.prayerjournal.me', '_blank') }
return {
hasSnoozed,
logOn,
logOff,
showHelp
}
}
}

View File

@ -3,9 +3,10 @@ h1(v-if='!hideOnPage'
v-html='title').md-title
</template>
<script>
<script lang="ts">
import { watch } from '@vue/composition-api'
export default {
name: 'page-title',
props: {
title: {
type: String,
@ -16,13 +17,11 @@ export default {
default: false
}
},
watch: {
title () {
document.title = `${this.title.replace('&rsquo;', "'")} « myPrayerJournal`
}
},
created () {
document.title = `${this.title.replace('&rsquo;', "'")} « myPrayerJournal`
setup (props: any) {
watch(props.title, (title, prevTitle) => {
document.title = `${props.title.replace('&rsquo;', "'")} « myPrayerJournal`
})
return { }
}
}
</script>

View File

@ -4,20 +4,37 @@ article.mpj-main-content(role='main')
p Logging you on...
</template>
<script>
'use strict'
<script lang="ts">
import { onBeforeMount } from '@vue/composition-api'
import VueRouter from 'vue-router' // eslint-disable-line no-unused-vars
import { Store } from 'vuex' // eslint-disable-line no-unused-vars
import { AppState } from '../../store/types' // eslint-disable-line no-unused-vars
import { AuthService } from '../../auth' // eslint-disable-line no-unused-vars
import { useAuth } from '../../plugins/auth'
import { useRouter } from '../../plugins/router'
import { useStore } from '../../plugins/store'
export default {
name: 'log-on',
inject: ['progress'],
async created () {
this.progress.$emit('show', 'indeterminate')
await this.$auth.handleAuthentication(this.$store)
},
methods: {
handleLoginEvent (data) {
this.$router.push(data.state.target || '/journal')
}
setup () {
/** Auth service instance */
const auth = useAuth() as AuthService
/** Store instance */
const store = useStore() as Store<AppState>
/** Router instance */
const router = useRouter() as VueRouter
/** Navigate on auth completion */
auth.on('loginEvent', (data: any) => {
router.push(data.state.target || '/journal')
})
// this.progress.$emit('show', 'indeterminate')
onBeforeMount(async () => { await auth.handleAuthentication(store) })
return { }
}
}
</script>

View File

@ -1,34 +1,33 @@
/* eslint-disable */
// Vue packages and components
import Vue from 'vue'
import { MdApp,
MdButton,
MdCard,
MdContent,
MdDatepicker,
MdDialog,
MdEmptyState,
MdField,
MdIcon,
MdLayout,
MdProgress,
MdRadio,
MdSnackbar,
MdTable,
MdTabs,
MdToolbar,
MdTooltip } from 'vue-material/dist/components'
import VueCompositionApi from '@vue/composition-api'
import {
MdApp,
MdButton,
MdCard,
MdContent,
MdDatepicker,
MdDialog,
MdEmptyState,
MdField,
MdIcon,
MdLayout,
MdProgress,
MdRadio,
MdSnackbar,
MdTable,
MdTabs,
MdToolbar,
MdTooltip
} from 'vue-material/dist/components'
// myPrayerJournal components
import App from './App.vue'
import router from './router'
import store from './store'
import App from './App.vue'
import router from './router'
import store from './store'
import DateFromNow from './components/common/DateFromNow.vue'
import PageTitle from './components/common/PageTitle.vue'
import AuthPlugin from './plugins/auth'
/* eslint-enable */
import PageTitle from './components/common/PageTitle.vue'
import AuthPlugin from './plugins/auth'
// Styles
import 'vue-material/dist/vue-material.min.css'
@ -54,6 +53,7 @@ Vue.use(MdTabs)
Vue.use(MdToolbar)
Vue.use(MdTooltip)
Vue.use(AuthPlugin)
Vue.use(VueCompositionApi)
Vue.component('date-from-now', DateFromNow)
Vue.component('page-title', PageTitle)

View File

@ -1,7 +1,8 @@
import authService from '@/auth'
import { inject, provide } from '@vue/composition-api'
import authService, { AuthService } from '@/auth'
export default {
install (Vue) {
install (Vue: any) {
Vue.prototype.$auth = authService
Vue.mixin({
@ -18,3 +19,18 @@ export default {
})
}
}
const AuthSymbol = Symbol('Auth service')
export function provideAuth (auth: AuthService) {
provide(AuthSymbol, auth)
}
/** Use the auth service */
export function useAuth (): AuthService {
const auth = inject(AuthSymbol)
if (!auth) {
throw new Error('Auth not configured!')
}
return auth as AuthService
}

View File

@ -0,0 +1,16 @@
import VueRouter from 'vue-router'
import { inject, provide } from '@vue/composition-api'
const RouterSymbol = Symbol('Vue router')
export function provideRouter (router: VueRouter) {
provide(RouterSymbol, router)
}
export function useRouter (): VueRouter {
const router = inject(RouterSymbol)
if (!router) {
throw new Error('Router not configured!')
}
return router as VueRouter
}

View File

@ -0,0 +1,20 @@
import { provide, inject } from '@vue/composition-api'
import { Store } from 'vuex'
import { AppState } from '@/store/types'
const StoreSymbol = Symbol('Vuex store')
/** Configure the store provided by this plugin */
export function provideStore (store: Store<AppState>) {
provide(StoreSymbol, store)
}
/** Use the provided store */
export function useStore (): Store<AppState> {
const store = inject(StoreSymbol)
if (!store) {
throw new Error('No store configured!')
}
return store as Store<AppState>
}

1
src/app/src/untyped-modules.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module 'vue-material/dist/components'

View File

@ -8,8 +8,8 @@
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"sourceMap": true,
"noImplicitAny": false,
"baseUrl": ".",
"types": [
"webpack-env"

View File

@ -837,6 +837,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.11.tgz#bec2961975888d964196bf0016a2f984d793d3ce"
integrity sha512-O+x6uIpa6oMNTkPuHDa9MhMMehlxLAd5QcOvKRjAFsBVpeFWTOPnXbDvILvFgFFZfQ1xh1EZi1FbXxUix+zpsQ==
"@types/node@^12.12.12":
version "12.12.12"
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.12.tgz#529bc3e73dbb35dd9e90b0a1c83606a9d3264bdb"
integrity sha512-MGuvYJrPU0HUwqF7LqvIj50RZUX23Z+m583KBygKYUZLlZ88n6w28XRNJRJgsHukLEnLz6w6SvxZoLgbr5wLqQ==
"@types/normalize-package-data@^2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
@ -1129,6 +1134,13 @@
source-map "~0.6.1"
vue-template-es2015-compiler "^1.9.0"
"@vue/composition-api@^0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@vue/composition-api/-/composition-api-0.3.2.tgz#2d797028e489bf7812f08c7bb33ffd03ef23c617"
integrity sha512-fD4dn9cJX62QSP2TMFLXCOQOa+Bu2o7kWDjrU/FNLkNqPPcCKBLxCH/Lc+gNCRBKdEUGyI3arjAw7j0Yz1hnvw==
dependencies:
tslib "^1.9.3"
"@vue/eslint-config-standard@^5.0.0":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@vue/eslint-config-standard/-/eslint-config-standard-5.0.0.tgz#2ffabb4056205a86782cd5a641cdbcd330d905b4"
@ -8789,7 +8801,7 @@ ts-loader@^6.2.0:
micromatch "^4.0.0"
semver "^6.0.0"
tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0:
tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
version "1.10.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==