diff --git a/i18n/en.pot b/i18n/en.pot index d0ad94f2..42ec926d 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-01-15T17:33:26.984Z\n" -"PO-Revision-Date: 2025-01-15T17:33:26.984Z\n" +"POT-Creation-Date: 2025-01-20T08:19:05.880Z\n" +"PO-Revision-Date: 2025-01-20T08:19:05.880Z\n" msgid "Low" msgstr "" @@ -150,6 +150,9 @@ msgstr "" msgid "Last updated: " msgstr "" +msgid "Add" +msgstr "" + msgid "Median" msgstr "" @@ -338,6 +341,14 @@ msgid "" "you sure you want to continue?" msgstr "" +msgid "Resource saved successfully" +msgstr "" + +msgid "" +"You have uploaded a new resource with an existing file name. This action " +"will replace the current resource. Are you sure you want to continue?" +msgstr "" + msgid "Add another" msgstr "" @@ -404,9 +415,24 @@ msgstr "" msgid "Are you sure you want to delete this team role?" msgstr "" +msgid "Are you sure you want to delete this resource? This action cannot be undone." +msgstr "" + +msgid "Upload File" +msgstr "" + msgid "Resources" msgstr "" +msgid "No resources created" +msgstr "" + +msgid "You can upload a file to create a new resource" +msgstr "" + +msgid "Contact admin to create resources" +msgstr "" + msgctxt "DATA" msgid "HISTORICAL_CASE" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index fb8c9dda..cd8fc0b5 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-01-14T15:51:01.421Z\n" +"POT-Creation-Date: 2025-01-15T16:05:28.418Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -149,6 +149,9 @@ msgstr "" msgid "Last updated: " msgstr "" +msgid "Add" +msgstr "Añadir" + msgid "Median" msgstr "" @@ -337,6 +340,14 @@ msgid "" "you sure you want to continue?" msgstr "" +msgid "Resource saved successfully" +msgstr "" + +msgid "" +"You have uploaded a new resource with an existing file name. This action " +"will replace the current resource. Are you sure you want to continue?" +msgstr "" + msgid "Add another" msgstr "" @@ -403,16 +414,29 @@ msgstr "" msgid "Are you sure you want to delete this team role?" msgstr "" +msgid "" +"Are you sure you want to delete this resource? This action cannot be undone." +msgstr "" + +msgid "Upload File" +msgstr "" + msgid "Resources" msgstr "" +msgid "No resources created" +msgstr "" + +msgid "You can upload a file to create a new resource" +msgstr "" + +msgid "Contact admin to create resources" +msgstr "" + msgctxt "DATA" msgid "HISTORICAL_CASE" msgstr "" -#~ msgid "Add" -#~ msgstr "Añadir" - #~ msgid "List" #~ msgstr "Listar" diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 7df8417a..40a3cc41 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -74,6 +74,16 @@ import { UserGroupD2Repository } from "./data/repositories/UserGroupD2Repository import { UserGroupRepository } from "./domain/repositories/UserGroupRepository"; import { UserGroupTestRepository } from "./data/repositories/test/UserGroupTestRepository"; import { GetAllAlertsPerformanceOverviewMetricsUseCase } from "./domain/usecases/GetAllAlertsPerformanceOverviewMetricsUseCase"; +import { ResourceRepository } from "./domain/repositories/ResourceRepository"; +import { ResourceD2Repository } from "./data/repositories/ResourceD2Repository"; +import { ResourceTestRepository } from "./data/repositories/test/ResourceTestRepository"; +import { GetResourcesUseCase } from "./domain/usecases/GetResourcesUseCase"; +import { DownloadResourceFileUseCase } from "./domain/usecases/DownloadResourceFileUseCase"; +import { DeleteResourceFileUseCase } from "./domain/usecases/DeleteResourceFileUseCase"; +import { ResourceFileTestRepository } from "./data/repositories/test/ResourceFileTestRepository"; +import { ResourceFileRepository } from "./domain/repositories/ResourceFileRepository"; +import { ResourceFileD2Repository } from "./data/repositories/ResourceFileD2Repository"; +import { GetResourceUserPermissionsUseCase } from "./domain/usecases/GetResourceUserPermissionsUseCase"; export type CompositionRoot = ReturnType; @@ -95,6 +105,8 @@ type Repositories = { configurationsRepository: ConfigurationsRepository; casesFileRepository: CasesFileRepository; userGroupRepository: UserGroupRepository; + resourceRepository: ResourceRepository; + resourceFileRepository: ResourceFileRepository; }; function getCompositionRoot(repositories: Repositories) { @@ -150,6 +162,14 @@ function getCompositionRoot(repositories: Repositories) { charts: { getCases: new GetChartConfigByTypeUseCase(repositories.chartConfigRepository), }, + resources: { + get: new GetResourcesUseCase(repositories.resourceRepository), + downloadResourceFile: new DownloadResourceFileUseCase( + repositories.resourceFileRepository + ), + deleteResourceFile: new DeleteResourceFileUseCase(repositories), + getPermissions: new GetResourceUserPermissionsUseCase(repositories), + }, }; } @@ -173,6 +193,8 @@ export function getWebappCompositionRoot(api: D2Api) { configurationsRepository: new ConfigurationsD2Repository(api), casesFileRepository: new CasesFileD2Repository(api, dataStoreClient), userGroupRepository: new UserGroupD2Repository(api), + resourceRepository: new ResourceD2Repository(api), + resourceFileRepository: new ResourceFileD2Repository(api), }; return getCompositionRoot(repositories); @@ -197,6 +219,8 @@ export function getTestCompositionRoot() { configurationsRepository: new ConfigurationsTestRepository(), casesFileRepository: new CasesFileTestRepository(), userGroupRepository: new UserGroupTestRepository(), + resourceRepository: new ResourceTestRepository(), + resourceFileRepository: new ResourceFileTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/ResourceD2Repository.ts b/src/data/repositories/ResourceD2Repository.ts new file mode 100644 index 00000000..22735bd9 --- /dev/null +++ b/src/data/repositories/ResourceD2Repository.ts @@ -0,0 +1,54 @@ +import { D2Api } from "../../types/d2-api"; +import { ResourceRepository } from "../../domain/repositories/ResourceRepository"; +import { DataStoreClient } from "../DataStoreClient"; +import { isExistingResource, Resource } from "../../domain/entities/resources/Resource"; +import { FutureData } from "../api-futures"; +import { Future } from "../../domain/entities/generic/Future"; +import { Id } from "../../domain/entities/Ref"; + +const RESOURCES_KEY = "resources"; + +export class ResourceD2Repository implements ResourceRepository { + private dataStoreClient: DataStoreClient; + + constructor(private api: D2Api) { + this.dataStoreClient = new DataStoreClient(api); + } + + getAllResources(): FutureData { + return this.dataStoreClient + .getObject(RESOURCES_KEY) + .flatMap(resources => Future.success(resources ?? [])); + } + + saveResource(resource: Resource): FutureData { + if (!resource) return Future.error(new Error("No resource form data found")); + + return this.getAllResources().flatMap(resourcesInDataStore => { + const updatedResources = this.getResourcesToSave(resourcesInDataStore, resource); + + return this.dataStoreClient.saveObject(RESOURCES_KEY, updatedResources); + }); + } + + private getResourcesToSave(resourcesInDataStore: Resource[], resource: Resource) { + const isResourceExisting = isExistingResource(resourcesInDataStore, resource); + + const updatedResources = isResourceExisting + ? resourcesInDataStore.map(resourceInDataStore => + isResourceExisting ? resource : resourceInDataStore + ) + : [...resourcesInDataStore, resource]; + return updatedResources; + } + + deleteResource(fileId: Id): FutureData { + return this.getAllResources().flatMap(resources => { + const updatedResources = resources.filter( + resource => resource.resourceFileId !== fileId + ); + + return this.dataStoreClient.saveObject(RESOURCES_KEY, updatedResources); + }); + } +} diff --git a/src/data/repositories/ResourceFileD2Repository.ts b/src/data/repositories/ResourceFileD2Repository.ts new file mode 100644 index 00000000..bd4eef18 --- /dev/null +++ b/src/data/repositories/ResourceFileD2Repository.ts @@ -0,0 +1,41 @@ +import { D2Api } from "../../types/d2-api"; +import { ResourceFile } from "../../domain/entities/resources/ResourceFile"; +import { apiToFuture, FutureData } from "../api-futures"; +import { Future } from "../../domain/entities/generic/Future"; +import { Id } from "../../domain/entities/Ref"; +import { ResourceFileRepository } from "../../domain/repositories/ResourceFileRepository"; + +export class ResourceFileD2Repository implements ResourceFileRepository { + constructor(private api: D2Api) {} + + uploadFile(file: File): FutureData { + return apiToFuture( + this.api.files.upload({ + name: file.name, + data: file, + }) + ).flatMap(fileResource => Future.success(fileResource.id)); + } + + downloadFile(fileId: Id): FutureData { + if (!fileId) return Future.error(new Error("No file id found")); + + return apiToFuture(this.api.files.get(fileId)) + .map(blob => { + return new File([blob], "file", { type: blob.type }); + }) + .flatMap(file => + Future.success({ + fileId: fileId, + file: file, + }) + ); + } + + deleteResourceFile(fileId: Id): FutureData { + return apiToFuture(this.api.files.delete(fileId)).flatMap(response => { + if (response.httpStatus === "OK") return Future.success(undefined); + else return Future.error(new Error("Error while deleting resource file")); + }); + } +} diff --git a/src/data/repositories/test/ResourceFileTestRepository.ts b/src/data/repositories/test/ResourceFileTestRepository.ts new file mode 100644 index 00000000..c42519bb --- /dev/null +++ b/src/data/repositories/test/ResourceFileTestRepository.ts @@ -0,0 +1,22 @@ +import { Id } from "@eyeseetea/d2-api"; +import { Future } from "../../../domain/entities/generic/Future"; +import { FutureData } from "../../api-futures"; +import { ResourceFileRepository } from "../../../domain/repositories/ResourceFileRepository"; +import { ResourceFile } from "../../../domain/entities/resources/ResourceFile"; + +export class ResourceFileTestRepository implements ResourceFileRepository { + uploadFile(_file: File): FutureData { + return Future.success("test-file-id"); + } + + downloadFile(fileId: Id): FutureData { + return Future.success({ + fileId: fileId, + file: new File(["test"], "test.txt", { type: "text/plain" }), + }); + } + + deleteResourceFile(_fileId: Id): FutureData { + return Future.success(undefined); + } +} diff --git a/src/data/repositories/test/ResourceTestRepository.ts b/src/data/repositories/test/ResourceTestRepository.ts new file mode 100644 index 00000000..bded4adb --- /dev/null +++ b/src/data/repositories/test/ResourceTestRepository.ts @@ -0,0 +1,33 @@ +import { Id } from "@eyeseetea/d2-api"; +import { Future } from "../../../domain/entities/generic/Future"; +import { Resource, ResourceType } from "../../../domain/entities/resources/Resource"; +import { ResourceRepository } from "../../../domain/repositories/ResourceRepository"; +import { FutureData } from "../../api-futures"; + +export class ResourceTestRepository implements ResourceRepository { + getAllResources(): FutureData { + const resources: Resource[] = [ + { + resourceLabel: "Incident Action Plan", + resourceType: ResourceType.TEMPLATE, + resourceFileId: "123", + }, + { + resourceLabel: "Excel line list", + resourceType: ResourceType.RESPONSE_DOCUMENT, + resourceFolder: "Case line lists", + resourceFileId: "456", + }, + ]; + + return Future.success(resources); + } + + saveResource(_resource: Resource): FutureData { + return Future.success(undefined); + } + + deleteResource(_fileId: Id): FutureData { + return Future.success(undefined); + } +} diff --git a/src/domain/entities/ConfigurableForm.ts b/src/domain/entities/ConfigurableForm.ts index a49733f8..636224b4 100644 --- a/src/domain/entities/ConfigurableForm.ts +++ b/src/domain/entities/ConfigurableForm.ts @@ -11,6 +11,7 @@ import { ActionPlanAttrs } from "./incident-action-plan/ActionPlan"; import { ResponseAction } from "./incident-action-plan/ResponseAction"; import { IncidentManagementTeam } from "./incident-management-team/IncidentManagementTeam"; import { Role } from "./incident-management-team/Role"; +import { Resource } from "./resources/Resource"; import { OrgUnit } from "./OrgUnit"; export type DiseaseOutbreakEventOptions = { @@ -62,6 +63,11 @@ export type IncidentResponseActionOptions = { verification: Option[]; }; +export type ResourceOptions = { + resourceType: Option[]; + resourceFolder: Option[]; +}; + export type FormLables = { errors: Record; }; @@ -124,6 +130,13 @@ export type SingleResponseActionFormData = BaseFormData & { options: IncidentResponseActionOptions; }; +export type ResourceFormData = BaseFormData & { + type: "resource"; + entity: Maybe; + uploadedResourceFile: Maybe; + options: ResourceOptions; +}; + export type IncidentManagementTeamRoleOptions = { roles: Role[]; teamMembers: TeamMember[]; @@ -147,4 +160,5 @@ export type ConfigurableForm = | ActionPlanFormData | ResponseActionFormData | SingleResponseActionFormData - | IncidentManagementTeamMemberFormData; + | IncidentManagementTeamMemberFormData + | ResourceFormData; diff --git a/src/domain/entities/resources/Resource.ts b/src/domain/entities/resources/Resource.ts new file mode 100644 index 00000000..8a39dc9c --- /dev/null +++ b/src/domain/entities/resources/Resource.ts @@ -0,0 +1,50 @@ +import { Maybe } from "../../../utils/ts-utils"; +import { Id } from "../Ref"; + +export enum ResourceType { + TEMPLATE = "template", + RESPONSE_DOCUMENT = "response-document", +} + +export type ResourceBase = { + resourceType: ResourceType; + resourceLabel: string; + resourceFileId: Maybe; +}; + +export type ResponseDocument = ResourceBase & { + resourceType: ResourceType.RESPONSE_DOCUMENT; + resourceFolder: string; +}; + +export type Template = ResourceBase & { + resourceType: ResourceType.TEMPLATE; +}; + +export type Resource = ResponseDocument | Template; + +export function isResponseDocument(resource: Resource): resource is ResponseDocument { + return resource.resourceType === ResourceType.RESPONSE_DOCUMENT; +} + +export function isTemplate(resource: Resource): resource is Template { + return resource.resourceType === ResourceType.TEMPLATE; +} + +export function isExistingResource(resources: Resource[], resource: Resource): boolean { + return resources.some(existingResource => { + const isSameType = existingResource.resourceType === resource.resourceType; + const isResponseDoc = isResponseDocument(resource); + const isExistingResponseDoc = isResponseDocument(existingResource); + const isTemplateResource = isTemplate(resource); + const isSameLabel = existingResource.resourceLabel === resource.resourceLabel; + + if (isResponseDoc && isExistingResponseDoc) { + return resource.resourceFolder === existingResource.resourceFolder && isSameLabel; + } else if (isTemplateResource) { + return isSameType && isSameLabel; + } else { + return false; + } + }); +} diff --git a/src/domain/entities/resources/ResourceFile.ts b/src/domain/entities/resources/ResourceFile.ts new file mode 100644 index 00000000..9748986e --- /dev/null +++ b/src/domain/entities/resources/ResourceFile.ts @@ -0,0 +1,6 @@ +import { Id } from "../Ref"; + +export type ResourceFile = { + fileId: Id; + file: File; +}; diff --git a/src/domain/entities/resources/ResourcePermissions.ts b/src/domain/entities/resources/ResourcePermissions.ts new file mode 100644 index 00000000..1db729c0 --- /dev/null +++ b/src/domain/entities/resources/ResourcePermissions.ts @@ -0,0 +1,9 @@ +export type ResourcePermissions = { + isAdmin: boolean; + isDataCapture: boolean; + isAccess: boolean; +}; + +export const RTSL_ZEBRA_ADMIN_RESOURCES = "RTSL_ZEBRA_ADMIN_RESOURCES"; +export const RTSL_ZEBRA_DATA_CAPTURE_RESOURCES = "RTSL_ZEBRA_DATA_CAPTURE_RESOURCES"; +export const RTSL_ZEBRA_ACCESS_RESOURCES = "RTSL_ZEBRA_ACCESS_RESOURCES"; diff --git a/src/domain/repositories/ResourceFileRepository.ts b/src/domain/repositories/ResourceFileRepository.ts new file mode 100644 index 00000000..0d9e20b1 --- /dev/null +++ b/src/domain/repositories/ResourceFileRepository.ts @@ -0,0 +1,9 @@ +import { FutureData } from "../../data/api-futures"; +import { Id } from "../entities/Ref"; +import { ResourceFile } from "../entities/resources/ResourceFile"; + +export interface ResourceFileRepository { + uploadFile(file: File): FutureData; + downloadFile(fileId: Id): FutureData; + deleteResourceFile(fileId: Id): FutureData; +} diff --git a/src/domain/repositories/ResourceRepository.ts b/src/domain/repositories/ResourceRepository.ts new file mode 100644 index 00000000..a64681ad --- /dev/null +++ b/src/domain/repositories/ResourceRepository.ts @@ -0,0 +1,9 @@ +import { FutureData } from "../../data/api-futures"; +import { Id } from "../entities/Ref"; +import { Resource } from "../entities/resources/Resource"; + +export interface ResourceRepository { + getAllResources(): FutureData; + saveResource(resource: Resource): FutureData; + deleteResource(fileId: Id): FutureData; +} diff --git a/src/domain/usecases/DeleteResourceFileUseCase.ts b/src/domain/usecases/DeleteResourceFileUseCase.ts new file mode 100644 index 00000000..24344d8d --- /dev/null +++ b/src/domain/usecases/DeleteResourceFileUseCase.ts @@ -0,0 +1,18 @@ +import { FutureData } from "../../data/api-futures"; +import { ResourceFileRepository } from "../repositories/ResourceFileRepository"; +import { ResourceRepository } from "../repositories/ResourceRepository"; + +export class DeleteResourceFileUseCase { + constructor( + private options: { + resourceRepository: ResourceRepository; + resourceFileRepository: ResourceFileRepository; + } + ) {} + + public execute(fileId: string): FutureData { + return this.options.resourceFileRepository + .deleteResourceFile(fileId) + .flatMap(() => this.options.resourceRepository.deleteResource(fileId)); + } +} diff --git a/src/domain/usecases/DownloadResourceFileUseCase.ts b/src/domain/usecases/DownloadResourceFileUseCase.ts new file mode 100644 index 00000000..b17e112a --- /dev/null +++ b/src/domain/usecases/DownloadResourceFileUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../data/api-futures"; +import { ResourceFile } from "../entities/resources/ResourceFile"; +import { ResourceFileRepository } from "../repositories/ResourceFileRepository"; + +export class DownloadResourceFileUseCase { + constructor(private resourceFileRepository: ResourceFileRepository) {} + + public execute(fileId: string): FutureData { + return this.resourceFileRepository.downloadFile(fileId); + } +} diff --git a/src/domain/usecases/GetConfigurableFormUseCase.ts b/src/domain/usecases/GetConfigurableFormUseCase.ts index 3de28bd3..c829c680 100644 --- a/src/domain/usecases/GetConfigurableFormUseCase.ts +++ b/src/domain/usecases/GetConfigurableFormUseCase.ts @@ -13,6 +13,7 @@ import { CasesFileRepository } from "../repositories/CasesFileRepository"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; import { IncidentActionRepository } from "../repositories/IncidentActionRepository"; import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; +import { ResourceRepository } from "../repositories/ResourceRepository"; import { RoleRepository } from "../repositories/RoleRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; import { getDiseaseOutbreakConfigurableForm } from "./utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm"; @@ -22,6 +23,7 @@ import { getSingleResponseActionConfigurableForm, } from "./utils/incident-action/GetResponseActionConfigurableForm"; import { getIncidentManagementTeamWithOptions } from "./utils/incident-management-team/GetIncidentManagementTeamWithOptions"; +import { getResourceConfigurableForm } from "./utils/resources/GetResourceConfigurableForm"; import { getRiskAssessmentGradingConfigurableForm } from "./utils/risk-assessment/GetGradingConfigurableForm"; import { getRiskAssessmentQuestionnaireConfigurableForm } from "./utils/risk-assessment/GetQuestionnaireConfigurableForm"; import { getRiskAssessmentSummaryConfigurableForm } from "./utils/risk-assessment/GetSummaryConfigurableForm"; @@ -35,6 +37,7 @@ export class GetConfigurableFormUseCase { incidentActionRepository: IncidentActionRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; casesFileRepository: CasesFileRepository; + resourceRepository: ResourceRepository; } ) {} @@ -146,6 +149,10 @@ export class GetConfigurableFormUseCase { }, configurations ); + case "resource": + return getResourceConfigurableForm({ + resourceRepository: this.options.resourceRepository, + }); default: return Future.error(new Error("Form type not supported")); } diff --git a/src/domain/usecases/GetResourceUserPermissionsUseCase.ts b/src/domain/usecases/GetResourceUserPermissionsUseCase.ts new file mode 100644 index 00000000..f634af2b --- /dev/null +++ b/src/domain/usecases/GetResourceUserPermissionsUseCase.ts @@ -0,0 +1,38 @@ +import { FutureData } from "../../data/api-futures"; +import { Future } from "../entities/generic/Future"; +import { + ResourcePermissions, + RTSL_ZEBRA_ACCESS_RESOURCES, + RTSL_ZEBRA_ADMIN_RESOURCES, + RTSL_ZEBRA_DATA_CAPTURE_RESOURCES, +} from "../entities/resources/ResourcePermissions"; +import { User } from "../entities/User"; +import { UserGroupRepository } from "../repositories/UserGroupRepository"; + +export class GetResourceUserPermissionsUseCase { + constructor( + private options: { + userGroupRepository: UserGroupRepository; + } + ) {} + + public execute(currentUser: User): FutureData { + return Future.joinObj({ + adminUserGroup: this.options.userGroupRepository.getUserGroupByCode( + RTSL_ZEBRA_ADMIN_RESOURCES + ), + dataCaptureUserGroup: this.options.userGroupRepository.getUserGroupByCode( + RTSL_ZEBRA_DATA_CAPTURE_RESOURCES + ), + accessUserGroup: this.options.userGroupRepository.getUserGroupByCode( + RTSL_ZEBRA_ACCESS_RESOURCES + ), + }).map(({ adminUserGroup, dataCaptureUserGroup, accessUserGroup }) => { + const isAdmin = currentUser.belongToUserGroup(adminUserGroup.id); + const isDataCapture = currentUser.belongToUserGroup(dataCaptureUserGroup.id); + const isAccess = currentUser.belongToUserGroup(accessUserGroup.id); + + return { isAdmin, isDataCapture, isAccess }; + }); + } +} diff --git a/src/domain/usecases/GetResourcesUseCase.ts b/src/domain/usecases/GetResourcesUseCase.ts new file mode 100644 index 00000000..384cb3c4 --- /dev/null +++ b/src/domain/usecases/GetResourcesUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../data/api-futures"; +import { Resource } from "../entities/resources/Resource"; +import { ResourceRepository } from "../repositories/ResourceRepository"; + +export class GetResourcesUseCase { + constructor(private resourceRepository: ResourceRepository) {} + + public execute(): FutureData { + return this.resourceRepository.getAllResources(); + } +} diff --git a/src/domain/usecases/SaveEntityUseCase.ts b/src/domain/usecases/SaveEntityUseCase.ts index 42987a2d..5ed6cc77 100644 --- a/src/domain/usecases/SaveEntityUseCase.ts +++ b/src/domain/usecases/SaveEntityUseCase.ts @@ -17,6 +17,8 @@ import { RoleRepository } from "../repositories/RoleRepository"; import { Configurations } from "../entities/AppConfigurations"; import moment from "moment"; import { CasesFileRepository } from "../repositories/CasesFileRepository"; +import { ResourceRepository } from "../repositories/ResourceRepository"; +import { ResourceFileRepository } from "../repositories/ResourceFileRepository"; export class SaveEntityUseCase { constructor( @@ -28,6 +30,8 @@ export class SaveEntityUseCase { teamMemberRepository: TeamMemberRepository; roleRepository: RoleRepository; casesFileRepository: CasesFileRepository; + resourceRepository: ResourceRepository; + resourceFileRepository: ResourceFileRepository; } ) {} @@ -148,6 +152,25 @@ export class SaveEntityUseCase { ); } } + case "resource": { + const { uploadedResourceFile } = formData; + if (!uploadedResourceFile) return Future.error(new Error("No file uploaded")); + + return this.options.resourceFileRepository + .uploadFile(uploadedResourceFile) + .flatMap(resourceFileId => { + const { entity } = formData; + + if (!entity) return Future.error(new Error("No resource found")); + + const resource = { + ...entity, + resourceFileId: resourceFileId, + }; + + return this.options.resourceRepository.saveResource(resource); + }); + } default: return Future.error(new Error("Form type not supported")); } diff --git a/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts b/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts new file mode 100644 index 00000000..6a8b89c5 --- /dev/null +++ b/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts @@ -0,0 +1,79 @@ +import { FutureData } from "../../../../data/api-futures"; +import { FormLables, ResourceFormData } from "../../../entities/ConfigurableForm"; +import _c from "../../../entities/generic/Collection"; +import { Future } from "../../../entities/generic/Future"; +import { Option } from "../../../entities/Ref"; +import { isResponseDocument, Resource, ResourceType } from "../../../entities/resources/Resource"; +import { Rule } from "../../../entities/Rule"; +import { ResourceRepository } from "../../../repositories/ResourceRepository"; + +const resourceTypeOptions: Option[] = [ + { + id: ResourceType.RESPONSE_DOCUMENT, + name: "Response document", + }, + { + id: ResourceType.TEMPLATE, + name: "Template", + }, +]; + +export function getResourceConfigurableForm(props: { + resourceRepository: ResourceRepository; +}): FutureData { + const { resourceRepository } = props; + const { rules, labels } = getResourceLabelsRules(); + + return resourceRepository.getAllResources().flatMap(resources => { + const resourceFolderOptions = getResourceFolderOptions(resources); + + const resourceFormData: ResourceFormData = { + type: "resource", + entity: undefined, + options: { + resourceType: resourceTypeOptions, + resourceFolder: resourceFolderOptions, + }, + uploadedResourceFile: undefined, + labels: labels, + rules: rules, + }; + + return Future.success(resourceFormData); + }); +} + +function getResourceFolderOptions(resources: Resource[]): Option[] { + const resourceFolders = _c(resources) + .map(resource => (isResponseDocument(resource) ? resource.resourceFolder : undefined)) + .compact() + .uniq() + .value(); + + const resourceFolderOptions: Option[] = resourceFolders.map(resourceFolder => ({ + id: resourceFolder, + name: resourceFolder, + })); + return resourceFolderOptions; +} + +function getResourceLabelsRules(): { rules: Rule[]; labels: FormLables } { + return { + // TODO: Get labels from Datastore used in mapEntityToInitialFormState to create initial form state + labels: { + errors: { + field_is_required: "This field is required", + field_is_required_na: "This field is required when not applicable", + }, + }, + // TODO: Get rules from Datastore used in applyRulesInFormState + rules: [ + { + type: "toggleSectionsVisibilityByFieldValue", + fieldId: "resourceType", + fieldValue: ResourceType.RESPONSE_DOCUMENT, + sectionIds: ["resourceFolder_section"], + }, + ], + }; +} diff --git a/src/webapp/components/form/FieldWidget.tsx b/src/webapp/components/form/FieldWidget.tsx index a1e56779..41a6f26c 100644 --- a/src/webapp/components/form/FieldWidget.tsx +++ b/src/webapp/components/form/FieldWidget.tsx @@ -58,6 +58,7 @@ export const FieldWidget: React.FC = React.memo((props): JSX.E placeholder={field.placeholder} selected={field.value} options={field.options} + addNewOption={field.addNewOption} /> ); } diff --git a/src/webapp/components/form/FormFieldsState.ts b/src/webapp/components/form/FormFieldsState.ts index 6e65fe89..f3085974 100644 --- a/src/webapp/components/form/FormFieldsState.ts +++ b/src/webapp/components/form/FormFieldsState.ts @@ -49,12 +49,14 @@ export type FormMultipleOptionsFieldState = FormFieldStateBase & { type: "select"; options: Option[]; multiple: true; + addNewOption?: boolean; }; export type FormOptionsFieldState = FormFieldStateBase & { type: "select" | "radio"; options: Option[]; multiple: false; + addNewOption?: boolean; }; export type FormDateFieldState = FormFieldStateBase & { diff --git a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx new file mode 100644 index 00000000..782769fe --- /dev/null +++ b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx @@ -0,0 +1,53 @@ +import { TreeItem as TreeItemMUI } from "@material-ui/lab"; +import React from "react"; +import styled from "styled-components"; +import { ResourceLabel } from "../../pages/resources/ResourceLabel"; +import { ResponseDocumentsByFolder } from "../../pages/resources/useResources"; + +type ResponseDocumentHierarchyItemProps = { + isDeleting: boolean; + responseDocument: ResponseDocumentsByFolder; + userCanDelete: boolean; + userCanDownload: boolean; + onDelete: () => void; +}; + +export const ResponseDocumentHierarchyItem: React.FC = + React.memo(props => { + const { isDeleting, responseDocument, userCanDelete, userCanDownload, onDelete } = props; + + return ( + + {responseDocument.resources.map(resource => { + return ( + + } + /> + ); + })} + + ); + }); + +const StyledTreeItemMUI = styled(TreeItemMUI)` + .MuiTreeItem-label { + margin-left: 12px; + } +`; diff --git a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyView.tsx b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyView.tsx new file mode 100644 index 00000000..52011c4c --- /dev/null +++ b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyView.tsx @@ -0,0 +1,53 @@ +import { TreeView as TreeViewMUI } from "@material-ui/lab"; +import React from "react"; +import { ArrowDropDown, ArrowRight, FolderOpenOutlined, FolderOutlined } from "@material-ui/icons"; +import { ResponseDocumentHierarchyItem } from "./ResponseDocumentHierarchyItem"; +import { ResponseDocumentsByFolder } from "../../pages/resources/useResources"; + +type ResponseDocumentHierarchyViewProps = { + isDeleting: boolean; + responseDocuments: ResponseDocumentsByFolder[]; + userCanDelete: boolean; + userCanDownload: boolean; + onDelete: () => void; +}; + +export const ResponseDocumentHierarchyView: React.FC = + React.memo(props => { + const { isDeleting, responseDocuments, userCanDelete, userCanDownload, onDelete } = props; + + const defaultCollapseIcon = ( + <> + + + + ); + + const defaultExpandIcon = ( + <> + + + + ); + + return ( + + {responseDocuments.map(responseDocument => { + return ( + + ); + })} + + ); + }); diff --git a/src/webapp/components/selector/AddNewOption.tsx b/src/webapp/components/selector/AddNewOption.tsx new file mode 100644 index 00000000..eb319847 --- /dev/null +++ b/src/webapp/components/selector/AddNewOption.tsx @@ -0,0 +1,53 @@ +import { TextField } from "@material-ui/core"; +import i18n from "../../../utils/i18n"; +import React, { useCallback } from "react"; +import styled from "styled-components"; +import { Button } from "../button/Button"; + +type AddNewOptionProps = { + value: string; + onAddNewOption: () => void; + onChangeValue: (value: string) => void; +}; + +export const AddNewOption: React.FC = React.memo( + ({ value, onAddNewOption, onChangeValue }) => { + const handleKeydown = useCallback((event: React.KeyboardEvent) => { + event.stopPropagation(); + }, []); + + return ( + <> + onChangeValue(e.target.value)} + value={value} + onKeyDown={handleKeydown} + onClickCapture={e => { + e.stopPropagation(); + }} + /> + + + + ); + } +); + +const StyledTextField = styled(TextField)<{ error?: boolean }>` + height: 40px; + .MuiOutlinedInput-root { + height: 40px; + } + .MuiFormHelperText-root { + color: ${props => + props.error ? props.theme.palette.common.red700 : props.theme.palette.common.grey700}; + } + .MuiInputBase-input { + padding-inline: 12px; + padding-block: 10px; + } +`; diff --git a/src/webapp/components/selector/Selector.tsx b/src/webapp/components/selector/Selector.tsx index 722bde5c..a6d2078f 100644 --- a/src/webapp/components/selector/Selector.tsx +++ b/src/webapp/components/selector/Selector.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useState } from "react"; import styled from "styled-components"; import { Select, InputLabel, MenuItem, FormHelperText } from "@material-ui/core"; import { IconChevronDown24, IconCross16 } from "@dhis2/ui"; @@ -6,6 +6,7 @@ import { getLabelFromValue } from "./utils/selectorHelper"; import { Option } from "../utils/option"; import { SearchInput } from "../search-input/SearchInput"; import { IconButton } from "../icon-button/IconButton"; +import { AddNewOption } from "./AddNewOption"; type SelectorProps = { id: string; @@ -21,6 +22,7 @@ type SelectorProps = { required?: boolean; disableSearch?: boolean; allowClear?: boolean; + addNewOption?: boolean; }; export function Selector({ @@ -29,7 +31,7 @@ export function Selector({ placeholder = "", selected, onChange, - options, + options: initialOptions, disabled = false, helperText = "", errorText = "", @@ -37,8 +39,11 @@ export function Selector({ required = false, disableSearch = false, allowClear = false, + addNewOption = false, }: SelectorProps): JSX.Element { const [searchTerm, setSearchTerm] = React.useState(""); + const [newOption, setNewOption] = useState(""); + const [options, setOptions] = useState[]>(initialOptions); const filteredOptions = React.useMemo( () => @@ -75,6 +80,17 @@ export function Selector({ [allowClear, onChange] ); + const handleAddNewOption = useCallback(() => { + const newSelectorOption = { + value: newOption as Value, + label: newOption, + }; + + setOptions(prevState => [...prevState, newSelectorOption]); + setNewOption(""); + onChange(newOption as Value); + }, [newOption, onChange]); + return ( {label && ( @@ -126,6 +142,17 @@ export function Selector({ )} + + {addNewOption && ( + + + + )} + {filteredOptions.map(option => ( {option.label} @@ -197,3 +224,14 @@ const StyledIconButton = styled(IconButton)` margin-inline-start: 4px; background-color: ${props => props.theme.palette.common.grey200}; `; + +const AddNewOptionContainer = styled.div` + display: flex; + justify-content: space-between; + width: auto; + padding-inline: 16px; + padding-block-end: 12px; + > div { + width: calc(50% - 10px); + } +`; diff --git a/src/webapp/hooks/useRoutes.ts b/src/webapp/hooks/useRoutes.ts index f78cecf6..07f2c37d 100644 --- a/src/webapp/hooks/useRoutes.ts +++ b/src/webapp/hooks/useRoutes.ts @@ -24,6 +24,7 @@ const formTypes = [ "incident-response-actions", "incident-response-action", "incident-management-team-member-assignment", + "resource", ] as const satisfies FormType[]; const formType = `:formType(${join(formTypes, "|")})` as const; diff --git a/src/webapp/pages/form-page/FormPage.tsx b/src/webapp/pages/form-page/FormPage.tsx index 51bef217..e6c7d488 100644 --- a/src/webapp/pages/form-page/FormPage.tsx +++ b/src/webapp/pages/form-page/FormPage.tsx @@ -16,7 +16,8 @@ export type FormType = | "incident-action-plan" | "incident-response-actions" | "incident-response-action" - | "incident-management-team-member-assignment"; + | "incident-management-team-member-assignment" + | "resource"; export const FormPage: React.FC = React.memo(() => { const { formType, id } = useParams<{ diff --git a/src/webapp/pages/form-page/mapEntityToFormState.ts b/src/webapp/pages/form-page/mapEntityToFormState.ts index ba181d75..b33830e7 100644 --- a/src/webapp/pages/form-page/mapEntityToFormState.ts +++ b/src/webapp/pages/form-page/mapEntityToFormState.ts @@ -5,6 +5,7 @@ import { } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { Option } from "../../../domain/entities/Ref"; +import { ResourcePermissions } from "../../../domain/entities/resources/ResourcePermissions"; import { FormState } from "../../components/form/FormState"; import { User } from "../../components/user-selector/UserSelector"; import { Option as PresentationOption } from "../../components/utils/option"; @@ -15,6 +16,7 @@ import { mapSingleIncidentResponseActionToInitialFormState, } from "./incident-action/mapIncidentActionToInitialFormState"; import { mapIncidentManagementTeamMemberToInitialFormState } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; +import { mapResourceToInitialFormState } from "./resources/mapResourceToInitialFormState"; import { mapRiskAssessmentQuestionnaireToInitialFormState, mapRiskAssessmentSummaryToInitialFormState, @@ -26,8 +28,15 @@ export function mapEntityToFormState(options: { editMode?: boolean; existingEventTrackerTypes?: (DiseaseNames | HazardNames)[]; isIncidentManager?: boolean; + resourcePermissions: ResourcePermissions; }): FormState { - const { configurableForm, editMode, existingEventTrackerTypes, isIncidentManager } = options; + const { + configurableForm, + editMode, + existingEventTrackerTypes, + isIncidentManager, + resourcePermissions, + } = options; switch (configurableForm.type) { case "disease-outbreak-event": @@ -57,6 +66,8 @@ export function mapEntityToFormState(options: { ); case "incident-management-team-member-assignment": return mapIncidentManagementTeamMemberToInitialFormState(configurableForm); + case "resource": + return mapResourceToInitialFormState(configurableForm, resourcePermissions); } } diff --git a/src/webapp/pages/form-page/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/mapFormStateToEntityData.ts index c6194feb..d8dff913 100644 --- a/src/webapp/pages/form-page/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/mapFormStateToEntityData.ts @@ -18,6 +18,7 @@ import { RiskAssessmentSummaryFormData, ResponseActionFormData, SingleResponseActionFormData, + ResourceFormData, } from "../../../domain/entities/ConfigurableForm"; import { RiskAssessmentGrading } from "../../../domain/entities/risk-assessment/RiskAssessmentGrading"; import { @@ -46,6 +47,7 @@ import { TeamMember } from "../../../domain/entities/incident-management-team/Te import { TEAM_ROLE_FIELD_ID } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; import { incidentManagementTeamBuilderCodesWithoutRoles } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { mapFormStateToDiseaseOutbreakEvent } from "./disease-outbreak-event/mapFormStateToDiseaseOutbreakEvent"; +import { Resource, ResourceType } from "../../../domain/entities/resources/Resource"; export function mapFormStateToEntityData( formState: FormState, @@ -143,6 +145,20 @@ export function mapFormStateToEntityData( }; return incidentManagementTeamMemberForm; } + case "resource": { + const resource = mapFormStateToResource(formState); + + const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); + const uploadedResourceFileValue = getFileFieldValue("resourceFile", allFields); + + const resourceForm: ResourceFormData = { + ...formData, + entity: resource, + uploadedResourceFile: uploadedResourceFileValue, + }; + + return resourceForm; + } default: return formData; @@ -624,6 +640,24 @@ function mapFormStateToIncidentManagementTeamMember( }); } +function mapFormStateToResource(formState: FormState): Resource { + const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); + + const resourceType = getStringFieldValue("resourceType", allFields) as ResourceType; + const resourceLabel = getStringFieldValue("resourceLabel", allFields); + const resourceFolder = getStringFieldValue("resourceFolder", allFields); + const uploadedResourceFileId = getFieldFileIdById("resourceFile", allFields); + + const resource: Resource = { + resourceType: resourceType, + resourceLabel: resourceLabel, + resourceFolder: resourceFolder, + resourceFileId: uploadedResourceFileId, + }; + + return resource; +} + function extractIndex(input: string): number | undefined { const parts = input.split("_"); const lastPart = parts[parts.length - 1]; diff --git a/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts b/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts new file mode 100644 index 00000000..7a418c7c --- /dev/null +++ b/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts @@ -0,0 +1,126 @@ +import { ResourceFormData } from "../../../../domain/entities/ConfigurableForm"; +import { ResourceType } from "../../../../domain/entities/resources/Resource"; +import { getFieldIdFromIdsDictionary } from "../../../components/form/FormFieldsState"; +import { Option as UIOption } from "../../../components/utils/option"; +import { FormState } from "../../../components/form/FormState"; +import { mapToPresentationOptions } from "../mapEntityToFormState"; +import { ResourcePermissions } from "../../../../domain/entities/resources/ResourcePermissions"; + +export const resourceFieldIds = { + resourceType: "resourceType", + resourceLabel: "resourceLabel", + resourceFolder: "resourceFolder", + resourceFile: "resourceFile", +}; + +export function mapResourceToInitialFormState( + formData: ResourceFormData, + resourcePermissions: ResourcePermissions +): FormState { + const { entity: resource, uploadedResourceFile, options } = formData; + const isResourceDocument = resource?.resourceType === ResourceType.RESPONSE_DOCUMENT; + + const { resourceType, resourceFolder } = options; + const resourceTypeOptions: UIOption[] = mapToPresentationOptions(resourceType); + const resourceFolderOptions: UIOption[] = mapToPresentationOptions(resourceFolder); + + const fromIdsDictionary = (key: keyof typeof resourceFieldIds) => + getFieldIdFromIdsDictionary(key, resourceFieldIds); + const { isAdmin } = resourcePermissions; + + return { + id: "", + title: "Resources", + subtitle: "", + saveButtonLabel: "Save", + isValid: false, + sections: [ + { + title: "Resource name", + id: `${fromIdsDictionary("resourceLabel")}_section`, + isVisible: true, + required: true, + fields: [ + { + id: fromIdsDictionary("resourceLabel"), + isVisible: true, + errors: [], + type: "text", + value: resource?.resourceLabel || "", + required: true, + }, + ], + }, + { + title: "Resource type", + id: `${fromIdsDictionary("resourceType")}_section`, + isVisible: true, + required: true, + fields: [ + { + id: fromIdsDictionary("resourceType"), + placeholder: "Select a resource type", + isVisible: true, + errors: [], + type: "select", + multiple: false, + options: resourceTypeOptions, + value: resource?.resourceType || "", + required: true, + }, + ], + }, + + { + title: "Resource folder", + id: `${fromIdsDictionary("resourceFolder")}_section`, + isVisible: isResourceDocument, + required: true, + fields: + resourceFolderOptions.length === 0 + ? [ + { + id: fromIdsDictionary("resourceFolder"), + isVisible: isResourceDocument, + errors: [], + type: "text", + value: isResourceDocument ? resource.resourceFolder : "", + required: true, + }, + ] + : [ + { + id: fromIdsDictionary("resourceFolder"), + isVisible: isResourceDocument, + errors: [], + type: "select", + options: resourceFolderOptions, + multiple: false, + addNewOption: isAdmin, + value: isResourceDocument ? resource.resourceFolder : "", + required: true, + }, + ], + }, + { + title: "Resource file", + id: `${fromIdsDictionary("resourceFile")}_section`, + isVisible: true, + required: true, + fields: [ + { + id: fromIdsDictionary("resourceFile"), + isVisible: true, + errors: [], + type: "file", + value: uploadedResourceFile || undefined, + fileId: resource?.resourceFileId || undefined, + required: true, + data: undefined, + fileTemplate: undefined, + }, + ], + }, + ], + }; +} diff --git a/src/webapp/pages/form-page/resources/useResourceForm.ts b/src/webapp/pages/form-page/resources/useResourceForm.ts new file mode 100644 index 00000000..46586ebe --- /dev/null +++ b/src/webapp/pages/form-page/resources/useResourceForm.ts @@ -0,0 +1,91 @@ +import { useCallback } from "react"; +import i18n from "../../../../utils/i18n"; +import { ModalData } from "../../../components/form/Form"; +import { useAppContext } from "../../../contexts/app-context"; +import { isExistingResource } from "../../../../domain/entities/resources/Resource"; +import { ConfigurableForm } from "../../../../domain/entities/ConfigurableForm"; +import { GlobalMessage } from "../useForm"; +import { RouteName, useRoutes } from "../../../hooks/useRoutes"; + +export function useResourceForm(options: { + editMode: boolean; + setIsLoading: (isLoading: boolean) => void; + setOpenModal: (openModal: boolean) => void; + setModalData: (modalData: ModalData) => void; + setGlobalMessage: (message: GlobalMessage) => void; +}) { + const { goTo } = useRoutes(); + const { compositionRoot, configurations } = useAppContext(); + + const { editMode, setOpenModal, setModalData, setGlobalMessage, setIsLoading } = options; + + const onSaveResource = useCallback( + (formData: ConfigurableForm) => { + if (formData.type === "resource") { + setIsLoading(true); + compositionRoot.save.execute(formData, configurations, editMode).run( + () => { + setIsLoading(false); + setGlobalMessage({ + text: i18n.t(`Resource saved successfully`), + type: "success", + }); + goTo(RouteName.RESOURCES); + }, + error => { + setGlobalMessage({ + text: i18n.t(`Error saving resource: ${error.message}`), + type: "error", + }); + } + ); + } + }, + [compositionRoot.save, configurations, editMode, goTo, setGlobalMessage, setIsLoading] + ); + + const onSaveResourceForm = useCallback( + (formData: ConfigurableForm) => { + compositionRoot.resources.get.execute().run( + resources => { + if (!formData.entity || formData.type !== "resource") return; + + const isResourceExisting = isExistingResource(resources, formData.entity); + if (isResourceExisting) { + setOpenModal(true); + setModalData({ + title: i18n.t("Warning"), + content: i18n.t( + "You have uploaded a new resource with an existing file name. This action will replace the current resource. Are you sure you want to continue?" + ), + cancelLabel: i18n.t("Cancel"), + confirmLabel: i18n.t("Save"), + onConfirm: () => { + onSaveResource(formData); + }, + }); + } else { + onSaveResource(formData); + } + }, + error => { + setGlobalMessage({ + text: i18n.t(`Error fetching resources: ${error.message}`), + type: "error", + }); + } + ); + }, + [ + compositionRoot.resources.get, + onSaveResource, + setGlobalMessage, + setModalData, + setOpenModal, + ] + ); + + return { + onSaveResourceForm: onSaveResourceForm, + }; +} diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index cca5b93c..39cfaa0d 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -29,6 +29,8 @@ import { useIncidentActionPlan } from "../incident-action-plan/useIncidentAction import { RiskAssessmentQuestionnaire } from "../../../domain/entities/risk-assessment/RiskAssessmentQuestionnaire"; import { ModalData } from "../../components/form/Form"; import { useDiseaseOutbreakEventForm } from "./disease-outbreak-event/useDiseaseOutbreakEventForm"; +import { useResourceForm } from "./resources/useResourceForm"; +import { useResources } from "../resources/useResources"; export type GlobalMessage = { text: string; @@ -96,6 +98,15 @@ export function useForm(formType: FormType, id?: Id): State { setModalData, }); + const { onSaveResourceForm } = useResourceForm({ + editMode: !!id, + setOpenModal, + setModalData, + setGlobalMessage, + setIsLoading, + }); + const { userPermissions: resourcePermissions } = useResources(); + const allDataPerformanceEvents = dataNationalPerformanceOverview?.map( event => event.hazardType || event.suspectedDisease ); @@ -125,6 +136,7 @@ export function useForm(formType: FormType, id?: Id): State { editMode: !!id, existingEventTrackerTypes: existingEventTrackerTypes, isIncidentManager: isIncidentManager, + resourcePermissions: resourcePermissions, }), }); setEntityData(formData); @@ -151,6 +163,7 @@ export function useForm(formType: FormType, id?: Id): State { goTo, isIncidentManager, existingEventTrackerTypes, + resourcePermissions, ]); const handleAddNew = useCallback(() => { @@ -364,6 +377,8 @@ export function useForm(formType: FormType, id?: Id): State { formData.type === "disease-outbreak-event-case-data" ) { onSaveDiseaseOutbreakEvent(formData); + } else if (formData.type === "resource") { + onSaveResourceForm(formData); } else { setIsLoading(true); compositionRoot.save.execute(formData, configurations, !!id, formSectionsToDelete).run( @@ -458,6 +473,7 @@ export function useForm(formType: FormType, id?: Id): State { goTo, id, onSaveDiseaseOutbreakEvent, + onSaveResourceForm, ]); const onCancelForm = useCallback(() => { @@ -475,6 +491,9 @@ export function useForm(formType: FormType, id?: Id): State { id: currentEventTracker.id, }); break; + case "resource": + goTo(RouteName.RESOURCES); + break; default: goTo(RouteName.EVENT_TRACKER, { id: currentEventTracker.id, diff --git a/src/webapp/pages/form-page/utils/updateAndValidateFormState.ts b/src/webapp/pages/form-page/utils/updateAndValidateFormState.ts index 8cc356c1..fb63e18d 100644 --- a/src/webapp/pages/form-page/utils/updateAndValidateFormState.ts +++ b/src/webapp/pages/form-page/utils/updateAndValidateFormState.ts @@ -92,8 +92,9 @@ function validateFormState( configurableForm?.currentIncidentManagementTeam?.teamHierarchy || [], updatedField.id ); - break; } + case "resource": + break; } return [...formValidationErrors, ...entityValidationErrors, ...sheetDataValidationErrors]; diff --git a/src/webapp/pages/resources/ResourceLabel.tsx b/src/webapp/pages/resources/ResourceLabel.tsx new file mode 100644 index 00000000..fc508758 --- /dev/null +++ b/src/webapp/pages/resources/ResourceLabel.tsx @@ -0,0 +1,112 @@ +import styled from "styled-components"; +import i18n from "../../../utils/i18n"; +import { ResourceBase } from "../../../domain/entities/resources/Resource"; +import { Delete, DescriptionOutlined } from "@material-ui/icons"; +import { Link } from "@mui/material"; +import { useEffect, useState } from "react"; +import { Button } from "@material-ui/core"; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; +import { useResourceFile } from "./useResourceFile"; +import { Loader } from "../../components/loader/Loader"; +import { SimpleModal } from "../../components/simple-modal/SimpleModal"; + +interface ResourceLabelProps { + isDeleting: boolean; + resource: ResourceBase; + userCanDelete: boolean; + userCanDownload: boolean; + onDelete: () => void; +} + +export const ResourceLabel: React.FC = ({ + isDeleting, + resource, + userCanDelete, + userCanDownload, + onDelete, +}) => { + const snackbar = useSnackbar(); + const [openModal, setOpenModal] = useState(false); + const { resourceFileId, resourceLabel } = resource; + const { globalMessage, resourceFile, deleteResource } = useResourceFile({ + resourceFileId: resourceFileId, + onDelete: onDelete, + }); + + useEffect(() => { + if (!globalMessage) return; + + snackbar[globalMessage.type](globalMessage.text); + }, [globalMessage, snackbar]); + + return ( + +

+ + {userCanDownload ? ( + + {resourceLabel} + + ) : ( + resourceLabel + )} +

+ + {resourceFileId && userCanDelete && ( + <> + + + setOpenModal(false)} + title={"Delete resource"} + closeLabel={i18n.t("Cancel")} + alignFooterButtons="end" + footerButtons={ + <> + {isDeleting && } + deleteResource(resourceFileId)} + > + {i18n.t("Delete")} + + + } + > +

+ {i18n.t( + "Are you sure you want to delete this resource? This action cannot be undone." + )} +

+
+ + )} +
+ ); +}; + +const StyledTemplateLabel = styled.div` + display: flex; + + justify-content: space-between; + align-items: center; + + p { + display: flex; + align-items: center; + gap: 4px; + margin: 0; + } +`; + +const StyledButton = styled(Button)` + background-color: ${props => props.theme.palette.error.main}; + color: ${props => props.theme.palette.common.white}; +`; diff --git a/src/webapp/pages/resources/ResourcesPage.tsx b/src/webapp/pages/resources/ResourcesPage.tsx index f3ce3e51..c970c94b 100644 --- a/src/webapp/pages/resources/ResourcesPage.tsx +++ b/src/webapp/pages/resources/ResourcesPage.tsx @@ -1,8 +1,116 @@ -import React from "react"; +import React, { useEffect } from "react"; import { Layout } from "../../components/layout/Layout"; import i18n from "../../../utils/i18n"; +import { Section } from "../../components/section/Section"; +import { Button } from "@material-ui/core"; +import { FileFileUpload } from "material-ui/svg-icons"; +import styled from "styled-components"; +import { ResponseDocumentHierarchyView } from "../../components/response-document-hierarchy/ResponseDocumentHierarchyView"; +import { ResourceLabel } from "./ResourceLabel"; +import { NoticeBox } from "../../components/notice-box/NoticeBox"; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; +import { useResources } from "./useResources"; export const ResourcesPage: React.FC = React.memo(() => { - return ; + const snackbar = useSnackbar(); + const { + globalMessage, + isDeleting, + resources, + userCanUploadAndDelete, + userCanDownload, + onUploadFileClick, + handleDelete, + } = useResources(); + + useEffect(() => { + if (!globalMessage) return; + + snackbar[globalMessage.type](globalMessage.text); + }, [globalMessage, snackbar]); + + const uploadButton = ( + + ); + + return ( + +
+ {resources && + (resources.responseDocuments.length > 0 || resources.templates.length > 0) ? ( + + {resources.responseDocuments.length > 0 && ( +
+ Response Documents + + + + +
+ )} + + {resources.templates.length > 0 && ( +
+ Templates + + {resources.templates.map(template => ( + + ))} + +
+ )} +
+ ) : ( + + {userCanUploadAndDelete + ? i18n.t("You can upload a file to create a new resource") + : i18n.t("Contact admin to create resources")} + + )} +
+
+ ); }); + +const Container = styled.div` + background-color: ${props => props.theme.palette.common.white}; + color: ${props => props.theme.palette.common.grey700}; + padding: 8px 16px; + font-size: 16px; + display: flex; + flex-direction: column; + justify-content: stretch; + height: 100%; +`; + +const ContentWrapper = styled.div` + display: grid; + width: 100%; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +`; + +const ResourceTypeLabel = styled.p` + font-size: 20px; +`; diff --git a/src/webapp/pages/resources/useResourceFile.tsx b/src/webapp/pages/resources/useResourceFile.tsx new file mode 100644 index 00000000..b6781a2e --- /dev/null +++ b/src/webapp/pages/resources/useResourceFile.tsx @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useState } from "react"; +import { useAppContext } from "../../contexts/app-context"; +import { ResourceFile } from "../../../domain/entities/resources/ResourceFile"; +import { Maybe } from "../../../utils/ts-utils"; +import { GlobalMessage } from "../form-page/useForm"; +import { Id } from "../../../domain/entities/Ref"; + +type ResourceFileProps = { + resourceFileId: Maybe; + onDelete: () => void; +}; + +type ResourceFileState = { + globalMessage: Maybe; + resourceFile: Maybe; + deleteResource: (fileId: string) => void; +}; + +export function useResourceFile(props: ResourceFileProps): ResourceFileState { + const { resourceFileId, onDelete } = props; + const { compositionRoot } = useAppContext(); + + const [resourceFile, setResourceFile] = useState>(undefined); + const [globalMessage, setGlobalMessage] = useState>(undefined); + + useEffect(() => { + if (resourceFileId) { + compositionRoot.resources.downloadResourceFile.execute(resourceFileId).run( + resourceFile => { + setResourceFile(resourceFile); + }, + error => { + setGlobalMessage({ + type: "error", + text: `Error downloading resource file: ${error}`, + }); + } + ); + } + }, [compositionRoot.resources.downloadResourceFile, resourceFileId]); + + const deleteResource = useCallback( + (fileId: string) => { + return compositionRoot.resources.deleteResourceFile.execute(fileId).run( + () => { + setResourceFile(undefined); + onDelete(); + }, + error => { + setGlobalMessage({ + type: "error", + text: `Error deleting resource file: ${error}`, + }); + } + ); + }, + [compositionRoot.resources.deleteResourceFile, onDelete] + ); + + return { + globalMessage: globalMessage, + resourceFile: resourceFile, + deleteResource: deleteResource, + }; +} diff --git a/src/webapp/pages/resources/useResources.tsx b/src/webapp/pages/resources/useResources.tsx new file mode 100644 index 00000000..2ef9e880 --- /dev/null +++ b/src/webapp/pages/resources/useResources.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useAppContext } from "../../contexts/app-context"; +import { RouteName, useRoutes } from "../../hooks/useRoutes"; +import { Maybe } from "../../../utils/ts-utils"; +import { GlobalMessage } from "../form-page/useForm"; +import { + isResponseDocument, + isTemplate, + Resource, + ResourceType, + ResponseDocument, + Template, +} from "../../../domain/entities/resources/Resource"; +import { Id } from "../../../domain/entities/Ref"; +import _c from "../../../domain/entities/generic/Collection"; +import { ResourcePermissions } from "../../../domain/entities/resources/ResourcePermissions"; + +export type ResponseDocumentsByFolder = { + resourceFolder: string; + resourceType: ResourceType; + resources: { + resourceFileId: Maybe; + resourceLabel: string; + }[]; +}; + +export type ResourceData = { + templates: Template[]; + responseDocuments: ResponseDocumentsByFolder[]; +}; + +export function useResources() { + const { goTo } = useRoutes(); + const { currentUser, compositionRoot } = useAppContext(); + + const [resources, setResources] = useState>(undefined); + const [globalMessage, setGlobalMessage] = useState>(); + const [isDeleting, setIsDeleting] = useState(false); + const [userPermissions, setUserPermissions] = + useState(defaultUserPermissions); + + const { isAccess, isAdmin, isDataCapture } = userPermissions; + + const onUploadFileClick = useCallback(() => { + goTo(RouteName.CREATE_FORM, { formType: "resource" }); + }, [goTo]); + + const getResources = useCallback(() => { + compositionRoot.resources.get.execute().run( + resources => { + const resourceData: ResourceData = getResourceData(resources); + setResources(resourceData); + setIsDeleting(false); + }, + error => { + setGlobalMessage({ + type: "error", + text: `Error getting resources: ${error}`, + }); + } + ); + }, [compositionRoot.resources.get]); + + const handleDelete = useCallback(() => { + setIsDeleting(true); + getResources(); + }, [getResources]); + + useEffect(() => { + getResources(); + }, [getResources]); + + useEffect(() => { + compositionRoot.resources.getPermissions.execute(currentUser).run( + permissions => { + setUserPermissions(permissions); + }, + error => { + setGlobalMessage({ + type: "error", + text: `Error getting user permissions: ${error}`, + }); + } + ); + }, [compositionRoot.resources.getPermissions, currentUser]); + + const userCanUploadAndDelete = useMemo(() => { + return isAdmin || isDataCapture; + }, [isAdmin, isDataCapture]); + + const userCanDownload = useMemo(() => { + return isAccess || isDataCapture || isAdmin; + }, [isAccess, isAdmin, isDataCapture]); + + return { + globalMessage, + isDeleting, + resources, + userCanUploadAndDelete, + userCanDownload, + userPermissions, + handleDelete, + onUploadFileClick, + }; +} + +const defaultUserPermissions = { + isAdmin: false, + isDataCapture: false, + isAccess: true, +}; + +function getResourceData(resources: Resource[]) { + const templates = resources.filter(resource => isTemplate(resource)) as Template[]; + const resourceDocumentsByFolder = getResponseDocumentsByFolder(resources); + + const resourceData: ResourceData = { + templates: templates, + responseDocuments: resourceDocumentsByFolder, + }; + return resourceData; +} + +function getResponseDocumentsByFolder(resources: Resource[]): ResponseDocumentsByFolder[] { + const responseDocuments = resources.filter(resource => + isResponseDocument(resource) + ) as ResponseDocument[]; + const groupedResources = _c(responseDocuments) + .groupBy(responseDocument => responseDocument.resourceFolder) + .values(); + + const responseDocumentsByFolder: ResponseDocumentsByFolder[] = _c(groupedResources) + .compactMap(group => { + const responseDocument = group[0]; + if (!responseDocument) return undefined; + + return { + resourceFolder: responseDocument.resourceFolder, + resourceType: responseDocument.resourceType, + resources: group.map(({ resourceFileId, resourceLabel }) => ({ + resourceFileId: resourceFileId, + resourceLabel: resourceLabel, + })), + }; + }) + .value(); + + return responseDocumentsByFolder; +}