Commit fea6aa1f authored by Oleksii Savran's avatar Oleksii Savran Committed by Matija Obreza

WIP: Shopping cart

parent c234712b
......@@ -14,7 +14,8 @@
"inventoryPolicy": "Inventory maintenance policy",
"taxonomyGenus": "Genera",
"taxonomySpecies": "Species",
"request": "Requests"
"request": "Requests",
"cart": "Cart"
},
"welcome": {
"p": {
......@@ -130,7 +131,8 @@
"public": {
"p": {
"accession": {
"list": "Accessions"
"list": "Accessions",
"addToCart": "Add to cart"
},
"actions": {
"title": "Accession actions",
......@@ -173,6 +175,17 @@
}
}
},
"cart": {
"public": {
"p": {
"cart": {
"title": "Cart",
"checkout": "Checkout",
"noItems": "Cart is empty"
}
}
}
},
"codevalue": {
"admin": {
"p": {
......
......@@ -2,7 +2,8 @@
"public": {
"p": {
"accession": {
"list": "Accessions"
"list": "Accessions",
"addToCart": "Add to cart"
},
"actions": {
"title": "Accession actions",
......
......@@ -2,10 +2,12 @@ import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, compose } from 'redux';
import { WithTranslation, withTranslation } from 'react-i18next';
import { createStyles, WithStyles, withStyles } from '@material-ui/core';
// Action
import { listAccessionsAction, loadMoreAccessionsAction } from 'accession/action/public';
import navigateTo from '@gringlobal/client/action/navigation';
import { addToCart } from 'cart/action/public';
// Model
import Accession from '@gringlobal/client/model/gringlobal/Accession';
import { FilteredPage, SortDirection } from '@gringlobal/client/model/page';
......@@ -23,9 +25,20 @@ import { CodeValueDisplay } from 'common/CodeValue';
import PageTitle from '@gringlobal/client/ui/common/PageTitle';
import Site from '@gringlobal/client/model/gringlobal/Site';
import { AccessionLink } from 'ui/common/Links';
import Button from '@material-ui/core/Button/Button';
interface IBrowsePageProps extends React.ClassAttributes<any>, WithTranslation, WithBrowsePageBase {
const styles = (theme) => createStyles({
controls: {
display: 'flex',
alignItems: 'center',
},
addButton: {
marginRight: '1rem',
},
});
interface IBrowsePageProps extends React.ClassAttributes<any>, WithTranslation, WithStyles, WithBrowsePageBase {
onSortChange: (sortBy: string, dir: SortDirection) => void;
applyFilter: (filter: AccessionFilter) => void;
loadMore: () => void;
......@@ -73,6 +86,10 @@ class BrowsePage extends React.Component<IBrowsePageProps> {
({}) => listAccessionsAction(),
];
public state = {
selected: [],
};
public constructor(props) {
super(props);
......@@ -84,8 +101,18 @@ class BrowsePage extends React.Component<IBrowsePageProps> {
listAction();
}
private rowToggled = (toggledRow: number, selectedRows: number[], rowData: any) => {
private rowToggled = (toggledRow: number, selectedRows: number[], rowData: Accession) => {
const { selected } = this.state;
console.log(`Row ${toggledRow} was toggled. Have ${selectedRows}`);
if (!selectedRows.some((row) => row === toggledRow)) {
return this.setState({ selected: selected.filter((item) => item.id !== rowData.id) });
}
this.setState({ selected: [...selected, { id: rowData.id, distributionFormCode: 'SD' }] }); // fixme
};
private addSelectedToCart = () => {
addToCart(this.state.selected);
this.setState({ selected: [] }); // todo: unselect rows in Table
};
private columnToggled = (toggledColumn: string, selectedColumns: string[]) => {
......@@ -98,17 +125,24 @@ class BrowsePage extends React.Component<IBrowsePageProps> {
};
public render() {
const { data, t, onSortChange, applyFilter, loadMore } = this.props;
const { data, t, onSortChange, applyFilter, loadMore, classes } = this.props;
const columns = AccessionTableConfig.getColumns(data && data.content ? data.content[0] : null);
return (
<>
<PageTitle title={ t('accession.public.p.accession.list') }/>
<ContentHeader title={ t('accession.public.p.accession.list') }>
<Filters
onSubmit={ applyFilter }
filter={ data && data.filter }
/>
<div className={ classes.controls }>
{ this.state.selected.length > 0 &&
<Button onClick={ this.addSelectedToCart } variant="contained" className={ classes.addButton }>
{ t('accession.public.p.accession.addToCart') }
</Button>
}
<Filters
onSubmit={ applyFilter }
filter={ data && data.filter }
/>
</div>
</ContentHeader>
<Table
tableKey="accession-list"
......@@ -145,4 +179,5 @@ const mapDispatchToProps = (dispatch) => bindActionCreators({
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withTranslation(),
withStyles(styles),
)(withBrowsePageBase(BrowsePage));
import { put, takeEvery } from 'redux-saga/effects';
import { SAGA_RECEIVE_CART_ITEMS, RECEIVE_CART_ITEMS, REMOVE_CART_ITEMS, SAGA_REMOVE_CART_ITEMS, CLEAR_CART_ITEMS } from 'cart/constants';
import { AccessionService } from '@gringlobal/client/service';
import { FilteredPage, IPageRequest, Page } from '@gringlobal/client/model/page';
import Accession from '@gringlobal/client/model/gringlobal/Accession';
// import AccessionFilter from '@gringlobal/client/model/gringlobal/AccessionFilter';
export const cartPublicSagas = [
takeEvery(SAGA_RECEIVE_CART_ITEMS, listCartItemsSaga),
takeEvery(SAGA_REMOVE_CART_ITEMS, removeCartItemsSaga),
];
export const addToCart = (items: any[]) => {
// console.log('add to cart: ', items);
const cartItems = getCartItemsLS();
if (!cartItems || !cartItems.length) {
return localStorage.setItem('cart', JSON.stringify(items));
}
const map = new Map();
cartItems.forEach((item) => map.set(item.id, item.distributionFormCode));
items.forEach((item) => {
if (map.get(item.id) === item.distributionFormCode) {
return;
}
map.set(item.id, item.distributionFormCode);
});
const cart = [];
map.forEach((value, key) => cart.push({ id: key, distributionFormCode: value }));
localStorage.setItem('cart', JSON.stringify(cart));
};
export const getCartItemsLS = () => JSON.parse(localStorage.getItem('cart'));
export const removeCartItemsAction = (ids: number[]) => ({
type: SAGA_REMOVE_CART_ITEMS,
payload: { ids },
});
export const listCartItemsAction = (pageR: IPageRequest = { page: 0, size: 100 }) => {
return {
type: SAGA_RECEIVE_CART_ITEMS,
payload: {
filter: null,
pageR,
},
};
};
export const loadMoreCartItemsAction = (accessions: FilteredPage<Accession>) => {
return {
type: SAGA_RECEIVE_CART_ITEMS,
payload: {
filter: accessions.filter,
pageR: Page.nextPage(accessions),
},
};
};
function* listCartItemsSaga(action) {
let { filter } = action.payload;
const cartItems = getCartItemsLS();
if (!filter) {
if (!cartItems || !cartItems.length) {
console.log('no items in LS!');
yield put({
type: CLEAR_CART_ITEMS,
});
return;
}
filter = { id: cartItems.map((item) => item.id) };
}
yield put({
type: 'API',
target: RECEIVE_CART_ITEMS,
method: AccessionService.filter,
params: [filter, action.payload.pageR],
onSuccess: (accessions: FilteredPage<Accession & any>) => {
accessions.content.forEach((accession) => accession.distributionFormCode = cartItems.find((item) => item.id === accession.id).distributionFormCode);
return accessions;
},
});
}
function* removeCartItemsSaga(action) {
const { ids } = action.payload;
const cartItems = getCartItemsLS();
const updatedCartItems = cartItems.filter((item) => !ids.some((idToRemove) => item.id === idToRemove));
localStorage.setItem('cart', JSON.stringify(updatedCartItems));
yield put({
type: REMOVE_CART_ITEMS,
payload: { ids },
});
}
export const RECEIVE_CART_ITEMS = 'cart/public/RECEIVE_CART_ITEMS';
export const SAGA_RECEIVE_CART_ITEMS = 'saga/cart/public/RECEIVE_CART_ITEMS';
export const REMOVE_CART_ITEMS = 'cart/public/REMOVE_CART_ITEMS';
export const SAGA_REMOVE_CART_ITEMS = 'saga/cart/public/REMOVE_CART_ITEMS';
export const CLEAR_CART_ITEMS = 'cart/public/CLEAR_CART_ITEMS';
import { combineReducers } from 'redux';
import cartPublic from 'cart/reducer/public';
const rootReducer = combineReducers({
public: cartPublic,
});
export default rootReducer;
import update from 'immutability-helper';
// Constants
import { RECEIVE_CART_ITEMS, REMOVE_CART_ITEMS, CLEAR_CART_ITEMS } from 'cart/constants';
// Model
import Accession from '@gringlobal/client/model/gringlobal/Accession';
import { FilteredPage } from '@gringlobal/client/model/page';
import { ApiCall } from '@gringlobal/client/model/common';
const initialState: {
accessionList: ApiCall<FilteredPage<Accession & { distributionFormCode: string }>>,
} = {
accessionList: null,
};
const cooperatorPublicReducer = (state = initialState, action) => {
switch (action.type) {
case RECEIVE_CART_ITEMS: {
const { apiCall: { loading, error, timestamp, data } } = action.payload;
return update(state, {
accessionList: {
$set: {
loading,
error,
timestamp,
data: FilteredPage.merge(state.accessionList && state.accessionList.data, data),
},
},
});
}
case REMOVE_CART_ITEMS: {
console.log('reducer remove cart items', action.payload);
const { ids } = action.payload;
if (state.accessionList && state.accessionList.data && state.accessionList.data.content) {
const updated = state.accessionList.data.content.filter((site) => !ids.some((id) => site.id === id));
return update(state, {
accessionList: {
data: {
content: {
$set: updated,
},
totalElements: {
$apply: (total) => total - ids.length,
},
},
},
});
}
return state;
}
case CLEAR_CART_ITEMS: {
return update(state, {
accessionList: { $set: null },
});
}
default:
return state;
}
};
export default cooperatorPublicReducer;
import Loadable from '@gringlobal/client/utilities/CustomReactLoadable';
// model
import IRoute from '@gringlobal/client/model/common/IRoute';
const publicRoutes: IRoute[] = [
{
exact: true,
component: Loadable({
loader: () => import(/* webpackMode:"lazy", webpackChunkName: "cooperator" */ 'cart/ui/CartPage'),
}),
path: '/cart',
},
];
export { publicRoutes as cartPublicRoutes };
{
"public": {
"p": {
"cart": {
"title": "Cart",
"checkout": "Checkout",
"noItems": "Cart is empty"
}
}
}
}
import * as React from 'react';
import { withTranslation, WithTranslation } from 'react-i18next';
import { bindActionCreators, compose } from 'redux';
import { connect } from 'react-redux';
import withBrowsePageBase, { WithBrowsePageBase } from 'ui/common/withBrowsePageBase';
import { listCartItemsAction, loadMoreCartItemsAction, removeCartItemsAction } from 'cart/action/public';
import Table, { TextAlign } from '@gringlobal/client/ui/common/table/Table';
import Accession from '@gringlobal/client/model/gringlobal/Accession';
import { AccessionLink } from 'ui/common/Links';
import { CodeValueDisplay } from 'common/CodeValue';
import { FilteredPage, SortDirection } from '@gringlobal/client/model/page';
import AccessionFilter from '@gringlobal/client/model/gringlobal/AccessionFilter';
import Button from '@material-ui/core/Button/Button';
import confirm from '@gringlobal/client/utilities/confirmAlert';
import { CooperatorOwnedTableConfiguration as TableConfiguration } from '@gringlobal/client/ui/common/table/TableConfiguration';
import withTabs, { IWithTabs } from 'ui/common/withTabs';
import Tab from '@material-ui/core/Tab/Tab';
import HeaderTabs from '@gringlobal/client/ui/common/tabs/HeaderTabs';
import TabPanel from '@gringlobal/client/ui/common/tabs/TabPanel';
import SlotLayout from '@gringlobal/client/ui/common/layout/SlotLayout';
import { createStyles, WithStyles, withStyles } from '@material-ui/core';
const styles = (theme) => createStyles({
controls: {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
margin: '8px 8px 0',
},
noItems: {
fontSize: '1.2rem',
fontWeight: 'bold',
textAlign: 'center',
marginTop: '2rem',
},
});
export const CartTableDefaultConfig = {
defaultColumns: [
'id',
'accession',
'taxonomySpecies',
'statusCode',
'doi',
'distributionFormCode',
],
defaultColumnSettings: {
id: { readonly: true, align: TextAlign.right },
taxonomySpecies: { sort: 'taxonomySpecies.name' },
},
columnsRenderers: {
// initialReceivedDate: Renderers.DATE_RENDERER,
// taxonomySpecies: (taxonomySpecies: TaxonomySpecies): JSX.Element => <PrintSpecies taxonomySpecies={ taxonomySpecies } />,
accession: (_: any, accession: Accession): JSX.Element => <AccessionLink accession={ accession }/>,
// site: (site: Site): JSX.Element => {
// return site ? (
// <>{ site.siteShortName }</>
// ) : null;
// },
statusCode: (codeValue) => <CodeValueDisplay codeGroup="ACCESSION_STATUS" value={ codeValue } />,
// lifeFormCode: (codeValue) => <CodeValueDisplay codeGroup="ACCESSION_LIFE_FORM" value={ codeValue } />,
// reproductiveUniformityCode: (codeValue) => <CodeValueDisplay codeGroup="REPRODUCTIVE_UNIFORMITY" value={ codeValue } />,
// improvementStatusCode: (codeValue) => <CodeValueDisplay codeGroup="IMPROVEMENT_LEVEL" value={ codeValue } />,
},
};
const CartTableConfig = new TableConfiguration(CartTableDefaultConfig);
interface ICartPageProps extends React.ClassAttributes<any>, WithTranslation, WithStyles, WithBrowsePageBase, IWithTabs {
onSortChange: (sortBy: string, dir: SortDirection) => void;
applyFilter: (filter: AccessionFilter) => void;
loadMore: () => void;
data: FilteredPage<Accession>;
removeCartItemsAction: (ids: number[]) => void;
}
enum CartTabs {
CART = 'cart',
CHECKOUT = 'checkout',
}
class CartPage extends React.Component<ICartPageProps> {
public static defaultTab = CartTabs.CART;
public state = {
selected: [],
};
public constructor(props) {
super(props);
const { listAction } = this.props;
listAction();
}
public componentDidMount(): void {
if (typeof document !== 'undefined') {
window.addEventListener('storage', this.handleLocalStorageUpdate);
}
}
public componentWillUnmount() {
if (typeof document !== 'undefined') {
window.removeEventListener('storage', this.handleLocalStorageUpdate);
}
}
private handleLocalStorageUpdate = (e: StorageEvent) => {
const { listAction } = this.props;
if (e.key === 'cart') {
listAction();
}
};
private rowToggled = (toggledRow: number, selectedRows: number[], rowData: Accession) => {
const { selected } = this.state;
console.log(`Row ${toggledRow} was toggled. Have ${selectedRows}`);
if (!selectedRows.some((row) => row === toggledRow)) {
return this.setState({ selected: selected.filter((id) => id !== rowData.id) });
}
this.setState({ selected: [...selected, rowData.id] });
};
private columnToggled = (toggledColumn: string, selectedColumns: string[]) => {
console.log(`Column ${toggledColumn} was toggled. Have ${selectedColumns}`);
};
private remove = () => {
const { t, removeCartItemsAction } = this.props;
const { selected } = this.state;
confirm(t('common:label.deleteListConfirm', { count: selected.length, what: t('client:model.name.Accession', { count: selected.length }) }), {
confirmLabel: t('common:label.yes'),
abortLabel: t('common:label.no'),
}).then(() => {
removeCartItemsAction(this.state.selected);
this.setState({ selected: [] }); // todo: unselect rows in Table
});
};
public render(): React.ReactNode {
const { data, t, onSortChange, loadMore, currentTab, onTabChange, loadableData, classes } = this.props;
const columns = CartTableConfig.defaultColumns;
const cartIsEmpty = !loadableData || (data && !data.content.length);
return (
<>
<HeaderTabs
value={ currentTab }
textColor="primary"
onChange={ onTabChange }
variant="scrollable"
scrollButtons="auto"
aria-label="Cart tabs"
>
<Tab value={ CartTabs.CART } label={ t('cart.public.p.cart.title') } />
<Tab value={ CartTabs.CHECKOUT } label={ t('cart.public.p.cart.checkout') } disabled={ cartIsEmpty }/>
</HeaderTabs>
<>
<TabPanel value={ currentTab } index={ CartTabs.CART }>
{ cartIsEmpty ? (
<div className={ classes.noItems }>{ t('cart.public.p.cart.noItems') }</div>
) : (
<SlotLayout
fixedContent={
<div className={ classes.controls }>
<Button onClick={ this.remove } variant="contained" disabled={ this.state.selected.length === 0 }>
{ t('common:action.remove') }
</Button>
</div>
}
>
<Table
tableKey="cart"
type={ 'Accession' }
columns={ columns }
data={ data && data.content }
tableConfig={ CartTableConfig }
total={ data && data.content && data.totalElements }
loadMore={ loadMore }
onRowToggled={ this.rowToggled }
onColumnToggled={ this.columnToggled }
sort={ data && data.sort }
onSortChange={ onSortChange }
/>
</SlotLayout>
) }
</TabPanel>
<TabPanel value={ currentTab } index={ CartTabs.CHECKOUT }>
<div>{ t('cart.public.p.cart.checkout') }</div>
</TabPanel>
</>
</>
);
}
}
const mapStateToProps = (state, ownProps) => ({
loadableData: state.cart.public.accessionList,
});
const mapDispatchToProps = (dispatch) => bindActionCreators({
listAction: listCartItemsAction,
loadMoreData: loadMoreCartItemsAction,
removeCartItemsAction,
}, dispatch);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withTranslation(),
withStyles(styles),
)(withBrowsePageBase(withTabs(CartPage)));
......@@ -19,6 +19,7 @@ import { cropPublicSagas } from 'crop/action/public';
import { cropAdminSagas } from 'crop/action/admin';
import { inventoryPolicyAdminSagas } from 'inventorypolicy/action/admin';
import { codeValueAdminSagas } from 'codevalue/action/admin';
import { cartPublicSagas } from 'cart/action/public';
import { AxiosRequestConfig } from 'axios';
......@@ -34,6 +35,7 @@ export default function*() {
...inventoryGroupPublicSagas,
...requestPublicSagas,
...cropPublicSagas,
...cartPublicSagas,
...siteAdminSagas,
...kpiAdminSagas,
......
......@@ -18,6 +18,7 @@ import repository from 'repository/reducer';
import crop from 'crop/reducer';
import inventoryPolicy from 'inventorypolicy/reducer';
import codeValue from 'codevalue/reducer';
import cart from 'cart/reducer';
const rootReducer = (history?) => (combineReducers({
// express reducers
......@@ -38,6 +39,7 @@ const rootReducer = (history?) => (combineReducers({
kpi,
repository,
crop,
cart,
...coreReducers(history),
}));
......
......@@ -14,7 +14,8 @@
"inventoryPolicy": "Inventory maintenance policy",
"taxonomyGenus": "Genera",
"taxonomySpecies": "Species",
"request": "Requests"
"request": "Requests",
"cart": "Cart"
},
"welcome": {
"p": {
......