# 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 to useProduct composable,
  • added a new FilterSidebar component,
  • modified usePagination composable to support filters pagination,
  • added new Sort by options
  • fixed an issue with getFacetsFromURL method of useUIHelpers 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>