diff --git a/.eslintrc.js b/.eslintrc.js index e80f150..f67ad9e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,7 +15,10 @@ module.exports = { "no-console": "off", "@typescript-eslint/camelcase": "off", "@typescript-eslint/explicit-function-return-type": ["off"], - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], "react/prop-types": "off", "react/display-name": "off", "no-unused-expressions": "off", @@ -23,6 +26,7 @@ module.exports = { "no-useless-constructor": "off", "no-unexpected-multiline": "off", "default-case": "off", + "no-debugger": "warn", "@typescript-eslint/no-use-before-define": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-empty-interface": "off", diff --git a/i18n/en.pot b/i18n/en.pot index c49dc00..8e4b6a9 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: 2021-06-16T11:22:27.225Z\n" -"PO-Revision-Date: 2021-06-16T11:22:27.225Z\n" +"POT-Creation-Date: 2021-06-23T06:35:51.611Z\n" +"PO-Revision-Date: 2021-06-23T06:35:51.611Z\n" msgid "Organisation unit is assigned to more than one option" msgstr "" @@ -29,6 +29,9 @@ msgstr "" msgid "Unknown group set" msgstr "" +msgid "Project saved" +msgstr "" + msgid "Organisation unit {{orgUnitId}} not found" msgstr "" @@ -65,11 +68,17 @@ msgstr "" msgid "Code" msgstr "" +msgid "Save" +msgstr "" + +msgid "Clear form" +msgstr "" + msgid "Back" msgstr "" msgid "Help" msgstr "" -msgid "Training session 2" +msgid "Training session 3" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 71059b3..82bc48c 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2021-06-16T11:22:27.225Z\n" +"POT-Creation-Date: 2021-06-23T06:35:51.611Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -29,6 +29,9 @@ msgstr "" msgid "Unknown group set" msgstr "" +msgid "Project saved" +msgstr "" + msgid "Organisation unit {{orgUnitId}} not found" msgstr "" @@ -65,11 +68,17 @@ msgstr "" msgid "Code" msgstr "" +msgid "Save" +msgstr "" + +msgid "Clear form" +msgstr "" + msgid "Back" msgstr "Volver" msgid "Help" msgstr "Ayuda" -msgid "Training session 2" +msgid "Training session 3" msgstr "" diff --git a/src/compositionRoot.ts b/src/compositionRoot.ts index 3819ed2..fb7525c 100644 --- a/src/compositionRoot.ts +++ b/src/compositionRoot.ts @@ -7,6 +7,7 @@ import { GetOrgUnitByIdUseCase } from "./domain/usecases/GetOrgUnitByIdUseCase"; import { GetOrgUnitsByIdsUseCase } from "./domain/usecases/GetOrgUnitsByIdsUseCase"; import { GetOrgUnitsByLevelUseCase } from "./domain/usecases/GetOrgUnitsByLevelUseCase"; import { GetOrgUnitsUseCase } from "./domain/usecases/GetOrgUnitsUseCase"; +import { SaveOrgUnitUseCase } from "./domain/usecases/SaveOrgUnitUseCase"; import { D2Api } from "./types/d2-api"; export function getCompositionRoot(api: D2Api) { @@ -20,6 +21,7 @@ export function getCompositionRoot(api: D2Api) { getById: new GetOrgUnitByIdUseCase(orgUnitRepository), getByIds: new GetOrgUnitsByIdsUseCase(orgUnitRepository), getByLevel: new GetOrgUnitsByLevelUseCase(orgUnitRepository), + save: new SaveOrgUnitUseCase(orgUnitRepository), }, groupSets: { getOptions: new GetGroupSetOptionsUseCase(groupSetRepository), diff --git a/src/data/OrgUnitDHIS2Repository.ts b/src/data/OrgUnitDHIS2Repository.ts index 0eafd79..181f518 100644 --- a/src/data/OrgUnitDHIS2Repository.ts +++ b/src/data/OrgUnitDHIS2Repository.ts @@ -1,7 +1,27 @@ -import { Id } from "../domain/entities/Base"; +import _ from "lodash"; +import { Id, NamedRef } from "../domain/entities/Base"; import { OrgUnit } from "../domain/entities/OrgUnit"; import { OrgUnitRepository } from "../domain/repositories/OrgUnitRepository"; -import { D2Api, D2ApiDefinition, D2OrganisationUnitSchema, Model } from "../types/d2-api"; +import { D2Api, D2ApiDefinition, D2OrganisationUnitSchema, Model, Ref } from "../types/d2-api"; + +interface D2OrgUnit { + id: Id; + code: string; + name: string; + shortName: string; + level: number; + path: string; + parent: Ref; + openingDate?: string; + closedDate?: string; + children: NamedRef[]; + organisationUnitGroups: NamedRef[]; +} + +type Options = Pick< + Parameters["get"]>[0], + "filter" +>; export class OrgUnitDHIS2Repository implements OrgUnitRepository { constructor(private api: D2Api) {} @@ -23,23 +43,71 @@ export class OrgUnitDHIS2Repository implements OrgUnitRepository { return this.request({ filter: { level: { eq: `${level}` } } }); } - private async request( - options: Omit< - Parameters["get"]>[0], - "paging" | "fields" - > + async save(orgUnit: OrgUnit) { + const existingD2OrgUnit = await this.getD2OrgUnitFromId(orgUnit.id); + const orgUnitGroupsToSave = await this.getOrgUnitGroupsToSave(orgUnit, existingD2OrgUnit); + const orgUnitToSave = { ...existingD2OrgUnit, ...this.buildD2OrgUnit(orgUnit) }; + const metadata = { + organisationUnits: [orgUnitToSave], + organisationUnitGroups: orgUnitGroupsToSave, + }; + const res = await this.api.metadata.post(metadata).getData(); + + if (res.status !== "OK") throw new Error("Cannot save organisation unit"); + } + + private async getOrgUnitGroupsToSave( + orgUnit: OrgUnit, + existingD2OrgUnit: D2OrgUnit | undefined ) { + const existingOrgUnitGroups = await this.getOrgUnitGroups(orgUnit, existingD2OrgUnit); + + const orgUnitGroupsUpdated = existingOrgUnitGroups.map(ouGroup => { + const ouWasInGroup = _(ouGroup.organisationUnits).some(ou => ou.id === orgUnit.id); + const ouInGroup = _(orgUnit.organisationUnitGroups).some(oug => oug.id === ouGroup.id); + const orgUnitGroupHasChanged = ouWasInGroup !== ouInGroup; + + if (!orgUnitGroupHasChanged) { + // As there are no changes, there is no need to save this org unit group. + return null; + } else { + const organisationUnitsUpdated = ouInGroup + ? ouGroup.organisationUnits.concat([orgUnit]) + : ouGroup.organisationUnits.filter(ou => ou.id !== orgUnit.id); + + return { ...ouGroup, organisationUnits: organisationUnitsUpdated }; + } + }); + + return _.compact(orgUnitGroupsUpdated); + } + + /* Get the union of org unit groups both for the existing org unit and the unsaved record */ + private async getOrgUnitGroups(orgUnit: OrgUnit, existingD2OrgUnit: D2OrgUnit | undefined) { + const existingOrgUnitGroupRefs = existingD2OrgUnit + ? existingD2OrgUnit.organisationUnitGroups + : []; + const newOrgUnitGroupRefs = orgUnit.organisationUnitGroups; + const allOrgUnitGroupRefs = _.concat(existingOrgUnitGroupRefs, newOrgUnitGroupRefs); + + const { organisationUnitGroups } = await this.api.metadata + .get({ + organisationUnitGroups: { + fields: { $owner: true }, + filter: { id: { in: _.uniq(allOrgUnitGroupRefs.map(oug => oug.id)) } }, + }, + }) + .getData(); + + return organisationUnitGroups; + } + + private async getD2OrgUnits(options: Options): Promise { const { objects } = await this.api.models.organisationUnits .get({ ...options, fields: { - id: true, - code: true, - name: true, - level: true, - openingDate: true, - closedDate: true, - parent: { id: true, name: true }, + $owner: true, children: { id: true, name: true }, organisationUnitGroups: { id: true, name: true }, }, @@ -47,12 +115,32 @@ export class OrgUnitDHIS2Repository implements OrgUnitRepository { }) .getData(); - return objects.map(data => - OrgUnit.create({ - ...data, - openingDate: data.openingDate ? new Date(data.openingDate) : undefined, - closedDate: data.closedDate ? new Date(data.closedDate) : undefined, - }) - ); + return objects; + } + + private async getD2OrgUnitFromId(id: Id): Promise { + const orgUnits = await this.getD2OrgUnits({ filter: { id: { eq: id } } }); + return _.first(orgUnits); + } + + private async request(options: Options): Promise { + const d2OrgUnits = await this.getD2OrgUnits(options); + return d2OrgUnits.map(data => this.buildOrgUnit(data)); + } + + private buildOrgUnit(d2OrgUnit: D2OrgUnit): OrgUnit { + return OrgUnit.create({ + ...d2OrgUnit, + openingDate: d2OrgUnit.openingDate ? new Date(d2OrgUnit.openingDate) : undefined, + closedDate: d2OrgUnit.closedDate ? new Date(d2OrgUnit.closedDate) : undefined, + }); + } + + private buildD2OrgUnit(orgUnit: OrgUnit): Partial { + return { + ...orgUnit, + openingDate: orgUnit.openingDate?.toISOString(), + closedDate: orgUnit.closedDate?.toISOString(), + }; } } diff --git a/src/domain/entities/OrgUnit.ts b/src/domain/entities/OrgUnit.ts index df9820f..cd7e4f5 100644 --- a/src/domain/entities/OrgUnit.ts +++ b/src/domain/entities/OrgUnit.ts @@ -47,4 +47,18 @@ export class OrgUnit { static create(data: OrgUnitData) { return new OrgUnit(data); } + + set(key: T, value: OrgUnitData[T]): OrgUnit { + return OrgUnit.create({ ...this, [key]: value }); + } + + setOrganisationUnitGroups(selected: NamedRef[], unselected: NamedRef[]): OrgUnit { + const selectedIds = new Set(selected.map(group => group.id)); + const unselectedIds = new Set(unselected.map(group => group.id)); + const newOrgUnitGroups = _(this.organisationUnitGroups) + .filter(group => !unselectedIds.has(group.id) && !selectedIds.has(group.id)) + .concat(selected) + .value(); + return OrgUnit.create({ ...this, organisationUnitGroups: newOrgUnitGroups }); + } } diff --git a/src/domain/repositories/OrgUnitRepository.ts b/src/domain/repositories/OrgUnitRepository.ts index 9c8efd8..efc2334 100644 --- a/src/domain/repositories/OrgUnitRepository.ts +++ b/src/domain/repositories/OrgUnitRepository.ts @@ -6,4 +6,5 @@ export interface OrgUnitRepository { getById(id: Id): Promise; getByIds(ids: Id[]): Promise; getByLevel(level: number): Promise; + save(orgUnit: OrgUnit): Promise; } diff --git a/src/domain/usecases/SaveOrgUnitUseCase.ts b/src/domain/usecases/SaveOrgUnitUseCase.ts new file mode 100644 index 0000000..633354c --- /dev/null +++ b/src/domain/usecases/SaveOrgUnitUseCase.ts @@ -0,0 +1,10 @@ +import { OrgUnit } from "../entities/OrgUnit"; +import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; + +export class SaveOrgUnitUseCase { + constructor(private orgUnitRepository: OrgUnitRepository) {} + + async execute(orgUnit: OrgUnit) { + return this.orgUnitRepository.save(orgUnit); + } +} diff --git a/src/webapp/components/group-set-dropdown/GroupSetDropdown.tsx b/src/webapp/components/group-set-dropdown/GroupSetDropdown.tsx index dc12adc..021bae8 100644 --- a/src/webapp/components/group-set-dropdown/GroupSetDropdown.tsx +++ b/src/webapp/components/group-set-dropdown/GroupSetDropdown.tsx @@ -13,34 +13,46 @@ export interface GroupSetDropdownProps { groupSet: OrgUnitGroupSet; orgUnit: OrgUnit; disabled?: boolean; + onChange?(selectedGroups: NamedRef[], unselectedGroups: NamedRef[]): void; } export const GroupSetDropdown: React.FC = ({ groupSet, orgUnit, disabled, + onChange, }) => { const { compositionRoot } = useAppContext(); - const [groupOptions, setGroupOptions] = useState([]); + const [groups, setGroups] = useState([]); + + const groupOptions = namedRefToOption(groups); + const intersection = _.intersection( orgUnit.organisationUnitGroups.map(({ id }) => id), groupOptions.map(({ value }) => value) ); useEffect(() => { - compositionRoot.groupSets.getOptions.execute(groupSet).then(options => { - setGroupOptions(namedRefToOption(options)); + compositionRoot.groupSets.getOptions.execute(groupSet).then(groups => { + setGroups(groups); }); }, [compositionRoot, groupSet]); + function notifyChange(groupId: string | undefined) { + if (!onChange) return; + const selectedGroup = groups.filter(group => group.id === groupId); + const unselectedGroups = groups.filter(group => group.id !== groupId); + onChange(selectedGroup, unselectedGroups); + } + return ( diff --git a/src/webapp/components/org-unit-detail/OrgUnitDetail.tsx b/src/webapp/components/org-unit-detail/OrgUnitDetail.tsx index 74be8d5..6aee4f8 100644 --- a/src/webapp/components/org-unit-detail/OrgUnitDetail.tsx +++ b/src/webapp/components/org-unit-detail/OrgUnitDetail.tsx @@ -1,3 +1,4 @@ +import { useSnackbar } from "@eyeseetea/d2-ui-components"; import { Paper } from "@material-ui/core"; import React, { useEffect, useState } from "react"; import styled from "styled-components"; @@ -10,16 +11,36 @@ import { ProjectLevelDetails } from "./levels/ProjectLevelDetails"; interface OrgUnitsDetailProps { orgUnitId: Id; + onSave(orgUnit: OrgUnit): void; } -const OrgUnitDetail: React.FC = ({ orgUnitId }) => { +const OrgUnitDetail: React.FC = ({ orgUnitId, onSave }) => { const { compositionRoot } = useAppContext(); const [orgUnit, setOrgUnit] = useState(); + const [orgUnitToSave, setOrgUnitToSave] = useState(); useEffect(() => { compositionRoot.orgUnits.getById.execute(orgUnitId).then(setOrgUnit); }, [compositionRoot, orgUnitId]); + const snackbar = useSnackbar(); + + useEffect(() => { + async function saveOrgUnit(orgUnit: OrgUnit) { + try { + await compositionRoot.orgUnits.save.execute(orgUnit); + onSave(orgUnit); + setOrgUnit(orgUnit); + setOrgUnitToSave(undefined); + snackbar.success(i18n.t("Project saved")); + } catch (err) { + snackbar.error(err.message); + } + } + + if (orgUnitToSave) saveOrgUnit(orgUnitToSave); + }, [orgUnit, setOrgUnit, orgUnitToSave, setOrgUnitToSave, compositionRoot, snackbar, onSave]); + if (!orgUnit) { return ( @@ -32,15 +53,18 @@ const OrgUnitDetail: React.FC = ({ orgUnitId }) => {

{formatOrgUnitLevel(orgUnit.type)}

- +
); }; -const DetailsByLevel: React.FC<{ orgUnit: OrgUnit }> = ({ orgUnit }) => { +const DetailsByLevel: React.FC<{ orgUnit: OrgUnit; onSave(orgUnit: OrgUnit): void }> = ({ + onSave, + orgUnit, +}) => { switch (orgUnit.type) { case "Project": - return ; + return ; default: return null; } diff --git a/src/webapp/components/org-unit-detail/levels/ProjectLevelDetails.tsx b/src/webapp/components/org-unit-detail/levels/ProjectLevelDetails.tsx index b805027..a3d6722 100644 --- a/src/webapp/components/org-unit-detail/levels/ProjectLevelDetails.tsx +++ b/src/webapp/components/org-unit-detail/levels/ProjectLevelDetails.tsx @@ -1,8 +1,9 @@ import { DatePicker } from "@eyeseetea/d2-ui-components"; -import { TextField } from "@material-ui/core"; +import { Button, TextField } from "@material-ui/core"; import _ from "lodash"; import React, { useState } from "react"; import styled from "styled-components"; +import { NamedRef } from "../../../../domain/entities/Base"; import { OrgUnit } from "../../../../domain/entities/OrgUnit"; import i18n from "../../../../locales"; import { GroupSetDropdown } from "../../group-set-dropdown/GroupSetDropdown"; @@ -10,19 +11,37 @@ import { IconButton } from "../../icon-button/IconButton"; export interface ProjectLevelDetailsProps { orgUnit: OrgUnit; + onSave(orgUnit: OrgUnit): void; } -export const ProjectLevelDetails: React.FC = ({ orgUnit }) => { +export const ProjectLevelDetails: React.FC = props => { + const [orgUnit, setOrgUnit] = useState(props.orgUnit); const [editable, setEditable] = useState(false); if (orgUnit.closedDate !== undefined) { return

{i18n.t("The selected organisation unit is disabled")}

; } + function updateOrgUnitGroups(selectedGroup: NamedRef[], unselectedGroups: NamedRef[]) { + const orgUnitUpdated = orgUnit.setOrganisationUnitGroups(selectedGroup, unselectedGroups); + setOrgUnit(orgUnitUpdated); + } + + function clearForm() { + setOrgUnit(props.orgUnit); + setEditable(false); + } + return ( - + setOrgUnit(orgUnit.set("name", ev.target.value))} + /> + = ({ orgUni + setOrgUnit(orgUnit.set("openingDate", momentDate.toDate())) + } disabled={!editable} /> - + setOrgUnit(orgUnit.set("code", ev.target.value))} + disabled={!editable} + /> - + - - + + + + {editable && ( + + props.onSave(orgUnit)}> + {i18n.t("Save")} + + + + {i18n.t("Clear form")} + + + )} ); }; +const FormButton = styled(Button)` + margin: 10px; +`; + const Container = styled.div` display: flex; flex-direction: column; diff --git a/src/webapp/components/org-unit-list/OrgUnitList.tsx b/src/webapp/components/org-unit-list/OrgUnitList.tsx index 6853134..26fe72b 100644 --- a/src/webapp/components/org-unit-list/OrgUnitList.tsx +++ b/src/webapp/components/org-unit-list/OrgUnitList.tsx @@ -8,9 +8,10 @@ import { TreeViewNode } from "../tree-view/TreeViewNode"; interface OrgUnitListProps { onSelectedOrgUnit?: (orgUnitId: Id) => void; + refreshKey: Date; } -const OrgUnitList: React.FC = ({ onSelectedOrgUnit }) => { +const OrgUnitList: React.FC = ({ onSelectedOrgUnit, refreshKey }) => { const { compositionRoot } = useAppContext(); const [roots, setOrgUnitRoots] = useState([]); @@ -28,7 +29,7 @@ const OrgUnitList: React.FC = ({ onSelectedOrgUnit }) => { .then(orgUnits => setOrgUnitRoots(buildNodes(orgUnits.filter(({ name }) => name === "OCBA"))) ); - }, [compositionRoot]); + }, [compositionRoot, refreshKey]); return ( diff --git a/src/webapp/components/tree-view/TreeView.tsx b/src/webapp/components/tree-view/TreeView.tsx index 3436b68..be1cc0b 100644 --- a/src/webapp/components/tree-view/TreeView.tsx +++ b/src/webapp/components/tree-view/TreeView.tsx @@ -17,7 +17,6 @@ const BaseTreeView: React.FC = ({ className, root, findNodes, onN return ( } defaultExpandIcon={} onNodeSelect={ diff --git a/src/webapp/pages/training/TrainingPage.tsx b/src/webapp/pages/training/TrainingPage.tsx index be394d7..bc25826 100644 --- a/src/webapp/pages/training/TrainingPage.tsx +++ b/src/webapp/pages/training/TrainingPage.tsx @@ -7,21 +7,28 @@ import OrgUnitsList from "../../components/org-unit-list/OrgUnitList"; export const TrainingPage: React.FC = () => { const [selectedOrgUnit, setSelectedOrgUnit] = useState(); + const [refreshKey, setRefreshKey] = useState(new Date()); const handleOnChange = (orgUnitId: Id) => { setSelectedOrgUnit(orgUnitId); }; + const refreshOrgUnitsList = () => { + setRefreshKey(new Date()); + }; + return ( - + -

{i18n.t("Training session 2")}

+

{i18n.t("Training session 3")}

- {selectedOrgUnit && } + {selectedOrgUnit && ( + + )}
);