diff --git a/i18n/en.pot b/i18n/en.pot index f9e5256a4..a22547c93 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: 2024-02-21T11:09:33.513Z\n" -"PO-Revision-Date: 2024-02-21T11:09:33.513Z\n" +"POT-Creation-Date: 2024-07-18T21:33:28.930Z\n" +"PO-Revision-Date: 2024-07-18T21:33:28.930Z\n" msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " @@ -243,7 +243,7 @@ msgstr "" msgid "Condition" msgstr "" -msgid "String to match (*)" +msgid "String to match" msgstr "" msgid "Edit" @@ -517,6 +517,12 @@ msgstr "" msgid "Show all entries" msgstr "" +msgid "Dynamic auto update" +msgstr "" + +msgid "Sync all Organisation Units" +msgstr "" + msgid "Select with children subtree" msgstr "" @@ -529,7 +535,7 @@ msgstr "" msgid "Custodian" msgstr "" -msgid "Search by " +msgid "Search by name, code or id" msgstr "" msgid "" @@ -2172,6 +2178,12 @@ msgid "" "{{period}}..." msgstr "" +msgid "Get configuration from HQ server" +msgstr "" + +msgid "Push data to HQ server" +msgstr "" + msgid "Output: " msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 6c7ecad65..fb1b6a453 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-02-20T05:48:46.170Z\n" +"POT-Creation-Date: 2024-07-17T07:09:52.268Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -243,7 +243,7 @@ msgstr "" msgid "Condition" msgstr "" -msgid "String to match (*)" +msgid "String to match" msgstr "" msgid "Edit" @@ -518,6 +518,12 @@ msgstr "" msgid "Show all entries" msgstr "" +msgid "Dynamic auto update" +msgstr "" + +msgid "Sync all Organisation Units" +msgstr "" + msgid "Select with children subtree" msgstr "" @@ -530,7 +536,7 @@ msgstr "" msgid "Custodian" msgstr "" -msgid "Search by " +msgid "Search by name, code or id" msgstr "" msgid "" @@ -2176,6 +2182,12 @@ msgid "" "{{period}}..." msgstr "" +msgid "Get configuration from HQ server" +msgstr "" + +msgid "Push data to HQ server" +msgstr "" + msgid "Output: " msgstr "" diff --git a/i18n/fr.po b/i18n/fr.po index 6c6dfddd3..817e51ad9 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-02-20T05:48:46.170Z\n" +"POT-Creation-Date: 2024-07-17T07:09:52.268Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -243,7 +243,7 @@ msgstr "" msgid "Condition" msgstr "" -msgid "String to match (*)" +msgid "String to match" msgstr "" msgid "Edit" @@ -518,6 +518,12 @@ msgstr "" msgid "Show all entries" msgstr "" +msgid "Dynamic auto update" +msgstr "" + +msgid "Sync all Organisation Units" +msgstr "" + msgid "Select with children subtree" msgstr "" @@ -530,7 +536,7 @@ msgstr "" msgid "Custodian" msgstr "" -msgid "Search by " +msgid "Search by name, code or id" msgstr "" msgid "" @@ -2175,6 +2181,12 @@ msgid "" "{{period}}..." msgstr "" +msgid "Get configuration from HQ server" +msgstr "" + +msgid "Push data to HQ server" +msgstr "" + msgid "Output: " msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index 6c6dfddd3..817e51ad9 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-02-20T05:48:46.170Z\n" +"POT-Creation-Date: 2024-07-17T07:09:52.268Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -243,7 +243,7 @@ msgstr "" msgid "Condition" msgstr "" -msgid "String to match (*)" +msgid "String to match" msgstr "" msgid "Edit" @@ -518,6 +518,12 @@ msgstr "" msgid "Show all entries" msgstr "" +msgid "Dynamic auto update" +msgstr "" + +msgid "Sync all Organisation Units" +msgstr "" + msgid "Select with children subtree" msgstr "" @@ -530,7 +536,7 @@ msgstr "" msgid "Custodian" msgstr "" -msgid "Search by " +msgid "Search by name, code or id" msgstr "" msgid "" @@ -2175,6 +2181,12 @@ msgid "" "{{period}}..." msgstr "" +msgid "Get configuration from HQ server" +msgstr "" + +msgid "Push data to HQ server" +msgstr "" + msgid "Output: " msgstr "" diff --git a/package.json b/package.json index 02c08a3ba..1287e6cab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "metadata-synchronization", "description": "Advanced metadata & data synchronization utility", - "version": "2.18.2", + "version": "2.19.0", "license": "GPL-3.0", "author": "EyeSeeTea team", "homepage": ".", diff --git a/src/data/metadata/models/MetadataSynchronizationParamsModel.ts b/src/data/metadata/models/MetadataSynchronizationParamsModel.ts index d80c4b93b..3c62bf055 100644 --- a/src/data/metadata/models/MetadataSynchronizationParamsModel.ts +++ b/src/data/metadata/models/MetadataSynchronizationParamsModel.ts @@ -42,5 +42,6 @@ export const MetadataSynchronizationParamsModel: Codec { + const oldRules = (await storage.get("rules")) ?? []; + const oldEventRules = oldRules.filter(rule => rule.type === "metadata"); + + await promiseMap(oldEventRules, async oldRule => { + const oldRuleDetails = await storage.get("rules-" + oldRule.id); + + if (oldRuleDetails) { + const oldSyncParams = oldRuleDetails.builder.syncParams; + if (!oldSyncParams) return; + + const newSyncParams: NewMetadataSynchronizationParams = { ...oldSyncParams, metadataModelsSyncAll: [] }; + + const newRuleDatails: NewSynchronizationRuleDetails = { + builder: { ...oldRuleDetails?.builder, syncParams: newSyncParams }, + }; + + await storage.save("rules-" + oldRule.id, newRuleDatails); + } + }); +} + +const migration: Migration = { + name: "Create in existed metadata rules a new field metadataModelsSyncAll to []", + migrate, +}; + +export default migration; diff --git a/src/data/migrations/tasks/index.ts b/src/data/migrations/tasks/index.ts index 8f6050697..a681f7eed 100644 --- a/src/data/migrations/tasks/index.ts +++ b/src/data/migrations/tasks/index.ts @@ -14,6 +14,7 @@ export function getMigrationTasks(): MigrationTasks { [9, import("./09.mapping-instances")], [10, import("./10.sync-all-teis")], [11, import("./11.rename-run-analytics")], + [12, import("./12.metadata-models-sync-all")], ]; } diff --git a/src/domain/entities/EmergencyResponses.ts b/src/domain/entities/EmergencyResponses.ts index 32891dee5..2bfaecdb4 100644 --- a/src/domain/entities/EmergencyResponses.ts +++ b/src/domain/entities/EmergencyResponses.ts @@ -2,7 +2,7 @@ export type EmergencyType = "efh" | "ebola"; export interface EmergencyResponseConfig { program: Code; - syncRules: { metadata: Code; data: Code }; + syncRules: { metadata: Code[]; data: Code[] }; } type Code = string; @@ -10,11 +10,11 @@ type Code = string; const emergencyResponseConfig: Record = { efh: { program: "EFH_GENERAL_INTAKE_FORM", - syncRules: { metadata: "EFH_METADATA", data: "EFH_DATA" }, + syncRules: { metadata: ["EFH_METADATA", "EFH_METADATA_ORGUNITS"], data: ["EFH_DATA"] }, }, ebola: { program: "EBOLA_GENERAL_INTAKE_FORM", - syncRules: { metadata: "EBOLA_METADATA", data: "EBOLA_DATA" }, + syncRules: { metadata: ["EBOLA_METADATA"], data: ["EBOLA_DATA"] }, }, }; diff --git a/src/domain/events/usecases/UpdateEmergencyResponseSyncRuleUseCase.tsx b/src/domain/events/usecases/UpdateEmergencyResponseSyncRuleUseCase.tsx index ac736f8fd..f593d8a9e 100644 --- a/src/domain/events/usecases/UpdateEmergencyResponseSyncRuleUseCase.tsx +++ b/src/domain/events/usecases/UpdateEmergencyResponseSyncRuleUseCase.tsx @@ -20,9 +20,8 @@ export class UpdateEmergencyResponseSyncRuleUseCase { if (!program) throw new Error(i18n.t("Program not found")); const orgUnitPaths = program.organisationUnits.map(ou => ou.path); - const teiIds: string[] = await this.getTeis(program, orgUnitPaths); - return rule.updateDataSyncOrgUnitPaths(orgUnitPaths).updateDataSyncTEIs(teiIds); + return rule.updateDataSyncOrgUnitPaths(orgUnitPaths); } private async getProgram(emergencyType: EmergencyType) { @@ -35,23 +34,6 @@ export class UpdateEmergencyResponseSyncRuleUseCase { const form = res.objects[0] as unknown as Program | undefined; return form; } - - private async getTeis(form: Program, orgUnitPaths: string[]) { - const teiIds: string[] = []; - let page = 1; - let done = false; - - while (!done) { - const { trackedEntityInstances } = await this.teiRepository.getTEIs({ orgUnitPaths }, form.id, page, 1000); - const teiIdsInPage = trackedEntityInstances.map(tei => tei.trackedEntityInstance); - teiIds.push(...teiIdsInPage); - - page++; - done = trackedEntityInstances.length === 0; - } - - return teiIds; - } } interface Program { diff --git a/src/domain/metadata/entities/MetadataFriendlyNames.ts b/src/domain/metadata/entities/MetadataFriendlyNames.ts index f79dcba2e..6f95e6c97 100644 --- a/src/domain/metadata/entities/MetadataFriendlyNames.ts +++ b/src/domain/metadata/entities/MetadataFriendlyNames.ts @@ -223,6 +223,8 @@ export const includeExcludeRulesFriendlyNames: Dictionary = { "trackedEntityAttributes.legendSets": "Legends of tracked entity attributes", "users.userRoles": "User roles of users", "users.userGroups": "User groups of users", + "users.avatar": "Avatar of users", + avatar: "Avatar", options: "Options", "options.attributes": "Attributes of options", "optionSets.attributes": "Attributes of option sets", diff --git a/src/domain/metadata/entities/MetadataSynchronizationParams.ts b/src/domain/metadata/entities/MetadataSynchronizationParams.ts index fce02e6df..4c46fe6f5 100644 --- a/src/domain/metadata/entities/MetadataSynchronizationParams.ts +++ b/src/domain/metadata/entities/MetadataSynchronizationParams.ts @@ -24,4 +24,5 @@ export interface MetadataSynchronizationParams extends MetadataImportParams { removeOrgUnitObjects?: boolean; useDefaultIncludeExclude: boolean; metadataIncludeExcludeRules?: MetadataIncludeExcludeRules; + metadataModelsSyncAll: string[]; //TODO: keyof MetadataEntities 963#discussion_r1682370900 } diff --git a/src/domain/metadata/usecases/MetadataSyncUseCase.ts b/src/domain/metadata/usecases/MetadataSyncUseCase.ts index 2c08376ad..0628d61aa 100644 --- a/src/domain/metadata/usecases/MetadataSyncUseCase.ts +++ b/src/domain/metadata/usecases/MetadataSyncUseCase.ts @@ -102,7 +102,18 @@ export class MetadataSyncUseCase extends GenericSyncUseCase { const allMetadataIds = _.union(metadataIds, filterRulesIds); const metadata = await metadataRepository.getMetadataByIds(allMetadataIds, "id,type"); //type is required to transform visualizations to charts and report tables - const exportResults = await promiseMap(_.keys(metadata), type => { + const metadataWithSyncAll: Partial> = await Promise.all( + (syncParams?.metadataModelsSyncAll ?? []).map( + async type => + await metadataRepository + .listAllMetadata({ type: type as keyof MetadataEntities, fields: { id: true, type: true } }) + .then(metadata => ({ + [type]: metadata, + })) + ) + ).then(syncAllMetadata => _.deepMerge(metadata, ...syncAllMetadata)); //TODO: don't mix async/.then 963#discussion_r1682376524 + + const exportResults = await promiseMap(_.keys(metadataWithSyncAll), type => { const myClass = modelFactory(type); const metadataType = myClass.getMetadataType(); const collectionName = myClass.getCollectionName(); @@ -111,7 +122,7 @@ export class MetadataSyncUseCase extends GenericSyncUseCase { return this.exportMetadata({ type: collectionName, - ids: metadata[collectionName]?.map(e => e.id) || [], + ids: metadataWithSyncAll[collectionName]?.map(e => e.id) || [], excludeRules: useDefaultIncludeExclude ? myClass.getExcludeRules() : metadataIncludeExcludeRules[metadataType].excludeRules.map(_.toPath), diff --git a/src/domain/modules/entities/MetadataModule.ts b/src/domain/modules/entities/MetadataModule.ts index 05d729492..8cc5ca180 100644 --- a/src/domain/modules/entities/MetadataModule.ts +++ b/src/domain/modules/entities/MetadataModule.ts @@ -54,6 +54,7 @@ export class MetadataModule extends GenericModule implements BaseMetadataModule includeSharingSettings: this.includeUserInformation, useDefaultIncludeExclude: this.useDefaultIncludeExclude, metadataIncludeExcludeRules: this.metadataIncludeExcludeRules, + metadataModelsSyncAll: [], }, }; } diff --git a/src/domain/rules/entities/SynchronizationRule.ts b/src/domain/rules/entities/SynchronizationRule.ts index f34ccd76a..2fe3586c2 100644 --- a/src/domain/rules/entities/SynchronizationRule.ts +++ b/src/domain/rules/entities/SynchronizationRule.ts @@ -1,6 +1,6 @@ import cronstrue from "cronstrue"; import { generateUid } from "d2/uid"; -import _ from "lodash"; +import _, { isEmpty } from "lodash"; import moment from "moment"; import { D2Model } from "../../../models/dhis/default"; import { extractChildrenFromRules, extractParentsFromRule } from "../../../utils/metadataIncludeExclude"; @@ -168,6 +168,10 @@ export class SynchronizationRule { return this.syncRule.builder?.syncParams?.metadataIncludeExcludeRules ?? {}; } + public get metadataModelsSyncAll(): string[] { + return this.syncRule.builder?.syncParams?.metadataModelsSyncAll ?? []; //TODO: might check if builder is really undefined, no need of "?", trust TS 963#discussion_r1682380337 + } + public get targetInstances(): string[] { return this.syncRule.targetInstances; } @@ -234,6 +238,7 @@ export class SynchronizationRule { removeUserObjectsAndReferences: false, removeOrgUnitObjects: false, useDefaultIncludeExclude: true, + metadataModelsSyncAll: [], ...params, }; } @@ -542,15 +547,31 @@ export class SynchronizationRule { return this.update({ targetInstances }).updateBuilder({ targetInstances }); } - public updateSyncParams(syncParams: MetadataSynchronizationParams): SynchronizationRule { + public updateSyncParams(syncParams: Partial): SynchronizationRule { + const params = this.syncRule.builder?.syncParams ?? { + enableMapping: false, + includeSharingSettings: true, + removeOrgUnitReferences: false, + removeUserObjects: false, + removeUserObjectsAndReferences: false, + removeOrgUnitObjects: false, + useDefaultIncludeExclude: true, + metadataModelsSyncAll: [], + }; + return this.updateBuilder({ syncParams: { + ...params, ...syncParams, removeOrgUnitObjects: syncParams.removeOrgUnitReferences ? true : syncParams.removeOrgUnitObjects, }, }); } + public updateMetadataModelsSyncAll(metadataModelsSyncAll: string[]): SynchronizationRule { + return this.updateSyncParams({ metadataModelsSyncAll }); + } + public updateDataParams(dataParams: DataSynchronizationParams): SynchronizationRule { return this.updateBuilder({ dataParams }); } @@ -605,7 +626,7 @@ export class SynchronizationRule { : null, ]), metadataIds: _.compact([ - !this.usesFilterRules && this.metadataIds.length === 0 + !this.usesFilterRules && isEmpty(this.metadataIds) ? { key: "cannot_be_empty", namespace: { element: "metadata element" }, @@ -613,7 +634,10 @@ export class SynchronizationRule { : null, ]), metadata: _.compact([ - this.usesFilterRules && this.metadataIds.length === 0 && this.filterRules.length === 0 + this.usesFilterRules && + isEmpty(this.metadataIds) && + isEmpty(this.filterRules) && + isEmpty(this.builder.syncParams?.metadataModelsSyncAll) ? { key: "cannot_be_empty", namespace: { element: "metadata element or create a filter rule" }, @@ -621,7 +645,7 @@ export class SynchronizationRule { : null, ]), dataSyncOrganisationUnits: _.compact([ - this.type !== "metadata" && this.type !== "deleted" && this.dataSyncOrgUnitPaths.length === 0 + this.type !== "metadata" && this.type !== "deleted" && isEmpty(this.dataSyncOrgUnitPaths) ? { key: "cannot_be_empty", namespace: { element: "organisation unit" }, @@ -657,8 +681,8 @@ export class SynchronizationRule { this.type === "events" && !this.dataSyncAllEvents && !this.dataSyncAllTEIs && - this.dataSyncEvents.length === 0 && - this.dataSyncTeis.length === 0 && + isEmpty(this.dataSyncEvents) && + isEmpty(this.dataSyncTeis) && !this.dataSyncEnableAggregation ? { key: "cannot_be_empty", @@ -679,7 +703,7 @@ export class SynchronizationRule { ]), metadataIncludeExclude: [], targetInstances: _.compact([ - this.originInstance === "LOCAL" && this.targetInstances.length === 0 + this.originInstance === "LOCAL" && isEmpty(this.targetInstances) ? { key: "cannot_be_empty", namespace: { element: "instance" }, @@ -707,7 +731,7 @@ export class SynchronizationRule { public async isValid(): Promise { const validation = this.validate(); - return _.flatten(Object.values(validation)).length === 0; + return _(Object.values(validation)).flatten().isEmpty(); } } diff --git a/src/domain/rules/usecases/SaveSyncRuleUseCase.ts b/src/domain/rules/usecases/SaveSyncRuleUseCase.ts index c8478d258..be2e8ca71 100644 --- a/src/domain/rules/usecases/SaveSyncRuleUseCase.ts +++ b/src/domain/rules/usecases/SaveSyncRuleUseCase.ts @@ -1,12 +1,35 @@ +import _ from "lodash"; import { UseCase } from "../../common/entities/UseCase"; import { RepositoryFactory } from "../../common/factories/RepositoryFactory"; import { Instance } from "../../instance/entities/Instance"; import { SynchronizationRule } from "../entities/SynchronizationRule"; +import { MetadataEntity } from "../../metadata/entities/MetadataEntities"; export class SaveSyncRuleUseCase implements UseCase { constructor(private repositoryFactory: RepositoryFactory, private localInstance: Instance) {} public async execute(rules: SynchronizationRule[]): Promise { - await this.repositoryFactory.rulesRepository(this.localInstance).save(rules); + const updatedRules = await Promise.all(rules.map(rule => this.excludeSyncAllIds(rule))); + + await this.repositoryFactory.rulesRepository(this.localInstance).save(updatedRules); + } + + private async excludeSyncAllIds(rule: SynchronizationRule): Promise { + const instanceRepository = this.repositoryFactory.instanceRepository(this.localInstance); + const instance = await instanceRepository.getById(rule.originInstance); + if (!instance) throw Error("Instance not found"); + const metadataRepository = this.repositoryFactory.metadataRepository(instance); + const metadata = await metadataRepository.getMetadataByIds(rule.metadataIds, "id"); + const idsFromSyncAllMetadataTypes = _(metadata) + .pick(rule.metadataModelsSyncAll) + .values() + .compact() + .flatten() + .map(entity => entity.id) + .value(); + + const remainingMetadataIds = _.difference(rule.metadataIds, idsFromSyncAllMetadataTypes); + + return rule.updateMetadataIds(remainingMetadataIds); } } diff --git a/src/domain/synchronization/entities/SynchronizationBuilder.ts b/src/domain/synchronization/entities/SynchronizationBuilder.ts index 249653ac6..4b0979adc 100644 --- a/src/domain/synchronization/entities/SynchronizationBuilder.ts +++ b/src/domain/synchronization/entities/SynchronizationBuilder.ts @@ -37,6 +37,7 @@ export const defaultSynchronizationBuilder: SynchronizationBuilder = { includeSharingSettings: true, removeOrgUnitReferences: false, useDefaultIncludeExclude: true, + metadataModelsSyncAll: [], atomicMode: "ALL", mergeMode: "MERGE", importMode: "COMMIT", diff --git a/src/models/dhis/metadata.ts b/src/models/dhis/metadata.ts index 2099a8c17..f0e1bdeb1 100644 --- a/src/models/dhis/metadata.ts +++ b/src/models/dhis/metadata.ts @@ -721,7 +721,7 @@ export class UserGroupModel extends D2Model { protected static metadataType = "userGroup"; protected static collectionName = "userGroups" as const; - protected static excludeRules = []; + protected static excludeRules = ["users.avatar"]; protected static includeRules = ["attributes", "users", "users.userRoles"]; } diff --git a/src/presentation/react/core/components/filter-rules-table/FilterRuleDialog.tsx b/src/presentation/react/core/components/filter-rules-table/FilterRuleDialog.tsx index 05faf1857..3cd318f2c 100644 --- a/src/presentation/react/core/components/filter-rules-table/FilterRuleDialog.tsx +++ b/src/presentation/react/core/components/filter-rules-table/FilterRuleDialog.tsx @@ -104,7 +104,7 @@ export const FilterRuleDialog: React.FC = props => { className={classes.dropdown} fullWidth={true} onChange={value => setFilterRule(filterRule => updateStringMatch(filterRule, { value }))} - label={i18n.t("String to match (*)")} + label={i18n.t("String to match")} value={filterRule.stringMatch?.value || ""} /> diff --git a/src/presentation/react/core/components/metadata-table/MetadataTable.tsx b/src/presentation/react/core/components/metadata-table/MetadataTable.tsx index 7e9bfcd26..c6bdb809d 100644 --- a/src/presentation/react/core/components/metadata-table/MetadataTable.tsx +++ b/src/presentation/react/core/components/metadata-table/MetadataTable.tsx @@ -1,4 +1,4 @@ -import { Checkbox, FormControlLabel, Icon, makeStyles } from "@material-ui/core"; +import { Box, Checkbox, FormControlLabel, Icon, Paper, Tooltip, makeStyles } from "@material-ui/core"; import DoneAllIcon from "@material-ui/icons/DoneAll"; import { isCancel } from "@eyeseetea/d2-api"; import { @@ -16,7 +16,7 @@ import { useSnackbar, } from "@eyeseetea/d2-ui-components"; import _ from "lodash"; -import React, { ChangeEvent, ReactNode, useCallback, useEffect, useState } from "react"; +import React, { ChangeEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { NamedRef } from "../../../../../domain/common/entities/Ref"; import { DataSource, isDhisInstance, isJSONDataSource } from "../../../../../domain/instance/entities/DataSource"; import { MetadataResponsible } from "../../../../../domain/metadata/entities/MetadataResponsible"; @@ -29,6 +29,7 @@ import { useAppContext } from "../../contexts/AppContext"; import Dropdown from "../dropdown/Dropdown"; import { ResponsibleDialog } from "../responsible-dialog/ResponsibleDialog"; import { getFilterData, getOrgUnitSubtree } from "./utils"; +import { Toggle } from "../toggle/Toggle"; export type MetadataTableFilters = | "group" @@ -58,6 +59,9 @@ export interface MetadataTableProps extends Omit notifyNewSelection?(selectedIds: string[], excludedIds: string[]): void; notifyNewModel?(model: typeof D2Model): void; notifyRowsChange?(rows: MetadataType[]): void; + notifyModelSyncAllChange?(value: boolean): void; + modelIsSyncAll?: boolean; + ignoreIds?: string[]; allowChangingResponsible?: boolean; showResponsible?: boolean; externalFilterComponents?: ReactNode; @@ -110,11 +114,14 @@ const MetadataTable: React.FC = ({ filterRows, transformRows = rows => rows, models, + modelIsSyncAll, selectedIds: externalSelection, excludedIds = [], + ignoreIds = [], notifyNewSelection = _.noop, notifyNewModel = _.noop, notifyRowsChange = _.noop, + notifyModelSyncAllChange = _.noop, childrenKeys = [], additionalColumns = [], additionalActions = [], @@ -148,8 +155,28 @@ const MetadataTable: React.FC = ({ const [responsibles, updateResponsibles] = useState([]); const [sharingSettingsElement, setSharingSettingsElement] = useState(); - const [stateSelection, setStateSelection] = useState(externalSelection ?? []); - const selectedIds = externalSelection ?? stateSelection; + const selectionWithoutExcludedRef = useRef(); + + useEffect(() => { + const updatedSelection = !externalSelection + ? undefined + : _.isEqual(externalSelection, ignoreIds) + ? [] + : _.difference(externalSelection, ignoreIds); + + if (!_.isEqual(updatedSelection, selectionWithoutExcludedRef.current)) { + selectionWithoutExcludedRef.current = updatedSelection; + } + }, [externalSelection, ignoreIds]); + + const selectionWithoutExcluded = selectionWithoutExcludedRef.current; + + const [stateSelection, setStateSelection] = useState(selectionWithoutExcluded ?? []); + const selectedIds = useMemo( + () => selectionWithoutExcluded ?? stateSelection, + [selectionWithoutExcluded, stateSelection] + ); + const [filters, setFilters] = useState({ type: model.getCollectionName(), showOnlySelected: initialShowOnlySelected, @@ -251,6 +278,11 @@ const MetadataTable: React.FC = ({ updateFilters({ disableFilterRows }); }; + const changeOrgUnitsSyncAll = (value: boolean) => { + // TODO: Sync All Types Feature: use this function and refactor in order to allow all types to be syncAll + notifyModelSyncAllChange(value); + }; + const changeParentOrgUnitFilter = useCallback( (parents: string[]) => { updateFilters({ parents, level: "" }); @@ -406,21 +438,39 @@ const MetadataTable: React.FC = ({ const orgUnitTreeFilter = viewFilters.includes("orgUnit") && model.getCollectionName() === "organisationUnits" && (
- + + {modelIsSyncAll !== undefined && ( + + + {i18n.t("Sync all Organisation Units")} + + } + value={Boolean(modelIsSyncAll)} + onValueChange={changeOrgUnitsSyncAll} + /> + + )} + + {!modelIsSyncAll && ( + + )} +
); @@ -606,22 +656,26 @@ const MetadataTable: React.FC = ({ indeterminate: false, })); - const childrenSelection: TableSelection[] = showIndeterminateSelection - ? _(rows) - .intersectionBy(selection, "id") - .map(row => _.values(_.pick(row, childrenKeys)) as unknown as MetadataType[]) - .flattenDeep() - .differenceBy(selection, "id") - .differenceBy(exclusion, "id") - .map(({ id }) => { - return { - id, - checked: true, - indeterminate: !_.find(selection, { id }), - }; - }) - .value() - : []; + const childrenSelection: TableSelection[] = useMemo( + () => + showIndeterminateSelection + ? _(rows) + .intersectionBy(selection, "id") + .map(row => _.values(_.pick(row, childrenKeys)) as unknown as MetadataType[]) + .flattenDeep() + .differenceBy(selection, "id") + .differenceBy(exclusion, "id") + .map(({ id }) => { + return { + id, + checked: true, + indeterminate: !_.find(selection, { id }), + }; + }) + .value() + : [], + [childrenKeys, exclusion, rows, selection, showIndeterminateSelection] + ); const responsibleField = showResponsibles ? { @@ -648,6 +702,12 @@ const MetadataTable: React.FC = ({ const actions: TableAction[] = uniqCombine([...tableActions, ...additionalActions]); + const uiSelection = useMemo(() => { + return [...selection, ...childrenSelection]; + }, [childrenSelection, selection]); + + const shownRows = useMemo(() => (modelIsSyncAll ? [] : transformRows(rows)), [modelIsSyncAll, rows, transformRows]); + return ( = ({ /> - rows={transformRows(rows)} + rows={shownRows} columns={columns} details={details} onChangeSearch={changeSearchFilter} @@ -669,7 +729,7 @@ const MetadataTable: React.FC = ({ onChange={handleTableChange} ids={ids} loading={providedLoading || loading} - selection={[...selection, ...childrenSelection]} + selection={uiSelection} childrenKeys={childrenKeys} filterComponents={filterComponents} forceSelectionColumn={true} diff --git a/src/presentation/react/core/components/sync-wizard/common/MetadataSelectionStep.tsx b/src/presentation/react/core/components/sync-wizard/common/MetadataSelectionStep.tsx index 4a0349a7e..da13ac46e 100644 --- a/src/presentation/react/core/components/sync-wizard/common/MetadataSelectionStep.tsx +++ b/src/presentation/react/core/components/sync-wizard/common/MetadataSelectionStep.tsx @@ -20,7 +20,6 @@ import { IndicatorModel, } from "../../../../../../models/dhis/metadata"; import { MetadataType } from "../../../../../../utils/d2"; -import { getMetadata } from "../../../../../../utils/synchronization"; import { useAppContext } from "../../../contexts/AppContext"; import { getChildrenRows } from "../../mapping-table/utils"; import MetadataTable from "../../metadata-table/MetadataTable"; @@ -57,7 +56,7 @@ const config = { }; export default function MetadataSelectionStep({ syncRule, onChange }: SyncWizardStepProps) { - const { api, compositionRoot } = useAppContext(); + const { compositionRoot } = useAppContext(); const snackbar = useSnackbar(); const [metadataIds, updateMetadataIds] = useState([]); @@ -68,6 +67,8 @@ export default function MetadataSelectionStep({ syncRule, onChange }: SyncWizard const [model, setModel] = useState(() => models[0] ?? {}); const [rows, setRows] = useState([]); + const [idsToIgnore, setIdsToIgnore] = useState([]); + const [metadataModelsSyncAll, setMetadataModelsSyncAll] = useState(syncRule.metadataModelsSyncAll); const changeSelection = useCallback( (newMetadataIds: string[], newExclusionIds: string[]) => { @@ -88,8 +89,9 @@ export default function MetadataSelectionStep({ syncRule, onChange }: SyncWizard ); } - getMetadata(api, newMetadataIds, "id").then(metadata => { - const types = _.keys(metadata); + compositionRoot.metadata.getByIds(newMetadataIds, remoteInstance, "id").then(metadata => { + const types = _(metadata).keys().concat(metadataModelsSyncAll).uniq().value(); + onChange( syncRule .updateMetadataIds(newMetadataIds) @@ -103,7 +105,7 @@ export default function MetadataSelectionStep({ syncRule, onChange }: SyncWizard updateMetadataIds(newMetadataIds); }, - [api, metadataIds, onChange, snackbar, syncRule] + [compositionRoot.metadata, metadataIds, metadataModelsSyncAll, onChange, remoteInstance, snackbar, syncRule] ); useEffect(() => { @@ -122,10 +124,49 @@ export default function MetadataSelectionStep({ syncRule, onChange }: SyncWizard }); }, [compositionRoot, snackbar, syncRule.originInstance]); + useEffect(() => { + if (!_.isEmpty(metadataModelsSyncAll)) { + compositionRoot.metadata.getByIds(syncRule.metadataIds, remoteInstance, "id").then(metadata => { + const idsFromSyncAllMetadataTypes = _(metadata) + .pick(metadataModelsSyncAll) + .values() + .compact() + .flatten() + .map(entity => entity.id) + .value(); + + setIdsToIgnore(idsFromSyncAllMetadataTypes); + }); + } else { + setIdsToIgnore([]); + } + }, [compositionRoot.metadata, metadataModelsSyncAll, remoteInstance, syncRule.metadataIds]); + const notifyNewModel = useCallback(model => { setModel(() => model); }, []); + const notifyModelSyncAllChange = useCallback( + (value: boolean) => { + setMetadataModelsSyncAll(types => { + const modelName = model.getCollectionName(); + const syncAllTypes = value ? _.uniq(types.concat(modelName)) : _.without(types, modelName); + const ruleTypes = _.uniq(syncRule.metadataTypes.concat(syncAllTypes)); + + onChange(syncRule.updateMetadataTypes(ruleTypes).updateMetadataModelsSyncAll(syncAllTypes)); + + return syncAllTypes; + }); + }, + [model, syncRule, onChange] + ); + + //TODO: Go in direction of useHooks 963#discussion_r1682397641 + const modelIsSyncAll = useMemo( + () => metadataModelsSyncAll.includes(model.getCollectionName()), + [metadataModelsSyncAll, model] + ); + const updateRows = useCallback( (rows: MetadataType[]) => { setRows([...rows, ...getChildrenRows(rows, model)]); @@ -182,6 +223,9 @@ export default function MetadataSelectionStep({ syncRule, onChange }: SyncWizard remoteInstance={remoteInstance} notifyNewModel={notifyNewModel} notifyRowsChange={updateRows} + notifyModelSyncAllChange={notifyModelSyncAllChange} + modelIsSyncAll={modelIsSyncAll} + ignoreIds={idsToIgnore} /> ); } diff --git a/src/presentation/react/core/components/sync-wizard/common/SummaryStep.tsx b/src/presentation/react/core/components/sync-wizard/common/SummaryStep.tsx index a8cc288e3..752c2d92f 100644 --- a/src/presentation/react/core/components/sync-wizard/common/SummaryStep.tsx +++ b/src/presentation/react/core/components/sync-wizard/common/SummaryStep.tsx @@ -158,6 +158,7 @@ export const SummaryStepContent = (props: SummaryStepContentProps) => { const [metadata, updateMetadata] = useState>({}); const [instanceOptions, setInstanceOptions] = useState<{ value: string; text: string }[]>([]); + const [syncAllTypesLength, setSyncAllTypesLength] = useState>({}); const aggregationItems = useMemo(buildAggregationItems, []); @@ -183,6 +184,18 @@ export const SummaryStepContent = (props: SummaryStepContentProps) => { result.match({ error: () => snackbar.error(i18n.t("Invalid origin instance")), success: instance => { + //TODO: UseCase should have all these Promise.all + map + ... 963#discussion_r1682399046 + Promise.all( + syncRule.metadataModelsSyncAll.map( + async type => + await compositionRoot.metadata + .listAll({ type: type as keyof MetadataEntities, fields: { id: true } }, instance) + .then(metadata => ({ + [type]: metadata.filter(({ id }) => !syncRule.excludedIds.includes(id)).length, + })) + ) + ).then(typeLength => setSyncAllTypesLength(Object.assign({}, ...typeLength))); + //type is required to transform visualizations to charts and report tables compositionRoot.metadata.getByIds(ids, instance, "id,name,type").then(updateMetadata); }, @@ -216,32 +229,48 @@ export const SummaryStepContent = (props: SummaryStepContentProps) => { - {_.keys(metadata).map(metadataType => { - //@ts-ignore - const modelByMetadataType = api.models[metadataType]; - if (!modelByMetadataType) { - console.warn(`Metadata type "${metadataType}" not supported in d2-api`); - return null; - } - const itemsByType = metadata[metadataType as keyof MetadataEntities] || []; + {_(metadata) + .keys() //TODO: use keys() typed function and change both metadataType and metadataModelsSyncAll to keyof MetadataEntities + .concat(syncRule.metadataModelsSyncAll) + .uniq() + .sort() + .value() + .map(metadataType => { + const modelByMetadataType = api.models[metadataType as keyof MetadataEntities]; //TODO: remove "as" + if (!modelByMetadataType) { + console.warn(`Metadata type "${metadataType}" not supported in d2-api`); //TODO: Remove d2-api reference (data layer) - here (presentation layer) 963#discussion_r1682402091 + return null; + } - const items = itemsByType.filter(({ id }) => !syncRule.excludedIds.includes(id)); + if (syncRule.metadataModelsSyncAll.includes(metadataType)) { + const length = syncAllTypesLength[metadataType]; - return ( - items.length > 0 && ( - -
    - {items.map(({ id, name }) => ( - - ))} -
-
- ) - ); - })} + return ( + + ); + } + + const itemsByType = metadata[metadataType as keyof MetadataEntities] || []; + const items = itemsByType.filter(({ id }) => !syncRule.excludedIds.includes(id)); + + return ( + items.length > 0 && ( + +
    + {items.map(({ id, name }) => ( + + ))} +
+
+ ) + ); + })} {syncRule.filterRules.length > 0 && ( = ({ syncRule, o compositionRoot.metadata .getByIds(syncRule.metadataIds, instance, "id,name,type") //type is required to transform visualizations to charts and report tables .then((metadata: MetadataPackage) => { - const models = _.keys(metadata).map((type: string) => modelFactory(type)); + const models = _(metadata) + .keys() + .concat(syncRule.metadataModelsSyncAll) + .sort() + .uniq() + .value() + .map(type => modelFactory(type)); const options = models - .filter((model: typeof D2Model) => model.getMetadataType() !== defaultName) - .map((model: typeof D2Model) => { + .filter(model => model.getMetadataType() !== defaultName) + .map(model => { const apiModel = api.models[model.getCollectionName()]; return apiModel.schema; }) - .map((schema: D2SchemaProperties) => ({ + .map(schema => ({ name: schema.displayName, id: schema.name, })); diff --git a/src/presentation/react/core/components/toggle/Toggle.tsx b/src/presentation/react/core/components/toggle/Toggle.tsx index 15031ad27..dd7303578 100644 --- a/src/presentation/react/core/components/toggle/Toggle.tsx +++ b/src/presentation/react/core/components/toggle/Toggle.tsx @@ -3,7 +3,7 @@ import _ from "lodash"; interface InputParameters { disabled?: boolean; - label: string; + label: React.ReactNode; onChange?: Function; onValueChange?: Function; value: boolean; diff --git a/src/presentation/webapp/sp-emergency-responses/EmergencyResponsesSyncHomePage.tsx b/src/presentation/webapp/sp-emergency-responses/EmergencyResponsesSyncHomePage.tsx index dd9c6a13b..4d9ec70d8 100644 --- a/src/presentation/webapp/sp-emergency-responses/EmergencyResponsesSyncHomePage.tsx +++ b/src/presentation/webapp/sp-emergency-responses/EmergencyResponsesSyncHomePage.tsx @@ -1,19 +1,19 @@ import _ from "lodash"; -import { useSnackbar } from "@eyeseetea/d2-ui-components"; -import { Box, LinearProgress, List, makeStyles, Paper, Typography } from "@material-ui/core"; +import moment from "moment"; import React from "react"; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; +import { Box, Button, LinearProgress, List, makeStyles, Paper, Typography } from "@material-ui/core"; import { SynchronizationRule } from "../../../domain/rules/entities/SynchronizationRule"; -import i18n from "../../../locales"; import { useAppContext } from "../../react/core/contexts/AppContext"; import { CompositionRoot } from "../../CompositionRoot"; import { formatDateLong } from "../../../utils/date"; -import { SyncRuleButtonProps, SyncRuleButton } from "./SyncRuleButton"; import { downloadFile } from "../../utils/download"; import { SynchronizationReport } from "../../../domain/reports/entities/SynchronizationReport"; import { SummaryTable } from "../../react/core/components/sync-summary/SummaryTable"; import { SynchronizationResult, SynchronizationStats } from "../../../domain/reports/entities/SynchronizationResult"; -import moment from "moment"; import { EmergencyType, getEmergencyResponseConfig } from "../../../domain/entities/EmergencyResponses"; +import i18n from "../../../locales"; +import { promiseMap } from "../../../utils/common"; interface EmergencyResponsesSyncHomePageProps { emergencyType: EmergencyType; @@ -27,12 +27,49 @@ export const EmergencyResponsesSyncHomePage: React.FC + promiseMap( + _.orderBy(rules, rule => rule.code === "EFH_METADATA_ORGUNITS", "desc"), + rule => { + console.debug("Running: " + rule.name); + return runSyncRule(rule); + } + ), + [runSyncRule] + ); + + const executeMetadataRules = React.useCallback( + () => executeRules(rules.filter(rule => rule.type === "metadata")), + [rules, executeRules] + ); + + const executeEventsRules = React.useCallback( + () => executeRules(rules.filter(rule => rule.type === "events")), + [rules, executeRules] + ); + return ( - {rules.map(rule => ( - - ))} + + @@ -153,7 +190,7 @@ function useSyncRulesList(emergencyType: EmergencyType) { const emergencyResponsesRules = _(rules) .keyBy(rule => rule.code || "") - .at([syncRules.metadata, syncRules.data]) + .at([...syncRules.metadata, ...syncRules.data]) .compact() .value(); @@ -172,27 +209,28 @@ function useSyncRulesList(emergencyType: EmergencyType) { function useSyncRulesExecuter(options: { logs: Logs; emergencyType: EmergencyType }) { const { emergencyType, logs } = options; - const [isRunning, setRunning] = React.useState(false); + const [running, setRunning] = React.useState>(); const { compositionRoot } = useAppContext(); const snackbar = useSnackbar(); - const execute = React.useCallback( + const execute = React.useCallback( async rule => { - setRunning(true); + setRunning(running => (running ? { ...running, [rule.id]: true } : { [rule.id]: true })); try { - logs.clear(); await executeRule(compositionRoot, emergencyType, rule.id, logs.log); } catch (err: any) { logs.log(`Error: ${err.message}`); snackbar.error(err.message); } finally { - setRunning(false); + setRunning(running => (running ? { ...running, [rule.id]: false } : { [rule.id]: false })); } }, [compositionRoot, snackbar, logs, emergencyType] ); + const isRunning = React.useMemo(() => Boolean(running && Object.values(running).some(Boolean)), [running]); + return [isRunning, execute] as const; } @@ -211,6 +249,9 @@ export const useStyles = makeStyles(theme => ({ overflow: "auto", padding: 10, }, + runButton: { + margin: "0 auto", + }, })); function getSynchronizationResultStats(result: SynchronizationResult): SynchronizationStats[] { diff --git a/src/presentation/webapp/sp-emergency-responses/SyncRuleButton.tsx b/src/presentation/webapp/sp-emergency-responses/SyncRuleButton.tsx deleted file mode 100644 index b6da58111..000000000 --- a/src/presentation/webapp/sp-emergency-responses/SyncRuleButton.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Button, makeStyles } from "@material-ui/core"; -import React from "react"; -import { SynchronizationRule } from "../../../domain/rules/entities/SynchronizationRule"; - -export interface SyncRuleButtonProps { - rule: SynchronizationRule; - onClick(rule: SynchronizationRule): void; - disabled: boolean; -} - -export const SyncRuleButton: React.FC = React.memo(props => { - const { rule, onClick, disabled } = props; - const classes = useStyles(); - - const notifyClick = React.useCallback(() => { - onClick(rule); - }, [onClick, rule]); - - return ( - - ); -}); - -export const useStyles = makeStyles(() => ({ - runButton: { - margin: "0 auto", - }, -}));