# v2.1.0 release notes
# Introduction
In this release, we added a filtering feature on category page. To add this feature, we:
- added a new
useFilters
composable, - added a new
filter
method touseProduct
composable, - added a new
FilterSidebar
component, - modified
usePagination
composable to support filters pagination, - added new
Sort by
options - fixed an issue with
getFacetsFromURL
method ofuseUIHelpers
composable, - added a filters guide showing how to implement filtering feature in existing projects.
# Filters guide
All necessary information, including how to use new composables, methods and component can be found in our Filters guide.
# Migration guide
# New useFilters
composable
To be able to load available filters, you should add useFilters
compsable and its types to your composables/
directory.
Implementation:
// composables/useFilters/index.ts
import { useContext, ref } from '@nuxtjs/composition-api';
import { useLogger } from '~/composables/useLogger';
import {
GetFiltersParameters,
GraphQL
} from '@vsf-enterprise/bigcommerce-api';
import { UseFilterErrors, UseFilterInterface } from './types';
/**
* @public
*
* Allows loading filters available for products.
*
* See the {@link UseFilterInterface} for a list of methods and values available in this composable.
*/
export function useFilters(): UseFilterInterface {
const { $bigcommerce } = useContext();
const { Logger } = useLogger();
const loading = ref(false);
const error = ref<UseFilterErrors>({
load: null
});
const load = async (
params: GetFiltersParameters
): Promise<GraphQL.SearchProductFilterConnection> => {
try {
loading.value = true;
error.value.load = null;
return await $bigcommerce.api.getFilters(params);
} catch (err) {
Logger.error('useFilters/load', err);
error.value.load = err;
} finally {
loading.value = false;
}
};
return {
load,
loading,
error
};
}
Types:
// composables/useFilters/types/index.ts
import { Ref } from '@nuxtjs/composition-api';
import {
GraphQL,
GetFiltersParameters
} from '@vsf-enterprise/bigcommerce-api';
/**
* `useFilters` composable errors.
*/
export interface UseFilterErrors {
/**
* Errors occurred during `load` action.
*/
load: Error;
}
/**
* Data and methods returned from the {@link useFilters|useFilters()} composable
*/
export interface UseFilterInterface {
/**
* Contains errors from the composable methods.
*/
error: Ref<UseFilterErrors>;
/**
* Indicates whether any of the methods is in progress.
*/
loading: Ref<boolean>;
/**
* load filters.
*/
load: (
params: GetFiltersParameters
) => Promise<GraphQL.SearchProductFilterConnection>;
}
Remember to export implementation from composables/index.ts
and types from composables/types.ts
files:
// composables/index.ts
...
+ export { useFilters } from './useFilters';
...
// composables/index.ts
...
+ export * from './useFilters/types';
...
# New method of useProduct
composable
To be able to filter products, you should add new filter
method to useProduct
composable.
// composables/useProduct/index.ts
+ const filter = async (
+ params: GetProductsWithFilterParameters
+ ): Promise<ProductsResponse> => {
+ try {
+ loading.value = true;
+ error.value.filter = null;
+
+ return await $bigcommerce.api.getProductsWithFilter(params);
+ } catch (err) {
+ Logger.error('useProduct/filter', err);
+ error.value.filter = err;
+ } finally {
+ loading.value = false;
+ }
+ };
and it's types:
// composables/useProduct/types/index.ts
export interface UseProductInterface {
/**
* Contains errors from the composable methods.
*/
error: Ref<UseProductErrors>;
/**
* Indicates whether any of the methods is in progress.
*/
loading: Ref<boolean>;
/**
* Searches for products.
*/
search: (params: GetProductsParameters) => Promise<ProductsResponse>;
+ /**
+ * Filters products.
+ */
+ filter: (params: GetProductsWithFilterParameters) => Promise<ProductsResponse>
}
# Modification of usePagination
Filtered products response includes pageInfo
property that should be handeled by usePagination
composable.
Implementation:
export const usePagination = (
collection: PaginatedCollection
): UsePaginationInterface => {
const { $config } = useContext();
const defaultPagination = {
currentPage: 1,
totalPages: 1,
totalItems: 1,
- itemsPerPage: 1,
+ itemsPerPage: $config.theme?.itemsPerPage?.[0] || 1,
pageOptions: $config.theme?.itemsPerPage
};
const pagination = computed(() =>
getPagination(collection.value?.meta, defaultPagination)
);
+ const pageInfo = computed(() => collection.value?.meta?.pageInfo);
return {
pagination,
+ pageInfo
};
};
New types:
export interface UsePaginationInterface {
/**
* Computed pagination.
*/
pagination: ComputedRef<GetPaginationResponse>;
+ /**
+ * Computed page info.
+ */
+ pageInfo: ComputedRef<MetaCollection['pageInfo']>;
}
Helpers:
export function getPagination(meta: MetaCollection, defaults: GetPaginationResponse): GetPaginationResponse {
return {
currentPage: meta?.pagination?.current_page ?? defaults.currentPage,
totalPages: meta?.pagination?.total_pages ?? defaults.totalPages,
- totalItems: meta?.pagination?.total ?? defaults.totalItems,
+ totalItems: meta?.pagination?.total ?? meta?.totalItems ?? defaults.totalItems,
itemsPerPage: meta?.pagination?.per_page ?? defaults.itemsPerPage,
pageOptions: defaults.pageOptions
};
}
# Using categorySlug
of useUIHelpers
composable
Previously, when loading the categorySlug
from getFacetsFromURL
, categorySlug
was not reactive. This was
// Old usage
const { getFacetsFromURL } = useUIHelpers();
const { categorySlug } = getFacetsFromURL();
To make it reactive, we changed useUIHelpers
implementation like this:
...
export function useUiHelpers(): UseUiHelpersInterface {
const { $config } = useContext();
const route = useRoute();
const router = useRouter();
+ const categorySlug = computed(() => {
+ return `/${route.value.path.split('/c/').pop()}${
+ /\/$/.test(route.value.path) ? '' : '/'
+ }`;
+ });
const getFiltersDataFromUrl = (onlyFilters) => {
const { query } = route.value;
return Object.keys(query)
.filter((f) =>
onlyFilters ? !nonFilters.includes(f) : nonFilters.includes(f)
)
.reduce(reduceFilters(query), {});
};
const getFacetsFromURL = () => {
const { query } = route.value;
- const categorySlug = `/${path.split('/c/').pop()}${
- /\/$/.test(path) ? '' : '/'
- }`;
return {
- categorySlug,
page: parseInt(query.page as string, 10) || 1,
sort: query.sort,
direction: query.direction,
filters: getFiltersDataFromUrl(true),
itemsPerPage:
parseInt(query.itemsperpage as string, 10) ||
$config.theme?.itemsPerPage?.[0] ||
20,
term: query.term
};
};
...
+ const changeFilters = (filters: Record<string, string[]> = {}) => {
+ /**
+ * Remove empty filter values from query.
+ */
+ const query = Object.fromEntries(
+ Object.entries(filters).filter(([_, value]) => value.length > 0)
+ );
+
+ router.push({
+ query
+ });
+ };
+ const getFilterFromUrlAsArray = (filterFromUrl: QueryItem) => {
+ if (!filterFromUrl) {
+ return [];
+ }
+
+ return Array.isArray(filterFromUrl) ? filterFromUrl : [filterFromUrl];
+ };
...
return {
+ categorySlug,
getFacetsFromURL,
changeSorting,
changeFilters,
changeItemsPerPage,
formatDateString,
getFilterFromUrlAsArray
};
}
# Sort by options
Filtering feature requires other values for sorting products, they can be modified in themeConfig.js
file:
productsSortOptions: [
// Rest API sort-by options, used with `search` method of `useProduct` composable
- { label: 'Latest', id: 1, value: { sort: 'id', direction: 'desc' } },
- { label: 'Oldest', id: 2, value: { sort: 'id' } },
- { label: 'Name: A to Z', id: 3, value: { sort: 'name' } },
- {
- label: 'Name: Z to A',
- id: 4,
- value: { sort: 'name', direction: 'desc' }
- },
// GraphQL API sort-by options, used with `filter` method of `useProduct` composable
+ { label: 'Featured', id: 1, value: { sort: 'FEATURED' } },
+ { label: 'Relevance', id: 2, value: { sort: 'RELEVANCE' } },
+ { label: 'Best selling', id: 3, value: { sort: 'BEST_SELLING' } },
+ { label: 'Best reviewed', id: 4, value: { sort: 'BEST_REVIEWED' } },
+ { label: 'Newest', id: 5, value: { sort: 'NEWEST' } },
+ { label: 'Name: A to Z', id: 6, value: { sort: 'A_TO_Z' } },
+ { label: 'Name: Z to A', id: 7, value: { sort: 'Z_TO_A' } },
+ {
+ label: 'Price from high to low',
+ id: 8,
+ value: { sort: 'HIGHEST_PRICE' }
+ },
+ { label: 'Price from low to high', id: 9, value: { sort: 'LOWEST_PRICE' } }
],
# New FilterSidebar
component
You can start building your FilterSidebar
component based on the example that we used in our template
<template>
<LazyHydrate when-idle>
<SfSidebar
:visible="isFilterSidebarOpen"
class="sidebar-filters"
data-testid="mobile-sidebar"
@close="toggleFilterSidebar"
>
<span v-if="filtersLoading" class="filters__loading-title">
{{ $t('Loading filters') }}...
</span>
<template v-else-if="areFiltersAvailable && !filtersLoading">
<SfAccordion class="filters-accordion smartphone-only">
<SfAccordionItem
key="filter-title-colors"
header="Color"
class="filters-accordion-item filters-accordion-item__colors"
v-if="colors.length > 0"
>
<SfColor
v-for="color of colors"
:key="color.value"
:color="color.value"
:selected="filters.color === color.value"
class="filters-item__colors"
@click="setSelectedColor(color.value)"
/>
</SfAccordionItem>
<SfAccordionItem
key="filter-title-sizes"
header="Size"
class="filters-accordion-item"
v-if="sizes.length > 0"
>
<SfFilter
v-for="size in sizes"
:key="size.value"
:label="size.value"
:count="size.productCount"
:selected="filters.size === size.value"
class="filters-item__sizes"
@change="setSelectedSize(size.value)"
/>
</SfAccordionItem>
</SfAccordion>
<div v-if="colors.length > 0" class="filters-container desktop-only">
<SfHeading
:level="4"
:title="$t('Color')"
class="filters-title__colors"
/>
<div class="filters-content__colors filters-content">
<SfColor
v-for="color of colors"
:key="color.value"
:color="color.value"
:selected="filters.color.includes(color.value)"
class="filters-item__colors"
@click="setSelectedColor(color.value)"
/>
</div>
</div>
<div v-if="sizes.length > 0" class="filters-container desktop-only">
<SfHeading
:level="4"
:title="$t('Size')"
class="filters-title__sizes"
/>
<div class="filters-content__sizes filters-content">
<SfFilter
v-for="size in sizes"
:key="size.value"
:label="size.value"
:count="size.productCount"
:selected="filters.size.includes(size.value)"
class="filters-item__sizes"
@change="setSelectedSize(size.value)"
/>
</div>
</div>
</template>
<span v-else class="filters__empty-title">
{{ $t('No filters are available now.') }}
</span>
<template #content-bottom>
<div class="filters__buttons">
<SfButton
class="sf-button--full-width"
:aria-label="$t('Apply filters')"
@click="applyFilter"
>
{{ $t('Done') }}
</SfButton>
<SfButton
class="sf-button--full-width filters__button-clear"
:aria-label="$t('Clear filters')"
@click="resetFilter"
>
{{ $t('Clear All') }}
</SfButton>
</div>
</template>
</SfSidebar>
</LazyHydrate>
</template>
<script lang="ts">
import {
computed,
defineComponent,
onMounted,
reactive,
ref,
watch
} from '@nuxtjs/composition-api';
import LazyHydrate from 'vue-lazy-hydration';
import {
SfAccordion,
SfMenuItem,
SfList,
SfLoader,
SfHeading,
SfColor,
SfSidebar,
SfFilter,
SfButton
} from '@storefront-ui/vue';
import { GraphQL } from '@vsf-enterprise/bigcommerce-api';
import { useUiHelpers, useUiState, useFilters } from '~/composables';
import { useCategoryTreeStore } from '~/stores';
export default defineComponent({
components: {
LazyHydrate,
SfAccordion,
SfFilter,
SfList,
SfHeading,
SfMenuItem,
SfLoader,
SfColor,
SfSidebar,
SfButton
},
transition: 'fade',
setup() {
const { load, loading: filtersLoading } = useFilters();
const {
categorySlug,
changeFilters,
getFacetsFromURL,
getFilterFromUrlAsArray
} = useUiHelpers();
const { isFilterSidebarOpen, toggleFilterSidebar } = useUiState();
const categoryTreeStore = useCategoryTreeStore();
const { filters: filtersFromUrl } = getFacetsFromURL();
const filters = reactive<{
color: string[];
size: string[];
}>({
color: getFilterFromUrlAsArray(filtersFromUrl.color),
size: getFilterFromUrlAsArray(filtersFromUrl.size)
});
const categorizedFilters = ref({});
const categoryId = computed(() => {
const category = categoryTreeStore.listOfRootBranches.find((item) =>
item.url.includes(categorySlug.value)
);
if (!category) return null;
return category.id;
});
async function fetchAvailableFilters() {
if (!categoryId.value || categorizedFilters.value[categoryId.value]) {
return;
}
const raw = await load({
filters: {
categoryEntityId: categoryId.value
}
});
categorizedFilters.value = {
...categorizedFilters.value,
[categoryId.value]: raw.edges.map((item) => item.node)
};
}
onMounted(() => fetchAvailableFilters());
watch(categorySlug, () => {
fetchAvailableFilters();
});
const currentFilters = computed(() => {
const currentCategoryFilters = categorizedFilters.value[categoryId.value];
if (!currentCategoryFilters) {
return { color: [], size: [] };
}
const allAttributesFilters: GraphQL.ProductAttributeSearchFilter[] =
currentCategoryFilters.filter((item) => Boolean(item.attributes));
return allAttributesFilters.reduce(
(obj, item) => {
obj[item.name.toLowerCase()] =
item.attributes.edges.map((item) => item.node) ?? [];
return obj;
},
{ color: [], size: [] }
);
});
const areFiltersAvailable = computed(
() =>
currentFilters.value.color.length > 0 ||
currentFilters.value.size.length > 0
);
function resetFilter() {
filters.color = [];
filters.size = [];
changeFilters();
toggleFilterSidebar();
}
function applyFilter() {
if (areFiltersAvailable.value) {
changeFilters({ color: filters.color, size: filters.size });
}
toggleFilterSidebar();
}
function setSelectedColor(color: string) {
if (filters.color.includes(color)) {
filters.color = filters.color.filter((item) => color !== item);
} else {
filters.color.push(color);
}
}
function setSelectedSize(size: string) {
if (filters.size.includes(size)) {
filters.size = filters.size.filter((item) => size !== item);
} else {
filters.size.push(size);
}
}
return {
filtersLoading,
colors: computed(() => currentFilters.value.color),
sizes: computed(() => currentFilters.value.size),
filters,
isFilterSidebarOpen,
areFiltersAvailable,
toggleFilterSidebar,
applyFilter,
resetFilter,
setSelectedColor,
setSelectedSize
};
}
});
</script>
<style lang="scss" scoped>
.sf-heading__title {
text-align: left;
display: block;
}
.list {
&__item {
.nuxt-link-exact-active {
text-decoration: underline;
}
}
}
.filters {
&__buttons {
margin: var(--spacer-sm) 0;
}
&__button-clear {
--button-background: var(--c-light);
--button-color: var(--c-dark-variant);
margin: var(--spacer-xs) 0 0 0;
}
}
.filters-container {
&:not(:first-of-type) {
margin-top: var(--spacer-lg);
}
}
.filters-content {
margin-top: var(--spacer-sm);
&__colors {
display: flex;
}
}
.filters-item {
&__colors {
margin: var(--spacer-xs);
margin-top: 0;
}
}
.filters-accordion-item {
&__colors {
::v-deep .sf-accordion-item__content {
display: flex;
}
}
}
::v-deep .sf-sidebar {
&__aside {
z-index: 3;
}
}
</style>