diff --git a/i18n/en.pot b/i18n/en.pot index 13a603ff8..3d40ab88f 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-06-13T06:24:14.176Z\n" -"PO-Revision-Date: 2019-06-13T06:24:14.176Z\n" +"POT-Creation-Date: 2019-06-28T16:11:03.901Z\n" +"PO-Revision-Date: 2019-06-28T16:11:03.901Z\n" msgid "" msgstr "" @@ -65,9 +65,6 @@ msgstr "" msgid "Ok" msgstr "" -msgid "Help text" -msgstr "" - msgid "Saving..." msgstr "" @@ -190,9 +187,111 @@ msgstr "" msgid "Back" msgstr "" +msgid "General info" +msgstr "" + +msgid "Metadata" +msgstr "" + +msgid "Instance Selection" +msgstr "" + +msgid "Summary" +msgstr "" + +msgid "New synchronization rule" +msgstr "" + +msgid "Edit synchronization rule" +msgstr "" + +msgid "Cancel synchronization rule creation" +msgstr "" + +msgid "Cancel synchronization rule editing" +msgstr "" + +msgid "Name (*)" +msgstr "" + +msgid "Name" +msgstr "" + +msgid "Metadata Type" +msgstr "" + +msgid "Last update" +msgstr "" + +msgid "Selected {{difference}} elements" +msgstr "" + +msgid "Removed {{difference}} elements" +msgstr "" + +msgid "Metadata type" +msgstr "" + +msgid "Last updated date" +msgstr "" + +msgid "{{displayName}} Group" +msgstr "" + +msgid "{{displayName}} Level" +msgstr "" + +msgid "Only selected items" +msgstr "" + +msgid "Cancel synchronization rule wizard" +msgstr "" + +msgid "" +"You are about to exit the Sync Rule Creation Wizard. All your changes will " +"be lost. Are you sure you want to proceed?" +msgstr "" + +msgid "Yes" +msgstr "" + +msgid "Target instances [{{total}}]" +msgstr "" + +msgid "Destination instances" +msgstr "" + +msgid "Deleting Sync Rules" +msgstr "" + +msgid "Failed to delete some rules" +msgstr "" + +msgid "Successfully deleted {{count}} rules" +msgid_plural "Successfully deleted {{count}} rules" +msgstr[0] "" +msgstr[1] "" + msgid "Synchronizing metadata" msgstr "" +msgid "Failed to execute rule {{name}}" +msgstr "" + +msgid "Execute" +msgstr "" + +msgid "Destination Instance" +msgstr "" + +msgid "Delete Rules?" +msgstr "" + +msgid "Are you sure you want to delete {{count}} rules?" +msgid_plural "Are you sure you want to delete {{count}} rules?" +msgstr[0] "" +msgstr[1] "" + msgid "Synchronize Metadata" msgstr "" @@ -229,9 +328,6 @@ msgstr "" msgid "Synchronization Results" msgstr "" -msgid "Summary" -msgstr "" - msgid "Messages" msgstr "" @@ -244,9 +340,6 @@ msgstr "" msgid "Please select at least one element from the table to synchronize" msgstr "" -msgid "Last updated date" -msgstr "" - msgid "{{type}} Group" msgstr "" @@ -289,12 +382,6 @@ msgstr "" msgid "Network error {{error}}, check if server is up and CORS is enabled" msgstr "" -msgid "Name" -msgstr "" - -msgid "Last update" -msgstr "" - msgid "Level" msgstr "" @@ -315,3 +402,6 @@ msgstr "" msgid "This URL and username combination already exists" msgstr "" + +msgid "You need to select at least one element" +msgstr "" diff --git a/package.json b/package.json index 81ec759bf..9f2824e7f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "cryptr": "^4.0.2", "d2": "^31.1.1", "d2-manifest": "^1.0.0", - "d2-ui-components": "^0.0.18", + "d2-ui-components": "^0.0.19", "enzyme": "^3.7.0", "enzyme-adapter-react-16": "^1.6.0", "enzyme-to-json": "^3.3.4", @@ -31,7 +31,7 @@ "jest": "^23.6.0", "lodash": "^4.17.11", "material-ui": "^0.20.2", - "nano-memoize": "^1.0.3", + "nano-memoize": "^1.1.5", "postcss-rtl": "^1.3.2", "react": "^16.6.0", "react-dom": "^16.6.0", diff --git a/src/components/app/App.css b/src/components/app/App.css index 2aff3195c..b8088861a 100644 --- a/src/components/app/App.css +++ b/src/components/app/App.css @@ -8,6 +8,10 @@ body { margin: 0; } +li { + line-height: 1.75; +} + .content { margin: 4rem 15px 15px; } diff --git a/src/components/app/Root.jsx b/src/components/app/Root.jsx index 61be702d1..729056bca 100644 --- a/src/components/app/Root.jsx +++ b/src/components/app/Root.jsx @@ -10,6 +10,8 @@ import DataElementPage from "../synchronization-page/DataElementPage"; import IndicatorPage from "../synchronization-page/IndicatorPage"; import ValidationRulePage from "../synchronization-page/ValidationRulePage"; import NotificationsPage from "../notification-list-page/NotificationsPage"; +import SyncRulesWizard from "../rules-creation-page/SyncRulesWizard"; +import SyncRulesConfigurator from "../rules-list-page/SyncRulesPage"; class Root extends React.Component { static propTypes = { @@ -56,6 +58,16 @@ class Root extends React.Component { render={props => } /> + } + /> + + } + /> + } /> ); diff --git a/src/components/dropdown/Dropdown.jsx b/src/components/dropdown/Dropdown.jsx index 47ae13121..5f6115fb9 100644 --- a/src/components/dropdown/Dropdown.jsx +++ b/src/components/dropdown/Dropdown.jsx @@ -56,14 +56,24 @@ const getMaterialTheme = () => }, }); -export default function Dropdown({ items, value, onChange, label }) { +export default function Dropdown({ items, value, onChange, label, hideEmpty }) { const materialTheme = getMaterialTheme(); return ( {label} - + {!hideEmpty && {i18n.t("")}} {items.map(i => ( {i.name} @@ -80,4 +90,9 @@ Dropdown.propTypes = { onChange: PropTypes.func.isRequired, label: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + hideEmpty: PropTypes.bool, +}; + +Dropdown.defaultProps = { + displayEmpty: true, }; diff --git a/src/components/instance-creation-page/InstanceCreationPage.jsx b/src/components/instance-creation-page/InstanceCreationPage.jsx index 3ad89af80..e110618ba 100644 --- a/src/components/instance-creation-page/InstanceCreationPage.jsx +++ b/src/components/instance-creation-page/InstanceCreationPage.jsx @@ -68,11 +68,7 @@ class InstanceCreationPage extends React.Component { saveText={i18n.t("Ok")} /> - + ({ tableContainer: { marginTop: 10 }, @@ -136,24 +137,8 @@ class NotificationsPage extends React.Component { this.setState({ summaryOpen: false }); }; - // TODO: We should fix d2-ui-components instead - getValueForCollection = values => { - const namesToDisplay = _(values) - .map(value => value.displayName || value.name || value.id) - .compact() - .value(); - - return ( -
    - {namesToDisplay.map(name => ( -
  • {name}
  • - ))} -
- ); - }; - getMetadataTypes = notification => { - return this.getValueForCollection(notification.types.map(type => ({ name: type }))); + return getValueForCollection(notification.types.map(type => ({ name: type }))); }; detailsFields = [ diff --git a/src/components/rules-creation-page/SyncRulesWizard.jsx b/src/components/rules-creation-page/SyncRulesWizard.jsx new file mode 100644 index 000000000..1524f9a40 --- /dev/null +++ b/src/components/rules-creation-page/SyncRulesWizard.jsx @@ -0,0 +1,149 @@ +import React from "react"; +import PropTypes from "prop-types"; +import i18n from "@dhis2/d2-i18n"; +import { withRouter } from "react-router-dom"; +import { ConfirmationDialog, Wizard } from "d2-ui-components"; + +import PageHeader from "../page-header/PageHeader"; +import GeneralInfoStep from "./steps/GeneralInfoStep"; +import SyncRule from "../../models/syncRule"; +import MetadataStep from "./steps/MetadataSelectionStep"; +import InstanceSelectionStep from "./steps/InstanceSelectionStep"; +import SaveStep from "./steps/SaveStep"; +import { getValidationMessages } from "../../utils/validations"; + +class SyncRulesWizard extends React.Component { + static propTypes = { + d2: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, + }; + + state = { + dialogOpen: false, + syncRule: SyncRule.create(), + }; + + id = this.props.match.params.id; + isEdit = this.props.match.params.action === "edit" && this.id; + + static getStepsBaseInfo = [ + { + key: "general-info", + label: i18n.t("General info"), + component: GeneralInfoStep, + validationKeys: ["name"], + description: undefined, + help: undefined, + }, + { + key: "metadata", + label: i18n.t("Metadata"), + component: MetadataStep, + validationKeys: ["selectedIds"], + description: undefined, + help: undefined, + }, + { + key: "instance-selection", + label: i18n.t("Instance Selection"), + component: InstanceSelectionStep, + validationKeys: ["targetInstances"], + description: undefined, + help: undefined, + }, + { + key: "summary", + label: i18n.t("Summary"), + component: SaveStep, + validationKeys: [], + description: undefined, + help: undefined, + }, + ]; + + componentDidMount = async () => { + if (this.isEdit) { + const syncRule = await SyncRule.get(this.props.d2, this.id); + this.setState({ syncRule }); + } + }; + + cancelSave = () => { + this.setState({ dialogOpen: true }); + }; + + handleConfirm = () => { + this.setState({ dialogOpen: false }); + this.props.history.push("/synchronization-rules"); + }; + + handleDialogCancel = () => { + this.setState({ dialogOpen: false }); + }; + + onChange = syncRule => { + this.setState({ syncRule }); + }; + + onStepChangeRequest = async currentStep => { + return getValidationMessages( + this.props.d2, + this.state.syncRule, + currentStep.validationKeys + ); + }; + + render() { + const { dialogOpen, syncRule } = this.state; + const { d2, location } = this.props; + + const title = !this.isEdit + ? i18n.t("New synchronization rule") + : i18n.t("Edit synchronization rule"); + + const cancel = !this.isEdit + ? i18n.t("Cancel synchronization rule creation") + : i18n.t("Cancel synchronization rule editing"); + + const steps = SyncRulesWizard.getStepsBaseInfo.map(step => ({ + ...step, + props: { + d2, + syncRule, + onCancel: this.handleConfirm, + onChange: this.onChange, + }, + })); + + const urlHash = location.hash.slice(1); + const stepExists = steps.find(step => step.key === urlHash); + const firstStepKey = steps.map(step => step.key)[0]; + const initialStepKey = stepExists ? urlHash : firstStepKey; + const lastClickableStepIndex = this.isEdit ? steps.length - 1 : 0; + + return ( + + + + + + + + ); + } +} + +export default withRouter(SyncRulesWizard); diff --git a/src/components/rules-creation-page/steps/GeneralInfoStep.jsx b/src/components/rules-creation-page/steps/GeneralInfoStep.jsx new file mode 100644 index 000000000..fd5d52c56 --- /dev/null +++ b/src/components/rules-creation-page/steps/GeneralInfoStep.jsx @@ -0,0 +1,57 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { FormBuilder, Validators } from "@dhis2/d2-ui-forms"; +import { TextField } from "@dhis2/d2-ui-core"; +import i18n from "@dhis2/d2-i18n"; + +const GeneralInfoStep = props => { + const { syncRule } = props; + + const fields = [ + { + name: "name", + value: syncRule.name, + component: TextField, + props: { + floatingLabelText: i18n.t("Name (*)"), + style: { width: "100%" }, + changeEvent: "onBlur", + "data-test": "name", + }, + validators: [ + { + message: i18n.t("Field cannot be blank"), + validator(value) { + return Validators.isRequired(value); + }, + }, + ], + }, + { + name: "description", + value: syncRule.description, + component: TextField, + props: { + floatingLabelText: i18n.t("Description"), + style: { width: "100%" }, + changeEvent: "onBlur", + "data-test": "description", + }, + validators: [], + }, + ]; + + const updateFields = (field, value) => { + props.syncRule[field] = value; + }; + + return ; +}; + +GeneralInfoStep.propTypes = { + syncRule: PropTypes.object.isRequired, +}; + +GeneralInfoStep.defaultProps = {}; + +export default GeneralInfoStep; diff --git a/src/components/rules-creation-page/steps/InstanceSelectionStep.jsx b/src/components/rules-creation-page/steps/InstanceSelectionStep.jsx new file mode 100644 index 000000000..ee942458c --- /dev/null +++ b/src/components/rules-creation-page/steps/InstanceSelectionStep.jsx @@ -0,0 +1,55 @@ +import React, { useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { MultiSelector } from "d2-ui-components"; +import Instance from "../../../models/instance"; + +export const getInstances = async d2 => { + const instances = await Instance.list( + d2, + { search: "" }, + { page: 1, pageSize: 100, sorting: [] } + ); + return instances.objects.map(instance => ({ + value: instance.id, + text: `${instance.name} (${instance.url} with user ${instance.username})`, + })); +}; + +const InstanceSelectionStep = props => { + const { d2, syncRule, onChange } = props; + const [instanceOptions, setInstanceOptions] = useState([]); + const [selectedOptions, setSelectedOptions] = useState(syncRule.targetInstances); + + const changeInstances = instances => { + setSelectedOptions(instances); + onChange(syncRule.updateTargetInstances(instances)); + }; + + const parseInstances = async () => { + const instances = await getInstances(d2); + setInstanceOptions(instances); + }; + + useEffect(() => { + parseInstances(); + }, [d2]); + + return ( + + ); +}; + +InstanceSelectionStep.propTypes = { + syncRule: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, +}; + +InstanceSelectionStep.defaultProps = {}; + +export default InstanceSelectionStep; diff --git a/src/components/rules-creation-page/steps/MetadataSelectionStep.jsx b/src/components/rules-creation-page/steps/MetadataSelectionStep.jsx new file mode 100644 index 000000000..8eb3c915a --- /dev/null +++ b/src/components/rules-creation-page/steps/MetadataSelectionStep.jsx @@ -0,0 +1,311 @@ +import React from "react"; +import PropTypes from "prop-types"; +import i18n from "@dhis2/d2-i18n"; +import memoize from "nano-memoize"; +import _ from "lodash"; +import { DatePicker, ObjectsTable, withSnackbar } from "d2-ui-components"; +import { Checkbox, FormControlLabel, withStyles } from "@material-ui/core"; + +import Dropdown from "../../dropdown/Dropdown"; +import { d2ModelFactory } from "../../../models/d2ModelFactory"; +import { listByIds } from "../../../logic/metadata"; +import { d2BaseModelDetails } from "../../../utils/d2"; + +const styles = { + checkbox: { + paddingLeft: 30, + }, +}; + +class MetadataSelectionStep extends React.Component { + static propTypes = { + d2: PropTypes.object.isRequired, + syncRule: PropTypes.object.isRequired, + classes: PropTypes.object.isRequired, + snackbar: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + }; + + defaultModel = { + getInitialSorting: () => [], + getColumns: () => [ + { name: "displayName", text: i18n.t("Name"), sortable: true }, + { + name: "metadataType", + text: i18n.t("Metadata Type"), + sortable: true, + getValue: element => { + const model = this.props.d2.models[element.metadataType]; + return model ? model.displayName : element.metadataType; + }, + }, + { name: "lastUpdated", text: i18n.t("Last update"), sortable: true }, + ], + getDetails: () => d2BaseModelDetails, + getGroupFilterName: () => null, + getLevelFilterName: () => null, + getMetadataType: () => "", + getD2Model: () => ({ + displayName: "Selected elements", + modelValidations: { + lastUpdated: { type: "DATE" }, + }, + }), + }; + + state = { + model: this.defaultModel, + filters: { + lastUpdatedDate: null, + groupFilter: null, + levelFilter: null, + metadataType: "", + }, + groupFilterData: [], + levelFilterData: [], + selectedIds: [], + showOnlySelectedItems: false, + tableKey: Math.random(), + }; + + models = [ + { + name: this.props.d2.models["organisationUnit"].displayName, + id: this.props.d2.models["organisationUnit"].name, + }, + { + name: this.props.d2.models["validationRule"].displayName, + id: this.props.d2.models["validationRule"].name, + }, + { + name: this.props.d2.models["indicator"].displayName, + id: this.props.d2.models["indicator"].name, + }, + { + name: this.props.d2.models["dataElement"].displayName, + id: this.props.d2.models["dataElement"].name, + }, + ]; + + actions = [ + { + name: "details", + text: i18n.t("Details"), + multiple: false, + type: "details", + }, + ]; + + updateFilterData = memoize(async model => { + const { d2 } = this.props; + const newState = {}; + + if (model && model.getGroupFilterName()) { + const groupClass = d2ModelFactory(d2, model.getGroupFilterName()); + const groupList = await groupClass.listMethod( + d2, + { customFields: ["id", "name"] }, + { paging: false } + ); + newState.groupFilterData = groupList.objects; + } + + if (model && model.getLevelFilterName()) { + const orgUnitLevelsClass = d2ModelFactory(d2, model.getLevelFilterName()); + const orgUnitLevelsList = await orgUnitLevelsClass.listMethod( + d2, + { customFields: ["level", "name"] }, + { paging: false, sorting: ["level", "asc"] } + ); + newState.levelFilterData = orgUnitLevelsList.objects.map(e => ({ + id: e.level, + name: `${e.level}. ${e.name}`, + })); + } + + return newState; + }); + + componentDidMount() { + const { selectedIds } = this.props.syncRule; + this.setState({ selectedIds, showOnlySelectedItems: selectedIds.length > 0 }); + } + + componentDidUpdate = async (prevProps, prevState) => { + const { model, filters } = this.state; + + if (prevState.model !== model) { + this.setState({ + ...(await this.updateFilterData(model)), + filters: { + ...filters, + groupFilter: null, + levelFilter: null, + }, + }); + } + }; + + changeSelection = selectedIds => { + const { selectedIds: oldSelection, model } = this.state; + const { d2, snackbar, syncRule, onChange } = this.props; + const type = model.getD2Model(d2).plural; + + const additions = _.difference(selectedIds, oldSelection); + if (additions.length > 0) { + onChange(syncRule.addMetadataIds(type, additions)); + snackbar.info( + i18n.t("Selected {{difference}} elements", { difference: additions.length }), + { + autoHideDuration: 1000, + } + ); + } + + const removals = _.difference(oldSelection, selectedIds); + if (removals.length > 0) { + onChange(syncRule.removeMetadataIds(removals)); + snackbar.info( + i18n.t("Removed {{difference}} elements", { + difference: Math.abs(removals.length), + }), + { autoHideDuration: 1000 } + ); + } + + this.setState({ selectedIds }); + }; + + changeModelName = event => { + const { d2 } = this.props; + const { filters } = this.state; + this.setState({ + model: event.target.value ? d2ModelFactory(d2, event.target.value) : this.defaultModel, + filters: { + ...filters, + metadataType: event.target.value, + }, + }); + }; + + changeDateFilter = value => { + const { filters } = this.state; + this.setState({ filters: { ...filters, lastUpdatedDate: value } }); + }; + + changeGroupFilter = event => { + const { filters } = this.state; + this.setState({ filters: { ...filters, groupFilter: event.target.value } }); + }; + + changeLevelFilter = event => { + const { filters } = this.state; + this.setState({ filters: { ...filters, levelFilter: event.target.value } }); + }; + + showSelectedItems = event => { + this.setState({ showOnlySelectedItems: event.target.checked, tableKey: Math.random() }); + }; + + renderCustomFilters = () => { + const { d2, classes } = this.props; + const { + model, + groupFilterData, + levelFilterData, + filters, + showOnlySelectedItems, + } = this.state; + const { lastUpdatedDate, groupFilter, levelFilter } = filters; + const displayName = model.getD2Model(d2).displayName; + + return ( + + + + {!showOnlySelectedItems && ( + + )} + + {!showOnlySelectedItems && model && model.getGroupFilterName() && ( + + )} + + {!showOnlySelectedItems && model && model.getLevelFilterName() && ( + + )} + + + } + label={i18n.t("Only selected items")} + /> + + ); + }; + + list = (...params) => { + const { syncRule } = this.props; + const { model, showOnlySelectedItems } = this.state; + if (!model.listMethod || showOnlySelectedItems) { + return listByIds(...params, syncRule.selectedIds); + } else { + return model.listMethod(...params); + } + }; + + render() { + const { d2, syncRule } = this.props; + const { model, filters, tableKey } = this.state; + + return ( + + ); + } +} + +export default withSnackbar(withStyles(styles)(MetadataSelectionStep)); diff --git a/src/components/rules-creation-page/steps/SaveStep.jsx b/src/components/rules-creation-page/steps/SaveStep.jsx new file mode 100644 index 000000000..8eb6f459f --- /dev/null +++ b/src/components/rules-creation-page/steps/SaveStep.jsx @@ -0,0 +1,133 @@ +import React, { useEffect, useState } from "react"; +import _ from "lodash"; +import PropTypes from "prop-types"; +import { Button, LinearProgress, withStyles } from "@material-ui/core"; +import { ConfirmationDialog } from "d2-ui-components"; +import i18n from "@dhis2/d2-i18n"; + +import { getInstances } from "./InstanceSelectionStep"; +import { getMetadata } from "../../../utils/synchronization"; + +const LiEntry = ({ label, value, children }) => { + return ( +
  • + {label} + {value || children ? ": " : ""} + {value} + {children} +
  • + ); +}; + +const styles = () => ({ + saveButton: { + margin: 10, + backgroundColor: "#2b98f0", + color: "white", + }, +}); + +const SaveStep = props => { + const { d2, syncRule, classes, onCancel } = props; + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [metadata, updateMetadata] = useState({}); + const [instanceOptions, setInstanceOptions] = useState([]); + + const parseMetadata = async () => { + const metadata = await getMetadata(d2, syncRule.selectedIds, "id,name"); + updateMetadata(metadata); + return metadata; + }; + + const openCancelDialog = () => setCancelDialogOpen(true); + + const closeCancelDialog = () => setCancelDialogOpen(false); + + const save = async () => { + setIsSaving(true); + await syncRule.save(d2); + setIsSaving(false); + onCancel(); + }; + + const parseInstances = async () => { + const instances = await getInstances(d2); + setInstanceOptions(instances); + }; + + useEffect(() => { + parseMetadata(); + parseInstances(); + }, [syncRule]); + + return ( + + + +
      + + + + + {_.keys(metadata).map(metadataType => ( + +
        + {metadata[metadataType].map(({ id, name }) => ( + + ))} +
      +
      + ))} + + +
        + {syncRule.targetInstances.map(id => { + const instance = instanceOptions.find(e => e.value === id); + return instance ? ( + + ) : null; + })} +
      +
      +
    + + + + + {isSaving && } +
    + ); +}; + +SaveStep.propTypes = { + syncRule: PropTypes.object.isRequired, + classes: PropTypes.object.isRequired, + onCancel: PropTypes.func.isRequired, +}; + +SaveStep.defaultProps = {}; + +export default withStyles(styles)(SaveStep); diff --git a/src/components/rules-list-page/SyncRulesPage.jsx b/src/components/rules-list-page/SyncRulesPage.jsx new file mode 100644 index 000000000..21fb9814c --- /dev/null +++ b/src/components/rules-list-page/SyncRulesPage.jsx @@ -0,0 +1,249 @@ +import React from "react"; +import PropTypes from "prop-types"; +import _ from "lodash"; +import i18n from "@dhis2/d2-i18n"; +import { ConfirmationDialog, ObjectsTable, withLoading, withSnackbar } from "d2-ui-components"; +import { withRouter } from "react-router-dom"; +import { withStyles } from "@material-ui/core/styles"; + +import PageHeader from "../page-header/PageHeader"; +import SyncRule from "../../models/syncRule"; +import Instance from "../../models/instance"; +import { getValueForCollection } from "../../utils/d2-ui-components"; +import { startSynchronization } from "../../logic/synchronization"; +import SyncReport from "../../models/syncReport"; +import SyncSummary from "../sync-summary/SyncSummary"; +import Dropdown from "../dropdown/Dropdown"; + +const styles = () => ({ + tableContainer: { marginTop: -10 }, +}); + +class SyncRulesPage extends React.Component { + static propTypes = { + d2: PropTypes.object.isRequired, + snackbar: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, + loading: PropTypes.object.isRequired, + }; + static model = { + modelValidations: { + name: { type: "TEXT" }, + description: { type: "TEXT" }, + targetInstances: { type: "COLLECTION" }, + }, + }; + + state = { + tableKey: Math.random(), + toDelete: null, + allInstances: [], + targetInstanceFilter: "", + syncReport: SyncReport.create(), + syncSummaryOpen: false, + }; + initialSorting = ["name", "asc"]; + + getValueForTargetInstances = ruleData => { + const { allInstances } = this.state; + const rule = SyncRule.build(ruleData); + return getValueForCollection( + rule.targetInstances + .map(id => allInstances.find(instance => instance.id === id)) + .map(({ name }) => ({ name })) + ); + }; + + columns = [ + { name: "name", text: i18n.t("Name"), sortable: true }, + { + name: "targetInstances", + text: i18n.t("Destination instances"), + sortable: false, + getValue: this.getValueForTargetInstances, + }, + ]; + detailsFields = [ + { name: "name", text: i18n.t("Name") }, + { name: "description", text: i18n.t("Description") }, + { + name: "targetInstances", + text: i18n.t("Destination instances"), + sortable: true, + getValue: this.getValueForTargetInstances, + }, + ]; + + async componentDidMount() { + const { d2 } = this.props; + const { objects: allInstances } = await Instance.list(d2, null, null); + this.setState({ allInstances }); + } + + backHome = () => { + this.props.history.push("/"); + }; + + deleteSyncRules = rules => { + this.setState({ toDelete: rules }); + }; + + cancelDelete = () => { + this.setState({ toDelete: null }); + }; + + confirmDelete = async () => { + const { loading, d2 } = this.props; + const { toDelete } = this.state; + + loading.show(true, i18n.t("Deleting Sync Rules")); + const rules = toDelete.map(data => new SyncRule(data)); + + const results = []; + for (const rule of rules) { + results.push(await rule.remove(d2)); + } + + loading.reset(); + this.setState({ tableKey: Math.random(), toDelete: null }); + + if (_.some(results, ["status", false])) { + this.props.snackbar.error(i18n.t("Failed to delete some rules")); + } else { + this.props.snackbar.success( + i18n.t("Successfully deleted {{count}} rules", { count: toDelete.length }) + ); + } + }; + + createRule = () => { + this.props.history.push("/synchronization-rules/new"); + }; + + editRule = rule => { + this.props.history.push(`/synchronization-rules/edit/${rule.id}`); + }; + + executeRule = async ({ builder, name }) => { + const { d2, loading } = this.props; + loading.show(true, i18n.t("Synchronizing metadata")); + try { + for await (const { message, syncReport, done } of startSynchronization(d2, builder)) { + if (message) loading.show(true, message); + if (syncReport) await syncReport.save(d2); + if (done && syncReport) { + this.setState({ syncSummaryOpen: true, syncReport }); + } + } + } catch (error) { + console.error(error); + this.props.snackbar.error(i18n.t("Failed to execute rule {{name}}", { name })); + } + loading.reset(); + }; + + actions = [ + { + name: "edit", + text: i18n.t("Edit"), + multiple: false, + onClick: this.editRule, + }, + { + name: "details", + text: i18n.t("Details"), + multiple: false, + type: "details", + }, + { + name: "delete", + text: i18n.t("Delete"), + multiple: true, + onClick: this.deleteSyncRules, + }, + { + name: "execute", + text: i18n.t("Execute"), + multiple: false, + onClick: this.executeRule, + icon: "settings_input_antenna", + }, + ]; + + closeSummary = () => this.setState({ syncSummaryOpen: false }); + + changeInstanceFilter = event => this.setState({ targetInstanceFilter: event.target.value }); + + renderCustomFilters = () => { + const { allInstances, targetInstanceFilter } = this.state; + + return ( + + + + ); + }; + + render() { + const { + tableKey, + toDelete, + syncSummaryOpen, + syncReport, + targetInstanceFilter, + } = this.state; + const { d2, classes } = this.props; + + return ( + + +
    + +
    + + + + +
    + ); + } +} + +export default withLoading(withSnackbar(withRouter(withStyles(styles)(SyncRulesPage)))); diff --git a/src/components/sync-dialog/SyncDialog.jsx b/src/components/sync-dialog/SyncDialog.jsx index f697548bc..8aaf65182 100644 --- a/src/components/sync-dialog/SyncDialog.jsx +++ b/src/components/sync-dialog/SyncDialog.jsx @@ -45,10 +45,7 @@ class SyncDialog extends React.Component { loading.show(true, i18n.t("Synchronizing metadata")); try { - const builder = { - metadata: metadata, - targetInstances: targetInstances, - }; + const builder = { metadata, targetInstances }; for await (const { message, syncReport, done } of startSynchronization(d2, builder)) { if (message) loading.show(true, message); if (syncReport) await syncReport.save(d2); diff --git a/src/components/synchronization-page/OrganisationUnitPage.jsx b/src/components/synchronization-page/OrganisationUnitPage.jsx index 92d147c2e..f01b09821 100644 --- a/src/components/synchronization-page/OrganisationUnitPage.jsx +++ b/src/components/synchronization-page/OrganisationUnitPage.jsx @@ -20,7 +20,7 @@ export default class OrganisationUnitPage extends React.Component { }; getExtraFilterState = memoize(orgUnitLevelFilterValue => ({ - orgUnitLevel: orgUnitLevelFilterValue, + levelFilter: orgUnitLevelFilterValue, })); componentDidMount() { diff --git a/src/logic/metadata.ts b/src/logic/metadata.ts new file mode 100644 index 000000000..df8f3f2e0 --- /dev/null +++ b/src/logic/metadata.ts @@ -0,0 +1,51 @@ +import _ from "lodash"; + +import { D2 } from "../types/d2"; +import { TableFilters, TableList, TablePagination } from "../types/d2-ui-components"; +import { getMetadata } from "../utils/synchronization"; +import { d2BaseModelDetails } from "../utils/d2"; + +export async function listByIds( + d2: D2, + filters: TableFilters, + pagination: TablePagination, + ids: string[] +): Promise { + const { page = 1, pageSize = 20, sorting = ["id", "asc"] } = pagination || {}; + const { metadataType, fields, search = null } = filters; + const [field, direction] = sorting; + + const metadata = await getMetadata( + d2, + ids, + fields ? fields.join(",") : d2BaseModelDetails.map(e => e.name).join(",") + ); + + const objects = _(metadata) + .mapValues((obj, key) => obj.map((el: any) => ({ ...el, metadataType: key }))) + .values() + .flatten() + .filter((el: any) => + metadataType ? d2.models[el.metadataType].name === metadataType : true + ) + .value(); + + const filteredData = _.filter(objects, (o: any) => + _(o) + .keys() + .some(k => o[k].toLowerCase().includes(search ? search.toLowerCase() : "")) + ); + + const sortedData = _.orderBy( + filteredData, + [(data: any) => (data[field] ? data[field].toLowerCase() : "")], + [direction as "asc" | "desc"] + ); + + const currentlyShown = (page - 1) * pageSize; + const pageCount = Math.ceil(sortedData.length / pageSize); + const total = sortedData.length; + const paginatedData = _.slice(sortedData, currentlyShown, currentlyShown + pageSize); + + return { objects: paginatedData, pager: { page, pageCount, total } }; +} diff --git a/src/models/d2Model.ts b/src/models/d2Model.ts index 4d8a07a8a..84c8d3ad2 100644 --- a/src/models/d2Model.ts +++ b/src/models/d2Model.ts @@ -21,6 +21,8 @@ export abstract class D2Model { // Metadata Type should be defined on subclasses protected static metadataType: string; protected static groupFilterName: string; + protected static levelFilterName: string; + protected static excludeRules: string[] = []; protected static includeRules: string[] = []; @@ -94,11 +96,20 @@ export abstract class D2Model { public static getInitialSorting(): string[] { return this.initialSorting; } + + public static getGroupFilterName(): string { + return this.groupFilterName; + } + + public static getLevelFilterName(): string { + return this.levelFilterName; + } } export class OrganisationUnitModel extends D2Model { protected static metadataType = "organisationUnit"; protected static groupFilterName = "organisationUnitGroups"; + protected static levelFilterName = "organisationUnitLevels"; protected static excludeRules = [ "legendSets", @@ -130,10 +141,10 @@ export class OrganisationUnitModel extends D2Model { filters: OrganisationUnitTableFilters, pagination: TablePagination ): Promise { - const { orgUnitLevel = null } = filters || {}; + const { levelFilter = null } = filters || {}; const newFilters = { ...filters, - customFilters: _.compact([orgUnitLevel ? `level:eq:${orgUnitLevel}` : null]), + customFilters: _.compact([levelFilter ? `level:eq:${levelFilter}` : null]), }; return super.listMethod(d2, newFilters, pagination); } diff --git a/src/models/dataStore.ts b/src/models/dataStore.ts index 47c73c7ab..a31dea579 100644 --- a/src/models/dataStore.ts +++ b/src/models/dataStore.ts @@ -80,6 +80,7 @@ export async function getPaginatedData( const filteredData = _.filter(rawData, o => _(o) .keys() + .filter(k => typeof o[k] === "string") .some(k => o[k].toLowerCase().includes(search ? search.toLowerCase() : "")) ); diff --git a/src/models/instance.ts b/src/models/instance.ts index 2dbf773ec..ab4f8b58e 100644 --- a/src/models/instance.ts +++ b/src/models/instance.ts @@ -90,7 +90,7 @@ export default class Instance { public async save(d2: D2): Promise { const instance = await this.encryptPassword(); - const exists = instance.data.id; + const exists = !!instance.data.id; const element = exists ? instance.data : { ...instance.data, id: generateUid() }; if (exists) await instance.remove(d2); diff --git a/src/models/syncReport.ts b/src/models/syncReport.ts index 7cb189461..0914c58a2 100644 --- a/src/models/syncReport.ts +++ b/src/models/syncReport.ts @@ -64,15 +64,12 @@ export default class SyncReport { } public async save(d2: D2): Promise { - console.debug("Start saving SyncReport to dataStore"); - const exists = this.syncReport.id; + const exists = !!this.syncReport.id; const element = exists ? this.syncReport : { ...this.syncReport, id: generateUid() }; if (exists) await this.remove(d2); await saveDataStore(d2, `${dataStoreKey}-${element.id}`, this.results); await saveData(d2, dataStoreKey, element); - - console.debug("Finish saving SyncReport to dataStore", element); } public async remove(d2: D2): Promise { diff --git a/src/models/syncRule.ts b/src/models/syncRule.ts new file mode 100644 index 000000000..943f40679 --- /dev/null +++ b/src/models/syncRule.ts @@ -0,0 +1,171 @@ +import _ from "lodash"; +import { generateUid } from "d2/uid"; + +import { deleteData, getDataById, getPaginatedData, saveData } from "./dataStore"; +import { D2 } from "../types/d2"; +import { SyncRuleTableFilters, TableList, TablePagination } from "../types/d2-ui-components"; +import { MetadataPackage, SynchronizationRule } from "../types/synchronization"; +import { Validation } from "../types/validations"; + +const dataStoreKey = "rules"; + +export default class SyncRule { + private readonly syncRule: SynchronizationRule; + + constructor(syncRule: SynchronizationRule) { + this.syncRule = { + id: generateUid(), + ..._.pick(syncRule, ["id", "name", "description", "originInstance", "builder"]), + }; + } + + public get name(): string { + return this.syncRule.name; + } + + public set name(name: string) { + this.syncRule.name = name; + } + + public get description(): string { + return this.syncRule.description || ""; + } + + public set description(description: string) { + this.syncRule.description = description; + } + + public addMetadataIds(type: string, ids: string[]): SyncRule { + const original = this.syncRule.builder.metadata[type]; + const updated = original ? [...original, ...ids] : [...ids]; + return SyncRule.build({ + ...this.syncRule, + builder: { + ...this.syncRule.builder, + metadata: { + ...this.syncRule.builder.metadata, + [type]: updated, + }, + }, + }); + } + + public removeMetadataIds(ids: string[]): SyncRule { + const metadata = _.clone(this.syncRule.builder.metadata); + for (const type in metadata) _.pullAll(metadata[type], ids); + return SyncRule.build({ + ...this.syncRule, + builder: { + ...this.syncRule.builder, + metadata, + }, + }); + } + + public get metadata(): MetadataPackage { + return this.syncRule.builder.metadata || {}; + } + + public get selectedIds(): string[] { + return ( + _(this.syncRule.builder.metadata) + .values() + .flatten() + .value() || [] + ); + } + + public updateTargetInstances(targetInstances: string[]): SyncRule { + return SyncRule.build({ + ...this.syncRule, + builder: { + ...this.syncRule.builder, + targetInstances, + }, + }); + } + + public get targetInstances(): string[] { + return this.syncRule.builder.targetInstances; + } + + public static create(): SyncRule { + return new SyncRule({ + id: "", + name: "", + description: "", + originInstance: "", + builder: { + targetInstances: [], + metadata: {}, + }, + }); + } + + public static build(syncRule: SynchronizationRule | undefined): SyncRule { + return syncRule ? new SyncRule(syncRule) : this.create(); + } + + public static async get(d2: D2, id: string): Promise { + const data = await getDataById(d2, dataStoreKey, id); + return this.build(data); + } + + public static async list( + d2: D2, + filters: SyncRuleTableFilters, + pagination: TablePagination + ): Promise { + const { targetInstanceFilter } = filters; + const data = await getPaginatedData(d2, dataStoreKey, filters, pagination); + return targetInstanceFilter + ? { + ...data, + objects: _.filter(data.objects, e => + e.builder.targetInstances.includes(targetInstanceFilter) + ), + } + : data; + } + + public async save(d2: D2): Promise { + const exists = !!this.syncRule.id; + const element = exists ? this.syncRule : { ...this.syncRule, id: generateUid() }; + + if (exists) await this.remove(d2); + await saveData(d2, dataStoreKey, element); + } + + public async remove(d2: D2): Promise { + await deleteData(d2, dataStoreKey, this.syncRule); + } + + public async validate(): Promise { + return _.pickBy({ + name: _.compact([ + !this.name.trim() + ? { + key: "cannot_be_blank", + namespace: { field: "name" }, + } + : null, + ]), + selectedIds: _.compact([ + this.selectedIds.length === 0 + ? { + key: "cannot_be_empty", + namespace: {}, + } + : null, + ]), + targetInstances: _.compact([ + this.targetInstances.length === 0 + ? { + key: "cannot_be_empty", + namespace: {}, + } + : null, + ]), + }); + } +} diff --git a/src/types/d2-ui-components.d.ts b/src/types/d2-ui-components.d.ts index 2158a43ad..3f5edfd9e 100644 --- a/src/types/d2-ui-components.d.ts +++ b/src/types/d2-ui-components.d.ts @@ -16,16 +16,21 @@ export interface TableFilters { groupFilter?: string; customFilters?: string[]; customFields?: string[]; + metadataType?: string; } export interface OrganisationUnitTableFilters extends TableFilters { - orgUnitLevel?: string; + levelFilter?: string; } export interface SyncReportTableFilters extends TableFilters { statusFilter?: string; } +export interface SyncRuleTableFilters extends TableFilters { + targetInstanceFilter?: string; +} + export interface TablePagination { page?: number; pageSize?: number; diff --git a/src/types/synchronization.d.ts b/src/types/synchronization.d.ts index fa53cf6ac..c28b6788c 100644 --- a/src/types/synchronization.d.ts +++ b/src/types/synchronization.d.ts @@ -51,3 +51,11 @@ export interface SynchronizationState { syncReport?: SyncReport; done?: boolean; } + +export interface SynchronizationRule { + id?: string; + name: string; + description?: string; + originInstance: string; + builder: SynchronizationBuilder; +} diff --git a/src/utils/d2-ui-components.tsx b/src/utils/d2-ui-components.tsx new file mode 100644 index 000000000..5a6bfed83 --- /dev/null +++ b/src/utils/d2-ui-components.tsx @@ -0,0 +1,17 @@ +import React, { ReactNode } from "react"; +import _ from "lodash"; + +export const getValueForCollection = (values: any): ReactNode => { + const namesToDisplay = _(values) + .map(value => value.displayName || value.name || value.id) + .compact() + .value(); + + return ( +
      + {namesToDisplay.map(name => ( +
    • {name}
    • + ))} +
    + ); +}; diff --git a/src/utils/synchronization.ts b/src/utils/synchronization.ts index 98373a703..ef47a553c 100644 --- a/src/utils/synchronization.ts +++ b/src/utils/synchronization.ts @@ -38,7 +38,11 @@ export function cleanReferences( return _.intersection(_.keys(references), rules); } -export async function getMetadata(d2: D2, elements: string[]): Promise { +export async function getMetadata( + d2: D2, + elements: string[], + fields: string = ":all" +): Promise { const promises = []; for (let i = 0; i < elements.length; i += 100) { const requestUrl = d2.Api.getApi().baseUrl + "/metadata.json"; @@ -47,15 +51,17 @@ export async function getMetadata(d2: D2, elements: string[]): Promise result.data)); + const response = await Promise.all(promises); + const results = _.deepMerge({}, ...response.map(result => result.data)); + if (results.system) delete results.system; + return results; } export async function postMetadata( diff --git a/src/utils/validations.js b/src/utils/validations.js index 01e649e3e..6999e0d0c 100644 --- a/src/utils/validations.js +++ b/src/utils/validations.js @@ -5,10 +5,11 @@ const translations = { cannot_be_blank: namespace => i18n.t("Field {{field}} cannot be blank", namespace), url_username_combo_already_exists: () => i18n.t("This URL and username combination already exists"), + cannot_be_empty: () => i18n.t("You need to select at least one element"), }; -export async function getValidationMessages(d2, instance, validationKeys) { - const validationObj = await instance.validate(d2); +export async function getValidationMessages(d2, model, validationKeys) { + const validationObj = await model.validate(d2); return _(validationObj) .at(validationKeys) diff --git a/yarn.lock b/yarn.lock index f2f6e1c06..10d4ccac6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3952,10 +3952,10 @@ d2-manifest@^1.0.0: minimist "^1.1.0" readline-sync "^1.4.1" -d2-ui-components@^0.0.18: - version "0.0.18" - resolved "https://registry.yarnpkg.com/d2-ui-components/-/d2-ui-components-0.0.18.tgz#81d985ca0c914d965021bc3913dcc91584f2d6a3" - integrity sha512-KE7J6t4XIz+bU0x4nH9AwsWUK2Laqc6ckZwdRVsrdTRaSe/zE3YLOoMJixfwE+TuHaZU8AGrRuQSlt80xdmvNA== +d2-ui-components@^0.0.19: + version "0.0.19" + resolved "https://registry.yarnpkg.com/d2-ui-components/-/d2-ui-components-0.0.19.tgz#80f8613811934433d24b0afb990f6029e3ff6640" + integrity sha512-zOnxhrdjR8IjnJcxpsmN7uT3mVNMkdk6Lr7oB1hGoMD6Bh1/BzuMEIFl0bGyPbSRFGvOWSj3ZNpAkUp2lZZMow== dependencies: "@date-io/moment" "^1.0.2" "@dhis2/d2-i18n" "^1.0.3" @@ -3969,7 +3969,7 @@ d2-ui-components@^0.0.18: lodash "^4.17.11" material-ui-pickers "^2.1.2" moment "^2.22.2" - nano-memoize "^1.0.3" + nano-memoize "^1.1.5" node-sass "^4.11.0" throttle-debounce "^2.1.0" @@ -9267,10 +9267,10 @@ nan@^2.12.1, nan@^2.13.2, nan@^2.9.2: resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== -nano-memoize@^1.0.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/nano-memoize/-/nano-memoize-1.1.3.tgz#3ce885f76898384ac78d39fe185b6419f71f8e12" - integrity sha512-+0yGTtwV70h7GourgGsy/AlxrQxqIvJdJjMCuBln+v9lc+u+STuEAscPc4YLgT7uVc+Su/osREG1Do9C/L5OVA== +nano-memoize@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/nano-memoize/-/nano-memoize-1.1.5.tgz#70de03b987a0435d5bd7be9425cce8c74ac440bc" + integrity sha512-AV4GIsQBJU8jpYWMClIm9cxSWRXZtgbkkaSXz9mSpTrJFLkMN3eXkTJDeO4SykHomjckiYE4Ba7UELto7KRMpA== nanomatch@^1.2.9: version "1.2.13"