From 330badf0169d3c66e61ba9a24c5a8a847254c4d1 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Wed, 4 Dec 2024 13:22:27 -0500 Subject: [PATCH 1/5] 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( - + ); } From 1a1bc02bd5de06bd4ccd3b5831c7cc77a7dea069 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Thu, 5 Dec 2024 14:38:17 -0500 Subject: [PATCH 2/5] Align the search input with the data table component --- src/webapp/pages/app/App.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/webapp/pages/app/App.css b/src/webapp/pages/app/App.css index 72901861..9728ac3e 100644 --- a/src/webapp/pages/app/App.css +++ b/src/webapp/pages/app/App.css @@ -23,3 +23,7 @@ li { tr:has(.MuiAlert-message) { background-color: rgb(255, 238, 238); } + +.MuiToolbar-root > div[data-test="search"] { + padding-inline-start: 0.4em; +} From f9a8c057c9cda14ff0f429b656fc5e49433dbb3c Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Thu, 5 Dec 2024 14:45:39 -0500 Subject: [PATCH 3/5] use a custom className --- src/webapp/components/dataset-table/DataSetTable.tsx | 1 + src/webapp/pages/app/App.css | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/webapp/components/dataset-table/DataSetTable.tsx b/src/webapp/components/dataset-table/DataSetTable.tsx index 30a6fa17..3fb01f21 100644 --- a/src/webapp/components/dataset-table/DataSetTable.tsx +++ b/src/webapp/components/dataset-table/DataSetTable.tsx @@ -94,6 +94,7 @@ export const DataSetTable: React.FC = React.memo(() => { div[data-test="search"] { +.dataset-table div[data-test="search"] { padding-inline-start: 0.4em; } From 1f048b79e2fa7f695b4070abfd96732663045533 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Mon, 6 Jan 2025 17:36:55 -0500 Subject: [PATCH 4/5] refactor fn to get region code, move permissions to its own file, improve functions names --- src/data/repositories/ConfigD2Repository.ts | 21 +++++-------- src/data/repositories/DataSetD2Api.ts | 3 +- src/data/repositories/DataSetD2Repository.ts | 31 ++++--------------- src/data/repositories/RegionD2Repository.ts | 12 ------- src/domain/entities/DataSet.ts | 23 ++++++++------ src/domain/entities/Permission.ts | 9 +++--- src/domain/entities/__tests__/DataSet.spec.ts | 6 ++-- src/domain/repositories/RegionRepository.ts | 6 ---- src/utils/permission.ts | 3 +- src/utils/string.ts | 26 ++++++++++++++++ .../dataset-wizard/DataSetWizard.tsx | 2 +- .../dataset-wizard/SummaryDataSet.tsx | 9 +----- 12 files changed, 66 insertions(+), 85 deletions(-) delete mode 100644 src/data/repositories/RegionD2Repository.ts delete mode 100644 src/domain/repositories/RegionRepository.ts create mode 100644 src/utils/string.ts diff --git a/src/data/repositories/ConfigD2Repository.ts b/src/data/repositories/ConfigD2Repository.ts index 9d5123b0..ce5f19df 100644 --- a/src/data/repositories/ConfigD2Repository.ts +++ b/src/data/repositories/ConfigD2Repository.ts @@ -5,6 +5,7 @@ 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"; +import { extractFirstTwoLetters, extractPrefix } from "$/utils/string"; export class ConfigD2Repository implements ConfigRepository { constructor(private api: D2Api) {} @@ -43,7 +44,8 @@ export class ConfigD2Repository implements ConfigRepository { return d2Response.objects.map(region => ({ id: region.id, name: region.name, - code: this.extractRegionCode(region.code), + // org. unit code includes the region code in the first two letters before the underscore + code: extractFirstTwoLetters(region.code), })); }); } @@ -55,19 +57,12 @@ export class ConfigD2Repository implements ConfigRepository { paging: false, }) ).map(d2Response => { - return d2Response.objects.map(region => ({ - id: region.id, - name: region.name, - code: this.extractCode(region.name), + return d2Response.objects.map(d2UserGroup => ({ + id: d2UserGroup.id, + name: d2UserGroup.name, + // user group name includes the region code in the first two letters + code: extractPrefix(d2UserGroup.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/DataSetD2Api.ts b/src/data/repositories/DataSetD2Api.ts index 68486ad7..75bd0c6f 100644 --- a/src/data/repositories/DataSetD2Api.ts +++ b/src/data/repositories/DataSetD2Api.ts @@ -7,14 +7,13 @@ import { DataSet, DataSetList, OrgUnit, - Permissions, } from "$/domain/entities/DataSet"; import { Paginated } from "$/domain/entities/Paginated"; import { GetDataSetOptions } from "$/domain/repositories/DataSetRepository"; import { Future, FutureData } from "$/domain/entities/generic/Future"; import { Maybe } from "$/utils/ts-utils"; import { Id } from "$/domain/entities/Ref"; -import { Permission } from "$/domain/entities/Permission"; +import { Permission, Permissions } from "$/domain/entities/Permission"; import _ from "$/domain/entities/generic/Collection"; import { Project } from "$/domain/entities/Project"; import { D2ApiCategoryOption } from "$/data/repositories/D2ApiCategoryOption"; diff --git a/src/data/repositories/DataSetD2Repository.ts b/src/data/repositories/DataSetD2Repository.ts index 108a0bd8..8f0d7985 100644 --- a/src/data/repositories/DataSetD2Repository.ts +++ b/src/data/repositories/DataSetD2Repository.ts @@ -2,7 +2,7 @@ import { D2AttributeValue, MetadataPick } from "@eyeseetea/d2-api/2.36"; import { D2Api, MetadataResponse } from "$/types/d2-api"; import { apiToFuture } from "$/data/api-futures"; -import { AccessData, DataSet, DataSetList } from "$/domain/entities/DataSet"; +import { DataSet, DataSetList } from "$/domain/entities/DataSet"; import { Paginated } from "$/domain/entities/Paginated"; import { DataSetName, @@ -12,11 +12,7 @@ import { import { Future, FutureData } from "$/domain/entities/generic/Future"; import { getUid } from "$/utils/uid"; import _ from "$/domain/entities/generic/Collection"; -import { - DataSetD2Api, - OctalNotationPermission, - dataSetFieldsWithOrgUnits, -} from "$/data/repositories/DataSetD2Api"; +import { DataSetD2Api, dataSetFieldsWithOrgUnits } from "$/data/repositories/DataSetD2Api"; import { Maybe } from "$/utils/ts-utils"; import { chunkRequest, runMetadata } from "$/data/utils"; import { D2Config } from "$/data/repositories/D2ApiMetadata"; @@ -314,11 +310,11 @@ export class DataSetD2Repository implements DataSetRepository { }), userGroupAccesses: _(dataSet.access) .filter(access => access.type === "groups") - .map(access => { + .map(groupAccess => { return { - access: this.d2DataSetApi.generateFullPermission(access.permissions), - id: access.id, - displayName: access.name, + access: this.d2DataSetApi.generateFullPermission(groupAccess.permissions), + id: groupAccess.id, + displayName: groupAccess.name, }; }) .value(), @@ -330,20 +326,6 @@ export class DataSetD2Repository implements DataSetRepository { }; } - private convertSharingGroupsToAccessData(d2UserGroups: Maybe): AccessData[] { - if (!d2UserGroups || Object.keys(d2UserGroups).length === 0) return []; - - return Object.values(d2UserGroups).map(({ id, access }) => ({ - id, - permissions: { - data: this.d2DataSetApi.buildPermission(access, "data"), - metadata: this.d2DataSetApi.buildPermission(access, "metadata"), - }, - name: "", - type: "groups", - })); - } - private buildDataSetElements(dataSet: DataSetToSave) { const relatedDataElements = dataSet.indicators .filter(indicator => indicator.type === "outcomes") @@ -416,7 +398,6 @@ type D2DataSetSection = { indicators: Ref[]; }; -type SharingUserGroup = Record; const indicatorTypeLabel: Record = { outcomes: "Outcomes", outputs: "Outputs", diff --git a/src/data/repositories/RegionD2Repository.ts b/src/data/repositories/RegionD2Repository.ts deleted file mode 100644 index 0cbe7da8..00000000 --- a/src/data/repositories/RegionD2Repository.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/DataSet.ts b/src/domain/entities/DataSet.ts index 2cdb9aac..2a769cc3 100644 --- a/src/domain/entities/DataSet.ts +++ b/src/domain/entities/DataSet.ts @@ -1,4 +1,4 @@ -import { Permission } from "$/domain/entities/Permission"; +import { Permission, Permissions } from "$/domain/entities/Permission"; import { Project } from "$/domain/entities/Project"; import { Id, ISODateString, Ref } from "$/domain/entities/Ref"; import { Struct } from "$/domain/entities/generic/Struct"; @@ -10,6 +10,7 @@ import { validateOrgUnits, validateRequired } from "$/domain/entities/generic/Va import { Indicator } from "$/domain/entities/Indicator"; import { Config, UserGroup } from "$/domain/entities/Config"; import { DataSetToSave } from "$/domain/entities/DataSetToSave"; +import { extractFirstTwoLetters, extractPrefix } from "$/utils/string"; export type DataSetAttrs = { created: ISODateString; @@ -29,7 +30,6 @@ export type DataSetAttrs = { }; export type OrgUnit = { id: Id; code: string; name: string; path: Id[] }; -export type Permissions = { data: Permission; metadata: Permission }; export type AccessData = { id: Id; permissions: Permissions; name: string; type: AccessType }; export type AccessType = "users" | "groups"; @@ -57,11 +57,16 @@ export class DataSet extends Struct() { updateProject(project: Maybe, config: Config): DataSet { const name = project ? `${project.name} DataSet` : ""; - const orgUnits = project ? project.orgsUnits : this.orgUnits; + const orgsUnits = project ? project.orgsUnits : this.orgUnits; const accessGroupsFromProject = this.getAccessFromProject(project, config); - return this._update({ access: accessGroupsFromProject, project, name, orgUnits }); + return this._update({ + access: accessGroupsFromProject, + project, + name, + orgUnits: orgsUnits, + }); } updateAccess(config: Config): DataSet { @@ -109,7 +114,7 @@ export class DataSet extends Struct() { : []; } - validateSharingStep(): ValidationError[] { + validateRegionCodes(): ValidationError[] { if (this.project) return []; const regionCodesFromOrgUnits = this.getRegionCodesFromAccess(); @@ -128,7 +133,7 @@ export class DataSet extends Struct() { getRegionCodesFromAccess(): string[] { return _(this.access) .filter(access => access.type === "groups") - .compactMap(access => access.name.split("_")[0]) + .compactMap(access => extractPrefix(access.name)) .uniq() .value(); } @@ -146,7 +151,7 @@ export class DataSet extends Struct() { private getValidationErrors(): ValidationError[] { const setupErrors = this.buildSetupErrors(); const indicatorsErrors = this.validateIndicatorsStep(); - const sharingErrors = this.validateSharingStep(); + const sharingErrors = this.validateRegionCodes(); return [...setupErrors, ...indicatorsErrors, ...sharingErrors]; } @@ -179,7 +184,7 @@ export class DataSet extends Struct() { } private getAccessFromOrgUnits(orgUnits: OrgUnit[], config: Config): AccessData[] { - const orgsUnitsCodes = orgUnits.map(orgUnit => orgUnit.code.slice(0, 2)); + const orgsUnitsCodes = orgUnits.map(orgUnit => extractFirstTwoLetters(orgUnit.code)); const regions = config.regions.filter(region => orgsUnitsCodes.includes(region.code)); const regionsCodes = regions.map(region => region.code); @@ -193,7 +198,7 @@ export class DataSet extends Struct() { private getAccessFromProject(project: Maybe, config: Config): AccessData[] { if (!project || !project.code) return []; - const regionCode = (project.code?.slice(0, 2) || "").toUpperCase(); + const regionCode = extractFirstTwoLetters(project.code); const region = config.regions.find(region => region.code === regionCode); const userGroups = config.userGroups.filter(userGroup => userGroup.code === region?.code); diff --git a/src/domain/entities/Permission.ts b/src/domain/entities/Permission.ts index 81d0c564..d9431e42 100644 --- a/src/domain/entities/Permission.ts +++ b/src/domain/entities/Permission.ts @@ -1,4 +1,3 @@ -import { Permissions } from "$/domain/entities/DataSet"; import { Struct } from "$/domain/entities/generic/Struct"; export type PermissionAttrs = { read: boolean; write: boolean }; @@ -13,12 +12,14 @@ export class Permission extends Struct() { } static setDefaultPermissionsForGroups(isAdmin: boolean): Permissions { - const adminDataPermission = Permission.create({ read: true, write: true }); + const readWritePermission = Permission.create({ read: true, write: true }); return { - data: adminDataPermission, + data: readWritePermission, metadata: isAdmin - ? adminDataPermission + ? readWritePermission : Permission.create({ read: true, write: false }), }; } } + +export type Permissions = { data: Permission; metadata: Permission }; diff --git a/src/domain/entities/__tests__/DataSet.spec.ts b/src/domain/entities/__tests__/DataSet.spec.ts index db37d295..645f9adf 100644 --- a/src/domain/entities/__tests__/DataSet.spec.ts +++ b/src/domain/entities/__tests__/DataSet.spec.ts @@ -22,7 +22,7 @@ describe("DataSet", () => { configTest ); - const errors = dataSetToSave.validateSharingStep(); + const errors = dataSetToSave.validateRegionCodes(); const errorMessage = getErrorMessageFromErrors(errors); expect(errors.length).toBeGreaterThan(0); expect(errorMessage).toMatch("Select at least one country"); @@ -49,7 +49,7 @@ describe("DataSet", () => { ], }).updateAccess(configTest); - const result = dataSetToSave.validateSharingStep(); + const result = dataSetToSave.validateRegionCodes(); expect(result).toHaveLength(0); expectUserGroups(dataSetToSave); @@ -62,7 +62,7 @@ describe("DataSet", () => { project: undefined, }).updateProject(projectTest, configTest); - const result = dataSetToSave.validateSharingStep(); + const result = dataSetToSave.validateRegionCodes(); expect(result).toHaveLength(0); expectUserGroups(dataSetToSave); diff --git a/src/domain/repositories/RegionRepository.ts b/src/domain/repositories/RegionRepository.ts deleted file mode 100644 index c587fec2..00000000 --- a/src/domain/repositories/RegionRepository.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Region } from "$/domain/entities/Region"; -import { FutureData } from "$/domain/entities/generic/Future"; - -export interface RegionRepository { - get(): FutureData; -} diff --git a/src/utils/permission.ts b/src/utils/permission.ts index 3ee31861..2bec39db 100644 --- a/src/utils/permission.ts +++ b/src/utils/permission.ts @@ -1,6 +1,5 @@ import { Maybe } from "$/utils/ts-utils"; -import { Permission } from "$/domain/entities/Permission"; -import { Permissions } from "$/domain/entities/DataSet"; +import { Permission, Permissions } from "$/domain/entities/Permission"; export const NO_ACCESS_NOTATION = "--------"; diff --git a/src/utils/string.ts b/src/utils/string.ts new file mode 100644 index 00000000..0469d84d --- /dev/null +++ b/src/utils/string.ts @@ -0,0 +1,26 @@ +/** + * Extracts the first two characters of a string and convert it to uppercase. + * If the input string is empty, returns an empty string. + * + * Example: + * Input: "us123" + * Output: "US" + * + */ + +export function extractFirstTwoLetters(value: string): string { + return (value.slice(0, 2) || "").toUpperCase(); +} + +/** + * Extracts the portion of a code string before the first underscore ("_") + * and converts it to uppercase. + * + * Example: + * Input: "us_region" + * Output: "US" + * + */ +export function extractPrefix(value: string): string { + return (value.split("_")[0] || "").toUpperCase(); +} diff --git a/src/webapp/components/dataset-wizard/DataSetWizard.tsx b/src/webapp/components/dataset-wizard/DataSetWizard.tsx index b0b5f28b..02490329 100644 --- a/src/webapp/components/dataset-wizard/DataSetWizard.tsx +++ b/src/webapp/components/dataset-wizard/DataSetWizard.tsx @@ -137,7 +137,7 @@ export function useValidateDataSetWizard(props: { const validationMap: ValidationStepType = { setup: () => dataSet.validateSetup(), indicators: () => dataSet.validateIndicatorsStep(), - share: () => dataSet.validateSharingStep(), + share: () => dataSet.validateRegionCodes(), }; const validate = validationMap[currentStep.key]; diff --git a/src/webapp/components/dataset-wizard/SummaryDataSet.tsx b/src/webapp/components/dataset-wizard/SummaryDataSet.tsx index dfe2c9b9..70846f0e 100644 --- a/src/webapp/components/dataset-wizard/SummaryDataSet.tsx +++ b/src/webapp/components/dataset-wizard/SummaryDataSet.tsx @@ -76,14 +76,7 @@ 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 regionsCodes = dataSet.getRegionCodesFromAccess(); const selectedRegions = config.regions.filter(region => regionsCodes.includes(region.code)); return ( From e39eb537566443b6d9b42049fa6a0c97771d1595 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Tue, 7 Jan 2025 15:21:09 -0500 Subject: [PATCH 5/5] move extract code logic to domain --- src/data/repositories/ConfigD2Repository.ts | 8 +++---- src/domain/entities/DataSet.ts | 8 +++---- src/domain/entities/Project.ts | 13 +++++++++++ src/domain/entities/Region.ts | 14 +++++++++++ src/utils/string.ts | 26 --------------------- 5 files changed, 35 insertions(+), 34 deletions(-) delete mode 100644 src/utils/string.ts diff --git a/src/data/repositories/ConfigD2Repository.ts b/src/data/repositories/ConfigD2Repository.ts index ce5f19df..6fc5db03 100644 --- a/src/data/repositories/ConfigD2Repository.ts +++ b/src/data/repositories/ConfigD2Repository.ts @@ -1,11 +1,11 @@ import { apiToFuture } from "$/data/api-futures"; import { metadataCodes } from "$/data/repositories/D2ApiMetadata"; import { Config, UserGroup } from "$/domain/entities/Config"; -import { Region } from "$/domain/entities/Region"; +import { Project } from "$/domain/entities/Project"; +import { Region, extractRegionCode } from "$/domain/entities/Region"; import { Future, FutureData } from "$/domain/entities/generic/Future"; import { ConfigRepository } from "$/domain/repositories/ConfigRepository"; import { D2Api } from "$/types/d2-api"; -import { extractFirstTwoLetters, extractPrefix } from "$/utils/string"; export class ConfigD2Repository implements ConfigRepository { constructor(private api: D2Api) {} @@ -45,7 +45,7 @@ export class ConfigD2Repository implements ConfigRepository { id: region.id, name: region.name, // org. unit code includes the region code in the first two letters before the underscore - code: extractFirstTwoLetters(region.code), + code: Project.extractCode(region.code), })); }); } @@ -61,7 +61,7 @@ export class ConfigD2Repository implements ConfigRepository { id: d2UserGroup.id, name: d2UserGroup.name, // user group name includes the region code in the first two letters - code: extractPrefix(d2UserGroup.name), + code: extractRegionCode(d2UserGroup.name), })); }); } diff --git a/src/domain/entities/DataSet.ts b/src/domain/entities/DataSet.ts index 2a769cc3..590e0bdf 100644 --- a/src/domain/entities/DataSet.ts +++ b/src/domain/entities/DataSet.ts @@ -10,7 +10,7 @@ import { validateOrgUnits, validateRequired } from "$/domain/entities/generic/Va import { Indicator } from "$/domain/entities/Indicator"; import { Config, UserGroup } from "$/domain/entities/Config"; import { DataSetToSave } from "$/domain/entities/DataSetToSave"; -import { extractFirstTwoLetters, extractPrefix } from "$/utils/string"; +import { extractRegionCode } from "$/domain/entities/Region"; export type DataSetAttrs = { created: ISODateString; @@ -133,7 +133,7 @@ export class DataSet extends Struct() { getRegionCodesFromAccess(): string[] { return _(this.access) .filter(access => access.type === "groups") - .compactMap(access => extractPrefix(access.name)) + .compactMap(access => Project.extractCode(access.name)) .uniq() .value(); } @@ -184,7 +184,7 @@ export class DataSet extends Struct() { } private getAccessFromOrgUnits(orgUnits: OrgUnit[], config: Config): AccessData[] { - const orgsUnitsCodes = orgUnits.map(orgUnit => extractFirstTwoLetters(orgUnit.code)); + const orgsUnitsCodes = orgUnits.map(orgUnit => extractRegionCode(orgUnit.code)); const regions = config.regions.filter(region => orgsUnitsCodes.includes(region.code)); const regionsCodes = regions.map(region => region.code); @@ -198,7 +198,7 @@ export class DataSet extends Struct() { private getAccessFromProject(project: Maybe, config: Config): AccessData[] { if (!project || !project.code) return []; - const regionCode = extractFirstTwoLetters(project.code); + const regionCode = extractRegionCode(project.code); const region = config.regions.find(region => region.code === regionCode); const userGroups = config.userGroups.filter(userGroup => userGroup.code === region?.code); diff --git a/src/domain/entities/Project.ts b/src/domain/entities/Project.ts index 0f9580e5..4992eb18 100644 --- a/src/domain/entities/Project.ts +++ b/src/domain/entities/Project.ts @@ -30,4 +30,17 @@ export class Project extends Struct() { static setDataSets(project: ProjectAttrs, dataSets: DataSet[]): Project { return Project.build({ ...project, dataSets }); } + + /** + * Extracts the portion of a code string before the first underscore ("_") + * and converts it to uppercase. + * + * Example: + * Input: "us_region" + * Output: "US" + * + */ + static extractCode(value: string): string { + return (value.split("_")[0] || "").toUpperCase(); + } } diff --git a/src/domain/entities/Region.ts b/src/domain/entities/Region.ts index a0c597c8..35d07872 100644 --- a/src/domain/entities/Region.ts +++ b/src/domain/entities/Region.ts @@ -1,3 +1,17 @@ import { NamedCodeRef } from "$/domain/entities/Ref"; export type Region = NamedCodeRef; + +/** + * Extracts the first two characters of a string and convert it to uppercase. + * If the input string is empty, returns an empty string. + * + * Example: + * Input: "us123" + * Output: "US" + * + */ + +export function extractRegionCode(value: string): string { + return (value.slice(0, 2) || "").toUpperCase(); +} diff --git a/src/utils/string.ts b/src/utils/string.ts deleted file mode 100644 index 0469d84d..00000000 --- a/src/utils/string.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Extracts the first two characters of a string and convert it to uppercase. - * If the input string is empty, returns an empty string. - * - * Example: - * Input: "us123" - * Output: "US" - * - */ - -export function extractFirstTwoLetters(value: string): string { - return (value.slice(0, 2) || "").toUpperCase(); -} - -/** - * Extracts the portion of a code string before the first underscore ("_") - * and converts it to uppercase. - * - * Example: - * Input: "us_region" - * Output: "US" - * - */ -export function extractPrefix(value: string): string { - return (value.split("_")[0] || "").toUpperCase(); -}