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 {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 {log} from 'utilities/debug';
import CropDetails from 'model/genesys/CropDetails';
const receiveCrops = (crops: Crop[]): IReducerAction => ({
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) => {
......@@ -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';
export const LOAD_CROPS = 'App/Crop/LOAD_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
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 admin from './admin';
import admin from './admin';
// import dashboard from './dashboard';
import root from './public';
const rootReducer = combineReducers({
// admin,
admin,
// dashboard,
public: root,
});
......
import update from 'immutability-helper';
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 = {
list: null,
lastFetched: 0,
details: null,
};
export default function crop(state = INITIAL_STATE, action: IReducerAction = {type: ''}) {
......@@ -17,6 +18,27 @@ export default function crop(state = INITIAL_STATE, action: IReducerAction = {ty
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:
return state;
......
// Root
import CropBrowsePage from 'crop/ui/BrowsePage';
import CropDisplayPage from 'crop/ui/DisplayPage';
import CropEditPage from 'crop/ui/EditPage';
// Admin
......@@ -14,6 +15,16 @@ const rootRoutes = [
component: CropBrowsePage,
exact: true,
},
{
path: '/c/:shortName/edit',
component: CropEditPage,
exact: true,
},
{
path: '/c/edit',
component: CropEditPage,
exact: true,
},
{
path: '/c/:shortName',
component: CropDisplayPage,
......
......@@ -3,7 +3,7 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
// Models
import Crop from 'model/genesys/Crop';
import CropDetails from 'model/genesys/CropDetails';
// UI
import PageLayout, { PageContents } from 'ui/layout/PageLayout';
......@@ -12,7 +12,7 @@ import CropCard from 'crop/ui/c/CropCard';
import GridLayout from 'ui/layout/GridLayout';
interface IBrowsePageProps extends React.ClassAttributes<any> {
crops: Crop[];
crops: CropDetails[];
}
class BrowsePage extends React.Component<IBrowsePageProps> {
......
import * as React from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import { connect } from 'react-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
import Crop from 'model/genesys/Crop';
import CropDetails from 'model/genesys/CropDetails';
// 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 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> {
loadCropDetails: (shortName: string) => void;
cropDetails: CropDetails;
crops: Crop[];
shortName: string;
i18n: any;
classes: any;
}
class DisplayPage extends React.Component<IDisplayPageProps, any> {
public state = {
crop: null,
};
protected static needs = [
({ params: { shortName } }) => {
return shortName ? loadCropDetails(shortName) : null;
},
];
public componentWillMount() {
const { crops, shortName } = this.props;
if (crops && shortName) {
const crop = crops.find((item) => item.shortName === 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});
const { loadCropDetails, shortName } = this.props;
if (shortName) {
loadCropDetails(shortName);
}
}
public render() {
const { crop } = this.state;
const { cropDetails, shortName, classes } = this.props;
const crop = cropDetails;
return !crop ? null : (
return (
<PageLayout withFooter>
<ContentHeader title="Crop details"/>
<PageContents>
<CropCard crop={ crop }/>
</PageContents>
{ !crop || crop.shortName !== shortName ? (<Loading/>) :
<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>
);
}
......@@ -54,9 +106,12 @@ class DisplayPage extends React.Component<IDisplayPageProps, any> {
const mapStateToProps = (state, ownProps) => ({
shortName: ownProps.match.params.shortName,
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';
import {Link} from 'react-router-dom';
// Models
import Crop from 'model/genesys/Crop';
import CropDetails from 'model/genesys/CropDetails';
// 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 PrettyDate from 'ui/common/time/PrettyDate';
import Grid from '@material-ui/core/Grid';
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) => ({
root: {
......@@ -18,23 +21,27 @@ const style = (theme) => ({
justifyContent: 'space-between',
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) {
return null;
}
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 }>
{ crop.covers && crop.covers.length > 0 && <CardMedia className={ classes.media } title={ crop.name } image={ `/proxy/uploads/d${crop.covers[0].storagePath}` } /> }
<CardHeader title={
<Link to={ `/c/${crop.shortName}${edit ? '/edit' : '' }` }>
{ crop.name }
</Link>
}/>
<CardContent>
{ crop.shortName }
<Number value={ crop.accessionCount } />
</CardContent>
</Card>
</Grid>
......@@ -53,6 +60,13 @@ const CropCard = ({crop, classes, compact = false, edit = false, ...other}: { cr
<PropertiesItem title="Last modified"><PrettyDate value={ new Date(crop.lastModifiedDate) }/></PropertiesItem>
</Properties>
</CardContent>
<Authorize role="ROLE_ADMINISTRATOR">
<CardActions>
<Link to={ `/c/${crop.shortName}/edit` }>
<Button variant="raised">Edit</Button>
</Link>
</CardActions>
</Authorize>
</Card>
</Grid>
);
......
import * as React from 'react';
import {Link} from 'react-router-dom';
import {Field, reduxForm} from 'redux-form';
import Button from '@material-ui/core/Button';
import {CROP_FORM} from 'crop/constants';
import {TextField} from 'ui/common/text-field';
import {log} from 'utilities/debug';
import Validators from 'utilities/Validators';
import ItemsEditor from 'ui/common/ItemsEditor';
import Divider from '@material-ui/core/Divider';
const onAddString = () => {
log('Adding new string item');
return '';
};
const onRemoveString = (item) => {
log('Removing string', item);
};
const CropForm = ({error, handleSubmit, initialValues, onDelete, cropNames}) => {
const notInUse = (value) => !value || cropNames && Object.keys(cropNames).filter((cropShortName) => cropNames[cropShortName].indexOf(value) !== -1 && cropShortName !== initialValues.shortName).length === 0 ? undefined : 'Already in use';
const stringField = (otherName, index, fields, itemLabel) => (
<Field required name={ `${otherName}` } type="text" component={ TextField } label={ itemLabel } validate={ [Validators.required, notInUse] }/>
);
return (
<form onSubmit={ handleSubmit }>
{ initialValues.version >= 0 ? <div>Version: { initialValues.version }</div> : null }
<Field disabled={ initialValues.shortName } required name="shortName" label="Crop code" component={ TextField } validate={ [Validators.required, Validators.maxLength20] }/>
<Field required name="name" label="Crop title" component={ TextField } validate={ [Validators.required] }/>
<div className="pb-20 pt-20">
<h4>Other names </h4>
<ItemsEditor name="otherNames" itemLabel="Other names" addItem={ onAddString } removeItem={ onRemoveString } component={ stringField }/>
</div>
<div>{ error && <strong>{ error }</strong> }</div>
<Divider/>
<div className="pt-20">
<Button variant="raised" type="submit">Save changes</Button>
{ (initialValues._permissions && initialValues._permissions.delete) && <Button onClick={ onDelete } type="button">Delete</Button> }
<Link to={ `/c/${initialValues.shortName}` }><Button type="button">Back</Button></Link>
</div>
</form>
);
};
const validate = (values) => {
if (values.otherNames) {
const otherNamesError = [];
values.otherNames.map((otherName, index, array) => {
if (array.indexOf(otherName) !== index) {
otherNamesError[index] = 'Duplicate';
}
});
return { otherNames : otherNamesError };
}
return {};
};