diff --git a/cypress/integration/landing-page.spec.js b/cypress/integration/landing-page.spec.js index 45781f236..1b895f0ed 100644 --- a/cypress/integration/landing-page.spec.js +++ b/cypress/integration/landing-page.spec.js @@ -12,11 +12,8 @@ context("Landing page", () => { .should("be.visible"); cy.contains("Instance Configuration"); - cy.contains("Organisation Units"); - cy.contains("Data Elements"); - cy.contains("Indicators"); - cy.contains("Validation Rules"); - cy.contains("Synchronization Rules"); + cy.contains("Metadata"); + cy.contains("Metadata Synchronization Rules"); cy.contains("Synchronization History"); }); @@ -25,24 +22,9 @@ context("Landing page", () => { cy.get(dataTest("page-header-title")).contains("Instance Configuration"); }); - it("enters the Organisation Units Synchronization page", function() { - cy.get(dataTest("page-sync/organisationUnits")).click(); - cy.get(dataTest("page-header-title")).contains("Organisation Units Synchronization"); - }); - - it("enter the Data Elements Synchronization page", function() { - cy.get(dataTest("page-sync/dataElements")).click(); - cy.get(dataTest("page-header-title")).contains("Data Elements Synchronization"); - }); - - it("enter the Indicators Synchronization page", function() { - cy.get(dataTest("page-sync/indicators")).click(); - cy.get(dataTest("page-header-title")).contains("Indicators Synchronization"); - }); - - it("enter the Validation Rules Synchronization page", function() { - cy.get(dataTest("page-sync/validationRules")).click(); - cy.get(dataTest("page-header-title")).contains("Validation Rules Synchronization"); + it("enters the Metadata Synchronization page", function() { + cy.get(dataTest("page-sync/metadata")).click(); + cy.get(dataTest("page-header-title")).contains("Metadata Synchronization"); }); it("enter the Synchronization Rules page", function() { diff --git a/i18n/en.pot b/i18n/en.pot index 75a6bbbbc..6f5795d24 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2019-09-17T10:45:09.309Z\n" -"PO-Revision-Date: 2019-09-17T10:45:09.309Z\n" +"POT-Creation-Date: 2019-10-16T09:02:16.076Z\n" +"PO-Revision-Date: 2019-10-16T09:02:16.076Z\n" msgid "Metadata Synchronization" msgstr "" @@ -160,19 +160,7 @@ msgstr[1] "" msgid "Instance Configuration" msgstr "" -msgid "Organisation Units" -msgstr "" - -msgid "Data Elements" -msgstr "" - -msgid "Indicators" -msgstr "" - -msgid "Validation Rules" -msgstr "" - -msgid "Synchronization Rules" +msgid "Metadata Synchronization Rules" msgstr "" msgid "Deleted objects" @@ -310,6 +298,21 @@ msgstr "" msgid "Target instances [{{total}}]" msgstr "" +msgid "Advanced options" +msgstr "" + +msgid "Include user information and sharing settings" +msgstr "" + +msgid "No" +msgstr "" + +msgid "Disable atomic verification" +msgstr "" + +msgid "Replace objects in destination instance" +msgstr "" + msgid "Enabled" msgstr "" @@ -378,12 +381,18 @@ msgstr "" msgid "Toggle scheduling" msgstr "" +msgid "Sharing settings" +msgstr "" + msgid "Last executed date" msgstr "" msgid "Destination Instance" msgstr "" +msgid "Synchronization Rules" +msgstr "" + msgid "Delete Rules?" msgstr "" @@ -392,6 +401,48 @@ msgid_plural "Are you sure you want to delete {{count}} rules?" msgstr[0] "" msgstr[1] "" +msgid "External access" +msgstr "" + +msgid "Anyone can view without login" +msgstr "" + +msgid "No access" +msgstr "" + +msgid "Public access" +msgstr "" + +msgid "METADATA" +msgstr "" + +msgid "Can edit and view" +msgstr "" + +msgid "Can view only" +msgstr "" + +msgid "DATA" +msgstr "" + +msgid "Can capture and view" +msgstr "" + +msgid "Created by" +msgstr "" + +msgid "Who has access" +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Add users and user groups" +msgstr "" + +msgid "Enter names" +msgstr "" + msgid "Synchronize Metadata" msgstr "" @@ -434,9 +485,6 @@ msgstr "" msgid "JSON Response" msgstr "" -msgid "Data Elements Synchronization" -msgstr "" - msgid "Code" msgstr "" @@ -452,15 +500,6 @@ msgstr "" msgid "Unknown error with the request" msgstr "" -msgid "Indicators Synchronization" -msgstr "" - -msgid "Organisation Units Synchronization" -msgstr "" - -msgid "Validation Rules Synchronization" -msgstr "" - msgid "Retrieving information from remote instances" msgstr "" diff --git a/package.json b/package.json index ba552ede7..c053b5030 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "metadata-synchronization", "description": "Advanced metadata synchronization utility", - "version": "0.4.0", + "version": "0.5.0", "license": "GPL-3.0", "author": "EyeSeeTea team", "homepage": ".", @@ -26,6 +26,7 @@ "d2": "^31.1.1", "d2-manifest": "^1.0.0", "d2-ui-components": "^0.0.25", + "downshift": "^3.3.4", "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.14.0", "enzyme-to-json": "^3.4.0", diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx index 933a67bea..4ff0f7f9e 100644 --- a/src/components/app/App.jsx +++ b/src/components/app/App.jsx @@ -13,6 +13,7 @@ import Share from "../share/Share"; import Instance from "../../models/instance"; import { muiTheme } from "./themes/dhis2.theme"; import muiThemeLegacy from "./themes/dhis2-legacy.theme"; +import { initializeAppRoles } from "../../utils/permissions"; import "./App.css"; const generateClassName = createGenerateClassName({ @@ -26,7 +27,7 @@ class App extends Component { appConfig: PropTypes.object.isRequired, }; - componentDidMount() { + async componentDidMount() { const { d2, appConfig } = this.props; const appKey = _(this.props.appConfig).get("appKey"); @@ -41,6 +42,8 @@ class App extends Component { if (appConfig && appConfig.encryptionKey) { Instance.setEncryptionKey(appConfig.encryptionKey); } + + await initializeAppRoles(d2.Api.getApi().baseUrl); } render() { diff --git a/src/components/app/Root.jsx b/src/components/app/Root.jsx index 0bf1050bb..5e981d370 100644 --- a/src/components/app/Root.jsx +++ b/src/components/app/Root.jsx @@ -5,10 +5,7 @@ import { Route, Switch } from "react-router-dom"; import LandingPage from "../landing-page/LandingPage"; import InstanceConfigurator from "../instance-list-page/InstancesPage"; import InstanceFormBuilder from "../instance-creation-page/InstanceCreationPage"; -import OrganisationUnitPage from "../synchronization-page/OrganisationUnitPage"; -import DataElementPage from "../synchronization-page/DataElementPage"; -import IndicatorPage from "../synchronization-page/IndicatorPage"; -import ValidationRulePage from "../synchronization-page/ValidationRulePage"; +import MetadataPage from "../synchronization-page/MetadataPage"; import DeletedObjectsPage from "../synchronization-page/DeletedObjectsPage"; import HistoryPage from "../history-list-page/HistoryPage"; import SyncRulesWizard from "../rules-creation-page/SyncRulesWizard"; @@ -20,58 +17,44 @@ class Root extends React.Component { }; render() { - const { d2 } = this.props; - return ( } + render={props => } /> } - /> - - } - /> - - } + render={props => } /> } + path="/sync/metadata" + render={props => } /> } + path="/sync/deleted" + render={props => } /> } + path="/history/:id?" + render={props => } /> - } /> - } + render={props => } /> } + render={props => } /> - } /> + } /> ); } diff --git a/src/components/instance-list-page/InstancesPage.jsx b/src/components/instance-list-page/InstancesPage.jsx index fe39b77ea..42673d64b 100644 --- a/src/components/instance-list-page/InstancesPage.jsx +++ b/src/components/instance-list-page/InstancesPage.jsx @@ -6,8 +6,8 @@ import { ConfirmationDialog, ObjectsTable, withLoading, withSnackbar } from "d2- import { withRouter } from "react-router-dom"; import PageHeader from "../page-header/PageHeader"; - import Instance from "../../models/instance"; +import { isAppConfigurator } from "../../utils/permissions"; class InstancesPage extends React.Component { static propTypes = { @@ -26,8 +26,16 @@ class InstancesPage extends React.Component { state = { tableKey: Math.random(), toDelete: null, + appConfigurator: false, }; + async componentDidMount() { + const { d2 } = this.props; + const appConfigurator = await isAppConfigurator(d2); + + this.setState({ appConfigurator }); + } + columns = [ { name: "name", text: i18n.t("Server name"), sortable: true }, { name: "url", text: i18n.t("URL endpoint"), sortable: true }, @@ -76,12 +84,14 @@ class InstancesPage extends React.Component { name: "edit", text: i18n.t("Edit"), multiple: false, + isActive: () => this.state.appConfigurator, onClick: this.editInstance, }, { name: "delete", text: i18n.t("Delete"), multiple: true, + isActive: () => this.state.appConfigurator, onClick: this.deleteInstance, }, { @@ -126,7 +136,7 @@ class InstancesPage extends React.Component { }; render() { - const { tableKey, toDelete } = this.state; + const { tableKey, toDelete, appConfigurator } = this.state; const { d2 } = this.props; return ( @@ -153,7 +163,7 @@ class InstancesPage extends React.Component { detailsFields={this.detailsFields} pageSize={10} actions={this.actions} - onButtonClick={this.createInstance} + onButtonClick={appConfigurator ? this.createInstance : null} list={Instance.list} /> diff --git a/src/components/landing-page/LandingPage.jsx b/src/components/landing-page/LandingPage.jsx index 050bfe27e..3fad6df6b 100644 --- a/src/components/landing-page/LandingPage.jsx +++ b/src/components/landing-page/LandingPage.jsx @@ -44,8 +44,7 @@ const styles = () => ({ }); const GRID_ROW_1 = 12; -const GRID_ROW_3 = 3; -const GRID_ROW_4 = 4; +const GRID_ROW_2 = 6; class LandingPage extends React.Component { static propTypes = { @@ -56,18 +55,15 @@ class LandingPage extends React.Component { const { classes } = this.props; const items = [ ["instance-configurator", i18n.t("Instance Configuration"), "edit", GRID_ROW_1], - ["sync/organisationUnits", i18n.t("Organisation Units"), "sync", GRID_ROW_3], - ["sync/dataElements", i18n.t("Data Elements"), "sync", GRID_ROW_3], - ["sync/indicators", i18n.t("Indicators"), "sync", GRID_ROW_3], - ["sync/validationRules", i18n.t("Validation Rules"), "sync", GRID_ROW_3], + ["sync/metadata", i18n.t("Metadata Synchronization"), "sync", GRID_ROW_2], [ "synchronization-rules", - i18n.t("Synchronization Rules"), + i18n.t("Metadata Synchronization Rules"), "playlist_add_check", - GRID_ROW_4, + GRID_ROW_2, ], - ["sync/deleted", i18n.t("Deleted objects"), "delete", GRID_ROW_4], - ["history", i18n.t("Synchronization History"), "history", GRID_ROW_4], + ["sync/deleted", i18n.t("Deleted objects"), "delete", GRID_ROW_2], + ["history", i18n.t("Synchronization History"), "history", GRID_ROW_2], ]; const menuItems = items.map(([key, title, icon, xs]) => ( diff --git a/src/components/rules-creation-page/steps/InstanceSelectionStep.jsx b/src/components/rules-creation-page/steps/InstanceSelectionStep.jsx index b87b9c066..2fbfd81f0 100644 --- a/src/components/rules-creation-page/steps/InstanceSelectionStep.jsx +++ b/src/components/rules-creation-page/steps/InstanceSelectionStep.jsx @@ -1,7 +1,9 @@ import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; import { MultiSelector } from "d2-ui-components"; + import Instance from "../../../models/instance"; +import SyncParamsSelector from "../../sync-params-selector/SyncParamsSelector"; export const getInstances = async d2 => { const { objects } = await Instance.list(d2, {}, { paging: false }); @@ -21,18 +23,25 @@ const InstanceSelectionStep = props => { onChange(syncRule.updateTargetInstances(instances)); }; + const changeSyncParams = syncParams => { + onChange(syncRule.updateSyncParams(syncParams)); + }; + useEffect(() => { getInstances(d2).then(setInstanceOptions); }, [d2]); return ( - + + + + ); }; diff --git a/src/components/rules-creation-page/steps/MetadataSelectionStep.jsx b/src/components/rules-creation-page/steps/MetadataSelectionStep.jsx index ca6691274..9ee30121c 100644 --- a/src/components/rules-creation-page/steps/MetadataSelectionStep.jsx +++ b/src/components/rules-creation-page/steps/MetadataSelectionStep.jsx @@ -5,19 +5,7 @@ import _ from "lodash"; import { withSnackbar } from "d2-ui-components"; import MetadataTable from "../../metadata-table/MetadataTable"; -import { - DataElementGroupModel, - DataElementGroupSetModel, - DataElementModel, - IndicatorGroupModel, - IndicatorGroupSetModel, - IndicatorModel, - OrganisationUnitGroupModel, - OrganisationUnitGroupSetModel, - OrganisationUnitModel, - ValidationRuleGroupModel, - ValidationRuleModel, -} from "../../../models/d2Model"; +import { metadataModels } from "../../../models/d2ModelFactory"; class MetadataSelectionStep extends React.Component { static propTypes = { @@ -31,20 +19,6 @@ class MetadataSelectionStep extends React.Component { metadataIds: [], }; - models = [ - DataElementModel, - DataElementGroupModel, - DataElementGroupSetModel, - IndicatorModel, - IndicatorGroupModel, - IndicatorGroupSetModel, - OrganisationUnitModel, - OrganisationUnitGroupModel, - OrganisationUnitGroupSetModel, - ValidationRuleModel, - ValidationRuleGroupModel, - ]; - componentDidMount() { const { metadataIds } = this.props.syncRule; this.setState({ metadataIds }); @@ -86,7 +60,7 @@ class MetadataSelectionStep extends React.Component { d2={d2} notifyNewSelection={this.changeSelection} initialSelection={syncRule.metadataIds} - models={this.models} + models={metadataModels} {...rest} /> ); diff --git a/src/components/rules-creation-page/steps/SaveStep.jsx b/src/components/rules-creation-page/steps/SaveStep.jsx index 72db8b895..6f29f40fd 100644 --- a/src/components/rules-creation-page/steps/SaveStep.jsx +++ b/src/components/rules-creation-page/steps/SaveStep.jsx @@ -104,6 +104,39 @@ const SaveStep = ({ d2, syncRule, classes, onCancel, snackbar }) => { + +
    + +
+
    + +
+
    + +
+
+ ( - onChange({ target: { value: e.target.checked } })} - checked={value} - color="primary" - /> - } - label={label} - /> -); +import { Toggle } from "../../toggle/Toggle"; +import isValidCronExpression from "../../../utils/validCronExpression"; const SchedulerStep = ({ syncRule, onChange }) => { const cronExpressions = [ diff --git a/src/components/rules-list-page/SyncRulesPage.jsx b/src/components/rules-list-page/SyncRulesPage.jsx index e336ef6ef..21635193d 100644 --- a/src/components/rules-list-page/SyncRulesPage.jsx +++ b/src/components/rules-list-page/SyncRulesPage.jsx @@ -19,7 +19,14 @@ import { startSynchronization } from "../../logic/synchronization"; import SyncReport from "../../models/syncReport"; import SyncSummary from "../sync-summary/SyncSummary"; import Dropdown from "../dropdown/Dropdown"; +import SharingDialog from "../sharing-dialog/SharingDialog"; import { getValidationMessages } from "../../utils/validations"; +import { + getUserInfo, + isGlobalAdmin, + isAppConfigurator, + isAppExecutor, +} from "../../utils/permissions"; class SyncRulesPage extends React.Component { static propTypes = { @@ -47,6 +54,10 @@ class SyncRulesPage extends React.Component { lastExecutedFilter: null, syncReport: SyncReport.create(), syncSummaryOpen: false, + userInfo: {}, + globalAdmin: false, + appConfigurator: false, + appExecutor: false, }; getTargetInstances = ruleData => { @@ -117,7 +128,12 @@ class SyncRulesPage extends React.Component { async componentDidMount() { const { d2 } = this.props; const { objects: allInstances } = await Instance.list(d2, null, null); - this.setState({ allInstances }); + const userInfo = await getUserInfo(d2); + const globalAdmin = await isGlobalAdmin(d2); + const appConfigurator = await isAppConfigurator(d2); + const appExecutor = await isAppExecutor(d2); + + this.setState({ allInstances, userInfo, globalAdmin, appConfigurator, appExecutor }); } backHome = () => { @@ -201,6 +217,46 @@ class SyncRulesPage extends React.Component { } }; + openSharingSettings = object => { + this.setState({ + sharingSettingsObject: { + object, + meta: { allowPublicAccess: true, allowExternalAccess: false }, + }, + }); + }; + + closeSharingSettings = () => { + this.setState({ sharingSettingsObject: null }); + }; + + verifyUserHasAccess = (d2, data, condition = false) => { + const { userInfo, globalAdmin } = this.state; + if (globalAdmin) return true; + + const rules = Array.isArray(data) ? data : [data]; + for (const ruleData of rules) { + const rule = SyncRule.build(ruleData); + if (!rule.isVisibleToUser(userInfo, "WRITE")) return false; + } + + return condition; + }; + + verifyUserCanEdit = (d2, data) => { + const { appConfigurator } = this.state; + return this.verifyUserHasAccess(d2, data, appConfigurator); + }; + + verifyUserCanEditSharingSettings = (d2, data) => { + const { appConfigurator, appExecutor } = this.state; + return this.verifyUserHasAccess(d2, data, appConfigurator || appExecutor); + }; + + verifyUserCanExecute = (d2, data) => { + return this.state.appExecutor; + }; + actions = [ { name: "details", @@ -212,18 +268,21 @@ class SyncRulesPage extends React.Component { name: "edit", text: i18n.t("Edit"), multiple: false, + isActive: this.verifyUserCanEdit, onClick: this.editRule, }, { name: "delete", text: i18n.t("Delete"), multiple: true, + isActive: this.verifyUserCanEdit, onClick: this.deleteSyncRules, }, { name: "execute", text: i18n.t("Execute"), multiple: false, + isActive: this.verifyUserCanExecute, onClick: this.executeRule, icon: "settings_input_antenna", }, @@ -231,9 +290,18 @@ class SyncRulesPage extends React.Component { name: "toggleEnable", text: i18n.t("Toggle scheduling"), multiple: false, + isActive: this.verifyUserCanEdit, onClick: this.toggleEnable, icon: "timer", }, + { + name: "sharingSettings", + text: i18n.t("Sharing settings"), + multiple: false, + isActive: this.verifyUserCanEditSharingSettings, + onClick: this.openSharingSettings, + icon: "share", + }, ]; closeSummary = () => this.setState({ syncSummaryOpen: false }); @@ -244,6 +312,27 @@ class SyncRulesPage extends React.Component { changeLastExecutedFilter = moment => this.setState({ lastExecutedFilter: moment }); + onSearchRequest = key => + this.props.d2.Api.getApi() + .get("sharing/search", { key }) + .then(searchResult => searchResult); + + onSharingChanged = async (updatedAttributes, onSuccess) => { + const sharingSettingsObject = { + meta: this.state.sharingSettingsObject.meta, + object: { + ...this.state.sharingSettingsObject.object, + ...updatedAttributes, + }, + }; + + const syncRule = SyncRule.build(sharingSettingsObject.object); + await syncRule.save(this.props.d2); + + this.setState({ sharingSettingsObject }); + if (onSuccess) onSuccess(); + }; + renderCustomFilters = () => { const { allInstances, @@ -292,6 +381,8 @@ class SyncRulesPage extends React.Component { targetInstanceFilter, enabledFilter, lastExecutedFilter, + sharingSettingsObject, + appConfigurator, } = this.state; const { d2 } = this.props; @@ -307,7 +398,7 @@ class SyncRulesPage extends React.Component { pageSize={10} actions={this.actions} list={SyncRule.list} - onButtonClick={this.createRule} + onButtonClick={appConfigurator ? this.createRule : null} customFiltersComponent={this.renderCustomFilters} customFilters={{ targetInstanceFilter, enabledFilter, lastExecutedFilter }} /> @@ -333,6 +424,15 @@ class SyncRulesPage extends React.Component { isOpen={syncSummaryOpen} handleClose={this.closeSummary} /> + + ); } diff --git a/src/components/sharing-dialog/Access.jsx b/src/components/sharing-dialog/Access.jsx new file mode 100644 index 000000000..2058e28db --- /dev/null +++ b/src/components/sharing-dialog/Access.jsx @@ -0,0 +1,165 @@ +import React from "react"; +import PropTypes from "prop-types"; +import IconButton from "@material-ui/core/IconButton"; +import ClearIcon from "@material-ui/icons/Clear"; +import PersonIcon from "@material-ui/icons/Person"; +import GroupIcon from "@material-ui/icons/Group"; +import PublicIcon from "@material-ui/icons/Public"; +import BusinessIcon from "@material-ui/icons/Business"; +import { withStyles } from "@material-ui/core/styles"; +import i18n from "@dhis2/d2-i18n"; + +import PermissionPicker from "./PermissionPicker"; +import { accessStringToObject, accessObjectToString } from "./utils"; + +const icons = { + user: PersonIcon, + userGroup: GroupIcon, + external: PublicIcon, + public: BusinessIcon, +}; + +const SvgIcon = ({ userType }) => { + const Icon = icons[userType] || PersonIcon; + + return ; +}; + +SvgIcon.propTypes = { + userType: PropTypes.string.isRequired, +}; + +const styles = { + accessView: { + fontWeight: "400", + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: "4px 8px", + }, + accessDescription: { + display: "flex", + flexDirection: "column", + flex: 1, + paddingLeft: 16, + }, +}; + +const useAccessObjectFormat = props => ({ + ...props, + access: accessStringToObject(props.access), + onChange: newAccess => props.onChange(accessObjectToString(newAccess)), +}); + +export const Access = withStyles(styles)( + ({ + access, + accessType, + accessOptions, + primaryText, + secondaryText, + onChange, + onRemove, + disabled, + classes, + }) => ( +
+ +
+
{primaryText}
+
{secondaryText || " "}
+
+ + + + +
+ ) +); + +Access.propTypes = { + access: PropTypes.object.isRequired, + accessType: PropTypes.string.isRequired, + accessOptions: PropTypes.object.isRequired, + primaryText: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool, + secondaryText: PropTypes.string, + onRemove: PropTypes.func, +}; + +Access.defaultProps = { + secondaryText: null, + onRemove: null, + disabled: false, +}; + +export const GroupAccess = basicProps => { + const props = useAccessObjectFormat(basicProps); + const newProps = { + accessType: props.groupType, + primaryText: props.groupName, + accessOptions: { + meta: { canView: true, canEdit: true, noAccess: false }, + data: props.dataShareable && { + canView: true, + canEdit: true, + noAccess: true, + }, + }, + }; + + return ; +}; + +export const ExternalAccess = props => { + const newProps = { + ...props, + accessType: "external", + primaryText: i18n.t("External access"), + secondaryText: props.access ? i18n.t("Anyone can view without login") : i18n.t("No access"), + access: { + meta: { canEdit: false, canView: props.access }, + data: { canEdit: false, canView: false }, + }, + onChange: newAccess => props.onChange(newAccess.meta.canView), + accessOptions: { + meta: { canView: true, canEdit: false, noAccess: true }, + }, + }; + + return ; +}; + +export const PublicAccess = basicProps => { + const props = useAccessObjectFormat(basicProps); + const { canEdit, canView } = props.access.meta; + const description = canEdit + ? "Anyone can find and view" + : canView + ? "Anyone can view" + : "No access"; + + const newProps = { + ...props, + accessType: "public", + primaryText: i18n.t("Public access"), + secondaryText: description, + accessOptions: { + meta: { canView: true, canEdit: true, noAccess: true }, + data: props.dataShareable && { + canView: true, + canEdit: true, + noAccess: true, + }, + }, + }; + + return ; +}; diff --git a/src/components/sharing-dialog/AutoComplete.jsx b/src/components/sharing-dialog/AutoComplete.jsx new file mode 100644 index 000000000..121000e41 --- /dev/null +++ b/src/components/sharing-dialog/AutoComplete.jsx @@ -0,0 +1,156 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import Downshift from "downshift"; +import { withStyles } from "@material-ui/core/styles"; +import TextField from "@material-ui/core/TextField"; +import Paper from "@material-ui/core/Paper"; +import MenuItem from "@material-ui/core/MenuItem"; +import Popper from "@material-ui/core/Popper"; + +const Input = ({ InputProps }) => { + return ; +}; + +Input.propTypes = { + InputProps: PropTypes.object.isRequired, +}; + +const Suggestion = ({ suggestion, itemProps, isHighlighted, selectedItem }) => { + const isSelected = selectedItem && selectedItem.id === suggestion.id; + + return ( + + {suggestion.displayName} + + ); +}; + +Suggestion.propTypes = { + isHighlighted: PropTypes.bool.isRequired, + itemProps: PropTypes.object, + selectedItem: PropTypes.object, + suggestion: PropTypes.shape({ displayName: PropTypes.string }).isRequired, +}; + +const styles = theme => ({ + root: { + flexGrow: 1, + height: 60, + }, + popper: { + zIndex: 2000, + maxHeight: "420px", + overflowY: "hidden", + boxShadow: "0px 0px 1px 1px rgba(0,0,0,0.2)", + }, + container: { + flexGrow: 1, + position: "relative", + }, + inputRoot: { + flexWrap: "wrap", + }, +}); + +let popperNode; + +class AutoComplete extends Component { + render() { + const { classes, placeholderText, suggestions, searchText } = this.props; + + return ( +
+ (item ? item.name : "")} + inputValue={searchText} + > + {({ + getInputProps, + getItemProps, + getMenuProps, + highlightedIndex, + isOpen, + selectedItem, + }) => { + return ( +
+ { + popperNode = node; + }, + })} + /> +
+ {isOpen && ( + + + {suggestions.map((suggestion, index) => { + return ( + + ); + })} + + + )} +
+
+ ); + }} +
+
+ ); + } +} + +AutoComplete.propTypes = { + classes: PropTypes.object.isRequired, + placeholderText: PropTypes.string, + onInputChanged: PropTypes.func.isRequired, + onItemSelected: PropTypes.func.isRequired, + suggestions: PropTypes.array.isRequired, +}; + +AutoComplete.defaultProps = { + placeholderText: "", +}; + +export default withStyles(styles)(AutoComplete); diff --git a/src/components/sharing-dialog/PermissionOption.jsx b/src/components/sharing-dialog/PermissionOption.jsx new file mode 100644 index 000000000..d9a498685 --- /dev/null +++ b/src/components/sharing-dialog/PermissionOption.jsx @@ -0,0 +1,36 @@ +import React from "react"; +import PropTypes from "prop-types"; +import DoneIcon from "@material-ui/icons/Done"; +import MenuItem from "@material-ui/core/MenuItem"; +import ListItemIcon from "@material-ui/core/ListItemIcon"; +import ListItemText from "@material-ui/core/ListItemText"; + +const PermissionOption = props => { + if (props.disabled) { + return null; + } + + return ( + + {props.isSelected && ( + + + + )} + + + ); +}; + +PermissionOption.propTypes = { + disabled: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + primaryText: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, +}; + +PermissionOption.defaultProps = { + isSelected: false, +}; + +export default PermissionOption; diff --git a/src/components/sharing-dialog/PermissionPicker.jsx b/src/components/sharing-dialog/PermissionPicker.jsx new file mode 100644 index 000000000..7c8333186 --- /dev/null +++ b/src/components/sharing-dialog/PermissionPicker.jsx @@ -0,0 +1,167 @@ +import PropTypes from "prop-types"; +import React, { Component, Fragment } from "react"; +import IconButton from "@material-ui/core/IconButton"; +import Divider from "@material-ui/core/Divider"; +import MenuList from "@material-ui/core/MenuList"; +import Popover from "@material-ui/core/Popover"; +import NotInterestedIcon from "@material-ui/icons/NotInterested"; +import CreateIcon from "@material-ui/icons/Create"; +import VisibilityIcon from "@material-ui/icons/Visibility"; +import { withStyles } from "@material-ui/core/styles"; +import i18n from "@dhis2/d2-i18n"; + +import PermissionOption from "./PermissionOption"; + +const styles = { + optionHeader: { + paddingLeft: 16, + paddingTop: 16, + fontWeight: "500", + color: "gray", + }, +}; + +const AccessIcon = ({ metaAccess, disabled }) => { + const iconProps = { + color: disabled ? "disabled" : "action", + }; + if (metaAccess.canEdit) { + return ; + } + + return metaAccess.canView ? ( + + ) : ( + + ); +}; + +class PermissionPicker extends Component { + state = { + open: false, + }; + + onOptionClick = access => () => { + const newAccess = { + ...this.props.access, + ...access, + }; + + this.props.onChange(newAccess); + }; + + openMenu = event => { + event.preventDefault(); + this.setState({ + open: true, + anchor: event.currentTarget, + }); + }; + + closeMenu = () => { + this.setState({ + open: false, + }); + }; + + render = () => { + const { data, meta } = this.props.access; + const { data: dataOptions, meta: metaOptions } = this.props.accessOptions; + + return ( + + + + + + + + + + + + + + {dataOptions && ( + + + + + + + + + )} + + + ); + }; +} + +PermissionPicker.propTypes = { + access: PropTypes.object.isRequired, + accessOptions: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool, +}; + +PermissionPicker.defaultProps = { + disabled: false, +}; + +const OptionHeader = withStyles(styles)(({ text, classes }) => ( +
{text.toUpperCase()}
+)); + +OptionHeader.propTypes = { + text: PropTypes.string.isRequired, +}; + +export default PermissionPicker; diff --git a/src/components/sharing-dialog/Sharing.jsx b/src/components/sharing-dialog/Sharing.jsx new file mode 100644 index 000000000..6c31958e9 --- /dev/null +++ b/src/components/sharing-dialog/Sharing.jsx @@ -0,0 +1,210 @@ +import PropTypes from "prop-types"; +import React from "react"; +import Divider from "@material-ui/core/Divider"; +import Typography from "@material-ui/core/Typography"; +import { withStyles } from "@material-ui/core/styles"; +import i18n from "@dhis2/d2-i18n"; + +import UserSearch from "./UserSearch"; +import { PublicAccess, ExternalAccess, GroupAccess } from "./Access"; + +const styles = { + title: { + fontSize: "24px", + fontWeight: 300, + color: "rgba(0, 0, 0, 0.87)", + padding: "16px 0px 5px", + margin: "0px", + }, + createdBy: { + color: "#818181", + }, + titleBodySpace: { + paddingTop: 30, + }, + rules: { + height: "240px", + overflowY: "scroll", + }, +}; + +/** + * Content of the sharing dialog; a set of components for changing sharing + * preferences. + */ +class Sharing extends React.Component { + onAccessRuleChange = id => accessRule => { + const changeWithId = rule => (rule.id === id ? { ...rule, access: accessRule } : rule); + const userAccesses = (this.props.sharedObject.object.userAccesses || []).map(changeWithId); + const userGroupAccesses = (this.props.sharedObject.object.userGroupAccesses || []).map( + changeWithId + ); + + this.props.onChange({ + userAccesses, + userGroupAccesses, + }); + }; + + onAccessRemove = accessOwnerId => () => { + const withoutId = accessOwner => accessOwner.id !== accessOwnerId; + const userAccesses = (this.props.sharedObject.object.userAccesses || []).filter(withoutId); + const userGroupAccesses = (this.props.sharedObject.object.userGroupAccesses || []).filter( + withoutId + ); + + this.props.onChange({ + userAccesses, + userGroupAccesses, + }); + }; + + onPublicAccessChange = publicAccess => { + this.props.onChange({ + publicAccess, + }); + }; + + onExternalAccessChange = externalAccess => { + this.props.onChange({ + externalAccess, + }); + }; + + setAccessListRef = ref => { + this.accessListRef = ref; + }; + + accessListRef = null; + + addUserAccess = userAccess => { + const currentAccesses = this.props.sharedObject.object.userAccesses || []; + this.props.onChange( + { + userAccesses: [...currentAccesses, userAccess], + }, + this.scrollAccessListToBottom() + ); + }; + + addUserGroupAccess = userGroupAccess => { + const currentAccesses = this.props.sharedObject.object.userGroupAccesses || []; + this.props.onChange( + { + userGroupAccesses: [...currentAccesses, userGroupAccess], + }, + this.scrollAccessListToBottom() + ); + }; + + scrollAccessListToBottom = () => { + this.accessListRef.scrollTop = this.accessListRef.scrollHeight; + }; + + render() { + const { + user, + displayName, + name, + userAccesses, + userGroupAccesses, + publicAccess, + externalAccess, + } = this.props.sharedObject.object; + const { allowPublicAccess, allowExternalAccess } = this.props.sharedObject.meta; + const { classes } = this.props; + + const accessIds = (userAccesses || []) + .map(access => access.id) + .concat((userGroupAccesses || []).map(access => access.id)); + + return ( +
+

{displayName || name}

+ {user && ( +
+ {`${i18n.t("Created by")}: ${user.name}`} +
+ )} +
+ {i18n.t("Who has access")} + +
+ + + + + {userAccesses && + userAccesses.map(access => ( +
+ + +
+ ))} + {userGroupAccesses && + userGroupAccesses.map(access => ( +
+ + +
+ ))} +
+ +
+ ); + } +} + +Sharing.propTypes = { + /** + * The object to share + */ + sharedObject: PropTypes.object.isRequired, + + /* + * If true, the object's data should have their own settings. + */ + dataShareable: PropTypes.bool.isRequired, + + /** + * Function that takes an object containing updated sharing preferences and + * an optional callback fired when the change was successfully posted. + */ + onChange: PropTypes.func.isRequired, + + /** + * Takes a string and a callback, and returns matching users and userGroups. + */ + onSearch: PropTypes.func.isRequired, +}; + +export default withStyles(styles)(Sharing); diff --git a/src/components/sharing-dialog/SharingDialog.jsx b/src/components/sharing-dialog/SharingDialog.jsx new file mode 100644 index 000000000..04a176d03 --- /dev/null +++ b/src/components/sharing-dialog/SharingDialog.jsx @@ -0,0 +1,42 @@ +import React from "react"; +import i18n from "@dhis2/d2-i18n"; +import { ConfirmationDialog } from "d2-ui-components"; +import DialogContent from "@material-ui/core/DialogContent"; + +import Sharing from "./Sharing"; + +const SharingDialog = ({ + isOpen, + isDataShareable, + sharedObject, + onCancel, + onSharingChanged, + onSearchRequest, +}) => { + return ( + + + + {sharedObject && ( + + )} + + + + ); +}; + +export default SharingDialog; diff --git a/src/components/sharing-dialog/UserSearch.jsx b/src/components/sharing-dialog/UserSearch.jsx new file mode 100644 index 000000000..d96c572f9 --- /dev/null +++ b/src/components/sharing-dialog/UserSearch.jsx @@ -0,0 +1,165 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { Subject } from "rxjs/Subject"; +import { timer } from "rxjs/observable/timer"; +import { debounce } from "rxjs/operators"; +import { withStyles } from "@material-ui/core/styles"; +import i18n from "@dhis2/d2-i18n"; + +import { accessObjectToString } from "./utils"; +import PermissionPicker from "./PermissionPicker"; +import AutoComplete from "./AutoComplete"; + +const styles = { + container: { + fontWeight: "400", + padding: 16, + backgroundColor: "#F5F5F5", + display: "flex", + flexDirection: "column", + justifyContent: "center", + }, + + innerContainer: { + display: "flex", + flexDirection: "row", + flex: 1, + }, + + title: { + color: "#818181", + paddingBottom: 8, + }, +}; + +const searchDelay = 300; + +class UserSearch extends Component { + state = { + defaultAccess: { + meta: { canView: true, canEdit: true }, + data: { canView: false, canEdit: false }, + }, + searchResult: [], + searchText: "", + }; + + componentDidMount() { + this.inputStream.pipe(debounce(() => timer(searchDelay))).subscribe(searchText => { + this.fetchSearchResult(searchText); + }); + } + + onItemSelected = selected => { + // Material UI triggers an 'onUpdateInput' when a search result is clicked. Therefore, we + // immediately pushes a new item to the search stream to prevent the stream from searching + // for the item again. + this.inputStream.next(""); + + const selection = this.state.searchResult.find(r => r.id === selected.id); + + const type = selection.type; + delete selection.type; + + if (type === "userAccess") { + this.props.addUserAccess({ + ...selection, + access: accessObjectToString(this.state.defaultAccess), + }); + } else { + this.props.addUserGroupAccess({ + ...selection, + access: accessObjectToString(this.state.defaultAccess), + }); + } + this.clearSearchText(); + }; + + inputStream = new Subject(); + + hasNoCurrentAccess = userOrGroup => this.props.currentAccessIds.indexOf(userOrGroup.id) === -1; + + fetchSearchResult = searchText => { + if (searchText === "") { + this.handleSearchResult([]); + } else { + this.props.onSearch(searchText).then(({ users, userGroups }) => { + const addType = type => result => ({ ...result, type }); + const searchResult = users + .map(addType("userAccess")) + .filter(this.hasNoCurrentAccess) + .concat( + userGroups.map(addType("userGroupAccess")).filter(this.hasNoCurrentAccess) + ); + + this.handleSearchResult(searchResult); + }); + } + }; + + handleSearchResult = searchResult => { + this.setState({ searchResult }); + }; + + onInputChanged = searchText => { + this.inputStream.next(searchText); + this.setState({ searchText }); + }; + + accessOptionsChanged = accessOptions => { + this.setState({ + defaultAccess: accessOptions, + }); + }; + + clearSearchText = () => { + this.setState({ + searchText: "", + }); + }; + + render() { + const { classes } = this.props; + return ( +
+
{i18n.t("Add users and user groups")}
+
+ + +
+
+ ); + } +} + +UserSearch.propTypes = { + onSearch: PropTypes.func.isRequired, + addUserAccess: PropTypes.func.isRequired, + dataShareable: PropTypes.bool.isRequired, + addUserGroupAccess: PropTypes.func.isRequired, + currentAccessIds: PropTypes.array.isRequired, +}; + +export default withStyles(styles)(UserSearch); diff --git a/src/components/sharing-dialog/utils.js b/src/components/sharing-dialog/utils.js new file mode 100644 index 000000000..db1ddabcd --- /dev/null +++ b/src/components/sharing-dialog/utils.js @@ -0,0 +1,56 @@ +export const cachedAccessTypeToString = (canView, canEdit) => { + if (canView) { + return canEdit ? "rw------" : "r-------"; + } + + return "--------"; +}; + +export const transformAccessObject = (access, type) => ({ + id: access.id, + name: access.name, + displayName: access.displayName, + type, + canView: access.access && access.access.includes("r"), + canEdit: access.access && access.access.includes("rw"), +}); + +export const accessStringToObject = access => { + if (!access) { + return { + data: { canView: false, canEdit: false }, + meta: { canView: false, canEdit: false }, + }; + } + + const metaAccess = access.substring(0, 2); + const dataAccess = access.substring(2, 4); + + return { + meta: { + canView: metaAccess.includes("r"), + canEdit: metaAccess.includes("rw"), + }, + data: { + canView: dataAccess.includes("r"), + canEdit: dataAccess.includes("rw"), + }, + }; +}; + +export const accessObjectToString = accessObject => { + const convert = ({ canEdit, canView }) => { + if (canEdit) { + return "rw"; + } + + return canView ? "r-" : "--"; + }; + + let accessString = ""; + accessString += convert(accessObject.meta); + accessString += convert(accessObject.data); + accessString += "----"; + + return accessString; +}; diff --git a/src/components/sync-dialog/SyncDialog.jsx b/src/components/sync-dialog/SyncDialog.jsx index d6e5b24f8..678defc2a 100644 --- a/src/components/sync-dialog/SyncDialog.jsx +++ b/src/components/sync-dialog/SyncDialog.jsx @@ -6,6 +6,13 @@ import { ConfirmationDialog, MultiSelector } from "d2-ui-components"; import DialogContent from "@material-ui/core/DialogContent"; import Instance from "../../models/instance"; +import SyncParamsSelector from "../sync-params-selector/SyncParamsSelector"; + +const defaultSyncParams = { + includeSharingSettings: true, + atomicMode: "ALL", + mergeMode: "MERGE", +}; class SyncDialog extends React.Component { static propTypes = { @@ -19,6 +26,7 @@ class SyncDialog extends React.Component { state = { instanceOptions: [], targetInstances: [], + syncParams: defaultSyncParams, }; async componentDidMount() { @@ -40,19 +48,23 @@ class SyncDialog extends React.Component { handleExecute = async () => { const { task } = this.props; - const { targetInstances } = this.state; + const { targetInstances, syncParams } = this.state; - await task(targetInstances); - this.setState({ targetInstances: [] }); + await task({ targetInstances, syncParams }); + this.setState({ targetInstances: [], syncParams: defaultSyncParams }); }; handleCancel = () => { this.props.handleClose(); }; + changeSyncParams = syncParams => { + this.setState({ syncParams }); + }; + render() { const { d2, isOpen } = this.props; - const { targetInstances } = this.state; + const { targetInstances, syncParams } = this.state; const disableSync = _.isEmpty(targetInstances); return ( @@ -74,6 +86,10 @@ class SyncDialog extends React.Component { onChange={this.onChangeInstances} options={this.state.instanceOptions} /> + diff --git a/src/components/sync-params-selector/SyncParamsSelector.tsx b/src/components/sync-params-selector/SyncParamsSelector.tsx new file mode 100644 index 000000000..eb8107d0e --- /dev/null +++ b/src/components/sync-params-selector/SyncParamsSelector.tsx @@ -0,0 +1,95 @@ +import React, { useState } from "react"; +import { Typography, withStyles } from "@material-ui/core"; +import i18n from "@dhis2/d2-i18n"; + +import { SynchronizationParams } from "../../types/synchronization"; +import { Toggle } from "../toggle/Toggle"; + +interface Props { + defaultParams: SynchronizationParams; + onChange(newParams: SynchronizationParams): void; + classes: any; +} + +interface PseudoEvent { + target: { + value: boolean; + }; +} + +const styles = () => ({ + advancedOptionsTitle: { + marginTop: "40px", + fontWeight: 500, + }, +}); + +const SyncParamsSelector = (props: Props) => { + const { defaultParams, onChange, classes } = props; + const [syncParams, updateSyncParams] = useState(defaultParams); + + const changeSharingSettings = (event: PseudoEvent) => { + const { value } = event.target; + const newParams: SynchronizationParams = { + ...syncParams, + includeSharingSettings: value, + }; + + updateSyncParams(newParams); + onChange(newParams); + }; + + const changeAtomic = (event: PseudoEvent) => { + const { value } = event.target; + const newParams: SynchronizationParams = { + ...syncParams, + atomicMode: value ? "NONE" : "ALL", + }; + + updateSyncParams(newParams); + onChange(newParams); + }; + + const changeReplace = (event: PseudoEvent) => { + const { value } = event.target; + const newParams: SynchronizationParams = { + ...syncParams, + mergeMode: value ? "REPLACE" : "MERGE", + }; + + updateSyncParams(newParams); + onChange(newParams); + }; + + return ( + + + {i18n.t("Advanced options")} + +
+ +
+ +
+ +
+
+ +
+
+ ); +}; + +export default withStyles(styles)(SyncParamsSelector); diff --git a/src/components/synchronization-page/DataElementPage.jsx b/src/components/synchronization-page/DataElementPage.jsx deleted file mode 100644 index 4fb6f5810..000000000 --- a/src/components/synchronization-page/DataElementPage.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import i18n from "@dhis2/d2-i18n"; -import { - DataElementGroupModel, - DataElementGroupSetModel, - DataElementModel, -} from "../../models/d2Model"; -import GenericSynchronizationPage from "./GenericSynchronizationPage"; - -export default class DataElementPage extends React.Component { - static propTypes = { - d2: PropTypes.object.isRequired, - }; - - models = [DataElementModel, DataElementGroupModel, DataElementGroupSetModel]; - - render() { - const { d2 } = this.props; - - const title = i18n.t("Data Elements Synchronization"); - - return ; - } -} diff --git a/src/components/synchronization-page/GenericSynchronizationPage.jsx b/src/components/synchronization-page/GenericSynchronizationPage.jsx index 2ae160433..ffc952a1a 100644 --- a/src/components/synchronization-page/GenericSynchronizationPage.jsx +++ b/src/components/synchronization-page/GenericSynchronizationPage.jsx @@ -12,6 +12,7 @@ import SyncDialog from "../sync-dialog/SyncDialog"; import SyncSummary from "../sync-summary/SyncSummary"; import PageHeader from "../page-header/PageHeader"; import SyncReport from "../../models/syncReport"; +import { isAppConfigurator } from "../../utils/permissions"; class GenericSynchronizationPage extends React.Component { static propTypes = { @@ -28,8 +29,16 @@ class GenericSynchronizationPage extends React.Component { importResponse: SyncReport.create(), syncDialogOpen: false, syncSummaryOpen: false, + appConfigurator: false, }; + async componentDidMount() { + const { d2 } = this.props; + const appConfigurator = await isAppConfigurator(d2); + + this.setState({ appConfigurator }); + } + goHome = () => { this.props.history.push("/"); }; @@ -63,7 +72,7 @@ class GenericSynchronizationPage extends React.Component { } }; - handleSynchronization = async targetInstances => { + handleSynchronization = async ({ targetInstances, syncParams }) => { const { isDelete, loading, d2 } = this.props; const { metadataIds } = this.state; @@ -71,7 +80,7 @@ class GenericSynchronizationPage extends React.Component { loading.show(true, i18n.t("Synchronizing metadata")); try { - const builder = { metadataIds, targetInstances }; + const builder = { metadataIds, targetInstances, syncParams }; for await (const { message, syncReport, done } of action(d2, builder)) { if (message) loading.show(true, message); if (syncReport) await syncReport.save(d2); @@ -90,7 +99,13 @@ class GenericSynchronizationPage extends React.Component { render() { const { d2, title, models, ...rest } = this.props; - const { syncDialogOpen, syncSummaryOpen, importResponse, metadataIds } = this.state; + const { + syncDialogOpen, + syncSummaryOpen, + importResponse, + metadataIds, + appConfigurator, + } = this.state; return ( @@ -102,7 +117,7 @@ class GenericSynchronizationPage extends React.Component { initialModel={models[0]} initialSelection={metadataIds} notifyNewSelection={this.changeSelection} - onButtonClick={this.startSynchronization} + onButtonClick={appConfigurator ? this.startSynchronization : null} buttonLabel={} {...rest} /> diff --git a/src/components/synchronization-page/IndicatorPage.jsx b/src/components/synchronization-page/IndicatorPage.jsx deleted file mode 100644 index 4a725c856..000000000 --- a/src/components/synchronization-page/IndicatorPage.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import i18n from "@dhis2/d2-i18n"; -import { IndicatorGroupModel, IndicatorGroupSetModel, IndicatorModel } from "../../models/d2Model"; -import GenericSynchronizationPage from "./GenericSynchronizationPage"; - -export default class IndicatorPage extends React.Component { - static propTypes = { - d2: PropTypes.object.isRequired, - }; - - models = [IndicatorModel, IndicatorGroupModel, IndicatorGroupSetModel]; - - render() { - const { d2 } = this.props; - - const title = i18n.t("Indicators Synchronization"); - - return ; - } -} diff --git a/src/components/synchronization-page/MetadataPage.jsx b/src/components/synchronization-page/MetadataPage.jsx new file mode 100644 index 000000000..e9c87c10b --- /dev/null +++ b/src/components/synchronization-page/MetadataPage.jsx @@ -0,0 +1,19 @@ +import React from "react"; +import PropTypes from "prop-types"; +import i18n from "@dhis2/d2-i18n"; +import { metadataModels } from "../../models/d2ModelFactory"; +import GenericSynchronizationPage from "./GenericSynchronizationPage"; + +export default class MetadataPage extends React.Component { + static propTypes = { + d2: PropTypes.object.isRequired, + }; + + render() { + const { d2 } = this.props; + + const title = i18n.t("Metadata Synchronization"); + + return ; + } +} diff --git a/src/components/synchronization-page/OrganisationUnitPage.jsx b/src/components/synchronization-page/OrganisationUnitPage.jsx deleted file mode 100644 index 82bbd783c..000000000 --- a/src/components/synchronization-page/OrganisationUnitPage.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import i18n from "@dhis2/d2-i18n"; -import { - OrganisationUnitGroupModel, - OrganisationUnitGroupSetModel, - OrganisationUnitModel, -} from "../../models/d2Model"; -import GenericSynchronizationPage from "./GenericSynchronizationPage"; - -export default class OrganisationUnitPage extends React.Component { - static propTypes = { - d2: PropTypes.object.isRequired, - }; - - models = [OrganisationUnitModel, OrganisationUnitGroupModel, OrganisationUnitGroupSetModel]; - - render() { - const { d2 } = this.props; - - const title = i18n.t("Organisation Units Synchronization"); - - return ; - } -} diff --git a/src/components/synchronization-page/ValidationRulePage.jsx b/src/components/synchronization-page/ValidationRulePage.jsx deleted file mode 100644 index 2d514a68a..000000000 --- a/src/components/synchronization-page/ValidationRulePage.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import i18n from "@dhis2/d2-i18n"; -import { ValidationRuleGroupModel, ValidationRuleModel } from "../../models/d2Model"; -import GenericSynchronizationPage from "./GenericSynchronizationPage"; - -export default class ValidationRulePage extends React.Component { - static propTypes = { - d2: PropTypes.object.isRequired, - }; - - models = [ValidationRuleModel, ValidationRuleGroupModel]; - - render() { - const { d2 } = this.props; - - const title = i18n.t("Validation Rules Synchronization"); - - return ; - } -} diff --git a/src/components/toggle/Toggle.tsx b/src/components/toggle/Toggle.tsx new file mode 100644 index 000000000..0483b41c3 --- /dev/null +++ b/src/components/toggle/Toggle.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { FormControlLabel, Switch } from "@material-ui/core"; + +interface InputParameters { + label: string; + onChange: Function; + value: boolean; +} + +export const Toggle = ({ label, onChange, value }: InputParameters) => ( + onChange({ target: { value: e.target.checked } })} + checked={value} + color="primary" + /> + } + label={label} + /> +); diff --git a/src/logic/synchronization.ts b/src/logic/synchronization.ts index ede1ff485..e97b2f461 100644 --- a/src/logic/synchronization.ts +++ b/src/logic/synchronization.ts @@ -28,7 +28,7 @@ import SyncRule from "../models/syncRule"; async function exportMetadata(d2: D2, originalBuilder: ExportBuilder): Promise { const visitedIds: Set = new Set(); const recursiveExport = async (builder: ExportBuilder): Promise => { - const { type, ids, excludeRules, includeRules } = builder; + const { type, ids, excludeRules, includeRules, includeSharingSettings } = builder; const model = d2ModelFactory(d2, type).getD2Model(d2); const result: MetadataPackage = {}; @@ -43,7 +43,7 @@ async function exportMetadata(d2: D2, originalBuilder: ExportBuilder): Promise !visitedIds.has(id)), excludeRules: nestedExcludeRules[type], includeRules: nestedIncludeRules[type], + includeSharingSettings, })) .map(newBuilder => { newBuilder.ids.forEach(id => { @@ -77,7 +78,7 @@ export async function* startSynchronization( d2: D2, builder: SynchronizationBuilder ): AsyncIterableIterator { - const { targetInstances: targetInstanceIds, metadataIds, syncRule } = builder; + const { targetInstances: targetInstanceIds, metadataIds, syncRule, syncParams = {} } = builder; const { baseUrl } = d2.Api.getApi(); // Phase 1: Export and package metadata from origin instance @@ -92,6 +93,7 @@ export async function* startSynchronization( ids: metadata[type].map(e => e.id), excludeRules: myClass.getExcludeRules(), includeRules: myClass.getIncludeRules(), + includeSharingSettings: !!syncParams.includeSharingSettings, }; }) .map(newBuilder => exportMetadata(d2, newBuilder)); @@ -128,7 +130,7 @@ export async function* startSynchronization( }), }; console.debug("Start import on destination instance", instance); - const response = await postMetadata(instance, metadataPackage); + const response = await postMetadata(instance, metadataPackage, syncParams); syncReport.addSyncResult(cleanImportResponse(response, instance)); console.debug("Finished importing data on instance", instance); diff --git a/src/models/__tests__/d2ModelFactory.spec.js b/src/models/__tests__/d2ModelFactory.spec.js new file mode 100644 index 000000000..077d02ee9 --- /dev/null +++ b/src/models/__tests__/d2ModelFactory.spec.js @@ -0,0 +1,16 @@ +import { d2ModelFactory } from "../d2ModelFactory"; +import { DataElementGroupModel } from "../d2Model"; + +describe("d2ModelFactory", () => { + describe("d2ModelFactory should return specific model", () => { + it("DataElementGroup", async () => { + const d2Stub = { models: { dataElementGroups: { name: "dataElementGroup" } } }; + + const d2Model = d2ModelFactory(d2Stub, "dataElementGroups"); + + expect(d2Model).toEqual(DataElementGroupModel); + }); + }); +}); + +export {}; diff --git a/src/models/d2Model.ts b/src/models/d2Model.ts index 2acec0652..b4e4d6303 100644 --- a/src/models/d2Model.ts +++ b/src/models/d2Model.ts @@ -256,6 +256,44 @@ export class IndicatorGroupSetModel extends D2Model { ]; } +export class ProgramIndicatorModel extends D2Model { + protected static metadataType = "programIndicator"; + protected static groupFilterName = "programIndicatorGroups"; + + protected static excludeRules = ["programs"]; + protected static includeRules = [ + "attributes", + "legendSets", + "programIndicatorGroups", + "programIndicatorGroups.attributes", + ]; +} + +export class ProgramIndicatorGroupModel extends D2Model { + protected static metadataType = "programIndicatorGroup"; + + protected static excludeRules = ["legendSets", "programIndicators.programIndicatorGroups"]; + protected static includeRules = [ + "attributes", + "programIndicators", + "programIndicators.attributes", + ]; +} + +export class ProgramRuleModel extends D2Model { + protected static metadataType = "programRule"; + + protected static excludeRules = []; + protected static includeRules = ["attributes", "programRuleActions"]; +} + +export class ProgramRuleVariableModel extends D2Model { + protected static metadataType = "programRuleVariable"; + + protected static excludeRules = []; + protected static includeRules = ["attributes"]; +} + export class ValidationRuleModel extends D2Model { protected static metadataType = "validationRule"; protected static groupFilterName = "validationRuleGroups"; diff --git a/src/models/d2ModelFactory.ts b/src/models/d2ModelFactory.ts index d21753b91..d367087b6 100644 --- a/src/models/d2ModelFactory.ts +++ b/src/models/d2ModelFactory.ts @@ -1,34 +1,45 @@ import { D2Model, + defaultModel, + DataElementModel, DataElementGroupModel, DataElementGroupSetModel, - DataElementModel, - defaultModel, + IndicatorModel, IndicatorGroupModel, IndicatorGroupSetModel, - IndicatorModel, + OrganisationUnitModel, OrganisationUnitGroupModel, OrganisationUnitGroupSetModel, - OrganisationUnitModel, - ValidationRuleGroupModel, ValidationRuleModel, + ValidationRuleGroupModel, + ProgramIndicatorModel, + ProgramIndicatorGroupModel, + ProgramRuleModel, + ProgramRuleVariableModel, } from "./d2Model"; + import { D2 } from "../types/d2"; const classes: { [modelName: string]: typeof D2Model } = { + DataElementModel, DataElementGroupModel, DataElementGroupSetModel, - DataElementModel, + IndicatorModel, IndicatorGroupModel, IndicatorGroupSetModel, - IndicatorModel, + OrganisationUnitModel, OrganisationUnitGroupModel, OrganisationUnitGroupSetModel, - OrganisationUnitModel, - ValidationRuleGroupModel, ValidationRuleModel, + ValidationRuleGroupModel, + ProgramIndicatorModel, + ProgramIndicatorGroupModel, + ProgramRuleModel, + ProgramRuleVariableModel, }; +export const metadataModels = Object.values(classes); + /** * D2ModelProxy allows to create on-demand d2Model classes * If the class doesn't exist a new default class is created @@ -37,5 +48,9 @@ const classes: { [modelName: string]: typeof D2Model } = { export function d2ModelFactory(d2: D2, d2ModelName: string): typeof D2Model { const modelName = d2.models[d2ModelName].name; const className = modelName.charAt(0).toUpperCase() + modelName.slice(1) + "Model"; + console.debug( + `d2ModelFactory for modelName ${d2ModelName} return ` + + (!classes[className] ? `defaultModel` : className) + ); return classes[className] || defaultModel(modelName); } diff --git a/src/models/syncRule.ts b/src/models/syncRule.ts index b7ee1d329..6fbdd17e6 100644 --- a/src/models/syncRule.ts +++ b/src/models/syncRule.ts @@ -5,9 +5,14 @@ import { generateUid } from "d2/uid"; import { deleteData, getDataById, getPaginatedData, saveData } from "./dataStore"; import isValidCronExpression from "../utils/validCronExpression"; +import { getUserInfo, isGlobalAdmin, UserInfo } from "../utils/permissions"; import { D2 } from "../types/d2"; import { SyncRuleTableFilters, TableList, TablePagination } from "../types/d2-ui-components"; -import { SynchronizationRule } from "../types/synchronization"; +import { + SynchronizationRule, + SharingSetting, + SynchronizationParams, +} from "../types/synchronization"; import { Validation } from "../types/validations"; const dataStoreKey = "rules"; @@ -26,6 +31,9 @@ export default class SyncRule { "enabled", "frequency", "lastExecuted", + "publicAccess", + "userAccesses", + "userGroupAccesses", ]), }; } @@ -72,6 +80,22 @@ export default class SyncRule { : undefined; } + public get publicAccess(): string { + return this.syncRule.publicAccess; + } + + public get userAccesses(): SharingSetting[] { + return this.syncRule.userAccesses; + } + + public get userGroupAccesses(): SharingSetting[] { + return this.syncRule.userGroupAccesses; + } + + public get syncParams(): SynchronizationParams { + return this.syncRule.builder.syncParams || {}; + } + public static create(): SyncRule { return new SyncRule({ id: "", @@ -80,8 +104,16 @@ export default class SyncRule { builder: { targetInstances: [], metadataIds: [], + syncParams: { + includeSharingSettings: true, + atomicMode: "ALL", + mergeMode: "MERGE", + }, }, enabled: false, + publicAccess: "rw------", + userAccesses: [], + userGroupAccesses: [], }); } @@ -101,8 +133,17 @@ export default class SyncRule { ): Promise { const { targetInstanceFilter = null, enabledFilter = null, lastExecutedFilter = null } = filters || {}; + const { page = 1, pageSize = 20, paging = true } = pagination || {}; + + const globalAdmin = await isGlobalAdmin(d2); + const userInfo = await getUserInfo(d2); + const data = await getPaginatedData(d2, dataStoreKey, filters, pagination); const objects = _(data.objects) + .filter(data => { + const rule = SyncRule.build(data); + return globalAdmin || rule.isVisibleToUser(userInfo); + }) .filter(rule => targetInstanceFilter ? rule.builder.targetInstances.includes(targetInstanceFilter) @@ -115,7 +156,11 @@ export default class SyncRule { : true ) .value(); - return { ...data, objects }; + + const total = objects.length; + const pageCount = paging ? Math.ceil(objects.length / pageSize) : 1; + + return { objects, pager: { page, pageCount, total } }; } public updateName(name: string): SyncRule { @@ -152,6 +197,16 @@ export default class SyncRule { }); } + public updateSyncParams(syncParams: SynchronizationParams): SyncRule { + return SyncRule.build({ + ...this.syncRule, + builder: { + ...this.syncRule.builder, + syncParams, + }, + }); + } + public updateEnabled(enabled: boolean): SyncRule { return SyncRule.build({ ...this.syncRule, @@ -173,6 +228,27 @@ export default class SyncRule { }); } + public isVisibleToUser(userInfo: UserInfo, permission: "READ" | "WRITE" = "READ") { + const { id: userId, userGroups } = userInfo; + const token = permission === "READ" ? "r" : "w"; + const { + publicAccess = "--------", + userAccesses = [], + userGroupAccesses = [], + } = this.syncRule; + + return ( + publicAccess.substring(0, 2).includes(token) || + !!_(userAccesses) + .filter(({ access }) => access.substring(0, 2).includes(token)) + .find(({ id }) => id === userId) || + _(userGroupAccesses) + .filter(({ access }) => access.substring(0, 2).includes(token)) + .intersectionBy(userGroups, "id") + .value().length > 0 + ); + } + public async save(d2: D2): Promise { const exists = !!this.syncRule.id; const element = exists ? this.syncRule : { ...this.syncRule, id: generateUid() }; diff --git a/src/types/d2.d.ts b/src/types/d2.d.ts index 5790ee14c..b5bfcb016 100644 --- a/src/types/d2.d.ts +++ b/src/types/d2.d.ts @@ -85,6 +85,8 @@ export interface D2 { username: string; name: string; email: string; + getUserRoles(): Promise; + getUserGroups(): Promise; }; } diff --git a/src/types/modules.d.ts b/src/types/modules.d.ts index 97b6142c7..980f6d09f 100644 --- a/src/types/modules.d.ts +++ b/src/types/modules.d.ts @@ -17,3 +17,7 @@ declare module "@dhis2/d2-i18n" { declare module "@dhis2/d2-i18n" { export function t(value: string, variable?: any): string; } + +declare module "nano-memoize" { + export default function memoize(method: Function): Function; +} diff --git a/src/types/synchronization.d.ts b/src/types/synchronization.d.ts index d476e5230..dba01eba0 100644 --- a/src/types/synchronization.d.ts +++ b/src/types/synchronization.d.ts @@ -1,10 +1,15 @@ -import { MetadataImportResponse, MetadataImportStats } from "./d2"; +import { MetadataImportResponse, MetadataImportStats, MetadataImportParams } from "./d2"; import SyncReport from "../models/syncReport"; export interface SynchronizationBuilder { targetInstances: string[]; metadataIds: string[]; syncRule?: string; + syncParams: SynchronizationParams; +} + +export interface SynchronizationParams extends MetadataImportParams { + includeSharingSettings?: boolean; } export interface ExportBuilder { @@ -12,6 +17,7 @@ export interface ExportBuilder { ids: string[]; excludeRules: string[][]; includeRules: string[][]; + includeSharingSettings: boolean; } export interface MetadataPackage { @@ -60,4 +66,14 @@ export interface SynchronizationRule { enabled: boolean; lastExecuted?: Date; frequency?: string; + publicAccess: string; + userAccesses: SharingSetting[]; + userGroupAccesses: SharingSetting[]; +} + +export interface SharingSetting { + access: string; + displayName: string; + id: string; + name: string; } diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts new file mode 100644 index 000000000..ccc950a8b --- /dev/null +++ b/src/utils/permissions.ts @@ -0,0 +1,108 @@ +import axios from "axios"; +import memoize from "nano-memoize"; + +import { D2 } from "../types/d2"; + +const AppRoles: { + [key: string]: { + name: string; + description: string; + }; +} = { + CONFIGURATION_ACCESS: { + name: "METADATA_SYNC_CONFIGURATOR", + description: + "APP - This role allows to create new instances and synchronization rules in the Metadata Sync app", + }, + SYNC_RULE_EXECUTION_ACCESS: { + name: "METADATA_SYNC_EXECUTOR", + description: + "APP - This role allows to execute synchronization rules in the Metadata Sync app", + }, +}; + +export interface UserInfo { + userGroups: any[]; + id: string; + name: string; + username: string; +} + +const getUserRoles = memoize(async (d2: D2) => { + const baseUrl = d2.Api.getApi().baseUrl; + const { userCredentials } = (await axios.get(baseUrl + "/me", { + withCredentials: true, + params: { + fields: "userCredentials[userRoles[:all]]", + }, + })).data; + return userCredentials.userRoles; +}); + +export const isGlobalAdmin = async (d2: D2) => { + const userRoles = await getUserRoles(d2); + return !!userRoles.find((role: any) => + role.authorities.find((authority: string) => authority === "ALL") + ); +}; + +export const isAppConfigurator = async (d2: D2) => { + const userRoles = await getUserRoles(d2); + const globalAdmin = await isGlobalAdmin(d2); + const { name } = AppRoles.CONFIGURATION_ACCESS; + + return globalAdmin || !!userRoles.find((role: any) => role.name === name); +}; + +export const isAppExecutor = async (d2: D2) => { + const userRoles = await getUserRoles(d2); + const globalAdmin = await isGlobalAdmin(d2); + const { name } = AppRoles.SYNC_RULE_EXECUTION_ACCESS; + + return globalAdmin || !!userRoles.find((role: any) => role.name === name); +}; + +export const getUserInfo = memoize( + async (d2: D2): Promise => { + const userGroups = await d2.currentUser.getUserGroups(); + + return { + userGroups: userGroups.toArray(), + id: d2.currentUser.id, + name: d2.currentUser.name, + username: d2.currentUser.username, + }; + } +); + +export const initializeAppRoles = async (baseUrl: string) => { + for (const role in AppRoles) { + const { name, description } = AppRoles[role]; + const { userRoles } = (await axios.get(baseUrl + "/metadata", { + withCredentials: true, + params: { + userRoles: true, + filter: `name:eq:${name}`, + fields: "id", + }, + })).data as { userRoles?: { id: string }[] }; + + if (!userRoles || userRoles.length === 0) { + await axios.post( + baseUrl + "/metadata.json", + { + userRoles: [ + { + name, + description, + publicAccess: "--------", + }, + ], + }, + { + withCredentials: true, + } + ); + } + } +}; diff --git a/src/utils/synchronization.ts b/src/utils/synchronization.ts index 489fd9455..29a8c89c2 100644 --- a/src/utils/synchronization.ts +++ b/src/utils/synchronization.ts @@ -8,7 +8,8 @@ import { MetadataPackage, NestedRules, SynchronizationResult } from "../types/sy import { cleanModelName, getClassName } from "./d2"; import { isValidUid } from "d2/uid"; -const blacklistedProperties = ["user", "userAccesses", "userGroupAccesses"]; +const blacklistedProperties = ["access"]; +const userProperties = ["user", "userAccesses", "userGroupAccesses"]; export function buildNestedRules(rules: string[][] = []): NestedRules { return _(rules) @@ -18,14 +19,23 @@ export function buildNestedRules(rules: string[][] = []): NestedRules { .value(); } -export function cleanObject(element: any, excludeRules: string[][] = []): any { +export function cleanObject( + element: any, + excludeRules: string[][] = [], + includeSharingSettings: boolean +): any { const leafRules = _(excludeRules) .filter(path => path.length === 1) .map(_.first) .compact() .value(); - return _.pick(element, _.difference(_.keys(element), leafRules, blacklistedProperties)); + const propsToRemove = includeSharingSettings ? [] : userProperties; + + return _.pick( + element, + _.difference(_.keys(element), leafRules, blacklistedProperties, propsToRemove) + ); } export function cleanReferences( @@ -76,11 +86,11 @@ export async function postMetadata( try { const params: MetadataImportParams = { importMode: "COMMIT", - identifier: "AUTO", + identifier: "UID", importReportMode: "FULL", importStrategy: "CREATE_AND_UPDATE", - mergeMode: "REPLACE", - atomicMode: "NONE", + mergeMode: "MERGE", + atomicMode: "ALL", ...additionalParams, }; diff --git a/yarn.lock b/yarn.lock index a325c1742..c125e7bea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1270,6 +1270,11 @@ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw== +"@reach/auto-id@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.2.0.tgz#97f9e48fe736aa5c6f4f32cf73c1f19d005f8550" + integrity sha512-lVK/svL2HuQdp7jgvlrLkFsUx50Az9chAhxpiPwBqcS83I2pVWvXp98FOcSCCJCV++l115QmzHhFd+ycw1zLBg== + "@sinonjs/commons@^1", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.4.0": version "1.6.0" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.6.0.tgz#ec7670432ae9c8eb710400d112c201a362d83393" @@ -3462,6 +3467,11 @@ compression@^1.5.2: safe-buffer "5.1.2" vary "~1.1.2" +compute-scroll-into-view@^1.0.9: + version "1.0.11" + resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.11.tgz#7ff0a57f9aeda6314132d8994cce7aeca794fecf" + integrity sha512-uUnglJowSe0IPmWOdDtrlHXof5CTIJitfJEyITHBW6zDVOGu9Pjk5puaLM73SLcwak0L4hEjO7Td88/a6P5i7A== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -4432,6 +4442,17 @@ dotenv@6.2.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.2.0.tgz#941c0410535d942c8becf28d3f357dbd9d476064" integrity sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w== +downshift@^3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/downshift/-/downshift-3.3.4.tgz#959be157e48be2ec2bbc50f184303647fc719026" + integrity sha512-3bM11S3p78p/moyJqDPc1j357dm/C+dN+54HKuc526k5etNXvnXyxsb+Ufd2yLL6qK4QZA62DysAgtMCIsKCNA== + dependencies: + "@babel/runtime" "^7.4.5" + "@reach/auto-id" "^0.2.0" + compute-scroll-into-view "^1.0.9" + prop-types "^15.7.2" + react-is "^16.9.0" + duplexer@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"