Commit 5be68d88 authored by Viacheslav Pavlov's avatar Viacheslav Pavlov
Browse files

Merge branch '90-crop-details-edit-pages' into 'master'

Resolve "Crop details/edit pages"

Closes #90

See merge request genesys-pgr/genesys-ui!88
parents 5b8261fa ceddb1c3
import Crop from 'model/genesys/Crop';
import {IReducerAction} from 'model/common.model';
import {ADMIN_RECEIVE_CROP} from 'crop/constants';
import {CropService} from 'service/CropService';
import navigateTo from 'actions/navigation';
import {loadCrops} from './public';
const receiveCrop = (cropDetails: Crop): IReducerAction => ({
type: ADMIN_RECEIVE_CROP, payload: cropDetails,
});
export const loadCrop = (shortName: string) => (dispatch) => {
CropService.getCrop(shortName)
.then((details) => dispatch(receiveCrop(details)));
};
export const saveCrop = (crop: Crop) => (dispatch) => {
return CropService.saveCrop(crop)
.then((crop) => dispatch(receiveCrop(crop)));
};
export const deleteCrop = (crop: Crop) => (dispatch) => {
return CropService.deleteCrop(crop.shortName)
.then(() => {
dispatch(loadCrops(true));
dispatch(navigateTo('/c'));
});
};
import Crop from 'model/genesys/Crop'; import Crop from 'model/genesys/Crop';
import {CropService} from 'service/CropService'; import {CropService} from 'service/CropService';
import {LOAD_CROPS_CACHE_IDLE, RECEIVE_CROPS} from 'crop/constants'; import {LOAD_CROPS_CACHE_IDLE, RECEIVE_CROPS, RECEIVE_CROP_DETAILS} from 'crop/constants';
import {IReducerAction} from 'model/common.model'; import {IReducerAction} from 'model/common.model';
import {log} from 'utilities/debug'; import {log} from 'utilities/debug';
import CropDetails from 'model/genesys/CropDetails';
const receiveCrops = (crops: Crop[]): IReducerAction => ({ const receiveCrops = (crops: Crop[]): IReducerAction => ({
type: RECEIVE_CROPS, payload: crops, type: RECEIVE_CROPS, payload: crops,
}); });
const receiveCropDetails = (cropDetails: CropDetails): IReducerAction => ({
type: RECEIVE_CROP_DETAILS, payload: cropDetails,
});
export const loadCropDetails = (shortName: string) => (dispatch) => {
CropService.getCropDetails(shortName)
.then((details) => dispatch(receiveCropDetails(details)));
};
export const loadCrops = (forceReload: boolean = false) => (dispatch, getState) => { export const loadCrops = (forceReload: boolean = false) => (dispatch, getState) => {
...@@ -26,23 +35,3 @@ export const loadCrops = (forceReload: boolean = false) => (dispatch, getState) ...@@ -26,23 +35,3 @@ export const loadCrops = (forceReload: boolean = false) => (dispatch, getState)
}); });
} }
}; };
export const saveCrop = (crop: Crop) => (dispatch, getState) => {
return CropService.saveCrop(crop)
.then(() => {
dispatch(loadCrops(true));
}).catch((error) => {
log('Save error', error);
});
};
export const deleteCrop = (crop: Crop) => (dispatch, getState) => {
return CropService.deleteCrop(crop.shortName)
.then(() => {
dispatch(loadCrops(true));
})
.catch((error) => {
log('Error', error);
});
};
...@@ -4,5 +4,10 @@ export const DELETE_CROP = 'App/Crop/DELETE_CROP'; ...@@ -4,5 +4,10 @@ export const DELETE_CROP = 'App/Crop/DELETE_CROP';
export const LOAD_CROPS = 'App/Crop/LOAD_CROPS'; export const LOAD_CROPS = 'App/Crop/LOAD_CROPS';
export const RECEIVE_CROPS = 'App/Crop/RECEIVE_CROPS'; export const RECEIVE_CROPS = 'App/Crop/RECEIVE_CROPS';
export const RECEIVE_CROP_DETAILS = 'App/Crop/RECEIVE_CROP_DETAILS';
export const ADMIN_RECEIVE_CROP = 'App/Crop/Admin/RECEIVE_CROP';
export const CROP_FORM = 'Form/CROP';
// 300000 ms = 5 min // 300000 ms = 5 min
export const LOAD_CROPS_CACHE_IDLE = 300000; export const LOAD_CROPS_CACHE_IDLE = 300000;
import update from 'immutability-helper';
import { IReducerAction } from 'model/common.model';
import {ADMIN_RECEIVE_CROP, RECEIVE_CROPS} from 'crop/constants';
const INITIAL_STATE = {
crop: null,
cropNames: {},
};
export default function crop(state = INITIAL_STATE, action: IReducerAction = {type: ''}) {
switch (action.type) {
case ADMIN_RECEIVE_CROP: {
return update(state, {
crop: {$set: action.payload},
});
}
case RECEIVE_CROPS: {
const cropNames = {};
const crops = action.payload;
crops.map((crop) => cropNames[crop.shortName] = crop.otherNames);
return update(state, {
cropNames: {$set: cropNames},
});
}
default:
return state;
}
}
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
// import admin from './admin'; import admin from './admin';
// import dashboard from './dashboard'; // import dashboard from './dashboard';
import root from './public'; import root from './public';
const rootReducer = combineReducers({ const rootReducer = combineReducers({
// admin, admin,
// dashboard, // dashboard,
public: root, public: root,
}); });
......
import update from 'immutability-helper'; import update from 'immutability-helper';
import { IReducerAction } from 'model/common.model'; import { IReducerAction } from 'model/common.model';
import { RECEIVE_CROPS } from 'crop/constants'; import {RECEIVE_CROP_DETAILS, RECEIVE_CROPS, ADMIN_RECEIVE_CROP} from 'crop/constants';
const INITIAL_STATE = { const INITIAL_STATE = {
list: null, list: null,
lastFetched: 0, lastFetched: 0,
details: null,
}; };
export default function crop(state = INITIAL_STATE, action: IReducerAction = {type: ''}) { export default function crop(state = INITIAL_STATE, action: IReducerAction = {type: ''}) {
...@@ -17,6 +18,27 @@ export default function crop(state = INITIAL_STATE, action: IReducerAction = {ty ...@@ -17,6 +18,27 @@ export default function crop(state = INITIAL_STATE, action: IReducerAction = {ty
lastFetched: {$set: Date.now()}, lastFetched: {$set: Date.now()},
}); });
} }
case ADMIN_RECEIVE_CROP: {
const updateIndex = state.list ? state.list.findIndex((crop) => crop.shortName === action.payload.shortName) : -1;
return updateIndex === -1 ?
update(state, {
details: {$set: null},
})
:
update(state, {
details: {$set: null},
list: {
[updateIndex]: {$set: action.payload},
},
});
}
case RECEIVE_CROP_DETAILS: {
return update(state, {
details: {$set: action.payload},
});
}
default: default:
return state; return state;
......
// Root // Root
import CropBrowsePage from 'crop/ui/BrowsePage'; import CropBrowsePage from 'crop/ui/BrowsePage';
import CropDisplayPage from 'crop/ui/DisplayPage'; import CropDisplayPage from 'crop/ui/DisplayPage';
import CropEditPage from 'crop/ui/EditPage';
// Admin // Admin
...@@ -14,6 +15,16 @@ const rootRoutes = [ ...@@ -14,6 +15,16 @@ const rootRoutes = [
component: CropBrowsePage, component: CropBrowsePage,
exact: true, exact: true,
}, },
{
path: '/c/:shortName/edit',
component: CropEditPage,
exact: true,
},
{
path: '/c/edit',
component: CropEditPage,
exact: true,
},
{ {
path: '/c/:shortName', path: '/c/:shortName',
component: CropDisplayPage, component: CropDisplayPage,
......
...@@ -3,7 +3,7 @@ import {connect} from 'react-redux'; ...@@ -3,7 +3,7 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux'; import {bindActionCreators} from 'redux';
// Models // Models
import Crop from 'model/genesys/Crop'; import CropDetails from 'model/genesys/CropDetails';
// UI // UI
import PageLayout, { PageContents } from 'ui/layout/PageLayout'; import PageLayout, { PageContents } from 'ui/layout/PageLayout';
...@@ -12,7 +12,7 @@ import CropCard from 'crop/ui/c/CropCard'; ...@@ -12,7 +12,7 @@ import CropCard from 'crop/ui/c/CropCard';
import GridLayout from 'ui/layout/GridLayout'; import GridLayout from 'ui/layout/GridLayout';
interface IBrowsePageProps extends React.ClassAttributes<any> { interface IBrowsePageProps extends React.ClassAttributes<any> {
crops: Crop[]; crops: CropDetails[];
} }
class BrowsePage extends React.Component<IBrowsePageProps> { class BrowsePage extends React.Component<IBrowsePageProps> {
......
import * as React from 'react'; import * as React from 'react';
import {connect} from 'react-redux'; import { connect } from 'react-redux';
import {bindActionCreators} from 'redux'; import { bindActionCreators } from 'redux';
import { translate } from 'react-i18next';
import withStyles from '@material-ui/core/styles/withStyles';
// Actions
import { loadCropDetails } from 'crop/actions/public';
// Models // Models
import Crop from 'model/genesys/Crop'; import Crop from 'model/genesys/Crop';
import CropDetails from 'model/genesys/CropDetails';
// UI // UI
import PageLayout, {PageContents} from 'ui/layout/PageLayout'; import PageLayout, {PageContents, PageSection} from 'ui/layout/PageLayout';
import {Properties, PropertiesItem} from 'ui/common/Properties';
import CropCard from 'crop/ui/c/CropCard'; import CropCard from 'crop/ui/c/CropCard';
import ContentHeader from 'ui/common/heading/ContentHeader'; import ContentHeader from 'ui/common/heading/ContentHeader';
import Section from 'ui/common/layout/Section';
import Loading from 'ui/common/Loading';
import PropertiesCard from 'ui/common/PropertiesCard';
import Number from 'ui/common/Number';
/*tslint:disable*/
const styles = (theme) => ({
cropBlurb: {
padding: '16px',
columnCount: 2,
columnGap: '3rem',
'& > h1':{
fontSize: '41px',
marginTop: '22px',
marginBottom: '11px',
fontFamily: 'Roboto-Bold',
},
'& > p': {
color: '#4d4c46',
fontSize: '18px',
fontFamily: 'Roboto-Regular',
lineHeight: '30px',
margin: '0 0 30px 0',
'& ~ a': {
color: '#006db4',
textDecoration: 'none',
},
},
},
});
/*tslint:enable*/
interface IDisplayPageProps extends React.ClassAttributes<any> { interface IDisplayPageProps extends React.ClassAttributes<any> {
loadCropDetails: (shortName: string) => void;
cropDetails: CropDetails;
crops: Crop[]; crops: Crop[];
shortName: string; shortName: string;
i18n: any;
classes: any;
} }
class DisplayPage extends React.Component<IDisplayPageProps, any> { class DisplayPage extends React.Component<IDisplayPageProps, any> {
public state = { protected static needs = [
crop: null, ({ params: { shortName } }) => {
}; return shortName ? loadCropDetails(shortName) : null;
},
];
public componentWillMount() { public componentWillMount() {
const { crops, shortName } = this.props; const { loadCropDetails, shortName } = this.props;
if (crops && shortName) { if (shortName) {
const crop = crops.find((item) => item.shortName === shortName); loadCropDetails(shortName);
this.setState({crop});
}
}
public componentWillReceiveProps(nextProps) {
const { crops, shortName } = nextProps;
if (crops && shortName) {
const crop = crops.find((item) => item.shortName === shortName);
this.setState({crop});
} }
} }
public render() { public render() {
const { crop } = this.state; const { cropDetails, shortName, classes } = this.props;
const crop = cropDetails;
return !crop ? null : ( return (
<PageLayout withFooter> <PageLayout withFooter>
<ContentHeader title="Crop details"/> <ContentHeader title="Crop details"/>
<PageContents> { !crop || crop.shortName !== shortName ? (<Loading/>) :
<CropCard crop={ crop }/> <PageContents>
</PageContents> { cropDetails.blurb && cropDetails.blurb.body &&
<Section title={ shortName }>
<div className={ classes.cropBlurb } dangerouslySetInnerHTML={ {__html: cropDetails.blurb.body} }/>
</Section>
}
<CropCard crop={ crop }/>
<PageSection title="General information">
<Properties>
<PropertiesItem title="Accessions in genesys" keepEmpty><Number value={ cropDetails.accessionCount || 0 } /></PropertiesItem>
</Properties>
</PageSection>
<PropertiesCard title="Other names" propertiesList={ crop.otherNames.map((otherName) => ({title: otherName})) } propertyItemProps={ {keepEmpty: true, small: true} }/>
</PageContents>
}
</PageLayout> </PageLayout>
); );
} }
...@@ -54,9 +106,12 @@ class DisplayPage extends React.Component<IDisplayPageProps, any> { ...@@ -54,9 +106,12 @@ class DisplayPage extends React.Component<IDisplayPageProps, any> {
const mapStateToProps = (state, ownProps) => ({ const mapStateToProps = (state, ownProps) => ({
shortName: ownProps.match.params.shortName, shortName: ownProps.match.params.shortName,
crops: state.crop.public.list, crops: state.crop.public.list,
cropDetails: state.crop.public.details,
}); });
const mapDispatchToProps = (dispatch) => bindActionCreators({}, dispatch); const mapDispatchToProps = (dispatch) => bindActionCreators({
loadCropDetails,
}, dispatch);
export default connect(mapStateToProps, mapDispatchToProps)(DisplayPage); export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(translate()(DisplayPage)));
import * as React from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import Grid from '@material-ui/core/Grid';
import Paper from '@material-ui/core/Paper';
import {log} from 'utilities/debug';
import confirm from 'utilities/confirmAlert';
import navigateTo from 'actions/navigation';
import {deleteCrop, loadCrop, saveCrop} from 'crop/actions/admin';
import Crop from 'model/genesys/Crop';
import CropForm from './c/CropForm';
import PageLayout, {PageContents} from 'ui/layout/PageLayout';
interface ICropEditPageProps extends React.ClassAttributes<any> {
classes: any;
shortName?: string;
cropNames: any;
loadCrop: (shortName: string) => void;
saveCrop: (crop: Crop) => Promise<any>;
deleteCrop: (crop: Crop) => void;
navigateTo: (path: string, query?: string) => void;
crop: Crop;
}
class CropEditPage extends React.Component<ICropEditPageProps, any> {
protected static needs = [
({params: {shortName}}) => loadCrop(shortName),
];
private onSave = (updatedCrop: Crop) => {
const {saveCrop, navigateTo} = this.props;
saveCrop(updatedCrop)
.then(() => navigateTo(`/c/${updatedCrop.shortName}/edit`));
}
private onDelete = () => {
const {crop, deleteCrop} = this.props;
confirm(`Delete crop '${crop.name}'?`, {
description: `Note, deleting any crop causes mayhem.`,
confirmLabel: 'Delete',
abortLabel: 'Cancel',
}).then(() => {
deleteCrop(crop);
}).catch(() => {
// don't delete
});
}
public componentDidMount() {
const {crop, loadCrop, shortName} = this.props;
if (shortName && (!crop || crop.shortName !== shortName)) {
loadCrop(shortName);
}
}
public render() {
const {shortName, cropNames} = this.props;
let {crop} = this.props;
if (!crop && !shortName) {
crop = new Crop();
}
if (!crop) {
log('No crop.');
return null;
}
return (
<PageLayout>
<PageContents>
<Grid item xs={ 12 }>
<Paper className="p-20 mb-10">
<CropForm initialValues={ crop } onSubmit={ this.onSave } onDelete={ this.onDelete } cropNames={ cropNames }/>
</Paper>
</Grid>
</PageContents>
</PageLayout>
);
}
}
const mapStateToProps = (state, ownProps) => ({
shortName: ownProps.match.params.shortName,
cropNames: state.crop.admin.cropNames,
crop: state.crop.admin.crop,
});
const mapDispatchToProps = (dispatch) => bindActionCreators({
loadCrop,
saveCrop,
deleteCrop,
navigateTo,
}, dispatch);
export default connect(mapStateToProps, mapDispatchToProps)(CropEditPage);
...@@ -2,14 +2,17 @@ import * as React from 'react'; ...@@ -2,14 +2,17 @@ import * as React from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
// Models // Models
import Crop from 'model/genesys/Crop'; import CropDetails from 'model/genesys/CropDetails';
// UI // UI
import Card, {CardHeader, CardContent} from 'ui/common/Card'; import Card, {CardHeader, CardContent, CardMedia, CardActions} from 'ui/common/Card';
import {Properties, PropertiesItem} from 'ui/common/Properties'; import {Properties, PropertiesItem} from 'ui/common/Properties';
import PrettyDate from 'ui/common/time/PrettyDate'; import PrettyDate from 'ui/common/time/PrettyDate';
import Grid from '@material-ui/core/Grid'; import Grid from '@material-ui/core/Grid';
import withStyles from '@material-ui/core/styles/withStyles'; import withStyles from '@material-ui/core/styles/withStyles';
import Button from '@material-ui/core/Button';
import Authorize from 'ui/common/authorized/Authorize';
import Number from 'ui/common/Number';
const style = (theme) => ({ const style = (theme) => ({
root: { root: {
...@@ -18,23 +21,27 @@ const style = (theme) => ({ ...@@ -18,23 +21,27 @@ const style = (theme) => ({
justifyContent: 'space-between', justifyContent: 'space-between',
display: 'flex', display: 'flex',
}, },
media: {
height: 150,
},
}); });
const CropCard = ({crop, classes, compact = false, edit = false, ...other}: { crop: Crop, classes?: any, compact?: boolean, edit?: boolean }) => { const CropCard = ({crop, classes, compact = false, edit = false, ...other}: { crop: CropDetails, classes?: any, compact?: boolean, edit?: boolean }) => {
if (!crop) { if (!crop) {
return null; return null;
} }
return compact ? ( return compact ? (
<Grid item xs={ 12 } sm={ 6 } md={ 4 }> <Grid item xs={ 12 } sm={ 6 } md={ 4 } lg={ 3 } xl={ 2 } >
<Card className={ classes.root }> <Card className={ classes.root }>
{ crop.covers && crop.covers.length > 0 && <CardMedia className={ classes.media } title={ crop.name } image={ `/proxy/uploads/d${crop.covers[0].storagePath}` } /> }
<CardHeader title={ <CardHeader title={
<Link to={ `/c/${crop.shortName}${edit ? '/edit' : '' }` }> <Link to={ `/c/${crop.shortName}${edit ? '/edit' : '' }` }>
{ crop.name } { crop.name }
</Link>