diff --git a/package.json b/package.json index b4c822c..4470781 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "d2-user-extended-app", - "version": "0.6.0", + "version": "0.7.0", "description": "DHIS2 Extended User app", "main": "src/index.html", "license": "GPL-3.0", diff --git a/src/List/List.component.js b/src/List/List.component.js index f93b9b2..91c600c 100644 --- a/src/List/List.component.js +++ b/src/List/List.component.js @@ -508,6 +508,7 @@ const List = React.createClass({ - {this.state.orgunitassignment.open ? ( + {this.state.orgunitassignment.open && + this.state.orgunitassignment.field === "organisationUnits" ? ( + ) : null} + + {this.state.orgunitassignment.open && + this.state.orgunitassignment.field === "dataViewOrganisationUnits" ? ( + ) : null} diff --git a/src/List/context.actions.js b/src/List/context.actions.js index 9e44997..72653b9 100644 --- a/src/List/context.actions.js +++ b/src/List/context.actions.js @@ -98,36 +98,37 @@ const contextActions = [ primary: true, }, { - name: "copyInUser", + name: "copy_in_user", multiple: false, icon: "content_copy", onClick: user => copyInUserStore.setState({ user, open: true }), allowed: checkAccess(["update"]), }, { - name: "assignToOrgUnitsCapture", + name: "assign_to_org_units_capture", multiple: true, icon: "business", - onClick: users => assignToOrgUnits(users, "organisationUnits", "assignToOrgUnitsCapture"), + onClick: users => + assignToOrgUnits(users, "organisationUnits", "assign_to_org_units_capture"), allowed: checkAccess(["update"]), }, { - name: "assignToOrgUnitsOutput", + name: "assign_to_org_units_output", multiple: true, icon: "business", onClick: users => - assignToOrgUnits(users, "dataViewOrganisationUnits", "assignToOrgUnitsOutput"), + assignToOrgUnits(users, "dataViewOrganisationUnits", "assign_to_org_units_output"), allowed: checkAccess(["update"]), }, { - name: "assignRoles", + name: "assign_roles", multiple: true, icon: "assignment", onClick: users => userRolesAssignmentDialogStore.setState({ users, open: true }), allowed: checkAccess(["update"]), }, { - name: "assignGroups", + name: "assign_groups", icon: "group_add", multiple: true, onClick: users => userGroupsAssignmentDialogStore.setState({ users, open: true }), @@ -161,7 +162,7 @@ const contextActions = [ onClick: datasets => deleteUserStore.delete(datasets), }, { - name: "replicateUser", + name: "replicate_user", icon: "content_copy", multiple: false, allowed: hasReplicateAuthority, diff --git a/src/List/list.store.js b/src/List/list.store.js index 7b17794..7311f1e 100644 --- a/src/List/list.store.js +++ b/src/List/list.store.js @@ -4,12 +4,12 @@ import Store from "d2-ui/lib/store/Store"; import _ from "lodash"; import appState from "../App/appStateStore"; -import { getList } from "../models/userHelpers"; +import { getUserList } from "../models/userList"; const orderForQuery = modelName => modelName === "organisationUnitLevel" ? "level:ASC" : "name:iasc"; -const columns = [ +export const columns = [ { name: "username", sortable: false }, { name: "firstName", sortable: true }, { name: "surname", sortable: true }, @@ -40,11 +40,11 @@ export default Store.create({ this.listSourceSubject .concatAll() .combineLatest(columnObservable) - .subscribe(([modelCollection, columns]) => { + .subscribe(([usersResponse, columns]) => { this.setState({ tableColumns: columns, - pager: modelCollection.pager, - list: modelCollection.toArray().map(user => ({ + pager: usersResponse.pager, + list: usersResponse.users.map(user => ({ ...user, ...(!user.userCredentials ? {} @@ -98,7 +98,7 @@ export default Store.create({ filter(options, complete, error) { getD2().then(d2 => { const { filters, ...listOptions } = options; - const listSearchPromise = getList(d2, filters, listOptions); + const listSearchPromise = getUserList(d2, filters, listOptions); this.listSourceSubject.onNext(Observable.fromPromise(listSearchPromise)); complete(`list with filters '${filters}' is loading`); }); diff --git a/src/List/organisation-unit-dialog/OrgUnitDialog.component.js b/src/List/organisation-unit-dialog/OrgUnitDialog.component.js index 8200adc..c80ab4e 100644 --- a/src/List/organisation-unit-dialog/OrgUnitDialog.component.js +++ b/src/List/organisation-unit-dialog/OrgUnitDialog.component.js @@ -101,7 +101,8 @@ class OrgUnitDialog extends React.Component { } render() { - const { root, title, models } = this.props; + const { root, title, models, filteringByNameLabel, orgUnitsSelectedLabel } = this.props; + const styles = { dialog: { minWidth: 875, @@ -144,6 +145,8 @@ class OrgUnitDialog extends React.Component { roots={this.props.roots} selected={this.state.selected} intersectionPolicy={true} + filteringByNameLabel={filteringByNameLabel} + orgUnitsSelectedLabel={orgUnitsSelectedLabel} /> ); @@ -158,6 +161,8 @@ OrgUnitDialog.propTypes = { title: PropTypes.string.isRequired, onOrgUnitAssignmentSaved: PropTypes.func.isRequired, onOrgUnitAssignmentError: PropTypes.func.isRequired, + filteringByNameLabel: PropTypes.string.isRequired, + orgUnitsSelectedLabel: PropTypes.string.isRequired, }; OrgUnitDialog.contextTypes = { diff --git a/src/components/CopyInUserDialog.component.js b/src/components/CopyInUserDialog.component.js index bbf3a20..5ed163e 100644 --- a/src/components/CopyInUserDialog.component.js +++ b/src/components/CopyInUserDialog.component.js @@ -8,7 +8,7 @@ import { getPayload } from "../models/userHelpers"; function getTitle(getTranslation, users) { const usernames = users && users.map(user => user.userCredentials.username); const info = usernames ? _m.joinString(getTranslation, usernames, 3, ", ") : "..."; - return getTranslation("copyInUser") + ": " + info; + return getTranslation("copy_in_user") + ": " + info; } function CopyInUserDialog(props, context) { diff --git a/src/components/ImportExport.component.js b/src/components/ImportExport.component.js index 4112354..535424b 100644 --- a/src/components/ImportExport.component.js +++ b/src/components/ImportExport.component.js @@ -11,7 +11,7 @@ import FileSaver from "file-saver"; import moment from "moment"; import fileDialog from "file-dialog"; -import { exportToCsv, importFromCsv } from "../models/userHelpers"; +import { exportToCsv, exportTemplateToCsv, importFromCsv } from "../models/userHelpers"; import snackActions from "../Snackbar/snack.actions"; import ModalLoadingMask from "./ModalLoadingMask.component"; @@ -23,6 +23,12 @@ class ImportExport extends React.Component { onImport: PropTypes.func.isRequired, maxUsers: PropTypes.number.isRequired, settings: PropTypes.object.isRequired, + allColumns: PropTypes.arrayOf( + React.PropTypes.shape({ + text: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }) + ).isRequired, }; state = { isMenuOpen: false, anchorEl: null, isProcessing: false }; @@ -64,17 +70,33 @@ class ImportExport extends React.Component { try { const csvString = await exportToCsv(d2, columns, filterOptions, { orgUnitsField }); - const blob = new Blob([csvString], { type: "text/plain;charset=utf-8" }); - const datetime = moment().format("YYYY-MM-DD_HH-mm-ss"); - const filename = `users-${datetime}.csv`; - FileSaver.saveAs(blob, filename); - snackActions.show({ message: `${this.t("table_exported")}: ${filename}` }); + this.saveCsv(csvString, "users"); + } finally { + this.closeMenu(); + this.setState({ isProcessing: false }); + } + }; + + exportEmptyTemplate = async () => { + this.setState({ isProcessing: true }); + + try { + const csvString = await exportTemplateToCsv(d2); + this.saveCsv(csvString, "empty-user-template"); } finally { this.closeMenu(); this.setState({ isProcessing: false }); } }; + saveCsv = (contents, name) => { + const blob = new Blob([contents], { type: "text/plain;charset=utf-8" }); + const datetime = moment().format("YYYY-MM-DD_HH-mm-ss"); + const filename = `${name}-${datetime}.csv`; + FileSaver.saveAs(blob, filename); + snackActions.show({ message: `${this.t("table_exported")}: ${filename}` }); + }; + importFromCsv = () => { const { onImport, maxUsers, settings } = this.props; const orgUnitsField = settings.get("organisationUnitsField"); @@ -95,7 +117,13 @@ class ImportExport extends React.Component { render() { const { d2 } = this.props; const { isMenuOpen, anchorEl, isProcessing } = this.state; - const { popoverConfig, closeMenu, importFromCsv, exportToCsvAndSave } = this; + const { + popoverConfig, + closeMenu, + importFromCsv, + exportToCsvAndSave, + exportEmptyTemplate, + } = this; const { t } = this; return ( @@ -120,13 +148,18 @@ class ImportExport extends React.Component { } + primaryText={t("import")} + onClick={importFromCsv} + /> + } primaryText={t("export")} onClick={exportToCsvAndSave} /> } - primaryText={t("import")} - onClick={importFromCsv} + primaryText={t("export_empty_template")} + onClick={exportEmptyTemplate} /> diff --git a/src/components/ImportTable.component.js b/src/components/ImportTable.component.js index 9a3f131..3b39d74 100644 --- a/src/components/ImportTable.component.js +++ b/src/components/ImportTable.component.js @@ -465,6 +465,20 @@ class ImportTable extends React.Component { /> ), }); + } else if (field === "disabled") { + return { + name: field, + component: Toggle, + props: { + name: field, + defaultToggled: value, + onToggle: (event, isInputChecked) => { + this.onUpdateField(user.id, field, isInputChecked); + }, + style: { width: "100%" }, + }, + validators, + }; } else { const extraProps = { changeEvent: "onBlur" }; return this.getTextField(field, value, { @@ -496,6 +510,7 @@ class ImportTable extends React.Component { organisationUnits: templateUser.attributes.organisationUnits, dataViewOrganisationUnits: templateUser.attributes.dataViewOrganisationUnits, email: templateUser.attributes.email, + disabled: templateUser.attributes.disabled, }; } else { newUser = { diff --git a/src/components/MultipleSelector.component.js b/src/components/MultipleSelector.component.js index be48081..94fc6ac 100644 --- a/src/components/MultipleSelector.component.js +++ b/src/components/MultipleSelector.component.js @@ -76,15 +76,16 @@ class MultipleSelector extends React.Component { } titleByField = { - userGroups: "assignGroups", - userRoles: "assignRoles", - organisationUnitsCapture: "assignToOrgUnitsCapture", - dataViewOrganisationUnits: "assignToOrgUnitsOutput", + userGroups: "assign_groups", + userRoles: "assign_roles", + organisationUnits: "assign_to_org_units_capture", + dataViewOrganisationUnits: "assign_to_org_units_output", }; renderForm() { const { field, options, orgUnitRoots } = this.props; const { selected } = this.state; + const t = this.getTranslation.bind(this); switch (field) { case "userGroups": @@ -102,7 +103,18 @@ class MultipleSelector extends React.Component { onChange={this.onMultiSelectChange} /> ); - case "organisationUnitsCapture": + case "organisationUnits": + return ( + + ); case "dataViewOrganisationUnits": return ( ); default: diff --git a/src/components/OrgUnitForm.js b/src/components/OrgUnitForm.js index 26026c7..1ecbabc 100644 --- a/src/components/OrgUnitForm.js +++ b/src/components/OrgUnitForm.js @@ -19,6 +19,8 @@ class OrgUnitForm extends React.Component { searchValue: "", originalRoots: this.props.roots, rootOrgUnits: this.props.roots, + filteringByNameLabel: this.props.filteringByNameLabel, + orgUnitsSelectedLabel: this.props.orgUnitsSelectedLabel, groups: [], levels: [], loading: false, @@ -131,7 +133,13 @@ class OrgUnitForm extends React.Component { return
this.context.d2.i18n.getTranslation('determining_your_root_orgunits')
; } - const { root, models, intersectionPolicy } = this.props; + const { + root, + models, + intersectionPolicy, + filteringByNameLabel, + orgUnitsSelectedLabel, + } = this.props; const styles = { wrapper: { position: "relative", @@ -173,9 +181,7 @@ class OrgUnitForm extends React.Component { this._searchOrganisationUnits(event.target.value)} - floatingLabelText={this.context.d2.i18n.getTranslation( - "filter_organisation_units_capture_by_name" - )} + floatingLabelText={filteringByNameLabel} fullWidth /> @@ -200,9 +206,7 @@ class OrgUnitForm extends React.Component {
- {`${this.props.selected.length} ${this.getTranslation( - "organisation_units_capture_selected" - )}`} + {`${this.props.selected.length} ${orgUnitsSelectedLabel}`}
{this.renderRoots()} @@ -215,6 +219,8 @@ OrgUnitForm.propTypes = { roots: PropTypes.arrayOf(PropTypes.object).isRequired, selected: PropTypes.arrayOf(PropTypes.object).isRequired, intersectionPolicy: PropTypes.bool, + filteringByNameLabel: PropTypes.string, + orgUnitsSelectedLabel: PropTypes.string, }; OrgUnitForm.defaultProps = { diff --git a/src/components/OrgUnitsFilter.component.js b/src/components/OrgUnitsFilter.component.js index b4f5105..66befd5 100644 --- a/src/components/OrgUnitsFilter.component.js +++ b/src/components/OrgUnitsFilter.component.js @@ -102,6 +102,7 @@ class OrgUnitsFilter extends React.Component { render() { const { title, styles } = this.props; const { dialogOpen, selected } = this.state; + const t = this.getTranslation.bind(this); return (
@@ -120,6 +121,16 @@ class OrgUnitsFilter extends React.Component { roots={this.state.roots} selected={this.state.selected} intersectionPolicy={true} + filteringByNameLabel={ + title.includes("organisation units capture") + ? t("filter_organisation_units_capture_by_name") + : t("filter_organisation_units_output_by_name") + } + orgUnitsSelectedLabel={ + title.includes("organisation units capture") + ? t("organisation_units_capture_selected") + : t("organisation_units_output_selected") + } /> diff --git a/src/components/ReplicateUserFromTable.component.js b/src/components/ReplicateUserFromTable.component.js index ccdf6a9..12baf74 100644 --- a/src/components/ReplicateUserFromTable.component.js +++ b/src/components/ReplicateUserFromTable.component.js @@ -13,7 +13,7 @@ class ReplicateUserFromTable extends React.Component { "firstName", "surname", "email", - "organisationUnitsCapture", + "organisationUnits", "dataViewOrganisationUnits", ]; @@ -52,7 +52,7 @@ class ReplicateUserFromTable extends React.Component { render() { const { onRequestClose } = this.props; const { userToReplicate } = this.state; - const title = this.t("replicate_user", { + const title = this.t("replicate_user_title", { user: userToReplicate ? `${userToReplicate.displayName} (${userToReplicate.username})` : "", diff --git a/src/components/ReplicateUserFromTemplate.component.js b/src/components/ReplicateUserFromTemplate.component.js index f732281..5453668 100644 --- a/src/components/ReplicateUserFromTemplate.component.js +++ b/src/components/ReplicateUserFromTemplate.component.js @@ -172,7 +172,7 @@ class ReplicateUserFromTemplate extends React.Component { isValid, validate, } = this.state; - const title = this.getTranslation("replicate_user", { + const title = this.getTranslation("replicate_user_title", { user: userToReplicate ? `${userToReplicate.displayName} (${userToReplicate.username})` : "", diff --git a/src/components/UserGroupsDialog.component.js b/src/components/UserGroupsDialog.component.js index 03eceb3..c2050be 100644 --- a/src/components/UserGroupsDialog.component.js +++ b/src/components/UserGroupsDialog.component.js @@ -42,7 +42,7 @@ function getPayload(allUserGroups, pairs) { function getTitle(getTranslation, users) { const usernames = users && users.map(user => user.userCredentials.username); const info = usernames ? _m.joinString(getTranslation, usernames, 3, ", ") : "..."; - return getTranslation("assignGroups") + ": " + info; + return getTranslation("assign_groups") + ": " + info; } function UserGroupsDialog(props, context) { diff --git a/src/components/UserRolesDialog.component.js b/src/components/UserRolesDialog.component.js index 5cf7f87..3199855 100644 --- a/src/components/UserRolesDialog.component.js +++ b/src/components/UserRolesDialog.component.js @@ -19,7 +19,7 @@ function getPayload(allUserRoles, pairs) { function getTitle(getTranslation, users) { const usernames = users && users.map(user => user.userCredentials.username); const info = usernames ? _m.joinString(getTranslation, usernames, 3, ", ") : "..."; - return getTranslation("assignRoles") + ": " + info; + return getTranslation("assign_roles") + ": " + info; } function UserRolesDialog(props, context) { diff --git a/src/components/batch-models-multi-select/CopyInUserBatchModelsMultiSelect.component.js b/src/components/batch-models-multi-select/CopyInUserBatchModelsMultiSelect.component.js index 69aca4e..b9050d6 100644 --- a/src/components/batch-models-multi-select/CopyInUserBatchModelsMultiSelect.component.js +++ b/src/components/batch-models-multi-select/CopyInUserBatchModelsMultiSelect.component.js @@ -210,9 +210,10 @@ export default class CopyInUserBatchModelsMultiSelectComponent extends React.Com const parentName = this.props.parents[0].name; const options = _(allChildren || []) .sortBy("name") - .map(obj => ({ value: obj.id, text: obj.name })) + .map(obj => ({ value: obj.id, text: `${obj.name} (${obj.userCredentials.username})` })) .filter(obj => obj.text !== parentName) .value(); + return ( toArray(getChildren(parent)), - childrenFields: childrenFields || "id,name", + childrenFields: childrenFields || "id,name,userCredentials[username]", getPayload, }); } diff --git a/src/i18n/i18n_module_ar.properties b/src/i18n/i18n_module_ar.properties index 00e95b3..62bdd1d 100644 --- a/src/i18n/i18n_module_ar.properties +++ b/src/i18n/i18n_module_ar.properties @@ -1,16 +1,17 @@ actions=Actions assign_all=Assign all -assignGroups=Assign to groups -assignRoles=Assign roles -assignToOrgUnitsCapture=Assign to organisation units cature -assignToOrgUnitsOutput=Assign to organisation units output -replicateUser=Replicate user +assign_groups=Assign to groups +assign_roles=Assign roles +assign_to_org_units_capture=Assign to organisation units capture +assign_to_org_units_output=Assign to organisation units output +replicate_user=Replicate user cancel=CANCEL close=Close -copyInUser=Copy in User +copy_in_user=Copy in user remove=Remove enable=Enable disable=Disable +export_empty_template=Export empty template email=Email phone_number=Phone number apply=APPLY @@ -30,7 +31,8 @@ enable_error=Error enabling users: $$error$$ disable_error=Error disabling users: $$error$$ edit=Edit filter_group=Filter by group -filter_organisation_units_capture_by_name=Filtering organisation units capture by name +filter_organisation_units_capture_by_name=Filter organisation units capture by name +filter_organisation_units_output_by_name=Filter organisation units output by name filter_role=Filter by role filter_by_organisation_units_capture=Filter by organisation units capture filter_by_organisation_units_output=Filter by organisation units output @@ -52,6 +54,7 @@ organisation_unit_group=مجموعة وحدة المنظمة organisation_unit_level=Organisation unit level organisation_units=OU Capture organisation_units_capture_selected=Organisation units capture selected +organisation_units_output_selected=Organisation units output selected save=SAVE search=Search search_by_name=Search by name @@ -87,7 +90,7 @@ value_required=Value is required replicate=Replicate replicate_user_from_template=From template replicate_user_from_table=From table -replicate_user=Replicate $$user$$ +replicate_user_title=Replicate $$user$$ metadata_error=Error on metadata action metadata_error_description=There was an error while posting metadata. Those are the details: replicate_successful=User '$$user$$' replicated successfully $$n$$ times diff --git a/src/i18n/i18n_module_en.properties b/src/i18n/i18n_module_en.properties index 9aa931a..65ee17e 100644 --- a/src/i18n/i18n_module_en.properties +++ b/src/i18n/i18n_module_en.properties @@ -1,16 +1,17 @@ actions=Actions assign_all=Assign all -assignGroups=Assign to groups -assignRoles=Assign roles -assignToOrgUnitsCapture=Assign to organisation units capture -assignToOrgUnitsOutput=Assign to organisation units output -replicateUser=Replicate user +assign_groups=Assign to groups +assign_roles=Assign roles +assign_to_org_units_capture=Assign to organisation units capture +assign_to_org_units_output=Assign to organisation units output +replicate_user=Replicate user cancel=CANCEL close=Close -copyInUser=Copy in User +copy_in_user=Copy in user remove=Remove enable=Enable disable=Disable +export_empty_template=Export empty template email=Email phone_number=Phone number apply=APPLY @@ -30,7 +31,8 @@ enable_error=Error enabling users: $$error$$ disable_error=Error disabling users: $$error$$ edit=Edit filter_group=Filter by group -filter_organisation_units_capture_by_name=Filtering organisation units capture by name +filter_organisation_units_capture_by_name=Filter organisation units capture by name +filter_organisation_units_output_by_name=Filter organisation units output by name filter_role=Filter by role filter_by_organisation_units_capture=Filter by organisation units capture filter_by_organisation_units_output=Filter by organisation units output @@ -48,10 +50,11 @@ disabled=Disabled no_roots_found=No roots found of_page=of org_unit_assignment=Organisation unit assignment -organisation_unit_group=Organisation Unit Group +organisation_unit_group=Organisation unit group organisation_unit_level=Organisation unit level organisation_units=OU Capture organisation_units_capture_selected=Organisation units capture selected +organisation_units_output_selected=Organisation units output selected save=SAVE search=Search search_by_name=Search by name @@ -87,7 +90,7 @@ value_required=Value is required replicate=Replicate replicate_user_from_template=From template replicate_user_from_table=From table -replicate_user=Replicate $$user$$ +replicate_user_title=Replicate $$user$$ metadata_error=Error on metadata action metadata_error_description=There was an error while posting metadata. Those are the details: replicate_successful=User '$$user$$' replicated successfully $$n$$ times diff --git a/src/i18n/i18n_module_es.properties b/src/i18n/i18n_module_es.properties index 69e17b7..c7b6a38 100644 --- a/src/i18n/i18n_module_es.properties +++ b/src/i18n/i18n_module_es.properties @@ -1,13 +1,13 @@ actions=Acciones assign_all=Assignar todos -assignGroups=Asignar a grupos -assignRoles=Asignar a roles -assignToOrgUnitsCapture=Asignar a unidades organizativas -assignToOrgUnitsOutput=Asignar a unidades organizativas de salida -replicateUser=Replicar usuario +assign_groups=Asignar a grupos +assign_roles=Asignar a roles +assign_to_org_units_capture=Asignar a la captura de unidades organizativas +assign_to_org_units_output=Asignar a la salida de las unidades organizativas +replicate_user=Replicar usuario cancel=CANCELAR close=Cerrar -copyInUser=Copiar en usuario +copy_in_user=Copiar en usuario remove=Eliminar enable=Habilitar disable=Deshabilitar @@ -17,6 +17,7 @@ apply=APLICAR create=Crear created=Creadas data_view_organisation_units=UO salida +export_empty_template=Exportar plantilla vacía deselect=Deseleccionar deselect_all=Deseleccionar todo details=Detalles @@ -30,10 +31,11 @@ enable_error=Error habilitando usuarios: $$error$$ disable_error=Error deshabilitando usuarios: $$error$$ edit=Editar filter_group=Filtrar por grupo -filter_organisation_units_capture_by_name=Filtrar por unidad organizativa +filter_organisation_units_capture_by_name=Filtrar las unidades organizativas capturadas por nombre +filter_organisation_units_output_by_name=Filtrar la salida de las unidades organizativas por nombre filter_role=Filtrar rol -filter_by_organisation_units_capture=Filtrar por unidad organizativa -filter_by_organisation_units_output=Filtrar por unidad organizativa de salida +filter_by_organisation_units_capture=Filtrar por captura de unidades organizativas +filter_by_organisation_units_output=Filtrar por salida de unidades organizativas href=API Url hidden_by_filters=ocultos por filtros id=Id @@ -50,8 +52,9 @@ of_page=de org_unit_assignment=Asignació de unidades organizativas organisation_unit_group=Grupo de unidades organizativas organisation_unit_level=Nivel de unidad organizativa -organisation_units=Unidades organizativas -organisation_units_capture_selected=Unidades organizativas seleccionadas +organisation_units=Captura UO +organisation_units_capture_selected=Captura de unidades organizativas seleccionada +organisation_units_output_selected=Salida de unidades organizativas seleccionada save=GUARDAR search=Search search_by_name=Buscar por nombre @@ -87,7 +90,7 @@ value_required=Valor obligatorio replicate=Replicar replicate_user_from_template=Con plantilla replicate_user_from_table=Con tabla -replicate_user=Replicar $$user$$ +replicate_user_title=Replicar $$user$$ metadata_error=Error al enviar metadatos metadata_error_description=Se ha producido un error al enviar metadatos. Detalles: replicate_successful=Usuario '$$user$$' replicado con éxito $$n$$ veces diff --git a/src/i18n/i18n_module_fr.properties b/src/i18n/i18n_module_fr.properties index 1db9632..9cba48b 100644 --- a/src/i18n/i18n_module_fr.properties +++ b/src/i18n/i18n_module_fr.properties @@ -1,16 +1,17 @@ actions=Actions assign_all=Assign all -assignGroups=Assign to groups -assignRoles=Assign roles -assignToOrgUnitsCapture=Assign to organisation units capture -assignToOrgUnitsOutput=Assign to organisation units output -replicateUser=Replicate user +assign_groups=Assign to groups +assign_roles=Assign roles +assign_to_org_units_capture=Assign to organisation units capture +assign_to_org_units_output=Assign to organisation units output +replicate_user=Replicate user cancel=CANCEL close=Close -copyInUser=Copy in User +copy_in_user=Copy in user remove=Remove enable=Enable disable=Disable +export_empty_template=Export empty template email=Email phone_number=Phone number apply=APPLY @@ -30,7 +31,8 @@ enable_error=Error enabling users: $$error$$ disable_error=Error disabling users: $$error$$ edit=Edit filter_group=Filter by group -filter_organisation_units_capture_by_name=Filtering organisation units capture by name +filter_organisation_units_capture_by_name=Filter organisation units capture by name +filter_organisation_units_output_by_name=Filter organisation units output by name filter_role=Filter by role filter_by_organisation_units_capture=Filter by organisation units capture filter_by_organisation_units_output=Filter by organisation units output @@ -50,8 +52,9 @@ of_page=of org_unit_assignment=Organisation unit assignment organisation_unit_group=Groupe d'Unités d'Organisation organisation_unit_level=Niveau d'unité d'organisation -organisation_units=unités d'organisation -organisation_units_capture_selected=Organisation units selected +organisation_units=Capture d'UO +organisation_units_capture_selected=Organisation units capture selected +organisation_units_output_selected=Organisation units output selected save=SAVE search=Search search_by_name=Rechercher par nom @@ -87,7 +90,7 @@ value_required=Value is required replicate=Replicate replicate_user_from_template=From template replicate_user_from_table=From table -replicate_user=Replicate $$user$$ +replicate_user_title=Replicate $$user$$ metadata_error=Error on metadata action metadata_error_description=There was an error while posting metadata. Those are the details: replicate_successful=User '$$user$$' replicated successfully $$n$$ times diff --git a/src/models/userHelpers.js b/src/models/userHelpers.js index e9de25d..021e8cd 100644 --- a/src/models/userHelpers.js +++ b/src/models/userHelpers.js @@ -5,6 +5,8 @@ import Papa from "papaparse"; import { generateUid } from "d2/lib/uid"; import { mapPromise, listWithInFilter } from "../utils/dhis2Helpers"; +import { getUserList } from "./userList"; +import { columns } from "../List/list.store"; // Delimiter to use in multiple-value fields (roles, groups, orgUnits) const fieldSplitChar = "||"; @@ -21,31 +23,18 @@ const queryFields = [ "id", "userCredentials[username,disabled,userRoles[id,displayName],lastLogin]", "lastUpdated", - "created", - "displayDescription", - "code", - "publicAccess", "access", "href", - "level", "userGroups[id,displayName,publicAccess]", "organisationUnits[id,code,shortName,displayName]", "dataViewOrganisationUnits[id,code,shortName,displayName]", ].join(","); -/* - Limit Uids to avoid 413 Request too large - maxUids = (maxSize - urlAndOtherParamsSize) / (uidSize + encodedCommaSize) -*/ -const maxUids = (4096 - 1000) / (11 + 3); - const requiredPropertiesOnImport = ["username", "password", "firstName", "surname"]; const propertiesIgnoredOnImport = ["id", "created", "lastUpdated", "lastLogin"]; -const columnsIgnoredOnExport = ["disabled"]; - -const userCredentialsFields = ["username", "password", "userRoles"]; +const userCredentialsFields = ["username", "password", "userRoles", "disabled"]; const columnNameFromPropertyMapping = { id: "ID", @@ -63,6 +52,7 @@ const columnNameFromPropertyMapping = { userGroups: "Groups", organisationUnits: "OUCapture", dataViewOrganisationUnits: "OUOutput", + disabled: "Disabled", }; const propertyFromColumnNameMapping = _.invert(columnNameFromPropertyMapping); @@ -132,20 +122,6 @@ async function getAssociations(d2, objs, { orgUnitsField }) { return _.fromPairs(pairs); } -function buildD2Filter(filters) { - return filters.map(([key, [operator, value]]) => - [ - key, - operator, - _.isArray(value) - ? `[${_(value) - .take(maxUids) - .join(",")}]` - : value, - ].join(":") - ); -} - function getColumnNameFromProperty(property) { return columnNameFromPropertyMapping[property] || property; } @@ -214,6 +190,7 @@ function getPlainUser(user, { orgUnitsField }) { user.dataViewOrganisationUnits, orgUnitsField ), + disabled: userCredentials.disabled, }; } @@ -337,7 +314,10 @@ async function getUsersFromCsv(d2, file, csv, { maxUsers, orgUnitsField }) { const data = userRows.map((userRow, rowIndex) => getPlainUserFromRow(userRow, modelValuesByField, rowIndex + 2) ); - const users = data.map(o => o.user); + const users = data.map(o => { + const disableStr = (o.user.disabled || "").toLowerCase(); + return { ...o.user, disabled: disableStr === "true" }; + }); const userWarnings = _(data) .flatMap(o => o.warnings) .value(); @@ -376,7 +356,7 @@ function parseResponse(response, payload) { } function getUserPayloadFromPlainAttributes(baseUser, userFields) { - const clean = obj => _.omitBy(obj, value => !value); + const clean = obj => _.omitBy(obj, value => value === undefined || value === null); const userRoot = { ...baseUser, @@ -547,78 +527,30 @@ async function saveCopyInUsers(d2, users, copyUserGroups) { } } -/* Return an array of users from DHIS2 API. - - filters: Object with `field` as keys, `[operator, value]` as values. - listOptions: Object to be passed directory to d2.models.users.list(...) -*/ -function getList(d2, filters, listOptions) { - const model = d2.models.user; - const activeFilters = _(filters) - .pickBy() - .toPairs() - .value(); - - /* Filtering over nested fields (table[.table].field) in N-to-N relationships (for - example: userCredentials.userRoles.id), fails in dhis2 < v2.30. So we need to make - separate calls to the API for those filters and use the returned IDs to build - the final, paginated call. */ - - const [preliminarFilters, normalFilters] = _.partition( - activeFilters, - ([key, [operator, value]]) => operator === "in" && key.match(/\./) - ); - - if (d2.system.version.minor >= 30) { - const listFilters = buildD2Filter(normalFilters.concat(preliminarFilters)); - return model.list({ - paging: true, - fields: queryFields, - filter: _(listFilters).isEmpty() ? "name:ne:default" : listFilters, - ...listOptions, - }); - } - - const preliminarD2Filters$ = preliminarFilters.map(preliminarFilter => - model - .list({ - paging: false, - fields: "id", - filter: buildD2Filter([preliminarFilter]), - }) - .then(collection => collection.toArray().map(obj => obj.id)) - .then( - ids => - `id:in:[${_(ids) - .take(maxUids) - .join(",")}]` - ) - ); - - return Promise.all(preliminarD2Filters$).then(preliminarD2Filters => { - const filters = buildD2Filter(normalFilters).concat(preliminarD2Filters); +/* Get users from Dhis2 API and export given columns to a CSV string */ +async function exportToCsv(d2, columns, filterOptions, { orgUnitsField }) { + const { filters, ...listOptions } = { ...filterOptions, pageSize: 1e6 }; + const { users } = await getUserList(d2, filters, listOptions); + const userRows = users.map(user => _.at(getPlainUser(user, { orgUnitsField }), columns)); + const header = columns.map(getColumnNameFromProperty); + const table = [header, ...userRows]; - return model.list({ - paging: true, - fields: queryFields, - filter: _(filters).isEmpty() ? "name:ne:default" : filters, - ...listOptions, - }); - }); + return Papa.unparse(table); } -/* Get users from Dhis2 API and export given columns to a CSV string */ -async function exportToCsv(d2, columns, filterOptions, { orgUnitsField }) { - const { filters, ...listOptions } = { ...filterOptions, paging: false }; - const users = await getList(d2, filters, listOptions); - const columnsToExport = _(columns) - .without(...columnsIgnoredOnExport) +async function exportTemplateToCsv(d2) { + const columnsAdded = ["password"]; + const columnsRemoved = ["lastUpdated", "created", "lastLogin"]; + const columnKeysToExport = _(columns) + .map(column => column.name) + .difference(columnsRemoved) + .union(columnsAdded) .value(); - const userRows = users - .toArray() - .map(user => _.at(getPlainUser(user, { orgUnitsField }), columnsToExport)); - const header = columnsToExport.map(getColumnNameFromProperty); - const table = [header, ...userRows]; + const header = _(columnKeysToExport) + .map(getColumnNameFromProperty) + .compact() + .value(); + const table = [header]; return Papa.unparse(table); } @@ -706,8 +638,8 @@ function getPayload(parentUser, destUsers, fields, updateStrategy) { } export { - getList, exportToCsv, + exportTemplateToCsv, importFromCsv, updateUsers, saveUsers, diff --git a/src/models/userList.js b/src/models/userList.js new file mode 100644 index 0000000..00bf5f5 --- /dev/null +++ b/src/models/userList.js @@ -0,0 +1,214 @@ +import _ from "lodash"; +import { mapPromise } from "../utils/dhis2Helpers"; + +/* Get user lists with support for DHIS2 API bugs and to avoid 414-URI-too-long errors */ + +const queryFields = [ + "id", + "email", + "displayName|rename(name)", + "shortName", + "firstName", + "surname", + "created", + "lastUpdated", + "access", + "href", + "userCredentials[username,disabled,userRoles[id,displayName],lastLogin]", + "userGroups[id,displayName,publicAccess]", + "organisationUnits[id,code,shortName,displayName]", + "dataViewOrganisationUnits[id,code,shortName,displayName]", +]; + +// (maxSize - urlAndOtherParamsSize) / (uidSize + encodedCommaSize) +const maxUids = Math.floor((4096 - 200) / (11 + 3)); + +// type FiltersObject = Record +// type ListOptions = {canManage?: boolean, query?: string, order?: string, page: number, pageSize: number } + +/* Return an array of users from DHIS2 API. */ +export async function getUserList(d2, filtersObject, listOptions) { + // Note these problems/shortcomings when using the /users API endpoint: + // + // 1) When passing simultaneously params `query` and `filter`, only the first page is returned. + // + // 2) Except for the first page, only one record is returned. + // + // 3) After a user update, the pager does not reflect the change for some time (10/20 seconds). + // + // 4) As we have to use a GET request, so we can easily hit the 414-URI-too-long errors when filtering. + // + // So we need to perform a custom filtering and pagination. Do separate queries for params + // `query` and `filter`, and manually sort and paginate the intersection of users. + // Also, split the requests whenever necessary to avoid 414 errors. + + const { canManage } = listOptions; + const query = (listOptions.query || "").trim(); + const filters = getFiltersFromObject(filtersObject); + const hasQuery = query !== "" || canManage !== undefined; + const hasFilters = !_.isEmpty(filters); + + if (!hasFilters) { + return getUserListStandard(d2, filtersObject, listOptions); + } + + const usersByQuery = hasQuery ? await getD2Users(d2, { query, canManage }) : null; + const usersByFilters = hasFilters ? await getFilteredUsers(d2, filters) : null; + const allUsers = !hasQuery && !hasFilters ? await getD2Users(d2, {}) : null; + + const groupOfUserIds = [usersByQuery, usersByFilters, allUsers] + .filter(users => users !== null) + .map(users => users.map(user => user.id)); + + const allIds = _.intersection(...groupOfUserIds); + const sortedUsers = await getSortedUsers(d2, allIds, listOptions.order); + const { pager, objects: pageObjects } = paginate(sortedUsers, listOptions); + + const users = await request(pageObjects, usersGroup => { + return getD2Users(d2, { + fields: queryFields, + filters: [{ field: "id", operator: "in", value: usersGroup.map(u => u.id) }], + paging: false, + }); + }); + + return { pager, users: sortObjectsByReference(pageObjects, users, "id") }; +} + +async function request(objects, getRequest) { + const objectsList = await mapPromise(getChunks(objects), objectsGroup => { + return getRequest(objectsGroup); + }); + + return _.flatten(objectsList); +} + +function getChunks(objs) { + return _(objs) + .chunk(maxUids) + .take(50) // Limit the total chunks + .value(); +} + +function sortObjectsByReference(referenceObjects, unsortedObjects, idField) { + const ids = referenceObjects.map(obj => obj[idField]); + + return _(unsortedObjects) + .keyBy(obj => obj[idField]) + .at(...ids) + .compact() + .value(); +} + +async function getSortedUsers(d2, userIds, order) { + if (order) { + const [orderField = "name", d2Direction = "asc"] = order.split(":"); + const direction = d2Direction.toLowerCase().includes("desc") ? "desc" : "asc"; + + const users = await request(userIds, userIdsGroup => { + return getD2Users(d2, { + fields: ["id", orderField], + filters: [{ field: "id", operator: "in", value: userIdsGroup }], + }); + }); + + return _.orderBy(users, [u => (u[orderField] || "").toString().toLowerCase()], [direction]); + } else { + return userIds.map(id => ({ id })); + } +} + +// Record -> Array<{field: string, operator: Operator, Value: value}> +function getFiltersFromObject(filtersObject) { + return _(filtersObject) + .pickBy() + .toPairs() + .map(([field, [operator, value]]) => ({ field, operator, value })) + .value(); +} + +// To be used when DHIS2 fixes all the API bugs */ +async function getUserListStandard(d2, filtersObject, listOptions) { + const filter = buildD2Filter(getFiltersFromObject(filtersObject)); + const collection = await d2.models.user.list({ + ..._.pick(listOptions, ["order", "page", "pageSize", "query", "canManage"]), + ...(!_.isEmpty(filter) ? { filter } : {}), + fields: queryFields.join(","), + paging: true, + }); + + return { pager: collection.pager, users: collection.toArray() }; +} + +/* Manually paginate a collection of objects */ +function paginate(objects, options) { + const { page = 1, pageSize = 50 } = options; + const start = (page - 1) * pageSize; + const pageCount = Math.ceil(objects.length / options.pageSize); + const paginatedObjects = _.sortBy(objects.slice(start, start + pageSize)); + + const pager = { + page, + pageSize, + pageCount, + total: objects.length, + hasNextPage: () => page < pageCount, + hasPreviousPage: () => page > 1, + }; + + return { pager, objects: paginatedObjects }; +} + +// Check if in:[...] filters size exceed the limit. Assume the values are a collection of UIDs */ +function filtersExceedLimit(filters) { + const inFilters = filters.filter(({ operator }) => operator === "in"); + const totalValues = _.sum(inFilters.map(({ value }) => value.length)); + return totalValues > maxUids; +} + +async function getD2Users(d2, options) { + const { fields = ["id"], paging = false, query, filters = [], ...otherOptions } = options; + + const listOptions = { + ...otherOptions, + fields: fields.join(","), + paging, + ...(query ? { query } : {}), + ...(!_.isEmpty(filters) ? { filter: buildD2Filter(filters) } : {}), + }; + + const collection = await d2.models.user.list(listOptions); + + return collection.toArray(); +} + +async function getFilteredUsers(d2, filters) { + if (!filtersExceedLimit(filters)) { + return getD2Users(d2, { filters }); + } else { + // We have too many in-filters values, make a one request per active filter and intersect the result. + const [inFilters, nonInFilters] = _.partition(filters, ({ operator }) => operator === "in"); + const groupsOfUserIds = []; + + for (const inFilter of inFilters) { + const userIdsForFilter = []; + + // For each filter, we can still have too many UIDs, split the requests and perform a union + for (const valuesGroup of getChunks(inFilter.value)) { + const filtersForGroup = [...nonInFilters, { ...inFilter, value: valuesGroup }]; + const usersForGroup = await getD2Users(d2, { filters: filtersForGroup }); + userIdsForFilter.push(usersForGroup.map(u => u.id)); + } + groupsOfUserIds.push(_.union(...userIdsForFilter)); + } + const ids = _.intersection(...groupsOfUserIds); + return ids.map(id => ({ id })); + } +} + +function buildD2Filter(filtersList) { + return filtersList.map(({ field, operator, value }) => { + const filterValue = _.isArray(value) ? `[${value.join(",")}]` : value; + return [field, operator, filterValue].join(":"); + }); +} diff --git a/src/utils/dhis2Helpers.js b/src/utils/dhis2Helpers.js index fdf09c8..7e6e5b1 100644 --- a/src/utils/dhis2Helpers.js +++ b/src/utils/dhis2Helpers.js @@ -10,10 +10,13 @@ function getOrgUnitsRoots() { .toPromise(); } -function mapPromise(items, mapper) { - const reducer = (promise, item) => - promise.then(mappedItems => mapper(item).then(res => mappedItems.concat([res]))); - return items.reduce(reducer, Promise.resolve([])); +async function mapPromise(inputValues, mapper) { + const output = []; + for (const value of inputValues) { + const res = await mapper(value); + output.push(res); + } + return output; } /* Perform a model.list with a filter=FIELD:in:[VALUE1,VALUE2,...], breaking values to