Skip to content

Commit ce45a06

Browse files
committed
feat: header categories
- add multilevel categories navigation - add unit tests for new components - upgrade @testing-library/user-event @13.5 -> @14.1.1 M2-424
1 parent bfd0018 commit ce45a06

File tree

13 files changed

+335
-43
lines changed

13 files changed

+335
-43
lines changed

.eslintrc.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ module.exports = {
3131
'@vue-storefront/eslint-config-jest',
3232
],
3333
rules: {
34-
"@typescript-eslint/no-floating-promises": "off"
34+
"@typescript-eslint/no-floating-promises": "off",
35+
"jest/expect-expect": "off"
3536
}
3637
}

packages/theme/components/AppHeader.vue

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,7 @@
99
<HeaderLogo />
1010
</template>
1111
<template #navigation>
12-
<HeaderNavigationItem
13-
v-for="(category, index) in categoryTree"
14-
:key="index"
15-
v-e2e="'app-header-url_women'"
16-
class="nav-item"
17-
:label="category.name"
18-
:link="localePath(getCatLink(category))"
19-
/>
12+
<HeaderNavigation :category-tree="categoryTree" />
2013
</template>
2114
<template #aside>
2215
<div class="sf-header__switchers">
@@ -122,7 +115,7 @@ import {
122115
onMounted,
123116
useFetch,
124117
} from '@nuxtjs/composition-api';
125-
import HeaderNavigationItem from '~/components/Header/Navigation/HeaderNavigationItem.vue';
118+
import HeaderNavigation from '~/components/Header/Navigation/HeaderNavigation.vue';
126119
import {
127120
useCart,
128121
useCategory,
@@ -132,14 +125,15 @@ import {
132125
useUser,
133126
} from '~/composables';
134127
128+
import type { CategoryTree } from '~/modules/GraphQL/types';
135129
import CurrencySelector from '~/components/CurrencySelector.vue';
136130
import HeaderLogo from '~/components/HeaderLogo.vue';
137131
import SvgImage from '~/components/General/SvgImage.vue';
138132
import StoreSwitcher from '~/components/StoreSwitcher.vue';
139133
140134
export default defineComponent({
141135
components: {
142-
HeaderNavigationItem,
136+
HeaderNavigation,
143137
SfHeader,
144138
SfOverlay,
145139
CurrencySelector,
@@ -170,7 +164,7 @@ export default defineComponent({
170164
171165
const wishlistHasProducts = computed(() => wishlistItemsQty.value > 0);
172166
const accountIcon = computed(() => (isAuthenticated.value ? 'profile_fill' : 'profile'));
173-
const categoryTree = ref([]);
167+
const categoryTree = ref<CategoryTree[]>([]);
174168
175169
const handleAccountClick = async () => {
176170
if (isAuthenticated.value) {
@@ -235,14 +229,6 @@ export default defineComponent({
235229
z-index: 2;
236230
}
237231
238-
.nav-item {
239-
--header-navigation-item-margin: 0 var(--spacer-sm);
240-
241-
.sf-header-navigation-item__item--mobile {
242-
display: none;
243-
}
244-
}
245-
246232
.cart-badge {
247233
position: absolute;
248234
bottom: 40%;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<template>
2+
<div
3+
class="header-navigation"
4+
>
5+
<div class="header-navigation__main">
6+
<HeaderNavigationItem
7+
v-for="(category, index) in categoryTree"
8+
:key="index"
9+
:data-testid="category.uid"
10+
class="nav-item"
11+
:label="category.name"
12+
:link="localePath(getCatLink(category))"
13+
@mouseenter.native="setCurrentCategory(category)"
14+
@keyup.tab.native="setCurrentCategory(category)"
15+
/>
16+
</div>
17+
<HeaderNavigationSubcategories
18+
v-if="hasChildren(currentCategory)"
19+
:current-category="currentCategory"
20+
@hideSubcategories="setCurrentCategory(null)"
21+
/>
22+
</div>
23+
</template>
24+
<script lang="ts">
25+
import {
26+
defineComponent, PropType, ref,
27+
} from '@nuxtjs/composition-api';
28+
import HeaderNavigationItem from './HeaderNavigationItem.vue';
29+
30+
import { CategoryTree } from '~/modules/GraphQL/types';
31+
import { useUiHelpers } from '~/composables';
32+
33+
export default defineComponent({
34+
name: 'HeaderNavigation',
35+
components: {
36+
HeaderNavigationSubcategories: () => import('~/components/Header/Navigation/HeaderNavigationSubcategories.vue'),
37+
HeaderNavigationItem,
38+
},
39+
props: {
40+
categoryTree: {
41+
type: Array as PropType<CategoryTree[]>,
42+
default: () => [],
43+
},
44+
},
45+
setup() {
46+
const { getCatLink } = useUiHelpers();
47+
const currentCategory = ref<CategoryTree>(null);
48+
49+
const setCurrentCategory = (category: CategoryTree | null) => {
50+
currentCategory.value = category;
51+
};
52+
53+
const hasChildren = (category: CategoryTree) => Boolean(category?.children);
54+
55+
return {
56+
getCatLink,
57+
setCurrentCategory,
58+
currentCategory,
59+
hasChildren,
60+
};
61+
},
62+
});
63+
</script>
64+
<style lang="scss" scoped>
65+
.header-navigation {
66+
&__main {
67+
display: flex;
68+
}
69+
}
70+
.nav-item {
71+
--header-navigation-item-margin: 0 var(--spacer-sm);
72+
73+
.sf-header-navigation-item__item--mobile {
74+
display: none;
75+
}
76+
}
77+
</style>

packages/theme/components/Header/Navigation/HeaderNavigationItem.vue

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
<template>
22
<div class="sf-header-navigation-item">
33
<div class="sf-header-navigation-item__item sf-header-navigation-item__item--desktop">
4-
<slot name="desktop-navigation-item">
5-
<SfLink
6-
class="sf-header-navigation-item__link"
7-
:link="link"
8-
>
9-
{{
10-
label
11-
}}
12-
</SfLink>
13-
</slot>
14-
<slot />
4+
<SfLink
5+
class="sf-header-navigation-item__link"
6+
:link="link"
7+
>
8+
{{
9+
label
10+
}}
11+
</SfLink>
1512
</div>
1613
</div>
1714
</template>
18-
<script>
15+
<script lang="ts">
1916
import { SfLink } from '@storefront-ui/vue';
2017
2118
export default {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<template>
2+
<div
3+
v-if="hasChildren(currentCategory)"
4+
data-testid="navigation-subcategories"
5+
class="header-navigation__subcategories"
6+
>
7+
<div
8+
v-for="(catLvl1, idxLvl1) in currentCategory.children"
9+
:key="idxLvl1"
10+
class="header-navigation__subcategory"
11+
>
12+
<SfLink
13+
class="header-navigation__link1"
14+
:link="localePath(getCatLink(catLvl1))"
15+
@click.native="$emit('hideSubcategories')"
16+
>
17+
<SfHeading
18+
:level="5"
19+
:title="catLvl1.name"
20+
class="sf-heading sf-heading--left"
21+
/>
22+
</SfLink>
23+
24+
<SfList v-if="hasChildren(catLvl1)">
25+
<SfListItem
26+
v-for="(catLvl2, idxLvl2) in catLvl1.children"
27+
:key="idxLvl2"
28+
>
29+
<SfLink
30+
class="header-navigation__link2"
31+
:link="localePath(getCatLink(catLvl2))"
32+
@click.native="$emit('hideSubcategories')"
33+
>
34+
{{ catLvl2.name }}
35+
</SfLink>
36+
</SfListItem>
37+
</SfList>
38+
</div>
39+
</div>
40+
</template>
41+
<script lang="ts">
42+
import { SfLink, SfList, SfHeading } from '@storefront-ui/vue';
43+
import { defineComponent, PropType } from '@nuxtjs/composition-api';
44+
import { CategoryTree } from '~/modules/GraphQL/types';
45+
import { useUiHelpers } from '~/composables';
46+
47+
export default defineComponent({
48+
name: 'HeaderNavigationSubcategories',
49+
components: {
50+
SfLink,
51+
SfList,
52+
SfHeading,
53+
},
54+
props: {
55+
currentCategory: {
56+
type: Object as PropType<CategoryTree | null>,
57+
default: () => null,
58+
},
59+
},
60+
setup() {
61+
const { getCatLink } = useUiHelpers();
62+
const hasChildren = (category: CategoryTree) => Boolean(category?.children.length > 0);
63+
64+
return {
65+
getCatLink,
66+
hasChildren,
67+
};
68+
},
69+
});
70+
</script>
71+
<style lang="scss" scoped>
72+
.header-navigation {
73+
&__subcategories {
74+
display: flex;
75+
position: absolute;
76+
z-index: 10;
77+
background-color: #fff;
78+
width: 100%;
79+
box-shadow: 0 3px var(--c-primary);
80+
left: 0;
81+
padding: 30px;
82+
right: 0;
83+
top: 95%;
84+
flex-wrap: wrap;
85+
justify-content: flex-start;
86+
}
87+
88+
&__subcategory {
89+
flex: 0 0 25%;
90+
}
91+
92+
.sf-heading {
93+
margin-bottom: var(--spacer-sm);
94+
}
95+
96+
.sf-link {
97+
text-decoration: none;
98+
&:hover {
99+
text-decoration: underline;
100+
}
101+
}
102+
103+
&__link2 {
104+
&:hover {
105+
color: var(--c-primary);
106+
107+
}
108+
}
109+
}
110+
</style>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import userEvent from '@testing-library/user-event';
2+
import HeaderNavigation from '../HeaderNavigation.vue';
3+
import { render } from '~/test-utils';
4+
5+
import { useUiHelpers } from '~/composables';
6+
import useUiHelpersMock from '~/test-utils/mocks/useUiHelpersMock';
7+
import CategoryTreeDataMock from '~/test-utils/mocks/categoryTreeDataMock';
8+
9+
jest.mock('~/composables');
10+
useUiHelpers.mockReturnValue(useUiHelpersMock());
11+
12+
const sharedOptions = { props: { categoryTree: CategoryTreeDataMock[0].children } };
13+
describe('HeaderNavigation', () => {
14+
it('displays main categories', () => {
15+
const { getByRole } = render(HeaderNavigation, sharedOptions);
16+
getByRole('link', { name: /women/i });
17+
getByRole('link', { name: /gear/i });
18+
getByRole('link', { name: /training/i });
19+
});
20+
21+
it('subcategories are hidden by default', () => {
22+
const { queryByTestId } = render(HeaderNavigation, sharedOptions);
23+
expect(queryByTestId(/navigation-subcategories/i)).toBeNull();
24+
});
25+
26+
it('subcategories are displayed on @mouseenter', async () => {
27+
const user = userEvent.setup();
28+
const { getByTestId, getByRole } = render(HeaderNavigation, sharedOptions);
29+
const womenCategory = getByTestId('MjA=');
30+
31+
await user.hover(womenCategory);
32+
getByTestId(/navigation-subcategories/i);
33+
getByRole('link', { name: /tops/i });
34+
getByRole('link', { name: /jackets/i });
35+
});
36+
37+
it('subcategories are displayed on @keyup.tab', async () => {
38+
const user = userEvent.setup();
39+
const { getByTestId, getByRole } = render(HeaderNavigation, sharedOptions);
40+
const womenCategory = getByRole('link', { name: /women/i });
41+
42+
await user.tab(); // "What's new" no children category
43+
await user.tab(); // "Women" category with children
44+
45+
expect(womenCategory).toHaveFocus();
46+
getByTestId(/navigation-subcategories/i);
47+
getByRole('link', { name: /tops/i });
48+
getByRole('link', { name: /jackets/i });
49+
});
50+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import HeaderNavigationItem from '../HeaderNavigationItem.vue';
2+
import { render } from '~/test-utils';
3+
4+
describe('HeaderNavigationItem', () => {
5+
it('display proper label and link', () => {
6+
const testLabel = 'test_label';
7+
const testLink = 'http://some_path/';
8+
const { getByText, getByRole } = render(HeaderNavigationItem, { props: { label: testLabel, link: testLink } });
9+
getByText(testLabel);
10+
expect(getByRole('link', { name: /test_label/i })).toHaveAttribute('href', testLink);
11+
});
12+
});

0 commit comments

Comments
 (0)