diff --git a/client/extensions/woocommerce/app/product-categories/index.js b/client/extensions/woocommerce/app/product-categories/index.js index 59a4a4c6f32c3..8d1d1608a7ac0 100644 --- a/client/extensions/woocommerce/app/product-categories/index.js +++ b/client/extensions/woocommerce/app/product-categories/index.js @@ -1,29 +1,88 @@ /** * External dependencies */ - import React, { Component } from 'react'; +import { bindActionCreators } from 'redux'; import classNames from 'classnames'; import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; +import { union, includes, trim, debounce } from 'lodash'; /** * Internal dependencies */ import ActionHeader from 'woocommerce/components/action-header'; +import { fetchProductCategories } from 'woocommerce/state/sites/product-categories/actions'; import { getLink } from 'woocommerce/lib/nav-utils'; import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; import Main from 'components/main'; import NavTabs from 'components/section-nav/tabs'; import NavItem from 'components/section-nav/item'; +import ProductCategoriesList from 'woocommerce/app/product-categories/list'; import SectionNav from 'components/section-nav'; import Search from 'components/search'; class ProductCategories extends Component { + state = { + requestedPages: [ 1 ], + requestedSearchPages: [], + }; + + constructor( props ) { + super( props ); + this.debouncedOnSearch = debounce( this.onSearch, 500 ); + } + + componentWillMount() { + const { siteId } = this.props; + if ( siteId ) { + this.props.fetchProductCategories( siteId, { page: 1 } ); + } + } + + componentWillReceiveProps( newProps ) { + const { siteId } = this.props; + const newSiteId = ( newProps.siteId ) || null; + if ( siteId !== newSiteId ) { + this.props.fetchProductCategories( newSiteId, { page: 1 } ); + } + } + + requestPages = pages => { + const { site } = this.props; + const { searchQuery } = this.state; + + const requestedPages = searchQuery && searchQuery.length && this.state.requestedSearchPages || this.state.requestedPages; + const stateName = searchQuery && searchQuery.length && 'requestedSearchPages' || 'requestedPages'; + + pages.forEach( page => { + if ( ! includes( requestedPages, page ) ) { + this.props.fetchProductCategories( site.ID, { search: searchQuery, page } ); + } + } ); + + this.setState( { + [ stateName ]: union( requestedPages, pages ), + } ); + }; + + onSearch = query => { + const { site } = this.props; + + if ( trim( query ) === '' ) { + this.setState( { searchQuery: '', requestedSearchPages: [] } ); + return; + } + + this.setState( { searchQuery: query, requestedSearchPages: [ 1 ] } ); + this.props.fetchProductCategories( site.ID, { search: query } ); + }; + render() { - const { className, translate, site } = this.props; - const classes = classNames( 'product_categories__list', className ); + const { site, className, translate } = this.props; + const { searchQuery } = this.state; + const classes = classNames( 'product_categories__list-wrapper', className ); const productsLabel = translate( 'Products' ); const categoriesLabel = translate( 'Categories' ); @@ -35,7 +94,7 @@ class ProductCategories extends Component { { categoriesLabel }, ] }> - + { productsLabel } @@ -46,9 +105,14 @@ class ProductCategories extends Component { + ); } @@ -57,9 +121,20 @@ class ProductCategories extends Component { function mapStateToProps( state ) { const site = getSelectedSiteWithFallback( state ); + const siteId = site ? site.ID : null; return { site, + siteId, }; } -export default connect( mapStateToProps )( localize( ProductCategories ) ); +function mapDispatchToProps( dispatch ) { + return bindActionCreators( + { + fetchProductCategories, + }, + dispatch + ); +} + +export default connect( mapStateToProps, mapDispatchToProps )( localize( ProductCategories ) ); diff --git a/client/extensions/woocommerce/app/product-categories/list.js b/client/extensions/woocommerce/app/product-categories/list.js new file mode 100644 index 0000000000000..7e5078e01c986 --- /dev/null +++ b/client/extensions/woocommerce/app/product-categories/list.js @@ -0,0 +1,222 @@ +/** + * External dependencies + */ +import React, { Component } from 'react'; +import { bindActionCreators } from 'redux'; +import classNames from 'classnames'; +import { connect } from 'react-redux'; +import { localize } from 'i18n-calypso'; +import { map, filter, reduce, includes } from 'lodash'; +import page from 'page'; +import WindowScroller from 'react-virtualized/WindowScroller'; + +/** + * Internal dependencies + */ +import { + areProductCategoriesLoadingIgnoringPage, + getProductCategoriesLastPage, + getProductCategoriesIgnoringPage, + areProductCategoriesLoaded, + getTotalProductCategories, +} from 'woocommerce/state/sites/product-categories/selectors'; +import Count from 'components/count'; +import CompactCard from 'components/card/compact'; +import { DEFAULT_QUERY } from 'woocommerce/state/sites/product-categories/utils'; +import EmptyContent from 'components/empty-content'; +import { fetchProductCategories } from 'woocommerce/state/sites/product-categories/actions'; +import { getLink } from 'woocommerce/lib/nav-utils'; +import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; +import VirtualList from 'components/virtual-list'; + +const ITEM_HEIGHT = 70; + +class ProductCategories extends Component { + + componentWillMount() { + this.catIds = map( this.props.categories, 'id' ); + } + + componentWillReceiveProps( newProps ) { + if ( newProps.categories !== this.props.categories ) { + this.catIds = map( newProps.categories, 'id' ); + } + } + + getChildren( id ) { + const { categories } = this.props; + return filter( categories, { parent: id } ); + } + + getItemHeight = ( item, _recurse = false ) => { + if ( ! item ) { + return ITEM_HEIGHT; + } + + // if item has a parent, and parent is in payload, height is already part of parent + if ( item.parent && ! _recurse && includes( this.catIds, item.parent ) ) { + return 0; + } + + return reduce( + this.getChildren( item.id ), + ( height, childItem ) => { + return height + this.getItemHeight( childItem, true ); + }, + ITEM_HEIGHT + ); + }; + + getRowHeight = ( { index } ) => { + return this.getItemHeight( this.getItem( index ) ); + }; + + getItem( index ) { + if ( this.props.categories ) { + return this.props.categories[ index ]; + } + } + + renderItem( item, _recurse = false ) { + const { site } = this.props; + + // if item has a parent and it is in current props.categories, do not render + if ( item.parent && ! _recurse && includes( this.catIds, item.parent ) ) { + return; + } + const children = this.getChildren( item.id ); + const itemId = item.id; + const image = item.image && item.image.src; + const imageClasses = classNames( 'product-categories__list-item-icon', { + 'is-thumb-placeholder': ! image, + } ); + const link = getLink( '/store/products/category/:site/' + itemId, site ); + + const goToLink = () => { + page( link ); + }; + + return ( +
+ +
+
+
+
+ { item.image && } +
+
+
+ + { item.name } + + { item.description } + +
+
+ { children.length > 0 && ( +
+ { children.map( child => this.renderItem( child, true ) ) } +
+ ) } +
+ ); + } + + renderRow = ( { index } ) => { + const item = this.getItem( index ); + if ( item ) { + return this.renderItem( item ); + } + + return null; + }; + + renderCategoryList() { + const { loading, categories, lastPage, searchQuery } = this.props; + return ( + + {( { height, scrollTop } ) => ( + + )} + + ); + } + + render() { + const { className, translate, totalCategories, searchQuery } = this.props; + + if ( this.props.isInitialRequestLoaded && 0 === totalCategories ) { + let message = null; + if ( searchQuery ) { + message = translate( 'No product categories found for {{query /}}.', { + components: { + query: { searchQuery }, + }, + } ); + } + return ; + } + + const classes = classNames( 'product-categories__list', className ); + + return ( +
+
+ { + this.props.isInitialRequestLoaded && this.renderCategoryList() || +
+ } +
+
+ ); + } + +} + +function mapStateToProps( state, ownProps ) { + const { searchQuery } = ownProps; + let query = {}; + if ( searchQuery && searchQuery.length ) { + query = { search: searchQuery, ...query }; + } + + const site = getSelectedSiteWithFallback( state ); + const loading = areProductCategoriesLoadingIgnoringPage( state, query ); + const isInitialRequestLoaded = areProductCategoriesLoaded( state, query ); // first page request + const categories = getProductCategoriesIgnoringPage( state, query ); + const totalCategories = getTotalProductCategories( state, query ); + const lastPage = getProductCategoriesLastPage( state, query ); + return { + site, + loading, + categories, + lastPage, + isInitialRequestLoaded, + totalCategories, + }; +} + +function mapDispatchToProps( dispatch ) { + return bindActionCreators( + { + fetchProductCategories, + }, + dispatch + ); +} + +export default connect( mapStateToProps, mapDispatchToProps )( localize( ProductCategories ) ); diff --git a/client/extensions/woocommerce/app/product-categories/style.scss b/client/extensions/woocommerce/app/product-categories/style.scss new file mode 100644 index 0000000000000..d29e6138e9810 --- /dev/null +++ b/client/extensions/woocommerce/app/product-categories/style.scss @@ -0,0 +1,92 @@ +.product-categories__list-placeholder { + @include placeholder(); + background: $white; + height: 525px; +} + +.product-categories__list-container { + border: 1px solid lighten( $gray, 30% ); + border-bottom: 0; +} + +.product-categories__list-item { + background-color: $gray-light; +} + +.product-categories__list-item-card { + cursor: pointer; +} + +.product-categories__list-item-card.card.is-compact { + padding: 0; +} + +.product-categories__list-thumb { + display: inline-flex; + flex-direction: row; + align-items: center; + transform: translateY( 2px ); + margin: 0px 16px 0px 16px; +} + +.product-categories__list-item-icon { + margin-bottom: 0; + margin-right: 0px; + flex-shrink: 0; + position: relative; + + figure { + width: 40px; + height: 40px; + overflow: hidden; + + img { + position: absolute; + left: 50%; + top: 50%; + height: 100%; + width: auto; + transform: translate( -50%,-50% ); + } + } + + &.is-thumb-placeholder { + height: 40px; + min-width: 40px; + overflow: hidden; + position: relative; + background: $gray-light; + } +} + +.product-categories__list-item-wrapper { + display: flex; + flex-direction: row; + + .product-categories__list-item-info { + line-height: 69px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-grow: 1; + position: relative; + padding-right: 16px; + } + + .product-categories__list-item-description { + margin-left: 24px; + } + + .count { + margin: 18px 0px 0px 16px; + } + + + &:hover { + background: lighten( $gray, 35% ); + } +} + +.product-categories__list-nested { + margin-left: 2em; +} \ No newline at end of file diff --git a/client/extensions/woocommerce/app/products/index.js b/client/extensions/woocommerce/app/products/index.js index 22200efe18d75..fa9d4379f3021 100644 --- a/client/extensions/woocommerce/app/products/index.js +++ b/client/extensions/woocommerce/app/products/index.js @@ -94,7 +94,7 @@ class Products extends Component { const productsLabel = translate( 'Products' ); return ( - + { productsLabel } diff --git a/client/extensions/woocommerce/state/data-layer/product-categories/test/index.js b/client/extensions/woocommerce/state/data-layer/product-categories/test/index.js index 2a3562e0068a4..33d276f7a4347 100644 --- a/client/extensions/woocommerce/state/data-layer/product-categories/test/index.js +++ b/client/extensions/woocommerce/state/data-layer/product-categories/test/index.js @@ -41,7 +41,7 @@ describe( 'handlers', () => { method: 'GET', path: `/jetpack-blogs/${ siteId }/rest-api/`, query: { - path: '/wc/v3/products/categories&page=1&per_page=10&_envelope&_method=GET', + path: '/wc/v3/products/categories&page=1&per_page=100&_envelope&_method=GET', json: true, apiVersion: '1.1', }, diff --git a/client/extensions/woocommerce/state/sites/product-categories/utils.js b/client/extensions/woocommerce/state/sites/product-categories/utils.js index 3086c4cd755c8..566ee7b9e3ece 100644 --- a/client/extensions/woocommerce/state/sites/product-categories/utils.js +++ b/client/extensions/woocommerce/state/sites/product-categories/utils.js @@ -9,7 +9,7 @@ import { omitBy } from 'lodash'; // Defaults from https://woocommerce.github.io/woocommerce-rest-api-docs/#list-all-product-categories export const DEFAULT_QUERY = { page: 1, - per_page: 10, + per_page: 100, search: '', }; diff --git a/client/extensions/woocommerce/style.scss b/client/extensions/woocommerce/style.scss index 7bccbe25068c2..91885b39a7e9e 100644 --- a/client/extensions/woocommerce/style.scss +++ b/client/extensions/woocommerce/style.scss @@ -17,6 +17,7 @@ @import 'app/settings/email/email-settings/style'; @import 'app/products/product-form'; @import 'app/products/products-list'; + @import 'app/product-categories/style'; @import 'app/promotions/style'; @import 'app/reviews/style'; @import 'app/settings/shipping/style';