Skip to content

Commit 1b11e9c

Browse files
authored
feat(theme): support navigating to nested categories in mobile side menu (#709)
* fix(theme): allow spec.ts files to be used Before this commit there were 2 issues: - Types from Cypress and Jest fighting for the `expect()` keyword in .spec.ts files but this happened only when running tests with Jest (ts-jest) - TypeScript didn't know where to find the @types/jest (when using tsserver), so I changed the typeRoots to point to the parent pkg This is only a half-solution as I think if I used some composite configs it'd be more manageable fix(theme): squash this fix(theme): squash this * feat(theme): add nested categories in mobile category menu refactor(theme): refactor find active category function to return whole object squash this feat(theme): pinia working wip feat(theme): reorganize folders for helpers feat(theme): reduce duplication in graphql query feat(theme): adjust desktop and mobile categories fix(theme): fix invalid import paths
1 parent ffaa427 commit 1b11e9c

File tree

15 files changed

+370
-126
lines changed

15 files changed

+370
-126
lines changed

packages/theme/components/BottomNavigation.vue

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
</SfBottomNavigationItem>
1919
<SfBottomNavigationItem
2020
label="Menu"
21-
@click="toggleMobileMenu"
21+
@click="loadCategoryMenu"
2222
>
2323
<template #icon>
2424
<SvgImage
@@ -72,22 +72,25 @@
7272
</template>
7373
</SfBottomNavigationItem>
7474
</SfBottomNavigation>
75-
<MobileMenuSidebar />
75+
<MobileCategorySidebar v-if="isMobileMenuOpen" />
7676
</div>
7777
</template>
7878

7979
<script>
8080
import { SfBottomNavigation, SfCircleIcon } from '@storefront-ui/vue';
8181
import { defineComponent, useRouter, useContext } from '@nuxtjs/composition-api';
8282
import { useUiState, useUser } from '~/composables';
83-
import MobileMenuSidebar from '~/components/MobileMenuSidebar.vue';
8483
import SvgImage from '~/components/General/SvgImage.vue';
84+
import { useCategoryStore } from '~/stores/category';
85+
import { useApi } from '~/composables/useApi';
86+
87+
const MobileCategorySidebar = () => import('~/modules/catalog/category/components/sidebar/MobileCategorySidebar/MobileCategorySidebar.vue');
8588
8689
export default defineComponent({
8790
components: {
8891
SfBottomNavigation,
8992
SfCircleIcon,
90-
MobileMenuSidebar,
93+
MobileCategorySidebar,
9194
SvgImage,
9295
},
9396
setup() {
@@ -101,6 +104,7 @@ export default defineComponent({
101104
const { isAuthenticated } = useUser();
102105
const router = useRouter();
103106
const { app } = useContext();
107+
const api = useApi();
104108
const handleAccountClick = async () => {
105109
if (isAuthenticated.value) {
106110
await router.push(`${app.localePath('/my-account')}`);
@@ -109,12 +113,21 @@ export default defineComponent({
109113
}
110114
};
111115
116+
const loadCategoryMenu = async () => {
117+
const categories = useCategoryStore(api);
118+
if (categories.categories === null) {
119+
await categories.load();
120+
}
121+
toggleMobileMenu();
122+
};
123+
112124
return {
113125
isAuthenticated,
114126
isMobileMenuOpen,
115127
toggleWishlistSidebar,
116128
toggleCartSidebar,
117129
toggleMobileMenu,
130+
loadCategoryMenu,
118131
handleAccountClick,
119132
app,
120133
};

packages/theme/lang/de.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,4 +253,5 @@ export default {
253253
"There was some error while trying to fetch shipping methods. We are sorry, please try with different shipping details.": "Beim Versuch, Versandarten abzurufen, ist ein Fehler aufgetreten. Es tut uns leid, bitte versuchen Sie es mit anderen Versanddetails oder später.",
254254
"There was some error while trying to select this shipping method. We are sorry, please try with different shipping method.": "Beim Versuch, diese Versandart auszuwählen, ist ein Fehler aufgetreten. Es tut uns leid, bitte versuchen Sie es mit einer anderen Versandart.",
255255
"We can't find products matching the selection.":"Wir können keine Produkte finden, die der Auswahl entsprechen.",
256+
"AllProductsFromCategory": "Alle {categoryName}"
256257
};

packages/theme/lang/en.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,4 +251,5 @@ export default {
251251
"There was some error while trying to fetch shipping methods. We are sorry, please try with different shipping details.": "There was some error while trying to fetch shipping methods. We are sorry, please try with different shipping details.",
252252
"There was some error while trying to select this shipping method. We are sorry, please try with different shipping method.": "There was some error while trying to select this shipping method. We are sorry, please try with different shipping method.",
253253
"We can't find products matching the selection.":"We can't find products matching the selection.",
254+
"AllProductsFromCategory": "All {categoryName}"
254255
};

packages/theme/modules/catalog/category/components/sidebar/CategorySidebar.vue

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@
44
height="500px"
55
>
66
<SfAccordion
7-
:open="activeCategory"
8-
:show-chevron="true"
7+
:open="topLevelCategoryLabel"
8+
show-chevron
99
>
1010
<SfAccordionItem
11-
v-for="(cat, i) in categoryTree.items"
11+
v-for="(cat, i) in categoryTree && categoryTree.items"
1212
:key="i"
1313
:header="cat.label"
1414
>
1515
<SfList class="list">
1616
<SfListItem
17-
v-for="(subCat, j) in cat.items"
17+
v-for="(subCat, j) in cat && cat.items"
1818
:key="j"
1919
class="list__item"
2020
>
@@ -54,23 +54,25 @@
5454
</SkeletonLoader>
5555
</template>
5656

57-
<script>
57+
<script lang="ts">
5858
import {
5959
SfList,
6060
SfMenuItem,
6161
SfAccordion,
6262
} from '@storefront-ui/vue';
63+
6364
import {
6465
defineComponent, onMounted, ref,
65-
useRoute,
66+
useRoute, computed,
6667
} from '@nuxtjs/composition-api';
67-
6868
import { useUiHelpers } from '~/composables';
69-
import { useSidebar } from './useSidebar.ts';
70-
import SkeletonLoader from '~/components/SkeletonLoader';
69+
import SkeletonLoader from '~/components/SkeletonLoader/index.vue';
70+
import { useApi } from '~/composables/useApi';
71+
import { useCategoryStore } from '~/stores/category';
72+
import { findActiveCategory, findCategoryAncestors } from '~/modules/catalog/category/helpers';
73+
import { CategoryTreeInterface } from '../../types';
7174
7275
export default defineComponent({
73-
name: 'CategorySidebar',
7476
components: {
7577
SfList,
7678
SfMenuItem,
@@ -79,23 +81,31 @@ export default defineComponent({
7981
},
8082
setup() {
8183
const uiHelpers = useUiHelpers();
82-
const categoryTree = ref({});
83-
const activeCategory = ref('');
84-
const isLoading = ref(true);
8584
const route = useRoute();
86-
const { loadCategoryTree, findActiveCategory } = useSidebar();
85+
const api = useApi();
86+
87+
const categoryStore = useCategoryStore(api);
88+
const categoryTree = computed(() => categoryStore.categories);
89+
const activeCategory = ref<CategoryTreeInterface | null>(null);
90+
const activeCategoryAncestors = ref(null);
91+
const isLoading = ref(true);
8792
8893
onMounted(async () => {
89-
categoryTree.value = await loadCategoryTree() ?? {};
90-
activeCategory.value = findActiveCategory(categoryTree.value, route.value.fullPath);
94+
if (categoryStore.categories === null) {
95+
await categoryStore.load();
96+
}
97+
activeCategory.value = findActiveCategory(categoryTree.value, route.value.fullPath.replace('/default/c', ''));
98+
activeCategoryAncestors.value = findCategoryAncestors(categoryStore.categories, activeCategory.value);
9199
isLoading.value = false;
92100
});
93101
102+
const topLevelCategoryLabel = computed(() => activeCategoryAncestors.value?.[0]?.label);
103+
94104
return {
95105
...uiHelpers,
96106
categoryTree,
97-
activeCategory,
98107
isLoading,
108+
topLevelCategoryLabel,
99109
};
100110
},
101111
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<template>
2+
<SfSidebar
3+
:title="currentCategory && currentCategory.label || $t('Menu')"
4+
visible
5+
class="mobile-menu-sidebar sf-sidebar--left"
6+
@close="toggleMobileMenu"
7+
>
8+
<SfList class="mobile-menu-sidebar__list">
9+
<template v-if="currentCategory">
10+
<SfMenuItem
11+
class="mobile-menu-sidebar__item"
12+
:label="$i18n.t('Go back')"
13+
@click="onGoCategoryUp()"
14+
>
15+
<template #mobile-nav-icon>
16+
<div v-show="false" />
17+
</template>
18+
</SfMenuItem>
19+
20+
<SfMenuItem
21+
class="mobile-menu-sidebar__item"
22+
:label="$i18n.t('AllProductsFromCategory', { categoryName: currentCategory.label })"
23+
:count="currentCategory.count"
24+
@click="navigate(currentCategory)"
25+
/>
26+
</template>
27+
<SfMenuItem
28+
v-for="(category, index) in currentItems || categoryTree.items"
29+
:key="index"
30+
:label="category.label"
31+
:count="category.count"
32+
class="mobile-menu-sidebar__item"
33+
@click="category.items.length === 0 ? navigate(category) : onGoCategoryDown(category)"
34+
/>
35+
</SfList>
36+
</SfSidebar>
37+
</template>
38+
<script lang="ts">
39+
import {
40+
SfSidebar, SfList, SfMenuItem,
41+
} from '@storefront-ui/vue';
42+
import {
43+
defineComponent, useRouter, useContext, useRoute,
44+
} from '@nuxtjs/composition-api';
45+
import { useUiHelpers, useUiState } from '~/composables';
46+
import { CategoryTreeInterface } from '~/modules/catalog/category/types';
47+
import { findActiveCategory, findCategoryAncestors } from '~/modules/catalog/category/helpers';
48+
import { useApi } from '~/composables/useApi';
49+
import { useCategoryStore } from '~/stores/category';
50+
import { useMobileCategoryTree } from './logic';
51+
52+
export default defineComponent({
53+
components: {
54+
SfSidebar,
55+
SfList,
56+
SfMenuItem,
57+
},
58+
setup() {
59+
const api = useApi();
60+
const { isMobileMenuOpen, toggleMobileMenu } = useUiState();
61+
const { getAgnosticCatLink } = useUiHelpers();
62+
const router = useRouter();
63+
const route = useRoute();
64+
const app = useContext();
65+
66+
const categoryStore = useCategoryStore(api);
67+
const categoryTree = categoryStore.categories;
68+
69+
const navigate = (category: CategoryTreeInterface) => {
70+
toggleMobileMenu();
71+
const path = app.localePath(getAgnosticCatLink(category) as string);
72+
router.push(path);
73+
};
74+
75+
const activeCategory = findActiveCategory(categoryTree, route.value.fullPath.replace('/default/c', ''));
76+
const initialHistory: CategoryTreeInterface[] = activeCategory === null ? [] : findCategoryAncestors(categoryTree, activeCategory);
77+
78+
// A category-less category can't be entered into - it can only navigated to
79+
const initialHistoryWithSnippedSubcategorylessTail = initialHistory.at(-1)?.items.length
80+
? initialHistory
81+
: initialHistory.slice(0, -1);
82+
83+
const {
84+
current: currentCategory, history, currentItems, onGoCategoryUp, onGoCategoryDown,
85+
} = useMobileCategoryTree(initialHistoryWithSnippedSubcategorylessTail);
86+
87+
return {
88+
currentCategory,
89+
currentItems,
90+
onGoCategoryUp,
91+
onGoCategoryDown,
92+
categoryTree,
93+
history,
94+
95+
navigate,
96+
isMobileMenuOpen,
97+
toggleMobileMenu,
98+
};
99+
},
100+
});
101+
</script>
102+
103+
<style lang="scss" scoped>
104+
.mobile-menu-sidebar {
105+
--sidebar-z-index: 3;
106+
--overlay-z-index: 3;
107+
108+
&__list {
109+
.mobile-menu-sidebar__item {
110+
padding: var(--spacer-base) 0;
111+
--menu-item-font-size: 1.75rem;
112+
113+
&:not(:first-of-type) {
114+
border-top: 1px solid var(--c-light);
115+
}
116+
117+
&:not(:last-of-type) {
118+
border-bottom: 1px solid var(--c-light);
119+
}
120+
}
121+
}
122+
123+
::v-deep {
124+
.nuxt-link-active {
125+
--menu-item-label-color: var(--c-primary);
126+
}
127+
}
128+
}
129+
.go-back {
130+
display: flex;
131+
align-items: center;
132+
}
133+
</style>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { CategoryTreeInterface } from '~/modules/catalog/category/types';
2+
import { useMobileCategoryTree } from '../logic';
3+
4+
const createCategoryItem = (label: string): CategoryTreeInterface => ({
5+
label, items: [], isCurrent: false, count: 10,
6+
});
7+
8+
describe('categoryTreeLogic', () => {
9+
it('can go down down a category', () => {
10+
const itemFirst = createCategoryItem('Itemless1');
11+
const { history, current, onGoCategoryDown } = useMobileCategoryTree();
12+
onGoCategoryDown(itemFirst);
13+
expect(current.value.label).toBe(itemFirst.label);
14+
expect(history.value).toHaveLength(1);
15+
});
16+
17+
it('can go up a category', () => {
18+
const itemFirst = createCategoryItem('Itemless1');
19+
const itemSecond = createCategoryItem('Itemless2');
20+
21+
const { current, onGoCategoryDown, onGoCategoryUp } = useMobileCategoryTree();
22+
23+
onGoCategoryDown(itemFirst);
24+
onGoCategoryDown(itemSecond);
25+
onGoCategoryUp();
26+
27+
expect(current.value.label).toBe(itemFirst.label);
28+
});
29+
30+
it('current item is last in history', () => {
31+
const itemFirst = createCategoryItem('Itemless1');
32+
const itemSecond = createCategoryItem('Itemless2');
33+
const { history, current, onGoCategoryDown } = useMobileCategoryTree();
34+
35+
onGoCategoryDown(itemFirst);
36+
onGoCategoryDown(itemSecond);
37+
38+
expect(current.value.label).toBe(itemSecond.label);
39+
expect(history.value).toHaveLength(2);
40+
});
41+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { computed, ref } from '@nuxtjs/composition-api';
2+
import { CategoryTreeInterface } from '~/modules/catalog/category/types';
3+
4+
export const useMobileCategoryTree = (initialHistory: CategoryTreeInterface[] = []) => {
5+
const history = ref<CategoryTreeInterface[]>(initialHistory);
6+
const current = computed<CategoryTreeInterface | null>(() => history.value.at(-1) ?? null);
7+
const currentItems = computed<CategoryTreeInterface[]>(() => current.value?.items);
8+
const onGoCategoryDown = (category: CategoryTreeInterface) => {
9+
history.value.push(category);
10+
};
11+
const onGoCategoryUp = () => history.value.pop();
12+
13+
return {
14+
history,
15+
current,
16+
currentItems,
17+
onGoCategoryUp,
18+
onGoCategoryDown,
19+
};
20+
};

0 commit comments

Comments
 (0)