From 0a8965c6f50265fa014d1dc27e43fe4a3d0be14e Mon Sep 17 00:00:00 2001 From: Stezido Date: Fri, 29 Nov 2019 14:05:30 +0100 Subject: [PATCH 1/4] ui: Change analytics access without permissions If workflowitems are redacted the projected budget (total budget) is still visible but the charts are hidden and a well displayed warning is shown --- frontend/src/languages/english.js | 2 + frontend/src/languages/french.js | 2 + frontend/src/languages/german.js | 2 + frontend/src/languages/portuguese.js | 2 + .../src/pages/Analytics/ProjectAnalytics.js | 50 ++++++++++++------- .../pages/Analytics/ProjectAnalyticsDialog.js | 9 +++- .../pages/Analytics/SubProjectAnalytics.js | 46 +++++++++++------ .../Analytics/SubProjectAnalyticsDialog.js | 10 +++- frontend/src/pages/Analytics/reducer.js | 17 ++++--- .../src/pages/SubProjects/ProjectDetails.js | 2 +- .../src/pages/Workflows/SubProjectDetails.js | 6 +-- 11 files changed, 100 insertions(+), 48 deletions(-) diff --git a/frontend/src/languages/english.js b/frontend/src/languages/english.js index a4ce46b2e..037fe93f2 100644 --- a/frontend/src/languages/english.js +++ b/frontend/src/languages/english.js @@ -274,6 +274,8 @@ const en = { available_unspent_budget: "Available Unspent Budget", converted_amount: "Converted Amount", disbursed_budget_ratio: "Disbursed Budget Ratio", + insufficient_permissions_text: + "One or more workflowitem are redacted. The analytics are hidden because they would be falsified.", project_analytics: "Project Analytics", projected_budget_ratio: "Projected Budget Ratio", projected_budgets_distribution: "Projected Budgets Distribution", diff --git a/frontend/src/languages/french.js b/frontend/src/languages/french.js index aefca5aad..69736ef67 100644 --- a/frontend/src/languages/french.js +++ b/frontend/src/languages/french.js @@ -277,6 +277,8 @@ const fr = { available_unspent_budget: "Budget non dépensé disponible", converted_amount: "Montant converti", disbursed_budget_ratio: "Taux de décaissement(décaissé/alloué)", + insufficient_permissions_text: + "Un ou plusieurs flux de travaux sont rédigés. Les analyses sont masquées car elles seraient falsifiées.", project_analytics: "Analyse de projet", projected_budget_ratio: "Taux d’estimation du budget(estimé/total)", projected_budgets_distribution: "Répartition du budget total", diff --git a/frontend/src/languages/german.js b/frontend/src/languages/german.js index 5a191ab7d..4a5bd72c2 100644 --- a/frontend/src/languages/german.js +++ b/frontend/src/languages/german.js @@ -275,6 +275,8 @@ const de = { available_unspent_budget: "Verfügbares Budget", converted_amount: "Umgerechneter Betrag", disbursed_budget_ratio: "Ausgezahlte Budgetquote", + insufficient_permissions_text: + "Ein oder mehrere Workflowitems sind zensiert. Die Analysen werden ausgeblendet, weil sie verfälscht würden.", project_analytics: "Projekt Analyse", projected_budget_ratio: "Projezierte Budgetquote", projected_budgets_distribution: "Verteilung des geplanten Budgets", diff --git a/frontend/src/languages/portuguese.js b/frontend/src/languages/portuguese.js index 78a868327..f50b6dbdb 100644 --- a/frontend/src/languages/portuguese.js +++ b/frontend/src/languages/portuguese.js @@ -276,6 +276,8 @@ const pt = { available_unspent_budget: "Orçamento Disponível", converted_amount: "Valor convertido", disbursed_budget_ratio: "% do Orçamento Desembolsado", + insufficient_permissions_text: + "Um ou mais itens do fluxo de trabalho são editados. As análises são ocultas porque seriam falsificadas.", project_analytics: "Dashboard do Projeto", projected_budget_ratio: "% do Orçamento Projetado", projected_budgets_distribution: "Distribuição dos orçamentos projetados", diff --git a/frontend/src/pages/Analytics/ProjectAnalytics.js b/frontend/src/pages/Analytics/ProjectAnalytics.js index 51d882ad9..171fa9f1b 100644 --- a/frontend/src/pages/Analytics/ProjectAnalytics.js +++ b/frontend/src/pages/Analytics/ProjectAnalytics.js @@ -9,7 +9,6 @@ import Typography from "@material-ui/core/Typography"; import React from "react"; import { Doughnut } from "react-chartjs-2"; import { connect } from "react-redux"; - import { toAmountString, toJS } from "../../helper"; import strings from "../../localizeStrings"; import { getProjectKPIs, resetKPIs } from "./actions"; @@ -38,12 +37,22 @@ const styles = { display: "flex", flexDirection: "column", marginBottom: "24px" + }, + warning: { + backgroundColor: "rgb(255, 165, 0, 0.7)", + color: "black", + borderStyle: "solid", + borderRadius: "4px", + borderColor: "orange", + padding: "2px", + textAlign: "center" } }; class ProjectAnalytics extends React.Component { componentDidMount() { this.props.getProjectKPIs(this.props.projectId); + this.props.getExchangeRates(this.props.indicatedCurrency); } componentWillUnmount() { this.props.resetKPIs(); @@ -52,7 +61,9 @@ class ProjectAnalytics extends React.Component { convertToSelectedCurrency(amount, sourceCurrency) { const sourceExchangeRate = this.props.exchangeRates[sourceCurrency]; const targetExchangeRate = this.props.exchangeRates[this.props.indicatedCurrency]; - return sourceExchangeRate && targetExchangeRate ? targetExchangeRate / sourceExchangeRate * parseFloat(amount) : 0; + return sourceExchangeRate && targetExchangeRate + ? (targetExchangeRate / sourceExchangeRate) * parseFloat(amount) + : 0; } convertTotalBudget() { @@ -95,8 +106,9 @@ class ProjectAnalytics extends React.Component { const disbursedBudget = this.props.disbursedBudget.reduce((acc, next) => { return acc + this.convertToSelectedCurrency(next.budget, next.currency); }, 0); - return this.props.canShowAnalytics ? ( -
+ + return !this.props.isFetchingKPIs ? ( + <>
@@ -133,19 +145,21 @@ class ProjectAnalytics extends React.Component {
- + {this.props.canShowAnalytics ? ( + + ) : ( + {strings.analytics.insufficient_permissions_text} + )}
-
- ) : this.props.canShowAnalytics === undefined ? null : ( -
Insufficient permissions.
- ); + + ) : null; } } @@ -310,10 +324,10 @@ const mapStateToProps = state => { projectedBudget: state.getIn(["analytics", "project", "projectedBudget"]), assignedBudget: state.getIn(["analytics", "project", "assignedBudget"]), disbursedBudget: state.getIn(["analytics", "project", "disbursedBudget"]), - totalBudget: state.getIn(["analytics", "project", "totalBudget"]), indicatedCurrency: state.getIn(["analytics", "currency"]), exchangeRates: state.getIn(["analytics", "exchangeRates"]), - canShowAnalytics: state.getIn(["analytics", "canShowAnalytics"]) + canShowAnalytics: state.getIn(["analytics", "canShowAnalytics"]), + isFetchingKPIs: state.getIn(["analytics", "isFetchingKPIs"]) }; }; diff --git a/frontend/src/pages/Analytics/ProjectAnalyticsDialog.js b/frontend/src/pages/Analytics/ProjectAnalyticsDialog.js index 3ef05594f..8e27d8ae4 100644 --- a/frontend/src/pages/Analytics/ProjectAnalyticsDialog.js +++ b/frontend/src/pages/Analytics/ProjectAnalyticsDialog.js @@ -51,7 +51,8 @@ const ProjectAnalyticsDialog = ({ displayCurrency, closeAnalyticsDialog, storeDisplayCurrency, - getExchangeRates + getExchangeRates, + projectProjectedBudgets }) => (
Loading...
}> - +
diff --git a/frontend/src/pages/Analytics/SubProjectAnalytics.js b/frontend/src/pages/Analytics/SubProjectAnalytics.js index 49dc590ae..26dd21c71 100644 --- a/frontend/src/pages/Analytics/SubProjectAnalytics.js +++ b/frontend/src/pages/Analytics/SubProjectAnalytics.js @@ -45,12 +45,22 @@ const styles = { display: "flex", flexDirection: "column", marginBottom: "24px" + }, + warning: { + backgroundColor: "rgb(255, 165, 0, 0.7)", + color: "black", + borderStyle: "solid", + borderRadius: "4px", + borderColor: "orange", + padding: "2px", + textAlign: "center" } }; class SubprojectAnalytics extends React.Component { componentDidMount() { this.props.getSubProjectKPIs(this.props.projectId, this.props.subProjectId); + this.props.getExchangeRates(this.props.indicatedCurrency); } componentWillUnmount() { this.props.resetKPIs(); @@ -59,7 +69,9 @@ class SubprojectAnalytics extends React.Component { convertToSelectedCurrency(amount, sourceCurrency) { const sourceExchangeRate = this.props.exchangeRates[sourceCurrency]; const targetExchangeRate = this.props.exchangeRates[this.props.indicatedCurrency]; - return sourceExchangeRate && targetExchangeRate ? targetExchangeRate / sourceExchangeRate * parseFloat(amount) : 0; + return sourceExchangeRate && targetExchangeRate + ? (targetExchangeRate / sourceExchangeRate) * parseFloat(amount) + : 0; } convertProjectedBudget() { @@ -79,8 +91,8 @@ class SubprojectAnalytics extends React.Component { }, 0); const convertedAssignedBudget = this.convertToSelectedCurrency(assignedBudget, subProjectCurrency); const convertedDisbursedBudget = this.convertToSelectedCurrency(disbursedBudget, subProjectCurrency); - return this.props.canShowAnalytics ? ( -
+ return !this.props.isFetchingKPIs ? ( + <>
@@ -117,18 +129,20 @@ class SubprojectAnalytics extends React.Component {
- + {this.props.canShowAnalytics ? ( + + ) : ( + {strings.analytics.insufficient_permissions_text} + )}
-
- ) : this.props.canShowAnalytics === undefined ? null : ( -
Insufficient permissions.
- ); + + ) : null; } } @@ -245,11 +259,11 @@ const mapStateToProps = state => { return { subProjectCurrency: state.getIn(["analytics", "subproject", "currency"]), indicatedCurrency: state.getIn(["analytics", "currency"]), - projectedBudgets: state.getIn(["analytics", "subproject", "projectedBudgets"]), assignedBudget: state.getIn(["analytics", "subproject", "assignedBudget"]), disbursedBudget: state.getIn(["analytics", "subproject", "disbursedBudget"]), exchangeRates: state.getIn(["analytics", "exchangeRates"]), - canShowAnalytics: state.getIn(["analytics", "canShowAnalytics"]) + canShowAnalytics: state.getIn(["analytics", "canShowAnalytics"]), + isFetchingKPIs: state.getIn(["analytics", "isFetchingKPIs"]) }; }; diff --git a/frontend/src/pages/Analytics/SubProjectAnalyticsDialog.js b/frontend/src/pages/Analytics/SubProjectAnalyticsDialog.js index d833b0e95..35ede2486 100644 --- a/frontend/src/pages/Analytics/SubProjectAnalyticsDialog.js +++ b/frontend/src/pages/Analytics/SubProjectAnalyticsDialog.js @@ -52,7 +52,8 @@ const SubProjectAnalyticsDialog = ({ displayCurrency, closeAnalyticsDialog, storeDisplayCurrency, - getExchangeRates + getExchangeRates, + projectedBudgets }) => (
Loading...
}> - {" "} +
diff --git a/frontend/src/pages/Analytics/reducer.js b/frontend/src/pages/Analytics/reducer.js index 9d249e8b1..149398901 100644 --- a/frontend/src/pages/Analytics/reducer.js +++ b/frontend/src/pages/Analytics/reducer.js @@ -11,7 +11,8 @@ import { OPEN_ANALYTICS_DIALOG, RESET_KPIS, STORE_DISPLAY_CURRENCY, - GET_SUBPROJECT_KPIS_FAIL + GET_SUBPROJECT_KPIS_FAIL, + GET_SUBPROJECT_KPIS } from "./actions"; /** @@ -45,16 +46,22 @@ const defaultState = fromJS({ }, dialogOpen: false, exchangeRates: {}, - canShowAnalytics: false + canShowAnalytics: false, + isFetchingKPIs: false }); export default function detailviewReducer(state = defaultState, action) { switch (action.type) { case GET_PROJECT_KPIS: - return state.set("canShowAnalytics", undefined); + case GET_SUBPROJECT_KPIS: + return state.set("isFetchingKPIs", true); + case GET_PROJECT_KPIS_FAIL: + case GET_SUBPROJECT_KPIS_FAIL: + return state.merge({ canShowAnalytics: false, isFetchingKPIs: false }); case GET_PROJECT_KPIS_SUCCESS: return state.merge({ canShowAnalytics: true, + isFetchingKPIs: false, project: { totalBudget: fromJS(action.totalBudget), projectedBudget: fromJS(action.projectedBudget), @@ -62,12 +69,10 @@ export default function detailviewReducer(state = defaultState, action) { disbursedBudget: fromJS(action.disbursedBudget) } }); - case GET_PROJECT_KPIS_FAIL: - case GET_SUBPROJECT_KPIS_FAIL: - return state.set("canShowAnalytics", false); case GET_SUBPROJECT_KPIS_SUCCESS: return state.merge({ canShowAnalytics: true, + isFetchingKPIs: false, subproject: { currency: action.subProjectCurrency, projectedBudgets: fromJS(action.projectedBudgets), diff --git a/frontend/src/pages/SubProjects/ProjectDetails.js b/frontend/src/pages/SubProjects/ProjectDetails.js index 3066239d2..dc847f9c4 100644 --- a/frontend/src/pages/SubProjects/ProjectDetails.js +++ b/frontend/src/pages/SubProjects/ProjectDetails.js @@ -184,7 +184,7 @@ const ProjectDetails = props => { - + ); }; diff --git a/frontend/src/pages/Workflows/SubProjectDetails.js b/frontend/src/pages/Workflows/SubProjectDetails.js index ff408c2f1..f6d4c7126 100644 --- a/frontend/src/pages/Workflows/SubProjectDetails.js +++ b/frontend/src/pages/Workflows/SubProjectDetails.js @@ -100,7 +100,7 @@ const SubProjectDetails = ({ closeSubproject, canCloseSubproject, openAnalyticsDialog, - ...props + projectedBudgets }) => { const mappedStatus = statusMapping(status); const statusIcon = statusIconMapping[status]; @@ -139,7 +139,7 @@ const SubProjectDetails = ({ - {props.projectedBudgets.map(budget => ( + {projectedBudgets.map(budget => ( {budget.organization} {toAmountString(budget.value)} @@ -196,7 +196,7 @@ const SubProjectDetails = ({ - + ); }; From cbf3728b42dbf024b17d93bc54be9c11c46cc161 Mon Sep 17 00:00:00 2001 From: Stezido Date: Fri, 29 Nov 2019 14:39:24 +0100 Subject: [PATCH 2/4] Update CHANGELOG --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c24c6fb0c..a9cd21db0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - +### Changed + +- The analytics total budget is shown whether the user has insufficient permissions or not [#410](https://github.com/openkfw/TruBudget/pull/410) From 320e0a1212ddee81d8aebc85c1324811fb7a1273 Mon Sep 17 00:00:00 2001 From: Stezido Date: Tue, 3 Dec 2019 09:44:52 +0100 Subject: [PATCH 3/4] e2e-tests: Add tests for project/subproject analytics Add helper for formating amounts to strings Add total string to table of subproject analytics --- .../integration/project_analytics_spec.js | 222 ++++++++++++++++++ .../integration/subproject_analytics_spec.js | 209 +++++++++++++++++ e2e-test/cypress/support/commands.js | 19 ++ e2e-test/cypress/support/helper.js | 33 +++ e2e-test/package-lock.json | 8 +- e2e-test/package.json | 1 + .../src/pages/Analytics/ProjectAnalytics.js | 62 +++-- .../pages/Analytics/ProjectAnalyticsDialog.js | 32 ++- .../pages/Analytics/SubProjectAnalytics.js | 59 +++-- .../Analytics/SubProjectAnalyticsDialog.js | 34 ++- .../src/pages/SubProjects/ProjectDetails.js | 7 +- .../src/pages/Workflows/SubProjectDetails.js | 7 +- 12 files changed, 636 insertions(+), 57 deletions(-) create mode 100644 e2e-test/cypress/integration/project_analytics_spec.js create mode 100644 e2e-test/cypress/integration/subproject_analytics_spec.js create mode 100644 e2e-test/cypress/support/helper.js diff --git a/e2e-test/cypress/integration/project_analytics_spec.js b/e2e-test/cypress/integration/project_analytics_spec.js new file mode 100644 index 000000000..2acfce0eb --- /dev/null +++ b/e2e-test/cypress/integration/project_analytics_spec.js @@ -0,0 +1,222 @@ +import { toAmountString } from "../support/helper"; + +describe("Project Analytics", function() { + const executingUser = "mstein"; + let project = { + id: "", + displayName: "p-analytics", + description: "project analytics test", + projectedBudgets: [ + { + organization: "Test", + value: "200000", + currencyCode: "EUR" + } + ] + }; + + let subproject = { + id: "", + displayName: "sp-analytics", + description: "project analytics test", + currency: "EUR", + projectedBudgets: [ + { + organization: "Test", + value: "100000", + currencyCode: "EUR" + } + ] + }; + + let allocatedWorkflowitem = { + id: "", + displayName: "w-analytics", + description: "project analytics test", + amountType: "allocated", + currency: "EUR", + amount: "50000", + exchangeRate: "1" + }; + + let disbursedWorkflowitem = { + id: "", + displayName: "w2-analytics", + description: "project analytics test", + amountType: "disbursed", + currency: "EUR", + amount: "10000", + exchangeRate: "1" + }; + + before(() => { + cy.login(); + cy.createProject(project.displayName, project.description, project.projectedBudgets).then(({ id }) => { + project.id = id; + cy.createSubproject(project.id, subproject.displayName, subproject.currency, { + projectedBudgets: subproject.projectedBudgets + }).then(({ id }) => { + subproject.id = id; + cy.createWorkflowitem(project.id, subproject.id, allocatedWorkflowitem.displayName, { + ...allocatedWorkflowitem + }).then(({ id }) => { + allocatedWorkflowitem.id = id; + cy.createWorkflowitem(project.id, subproject.id, disbursedWorkflowitem.displayName, { + ...disbursedWorkflowitem + }).then(({ id }) => { + disbursedWorkflowitem.id = id; + // Create the workflowitem with status "closed" instead (currently bugged) + cy.closeWorkflowitem( + project.id, + subproject.id, + allocatedWorkflowitem.id, + allocatedWorkflowitem.exchangeRate + ); + cy.closeWorkflowitem( + project.id, + subproject.id, + disbursedWorkflowitem.id, + disbursedWorkflowitem.exchangeRate + ); + }); + }); + }); + }); + }); + + beforeEach(function() { + cy.login(); + cy.visit(`/projects/${project.id}`); + }); + + function calcProjectedBudgetRatio(totalBudget, projectedBudget) { + return (projectedBudget / totalBudget) * 100; + } + + it("The analytics-screen can be opened and closed", function() { + // Open dialog + cy.get("[data-test=details-analytics-button]") + .should("be.visible") + .click(); + cy.get("[data-test=close-analytics-button]").should("be.visible"); + }); + + it("The analytics-charts are calculated correctly", function() { + // Open dialog + cy.get("[data-test=details-analytics-button]") + .should("be.visible") + .click(); + // Total Budget + cy.get("[data-test=number-chart-total-budget]").should( + "have.text", + toAmountString(project.projectedBudgets[0].value, subproject.currency) + ); + // Projected Budget + cy.get("[data-test=number-chart-projected-budget]").should( + "have.text", + toAmountString(subproject.projectedBudgets[0].value, subproject.currency) + ); + cy.get("[data-test=ratio-chart-projected-budget]").should( + "have.text", + calcProjectedBudgetRatio(project.projectedBudgets[0].value, subproject.projectedBudgets[0].value).toFixed(2) + "%" + ); + // Assigned Budget + cy.get("[data-test=number-chart-assigned-budget]").should( + "have.text", + toAmountString(allocatedWorkflowitem.amount, allocatedWorkflowitem.currency) + ); + cy.get("[data-test=ratio-chart-assigned-budget]").should( + "have.text", + calcProjectedBudgetRatio(subproject.projectedBudgets[0].value, allocatedWorkflowitem.amount).toFixed(2) + "%" + ); + // Disbursed Budget + cy.get("[data-test=number-chart-disbursed-budget]").should( + "have.text", + toAmountString(disbursedWorkflowitem.amount, disbursedWorkflowitem.currency) + ); + cy.get("[data-test=ratio-chart-disbursed-budget]").should( + "have.text", + calcProjectedBudgetRatio(allocatedWorkflowitem.amount, disbursedWorkflowitem.amount).toFixed(2) + "%" + ); + }); + + it("Without view permission of every workflowitem of all subprojects the user can see the analytics-charts are not visible", function() { + // Setup permissions + cy.revokeWorkflowitemPermission( + project.id, + subproject.id, + allocatedWorkflowitem.id, + "workflowitem.view", + executingUser + ); + + // Open dialog + cy.get("[data-test=details-analytics-button]") + .should("be.visible") + .click(); + + cy.get("[data-test=number-chart-total-budget]").should("not.be.visible"); + cy.get("[data-test=projected-budget-table]").should("be.visible"); + cy.get("[data-test=redacted-warning]").should("be.visible"); + + // Reset permissions + cy.login("root", "root-secret"); + cy.grantWorkflowitemPermission( + project.id, + subproject.id, + allocatedWorkflowitem.id, + "workflowitem.view", + executingUser + ); + }); + + it("Without view permission of every subproject the analytics calculate all budgets using all subprojects seen", function() { + let notListedSubprojectId; + // Create subproject + cy.createSubproject(project.id, "test", "EUR", { + projectedBudgets: [ + { + organization: "Test", + value: "50000", + currencyCode: "EUR" + } + ] + }).then(({ id }) => { + notListedSubprojectId = id; + // Revoke subproject view permissions + cy.revokeSubprojectPermission(project.id, notListedSubprojectId, "subproject.viewSummary", executingUser); + cy.revokeSubprojectPermission(project.id, notListedSubprojectId, "subproject.viewDetails", executingUser); + + // Open dialog + cy.get("[data-test=details-analytics-button]") + .should("be.visible") + .click(); + + // Projected Budget must be unchanged + cy.get("[data-test=number-chart-projected-budget]").should( + "have.text", + toAmountString(subproject.projectedBudgets[0].value, subproject.currency) + ); + cy.get("[data-test=ratio-chart-projected-budget]").should( + "have.text", + calcProjectedBudgetRatio(project.projectedBudgets[0].value, subproject.projectedBudgets[0].value).toFixed(2) + + "%" + ); + }); + }); + + it("Changing the currency converts all calculated amounts into the new currency", function() { + // Open dialog + cy.get("[data-test=details-analytics-button]") + .should("be.visible") + .click(); + cy.get("[data-test=select-currencies]") + .should("be.visible") + .click(); + cy.get("[data-test=currency-menuitem-USD]") + .should("be.visible") + .click(); + cy.get("[data-test=number-chart-projected-budget]").contains("$"); + cy.get("[data-test=table-total-budget]").contains("$"); + }); +}); diff --git a/e2e-test/cypress/integration/subproject_analytics_spec.js b/e2e-test/cypress/integration/subproject_analytics_spec.js new file mode 100644 index 000000000..0ac3a2264 --- /dev/null +++ b/e2e-test/cypress/integration/subproject_analytics_spec.js @@ -0,0 +1,209 @@ +import { toAmountString } from "../support/helper"; + +describe("Subproject Analytics", function() { + const executingUser = "mstein"; + const description = "subproject analytics test"; + let project = { + id: "", + displayName: "p-analytics", + description, + projectedBudgets: [ + { + organization: "Test", + value: "200000", + currencyCode: "EUR" + } + ] + }; + + let subproject = { + id: "", + displayName: "sp-analytics", + description, + currency: "EUR", + projectedBudgets: [ + { + organization: "Test", + value: "100000", + currencyCode: "EUR" + } + ] + }; + + let allocatedWorkflowitem = { + id: "", + displayName: "w-analytics", + description, + amountType: "allocated", + currency: "EUR", + amount: "50000", + exchangeRate: "1" + }; + + let disbursedWorkflowitem = { + id: "", + displayName: "w2-analytics", + description, + amountType: "disbursed", + currency: "EUR", + amount: "10000", + exchangeRate: "1" + }; + + before(() => { + cy.login(); + cy.createProject(project.displayName, project.description, project.projectedBudgets).then(({ id }) => { + project.id = id; + cy.createSubproject(project.id, subproject.displayName, subproject.currency, { + projectedBudgets: subproject.projectedBudgets + }).then(({ id }) => { + subproject.id = id; + cy.createWorkflowitem(project.id, subproject.id, allocatedWorkflowitem.displayName, { + ...allocatedWorkflowitem + }).then(({ id }) => { + allocatedWorkflowitem.id = id; + cy.createWorkflowitem(project.id, subproject.id, disbursedWorkflowitem.displayName, { + ...disbursedWorkflowitem + }).then(({ id }) => { + disbursedWorkflowitem.id = id; + // Create the workflowitem with status "closed" instead (currently bugged) + cy.closeWorkflowitem( + project.id, + subproject.id, + allocatedWorkflowitem.id, + allocatedWorkflowitem.exchangeRate + ); + cy.closeWorkflowitem( + project.id, + subproject.id, + disbursedWorkflowitem.id, + disbursedWorkflowitem.exchangeRate + ); + }); + }); + }); + }); + }); + + beforeEach(function() { + cy.login(); + cy.visit(`/projects/${project.id}/${subproject.id}`); + }); + + function calcProjectedBudgetRatio(totalBudget, projectedBudget) { + return (projectedBudget / totalBudget) * 100; + } + + it("The analytics-screen can be opened and closed", function() { + // Open dialog + cy.get("[data-test=details-analytics-button]") + .should("be.visible") + .click(); + cy.get("[data-test=close-analytics-button]").should("be.visible"); + }); + + it("The analytics-charts are calculated correctly", function() { + // Open dialog + cy.get("[data-test=details-analytics-button]") + .should("be.visible") + .click(); + // Projected Budget + cy.get("[data-test=number-chart-projected-budget]").should( + "have.text", + toAmountString(subproject.projectedBudgets[0].value, subproject.currency) + ); + // Assigned Budget + cy.get("[data-test=number-chart-assigned-budget]").should( + "have.text", + toAmountString(allocatedWorkflowitem.amount, allocatedWorkflowitem.currency) + ); + cy.get("[data-test=ratio-chart-assigned-budget]").should( + "have.text", + calcProjectedBudgetRatio(subproject.projectedBudgets[0].value, allocatedWorkflowitem.amount).toFixed(2) + "%" + ); + // Disbursed Budget + cy.get("[data-test=number-chart-disbursed-budget]").should( + "have.text", + toAmountString(disbursedWorkflowitem.amount, disbursedWorkflowitem.currency) + ); + cy.get("[data-test=ratio-chart-disbursed-budget]").should( + "have.text", + calcProjectedBudgetRatio(allocatedWorkflowitem.amount, disbursedWorkflowitem.amount).toFixed(2) + "%" + ); + }); + + it("Without view permission of every workflowitem of all subprojects the user can see the analytics-charts are not visible", function() { + // Setup permissions + cy.revokeWorkflowitemPermission( + project.id, + subproject.id, + allocatedWorkflowitem.id, + "workflowitem.view", + executingUser + ); + + // Open dialog + cy.get("[data-test=details-analytics-button]") + .should("be.visible") + .click(); + + cy.get("[data-test=number-chart-total-budget]").should("not.be.visible"); + cy.get("[data-test=projected-budget-table]").should("be.visible"); + cy.get("[data-test=redacted-warning]").should("be.visible"); + + // Reset permissions + cy.login("root", "root-secret"); + cy.grantWorkflowitemPermission( + project.id, + subproject.id, + allocatedWorkflowitem.id, + "workflowitem.view", + executingUser + ); + }); + + it("Without view permission of every subproject the analytics calculate all budgets using all subprojects seen", function() { + let notListedSubprojectId; + // Create subproject + cy.createSubproject(project.id, "test", "EUR", { + projectedBudgets: [ + { + organization: "Test", + value: "50000", + currencyCode: "EUR" + } + ] + }).then(({ id }) => { + notListedSubprojectId = id; + // Revoke subproject view permissions + cy.revokeSubprojectPermission(project.id, notListedSubprojectId, "subproject.viewSummary", executingUser); + cy.revokeSubprojectPermission(project.id, notListedSubprojectId, "subproject.viewDetails", executingUser); + + // Open dialog + cy.get("[data-test=details-analytics-button]") + .should("be.visible") + .click(); + + // Projected Budget must be unchanged + cy.get("[data-test=number-chart-projected-budget]").should( + "have.text", + toAmountString(subproject.projectedBudgets[0].value, subproject.currency) + ); + }); + }); + + it("Changing the currency converts all calculated amounts into the new currency", function() { + // Open dialog + cy.get("[data-test=details-analytics-button]") + .should("be.visible") + .click(); + cy.get("[data-test=select-currencies]") + .should("be.visible") + .click(); + cy.get("[data-test=currency-menuitem-USD]") + .should("be.visible") + .click(); + cy.get("[data-test=number-chart-projected-budget]").contains("$"); + cy.get("[data-test=table-total-budget]").contains("$"); + }); +}); diff --git a/e2e-test/cypress/support/commands.js b/e2e-test/cypress/support/commands.js index dd04ed9d8..edd5beb6a 100644 --- a/e2e-test/cypress/support/commands.js +++ b/e2e-test/cypress/support/commands.js @@ -404,6 +404,25 @@ Cypress.Commands.add("closeProject", projectId => { .its("body") .then(body => Promise.resolve(body.data)); }); +Cypress.Commands.add("closeWorkflowitem", (projectId, subprojectId, workflowitemId) => { + cy.request({ + url: `${baseUrl}/api/workflowitem.close`, + method: "POST", + headers: { + Authorization: `Bearer ${token}` + }, + body: { + apiVersion: "1.0", + data: { + projectId, + subprojectId, + workflowitemId + } + } + }) + .its("body") + .then(body => Promise.resolve(body.data)); +}); Cypress.Commands.add("getUserList", () => { cy.request({ diff --git a/e2e-test/cypress/support/helper.js b/e2e-test/cypress/support/helper.js new file mode 100644 index 000000000..9bafe84ed --- /dev/null +++ b/e2e-test/cypress/support/helper.js @@ -0,0 +1,33 @@ +import _isString from "lodash/isString"; + +import accounting from "accounting"; + +const numberFormat = { + decimal: ".", + thousand: ",", + precision: 2 +}; + +const currencies = { + EUR: { symbol: "€", format: "%v %s" }, + USD: { symbol: "$", format: "%s %v" }, + BRL: { symbol: "R$", format: "%s %v" }, + XOF: { symbol: "CFA", format: "%s %v" }, + DKK: { symbol: "kr.", format: "%v %s" } +}; + +const getCurrencyFormat = currency => ({ + ...numberFormat, + ...currencies[currency] +}); + +export function toAmountString(amount, currency) { + if (_isString(amount) && amount.trim().length <= 0) { + return ""; + } + if (!currency) { + return accounting.formatNumber(amount, numberFormat.precision, numberFormat.thousand, numberFormat.decimal); + } + + return accounting.formatMoney(amount, getCurrencyFormat(currency)); +} diff --git a/e2e-test/package-lock.json b/e2e-test/package-lock.json index 7407112f8..9669453f8 100644 --- a/e2e-test/package-lock.json +++ b/e2e-test/package-lock.json @@ -1,6 +1,6 @@ { "name": "trubudget-e2e-test", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -67,6 +67,12 @@ "lodash.once": "^4.1.1" } }, + "accounting": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/accounting/-/accounting-0.4.1.tgz", + "integrity": "sha1-h91BA+/39EYPHhhvXGd+1s9WaIM=", + "dev": true + }, "acorn": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.1.1.tgz", diff --git a/e2e-test/package.json b/e2e-test/package.json index 5aa0ecde0..4d94faf52 100644 --- a/e2e-test/package.json +++ b/e2e-test/package.json @@ -18,6 +18,7 @@ ], "main": "index.js", "devDependencies": { + "accounting": "^0.4.1", "cypress": "^3.4.1", "cypress-file-upload": "^3.1.1", "eslint": "^5.16.0", diff --git a/frontend/src/pages/Analytics/ProjectAnalytics.js b/frontend/src/pages/Analytics/ProjectAnalytics.js index 171fa9f1b..627cc333f 100644 --- a/frontend/src/pages/Analytics/ProjectAnalytics.js +++ b/frontend/src/pages/Analytics/ProjectAnalytics.js @@ -112,7 +112,7 @@ class ProjectAnalytics extends React.Component {
- +
{strings.common.organization} @@ -139,7 +139,7 @@ class ProjectAnalytics extends React.Component { {strings.analytics.total} - {toAmountString(totalBudget, indicatedCurrency)} + {toAmountString(totalBudget, indicatedCurrency)}
@@ -155,7 +155,9 @@ class ProjectAnalytics extends React.Component { assignedBudget={assignedBudget} /> ) : ( - {strings.analytics.insufficient_permissions_text} + + {strings.analytics.insufficient_permissions_text} + )}
@@ -196,25 +198,25 @@ const dashboardStyles = { const onlyPositive = number => (number < 0 ? 0 : number); -const NumberChart = ({ title, budget, currency }) => ( +const NumberChart = ({ title, budget, currency, dataTest }) => ( {title} - + {toAmountString(budget, currency)} ); -const RatioChart = ({ title, budget }) => ( +const RatioChart = ({ title, budget, dataTest }) => ( {title} - + {budget ? `${(budget * 100).toFixed(2)}%` : "-"} @@ -240,10 +242,30 @@ const Dashboard = ({ }) => { return (
- - - - + + + + } /> - - - + + + import("./ProjectAnalytics")); +import ProjectAnalytics from "./ProjectAnalytics"; const styles = { container: { @@ -28,13 +27,18 @@ const styles = { dropdown: { marginLeft: "auto", marginTop: "0" + }, + loadingCharts: { + marginTop: "16px", + display: "flex", + justifyContent: "center" } }; function getMenuItems(currencies) { return currencies.map((currency, index) => { return ( - + {currency.primaryText} ); @@ -63,7 +67,12 @@ const ProjectAnalyticsDialog = ({ > - + @@ -81,6 +90,7 @@ const ProjectAnalyticsDialog = ({ name: "currencies", id: "currencies" }} + data-test="select-currencies" IconComponent={props => } style={{ color: "white" }} > @@ -91,13 +101,11 @@ const ProjectAnalyticsDialog = ({
- Loading...
}> - - +
); diff --git a/frontend/src/pages/Analytics/SubProjectAnalytics.js b/frontend/src/pages/Analytics/SubProjectAnalytics.js index 26dd21c71..2dee85ca6 100644 --- a/frontend/src/pages/Analytics/SubProjectAnalytics.js +++ b/frontend/src/pages/Analytics/SubProjectAnalytics.js @@ -96,7 +96,7 @@ class SubprojectAnalytics extends React.Component {
- +
{strings.common.organization} @@ -122,8 +122,10 @@ class SubprojectAnalytics extends React.Component { - - {toAmountString(projectedBudget, indicatedCurrency)} + {strings.analytics.total} + + {toAmountString(projectedBudget, indicatedCurrency)} +
@@ -138,7 +140,9 @@ class SubprojectAnalytics extends React.Component { assignedBudget={convertedAssignedBudget} /> ) : ( - {strings.analytics.insufficient_permissions_text} + + {strings.analytics.insufficient_permissions_text} + )}
@@ -177,35 +181,37 @@ const dashboardStyles = { } }; -const NumberChart = ({ title, budget, currency }) => ( +const NumberChart = ({ title, budget, currency, dataTest }) => ( {title} - + {toAmountString(budget, currency)} ); -const RatioChart = ({ title, budget }) => ( +const RatioChart = ({ title, budget, dataTest }) => ( {title} - + {budget ? `${(budget * 100).toFixed(2)}%` : "-"} ); -const Chart = ({ title, chart }) => ( +const Chart = ({ title, chart, dataTest }) => ( - {title} + + {title} +
{chart}
@@ -246,11 +252,34 @@ const Dashboard = ({ indicatedCurrency, projectedBudgets, projectedBudget, assig /> } /> - - - - - + + + + +
); }; diff --git a/frontend/src/pages/Analytics/SubProjectAnalyticsDialog.js b/frontend/src/pages/Analytics/SubProjectAnalyticsDialog.js index 35ede2486..1d2f90c34 100644 --- a/frontend/src/pages/Analytics/SubProjectAnalyticsDialog.js +++ b/frontend/src/pages/Analytics/SubProjectAnalyticsDialog.js @@ -9,14 +9,13 @@ import Toolbar from "@material-ui/core/Toolbar"; import Typography from "@material-ui/core/Typography"; import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown"; import CloseIcon from "@material-ui/icons/Close"; -import React, { lazy, Suspense } from "react"; +import React from "react"; import { connect } from "react-redux"; import { getCurrencies } from "../../helper"; import { closeAnalyticsDialog, getExchangeRates, storeDisplayCurrency } from "./actions"; import strings from "../../localizeStrings"; - -const SubProjectAnalytics = lazy(() => import("./SubProjectAnalytics")); +import SubProjectAnalytics from "./SubProjectAnalytics"; const styles = { container: { @@ -28,13 +27,18 @@ const styles = { dropdown: { marginLeft: "auto", marginTop: "0" + }, + loadingCharts: { + marginTop: "16px", + display: "flex", + justifyContent: "center" } }; function getMenuItems(currencies) { return currencies.map((currency, index) => { return ( - + {currency.primaryText} ); @@ -64,7 +68,12 @@ const SubProjectAnalyticsDialog = ({ > - + @@ -82,6 +91,7 @@ const SubProjectAnalyticsDialog = ({ name: "currencies", id: "currencies" }} + data-test="select-currencies" IconComponent={props => } style={{ color: "white" }} > @@ -92,14 +102,12 @@ const SubProjectAnalyticsDialog = ({
- Loading...
}> - - +
); diff --git a/frontend/src/pages/SubProjects/ProjectDetails.js b/frontend/src/pages/SubProjects/ProjectDetails.js index dc847f9c4..01138d821 100644 --- a/frontend/src/pages/SubProjects/ProjectDetails.js +++ b/frontend/src/pages/SubProjects/ProjectDetails.js @@ -148,7 +148,12 @@ const ProjectDetails = props => {
- diff --git a/frontend/src/pages/Workflows/SubProjectDetails.js b/frontend/src/pages/Workflows/SubProjectDetails.js index f6d4c7126..2787216c4 100644 --- a/frontend/src/pages/Workflows/SubProjectDetails.js +++ b/frontend/src/pages/Workflows/SubProjectDetails.js @@ -149,7 +149,12 @@ const SubProjectDetails = ({
- From 5a6da12252372f309e7a5bfd02af746e9a30f5c5 Mon Sep 17 00:00:00 2001 From: Stezido Date: Tue, 3 Dec 2019 10:42:24 +0100 Subject: [PATCH 4/4] ui: Use projectId from URL instead of state --- .../src/pages/Workflows/SubProjectDetails.js | 18 +++++++------- .../src/pages/Workflows/WorkflowContainer.js | 24 ++++++++++--------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/frontend/src/pages/Workflows/SubProjectDetails.js b/frontend/src/pages/Workflows/SubProjectDetails.js index 2787216c4..c36b30801 100644 --- a/frontend/src/pages/Workflows/SubProjectDetails.js +++ b/frontend/src/pages/Workflows/SubProjectDetails.js @@ -85,18 +85,14 @@ const SubProjectDetails = ({ displayName, description, currency, - id, + subprojectId, status, - roles, assignee, workflowItems, created, - budgetEditEnabled, - canViewPermissions, canAssignSubproject, - parentProject, + projectId, users, - showSubProjectAssignee, closeSubproject, canCloseSubproject, openAnalyticsDialog, @@ -189,8 +185,8 @@ const SubProjectDetails = ({ - +
); }; diff --git a/frontend/src/pages/Workflows/WorkflowContainer.js b/frontend/src/pages/Workflows/WorkflowContainer.js index 7242fa3fa..c8258725b 100644 --- a/frontend/src/pages/Workflows/WorkflowContainer.js +++ b/frontend/src/pages/Workflows/WorkflowContainer.js @@ -48,12 +48,12 @@ class WorkflowContainer extends Component { super(props); const path = props.location.pathname.split("/"); this.projectId = path[2]; - this.subProjectId = path[3]; + this.subprojectId = path[3]; } componentDidMount() { - this.props.setSelectedView(this.subProjectId, "subProject"); - this.props.fetchAllSubprojectDetails(this.projectId, this.subProjectId, true); + this.props.setSelectedView(this.subprojectId, "subProject"); + this.props.fetchAllSubprojectDetails(this.projectId, this.subprojectId, true); this.props.fetchUser(); } @@ -66,16 +66,16 @@ class WorkflowContainer extends Component { closeSubproject = () => { const openWorkflowItems = this.props.workflowItems.find(wItem => wItem.data.status === "open"); if (!openWorkflowItems) { - this.props.closeSubproject(this.projectId, this.subProjectId); + this.props.closeSubproject(this.projectId, this.subprojectId); } }; - closeWorkflowItem = wId => this.props.closeWorkflowItem(this.projectId, this.subProjectId, wId); + closeWorkflowItem = wId => this.props.closeWorkflowItem(this.projectId, this.subprojectId, wId); - closeSubproject = () => this.props.closeSubproject(this.projectId, this.subProjectId, true); + closeSubproject = () => this.props.closeSubproject(this.projectId, this.subprojectId, true); update = () => { - this.props.updateSubProject(this.projectId, this.subProjectId); + this.props.updateSubProject(this.projectId, this.subprojectId); }; addLiveUpdates = () => { @@ -92,6 +92,8 @@ class WorkflowContainer extends Component {
{this.props.permissionDialogShown ? ( - + ) : null} @@ -114,8 +116,8 @@ class WorkflowContainer extends Component { hideAdditionalData={this.props.hideWorkflowitemAdditionalData} {...this.props} /> - - + +
);