Skip to content

Commit

Permalink
Store: Add UI for product category listing (Automattic#21227)
Browse files Browse the repository at this point in the history
* Adds UI for product category listing

* Handle PR feedback

* Fix product category test

* Fix right padding
  • Loading branch information
justinshreve authored Jan 4, 2018
1 parent b943bcf commit 8f1aac9
Show file tree
Hide file tree
Showing 7 changed files with 398 additions and 8 deletions.
85 changes: 80 additions & 5 deletions client/extensions/woocommerce/app/product-categories/index.js
Original file line number Diff line number Diff line change
@@ -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' );
Expand All @@ -35,7 +94,7 @@ class ProductCategories extends Component {
<span>{ categoriesLabel }</span>,
] }>
</ActionHeader>
<SectionNav>
<SectionNav selectedText={ categoriesLabel }>
<NavTabs label={ translate( 'Products' ) } selectedText={ categoriesLabel }>
<NavItem path={ getLink( '/store/products/:site/', site ) }>{ productsLabel }</NavItem>
<NavItem path={ getLink( '/store/products/categories/:site/', site ) } selected>
Expand All @@ -46,9 +105,14 @@ class ProductCategories extends Component {
<Search
pinned
fitsContainer
onSearch={ this.debouncedOnSearch }
placeholder={ translate( 'Search categories…' ) }
/>
</SectionNav>
<ProductCategoriesList
searchQuery={ searchQuery }
requestPages={ this.requestPages }
/>
</Main>
);
}
Expand All @@ -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 ) );
222 changes: 222 additions & 0 deletions client/extensions/woocommerce/app/product-categories/list.js
Original file line number Diff line number Diff line change
@@ -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 (
<div key={ 'product-category-' + itemId } className="product-categories__list-item">
<CompactCard key={ itemId } className="product-categories__list-item-card" onClick={ goToLink }>
<div className="product-categories__list-item-wrapper">
<div className="product-categories__list-thumb">
<div className={ imageClasses }>
<figure>
{ item.image && <img src={ item.image.src } /> }
</figure>
</div>
</div>
<span className="product-categories__list-item-info">
<a href={ link }>{ item.name }</a>
<Count count={ item.count } />
<span className="product-categories__list-item-description">{ item.description }</span>
</span>
</div>
</CompactCard>
{ children.length > 0 && (
<div className="product-categories__list-nested">
{ children.map( child => this.renderItem( child, true ) ) }
</div>
) }
</div>
);
}

renderRow = ( { index } ) => {
const item = this.getItem( index );
if ( item ) {
return this.renderItem( item );
}

return null;
};

renderCategoryList() {
const { loading, categories, lastPage, searchQuery } = this.props;
return (
<WindowScroller>
{( { height, scrollTop } ) => (
<VirtualList
items={ categories }
lastPage={ lastPage }
loading={ loading }
getRowHeight={ this.getRowHeight }
renderRow={ this.renderRow }
onRequestPages={ this.props.requestPages }
perPage={ DEFAULT_QUERY.per_page }
loadOffset={ 10 }
searching={ searchQuery && searchQuery.length }
defaultRowHeight={ ITEM_HEIGHT }
height={ height }
scrollTop={ scrollTop }
/>
)}
</WindowScroller>
);
}

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: <em>{ searchQuery }</em>,
},
} );
}
return <EmptyContent title={ translate( 'No product categories found.' ) } line={ message } />;
}

const classes = classNames( 'product-categories__list', className );

return (
<div className="product-categories__list-container">
<div className={ classes }>
{
this.props.isInitialRequestLoaded && this.renderCategoryList() ||
<div className="product-categories__list-placeholder" />
}
</div>
</div>
);
}

}

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 ) );
Loading

0 comments on commit 8f1aac9

Please sign in to comment.