Commit 8a237ca4 authored by Abdul Moiz Lakhani's avatar Abdul Moiz Lakhani

wsi poc microfronted

parents
File added
{
"cSpell.words": [
"amlakhani",
"vuex"
]
}
\ No newline at end of file
{
"extends": ["plugin:vue/base"],
"plugins": ["jest", "vue"],
"parser": "vue-eslint-parser"
}
.DS_Store
node_modules
/dist
/coverage
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# wsi-poc
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}
module.exports = {
preset: "@vue/cli-plugin-unit-jest",
collectCoverage: true,
transformIgnorePatterns: ["node_modules/(?!axios)"],
};
\ No newline at end of file
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}
This diff is collapsed.
{
"name": "wsi-poc",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^1.6.0",
"core-js": "^3.8.3",
"flush-promises": "^1.0.2",
"vue": "^2.7.14",
"vue-router": "^3.4.3",
"vue-server-renderer": "^2.7.14",
"vue-template-compiler": "^2.7.14",
"vuex": "^3.5.1",
"vuex-router-sync": "^5.0.0"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-unit-jest": "~5.0.0",
"@vue/cli-service": "^5.0.8",
"@vue/test-utils": "^1.1.3",
"@vue/vue2-jest": "^27.0.0-alpha.2",
"babel-eslint": "^10.1.0",
"babel-jest": "^27.0.6",
"eslint": "^7.32.0",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-vue": "^8.0.3",
"jest": "^27.0.5",
"node-sass": "^9.0.0",
"sass-loader": "^13.3.2",
"tailwindcss": "^3.3.5",
"vue-template-compiler": "^2.6.14"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {},
"overrides": [
{
"files": [
"**/__tests__/*.{j,t}s?(x)",
"**/tests/unit/**/*.spec.{j,t}s?(x)"
],
"env": {
"jest": true,
"jest/globals": true
}
}
]
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
module.exports = {
plugins: [require("tailwindcss")],
};
\ No newline at end of file
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
<template>
<div data-test="app">
<app-header
:logoIcon="icon"
:logoImage="logo"
:cartIcon="cart"
:cartItems="cartItems"
:cartSubtotal="cartSubtotal"
@onClickLogo="onClickLogo"
@openDrawer="openDrawer"
/>
<router-view></router-view>
<side-drawer
:isDrawerOpen="isDrawerOpen"
:cartItems="cartItems"
:cartSubtotal="cartSubtotal"
@closeDrawer="closeDrawer"
@increaseQuantity="increaseQuantity"
@decreaseQuantity="decreaseQuantity"
/>
</div>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
// Components
import AppHeader from "wsi-poc-components/Header";
import SideDrawer from "wsi-poc-components/SideDrawer";
export default {
name: "App",
components: {
AppHeader,
SideDrawer,
},
data: function () {
return {
icon: require("./assets/ws_logo_icon.png"),
logo: require("./assets/ws_horizontal.svg"),
cart: require("./assets/grocery-store.png"),
};
},
computed: {
...mapGetters("cart", ["cartItems", "cartSubtotal"]),
...mapGetters("drawer", ["isDrawerOpen"]),
},
methods: {
...mapActions("drawer", ["openDrawer", "closeDrawer"]),
...mapActions("cart", ["increaseQuantity", "decreaseQuantity"]),
onClickLogo() {
this.$router.push("/");
},
},
};
</script>
import { shallowMount } from "@vue/test-utils";
import App from "../App.vue";
import AppHeader from "../components/Header.vue";
import SideDrawer from "../components/SideDrawer.vue";
describe("App.vue", () => {
it("renders app correctly", () => {
const wrapper = shallowMount(App, {
components: { AppHeader, SideDrawer },
stubs: ["router-view"],
});
expect(wrapper.find('[data-test="app"]').exists()).toBe(true);
});
});
\ No newline at end of file
import * as productList from "./products.json";
export { productList };
[
{
"price": 59.95,
"pid": "apilco-porcelain-ramekin",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0051/img76c.jpg",
"title": "Apilco Porcelain Ramekins"
},
{
"price": 79.95,
"pid": "apilco-porcelain-oval-au-gratin-baker",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0052/img29c.jpg",
"title": "Apilco Porcelain Au Gratin Bakers"
},
{
"price": 119.95,
"pid": "pillivuyt-coupe-porcelain-soup-and-pasta-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0007/img1c.jpg",
"title": "Pillivuyt Coupe Porcelain Soup/Pasta Plates"
},
{
"price": 107.95,
"pid": "pillivuyt-coupe-porcelain-cereal-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0006/img36c.jpg",
"title": "Pillivuyt Coupe Porcelain Cereal Bowls"
},
{
"price": 119.95,
"pid": "pillivuyt-coupe-porcelain-dinner-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0002/img91c.jpg",
"title": "Pillivuyt Coupe Porcelain Dinner Plates"
},
{
"price": 119.95,
"pid": "pillivuyt-coupe-porcelain-dinnerware-collection",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0005/img32c.jpg",
"title": "Pillivuyt Coupe Porcelain Dinnerware Collection"
},
{
"price": 107.95,
"pid": "pillivuyt-beaded-salad-plates",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202329/0055/img49c.jpg",
"title": "Pillivuyt Beaded Coupe Salad Plates"
},
{
"price": 129.95,
"pid": "pillivuyt-shallow-coupe-porcelain-serving-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0016/img11c.jpg",
"title": "Pillivuyt Coupe Porcelain Shallow Serving Bowl"
},
{
"price": 59.95,
"pid": "apilco-porcelain-souffle-dish",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202340/0455/img92c.jpg",
"title": "Apilco Porcelain Souffl\u00e9 Dishes"
},
{
"price": 119.95,
"pid": "pillivuyt-beaded-dinner-plates",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0022/img25c.jpg",
"title": "Pillivuyt Beaded Coupe Dinner Plates"
},
{
"price": 119.95,
"pid": "apilco-tradition-porcelain-dinnerware-collection",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0003/img1c.jpg",
"title": "Apilco Tradition Porcelain Dinnerware Collection"
},
{
"price": 119.95,
"pid": "apilco-tradition-porcelain-dinner-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0003/img33c.jpg",
"title": "Apilco Tradition Porcelain Dinner Plates"
},
{
"price": 79.95,
"pid": "apilco-tuileries-porcelain-salad-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0009/img80c.jpg",
"title": "Apilco Tuileries Porcelain Salad Serving Bowls"
},
{
"price": 89.95,
"pid": "gravy-boat-and-warming-base",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0033/img59c.jpg",
"title": "Pillivuyt Porcelain Gravy Boat with Warming Base"
},
{
"price": 107.95,
"pid": "pillivuyt-coupe-porcelain-salad-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0005/img10c.jpg",
"title": "Pillivuyt Coupe Porcelain Salad Plates"
},
{
"price": 107.95,
"pid": "pillivuyt-beaded-cereal-bowls",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202331/0044/img8c.jpg",
"title": "Pillivuyt Beaded Coupe Cereal Bowls"
},
{
"price": 69.95,
"pid": "pillivuyt-covered-butter-dish",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0023/img3c.jpg",
"title": "Pillivuyt Porcelain Covered Butter Dish"
},
{
"price": 89.95,
"pid": "apilco-zen-porcelain-platter",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0022/img85c.jpg",
"title": "Apilco Zen Porcelain Platters"
},
{
"price": 107.95,
"pid": "apilco-tradition-porcelain-cereal-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0016/img20c.jpg",
"title": "Apilco Tradition Porcelain Cereal Bowls"
},
{
"price": 149.95,
"pid": "pillivuyt-oval-porcelain-serving-platter",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0051/img15c.jpg",
"title": "Pillivuyt Oval Porcelain Serving Platters"
},
{
"price": 99.95,
"pid": "apilco-porcelain-deep-oval-roaster",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0051/img4c.jpg",
"title": "Apilco Porcelain Deep Oval Roasters"
},
{
"price": 67.95,
"pid": "pillivuyt-coupe-porcelain-bread-and-butter-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0033/img42c.jpg",
"title": "Pillivuyt Coupe Porcelain Appetizer Plates"
},
{
"price": 107.95,
"pid": "pillivuyt-plisse-cereal-bowls",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0035/img3c.jpg",
"title": "Pillivuyt Plisse Porcelain Cereal Bowls"
},
{
"price": 107.95,
"pid": "apilco-tradition-blue-banded-porcelain-cereal-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0016/img26c.jpg",
"title": "Apilco Tradition Blue-Banded Porcelain Cereal Bowls"
},
{
"price": 149.95,
"pid": "pillivuyt-porcelain-rectangular-roaster",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0023/img46c.jpg",
"title": "Pillivuyt Porcelain Rectangular Roasters"
},
{
"price": 107.95,
"pid": "apilco-tradition-blue-banded-porcelain-salad-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0013/img88c.jpg",
"title": "Apilco Tradition Blue-Banded Porcelain Salad Plates"
},
{
"price": 107.95,
"pid": "apilco-tradition-porcelain-salad-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0003/img86c.jpg",
"title": "Apilco Tradition Porcelain Salad Plates"
},
{
"price": 107.95,
"pid": "pillivuyt-coupe-porcelain-mug",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0005/img32c.jpg",
"title": "Pillivuyt Coupe Porcelain Mugs"
},
{
"price": 119.95,
"pid": "pillivuyt-plisse-dinner-plates",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0002/img85c.jpg",
"title": "Pillivuyt Plisse Porcelain Dinner Plates"
},
{
"price": 119.95,
"pid": "apilco-tradition-blue-banded-porcelain-dinner-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0014/img87c.jpg",
"title": "Apilco Tradition Blue-Banded Porcelain Dinner Plates"
},
{
"price": 119.95,
"pid": "apilco-tuileries-porcelain-dinner-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0030/img77c.jpg",
"title": "Apilco Tuileries Porcelain Dinner Plates"
},
{
"price": 119.95,
"pid": "pillivuyt-perle-pasta-bowls",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0052/img18c.jpg",
"title": "Pillivuyt Perle Porcelain Pasta Bowls"
},
{
"price": 99.95,
"pid": "pillivuyt-coupe-porcelain-platter",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202326/0046/img95c.jpg",
"title": "Pillivuyt Coupe Porcelain Platter"
},
{
"price": 99.95,
"pid": "apilco-octagonal-porcelain-serving-platter",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0013/img8c.jpg",
"title": "Apilco Octagonal Porcelain Serving Platter"
},
{
"price": 139.95,
"pid": "pillivuyt-porcelain-cake-stand",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0021/img74c.jpg",
"title": "Pillivuyt Porcelain Cake Stands"
},
{
"price": 67.95,
"pid": "pillivuyt-beaded-bread-and-butter-plates",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202326/0060/img8c.jpg",
"title": "Pillivuyt Beaded Coupe Bread & Butter Plates"
},
{
"price": 119.95,
"pid": "pillivuyt-beaded-pasta-bowls",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202325/0024/img49c.jpg",
"title": "Pillivuyt Beaded Coupe Pasta Bowls, Set of 4"
},
{
"price": 119.95,
"pid": "pillivuyt-queen-anne-porcelain-cereal-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0011/img30c.jpg",
"title": "Pillivuyt Queen Anne Porcelain Cereal Bowls"
},
{
"price": 159.95,
"pid": "pillivuyt-beaded-oval-platter",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202336/0066/img58c.jpg",
"title": "Pillivuyt Beaded Coupe Oval Platter"
},
{
"price": 129.95,
"pid": "pillivuyt-beaded-serving-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202334/0077/img21c.jpg",
"title": "Pillivuyt Beaded Coupe Serving Bowl"
}
]
@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap");
@import "./tailwind.scss";
* {
font-family: Roboto, Arial, sans-serif;
}
.disable-scroll {
overflow: hidden;
}
\ No newline at end of file
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
\ No newline at end of file
This diff is collapsed.
import Vue from "vue";
import App from "./App.vue";
import store from "./store";
import router from "./router";
// Stylesheet
import "wsi-poc-components/styles";
import "./assets/scss/tailwind.scss";
Vue.config.productionTip = false;
new Vue({
render: (h) => h(App),
store,
router,
}).$mount("#app");
import('./bootstrap');
\ No newline at end of file
import Vue from "vue";
import VueRouter from "vue-router";
import ProductList from "../views/ProductList.vue";
import ProductDetail from "../views/ProductDetail.vue";
Vue.use(VueRouter);
const routes = [
{ path: "/", component: ProductList },
{ path: "/product-detail/:productId", component: ProductDetail },
// Add more routes as needed
];
const router = new VueRouter({
routes,
mode: "history",
});
export default router;
\ No newline at end of file
// store.js
import Vue from "vue";
import Vuex from "vuex";
// Store Modules
import cart from "./modules/cart";
import product from "./modules/product";
import drawer from "./modules/drawer";
Vue.use(Vuex);
const store = new Vuex.Store({
modules: {
cart,
product,
drawer,
},
});
export default store;
\ No newline at end of file
import Vuex from "vuex";
import { createLocalVue } from "@vue/test-utils";
import cartModule from "../cart";
const localVue = createLocalVue();
localVue.use(Vuex);
const createItem = (pid, title, price) => ({ pid, title, price, quantity: 0 });
describe("Cart Module", () => {
let store;
beforeEach(() => {
store = new Vuex.Store({
modules: {
cart: cartModule,
},
});
});
it("correctly adds an item to the cart", () => {
const item = createItem("product-a", "Product A", 10);
store.dispatch("cart/addItemToCart", item);
expect(store.getters["cart/cartItems"]).toHaveLength(1);
});
it("correctly removes an item from the cart", () => {
const item = createItem("product-a", "Product A", 10);
store.dispatch("cart/addItemToCart", item);
store.dispatch("cart/removeItemFromCart", item);
expect(store.getters["cart/cartItems"]).toHaveLength(0);
});
it("correctly increments the quantity of an item in the cart", () => {
const item = createItem("product-a", "Product A", 10);
store.dispatch("cart/addItemToCart", item);
store.dispatch("cart/increaseQuantity", item.pid);
const cartItems = store.getters["cart/cartItems"];
expect(cartItems.length).toBe(1);
expect(cartItems[0].quantity).toBe(2);
});
it("correctly decrements the quantity of an item in the cart", () => {
const item = createItem("product-a", "Product A", 10);
store.dispatch("cart/addItemToCart", item);
store.dispatch("cart/increaseQuantity", item.pid);
store.dispatch("cart/decreaseQuantity", item.pid);
const cartItems = store.getters["cart/cartItems"];
expect(cartItems.length).toBe(2); // - 1
expect(cartItems[0].quantity).toBe(2); // - 1
});
it("correctly removes an item quantity is 1 in the cart if decrease it's quantity", () => {
const item = createItem("product-a", "Product A", 10);
store.dispatch("cart/decreaseQuantity", item.pid);
const cartItems = store.getters["cart/cartItems"];
expect(cartItems.length).toBe(2); // - 0
});
it("correctly removes an item when decreasing its quantity to 0", () => {
const item = createItem("product-a", "Product A", 10);
store.dispatch("cart/addItemToCart", item);
store.dispatch("cart/decreaseQuantity", item.pid);
expect(store.getters["cart/cartItems"]).toHaveLength(0);
});
it("calculates the cart subtotal correctly", () => {
const item1 = createItem("product-a", "Product A", 10);
const item2 = createItem("product-b", "Product B", 20);
store.dispatch("cart/addItemToCart", item1);
store.dispatch("cart/addItemToCart", item2);
store.dispatch("cart/increaseQuantity", item2.pid);
const subtotal = store.getters["cart/cartSubtotal"];
expect(subtotal).toBe(10 + (20 * 2));
});
});
\ No newline at end of file
import Vuex from "vuex";
import { createLocalVue } from "@vue/test-utils";
import drawerModule from "../drawer";
const localVue = createLocalVue();
localVue.use(Vuex);
describe("Drawer Module", () => {
let store;
beforeEach(() => {
store = new Vuex.Store({
modules: {
drawer: drawerModule,
},
});
});
it("correctly opens the drawer", () => {
store.dispatch("drawer/openDrawer");
expect(store.getters["drawer/isDrawerOpen"]).toBe(true);
expect(document.body.classList.contains("disable-scroll")).toBe(true);
});
it("correctly closes the drawer", () => {
store.dispatch("drawer/openDrawer");
store.dispatch("drawer/closeDrawer");
expect(store.getters["drawer/isDrawerOpen"]).toBe(false);
expect(document.body.classList.contains("disable-scroll")).toBe(false);
});
it("correctly sets drawer state with mutations", () => {
store.commit("drawer/setDrawerState", true);
expect(store.getters["drawer/isDrawerOpen"]).toBe(true);
});
});
import Vuex from "vuex";
import { createLocalVue } from "@vue/test-utils";
import axios from "axios";
import flushPromises from "flush-promises";
import { productList } from "../../../assets/dummy-data";
import productsModule from "../product";
const localVue = createLocalVue();
localVue.use(Vuex);
jest.mock("axios");
describe("Product Module", () => {
const mockedResponse = {
data: [productList[0], productList[2]].map((p) => ({
...p,
id: p.pid,
image: p.thumb_image,
description: "lorem ipsum",
})),
};
let store;
let commit;
beforeEach(() => {
commit = jest.fn();
store = new Vuex.Store({
modules: {
product: {
namespaced: true,
state: {
products: mockedResponse.data,
},
actions: productsModule.actions,
mutations: productsModule.mutations,
getters: productsModule.getters,
},
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
it("should correctly retrieves products", async () => {
axios.get.mockResolvedValue(mockedResponse);
const expectedProductList = [
{
pid: "apilco_porcelain_ramekins_apilco-porcelain-ramekin",
title: "Apilco Porcelain Ramekins",
description: "lorem ipsum",
price: 59.95,
thumb_image:
"https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0051/img76c.jpg",
},
{
pid: "pillivuyt_coupe_porcelain_soup/pasta_plates_pillivuyt-coupe-porcelain-soup-and-pasta-plate",
title: "Pillivuyt Coupe Porcelain Soup/Pasta Plates",
description: "lorem ipsum",
price: 119.95,
thumb_image:
"https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0007/img1c.jpg",
},
];
await store.dispatch("product/fetchProducts", { commit });
await flushPromises();
expect(axios.get).toHaveBeenCalledWith("https://fakestoreapi.com/products");
const products = store.getters["product/products"];
expect(products).toEqual(expectedProductList);
});
it("should fetchProducts action handles error", async () => {
console.log = jest.fn();
axios.get.mockRejectedValue(new Error("Request failed"));
await store.dispatch("product/fetchProducts", { commit });
await flushPromises();
expect(console.log).toHaveBeenCalledWith("Error: ", "Request failed");
});
it("should correctly retrieves a product by ID", async () => {
const productId = "pillivuyt-coupe-porcelain-soup-and-pasta-plate";
const product = store.getters["product/getProductById"](productId);
expect(product).toEqual(
expect.objectContaining({
pid: productId,
})
);
});
});
const state = {
items: [],
};
const mutations = {
addItem(state, item) {
state.items.push({ ...item, quantity: 1 });
},
removeItem(state, item) {
state.items = [...state.items].filter((i) => i.pid !== item.pid);
},
incrementQuantity(state, itemId) {
const item = state.items.find((i) => i.pid === itemId);
if (item) {
item.quantity++;
}
},
decrementQuantity(state, itemId) {
const item = state.items.find((i) => i.pid === itemId);
if (item && item.quantity > 1) {
item.quantity--;
} else {
state.items = [...state.items].filter((i) => i.pid !== itemId);
}
},
};
const actions = {
addItemToCart({ commit }, item) {
commit("addItem", item);
},
removeItemFromCart({ commit }, item) {
commit("removeItem", item);
},
increaseQuantity({ commit }, itemId) {
commit("incrementQuantity", itemId);
},
decreaseQuantity({ commit }, itemId) {
commit("decrementQuantity", itemId);
},
};
const getters = {
cartItems: (state) => state.items,
cartSubtotal: (state) => {
return state.items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
},
};
export default {
namespaced: true,
state,
mutations,
actions,
getters,
};
const state = {
drawerIsOpen: false,
};
const mutations = {
setDrawerState(state, isOpen) {
state.drawerIsOpen = isOpen;
},
};
const actions = {
openDrawer({ commit }) {
commit("setDrawerState", true);
document.body.classList.add('disable-scroll');
},
closeDrawer({ commit }) {
commit("setDrawerState", false);
document.body.classList.remove('disable-scroll');
},
};
const getters = {
isDrawerOpen: (state) => state.drawerIsOpen,
};
export default {
namespaced: true,
state,
mutations,
actions,
getters,
};
import axios from "axios";
const state = {
products: [],
loading: true,
};
const mutations = {
setProducts(state, products) {
state.products = products;
},
setLoading(state, status) {
state.loading = status;
},
};
const actions = {
async fetchProducts({ commit }) {
try {
const response = await axios.get("https://fakestoreapi.com/products");
const products = response.data.map((product) => ({
pid: `${product.title.toLowerCase().replace(/ /g, "_")}_${product.id}`,
title: product.title,
description: product.description,
price: product.price,
thumb_image: product.image,
}));
commit("setProducts", products);
commit("setLoading", false);
} catch (error) {
console.log("Error: ", error.message);
}
},
};
const getters = {
products: (state) => state.products,
getProductById: (state) => (pid) => {
const productArray = Object.values(state.products).filter(item => typeof item === 'object');
return productArray.find(product => product.pid === pid);
},
};
export default {
namespaced: true,
state,
mutations,
actions,
getters,
};
<template>
<div data-test="product-detail" class="max-w-2xl mx-auto p-4">
<div class="flex flex-col md:flex-row">
<div class="md:w-1/2">
<img
:src="product.thumb_image"
:alt="product.title"
class="w-full rounded-lg"
/>
</div>
<div class="md:w-1/2 mt-4 md:mt-0 md:ml-4">
<h1 class="text-2xl font-semibold">{{ product.title }}</h1>
<p class="text-gray-600 text-lg mt-2">{{ product.description }}</p>
<p class="text-2xl font-semibold text-primary mt-4">
{{ formatCurrency(product.price) }}
</p>
<div class="mt-4">
<div class="flex items-center">
<button
data-test="pd-dec-qty-btn"
@click="decreaseQuantity(product.pid)"
class="px-4 py-2 rounded bg-zinc-950 text-white"
>
-
</button>
<span class="px-4 py-2">{{ productCartQty }}</span>
<button
data-test="pd-inc-qty-btn"
@click="
!!productCartQty
? increaseQuantity(product.pid)
: addItemToCart(product)
"
class="px-4 py-2 rounded bg-zinc-950 text-white"
>
+
</button>
</div>
<button
data-test="pd-atc-btn"
v-if="productCartQty === 0"
@click="addItemToCart(product)"
class="px-4 py-2 rounded bg-zinc-950 text-white mt-4"
>
Add to Cart
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
export default {
name: "product-detail",
methods: {
...mapActions("cart", [
"increaseQuantity",
"decreaseQuantity",
"addItemToCart",
]),
formatCurrency(amount) {
return `$${amount.toFixed(2)}`;
},
},
computed: {
...mapGetters("product", ["getProductById"]),
...mapGetters("cart", ["cartItems"]),
product() {
return this.getProductById(this.$route.params.productId);
},
productCartQty() {
const product = this.cartItems.find(
(item) => item.pid === this.$route.params.productId
);
return product ? product.quantity : 0;
},
},
};
</script>
<template>
<main data-test="product-list" class="p-8 mx-auto">
<div
v-if="!loading"
class="grid md:grid-cols-3 sm:grid-cols-2 grid-cols-1 justify-items-center gap-8"
>
<single-product
data-test="single-product"
v-for="product in products"
:product="product"
:key="product.pid"
imgClass="h-[350px]"
:cartItems="cartItems"
@addItemToCart="addItemToCart"
@removeItemFromCart="removeItemFromCart"
@onClickProductTile="onClickProductTile"
/>
</div>
<p v-if="loading">Loading products...</p>
</main>
</template>
<script>
import { mapState, mapGetters, mapActions } from "vuex";
import SingleProduct from "wsi-poc-components/Product";
export default {
name: "product-list",
components: {
SingleProduct,
},
methods: {
...mapActions("product", ["fetchProducts"]),
...mapActions("cart", ["addItemToCart", "removeItemFromCart"]),
onClickProductTile(product) {
this.$router.push(`/product-detail/${product.pid}`);
},
},
computed: {
...mapGetters("cart", ["cartItems"]),
...mapState("product", ["products", "loading"]),
},
mounted() {
this.fetchProducts();
},
};
</script>
import { shallowMount, createLocalVue } from "@vue/test-utils";
import Vuex from "vuex";
import { productList } from "../../assets/dummy-data";
import ProductDetail from "../ProductDetail.vue";
import ProductModule from "../../store/modules/product";
import CartModule from "../../store/modules/cart";
const localVue = createLocalVue();
localVue.use(Vuex);
const $route = {
params: {
productId: "apilco-porcelain-ramekin",
},
};
describe("ProductDetail.vue", () => {
const productArray = Object.values(productList).filter(
(item) => typeof item === "object"
);
let store;
let modules = {
cart: {
actions: undefined,
state: undefined,
},
product: {
actions: undefined,
state: undefined,
},
};
beforeEach(() => {
modules.cart.state = {
items: [{ ...productArray[0], quantity: 1 }],
};
modules.product.state = {
products: productList,
};
modules.cart.actions = {
increaseQuantity: jest.fn(),
decreaseQuantity: jest.fn(),
addItemToCart: jest.fn(),
};
store = new Vuex.Store({
modules: {
cart: {
state: modules.cart.state,
actions: modules.cart.actions,
getters: CartModule.getters,
namespaced: true,
},
product: {
state: modules.product.state,
actions: modules.product.actions,
getters: ProductModule.getters,
namespaced: true,
},
},
});
});
it("renders ProductDetail correctly", () => {
const wrapper = shallowMount(ProductDetail, {
store,
localVue,
mocks: {
$route,
},
});
expect(wrapper.find('[data-test="product-detail"]').exists()).toBe(true);
});
it('calls store action "increaseQuantity" when "+" button is clicked', () => {
const wrapper = shallowMount(ProductDetail, {
store,
localVue,
mocks: {
$route,
},
});
const button = wrapper.find('[data-test="pd-inc-qty-btn"]');
button.trigger("click");
expect(modules.cart.actions.increaseQuantity).toHaveBeenCalled();
});
it('calls store action "decreaseQuantity" when "-" button is clicked', () => {
const wrapper = shallowMount(ProductDetail, {
store,
localVue,
mocks: {
$route,
},
});
const button = wrapper.find('[data-test="pd-dec-qty-btn"]');
button.trigger("click");
expect(modules.cart.actions.decreaseQuantity).toHaveBeenCalled();
});
it('calls store action "addItemToCart" when "Add to Cart" button is clicked', () => {
const wrapper = shallowMount(ProductDetail, {
store,
localVue,
mocks: {
$route: {
params: {
productId: "apilco-porcelain-oval-au-gratin-baker",
},
},
},
});
const button = wrapper.find('[data-test="pd-atc-btn"]');
button.trigger("click");
expect(modules.cart.actions.addItemToCart).toHaveBeenCalled();
});
});
import { shallowMount, createLocalVue } from "@vue/test-utils";
import Vuex from "vuex";
import { productList } from "../../assets/dummy-data";
import ProductList from "../ProductList.vue";
import SingleProduct from "../../components/Product.vue";
import ProductModule from "../../store/modules/product";
const localVue = createLocalVue();
localVue.use(Vuex);
describe("ProductList.vue", () => {
let store;
let modules = {
product: {
actions: undefined,
state: undefined,
},
};
beforeEach(() => {
modules.product.state = {
products: productList,
};
modules.product.actions = {
fetchProducts: jest.fn(), // Mock the action
};
store = new Vuex.Store({
modules: {
product: {
state: modules.product.state,
actions: modules.product.actions,
getters: ProductModule.getters,
namespaced: true,
},
},
});
});
it("renders ProductList correctly", () => {
const wrapper = shallowMount(ProductList, {
store,
localVue,
});
expect(wrapper.find('[data-test="product-list"]').exists()).toBe(true);
});
it("renders ProductList with list of 41 Products", () => {
const wrapper = shallowMount(ProductList, {
store,
localVue,
components: { SingleProduct },
});
const SingleProducts = wrapper.findAll('[data-test="single-product"]');
expect(SingleProducts).toHaveLength(41);
});
});
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
publicPath: "http://localhost:8080/",
transpileDependencies: true,
devServer: {
port: 8080,
},
chainWebpack: (config) => {
config.optimization.delete("splitChunks");
config
.plugin("module-federation-plugin")
.use(require("webpack").container.ModuleFederationPlugin, [
{
remotes: {
"wsi-poc-components":
"wsi_poc_components@http://localhost:8081/remoteEntry.js",
},
shared: {
vue: {
eager: true,
singleton: false,
},
},
},
]);
},
});
module.exports = {
module: {
rules: [
// ... other rules omitted
// this will apply to both plain `.scss` files
// AND `<style lang="scss">` blocks in `.vue` files
{
test: /\.scss$/,
use: ["vue-style-loader", "css-loader", "sass-loader"],
},
],
},
// plugin omitted
};
{
"extends": ["plugin:vue/base"],
"plugins": ["jest", "vue"],
"parser": "vue-eslint-parser"
}
.DS_Store
node_modules
/dist
/coverage
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# wsi-poc
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}
{
"name": "wsi_poc_components",
"exposes": {
"./Header": "./src/components/Header.vue",
"./Product": "./src/components/Product.vue",
"./SideDrawer": "./src/components/SideDrawer.vue",
"./styles": "./src/assets/scss/style.scss"
}
}
\ No newline at end of file
module.exports = {
preset: "@vue/cli-plugin-unit-jest",
collectCoverage: true,
transformIgnorePatterns: ["node_modules/(?!axios)"],
};
\ No newline at end of file
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}
This diff is collapsed.
{
"name": "wsi-poc",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^1.6.0",
"core-js": "^3.8.3",
"flush-promises": "^1.0.2",
"vue": "^2.7.14",
"vue-router": "^3.4.3",
"vue-server-renderer": "^2.7.14",
"vue-template-compiler": "^2.7.14",
"vuex": "^3.5.1",
"vuex-router-sync": "^5.0.0"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-unit-jest": "~5.0.0",
"@vue/cli-service": "^5.0.8",
"@vue/test-utils": "^1.1.3",
"@vue/vue2-jest": "^27.0.0-alpha.2",
"babel-eslint": "^10.1.0",
"babel-jest": "^27.0.6",
"eslint": "^7.32.0",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-vue": "^8.0.3",
"jest": "^27.0.5",
"node-sass": "^9.0.0",
"sass-loader": "^13.3.2",
"tailwindcss": "^3.3.5",
"vue-template-compiler": "^2.6.14"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {},
"overrides": [
{
"files": [
"**/__tests__/*.{j,t}s?(x)",
"**/tests/unit/**/*.spec.{j,t}s?(x)"
],
"env": {
"jest": true,
"jest/globals": true
}
}
]
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
module.exports = {
plugins: [require("tailwindcss")],
};
\ No newline at end of file
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
<template>
<div data-test="app">
<app-header
:logoIcon="icon"
:logoImage="logo"
:cartIcon="cart"
:cartItems="cartItems"
:cartSubtotal="cartSubtotal"
@onClickLogo="onClickLogo"
@openDrawer="openDrawer"
/>
<main data-test="product-list" class="p-8 mx-auto">
<div
class="grid md:grid-cols-3 sm:grid-cols-2 grid-cols-1 justify-items-center gap-8"
>
<single-product
data-test="single-product"
v-for="product in products"
:product="product"
:key="product.pid"
:cartItems="cartItems"
@addItemToCart="addItemToCart"
@removeItemFromCart="removeItemFromCart"
@onClickProductTile="onClickProductTile"
/>
</div>
</main>
<side-drawer
:isDrawerOpen="drawerStatus"
:cartItems="cartItems"
:cartSubtotal="cartSubtotal"
@closeDrawer="closeDrawer"
@increaseQuantity="increaseQuantity"
@decreaseQuantity="decreaseQuantity"
/>
</div>
</template>
<script>
import { productList } from "./assets/dummy-data";
// Components
import AppHeader from "./components/Header.vue";
import SideDrawer from "./components/SideDrawer.vue";
import SingleProduct from "./components/Product.vue";
export default {
name: "App",
data: function () {
return {
icon: require("./assets/ws_logo_icon.png"),
logo: require("./assets/ws_horizontal.svg"),
cart: require("./assets/grocery-store.png"),
drawerStatus: false,
cartItems: [],
products: Array.from(productList)
};
},
methods: {
onClickLogo() {
console.log("Clicked on logo, route to home!");
},
openDrawer() {
this.drawerStatus = true;
},
closeDrawer() {
this.drawerStatus = false;
},
increaseQuantity(itemId) {
const item = this.cartItems.find((i) => i.pid === itemId);
if (item) {
item.quantity++;
}
},
decreaseQuantity(itemId) {
const item = this.cartItems.find((i) => i.pid === itemId);
if (item && item.quantity > 1) {
item.quantity--;
} else {
this.cartItems = [...this.cartItems].filter((i) => i.pid !== itemId);
}
},
addItemToCart(product) {
this.cartItems.push({ ...product, quantity: 1 });
},
removeItemFromCart(product) {
this.cartItems = [...this.cartItems].filter((i) => i.pid !== product.pid);
},
onClickProductTile(product) {
console.log("Clicked on product tile, route to product detail!", product);
// this.$router.push(`/product-detail/${this.product.pid}`);
},
},
computed: {
cartSubtotal() {
return this.cartItems.reduce(
(total, item) => total + item.price * item.quantity,
0
);
},
},
components: {
AppHeader,
SideDrawer,
SingleProduct,
},
};
</script>
import { shallowMount } from "@vue/test-utils";
import App from "../App.vue";
import AppHeader from "../components/Header.vue";
import SideDrawer from "../components/SideDrawer.vue";
import SingleProduct from "../components/Product.vue";
describe("App.vue", () => {
it("renders app correctly", () => {
const wrapper = shallowMount(App, {
components: { AppHeader, SingleProduct, SideDrawer },
stubs: ["router-view"],
});
expect(wrapper.find('[data-test="app"]').exists()).toBe(true);
});
});
\ No newline at end of file
import * as productList from "./products.json";
export { productList };
[
{
"price": 59.95,
"pid": "apilco-porcelain-ramekin",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0051/img76c.jpg",
"title": "Apilco Porcelain Ramekins"
},
{
"price": 79.95,
"pid": "apilco-porcelain-oval-au-gratin-baker",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0052/img29c.jpg",
"title": "Apilco Porcelain Au Gratin Bakers"
},
{
"price": 119.95,
"pid": "pillivuyt-coupe-porcelain-soup-and-pasta-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0007/img1c.jpg",
"title": "Pillivuyt Coupe Porcelain Soup/Pasta Plates"
},
{
"price": 107.95,
"pid": "pillivuyt-coupe-porcelain-cereal-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0006/img36c.jpg",
"title": "Pillivuyt Coupe Porcelain Cereal Bowls"
},
{
"price": 119.95,
"pid": "pillivuyt-coupe-porcelain-dinner-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0002/img91c.jpg",
"title": "Pillivuyt Coupe Porcelain Dinner Plates"
},
{
"price": 119.95,
"pid": "pillivuyt-coupe-porcelain-dinnerware-collection",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0005/img32c.jpg",
"title": "Pillivuyt Coupe Porcelain Dinnerware Collection"
},
{
"price": 107.95,
"pid": "pillivuyt-beaded-salad-plates",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202329/0055/img49c.jpg",
"title": "Pillivuyt Beaded Coupe Salad Plates"
},
{
"price": 129.95,
"pid": "pillivuyt-shallow-coupe-porcelain-serving-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0016/img11c.jpg",
"title": "Pillivuyt Coupe Porcelain Shallow Serving Bowl"
},
{
"price": 59.95,
"pid": "apilco-porcelain-souffle-dish",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202340/0455/img92c.jpg",
"title": "Apilco Porcelain Souffl\u00e9 Dishes"
},
{
"price": 119.95,
"pid": "pillivuyt-beaded-dinner-plates",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0022/img25c.jpg",
"title": "Pillivuyt Beaded Coupe Dinner Plates"
},
{
"price": 119.95,
"pid": "apilco-tradition-porcelain-dinnerware-collection",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0003/img1c.jpg",
"title": "Apilco Tradition Porcelain Dinnerware Collection"
},
{
"price": 119.95,
"pid": "apilco-tradition-porcelain-dinner-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0003/img33c.jpg",
"title": "Apilco Tradition Porcelain Dinner Plates"
},
{
"price": 79.95,
"pid": "apilco-tuileries-porcelain-salad-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0009/img80c.jpg",
"title": "Apilco Tuileries Porcelain Salad Serving Bowls"
},
{
"price": 89.95,
"pid": "gravy-boat-and-warming-base",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0033/img59c.jpg",
"title": "Pillivuyt Porcelain Gravy Boat with Warming Base"
},
{
"price": 107.95,
"pid": "pillivuyt-coupe-porcelain-salad-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0005/img10c.jpg",
"title": "Pillivuyt Coupe Porcelain Salad Plates"
},
{
"price": 107.95,
"pid": "pillivuyt-beaded-cereal-bowls",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202331/0044/img8c.jpg",
"title": "Pillivuyt Beaded Coupe Cereal Bowls"
},
{
"price": 69.95,
"pid": "pillivuyt-covered-butter-dish",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0023/img3c.jpg",
"title": "Pillivuyt Porcelain Covered Butter Dish"
},
{
"price": 89.95,
"pid": "apilco-zen-porcelain-platter",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0022/img85c.jpg",
"title": "Apilco Zen Porcelain Platters"
},
{
"price": 107.95,
"pid": "apilco-tradition-porcelain-cereal-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0016/img20c.jpg",
"title": "Apilco Tradition Porcelain Cereal Bowls"
},
{
"price": 149.95,
"pid": "pillivuyt-oval-porcelain-serving-platter",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0051/img15c.jpg",
"title": "Pillivuyt Oval Porcelain Serving Platters"
},
{
"price": 99.95,
"pid": "apilco-porcelain-deep-oval-roaster",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0051/img4c.jpg",
"title": "Apilco Porcelain Deep Oval Roasters"
},
{
"price": 67.95,
"pid": "pillivuyt-coupe-porcelain-bread-and-butter-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0033/img42c.jpg",
"title": "Pillivuyt Coupe Porcelain Appetizer Plates"
},
{
"price": 107.95,
"pid": "pillivuyt-plisse-cereal-bowls",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0035/img3c.jpg",
"title": "Pillivuyt Plisse Porcelain Cereal Bowls"
},
{
"price": 107.95,
"pid": "apilco-tradition-blue-banded-porcelain-cereal-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0016/img26c.jpg",
"title": "Apilco Tradition Blue-Banded Porcelain Cereal Bowls"
},
{
"price": 149.95,
"pid": "pillivuyt-porcelain-rectangular-roaster",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0023/img46c.jpg",
"title": "Pillivuyt Porcelain Rectangular Roasters"
},
{
"price": 107.95,
"pid": "apilco-tradition-blue-banded-porcelain-salad-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0013/img88c.jpg",
"title": "Apilco Tradition Blue-Banded Porcelain Salad Plates"
},
{
"price": 107.95,
"pid": "apilco-tradition-porcelain-salad-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0003/img86c.jpg",
"title": "Apilco Tradition Porcelain Salad Plates"
},
{
"price": 107.95,
"pid": "pillivuyt-coupe-porcelain-mug",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0005/img32c.jpg",
"title": "Pillivuyt Coupe Porcelain Mugs"
},
{
"price": 119.95,
"pid": "pillivuyt-plisse-dinner-plates",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0002/img85c.jpg",
"title": "Pillivuyt Plisse Porcelain Dinner Plates"
},
{
"price": 119.95,
"pid": "apilco-tradition-blue-banded-porcelain-dinner-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0014/img87c.jpg",
"title": "Apilco Tradition Blue-Banded Porcelain Dinner Plates"
},
{
"price": 119.95,
"pid": "apilco-tuileries-porcelain-dinner-plate",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0030/img77c.jpg",
"title": "Apilco Tuileries Porcelain Dinner Plates"
},
{
"price": 119.95,
"pid": "pillivuyt-perle-pasta-bowls",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0052/img18c.jpg",
"title": "Pillivuyt Perle Porcelain Pasta Bowls"
},
{
"price": 99.95,
"pid": "pillivuyt-coupe-porcelain-platter",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202326/0046/img95c.jpg",
"title": "Pillivuyt Coupe Porcelain Platter"
},
{
"price": 99.95,
"pid": "apilco-octagonal-porcelain-serving-platter",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0013/img8c.jpg",
"title": "Apilco Octagonal Porcelain Serving Platter"
},
{
"price": 139.95,
"pid": "pillivuyt-porcelain-cake-stand",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0021/img74c.jpg",
"title": "Pillivuyt Porcelain Cake Stands"
},
{
"price": 67.95,
"pid": "pillivuyt-beaded-bread-and-butter-plates",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202326/0060/img8c.jpg",
"title": "Pillivuyt Beaded Coupe Bread & Butter Plates"
},
{
"price": 119.95,
"pid": "pillivuyt-beaded-pasta-bowls",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202325/0024/img49c.jpg",
"title": "Pillivuyt Beaded Coupe Pasta Bowls, Set of 4"
},
{
"price": 119.95,
"pid": "pillivuyt-queen-anne-porcelain-cereal-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202322/0011/img30c.jpg",
"title": "Pillivuyt Queen Anne Porcelain Cereal Bowls"
},
{
"price": 159.95,
"pid": "pillivuyt-beaded-oval-platter",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202336/0066/img58c.jpg",
"title": "Pillivuyt Beaded Coupe Oval Platter"
},
{
"price": 129.95,
"pid": "pillivuyt-beaded-serving-bowl",
"thumb_image": "https://assets.wsimgs.com/wsimgs/rk/images/dp/wcm/202334/0077/img21c.jpg",
"title": "Pillivuyt Beaded Coupe Serving Bowl"
}
]
@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap");
@import "./tailwind.scss";
* {
font-family: Roboto, Arial, sans-serif;
}
.disable-scroll {
overflow: hidden;
}
\ No newline at end of file
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
\ No newline at end of file
This diff is collapsed.
<template>
<header
data-test="header"
class="flex justify-between h-14 border-b border-gray-500 px-8"
>
<div
class="flex-1 flex items-center max-w-fit cursor-pointer"
@click="$emit('onClickLogo')"
>
<img
class="w-10 rounded"
:src="logoIcon"
alt="William Sonoma Logo Icon"
/>
<img class="ml-3" :src="logoImage" alt="William Sonoma Logo" />
</div>
<div class="flex-1 flex justify-end items-center">
<button class="flex" @click="$emit('openDrawer')">
<img class="w-6 mr-2" :src="cartIcon" alt="Cart Icon" />
<span>({{ cartItems.length }})</span>
</button>
</div>
</header>
</template>
<script>
export default {
name: "app-header",
props: ["logoIcon", "logoImage", "cartIcon", "cartItems"],
};
</script>
<template>
<div
data-test="product-container"
:key="product.pid"
class="cursor-pointer w-full"
>
<div class="w-full flex justify-center relative">
<img
:class="imgClass"
:src="product.thumb_image"
:alt="product.title"
@click="$emit('onClickProductTile', product)"
/>
<button
class="w-full flex justify-center items-center py-3 bg-black/50 absolute bottom-0"
@click="
!isSelected
? $emit('addItemToCart', product)
: $emit('removeItemFromCart', product)
"
>
<span class="uppercase text-white">
{{ isSelected ? "- Remove from" : "+ Add to" }} cart
</span>
</button>
</div>
<div class="py-2" @click="$emit('onClickProductTile', product)">
<h3 class="font-bold text-xl mb-1">{{ product.title }}</h3>
<p class="text-gray-900 mb-1 text-sm">{{ product.description }}</p>
<p class="text-gray-900 font-bold text-base">${{ product.price }}</p>
</div>
</div>
</template>
<script>
export default {
name: "single-product",
props: ["product", "cartItems", "imgClass"],
computed: {
isSelected() {
return this.cartItems.find((item) => item.pid === this.product.pid);
},
},
};
</script>
<template>
<transition name="slide" mode="out-in">
<div
data-test="side-drawer-container"
:class="isDrawerOpen ? 'block' : 'hidden'"
>
<div
data-test="side-drawer-overlay"
:class="isDrawerOpen ? containerClasses : 'w-0'"
@click="$emit('closeDrawer')"
></div>
<div class="w-1/3 h-full absolute top-0 right-0">
<div class="bg-white h-screen p-4">
<h2 class="font-bold text-2xl mb-4">Your Cart</h2>
<ul>
<li
v-for="item in cartItems"
:key="item.pid"
class="flex items-center justify-between p-2 border-b border-gray-300"
>
<div class="flex items-center">
<span class="text-base font-medium">{{ item.title }}</span>
<span class="text-gray-600 ml-2">{{
formatCurrency(item.price)
}}</span>
</div>
<div class="flex items-center">
<button
@click="$emit('decreaseQuantity', item.pid)"
class="text-red-500 p-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
class="h-4 w-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 12H6"
/>
</svg>
</button>
<span class="text-lg font-medium">{{ item.quantity }}</span>
<button
@click="$emit('increaseQuantity', item.pid)"
class="text-green-500 p-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
class="h-4 w-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</button>
</div>
<span class="text-gray-600"
>Subtotal:
{{ formatCurrency(item.price * item.quantity) }}</span
>
</li>
</ul>
<p v-if="!cartItems.length" class="mt-2 text-gray-500">
Please add items to your cart.
</p>
<div class="mt-4 flex justify-end">
<p class="text-lg font-semibold">
Total: {{ formatCurrency(cartSubtotal) }}
</p>
</div>
</div>
</div>
</div>
</transition>
</template>
<script>
const containerClasses = "bg-black w-full h-full absolute top-0 opacity-60";
export default {
name: "side-drawer",
data: function () {
return { containerClasses };
},
props: ["isDrawerOpen", "cartItems", "cartSubtotal"],
methods: {
formatCurrency(amount) {
return `$${amount.toFixed(2)}`;
},
},
};
</script>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s;
}
.slide-enter,
.slide-leave-to {
transform: translateX(-100%);
}
</style>
import { shallowMount } from "@vue/test-utils";
import { productList } from "../../assets/dummy-data";
import AppHeader from "../Header.vue";
describe("AppHeader.vue", () => {
const productArray = Object.values(productList).filter(
(item) => typeof item === "object"
);
const propsData = {
logoIcon: require("../../assets/ws_logo_icon.png"),
logoImage: require("../../assets/ws_horizontal.svg"),
cartIcon: require("../../assets/grocery-store.png"),
cartItems: [productArray[0], productArray[2], productArray[3]],
};
const listeners = {
onClickLogo: jest.fn(),
openDrawer: jest.fn(),
};
it("renders header correctly", () => {
const wrapper = shallowMount(AppHeader, { propsData, listeners });
expect(wrapper.find('[data-test="header"]').exists()).toBe(true);
});
it('calls store action "openDrawer" when cart icon is clicked', () => {
const wrapper = shallowMount(AppHeader, { propsData, listeners });
const button = wrapper.find("button");
button.trigger("click");
expect(listeners.openDrawer).toHaveBeenCalled();
});
});
import { shallowMount } from "@vue/test-utils";
import { productList } from "../../assets/dummy-data";
import Product from "../Product.vue";
describe("Product.vue", () => {
const productArray = Object.values(productList).filter(
(item) => typeof item === "object"
);
const propsData = {
product: productArray[0],
cartItems: [productArray[0], productArray[2], productArray[3]],
};
const listeners = {
onClickProductTile: jest.fn(),
addItemToCart: jest.fn(),
removeItemFromCart: jest.fn(),
onClickProductTile: jest.fn(),
};
it("renders product correctly", () => {
const wrapper = shallowMount(Product, {
propsData,
listeners,
computed: {
isSelected: jest.fn().mockReturnValue(true),
},
});
expect(wrapper.find('[data-test="product-container"]').exists()).toBe(true);
});
it("renders product correctly", () => {
const wrapper = shallowMount(Product, {
propsData: { ...propsData, cartItems: [] },
listeners,
computed: {
isSelected: jest.fn().mockReturnValue(false),
},
});
expect(wrapper.find('[data-test="product-container"]').exists()).toBe(true);
});
it('calls store action "removeItemFromCart" when "Remove from Cart" button is clicked', () => {
const wrapper = shallowMount(Product, {
propsData,
listeners,
computed: {
isSelected: jest.fn().mockReturnValue(true),
},
});
const button = wrapper.find("button");
button.trigger("click");
expect(listeners.removeItemFromCart).toHaveBeenCalled();
});
it('calls store action "addItemToCart" when "Add to Cart" button is clicked', () => {
const wrapper = shallowMount(Product, {
propsData: { ...propsData, cartItems: [] },
listeners,
computed: {
isSelected: jest.fn().mockReturnValue(false),
},
});
const button = wrapper.find("button");
button.trigger("click");
expect(listeners.addItemToCart).toHaveBeenCalled();
});
});
import { shallowMount } from "@vue/test-utils";
import { productList } from "../../assets/dummy-data";
import SideDrawer from "../SideDrawer.vue";
describe("SideDrawer.vue", () => {
const productArray = Object.values(productList).filter(
(item) => typeof item === "object"
);
const propsData = {
isDrawerOpen: true,
cartItems: [productArray[0], productArray[2], productArray[3]],
cartSubtotal: 0,
};
const listeners = {
closeDrawer: jest.fn(),
};
const containerClasses = "bg-black w-full h-full absolute top-0 opacity-60";
it("renders SideDrawer correctly", () => {
const wrapper = shallowMount(SideDrawer, {
propsData,
listeners,
data: function () {
return { containerClasses };
},
});
expect(wrapper.find('[data-test="side-drawer-container"]').exists()).toBe(
true
);
});
it('calls store action "closeDrawer" when "Drawer Overlay" is clicked', () => {
const wrapper = shallowMount(SideDrawer, {
propsData,
listeners,
data: function () {
return { containerClasses };
},
});
const overlay = wrapper.find('[data-test="side-drawer-overlay"]');
overlay.trigger("click");
expect(listeners.closeDrawer).toHaveBeenCalled();
});
});
import Vue from "vue";
import App from "./App.vue";
// Stylesheet
import "./assets/scss/style.scss";
Vue.config.productionTip = false;
new Vue({
render: (h) => h(App),
}).$mount("#app");
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
const { defineConfig } = require("@vue/cli-service");
const federationConfig = require("./federationConfig.json");
module.exports = defineConfig({
publicPath: "http://localhost:8081/",
transpileDependencies: true,
devServer: {
port: 8081,
},
chainWebpack: (config) => {
config.optimization.delete("splitChunks");
config
.plugin("module-federation-plugin")
.use(require("webpack").container.ModuleFederationPlugin, [
{
...federationConfig,
shared: {
vue: {
eager: true,
singleton: false,
},
},
filename: "remoteEntry.js",
},
]);
},
});
module.exports = {
module: {
rules: [
// ... other rules omitted
// this will apply to both plain `.scss` files
// AND `<style lang="scss">` blocks in `.vue` files
{
test: /\.scss$/,
use: ["vue-style-loader", "css-loader", "sass-loader"],
},
],
},
// plugin omitted
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment