From 330badf0169d3c66e61ba9a24c5a8a847254c4d1 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Wed, 4 Dec 2024 13:22:27 -0500 Subject: [PATCH] add sharing step --- src/data/repositories/ConfigD2Repository.ts | 73 ++++++++++++ src/data/repositories/D2ApiCategoryOption.ts | 9 +- src/data/repositories/D2ApiConfig.ts | 1 + src/data/repositories/DataSetD2Api.ts | 7 +- .../repositories/DataSetTestRepository.ts | 4 +- src/data/repositories/OrgUnitD2Repository.ts | 5 +- src/data/repositories/ProjectD2Repository.ts | 30 ++++- src/data/repositories/RegionD2Repository.ts | 12 ++ src/domain/entities/Config.ts | 6 + src/domain/entities/DataSet.ts | 104 +++++++++++++++--- src/domain/entities/Permission.ts | 11 ++ src/domain/entities/Project.ts | 5 +- src/domain/entities/Ref.ts | 4 + src/domain/entities/Region.ts | 3 + src/domain/entities/__tests__/DataSet.spec.ts | 88 +++++++++++++++ src/domain/entities/generic/Error.ts | 4 +- src/domain/repositories/ConfigRepository.ts | 6 + src/domain/repositories/RegionRepository.ts | 6 + src/domain/usecases/SaveDataSetUseCase.ts | 12 +- src/utils/tests.tsx | 9 ++ .../dataset-wizard/DataSetWizard.tsx | 42 ++++--- .../dataset-wizard/SetupDataSet.tsx | 17 +-- .../dataset-wizard/ShareOptionsDataSet.tsx | 51 +++++---- .../dataset-wizard/SummaryDataSet.tsx | 16 ++- .../components/projects/ProjectTable.tsx | 2 +- src/webapp/contexts/app-context.ts | 2 + src/webapp/pages/app/App.tsx | 8 +- src/webapp/pages/app/Dhis2App.tsx | 11 +- src/webapp/pages/app/__tests__/App.spec.tsx | 4 +- 29 files changed, 475 insertions(+), 77 deletions(-) create mode 100644 src/data/repositories/ConfigD2Repository.ts create mode 100644 src/data/repositories/RegionD2Repository.ts create mode 100644 src/domain/entities/Config.ts create mode 100644 src/domain/entities/Region.ts create mode 100644 src/domain/entities/__tests__/DataSet.spec.ts create mode 100644 src/domain/repositories/ConfigRepository.ts create mode 100644 src/domain/repositories/RegionRepository.ts diff --git a/src/data/repositories/ConfigD2Repository.ts b/src/data/repositories/ConfigD2Repository.ts new file mode 100644 index 00000000..af984145 --- /dev/null +++ b/src/data/repositories/ConfigD2Repository.ts @@ -0,0 +1,73 @@ +import { apiToFuture } from "$/data/api-futures"; +import { metadataCodes } from "$/data/repositories/D2ApiConfig"; +import { Config, UserGroup } from "$/domain/entities/Config"; +import { Region } from "$/domain/entities/Region"; +import { Future, FutureData } from "$/domain/entities/generic/Future"; +import { ConfigRepository } from "$/domain/repositories/ConfigRepository"; +import { D2Api } from "$/types/d2-api"; + +export class ConfigD2Repository implements ConfigRepository { + constructor(private api: D2Api) {} + + get(): FutureData { + return this.getOrgUnitLevelGroup().flatMap(orgUnitLevel => { + return Future.joinObj({ + regions: this.getRegions(orgUnitLevel), + userGroups: this.getUserGroups(), + }); + }); + } + + private getOrgUnitLevelGroup(): FutureData { + return apiToFuture( + this.api.models.organisationUnitLevels.get({ + fields: { id: true, level: true }, + filter: { name: { eq: metadataCodes.orgUnitLevels.country } }, + }) + ).flatMap(d2Response => { + const orgUnitLevel = d2Response.objects[0]; + return orgUnitLevel + ? Future.success(orgUnitLevel.level) + : Future.error(new Error("Country level not found")); + }); + } + + private getRegions(level: number): FutureData { + return apiToFuture( + this.api.models.organisationUnits.get({ + fields: { id: true, code: true, name: true }, + filter: { level: { eq: String(level) }, children: { gt: "0" } }, + paging: false, + }) + ).map(d2Response => { + return d2Response.objects.map(region => ({ + id: region.id, + name: region.name, + code: this.extractRegionCode(region.code), + })); + }); + } + + private getUserGroups(): FutureData { + return apiToFuture( + this.api.models.userGroups.get({ + fields: { id: true, name: true }, + paging: false, + }) + ).map(d2Response => { + return d2Response.objects.map(region => ({ + id: region.id, + name: region.name, + code: this.extractCode(region.name), + })); + }); + } + + private extractRegionCode(code: string): string { + return (code.slice(0, 2) || "").toUpperCase(); + } + + private extractCode(code: string): string { + return (code.split("_")[0] || "").toUpperCase(); + } +} diff --git a/src/data/repositories/D2ApiCategoryOption.ts b/src/data/repositories/D2ApiCategoryOption.ts index 8f267c64..fa689b23 100644 --- a/src/data/repositories/D2ApiCategoryOption.ts +++ b/src/data/repositories/D2ApiCategoryOption.ts @@ -13,7 +13,7 @@ export class D2ApiCategoryOption { return apiToFuture( this.api.models.categoryOptions.get({ filter: { id: { in: categoryOptionsIds } }, - fields: { id: true, displayName: true, lastUpdated: true }, + fields: { id: true, code: true, displayName: true, lastUpdated: true }, paging: false, }) ).map(response => response.objects); @@ -21,4 +21,9 @@ export class D2ApiCategoryOption { } } -export type D2CategoryOptionType = { id: string; displayName: string; lastUpdated: ISODateString }; +export type D2CategoryOptionType = { + id: string; + code: string; + displayName: string; + lastUpdated: ISODateString; +}; diff --git a/src/data/repositories/D2ApiConfig.ts b/src/data/repositories/D2ApiConfig.ts index 72ff8fa2..fb446a94 100644 --- a/src/data/repositories/D2ApiConfig.ts +++ b/src/data/repositories/D2ApiConfig.ts @@ -27,6 +27,7 @@ export const metadataCodes = { localIndicator: "Local Indicators", }, indicatorGroupSets: { theme: "Theme", status: "Status" }, + orgUnitLevels: { country: "Country" }, }; const metadataFields = { diff --git a/src/data/repositories/DataSetD2Api.ts b/src/data/repositories/DataSetD2Api.ts index 119bd76d..ea2e823f 100644 --- a/src/data/repositories/DataSetD2Api.ts +++ b/src/data/repositories/DataSetD2Api.ts @@ -20,6 +20,7 @@ import { Project } from "$/domain/entities/Project"; import { D2ApiCategoryOption } from "$/data/repositories/D2ApiCategoryOption"; import { D2ApiConfig, D2Config } from "$/data/repositories/D2ApiConfig"; import { Pager } from "@eyeseetea/d2-api/api"; +import { D2OrgUnit } from "$/data/repositories/OrgUnitD2Repository"; export class DataSetD2Api { private d2ApiCategoryOption: D2ApiCategoryOption; @@ -149,6 +150,8 @@ export class DataSetD2Api { name: categoryOption.displayName, lastUpdated: categoryOption.lastUpdated, isOpen: false, + orgsUnits: [], + code: categoryOption.code, }); }); }); @@ -207,6 +210,7 @@ export class DataSetD2Api { orgUnits: d2DataSet.organisationUnits ? d2DataSet.organisationUnits.map((ou): OrgUnit => { return { + code: ou.code, id: ou.id, name: ou.displayName, path: ou.path.split("/").slice(1), @@ -315,7 +319,7 @@ export const dataSetFields = { export const dataSetFieldsWithOrgUnits = { ...dataSetFields, - organisationUnits: { id: true, displayName: true, path: true }, + organisationUnits: { id: true, code: true, displayName: true, path: true }, }; type D2DataSetFields = MetadataPick<{ @@ -323,5 +327,4 @@ type D2DataSetFields = MetadataPick<{ }>["dataSets"][number]; type D2DataSet = { organisationUnits?: D2OrgUnit[] } & D2DataSetFields; -type D2OrgUnit = { id: Id; path: string; displayName: string }; export type OctalNotationPermission = string; diff --git a/src/data/repositories/DataSetTestRepository.ts b/src/data/repositories/DataSetTestRepository.ts index 124a40eb..61216e3d 100644 --- a/src/data/repositories/DataSetTestRepository.ts +++ b/src/data/repositories/DataSetTestRepository.ts @@ -14,10 +14,10 @@ export class DataSetTestRepository implements DataSetRepository { throw new Error("Method not implemented."); } getByIds(): FutureData { - throw new Error("Method not implemented."); + return Future.success([]); } save(): FutureData { - throw new Error("Method not implemented."); + return Future.void(); } delete(): FutureData { throw new Error("Method not implemented."); diff --git a/src/data/repositories/OrgUnitD2Repository.ts b/src/data/repositories/OrgUnitD2Repository.ts index 22955efb..c3da3493 100644 --- a/src/data/repositories/OrgUnitD2Repository.ts +++ b/src/data/repositories/OrgUnitD2Repository.ts @@ -15,7 +15,7 @@ export class OrgUnitD2Repository implements OrgUnitRepository { const $requests = chunkRequest(ids, idsToFetch => { return apiToFuture( this.api.models.organisationUnits.get({ - fields: { id: true, displayName: true, path: true }, + fields: { id: true, code: true, displayName: true, path: true }, filter: { id: { in: idsToFetch } }, paging: false, }) @@ -25,6 +25,7 @@ export class OrgUnitD2Repository implements OrgUnitRepository { return $requests.map(response => { return response.map(d2OrgUnit => { return { + code: d2OrgUnit.code, id: d2OrgUnit.id, name: d2OrgUnit.displayName, path: d2OrgUnit.path.split("/").slice(1), @@ -34,4 +35,4 @@ export class OrgUnitD2Repository implements OrgUnitRepository { } } -type D2OrgUnit = { id: string; displayName: string; path: string }; +export type D2OrgUnit = { id: string; code: string; displayName: string; path: string }; diff --git a/src/data/repositories/ProjectD2Repository.ts b/src/data/repositories/ProjectD2Repository.ts index c018ec93..55e358bf 100644 --- a/src/data/repositories/ProjectD2Repository.ts +++ b/src/data/repositories/ProjectD2Repository.ts @@ -26,11 +26,13 @@ export class ProjectD2Repository implements ProjectRepository { return apiToFuture( this.api.models.categoryOptions.get({ fields: { + code: true, id: true, displayName: true, startDate: true, endDate: true, lastUpdated: true, + organisationUnits: { id: true, code: true, displayName: true, path: true }, }, filter: { "categories.code": { eq: categories.project.code } }, order: "displayName:asc", @@ -40,6 +42,7 @@ export class ProjectD2Repository implements ProjectRepository { return d2Response.objects.map((d2CategoryOption): Project => { return Project.build({ dataSets: [], + code: d2CategoryOption.code, id: d2CategoryOption.id, name: d2CategoryOption.displayName, lastUpdated: d2CategoryOption.lastUpdated, @@ -47,6 +50,12 @@ export class ProjectD2Repository implements ProjectRepository { d2CategoryOption.startDate, d2CategoryOption.endDate ), + orgsUnits: d2CategoryOption.organisationUnits.map(orgUnit => ({ + id: orgUnit.id, + code: orgUnit.code, + name: orgUnit.displayName, + path: orgUnit.path.split("/").slice(1), + })), }); }); }); @@ -100,7 +109,13 @@ export class ProjectD2Repository implements ProjectRepository { }, page: options.paging.page, pageSize: options.paging.pageSize, - fields: { id: true, displayName: true, lastUpdated: true }, + fields: { + id: true, + code: true, + displayName: true, + lastUpdated: true, + organisationUnits: { id: true, code: true, path: true, displayName: true }, + }, order: this.buildOrderParam(options), }) ).flatMap(d2Response => { @@ -144,13 +159,22 @@ export class ProjectD2Repository implements ProjectRepository { }); } - private buildProject(d2CategoryOption: D2CategoryOptionType): Project { + private buildProject( + d2CategoryOption: D2CategoryOptionType & { organisationUnits: D2OrgUnit[] } + ): Project { return Project.build({ + code: d2CategoryOption.code, id: d2CategoryOption.id, name: d2CategoryOption.displayName, lastUpdated: d2CategoryOption.lastUpdated, dataSets: [], isOpen: false, + orgsUnits: d2CategoryOption.organisationUnits.map(orgUnit => ({ + code: orgUnit.code, + id: orgUnit.id, + name: orgUnit.displayName, + path: orgUnit.path.split("/").slice(1), + })), }); } @@ -159,3 +183,5 @@ export class ProjectD2Repository implements ProjectRepository { return `${options.sorting.field}:${options.sorting.order}`; } } + +type D2OrgUnit = { id: Id; code: string; displayName: string; path: string }; diff --git a/src/data/repositories/RegionD2Repository.ts b/src/data/repositories/RegionD2Repository.ts new file mode 100644 index 00000000..0cbe7da8 --- /dev/null +++ b/src/data/repositories/RegionD2Repository.ts @@ -0,0 +1,12 @@ +import { Future, FutureData } from "$/domain/entities/generic/Future"; +import { Region } from "$/domain/entities/Region"; +import { RegionRepository } from "$/domain/repositories/RegionRepository"; +import { D2Api } from "$/types/d2-api"; + +export class RegionD2Repository implements RegionRepository { + constructor(private _api: D2Api) {} + + get(): FutureData { + return Future.success([]); + } +} diff --git a/src/domain/entities/Config.ts b/src/domain/entities/Config.ts new file mode 100644 index 00000000..8b9f748b --- /dev/null +++ b/src/domain/entities/Config.ts @@ -0,0 +1,6 @@ +import { NamedCodeRef } from "$/domain/entities/Ref"; +import { Region } from "$/domain/entities/Region"; + +export type UserGroup = NamedCodeRef; + +export type Config = { regions: Region[]; userGroups: UserGroup[] }; diff --git a/src/domain/entities/DataSet.ts b/src/domain/entities/DataSet.ts index 46653f52..a2ee1855 100644 --- a/src/domain/entities/DataSet.ts +++ b/src/domain/entities/DataSet.ts @@ -9,6 +9,7 @@ import { Either } from "$/domain/entities/generic/Either"; import { ValidationError } from "$/domain/entities/generic/Error"; import { validateOrgUnits, validateRequired } from "$/domain/entities/generic/Validation"; import { Indicator } from "$/domain/entities/Indicator"; +import { Config, UserGroup } from "$/domain/entities/Config"; export type DataSetAttrs = { created: ISODateString; @@ -32,7 +33,7 @@ export type DataSetToSave = Omit() { return errors.length === 0 ? Either.success(this) : Either.error(errors); } - updateProject(project: Maybe): DataSet { + updateProject(project: Maybe, config: Config): DataSet { const name = project ? `${project.name} DataSet` : ""; - return this._update({ project, name }); + const orgUnits = project ? project.orgsUnits : this.orgUnits; + + const accessGroupsFromProject = this.getAccessFromProject(project, config); + + return this._update({ access: accessGroupsFromProject, project, name, orgUnits }); + } + + updateAccess(config: Config): DataSet { + const accessGroupsFromProject = this.getAccessFromProject(this.project, config); + const accessFromOrgUnits = this.getAccessFromOrgUnits(this.orgUnits, config); + + return this._update({ + access: this.project ? accessGroupsFromProject : accessFromOrgUnits, + }); + } + + updateAccessFromRegionsCodes(codes: string[], config: Config): DataSet { + const access = codes.flatMap(code => { + const userGroups = config.userGroups.filter(userGroup => userGroup.code === code); + return this.buildAccessGroups(userGroups); + }); + return this._update({ access: access }); } update(fieldName: keyof DataSet, value: string | number | boolean): DataSet { @@ -84,7 +106,31 @@ export class DataSet extends Struct() { ]); } - static createEmpty(id: Id): DataSet { + validateSharingStep(): Either[], DataSet> { + if (this.project) return Either.success(this); + + const regionCodesFromOrgUnits = this.getRegionCodesFromAccess(); + + return regionCodesFromOrgUnits.length > 0 + ? Either.success(this) + : Either.error([ + { + property: "access" as const, + errors: ["regions_required"], + value: this.access, + }, + ]); + } + + getRegionCodesFromAccess(): string[] { + return _(this.access) + .filter(access => access.type === "groups") + .compactMap(access => access.name.split("_")[0]) + .uniq() + .value(); + } + + static createEmpty(id: Id, initialData: Partial = {}): DataSet { return DataSet.create({ indicators: [], access: [], @@ -104,18 +150,10 @@ export class DataSet extends Struct() { expiryDays: 0, openFuturePeriods: 0, notifyUser: false, + ...initialData, }); } - static buildOrgUnitsFromPaths(paths: string[]): OrgUnit[] { - const orgUnits = paths.map(path => ({ - id: _(path.split("/")).last() || "", - name: path, - path: path.split("/").slice(1), - })); - return orgUnits; - } - static buildAccess(permissions: Permissions): string { const dataDescription = DataSet.buildAccessDescription(permissions.data); const metadataDescription = DataSet.buildAccessDescription(permissions.metadata); @@ -129,8 +167,9 @@ export class DataSet extends Struct() { private getValidationErrors(): ValidationError[] { const setupErrors = this.buildSetupErrors(); const indicatorsErrors = this.validateIndicatorsStep().value.error || []; + const sharingErrors = this.validateSharingStep().value.error || []; - return [...setupErrors, ...indicatorsErrors]; + return [...setupErrors, ...indicatorsErrors, ...sharingErrors]; } private buildSetupErrors(): ValidationError[] { @@ -159,4 +198,41 @@ export class DataSet extends Struct() { return ""; } } + + private getAccessFromOrgUnits(orgUnits: OrgUnit[], config: Config): AccessData[] { + const orgsUnitsCodes = orgUnits.map(orgUnit => orgUnit.code.slice(0, 2)); + + const regions = config.regions.filter(region => orgsUnitsCodes.includes(region.code)); + const regionsCodes = regions.map(region => region.code); + + const userGroups = config.userGroups.filter(userGroup => + regionsCodes.includes(userGroup.code) + ); + + return this.buildAccessGroups(userGroups); + } + + private getAccessFromProject(project: Maybe, config: Config): AccessData[] { + if (!project || !project.code) return []; + const regionCode = (project.code?.slice(0, 2) || "").toUpperCase(); + + const region = config.regions.find(region => region.code === regionCode); + const userGroups = config.userGroups.filter(userGroup => userGroup.code === region?.code); + + return this.buildAccessGroups(userGroups); + } + + private buildAccessGroups(userGroups: UserGroup[]): AccessData[] { + return userGroups.map(userGroup => this.setAccessPermissionGroup(userGroup)); + } + + private setAccessPermissionGroup(userGroup: UserGroup): AccessData { + const isAdmin = userGroup.name.split("_")[1]?.toLowerCase() === "administrators"; + return { + id: userGroup.id, + name: userGroup.name, + permissions: Permission.setDefaultPermissionsForGroups(isAdmin), + type: "groups", + }; + } } diff --git a/src/domain/entities/Permission.ts b/src/domain/entities/Permission.ts index d5b402bc..81d0c564 100644 --- a/src/domain/entities/Permission.ts +++ b/src/domain/entities/Permission.ts @@ -1,3 +1,4 @@ +import { Permissions } from "$/domain/entities/DataSet"; import { Struct } from "$/domain/entities/generic/Struct"; export type PermissionAttrs = { read: boolean; write: boolean }; @@ -10,4 +11,14 @@ export class Permission extends Struct() { static buildWithOutAccess(): Permission { return Permission.create({ read: false, write: false }); } + + static setDefaultPermissionsForGroups(isAdmin: boolean): Permissions { + const adminDataPermission = Permission.create({ read: true, write: true }); + return { + data: adminDataPermission, + metadata: isAdmin + ? adminDataPermission + : Permission.create({ read: true, write: false }), + }; + } } diff --git a/src/domain/entities/Project.ts b/src/domain/entities/Project.ts index f8920bfd..0f9580e5 100644 --- a/src/domain/entities/Project.ts +++ b/src/domain/entities/Project.ts @@ -1,14 +1,17 @@ -import { DataSet } from "$/domain/entities/DataSet"; +import { DataSet, OrgUnit } from "$/domain/entities/DataSet"; import { ISODateString, Id } from "$/domain/entities/Ref"; import { Struct } from "$/domain/entities/generic/Struct"; import i18n from "$/utils/i18n"; +import { Maybe } from "$/utils/ts-utils"; export type ProjectAttrs = { id: Id; + code: Maybe; name: string; isOpen: boolean; dataSets: DataSet[]; lastUpdated: ISODateString; + orgsUnits: OrgUnit[]; }; export class Project extends Struct() { diff --git a/src/domain/entities/Ref.ts b/src/domain/entities/Ref.ts index 700ad6c1..b5a35712 100644 --- a/src/domain/entities/Ref.ts +++ b/src/domain/entities/Ref.ts @@ -8,3 +8,7 @@ export interface Ref { export interface NamedRef extends Ref { name: string; } + +export interface NamedCodeRef extends NamedRef { + code: string; +} diff --git a/src/domain/entities/Region.ts b/src/domain/entities/Region.ts new file mode 100644 index 00000000..a0c597c8 --- /dev/null +++ b/src/domain/entities/Region.ts @@ -0,0 +1,3 @@ +import { NamedCodeRef } from "$/domain/entities/Ref"; + +export type Region = NamedCodeRef; diff --git a/src/domain/entities/__tests__/DataSet.spec.ts b/src/domain/entities/__tests__/DataSet.spec.ts new file mode 100644 index 00000000..59b8945a --- /dev/null +++ b/src/domain/entities/__tests__/DataSet.spec.ts @@ -0,0 +1,88 @@ +import { DataSet, DataSetAttrs } from "$/domain/entities/DataSet"; +import { Project } from "$/domain/entities/Project"; +import { getErrorMessageFromErrors } from "$/domain/entities/generic/Error"; +import { configTest } from "$/utils/tests"; +import { Maybe } from "$/utils/ts-utils"; +import { getUid } from "$/utils/uid"; + +const projectTest: Project = Project.create({ + id: getUid(new Date().getTime().toString()), + name: "Test Project Afghanistan", + isOpen: true, + code: "AFFE1507", + lastUpdated: new Date().toISOString(), + orgsUnits: [], + dataSets: [], +}); + +describe("DataSet", () => { + it("should throw an error if project and org. units are not present", async () => { + const dataSetToSave = createDataSet({ project: projectTest, orgUnits: [] }).updateProject( + undefined, + configTest + ); + + const errors = dataSetToSave.validateSharingStep(); + const errorMessage = getErrorMessageFromErrors(errors.value.error || []); + expect(errors.isError()).toBe(true); + expect(errorMessage).toMatch("Select at least one country"); + }); + + it("should have access groups if org. units is present and project is not defined", async () => { + const dataSetToSave = createDataSet({ + name: "Test DataSet", + indicators: [], + project: undefined, + orgUnits: [ + { + id: "gEcRYGMcEbO", + code: "NRC", + name: "NRC", + path: [], + }, + { + id: "ISeK6bTD3hr", + code: "AF_AO_Central", + name: "AF_AO_Central (Kabul)", + path: [], + }, + ], + }).updateAccess(configTest); + + const result = dataSetToSave.validateSharingStep(); + expect(result.isSuccess()).toBe(true); + + expectUserGroups(result.value.data); + }); + + it("should have access groups from project code", async () => { + const dataSetToSave = createDataSet({ + name: "Test DataSet", + indicators: [], + project: undefined, + }).updateProject(projectTest, configTest); + + const result = dataSetToSave.validateSharingStep(); + expect(result.isSuccess()).toBe(true); + + expectUserGroups(result.value.data); + }); +}); + +function createDataSet(data?: Partial): DataSet { + return DataSet.createEmpty(getUid(new Date().getTime().toString()), data); +} + +function expectUserGroups(dataSet: Maybe) { + const adminUserGroupCode = "AF_Administrators"; + const userUserGroupCode = "AF_Users"; + + const getUserGroupByName = (name: string) => + dataSet?.access.find(access => access.name === name); + + const adminGroup = getUserGroupByName(adminUserGroupCode); + const userGroup = getUserGroupByName(userUserGroupCode); + + expect(adminGroup?.name).toBe(adminUserGroupCode); + expect(userGroup?.name).toBe(userUserGroupCode); +} diff --git a/src/domain/entities/generic/Error.ts b/src/domain/entities/generic/Error.ts index 696a56c5..e1510c42 100644 --- a/src/domain/entities/generic/Error.ts +++ b/src/domain/entities/generic/Error.ts @@ -4,7 +4,8 @@ export type ValidationErrorKey = | "field_cannot_be_blank" | "positive_number" | "org_unit_required" - | "indicators_required"; + | "indicators_required" + | "regions_required"; export const validationErrorMessages: Record< ValidationErrorKey, @@ -19,6 +20,7 @@ export const validationErrorMessages: Record< }, org_unit_required: () => i18n.t("At least one org. unit is required"), indicators_required: () => i18n.t("At least one indicator is required"), + regions_required: () => i18n.t("Select at least one country"), }; export function getErrorMessageFromErrors(errors: ValidationError[]): string { diff --git a/src/domain/repositories/ConfigRepository.ts b/src/domain/repositories/ConfigRepository.ts new file mode 100644 index 00000000..7c092e20 --- /dev/null +++ b/src/domain/repositories/ConfigRepository.ts @@ -0,0 +1,6 @@ +import { Config } from "$/domain/entities/Config"; +import { FutureData } from "$/domain/entities/generic/Future"; + +export interface ConfigRepository { + get(): FutureData; +} diff --git a/src/domain/repositories/RegionRepository.ts b/src/domain/repositories/RegionRepository.ts new file mode 100644 index 00000000..c587fec2 --- /dev/null +++ b/src/domain/repositories/RegionRepository.ts @@ -0,0 +1,6 @@ +import { Region } from "$/domain/entities/Region"; +import { FutureData } from "$/domain/entities/generic/Future"; + +export interface RegionRepository { + get(): FutureData; +} diff --git a/src/domain/usecases/SaveDataSetUseCase.ts b/src/domain/usecases/SaveDataSetUseCase.ts index 7f8e2609..51efbeeb 100644 --- a/src/domain/usecases/SaveDataSetUseCase.ts +++ b/src/domain/usecases/SaveDataSetUseCase.ts @@ -1,5 +1,5 @@ import _ from "$/domain/entities/generic/Collection"; -import { DataSet } from "$/domain/entities/DataSet"; +import { AccessData, DataSet } from "$/domain/entities/DataSet"; import { Future, FutureData } from "$/domain/entities/generic/Future"; import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; import { Maybe } from "$/utils/ts-utils"; @@ -20,6 +20,7 @@ export class SaveDataSetUseCase { const dataSetToSave = DataSet.create({ ...(existingDataSet || {}), ...dataSet, + access: this.mergeExistingUserGroups(dataSet, existingDataSet), shortName: this.truncateValue(dataSet.name), }); @@ -54,6 +55,15 @@ export class SaveDataSetUseCase { }); } + private mergeExistingUserGroups( + dataSet: DataSet, + existingDataSet: Maybe + ): AccessData[] { + if (!existingDataSet) return dataSet.access; + const userGroups = existingDataSet.access.filter(access => access.type === "users"); + return dataSet.access.concat(userGroups); + } + private getDataElementsByIds(ids: Id[]): FutureData { return this.dataElementRepository.getBy(ids); } diff --git a/src/utils/tests.tsx b/src/utils/tests.tsx index c8f55063..18ccf887 100644 --- a/src/utils/tests.tsx +++ b/src/utils/tests.tsx @@ -6,11 +6,20 @@ import { getTestCompositionRoot } from "$/CompositionRoot"; import { createAdminUser } from "$/domain/entities/__tests__/userFixtures"; import { D2Api } from "$/types/d2-api"; +export const configTest = { + regions: [{ id: "ISeK6bTD3hr", code: "AF", name: "Afghanistan" }], + userGroups: [ + { id: "JqI1AgplhXe", code: "AF", name: "AF_Administrators" }, + { id: "da40GWQupNL", code: "AF", name: "AF_Users" }, + ], +}; + export function getTestContext() { const context: AppContextState = { currentUser: createAdminUser(), compositionRoot: getTestCompositionRoot(), api: {} as D2Api, + config: configTest, }; return context; diff --git a/src/webapp/components/dataset-wizard/DataSetWizard.tsx b/src/webapp/components/dataset-wizard/DataSetWizard.tsx index 0fddbf3f..2ecb7fd1 100644 --- a/src/webapp/components/dataset-wizard/DataSetWizard.tsx +++ b/src/webapp/components/dataset-wizard/DataSetWizard.tsx @@ -31,7 +31,7 @@ const useStyles = makeStyles((theme: Theme) => export type ValidationStatusType = "idle" | "loading" | "error" | "success"; export const DataSetWizard = React.memo((props: DataSetWizardProps) => { - const { compositionRoot } = useAppContext(); + const { compositionRoot, config } = useAppContext(); const { dataSet, id, projects, updateDataSet, dataSetSettings } = props; const isEditing = Boolean(id); const actionTitle = isEditing ? i18n.t("Edit") : i18n.t("Create"); @@ -41,7 +41,7 @@ export const DataSetWizard = React.memo((props: DataSetWizardProps) => { const [validationStatus, setValidationStatus] = React.useState("idle"); const goBackToHome = React.useCallback(() => { - navigateTo("createDataSets"); + navigateTo("dataSets"); }, [navigateTo]); const validateDataSetName = React.useCallback( @@ -63,20 +63,27 @@ export const DataSetWizard = React.memo((props: DataSetWizardProps) => { ); const stepsWithProps = React.useMemo(() => { - return steps.map(step => { - return { - ...step, - props: { - dataSet, - onValidate: validateDataSetName, - validationStatus, - onChange: updateDataSet, - projects, - dataSetSettings, - }, - }; - }); + return steps + .filter(step => { + if (step.key !== "share") return true; + return dataSet.project === undefined; + }) + .map(step => { + return { + ...step, + props: { + config, + dataSet, + onValidate: validateDataSetName, + validationStatus, + onChange: updateDataSet, + projects, + dataSetSettings, + }, + }; + }); }, [ + config, dataSet, dataSetSettings, projects, @@ -103,6 +110,11 @@ export const DataSetWizard = React.memo((props: DataSetWizardProps) => { if (result.isError()) { return Promise.resolve(getErrors(result.value.error)); } + } else if (currentStep.key === "share") { + const result = dataSet.validateSharingStep(); + if (result.isError()) { + return Promise.resolve(getErrors(result.value.error)); + } } return Promise.resolve([]); }, diff --git a/src/webapp/components/dataset-wizard/SetupDataSet.tsx b/src/webapp/components/dataset-wizard/SetupDataSet.tsx index 27825026..c7e15580 100644 --- a/src/webapp/components/dataset-wizard/SetupDataSet.tsx +++ b/src/webapp/components/dataset-wizard/SetupDataSet.tsx @@ -18,6 +18,7 @@ import { Project } from "$/domain/entities/Project"; import { ProjectsSelectorModal } from "$/webapp/components/dataset-wizard/ProjectsSelectorModal"; import { Maybe } from "$/utils/ts-utils"; import { component } from "$/utils/react"; +import _ from "$/domain/entities/generic/Collection"; export type SetupDataSetProps = { dataSet: DataSet; @@ -28,7 +29,7 @@ export type SetupDataSetProps = { }; const SetupDataSet_ = React.memo((props: SetupDataSetProps) => { - const { api } = useAppContext(); + const { api, config, compositionRoot } = useAppContext(); const { dataSet, onChange, onValidate, projects, validationStatus } = props; const [projectModalOpen, setProjectModalOpen] = React.useState(false); @@ -64,20 +65,22 @@ const SetupDataSet_ = React.memo((props: SetupDataSetProps) => { const updateOrgUnits = React.useCallback( (paths: string[]) => { - const orgUnits = DataSet.buildOrgUnitsFromPaths(paths); - const updateData = DataSet.create({ ...dataSet, orgUnits }); - onChange(updateData); + const idsFromPaths = paths.map(path => _(path.split("/")).last() || ""); + compositionRoot.orgUnits.getByIds.execute(idsFromPaths).run(orgUnitsDetails => { + const updateData = DataSet.create({ ...dataSet, orgUnits: orgUnitsDetails }); + onChange(updateData.updateAccess(config)); + }, console.error); }, - [onChange, dataSet] + [compositionRoot.orgUnits.getByIds, onChange, dataSet, config] ); const updateProject = React.useCallback( (project: Maybe) => { - const updatedData = dataSet.updateProject(project); + const updatedData = dataSet.updateProject(project, config); onChange(updatedData); setProjectModalOpen(false); }, - [onChange, setProjectModalOpen, dataSet] + [config, onChange, setProjectModalOpen, dataSet] ); return ( diff --git a/src/webapp/components/dataset-wizard/ShareOptionsDataSet.tsx b/src/webapp/components/dataset-wizard/ShareOptionsDataSet.tsx index 11916cb1..e20cbbcb 100644 --- a/src/webapp/components/dataset-wizard/ShareOptionsDataSet.tsx +++ b/src/webapp/components/dataset-wizard/ShareOptionsDataSet.tsx @@ -2,27 +2,30 @@ import React from "react"; import i18n from "$/utils/i18n"; import { MultiSelector } from "@eyeseetea/d2-ui-components"; import { component } from "$/utils/react"; +import { DataSet } from "$/domain/entities/DataSet"; +import { useAppContext } from "$/webapp/contexts/app-context"; +import _ from "$/domain/entities/generic/Collection"; +import { Config } from "$/domain/entities/Config"; + +export type ShareOptionsDataSetProps = { dataSet: DataSet; onChange: (dataSet: DataSet) => void }; -export type ShareOptionsDataSetProps = {}; export type SelectorItem = { text: string; value: string }; -export const ShareOptionsDataSet_ = React.memo((_props: ShareOptionsDataSetProps) => { - const [selected, setSelected] = React.useState([]); +export const ShareOptionsDataSet_ = React.memo((props: ShareOptionsDataSetProps) => { + const { config } = useAppContext(); + const { dataSet, onChange } = props; - const items: SelectorItem[] = [ - { - text: "Algeria", - value: "Bangladesh", - }, - { - text: "Brazil", - value: "Brazil", - }, - { - text: "Canada", - value: "Canada", + const regionsCodes = dataSet.getRegionCodesFromAccess(); + + const selectedRegions = config.regions.filter(region => regionsCodes.includes(region.code)); + + const updateRegions = React.useCallback( + (regionsCodes: string[]) => { + const updateData = dataSet.updateAccessFromRegionsCodes(regionsCodes, config); + onChange(updateData); }, - ]; + [config, onChange, dataSet] + ); return (
@@ -30,12 +33,22 @@ export const ShareOptionsDataSet_ = React.memo((_props: ShareOptionsDataSetProps ({ + text: region.name, + value: region.code, + }))} + selected={selectedRegions.map(region => region.code)} + onChange={updateRegions} />
); }); export const ShareOptionsDataSet = component(ShareOptionsDataSet_); + +function filterRegionsWithoutUserGroups(config: Config) { + const result = config.regions.filter(region => { + return config.userGroups.some(userGroup => userGroup.code === region.code); + }); + return result; +} diff --git a/src/webapp/components/dataset-wizard/SummaryDataSet.tsx b/src/webapp/components/dataset-wizard/SummaryDataSet.tsx index 2faa6740..dfe2c9b9 100644 --- a/src/webapp/components/dataset-wizard/SummaryDataSet.tsx +++ b/src/webapp/components/dataset-wizard/SummaryDataSet.tsx @@ -65,6 +65,7 @@ const SummaryDataSet_ = React.memo((props: SummaryDataSetProps) => { export const SummaryDataSet = component(SummaryDataSet_); export const SummaryList = React.memo((props: { dataSet: DataSet; orgUnits: OrgUnit[] }) => { + const { config } = useAppContext(); const { dataSet, orgUnits } = props; const extraOrgUnits = dataSet.orgUnits.length - MAX_ORG_UNITS_TO_SHOW; @@ -75,6 +76,16 @@ export const SummaryList = React.memo((props: { dataSet: DataSet; orgUnits: OrgU .map(indicator => indicator.coreCompetency?.name || "") .join(", "); + const regionsCodes = _(dataSet.access) + .filter(access => access.type === "groups") + .compactMap(access => { + return access.name.split("_")[0]; + }) + .uniq() + .value(); + + const selectedRegions = config.regions.filter(region => regionsCodes.includes(region.code)); + return (
    @@ -86,7 +97,10 @@ export const SummaryList = React.memo((props: { dataSet: DataSet; orgUnits: OrgU label={i18n.t("Organisation Units")} value={`${orgUnits.map(ou => ou.name).join(", ")} ${orgUnitMessage}`} /> - + region.name).join(", ")} + />
); diff --git a/src/webapp/components/projects/ProjectTable.tsx b/src/webapp/components/projects/ProjectTable.tsx index a333eca3..9b1483f2 100644 --- a/src/webapp/components/projects/ProjectTable.tsx +++ b/src/webapp/components/projects/ProjectTable.tsx @@ -73,7 +73,7 @@ export const ProjectTable = React.memo(() => { sortable: false, getValue: project => { if (project instanceof DataSet) { - const items = project.orgUnits.map(x => x.name); + const items = project.orgsUnits.map(x => x.name); return ; } else { return " - "; diff --git a/src/webapp/contexts/app-context.ts b/src/webapp/contexts/app-context.ts index 6bbdd27e..a8bf1c76 100644 --- a/src/webapp/contexts/app-context.ts +++ b/src/webapp/contexts/app-context.ts @@ -2,11 +2,13 @@ import React, { useContext } from "react"; import { CompositionRoot } from "$/CompositionRoot"; import { User } from "$/domain/entities/User"; import { D2Api } from "$/types/d2-api"; +import { Config } from "$/domain/entities/Config"; export interface AppContextState { currentUser: User; compositionRoot: CompositionRoot; api: D2Api; + config: Config; } export const AppContext = React.createContext(null); diff --git a/src/webapp/pages/app/App.tsx b/src/webapp/pages/app/App.tsx index 4d5483e2..f7bba004 100644 --- a/src/webapp/pages/app/App.tsx +++ b/src/webapp/pages/app/App.tsx @@ -12,14 +12,16 @@ import "./App.css"; import muiThemeLegacy from "./themes/dhis2-legacy.theme"; import { muiTheme } from "./themes/dhis2.theme"; import { D2Api } from "$/types/d2-api"; +import { Config } from "$/domain/entities/Config"; export interface AppProps { compositionRoot: CompositionRoot; api: D2Api; + config: Config; } function App(props: AppProps) { - const { api, compositionRoot } = props; + const { api, config, compositionRoot } = props; const [loading, setLoading] = useState(true); const [appContext, setAppContext] = useState(null); @@ -28,11 +30,11 @@ function App(props: AppProps) { const currentUser = await compositionRoot.users.getCurrent.execute().toPromise(); if (!currentUser) throw new Error("User not logged in"); - setAppContext({ api, currentUser, compositionRoot }); + setAppContext({ config, api, currentUser, compositionRoot }); setLoading(false); } setup(); - }, [compositionRoot, api]); + }, [config, compositionRoot, api]); if (loading) return null; diff --git a/src/webapp/pages/app/Dhis2App.tsx b/src/webapp/pages/app/Dhis2App.tsx index 55dd86c2..fcd56fbc 100644 --- a/src/webapp/pages/app/Dhis2App.tsx +++ b/src/webapp/pages/app/Dhis2App.tsx @@ -4,6 +4,8 @@ import { Provider } from "@dhis2/app-runtime"; import { D2Api } from "$/types/d2-api"; import App from "./App"; import { CompositionRoot, getWebappCompositionRoot } from "$/CompositionRoot"; +import { ConfigD2Repository } from "$/data/repositories/ConfigD2Repository"; +import { Config } from "$/domain/entities/Config"; export function Dhis2App(_props: {}) { const [compositionRootRes, setCompositionRootRes] = React.useState({ @@ -29,12 +31,12 @@ export function Dhis2App(_props: {}) { ); } case "loaded": { - const { api, baseUrl, compositionRoot } = compositionRootRes.data; + const { api, baseUrl, compositionRoot, config: configData } = compositionRootRes.data; const config = { baseUrl, apiVersion: 30 }; return ( - + ); } @@ -42,6 +44,7 @@ export function Dhis2App(_props: {}) { } type Data = { + config: Config; compositionRoot: CompositionRoot; baseUrl: string; api: D2Api; @@ -57,11 +60,13 @@ async function getData(): Promise { : new D2Api({ baseUrl: baseUrl }); try { + const configRepository = new ConfigD2Repository(api); + const config = await configRepository.get().toPromise(); const compositionRoot = getWebappCompositionRoot(api); const userSettings = await api.get<{ keyUiLocale: string }>("/userSettings").getData(); configI18n(userSettings); - return { type: "loaded", data: { baseUrl, compositionRoot, api } }; + return { type: "loaded", data: { baseUrl, compositionRoot, api, config } }; } catch (err) { return { type: "error", error: { baseUrl, error: err as Error } }; } diff --git a/src/webapp/pages/app/__tests__/App.spec.tsx b/src/webapp/pages/app/__tests__/App.spec.tsx index 453f4a34..22e537c3 100644 --- a/src/webapp/pages/app/__tests__/App.spec.tsx +++ b/src/webapp/pages/app/__tests__/App.spec.tsx @@ -4,6 +4,7 @@ import App from "$/webapp/pages/app/App"; import { getTestContext } from "$/utils/tests"; import { Provider } from "@dhis2/app-runtime"; import { getD2APiFromInstance } from "$/types/d2-api"; +import { Config } from "$/domain/entities/Config"; describe("App", () => { it("navigates to page", async () => { @@ -17,9 +18,10 @@ function getView() { const { compositionRoot } = getTestContext(); const baseUrl = "http://localhost:8080"; const api = getD2APiFromInstance({ type: "local", url: baseUrl }); + const configTests: Config = { regions: [], userGroups: [] }; return render( - + ); }