Commit c60b2f1d authored by Benjamin Bouvier's avatar Benjamin Bouvier
Browse files

Put operation details into a modal;

Known limitations:
- there's a small width glitch when opening the modal, probably because it's
  created lazily (and polyfilled by an empty div otherwise).
- Bootstrap can't handle modal-in-a-modal, so closing the confirm-delete-modal
  within the details modal will close both.
parent 46c21f21
import React from 'react';
import { connect } from 'react-redux';
import { translate as $t } from '../../helpers';
import { get, actions } from '../../store';
import Modal from '../ui/modal';
import { DetailedViewLabel } from './label';
import OperationTypeSelect from './type-select';
import CategorySelect from './category-select';
import DeleteOperation from './delete-operation';
export function computeAttachmentLink(op) {
......@@ -10,92 +16,196 @@ export function computeAttachmentLink(op) {
return `operations/${op.id}/${file}`;
}
export default class OperationDetails extends React.Component {
const MODAL_ID = 'details-modal';
class DetailsModal extends React.Component {
render() {
let op = this.props.operation;
let maybeAttachment = '';
if (op === null) {
return <div/>;
}
let typeSelect = (
<OperationTypeSelect
operation={ op }
onSelectId={ this.props.makeHandleSelectType(op) }
/>
);
let categorySelect = (
<CategorySelect
operation={ op }
onSelectId={ this.props.makeHandleSelectCategory(op) }
categories={ this.props.categories }
getCategoryTitle={ this.props.getCategoryTitle }
/>
);
let modalTitle = $t('client.operations.details');
let modalFooter = (
<div>
<DeleteOperation
operation={ op }
formatCurrency={ this.props.formatCurrency }
/>
</div>
);
let attachment = null;
if (op.binary !== null) {
let opLink = computeAttachmentLink(op);
maybeAttachment = (
<span>
<a href={ opLink } target="_blank">
<span className="glyphicon glyphicon-file"></span>
{ $t('client.operations.attached_file') }
</a>
</span>
);
attachment = {
link: computeAttachmentLink(op),
text: $t('client.operations.attached_file')
};
} else if (op.attachments && op.attachments.url !== null) {
maybeAttachment = (
<span>
<a href={ op.attachments.url } target="_blank">
<span className="glyphicon glyphicon-file"></span>
{ $t(`client.${op.attachments.linkTranslationKey}`) }
</a>
</span>
attachment = {
link: op.attachments.url,
text: $t(`client.${op.attachments.linkTranslationKey}`)
};
}
if (attachment) {
attachment = (
<div className="form-group clearfix">
<label className="col-xs-4 control-label">
{ attachment.text }
</label>
<label className="col-xs-8 text-info">
<a href={ attachment.link } target="_blank">
<span className="glyphicon glyphicon-file"></span>
</a>
</label>
</div>
);
}
let modalBody = (
<div>
<div className="form-group clearfix">
<label className="col-xs-4 control-label">
{ $t('client.operations.full_label') }
</label>
<label className="col-xs-8">
{ op.raw }
</label>
</div>
<div className="form-group clearfix">
<label className="col-xs-4 control-label">
{ $t('client.operations.custom_label') }
</label>
<div className="col-xs-8">
<DetailedViewLabel operation={ op } />
</div>
</div>
<div className="form-group clearfix">
<label className="col-xs-4 control-label">
{ $t('client.operations.amount') }
</label>
<label className="col-xs-8">
{ this.props.formatCurrency(op.amount) }
</label>
</div>
<div className="form-group clearfix">
<label className="col-xs-4 control-label">
{ $t('client.operations.type') }
</label>
<div className="col-xs-8">
{ typeSelect }
</div>
</div>
<div className="form-group clearfix">
<label className="col-xs-4 control-label">
{ $t('client.operations.category') }
</label>
<div className="col-xs-8">
{ categorySelect }
</div>
</div>
{ attachment }
</div>
);
return (
<tr className={ this.props.rowClassName }>
<td>
<a href="#" onClick={ this.props.onToggleDetails }>
<i className="fa fa-minus-square"></i>
</a>
</td>
<td colSpan="5" className="text-uppercase">
<ul>
<li>
{ $t('client.operations.full_label') }
{ op.raw }
</li>
<li className="form-inline">
{ $t('client.operations.custom_label') }
<DetailedViewLabel operation={ op } />
</li>
<li>
{ $t('client.operations.amount') }
{ this.props.formatCurrency(op.amount) }
</li>
<li className="form-inline">
{ $t('client.operations.type') }
{ this.props.typeSelect }
</li>
<li className="form-inline">
{ $t('client.operations.category') }
{ this.props.categorySelect }
</li>
{ maybeAttachment }
<li>
<DeleteOperation
operation={ this.props.operation }
formatCurrency={ this.props.formatCurrency }
/>
</li>
</ul>
</td>
</tr>
<Modal
modalId={ MODAL_ID }
modalBody={ modalBody }
modalTitle={ modalTitle }
modalFooter={ modalFooter }
/>
);
}
}
OperationDetails.propTypes = {
// The Operation itself.
operation: React.PropTypes.object.isRequired,
let ConnectedModal = connect((state, props) => {
let operation = props.operationId ? get.operationById(state, props.operationId) : null;
return {
operation
};
}, dispatch => {
return {
makeHandleSelectType: operation => type => {
actions.setOperationType(dispatch, operation, type);
},
makeHandleSelectCategory: operation => category => {
actions.setOperationCategory(dispatch, operation, category);
}
};
})(DetailsModal);
// Function describing what happens when we untoggle details.
onToggleDetails: React.PropTypes.func.isRequired,
ConnectedModal.propTypes = {
// An operation id (can be null) from which we may retrieve a full
// operation.
operationId: React.PropTypes.string,
// Function called to format amounts.
formatCurrency: React.PropTypes.func.isRequired,
// CSS class name for the current row.
rowClassName: React.PropTypes.string.isRequired,
// Array of categories (used for the category select).
categories: React.PropTypes.array.isRequired,
// Type select for the current operation.
typeSelect: React.PropTypes.object.isRequired,
// Category select for the current operation.
categorySelect: React.PropTypes.object.isRequired,
// Maps categories => titles (used for the category select).
getCategoryTitle: React.PropTypes.func.isRequired,
};
// Simple wrapper that exposes one setter (setOperationId), to not expose a
// ref'd redux component to the above component.
class Wrapper extends React.Component {
constructor(props) {
super(props);
this.state = {
selectedOperationId: null
};
// Togglable state to only show right thereafter the user asked.
this.show = false;
}
setOperationId(operationId) {
this.show = true;
this.setState({
selectedOperationId: operationId
});
}
componentDidUpdate() {
if (this.show && this.state.selectedOperationId !== null) {
$(`#${MODAL_ID}`).modal('show');
this.show = false;
}
}
render() {
return (
<ConnectedModal
operationId={ this.state.selectedOperationId }
formatCurrency={ this.props.formatCurrency }
categories={ this.props.categories }
getCategoryTitle={ this.props.getCategoryTitle }
/>
);
}
}
export default Wrapper;
......@@ -8,11 +8,13 @@ import { translate as $t } from '../../helpers';
import { get } from '../../store';
import InfiniteList from '../ui/infinite-list';
import AmountWell from './amount-well';
import DetailsModal from './details';
import SearchComponent from './search';
import OperationItem from './item';
import SyncButton from './sync-button';
import InfiniteList from '../ui/infinite-list';
// Infinite list properties.
const OPERATION_BALLAST = 10;
......@@ -49,19 +51,30 @@ class OperationsComponent extends React.Component {
this.handleWindowResize = this.handleWindowResize.bind(this);
this.operationHeight = computeOperationHeight();
this.selectModalOperation = this.selectModalOperation.bind(this);
}
selectModalOperation(operationId) {
this.refs.detailsModal.setOperationId(operationId);
}
// Implementation of infinite list.
renderItems(low, high) {
return this.props.filteredOperations
.slice(low, high)
.map(o =>
<OperationItem key={ o.id }
operation={ o }
formatCurrency={ this.props.account.formatCurrency }
categories={ this.props.categories }
getCategoryTitle={ this.props.getCategoryTitle }
/>);
.map(o => {
let handleOpenModal = () => this.selectModalOperation(o.id);
return (
<OperationItem key={ o.id }
operation={ o }
formatCurrency={ this.props.account.formatCurrency }
categories={ this.props.categories }
getCategoryTitle={ this.props.getCategoryTitle }
onOpenModal={ handleOpenModal }
/>
);
});
}
componentDidMount() {
......@@ -120,6 +133,13 @@ class OperationsComponent extends React.Component {
return (
<div>
<DetailsModal
ref="detailsModal"
formatCurrency={ format }
categories={ this.props.categories }
getCategoryTitle={ this.props.getCategoryTitle }
/>
<div className="row operation-wells" ref="wells">
<AmountWell
......
......@@ -5,27 +5,13 @@ import { actions } from '../../store';
import { translate as $t } from '../../helpers';
import { default as OperationDetails, computeAttachmentLink } from './details';
import { computeAttachmentLink } from './details';
import { OperationListViewLabel } from './label';
import OperationTypeSelect from './type-select';
import CategorySelect from './category-select';
class Operation extends React.Component {
constructor(props) {
super(props);
this.state = {
showDetails: false
};
this.handleToggleDetails = this.handleToggleDetails.bind(this);
}
handleToggleDetails(e) {
e.preventDefault();
this.setState({ showDetails: !this.state.showDetails });
}
render() {
let op = this.props.operation;
......@@ -47,19 +33,6 @@ class Operation extends React.Component {
/>
);
if (this.state.showDetails) {
return (
<OperationDetails
onToggleDetails={ this.handleToggleDetails }
operation={ op }
rowClassName={ rowClassName }
formatCurrency={ this.props.formatCurrency }
typeSelect={ typeSelect }
categorySelect={ categorySelect }
/>
);
}
// Add a link to the attached file, if there is any.
let link;
if (op.binary !== null) {
......@@ -92,7 +65,7 @@ class Operation extends React.Component {
return (
<tr className={ rowClassName }>
<td className="hidden-xs">
<a href="#" onClick={ this.handleToggleDetails }>
<a onClick={ this.props.onOpenModal }>
<i className="fa fa-plus-square"></i>
</a>
</td>
......
......@@ -9,8 +9,13 @@ export default props => {
assertHas(props, 'modalFooter');
return (
<div className="modal fade" id={ props.modalId } tabIndex="-1"
role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div
className="modal fade"
id={ props.modalId }
tabIndex="-1"
role="dialog"
aria-labelledby="myModalLabel"
aria-hidden="true" >
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
......
......@@ -1155,6 +1155,11 @@ export function accountsByAccessId(state, accessId) {
return state.accounts.filter(acc => acc.bankAccess === accessId);
}
export function operationById(state, operationId) {
let candidates = state.operations.filter(operation => operation.id === operationId);
return candidates.length ? candidates[0] : null;
}
export function operationsByAccountId(state, accountId) {
let { accountNumber } = accountById(state, accountId);
return state.operations.filter(op => op.bankAccount === accountNumber);
......
......@@ -143,6 +143,12 @@ export const get = {
(banks, accountId) => Bank.operationsByAccountId(banks, accountId)
),
// Operation
operationById(state, id) {
assertDefined(state);
return Bank.operationById(state.banks, id);
},
// String
defaultAccountId(state) {
assertDefined(state);
......
......@@ -89,7 +89,7 @@ module.exports = {
},
operations: {
amount: 'Amount:',
amount: 'Amount',
column_date: 'Date',
column_name: 'Transaction',
......@@ -103,18 +103,19 @@ module.exports = {
spent: 'Spent',
saved: 'Saved',
details: 'Operation details',
attached_file: 'Download the attached file',
edf_details: 'See the bill in the EDF application',
full_label: 'Full label:',
category: 'Category:',
full_label: 'Full label',
category: 'Category',
last_sync: 'Last sync:',
sync_now: 'Synchroniser maintenant',
syncing: 'Fetching your latest bank transactions…',
title: 'Transactions',
type: 'Type:',
type: 'Type',
custom_label: 'Custom label',
add_custom_label: 'Add a custom label',
......
......@@ -89,7 +89,7 @@ module.exports = {
},
operations: {
amount: 'Montant :',
amount: 'Montant',
column_date: 'Date',
column_name: 'Opération',
......@@ -103,18 +103,19 @@ module.exports = {
spent: 'Dépensés',
saved: 'Économisés',
details: "Détails de l'opération",
attached_file: 'Télécharger le fichier associé',
edf_details: `Voir sa facture dans l'application EDF`,
full_label: 'Libellé complet :',
category: 'Catégorie :',
full_label: 'Libellé complet',
category: 'Catégorie',
last_sync: 'Dernière synchronisation avec votre banque :',
sync_now: 'Synchroniser maintenant',
syncing: 'Récupération de vos dernières opérations en cours…',
title: 'Opérations',
type: 'Type :',
type: 'Type',
custom_label: 'Libellé personnalisé :',
add_custom_label: 'Ajouter un libellé personnalisé',
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment