diff --git a/.vite/deps_temp_2fce2642/package.json b/.vite/deps_temp_2fce2642/package.json deleted file mode 100644 index 3dbc1ca5..00000000 --- a/.vite/deps_temp_2fce2642/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} 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..a6144c53 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,31 +182,72 @@ msgstr "" msgid "No Selected" msgstr "" +msgid "Show closed projects" +msgstr "" + +msgid "Filter projects" +msgstr "" + +msgid "" +msgstr "" + msgid "Select Project" msgstr "" msgid "DataSet name" msgstr "" -#, fuzzy msgid "DataSet description" -msgstr "Sección" +msgstr "" + +msgid "Expiry Days" +msgstr "" -msgid "Start Date" +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 "" @@ -216,12 +266,6 @@ msgstr "" msgid "Merge" msgstr "" -msgid "Organisation Units" -msgstr "" - -msgid "Save" -msgstr "" - msgid "Bulk update strategy: {{actionLabel}}" msgstr "" @@ -267,9 +311,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 1b840ecb..6009383a 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,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", @@ -57,6 +58,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..bc2d7e9a 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 { ValidateDataSetNameUseCase } from "$/domain/usecases/ValidateDataSetNameUseCase"; 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 ValidateDataSetNameUseCase(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/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 f99c5626..9b478b8e 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({ @@ -225,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") ), @@ -233,6 +228,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 +298,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..8c28ac82 100644 --- a/src/data/repositories/DataSetD2Repository.ts +++ b/src/data/repositories/DataSetD2Repository.ts @@ -2,9 +2,13 @@ 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 { 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"; @@ -12,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; @@ -20,6 +25,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 +80,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; }); }); } @@ -150,9 +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") @@ -175,6 +193,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 +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 projectAttribute = { + attribute: { id: attributes.project.id }, + value: dataSet.project?.id, + }; + const createdByAttribute = { attribute: { id: attributes.createdByApp.id }, value: "true" }; + + const attributesToSave = [projectAttribute, createdByAttribute].filter( + attribute => attribute.value ); - 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, - }, - ]; - } + const filteredExisting = + existingAttributes?.filter( + attr => !attributesToSave.some(save => save.attribute.id === attr.attribute.id) + ) || []; + + 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..f94f6794 --- /dev/null +++ b/src/data/repositories/OrgUnitD2Repository.ts @@ -0,0 +1,35 @@ +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 { 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 { + const d2OrgsUnits$ = 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 d2OrgsUnits$.map(d2OrgUnits => { + return d2OrgUnits.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..0b92d3d2 100644 --- a/src/data/repositories/ProjectD2Repository.ts +++ b/src/data/repositories/ProjectD2Repository.ts @@ -6,11 +6,15 @@ 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 { + 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; @@ -21,10 +25,57 @@ export class ProjectD2Repository implements ProjectRepository { this.d2ApiConfig = new D2ApiConfig(this.api); } + getList(): FutureData { + return this.getCategories().flatMap(categories => { + return this.getCategoryOptionsByCode(categories.project.code).map(d2Response => { + return this.getProjectsWithDates(d2Response.objects); + }); + }); + } + getAll(): FutureData { return this.getAllProjects(1, []); } + 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(); + 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 +159,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..414e50a6 100644 --- a/src/domain/entities/DataSet.ts +++ b/src/domain/entities/DataSet.ts @@ -4,6 +4,11 @@ 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"; +import { DataSetToSave } from "$/domain/entities/DataSetToSave"; export type DataSetAttrs = { created: ISODateString; @@ -13,15 +18,17 @@ export type DataSetAttrs = { lastUpdated: ISODateString; permissions: Permissions; project: Maybe; - shortName: string; coreCompetencies: CoreCompetency[]; access: AccessData[]; orgUnits: OrgUnit[]; + expiryDays: number; + openFuturePeriods: number; + 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 }; @@ -32,6 +39,41 @@ 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[] = [ + { + 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: K, value: DataSet[K]): DataSet { + return this._update({ [fieldName]: value }); + } + setOrgUnits(orgUnits: Ref[]): DataSetToSave { const idsNotPresent = orgUnits.some(orgUnit => orgUnit.id === ""); if (idsNotPresent) { @@ -61,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/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..1f717154 --- /dev/null +++ b/src/domain/entities/generic/Error.ts @@ -0,0 +1,40 @@ +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, error.value) + ); + }) + .flat() + .join("\n"); +} + +export function getErrors(errors: ValidationError[]): string[] { + return errors.flatMap(error => { + return error.errors.map(err => validationErrorMessages[err](error.property, error.value)); + }); +} + +export type ValidationError = { + property: keyof T & string; + 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..b940faa2 100644 --- a/src/domain/repositories/DataSetRepository.ts +++ b/src/domain/repositories/DataSetRepository.ts @@ -1,17 +1,20 @@ 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; 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..0e3af537 --- /dev/null +++ b/src/domain/usecases/SaveDataSetUseCase.ts @@ -0,0 +1,19 @@ +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 { getErrors } from "$/domain/entities/generic/Error"; + +export class SaveDataSetUseCase { + constructor(private dataSetRepository: DataSetRepository) {} + + execute(dataSet: DataSet): FutureData { + const result = dataSet.validateSetup(); + if (result.isError()) { + const errors = getErrors(result.value.error); + return Future.error(new Error(errors.join("\n"))); + } + + 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/ValidateDataSetNameUseCase.ts b/src/domain/usecases/ValidateDataSetNameUseCase.ts new file mode 100644 index 00000000..d83d6161 --- /dev/null +++ b/src/domain/usecases/ValidateDataSetNameUseCase.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 ValidateDataSetNameUseCase { + constructor(private dataSetRepository: DataSetRepository) {} + + execute(options: ValidateDataSetNameOptions): 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 ValidateDataSetNameOptions = { name: string; dataSetId: Id }; diff --git a/src/webapp/components/dataset-wizard/DataSetWizard.tsx b/src/webapp/components/dataset-wizard/DataSetWizard.tsx index d511b872..ee5770c1 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,61 @@ 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 { validateSteps } = useValidateDataSetWizard({ validationStatus, dataSet }); 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]); + return ( - + @@ -43,15 +91,42 @@ export const DataSetWizard = React.memo((props: DataSetWizardProps) => { { - return Promise.resolve([]); - }} + onStepChangeRequest={validateSteps} useSnackFeedback - steps={steps} + steps={stepsWithProps} + initialStepKey="setup" /> ); }); +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/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..d5d27cfb 100644 --- a/src/webapp/components/dataset-wizard/SetupDataSet.tsx +++ b/src/webapp/components/dataset-wizard/SetupDataSet.tsx @@ -1,88 +1,213 @@ 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"; +import _ from "$/domain/entities/generic/Collection"; -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 { api } = useAppContext(); - const { dataSet } = props; +const SetupDataSet_ = React.memo((props: SetupDataSetProps) => { + const { api, compositionRoot } = useAppContext(); + 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 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); + }, + [compositionRoot.orgUnits.getByIds, 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..540b9a64 100644 --- a/src/webapp/pages/register-dataset/RegisterDataSetPage.tsx +++ b/src/webapp/pages/register-dataset/RegisterDataSetPage.tsx @@ -1,10 +1,84 @@ -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 { 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.initial(getUid(new Date().getTime().toString())); + }); + + 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 LoadingStatus = "idle" | "loading" | "finished" | "error"; + +export const RegisterDataSetPage = component(RegisterDataSetPage_); diff --git a/yarn.lock b/yarn.lock index 243620cd..7aa5f1d4 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.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" @@ -3253,6 +3260,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" @@ -7563,6 +7577,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" @@ -8637,6 +8656,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"