Skip to content

Commit 34e2f2f

Browse files
authored
fix(theme): out of stock product will no longer break cart (#404)
* fix(theme): out of stock product will no longer break cart * fix(theme): remove cart load from layout * docs(theme): update tests for CartSidebar Co-authored-by: Bartosz Herba <[email protected]>
1 parent 03102de commit 34e2f2f

File tree

14 files changed

+472
-32
lines changed

14 files changed

+472
-32
lines changed

packages/api-client/src/index.server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ const onCreate = (settings: Config): { config: Config; client: ClientInstance }
3232
const client = apolloClientFactory({
3333
link: apolloLink,
3434
...settings.customOptions,
35+
defaultOptions: {
36+
query: {
37+
errorPolicy: 'all',
38+
},
39+
},
3540
});
3641

3742
return {

packages/composables/src/composables/useCart/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const factoryParams: UseCartFactoryParams<Cart, CartItem, Product> = {
5656
throw errors[0];
5757
}
5858

59+
data.cart.items = data.cart.items.filter(Boolean);
5960
return data.cart as unknown as Cart;
6061
};
6162

@@ -83,6 +84,7 @@ const factoryParams: UseCartFactoryParams<Cart, CartItem, Product> = {
8384
}
8485

8586
apiState.setCartId(data.customerCart.id);
87+
data.customerCart.items = data.customerCart.items.filter(Boolean);
8688

8789
return data.customerCart as unknown as Cart;
8890
} catch {
@@ -381,7 +383,7 @@ const factoryParams: UseCartFactoryParams<Cart, CartItem, Product> = {
381383
currentCart,
382384
product,
383385
},
384-
) => !!currentCart?.items.find((cartItem) => cartItem.product.uid === product.uid),
386+
) => !!currentCart?.items.find((cartItem) => cartItem?.product?.uid === product.uid),
385387
};
386388

387389
export default useCartFactory<Cart, CartItem, Product>(factoryParams);

packages/composables/src/getters/cartGetters.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,7 @@ export const getItemAttributes = (
8989
return attributes;
9090
};
9191

92-
export const getItemSku = (product: CartItem): string => {
93-
if (!product.product) {
94-
return '';
95-
}
96-
97-
return product.product.sku;
98-
};
92+
export const getItemSku = (product: CartItem): string => product?.product?.sku || '';
9993

10094
const calculateDiscounts = (discounts: Discount[]): number => discounts.reduce((a, b) => Number.parseFloat(`${a}`) + Number.parseFloat(`${b.amount.value}`), 0);
10195

@@ -173,14 +167,13 @@ export const getAvailablePaymentMethods = (cart: Cart): AgnosticPaymentMethod[]
173167
value: p.code,
174168
}));
175169

170+
export const getStockStatus = (product: CartItem): string => product.product.stock_status;
176171
export interface CartGetters extends CartGettersBase<Cart, CartItem> {
177172
getAppliedCoupon(cart: Cart): AgnosticCoupon | null;
178-
179173
getAvailablePaymentMethods(cart: Cart): AgnosticPaymentMethod[];
180-
181174
getSelectedShippingMethod(cart: Cart): SelectedShippingMethod | null;
182-
183175
productHasSpecialPrice(product: CartItem): boolean;
176+
getStockStatus(product: CartItem): string;
184177
}
185178

