diff --git a/i18n/en.pot b/i18n/en.pot index a3742f9d1..63b80fda5 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: 2025-11-11T12:44:20.444Z\n" -"PO-Revision-Date: 2025-11-11T12:44:20.444Z\n" +"POT-Creation-Date: 2026-01-29T09:34:07.496Z\n" +"PO-Revision-Date: 2026-01-29T09:34:07.496Z\n" msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " @@ -529,9 +529,6 @@ msgstr "" msgid "Select with children subtree" msgstr "" -msgid "Select" -msgstr "" - msgid "Set metadata custodian" msgstr "" @@ -1452,6 +1449,9 @@ msgstr "" msgid "Due date" msgstr "" +msgid "Select" +msgstr "" + msgid "An error ocurred while trying to access the required events" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 4332a6485..798221d3a 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-11-11T12:44:20.444Z\n" +"POT-Creation-Date: 2026-01-29T07:30:29.800Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -530,9 +530,6 @@ msgstr "" msgid "Select with children subtree" msgstr "" -msgid "Select" -msgstr "" - msgid "Set metadata custodian" msgstr "" @@ -1456,6 +1453,9 @@ msgstr "" msgid "Due date" msgstr "" +msgid "Select" +msgstr "" + msgid "An error ocurred while trying to access the required events" msgstr "" diff --git a/i18n/fr.po b/i18n/fr.po index 52ae38817..9c047350a 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-11-11T12:44:20.444Z\n" +"POT-Creation-Date: 2026-01-29T07:30:29.800Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -530,9 +530,6 @@ msgstr "" msgid "Select with children subtree" msgstr "" -msgid "Select" -msgstr "" - msgid "Set metadata custodian" msgstr "" @@ -1456,6 +1453,9 @@ msgstr "" msgid "Due date" msgstr "" +msgid "Select" +msgstr "" + msgid "An error ocurred while trying to access the required events" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index 52ae38817..9c047350a 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-11-11T12:44:20.444Z\n" +"POT-Creation-Date: 2026-01-29T07:30:29.800Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -530,9 +530,6 @@ msgstr "" msgid "Select with children subtree" msgstr "" -msgid "Select" -msgstr "" - msgid "Set metadata custodian" msgstr "" @@ -1456,6 +1453,9 @@ msgstr "" msgid "Due date" msgstr "" +msgid "Select" +msgstr "" + msgid "An error ocurred while trying to access the required events" msgstr "" diff --git a/src/domain/data-store/DataStoreMetadata.ts b/src/domain/data-store/DataStoreMetadata.ts index f01a03c01..b81cdfef5 100644 --- a/src/domain/data-store/DataStoreMetadata.ts +++ b/src/domain/data-store/DataStoreMetadata.ts @@ -31,12 +31,13 @@ export class DataStoreMetadata { this.action = data.action; } - static buildFromKeys(keysWithNamespaces: string[]): DataStoreMetadata[] { - const dataStoreIds = this.getDataStoreIds(keysWithNamespaces); + static buildFromKeys(dataStoreIds: string[], excludedDataStoreIds: string[]): DataStoreMetadata[] { + const excludedSet = new Set(excludedDataStoreIds); + const filteredDataStoreIds = dataStoreIds.filter(id => !excludedSet.has(id)); - const namespaceAndKey = dataStoreIds.map(dataStoreId => { + const namespaceAndKey = filteredDataStoreIds.map(dataStoreId => { const match = dataStoreId.split(DataStoreMetadata.NS_SEPARATOR); - if (!match) { + if (match.length !== 2) { throw new Error(`dataStore value does not match expected format: ${dataStoreId}`); } const [namespace, key] = match; @@ -109,7 +110,7 @@ export class DataStoreMetadata { } static getDataStoreIds(keys: string[]): string[] { - return keys.filter(id => id.includes(DataStoreMetadata.NS_SEPARATOR)); + return keys.filter(id => this.isDataStoreId(id)); } static isDataStoreId(id: string): boolean { diff --git a/src/domain/metadata/builders/MetadataPayloadBuilder.ts b/src/domain/metadata/builders/MetadataPayloadBuilder.ts index 7ee4fc5d0..7d68669d9 100644 --- a/src/domain/metadata/builders/MetadataPayloadBuilder.ts +++ b/src/domain/metadata/builders/MetadataPayloadBuilder.ts @@ -160,6 +160,36 @@ export class MetadataPayloadBuilder { return finalMetadataPackage; } + public async buildDataStorePayload( + syncBuilder: SynchronizationBuilder, + remoteInstance: Instance + ): Promise { + const { metadataIds, excludedIds, syncParams, originInstance: originInstanceId } = syncBuilder; + + const dataStoreIds = DataStoreMetadata.getDataStoreIds(metadataIds); + const excludedDataStoreIds = DataStoreMetadata.getDataStoreIds(excludedIds); + const dataStore = DataStoreMetadata.buildFromKeys(dataStoreIds, excludedDataStoreIds); + + if (dataStore.length === 0) return []; + + const originInstance = await this.getOriginInstance(originInstanceId); + const dataStoreRepository = this.repositoryFactory.dataStoreMetadataRepository(originInstance); + + const dataStoreRemoteRepository = this.repositoryFactory.dataStoreMetadataRepository(remoteInstance); + + const dataStoreLocal = await dataStoreRepository.get(dataStore); + const dataStoreRemote = await dataStoreRemoteRepository.get(dataStore); + + const dataStorePayload = DataStoreMetadata.combine(metadataIds, dataStoreLocal, dataStoreRemote, { + action: syncParams?.mergeMode, + }); + + return syncParams?.includeSharingSettingsObjectsAndReferences || + syncParams?.includeOnlySharingSettingsReferences + ? dataStorePayload + : DataStoreMetadata.removeSharingSettings(dataStorePayload); + } + @cache() public async getOriginInstance(originInstanceId: string): Promise { const instance = await this.getInstanceById(originInstanceId); diff --git a/src/domain/metadata/usecases/MetadataSyncUseCase.ts b/src/domain/metadata/usecases/MetadataSyncUseCase.ts index 0e64290d3..d7eea5ea1 100644 --- a/src/domain/metadata/usecases/MetadataSyncUseCase.ts +++ b/src/domain/metadata/usecases/MetadataSyncUseCase.ts @@ -69,23 +69,7 @@ export class MetadataSyncUseCase extends GenericSyncUseCase { } private async buildDataStorePayload(instance: Instance): Promise { - const { metadataIds, syncParams } = this.builder; - const dataStore = DataStoreMetadata.buildFromKeys(metadataIds); - if (dataStore.length === 0) return []; - - const dataStoreRepository = await this.getDataStoreMetadataRepository(); - const dataStoreRemoteRepository = await this.getDataStoreMetadataRepository(instance); - - const dataStoreLocal = await dataStoreRepository.get(dataStore); - const dataStoreRemote = await dataStoreRemoteRepository.get(dataStore); - - const dataStorePayload = DataStoreMetadata.combine(metadataIds, dataStoreLocal, dataStoreRemote, { - action: syncParams?.mergeMode, - }); - return syncParams?.includeSharingSettingsObjectsAndReferences || - syncParams?.includeOnlySharingSettingsReferences - ? dataStorePayload - : DataStoreMetadata.removeSharingSettings(dataStorePayload); + return this.metadataPayloadBuilder.buildDataStorePayload(this.builder, instance); } private async saveDataStorePayload( diff --git a/src/domain/synchronization/usecases/DownloadPayloadFromSyncRuleUseCase.ts b/src/domain/synchronization/usecases/DownloadPayloadFromSyncRuleUseCase.ts index 6b13d71dc..5c97978a9 100644 --- a/src/domain/synchronization/usecases/DownloadPayloadFromSyncRuleUseCase.ts +++ b/src/domain/synchronization/usecases/DownloadPayloadFromSyncRuleUseCase.ts @@ -19,10 +19,12 @@ import { SynchronizationResultType, SynchronizationType } from "../entities/Sync import { PayloadMapper } from "../mapper/PayloadMapper"; import { GenericSyncUseCase } from "./GenericSyncUseCase"; import { MetadataPayloadBuilder } from "../../metadata/builders/MetadataPayloadBuilder"; -import { DownloadRepository } from "../../storage/repositories/DownloadRepository"; +import { DownloadItem, DownloadRepository } from "../../storage/repositories/DownloadRepository"; import { TransformationRepository } from "../../transformations/repositories/TransformationRepository"; import { EventsPayloadBuilder } from "../../events/builders/EventsPayloadBuilder"; import { AggregatedPayloadBuilder } from "../../aggregated/builders/AggregatedPayloadBuilder"; +import { DataStoreMetadata } from "../../data-store/DataStoreMetadata"; +import { isEmptyContent } from "../utils"; type DownloadErrors = string[]; @@ -84,11 +86,18 @@ export class DownloadPayloadFromSyncRuleUseCase implements UseCase { return { name: item.name, content: payload }; }) ); - - if (files.length === 1) { - this.downloadRepository.downloadFile(files[0].name, files[0].content); - } else if (files.length > 1) { - await this.downloadRepository.downloadZippedFiles(`synchronization-${date}`, files); + const { metadataIds } = rule.builder; + const dataStoreIds = DataStoreMetadata.getDataStoreIds(metadataIds); + const dataStoreFiles: DownloadItem[] = + dataStoreIds.length > 0 ? await this.mapDatastoreToDownloadItems(rule) : []; + + const cleanedFiles = files.filter(file => !isEmptyContent(file.content)); + const allFiles = [...cleanedFiles, ...dataStoreFiles]; + + if (allFiles.length === 1) { + this.downloadRepository.downloadFile(allFiles[0].name, allFiles[0].content); + } else if (allFiles.length > 1) { + await this.downloadRepository.downloadZippedFiles(`synchronization-${date}`, allFiles); } if (errors.length === 0) { @@ -200,6 +209,40 @@ export class DownloadPayloadFromSyncRuleUseCase implements UseCase { return [...downloadItemsByEvents, ...downloadItemsByTEIS, ...downloadItemsByAggregated]; } + private async mapDatastoreToDownloadItems(rule: SynchronizationRule): Promise { + const date = moment().format("YYYYMMDDHHmm"); + + const { targetInstances: targetInstanceIds } = rule.builder; + + const instanceRepository = this.repositoryFactory.instanceRepository(this.localInstance); + + return promiseMap(targetInstanceIds, async remoteInstanceId => { + const remoteInstance = await instanceRepository.getById(remoteInstanceId); + if (!remoteInstance) throw new Error("Unable to read remote instance"); + + try { + const dataStorePayload = await this.metadataPayloadBuilder.buildDataStorePayload( + rule.builder, + remoteInstance + ); + + const downloadItem: DownloadItem = { + name: _(["synchronization", rule.name, "datastore", remoteInstance.name, date]) + .compact() + .kebabCase(), + content: dataStorePayload.map(datastoreMetadata => ({ + namespace: datastoreMetadata.namespace, + keys: datastoreMetadata.keys, + sharing: datastoreMetadata.sharing ?? undefined, + })), + }; + return downloadItem; + } catch (error: unknown) { + throw new Error(`An error has ocurred while downloading datastore payload: ${error}`); + } + }); + } + private async getSyncRule(params: DownloadPayloadParams): Promise { switch (params.kind) { case "syncRuleId": { diff --git a/src/domain/synchronization/utils.ts b/src/domain/synchronization/utils.ts index 1dad1f5e4..fe83b85d2 100644 --- a/src/domain/synchronization/utils.ts +++ b/src/domain/synchronization/utils.ts @@ -46,3 +46,15 @@ export function cleanOrgUnitPath(orgUnitPath?: string): string { export function cleanOrgUnitPaths(orgUnitPaths: string[]): string[] { return orgUnitPaths.map(cleanOrgUnitPath); } + +export function isEmptyContent(content: unknown): boolean { + if (!content || typeof content !== "object") return true; + + return Object.values(content).every( + value => + value === undefined || + value === null || + (Array.isArray(value) && value.length === 0) || + (typeof value === "object" && Object.keys(value).length === 0) + ); +} diff --git a/src/presentation/react/core/components/metadata-table/MetadataTable.tsx b/src/presentation/react/core/components/metadata-table/MetadataTable.tsx index 397e96be8..2def1ad01 100644 --- a/src/presentation/react/core/components/metadata-table/MetadataTable.tsx +++ b/src/presentation/react/core/components/metadata-table/MetadataTable.tsx @@ -311,13 +311,6 @@ const MetadataTable: React.FC = ({ changeParentOrgUnitFilter(orgUnitPaths); }; - const addToSelection = (ids: string[]) => { - const oldSelection = _.difference(selectedIds, ids); - const newSelection = _.difference(ids, selectedIds); - - notifyNewSelection([...oldSelection, ...newSelection], excludedIds); - }; - const openResponsibleDialog = (ids: string[]) => { const { id, name } = rows.find(({ id }) => ids[0] === id) ?? {}; if (!id || !name) return; @@ -503,14 +496,6 @@ const MetadataTable: React.FC = ({ return model.getMetadataType() === "organisationUnit"; }, }, - { - name: "select", - text: i18n.t("Select"), - primary: true, - multiple: true, - onClick: addToSelection, - isActive: () => false, - }, { name: "set-responsible", text: i18n.t("Set metadata custodian"), @@ -629,19 +614,81 @@ const MetadataTable: React.FC = ({ .map(({ id }) => id) .value(); - const excluded = _(excludedIds) - .union(newlyUnselectedIds) - .difference(parseChildren(newlyUnselectedIds)) - .difference(newlySelectedIds) - .difference(parseChildren(newlySelectedIds)) - .filter(id => !_.find(rows, { id })) - .value(); + if (model.getMetadataType() === "dataStore") { + const childrenOf = (ids: string[]) => parseChildren(ids); + const hasChildren = (id: string) => childrenOf([id]).length > 0; + + const parentsSelected = newlySelectedIds.filter(hasChildren); + const parentsUnselected = newlyUnselectedIds.filter(hasChildren); + const childUnselected = newlyUnselectedIds.filter(id => !hasChildren(id)); + + const removedByParentUnselect = _(parentsUnselected).union(childrenOf(parentsUnselected)).value(); + + // Downward rule: + // - if a parent is selected, all its children are selected + // - if a parent is unselected, the parent and all its children are removed + const includedAfterDown = _([included, childrenOf(parentsSelected)]) + .flatten() + .uniq() + .difference(removedByParentUnselect) + .value(); + + const affectedParents = _(rows) + .filter(r => hasChildren(r.id)) + .filter(r => { + const kids = childrenOf([r.id]); + return ( + _.intersection(kids, newlySelectedIds).length > 0 || + _.intersection(kids, newlyUnselectedIds).length > 0 + ); + }) + .map(r => r.id) + .value(); + + // Upward rule: + // - if all children are selected, select the parent + // - if not all children are selected, unselect the parent + const includedFinal = affectedParents.reduce((accIncluded, parentId) => { + const kids = childrenOf([parentId]); + const selectedKids = _.intersection(kids, accIncluded); + + return kids.length > 0 && selectedKids.length === kids.length + ? _.union(accIncluded, [parentId]) + : _.difference(accIncluded, [parentId]); + }, includedAfterDown); + + const excludedBase = _(excludedIds) + .union(childUnselected) + .difference(includedFinal) + .difference(removedByParentUnselect) + .difference(childrenOf(parentsSelected)) + .filter(id => !_.find(rows, { id })) + .value(); + + const parentsForcedUnselected = affectedParents.filter(p => !includedFinal.includes(p)); + const excludedFinal = _.difference(excludedBase, parentsForcedUnselected); + + if (!_.isEqual(stateSelection, includedFinal)) { + notifyNewSelection(includedFinal, excludedFinal); + } + + setStateSelection(includedFinal); + } else { + const excluded = _(excludedIds) + .union(newlyUnselectedIds) + .difference(parseChildren(newlyUnselectedIds)) + .difference(newlySelectedIds) + .difference(parseChildren(newlySelectedIds)) + .filter(id => !_.find(rows, { id })) + .value(); + + if (!_.isEqual(stateSelection, included)) { + notifyNewSelection(included, excluded); + } - if (!_.isEqual(stateSelection, included)) { - notifyNewSelection(included, excluded); + setStateSelection(included); } - setStateSelection(included); updateFilters({ order: sorting, page: pagination.page, 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 fe96cc5e5..8023407bc 100644 --- a/src/presentation/react/core/components/sync-wizard/common/SummaryStep.tsx +++ b/src/presentation/react/core/components/sync-wizard/common/SummaryStep.tsx @@ -275,7 +275,7 @@ export const SummaryStepContent = (props: SummaryStepContentProps) => { ); })} - + {syncRule.filterRules.length > 0 && ( { const values = Object.keys(metadata).map(key => metadata[key as keyof MetadataEntities]); const element = values.flat().find(element => element?.id === id); - - return ; + const isDatastore = DataStoreMetadata.isDataStoreId(id); + if (isDatastore) { + const [namespace, key] = id.split(DataStoreMetadata.NS_SEPARATOR); + return ; + } else { + return ; + } })} @@ -564,14 +569,44 @@ export const SummaryStepContent = (props: SummaryStepContentProps) => { ); }; -export const DataStoreSectionContent = (props: { metadataIds: string[] }) => { - const { metadataIds } = props; +export const DataStoreSectionContent = (props: { metadataIds: string[]; excludedIds: string[] }) => { + const { metadataIds, excludedIds } = props; + + const excludedDatastoreIds = React.useMemo(() => { + return DataStoreMetadata.getDataStoreIds(excludedIds); + }, [excludedIds]); + + const namespaceWithSomeExcludedKey = React.useMemo(() => { + return excludedDatastoreIds.map(excludedId => { + const [namespace] = excludedId.split(DataStoreMetadata.NS_SEPARATOR); + return namespace; + }); + }, [excludedDatastoreIds]); const dataStoreInfo = React.useMemo(() => { - return metadataIds.filter(metadataId => { - return metadataId.includes(DataStoreMetadata.NS_SEPARATOR); + return metadataIds.filter(dataStoreId => { + if (DataStoreMetadata.isDataStoreId(dataStoreId)) { + const isOnlyNamespaceId = DataStoreMetadata.isNamespaceOnlySelected(dataStoreId); + + const [namespace] = dataStoreId.split(DataStoreMetadata.NS_SEPARATOR); + const hasSomeExcludedKeys = namespaceWithSomeExcludedKey.includes(namespace); + return (isOnlyNamespaceId && !hasSomeExcludedKeys) || !isOnlyNamespaceId; + } + return false; + }); + }, [metadataIds, namespaceWithSomeExcludedKey]); + + const summaryInfo = React.useMemo(() => { + const namespaces = new Set( + dataStoreInfo.filter(data => data.endsWith("[NS]")).map(data => data.split("[NS]")[0]) + ); + + return dataStoreInfo.filter(data => { + const [namespace, key] = data.split("[NS]"); + const isNamespace = key === ""; + return isNamespace || !namespaces.has(namespace); }); - }, [metadataIds]); + }, [dataStoreInfo]); if (dataStoreInfo.length === 0) return null; @@ -579,10 +614,10 @@ export const DataStoreSectionContent = (props: { metadataIds: string[] }) => { <>
    - {dataStoreInfo.map(dataStore => { + {summaryInfo.map(dataStore => { const [namespace, key] = dataStore.split(DataStoreMetadata.NS_SEPARATOR); - const keyName = key ? `${key}` : "All Keys"; - return ; + const keyName = key ? `Key: ${key}` : "All Keys"; + return ; })}