diff options
author | Marcin Gębala <elwoodxblues@users.noreply.github.com> | 2017-10-06 16:13:09 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-10-06 16:13:09 +0200 |
commit | d65b80f70e97c108cbc5cf8359266d5e0cee4766 (patch) | |
tree | e46c7f0c93bcfbb118fc609d6fc6c02851da1259 | |
parent | 9e00c6572bc3e6df855f431ef4b3db2d59f11e0a (diff) | |
parent | ff205096360362eb48cad5a45ef67467e0569885 (diff) | |
download | saleor-frontend-d65b80f70e97c108cbc5cf8359266d5e0cee4766.tar.gz saleor-frontend-d65b80f70e97c108cbc5cf8359266d5e0cee4766.tar.bz2 saleor-frontend-d65b80f70e97c108cbc5cf8359266d5e0cee4766.zip |
Merge pull request #1135 from mirumee/static_category_view
Static category view
32 files changed, 459 insertions, 1027 deletions
diff --git a/package.json b/package.json index 03532d74..cce8900d 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,6 @@ "query-string": "^5.0.0", "react": "^15.4.2", "react-dom": "^15.4.2", - "react-inlinesvg": "^0.6.2", - "react-relay": "^1.3.0", "select2": "^4.0.3", "sortablejs": "^1.5.0-rc1", "svg-injector-2": "^2.0.35", @@ -54,8 +52,6 @@ "babel-preset-es2015": "^6.22.0", "babel-preset-react": "^6.22.0", "babel-preset-stage-0": "^6.22.0", - "babel-relay-plugin": "^0.11.0", - "babel-relay-plugin-loader": "^0.11.0", "css-loader": "^0.28.7", "eslint": "^4.6.1", "eslint-config-standard": "^10.2.1", @@ -81,8 +77,7 @@ "plugins": [ "transform-decorators-legacy", "transform-class-properties", - "transform-object-rest-spread", - "babel-relay-plugin-loader" + "transform-object-rest-spread" ] }, "metadata": { diff --git a/requirements.in b/requirements.in index 2e9851c3..45b9a72b 100644 --- a/requirements.in +++ b/requirements.in @@ -7,6 +7,7 @@ dj_email_url>=0.0.4 django-bootstrap3>=8.2.1 django-cache-url>=1.0.0 django-countries>=4.1 +django-filter==1.0.4 django-mptt>=0.7.1 django-payments>=0.11.0 django-prices>=0.4.11 diff --git a/requirements.txt b/requirements.txt index f653c836..b849e94c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ dj-email-url==0.0.10 django-bootstrap3==9.0.0 django-cache-url==1.3.1 django-countries==4.6.1 +django-filter==1.0.4 django-mptt==0.8.7 django-payments==0.11.0.3 django-prices-openexchangerates==0.1.15 diff --git a/saleor/core/permissions.py b/saleor/core/permissions.py index 941b79f8..b0ced75a 100644 --- a/saleor/core/permissions.py +++ b/saleor/core/permissions.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals from django.contrib.auth.models import Permission diff --git a/saleor/core/templatetags/shop.py b/saleor/core/templatetags/shop.py index f5318703..598baa6e 100644 --- a/saleor/core/templatetags/shop.py +++ b/saleor/core/templatetags/shop.py @@ -1,9 +1,12 @@ +from __future__ import unicode_literals try: from itertools import zip_longest except ImportError: from itertools import izip_longest as zip_longest from django.template import Library +from django.utils.http import urlencode + register = Library() @@ -13,3 +16,14 @@ def slice(items, group_size=1): args = [iter(items)] * group_size return (filter(None, group) for group in zip_longest(*args, fillvalue=None)) + + +@register.simple_tag(takes_context=True) +def get_sort_by_url(context, field, descending=False): + request = context['request'] + request_get = request.GET.dict() + if descending: + request_get['sort_by'] = '-' + field + else: + request_get['sort_by'] = field + return '%s?%s' % (request.path, urlencode(request_get)) diff --git a/saleor/product/filters.py b/saleor/product/filters.py new file mode 100644 index 00000000..0773fca3 --- /dev/null +++ b/saleor/product/filters.py @@ -0,0 +1,81 @@ +from __future__ import unicode_literals +from collections import OrderedDict + +from django_filters import (FilterSet, MultipleChoiceFilter, RangeFilter, + OrderingFilter) +from django.forms import CheckboxSelectMultiple +from django.utils.translation import pgettext_lazy + +from django_prices.models import PriceField + +from .models import Product, ProductAttribute + + +DEFAULT_SORT = 'name' + + +SORT_BY_FIELDS = [{'value': 'name', + 'label': pgettext_lazy('Sort by filter', 'name')}, + {'value': 'price', + 'label': pgettext_lazy('Sort by filter', 'price')}] + + +class ProductFilter(FilterSet): + def __init__(self, *args, **kwargs): + self.category = kwargs.pop('category') + super(ProductFilter, self).__init__(*args, **kwargs) + self.product_attributes, self.variant_attributes = ( + self._get_attributes()) + self.filters.update(self._get_product_attributes_filters()) + self.filters.update(self._get_product_variants_attributes_filters()) + self.filters = OrderedDict(sorted(self.filters.items())) + + sort_by = OrderingFilter( + label='Sort by', + fields=[(field['value'], field['value']) for field in SORT_BY_FIELDS] + ) + + class Meta: + model = Product + fields = ['price'] + filter_overrides = { + PriceField: { + 'filter_class': RangeFilter + } + } + + def _get_attributes(self): + product_attributes = ( + ProductAttribute.objects.all() + .prefetch_related('values') + .filter(products_class__products__categories=self.category) + .distinct()) + variant_attributes = ( + ProductAttribute.objects.all() + .prefetch_related('values') + .filter(product_variants_class__products__categories=self.category) + .distinct()) + return product_attributes, variant_attributes + + def _get_product_attributes_filters(self): + filters = {} + for attribute in self.product_attributes: + filters[attribute.slug] = MultipleChoiceFilter( + name='attributes__%s' % attribute.pk, + label=attribute.name, + widget=CheckboxSelectMultiple, + choices=self._get_attribute_choices(attribute)) + return filters + + def _get_product_variants_attributes_filters(self): + filters = {} + for attribute in self.variant_attributes: + filters[attribute.slug] = MultipleChoiceFilter( + name='variants__attributes__%s' % attribute.pk, + label=attribute.name, + widget=CheckboxSelectMultiple, + choices=self._get_attribute_choices(attribute)) + return filters + + def _get_attribute_choices(self, attribute): + return [(choice.pk, choice.name) for choice in attribute.values.all()] diff --git a/saleor/product/views.py b/saleor/product/views.py index 7bdc5b39..1a793226 100644 --- a/saleor/product/views.py +++ b/saleor/product/views.py @@ -9,12 +9,15 @@ from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse from ..cart.utils import set_cart_cookie -from ..core.utils import serialize_decimal +from ..core.utils import get_paginator_items, serialize_decimal +from ..settings import PAGINATE_BY +from .filters import DEFAULT_SORT, ProductFilter, SORT_BY_FIELDS from .models import Category from .utils import (products_with_details, products_for_cart, handle_cart_form, get_availability, get_product_images, get_variant_picker_data, - get_product_attributes_data, product_json_ld) + get_product_attributes_data, + product_json_ld, products_with_availability) def product_details(request, slug, product_id, form=None): @@ -115,5 +118,20 @@ def category_index(request, path, category_id): if actual_path != path: return redirect('product:category', permanent=True, path=actual_path, category_id=category_id) - return TemplateResponse(request, 'category/index.html', - {'category': category}) + products = (products_with_details(user=request.user) + .filter(categories__name=category) + .order_by(DEFAULT_SORT)) + product_filter = ProductFilter( + request.GET, queryset=products, category=category) + products_paginated = get_paginator_items( + product_filter.qs, PAGINATE_BY, request.GET.get('page')) + products_and_availability = list(products_with_availability( + products_paginated, request.discounts, request.currency)) + sort_by = request.GET.get('sort_by', DEFAULT_SORT) + ctx = {'category': category, 'filter': product_filter, + 'products': products_and_availability, + 'products_paginated': products_paginated, + 'sort_by_choices': SORT_BY_FIELDS, + 'sort_by_label': sort_by.strip('-'), + 'is_descending': sort_by.startswith('-')} + return TemplateResponse(request, 'category/index.html', ctx) diff --git a/saleor/settings.py b/saleor/settings.py index db4517ef..a72cf5a3 100644 --- a/saleor/settings.py +++ b/saleor/settings.py @@ -179,6 +179,7 @@ INSTALLED_APPS = [ 'webpack_loader', 'social_django', 'django_countries', + 'django_filters', ] LOGGING = { diff --git a/saleor/static/js/category.js b/saleor/static/js/category.js deleted file mode 100644 index 1d92c909..00000000 --- a/saleor/static/js/category.js +++ /dev/null @@ -1,58 +0,0 @@ -import React, { PropTypes } from 'react'; -import ReactDOM from 'react-dom'; -import Relay from 'react-relay/classic'; - -import CategoryPage from './components/categoryPage/CategoryPage'; -import ProductFilters from './components/categoryPage/ProductFilters'; -import Loading from './components/Loading'; - -const categoryPage = document.getElementById('category-page'); -const categoryData = JSON.parse(categoryPage.getAttribute('data-category')); - -class App extends React.Component { - - static propTypes = { - root: PropTypes.object - } - - render() { - return <CategoryPage {...this.props.root} />; - } -} - -const RelayApp = Relay.createContainer(App, { - initialVariables: { - categoryId: categoryData.id - }, - fragments: { - root: () => Relay.QL` - fragment on Query { - category(pk: $categoryId) { - ${CategoryPage.getFragment('category')} - } - attributes(categoryPk: $categoryId) { - ${ProductFilters.getFragment('attributes')} - } - } - ` - } -}); - -const AppRoute = { - queries: { - root: () => Relay.QL` - query { root } - ` - }, - params: {}, - name: 'Root' -}; - -ReactDOM.render( - <Relay.RootContainer - Component={RelayApp} - route={AppRoute} - renderLoading={() => <Loading />} - />, - categoryPage -); diff --git a/saleor/static/js/components/Loading.js b/saleor/static/js/components/Loading.js deleted file mode 100644 index 17a7f4fc..00000000 --- a/saleor/static/js/components/Loading.js +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import InlineSVG from 'react-inlinesvg'; - -import loader from '../../images/loader.svg'; - -const Loading = () => { - return ( - <div className="row loader"> - <div className="col-12"> - <InlineSVG src={loader} /> - </div> - </div> - ); -}; - -export default Loading; diff --git a/saleor/static/js/components/categoryPage/AttributeInput.js b/saleor/static/js/components/categoryPage/AttributeInput.js deleted file mode 100644 index 367d2bf1..00000000 --- a/saleor/static/js/components/categoryPage/AttributeInput.js +++ /dev/null @@ -1,30 +0,0 @@ -import React, { PropTypes } from 'react'; - -const AttributeInput = ({attribute, checked, onClick, value}) => { - const handleChange = (event) => { - const { name, value } = event.target; - onClick(name, value); - }; - - return ( - <label> - <input - checked={checked} - name={attribute.slug} - onChange={handleChange} - type="checkbox" - value={value.slug} - /> - {value.name} - </label> - ); -}; - -AttributeInput.propTypes = { - checked: PropTypes.bool, - attribute: PropTypes.object.isRequired, - value: PropTypes.object.isRequired, - onClick: PropTypes.func.isRequired -}; - -export default AttributeInput; diff --git a/saleor/static/js/components/categoryPage/CategoryFilter.js b/saleor/static/js/components/categoryPage/CategoryFilter.js deleted file mode 100644 index 825571bc..00000000 --- a/saleor/static/js/components/categoryPage/CategoryFilter.js +++ /dev/null @@ -1,36 +0,0 @@ -import React, { Component, PropTypes } from 'react'; -import InlineSVG from 'react-inlinesvg'; - -import arrowLeftIcon from '../../../images/arrow_left.svg'; - -export default class CategoryFilter extends Component { - - static propTypes = { - category: PropTypes.object.isRequired - } - - render() { - const { category } = this.props; - const parent = category.ancestors ? category.ancestors[category.ancestors.length - 1] : null; - return ( - <div className="product-filters__categories"> - <h2><strong>{category.name}</strong></h2> - {parent && ( - <div className="product-filters__categories__parents"> - <InlineSVG src={arrowLeftIcon} /> - <a href={parent.url}>{`${pgettext('Category page filters', 'See all')} ${parent.name}`}</a> - </div> - )} - <ul className={category.parent ? ('product-filters__categories__childs') : ('product-filters__categories__childs no-parent')}> - {category.children && (category.children.map((child) => { - return ( - <li key={child.pk} className="item"> - <a href={child.url}>{child.name}</a> - </li> - ); - }))} - </ul> - </div> - ); - } -} diff --git a/saleor/static/js/components/categoryPage/CategoryPage.js b/saleor/static/js/components/categoryPage/CategoryPage.js deleted file mode 100644 index ca82ca53..00000000 --- a/saleor/static/js/components/categoryPage/CategoryPage.js +++ /dev/null @@ -1,225 +0,0 @@ -import queryString from 'query-string'; -import React, { Component, PropTypes } from 'react'; -import Relay from 'react-relay/classic'; - -import CategoryFilter from './CategoryFilter'; -import PriceFilter from './PriceFilter'; -import ProductFilters from './ProductFilters'; -import ProductList from './ProductList'; -import SortBy from './SortBy'; -import { ensureAllowedName, getAttributesFromQuery, getFromQuery } from './utils'; -import {isMobile} from '../utils'; - -const PAGINATE_BY = 24; -const SORT_BY_FIELDS = ['name', 'price']; - -class CategoryPage extends Component { - - constructor(props) { - super(props); - this.state = { - filtersMenu: !isMobile() - }; - } - - static propTypes = { - attributes: PropTypes.array, - category: PropTypes.object, - relay: PropTypes.object - } - - incrementProductsCount = () => { - this.props.relay.setVariables({ - count: this.props.relay.variables.count + PAGINATE_BY - }); - } - - setSorting = (value) => { - this.props.relay.setVariables({ - sortBy: value - }); - } - - toggleMenu = (target) => { - this.setState({ - filtersMenu: !target - }); - } - - clearFilters = () => { - this.props.relay.setVariables({ - attributesFilter: [], - minPrice: null, - maxPrice: null - }); - } - - updateAttributesFilter = (key) => { - // Create a new attributesFilter array by cloning the current one to make - // Relay refetch products with new attributes. Passing the same array (even - // if it's modified) would not result in new query, but would return cached - // results. - const attributesFilter = this.props.relay.variables.attributesFilter.slice(0); - const index = attributesFilter.indexOf(key); - if (index < 0) { - attributesFilter.push(key); - } else { - attributesFilter.splice(index, 1); - } - this.props.relay.setVariables({ attributesFilter }); - } - - updatePriceFilter = (minPrice, maxPrice) => { - this.props.relay.setVariables({ - minPrice: parseInt(minPrice) || null, - maxPrice: parseInt(maxPrice) || null - }); - } - - persistStateInUrl() { - const { attributesFilter, count, maxPrice, minPrice, sortBy } = this.props.relay.variables; - let urlParams = {}; - if (minPrice) { - urlParams['minPrice'] = minPrice; - } - if (maxPrice) { - urlParams['maxPrice'] = maxPrice; - } - if (count > PAGINATE_BY) { - urlParams['count'] = count; - } - if (sortBy) { - urlParams['sortBy'] = sortBy; - } - attributesFilter.forEach(filter => { - const [ attributeName, valueSlug ] = filter.split(':'); - if (attributeName in urlParams) { - urlParams[attributeName].push(valueSlug); - } else { - urlParams[attributeName] = [valueSlug]; - } - }); - const url = Object.keys(urlParams).length ? '?' + queryString.stringify(urlParams) : location.href.split('?')[0]; - history.pushState({}, null, url); - } - - componentDidUpdate() { - // Persist current state of relay variables as query string. Current - // variables are available in props after component rerenders, so it has to - // be called inside componentDidUpdate method. - this.persistStateInUrl(); - } - - render() { - const { attributes, category, relay: { variables }, relay } = this.props; - const { pendingVariables } = relay; - const { filtersMenu } = this.state; - return ( - <div className="category-page"> - <div className="category-top"> - <div className="row"> - <div className="col-md-7"> - <ul className="breadcrumbs list-unstyled d-none d-md-block"> - <li><a href="/">{pgettext('Main navigation item', 'Home')}</a></li> - {category.ancestors && (category.ancestors.map((ancestor) => { - return ( - <li key={ancestor.pk}><a href={ancestor.url}>{ancestor.name}</a></li> - ); - }))} - <li><a href={category.url}>{category.name}</a></li> - </ul> - </div> - <div className="col-md-5"> - <div className="row"> - <div className="col-6 col-md-2 col-lg-6 filters-menu"> - <span className="filters-menu__label d-sm-none" onClick={() => this.toggleMenu(filtersMenu)}>{pgettext('Category page filters', 'Filters')}</span> - {(variables.attributesFilter.length || variables.minPrice || variables.maxPrice) && ( - <span className="filters-menu__icon d-sm-none"></span> - )} - </div> - <div className="col-6 col-md-10 col-lg-6"> - <SortBy sortedValue={variables.sortBy} setSorting={this.setSorting} /> - </div> - </div> - </div> - </div> - </div> - <div className="row"> - <div className="col-md-4 col-lg-3"> - <div className="product-filters"> - <CategoryFilter category={category} /> - </div> - {filtersMenu && ( - <div> - <h2> - {pgettext('Category page filters', 'Filters')} - <span className="clear-filters float-right" onClick={this.clearFilters}>{pgettext('Category page filters', 'Clear filters')}</span> - </h2> - <div className="product-filters"> - <ProductFilters - attributes={attributes} - checkedAttributes={variables.attributesFilter} - onFilterChanged={this.updateAttributesFilter} - /> - <PriceFilter - onFilterChanged={this.updatePriceFilter} - maxPrice={variables.maxPrice} - minPrice={variables.minPrice} - /> - </div> - </div> - )} - </div> - <div className="col-md-8 col-lg-9 category-list"> - <div> - <ProductList - onLoadMore={this.incrementProductsCount} - products={category.products} - updating={pendingVariables} - /> - </div> - </div> - </div> - </div> - ); - } -} - -export default Relay.createContainer(CategoryPage, { - initialVariables: { - attributesFilter: getAttributesFromQuery(['count', 'minPrice', 'maxPrice', 'sortBy']), - count: parseInt(getFromQuery('count', PAGINATE_BY)) || PAGINATE_BY, - minPrice: parseInt(getFromQuery('minPrice')) || null, - maxPrice: parseInt(getFromQuery('maxPrice')) || null, - sortBy: ensureAllowedName(getFromQuery('sortBy', 'name'), SORT_BY_FIELDS) - }, - fragments: { - category: () => Relay.QL` - fragment on CategoryType { - pk - name - url - ancestors { - name - pk - url - } - children { - name - pk - url - slug - } - products ( - first: $count, - attributes: $attributesFilter, - priceGte: $minPrice, - priceLte: $maxPrice, - orderBy: $sortBy - ) { - ${ProductList.getFragment('products')} - } - } - ` - } -}); diff --git a/saleor/static/js/components/categoryPage/FilterHeader.js b/saleor/static/js/components/categoryPage/FilterHeader.js deleted file mode 100644 index 76f68267..00000000 --- a/saleor/static/js/components/categoryPage/FilterHeader.js +++ /dev/null @@ -1,27 +0,0 @@ -import React, { PropTypes } from 'react'; - -import InlineSVG from 'react-inlinesvg'; - -import chevronUpIcon from '../../../images/chevron_up.svg'; -import chevronDownIcon from '../../../images/chevron_down.svg'; - -const FilterHeader = ({ onClick, title, visibility }) => { - const imageSrc = visibility ? (chevronUpIcon) : (chevronDownIcon); - const key = visibility ? 'chevronUpIcon' : 'chevronDownIcon'; - return ( - <h3 onClick={onClick}> - {title} - <div className="collapse-filters-icon"> - <InlineSVG key={key} src={imageSrc} /> - </div> - </h3> - ); -}; - -FilterHeader.propTypes = { - onClick: PropTypes.func.isRequired, - title: PropTypes.string.isRequired, - visibility: PropTypes.bool -}; - -export default FilterHeader; diff --git a/saleor/static/js/components/categoryPage/NoResults.js b/saleor/static/js/components/categoryPage/NoResults.js deleted file mode 100644 index 09d39426..00000000 --- a/saleor/static/js/components/categoryPage/NoResults.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import InlineSVG from 'react-inlinesvg'; - -import noResultsImg from '../../../images/pirate.svg'; - -const NoResults = () => { - return ( - <div className="no-results"> - <div className="col-12"> - <InlineSVG src={noResultsImg} /> - <p>{pgettext('Epty search results', 'Sorry, no matches found for your request.')}</p> - <p>{pgettext('Epty search results', 'Try again or shop new arrivals.')}</p> - </div> - </div> - ); -}; - -export default NoResults; diff --git a/saleor/static/js/components/categoryPage/PriceFilter.js b/saleor/static/js/components/categoryPage/PriceFilter.js deleted file mode 100644 index c907c8b2..00000000 --- a/saleor/static/js/components/categoryPage/PriceFilter.js +++ /dev/null @@ -1,78 +0,0 @@ -import React, { Component, PropTypes } from 'react'; -import FilterHeader from './FilterHeader'; -import {isMobile} from '../utils'; - -export default class PriceFilter extends Component { - - constructor(props) { - super(props); - this.state = { - visibility: !isMobile() - }; - } - - static propTypes = { - minPrice: PropTypes.number, - maxPrice: PropTypes.number, - onFilterChanged: PropTypes.func.isRequired - } - - checkKey = (event) => { - if (event.key === 'Enter') { - this.updateFilter(); - } - } - - updateFilter = () => { - const minPrice = this.minPriceInput.value; - const maxPrice = this.maxPriceInput.value; - this.props.onFilterChanged(minPrice, maxPrice); - } - - changeVisibility = () => { - const { minPrice, maxPrice } = this.props; - if (!(minPrice || maxPrice)) { - this.setState({ - visibility: !this.state.visibility - }); - } - } - - render() { - const { maxPrice, minPrice } = this.props; - const { visibility } = this.state; - return ( - <div className="product-filters__price-range"> - <FilterHeader - onClick={this.changeVisibility} - title={pgettext('Price filter on category page', 'Price range')} - visibility={visibility} - /> - {(visibility || minPrice || maxPrice) && ( - <div> - <input - className="form-control" - defaultValue={minPrice} - min="0" - onKeyUp={this.checkKey} - placeholder={pgettext('Price filter on category page', 'from')} - ref={input => (this.minPriceInput = input)} - type="number" - /> - <span>—</span> - <input - className="form-control" - defaultValue={maxPrice} - min="0" - onKeyUp={this.checkKey} - placeholder={pgettext('Price filter on category page', 'to')} - ref={input => (this.maxPriceInput = input)} - type="number" - /> - <button className="btn primary" onClick={this.updateFilter}>{pgettext('Price filter on category page', 'Update')}</button> - </div> - )} - </div> - ); - } -} diff --git a/saleor/static/js/components/categoryPage/ProductFilters.js b/saleor/static/js/components/categoryPage/ProductFilters.js deleted file mode 100644 index 71f7ffac..00000000 --- a/saleor/static/js/components/categoryPage/ProductFilters.js +++ /dev/null @@ -1,102 +0,0 @@ -import React, { Component, PropTypes } from 'react'; -import Relay from 'react-relay/classic'; - -import AttributeInput from './AttributeInput'; -import FilterHeader from './FilterHeader'; -import {isMobile} from '../utils'; - -class ProductFilters extends Component { - - constructor(props) { - super(props); - this.state = { - visibility: {} - }; - } - - static propTypes = { - attributes: PropTypes.array, - checkedAttributes: PropTypes.array, - onFilterChanged: PropTypes.func.isRequired - } - - getFilterKey(attributeSlug, valueSlug) { - return `${attributeSlug}:${valueSlug}`; - } - - onClick = (attributeSlug, valueSlug) => { - this.props.onFilterChanged(this.getFilterKey(attributeSlug, valueSlug)); - } - - changeVisibility = (target) => { - this.setState({ - visibility: Object.assign(this.state.visibility, {[target]: !this.state.visibility[target]}) - }); - } - - componentWillMount() { - this.props.attributes.map((attribute) => { - const attrValue = `${attribute.slug}`; - this.setState({ - visibility: Object.assign(this.state.visibility, {[attrValue]: !isMobile()}) - }); - }); - } - - render() { - const { attributes, checkedAttributes } = this.props; - const { visibility } = this.state; - return ( - <div className="product-filters__attributes"> - {attributes && (attributes.map((attribute) => { - return ( - <div key={attribute.id}> - <FilterHeader - onClick={() => this.changeVisibility(attribute.slug)} - title={attribute.name} - visibility={visibility[attribute.slug]} - /> - <ul id={attribute.slug}> - {attribute.values.map((value) => { - const key = this.getFilterKey(attribute.slug, value.slug); - const isKeyChecked = checkedAttributes.indexOf(key) > -1; - if (visibility[attribute.slug] || isKeyChecked) { - return ( - <li key={value.id} className="item"> - <AttributeInput - attribute={attribute} - checked={isKeyChecked} - onClick={this.onClick} - value={value} - /> - </li> - ); - } - })} - </ul> - </div> - ); - }))} - </div> - ); - } -} - -export default Relay.createContainer(ProductFilters, { - fragments: { - attributes: () => Relay.QL` - fragment on ProductAttributeType @relay(plural: true) { - id - pk - name - slug - values { - id - name - slug - color - } - } - ` - } -}); diff --git a/saleor/static/js/components/categoryPage/ProductItem.js b/saleor/static/js/components/categoryPage/ProductItem.js deleted file mode 100644 index 5be4101c..00000000 --- a/saleor/static/js/components/categoryPage/ProductItem.js +++ /dev/null @@ -1,72 +0,0 @@ -import React, { Component, PropTypes } from 'react'; -import Relay from 'react-relay/classic'; - -import ProductPrice from './ProductPrice'; - -class ProductItem extends Component { - - static propTypes = { - product: PropTypes.object - }; - - getSchema = () => { - const { product } = this.props; - let data = { - "@context": "http://schema.org/", - "@type": "Product", - "name": product.name, - "image": product.thumbnailUrl1x, - "offers": { - "@type": "Offer", - "priceCurrency": product.price.currency, - "price": product.price.net, - } - }; - return JSON.stringify(data); - }; - - render() { - const { product } = this.props; - let productSchema = this.getSchema(); - let srcset = product.thumbnailUrl1x + ' 1x, ' + product.thumbnailUrl2x + ' 2x'; - return ( - <div className="col-6 col-md-4 product-list"> - <script type="application/ld+json">{productSchema}</script> - <a href={product.url} className="link--clean"> - <div className="text-center"> - <div> - <img className="img-responsive" src={product.thumbnailUrl1x} srcSet={srcset} alt="" /> - <span className="product-list-item-name" title={product.name}>{product.name}</span> - </div> - <div className="panel-footer"> - <ProductPrice price={product.price} availability={product.availability} /> - </div> - </div> - </a> - </div> - ); - } -} - -export default Relay.createContainer(ProductItem, { - fragments: { - product: () => Relay.QL` - fragment on ProductType { - id - name - price { - currency - gross - grossLocalized - net - } - availability { - ${ProductPrice.getFragment('availability')} - } - thumbnailUrl1x: thumbnailUrl(size: "255x255") - thumbnailUrl2x: thumbnailUrl(size: "510x510") - url - } - ` - } -}); diff --git a/saleor/static/js/components/categoryPage/ProductList.js b/saleor/static/js/components/categoryPage/ProductList.js deleted file mode 100644 index 953e83e5..00000000 --- a/saleor/static/js/components/categoryPage/ProductList.js +++ /dev/null @@ -1,53 +0,0 @@ -import React, { Component, PropTypes } from 'react'; -import Relay from 'react-relay/classic'; - -import ProductItem from './ProductItem'; -import NoResults from './NoResults'; - -class ProductList extends Component { - - static propTypes = { - onLoadMore: PropTypes.func.isRequired, - products: PropTypes.object, - setSorting: PropTypes.object, - updating: PropTypes.object - }; - - onLoadMore = () => this.props.onLoadMore(); - setSorting = (event) => this.props.setSorting(event); - - render() { - const { edges, pageInfo: { hasNextPage } } = this.props.products; - return ( - <div className={this.props.updating && 'category-list--loading'}> - <div className="row"> - {edges.length > 0 ? (edges.map((edge, i) => ( - <ProductItem key={i} product={edge.node} /> - ))) : (<NoResults />)} - </div> - <div className="load-more"> - {hasNextPage && ( - <button className="btn gray" onClick={this.onLoadMore}>{pgettext('Load more products on category view', 'Load more')}</button> - )} - </div> - </div> - ); - } -} - -export default Relay.createContainer(ProductList, { - fragments: { - products: () => Relay.QL` - fragment on ProductTypeConnection { - edges { - node { - ${ProductItem.getFragment('product')} - } - } - pageInfo { - hasNextPage - } - } - ` - } -}); diff --git a/saleor/static/js/components/categoryPage/ProductPrice.js b/saleor/static/js/components/categoryPage/ProductPrice.js deleted file mode 100644 index d9ea060d..00000000 --- a/saleor/static/js/components/categoryPage/ProductPrice.js +++ /dev/null @@ -1,40 +0,0 @@ -import React, { PropTypes } from 'react'; -import Relay from 'react-relay/classic'; -import InlineSVG from 'react-inlinesvg'; - -import SaleImg from '../../../images/sale_bg.svg'; - -const ProductPrice = ({ availability, price }) => { - const { discount, priceRange } = availability; - const isPriceRange = priceRange && priceRange.minPrice.gross !== priceRange.maxPrice.gross; - return ( - <div> - <span itemProp="price"> - {isPriceRange && <span>{pgettext('product price range', 'from')} </span>} {priceRange.minPrice.grossLocalized} - </span> - {discount && ( - <div className="product-list__sale"><InlineSVG src={SaleImg} /><span className="product-list__sale__text">{pgettext('Sale (discount) label for item in product list', 'Sale')}</span></div> - )} - </div> - ); -}; - -ProductPrice.propTypes = { - availability: PropTypes.object.isRequired, - price: PropTypes.object.isRequired -}; - -export default Relay.createContainer(ProductPrice, { - fragments: { - availability: () => Relay.QL` - fragment on ProductAvailabilityType { - available, - discount { gross }, - priceRange { - maxPrice { gross, grossLocalized, currency }, - minPrice { gross, grossLocalized, currency } - } - } - ` - } -}); diff --git a/saleor/static/js/components/categoryPage/SortBy.js b/saleor/static/js/components/categoryPage/SortBy.js deleted file mode 100644 index 9e000656..00000000 --- a/saleor/static/js/components/categoryPage/SortBy.js +++ /dev/null @@ -1,115 +0,0 @@ -import React, { Component, PropTypes } from 'react'; -import InlineSVG from 'react-inlinesvg'; - -import arrowUpIcon from '../../../images/arrow_up.svg'; -import arrowDownIcon from '../../../images/arrow_down.svg'; - -export default class sortBy extends Component { - - constructor(props) { - super(props); - this.state = { - visibility: false - }; - } - - static propTypes = { - setSorting: PropTypes.func, - sortedValue: PropTypes.string - } - - setSorting = (event) => { - const value = event.currentTarget.className; - this.props.setSorting(value); - this.changeVisibility(); - } - - changeVisibility = () => { - this.setState({ - visibility: !this.state.visibility - }); - } - - render() { - const { sortedValue } = this.props; - const { visibility } = this.state; - return ( - <div className="sort-by"> - <div className={visibility ? ('click-area') : ('click-area hide')} onClick={this.changeVisibility}></div> - <button className="btn btn-link" onClick={this.changeVisibility}> - {sortedValue ? ( - sortedValue.search('-') ? ( - <div> - <span>{pgettext('Category page filters','Sort by:')} <strong>{sortedValue}</strong></span> - <div className="sort-order-icon"> - <InlineSVG key="arrowUpIcon" src={arrowUpIcon} /> - </div> - </div> - ) : ( - <div> - <span>{pgettext('Category page filters', 'Sort by:')} <strong>{sortedValue.replace('-', '')}</strong></span> - <div className="sort-order-icon"> - <InlineSVG key="arrowDownIcon" src={arrowDownIcon} /> - </div> - </div> - ) - ) : ( - <span>{pgettext('Category page filters', 'Sort by:')} <strong>{pgettext('Category page filters', 'default')}</strong></span> - )} - </button> - {visibility && ( - <ul className="sort-list"> - <li className="name"> - <div className="row"> - <div className="col-6">{pgettext('Category page filters', 'Sort by:')} <strong>{gettext('Name')}</strong></div> - <div className="col-6"> - <div className="name" onClick={this.setSorting}> - <span>{pgettext('Category page filters', 'ascending')}</span> - <div className="float-right sort-order-icon"> - <InlineSVG src={arrowUpIcon} /> - </div> - </div> - </div> - </div> - <div className="row"> - <div className="col-6"></div> - <div className="col-6"> - <div className="-name" onClick={this.setSorting}> - <span>{pgettext('Category page filters', 'descending')}</span> - <div className="float-right sort-order-icon"> - <InlineSVG src={arrowDownIcon} /> - </div> - </div> - </div> - </div> - </li> - <li className="price"> - <div className="row"> - <div className="col-6">{pgettext('Category page filters', 'Sort by:')} <strong>{pgettext('Category page filters', 'Price')}</strong></div> - <div className="col-6"> - <div className="price" onClick={this.setSorting}> - <span>{pgettext('Category page filters', 'ascending')}</span> - <div className="float-right sort-order-icon"> - <InlineSVG src={arrowUpIcon} /> - </div> - </div> - </div> - </div> - <div className="row"> - <div className="col-6"></div> - <div className="col-6"> - <div className="-price" onClick={this.setSorting}> - <span>{pgettext('Category page filters', 'descending')}</span> - <div className="float-right sort-order-icon"> - <InlineSVG src={arrowDownIcon} /> - </div> - </div> - </div> - </div> - </li> - </ul> - )} - </div> - ); - } -} diff --git a/saleor/static/js/components/categoryPage/utils.js b/saleor/static/js/components/categoryPage/utils.js deleted file mode 100644 index fc4af715..00000000 --- a/saleor/static/js/components/categoryPage/utils.js +++ /dev/null @@ -1,35 +0,0 @@ -import queryString from 'query-string'; - -export const getFromQuery = (key, defaultValue = null) => { - let value = queryString.parse(location.search)[key]; - return value || defaultValue; -}; - -export const getAttributesFromQuery = (exclude) => { - // Exclude parameter is used to exclude other query string parameters than - // product attribute filters. - const urlParams = queryString.parse(location.search); - let attributes = []; - Object.keys(urlParams).forEach(key => { - if (exclude.indexOf(key) === -1) { - if (Array.isArray(urlParams[key])) { - const values = urlParams[key]; - values.map((valueSlug) => { - attributes.push(`${key}:${valueSlug}`); - }); - } else { - const valueSlug = urlParams[key]; - attributes.push(`${key}:${valueSlug}`); - } - } - }); - return attributes; -}; - -export const ensureAllowedName = (name, allowed) => { - let origName = name; - if (name && name.startsWith('-')) { - name = name.substr(1, name.length); - } - return allowed.indexOf(name) > -1 ? origName : null; -}; diff --git a/saleor/static/js/components/utils.js b/saleor/static/js/components/utils.js deleted file mode 100644 index 6b0a2d5f..00000000 --- a/saleor/static/js/components/utils.js +++ /dev/null @@ -1,12 +0,0 @@ -export const xsBreakpoint = 576; -export const smBreakpoint = 768; -export const mdBreakpoint = 992; -export const lgBreakpoint = 1200; - -export const isMobile = () => { - return window.innerWidth < smBreakpoint; -} - -export const isTablet = () => { - return window.innerWidth >= smBreakpoint && window.innerWidth <= mdBreakpoint; -}
\ No newline at end of file diff --git a/saleor/static/js/storefront.js b/saleor/static/js/storefront.js index 5da7ca43..e2ab6416 100644 --- a/saleor/static/js/storefront.js +++ b/saleor/static/js/storefront.js @@ -2,7 +2,6 @@ import '../scss/storefront.scss'; import 'jquery.cookie'; import React from 'react'; import ReactDOM from 'react-dom'; -import Relay from 'react-relay/classic'; import SVGInjector from 'svg-injector-2'; import variantPickerStore from './stores/variantPicker'; @@ -16,7 +15,7 @@ import ProductSchema from './components/variantPicker/ProductSchema'; let csrftoken = $.cookie('csrftoken'); -function csrfSafeMethod (method) { +function csrfSafeMethod(method) { return /^(GET|HEAD|OPTIONS|TRACE)$/.test(method); } @@ -28,15 +27,6 @@ $.ajaxSetup({ } }); -Relay.injectNetworkLayer( - new Relay.DefaultNetworkLayer('/graphql/', { - credentials: 'same-origin', - headers: { - 'X-CSRFToken': csrftoken - } - }) -); - new SVGInjector().inject(document.querySelectorAll('svg[data-src]')); let getAjaxError = (response) => { @@ -54,8 +44,8 @@ $(document).ready((e) => { if (windowWidth < 767) { $mobileNav.append('<ul class="nav navbar-nav navbar__menu__login"></ul>'); $('.navbar__login a').appendTo('.navbar__menu__login') - .wrap('<li class="nav-item login-item"></li>') - .addClass('nav-link'); + .wrap('<li class="nav-item login-item"></li>') + .addClass('nav-link'); } $toogleIcon.click((e) => { @@ -106,7 +96,7 @@ if ($initialValue) { // Smart address form -$(function() { +$(function () { const $i18nAddresses = $('.i18n-address'); $i18nAddresses.each(function () { const $form = $(this).closest('form'); @@ -119,18 +109,51 @@ $(function() { }); }); +// Sorter dropdown + +$(document).ready((e) => { + $('.sort-by button').on('click', (e) => { + const t = $(e.currentTarget).parent(); + const l = t.find('.sort-list'); + if (l.hasClass('d-none')) { + l.removeClass('d-none'); + t.find('.click-area').removeClass('d-none'); + } else { + l.addClass('d-none'); + t.find('.click-area').addClass('d-none'); + } + }); + $('.sort-by .click-area').on('click', (e) => { + $('.sort-by .sort-list').addClass('d-none'); + $(e.currentTarget).addClass('d-none'); + }); +}); + +// Mobile filters menu + +$(document).ready((e) => { + $('.filters-menu').on('click', (e) => { + const t = $('.filters-menu__body'); + if (t.hasClass('d-none')) { + t.removeClass('d-none'); + } else { + t.addClass('d-none'); + } + }); +}); + // Input Passwords let $inputPassword = $('input[type=password]'); -$("<img class='passIcon' src="+passwordIvisible+" />").insertAfter($inputPassword); +$("<img class='passIcon' src=" + passwordIvisible + " />").insertAfter($inputPassword); $inputPassword.parent().addClass('relative'); $('.passIcon').on('click', (e) => { let $input = $(e.target).parent().find('input'); if ($input.attr('type') == 'password') { - $input.attr('type','text'); + $input.attr('type', 'text'); $(e.target).attr('src', passwordVisible); } else { - $input.attr('type','password'); + $input.attr('type', 'password'); $(e.target).attr('src', passwordIvisible); } }); @@ -253,7 +276,7 @@ if (variantPickerContainer) { // Product Schema const productSchemaContainer = document.getElementById('product-schema-component'); if (productSchemaContainer) { - let productSchema = JSON.parse(document.getElementById('product-schema-component').children[0].text) + let productSchema = JSON.parse(document.getElementById('product-schema-component').children[0].text); ReactDOM.render( <ProductSchema variantStore={variantPickerStore} @@ -270,20 +293,19 @@ let $deleteAdressIcons = $('.icons'); let $deleteAdressIcon = $('.delete-icon'); let $deleteAddress = $('.address-delete'); - $deleteAdressIcon.on('click', (e) => { +$deleteAdressIcon.on('click', (e) => { if ($deleteAddress.hasClass('none')) { $deleteAddress.removeClass('none'); $deleteAdressIcons.addClass('none'); } else { $deleteAddress.addClass('none'); } - }); +}); - $deleteAddress.find('.cancel').on('click', (e) => { +$deleteAddress.find('.cancel').on('click', (e) => { $deleteAddress.addClass('none'); $deleteAdressIcons.removeClass('none'); - }); - +}); // Cart quantity form @@ -293,7 +315,7 @@ let $total = $('.cart-subtotal'); let $cartBadge = $('.navbar__brand__cart .badge'); let $removeProductSucces = $('.remove-product-alert'); let $closeMsg = $('.close-msg'); -$cartLine.each(function() { +$cartLine.each(function () { let $quantityInput = $(this).find('#id_quantity'); let cartFormUrl = $(this).find('.form-cart').attr('action'); let $qunatityError = $(this).find('.cart__line__quantity-error'); @@ -308,7 +330,7 @@ $cartLine.each(function() { success: (response) => { if (newQuantity == 0) { if (response.cart.numLines == 0) { - $.cookie('alert', 'true', { path: '/cart' }); + $.cookie('alert', 'true', {path: '/cart'}); location.reload(); } else { $removeProductSucces.removeClass('hidden-xs-up'); @@ -340,7 +362,7 @@ $cartLine.each(function() { $cartDropdown.load(summaryLink); $removeProductSucces.removeClass('hidden-xs-up'); } else { - $.cookie('alert', 'true', { path: '/cart' }); + $.cookie('alert', 'true', {path: '/cart'}); location.reload(); } deliveryAjax(); @@ -351,7 +373,7 @@ $cartLine.each(function() { // StyleGuide fixed menu -$(document).ready(function() { +$(document).ready(function () { let styleGuideMenu = $('.styleguide__nav'); $(window).scroll(function () { if ($(this).scrollTop() > 100) { @@ -359,14 +381,41 @@ $(document).ready(function() { } else { styleGuideMenu.removeClass("fixed"); } - }) + }); }); if ($.cookie('alert') === 'true') { $removeProductSucces.removeClass('hidden-xs-up'); - $.cookie('alert', 'false', { path: '/cart' }); + $.cookie('alert', 'false', {path: '/cart'}); } $closeMsg.on('click', (e) => { $removeProductSucces.addClass('hidden-xs-up'); }); + + +$('.toggle-filter').each(function () { + let icon = $(this).find('.collapse-filters-icon'); + let ele = $(this).find('.filter-form-field'); + + let filterArrowDown = $('.product-filters__attributes').data('icon-down'); + let filterArrowUp = $('.product-filters__attributes').data('icon-up'); + + ele.attr('aria-expanded', ''); + + $(this).find('.filter-label').on('click', () => { + if (ele.attr('aria-expanded') !== undefined) { + ele.removeAttr('aria-expanded').find('input[type="checkbox"], input[type="number"]').each((i, el) => { + if (!el.checked) { + $(el.parentNode.parentNode).addClass('d-none'); + } + }); + icon.find('img').attr('src', filterArrowDown); + } else { + ele.attr('aria-expanded', '').find('input[type="checkbox"], input[type="number"]').each((i, el) => { + $(el.parentNode.parentNode).removeClass('d-none'); + }); + icon.find('img').attr('src', filterArrowUp); + } + }); +}); diff --git a/saleor/static/scss/components/_filters.scss b/saleor/static/scss/components/_filters.scss index cafd80c0..9f614cb0 100644 --- a/saleor/static/scss/components/_filters.scss +++ b/saleor/static/scss/components/_filters.scss @@ -160,6 +160,9 @@ svg { margin-top: $global-padding/3; } + a { + color: inherit; + } &:last-child { border: none; padding-bottom: 0; diff --git a/saleor/static/scss/components/_header.scss b/saleor/static/scss/components/_header.scss index 0df22375..d97e1fc1 100644 --- a/saleor/static/scss/components/_header.scss +++ b/saleor/static/scss/components/_header.scss @@ -202,7 +202,7 @@ background-color: $white; padding-left: 0; padding-right: $global-padding; - padding-top: $global-padding * 2; + padding-top: $global-padding * 5; transition: 0.3s ease-in-out; ul { text-align: left; diff --git a/templates/account/login.html b/templates/account/login.html index 328f9a55..bd6af86c 100644 --- a/templates/account/login.html +++ b/templates/account/login.html @@ -6,7 +6,7 @@ {% block content %} - <div class="col-lg-10 offset-lg-1 col-sm-12"> + <div class="col-lg-10 col-sm-12 m-auto"> <div class="row login"> <div class="col-md-6 login__register"> <div class="login__register-link"> diff --git a/templates/category/_items.html b/templates/category/_items.html index 78b5da2f..ef276a33 100644 --- a/templates/category/_items.html +++ b/templates/category/_items.html @@ -13,13 +13,13 @@ <a href="{{ product.get_absolute_url }}" class="link--clean"> <div class="text-center"> <div> - <img class="img-responsive" - src="{% product_first_image product method="crop" size="255x255" %}" + <img class="img-responsive" + src="{% product_first_image product method="crop" size="255x255" %}" srcset="{% product_first_image product method="crop" size="255x255" %} 1x, {% product_first_image product method="crop" size="510x510" %} 2x" alt=""> <span class="product-list-item-name" title="{{ product }}">{{ product }}</span> </div> - + <div class="panel-footer"> {% if availability.available %} {% price_range availability.price_range %} diff --git a/templates/category/index.html b/templates/category/index.html index fdae31ee..5d1018c3 100644 --- a/templates/category/index.html +++ b/templates/category/index.html @@ -1,30 +1,190 @@ {% extends "base.html" %} -{% load slice from shop %} -{% load staticfiles %} {% load bootstrap_pagination from bootstrap3 %} +{% load i18n %} +{% load shop %} +{% load staticfiles %} {% load render_bundle from webpack_loader %} +{% load prices_i18n %} {% block footer_scripts %} {{ block.super }} - {% render_bundle 'category' 'js' %} {% endblock footer_scripts %} {% block title %}{{ category }} — {{ block.super }}{% endblock %} {% block breadcrumb %} - {{ block.super }} - {% for breadcrumb in breadcrumbs %} - <li{% if forloop.last %} class="active"{% endif %}> - {% if not forloop.last %} - <a href="{{ breadcrumb.get_absolute_url }}">{{ breadcrumb }}</a> - {% else %} - <span>{{ breadcrumb }}</span> - {% endif %} - </li> - {% endfor %} + {{ block.super }} + {% for breadcrumb in breadcrumbs %} + <li{% if forloop.last %} class="active"{% endif %}> + {% if not forloop.last %} + <a href="{{ breadcrumb.get_absolute_url }}">{{ breadcrumb }}</a> + {% else %} + <span>{{ breadcrumb }}</span> + {% endif %} + </li> + {% endfor %} {% endblock breadcrumb %} {% block content %} - {% csrf_token %} - <div id="category-page" data-category='{"id":{{category.id}}}'></div> + <div id="category-page"> + <div class="category-top"> + <div class="row"> + <div class="col-md-7"> + <ul class="breadcrumbs list-unstyled d-none d-md-block"> + <li><a href="/">Home</a></li> + {% for ancestor in category.get_ancestors %} + <li><a href='{{ ancestor.get_absolute_url }}'>{{ ancestor.name }}</a></li> + {% endfor %} + <li><a href='{{ category.get_absolute_url }}'>{{ category.name }}</a></li> + </ul> + </div> + <div class="col-md-5"> + <div class="row"> + <div class="col-6 col-md-2 col-lg-6 filters-menu"> + <span class="filters-menu__label d-sm-none">Filters</span> + </div> + <div class="col-6 col-md-10 col-lg-6"> + <div class="sort-by"> + <div class="click-area d-none"></div> + <button class="btn btn-link"> + <div> + <span> + Sort by: + <strong> + {{ sort_by_label }} + </strong> + </span> + <div class="sort-order-icon"> + {% if is_descending %} + <svg data-src="{% static "assets/arrow_down.svg" %}"> + {% else %} + <svg data-src="{% static "assets/arrow_up.svg" %}"> + {% endif %} + </div> + </div> + </button> + <ul class="sort-list d-none"> + {% for choice in sort_by_choices %} + <li> + <div class="row"> + <div class="col-6"> + Sort by: <strong>{{ choice.label }}</strong> + </div> + <div class="col-6"> + <div> + <a href="{% get_sort_by_url choice.value %}"> + <span>{% trans 'ascending' context 'sort by option' %}</span> + <div class="sort-order-icon float-right"> + <img src="{% static "assets/arrow_up.svg" %}"> + </div> + </a> + <a href="{% get_sort_by_url choice.value descending=True %}"> + <span>{% trans 'descending' context 'sort by option' %}</span> + <div class="sort-order-icon float-right"> + <img src="{% static "assets/arrow_down.svg" %}"> + </div> + </a> + </div> + </div> + </div> + </li> + {% endfor %} + </ul> + </div> + </div> + </div> + </div> + </div> + </div> + <div class="row"> + <div class="col-md-4 col-lg-3"> + <div class="product-filters"> + <div class="product-filters__categories"> + <h2> + <strong>{{ category.name }}</strong> + </h2> + <ul class="product-filters__categories__childs no-parent"> + {% for child in category.get_children %} + <li><a href="{{ child.get_absolute_url }}">{{ child.name }}</a></li> + {% endfor %} + </ul> + </div> + </div> + <div class="filters-menu__body d-none d-md-block"> + <h2> + {% trans 'Filters' context 'Filter heading title' %} + <a href="?"> + <span class="clear-filters float-right">{% trans 'Clear filters' context 'Category page filters' %}</span> + </a> + </h2> + <div class="product-filters"> + <div class="product-filters__attributes" data-icon-up="{% static "assets/chevron_up.svg" %}" + data-icon-down="{% static "assets/chevron_down.svg" %}"> + <form method="get"> + {% for field in filter.form %} + {% if field.name == 'sort_by' %} + <!--Field 'sort_by' is hidden because it is rendered in header. + This is required if you want to have sorting to be kept during further filtering.--> + <input type="hidden" name="sort_by" + value="{% if request.GET.sort_by %}{{ request.GET.sort_by }}{% endif %}"> + {% elif field.name != 'price' %} + <div class="toggle-filter"> + <h3 class="filter-label"> + {{ field.label }} + <div class="collapse-filters-icon"> + <img src="{% static "assets/chevron_up.svg" %}"> + </div> + </h3> + <div class="filter-form-field" style="display:block"> + {{ field }} + </div> + </div> + {% endif %} + {% endfor %} + {% for field in filter.form %} + {% if field.name == 'price' %} + <div class="toggle-filter product-filters__price-range"> + <h3 class="filter-label"> + {{ field.label }} + <div class="collapse-filters-icon"> + <img src="{% static "assets/chevron_up.svg" %}"> + </div> + </h3> + <div class="filter-form-field" style="display:block"> + <div> + <input id="{{ field.auto_id }}_0" name="{{ field.name }}_0" + value="{% if field.value.0 %}{{ field.value.0 }}{% endif %}" type="number" min="0" + class="form-control d-inline" + placeholder="from"><span>-</span><input id="{{ field.auto_id }}_1" name="{{ field.name }}_1" + value="{% if field.value.1 %}{{ field.value.1 }}{% endif %}" type="number" min="0" + class="form-control d-inline" placeholder="to"> + </div> + </div> + <button class="btn primary" type="submit">Update</button> + </div> + {% endif %} + {% endfor %} + </form> + </div> + </div> + </div> + </div> + <div class="col-md-8 col-lg-9 category-list"> + <div> + <div> + <div class="row"> + {% include "category/_items.html" with products=products %} + </div> + <div class="row"> + <div class="m-auto"> + {% if products_paginated.has_other_pages %} + {% bootstrap_pagination products_paginated extra=request.GET.urlencode %} + {% endif %} + </div> + </div> + </div> + </div> + </div> + </div> + </div> {% endblock content %} diff --git a/tests/test_product.py b/tests/test_product.py index 947c427d..e915f832 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -369,3 +369,46 @@ def test_variant_availability_status(unavailable_product): stock.save() status = get_variant_availability_status(variant) assert status == VariantAvailabilityStatus.AVAILABLE + + +def test_product_filter_before_filtering( + authorized_client, product_in_stock, default_category): + products = models.Product.objects.all() + url = reverse( + 'product:category', kwargs={'path': default_category.slug, + 'category_id': default_category.pk}) + response = authorized_client.get(url) + assert list(products) == list(response.context['filter'].qs) + + +def test_product_filter_product_exists(authorized_client, product_in_stock, + default_category): + products = models.Product.objects.all() + url = reverse( + 'product:category', kwargs={'path': default_category.slug, + 'category_id': default_category.pk}) + data = {'price_0': [''], 'price_1': ['20']} + response = authorized_client.get(url, data) + assert list(response.context['filter'].qs) == list(products) + + +def test_product_filter_product_does_not_exists( + authorized_client, product_in_stock, default_category): + url = reverse( + 'product:category', kwargs={'path': default_category.slug, + 'category_id': default_category.pk}) + data = {'price_0': ['20'], 'price_1': ['']} + response = authorized_client.get(url, data) + assert list(response.context['filter'].qs) == [] + + +def test_product_filter_form(authorized_client, product_in_stock, + default_category): + products = models.Product.objects.all() + url = reverse( + 'product:category', kwargs={'path': default_category.slug, + 'category_id': default_category.pk}) + response = authorized_client.get(url) + assert 'price' in response.context['filter'].form.fields.keys() + assert 'sort_by' in response.context['filter'].form.fields.keys() + assert list(response.context['filter'].qs) == list(products) diff --git a/webpack.config.js b/webpack.config.js index b34b3f5a..cab86784 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -63,7 +63,6 @@ var faviconsWebpackPlugin = new FaviconsWebpackPlugin({ var config = { entry: { - category: './saleor/static/js/category.js', dashboard: './saleor/static/dashboard/js/dashboard.js', storefront: './saleor/static/js/storefront.js', vendor: [ @@ -71,8 +70,7 @@ var config = { 'bootstrap', 'jquery', 'jquery.cookie', - 'react', - 'react-relay' + 'react' ] }, output: output, @@ -343,8 +343,8 @@ babel-es6-polyfill@^1.1.0: regenerator-babel "^0.8.13-2" babel-eslint@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-8.0.0.tgz#ce06f385bdfb5b6d7e603f06222f891abd14c240" + version "8.0.1" + resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-8.0.1.tgz#5d718be7a328625d006022eb293ed3008cbd6346" dependencies: babel-code-frame "7.0.0-beta.0" babel-traverse "7.0.0-beta.0" @@ -1879,13 +1879,6 @@ deep-is@~0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" -default-gateway@^2.2.2: - version "2.5.0" - resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-2.5.0.tgz#78e24dbd2e1df7490c2b8050515b8e816bfa7da5" - dependencies: - execa "^0.7.0" - ip-regex "^2.1.0" - define-properties@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.2.tgz#83a73f2fea569898fb737193c8f873caf6d45c94" @@ -2242,8 +2235,8 @@ eslint-scope@^3.7.1: estraverse "^4.1.1" eslint@^4.6.1: - version "4.7.2" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.7.2.tgz#ff6f5f5193848a27ee9b627be3e73fb9cb5e662e" + version "4.8.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.8.0.tgz#229ef0e354e0e61d837c7a80fdfba825e199815e" dependencies: ajv "^5.2.0" babel-code-frame "^6.22.0" @@ -2435,8 +2428,8 @@ extglob@^0.3.1: is-extglob "^1.0.0" extract-text-webpack-plugin@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/extract-text-webpack-plugin/-/extract-text-webpack-plugin-3.0.0.tgz#90caa7907bc449f335005e3ac7532b41b00de612" + version "3.0.1" + resolved "https://registry.yarnpkg.com/extract-text-webpack-plugin/-/extract-text-webpack-plugin-3.0.1.tgz#605a8893faca1dd49bb0d2ca87493f33fd43d102" dependencies: async "^2.4.1" loader-utils "^1.1.0" @@ -2974,9 +2967,9 @@ hoek@4.x.x: version "4.2.0" resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d" -hoist-non-react-statics@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" +hoist-non-react-statics@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.3.1.tgz#343db84c6018c650778898240135a1420ee22ce0" home-or-tmp@^2.0.0: version "2.0.0" @@ -3167,12 +3160,11 @@ inquirer@^3.0.6: strip-ansi "^4.0.0" through "^2.3.6" -internal-ip@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-2.0.3.tgz#ed3cf9b671ac7ff23037bfacad42eb439cd9546c" +internal-ip@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-1.2.0.tgz#ae9fbf93b984878785d50a8de1b356956058cf5c" dependencies: - default-gateway "^2.2.2" - ipaddr.js "^1.5.2" + meow "^3.3.0" interpret@^0.6.4: version "0.6.6" @@ -3196,10 +3188,6 @@ ip-regex@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-1.0.3.tgz#dc589076f659f419c222039a33316f1c7387effd" -ip-regex@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" - ip@^1.1.0, ip@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" @@ -3208,10 +3196,6 @@ ipaddr.js@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.4.0.tgz#296aca878a821816e5b85d0a285a99bcff4582f0" -ipaddr.js@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.5.2.tgz#d4b505bde9946987ccf0fc58d9010ff9607e3fa0" - is-absolute-url@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" @@ -3809,7 +3793,7 @@ memory-fs@~0.3.0: errno "^0.1.3" readable-stream "^2.0.1" -meow@^3.7.0: +meow@^3.3.0, meow@^3.7.0: version "3.7.0" resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" dependencies: @@ -3933,10 +3917,10 @@ mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdi minimist "0.0.8" mobx-react@^4.1.0: - version "4.3.2" - resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-4.3.2.tgz#1ecbffa5690cc6460db6bb16c0c11034f0b783da" + version "4.3.3" + resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-4.3.3.tgz#4ad76c03d1e942b431e942f9ea18df0756771655" dependencies: - hoist-non-react-statics "^1.2.0" + hoist-non-react-statics "^2.3.1" mobx@^3.0.2: version "3.3.0" @@ -4965,8 +4949,8 @@ rc@^1.1.7: strip-json-comments "~2.0.1" react-dom@^15.4.2: - version "15.6.1" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.6.1.tgz#2cb0ed4191038e53c209eb3a79a23e2a4cf99470" + version "15.6.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.6.2.tgz#41cfadf693b757faf2708443a1d1fd5a02bef730" dependencies: fbjs "^0.8.9" loose-envify "^1.1.0" @@ -4981,17 +4965,17 @@ react-inlinesvg@^0.6.2: once "^1.4" react-relay@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/react-relay/-/react-relay-1.4.0.tgz#69ab51917bef59359e0f9b229069239f9023c21b" + version "1.4.1" + resolved "https://registry.yarnpkg.com/react-relay/-/react-relay-1.4.1.tgz#61f7b5802d70446e154490f9d0fceec9150ebda5" dependencies: babel-runtime "^6.23.0" fbjs "^0.8.14" prop-types "^15.5.8" - relay-runtime "1.4.0" + relay-runtime "1.4.1" react@^15.4.2: - version "15.6.1" - resolved "https://registry.yarnpkg.com/react/-/react-15.6.1.tgz#baa8434ec6780bde997cdc380b79cd33b96393df" + version "15.6.2" + resolved "https://registry.yarnpkg.com/react/-/react-15.6.2.tgz#dba0434ab439cfe82f108f0f511663908179aa72" dependencies: create-react-class "^15.6.0" fbjs "^0.8.9" @@ -5154,9 +5138,9 @@ relay-debugger-react-native-runtime@0.0.10: version "0.0.10" resolved "https://registry.yarnpkg.com/relay-debugger-react-native-runtime/-/relay-debugger-react-native-runtime-0.0.10.tgz#0ef36012a1fba928962205514b46f635c652f235" -relay-runtime@1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/relay-runtime/-/relay-runtime-1.4.0.tgz#d91e4649324696060137b014aaaad08765018451" +relay-runtime@1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/relay-runtime/-/relay-runtime-1.4.1.tgz#f88dcd0a422700a04563f291f570e4ce368e36d0" dependencies: babel-runtime "^6.23.0" fbjs "^0.8.14" @@ -5396,8 +5380,8 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" select2@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/select2/-/select2-4.0.3.tgz#207733fe91eacb9cb1a13f12463401f472449e0f" + version "4.0.4" + resolved "https://registry.yarnpkg.com/select2/-/select2-4.0.4.tgz#a18a628785f98d13999971ae95d8d7e57268076b" dependencies: almond "~0.3.1" jquery-mousewheel "~3.1.13" @@ -6179,8 +6163,8 @@ webpack-dev-middleware@^1.11.0: time-stamp "^2.0.0" webpack-dev-server@^2.7.1: - version "2.8.2" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.8.2.tgz#abd61f410778cc4c843d7cebbf41465b1ab7734c" + version "2.9.1" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.9.1.tgz#7ac9320b61b00eb65b2109f15c82747fc5b93585" dependencies: ansi-html "0.0.7" array-includes "^3.0.3" @@ -6192,7 +6176,7 @@ webpack-dev-server@^2.7.1: express "^4.13.3" html-entities "^1.2.0" http-proxy-middleware "~0.17.4" - internal-ip "^2.0.2" + internal-ip "1.2.0" ip "^1.1.5" loglevel "^1.4.1" opn "^5.1.0" |