vue2 composition api beforeRouteUpdate updating after the route changes - vuejs2
I have this route:
{
path: "/categories/:categorySlug",
name: "product-list",
meta: {
title: "Categories",
},
},
When the component first loads, it pulls in the category and any products related to it. The problem is when I change the category slug, for some reason the category updates fine, but the products do not.
I decided to add a beforeRouteUpdate to force the products to change and I set it up like this:
export default defineComponent({
name: "ProductList",
components: { Brands, Chooser, Products },
setup() {
const instance = getCurrentInstance();
const searchTerm = computed(() => {
return instance.proxy.$route.params.searchTerm;
});
const {
brands,
brandError,
brandFacets,
brandsLoading,
brandsHasMoreResults,
brandsItemsToShow,
brandsQuery,
brandsTotal,
brandsFetchMore,
} = useSearchBrands(searchTerm, 12, [], true);
const {
category,
categoryError,
categoryLoading,
products,
productError,
productFacets,
productsLoading,
productsHasMoreResults,
productsItemsToShow,
productsQuery,
productsTotal,
productsFetchMore,
} = useListProducts(instance);
return {
brands,
brandError,
brandFacets,
brandsLoading,
brandsHasMoreResults,
brandsItemsToShow,
brandsQuery,
brandsTotal,
category,
categoryError,
categoryLoading,
products,
productError,
productFacets,
productsLoading,
productsHasMoreResults,
productsItemsToShow,
productsQuery,
productsTotal,
brandsFetchMore,
productsFetchMore,
};
},
beforeRouteUpdate(to, from, next) {
console.log(to);
this.productsQuery.refetch();
next();
},
});
At first glance it didn't look like anything happened, because if I changed route it still showed the same products as the first load. But if I change again, I noticed that it now displayed the products from the previous route change:
And if I change route from ovens to coffee-machines it will show the ovens.
I cant use beforeRouteEnter because I don't have access to this and using beforeRouteLeave doesn't update the component at all, even though in my logs, I can see the request is changing.
So, to summarise, when I change route using beforeRouteUpdate I can see in my logs that the request changes to the correct category and I can see the correct products returned, but I don't see the results in the component until I change route again (with the same component).
Does anyone know how I can fix this?
Update
I have been asked to display the code for my product list. I use vue apollo and I have two generic functions, the first is useGraphQuery which looks like this:
import { ref } from "vue-demi";
import { useQuery, useResult } from "#vue/apollo-composable";
export function useGraphQuery(params, gql, pathFn, clientId = "apiClient") {
if (!params?.value)
return {
response: ref(undefined),
loading: ref(false),
error: ref(undefined),
query: ref(undefined),
};
// TODO: figure our a way to skip the call if the parameters are null
const { result, loading, error, query, fetchMore } = useQuery(gql, params, {
clientId,
//enabled: !!params?.value,
});
const response = useResult(result, null, pathFn);
return { response, loading, error, query, fetchMore };
}
All my graphql queries use this.
Then for searches (i.e. the product search), uses another function called useGraphSearch:
import { computed } from "#vue/composition-api";
import { useGraphQuery } from "./graph-query";
export function useGraphSearch(params, gql, pathFn) {
const { response, loading, error, query, fetchMore } = useGraphQuery(
params,
gql,
pathFn
);
const items = computed(() => {
if (!response.value) return [];
return response.value.items;
});
const facets = computed(() => {
if (!response.value) return [];
return response.value.facets;
});
const total = computed(() => {
if (!response.value) return 0;
return response.value.total;
});
const hasMoreResults = computed(() => {
if (!response.value) return false;
return response.value.hasMoreResults;
});
const itemsToShow = computed(() => params.value.search.itemsToShow);
const more = () => {
useGetMore(params.value, fetchMore);
};
return {
error,
facets,
hasMoreResults,
items,
itemsToShow,
loading,
query,
total,
more,
};
}
function useGetMore(params, fetchMore) {
params.search.page++;
fetchMore({
variables: params,
});
}
On the route I mentioned before, there are 3 queries running. One of them seems to work without doing anything. that is the useGetCategory which looks like this:
import { computed } from "#vue/composition-api";
import * as getCategoryBySlug from "#graphql/api/query.category.gql";
import { useGraphQuery } from "./graph-query";
export function useGetCategory(instance) {
const params = computed(() => {
const route = instance.proxy.$route;
const slug = route.params.categorySlug;
if (!slug) return;
return { slug };
});
const { response, error, loading } = useGraphQuery(
params,
getCategoryBySlug,
(data) => data.categoryBySlug
);
return { category: response, categoryError: error, categoryLoading: loading };
}
This updates regardless whether I called beforeRouteUpdate or not.
The second one is the useListProducts which looks like this:
import { ComponentInternalInstance } from "#vue/composition-api";
import { useSearchCategoryProducts } from "#logic/search-products";
import { useTrackProductImpressions } from "#logic/track-product-impressions";
import { useTrackProductClick } from "#/_shared/logic/track-product-click";
export function useListProducts(instance: ComponentInternalInstance) {
const {
products,
productError,
productFacets,
productsLoading,
productsHasMoreResults,
productsItemsToShow,
productsQuery,
productsTotal,
productsFetchMore,
} = useSearchCategoryProducts(instance);
return {
products,
productError,
productFacets,
productsLoading,
productsHasMoreResults,
productsItemsToShow,
productsQuery,
productsTotal,
productsFetchMore,
};
}
as you can see here, this calls useSearchCategoryProducts which is just used to create the parameters and looks like this:
export function useSearchCategoryProducts(
instance: ComponentInternalInstance,
orderBy = [{ key: "InVenue", value: "desc" }]
) {
const params = computed(() => {
const slug = instance.proxy.$route.params.categorySlug;
if (!slug) return;
const filters = createFilters("CategorySlug", [slug]);
const request = createRequest(defaultParameters, 1, filters, orderBy);
return { search: request };
});
return queryProducts(params);
}
function queryProducts(params) {
console.log(params);
const {
error,
facets,
hasMoreResults,
items,
itemsToShow,
loading,
query,
total,
more,
} = useGraphSearch(params, searchProducts, (data) => data.searchProducts);
return {
products: items,
productError: error,
productsLoading: loading,
productFacets: facets,
productsHasMoreResults: hasMoreResults,
productsItemsToShow: itemsToShow,
productsTotal: total,
productsQuery: query,
productsFetchMore: more,
};
}
You can see the console.log in the private function queryProducts which I can see that the params are updating.
I know this is a lot to take in, but I have created the useGraphQuery and useGraphSearch so I can ensure that every query I create is the same and should work in the same way. The reason useGetCategory and useListProducts don't work in the same way (i.e. the category changes when the route does, but the product list doesn't) is beyond me and it is the reason I am trying to implement beforeRouteUpdate at all.
The code for the setup looks like this btw:
import {
computed,
defineComponent,
getCurrentInstance,
} from "#vue/composition-api";
import Brands from "#components/brands/brands.component.vue";
import Chooser from "#components/chooser/chooser.component.vue";
import Products from "#components/products/products.component.vue";
import { useListProducts } from "./list-products";
import { useSearchBrands } from "#logic/search-brands";
import { useGetCategory } from "#logic/get-category";
export default defineComponent({
name: "ProductList",
components: { Brands, Chooser, Products },
setup() {
const instance = getCurrentInstance();
const searchTerm = computed(() => {
return instance.proxy.$route.params.categorySlug;
});
const { category, categoryError, categoryLoading } =
useGetCategory(instance);
const {
brands,
brandError,
brandFacets,
brandsLoading,
brandsHasMoreResults,
brandsItemsToShow,
brandsQuery,
brandsTotal,
brandsFetchMore,
} = useSearchBrands(searchTerm, 12, [], true);
const {
products,
productError,
productFacets,
productsLoading,
productsHasMoreResults,
productsItemsToShow,
productsQuery,
productsTotal,
productsFetchMore,
} = useListProducts(instance);
return {
brands,
brandError,
brandFacets,
brandsLoading,
brandsHasMoreResults,
brandsItemsToShow,
brandsQuery,
brandsTotal,
category,
categoryError,
categoryLoading,
products,
productError,
productFacets,
productsLoading,
productsHasMoreResults,
productsItemsToShow,
productsQuery,
productsTotal,
brandsFetchMore,
productsFetchMore,
};
},
beforeRouteUpdate(to, from, next) {
console.log(to);
this.brandsQuery.refetch();
this.productsQuery.refetch();
next();
},
});
You’re not using to for anything in the route update handler. If your loading code depends on the route it will have the previous information since the event happens before it is updated.
You’ll need to give the information about the to route to the code that is doing the loading, or use an after event.
So I managed to fix this. It was down to my cache policy.
I had been doing this:
const typePolicy = {
keyArgs: ["search", ["page", "skip"]],
// Concatenate the incoming list items with
// the existing list items.
merge(existing: any = {}, incoming: any) {
const items = (existing.items ? existing.items : []).concat(incoming.items);
const item = { ...existing, ...incoming };
item.items = items;
return item;
},
};
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
searchCategories: typePolicy,
searchBrands: typePolicy,
searchPages: typePolicy,
searchProducts: typePolicy,
},
},
},
});
So that my paginated results were always appended to my current list, but for some reason that was causing an issue. When I swapped to this:
const cache = new InMemoryCache();
My issues were resolved.
Related
Trying to change change page title, using Quasar, meta and vue-i18n
I am using Quasar v2, using the Vue Composition API and vue-i18n, and I would like the site title to change display when the active language changes (via a drop down), but whatever I am trying does not result in the title language being changed. Any ideas? Below is what I have right now (just the essentials): import { defineComponent, ref, computed } from 'vue'; import { useMeta } from 'quasar'; export default defineComponent({ setup () { const { t: translate } = useI18n() as any; const siteTitle = computed(() => translate('title.app') as string); const pageMetadata = { title: 'untitled', titleTemplate: (title: string) => `${title} - ${siteTitle.value}` }; useMeta(pageMetadata); } }); The code I am using to switch languages: async onChangeLanguage () { try { let locale = this.language; if (this.language === 'en') { locale = 'en-GB'; } this.$i18n.locale = locale; const quasarLang = await import(`quasar/lang/${locale}`); if (quasarLang) { Quasar.lang.set(quasarLang.default); } } catch (error) { this.$log.error(error); } }
According to the documentation, useMeta will not be reactive if you pass a simple object to it. Rather, you should pass a function that returns the desired value: export default defineComponent({ setup () { const { t: translate } = useI18n() as any; const siteTitle = computed(() => translate('title.app') as string); useMeta(() => { const title = 'untitled'; const titleTemplate = `${title} - ${siteTitle.value}` return { title, titleTemplate } }); });
How to get the cookie value and put it into the Vuex store after refreshing page in vue
I have a product component and I have an I am adding products into cart there: addToCart: function () { this.amount = this.itemsCount !== "" ? this.itemsCount : 1; if(this.variationId != null) { this.warningMessage = false; cartHelper.addToCart(this.product.id, this.variationId, parseInt(this.amount), (response) => { this.$store.dispatch('addProductToCart', { cart: response.data, }) }); } else { this.warningMessage = true; } }, And I also have cart helper where I am making my API calls and store the cart_guid in the cookie: let cartHelper = { cartCookieName: "_cart", cookieValue: "", getCart: function (callback = undefined) { return apiHelper.getRequest( "/carts", (response) => { document.cookie = `${this.cartCookieName}=${response.data.attributes.cart_guid};`; this.cookieValue = response.data.attributes.cart_guid; if (callback) { callback(response); } } ) }, addToCart: function (product, variation_id, amount, callback = undefined) { if(this.cookieValue == "") { this.getCart(() => { this._addToCart(product, variation_id, amount, callback); }); } else { this._addToCart(product, variation_id, amount, callback) } }, _addToCart(product, variation_id, amount, callback = undefined) { return apiHelper.postRequest( `/carts/${this.cookieValue}/add-item`, (response) => { document.cookie = `${this.cartCookieName}=${response.data.attributes.cart_guid};`; if (callback) { callback(response); } }, { product_id: product, variation_id: variation_id, amount: amount, } ) }, export default cartHelper; (I didnt write the code where I am storing the cart_guid in the cookie. I dont think it is necessary, it is basically cookieValue) So when I add the product into the cart, I am storing this data in Vuex. For this my action: export const addProductToCart = ({commit}, {cart}) => { commit('ADD_TO_CART', {cart}); } my mutation: export const ADD_TO_CART = (state, {cart}) => { state.cart = cart; } and my state: export default { cart: { "attributes": { "items": [], } } } What I am trying to do when I refresh the page, the values in Vuex are lost but since there is still a cookie with the value cart_guid, I should basically make this call and fill the Vuex again with the cart_guid. But I am quite new in Vuex, so I don't know where I should put the logic. I would be really glad if you give me any hint or code.
There is a onMounted lifecycle where the code inside will run whenever the vue component has been successfully mounted onto the DOM. You can put your function where it retrieves the value of your cookie in there so it will after mounted.
vue apollo 2 composition api track result
So I am trying to add product impressions to my site by following this article: https://developers.google.com/tag-manager/enhanced-ecommerce#product-impressions I have created a bit of logic to fire off the required data like this: import { getCurrentInstance } from "#vue/composition-api"; import { useGtm } from "#gtm-support/vue2-gtm"; export function useTrackProductImpressions(items: any[]) { console.log("trying to track products", items); if (!items?.length) return; const gtm = useGtm(); if (!gtm.enabled()) return; const dataLayer = window.dataLayer; if (!dataLayer) return; console.log(items); const products = items.map((product, i) => { const retailers = product.retailers ?? []; return { name: product.title, // Name or ID is required. id: product.id, price: retailers[0].price, brand: product.brand, category: product.categorySlug, variant: product.variant, position: i, }; }); const instance = getCurrentInstance(); const route = instance.proxy.$route; const routeName = route.meta?.title ?? route.name; dataLayer.push({ ecommerce: null }); // Clear the previous ecommerce object. dataLayer.push({ event: "productClick", ecommerce: { click: { actionField: { list: routeName }, // Optional list property. products, }, }, // eventCallback: function () { // document.location = productObj.url; // }, }); } This seems pretty normal and I have a click version of this that works fine. The problem is, the click event can be fired when a link is clicked, this one needs to fire when the view loads, I assume in setup. So, I have my apollo logic: import { useQuery, useResult } from "#vue/apollo-composable"; import * as listProducts from "#graphql/api/query.products.gql"; export const defaultParameters: { identifier?: string; searchTerm: string; itemsToShow: number; page: number; filters: any; facets: string[]; } = { searchTerm: "*", itemsToShow: 12, page: 1, filters: [], facets: ["Criteria/Attribute,count:100"], }; export function useSearchProducts(params) { const { result, loading, error, fetchMore } = useQuery(listProducts, params); const response = useResult(result, null, (data) => data.searchProducts); return { response, loading, error, fetchMore }; } And from my setup I invoke like this: const { category } = toRefs(props); const page = ref(1); const skip = ref(0); const orderBy = ref([ { key: "InVenue", value: "desc", }, ]); const params = computed(() => { const filters = createFilters("CategorySlug", [category.value.slug]); const request = createRequest( defaultParameters, page.value, filters, orderBy.value ); return { search: request }; }); const { response, loading, error} = useSearchProducts(params); Which I can then return to the template like this: return { response, loading, error }; Now I have done this, I want to add some tracking, so initially I did this: watch(response, (result) => useTrackProductImpressions(result?.value?.items)); But it was always undefined. I added console log on result within the watch method and it is always undefined. So I changed to this: const track = computed(() => { useTrackProductImpressions(response.value.items); }); But this never gets invoked (I assume because it has no return value and I don't use it in the template). My question is, which is the best way to do what I am attempting? Am I missing something or am I on the write track?
I think I was close, I just used the computed property to return my products like this: const products = computed(() => { if (!response.value) return []; useTrackProductImpressions(response.value.items); return response.value.items; }); const total = computed(() => { if (!response.value) return 0; return response.value.total; }); const hasMoreResults = computed(() => { if (!response.value) return false; return response.value.hasMoreResults; }); return { products, loading, error, total, hasMoreResults, skip, more, search, };
Vue 2 composition API watching the store
I have a store which is just an array of strings. I am trying to watch it and do a search when it has changed. Originally I had a computed value a bit like this: const { value } = computed(() => { const urls = store.getters.wishlist; filters.value = createFilters("IndexUrl", urls); return useListProducts(page.value, filters.value); }); which I returned like this: return { ...value, skip, more }; This worked fine when loading the page the first time, but if another component adds/removes something from the wishlist I want the function to fire again. For context, here is the whole component: import { computed, defineComponent, getCurrentInstance, ref, } from "#vue/composition-api"; import Product from "#components/product/product.component.vue"; import { createFilters, createRequest, useListProducts, } from "#/_shared/logic/list-products"; export default defineComponent({ name: "Wishlist", components: { Product }, setup() { const instance = getCurrentInstance(); const store = instance.proxy.$store; const page = ref(1); const skip = ref(0); const filters = ref([]); const { value } = computed(() => { const urls = store.getters.wishlist; filters.value = createFilters("IndexUrl", urls); return useListProducts(page.value, filters.value); }); const more = () => { skip.value += 12; page.value += 1; const request = createRequest(page.value, filters.value); value.fetchMore({ variables: { search: request }, updateQuery: (prev, { fetchMoreResult }) => { if (!fetchMoreResult) return prev; return { search: { __typename: prev.search.__typename, hasMoreResults: fetchMoreResult.search.hasMoreResults, total: fetchMoreResult.search.total, facets: [...prev.search.facets, ...fetchMoreResult.search.facets], items: [...prev.search.items, ...fetchMoreResult.search.items], }, }; }, }); }; return { ...value, skip, more }; }, }); So I figured that the issue was that I wasn't actually watching anything, so I removed the computed method and instead decided to setup a watch. First I created a listProducts method: const result = reactive({ result: null, loading: null, error: null, fetchMore: null, }); const listProducts = (urls: string[]) => { console.log(urls); filters.value = createFilters("IndexUrl", urls); Object.assign(result, useListProducts(page.value, filters.value)); }; And then I invoked that in my setup: listProducts(store.getters.wishlist); Then I setup a watch: watch(store.getters.wishlist, (urls: string[]) => listProducts(urls)); What I expected to happen, was that when an item was added/remove from the wishlist store, it would then invoke listProducts with the new set of urls. But it didn't fire at all. Does anyone know what I am doing wrong?
I believe the issue is with destructuring the reactive property, on destructuring you assign the properties to variables and no longer have a proxy to react to changes..try return { value, skip, more }; and reference the property in your template <template> {{value.foo}} </template this question has to do with setup props but the same concept applies Vue 3 watch doesn’t work if I watch a destructured prop
How to correctly test effects in ngrx 4?
There are plenty of tutorials how to test effects in ngrx 3. However, I've found only 1 or 2 for ngrx4 (where they removed the classical approach via EffectsTestingModule ), e.g. the official tutorial However, in my case their approach doesn't work. effects.spec.ts (under src/modules/list/store/list in the link below) describe('addItem$', () => { it('should return LoadItemsSuccess action for each item', async() => { const item = makeItem(Faker.random.word); actions = hot('--a-', { a: new AddItem({ item })}); const expected = cold('--b', { b: new AddUpdateItemSuccess({ item }) }); // comparing marbles expect(effects.addItem$).toBeObservable(expected); }); }) effects.ts (under src/modules/list/store/list in the link below) ... #Effect() addItem$ = this._actions$ .ofType(ADD_ITEM) .map<AddItem, {item: Item}>(action => { return action.payload }) .mergeMap<{item: Item}, Observable<Item>>(payload => { return Observable.fromPromise(this._listService.add(payload.item)) }) .map<any, AddUpdateItemSuccess>(item => { return new AddUpdateItemSuccess({ item, }) }); ... Error should return LoadItemsSuccess action for each item Expected $.length = 0 to equal 1. Expected $[0] = undefined to equal Object({ frame: 20, notification: Notification({ kind: 'N', value: AddUpdateItemSuccess({ payload: Object({ item: Object({ title: Function }) }), type: 'ADD_UPDATE_ITEM_SUCCESS' }), error: undefined, hasValue: true }) }). at compare (webpack:///node_modules/jasmine-marbles/index.js:82:0 <- karma-test-shim.js:159059:33) at Object.<anonymous> (webpack:///src/modules/list/store/list/effects.spec.ts:58:31 <- karma-test-shim.js:131230:42) at step (karma-test-shim.js:131170:23) NOTE: the effects use a service which involves writing to PouchDB. However, the issue doesn't seem related to that and also the effects work in the running app. The full code is a Ionic 3 app and be found here (just clone, npm i and npm run test) UPDATE: With ReplaySubject it works, but not with hot/cold marbles const item = makeItem(Faker.random.word); actions = new ReplaySubject(1) // = Observable + Observer, 1 = buffer size actions.next(new AddItem({ item })); effects.addItem$.subscribe(result => { expect(result).toEqual(new AddUpdateItemSuccess({ item })); });
My question was answered by #phillipzada at the Github issue I posted. For anyone checking this out later, I report here the answer: Looks like this is a RxJS issue when using promises using marbles. https://stackoverflow.com/a/46313743/4148561 I did manage to do a bit of a hack which should work, however, you will need to put a separate test the service is being called unless you can update the service to return an observable instead of a promise. Essentially what I did was extract the Observable.fromPromise call into its own "internal function" which we can mock to simulate a call to the service, then it looks from there. This way you can test the internal function _addItem without using marbles. Effect import 'rxjs/add/observable/fromPromise'; import 'rxjs/add/operator/map'; import 'rxjs/add/operator/mergeMap'; import { Injectable } from '#angular/core'; import { Actions, Effect } from '#ngrx/effects'; import { Action } from '#ngrx/store'; import { Observable } from 'rxjs/Observable'; export const ADD_ITEM = 'Add Item'; export const ADD_UPDATE_ITEM_SUCCESS = 'Add Item Success'; export class AddItem implements Action { type: string = ADD_ITEM; constructor(public payload: { item: any }) { } } export class AddUpdateItemSuccess implements Action { type: string = ADD_UPDATE_ITEM_SUCCESS; constructor(public payload: { item: any }) { } } export class Item { } export class ListingService { add(item: Item) { return new Promise((resolve, reject) => { resolve(item); }); } } #Injectable() export class SutEffect { _addItem(payload: { item: Item }) { return Observable.fromPromise(this._listService.add(payload.item)); } #Effect() addItem$ = this._actions$ .ofType<AddItem>(ADD_ITEM) .map(action => action.payload) .mergeMap<{ item: Item }, Observable<Item>>(payload => { return this._addItem(payload).map(item => new AddUpdateItemSuccess({ item, })); }); constructor( private _actions$: Actions, private _listService: ListingService) { } } Spec import { cold, hot, getTestScheduler } from 'jasmine-marbles'; import { async, TestBed } from '#angular/core/testing'; import { Actions } from '#ngrx/effects'; import { Store, StoreModule } from '#ngrx/store'; import { getTestActions, TestActions } from 'app/tests/sut.helpers'; import { AddItem, AddUpdateItemSuccess, ListingService, SutEffect } from './sut.effect'; import { Observable } from 'rxjs/Observable'; import 'rxjs/add/observable/of'; describe('Effect Tests', () => { let store: Store<any>; let storeSpy: jasmine.Spy; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ StoreModule.forRoot({}) ], providers: [ SutEffect, { provide: ListingService, useValue: jasmine.createSpyObj('ListingService', ['add']) }, { provide: Actions, useFactory: getTestActions } ] }); store = TestBed.get(Store); storeSpy = spyOn(store, 'dispatch').and.callThrough(); storeSpy = spyOn(store, 'select').and.callThrough(); })); function setup() { return { effects: TestBed.get(SutEffect) as SutEffect, listingService: TestBed.get(ListingService) as jasmine.SpyObj<ListingService>, actions$: TestBed.get(Actions) as TestActions }; } fdescribe('addItem$', () => { it('should return LoadItemsSuccess action for each item', async () => { const { effects, listingService, actions$ } = setup(); const action = new AddItem({ item: 'test' }); const completion = new AddUpdateItemSuccess({ item: 'test' }); // mock this function which we can test later on, due to the promise issue spyOn(effects, '_addItem').and.returnValue(Observable.of('test')); actions$.stream = hot('-a|', { a: action }); const expected = cold('-b|', { b: completion }); expect(effects.addItem$).toBeObservable(expected); expect(effects._addItem).toHaveBeenCalled(); }); }) }) Helpers import { Actions } from '#ngrx/effects'; import { Observable } from 'rxjs/Observable'; import { empty } from 'rxjs/observable/empty'; export class TestActions extends Actions { constructor() { super(empty()); } set stream(source: Observable<any>) { this.source = source; } } export function getTestActions() { return new TestActions(); }