186179
const cartGetters: CartGetters = {
@@ -202,6 +195,7 @@ const cartGetters: CartGetters = {
202195
getTotalItems,
203196
getTotals,
204197
productHasSpecialPrice,
198+
getStockStatus,
205199
};
206200

207201
export default cartGetters;

packages/theme/components/CartSidebar.vue

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,6 @@
105105
$n(cartGetters.getItemPrice(product).special, 'currency')
106106
: ''
107107
"
108-
:stock="99999"
109-
:qty="cartGetters.getItemQty(product)"
110108
:link="
111109
localePath(
112110
`/p/${cartGetters.getItemSku(product)}${cartGetters.getSlug(
@@ -119,14 +117,25 @@
119117
@click:remove="sendToRemove({ product })"
120118
>
121119
<template #input>
122-
<div class="sf-collected-product__quantity-wrapper">
120+
<div
121+
v-if="isInStock(product)"
122+
class="sf-collected-product__quantity-wrapper"
123+
>
123124
<SfQuantitySelector
124125
:disabled="loading"
125126
:qty="cartGetters.getItemQty(product)"
126127
class="sf-collected-product__quantity-selector"
127128
@input="updateItemQty({ product, quantity: $event })"
128129
/>
129130
</div>
131+
<SfBadge
132+
v-else
133+
class="color-danger sf-badge__absolute"
134+
>
135+
<template #default>
136+
<span>{{ $t('Out of stock') }}</span>
137+
</template>
138+
</SfBadge>
130139
</template>
131140
<template #configuration>
132141
<div v-if="getAttributes(product).length > 0">
@@ -221,6 +230,7 @@
221230
</div>
222231
</template>
223232
<script>
233+
/* eslint-disable @typescript-eslint/no-unsafe-argument */
224234
import {
225235
SfLoader,
226236
SfNotification,
@@ -232,23 +242,24 @@ import {
232242
SfCollectedProduct,
233243
SfImage,
234244
SfQuantitySelector,
245+
SfBadge,
235246
} from '@storefront-ui/vue';
236247
import {
237248
computed,
238249
defineComponent,
239250
ref,
240251
useRouter,
241-
useContext,
252+
useContext, onMounted,
242253
} from '@nuxtjs/composition-api';
243254
import {
244255
useCart,
245256
useUser,
246257
cartGetters,
247258
useExternalCheckout,
248259
} from '@vue-storefront/magento';
249-
import { onSSR } from '@vue-storefront/core';
250260
import { useUiState, useUiNotification } from '~/composables';
251261
import CouponCode from './CouponCode.vue';
262+
import stockStatusEnum from '~/enums/stockStatusEnum';
252263
253264
export default defineComponent({
254265
name: 'CartSidebar',
@@ -263,6 +274,7 @@ export default defineComponent({
263274
SfCollectedProduct,
264275
SfImage,
265276
SfQuantitySelector,
277+
SfBadge,
266278
CouponCode,
267279
},
268280
setup() {
@@ -280,7 +292,7 @@ export default defineComponent({
280292
const { isAuthenticated } = useUser();
281293
const { send: sendNotification, notifications } = useUiNotification();
282294
283-
const products = computed(() => cartGetters.getItems(cart.value));
295+
const products = computed(() => cartGetters.getItems(cart.value).filter(Boolean));
284296
const totals = computed(() => cartGetters.getTotals(cart.value));
285297
const totalItems = computed(() => cartGetters.getTotalItems(cart.value));
286298
const getAttributes = (product) => product.configurable_options || [];
@@ -289,8 +301,9 @@ export default defineComponent({
289301
const isLoaderVisible = ref(false);
290302
const tempProduct = ref();
291303
292-
onSSR(async () => {
293-
await loadCart();
304+
onMounted(() => {
305+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
306+
loadCart();
294307
});
295308
296309
const goToCheckout = async () => {
@@ -327,10 +340,12 @@ export default defineComponent({
327340
});
328341
};
329342
343+
const isInStock = (product) => cartGetters.getStockStatus(product) === stockStatusEnum.inStock;
344+
330345
return {
331346
sendToRemove,
332347
actionRemoveItem,
333-
loading,
348+
loading: computed(() => (!!loading.value)),
334349
isAuthenticated,
335350
products,
336351
removeItem,
@@ -347,6 +362,7 @@ export default defineComponent({
347362
cartGetters,
348363
getAttributes,
349364
getBundles,
365+
isInStock,
350366
};
351367
},
352368
});
@@ -494,5 +510,9 @@ export default defineComponent({
494510
}
495511
}
496512
}
513+
.sf-badge__absolute {
514+
position: absolute;
515+
left: 0;
516+
}
497517
}
498518
</style>

packages/theme/components/SearchResults.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
>
3737
<SfMenuItem
3838
:label="category.label"
39-
:link="localePath(th.getAgnosticCatLink(category))"
39+
:link="th.getAgnosticCatLink(category)"
4040
>
4141
<template #mobile-nav-icon>
4242
&#8203;
@@ -76,7 +76,7 @@
7676
:image="productGetters.getProductThumbnailImage(product)"
7777
:alt="productGetters.getName(product)"
7878
:title="productGetters.getName(product)"
79-
:link="localePath(`/p/${productGetters.getProductSku(product)}${productGetters.getSlug(product, product.categories[0])}`)"
79+
:link="`/p/${productGetters.getProductSku(product)}${productGetters.getSlug(product, product.categories[0])}`"
8080
:wishlist-icon="isAuthenticated ? 'heart' : ''"
8181
:is-in-wishlist-icon="isAuthenticated ? 'heart_fill' : ''"
8282
:is-in-wishlist="product.isInWishlist"
@@ -96,7 +96,7 @@
9696
:image="productGetters.getProductThumbnailImage(product)"
9797
:alt="productGetters.getName(product)"
9898
:title="productGetters.getName(product)"
99-
:link="localePath(`/p/${productGetters.getProductSku(product)}${productGetters.getSlug(product, product.categories[0])}`)"
99+
:link="`/p/${productGetters.getProductSku(product)}${productGetters.getSlug(product, product.categories[0])}`"
100100
:wishlist-icon="isAuthenticated ? 'heart' : ''"
101101
:is-in-wishlist-icon="isAuthenticated ? 'heart_fill' : ''"
102102
:is-in-wishlist="product.isInWishlist"
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import userEvent from '@testing-library/user-event';
2+
import {
3+
useCart,
4+
useUser,
5+
} from '@vue-storefront/magento';
6+
7+
import { useUiState } from '~/composables';
8+
import {
9+
render, useCartMock, useUserMock, useUiStateMock, useEmptyCartMock,
10+
} from '~/test-utils';
11+
import CartSidebar from '~/components/CartSidebar';
12+
13+
jest.mock('@vue-storefront/magento', () => ({
14+
...jest.requireActual('@vue-storefront/magento'),
15+
useExternalCheckout: jest.fn(() => ({ initializeCheckout: {} })),
16+
useCart: jest.fn(),
17+
useUser: jest.fn(),
18+
}));
19+
20+
jest.mock('~/composables/useUiState');
21+
22+
useUser.mockReturnValue(useUserMock());
23+
useCart.mockReturnValue(useCartMock());
24+
25+
describe('<CartSidebar>', () => {
26+
it('should be not visible by default', () => {
27+
useUiState.mockReturnValue(useUiStateMock());
28+
const { queryByText } = render(CartSidebar);
29+
expect(queryByText('My Cart')).toBeNull();
30+
});
31+
32+
describe('If the cart is empty', () => {
33+
beforeAll(() => {
34+
useCart.mockReturnValue(useEmptyCartMock());
35+
});
36+
describe('And the cart sidebar is open', () => {
37+
it('should render empty state', () => {
38+
useUiState.mockReturnValue(useUiStateMock({ isCartSidebarOpen: true }));
39+
const { queryByText } = render(CartSidebar);
40+
expect(queryByText('Your cart is empty')).toBeTruthy();
41+
});
42+
43+
it('go back button must close the cart sidebar', () => {
44+
const uiStateMock = useUiStateMock({ isCartSidebarOpen: true });
45+
useUiState.mockReturnValue(uiStateMock);
46+
const { queryByText } = render(CartSidebar);
47+
const closeSidebarBtn = queryByText('Go back shopping');
48+
expect(closeSidebarBtn).toBeTruthy();
49+
50+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
51+
userEvent.click(closeSidebarBtn);
52+
expect(uiStateMock.toggleCartSidebar).toHaveBeenCalledTimes(1);
53+
});
54+
});
55+
});
56+
57+
describe('If the cart has two products', () => {
58+
beforeAll(() => {
59+
useCart.mockReturnValue(useCartMock());
60+
useUiState.mockReturnValue(useUiStateMock({ isCartSidebarOpen: true }));
61+
});
62+
63+
it('should render two Product Cards', () => {
64+
const { container } = render(CartSidebar);
65+
const productCards = container.querySelectorAll('div.sf-collected-product');
66+
expect(productCards).toHaveLength(2);
67+
});
68+
69+
it('should display proper total items value', () => {
70+
const { container } = render(CartSidebar);
71+
const totalItemsLValue = container.querySelector('div.cart-summary .sf-property__value');
72+
const totalItemsLabel = container.querySelector('div.cart-summary .sf-property__name');
73+
expect(totalItemsLabel).toHaveTextContent('Total items');
74+
expect(totalItemsLValue).toHaveTextContent(2);
75+
});
76+
77+
it('should render "go to checkout" button', () => {
78+
const { getByText } = render(CartSidebar);
79+
expect(getByText('Go to checkout')).toBeTruthy();
80+
});
81+
82+
it('should render promo code input', () => {
83+
const { getByTestId } = render(CartSidebar);
84+
expect(getByTestId('promoCode')).toBeTruthy();
85+
});
86+
87+
describe('And exactly one product is out of stock', () => {
88+
it('should display exactly one out of stock badge', () => {
89+
const { getAllByText } = render(CartSidebar);
90+
expect(getAllByText('Out of stock')).toHaveLength(1);
91+
});
92+
});
93+
});
94+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module.exports = {
2+
inStock: 'IN_STOCK',
3+
outOfStock: 'OUT_OF_STOCK',
4+
};

packages/theme/lang/en.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,5 +167,6 @@ export default {
167167
forgotPasswordConfirmation: 'Thanks! If there is an account registered with the {0} email, you will find message with a password reset link in your inbox.',
168168
subscribeToNewsletterModalContent: 'After signing up for the newsletter, you will receive special offers and messages from VSF via email. We will not sell or distribute your email to any third party at any time. Please see our {0}.',
169169
'Default Shipping Address': 'Default Shipping Address',
170-
'Default Billing Address': 'Default Billing Address'
170+
'Default Billing Address': 'Default Billing Address',
171+
'Out of stock': 'Out of stock'
171172
};

packages/theme/layouts/default.vue

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import LazyHydrate from 'vue-lazy-hydration';
2626
import { onSSR } from '@vue-storefront/core';
2727
import { useRoute, defineComponent } from '@nuxtjs/composition-api';
2828
import {
29-
useCart,
3029
useUser,
3130
} from '@vue-storefront/magento';
3231
import AppHeader from '~/components/AppHeader.vue';
@@ -57,16 +56,12 @@ export default defineComponent({
5756
setup() {
5857
const route = useRoute();
5958
const { load: loadUser } = useUser();
60-
const { load: loadCart } = useCart();
6159
6260
const { loadConfiguration } = useMagentoConfiguration();
6361
6462
onSSR(async () => {
6563
await loadConfiguration();
66-
await Promise.all([
67-
loadUser(),
68-
loadCart(),
69-
]);
64+
await loadUser();
7065
});
7166
7267
return {

packages/theme/test-utils.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { render } from '@testing-library/vue';
22

33
const $t = (text) => text;
4-
4+
const $n = (text) => text;
5+
const localePath = (path) => path;
56
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
67
const customRender = (component, options = {}, callback = null) => render(component, {
78
mocks: {
89
$t,
10+
$n,
11+
localePath,
912
$nuxt: {
1013
context: {
1114
app: {
12-
localePath: (path) => path,
15+
localePath,
1316
},
1417
},
1518
},

0 commit comments

Comments
 (0)