From 521343e80f5bec52b013226e10ec2f5b06b8676b Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 3 May 2019 12:20:54 +0200 Subject: [PATCH 01/13] Add invoices page & lower z-index to 1000 --- frontend/components/Page.jsx | 5 ++++- frontend/pages/invoices.jsx | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 frontend/pages/invoices.jsx diff --git a/frontend/components/Page.jsx b/frontend/components/Page.jsx index a7fc7f9..57727cf 100644 --- a/frontend/components/Page.jsx +++ b/frontend/components/Page.jsx @@ -9,7 +9,7 @@ const Page = ({ children }) => { return (
@@ -28,6 +28,9 @@ const Page = ({ children }) => { Expenses + + Invoices + Profile diff --git a/frontend/pages/invoices.jsx b/frontend/pages/invoices.jsx new file mode 100644 index 0000000..82ef04f --- /dev/null +++ b/frontend/pages/invoices.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import Page from '../components/Page'; +import redirect from '../lib/redirect'; +import checkLoggedIn from '../lib/checkLoggedIn'; + +const InvoicesPage = () => { + return This is the invoices page; +}; + +InvoicesPage.getInitialProps = async context => { + const { loggedInUser } = await checkLoggedIn(context.apolloClient); + if (!loggedInUser.me) { + redirect(context, '/login'); + } + + return {}; +}; + +export default InvoicesPage; From 871a16b3b998c429117b278cf3151b8519408e9a Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 3 May 2019 12:57:39 +0200 Subject: [PATCH 02/13] Add upload invoice query & change expense claim query --- frontend/graphql/queries.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/graphql/queries.js b/frontend/graphql/queries.js index 3191f71..9416c3b 100644 --- a/frontend/graphql/queries.js +++ b/frontend/graphql/queries.js @@ -47,10 +47,8 @@ export const REGISTER_ME = gql` `; export const EXPENSE_CLAIM = gql` - mutation($amount: Float!, $description: String!, $VAT: Int, $receipt: Upload!) { - expenseClaim( - expense: { amount: $amount, description: $description, VAT: $VAT, receipt: $receipt } - ) { + mutation($expense: Expense!) { + expenseClaim(expense: $expense) { id user { id @@ -60,6 +58,14 @@ export const EXPENSE_CLAIM = gql` } `; +export const INVOICE_UPLOAD = gql` + mutation($invoice: InvoiceUpload!) { + uploadInvoice(invoice: $invoice) { + id + } + } +`; + export const LOG_ME_OUT = gql` mutation { logout From a5b4715446068649b6ec574903d2007fa5c809d4 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 3 May 2019 12:58:33 +0200 Subject: [PATCH 03/13] Modify to match querystring changes --- frontend/components/ExpenseForm.jsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/components/ExpenseForm.jsx b/frontend/components/ExpenseForm.jsx index 120f352..8233fba 100644 --- a/frontend/components/ExpenseForm.jsx +++ b/frontend/components/ExpenseForm.jsx @@ -91,10 +91,12 @@ const ExpenseForm = () => { const receipt = useInputFile({}); const [errors, setErrors] = useState({}); const variables = { - receipt: receipt.file.file, - amount: expense.fields.amount ? parseFloat(expense.fields.amount) : undefined, - description: expense.fields.description, - VAT: expense.fields.VAT ? parseInt(expense.fields.VAT, 10) : undefined + expense: { + receipt: receipt.file.file, + amount: expense.fields.amount ? parseFloat(expense.fields.amount) : undefined, + description: expense.fields.description, + VAT: expense.fields.VAT ? parseInt(expense.fields.VAT, 10) : undefined + } }; const handleSubmit = (e, claim) => { e.preventDefault(); @@ -116,7 +118,7 @@ const ExpenseForm = () => { VAT: NON_NEGATIVE.rule }; - validateAll(variables, rules, messages) + validateAll(variables.expense, rules, messages) .then(() => claim()) .catch(errs => { setErrors(formatErrors(errs)); From 6ed6ca41737c76b13787336d769dc3a839d3f3ae Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 3 May 2019 13:55:12 +0200 Subject: [PATCH 04/13] Create InvoiceUploadForm - simplified version --- frontend/components/InvoiceUploadForm.jsx | 162 ++++++++++++++++++++++ frontend/pages/invoices.jsx | 7 +- 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 frontend/components/InvoiceUploadForm.jsx diff --git a/frontend/components/InvoiceUploadForm.jsx b/frontend/components/InvoiceUploadForm.jsx new file mode 100644 index 0000000..b924c60 --- /dev/null +++ b/frontend/components/InvoiceUploadForm.jsx @@ -0,0 +1,162 @@ +import React, { useState } from 'react'; +import { validateAll } from 'indicative'; +import { Mutation } from 'react-apollo'; +import { Button, Card, Form, Label } from 'semantic-ui-react'; +import InputField from './commons/InputField'; +import formatErrors from '../lib/formatErrors'; +import useFormFields from './hooks/useFormFields'; +import useInputFile from './hooks/useInputFile'; +import { validateFile, NON_NEGATIVE, required } from '../lib/validation'; +import { INVOICE_UPLOAD } from '../graphql/queries'; +import SuccessMessage from './commons/SuccessMessage'; +import ErrorMessage from './commons/ErrorMessage'; + +const renderUI = (invoice, invoiceFile, errors, success, error, handleSubmit, save, loading) => { + return ( + + +

Submit an invoice

+
+ +
handleSubmit(e, save)} + > + + + + + + + + + + + + + + + +
+
+ ); +}; + +const InvoiceUploadForm = () => { + const invoice = useFormFields({ VAT: 21 }); + const invoiceFile = useInputFile({}); + const [errors, setErrors] = useState({}); + const [state, setState] = useState({ success: false }); + + const variables = { + invoice: { + amount: invoice.fields.amount ? parseFloat(invoice.fields.amount) : undefined, + VAT: invoice.fields.VAT ? parseInt(invoice.fields.VAT, 10) : undefined, + date: invoice.fields.date, + expDate: invoice.fields.expDate, + invoice: invoiceFile.file.file + } + }; + + const handleCompleted = () => { + setState({ success: true }); + }; + + const handleError = error => setState({ error }); + + const handleSubmit = (e, submit) => { + e.preventDefault(); + setErrors({}); + validateFile(invoiceFile); + + setState({}); + + const amountRequired = required('Amount'); + + const messages = { + ...amountRequired.message, + above: NON_NEGATIVE.message + }; + + const rules = { + amount: `${amountRequired.rule.amount}|${NON_NEGATIVE.rule}`, + VAT: NON_NEGATIVE.rule + }; + + validateAll(variables.invoice, rules, messages) + .then(() => submit()) + .catch(errs => { + setErrors(formatErrors(errs)); + }); + }; + + return ( + + {(claim, { loading }) => { + return renderUI( + invoice, + invoiceFile, + errors, + state.success, + state.error, + handleSubmit, + claim, + loading + ); + }} + + ); +}; + +export default InvoiceUploadForm; diff --git a/frontend/pages/invoices.jsx b/frontend/pages/invoices.jsx index 82ef04f..cd37f9f 100644 --- a/frontend/pages/invoices.jsx +++ b/frontend/pages/invoices.jsx @@ -2,9 +2,14 @@ import React from 'react'; import Page from '../components/Page'; import redirect from '../lib/redirect'; import checkLoggedIn from '../lib/checkLoggedIn'; +import InvoiceUploadForm from '../components/InvoiceUploadForm'; const InvoicesPage = () => { - return This is the invoices page; + return ( + + + + ); }; InvoicesPage.getInitialProps = async context => { From 1e02a38541a3eb220309f5d8f80505bd6e85b172 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Mon, 6 May 2019 09:40:35 +0200 Subject: [PATCH 05/13] Add todo comment --- frontend/components/InvoiceUploadForm.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/components/InvoiceUploadForm.jsx b/frontend/components/InvoiceUploadForm.jsx index b924c60..190f4db 100644 --- a/frontend/components/InvoiceUploadForm.jsx +++ b/frontend/components/InvoiceUploadForm.jsx @@ -37,6 +37,7 @@ const renderUI = (invoice, invoiceFile, errors, success, error, handleSubmit, sa onChange={invoiceFile.onChange} errorMessage={errors.invoice} /> + {/* TODO company selector & category selector */} Date: Thu, 9 May 2019 09:54:02 +0200 Subject: [PATCH 06/13] Add companies query --- backend/src/graphql/resolvers/queries.js | 3 ++- backend/src/graphql/types.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/graphql/resolvers/queries.js b/backend/src/graphql/resolvers/queries.js index 47714c4..714ac42 100644 --- a/backend/src/graphql/resolvers/queries.js +++ b/backend/src/graphql/resolvers/queries.js @@ -9,5 +9,6 @@ module.exports = { }); return expenses; }, - transactions: (root, args, { models: { Transaction } }) => Transaction.find() + transactions: (root, args, { models: { Transaction } }) => Transaction.find(), + companies: (root, args, { models: { Company } }) => Company.find() }; diff --git a/backend/src/graphql/types.js b/backend/src/graphql/types.js index 8e95b8c..144d91b 100644 --- a/backend/src/graphql/types.js +++ b/backend/src/graphql/types.js @@ -10,6 +10,7 @@ module.exports = gql` me: User @auth myExpenses: [Transaction]! @auth transactions: [Transaction]! @auth + companies: [Company] @auth } type Mutation { From 000fea2ca1b54b10d5b16d66117fcbd979527ff3 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 10 May 2019 10:46:47 +0200 Subject: [PATCH 07/13] Create & style invoice creation component --- frontend/components/InvoiceCreationForm.jsx | 176 ++++++++++++++++++++ frontend/pages/_app.jsx | 4 + frontend/pages/invoices.jsx | 2 + 3 files changed, 182 insertions(+) create mode 100644 frontend/components/InvoiceCreationForm.jsx diff --git a/frontend/components/InvoiceCreationForm.jsx b/frontend/components/InvoiceCreationForm.jsx new file mode 100644 index 0000000..81a5029 --- /dev/null +++ b/frontend/components/InvoiceCreationForm.jsx @@ -0,0 +1,176 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/label-has-for */ +import React, { useState } from 'react'; +import { Button, Card, Form, Grid, Icon, Dropdown, Label, Modal } from 'semantic-ui-react'; +import styled from 'styled-components'; +import InputField from './commons/InputField'; +import useFormFields from './hooks/useFormFields'; + +const CompanyDropdownStyle = styled.div` + display: flex; + justify-content: space-between; + div:first-child { + width: 100%; + padding-right: 5px; + } + i { + align-self: center; + } +`; + +const renderDetails = (details, removeDetail, handleDetailChange) => { + return ( + + {details.map((detail, idx) => ( + + + handleDetailChange(e, idx)} + value={detail.description} + name="description" + label="Description" + /> + + + handleDetailChange(e, idx)} + value={detail.amount} + name="amount" + label="Amount" + type="number" + /> + + + removeDetail(idx)} + style={{ cursor: 'pointer' }} + name="minus circle" + /> + + + ))} + + ); +}; + +const renderUI = ( + details, + addDetail, + removeDetail, + handleDetailChange, + company, + handleCompanyName, + companyModalStatus, + toggleCompanyModal +) => { + const trigger = ; + return ( +
+ + + + + + + +

Company: {company.name}

+
+ + + + + + + + + + + +
+
+
+ Details +
+
+ {renderDetails(details, removeDetail, handleDetailChange)} + + + + ); +}; + +const InvoiceCreationForm = () => { + const [details, setDetail] = useState([{}]); + const [companyModalStatus, setCompanyModalStatus] = useState(false); + const company = useFormFields({}); + + const addDetail = () => { + setDetail([...details, {}]); + }; + + const toggleCompanyModal = () => { + setCompanyModalStatus(!companyModalStatus); + }; + + const removeDetail = idx => { + const copyDetails = [...details]; + copyDetails.splice(idx, 1); + setDetail(copyDetails); + }; + + const handleCompanyName = (e, { value }) => { + company.onChange({ target: { name: 'name', value } }); + }; + + const handleDetailChange = (e, idx) => { + const copyDetails = [...details]; + copyDetails[idx] = { ...copyDetails[idx], [e.target.name]: e.target.value }; + setDetail(copyDetails); + }; + + return ( + + +

Generate an invoice

+
+ + {renderUI( + details, + addDetail, + removeDetail, + handleDetailChange, + company, + handleCompanyName, + companyModalStatus, + toggleCompanyModal + )} + +
+ ); +}; + +export default InvoiceCreationForm; diff --git a/frontend/pages/_app.jsx b/frontend/pages/_app.jsx index 940b203..57f1225 100644 --- a/frontend/pages/_app.jsx +++ b/frontend/pages/_app.jsx @@ -60,6 +60,10 @@ const GlobalStyle = createGlobalStyle` h4 { font-size: 1.6rem; } + + .ui.dropdown .menu>.item { + font-size: inherit; + } `; class MyApp extends App { diff --git a/frontend/pages/invoices.jsx b/frontend/pages/invoices.jsx index cd37f9f..c6945d6 100644 --- a/frontend/pages/invoices.jsx +++ b/frontend/pages/invoices.jsx @@ -3,11 +3,13 @@ import Page from '../components/Page'; import redirect from '../lib/redirect'; import checkLoggedIn from '../lib/checkLoggedIn'; import InvoiceUploadForm from '../components/InvoiceUploadForm'; +import InvoiceCreationForm from '../components/InvoiceCreationForm'; const InvoicesPage = () => { return ( + ); }; From 317c44a1e5751e6604354b5061fd595ac37a5c30 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 10 May 2019 15:44:24 +0200 Subject: [PATCH 08/13] Add company type --- frontend/types/index.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/types/index.js b/frontend/types/index.js index 2d51388..46ad5fe 100644 --- a/frontend/types/index.js +++ b/frontend/types/index.js @@ -18,3 +18,10 @@ export const userType = shape({ bankDetails: bankDetailsType, address: addressType }); + +export const companyType = shape({ + name: string, + bankDetails: bankDetailsType, + address: addressType, + VAT: string +}); From 84507ad99e1d62ae300ca4563b91cc28dba381f8 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 10 May 2019 15:47:07 +0200 Subject: [PATCH 09/13] Change null values to undefined --- frontend/components/commons/InputField.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/commons/InputField.jsx b/frontend/components/commons/InputField.jsx index 6f5f027..7697be7 100644 --- a/frontend/components/commons/InputField.jsx +++ b/frontend/components/commons/InputField.jsx @@ -29,7 +29,7 @@ const InputField = ({ name={name} type={type} disabled={disabled} - value={value} + value={value === null ? undefined : value} onChange={onChange} autoFocus={autoFocus} action={action} From 249e8dae2be8fe3fdcfa51eaa32538e825cf3e04 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 10 May 2019 15:48:35 +0200 Subject: [PATCH 10/13] Fetch & render companies + clean up --- frontend/components/InvoiceCreationForm.jsx | 366 +++++++++++++------- frontend/graphql/queries.js | 19 + 2 files changed, 262 insertions(+), 123 deletions(-) diff --git a/frontend/components/InvoiceCreationForm.jsx b/frontend/components/InvoiceCreationForm.jsx index 81a5029..90f9eda 100644 --- a/frontend/components/InvoiceCreationForm.jsx +++ b/frontend/components/InvoiceCreationForm.jsx @@ -1,10 +1,14 @@ /* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/label-has-for */ import React, { useState } from 'react'; -import { Button, Card, Form, Grid, Icon, Dropdown, Label, Modal } from 'semantic-ui-react'; +import PropTypes from 'prop-types'; +import { Button, Card, Form, Grid, Icon, Dropdown, Label, Modal, Popup } from 'semantic-ui-react'; +import { Query } from 'react-apollo'; import styled from 'styled-components'; import InputField from './commons/InputField'; -import useFormFields from './hooks/useFormFields'; +import useFormInput from './hooks/useFormInput'; +import { QUERY_COMPANIES } from '../graphql/queries'; +import { companyType } from '../types'; const CompanyDropdownStyle = styled.div` display: flex; @@ -18,138 +22,250 @@ const CompanyDropdownStyle = styled.div` } `; -const renderDetails = (details, removeDetail, handleDetailChange) => { - return ( - - {details.map((detail, idx) => ( - - - handleDetailChange(e, idx)} - value={detail.description} - name="description" - label="Description" - /> - - - handleDetailChange(e, idx)} - value={detail.amount} - name="amount" - label="Amount" - type="number" - /> - - - removeDetail(idx)} - style={{ cursor: 'pointer' }} - name="minus circle" - /> - - - ))} - - ); -}; +const Details = ({ details: { details, setDetail } }) => { + const addDetail = () => { + setDetail([...details, { id: details.length }]); + }; -const renderUI = ( - details, - addDetail, - removeDetail, - handleDetailChange, - company, - handleCompanyName, - companyModalStatus, - toggleCompanyModal -) => { - const trigger = ; + const removeDetail = idx => { + const copyDetails = [...details]; + copyDetails.splice(idx, 1); + setDetail(copyDetails); + }; + + const handleDetailChange = (e, idx) => { + const copyDetails = [...details]; + copyDetails[idx] = { ...copyDetails[idx], [e.target.name]: e.target.value }; + setDetail(copyDetails); + }; return ( -
- - - - - - - -

Company: {company.name}

-
- - - - - - - - - - - -
-
+
Details

- {renderDetails(details, removeDetail, handleDetailChange)} - - - + + {details.map((detail, idx) => ( + + + handleDetailChange(e, idx)} + value={detail.description} + name="description" + label="Description" + /> + + + handleDetailChange(e, idx)} + value={detail.amount} + placeholder="Excluding VAT" + name="amount" + label="Amount (€)" + type="number" + /> + + + removeDetail(idx)} + style={{ cursor: 'pointer' }} + name="minus circle" + /> + + + ))} + +
); }; -const InvoiceCreationForm = () => { - const [details, setDetail] = useState([{}]); - const [companyModalStatus, setCompanyModalStatus] = useState(false); - const company = useFormFields({}); +Details.propTypes = { + details: PropTypes.shape({ + details: PropTypes.arrayOf( + PropTypes.shape({ + id: Number, + description: String, + Amount: Number + }) + ), + setDetail: PropTypes.func + }).isRequired +}; - const addDetail = () => { - setDetail([...details, {}]); - }; +const Company = ({ companies, selectedCompany: { selectedCompany, setSelectedCompany } }) => { + const [$companies, setCompanies] = useState(companies || []); + const [companyModalStatus, setCompanyModalStatus] = useState(false); const toggleCompanyModal = () => { + if (companyModalStatus) { + const copyCompanies = [...$companies]; + const idx = $companies.findIndex(company => company.name === selectedCompany.name); + copyCompanies[idx] = selectedCompany; + setCompanies(copyCompanies); + } else if (!selectedCompany.name) return; setCompanyModalStatus(!companyModalStatus); }; - const removeDetail = idx => { - const copyDetails = [...details]; - copyDetails.splice(idx, 1); - setDetail(copyDetails); + const formattedCompanies = $companies.map(company => ({ + value: company.name, + text: company.name, + key: company.name, + selected: company.name === selectedCompany.name + })); + + const addCompany = (e, { value }) => { + setCompanies([...$companies, { name: value }]); }; const handleCompanyName = (e, { value }) => { - company.onChange({ target: { name: 'name', value } }); + const company = $companies.find(c => value === c.name); + if (company) { + setSelectedCompany({ ...company, ...company.address }); + } else { + setSelectedCompany({ name: value }); + } }; - const handleDetailChange = (e, idx) => { - const copyDetails = [...details]; - copyDetails[idx] = { ...copyDetails[idx], [e.target.name]: e.target.value }; - setDetail(copyDetails); + const handleCompanyFieldsChange = e => { + const { name, value } = e.target; + setSelectedCompany({ ...selectedCompany, [name]: value }); + }; + + const trigger = ; + + return ( + + + + + + + + +

Edit the company's data

+
+ +
+ + + + + + + + +
+
+
+ ); +}; + +Company.defaultProps = { + companies: [] +}; + +Company.propTypes = { + companies: PropTypes.arrayOf(companyType), + selectedCompany: PropTypes.shape({ + selectedCompany: companyType, + setSelectedCompany: PropTypes.func + }).isRequired +}; + +const renderUI = (details, companies, selectedCompany, vat, handleSubmit) => { + return ( +
+ +
+ + + + ); +}; + +const FormManager = ({ data }) => { + const [details, setDetail] = useState([{ id: 0 }]); + const [selectedCompany, setSelectedCompany] = useState({}); + const vat = useFormInput(21); + + const handleSubmit = e => { + e.preventDefault(); + console.log(selectedCompany); + console.log(details); }; return ( @@ -159,18 +275,22 @@ const InvoiceCreationForm = () => { {renderUI( - details, - addDetail, - removeDetail, - handleDetailChange, - company, - handleCompanyName, - companyModalStatus, - toggleCompanyModal + { details, setDetail }, + data.companies, + { + selectedCompany, + setSelectedCompany + }, + vat, + handleSubmit )} ); }; -export default InvoiceCreationForm; +const Main = () => { + return {({ data }) => }; +}; + +export default Main; diff --git a/frontend/graphql/queries.js b/frontend/graphql/queries.js index 9416c3b..24e05ff 100644 --- a/frontend/graphql/queries.js +++ b/frontend/graphql/queries.js @@ -28,6 +28,25 @@ export const QUERY_ME = gql` } `; +export const QUERY_COMPANIES = gql` + query COMPANIES { + companies { + name + VAT + bankDetails { + iban + bic + } + address { + street + city + country + zipCode + } + } + } +`; + export const LOG_ME_IN = gql` mutation Login($email: String!, $password: String!) { login(email: $email, password: $password) { From 6fa69de8b3537fa08d036c78cac82328b86d2deb Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 10 May 2019 17:16:17 +0200 Subject: [PATCH 11/13] Fix [react warning: uncontrolled input being changed to controlled] --- frontend/components/commons/InputField.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/commons/InputField.jsx b/frontend/components/commons/InputField.jsx index 7697be7..7e73045 100644 --- a/frontend/components/commons/InputField.jsx +++ b/frontend/components/commons/InputField.jsx @@ -29,7 +29,7 @@ const InputField = ({ name={name} type={type} disabled={disabled} - value={value === null ? undefined : value} + value={value === null ? '' : value} onChange={onChange} autoFocus={autoFocus} action={action} From f66d4dab1c88e9ca028cac51084c9eb0a8bdb1e1 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 10 May 2019 17:18:33 +0200 Subject: [PATCH 12/13] Call api to generate the invoice --- frontend/components/InvoiceCreationForm.jsx | 123 ++++++++++++++++---- frontend/graphql/queries.js | 11 ++ 2 files changed, 111 insertions(+), 23 deletions(-) diff --git a/frontend/components/InvoiceCreationForm.jsx b/frontend/components/InvoiceCreationForm.jsx index 90f9eda..2d8b7b7 100644 --- a/frontend/components/InvoiceCreationForm.jsx +++ b/frontend/components/InvoiceCreationForm.jsx @@ -3,12 +3,14 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { Button, Card, Form, Grid, Icon, Dropdown, Label, Modal, Popup } from 'semantic-ui-react'; -import { Query } from 'react-apollo'; +import { Query, Mutation } from 'react-apollo'; import styled from 'styled-components'; import InputField from './commons/InputField'; import useFormInput from './hooks/useFormInput'; -import { QUERY_COMPANIES } from '../graphql/queries'; +import { QUERY_COMPANIES, GENERATE_INVOICE } from '../graphql/queries'; import { companyType } from '../types'; +import ErrorMessage from './commons/ErrorMessage'; +import InfoMessage from './commons/InfoMessage'; const CompanyDropdownStyle = styled.div` display: flex; @@ -111,8 +113,7 @@ const Company = ({ companies, selectedCompany: { selectedCompany, setSelectedCom const formattedCompanies = $companies.map(company => ({ value: company.name, text: company.name, - key: company.name, - selected: company.name === selectedCompany.name + key: company.name })); const addCompany = (e, { value }) => { @@ -178,7 +179,7 @@ const Company = ({ companies, selectedCompany: { selectedCompany, setSelectedCom id="invoice-gen-company-name" value={selectedCompany.name} onChange={handleCompanyFieldsChange} - disabled={!!selectedCompany.name} + disabled={selectedCompany.disableName} /> { +const renderUI = (details, companies, selectedCompany, vat, handleSubmit, save, loading, state) => { return ( -
+ handleSubmit(e, save)} + error={!!state.error} + success={!!state.data} + loading={loading} + > + {state.error && } + {state.data && ( + + Success! The invoice has been generated! Click{' '} + + here + {' '} + to download (you can also grab the link and send it by email). + + )}
@@ -257,15 +274,51 @@ const renderUI = (details, companies, selectedCompany, vat, handleSubmit) => { ); }; -const FormManager = ({ data }) => { +const FormManager = ({ companies }) => { const [details, setDetail] = useState([{ id: 0 }]); const [selectedCompany, setSelectedCompany] = useState({}); const vat = useFormInput(21); + const [state, setState] = useState({ success: false }); - const handleSubmit = e => { + const expandedCompanies = companies.map(c => ({ + ...c, + disableName: !!c.name, + disableVAT: !!c.VAT + })); + + const variables = { + invoice: { + VAT: vat.value, + company: { + name: selectedCompany.name, + VAT: selectedCompany.VAT, + address: { + street: selectedCompany.street, + city: selectedCompany.city, + zipCode: selectedCompany.zipCode + ? Number.parseInt(selectedCompany.zipCode, 10) + : undefined, + country: selectedCompany.country + } + }, + details: details.map(detail => ({ + description: detail.description, + amount: detail.amount ? Number.parseFloat(detail.amount) : undefined + })) + } + }; + + const handleCompleted = data => { + setState({ data }); + }; + + const handleError = error => setState({ error }); + + const handleSubmit = (e, save) => { e.preventDefault(); - console.log(selectedCompany); - console.log(details); + console.log(variables); + setState({}); + save(); }; return ( @@ -274,23 +327,47 @@ const FormManager = ({ data }) => {

Generate an invoice

- {renderUI( - { details, setDetail }, - data.companies, - { - selectedCompany, - setSelectedCompany - }, - vat, - handleSubmit - )} + + {(save, { loading }) => + renderUI( + { details, setDetail }, + expandedCompanies, + { + selectedCompany, + setSelectedCompany + }, + vat, + handleSubmit, + save, + loading, + state + ) + } + ); }; +FormManager.defaultProps = { + companies: [] +}; + +FormManager.propTypes = { + companies: PropTypes.arrayOf(companyType) +}; + const Main = () => { - return {({ data }) => }; + return ( + + {({ data }) => } + + ); }; export default Main; diff --git a/frontend/graphql/queries.js b/frontend/graphql/queries.js index 24e05ff..f39608a 100644 --- a/frontend/graphql/queries.js +++ b/frontend/graphql/queries.js @@ -90,3 +90,14 @@ export const LOG_ME_OUT = gql` logout } `; + +export const GENERATE_INVOICE = gql` + mutation($invoice: GenerateInvoiceInput!) { + generateInvoice(invoice: $invoice) { + id + type + flow + file + } + } +`; From d71f00aec393bb061d1c39dd6631a1de7ffef237 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Sat, 11 May 2019 15:05:32 +0200 Subject: [PATCH 13/13] Add client side validation --- frontend/components/InvoiceCreationForm.jsx | 95 ++++++++++++++++++--- 1 file changed, 81 insertions(+), 14 deletions(-) diff --git a/frontend/components/InvoiceCreationForm.jsx b/frontend/components/InvoiceCreationForm.jsx index 2d8b7b7..a25ef58 100644 --- a/frontend/components/InvoiceCreationForm.jsx +++ b/frontend/components/InvoiceCreationForm.jsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { Button, Card, Form, Grid, Icon, Dropdown, Label, Modal, Popup } from 'semantic-ui-react'; +import { validateAll } from 'indicative'; import { Query, Mutation } from 'react-apollo'; import styled from 'styled-components'; import InputField from './commons/InputField'; @@ -11,6 +12,8 @@ import { QUERY_COMPANIES, GENERATE_INVOICE } from '../graphql/queries'; import { companyType } from '../types'; import ErrorMessage from './commons/ErrorMessage'; import InfoMessage from './commons/InfoMessage'; +import { NON_NEGATIVE } from '../lib/validation'; +import formatErrors from '../lib/formatErrors'; const CompanyDropdownStyle = styled.div` display: flex; @@ -19,14 +22,11 @@ const CompanyDropdownStyle = styled.div` width: 100%; padding-right: 5px; } - i { - align-self: center; - } `; -const Details = ({ details: { details, setDetail } }) => { +const Details = ({ details: { details, setDetail }, errors }) => { const addDetail = () => { - setDetail([...details, { id: details.length }]); + setDetail([...details, { id: details[details.length - 1].id + 1 }]); }; const removeDetail = idx => { @@ -54,6 +54,7 @@ const Details = ({ details: { details, setDetail } }) => { id={`invoice-gen-form-description-${idx}`} onChange={e => handleDetailChange(e, idx)} value={detail.description} + errorMessage={errors[`details.${idx}.description`]} name="description" label="Description" /> @@ -64,6 +65,7 @@ const Details = ({ details: { details, setDetail } }) => { onChange={e => handleDetailChange(e, idx)} value={detail.amount} placeholder="Excluding VAT" + errorMessage={errors[`details.${idx}.amount`]} name="amount" label="Amount (€)" type="number" @@ -96,10 +98,17 @@ Details.propTypes = { }).isRequired }; -const Company = ({ companies, selectedCompany: { selectedCompany, setSelectedCompany } }) => { +const Company = ({ + companies, + selectedCompany: { selectedCompany, setSelectedCompany }, + errors +}) => { const [$companies, setCompanies] = useState(companies || []); const [companyModalStatus, setCompanyModalStatus] = useState(false); + const errorMessage = + errors['company.name'] || errors[Object.keys(errors).find(k => k.includes('company.'))]; + const toggleCompanyModal = () => { if (companyModalStatus) { const copyCompanies = [...$companies]; @@ -138,7 +147,7 @@ const Company = ({ companies, selectedCompany: { selectedCompany, setSelectedCom return ( - + + {errorMessage && ( + + )} { +const renderUI = ( + details, + companies, + selectedCompany, + vat, + handleSubmit, + save, + loading, + state, + errors +) => { return ( )} - -
- + +
+ @@ -279,6 +309,7 @@ const FormManager = ({ companies }) => { const [selectedCompany, setSelectedCompany] = useState({}); const vat = useFormInput(21); const [state, setState] = useState({ success: false }); + const [errors, setErrors] = useState({}); const expandedCompanies = companies.map(c => ({ ...c, @@ -316,9 +347,44 @@ const FormManager = ({ companies }) => { const handleSubmit = (e, save) => { e.preventDefault(); - console.log(variables); setState({}); - save(); + setErrors({}); + + const rules = { + VAT: `required|${NON_NEGATIVE.rule}`, + 'details.*.amount': `required|${NON_NEGATIVE.rule}`, + 'details.*.description': 'required', + 'company.VAT': 'required', + 'company.name': 'required', + 'company.address.street': 'required', + 'company.address.city': 'required', + 'company.address.zipCode': 'required', + 'company.address.country': 'required' + }; + + const messages = { + above: NON_NEGATIVE.message, + 'VAT.required': 'VAT is required.', + 'details.*.amount.required': 'Amount is required.', + 'details.*.description.required': 'Description is required.', + 'company.VAT.required': + 'Incomplete company, please update it by clicking on the edit button.', + 'company.name.required': 'Please select or add a company.', + 'company.address.street.required': + 'Incomplete company, please update it by clicking on the edit button.', + 'company.address.city.required': + 'Incomplete company, please update it by clicking on the edit button.', + 'company.address.zipCode.required': + 'Incomplete company, please update it by clicking on the edit button.', + 'company.address.country.required': + 'Incomplete company, please update it by clicking on the edit button.' + }; + + validateAll(variables.invoice, rules, messages) + .then(() => save()) + .catch(errs => { + setErrors(formatErrors(errs)); + }); }; return ( @@ -345,7 +411,8 @@ const FormManager = ({ companies }) => { handleSubmit, save, loading, - state + state, + errors ) }