diff --git a/.changeset/angry-panthers-prove.md b/.changeset/angry-panthers-prove.md new file mode 100644 index 000000000..b09085bb1 --- /dev/null +++ b/.changeset/angry-panthers-prove.md @@ -0,0 +1,7 @@ +--- +"druxt-views": minor +--- + +feat(#639): added druxt/views/flushResults mutation +feat(#639): added bypassCache option to druxt/views/getResults action +feat(#639): added druxt.query.bypassCache option to DruxtView diff --git a/.changeset/few-pets-suffer.md b/.changeset/few-pets-suffer.md new file mode 100644 index 000000000..9f5cee8bb --- /dev/null +++ b/.changeset/few-pets-suffer.md @@ -0,0 +1,6 @@ +--- +"druxt": minor +--- + +feat(#639): added druxt/flushCollection and druxt/flushResource mutations +feat(#639): added bypassCache option to druxt/getCollection and druxt/getResource actions diff --git a/.changeset/khaki-glasses-sing.md b/.changeset/khaki-glasses-sing.md new file mode 100644 index 000000000..0879dc757 --- /dev/null +++ b/.changeset/khaki-glasses-sing.md @@ -0,0 +1,5 @@ +--- +"druxt-entity": minor +--- + +feat(#639): add bypassCache druxt setting to DruxtEntity components. diff --git a/packages/druxt/src/stores/druxt.js b/packages/druxt/src/stores/druxt.js index 5218956b8..883036ac3 100644 --- a/packages/druxt/src/stores/druxt.js +++ b/packages/druxt/src/stores/druxt.js @@ -140,6 +140,44 @@ const DruxtStore = ({ store }) => { Vue.set(state.resources[type][id], prefix, resource) }, + + /** + * @name flushCollection + * @mutator {object} flushCollection=collections Removes JSON:API collections from the Vuex state object. + * @param {flushCollectionContext} context + * + * @example @lang js + * // Flush all collections. + * this.$store.commit('druxt/flushCollection', {}) + * + * // Flush target collection. + * this.$store.commit('druxt/flushCollection', { type, hash, prefix }) + */ + flushCollection (state, { type, hash, prefix }) { + if (!type) Vue.set(state, 'collections', {}) + else if (type && !hash && !prefix) Vue.set(state.collections, type, {}) + else if (type && hash && !prefix) Vue.set(state.collections[type], hash, {}) + else if (type && hash && prefix) Vue.set(state.collections[type][hash], prefix, {}) + }, + + /** + * @name flushResource + * @mutator {object} flushResource=resources Removes JSON:API resources from the Vuex state object. + * @param {flushResourceContext} context + * + * @example @lang js + * // Flush all resources. + * this.$store.commit('druxt/flushResource', {}) + * + * // Flush target resource. + * this.$store.commit('druxt/flushResource', { id, type, prefix, hash }) + */ + flushResource (state, { type, id, prefix }) { + if (!type) Vue.set(state, 'resources', {}) + else if (type && !id && !prefix) Vue.set(state.resources, type, {}) + else if (type && id && !prefix) Vue.set(state.resources[type], id, {}) + else if (type && id && prefix) Vue.set(state.resources[type][id], prefix, {}) + } }, /** @@ -159,15 +197,16 @@ const DruxtStore = ({ store }) => { * const resources = await this.$store.dispatch('druxt/getCollection', { * type: 'node--article', * query: new DrupalJsonApiParams().addFilter('status', '1'), + * bypassCache: false * }) */ - async getCollection ({ commit, state }, { type, query, prefix }) { + async getCollection ({ commit, state }, { type, query, prefix, bypassCache = false }) { // Generate a hash using query data excluding the 'fields' and 'include' data. const queryObject = getDrupalJsonApiParams(query).getQueryObject() const hash = query ? md5(JSON.stringify({ ...queryObject, fields: {}, include: [] })) : '_default' // If collection hash exists, re-hydrate and return the data. - if (((state.collections[type] || {})[hash] || {})[prefix]) { + if (!bypassCache && ((state.collections[type] || {})[hash] || {})[prefix]) { return { ...state.collections[type][hash][prefix], // Hydrate resource data. @@ -197,9 +236,13 @@ const DruxtStore = ({ store }) => { * @return {object} The Drupal JSON:API resource. * * @example @lang js - * const resource = await this.$store.dispatch('druxt/getResource', { type: 'node--article', id }) + * const resource = await this.$store.dispatch('druxt/getResource', { + * type: 'node--article', + * id, + * bypassCache: false + * }) */ - async getResource ({ commit, dispatch, state }, { type, id, query, prefix }) { + async getResource ({ commit, dispatch, state }, { type, id, query, prefix, bypassCache = false }) { // Get the resource from the store if it's avaialble. const storedResource = ((state.resources[type] || {})[id] || {})[prefix] ? { ...state.resources[type][id][prefix] } @@ -257,7 +300,7 @@ const DruxtStore = ({ store }) => { } // Return if we have the full resource. - if ((storedResource || {})._druxt_full) { + if (!bypassCache && (storedResource || {})._druxt_full) { return storedResource } const isFull = typeof (queryObject.fields || {})[type] !== 'string' @@ -279,9 +322,13 @@ const DruxtStore = ({ store }) => { // Request the resource from the DruxtClient if required. let resource - if (!storedResource || fields) { - resource = await this.$druxt.getResource(type, id, getDrupalJsonApiParams(queryObject), prefix) - commit('addResource', { prefix, resource: { ...resource } }) + if (bypassCache || !storedResource || fields) { + try { + resource = await this.$druxt.getResource(type, id, getDrupalJsonApiParams(queryObject), prefix) + commit('addResource', { prefix, resource: { ...resource } }) + } catch(e) { + // Do nothing, just don't error. + } } // Build resource to be returned. @@ -351,6 +398,40 @@ export { DruxtStore } * } */ +/** + * Parameters for the `flushCollection` mutation. + * + * @typedef {object} flushCollectionContext + * + * @param {string} type - The JSON:API collection resource type. + * @param {string} hash - An md5 hash of the query string. + * @param {string} [prefix] - (Optional) The JSON:API endpoint prefix or langcode. + * + * @example @lang js + * { + * type: 'node--page', + * hash: '_default', + * prefix: 'en' + * } + */ + +/** + * Parameters for the `flushResource` mutation. + * + * @typedef {object} flushResourceContext + * + * @param {string} [type] - The JSON:API Resource type. + * @param {string} [id] - The Drupal resource UUID. + * @param {string} [prefix] - (Optional) The JSON:API endpoint prefix or langcode. + * + * @example @lang js + * { + * type: 'node--page', + * id: 'd8dfd355-7f2f-4fc3-a149-288e4e293bdd', + * prefix: 'en' + * } + */ + /** * Parameters for the `getCollection` action. * @@ -359,11 +440,13 @@ export { DruxtStore } * @param {string} type - The JSON:API collection resource type. * @param {DruxtClientQuery} [query] - A correctly formatted JSON:API query string or object. * @param {string} [prefix] - (Optional) The JSON:API endpoint prefix or langcode. + * @param {boolean} [bypassCache] - (Optional) Bypass the Vuex cached collection. * * @example @lang js * { * type: 'node--page', - * query: new DrupalJsonApiParams().addFilter('status', '1') + * query: new DrupalJsonApiParams().addFilter('status', '1'), + * bypassCache: false * } */ @@ -376,12 +459,14 @@ export { DruxtStore } * @param {string} id - The Drupal resource UUID. * @param {DruxtClientQuery} [query] - A correctly formatted JSON:API query string or object. * @param {string} [prefix] - (Optional) The JSON:API endpoint prefix or langcode. + * @param {boolean} [bypassCache] - (Optional) Bypass the Vuex cached resource. * * @example @lang js * { * type: 'node--page', * id: 'd8dfd355-7f2f-4fc3-a149-288e4e293bdd', - * prefix: 'en' + * prefix: 'en', + * bypassCache: false * } */ diff --git a/packages/druxt/test/stores/druxt.test.js b/packages/druxt/test/stores/druxt.test.js index eaa84004e..155ab394a 100644 --- a/packages/druxt/test/stores/druxt.test.js +++ b/packages/druxt/test/stores/druxt.test.js @@ -143,6 +143,22 @@ describe('DruxtStore', () => { expect(mockAxios.get).toHaveBeenCalledTimes(2) expect(storedResource).toStrictEqual(resource) expect(storedResource).toStrictEqual(expected) + + // Assert that: + // - Cache is bypassed + const bypassedResource = await store.dispatch('druxt/getResource', { ...mockPage.data, bypassCache: true }) + delete resource._druxt_full + delete bypassedResource._druxt_full + expect(mockAxios.get).toHaveBeenCalledTimes(3) + expect(bypassedResource).toStrictEqual(resource) + + // Assert that: + // - When bypassing cache, in case live data is unavailable, fallback to cache. + store.$druxt.getResource = jest.fn(() => { throw new Error() }) + const fallback = await store.dispatch('druxt/getResource', { ...mockPage.data, bypassCache: true }) + delete fallback._druxt_full + expect(mockAxios.get).toHaveBeenCalledTimes(3) + expect(fallback).toStrictEqual(bypassedResource) }) test('getResource - filter', async () => { @@ -282,4 +298,51 @@ describe('DruxtStore', () => { await store.dispatch('druxt/getCollection', { type: 'node--page', query: {} }) expect(mockAxios.get).toHaveBeenCalledTimes(2) }) + + test('flushCollection', async () => { + const type = 'node--page' + const hash ='_default' + const prefix = 'en' + + // Ensure that the results state is populated. + const collection = await getMockCollection(type) + store.commit('druxt/addCollection', { collection, type, prefix, hash }) + expect(store.state.druxt.collections[type][hash][prefix]).toStrictEqual(collection) + + store.commit('druxt/flushCollection', { type, hash, prefix }) + expect(store.state.druxt.collections[type][hash][prefix]).toStrictEqual({}) + + store.commit('druxt/flushCollection', { type, hash }) + expect(store.state.druxt.collections[type][hash]).toStrictEqual({}) + + store.commit('druxt/flushCollection', { type }) + expect(store.state.druxt.collections[type]).toStrictEqual({}) + + store.commit('druxt/flushCollection', {}) + expect(store.state.druxt.collections).toStrictEqual({}) + + }) + + test('flushResource', async () => { + const type = 'node--page' + const prefix = 'en' + + // Ensure that the results state is populated. + const resource = await getMockResource(type) + const id = resource.data.id + store.commit('druxt/addResource', { prefix, resource }) + expect(store.state.druxt.resources[type][id][prefix]).toStrictEqual(resource) + + store.commit('druxt/flushResource', { type, id, prefix }) + expect(store.state.druxt.resources[type][id][prefix]).toStrictEqual({}) + + store.commit('druxt/flushResource', { type, id }) + expect(store.state.druxt.resources[type][id]).toStrictEqual({}) + + store.commit('druxt/flushResource', { type }) + expect(store.state.druxt.resources[type]).toStrictEqual({}) + + store.commit('druxt/flushResource', {}) + expect(store.state.druxt.resources).toStrictEqual({}) + }) }) diff --git a/packages/entity/src/components/DruxtEntity.vue b/packages/entity/src/components/DruxtEntity.vue index 87f9a0eed..23ccab4d8 100644 --- a/packages/entity/src/components/DruxtEntity.vue +++ b/packages/entity/src/components/DruxtEntity.vue @@ -187,6 +187,14 @@ export default { }, }, + mounted() { + // If static, re-fetch data allowing for cache-bypass. + // @TODO - Don't re-fetch in serverless configuration. + if (this.$store.app.context.isStatic) { + this.$fetch() + } + }, + methods: { /** * Builds the query for the JSON:API request. @@ -318,12 +326,22 @@ export default { } if (this.uuid && !this.value) { + // Check if we need to bypass cache. + let bypassCache = false + if (typeof (settings.query || {}).bypassCache === 'boolean') { + bypassCache = settings.query.bypassCache + } + + // Build query. const query = this.getQuery(settings) + + // Execute the resquest. const resource = await this.getResource({ id: this.uuid, prefix: this.lang, type: this.type, - query + query, + bypassCache }) const entity = { ...(resource.data || {}) } entity.included = resource.included @@ -342,12 +360,20 @@ export default { /** * Component settings. */ - settings: ({ $druxt, settings }, wrapperSettings) => { + settings: (context, wrapperSettings) => { + const { $druxt, settings } = context + // Start with the `nuxt.config.js` `druxt.settings.entity` settings and // merge the Wrapper component settings on top. - let mergedSettings = merge($druxt.settings.entity || {}, wrapperSettings, { arrayMerge: (dest, src) => src }) + let mergedSettings = merge(($druxt.settings || {}).entity || {}, wrapperSettings || {}, { arrayMerge: (dest, src) => src }) // Merge the DruxtEntity component `settings` property on top. - mergedSettings = merge(mergedSettings || {}, settings, { arrayMerge: (dest, src) => src }) + mergedSettings = merge(mergedSettings || {}, settings || {}, { arrayMerge: (dest, src) => src }) + + // Evaluate the bypass cache function. + if (typeof (mergedSettings.query || {}).bypassCache === 'function') { + mergedSettings.query.bypassCache = !!mergedSettings.query.bypassCache(context) + } + // Currently only returning the query settings. return { query: mergedSettings.query || {}, @@ -485,7 +511,8 @@ export default { * property. * * @typedef {object} ModuleSettings - * @param {object} query - Entity Query settings: + * @param {object} query - Entity query settings: + * @param {(boolean|function)} query.bypassCache - Whether to pull the data from the Vuex store or from the JSON:API. * @param {(string[]|array[])} query.fields - An array or arrays of fields to filter from the JSON:API Resources. * @param {string[]} query.include - An array of relationships to include alongside the JSON:API Resource. * @param {boolean} query.schema - Whether to automatically detect fields to filter, per the Display mode. @@ -495,11 +522,12 @@ export default { * export default { * druxt: { * query: { + * bypassCache: ({ $store }) => $store.$auth.loggedIn, * fields: [['title'], ['user--user', ['display_name']]], * include: ['uid'] * schema: true, * }, - * } + * }, * } * * @example DruxtEntity component with settings @lang vue @@ -509,6 +537,7 @@ export default { * :uuid="uuid" * :settings="{ * query: { + * bypassCache: true, * fields: [['title'], ['user--user', ['display_name']]], * include: ['uid'] * schema: true, diff --git a/packages/views/src/components/DruxtView.vue b/packages/views/src/components/DruxtView.vue index bc066e186..3159645e2 100644 --- a/packages/views/src/components/DruxtView.vue +++ b/packages/views/src/components/DruxtView.vue @@ -78,6 +78,17 @@ export default { default: 'default', }, + /** + * Module settings object. + * + * @type {ModuleSettings} + * @default {} + */ + settings: { + type: Object, + default: () => ({}), + }, + /** * JSON:API Resource type. * @@ -329,6 +340,14 @@ export default { }, }, + mounted() { + // If static, re-fetch data allowing for cache-bypass. + // @TODO - Don't re-fetch in serverless configuration. + if (this.$store.app.context.isStatic) { + this.$fetch() + } + }, + methods: { /** * Builds the query for the JSON:API request. @@ -446,12 +465,22 @@ export default { async fetchData(settings) { const viewId = this.viewId || (((this.view || {}).data || {}).attributes || {}).drupal_internal__id if (viewId) { + // Check if we need to bypass cache. + let bypassCache = false + if (typeof (settings.query || {}).bypassCache === 'boolean') { + bypassCache = settings.query.bypassCache + } + + // Build query. const query = this.getQuery(settings) + + // Execute the resquest. this.resource = await this.getResults({ displayId: this.displayId, prefix: this.lang, query: stringify(query), - viewId + viewId, + bypassCache }) } }, @@ -474,10 +503,24 @@ export default { /** * Component settings. */ - settings: ({ $druxt }, wrapperSettings) => { - const settings = merge($druxt.settings.views || {}, wrapperSettings, { arrayMerge: (dest, src) => src }) + settings: (context, wrapperSettings) => { + const { $druxt, settings } = context + + // Start with the `nuxt.config.js` `druxt.settings.views` settings and + // merge the Wrapper component settings on top. + let mergedSettings = merge(($druxt.settings || {}).views || {}, wrapperSettings || {}, { arrayMerge: (dest, src) => src }) + // Merge the DruxtViews component `settings` property on top. + mergedSettings = merge(mergedSettings || {}, settings || {}, { arrayMerge: (dest, src) => src }) + + // Evaluate the bypass cache function. + if (typeof (mergedSettings.query || {}).bypassCache === 'function') { + mergedSettings.query.bypassCache = !!mergedSettings.query.bypassCache(context) + } + + console.log('mergedSettings', mergedSettings) + return { - query: settings.query || {}, + query: mergedSettings.query || {}, } }, @@ -692,28 +735,40 @@ export default { * or the Wrapper component `druxt` object. * * @typedef {object} ModuleSettings - * @param {boolean} bundleFilter - Whether to automatically detect Resource types to filter, based on the View `bundle` filter. - * @param {string[]} fields - An array of fields to filter from the JSON:API Views Resource types. - * @param {string[]} resourceTypes - An array of Resource types to be used by the Fields filter. - * - * @example @lang js - * { - * bundleFilter: false, - * fields: [], - * resourceTypes: [] - * } + * @param {object} query - View results query settings: + * @param {(boolean|function)} query.bypassCache - Whether to pull the data from the Vuex store or from the JSON:API. + * @param {boolean} query.bundleFilter - Whether to automatically detect Resource types to filter, based on the View `bundle` filter. + * @param {string[]} query.fields - An array of fields to filter from the JSON:API Views Resource types. + * @param {string[]} query.resourceTypes - An array of Resource types to be used by the Fields filter. * - * @example @lang vue + * @example DruxtView Wrapper component @lang vue *