From 6cdf39c615502f83132a308f910b4be894c6a0f2 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Tue, 26 Nov 2024 00:14:58 -0500 Subject: [PATCH 1/2] add setup screen --- i18n/en.pot | 68 ++++- i18n/es.po | 69 ++++- package.json | 2 + src/CompositionRoot.ts | 18 +- src/data/repositories/DataSetD2Api.ts | 16 +- src/data/repositories/DataSetD2Repository.ts | 83 +++--- .../repositories/DataSetTestRepository.ts | 5 +- src/data/repositories/OrgUnitD2Repository.ts | 38 +++ .../repositories/OrgUnitTestRepository.ts | 9 + src/data/repositories/ProjectD2Repository.ts | 45 +++- .../repositories/ProjectTestRepository.ts | 3 + src/domain/entities/DataSet.ts | 42 +++ src/domain/entities/Project.ts | 1 + src/domain/entities/generic/Error.ts | 44 +++ src/domain/entities/generic/Validation.ts | 18 ++ src/domain/repositories/DataSetRepository.ts | 2 + src/domain/repositories/OrgUnitRepository.ts | 7 + src/domain/repositories/ProjectRepository.ts | 1 + src/domain/usecases/GetAllProjectsUseCase.ts | 11 + .../usecases/GetOrgUnitsByIdsUseCase.ts | 12 + src/domain/usecases/SaveDataSetUseCase.ts | 39 +++ src/domain/usecases/ValidateNameUseCase.ts | 18 ++ .../dataset-wizard/DataSetWizard.tsx | 77 +++++- .../dataset-wizard/IndicatorsDataSet.tsx | 4 +- .../dataset-wizard/ProjectsSelectorModal.tsx | 90 +++++++ .../dataset-wizard/SetupDataSet.tsx | 254 +++++++++++++----- .../dataset-wizard/SummaryDataSet.tsx | 110 +++++++- .../components/dataset-wizard/utils.tsx | 8 +- .../components/edit-sharing/EditSharing.tsx | 11 +- .../register-dataset/RegisterDataSetPage.tsx | 103 ++++++- yarn.lock | 27 ++ 31 files changed, 1071 insertions(+), 164 deletions(-) create mode 100644 src/data/repositories/OrgUnitD2Repository.ts create mode 100644 src/data/repositories/OrgUnitTestRepository.ts create mode 100644 src/domain/entities/generic/Error.ts create mode 100644 src/domain/entities/generic/Validation.ts create mode 100644 src/domain/repositories/OrgUnitRepository.ts create mode 100644 src/domain/usecases/GetAllProjectsUseCase.ts create mode 100644 src/domain/usecases/GetOrgUnitsByIdsUseCase.ts create mode 100644 src/domain/usecases/SaveDataSetUseCase.ts create mode 100644 src/domain/usecases/ValidateNameUseCase.ts create mode 100644 src/webapp/components/dataset-wizard/ProjectsSelectorModal.tsx diff --git a/i18n/en.pot b/i18n/en.pot index e4fcc963..995e6ff0 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-11-14T05:07:50.716Z\n" -"PO-Revision-Date: 2024-11-14T05:07:50.716Z\n" +"POT-Creation-Date: 2024-11-26T05:08:32.550Z\n" +"PO-Revision-Date: 2024-11-26T05:08:32.550Z\n" msgid "edit dataset" msgstr "" @@ -38,6 +38,15 @@ msgstr "" msgid "Project name is required" msgstr "" +msgid "Cannot be blank: {{fieldName}}" +msgstr "" + +msgid "{{fieldName}} must be a positive number" +msgstr "" + +msgid "At least one org. unit is required" +msgstr "" + msgid "Add" msgstr "" @@ -173,6 +182,15 @@ msgstr "" msgid "No Selected" msgstr "" +msgid "Show closed projects" +msgstr "" + +msgid "Filter projects" +msgstr "" + +msgid "" +msgstr "" + msgid "Select Project" msgstr "" @@ -182,21 +200,54 @@ msgstr "" msgid "DataSet description" msgstr "" -msgid "Start Date" +msgid "Expiry Days" +msgstr "" + +msgid "" +"How many days after the period before the dataSet is locked for changing " +"data. Example: 5 means: after February 5th it's not possible to make " +"changes to January anymore. 0 is no lock" msgstr "" -msgid "End Date" +msgid "Open future periods for data entry" msgstr "" msgid "Select org units" msgstr "" +msgid "There is already a dataset with this name" +msgstr "" + +msgid "Validating..." +msgstr "" + msgid "Please select the regions you want to share this dataSet with" msgstr "" +msgid "Saving..." +msgstr "" + +msgid "Data set saved successfully" +msgstr "" + msgid "The dataSet is finished. Press the button Save to save the data" msgstr "" +msgid "Save" +msgstr "" + +msgid "and {{number}} more." +msgstr "" + +msgid "Description" +msgstr "" + +msgid "Linked Project" +msgstr "" + +msgid "Organisation Units" +msgstr "" + msgid "Setup" msgstr "" @@ -215,12 +266,6 @@ msgstr "" msgid "Merge" msgstr "" -msgid "Organisation Units" -msgstr "" - -msgid "Save" -msgstr "" - msgid "Bulk update strategy: {{actionLabel}}" msgstr "" @@ -265,3 +310,6 @@ msgstr "" msgid "Removing DataSets" msgstr "" + +msgid "Loading..." +msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 25b0af0e..ed5b7180 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-11-14T05:07:50.716Z\n" +"POT-Creation-Date: 2024-11-26T05:08:32.550Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -38,6 +38,15 @@ msgstr "" msgid "Project name is required" msgstr "" +msgid "Cannot be blank: {{fieldName}}" +msgstr "" + +msgid "{{fieldName}} must be a positive number" +msgstr "" + +msgid "At least one org. unit is required" +msgstr "" + msgid "Add" msgstr "Añadir" @@ -173,6 +182,15 @@ msgstr "" msgid "No Selected" msgstr "" +msgid "Show closed projects" +msgstr "" + +msgid "Filter projects" +msgstr "" + +msgid "" +msgstr "" + msgid "Select Project" msgstr "" @@ -183,21 +201,55 @@ msgstr "" msgid "DataSet description" msgstr "Sección" -msgid "Start Date" +msgid "Expiry Days" +msgstr "" + +msgid "" +"How many days after the period before the dataSet is locked for changing " +"data. Example: 5 means: after February 5th it's not possible to make changes " +"to January anymore. 0 is no lock" msgstr "" -msgid "End Date" +msgid "Open future periods for data entry" msgstr "" msgid "Select org units" msgstr "" +msgid "There is already a dataset with this name" +msgstr "" + +msgid "Validating..." +msgstr "" + msgid "Please select the regions you want to share this dataSet with" msgstr "" +msgid "Saving..." +msgstr "" + +msgid "Data set saved successfully" +msgstr "" + msgid "The dataSet is finished. Press the button Save to save the data" msgstr "" +msgid "Save" +msgstr "" + +msgid "and {{number}} more." +msgstr "" + +#, fuzzy +msgid "Description" +msgstr "Sección" + +msgid "Linked Project" +msgstr "" + +msgid "Organisation Units" +msgstr "" + msgid "Setup" msgstr "" @@ -216,12 +268,6 @@ msgstr "" msgid "Merge" msgstr "" -msgid "Organisation Units" -msgstr "" - -msgid "Save" -msgstr "" - msgid "Bulk update strategy: {{actionLabel}}" msgstr "" @@ -267,9 +313,8 @@ msgstr "" msgid "Removing DataSets" msgstr "" -#, fuzzy -#~ msgid "Description" -#~ msgstr "Sección" +msgid "Loading..." +msgstr "" #~ msgid "Hello {{name}}" #~ msgstr "Hola {{name}}" diff --git a/package.json b/package.json index 03b38a55..78a53a4d 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "5.2.0", + "react-window": "1.8.10", "real-cancellable-promise": "^1.1.2", "styled-components": "6.1.11", "styled-jsx": "^5.1.6", @@ -58,6 +59,7 @@ "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", "@types/react-router-dom": "5.3.3", + "@types/react-window": "1.8.8", "@types/styled-components": "5.1.24", "@vitejs/plugin-react": "^3.1.0", "@vitest/coverage-v8": "^0.32.4", diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index fcbd71d5..78816d1c 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -2,6 +2,8 @@ import { DataSetD2Repository } from "$/data/repositories/DataSetD2Repository"; import { DataSetTestRepository } from "$/data/repositories/DataSetTestRepository"; import { LogD2Repository } from "$/data/repositories/LogD2Repository"; import { LogTestRepository } from "$/data/repositories/LogTestRepository"; +import { OrgUnitD2Repository } from "$/data/repositories/OrgUnitD2Repository"; +import { OrgUnitTestRepository } from "$/data/repositories/OrgUnitTestRepository"; import { ProjectD2Repository } from "$/data/repositories/ProjectD2Repository"; import { ProjectTestRepository } from "$/data/repositories/ProjectTestRepository"; import { SharingD2Repository } from "$/data/repositories/SharingD2Repository"; @@ -9,16 +11,21 @@ import { SharingRepository } from "$/data/repositories/SharingRepository"; import { SharingTestRepository } from "$/data/repositories/SharingTestRepository"; import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; import { LogRepository } from "$/domain/repositories/LogRepository"; +import { OrgUnitRepository } from "$/domain/repositories/OrgUnitRepository"; import { ProjectRepository } from "$/domain/repositories/ProjectRepository"; +import { GetAllProjectsUseCase } from "$/domain/usecases/GetAllProjectsUseCase"; import { GetDataSetsByIdsUseCase } from "$/domain/usecases/GetDataSetsByIdsUseCase"; import { GetDataSetsUseCase } from "$/domain/usecases/GetDataSetsUseCase"; import { GetLogsUseCase } from "$/domain/usecases/GetLogsUseCase"; +import { GetOrgUnitsByIdsUseCase } from "$/domain/usecases/GetOrgUnitsByIdsUseCase"; import { GetProjectsUseCase } from "$/domain/usecases/GetProjectsUseCase"; import { MigrateDataSetProjectsUseCase } from "$/domain/usecases/MigrateDataSetProjectsUseCase"; import { RemoveDataSetsUseCase } from "$/domain/usecases/RemoveDataSetsUseCase"; +import { SaveDataSetUseCase } from "$/domain/usecases/SaveDataSetUseCase"; import { SaveOrgUnitDataSetUseCase } from "$/domain/usecases/SaveOrgUnitDataSetUseCase"; import { SaveSharingDataSetsUseCase } from "$/domain/usecases/SaveSharingDataSetsUseCase"; import { SearchSharingUseCase } from "$/domain/usecases/SearchSharingUseCase"; +import { ValidateNameUseCase } from "$/domain/usecases/ValidateNameUseCase"; import { UserD2Repository } from "./data/repositories/UserD2Repository"; import { UserTestRepository } from "./data/repositories/UserTestRepository"; import { UserRepository } from "./domain/repositories/UserRepository"; @@ -33,6 +40,7 @@ type Repositories = { dataSetsRepository: DataSetRepository; logRepository: LogRepository; projectRepository: ProjectRepository; + orgUnitRepository: OrgUnitRepository; }; function getCompositionRoot(repositories: Repositories) { @@ -41,12 +49,14 @@ function getCompositionRoot(repositories: Repositories) { getByIds: new GetDataSetsByIdsUseCase(repositories.dataSetsRepository), getAll: new GetDataSetsUseCase(repositories.dataSetsRepository), remove: new RemoveDataSetsUseCase(repositories.dataSetsRepository), - save: new SaveSharingDataSetsUseCase(repositories.dataSetsRepository), + saveSharing: new SaveSharingDataSetsUseCase(repositories.dataSetsRepository), saveOrgUnits: new SaveOrgUnitDataSetUseCase(repositories.dataSetsRepository), migrateProjects: new MigrateDataSetProjectsUseCase( repositories.dataSetsRepository, repositories.projectRepository ), + validateName: new ValidateNameUseCase(repositories.dataSetsRepository), + save: new SaveDataSetUseCase(repositories.dataSetsRepository), }, logs: { getByDataSets: new GetLogsUseCase( @@ -56,11 +66,15 @@ function getCompositionRoot(repositories: Repositories) { }, projects: { get: new GetProjectsUseCase(repositories.projectRepository), + getAll: new GetAllProjectsUseCase(repositories.projectRepository), }, sharing: { search: new SearchSharingUseCase(repositories.sharingRepository), }, users: { getCurrent: new GetCurrentUserUseCase(repositories.usersRepository) }, + orgUnits: { + getByIds: new GetOrgUnitsByIdsUseCase(repositories.orgUnitRepository), + }, }; } @@ -71,6 +85,7 @@ export function getWebappCompositionRoot(api: D2Api) { sharingRepository: new SharingD2Repository(api), logRepository: new LogD2Repository(api), projectRepository: new ProjectD2Repository(api), + orgUnitRepository: new OrgUnitD2Repository(api), }; return getCompositionRoot(repositories); @@ -83,6 +98,7 @@ export function getTestCompositionRoot() { sharingRepository: new SharingTestRepository(), logRepository: new LogTestRepository(), projectRepository: new ProjectTestRepository(), + orgUnitRepository: new OrgUnitTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/DataSetD2Api.ts b/src/data/repositories/DataSetD2Api.ts index f99c5626..9753895f 100644 --- a/src/data/repositories/DataSetD2Api.ts +++ b/src/data/repositories/DataSetD2Api.ts @@ -114,7 +114,7 @@ export class DataSetD2Api { }); } - private getProjectIds(d2DataSets: D2DataSet[], attributes: D2Config["attributes"]): Id[] { + getProjectIds(d2DataSets: D2DataSet[], attributes: D2Config["attributes"]): Id[] { return _(d2DataSets) .compactMap(d2DataSet => { const projectAttribute = d2DataSet.attributeValues.find( @@ -140,7 +140,7 @@ export class DataSetD2Api { return this.d2ApiConfig.get(); } - private getProjectsByIds(ids: Id[]): FutureData { + getProjectsByIds(ids: Id[]): FutureData { return this.d2ApiCategoryOption.getByIds(ids).map(categoryOptions => { return categoryOptions.map(categoryOption => { return Project.create({ @@ -148,16 +148,12 @@ export class DataSetD2Api { dataSets: [], name: categoryOption.displayName, lastUpdated: categoryOption.lastUpdated, + isOpen: false, }); }); }); } - private getOrThrow(value: Maybe): T { - if (!value) throw new Error("Value not found"); - return value; - } - private getAllCompetencies(d2Config: D2Config): FutureData { return apiToFuture( this.api.models.dataElementGroupSets.get({ @@ -233,6 +229,9 @@ export class DataSetD2Api { .compactMap(degCode => coreCompetencies.find(cc => cc.code === degCode)) .value(), project: projectDetails ? projectDetails : undefined, + notifyUser: d2DataSet.notifyCompletingUser, + expiryDays: d2DataSet.expiryDays, + openFuturePeriods: d2DataSet.openFuturePeriods, }); } @@ -300,6 +299,9 @@ export const dataSetFields = { created: true, displayDescription: true, displayName: true, + expiryDays: true, + openFuturePeriods: true, + notifyCompletingUser: true, id: true, lastUpdated: true, sharing: { public: true }, diff --git a/src/data/repositories/DataSetD2Repository.ts b/src/data/repositories/DataSetD2Repository.ts index a6869e16..4f6aed54 100644 --- a/src/data/repositories/DataSetD2Repository.ts +++ b/src/data/repositories/DataSetD2Repository.ts @@ -4,7 +4,11 @@ import { D2Api } from "$/types/d2-api"; import { apiToFuture } from "$/data/api-futures"; import { DataSet, DataSetList, DataSetToSave } from "$/domain/entities/DataSet"; import { Paginated } from "$/domain/entities/Paginated"; -import { DataSetRepository, GetDataSetOptions } from "$/domain/repositories/DataSetRepository"; +import { + DataSetName, + DataSetRepository, + GetDataSetOptions, +} from "$/domain/repositories/DataSetRepository"; import { Future, FutureData } from "$/domain/entities/generic/Future"; import { getUid } from "$/utils/uid"; import _ from "$/domain/entities/generic/Collection"; @@ -20,6 +24,16 @@ export class DataSetD2Repository implements DataSetRepository { this.d2DataSetApi = new DataSetD2Api(this.api); } + getByName(name: string): FutureData { + return apiToFuture( + this.api.models.dataSets.get({ + fields: { id: true, name: true }, + filter: { name: { $ilike: name } }, + paging: false, + }) + ).map(response => response.objects); + } + getList(options: GetDataSetOptions): FutureData> { return this.d2DataSetApi.getList(options); } @@ -65,16 +79,18 @@ export class DataSetD2Repository implements DataSetRepository { ).map(d2Response => d2Response.objects); }); - return $requests.map(allDataSets => { - const dataSets = allDataSets.map(d2DataSet => { - return this.d2DataSetApi.buildDataSet( - d2DataSet, - coreCompetencies, - [], - attributes - ); + return $requests.flatMap(allDataSets => { + const projectIds = this.d2DataSetApi.getProjectIds(allDataSets, attributes); + return this.d2DataSetApi.getProjectsByIds(projectIds).map(projects => { + return allDataSets.map(d2DataSet => { + return this.d2DataSetApi.buildDataSet( + d2DataSet, + coreCompetencies, + projects, + attributes + ); + }); }); - return dataSets; }); }); } @@ -151,6 +167,7 @@ export class DataSetD2Repository implements DataSetRepository { return { id: dataSet.id || getUid(dataSet.name), name: dataSet.name, + periodType: "Monthly", description: dataSet.description, shortName: dataSet.shortName, publicAccess: this.d2DataSetApi.generateFullPermission(dataSet.permissions), @@ -175,6 +192,9 @@ export class DataSetD2Repository implements DataSetRepository { .value(), organisationUnits: dataSet.orgUnits.map(ou => ({ id: ou.id })), attributeValues: this.buildD2Attributes(existingAttributes, dataSet, attributes), + notifyCompletingUser: dataSet.notifyUser, + openFuturePeriods: dataSet.openFuturePeriods, + expiryDays: dataSet.expiryDays, }; } @@ -183,28 +203,25 @@ export class DataSetD2Repository implements DataSetRepository { dataSet: DataSetToSave, attributes: D2Config["attributes"] ) { - if (!dataSet.project) return existingAttributes || []; - const projectAttributeId = attributes.project.id; - const projectAttribute = existingAttributes?.find( - attribute => attribute.attribute.id === projectAttributeId - ); - - if (projectAttribute && existingAttributes) { - return existingAttributes.map(d2Attribute => { - if (!dataSet.project) return d2Attribute; - if (d2Attribute.attribute.id === projectAttributeId) { - return { ...d2Attribute, value: dataSet.project.id }; - } - return d2Attribute; - }); - } else { - return [ - ...(existingAttributes || []), - { - attribute: { id: projectAttributeId }, - value: dataSet.project.id, - }, - ]; - } + // if (!dataSet.project) return existingAttributes || []; + // const projectAttributeId = attributes.project.id; + // const projectAttribute = existingAttributes?.find( + // attribute => attribute.attribute.id === projectAttributeId + // ); + + const pa = { attribute: { id: attributes.project.id }, value: dataSet.project?.id }; + const createdByAttribute = { attribute: { id: attributes.createdByApp.id }, value: "true" }; + + const attributesToSave = _([pa, createdByAttribute]) + .compactMap(attribute => (attribute.value ? attribute : undefined)) + .value(); + + const filteredExisting = + existingAttributes?.filter( + attr => !attributesToSave.some(save => save.attribute.id === attr.attribute.id) + ) || []; + + // Combinar `filteredExisting` con `toSave` + return [...filteredExisting, ...attributesToSave]; } } diff --git a/src/data/repositories/DataSetTestRepository.ts b/src/data/repositories/DataSetTestRepository.ts index 800a6ecb..124a40eb 100644 --- a/src/data/repositories/DataSetTestRepository.ts +++ b/src/data/repositories/DataSetTestRepository.ts @@ -1,9 +1,12 @@ import { Future, FutureData } from "$/domain/entities/generic/Future"; import { DataSet, DataSetList } from "$/domain/entities/DataSet"; import { Paginated } from "$/domain/entities/Paginated"; -import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; +import { DataSetName, DataSetRepository } from "$/domain/repositories/DataSetRepository"; export class DataSetTestRepository implements DataSetRepository { + getByName(): FutureData { + throw new Error("Method not implemented."); + } getList(): FutureData> { return Future.success({ data: [], page: 1, pageCount: 1, total: 0, pageSize: 10 }); } diff --git a/src/data/repositories/OrgUnitD2Repository.ts b/src/data/repositories/OrgUnitD2Repository.ts new file mode 100644 index 00000000..89e4397f --- /dev/null +++ b/src/data/repositories/OrgUnitD2Repository.ts @@ -0,0 +1,38 @@ +import { D2Api } from "$/types/d2-api"; +import { apiToFuture } from "$/data/api-futures"; +import { OrgUnit } from "$/domain/entities/DataSet"; +import { Id } from "$/domain/entities/Ref"; +import { Future, FutureData } from "$/domain/entities/generic/Future"; +import { OrgUnitRepository } from "$/domain/repositories/OrgUnitRepository"; +import { chunkRequest } from "$/data/utils"; + +export class OrgUnitD2Repository implements OrgUnitRepository { + constructor(private api: D2Api) {} + + getByIds(ids: Id[]): FutureData { + if (ids.length === 0) return Future.success([]); + + const $requests = chunkRequest(ids, idsToFetch => { + return apiToFuture( + this.api.models.organisationUnits.get({ + fields: { id: true, displayName: true, path: true }, + filter: { id: { in: idsToFetch } }, + paging: false, + }) + ).map(response => response.objects); + }); + + return $requests.map(response => { + // const allRecords = response.flatMap(r => r); + return response.map(d2OrgUnit => { + return { + id: d2OrgUnit.id, + name: d2OrgUnit.displayName, + path: d2OrgUnit.path.split("/").slice(1), + }; + }); + }); + } +} + +type D2OrgUnit = { id: string; displayName: string; path: string }; diff --git a/src/data/repositories/OrgUnitTestRepository.ts b/src/data/repositories/OrgUnitTestRepository.ts new file mode 100644 index 00000000..a5c86121 --- /dev/null +++ b/src/data/repositories/OrgUnitTestRepository.ts @@ -0,0 +1,9 @@ +import { OrgUnit } from "$/domain/entities/DataSet"; +import { FutureData } from "$/domain/entities/generic/Future"; +import { OrgUnitRepository } from "$/domain/repositories/OrgUnitRepository"; + +export class OrgUnitTestRepository implements OrgUnitRepository { + getByIds(): FutureData { + throw new Error("Method not implemented."); + } +} diff --git a/src/data/repositories/ProjectD2Repository.ts b/src/data/repositories/ProjectD2Repository.ts index 3bda02fc..c018ec93 100644 --- a/src/data/repositories/ProjectD2Repository.ts +++ b/src/data/repositories/ProjectD2Repository.ts @@ -6,7 +6,7 @@ import { GetDataSetOptions } from "$/domain/repositories/DataSetRepository"; import { ProjectRepository } from "$/domain/repositories/ProjectRepository"; import _ from "$/domain/entities/generic/Collection"; import { DataSetD2Api } from "$/data/repositories/DataSetD2Api"; -import { Id } from "$/domain/entities/Ref"; +import { ISODateString, Id } from "$/domain/entities/Ref"; import { DataSet } from "$/domain/entities/DataSet"; import { D2CategoryOptionType } from "$/data/repositories/D2ApiCategoryOption"; import { Future, FutureData } from "$/domain/entities/generic/Future"; @@ -21,10 +21,52 @@ export class ProjectD2Repository implements ProjectRepository { this.d2ApiConfig = new D2ApiConfig(this.api); } + getList(): FutureData { + return this.getCategories().flatMap(categories => { + return apiToFuture( + this.api.models.categoryOptions.get({ + fields: { + id: true, + displayName: true, + startDate: true, + endDate: true, + lastUpdated: true, + }, + filter: { "categories.code": { eq: categories.project.code } }, + order: "displayName:asc", + paging: false, + }) + ).map(d2Response => { + return d2Response.objects.map((d2CategoryOption): Project => { + return Project.build({ + dataSets: [], + id: d2CategoryOption.id, + name: d2CategoryOption.displayName, + lastUpdated: d2CategoryOption.lastUpdated, + isOpen: this.isProjectOpen( + d2CategoryOption.startDate, + d2CategoryOption.endDate + ), + }); + }); + }); + }); + } + getAll(): FutureData { return this.getAllProjects(1, []); } + private isProjectOpen(date1: ISODateString, date2: ISODateString): boolean { + if (!date1 || !date2) return false; + + const today = new Date(); + const startDate = new Date(date1); + const endDate = new Date(date2); + + return today >= startDate && today <= endDate; + } + private getCategories(): FutureData { return this.d2ApiConfig.get().map(({ categories }) => categories); } @@ -108,6 +150,7 @@ export class ProjectD2Repository implements ProjectRepository { name: d2CategoryOption.displayName, lastUpdated: d2CategoryOption.lastUpdated, dataSets: [], + isOpen: false, }); } diff --git a/src/data/repositories/ProjectTestRepository.ts b/src/data/repositories/ProjectTestRepository.ts index 683b8407..96ec2e5b 100644 --- a/src/data/repositories/ProjectTestRepository.ts +++ b/src/data/repositories/ProjectTestRepository.ts @@ -4,6 +4,9 @@ import { Project } from "$/domain/entities/Project"; import { ProjectRepository } from "$/domain/repositories/ProjectRepository"; export class ProjectTestRepository implements ProjectRepository { + getList(): FutureData { + throw new Error("Method not implemented."); + } getAll(): FutureData { throw new Error("Method not implemented."); } diff --git a/src/domain/entities/DataSet.ts b/src/domain/entities/DataSet.ts index c45c9891..00afc59e 100644 --- a/src/domain/entities/DataSet.ts +++ b/src/domain/entities/DataSet.ts @@ -4,6 +4,10 @@ import { Id, ISODateString, Ref } from "$/domain/entities/Ref"; import { Struct } from "$/domain/entities/generic/Struct"; import i18n from "$/utils/i18n"; import { Maybe } from "$/utils/ts-utils"; +import _ from "$/domain/entities/generic/Collection"; +import { Either } from "$/domain/entities/generic/Either"; +import { ValidationError } from "$/domain/entities/generic/Error"; +import { validateOrgUnits, validateRequired } from "$/domain/entities/generic/Validation"; export type DataSetAttrs = { created: ISODateString; @@ -17,6 +21,9 @@ export type DataSetAttrs = { coreCompetencies: CoreCompetency[]; access: AccessData[]; orgUnits: OrgUnit[]; + expiryDays: number; + openFuturePeriods: number; + notifyUser: boolean; }; export type DataSetToSave = Omit & { @@ -32,6 +39,32 @@ export type CoreCompetency = { id: Id; name: string; code: string }; export type DataSetList = Pick; export class DataSet extends Struct() { + validateSetup(): Either[], DataSet> { + const errors: ValidationError[] = [ + { + property: "name" as const, + errors: validateRequired(this.name), + value: this.name, + }, + { + property: "orgUnits" as const, + errors: validateOrgUnits(this.orgUnits), + value: this.orgUnits, + }, + ].filter(validation => validation.errors.length > 0); + + return errors.length === 0 ? Either.success(this) : Either.error(errors); + } + + updateProject(project: Maybe): DataSet { + const name = project ? `${project.name} DataSet` : ""; + return this._update({ project, name }); + } + + update(fieldName: keyof DataSet, value: string | number | boolean): DataSet { + return this._update({ [fieldName]: value }); + } + setOrgUnits(orgUnits: Ref[]): DataSetToSave { const idsNotPresent = orgUnits.some(orgUnit => orgUnit.id === ""); if (idsNotPresent) { @@ -40,6 +73,15 @@ export class DataSet extends Struct() { return DataSet.create({ ...this, orgUnits }); } + 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); diff --git a/src/domain/entities/Project.ts b/src/domain/entities/Project.ts index 5cc5ea87..f8920bfd 100644 --- a/src/domain/entities/Project.ts +++ b/src/domain/entities/Project.ts @@ -6,6 +6,7 @@ import i18n from "$/utils/i18n"; export type ProjectAttrs = { id: Id; name: string; + isOpen: boolean; dataSets: DataSet[]; lastUpdated: ISODateString; }; diff --git a/src/domain/entities/generic/Error.ts b/src/domain/entities/generic/Error.ts new file mode 100644 index 00000000..a74df4f8 --- /dev/null +++ b/src/domain/entities/generic/Error.ts @@ -0,0 +1,44 @@ +import i18n from "$/utils/i18n"; + +export type ValidationErrorKey = "field_cannot_be_blank" | "positive_number" | "org_unit_required"; + +export const validationErrorMessages: Record< + ValidationErrorKey, + (fieldName: string, value: unknown) => string +> = { + field_cannot_be_blank: (fieldName: string) => + i18n.t(`Cannot be blank: {{fieldName}}`, { fieldName: fieldName, nsSeparator: false }), + positive_number: (fieldName: string) => { + return i18n.t(`{{fieldName}} must be a positive number`, { + fieldName: fieldName, + }); + }, + org_unit_required: () => i18n.t("At least one org. unit is required"), +}; + +export function getErrorMessageFromErrors(errors: ValidationError[]): string { + return errors + .map(error => { + return error.errors.map(err => + validationErrorMessages[err](error.property as string, error.value) + ); + }) + .flat() + .join("\n"); +} + +export function getErrors(errors: ValidationError[]): string[] { + return errors + .map(error => { + return error.errors.map(err => + validationErrorMessages[err](error.property as string, error.value) + ); + }) + .flat(); +} + +export type ValidationError = { + property: keyof T; + value: unknown; + errors: ValidationErrorKey[]; +}; diff --git a/src/domain/entities/generic/Validation.ts b/src/domain/entities/generic/Validation.ts new file mode 100644 index 00000000..d90a7b56 --- /dev/null +++ b/src/domain/entities/generic/Validation.ts @@ -0,0 +1,18 @@ +import { OrgUnit } from "$/domain/entities/DataSet"; +import { ValidationErrorKey } from "$/domain/entities/generic/Error"; + +export const periodsTypes = ["CUSTOM", "ANNUAL", "SEMIANNUAL"] as const; + +export function validateRequired(value: any): ValidationErrorKey[] { + const isBlank = !value || (value.length !== undefined && value.length === 0); + + return isBlank ? ["field_cannot_be_blank"] : []; +} + +export function isPositive(value: number): ValidationErrorKey[] { + return value >= 0 ? [] : ["positive_number"]; +} + +export function validateOrgUnits(value: OrgUnit[]): ValidationErrorKey[] { + return value.length > 0 ? [] : ["org_unit_required"]; +} diff --git a/src/domain/repositories/DataSetRepository.ts b/src/domain/repositories/DataSetRepository.ts index 72e8f8f0..c3be2054 100644 --- a/src/domain/repositories/DataSetRepository.ts +++ b/src/domain/repositories/DataSetRepository.ts @@ -7,11 +7,13 @@ export interface DataSetRepository { getByIds(ids: Id[]): FutureData; getAll(): FutureData; getList(options: GetDataSetOptions): FutureData>; + getByName(name: string): FutureData; delete(ids: Id[]): FutureData; save(dataSets: DataSetToSave[]): FutureData; } export type DataSetOrderFields = keyof Pick; +export type DataSetName = Pick; export type GetDataSetOptions = { paging: { page: number; pageSize: number }; diff --git a/src/domain/repositories/OrgUnitRepository.ts b/src/domain/repositories/OrgUnitRepository.ts new file mode 100644 index 00000000..fec43ef7 --- /dev/null +++ b/src/domain/repositories/OrgUnitRepository.ts @@ -0,0 +1,7 @@ +import { OrgUnit } from "$/domain/entities/DataSet"; +import { Id } from "$/domain/entities/Ref"; +import { FutureData } from "$/domain/entities/generic/Future"; + +export interface OrgUnitRepository { + getByIds(ids: Id[]): FutureData; +} diff --git a/src/domain/repositories/ProjectRepository.ts b/src/domain/repositories/ProjectRepository.ts index 10044268..27c56290 100644 --- a/src/domain/repositories/ProjectRepository.ts +++ b/src/domain/repositories/ProjectRepository.ts @@ -6,4 +6,5 @@ import { GetDataSetOptions } from "$/domain/repositories/DataSetRepository"; export interface ProjectRepository { get(options: GetDataSetOptions): FutureData>; getAll(): FutureData; + getList(): FutureData; } diff --git a/src/domain/usecases/GetAllProjectsUseCase.ts b/src/domain/usecases/GetAllProjectsUseCase.ts new file mode 100644 index 00000000..a1422547 --- /dev/null +++ b/src/domain/usecases/GetAllProjectsUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "$/domain/entities/generic/Future"; +import { Project } from "$/domain/entities/Project"; +import { ProjectRepository } from "$/domain/repositories/ProjectRepository"; + +export class GetAllProjectsUseCase { + constructor(private projectRepository: ProjectRepository) {} + + execute(): FutureData { + return this.projectRepository.getList(); + } +} diff --git a/src/domain/usecases/GetOrgUnitsByIdsUseCase.ts b/src/domain/usecases/GetOrgUnitsByIdsUseCase.ts new file mode 100644 index 00000000..04996f2a --- /dev/null +++ b/src/domain/usecases/GetOrgUnitsByIdsUseCase.ts @@ -0,0 +1,12 @@ +import { OrgUnit } from "$/domain/entities/DataSet"; +import { Id } from "$/domain/entities/Ref"; +import { FutureData } from "$/domain/entities/generic/Future"; +import { OrgUnitRepository } from "$/domain/repositories/OrgUnitRepository"; + +export class GetOrgUnitsByIdsUseCase { + constructor(private orgUnitRepository: OrgUnitRepository) {} + + public execute(ids: Id[]): FutureData { + return this.orgUnitRepository.getByIds(ids); + } +} diff --git a/src/domain/usecases/SaveDataSetUseCase.ts b/src/domain/usecases/SaveDataSetUseCase.ts new file mode 100644 index 00000000..8a1a8bf2 --- /dev/null +++ b/src/domain/usecases/SaveDataSetUseCase.ts @@ -0,0 +1,39 @@ +import _ from "$/domain/entities/generic/Collection"; +import { 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"; +import { getErrors } from "$/domain/entities/generic/Error"; + +export class SaveDataSetUseCase { + constructor(private dataSetRepository: DataSetRepository) {} + + execute(dataSet: DataSet): FutureData { + return this.getDataSetById(dataSet.id).flatMap(existingDataSet => { + const dataSetToSave = DataSet.create({ + ...(existingDataSet || {}), + ...dataSet, + shortName: this.truncateValue(dataSet.name), + }); + + const result = dataSetToSave.validateSetup(); + if (result.isError()) { + const errors = getErrors(result.value.error); + return Future.error(new Error(errors.join("\n"))); + } + + return this.dataSetRepository.save([dataSetToSave]); + }); + } + + private getDataSetById(id: string): FutureData> { + return this.dataSetRepository.getByIds([id]).map(dataSets => { + return _(dataSets).first(); + }); + } + + private truncateValue(input: string): string { + const targetLength = 50; + return input.length > targetLength ? input.slice(0, targetLength) : input; + } +} diff --git a/src/domain/usecases/ValidateNameUseCase.ts b/src/domain/usecases/ValidateNameUseCase.ts new file mode 100644 index 00000000..d335a29b --- /dev/null +++ b/src/domain/usecases/ValidateNameUseCase.ts @@ -0,0 +1,18 @@ +import { Id } from "$/domain/entities/Ref"; +import { FutureData } from "$/domain/entities/generic/Future"; +import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; + +export class ValidateNameUseCase { + constructor(private dataSetRepository: DataSetRepository) {} + + execute(options: ValidateNameOptions): FutureData { + return this.dataSetRepository.getByName(options.name).map(dataSets => { + return dataSets.some( + dataSet => + dataSet.id !== options.dataSetId && + dataSet.name.toLowerCase() === options.name.toLowerCase() + ); + }); + } +} +export type ValidateNameOptions = { name: string; dataSetId: Id }; diff --git a/src/webapp/components/dataset-wizard/DataSetWizard.tsx b/src/webapp/components/dataset-wizard/DataSetWizard.tsx index d511b872..6fa86f7d 100644 --- a/src/webapp/components/dataset-wizard/DataSetWizard.tsx +++ b/src/webapp/components/dataset-wizard/DataSetWizard.tsx @@ -7,8 +7,17 @@ import ArrowBackIcon from "@material-ui/icons/ArrowBack"; import i18n from "$/utils/i18n"; import { getDataSetSteps } from "$/webapp/components/dataset-wizard/utils"; import { useNavigateTo } from "$/webapp/routes"; +import { DataSet } from "$/domain/entities/DataSet"; +import { useAppContext } from "$/webapp/contexts/app-context"; +import { getErrors } from "$/domain/entities/generic/Error"; +import { Project } from "$/domain/entities/Project"; -export type DataSetWizardProps = { id?: string }; +export type DataSetWizardProps = { + id?: string; + projects: Project[]; + dataSet: DataSet; + updateDataSet: React.Dispatch>; +}; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -17,22 +26,77 @@ const useStyles = makeStyles((theme: Theme) => }) ); +export type ValidationStatusType = "idle" | "loading" | "error" | "success"; + export const DataSetWizard = React.memo((props: DataSetWizardProps) => { - const { id } = props; + const { compositionRoot } = useAppContext(); + const { id, dataSet, projects, updateDataSet } = props; const isEditing = Boolean(id); const actionTitle = isEditing ? i18n.t("Edit") : i18n.t("Create"); const steps = getDataSetSteps(); const classes = useStyles(); const navigateTo = useNavigateTo(); + const [validationStatus, setValidationStatus] = React.useState("idle"); const goBackToHome = React.useCallback(() => { navigateTo("dataSets"); }, [navigateTo]); + const validateDataSetName = React.useCallback( + (name: string) => { + setValidationStatus("loading"); + return compositionRoot.dataSets.validateName + .execute({ name, dataSetId: dataSet.id }) + .run( + duplicateName => { + setValidationStatus(duplicateName ? "error" : "success"); + }, + error => { + console.error(error.message); + setValidationStatus("error"); + } + ); + }, + [compositionRoot.dataSets.validateName, dataSet.id] + ); + + const stepsWithProps = React.useMemo(() => { + return steps.map(step => { + return { + ...step, + props: { + dataSet, + onValidate: validateDataSetName, + validationStatus, + onChange: updateDataSet, + projects, + }, + }; + }); + }, [dataSet, projects, steps, validateDataSetName, validationStatus, updateDataSet]); + + const validationInProgressOrError = + !validationStatus || validationStatus === "error" || validationStatus === "loading"; + + const validateSteps = React.useCallback( + (currentStep: WizardStep) => { + if (validationInProgressOrError) + return Promise.resolve(["Validation name in progress"]); + if (currentStep.key === "setup") { + const result = dataSet.validateSetup(); + if (result.isError()) { + return Promise.resolve(getErrors(result.value.error)); + } + } + return Promise.resolve([]); + }, + [dataSet, validationInProgressOrError] + ); + return ( - + @@ -43,11 +107,10 @@ export const DataSetWizard = React.memo((props: DataSetWizardProps) => { { - return Promise.resolve([]); - }} + onStepChangeRequest={validateSteps} useSnackFeedback - steps={steps} + steps={stepsWithProps} + initialStepKey="setup" /> diff --git a/src/webapp/components/dataset-wizard/IndicatorsDataSet.tsx b/src/webapp/components/dataset-wizard/IndicatorsDataSet.tsx index 2b28233c..dac9a987 100644 --- a/src/webapp/components/dataset-wizard/IndicatorsDataSet.tsx +++ b/src/webapp/components/dataset-wizard/IndicatorsDataSet.tsx @@ -10,7 +10,7 @@ import { FilterWrapper, } from "$/webapp/components/dataset-wizard/FilterIndicators"; -export type IndicatorsDataSetProps = { dataSet?: DataSet }; +export type IndicatorsDataSetProps = { dataSet: DataSet }; export type IndicatorsColumns = { id: string; @@ -32,7 +32,7 @@ const coreCompetencies = [ "Wash", ]; -export const IndicatorsDataSet = React.memo(() => { +export const IndicatorsDataSet = React.memo((_props: IndicatorsDataSetProps) => { const [showFilterModal, setShowFilterModal] = React.useState(false); const [scope, setScope] = React.useState("Core"); const [core, setCore] = React.useState("Icla"); diff --git a/src/webapp/components/dataset-wizard/ProjectsSelectorModal.tsx b/src/webapp/components/dataset-wizard/ProjectsSelectorModal.tsx new file mode 100644 index 00000000..bf91855c --- /dev/null +++ b/src/webapp/components/dataset-wizard/ProjectsSelectorModal.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { ConfirmationDialog } from "@eyeseetea/d2-ui-components"; +import { Grid, Button, Checkbox, FormControlLabel, TextField } from "@material-ui/core"; +import { FixedSizeList as List } from "react-window"; +import i18n from "$/utils/i18n"; +import { Project } from "$/domain/entities/Project"; +import { component } from "$/utils/react"; +import { Maybe } from "$/utils/ts-utils"; + +export type ProjectsSelectorModalProps = { + onChange: (project: Maybe) => void; + onClose: () => void; + projects: Project[]; +}; + +const ProjectsSelectorModal_ = React.memo((props: ProjectsSelectorModalProps) => { + const { onClose, onChange, projects } = props; + const [showClosedProjects, setShowClosedProjects] = React.useState(false); + const [searchProject, setSearchProject] = React.useState(""); + + const onSelectProject = React.useCallback( + (project: Project) => { + onChange(project); + onClose(); + }, + [onChange, onClose] + ); + + const projectsToShow = React.useMemo(() => { + return projects.filter(project => { + const matchesSearch = + !searchProject || project.name.toLowerCase().includes(searchProject.toLowerCase()); + + return showClosedProjects ? matchesSearch : matchesSearch && project.isOpen; + }); + }, [projects, searchProject, showClosedProjects]); + + const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => { + const project = projectsToShow[index]; + if (!project) return null; + + return ( +
+ + + +
+ ); + }; + + return ( + + + + setShowClosedProjects(event.target.checked)} + /> + } + label={i18n.t("Show closed projects")} + /> + + + setSearchProject(event.target.value)} + /> + + + + + {Row} + + + + ); +}); + +export const ProjectsSelectorModal = component(ProjectsSelectorModal_); diff --git a/src/webapp/components/dataset-wizard/SetupDataSet.tsx b/src/webapp/components/dataset-wizard/SetupDataSet.tsx index 5ac572d2..27825026 100644 --- a/src/webapp/components/dataset-wizard/SetupDataSet.tsx +++ b/src/webapp/components/dataset-wizard/SetupDataSet.tsx @@ -1,88 +1,210 @@ import React from "react"; -import { ConfirmationDialog, OrgUnitsSelector } from "@eyeseetea/d2-ui-components"; -import { Grid, IconButton, Typography, TextField, Button } from "@material-ui/core"; +import { OrgUnitsSelector } from "@eyeseetea/d2-ui-components"; +import { + Grid, + IconButton, + Typography, + TextField, + Checkbox, + FormControlLabel, +} from "@material-ui/core"; import OpenInNewIcon from "@material-ui/icons/OpenInNew"; import { DataSet } from "$/domain/entities/DataSet"; import i18n from "$/utils/i18n"; import { useAppContext } from "$/webapp/contexts/app-context"; +import { ValidationStatusType } from "$/webapp/components/dataset-wizard/DataSetWizard"; +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"; -export type SetupDataSetProps = { dataSet?: DataSet }; +export type SetupDataSetProps = { + dataSet: DataSet; + onValidate: (value: string) => void; + onChange: (dataSet: DataSet) => void; + validationStatus: ValidationStatusType; + projects: Project[]; +}; -export const SetupDataSet = React.memo((props: SetupDataSetProps) => { +const SetupDataSet_ = React.memo((props: SetupDataSetProps) => { const { api } = useAppContext(); - const { dataSet } = props; + const { dataSet, onChange, onValidate, projects, validationStatus } = props; const [projectModalOpen, setProjectModalOpen] = React.useState(false); const openProjectModal = React.useCallback(() => { setProjectModalOpen(true); }, []); + const validateName = React.useMemo( + () => + debounce((value: string) => { + if (value) onValidate(value); + }, 700), + [onValidate] + ); + + const updateValues = React.useCallback( + (field: keyof DataSet, value: string | boolean) => { + if (typeof value === "boolean") { + const updateData = dataSet.update(field, value); + onChange(updateData); + } else if (typeof value === "number") { + const updateData = dataSet.update(field, Number(value) ?? 0); + onChange(updateData); + } else { + const updateData = dataSet.update(field, value); + onChange(updateData); + } + + if (field === "name") validateName(value.toString()); + }, + [onChange, validateName, dataSet] + ); + + const updateOrgUnits = React.useCallback( + (paths: string[]) => { + const orgUnits = DataSet.buildOrgUnitsFromPaths(paths); + const updateData = DataSet.create({ ...dataSet, orgUnits }); + onChange(updateData); + }, + [onChange, dataSet] + ); + + const updateProject = React.useCallback( + (project: Maybe) => { + const updatedData = dataSet.updateProject(project); + onChange(updatedData); + setProjectModalOpen(false); + }, + [onChange, setProjectModalOpen, dataSet] + ); + return ( -
- - - - - - ), - }} - /> - - - - - - - - - - - - - - - - {i18n.t("Select org units")} - - - - + + + + + + ), + }} + value={dataSet.project?.name ?? ""} + /> + + + + updateValues("name", event.target.value)} + /> + + + + updateValues("description", event.target.value)} + /> + + + + updateValues("expiryDays", event.target.value)} + /> + + + + updateValues("openFuturePeriods", event.target.value)} + /> + + + + updateValues("notifyUser", event.target.checked)} + name="notification" + /> + } + label="Send notification to completing user" + /> + + + + {i18n.t("Select org units")} + + + + `/${orgUnit.path.join("/")}`)} + onChange={updateOrgUnits} + /> {projectModalOpen && ( - setProjectModalOpen(false)} - > - - - - - - - - - + setProjectModalOpen(false)} + /> )} - + ); }); + +function debounce any>(func: F, delay: number) { + let timeout: ReturnType | null = null; + + const debounced = (...args: Parameters): void => { + if (timeout !== null) { + clearTimeout(timeout); + } + timeout = setTimeout(() => func(...args), delay); + }; + + return debounced; +} + +function getDuplicateNameError(validationStatus: ValidationStatusType) { + switch (validationStatus) { + case "error": + return i18n.t("There is already a dataset with this name"); + case "loading": + return i18n.t("Validating..."); + case "success": + return ""; + default: + return ""; + } +} + +export const SetupDataSet = component(SetupDataSet_); diff --git a/src/webapp/components/dataset-wizard/SummaryDataSet.tsx b/src/webapp/components/dataset-wizard/SummaryDataSet.tsx index f44b407f..5bf8885d 100644 --- a/src/webapp/components/dataset-wizard/SummaryDataSet.tsx +++ b/src/webapp/components/dataset-wizard/SummaryDataSet.tsx @@ -1,10 +1,50 @@ -import i18n from "$/utils/i18n"; -import { Grid, Typography } from "@material-ui/core"; import React from "react"; +import { useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; +import { Button, Grid, Typography } from "@material-ui/core"; + +import { DataSet, OrgUnit } from "$/domain/entities/DataSet"; +import i18n from "$/utils/i18n"; +import { useAppContext } from "$/webapp/contexts/app-context"; +import _ from "$/domain/entities/generic/Collection"; +import { component } from "$/utils/react"; +import { useNavigateTo } from "$/webapp/routes"; + +export type SummaryDataSetProps = { dataSet: DataSet }; + +const MAX_ORG_UNITS_TO_SHOW = 3; -export type SummaryDataSetProps = {}; +const SummaryDataSet_ = React.memo((props: SummaryDataSetProps) => { + const { compositionRoot } = useAppContext(); + const { dataSet } = props; + const [orgUnits, setOrgUnits] = React.useState([]); + const snackbar = useSnackbar(); + const navigateTo = useNavigateTo(); + const loading = useLoading(); + const { saveDataSet } = useSaveDataSet({ + dataSet, + onLoading: () => loading.show(true, i18n.t("Saving...")), + onSuccess: () => { + loading.hide(); + snackbar.success(i18n.t("Data set saved successfully")); + navigateTo("dataSets"); + }, + onError: error => { + loading.hide(); + snackbar.error(error); + }, + }); + + React.useEffect(() => { + const firstThreeOrgUnits = _(dataSet.orgUnits) + .take(MAX_ORG_UNITS_TO_SHOW) + .map(ou => ou.id) + .value(); + + return compositionRoot.orgUnits.getByIds + .execute(firstThreeOrgUnits) + .run(setOrgUnits, error => snackbar.error(error.message)); + }, [compositionRoot.orgUnits.getByIds, dataSet.orgUnits, snackbar]); -export const SummaryDataSet = React.memo((_props: SummaryDataSetProps) => { return ( @@ -12,20 +52,42 @@ export const SummaryDataSet = React.memo((_props: SummaryDataSetProps) => { {i18n.t("The dataSet is finished. Press the button Save to save the data")} + -
    - - - - - - -
+
); }); +export const SummaryDataSet = component(SummaryDataSet_); + +export const SummaryList = React.memo((props: { dataSet: DataSet; orgUnits: OrgUnit[] }) => { + const { dataSet, orgUnits } = props; + + const extraOrgUnits = dataSet.orgUnits.length - MAX_ORG_UNITS_TO_SHOW; + const orgUnitMessage = + extraOrgUnits > 0 ? i18n.t("and {{number}} more.", { number: extraOrgUnits }) : ""; + + return ( + +
    + + + + + ou.name).join(", ")} ${orgUnitMessage}`} + /> + +
+
+ ); +}); + export const SummaryItem = React.memo((props: { label: string; value: string }) => { return (
  • @@ -34,4 +96,26 @@ export const SummaryItem = React.memo((props: { label: string; value: string }) ); }); -SummaryDataSet.displayName = "SummaryDataSet"; +function useSaveDataSet(props: { + dataSet: DataSet; + onLoading: () => void; + onSuccess: () => void; + onError: (error: string) => void; +}) { + const { compositionRoot } = useAppContext(); + const { dataSet, onLoading, onSuccess, onError } = props; + + const saveDataSet = React.useCallback(() => { + onLoading(); + return compositionRoot.dataSets.save.execute(dataSet).run( + () => { + onSuccess(); + }, + error => { + onError(error.message); + } + ); + }, [compositionRoot.dataSets.save, dataSet, onLoading, onSuccess, onError]); + + return { saveDataSet }; +} diff --git a/src/webapp/components/dataset-wizard/utils.tsx b/src/webapp/components/dataset-wizard/utils.tsx index 2e97cd07..8ce571ff 100644 --- a/src/webapp/components/dataset-wizard/utils.tsx +++ b/src/webapp/components/dataset-wizard/utils.tsx @@ -7,22 +7,22 @@ import { SummaryDataSet } from "$/webapp/components/dataset-wizard/SummaryDataSe export function getDataSetSteps() { const steps = [ { - component: () => , + component: SetupDataSet, label: i18n.t("Setup"), key: "setup", }, { - component: () => , + component: IndicatorsDataSet, label: i18n.t("Indicators"), key: "indicators", }, { - component: () => , + component: ShareOptionsDataSet, label: i18n.t("Share"), key: "share", }, { - component: () => , + component: SummaryDataSet, label: i18n.t("Summary and Save"), key: "summary", }, diff --git a/src/webapp/components/edit-sharing/EditSharing.tsx b/src/webapp/components/edit-sharing/EditSharing.tsx index 680f252c..759cd650 100644 --- a/src/webapp/components/edit-sharing/EditSharing.tsx +++ b/src/webapp/components/edit-sharing/EditSharing.tsx @@ -53,7 +53,7 @@ export const EditSharing = React.memo((props: EditSharingProps) => { const dataPermission = buildDataPermissions(shareUpdate.publicAccess, "data"); const metadataPermission = buildDataPermissions(shareUpdate.publicAccess, "metadata"); - return compositionRoot.dataSets.save + return compositionRoot.dataSets.saveSharing .execute({ dataSets: dataSets, accessData: accessDataUsers.concat(accessDataGroups), @@ -72,7 +72,14 @@ export const EditSharing = React.memo((props: EditSharingProps) => { loading.hide(); }); }, - [compositionRoot.dataSets.save, dataSets, loading, snackbar, setDataSets, setSharingValue] + [ + compositionRoot.dataSets.saveSharing, + dataSets, + loading, + snackbar, + setDataSets, + setSharingValue, + ] ); const searchSharing = React.useCallback( diff --git a/src/webapp/pages/register-dataset/RegisterDataSetPage.tsx b/src/webapp/pages/register-dataset/RegisterDataSetPage.tsx index ac018a36..caa1936f 100644 --- a/src/webapp/pages/register-dataset/RegisterDataSetPage.tsx +++ b/src/webapp/pages/register-dataset/RegisterDataSetPage.tsx @@ -1,10 +1,103 @@ -import { Ref } from "$/domain/entities/Ref"; -import { DataSetWizard } from "$/webapp/components/dataset-wizard/DataSetWizard"; +import React from "react"; import { useParams } from "react-router"; -export const RegisterDataSetPage = () => { +import { DataSet } from "$/domain/entities/DataSet"; +import { Permission } from "$/domain/entities/Permission"; +import { Id, Ref } from "$/domain/entities/Ref"; +import { DataSetWizard } from "$/webapp/components/dataset-wizard/DataSetWizard"; +import { useAppContext } from "$/webapp/contexts/app-context"; +import { useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; +import { Project } from "$/domain/entities/Project"; +import { getUid } from "$/utils/uid"; +import { component } from "$/utils/react"; +import { Maybe } from "$/utils/ts-utils"; +import i18n from "$/utils/i18n"; + +const RegisterDataSetPage_ = () => { const { id } = useParams>(); - return ; + const { dataSet, status, updateDataSet } = useGetDataSetById({ id }); + const { projects } = useGetProjects(); + const loading = useLoading(); + + React.useEffect(() => { + if (status === "loading") { + loading.show(true, i18n.t("Loading...")); + } else { + loading.hide(); + } + }, [loading, status]); + + return ( + + ); }; -RegisterDataSetPage.displayName = "RegisterDataSetPage"; +function useGetProjects() { + const { compositionRoot } = useAppContext(); + const snackbar = useSnackbar(); + + const [projects, setProjects] = React.useState([]); + + React.useEffect(() => { + return compositionRoot.projects.getAll.execute().run(setProjects, error => { + snackbar.error(error.message); + }); + }, [compositionRoot.projects.getAll, snackbar]); + + return { projects }; +} + +function useGetDataSetById(props: { id: Maybe }) { + const { id } = props; + const { compositionRoot } = useAppContext(); + const snackbar = useSnackbar(); + const [status, setStatus] = React.useState("idle"); + const [dataSet, updateDataSet] = React.useState(() => { + return DataSet.create({ + access: [], + coreCompetencies: [], + created: "", + description: "", + id: getUid(new Date().getTime().toString()), + lastUpdated: "", + name: "", + orgUnits: [], + permissions: { + data: Permission.create({ read: false, write: false }), + metadata: Permission.create({ read: false, write: false }), + }, + project: undefined, + shortName: "", + expiryDays: 0, + openFuturePeriods: 0, + notifyUser: false, + }); + }); + + React.useEffect(() => { + if (!id) return; + setStatus("loading"); + return compositionRoot.dataSets.getByIds.execute([id]).run( + result => { + const firstDataSet = result[0]; + if (firstDataSet) updateDataSet(firstDataSet); + setStatus("finished"); + }, + error => { + snackbar.error(error.message); + setStatus("error"); + } + ); + }, [compositionRoot.dataSets.getByIds, id, snackbar]); + + return { dataSet, status, updateDataSet }; +} + +export type HttpStatus = "idle" | "loading" | "finished" | "error"; + +export const RegisterDataSetPage = component(RegisterDataSetPage_); diff --git a/yarn.lock b/yarn.lock index ea8651f8..61a18798 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1096,6 +1096,13 @@ resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== +"@babel/runtime@^7.0.0": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1" + integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.8", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.1", "@babel/runtime@^7.9.2": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8" @@ -4017,6 +4024,13 @@ dependencies: "@types/react" "*" +"@types/react-window@1.8.8": + version "1.8.8" + resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.8.tgz#c20645414d142364fbe735818e1c1e0a145696e3" + integrity sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@^18.2.21": version "18.2.22" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.22.tgz#abe778a1c95a07fa70df40a52d7300a40b949ccb" @@ -8369,6 +8383,11 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== +"memoize-one@>=3.1.1 <6": + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + merge-descriptors@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" @@ -9453,6 +9472,14 @@ react-transition-group@^4.0.0, react-transition-group@^4.4.0: loose-envify "^1.4.0" prop-types "^15.6.2" +react-window@1.8.10: + version "1.8.10" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03" + integrity sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@^16.12.0: version "16.14.0" resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" From 7d3831aef3c180a9b657850a3d585a06d28ac334 Mon Sep 17 00:00:00 2001 From: Eduardo Peredo Rivero Date: Wed, 4 Dec 2024 16:53:11 -0500 Subject: [PATCH 2/2] fixed code review observations --- i18n/es.po | 6 +- src/CompositionRoot.ts | 4 +- src/data/repositories/D2ApiCategoryOption.ts | 7 ++ src/data/repositories/DataSetD2Api.ts | 1 - src/data/repositories/DataSetD2Repository.ts | 23 +++---- src/data/repositories/OrgUnitD2Repository.ts | 11 ++-- src/data/repositories/ProjectD2Repository.ts | 65 +++++++++++-------- src/domain/entities/DataSet.ts | 49 ++++++++++---- src/domain/entities/DataSetToSave.ts | 8 +++ src/domain/entities/generic/Error.ts | 14 ++-- src/domain/repositories/DataSetRepository.ts | 3 +- src/domain/usecases/SaveDataSetUseCase.ts | 32 ++------- .../usecases/SaveOrgUnitDataSetUseCase.ts | 3 +- ...eCase.ts => ValidateDataSetNameUseCase.ts} | 6 +- .../dataset-wizard/DataSetWizard.tsx | 48 +++++++++----- .../dataset-wizard/SetupDataSet.tsx | 13 ++-- .../register-dataset/RegisterDataSetPage.tsx | 25 +------ 17 files changed, 164 insertions(+), 154 deletions(-) create mode 100644 src/domain/entities/DataSetToSave.ts rename src/domain/usecases/{ValidateNameUseCase.ts => ValidateDataSetNameUseCase.ts} (74%) diff --git a/i18n/es.po b/i18n/es.po index ed5b7180..a6144c53 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -197,9 +197,8 @@ msgstr "" msgid "DataSet name" msgstr "" -#, fuzzy msgid "DataSet description" -msgstr "Sección" +msgstr "" msgid "Expiry Days" msgstr "" @@ -240,9 +239,8 @@ msgstr "" msgid "and {{number}} more." msgstr "" -#, fuzzy msgid "Description" -msgstr "Sección" +msgstr "" msgid "Linked Project" msgstr "" diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 78816d1c..bc2d7e9a 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -25,7 +25,7 @@ import { SaveDataSetUseCase } from "$/domain/usecases/SaveDataSetUseCase"; import { SaveOrgUnitDataSetUseCase } from "$/domain/usecases/SaveOrgUnitDataSetUseCase"; import { SaveSharingDataSetsUseCase } from "$/domain/usecases/SaveSharingDataSetsUseCase"; import { SearchSharingUseCase } from "$/domain/usecases/SearchSharingUseCase"; -import { ValidateNameUseCase } from "$/domain/usecases/ValidateNameUseCase"; +import { ValidateDataSetNameUseCase } from "$/domain/usecases/ValidateDataSetNameUseCase"; import { UserD2Repository } from "./data/repositories/UserD2Repository"; import { UserTestRepository } from "./data/repositories/UserTestRepository"; import { UserRepository } from "./domain/repositories/UserRepository"; @@ -55,7 +55,7 @@ function getCompositionRoot(repositories: Repositories) { repositories.dataSetsRepository, repositories.projectRepository ), - validateName: new ValidateNameUseCase(repositories.dataSetsRepository), + validateName: new ValidateDataSetNameUseCase(repositories.dataSetsRepository), save: new SaveDataSetUseCase(repositories.dataSetsRepository), }, logs: { diff --git a/src/data/repositories/D2ApiCategoryOption.ts b/src/data/repositories/D2ApiCategoryOption.ts index 8f267c64..5f55e011 100644 --- a/src/data/repositories/D2ApiCategoryOption.ts +++ b/src/data/repositories/D2ApiCategoryOption.ts @@ -4,6 +4,7 @@ import { D2Api } from "$/types/d2-api"; import _ from "$/domain/entities/generic/Collection"; import { chunkRequest } from "$/data/utils"; import { FutureData } from "$/domain/entities/generic/Future"; +import { Maybe } from "$/utils/ts-utils"; export class D2ApiCategoryOption { constructor(private api: D2Api) {} @@ -22,3 +23,9 @@ export class D2ApiCategoryOption { } export type D2CategoryOptionType = { id: string; displayName: string; lastUpdated: ISODateString }; +export type D2CategoryOptionDates = { + startDate: Maybe; + endDate: Maybe; +}; + +export type D2CategoryOptionWithDates = D2CategoryOptionType & D2CategoryOptionDates; diff --git a/src/data/repositories/DataSetD2Api.ts b/src/data/repositories/DataSetD2Api.ts index 9753895f..9b478b8e 100644 --- a/src/data/repositories/DataSetD2Api.ts +++ b/src/data/repositories/DataSetD2Api.ts @@ -221,7 +221,6 @@ export class DataSetD2Api { data: this.buildPermission(d2DataSet.sharing.public, "data"), metadata: this.buildPermission(d2DataSet.sharing.public, "metadata"), }, - shortName: d2DataSet.displayShortName, access: this.buildAccessByType(d2DataSet.userAccesses, "users").concat( this.buildAccessByType(d2DataSet.userGroupAccesses, "groups") ), diff --git a/src/data/repositories/DataSetD2Repository.ts b/src/data/repositories/DataSetD2Repository.ts index 4f6aed54..8c28ac82 100644 --- a/src/data/repositories/DataSetD2Repository.ts +++ b/src/data/repositories/DataSetD2Repository.ts @@ -2,7 +2,7 @@ import { D2AttributeValue } from "@eyeseetea/d2-api/2.36"; import { D2Api } from "$/types/d2-api"; import { apiToFuture } from "$/data/api-futures"; -import { DataSet, DataSetList, DataSetToSave } from "$/domain/entities/DataSet"; +import { DataSet, DataSetList } from "$/domain/entities/DataSet"; import { Paginated } from "$/domain/entities/Paginated"; import { DataSetName, @@ -16,6 +16,7 @@ import { DataSetD2Api, dataSetFieldsWithOrgUnits } from "$/data/repositories/Dat import { Maybe } from "$/utils/ts-utils"; import { chunkRequest } from "$/data/utils"; import { D2Config } from "$/data/repositories/D2ApiConfig"; +import { DataSetToSave } from "$/domain/entities/DataSetToSave"; export class DataSetD2Repository implements DataSetRepository { private d2DataSetApi: DataSetD2Api; @@ -166,10 +167,10 @@ export class DataSetD2Repository implements DataSetRepository { ) { return { id: dataSet.id || getUid(dataSet.name), + shortName: dataSet.shortName, name: dataSet.name, periodType: "Monthly", description: dataSet.description, - shortName: dataSet.shortName, publicAccess: this.d2DataSetApi.generateFullPermission(dataSet.permissions), userAccesses: dataSet.access .filter(access => access.type === "users") @@ -203,25 +204,21 @@ export class DataSetD2Repository implements DataSetRepository { dataSet: DataSetToSave, attributes: D2Config["attributes"] ) { - // if (!dataSet.project) return existingAttributes || []; - // const projectAttributeId = attributes.project.id; - // const projectAttribute = existingAttributes?.find( - // attribute => attribute.attribute.id === projectAttributeId - // ); - - const pa = { attribute: { id: attributes.project.id }, value: dataSet.project?.id }; + const projectAttribute = { + attribute: { id: attributes.project.id }, + value: dataSet.project?.id, + }; const createdByAttribute = { attribute: { id: attributes.createdByApp.id }, value: "true" }; - const attributesToSave = _([pa, createdByAttribute]) - .compactMap(attribute => (attribute.value ? attribute : undefined)) - .value(); + const attributesToSave = [projectAttribute, createdByAttribute].filter( + attribute => attribute.value + ); const filteredExisting = existingAttributes?.filter( attr => !attributesToSave.some(save => save.attribute.id === attr.attribute.id) ) || []; - // Combinar `filteredExisting` con `toSave` return [...filteredExisting, ...attributesToSave]; } } diff --git a/src/data/repositories/OrgUnitD2Repository.ts b/src/data/repositories/OrgUnitD2Repository.ts index 89e4397f..f94f6794 100644 --- a/src/data/repositories/OrgUnitD2Repository.ts +++ b/src/data/repositories/OrgUnitD2Repository.ts @@ -2,7 +2,7 @@ import { D2Api } from "$/types/d2-api"; import { apiToFuture } from "$/data/api-futures"; import { OrgUnit } from "$/domain/entities/DataSet"; import { Id } from "$/domain/entities/Ref"; -import { Future, FutureData } from "$/domain/entities/generic/Future"; +import { FutureData } from "$/domain/entities/generic/Future"; import { OrgUnitRepository } from "$/domain/repositories/OrgUnitRepository"; import { chunkRequest } from "$/data/utils"; @@ -10,9 +10,7 @@ export class OrgUnitD2Repository implements OrgUnitRepository { constructor(private api: D2Api) {} getByIds(ids: Id[]): FutureData { - if (ids.length === 0) return Future.success([]); - - const $requests = chunkRequest(ids, idsToFetch => { + const d2OrgsUnits$ = chunkRequest(ids, idsToFetch => { return apiToFuture( this.api.models.organisationUnits.get({ fields: { id: true, displayName: true, path: true }, @@ -22,9 +20,8 @@ export class OrgUnitD2Repository implements OrgUnitRepository { ).map(response => response.objects); }); - return $requests.map(response => { - // const allRecords = response.flatMap(r => r); - return response.map(d2OrgUnit => { + return d2OrgsUnits$.map(d2OrgUnits => { + return d2OrgUnits.map(d2OrgUnit => { return { id: d2OrgUnit.id, name: d2OrgUnit.displayName, diff --git a/src/data/repositories/ProjectD2Repository.ts b/src/data/repositories/ProjectD2Repository.ts index c018ec93..0b92d3d2 100644 --- a/src/data/repositories/ProjectD2Repository.ts +++ b/src/data/repositories/ProjectD2Repository.ts @@ -8,9 +8,13 @@ import _ from "$/domain/entities/generic/Collection"; import { DataSetD2Api } from "$/data/repositories/DataSetD2Api"; import { ISODateString, Id } from "$/domain/entities/Ref"; import { DataSet } from "$/domain/entities/DataSet"; -import { D2CategoryOptionType } from "$/data/repositories/D2ApiCategoryOption"; +import { + D2CategoryOptionType, + D2CategoryOptionWithDates, +} from "$/data/repositories/D2ApiCategoryOption"; import { Future, FutureData } from "$/domain/entities/generic/Future"; import { D2ApiConfig, D2Config } from "$/data/repositories/D2ApiConfig"; +import { Maybe } from "$/utils/ts-utils"; export class ProjectD2Repository implements ProjectRepository { private d2DataSetApi: DataSetD2Api; @@ -23,32 +27,8 @@ export class ProjectD2Repository implements ProjectRepository { getList(): FutureData { return this.getCategories().flatMap(categories => { - return apiToFuture( - this.api.models.categoryOptions.get({ - fields: { - id: true, - displayName: true, - startDate: true, - endDate: true, - lastUpdated: true, - }, - filter: { "categories.code": { eq: categories.project.code } }, - order: "displayName:asc", - paging: false, - }) - ).map(d2Response => { - return d2Response.objects.map((d2CategoryOption): Project => { - return Project.build({ - dataSets: [], - id: d2CategoryOption.id, - name: d2CategoryOption.displayName, - lastUpdated: d2CategoryOption.lastUpdated, - isOpen: this.isProjectOpen( - d2CategoryOption.startDate, - d2CategoryOption.endDate - ), - }); - }); + return this.getCategoryOptionsByCode(categories.project.code).map(d2Response => { + return this.getProjectsWithDates(d2Response.objects); }); }); } @@ -57,7 +37,36 @@ export class ProjectD2Repository implements ProjectRepository { return this.getAllProjects(1, []); } - private isProjectOpen(date1: ISODateString, date2: ISODateString): boolean { + private getCategoryOptionsByCode(code: string) { + return apiToFuture( + this.api.models.categoryOptions.get({ + fields: { + id: true, + displayName: true, + startDate: true, + endDate: true, + lastUpdated: true, + }, + filter: { "categories.code": { eq: code } }, + order: "displayName:asc", + paging: false, + }) + ); + } + + private getProjectsWithDates(categoryOptions: D2CategoryOptionWithDates[]): Project[] { + return categoryOptions.map(d2CategoryOption => { + return Project.build({ + dataSets: [], + id: d2CategoryOption.id, + name: d2CategoryOption.displayName, + lastUpdated: d2CategoryOption.lastUpdated, + isOpen: this.isProjectOpen(d2CategoryOption.startDate, d2CategoryOption.endDate), + }); + }); + } + + private isProjectOpen(date1: Maybe, date2: Maybe): boolean { if (!date1 || !date2) return false; const today = new Date(); diff --git a/src/domain/entities/DataSet.ts b/src/domain/entities/DataSet.ts index 00afc59e..414e50a6 100644 --- a/src/domain/entities/DataSet.ts +++ b/src/domain/entities/DataSet.ts @@ -8,6 +8,7 @@ import _ from "$/domain/entities/generic/Collection"; import { Either } from "$/domain/entities/generic/Either"; import { ValidationError } from "$/domain/entities/generic/Error"; import { validateOrgUnits, validateRequired } from "$/domain/entities/generic/Validation"; +import { DataSetToSave } from "$/domain/entities/DataSetToSave"; export type DataSetAttrs = { created: ISODateString; @@ -17,7 +18,6 @@ export type DataSetAttrs = { lastUpdated: ISODateString; permissions: Permissions; project: Maybe; - shortName: string; coreCompetencies: CoreCompetency[]; access: AccessData[]; orgUnits: OrgUnit[]; @@ -26,9 +26,9 @@ export type DataSetAttrs = { notifyUser: boolean; }; -export type DataSetToSave = Omit & { - orgUnits: Ref[]; -}; +// export type DataSetToSave = Omit & { +// orgUnits: Ref[]; +// }; export type OrgUnit = { id: Id; name: string; path: Id[] }; export type Permissions = { data: Permission; metadata: Permission }; @@ -39,6 +39,15 @@ export type CoreCompetency = { id: Id; name: string; code: string }; export type DataSetList = Pick; export class DataSet extends Struct() { + get shortName(): string { + return this.truncateValue(this.name); + } + + private truncateValue(input: string): string { + const targetLength = 50; + return input.length > targetLength ? input.slice(0, targetLength) : input; + } + validateSetup(): Either[], DataSet> { const errors: ValidationError[] = [ { @@ -61,7 +70,7 @@ export class DataSet extends Struct() { return this._update({ project, name }); } - update(fieldName: keyof DataSet, value: string | number | boolean): DataSet { + update(fieldName: K, value: DataSet[K]): DataSet { return this._update({ [fieldName]: value }); } @@ -73,15 +82,6 @@ export class DataSet extends Struct() { return DataSet.create({ ...this, orgUnits }); } - 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); @@ -103,4 +103,25 @@ export class DataSet extends Struct() { return ""; } } + + static initial(id: Id): DataSet { + return DataSet.create({ + access: [], + coreCompetencies: [], + created: "", + description: "", + id, + lastUpdated: "", + name: "", + orgUnits: [], + permissions: { + data: Permission.create({ read: false, write: false }), + metadata: Permission.create({ read: false, write: false }), + }, + project: undefined, + expiryDays: 0, + openFuturePeriods: 0, + notifyUser: false, + }); + } } diff --git a/src/domain/entities/DataSetToSave.ts b/src/domain/entities/DataSetToSave.ts new file mode 100644 index 00000000..1dd67123 --- /dev/null +++ b/src/domain/entities/DataSetToSave.ts @@ -0,0 +1,8 @@ +import { DataSet, DataSetAttrs } from "$/domain/entities/DataSet"; +import { Ref } from "$/domain/entities/Ref"; + +export type DataSetToSaveAttrs = Omit & { + orgUnits: Ref[]; +}; + +export class DataSetToSave extends DataSet {} diff --git a/src/domain/entities/generic/Error.ts b/src/domain/entities/generic/Error.ts index a74df4f8..1f717154 100644 --- a/src/domain/entities/generic/Error.ts +++ b/src/domain/entities/generic/Error.ts @@ -20,7 +20,7 @@ export function getErrorMessageFromErrors(errors: ValidationError[]): stri return errors .map(error => { return error.errors.map(err => - validationErrorMessages[err](error.property as string, error.value) + validationErrorMessages[err](error.property, error.value) ); }) .flat() @@ -28,17 +28,13 @@ export function getErrorMessageFromErrors(errors: ValidationError[]): stri } export function getErrors(errors: ValidationError[]): string[] { - return errors - .map(error => { - return error.errors.map(err => - validationErrorMessages[err](error.property as string, error.value) - ); - }) - .flat(); + return errors.flatMap(error => { + return error.errors.map(err => validationErrorMessages[err](error.property, error.value)); + }); } export type ValidationError = { - property: keyof T; + property: keyof T & string; value: unknown; errors: ValidationErrorKey[]; }; diff --git a/src/domain/repositories/DataSetRepository.ts b/src/domain/repositories/DataSetRepository.ts index c3be2054..b940faa2 100644 --- a/src/domain/repositories/DataSetRepository.ts +++ b/src/domain/repositories/DataSetRepository.ts @@ -1,7 +1,8 @@ import { FutureData } from "$/domain/entities/generic/Future"; -import { DataSet, DataSetList, DataSetToSave } from "$/domain/entities/DataSet"; +import { DataSet, DataSetList } from "$/domain/entities/DataSet"; import { Paginated } from "$/domain/entities/Paginated"; import { Id } from "$/domain/entities/Ref"; +import { DataSetToSave } from "$/domain/entities/DataSetToSave"; export interface DataSetRepository { getByIds(ids: Id[]): FutureData; diff --git a/src/domain/usecases/SaveDataSetUseCase.ts b/src/domain/usecases/SaveDataSetUseCase.ts index 8a1a8bf2..0e3af537 100644 --- a/src/domain/usecases/SaveDataSetUseCase.ts +++ b/src/domain/usecases/SaveDataSetUseCase.ts @@ -2,38 +2,18 @@ import _ from "$/domain/entities/generic/Collection"; import { 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"; import { getErrors } from "$/domain/entities/generic/Error"; export class SaveDataSetUseCase { constructor(private dataSetRepository: DataSetRepository) {} execute(dataSet: DataSet): FutureData { - return this.getDataSetById(dataSet.id).flatMap(existingDataSet => { - const dataSetToSave = DataSet.create({ - ...(existingDataSet || {}), - ...dataSet, - shortName: this.truncateValue(dataSet.name), - }); + const result = dataSet.validateSetup(); + if (result.isError()) { + const errors = getErrors(result.value.error); + return Future.error(new Error(errors.join("\n"))); + } - const result = dataSetToSave.validateSetup(); - if (result.isError()) { - const errors = getErrors(result.value.error); - return Future.error(new Error(errors.join("\n"))); - } - - return this.dataSetRepository.save([dataSetToSave]); - }); - } - - private getDataSetById(id: string): FutureData> { - return this.dataSetRepository.getByIds([id]).map(dataSets => { - return _(dataSets).first(); - }); - } - - private truncateValue(input: string): string { - const targetLength = 50; - return input.length > targetLength ? input.slice(0, targetLength) : input; + return this.dataSetRepository.save([dataSet]); } } diff --git a/src/domain/usecases/SaveOrgUnitDataSetUseCase.ts b/src/domain/usecases/SaveOrgUnitDataSetUseCase.ts index fdf1a2e5..22fe97be 100644 --- a/src/domain/usecases/SaveOrgUnitDataSetUseCase.ts +++ b/src/domain/usecases/SaveOrgUnitDataSetUseCase.ts @@ -1,8 +1,9 @@ import { FutureData } from "$/domain/entities/generic/Future"; -import { DataSet, DataSetToSave } from "$/domain/entities/DataSet"; +import { DataSet } from "$/domain/entities/DataSet"; import { Id, Ref } from "$/domain/entities/Ref"; import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; import _ from "$/domain/entities/generic/Collection"; +import { DataSetToSave } from "$/domain/entities/DataSetToSave"; export class SaveOrgUnitDataSetUseCase { constructor(private dataSetRepository: DataSetRepository) {} diff --git a/src/domain/usecases/ValidateNameUseCase.ts b/src/domain/usecases/ValidateDataSetNameUseCase.ts similarity index 74% rename from src/domain/usecases/ValidateNameUseCase.ts rename to src/domain/usecases/ValidateDataSetNameUseCase.ts index d335a29b..d83d6161 100644 --- a/src/domain/usecases/ValidateNameUseCase.ts +++ b/src/domain/usecases/ValidateDataSetNameUseCase.ts @@ -2,10 +2,10 @@ import { Id } from "$/domain/entities/Ref"; import { FutureData } from "$/domain/entities/generic/Future"; import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; -export class ValidateNameUseCase { +export class ValidateDataSetNameUseCase { constructor(private dataSetRepository: DataSetRepository) {} - execute(options: ValidateNameOptions): FutureData { + execute(options: ValidateDataSetNameOptions): FutureData { return this.dataSetRepository.getByName(options.name).map(dataSets => { return dataSets.some( dataSet => @@ -15,4 +15,4 @@ export class ValidateNameUseCase { }); } } -export type ValidateNameOptions = { name: string; dataSetId: Id }; +export type ValidateDataSetNameOptions = { name: string; dataSetId: Id }; diff --git a/src/webapp/components/dataset-wizard/DataSetWizard.tsx b/src/webapp/components/dataset-wizard/DataSetWizard.tsx index 6fa86f7d..ee5770c1 100644 --- a/src/webapp/components/dataset-wizard/DataSetWizard.tsx +++ b/src/webapp/components/dataset-wizard/DataSetWizard.tsx @@ -38,6 +38,8 @@ export const DataSetWizard = React.memo((props: DataSetWizardProps) => { const navigateTo = useNavigateTo(); const [validationStatus, setValidationStatus] = React.useState("idle"); + const { validateSteps } = useValidateDataSetWizard({ validationStatus, dataSet }); + const goBackToHome = React.useCallback(() => { navigateTo("dataSets"); }, [navigateTo]); @@ -75,24 +77,6 @@ export const DataSetWizard = React.memo((props: DataSetWizardProps) => { }); }, [dataSet, projects, steps, validateDataSetName, validationStatus, updateDataSet]); - const validationInProgressOrError = - !validationStatus || validationStatus === "error" || validationStatus === "loading"; - - const validateSteps = React.useCallback( - (currentStep: WizardStep) => { - if (validationInProgressOrError) - return Promise.resolve(["Validation name in progress"]); - if (currentStep.key === "setup") { - const result = dataSet.validateSetup(); - if (result.isError()) { - return Promise.resolve(getErrors(result.value.error)); - } - } - return Promise.resolve([]); - }, - [dataSet, validationInProgressOrError] - ); - return ( @@ -117,4 +101,32 @@ export const DataSetWizard = React.memo((props: DataSetWizardProps) => { ); }); +export function useValidateDataSetWizard(props: { + validationStatus: ValidationStatusType; + dataSet: DataSet; +}) { + const { validationStatus, dataSet } = props; + + const validationInProgressOrError = + !validationStatus || validationStatus === "error" || validationStatus === "loading"; + + const validateSteps = React.useCallback( + (currentStep: WizardStep) => { + if (validationInProgressOrError) { + return Promise.resolve(["Validation name in progress"]); + } else if (currentStep.key === "setup") { + const result = dataSet.validateSetup(); + return result.isError() + ? Promise.resolve(getErrors(result.value.error)) + : Promise.resolve([]); + } else { + return Promise.resolve([]); + } + }, + [dataSet, validationInProgressOrError] + ); + + return { validateSteps }; +} + DataSetWizard.displayName = "DataSetWizard"; diff --git a/src/webapp/components/dataset-wizard/SetupDataSet.tsx b/src/webapp/components/dataset-wizard/SetupDataSet.tsx index 27825026..d5d27cfb 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, compositionRoot } = useAppContext(); const { dataSet, onChange, onValidate, projects, validationStatus } = props; const [projectModalOpen, setProjectModalOpen] = React.useState(false); @@ -64,11 +65,13 @@ 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); + }, console.error); }, - [onChange, dataSet] + [compositionRoot.orgUnits.getByIds, onChange, dataSet] ); const updateProject = React.useCallback( diff --git a/src/webapp/pages/register-dataset/RegisterDataSetPage.tsx b/src/webapp/pages/register-dataset/RegisterDataSetPage.tsx index caa1936f..540b9a64 100644 --- a/src/webapp/pages/register-dataset/RegisterDataSetPage.tsx +++ b/src/webapp/pages/register-dataset/RegisterDataSetPage.tsx @@ -2,7 +2,6 @@ import React from "react"; import { useParams } from "react-router"; import { DataSet } from "$/domain/entities/DataSet"; -import { Permission } from "$/domain/entities/Permission"; import { Id, Ref } from "$/domain/entities/Ref"; import { DataSetWizard } from "$/webapp/components/dataset-wizard/DataSetWizard"; import { useAppContext } from "$/webapp/contexts/app-context"; @@ -56,27 +55,9 @@ function useGetDataSetById(props: { id: Maybe }) { const { id } = props; const { compositionRoot } = useAppContext(); const snackbar = useSnackbar(); - const [status, setStatus] = React.useState("idle"); + const [status, setStatus] = React.useState("idle"); const [dataSet, updateDataSet] = React.useState(() => { - return DataSet.create({ - access: [], - coreCompetencies: [], - created: "", - description: "", - id: getUid(new Date().getTime().toString()), - lastUpdated: "", - name: "", - orgUnits: [], - permissions: { - data: Permission.create({ read: false, write: false }), - metadata: Permission.create({ read: false, write: false }), - }, - project: undefined, - shortName: "", - expiryDays: 0, - openFuturePeriods: 0, - notifyUser: false, - }); + return DataSet.initial(getUid(new Date().getTime().toString())); }); React.useEffect(() => { @@ -98,6 +79,6 @@ function useGetDataSetById(props: { id: Maybe }) { return { dataSet, status, updateDataSet }; } -export type HttpStatus = "idle" | "loading" | "finished" | "error"; +export type LoadingStatus = "idle" | "loading" | "finished" | "error"; export const RegisterDataSetPage = component(RegisterDataSetPage_);