diff --git a/README.md b/README.md index 6396a42..7718e46 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ - [FAQ](#faq) - [How do I use component data in `metaInfo`?](#how-do-i-use-component-data-in-metainfo) - [How do I use component props in `metaInfo`?](#how-do-i-use-component-props-in-metainfo) + - [How do I populate `metaInfo` from the result of an asynchronous action?](#how-do-i-populate-metainfo-from-the-result-of-an-asynchronous-action) @@ -675,3 +676,38 @@ The same way you use data - specify a function instead of an object. It will nee } ``` + +## How do I populate `metaInfo` from the result of an asynchronous action? + +`vue-meta` exposes a method called `refresh` on the client-side that allows you to trigger an update at any given point in time. + +In the same way you access `$meta().inject()` on the server, you can access `$meta().refresh()`. + +For example, if you're using Vuex and you have an action that fetches a `post` asynchronously, you should ensure that it returns a promise so that you are notified when the fetching is complete: + +```js +{ + actions: { + async fetchPost ({ commit }, payload) { + const post = yield db.fetch('posts', payload.postId) + commit('fetchedPost', post) + } + } +} +``` + +Then in your component, you can call `refresh()` to trigger an update once the fetch is complete: + +```js +{ + beforeMount () { + const postId = this.$router.params.id + this.$store.dispatch('fetchPost', { postId }) + .then(() => this.$meta().refresh()) + } +} +``` + +Just make sure that whatever data source you're using (`store` if you're using Vuex, component `data` otherwise) has some sane defaults set so Vue doesn't complain about `null` property accessors. + +Check out the [vuex-async](https://github.com/declandewet/vue-meta/tree/master/examples/vuex-async) example for a far more detailed demonstration of how this works. diff --git a/examples/index.html b/examples/index.html index fb6e04b..54711b6 100644 --- a/examples/index.html +++ b/examples/index.html @@ -11,6 +11,7 @@
  • Basic Render
  • Usage with vue-router
  • Usage with vuex
  • +
  • Usage with vuex + async actions
  • diff --git a/examples/vuex-async/App.vue b/examples/vuex-async/App.vue new file mode 100644 index 0000000..40c8e58 --- /dev/null +++ b/examples/vuex-async/App.vue @@ -0,0 +1,7 @@ + diff --git a/examples/vuex-async/app.js b/examples/vuex-async/app.js new file mode 100644 index 0000000..5d78930 --- /dev/null +++ b/examples/vuex-async/app.js @@ -0,0 +1,9 @@ +import assign from 'object-assign' +import Vue from 'vue' +import store from './store' +import router from './router' +import App from './App.vue' + +const app = new Vue(assign(App, { router, store })) + +app.$mount('#app') diff --git a/examples/vuex-async/index.html b/examples/vuex-async/index.html new file mode 100644 index 0000000..27b83a3 --- /dev/null +++ b/examples/vuex-async/index.html @@ -0,0 +1,5 @@ + + +← Examples index +
    + diff --git a/examples/vuex-async/router.js b/examples/vuex-async/router.js new file mode 100644 index 0000000..536fe87 --- /dev/null +++ b/examples/vuex-async/router.js @@ -0,0 +1,17 @@ +import Vue from 'vue' +import Router from 'vue-router' +import Meta from 'vue-meta' +import Home from './views/Home.vue' +import Post from './views/Post.vue' + +Vue.use(Router) +Vue.use(Meta) + +export default new Router({ + mode: 'history', + base: '/vuex-async', + routes: [ + { path: '/', component: Home }, + { path: '/posts/:slug', component: Post } + ] +}) diff --git a/examples/vuex-async/store.js b/examples/vuex-async/store.js new file mode 100644 index 0000000..6bbc37b --- /dev/null +++ b/examples/vuex-async/store.js @@ -0,0 +1,77 @@ +import Vue from 'vue' +import Vuex from 'vuex' + +Vue.use(Vuex) + +export default new Vuex.Store({ + // STATE + state: { + isLoading: false, + // its important that we set some defaults for the current post + // otherwise Vue will complain that properties are `null` + post: { + title: '', + content: '', + slug: '', + published: false + }, + posts: [{ + slug: 'a-sample-blog-post', + title: 'A Sample Blog Post', + content: 'This is the blog post content', + published: true + }, { + slug: 'an-unpublished-blog-post', + title: 'An Unpublished Blog Post', + content: 'This is the blog post content', + published: false + }, { + slug: 'another-blog-post', + title: 'Another Blog Post', + content: 'This is the blog post content', + published: true + }] + }, + + // GETTERS + getters: { + isLoading (state) { + return state.isLoading + }, + post (state) { + return state.post + }, + publishedPosts (state) { + return state.posts.filter((post) => post.published) + }, + publishedPostsCount (state, getters) { + return getters.publishedPosts.length + } + }, + + // MUTATIONS + mutations: { + loadingState (state, { isLoading }) { + state.isLoading = isLoading + }, + getPost (state, { slug }) { + state.post = state.posts.find((post) => post.slug === slug) + } + }, + + // ACTIONS + actions: { + getPost ({ commit }, payload) { + commit('loadingState', { isLoading: true }) + // we have to return a promise from this action so we know + // when it is finished + return new Promise((resolve) => { + setTimeout(() => { + commit('getPost', payload) + resolve() + }, 2000) + }) + .then(() => commit('loadingState', { isLoading: false })) + } + } +}) diff --git a/examples/vuex-async/views/Home.vue b/examples/vuex-async/views/Home.vue new file mode 100644 index 0000000..b97fdf7 --- /dev/null +++ b/examples/vuex-async/views/Home.vue @@ -0,0 +1,26 @@ + + + diff --git a/examples/vuex-async/views/Post.vue b/examples/vuex-async/views/Post.vue new file mode 100644 index 0000000..c648891 --- /dev/null +++ b/examples/vuex-async/views/Post.vue @@ -0,0 +1,37 @@ + + + diff --git a/examples/vuex/store.js b/examples/vuex/store.js index 3796565..dd58b7e 100644 --- a/examples/vuex/store.js +++ b/examples/vuex/store.js @@ -6,7 +6,15 @@ Vue.use(Vuex) export default new Vuex.Store({ // STATE state: { - post: null, + isLoading: false, + // its important that we set some defaults for the current post + // otherwise Vue will complain that properties are `null` + post: { + title: '', + content: '', + slug: '', + published: false + }, posts: [{ slug: 'a-sample-blog-post', title: 'A Sample Blog Post', diff --git a/examples/vuex/views/Post.vue b/examples/vuex/views/Post.vue index 0c6f47c..1a1202c 100644 --- a/examples/vuex/views/Post.vue +++ b/examples/vuex/views/Post.vue @@ -10,7 +10,7 @@ import { mapGetters } from 'vuex' export default { - name: 'blog-post', + name: 'post', beforeMount () { const { slug } = this.$route.params this.$store.dispatch('getPost', { slug }) diff --git a/src/client/refresh.js b/src/client/refresh.js new file mode 100644 index 0000000..8567fd0 --- /dev/null +++ b/src/client/refresh.js @@ -0,0 +1,18 @@ +import getMetaInfo from '../shared/getMetaInfo' +import updateClientMetaInfo from './updateClientMetaInfo' + +/** + * When called, will update the current meta info with new meta info. + * Useful when updating meta info as the result of an asynchronous + * action that resolves after the initial render takes place. + * + * Credit to [Sébastien Chopin](https://github.com/Atinux) for the suggestion + * to implement this method. + * + * @return {Object} - new meta info + */ +export default function refresh () { + const info = getMetaInfo(this.$root) + updateClientMetaInfo(info) + return info +} diff --git a/src/server/$meta.js b/src/shared/$meta.js similarity index 59% rename from src/server/$meta.js rename to src/shared/$meta.js index 908a537..e41e7a1 100644 --- a/src/server/$meta.js +++ b/src/shared/$meta.js @@ -1,4 +1,5 @@ -import inject from './inject' +import inject from '../server/inject' +import refresh from '../client/refresh' /** * Returns an injector for server-side rendering. @@ -7,5 +8,8 @@ import inject from './inject' */ export default function $meta () { // bind inject method to this component - return { inject: inject.bind(this) } + return { + inject: inject.bind(this), + refresh: refresh.bind(this) + } } diff --git a/src/shared/getComponentOption.js b/src/shared/getComponentOption.js index 7c751d8..6667252 100644 --- a/src/shared/getComponentOption.js +++ b/src/shared/getComponentOption.js @@ -12,9 +12,11 @@ import deepmerge from 'deepmerge' * @param {Boolean} opts.deep - look for data in child components as well? * @param {Function} opts.arrayMerge - how should arrays be merged? * @param {Object} [result={}] - result so far - * @return {Object} - final aggregated result + * @return {Object} result - final aggregated result + * @return {Object} result.mergedOption - the actual merged options + * @return {Object} result.deepestComponentWithMetaInfo - the deepest component in the heirarchy that has a `metaInfo` instance property */ -export default function getComponentOption (opts, result = {}) { +export default function getComponentOption (opts, result = { mergedOption: {} }) { const { component, option, deep, arrayMerge } = opts const { $options } = component @@ -24,12 +26,13 @@ export default function getComponentOption (opts, result = {}) { if (typeof data === 'object') { // merge with existing options - result = deepmerge(result, data, { + result.mergedOption = deepmerge(result.mergedOption, data, { clone: true, arrayMerge }) + result.deepestComponentWithMetaInfo = component } else { - result = data + result.mergedOption = data } } diff --git a/src/shared/getMetaInfo.js b/src/shared/getMetaInfo.js index a126eab..90cbd32 100644 --- a/src/shared/getMetaInfo.js +++ b/src/shared/getMetaInfo.js @@ -1,6 +1,5 @@ import deepmerge from 'deepmerge' import getComponentOption from './getComponentOption' -import mergeComponentData from './mergeComponentData' /** * Returns the correct meta info for the given component @@ -26,7 +25,7 @@ export default function getMetaInfo (component) { } // collect & aggregate all metaInfo $options - const info = getComponentOption({ + const { mergedOption: info, deepestComponentWithMetaInfo } = getComponentOption({ component, option: 'metaInfo', deep: true, @@ -73,13 +72,12 @@ export default function getMetaInfo (component) { } const metaInfo = deepmerge(defaultInfo, info) - const componentData = mergeComponentData(component) // inject component context into functions & call to normalize data Object.keys(metaInfo).forEach((key) => { const val = metaInfo[key] if (typeof val === 'function') { - metaInfo[key] = val.call(componentData) + metaInfo[key] = val.call(deepestComponentWithMetaInfo) } }) diff --git a/src/shared/mergeComponentData.js b/src/shared/mergeComponentData.js deleted file mode 100644 index 78c917c..0000000 --- a/src/shared/mergeComponentData.js +++ /dev/null @@ -1,18 +0,0 @@ -import assign from 'object-assign' - -/** - * Recursively shallow-merges component object with it's children component objects. - * This function is responsible for obtaining the `this` context of metaInfo props when - * declared in function form. - * - * @param {Object} component - the component object - * @return {Object} - the merged data - */ -export default function mergeComponentData (component) { - if (component.$children.length) { - return component.$children.reduce((data, child) => { - return assign({}, data, mergeComponentData(child)) - }, component) - } - return component -} diff --git a/src/shared/plugin.js b/src/shared/plugin.js index 05630a2..e333c19 100644 --- a/src/shared/plugin.js +++ b/src/shared/plugin.js @@ -1,6 +1,4 @@ -import getMetaInfo from './getMetaInfo' -import $meta from '../server/$meta' -import updateClientMetaInfo from '../client/updateClientMetaInfo' +import $meta from './$meta' // automatic install if (typeof Vue !== 'undefined') { @@ -26,10 +24,7 @@ export default function VueMeta (Vue) { requestId = window.requestAnimationFrame(() => { requestId = null - const info = getMetaInfo(this.$root) - - // update the meta info - updateClientMetaInfo(info) + this.$meta().refresh() }) } }) diff --git a/test/getComponentOption.spec.js b/test/getComponentOption.spec.js index 93b548c..839793d 100644 --- a/test/getComponentOption.spec.js +++ b/test/getComponentOption.spec.js @@ -9,14 +9,14 @@ describe('getComponentOption', () => { it('returns an empty object when no matching options are found', () => { component = new Vue() - const fetchedOption = getComponentOption({ component, option: 'noop' }) - expect(fetchedOption).to.eql({}) + const { mergedOption } = getComponentOption({ component, option: 'noop' }) + expect(mergedOption).to.eql({}) }) it('fetches the given option from the given component', () => { component = new Vue({ someOption: 'foo' }) - const fetchedOption = getComponentOption({ component, option: 'someOption' }) - expect(fetchedOption).to.eql('foo') + const { mergedOption } = getComponentOption({ component, option: 'someOption' }) + expect(mergedOption).to.eql('foo') }) it('fetches deeply nested component options and merges them', () => { @@ -28,8 +28,8 @@ describe('getComponentOption', () => { el: container }) - const fetchedOption = getComponentOption({ component, option: 'foo', deep: true }) - expect(fetchedOption).to.eql({ bar: 'baz', fizz: 'buzz' }) + const { mergedOption } = getComponentOption({ component, option: 'foo', deep: true }) + expect(mergedOption).to.eql({ bar: 'baz', fizz: 'buzz' }) }) it('allows for a custom array merge strategy', () => { @@ -48,7 +48,7 @@ describe('getComponentOption', () => { el: container }) - const fetchedOption = getComponentOption({ + const { mergedOption } = getComponentOption({ component, option: 'foo', deep: true, @@ -57,7 +57,7 @@ describe('getComponentOption', () => { } }) - expect(fetchedOption).to.eql([ + expect(mergedOption).to.eql([ { name: 'flower', content: 'tulip' }, { name: 'flower', content: 'rose' } ])