From ebe249f5d3518749f7b4d9ae4b8b25034b9466af Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 6 Jan 2025 15:59:34 +0100 Subject: [PATCH 01/27] feat: create resource form --- src/domain/entities/ConfigurableForm.ts | 11 ++- .../DiseaseOutbreakEvent.ts | 2 + src/domain/entities/resources/Resource.ts | 8 ++ .../usecases/GetConfigurableFormUseCase.ts | 10 ++ .../usecases/GetDiseaseOutbreakByIdUseCase.ts | 1 + src/domain/usecases/SaveEntityUseCase.ts | 3 + .../resources/GetResourceConfigurableForm.ts | 44 +++++++++ src/webapp/components/form/FieldWidget.tsx | 4 + src/webapp/components/form/FormFieldsState.ts | 23 ++++- src/webapp/hooks/useRoutes.ts | 1 + src/webapp/pages/form-page/FormPage.tsx | 3 +- .../pages/form-page/mapEntityToFormState.ts | 3 + .../form-page/mapFormStateToEntityData.ts | 22 +++++ .../mapResourcesToInitialFormState.ts | 98 +++++++++++++++++++ src/webapp/pages/form-page/useForm.ts | 10 ++ .../updateDiseaseOutbreakEventFormState.ts | 3 +- src/webapp/pages/resources/ResourcesPage.tsx | 47 ++++++++- 17 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 src/domain/entities/resources/Resource.ts create mode 100644 src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts create mode 100644 src/webapp/pages/form-page/resources/mapResourcesToInitialFormState.ts diff --git a/src/domain/entities/ConfigurableForm.ts b/src/domain/entities/ConfigurableForm.ts index fb472a46..497099b8 100644 --- a/src/domain/entities/ConfigurableForm.ts +++ b/src/domain/entities/ConfigurableForm.ts @@ -14,6 +14,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"; export type DiseaseOutbreakEventOptions = { dataSources: Option[]; @@ -120,6 +121,13 @@ export type SingleResponseActionFormData = BaseFormData & { options: IncidentResponseActionOptions; }; +export type ResourceFormData = BaseFormData & { + type: "resource"; + entity: Maybe; + uploadedResourceFile: Maybe; + uploadedResourceFileId: Maybe; +}; + export type IncidentManagementTeamRoleOptions = { roles: Role[]; teamMembers: TeamMember[]; @@ -143,4 +151,5 @@ export type ConfigurableForm = | ActionPlanFormData | ResponseActionFormData | SingleResponseActionFormData - | IncidentManagementTeamMemberFormData; + | IncidentManagementTeamMemberFormData + | ResourceFormData; diff --git a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts index d6cbf1c4..ee9422ee 100644 --- a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts +++ b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts @@ -6,6 +6,7 @@ import { Code, NamedRef } from "../Ref"; import { RiskAssessment } from "../risk-assessment/RiskAssessment"; import { Maybe } from "../../../utils/ts-utils"; import { ValidationError } from "../ValidationError"; +import { Resource } from "../resources/Resource"; export const hazardTypes = [ "Biological:Human", @@ -80,6 +81,7 @@ export type DiseaseOutbreakEventAttrs = DiseaseOutbreakEventBaseAttrs & { riskAssessment: Maybe; incidentActionPlan: Maybe; incidentManagementTeam: Maybe; + resource: Maybe; }; /** diff --git a/src/domain/entities/resources/Resource.ts b/src/domain/entities/resources/Resource.ts new file mode 100644 index 00000000..4d8fa123 --- /dev/null +++ b/src/domain/entities/resources/Resource.ts @@ -0,0 +1,8 @@ +export enum ResourceType { + TEMPLATE = "template", + RESOURCE_DOCUMENT = "resource-document", +} + +export type Resource = { + resourceType: ResourceType; +}; diff --git a/src/domain/usecases/GetConfigurableFormUseCase.ts b/src/domain/usecases/GetConfigurableFormUseCase.ts index baf9adf3..3451c942 100644 --- a/src/domain/usecases/GetConfigurableFormUseCase.ts +++ b/src/domain/usecases/GetConfigurableFormUseCase.ts @@ -18,6 +18,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"; @@ -124,6 +125,15 @@ export class GetConfigurableFormUseCase { }, configurations ); + case "resource": + if (!eventTrackerDetails) + return Future.error( + new Error( + "Disease outbreak id is required for incident management team member builder" + ) + ); + + return getResourceConfigurableForm(eventTrackerDetails); default: return Future.error(new Error("Form type not supported")); } diff --git a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts index 93fa90e1..5a8aff30 100644 --- a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts +++ b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts @@ -81,6 +81,7 @@ export class GetDiseaseOutbreakByIdUseCase { riskAssessment: riskAssessment, incidentActionPlan: undefined, //IAP is fetched on menu click. It is not needed here. incidentManagementTeam: undefined, //IMT is fetched on menu click. It is not needed here. + resource: undefined, // Resource is fetched on menu click. It is not needed here. }); return Future.success(diseaseOutbreakEvent); }); diff --git a/src/domain/usecases/SaveEntityUseCase.ts b/src/domain/usecases/SaveEntityUseCase.ts index b1f581a0..fb835928 100644 --- a/src/domain/usecases/SaveEntityUseCase.ts +++ b/src/domain/usecases/SaveEntityUseCase.ts @@ -117,6 +117,9 @@ export class SaveEntityUseCase { ); } } + case "resource": + console.log("saved resource"); + return Future.success(undefined); 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..3a2c757b --- /dev/null +++ b/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts @@ -0,0 +1,44 @@ +import { FutureData } from "../../../../data/api-futures"; +import { FormLables, ResourceFormData } from "../../../entities/ConfigurableForm"; +import { DiseaseOutbreakEvent } from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { Future } from "../../../entities/generic/Future"; +import { ResourceType } from "../../../entities/resources/Resource"; +import { Rule } from "../../../entities/Rule"; + +export function getResourceConfigurableForm( + eventTrackerDetails: DiseaseOutbreakEvent +): FutureData { + const { rules, labels } = getResourceLabelsRules(); + + const resourceFormData: ResourceFormData = { + type: "resource", + entity: eventTrackerDetails.resource, + uploadedResourceFile: undefined, + uploadedResourceFileId: undefined, + labels: labels, + rules: rules, + }; + + return Future.success(resourceFormData); +} + +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.RESOURCE_DOCUMENT, + sectionIds: ["resourceFolder_section"], + }, + ], + }; +} diff --git a/src/webapp/components/form/FieldWidget.tsx b/src/webapp/components/form/FieldWidget.tsx index bfdcdfcf..f6061c0c 100644 --- a/src/webapp/components/form/FieldWidget.tsx +++ b/src/webapp/components/form/FieldWidget.tsx @@ -100,5 +100,9 @@ export const FieldWidget: React.FC = React.memo((props): JSX.E /> ); } + + case "file": { + return ; + } } }); diff --git a/src/webapp/components/form/FormFieldsState.ts b/src/webapp/components/form/FormFieldsState.ts index 2b11d789..c8e68d0d 100644 --- a/src/webapp/components/form/FormFieldsState.ts +++ b/src/webapp/components/form/FormFieldsState.ts @@ -6,7 +6,15 @@ import { ValidationError, ValidationErrorKey } from "../../../domain/entities/Va import { FormSectionState } from "./FormSectionsState"; import { Rule } from "../../../domain/entities/Rule"; -export type FieldType = "text" | "boolean" | "select" | "radio" | "date" | "user" | "addNew"; +export type FieldType = + | "text" + | "boolean" + | "select" + | "radio" + | "date" + | "user" + | "addNew" + | "file"; type FormFieldStateBase = { id: string; @@ -57,6 +65,12 @@ export type FormAvatarFieldState = FormFieldStateBase> & { options: User[]; }; +export type FormFileFieldState = FormFieldStateBase> & { + type: "file"; + fileId: Maybe; + fileNameLabel?: string; +}; + export type AddNewFieldState = FormFieldStateBase & { type: "addNew"; }; @@ -67,7 +81,8 @@ export type FormFieldState = | FormMultipleOptionsFieldState | FormBooleanFieldState | FormDateFieldState - | FormAvatarFieldState; + | FormAvatarFieldState + | FormFileFieldState; // HELPERS: @@ -137,6 +152,8 @@ export function getFieldWithEmptyValue(field: FormFieldState): FormFieldState { return { ...field, value: null }; case "user": return { ...field, value: undefined }; + case "file": + return { ...field, value: undefined, fileId: undefined }; } } @@ -202,7 +219,7 @@ export function validateField( ? { property: field.id, errors: errors, - value: field.value, + value: field.type === "file" ? field.fileId : field.value, } : undefined; } diff --git a/src/webapp/hooks/useRoutes.ts b/src/webapp/hooks/useRoutes.ts index 9b50504f..4424a72b 100644 --- a/src/webapp/hooks/useRoutes.ts +++ b/src/webapp/hooks/useRoutes.ts @@ -23,6 +23,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 5408a886..31765155 100644 --- a/src/webapp/pages/form-page/FormPage.tsx +++ b/src/webapp/pages/form-page/FormPage.tsx @@ -15,7 +15,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 386ecb7f..46529590 100644 --- a/src/webapp/pages/form-page/mapEntityToFormState.ts +++ b/src/webapp/pages/form-page/mapEntityToFormState.ts @@ -15,6 +15,7 @@ import { mapSingleIncidentResponseActionToInitialFormState, } from "./incident-action/mapIncidentActionToInitialFormState"; import { mapIncidentManagementTeamMemberToInitialFormState } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; +import { mapResourcesToInitialFormState } from "./resources/mapResourcesToInitialFormState"; import { mapRiskAssessmentQuestionnaireToInitialFormState, mapRiskAssessmentSummaryToInitialFormState, @@ -56,6 +57,8 @@ export function mapEntityToFormState(options: { ); case "incident-management-team-member-assignment": return mapIncidentManagementTeamMemberToInitialFormState(configurableForm); + case "resource": + return mapResourcesToInitialFormState(configurableForm); } } diff --git a/src/webapp/pages/form-page/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/mapFormStateToEntityData.ts index 8f360a09..26492d14 100644 --- a/src/webapp/pages/form-page/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/mapFormStateToEntityData.ts @@ -25,6 +25,7 @@ import { RiskAssessmentSummaryFormData, ResponseActionFormData, SingleResponseActionFormData, + ResourceFormData, } from "../../../domain/entities/ConfigurableForm"; import { Maybe } from "../../../utils/ts-utils"; import { RiskAssessmentGrading } from "../../../domain/entities/risk-assessment/RiskAssessmentGrading"; @@ -53,6 +54,7 @@ import { import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { TEAM_ROLE_FIELD_ID } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; import { incidentManagementTeamBuilderCodesWithoutRoles } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { Resource, ResourceType } from "../../../domain/entities/resources/Resource"; export function mapFormStateToEntityData( formState: FormState, @@ -136,6 +138,15 @@ export function mapFormStateToEntityData( }; return incidentManagementTeamMemberForm; } + case "resource": { + const resource = mapFormStateToResource(formState, formData); + const resourceForm: ResourceFormData = { + ...formData, + entity: resource, + }; + + return resourceForm; + } default: return formData; @@ -756,6 +767,17 @@ function mapFormStateToIncidentManagementTeamMember( }); } +function mapFormStateToResource(formState: FormState, _formData: ResourceFormData): Resource { + const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); + + const resource: Resource = { + resourceType: allFields.find(field => field.id.includes("resourceType")) + ?.value as ResourceType, + }; + + 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/mapResourcesToInitialFormState.ts b/src/webapp/pages/form-page/resources/mapResourcesToInitialFormState.ts new file mode 100644 index 00000000..df59d820 --- /dev/null +++ b/src/webapp/pages/form-page/resources/mapResourcesToInitialFormState.ts @@ -0,0 +1,98 @@ +import { ResourceFormData } from "../../../../domain/entities/ConfigurableForm"; +import { ResourceType } from "../../../../domain/entities/resources/Resource"; +import { FormState } from "../../../components/form/FormState"; + +export function mapResourcesToInitialFormState(formData: ResourceFormData): FormState { + const { entity: resource, uploadedResourceFile, uploadedResourceFileId } = formData; + + const isResourceDocument = resource?.resourceType === ResourceType.RESOURCE_DOCUMENT; + + return { + id: "", + title: "Resources", + subtitle: "", + saveButtonLabel: "Save", + isValid: false, + sections: [ + { + title: "Resource type", + id: "resourceType_section", + isVisible: true, + required: true, + fields: [ + { + id: "resourceType", + placeholder: "Select a resource type", + isVisible: true, + errors: [], + type: "select", + multiple: false, + options: [ + { + value: ResourceType.RESOURCE_DOCUMENT, + label: "Resource document", + }, + { + value: ResourceType.TEMPLATE, + label: "Template", + }, + ], + value: resource?.resourceType || "", + required: true, + }, + ], + }, + { + title: "Resource name", + id: "resourceName_section", + isVisible: true, + required: true, + fields: [ + { + id: "resourceName", + isVisible: true, + errors: [], + type: "text", + value: "", + required: true, + }, + ], + }, + { + title: "Resource folder", + id: "resourceFolder_section", + isVisible: isResourceDocument, + required: true, + fields: [ + { + id: "resourceFolder", + isVisible: isResourceDocument, + errors: [], + type: "select", + options: [], + multiple: false, + value: isResourceDocument ? "" : "", + required: true, + }, + ], + }, + { + title: "Resource file", + id: "resourceFile_section", + isVisible: true, + required: false, + fields: [ + { + id: "resourceFile", + isVisible: true, + errors: [], + type: "file", + value: uploadedResourceFile || undefined, + fileId: uploadedResourceFileId || undefined, + required: false, + }, + ], + }, + ], + }; +} diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index a340bd27..7fa0f435 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -443,6 +443,13 @@ export function useForm(formType: FormType, id?: Id): State { type: "success", }); break; + case "resource": + goTo(RouteName.RESOURCES); + setGlobalMessage({ + text: i18n.t(`Resource saved successfully`), + type: "success", + }); + break; } }, err => { @@ -479,6 +486,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/updateDiseaseOutbreakEventFormState.ts b/src/webapp/pages/form-page/utils/updateDiseaseOutbreakEventFormState.ts index c098c297..32bdf483 100644 --- a/src/webapp/pages/form-page/utils/updateDiseaseOutbreakEventFormState.ts +++ b/src/webapp/pages/form-page/utils/updateDiseaseOutbreakEventFormState.ts @@ -86,8 +86,9 @@ function validateFormState( configurableForm?.currentIncidentManagementTeam?.teamHierarchy || [], updatedField.id ); - break; } + case "resource": + break; } return [...formValidationErrors, ...entityValidationErrors]; diff --git a/src/webapp/pages/resources/ResourcesPage.tsx b/src/webapp/pages/resources/ResourcesPage.tsx index f3ce3e51..33f2a95a 100644 --- a/src/webapp/pages/resources/ResourcesPage.tsx +++ b/src/webapp/pages/resources/ResourcesPage.tsx @@ -1,8 +1,51 @@ -import React from "react"; +import React, { useCallback } 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 { RouteName, useRoutes } from "../../hooks/useRoutes"; export const ResourcesPage: React.FC = React.memo(() => { - return ; + const { goTo } = useRoutes(); + + const onUploadFileClick = useCallback(() => { + goTo(RouteName.CREATE_FORM, { formType: "resource" }); + }, [goTo]); + + const uploadButton = ( + + ); + + return ( + +
+
+
+

Response Documents

+
hi
+
+
+

Templates

+
hi
+
+
+
+
+ ); }); From 53bc91cd87dad01335332207c024f626a924b0a0 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 6 Jan 2025 22:02:12 +0100 Subject: [PATCH 02/27] feat: save resources to datastore and get all resources --- src/CompositionRoot.ts | 10 ++ .../ResourceDataStoreRepository.ts | 46 ++++++++ .../test/ResourceTestRepository.ts | 31 +++++ src/domain/entities/ConfigurableForm.ts | 4 + .../DiseaseOutbreakEvent.ts | 2 +- src/domain/entities/resources/Resource.ts | 14 ++- src/domain/repositories/ResourceRepository.ts | 9 ++ .../usecases/GetDiseaseOutbreakByIdUseCase.ts | 2 +- src/domain/usecases/GetResourcesUseCase.ts | 30 +++++ src/domain/usecases/SaveEntityUseCase.ts | 5 +- .../resources/GetResourceConfigurableForm.ts | 2 +- .../pages/form-page/mapEntityToFormState.ts | 4 +- .../form-page/mapFormStateToEntityData.ts | 16 ++- ...te.ts => mapResourceToInitialFormState.ts} | 15 +-- src/webapp/pages/resources/ResourcesPage.tsx | 110 +++++++++++++++--- 15 files changed, 262 insertions(+), 38 deletions(-) create mode 100644 src/data/repositories/ResourceDataStoreRepository.ts create mode 100644 src/data/repositories/test/ResourceTestRepository.ts create mode 100644 src/domain/repositories/ResourceRepository.ts create mode 100644 src/domain/usecases/GetResourcesUseCase.ts rename src/webapp/pages/form-page/resources/{mapResourcesToInitialFormState.ts => mapResourceToInitialFormState.ts} (88%) diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index f54db51d..673007d8 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -71,6 +71,10 @@ import { CompleteEventTrackerUseCase } from "./domain/usecases/CompleteEventTrac import { UserGroupD2Repository } from "./data/repositories/UserGroupD2Repository"; import { UserGroupRepository } from "./domain/repositories/UserGroupRepository"; import { UserGroupTestRepository } from "./data/repositories/test/UserGroupTestRepository"; +import { ResourceRepository } from "./domain/repositories/ResourceRepository"; +import { ResourceDataStoreRepository } from "./data/repositories/ResourceDataStoreRepository"; +import { ResourceTestRepository } from "./data/repositories/test/ResourceTestRepository"; +import { GetResourcesUseCase } from "./domain/usecases/GetResourcesUseCase"; export type CompositionRoot = ReturnType; @@ -91,6 +95,7 @@ type Repositories = { systemRepository: SystemRepository; configurationsRepository: ConfigurationsRepository; userGroupRepository: UserGroupRepository; + resourceRepository: ResourceRepository; }; function getCompositionRoot(repositories: Repositories) { @@ -144,6 +149,9 @@ function getCompositionRoot(repositories: Repositories) { charts: { getCases: new GetChartConfigByTypeUseCase(repositories.chartConfigRepository), }, + resources: { + get: new GetResourcesUseCase(repositories), + }, }; } @@ -166,6 +174,7 @@ export function getWebappCompositionRoot(api: D2Api) { systemRepository: new SystemD2Repository(api), configurationsRepository: new ConfigurationsD2Repository(api), userGroupRepository: new UserGroupD2Repository(api), + resourceRepository: new ResourceDataStoreRepository(api), }; return getCompositionRoot(repositories); @@ -189,6 +198,7 @@ export function getTestCompositionRoot() { systemRepository: new SystemTestRepository(), configurationsRepository: new ConfigurationsTestRepository(), userGroupRepository: new UserGroupTestRepository(), + resourceRepository: new ResourceTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/ResourceDataStoreRepository.ts b/src/data/repositories/ResourceDataStoreRepository.ts new file mode 100644 index 00000000..4912b5e5 --- /dev/null +++ b/src/data/repositories/ResourceDataStoreRepository.ts @@ -0,0 +1,46 @@ +import { D2Api } from "@eyeseetea/d2-api/2.36"; +import { ResourceRepository } from "../../domain/repositories/ResourceRepository"; +import { DataStoreClient } from "../DataStoreClient"; +import { Resource } from "../../domain/entities/resources/Resource"; +import { FutureData } from "../api-futures"; +import { Future } from "../../domain/entities/generic/Future"; +import { ResourceFormData } from "../../domain/entities/ConfigurableForm"; + +const RESOURCES_KEY = "resources"; + +export class ResourceDataStoreRepository 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(formData: ResourceFormData): FutureData { + const { entity: resource } = formData; + if (!resource) throw new Error("No resource form data found"); + + return this.getAllResources().flatMap(resourcesInDataStore => { + const updatedResources = resourcesInDataStore.some( + resourceInDataStore => resourceInDataStore.resourceLabel === resource.resourceLabel + ) + ? resourcesInDataStore.map(resourceInDataStore => + resourceInDataStore.resourceLabel === resource.resourceLabel + ? resource + : resourceInDataStore + ) + : [...resourcesInDataStore, resource]; + + return this.dataStoreClient.saveObject(RESOURCES_KEY, updatedResources); + }); + } + + deleteResource(): FutureData { + throw new Error("Method not implemented."); + } +} diff --git a/src/data/repositories/test/ResourceTestRepository.ts b/src/data/repositories/test/ResourceTestRepository.ts new file mode 100644 index 00000000..25488d7e --- /dev/null +++ b/src/data/repositories/test/ResourceTestRepository.ts @@ -0,0 +1,31 @@ +import { ResourceFormData } from "../../../domain/entities/ConfigurableForm"; +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, + }, + { + resourceLabel: "Excel line list", + resourceType: ResourceType.RESOURCE_DOCUMENT, + resourceFolder: "Case line lists", + }, + ]; + + return Future.success(resources); + } + + saveResource(_formData: ResourceFormData): FutureData { + return Future.success(undefined); + } + + deleteResource(): FutureData { + throw new Error("Method not implemented."); + } +} diff --git a/src/domain/entities/ConfigurableForm.ts b/src/domain/entities/ConfigurableForm.ts index 497099b8..ba82cbd4 100644 --- a/src/domain/entities/ConfigurableForm.ts +++ b/src/domain/entities/ConfigurableForm.ts @@ -64,6 +64,10 @@ export type IncidentResponseActionOptions = { verification: Option[]; }; +export type ResourceOptions = { + resourceType: Option[]; +}; + export type FormLables = { errors: Record; }; diff --git a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts index ee9422ee..1d5f966d 100644 --- a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts +++ b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts @@ -81,7 +81,7 @@ export type DiseaseOutbreakEventAttrs = DiseaseOutbreakEventBaseAttrs & { riskAssessment: Maybe; incidentActionPlan: Maybe; incidentManagementTeam: Maybe; - resource: Maybe; + resource: Resource[]; }; /** diff --git a/src/domain/entities/resources/Resource.ts b/src/domain/entities/resources/Resource.ts index 4d8fa123..637cf054 100644 --- a/src/domain/entities/resources/Resource.ts +++ b/src/domain/entities/resources/Resource.ts @@ -3,6 +3,18 @@ export enum ResourceType { RESOURCE_DOCUMENT = "resource-document", } -export type Resource = { +type ResourceBase = { resourceType: ResourceType; + resourceLabel: string; }; + +export type ResourceDocument = ResourceBase & { + resourceType: ResourceType.RESOURCE_DOCUMENT; + resourceFolder: string; +}; + +export type Template = ResourceBase & { + resourceType: ResourceType.TEMPLATE; +}; + +export type Resource = ResourceDocument | Template; diff --git a/src/domain/repositories/ResourceRepository.ts b/src/domain/repositories/ResourceRepository.ts new file mode 100644 index 00000000..9bd98a72 --- /dev/null +++ b/src/domain/repositories/ResourceRepository.ts @@ -0,0 +1,9 @@ +import { FutureData } from "../../data/api-futures"; +import { ResourceFormData } from "../entities/ConfigurableForm"; +import { Resource } from "../entities/resources/Resource"; + +export interface ResourceRepository { + getAllResources(): FutureData; + saveResource(formData: ResourceFormData): FutureData; + deleteResource(): FutureData; +} diff --git a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts index 5a8aff30..2e8e7d5f 100644 --- a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts +++ b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts @@ -81,7 +81,7 @@ export class GetDiseaseOutbreakByIdUseCase { riskAssessment: riskAssessment, incidentActionPlan: undefined, //IAP is fetched on menu click. It is not needed here. incidentManagementTeam: undefined, //IMT is fetched on menu click. It is not needed here. - resource: undefined, // Resource is fetched on menu click. It is not needed here. + resource: [], // Resource is fetched on menu click. It is not needed here. }); return Future.success(diseaseOutbreakEvent); }); diff --git a/src/domain/usecases/GetResourcesUseCase.ts b/src/domain/usecases/GetResourcesUseCase.ts new file mode 100644 index 00000000..3cd74ea7 --- /dev/null +++ b/src/domain/usecases/GetResourcesUseCase.ts @@ -0,0 +1,30 @@ +import { FutureData } from "../../data/api-futures"; +import { Future } from "../entities/generic/Future"; +import { ResourceDocument, ResourceType, Template } from "../entities/resources/Resource"; +import { ResourceRepository } from "../repositories/ResourceRepository"; + +type ResourceData = { + templates: Template[]; + resourceDocuments: ResourceDocument[]; +}; + +export class GetResourcesUseCase { + constructor( + private options: { + resourceRepository: ResourceRepository; + } + ) {} + + public execute(): FutureData { + return this.options.resourceRepository.getAllResources().flatMap(resources => { + const templates = resources.filter( + resource => resource.resourceType === ResourceType.TEMPLATE + ) as Template[]; + const resourceDocuments = resources.filter( + resource => resource.resourceType === ResourceType.RESOURCE_DOCUMENT + ) as ResourceDocument[]; + + return Future.success({ templates: templates, resourceDocuments: resourceDocuments }); + }); + } +} diff --git a/src/domain/usecases/SaveEntityUseCase.ts b/src/domain/usecases/SaveEntityUseCase.ts index fb835928..1d8bb0d6 100644 --- a/src/domain/usecases/SaveEntityUseCase.ts +++ b/src/domain/usecases/SaveEntityUseCase.ts @@ -13,6 +13,7 @@ import { saveDiseaseOutbreak } from "./utils/disease-outbreak/SaveDiseaseOutbrea import { RoleRepository } from "../repositories/RoleRepository"; import { Configurations } from "../entities/AppConfigurations"; import moment from "moment"; +import { ResourceRepository } from "../repositories/ResourceRepository"; export class SaveEntityUseCase { constructor( @@ -23,6 +24,7 @@ export class SaveEntityUseCase { incidentManagementTeamRepository: IncidentManagementTeamRepository; teamMemberRepository: TeamMemberRepository; roleRepository: RoleRepository; + resourceRepository: ResourceRepository; } ) {} @@ -118,8 +120,7 @@ export class SaveEntityUseCase { } } case "resource": - console.log("saved resource"); - return Future.success(undefined); + return this.options.resourceRepository.saveResource(formData); 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 index 3a2c757b..b7d6e52f 100644 --- a/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts +++ b/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts @@ -12,7 +12,7 @@ export function getResourceConfigurableForm( const resourceFormData: ResourceFormData = { type: "resource", - entity: eventTrackerDetails.resource, + entity: eventTrackerDetails.resource.find(resource => resource.resourceLabel === "abc"), uploadedResourceFile: undefined, uploadedResourceFileId: undefined, labels: labels, diff --git a/src/webapp/pages/form-page/mapEntityToFormState.ts b/src/webapp/pages/form-page/mapEntityToFormState.ts index 46529590..0b7cc404 100644 --- a/src/webapp/pages/form-page/mapEntityToFormState.ts +++ b/src/webapp/pages/form-page/mapEntityToFormState.ts @@ -15,7 +15,7 @@ import { mapSingleIncidentResponseActionToInitialFormState, } from "./incident-action/mapIncidentActionToInitialFormState"; import { mapIncidentManagementTeamMemberToInitialFormState } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; -import { mapResourcesToInitialFormState } from "./resources/mapResourcesToInitialFormState"; +import { mapResourceToInitialFormState } from "./resources/mapResourceToInitialFormState"; import { mapRiskAssessmentQuestionnaireToInitialFormState, mapRiskAssessmentSummaryToInitialFormState, @@ -58,7 +58,7 @@ export function mapEntityToFormState(options: { case "incident-management-team-member-assignment": return mapIncidentManagementTeamMemberToInitialFormState(configurableForm); case "resource": - return mapResourcesToInitialFormState(configurableForm); + return mapResourceToInitialFormState(configurableForm); } } diff --git a/src/webapp/pages/form-page/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/mapFormStateToEntityData.ts index 26492d14..256c71b9 100644 --- a/src/webapp/pages/form-page/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/mapFormStateToEntityData.ts @@ -139,7 +139,7 @@ export function mapFormStateToEntityData( return incidentManagementTeamMemberForm; } case "resource": { - const resource = mapFormStateToResource(formState, formData); + const resource = mapFormStateToResource(formState); const resourceForm: ResourceFormData = { ...formData, entity: resource, @@ -767,12 +767,20 @@ function mapFormStateToIncidentManagementTeamMember( }); } -function mapFormStateToResource(formState: FormState, _formData: ResourceFormData): Resource { +function mapFormStateToResource(formState: FormState): Resource { const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); + const resourceType = allFields.find(field => field.id.includes("resourceType")) + ?.value as ResourceType; + const resourceLabel = allFields.find(field => field.id.includes("resourceLabel")) + ?.value as string; + const resourceFolder = allFields.find(field => field.id.includes("resourceFolder")) + ?.value as string; + const resource: Resource = { - resourceType: allFields.find(field => field.id.includes("resourceType")) - ?.value as ResourceType, + resourceType: resourceType, + resourceLabel: resourceLabel, + resourceFolder: resourceFolder, }; return resource; diff --git a/src/webapp/pages/form-page/resources/mapResourcesToInitialFormState.ts b/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts similarity index 88% rename from src/webapp/pages/form-page/resources/mapResourcesToInitialFormState.ts rename to src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts index df59d820..8a850392 100644 --- a/src/webapp/pages/form-page/resources/mapResourcesToInitialFormState.ts +++ b/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts @@ -2,9 +2,8 @@ import { ResourceFormData } from "../../../../domain/entities/ConfigurableForm"; import { ResourceType } from "../../../../domain/entities/resources/Resource"; import { FormState } from "../../../components/form/FormState"; -export function mapResourcesToInitialFormState(formData: ResourceFormData): FormState { +export function mapResourceToInitialFormState(formData: ResourceFormData): FormState { const { entity: resource, uploadedResourceFile, uploadedResourceFileId } = formData; - const isResourceDocument = resource?.resourceType === ResourceType.RESOURCE_DOCUMENT; return { @@ -44,16 +43,16 @@ export function mapResourcesToInitialFormState(formData: ResourceFormData): Form }, { title: "Resource name", - id: "resourceName_section", + id: "resourceLabel_section", isVisible: true, required: true, fields: [ { - id: "resourceName", + id: "resourceLabel", isVisible: true, errors: [], type: "text", - value: "", + value: resource?.resourceLabel || "", required: true, }, ], @@ -68,10 +67,8 @@ export function mapResourcesToInitialFormState(formData: ResourceFormData): Form id: "resourceFolder", isVisible: isResourceDocument, errors: [], - type: "select", - options: [], - multiple: false, - value: isResourceDocument ? "" : "", + type: "text", + value: isResourceDocument ? resource.resourceFolder : "", required: true, }, ], diff --git a/src/webapp/pages/resources/ResourcesPage.tsx b/src/webapp/pages/resources/ResourcesPage.tsx index 33f2a95a..d8f0ca12 100644 --- a/src/webapp/pages/resources/ResourcesPage.tsx +++ b/src/webapp/pages/resources/ResourcesPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { Layout } from "../../components/layout/Layout"; import i18n from "../../../utils/i18n"; @@ -6,14 +6,55 @@ import { Section } from "../../components/section/Section"; import { Button } from "@material-ui/core"; import { FileFileUpload } from "material-ui/svg-icons"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; +import { useAppContext } from "../../contexts/app-context"; +import { ResourceDocument, Template } from "../../../domain/entities/resources/Resource"; +import { Maybe } from "../../../utils/ts-utils"; +import { DescriptionOutlined, FolderOutlined } from "@material-ui/icons"; export const ResourcesPage: React.FC = React.memo(() => { + const { compositionRoot } = useAppContext(); + // const { changeCurrentEventTracker, getCurrentEventTracker } = useCurrentEventTracker(); + // const currentEventTracker = getCurrentEventTracker(); + const { goTo } = useRoutes(); const onUploadFileClick = useCallback(() => { goTo(RouteName.CREATE_FORM, { formType: "resource" }); }, [goTo]); + const [resources, setResources] = useState< + Maybe<{ + templates: Template[]; + resourceDocuments: ResourceDocument[]; + }> + >(undefined); + + useEffect(() => { + compositionRoot.resources.get.execute().run( + resources => { + setResources(resources); + }, + error => console.debug({ error }) + ); + }, [compositionRoot.resources.get]); + + // useEffect(() => { + // if ( + // currentEventTracker && + // (currentEventTracker.incidentActionPlan?.actionPlan?.lastUpdated !== + // incidentActionPlan?.actionPlan?.lastUpdated || + // currentEventTracker.incidentActionPlan?.responseActions.length !== + // incidentActionPlan?.responseActions.length) + // ) { + // const updatedEventTracker = new DiseaseOutbreakEvent({ + // ...currentEventTracker, + // resource: resources, + // }); + + // changeCurrentEventTracker(updatedEventTracker); + // } + // }, [changeCurrentEventTracker, currentEventTracker]); + const uploadButton = ( + + ); + } +); + +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/pages/form-page/resources/mapResourceToInitialFormState.ts b/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts index f6ca13a3..e03ce7fc 100644 --- a/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts +++ b/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts @@ -1,7 +1,9 @@ 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"; export const resourceFieldIds = { resourceType: "resourceType", @@ -10,21 +12,14 @@ export const resourceFieldIds = { resourceFile: "resourceFile", }; -const resourceTypeOptions = [ - { - value: ResourceType.RESPONSE_DOCUMENT, - label: "Response document", - }, - { - value: ResourceType.TEMPLATE, - label: "Template", - }, -]; - export function mapResourceToInitialFormState(formData: ResourceFormData): FormState { - const { entity: resource, uploadedResourceFile, uploadedResourceFileId } = formData; + const { entity: resource, uploadedResourceFile, uploadedResourceFileId, 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); @@ -80,7 +75,10 @@ export function mapResourceToInitialFormState(formData: ResourceFormData): FormS id: fromIdsDictionary("resourceFolder"), isVisible: isResourceDocument, errors: [], - type: "text", + type: "select", + options: resourceFolderOptions, + multiple: false, + addNewOption: true, value: isResourceDocument ? resource.resourceFolder : "", required: true, }, From a232ed56d499869b51b1435c93a65f58baf578e6 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Thu, 9 Jan 2025 17:25:10 +0100 Subject: [PATCH 07/27] feat: delete resources (wip) --- src/CompositionRoot.ts | 6 +- ...eRepository.ts => ResourceD2Repository.ts} | 65 ++++++++++++----- .../repositories/ResourceFileD2Repository.ts | 0 .../test/ResourceTestRepository.ts | 7 +- src/domain/repositories/ResourceRepository.ts | 5 +- .../usecases/DeleteResourceFileUseCase.ts | 10 +++ .../usecases/DownloadResourceFileUseCase.ts | 3 +- src/domain/usecases/GetResourcesUseCase.ts | 52 ++++++++------ .../ResponseDocumentHierarchyItem.tsx | 1 - src/webapp/pages/resources/ResourceLabel.tsx | 70 +++++++++++-------- src/webapp/pages/resources/ResourcesPage.tsx | 48 +++++++------ .../pages/resources/useResourceFile.tsx | 52 ++++++++++++++ src/webapp/pages/resources/useResources.tsx | 43 ++++++++++++ 13 files changed, 259 insertions(+), 103 deletions(-) rename src/data/repositories/{ResourceDataStoreRepository.ts => ResourceD2Repository.ts} (51%) create mode 100644 src/data/repositories/ResourceFileD2Repository.ts create mode 100644 src/domain/usecases/DeleteResourceFileUseCase.ts create mode 100644 src/webapp/pages/resources/useResourceFile.tsx create mode 100644 src/webapp/pages/resources/useResources.tsx diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 8da2a33a..64f489a9 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -74,10 +74,11 @@ import { UserGroupD2Repository } from "./data/repositories/UserGroupD2Repository import { UserGroupRepository } from "./domain/repositories/UserGroupRepository"; import { UserGroupTestRepository } from "./data/repositories/test/UserGroupTestRepository"; import { ResourceRepository } from "./domain/repositories/ResourceRepository"; -import { ResourceDataStoreRepository } from "./data/repositories/ResourceDataStoreRepository"; +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"; export type CompositionRoot = ReturnType; @@ -156,6 +157,7 @@ function getCompositionRoot(repositories: Repositories) { resources: { get: new GetResourcesUseCase(repositories.resourceRepository), downloadResourceFile: new DownloadResourceFileUseCase(repositories.resourceRepository), + deleteResourceFile: new DeleteResourceFileUseCase(repositories.resourceRepository), }, }; } @@ -180,7 +182,7 @@ export function getWebappCompositionRoot(api: D2Api) { configurationsRepository: new ConfigurationsD2Repository(api), casesFileRepository: new CasesFileD2Repository(api, dataStoreClient), userGroupRepository: new UserGroupD2Repository(api), - resourceRepository: new ResourceDataStoreRepository(api), + resourceRepository: new ResourceD2Repository(api), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/ResourceDataStoreRepository.ts b/src/data/repositories/ResourceD2Repository.ts similarity index 51% rename from src/data/repositories/ResourceDataStoreRepository.ts rename to src/data/repositories/ResourceD2Repository.ts index fdab3fbb..7aa3adb4 100644 --- a/src/data/repositories/ResourceDataStoreRepository.ts +++ b/src/data/repositories/ResourceD2Repository.ts @@ -6,12 +6,11 @@ import { apiToFuture, FutureData } from "../api-futures"; import { Future } from "../../domain/entities/generic/Future"; import { ResourceFormData } from "../../domain/entities/ConfigurableForm"; import { Id } from "../../domain/entities/Ref"; -import { Maybe } from "../../utils/ts-utils"; import _c from "../../domain/entities/generic/Collection"; const RESOURCES_KEY = "resources"; -export class ResourceDataStoreRepository implements ResourceRepository { +export class ResourceD2Repository implements ResourceRepository { private dataStoreClient: DataStoreClient; constructor(private api: D2Api) { @@ -24,7 +23,8 @@ export class ResourceDataStoreRepository implements ResourceRepository { .flatMap(resources => Future.success(resources ?? [])); } - downloadFile(fileId: Maybe): FutureData { + // should this be in it's own ResourceFileRepository? or should it be a useCase? + downloadFile(fileId: Id): FutureData { if (!fileId) return Future.error(new Error("No file id found")); return apiToFuture(this.api.files.get(fileId)) @@ -40,33 +40,45 @@ export class ResourceDataStoreRepository implements ResourceRepository { } saveResource(formData: ResourceFormData): FutureData { - const { entity: resource, uploadedResourceFile, uploadedResourceFileId } = formData; + const { entity: resource, uploadedResourceFile } = formData; if (!resource) throw new Error("No resource form data found"); if (!uploadedResourceFile) return Future.error(new Error("No file uploaded")); return this.getAllResources().flatMap(resourcesInDataStore => { return this.uploadFile(uploadedResourceFile).flatMap(resourceFileId => { - const isResourceExisting = resourcesInDataStore.some( - resourceInDataStore => - resourceInDataStore.resourceLabel === resource.resourceLabel + const updatedResources = this.getResourcesToSave( + resourcesInDataStore, + resource, + resourceFileId ); - const resourceWithFileId = { ...resource, resourceFileId: resourceFileId }; - const updatedResources = isResourceExisting - ? resourcesInDataStore.map(resourceInDataStore => - resourceInDataStore.resourceLabel === resource.resourceLabel - ? resourceWithFileId - : resourceInDataStore - ) - : [...resourcesInDataStore, resourceWithFileId]; - return this.dataStoreClient.saveObject(RESOURCES_KEY, updatedResources); }); }); } - uploadFile(file: File): FutureData { + private getResourcesToSave( + resourcesInDataStore: Resource[], + resource: Resource, + resourceFileId: string + ) { + const isResourceExisting = resourcesInDataStore.some( + resourceInDataStore => resourceInDataStore.resourceLabel === resource.resourceLabel + ); + + const resourceWithFileId = { ...resource, resourceFileId: resourceFileId }; + const updatedResources = isResourceExisting + ? resourcesInDataStore.map(resourceInDataStore => + resourceInDataStore.resourceLabel === resource.resourceLabel + ? resourceWithFileId + : resourceInDataStore + ) + : [...resourcesInDataStore, resourceWithFileId]; + return updatedResources; + } + + private uploadFile(file: File): FutureData { return apiToFuture( this.api.files.upload({ name: file.name, @@ -75,7 +87,22 @@ export class ResourceDataStoreRepository implements ResourceRepository { ).flatMap(fileResource => Future.success(fileResource.id)); } - deleteResource(): FutureData { - throw new Error("Method not implemented."); + deleteResource(fileId: Id): FutureData { + return apiToFuture(this.api.files.delete(fileId)).flatMap(response => { + if (response.httpStatus === "OK") { + return this.getAllResources().flatMap(resources => { + const updatedResources = resources.filter( + resource => resource.resourceFileId !== fileId + ); + + return this.dataStoreClient.saveObject( + RESOURCES_KEY, + updatedResources + ); + }); + } else { + return Future.error(new Error("Error while deleting resource file")); + } + }); } } diff --git a/src/data/repositories/ResourceFileD2Repository.ts b/src/data/repositories/ResourceFileD2Repository.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/data/repositories/test/ResourceTestRepository.ts b/src/data/repositories/test/ResourceTestRepository.ts index 97f4dea1..a194a813 100644 --- a/src/data/repositories/test/ResourceTestRepository.ts +++ b/src/data/repositories/test/ResourceTestRepository.ts @@ -4,7 +4,6 @@ import { Future } from "../../../domain/entities/generic/Future"; import { Resource, ResourceFile, ResourceType } from "../../../domain/entities/resources/Resource"; import { ResourceRepository } from "../../../domain/repositories/ResourceRepository"; import { FutureData } from "../../api-futures"; -import { Maybe } from "../../../utils/ts-utils"; export class ResourceTestRepository implements ResourceRepository { getAllResources(): FutureData { @@ -25,9 +24,9 @@ export class ResourceTestRepository implements ResourceRepository { return Future.success(resources); } - downloadFile(_fileId: Maybe): FutureData { + downloadFile(fileId: Id): FutureData { return Future.success({ - fileId: "123", + fileId: fileId, file: new File(["test"], "test.txt", { type: "text/plain" }), }); } @@ -36,7 +35,7 @@ export class ResourceTestRepository implements ResourceRepository { return Future.success(undefined); } - deleteResource(): FutureData { + deleteResource(_fileId: Id): FutureData { return Future.success(undefined); } } diff --git a/src/domain/repositories/ResourceRepository.ts b/src/domain/repositories/ResourceRepository.ts index 8aa459d5..b9b4b8da 100644 --- a/src/domain/repositories/ResourceRepository.ts +++ b/src/domain/repositories/ResourceRepository.ts @@ -1,12 +1,11 @@ import { FutureData } from "../../data/api-futures"; -import { Maybe } from "../../utils/ts-utils"; import { ResourceFormData } from "../entities/ConfigurableForm"; import { Id } from "../entities/Ref"; import { Resource, ResourceFile } from "../entities/resources/Resource"; export interface ResourceRepository { getAllResources(): FutureData; - downloadFile(fileId: Maybe): FutureData; + downloadFile(fileId: Id): FutureData; saveResource(formData: ResourceFormData): FutureData; - deleteResource(): 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..eab0b58f --- /dev/null +++ b/src/domain/usecases/DeleteResourceFileUseCase.ts @@ -0,0 +1,10 @@ +import { FutureData } from "../../data/api-futures"; +import { ResourceRepository } from "../repositories/ResourceRepository"; + +export class DeleteResourceFileUseCase { + constructor(private resourceRepository: ResourceRepository) {} + + public execute(fileId: string): FutureData { + return this.resourceRepository.deleteResource(fileId); + } +} diff --git a/src/domain/usecases/DownloadResourceFileUseCase.ts b/src/domain/usecases/DownloadResourceFileUseCase.ts index 737d422e..e911ef08 100644 --- a/src/domain/usecases/DownloadResourceFileUseCase.ts +++ b/src/domain/usecases/DownloadResourceFileUseCase.ts @@ -1,12 +1,11 @@ import { FutureData } from "../../data/api-futures"; -import { Maybe } from "../../utils/ts-utils"; import { ResourceFile } from "../entities/resources/Resource"; import { ResourceRepository } from "../repositories/ResourceRepository"; export class DownloadResourceFileUseCase { constructor(private resourceRepository: ResourceRepository) {} - public execute(fileId: Maybe): FutureData { + public execute(fileId: string): FutureData { return this.resourceRepository.downloadFile(fileId); } } diff --git a/src/domain/usecases/GetResourcesUseCase.ts b/src/domain/usecases/GetResourcesUseCase.ts index b969bd79..9a42b95c 100644 --- a/src/domain/usecases/GetResourcesUseCase.ts +++ b/src/domain/usecases/GetResourcesUseCase.ts @@ -3,7 +3,7 @@ import { Maybe } from "../../utils/ts-utils"; import _c from "../entities/generic/Collection"; import { Future } from "../entities/generic/Future"; import { Id } from "../entities/Ref"; -import { ResourceType, ResponseDocument, Template } from "../entities/resources/Resource"; +import { Resource, ResourceType, ResponseDocument, Template } from "../entities/resources/Resource"; import { ResourceRepository } from "../repositories/ResourceRepository"; export type ResponseDocumentsByFolder = { @@ -25,28 +25,7 @@ export class GetResourcesUseCase { public execute(): FutureData { return this.resourceRepository.getAllResources().flatMap(resources => { - const responseDocuments = resources.filter( - resource => resource.resourceType === ResourceType.RESPONSE_DOCUMENT - ) 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(); - + const responseDocumentsByFolder = this.getResponseDocumentsByFolder(resources); const templates = resources.filter( resource => resource.resourceType === ResourceType.TEMPLATE ) as Template[]; @@ -57,4 +36,31 @@ export class GetResourcesUseCase { }); }); } + + private getResponseDocumentsByFolder(resources: Resource[]): ResponseDocumentsByFolder[] { + const responseDocuments = resources.filter( + resource => resource.resourceType === ResourceType.RESPONSE_DOCUMENT + ) 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; + } } diff --git a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx index feeffeaa..cf3ab06f 100644 --- a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx +++ b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx @@ -39,7 +39,6 @@ export const ResponseDocumentHierarchyItem: React.FC = ({ resource }) => { - const { compositionRoot } = useAppContext(); - const [resourceFile, setResourceFile] = useState>(undefined); + const snackbar = useSnackbar(); + const { resourceFileId, resourceLabel } = resource; + const { globalMessage, resourceFile, deleteResource } = useResourceFile(resourceFileId); useEffect(() => { - compositionRoot.resources.downloadResourceFile.execute(resource.resourceFileId).run( - resourceFile => { - setResourceFile(resourceFile); - }, - err => { - console.log({ err }); - } - ); - }, [compositionRoot.resources.downloadResourceFile, resource.resourceFileId]); + if (!globalMessage) return; + + snackbar[globalMessage.type](globalMessage.text); + }, [globalMessage, snackbar]); return ( - - - - {resource.resourceLabel} - + +

+ + + {resourceLabel} + +

+ + {resourceFileId && ( + + )}
); }; -const StyledTemplateLabel = styled.p` +const StyledTemplateLabel = styled.div` display: flex; - gap: 8px; - margin: 0; - cursor: pointer; + + justify-content: space-between; + align-items: center; + + p { + display: flex; + align-items: center; + gap: 4px; + margin: 0; + } `; diff --git a/src/webapp/pages/resources/ResourcesPage.tsx b/src/webapp/pages/resources/ResourcesPage.tsx index ce8a3960..ae3de26f 100644 --- a/src/webapp/pages/resources/ResourcesPage.tsx +++ b/src/webapp/pages/resources/ResourcesPage.tsx @@ -1,37 +1,32 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useState, useCallback } 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 { RouteName, useRoutes } from "../../hooks/useRoutes"; -import { useAppContext } from "../../contexts/app-context"; -import { Maybe } from "../../../utils/ts-utils"; import styled from "styled-components"; import { ResponseDocumentHierarchyView } from "../../components/response-document-hierarchy/ResponseDocumentHierarchyView"; -import { ResourceData } from "../../../domain/usecases/GetResourcesUseCase"; 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(() => { - const { compositionRoot } = useAppContext(); - const { goTo } = useRoutes(); + const snackbar = useSnackbar(); + const { resources, onUploadFileClick, globalMessage, getResources } = useResources(); + const [reload, setReload] = useState(false); - const onUploadFileClick = useCallback(() => { - goTo(RouteName.CREATE_FORM, { formType: "resource" }); - }, [goTo]); + useEffect(() => { + if (!globalMessage) return; - const [resources, setResources] = useState>(undefined); + snackbar[globalMessage.type](globalMessage.text); + }, [globalMessage, snackbar]); - useEffect(() => { - compositionRoot.resources.get.execute().run( - resources => { - setResources(resources); - }, - error => console.debug({ error }) - ); - }, [compositionRoot.resources.get]); + const handleReload = useCallback(() => { + getResources(); + setReload(!reload); + }, [getResources, reload]); const uploadButton = ( ); + const reloadButton = ( + + ); + return ( -
+
+ {uploadButton} + {reloadButton} + + } + > {resources && (resources.responseDocuments.length > 0 || resources.templates.length > 0) ? ( diff --git a/src/webapp/pages/resources/useResourceFile.tsx b/src/webapp/pages/resources/useResourceFile.tsx new file mode 100644 index 00000000..9d0ed5a5 --- /dev/null +++ b/src/webapp/pages/resources/useResourceFile.tsx @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from "react"; +import { useAppContext } from "../../contexts/app-context"; +import { ResourceFile } from "../../../domain/entities/resources/Resource"; +import { Maybe } from "../../../utils/ts-utils"; +import { GlobalMessage } from "../form-page/useForm"; +import { Id } from "../../../domain/entities/Ref"; + +export function useResourceFile(resourceFileId: Maybe) { + 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); + }, + error => { + setGlobalMessage({ + type: "error", + text: `Error deleting resource file: ${error}`, + }); + } + ); + }, + [compositionRoot.resources.deleteResourceFile] + ); + + 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..aafa6e1d --- /dev/null +++ b/src/webapp/pages/resources/useResources.tsx @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from "react"; +import { useAppContext } from "../../contexts/app-context"; +import { RouteName, useRoutes } from "../../hooks/useRoutes"; +import { Maybe } from "../../../utils/ts-utils"; +import { ResourceData } from "../../../domain/usecases/GetResourcesUseCase"; +import { GlobalMessage } from "../form-page/useForm"; + +export function useResources() { + const { goTo } = useRoutes(); + const { compositionRoot } = useAppContext(); + + const [resources, setResources] = useState>(undefined); + const [globalMessage, setGlobalMessage] = useState>(); + + const onUploadFileClick = useCallback(() => { + goTo(RouteName.CREATE_FORM, { formType: "resource" }); + }, [goTo]); + + const getResources = useCallback(() => { + compositionRoot.resources.get.execute().run( + resources => { + setResources(resources); + }, + error => { + setGlobalMessage({ + type: "error", + text: `Error getting resources: ${error}`, + }); + } + ); + }, [compositionRoot.resources.get]); + + useEffect(() => { + getResources(); + }, [getResources]); + + return { + globalMessage, + resources, + onUploadFileClick, + getResources, + }; +} From 6f2be3cbdf4b120fed1e2ee82bb1c3903938297d Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Thu, 9 Jan 2025 18:28:52 +0100 Subject: [PATCH 08/27] feat: implement resource deletion functionality and improve resource handling --- src/data/repositories/ResourceD2Repository.ts | 11 +++++-- .../resources/GetResourceConfigurableForm.ts | 4 +-- .../ResponseDocumentHierarchyItem.tsx | 6 +++- .../ResponseDocumentHierarchyView.tsx | 6 +++- src/webapp/pages/form-page/useForm.ts | 2 +- src/webapp/pages/resources/ResourceLabel.tsx | 19 ++++++++---- src/webapp/pages/resources/ResourcesPage.tsx | 30 +++++-------------- .../pages/resources/useResourceFile.tsx | 17 +++++++++-- src/webapp/pages/resources/useResources.tsx | 10 ++++++- 9 files changed, 67 insertions(+), 38 deletions(-) diff --git a/src/data/repositories/ResourceD2Repository.ts b/src/data/repositories/ResourceD2Repository.ts index 7aa3adb4..727c44cd 100644 --- a/src/data/repositories/ResourceD2Repository.ts +++ b/src/data/repositories/ResourceD2Repository.ts @@ -63,9 +63,14 @@ export class ResourceD2Repository implements ResourceRepository { resource: Resource, resourceFileId: string ) { - const isResourceExisting = resourcesInDataStore.some( - resourceInDataStore => resourceInDataStore.resourceLabel === resource.resourceLabel - ); + const isResourceExisting = resourcesInDataStore.some(resourceInDataStore => { + const isMatchingResourceType = + resourceInDataStore.resourceType === resource.resourceType; + const isMatchingResourceLabel = + resourceInDataStore.resourceLabel === resource.resourceLabel; + + return isMatchingResourceType && isMatchingResourceLabel; + }); const resourceWithFileId = { ...resource, resourceFileId: resourceFileId }; const updatedResources = isResourceExisting diff --git a/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts b/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts index 7a8c5abd..ce58ae7f 100644 --- a/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts +++ b/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts @@ -48,7 +48,7 @@ function getResourceFolderOptions(resources: Resource[]): Option[] { const resourceFolders = _c(resources) .map(resource => resource.resourceType === ResourceType.RESPONSE_DOCUMENT - ? resource.resourceLabel + ? resource.resourceFolder : undefined ) .compact() @@ -56,7 +56,7 @@ function getResourceFolderOptions(resources: Resource[]): Option[] { .value(); const resourceFolderOptions: Option[] = resourceFolders.map(resourceFolder => ({ - id: `${resourceFolder}_folder`, + id: resourceFolder, name: resourceFolder, })); return resourceFolderOptions; diff --git a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx index cf3ab06f..0c648867 100644 --- a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx +++ b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx @@ -5,12 +5,14 @@ import styled from "styled-components"; import { ResourceLabel } from "../../pages/resources/ResourceLabel"; type ResponseDocumentHierarchyItemProps = { + isDeleting: boolean; responseDocument: ResponseDocumentsByFolder; + onDelete: () => void; }; export const ResponseDocumentHierarchyItem: React.FC = React.memo(props => { - const { responseDocument } = props; + const { isDeleting, responseDocument, onDelete } = props; return ( void; }; export const ResponseDocumentHierarchyView: React.FC = React.memo(props => { - const { responseDocuments } = props; + const { isDeleting, responseDocuments, onDelete } = props; const defaultCollapseIcon = ( <> @@ -36,7 +38,9 @@ export const ResponseDocumentHierarchyView: React.FC ); })} diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index 5c098db0..38f84e1b 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -438,7 +438,7 @@ export function useForm(formType: FormType, id?: Id): State { }); break; case "resource": - goTo(RouteName.RESOURCES); + // goTo(RouteName.RESOURCES); setGlobalMessage({ text: i18n.t(`Resource saved successfully`), type: "success", diff --git a/src/webapp/pages/resources/ResourceLabel.tsx b/src/webapp/pages/resources/ResourceLabel.tsx index 83addf89..1bbff2e6 100644 --- a/src/webapp/pages/resources/ResourceLabel.tsx +++ b/src/webapp/pages/resources/ResourceLabel.tsx @@ -6,15 +6,21 @@ import { useEffect } 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"; interface ResourceLabelProps { + isDeleting: boolean; resource: ResourceBase; + onDelete: () => void; } -export const ResourceLabel: React.FC = ({ resource }) => { +export const ResourceLabel: React.FC = ({ isDeleting, resource, onDelete }) => { const snackbar = useSnackbar(); const { resourceFileId, resourceLabel } = resource; - const { globalMessage, resourceFile, deleteResource } = useResourceFile(resourceFileId); + const { globalMessage, resourceFile, deleteResource } = useResourceFile({ + resourceFileId: resourceFileId, + onDelete: onDelete, + }); useEffect(() => { if (!globalMessage) return; @@ -36,9 +42,12 @@ export const ResourceLabel: React.FC = ({ resource }) => {

{resourceFileId && ( - +
+ {isDeleting && } + +
)} ); diff --git a/src/webapp/pages/resources/ResourcesPage.tsx b/src/webapp/pages/resources/ResourcesPage.tsx index ae3de26f..abe6cd80 100644 --- a/src/webapp/pages/resources/ResourcesPage.tsx +++ b/src/webapp/pages/resources/ResourcesPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect } from "react"; import { Layout } from "../../components/layout/Layout"; import i18n from "../../../utils/i18n"; @@ -14,8 +14,8 @@ import { useResources } from "./useResources"; export const ResourcesPage: React.FC = React.memo(() => { const snackbar = useSnackbar(); - const { resources, onUploadFileClick, globalMessage, getResources } = useResources(); - const [reload, setReload] = useState(false); + const { resources, onUploadFileClick, globalMessage, isDeleting, handleDelete } = + useResources(); useEffect(() => { if (!globalMessage) return; @@ -23,11 +23,6 @@ export const ResourcesPage: React.FC = React.memo(() => { snackbar[globalMessage.type](globalMessage.text); }, [globalMessage, snackbar]); - const handleReload = useCallback(() => { - getResources(); - setReload(!reload); - }, [getResources, reload]); - const uploadButton = ( ); - const reloadButton = ( - - ); - return ( -
- {uploadButton} - {reloadButton} - - } - > +
{resources && (resources.responseDocuments.length > 0 || resources.templates.length > 0) ? ( @@ -65,6 +47,8 @@ export const ResourcesPage: React.FC = React.memo(() => { @@ -78,6 +62,8 @@ export const ResourcesPage: React.FC = React.memo(() => { ))} diff --git a/src/webapp/pages/resources/useResourceFile.tsx b/src/webapp/pages/resources/useResourceFile.tsx index 9d0ed5a5..e633f03d 100644 --- a/src/webapp/pages/resources/useResourceFile.tsx +++ b/src/webapp/pages/resources/useResourceFile.tsx @@ -5,7 +5,19 @@ import { Maybe } from "../../../utils/ts-utils"; import { GlobalMessage } from "../form-page/useForm"; import { Id } from "../../../domain/entities/Ref"; -export function useResourceFile(resourceFileId: Maybe) { +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); @@ -32,6 +44,7 @@ export function useResourceFile(resourceFileId: Maybe) { return compositionRoot.resources.deleteResourceFile.execute(fileId).run( () => { setResourceFile(undefined); + onDelete(); }, error => { setGlobalMessage({ @@ -41,7 +54,7 @@ export function useResourceFile(resourceFileId: Maybe) { } ); }, - [compositionRoot.resources.deleteResourceFile] + [compositionRoot.resources.deleteResourceFile, onDelete] ); return { diff --git a/src/webapp/pages/resources/useResources.tsx b/src/webapp/pages/resources/useResources.tsx index aafa6e1d..d2bf9b04 100644 --- a/src/webapp/pages/resources/useResources.tsx +++ b/src/webapp/pages/resources/useResources.tsx @@ -11,6 +11,7 @@ export function useResources() { const [resources, setResources] = useState>(undefined); const [globalMessage, setGlobalMessage] = useState>(); + const [isDeleting, setIsDeleting] = useState(false); const onUploadFileClick = useCallback(() => { goTo(RouteName.CREATE_FORM, { formType: "resource" }); @@ -20,6 +21,7 @@ export function useResources() { compositionRoot.resources.get.execute().run( resources => { setResources(resources); + setIsDeleting(false); }, error => { setGlobalMessage({ @@ -30,14 +32,20 @@ export function useResources() { ); }, [compositionRoot.resources.get]); + const handleDelete = useCallback(() => { + setIsDeleting(true); + getResources(); + }, [getResources]); + useEffect(() => { getResources(); }, [getResources]); return { globalMessage, + isDeleting, resources, + handleDelete, onUploadFileClick, - getResources, }; } From 977fc5826b61701f99f461bce086a4c19891e8d6 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 13 Jan 2025 11:59:58 +0100 Subject: [PATCH 09/27] feat: display popup when existing resource is saved add ResourceFile entity and repository for file handling --- src/CompositionRoot.ts | 10 +- src/data/repositories/ResourceD2Repository.ts | 27 +----- .../repositories/ResourceFileD2Repository.ts | 25 +++++ .../test/ResourceFileTestRepository.ts | 14 +++ .../test/ResourceTestRepository.ts | 9 +- src/domain/entities/resources/Resource.ts | 31 ++++++- src/domain/entities/resources/ResourceFile.ts | 6 ++ .../repositories/ResourceFileRepository.ts | 7 ++ src/domain/repositories/ResourceRepository.ts | 3 +- .../usecases/DownloadResourceFileUseCase.ts | 8 +- src/domain/usecases/GetResourcesUseCase.ts | 62 +------------ .../resources/GetResourceConfigurableForm.ts | 8 +- .../ResponseDocumentHierarchyItem.tsx | 2 +- .../ResponseDocumentHierarchyView.tsx | 2 +- .../form-page/resources/useResourceForm.ts | 91 +++++++++++++++++++ src/webapp/pages/form-page/useForm.ts | 18 ++-- .../pages/resources/useResourceFile.tsx | 2 +- src/webapp/pages/resources/useResources.tsx | 66 +++++++++++++- 18 files changed, 272 insertions(+), 119 deletions(-) create mode 100644 src/data/repositories/test/ResourceFileTestRepository.ts create mode 100644 src/domain/entities/resources/ResourceFile.ts create mode 100644 src/domain/repositories/ResourceFileRepository.ts create mode 100644 src/webapp/pages/form-page/resources/useResourceForm.ts diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 64f489a9..64d6e3d0 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -79,6 +79,9 @@ import { ResourceTestRepository } from "./data/repositories/test/ResourceTestRep 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"; export type CompositionRoot = ReturnType; @@ -101,6 +104,7 @@ type Repositories = { casesFileRepository: CasesFileRepository; userGroupRepository: UserGroupRepository; resourceRepository: ResourceRepository; + resourceFileRepository: ResourceFileRepository; }; function getCompositionRoot(repositories: Repositories) { @@ -156,7 +160,9 @@ function getCompositionRoot(repositories: Repositories) { }, resources: { get: new GetResourcesUseCase(repositories.resourceRepository), - downloadResourceFile: new DownloadResourceFileUseCase(repositories.resourceRepository), + downloadResourceFile: new DownloadResourceFileUseCase( + repositories.resourceFileRepository + ), deleteResourceFile: new DeleteResourceFileUseCase(repositories.resourceRepository), }, }; @@ -183,6 +189,7 @@ export function getWebappCompositionRoot(api: D2Api) { casesFileRepository: new CasesFileD2Repository(api, dataStoreClient), userGroupRepository: new UserGroupD2Repository(api), resourceRepository: new ResourceD2Repository(api), + resourceFileRepository: new ResourceFileD2Repository(api), }; return getCompositionRoot(repositories); @@ -208,6 +215,7 @@ export function getTestCompositionRoot() { 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 index 727c44cd..76c44cfd 100644 --- a/src/data/repositories/ResourceD2Repository.ts +++ b/src/data/repositories/ResourceD2Repository.ts @@ -1,7 +1,7 @@ import { D2Api } from "@eyeseetea/d2-api/2.36"; import { ResourceRepository } from "../../domain/repositories/ResourceRepository"; import { DataStoreClient } from "../DataStoreClient"; -import { Resource, ResourceFile } from "../../domain/entities/resources/Resource"; +import { isExistingResource, Resource } from "../../domain/entities/resources/Resource"; import { apiToFuture, FutureData } from "../api-futures"; import { Future } from "../../domain/entities/generic/Future"; import { ResourceFormData } from "../../domain/entities/ConfigurableForm"; @@ -23,22 +23,6 @@ export class ResourceD2Repository implements ResourceRepository { .flatMap(resources => Future.success(resources ?? [])); } - // should this be in it's own ResourceFileRepository? or should it be a useCase? - 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: "application/pdf" }); - }) - .flatMap(file => - Future.success({ - fileId: fileId, - file: file, - }) - ); - } - saveResource(formData: ResourceFormData): FutureData { const { entity: resource, uploadedResourceFile } = formData; @@ -63,14 +47,7 @@ export class ResourceD2Repository implements ResourceRepository { resource: Resource, resourceFileId: string ) { - const isResourceExisting = resourcesInDataStore.some(resourceInDataStore => { - const isMatchingResourceType = - resourceInDataStore.resourceType === resource.resourceType; - const isMatchingResourceLabel = - resourceInDataStore.resourceLabel === resource.resourceLabel; - - return isMatchingResourceType && isMatchingResourceLabel; - }); + const isResourceExisting = isExistingResource(resourcesInDataStore, resource); const resourceWithFileId = { ...resource, resourceFileId: resourceFileId }; const updatedResources = isResourceExisting diff --git a/src/data/repositories/ResourceFileD2Repository.ts b/src/data/repositories/ResourceFileD2Repository.ts index e69de29b..690e11b3 100644 --- a/src/data/repositories/ResourceFileD2Repository.ts +++ b/src/data/repositories/ResourceFileD2Repository.ts @@ -0,0 +1,25 @@ +import { D2Api } from "@eyeseetea/d2-api/2.36"; +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) {} + + 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: "application/pdf" }); + }) + .flatMap(file => + Future.success({ + fileId: fileId, + file: file, + }) + ); + } +} diff --git a/src/data/repositories/test/ResourceFileTestRepository.ts b/src/data/repositories/test/ResourceFileTestRepository.ts new file mode 100644 index 00000000..efb68d04 --- /dev/null +++ b/src/data/repositories/test/ResourceFileTestRepository.ts @@ -0,0 +1,14 @@ +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 { + downloadFile(fileId: Id): FutureData { + return Future.success({ + fileId: fileId, + file: new File(["test"], "test.txt", { type: "text/plain" }), + }); + } +} diff --git a/src/data/repositories/test/ResourceTestRepository.ts b/src/data/repositories/test/ResourceTestRepository.ts index a194a813..5f34630f 100644 --- a/src/data/repositories/test/ResourceTestRepository.ts +++ b/src/data/repositories/test/ResourceTestRepository.ts @@ -1,7 +1,7 @@ import { Id } from "@eyeseetea/d2-api"; import { ResourceFormData } from "../../../domain/entities/ConfigurableForm"; import { Future } from "../../../domain/entities/generic/Future"; -import { Resource, ResourceFile, ResourceType } from "../../../domain/entities/resources/Resource"; +import { Resource, ResourceType } from "../../../domain/entities/resources/Resource"; import { ResourceRepository } from "../../../domain/repositories/ResourceRepository"; import { FutureData } from "../../api-futures"; @@ -24,13 +24,6 @@ export class ResourceTestRepository implements ResourceRepository { return Future.success(resources); } - downloadFile(fileId: Id): FutureData { - return Future.success({ - fileId: fileId, - file: new File(["test"], "test.txt", { type: "text/plain" }), - }); - } - saveResource(_formData: ResourceFormData): FutureData { return Future.success(undefined); } diff --git a/src/domain/entities/resources/Resource.ts b/src/domain/entities/resources/Resource.ts index 0c549af9..8a39dc9c 100644 --- a/src/domain/entities/resources/Resource.ts +++ b/src/domain/entities/resources/Resource.ts @@ -1,11 +1,6 @@ import { Maybe } from "../../../utils/ts-utils"; import { Id } from "../Ref"; -export type ResourceFile = { - fileId: Id; - file: File; -}; - export enum ResourceType { TEMPLATE = "template", RESPONSE_DOCUMENT = "response-document", @@ -27,3 +22,29 @@ export type Template = ResourceBase & { }; 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/repositories/ResourceFileRepository.ts b/src/domain/repositories/ResourceFileRepository.ts new file mode 100644 index 00000000..8d3272c1 --- /dev/null +++ b/src/domain/repositories/ResourceFileRepository.ts @@ -0,0 +1,7 @@ +import { FutureData } from "../../data/api-futures"; +import { Id } from "../entities/Ref"; +import { ResourceFile } from "../entities/resources/ResourceFile"; + +export interface ResourceFileRepository { + downloadFile(fileId: Id): FutureData; +} diff --git a/src/domain/repositories/ResourceRepository.ts b/src/domain/repositories/ResourceRepository.ts index b9b4b8da..e232d271 100644 --- a/src/domain/repositories/ResourceRepository.ts +++ b/src/domain/repositories/ResourceRepository.ts @@ -1,11 +1,10 @@ import { FutureData } from "../../data/api-futures"; import { ResourceFormData } from "../entities/ConfigurableForm"; import { Id } from "../entities/Ref"; -import { Resource, ResourceFile } from "../entities/resources/Resource"; +import { Resource } from "../entities/resources/Resource"; export interface ResourceRepository { getAllResources(): FutureData; - downloadFile(fileId: Id): FutureData; saveResource(formData: ResourceFormData): FutureData; deleteResource(fileId: Id): FutureData; } diff --git a/src/domain/usecases/DownloadResourceFileUseCase.ts b/src/domain/usecases/DownloadResourceFileUseCase.ts index e911ef08..b17e112a 100644 --- a/src/domain/usecases/DownloadResourceFileUseCase.ts +++ b/src/domain/usecases/DownloadResourceFileUseCase.ts @@ -1,11 +1,11 @@ import { FutureData } from "../../data/api-futures"; -import { ResourceFile } from "../entities/resources/Resource"; -import { ResourceRepository } from "../repositories/ResourceRepository"; +import { ResourceFile } from "../entities/resources/ResourceFile"; +import { ResourceFileRepository } from "../repositories/ResourceFileRepository"; export class DownloadResourceFileUseCase { - constructor(private resourceRepository: ResourceRepository) {} + constructor(private resourceFileRepository: ResourceFileRepository) {} public execute(fileId: string): FutureData { - return this.resourceRepository.downloadFile(fileId); + return this.resourceFileRepository.downloadFile(fileId); } } diff --git a/src/domain/usecases/GetResourcesUseCase.ts b/src/domain/usecases/GetResourcesUseCase.ts index 9a42b95c..7c4e871f 100644 --- a/src/domain/usecases/GetResourcesUseCase.ts +++ b/src/domain/usecases/GetResourcesUseCase.ts @@ -1,66 +1,14 @@ import { FutureData } from "../../data/api-futures"; -import { Maybe } from "../../utils/ts-utils"; -import _c from "../entities/generic/Collection"; import { Future } from "../entities/generic/Future"; -import { Id } from "../entities/Ref"; -import { Resource, ResourceType, ResponseDocument, Template } from "../entities/resources/Resource"; +import { Resource } from "../entities/resources/Resource"; import { ResourceRepository } from "../repositories/ResourceRepository"; -export type ResponseDocumentsByFolder = { - resourceFolder: string; - resourceType: ResourceType; - resources: { - resourceFileId: Maybe; - resourceLabel: string; - }[]; -}; - -export type ResourceData = { - templates: Template[]; - responseDocuments: ResponseDocumentsByFolder[]; -}; - export class GetResourcesUseCase { constructor(private resourceRepository: ResourceRepository) {} - public execute(): FutureData { - return this.resourceRepository.getAllResources().flatMap(resources => { - const responseDocumentsByFolder = this.getResponseDocumentsByFolder(resources); - const templates = resources.filter( - resource => resource.resourceType === ResourceType.TEMPLATE - ) as Template[]; - - return Future.success({ - templates: templates, - responseDocuments: responseDocumentsByFolder, - }); - }); - } - - private getResponseDocumentsByFolder(resources: Resource[]): ResponseDocumentsByFolder[] { - const responseDocuments = resources.filter( - resource => resource.resourceType === ResourceType.RESPONSE_DOCUMENT - ) 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; + public execute(): FutureData { + return this.resourceRepository + .getAllResources() + .flatMap(resources => Future.success(resources)); } } diff --git a/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts b/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts index ce58ae7f..53288791 100644 --- a/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts +++ b/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts @@ -3,7 +3,7 @@ import { FormLables, ResourceFormData } from "../../../entities/ConfigurableForm import _c from "../../../entities/generic/Collection"; import { Future } from "../../../entities/generic/Future"; import { Option } from "../../../entities/Ref"; -import { Resource, ResourceType } from "../../../entities/resources/Resource"; +import { isResponseDocument, Resource, ResourceType } from "../../../entities/resources/Resource"; import { Rule } from "../../../entities/Rule"; import { ResourceRepository } from "../../../repositories/ResourceRepository"; @@ -46,11 +46,7 @@ export function getResourceConfigurableForm(props: { function getResourceFolderOptions(resources: Resource[]): Option[] { const resourceFolders = _c(resources) - .map(resource => - resource.resourceType === ResourceType.RESPONSE_DOCUMENT - ? resource.resourceFolder - : undefined - ) + .map(resource => (isResponseDocument(resource) ? resource.resourceFolder : undefined)) .compact() .uniq() .value(); diff --git a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx index 0c648867..5f06f85d 100644 --- a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx +++ b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx @@ -1,8 +1,8 @@ import { TreeItem as TreeItemMUI } from "@material-ui/lab"; import React from "react"; -import { ResponseDocumentsByFolder } from "../../../domain/usecases/GetResourcesUseCase"; import styled from "styled-components"; import { ResourceLabel } from "../../pages/resources/ResourceLabel"; +import { ResponseDocumentsByFolder } from "../../pages/resources/useResources"; type ResponseDocumentHierarchyItemProps = { isDeleting: boolean; diff --git a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyView.tsx b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyView.tsx index 23898576..562c5b25 100644 --- a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyView.tsx +++ b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyView.tsx @@ -2,7 +2,7 @@ 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 "../../../domain/usecases/GetResourcesUseCase"; +import { ResponseDocumentsByFolder } from "../../pages/resources/useResources"; type ResponseDocumentHierarchyViewProps = { isDeleting: boolean; 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 38f84e1b..a0c9a50b 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -29,6 +29,7 @@ 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"; export type GlobalMessage = { text: string; @@ -96,6 +97,14 @@ export function useForm(formType: FormType, id?: Id): State { setModalData, }); + const { onSaveResourceForm } = useResourceForm({ + editMode: !!id, + setOpenModal, + setModalData, + setGlobalMessage, + setIsLoading, + }); + const allDataPerformanceEvents = dataPerformanceOverview?.map( event => event.hazardType || event.suspectedDisease ); @@ -364,6 +373,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( @@ -437,12 +448,6 @@ export function useForm(formType: FormType, id?: Id): State { type: "success", }); break; - case "resource": - // goTo(RouteName.RESOURCES); - setGlobalMessage({ - text: i18n.t(`Resource saved successfully`), - type: "success", - }); } }, err => { @@ -464,6 +469,7 @@ export function useForm(formType: FormType, id?: Id): State { goTo, id, onSaveDiseaseOutbreakEvent, + onSaveResourceForm, ]); const onCancelForm = useCallback(() => { diff --git a/src/webapp/pages/resources/useResourceFile.tsx b/src/webapp/pages/resources/useResourceFile.tsx index e633f03d..b6781a2e 100644 --- a/src/webapp/pages/resources/useResourceFile.tsx +++ b/src/webapp/pages/resources/useResourceFile.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { useAppContext } from "../../contexts/app-context"; -import { ResourceFile } from "../../../domain/entities/resources/Resource"; +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"; diff --git a/src/webapp/pages/resources/useResources.tsx b/src/webapp/pages/resources/useResources.tsx index d2bf9b04..fa37be3b 100644 --- a/src/webapp/pages/resources/useResources.tsx +++ b/src/webapp/pages/resources/useResources.tsx @@ -2,8 +2,31 @@ import { useCallback, useEffect, useState } from "react"; import { useAppContext } from "../../contexts/app-context"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { Maybe } from "../../../utils/ts-utils"; -import { ResourceData } from "../../../domain/usecases/GetResourcesUseCase"; 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"; + +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(); @@ -20,7 +43,8 @@ export function useResources() { const getResources = useCallback(() => { compositionRoot.resources.get.execute().run( resources => { - setResources(resources); + const resourceData: ResourceData = getResourceData(resources); + setResources(resourceData); setIsDeleting(false); }, error => { @@ -49,3 +73,41 @@ export function useResources() { onUploadFileClick, }; } + +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; +} From 9da21b874ae0d55164d3f415161230ee93e1691a Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:48:19 +0100 Subject: [PATCH 10/27] feat: implement resource user permissions --- src/CompositionRoot.ts | 10 +++++ src/data/repositories/ResourceD2Repository.ts | 2 +- .../repositories/ResourceFileD2Repository.ts | 2 +- .../ResourcePermissionsD2Repository.ts | 30 ++++++++++++++ .../test/ResourcePermissionsTestRepository.ts | 15 +++++++ .../entities/resources/ResourcePermissions.ts | 9 +++++ .../ResourcePermissionsRepository.ts | 7 ++++ .../GetResourceUserPermissionsUseCase.ts | 12 ++++++ .../ResponseDocumentHierarchyItem.tsx | 6 ++- .../ResponseDocumentHierarchyView.tsx | 6 ++- .../pages/form-page/mapEntityToFormState.ts | 12 +++++- .../mapResourceToInitialFormState.ts | 10 ++++- src/webapp/pages/form-page/useForm.ts | 4 ++ src/webapp/pages/resources/ResourceLabel.tsx | 30 +++++++++----- src/webapp/pages/resources/ResourcesPage.tsx | 17 ++++++-- src/webapp/pages/resources/useResources.tsx | 40 ++++++++++++++++++- 16 files changed, 190 insertions(+), 22 deletions(-) create mode 100644 src/data/repositories/ResourcePermissionsD2Repository.ts create mode 100644 src/data/repositories/test/ResourcePermissionsTestRepository.ts create mode 100644 src/domain/entities/resources/ResourcePermissions.ts create mode 100644 src/domain/repositories/ResourcePermissionsRepository.ts create mode 100644 src/domain/usecases/GetResourceUserPermissionsUseCase.ts diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 64d6e3d0..9e70f5e5 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -82,6 +82,10 @@ import { DeleteResourceFileUseCase } from "./domain/usecases/DeleteResourceFileU import { ResourceFileTestRepository } from "./data/repositories/test/ResourceFileTestRepository"; import { ResourceFileRepository } from "./domain/repositories/ResourceFileRepository"; import { ResourceFileD2Repository } from "./data/repositories/ResourceFileD2Repository"; +import { ResourcePermissionsD2Repository } from "./data/repositories/ResourcePermissionsD2Repository"; +import { ResourcePermissionsTestRepository } from "./data/repositories/test/ResourcePermissionsTestRepository"; +import { ResourcePermissionsRepository } from "./domain/repositories/ResourcePermissionsRepository"; +import { GetResourceUserPermissionsUseCase } from "./domain/usecases/GetResourceUserPermissionsUseCase"; export type CompositionRoot = ReturnType; @@ -105,6 +109,7 @@ type Repositories = { userGroupRepository: UserGroupRepository; resourceRepository: ResourceRepository; resourceFileRepository: ResourceFileRepository; + resourcePermissionsRepository: ResourcePermissionsRepository; }; function getCompositionRoot(repositories: Repositories) { @@ -164,6 +169,9 @@ function getCompositionRoot(repositories: Repositories) { repositories.resourceFileRepository ), deleteResourceFile: new DeleteResourceFileUseCase(repositories.resourceRepository), + getPermissions: new GetResourceUserPermissionsUseCase( + repositories.resourcePermissionsRepository + ), }, }; } @@ -190,6 +198,7 @@ export function getWebappCompositionRoot(api: D2Api) { userGroupRepository: new UserGroupD2Repository(api), resourceRepository: new ResourceD2Repository(api), resourceFileRepository: new ResourceFileD2Repository(api), + resourcePermissionsRepository: new ResourcePermissionsD2Repository(api), }; return getCompositionRoot(repositories); @@ -216,6 +225,7 @@ export function getTestCompositionRoot() { userGroupRepository: new UserGroupTestRepository(), resourceRepository: new ResourceTestRepository(), resourceFileRepository: new ResourceFileTestRepository(), + resourcePermissionsRepository: new ResourcePermissionsTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/ResourceD2Repository.ts b/src/data/repositories/ResourceD2Repository.ts index 76c44cfd..61f2caa8 100644 --- a/src/data/repositories/ResourceD2Repository.ts +++ b/src/data/repositories/ResourceD2Repository.ts @@ -1,4 +1,4 @@ -import { D2Api } from "@eyeseetea/d2-api/2.36"; +import { D2Api } from "../../types/d2-api"; import { ResourceRepository } from "../../domain/repositories/ResourceRepository"; import { DataStoreClient } from "../DataStoreClient"; import { isExistingResource, Resource } from "../../domain/entities/resources/Resource"; diff --git a/src/data/repositories/ResourceFileD2Repository.ts b/src/data/repositories/ResourceFileD2Repository.ts index 690e11b3..4ad8a00c 100644 --- a/src/data/repositories/ResourceFileD2Repository.ts +++ b/src/data/repositories/ResourceFileD2Repository.ts @@ -1,4 +1,4 @@ -import { D2Api } from "@eyeseetea/d2-api/2.36"; +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"; diff --git a/src/data/repositories/ResourcePermissionsD2Repository.ts b/src/data/repositories/ResourcePermissionsD2Repository.ts new file mode 100644 index 00000000..ec67e71f --- /dev/null +++ b/src/data/repositories/ResourcePermissionsD2Repository.ts @@ -0,0 +1,30 @@ +import { Future } from "../../domain/entities/generic/Future"; +import { + ResourcePermissions, + RTSL_ZEBRA_ACCESS_RESOURCES, + RTSL_ZEBRA_ADMIN_RESOURCES, + RTSL_ZEBRA_DATA_CAPTURE_RESOURCES, +} from "../../domain/entities/resources/ResourcePermissions"; +import { User } from "../../domain/entities/User"; +import { ResourcePermissionsRepository } from "../../domain/repositories/ResourcePermissionsRepository"; +import { D2Api } from "../../types/d2-api"; +import { FutureData } from "../api-futures"; +import { getUserGroupByCode } from "./utils/MetadataHelper"; + +export class ResourcePermissionsD2Repository implements ResourcePermissionsRepository { + constructor(private api: D2Api) {} + + getPermissions(currentUser: User): FutureData { + return Future.joinObj({ + adminUserGroup: getUserGroupByCode(this.api, RTSL_ZEBRA_ADMIN_RESOURCES), + dataCaptureUserGroup: getUserGroupByCode(this.api, RTSL_ZEBRA_DATA_CAPTURE_RESOURCES), + accessUserGroup: getUserGroupByCode(this.api, 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/data/repositories/test/ResourcePermissionsTestRepository.ts b/src/data/repositories/test/ResourcePermissionsTestRepository.ts new file mode 100644 index 00000000..0ad69d75 --- /dev/null +++ b/src/data/repositories/test/ResourcePermissionsTestRepository.ts @@ -0,0 +1,15 @@ +import { Future } from "../../../domain/entities/generic/Future"; +import { ResourcePermissions } from "../../../domain/entities/resources/ResourcePermissions"; +import { User } from "../../../domain/entities/User"; +import { ResourcePermissionsRepository } from "../../../domain/repositories/ResourcePermissionsRepository"; +import { FutureData } from "../../api-futures"; + +export class ResourcePermissionsTestRepository implements ResourcePermissionsRepository { + getPermissions(_currentUser: User): FutureData { + return Future.success({ + isAdmin: true, + isDataCapture: false, + isAccess: true, + }); + } +} 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/ResourcePermissionsRepository.ts b/src/domain/repositories/ResourcePermissionsRepository.ts new file mode 100644 index 00000000..69365cca --- /dev/null +++ b/src/domain/repositories/ResourcePermissionsRepository.ts @@ -0,0 +1,7 @@ +import { FutureData } from "../../data/api-futures"; +import { ResourcePermissions } from "../entities/resources/ResourcePermissions"; +import { User } from "../entities/User"; + +export interface ResourcePermissionsRepository { + getPermissions(currentUser: User): FutureData; +} diff --git a/src/domain/usecases/GetResourceUserPermissionsUseCase.ts b/src/domain/usecases/GetResourceUserPermissionsUseCase.ts new file mode 100644 index 00000000..0ff81042 --- /dev/null +++ b/src/domain/usecases/GetResourceUserPermissionsUseCase.ts @@ -0,0 +1,12 @@ +import { FutureData } from "../../data/api-futures"; +import { ResourcePermissions } from "../entities/resources/ResourcePermissions"; +import { User } from "../entities/User"; +import { ResourcePermissionsRepository } from "../repositories/ResourcePermissionsRepository"; + +export class GetResourceUserPermissionsUseCase { + constructor(private resourcePermissionsRepository: ResourcePermissionsRepository) {} + + public execute(currentUser: User): FutureData { + return this.resourcePermissionsRepository.getPermissions(currentUser); + } +} diff --git a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx index 5f06f85d..782769fe 100644 --- a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx +++ b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyItem.tsx @@ -7,12 +7,14 @@ 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, onDelete } = props; + const { isDeleting, responseDocument, userCanDelete, userCanDownload, onDelete } = props; return ( } /> diff --git a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyView.tsx b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyView.tsx index 562c5b25..52011c4c 100644 --- a/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyView.tsx +++ b/src/webapp/components/response-document-hierarchy/ResponseDocumentHierarchyView.tsx @@ -7,12 +7,14 @@ 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, onDelete } = props; + const { isDeleting, responseDocuments, userCanDelete, userCanDownload, onDelete } = props; const defaultCollapseIcon = ( <> @@ -40,6 +42,8 @@ export const ResponseDocumentHierarchyView: React.FC ); diff --git a/src/webapp/pages/form-page/mapEntityToFormState.ts b/src/webapp/pages/form-page/mapEntityToFormState.ts index bd4b8e64..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"; @@ -27,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": @@ -59,7 +67,7 @@ export function mapEntityToFormState(options: { case "incident-management-team-member-assignment": return mapIncidentManagementTeamMemberToInitialFormState(configurableForm); case "resource": - return mapResourceToInitialFormState(configurableForm); + return mapResourceToInitialFormState(configurableForm, resourcePermissions); } } diff --git a/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts b/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts index e03ce7fc..a2a30c93 100644 --- a/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts +++ b/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts @@ -4,6 +4,7 @@ import { getFieldIdFromIdsDictionary } from "../../../components/form/FormFields 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", @@ -12,7 +13,10 @@ export const resourceFieldIds = { resourceFile: "resourceFile", }; -export function mapResourceToInitialFormState(formData: ResourceFormData): FormState { +export function mapResourceToInitialFormState( + formData: ResourceFormData, + resourcePermissions: ResourcePermissions +): FormState { const { entity: resource, uploadedResourceFile, uploadedResourceFileId, options } = formData; const isResourceDocument = resource?.resourceType === ResourceType.RESPONSE_DOCUMENT; @@ -23,6 +27,8 @@ export function mapResourceToInitialFormState(formData: ResourceFormData): FormS const fromIdsDictionary = (key: keyof typeof resourceFieldIds) => getFieldIdFromIdsDictionary(key, resourceFieldIds); + const { isAdmin } = resourcePermissions; + return { id: "", title: "Resources", @@ -78,7 +84,7 @@ export function mapResourceToInitialFormState(formData: ResourceFormData): FormS type: "select", options: resourceFolderOptions, multiple: false, - addNewOption: true, + addNewOption: isAdmin, value: isResourceDocument ? resource.resourceFolder : "", required: true, }, diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index a0c9a50b..9c1dbbcb 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -30,6 +30,7 @@ import { RiskAssessmentQuestionnaire } from "../../../domain/entities/risk-asses 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; @@ -104,6 +105,7 @@ export function useForm(formType: FormType, id?: Id): State { setGlobalMessage, setIsLoading, }); + const { userPermissions: resourcePermissions } = useResources(); const allDataPerformanceEvents = dataPerformanceOverview?.map( event => event.hazardType || event.suspectedDisease @@ -134,6 +136,7 @@ export function useForm(formType: FormType, id?: Id): State { editMode: !!id, existingEventTrackerTypes: existingEventTrackerTypes, isIncidentManager: isIncidentManager, + resourcePermissions: resourcePermissions, }), }); setEntityData(formData); @@ -160,6 +163,7 @@ export function useForm(formType: FormType, id?: Id): State { goTo, isIncidentManager, existingEventTrackerTypes, + resourcePermissions, ]); const handleAddNew = useCallback(() => { diff --git a/src/webapp/pages/resources/ResourceLabel.tsx b/src/webapp/pages/resources/ResourceLabel.tsx index 1bbff2e6..dd427d0e 100644 --- a/src/webapp/pages/resources/ResourceLabel.tsx +++ b/src/webapp/pages/resources/ResourceLabel.tsx @@ -11,10 +11,18 @@ import { Loader } from "../../components/loader/Loader"; interface ResourceLabelProps { isDeleting: boolean; resource: ResourceBase; + userCanDelete: boolean; + userCanDownload: boolean; onDelete: () => void; } -export const ResourceLabel: React.FC = ({ isDeleting, resource, onDelete }) => { +export const ResourceLabel: React.FC = ({ + isDeleting, + resource, + userCanDelete, + userCanDownload, + onDelete, +}) => { const snackbar = useSnackbar(); const { resourceFileId, resourceLabel } = resource; const { globalMessage, resourceFile, deleteResource } = useResourceFile({ @@ -32,16 +40,20 @@ export const ResourceLabel: React.FC = ({ isDeleting, resour

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

- {resourceFileId && ( + {resourceFileId && userCanDelete && (
{isDeleting && }
@@ -64,6 +73,8 @@ export const ResourcesPage: React.FC = React.memo(() => { resource={template} isDeleting={isDeleting} onDelete={handleDelete} + userCanDelete={userCanUploadAndDelete} + userCanDownload={userCanDownload} /> ))} diff --git a/src/webapp/pages/resources/useResources.tsx b/src/webapp/pages/resources/useResources.tsx index fa37be3b..2ef9e880 100644 --- a/src/webapp/pages/resources/useResources.tsx +++ b/src/webapp/pages/resources/useResources.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +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"; @@ -13,6 +13,7 @@ import { } 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; @@ -30,11 +31,15 @@ export type ResourceData = { export function useResources() { const { goTo } = useRoutes(); - const { compositionRoot } = useAppContext(); + 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" }); @@ -65,15 +70,46 @@ export function useResources() { 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); From d22196ba4491a9248b2307b6aaf29f9a1244d0c2 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:52:16 +0100 Subject: [PATCH 11/27] feat: update translation files --- i18n/en.pot | 18 ++++++++++++++++-- i18n/es.po | 16 +++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index e8d1082c..f81aabb0 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-08T10:28:12.841Z\n" -"PO-Revision-Date: 2025-01-08T10:28:12.841Z\n" +"POT-Creation-Date: 2025-01-13T12:50:10.915Z\n" +"PO-Revision-Date: 2025-01-13T12:50:10.915Z\n" msgid "Low" msgstr "" @@ -239,6 +239,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 "" @@ -311,6 +319,12 @@ msgstr "" msgid "Resources" msgstr "" +msgid "No resources created" +msgstr "" + +msgid "You can upload a file to create a new resource" +msgstr "" + msgctxt "DATA" msgid "HISTORICAL_CASE" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 0f67938f..bb7aed73 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-08T10:28:12.841Z\n" +"POT-Creation-Date: 2025-01-13T12:50:10.915Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -238,6 +238,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 "" @@ -310,6 +318,12 @@ msgstr "" msgid "Resources" msgstr "" +msgid "No resources created" +msgstr "" + +msgid "You can upload a file to create a new resource" +msgstr "" + msgctxt "DATA" msgid "HISTORICAL_CASE" msgstr "" From c210e06a8e12ae4935c3094f5bc946ec29c44647 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:00:58 +0100 Subject: [PATCH 12/27] fix: update existing resource in datastore --- i18n/en.pot | 4 ++-- src/data/repositories/ResourceD2Repository.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index f81aabb0..10f77a5e 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-13T12:50:10.915Z\n" -"PO-Revision-Date: 2025-01-13T12:50:10.915Z\n" +"POT-Creation-Date: 2025-01-13T12:53:26.908Z\n" +"PO-Revision-Date: 2025-01-13T12:53:26.908Z\n" msgid "Low" msgstr "" diff --git a/src/data/repositories/ResourceD2Repository.ts b/src/data/repositories/ResourceD2Repository.ts index 61f2caa8..5ad4f833 100644 --- a/src/data/repositories/ResourceD2Repository.ts +++ b/src/data/repositories/ResourceD2Repository.ts @@ -52,9 +52,7 @@ export class ResourceD2Repository implements ResourceRepository { const resourceWithFileId = { ...resource, resourceFileId: resourceFileId }; const updatedResources = isResourceExisting ? resourcesInDataStore.map(resourceInDataStore => - resourceInDataStore.resourceLabel === resource.resourceLabel - ? resourceWithFileId - : resourceInDataStore + isResourceExisting ? resourceWithFileId : resourceInDataStore ) : [...resourcesInDataStore, resourceWithFileId]; return updatedResources; From 4cf04c86a07755eaad069d147d8b091bf4cc13d9 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:18:26 +0100 Subject: [PATCH 13/27] fix: remove unnecessary error handling --- src/domain/usecases/GetConfigurableFormUseCase.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/domain/usecases/GetConfigurableFormUseCase.ts b/src/domain/usecases/GetConfigurableFormUseCase.ts index 59f2b6f8..c829c680 100644 --- a/src/domain/usecases/GetConfigurableFormUseCase.ts +++ b/src/domain/usecases/GetConfigurableFormUseCase.ts @@ -150,13 +150,6 @@ export class GetConfigurableFormUseCase { configurations ); case "resource": - if (!eventTrackerDetails) - return Future.error( - new Error( - "Disease outbreak id is required for incident management team member builder" - ) - ); - return getResourceConfigurableForm({ resourceRepository: this.options.resourceRepository, }); From 5bc7df7b16552688e3059e82aee3696f4a7dd119 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:21:49 +0100 Subject: [PATCH 14/27] fix: refactor navigation logic for resource form type --- src/webapp/pages/form-page/useForm.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index 9c1dbbcb..ba9fa014 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -491,15 +491,15 @@ 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, }); break; } + } else if (formType === "resource") { + goTo(RouteName.RESOURCES); } else { goTo(RouteName.DASHBOARD); } From d42e16d1437c7a361674b72ef51869f7b44d75a0 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:26:25 +0100 Subject: [PATCH 15/27] fix: simplify navigation logic in onCancelForm --- src/webapp/pages/form-page/useForm.ts | 29 +++++++++------------------ 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index ba9fa014..3d909928 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -478,26 +478,15 @@ export function useForm(formType: FormType, id?: Id): State { const onCancelForm = useCallback(() => { if (currentEventTracker) { - switch (formType) { - case "incident-management-team-member-assignment": - goTo(RouteName.IM_TEAM_BUILDER, { - id: currentEventTracker.id, - }); - break; - case "incident-action-plan": - case "incident-response-actions": - case "incident-response-action": - goTo(RouteName.INCIDENT_ACTION_PLAN, { - id: currentEventTracker.id, - }); - break; - - default: - goTo(RouteName.EVENT_TRACKER, { - id: currentEventTracker.id, - }); - break; - } + const routeMap: Record = { + "incident-management-team-member-assignment": RouteName.IM_TEAM_BUILDER, + "incident-action-plan": RouteName.INCIDENT_ACTION_PLAN, + "incident-response-actions": RouteName.INCIDENT_ACTION_PLAN, + "incident-response-action": RouteName.INCIDENT_ACTION_PLAN, + }; + + const route = routeMap[formType] || RouteName.EVENT_TRACKER; + goTo(route, { id: currentEventTracker.id }); } else if (formType === "resource") { goTo(RouteName.RESOURCES); } else { From eb659d8c076355a49ffd217a38bb5392f9b02e51 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:31:24 +0100 Subject: [PATCH 16/27] feat: add admin contact message for resource creation --- i18n/en.pot | 7 +++++-- i18n/es.po | 5 ++++- src/webapp/pages/resources/ResourcesPage.tsx | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 10f77a5e..26986511 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-13T12:53:26.908Z\n" -"PO-Revision-Date: 2025-01-13T12:53:26.908Z\n" +"POT-Creation-Date: 2025-01-13T13:30:38.711Z\n" +"PO-Revision-Date: 2025-01-13T13:30:38.711Z\n" msgid "Low" msgstr "" @@ -325,6 +325,9 @@ 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 bb7aed73..68087408 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-13T12:50:10.915Z\n" +"POT-Creation-Date: 2025-01-13T13:30:38.711Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -324,6 +324,9 @@ 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/src/webapp/pages/resources/ResourcesPage.tsx b/src/webapp/pages/resources/ResourcesPage.tsx index e8dbab4b..c970c94b 100644 --- a/src/webapp/pages/resources/ResourcesPage.tsx +++ b/src/webapp/pages/resources/ResourcesPage.tsx @@ -83,7 +83,9 @@ export const ResourcesPage: React.FC = React.memo(() => {
) : ( - {i18n.t("You can upload a file to create a new resource")} + {userCanUploadAndDelete + ? i18n.t("You can upload a file to create a new resource") + : i18n.t("Contact admin to create resources")} )}
From 2df05d7de797f34286cd6eef1b6c46a55a9a6e0d Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:55:22 +0100 Subject: [PATCH 17/27] fix: update resource folder form field --- .../mapResourceToInitialFormState.ts | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts b/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts index a2a30c93..1acda6c8 100644 --- a/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts +++ b/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts @@ -26,7 +26,6 @@ export function mapResourceToInitialFormState( const fromIdsDictionary = (key: keyof typeof resourceFieldIds) => getFieldIdFromIdsDictionary(key, resourceFieldIds); - const { isAdmin } = resourcePermissions; return { @@ -37,58 +36,71 @@ export function mapResourceToInitialFormState( isValid: false, sections: [ { - title: "Resource type", - id: `${fromIdsDictionary("resourceType")}_section`, + title: "Resource name", + id: `${fromIdsDictionary("resourceLabel")}_section`, isVisible: true, required: true, fields: [ { - id: fromIdsDictionary("resourceType"), - placeholder: "Select a resource type", + id: fromIdsDictionary("resourceLabel"), isVisible: true, errors: [], - type: "select", - multiple: false, - options: resourceTypeOptions, - value: resource?.resourceType || "", + type: "text", + value: resource?.resourceLabel || "", required: true, }, ], }, { - title: "Resource name", - id: `${fromIdsDictionary("resourceLabel")}_section`, + title: "Resource type", + id: `${fromIdsDictionary("resourceType")}_section`, isVisible: true, required: true, fields: [ { - id: fromIdsDictionary("resourceLabel"), + id: fromIdsDictionary("resourceType"), + placeholder: "Select a resource type", isVisible: true, errors: [], - type: "text", - value: resource?.resourceLabel || "", + type: "select", + multiple: false, + options: resourceTypeOptions, + value: resource?.resourceType || "", required: true, }, ], }, + { title: "Resource folder", id: `${fromIdsDictionary("resourceFolder")}_section`, isVisible: isResourceDocument, required: true, - fields: [ - { - id: fromIdsDictionary("resourceFolder"), - isVisible: isResourceDocument, - errors: [], - type: "select", - options: resourceFolderOptions, - multiple: false, - addNewOption: isAdmin, - value: isResourceDocument ? resource.resourceFolder : "", - 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", From d37a61956941da1d03bdd5a23a27dfc8c423cc65 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 13 Jan 2025 15:03:51 +0100 Subject: [PATCH 18/27] fix: simplify navigation logic in onCancelForm --- src/webapp/pages/form-page/useForm.ts | 33 ++++++++++++++++++--------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index 3d909928..9c1dbbcb 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -478,17 +478,28 @@ export function useForm(formType: FormType, id?: Id): State { const onCancelForm = useCallback(() => { if (currentEventTracker) { - const routeMap: Record = { - "incident-management-team-member-assignment": RouteName.IM_TEAM_BUILDER, - "incident-action-plan": RouteName.INCIDENT_ACTION_PLAN, - "incident-response-actions": RouteName.INCIDENT_ACTION_PLAN, - "incident-response-action": RouteName.INCIDENT_ACTION_PLAN, - }; - - const route = routeMap[formType] || RouteName.EVENT_TRACKER; - goTo(route, { id: currentEventTracker.id }); - } else if (formType === "resource") { - goTo(RouteName.RESOURCES); + switch (formType) { + case "incident-management-team-member-assignment": + goTo(RouteName.IM_TEAM_BUILDER, { + id: currentEventTracker.id, + }); + break; + case "incident-action-plan": + case "incident-response-actions": + case "incident-response-action": + goTo(RouteName.INCIDENT_ACTION_PLAN, { + id: currentEventTracker.id, + }); + break; + case "resource": + goTo(RouteName.RESOURCES); + break; + default: + goTo(RouteName.EVENT_TRACKER, { + id: currentEventTracker.id, + }); + break; + } } else { goTo(RouteName.DASHBOARD); } From c50899a0ba2f5984659d3a50b338d242b5984d1a Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 13 Jan 2025 15:19:46 +0100 Subject: [PATCH 19/27] fix: correct value assignment in validateField function --- src/domain/usecases/utils/resources/SaveResources.ts | 0 src/webapp/components/form/FormFieldsState.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 src/domain/usecases/utils/resources/SaveResources.ts diff --git a/src/domain/usecases/utils/resources/SaveResources.ts b/src/domain/usecases/utils/resources/SaveResources.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/webapp/components/form/FormFieldsState.ts b/src/webapp/components/form/FormFieldsState.ts index 56b95288..f3085974 100644 --- a/src/webapp/components/form/FormFieldsState.ts +++ b/src/webapp/components/form/FormFieldsState.ts @@ -257,7 +257,7 @@ export function validateField( ? { property: field.id, errors: errors, - value: field.type === "file" ? field.fileId : field.value, + value: field.value, } : undefined; } From 8c12e0c947f82500855b4bcd5c60bf6a651796af Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:08:43 +0100 Subject: [PATCH 20/27] fix: update translation and addNew button label --- i18n/en.pot | 7 +++++-- i18n/es.po | 8 ++++---- src/webapp/components/selector/AddNewOption.tsx | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 26986511..e7202a77 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-13T13:30:38.711Z\n" -"PO-Revision-Date: 2025-01-13T13:30:38.711Z\n" +"POT-Creation-Date: 2025-01-15T15:08:07.179Z\n" +"PO-Revision-Date: 2025-01-15T15:08:07.180Z\n" msgid "Low" msgstr "" @@ -150,6 +150,9 @@ msgstr "" msgid "Last updated: " msgstr "" +msgid "Add" +msgstr "" + msgid "Median" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 68087408..6b56a9c2 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-13T13:30:38.711Z\n" +"POT-Creation-Date: 2025-01-15T15:08:07.179Z\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 "" @@ -331,9 +334,6 @@ msgctxt "DATA" msgid "HISTORICAL_CASE" msgstr "" -#~ msgid "Add" -#~ msgstr "Añadir" - #~ msgid "List" #~ msgstr "Listar" diff --git a/src/webapp/components/selector/AddNewOption.tsx b/src/webapp/components/selector/AddNewOption.tsx index 2001d0bf..eb319847 100644 --- a/src/webapp/components/selector/AddNewOption.tsx +++ b/src/webapp/components/selector/AddNewOption.tsx @@ -31,7 +31,7 @@ export const AddNewOption: React.FC = React.memo( }} /> - + ); } From 41b53354be2e13c24af4d5b9f2063f31fa0fdaef Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:12:20 +0100 Subject: [PATCH 21/27] refactor: update saveResource logic --- src/data/repositories/ResourceD2Repository.ts | 30 ++++++------------- .../repositories/ResourceFileD2Repository.ts | 9 ++++++ .../test/ResourceFileTestRepository.ts | 4 +++ .../repositories/ResourceFileRepository.ts | 1 + src/domain/usecases/GetResourcesUseCase.ts | 5 +--- src/domain/usecases/SaveEntityUseCase.ts | 17 +++++++++-- 6 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/data/repositories/ResourceD2Repository.ts b/src/data/repositories/ResourceD2Repository.ts index 5ad4f833..ddf6323f 100644 --- a/src/data/repositories/ResourceD2Repository.ts +++ b/src/data/repositories/ResourceD2Repository.ts @@ -6,7 +6,6 @@ import { apiToFuture, FutureData } from "../api-futures"; import { Future } from "../../domain/entities/generic/Future"; import { ResourceFormData } from "../../domain/entities/ConfigurableForm"; import { Id } from "../../domain/entities/Ref"; -import _c from "../../domain/entities/generic/Collection"; const RESOURCES_KEY = "resources"; @@ -24,21 +23,19 @@ export class ResourceD2Repository implements ResourceRepository { } saveResource(formData: ResourceFormData): FutureData { - const { entity: resource, uploadedResourceFile } = formData; + const { entity: resource, uploadedResourceFileId } = formData; - if (!resource) throw new Error("No resource form data found"); - if (!uploadedResourceFile) return Future.error(new Error("No file uploaded")); + if (!resource) return Future.error(new Error("No resource form data found")); + if (!uploadedResourceFileId) return Future.error(new Error("No resource file id found")); return this.getAllResources().flatMap(resourcesInDataStore => { - return this.uploadFile(uploadedResourceFile).flatMap(resourceFileId => { - const updatedResources = this.getResourcesToSave( - resourcesInDataStore, - resource, - resourceFileId - ); + const updatedResources = this.getResourcesToSave( + resourcesInDataStore, + resource, + uploadedResourceFileId + ); - return this.dataStoreClient.saveObject(RESOURCES_KEY, updatedResources); - }); + return this.dataStoreClient.saveObject(RESOURCES_KEY, updatedResources); }); } @@ -58,15 +55,6 @@ export class ResourceD2Repository implements ResourceRepository { return updatedResources; } - private uploadFile(file: File): FutureData { - return apiToFuture( - this.api.files.upload({ - name: file.name, - data: file, - }) - ).flatMap(fileResource => Future.success(fileResource.id)); - } - deleteResource(fileId: Id): FutureData { return apiToFuture(this.api.files.delete(fileId)).flatMap(response => { if (response.httpStatus === "OK") { diff --git a/src/data/repositories/ResourceFileD2Repository.ts b/src/data/repositories/ResourceFileD2Repository.ts index 4ad8a00c..bfdbe29a 100644 --- a/src/data/repositories/ResourceFileD2Repository.ts +++ b/src/data/repositories/ResourceFileD2Repository.ts @@ -8,6 +8,15 @@ import { ResourceFileRepository } from "../../domain/repositories/ResourceFileRe 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")); diff --git a/src/data/repositories/test/ResourceFileTestRepository.ts b/src/data/repositories/test/ResourceFileTestRepository.ts index efb68d04..75726c03 100644 --- a/src/data/repositories/test/ResourceFileTestRepository.ts +++ b/src/data/repositories/test/ResourceFileTestRepository.ts @@ -5,6 +5,10 @@ import { ResourceFileRepository } from "../../../domain/repositories/ResourceFil 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, diff --git a/src/domain/repositories/ResourceFileRepository.ts b/src/domain/repositories/ResourceFileRepository.ts index 8d3272c1..335a0731 100644 --- a/src/domain/repositories/ResourceFileRepository.ts +++ b/src/domain/repositories/ResourceFileRepository.ts @@ -3,5 +3,6 @@ import { Id } from "../entities/Ref"; import { ResourceFile } from "../entities/resources/ResourceFile"; export interface ResourceFileRepository { + uploadFile(file: File): FutureData; downloadFile(fileId: Id): FutureData; } diff --git a/src/domain/usecases/GetResourcesUseCase.ts b/src/domain/usecases/GetResourcesUseCase.ts index 7c4e871f..384cb3c4 100644 --- a/src/domain/usecases/GetResourcesUseCase.ts +++ b/src/domain/usecases/GetResourcesUseCase.ts @@ -1,5 +1,4 @@ import { FutureData } from "../../data/api-futures"; -import { Future } from "../entities/generic/Future"; import { Resource } from "../entities/resources/Resource"; import { ResourceRepository } from "../repositories/ResourceRepository"; @@ -7,8 +6,6 @@ export class GetResourcesUseCase { constructor(private resourceRepository: ResourceRepository) {} public execute(): FutureData { - return this.resourceRepository - .getAllResources() - .flatMap(resources => Future.success(resources)); + return this.resourceRepository.getAllResources(); } } diff --git a/src/domain/usecases/SaveEntityUseCase.ts b/src/domain/usecases/SaveEntityUseCase.ts index 7ecc6aec..8d53d0e1 100644 --- a/src/domain/usecases/SaveEntityUseCase.ts +++ b/src/domain/usecases/SaveEntityUseCase.ts @@ -18,6 +18,7 @@ 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( @@ -30,6 +31,7 @@ export class SaveEntityUseCase { roleRepository: RoleRepository; casesFileRepository: CasesFileRepository; resourceRepository: ResourceRepository; + resourceFileRepository: ResourceFileRepository; } ) {} @@ -150,8 +152,19 @@ export class SaveEntityUseCase { ); } } - case "resource": - return this.options.resourceRepository.saveResource(formData); + case "resource": { + const { uploadedResourceFile } = formData; + if (!uploadedResourceFile) return Future.error(new Error("No file uploaded")); + + return this.options.resourceFileRepository + .uploadFile(uploadedResourceFile) + .flatMap(resourceFileId => { + return this.options.resourceRepository.saveResource({ + ...formData, + uploadedResourceFileId: resourceFileId, + }); + }); + } default: return Future.error(new Error("Form type not supported")); } From 1c4575adbcae19d0aca0390cc8dd42224202da0a Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:25:52 +0100 Subject: [PATCH 22/27] refactor: update deleteResourceFile logic and repository structure --- src/CompositionRoot.ts | 2 +- src/data/repositories/ResourceD2Repository.ts | 21 ++++++------------- .../repositories/ResourceFileD2Repository.ts | 7 +++++++ .../test/ResourceFileTestRepository.ts | 4 ++++ .../repositories/ResourceFileRepository.ts | 1 + .../usecases/DeleteResourceFileUseCase.ts | 12 +++++++++-- 6 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 9e70f5e5..12960dd3 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -168,7 +168,7 @@ function getCompositionRoot(repositories: Repositories) { downloadResourceFile: new DownloadResourceFileUseCase( repositories.resourceFileRepository ), - deleteResourceFile: new DeleteResourceFileUseCase(repositories.resourceRepository), + deleteResourceFile: new DeleteResourceFileUseCase(repositories), getPermissions: new GetResourceUserPermissionsUseCase( repositories.resourcePermissionsRepository ), diff --git a/src/data/repositories/ResourceD2Repository.ts b/src/data/repositories/ResourceD2Repository.ts index ddf6323f..9f383a7c 100644 --- a/src/data/repositories/ResourceD2Repository.ts +++ b/src/data/repositories/ResourceD2Repository.ts @@ -2,7 +2,7 @@ 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 { apiToFuture, FutureData } from "../api-futures"; +import { FutureData } from "../api-futures"; import { Future } from "../../domain/entities/generic/Future"; import { ResourceFormData } from "../../domain/entities/ConfigurableForm"; import { Id } from "../../domain/entities/Ref"; @@ -56,21 +56,12 @@ export class ResourceD2Repository implements ResourceRepository { } deleteResource(fileId: Id): FutureData { - return apiToFuture(this.api.files.delete(fileId)).flatMap(response => { - if (response.httpStatus === "OK") { - return this.getAllResources().flatMap(resources => { - const updatedResources = resources.filter( - resource => resource.resourceFileId !== fileId - ); + return this.getAllResources().flatMap(resources => { + const updatedResources = resources.filter( + resource => resource.resourceFileId !== fileId + ); - return this.dataStoreClient.saveObject( - RESOURCES_KEY, - updatedResources - ); - }); - } else { - return Future.error(new Error("Error while deleting resource file")); - } + return this.dataStoreClient.saveObject(RESOURCES_KEY, updatedResources); }); } } diff --git a/src/data/repositories/ResourceFileD2Repository.ts b/src/data/repositories/ResourceFileD2Repository.ts index bfdbe29a..3d789033 100644 --- a/src/data/repositories/ResourceFileD2Repository.ts +++ b/src/data/repositories/ResourceFileD2Repository.ts @@ -31,4 +31,11 @@ export class ResourceFileD2Repository implements ResourceFileRepository { }) ); } + + 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 index 75726c03..c42519bb 100644 --- a/src/data/repositories/test/ResourceFileTestRepository.ts +++ b/src/data/repositories/test/ResourceFileTestRepository.ts @@ -15,4 +15,8 @@ export class ResourceFileTestRepository implements ResourceFileRepository { file: new File(["test"], "test.txt", { type: "text/plain" }), }); } + + deleteResourceFile(_fileId: Id): FutureData { + return Future.success(undefined); + } } diff --git a/src/domain/repositories/ResourceFileRepository.ts b/src/domain/repositories/ResourceFileRepository.ts index 335a0731..0d9e20b1 100644 --- a/src/domain/repositories/ResourceFileRepository.ts +++ b/src/domain/repositories/ResourceFileRepository.ts @@ -5,4 +5,5 @@ 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/usecases/DeleteResourceFileUseCase.ts b/src/domain/usecases/DeleteResourceFileUseCase.ts index eab0b58f..24344d8d 100644 --- a/src/domain/usecases/DeleteResourceFileUseCase.ts +++ b/src/domain/usecases/DeleteResourceFileUseCase.ts @@ -1,10 +1,18 @@ import { FutureData } from "../../data/api-futures"; +import { ResourceFileRepository } from "../repositories/ResourceFileRepository"; import { ResourceRepository } from "../repositories/ResourceRepository"; export class DeleteResourceFileUseCase { - constructor(private resourceRepository: ResourceRepository) {} + constructor( + private options: { + resourceRepository: ResourceRepository; + resourceFileRepository: ResourceFileRepository; + } + ) {} public execute(fileId: string): FutureData { - return this.resourceRepository.deleteResource(fileId); + return this.options.resourceFileRepository + .deleteResourceFile(fileId) + .flatMap(() => this.options.resourceRepository.deleteResource(fileId)); } } From bbb5374b78ae89bcd08190c29167c3c3fe50c456 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Wed, 15 Jan 2025 17:04:41 +0100 Subject: [PATCH 23/27] feat: add confirmation modal for resource deletion --- src/webapp/pages/resources/ResourceLabel.tsx | 42 +++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/webapp/pages/resources/ResourceLabel.tsx b/src/webapp/pages/resources/ResourceLabel.tsx index dd427d0e..fc508758 100644 --- a/src/webapp/pages/resources/ResourceLabel.tsx +++ b/src/webapp/pages/resources/ResourceLabel.tsx @@ -1,12 +1,14 @@ 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 } from "react"; +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; @@ -24,6 +26,7 @@ export const ResourceLabel: React.FC = ({ onDelete, }) => { const snackbar = useSnackbar(); + const [openModal, setOpenModal] = useState(false); const { resourceFileId, resourceLabel } = resource; const { globalMessage, resourceFile, deleteResource } = useResourceFile({ resourceFileId: resourceFileId, @@ -54,12 +57,36 @@ export const ResourceLabel: React.FC = ({

{resourceFileId && userCanDelete && ( -
- {isDeleting && } - -
+ + 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." + )} +

+
+ )} ); @@ -78,3 +105,8 @@ const StyledTemplateLabel = styled.div` margin: 0; } `; + +const StyledButton = styled(Button)` + background-color: ${props => props.theme.palette.error.main}; + color: ${props => props.theme.palette.common.white}; +`; From 829f5ba69373a0fc6e71d050ee1f8a6d6d11e837 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Wed, 15 Jan 2025 17:16:26 +0100 Subject: [PATCH 24/27] fix: update translation files --- i18n/en.pot | 7 +++++-- i18n/es.po | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index e7202a77..7e3cdd57 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-15T15:08:07.179Z\n" -"PO-Revision-Date: 2025-01-15T15:08:07.180Z\n" +"POT-Creation-Date: 2025-01-15T16:05:28.418Z\n" +"PO-Revision-Date: 2025-01-15T16:05:28.418Z\n" msgid "Low" msgstr "" @@ -316,6 +316,9 @@ 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 "" diff --git a/i18n/es.po b/i18n/es.po index 6b56a9c2..e390ef36 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-15T15:08:07.179Z\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" @@ -315,6 +315,10 @@ 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 "" From c8a53e35d656113beeca867b7ca318f168cacc9a Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:43:06 +0100 Subject: [PATCH 25/27] refactor: use userGroupRepository to get resource permission userGroups --- i18n/en.pot | 4 +-- src/CompositionRoot.ts | 10 +----- .../ResourcePermissionsD2Repository.ts | 30 ---------------- .../test/ResourcePermissionsTestRepository.ts | 15 -------- .../ResourcePermissionsRepository.ts | 7 ---- .../GetResourceUserPermissionsUseCase.ts | 34 ++++++++++++++++--- 6 files changed, 33 insertions(+), 67 deletions(-) delete mode 100644 src/data/repositories/ResourcePermissionsD2Repository.ts delete mode 100644 src/data/repositories/test/ResourcePermissionsTestRepository.ts delete mode 100644 src/domain/repositories/ResourcePermissionsRepository.ts diff --git a/i18n/en.pot b/i18n/en.pot index d89ad317..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 "" diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 599b4bce..40a3cc41 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -83,9 +83,6 @@ import { DeleteResourceFileUseCase } from "./domain/usecases/DeleteResourceFileU import { ResourceFileTestRepository } from "./data/repositories/test/ResourceFileTestRepository"; import { ResourceFileRepository } from "./domain/repositories/ResourceFileRepository"; import { ResourceFileD2Repository } from "./data/repositories/ResourceFileD2Repository"; -import { ResourcePermissionsD2Repository } from "./data/repositories/ResourcePermissionsD2Repository"; -import { ResourcePermissionsTestRepository } from "./data/repositories/test/ResourcePermissionsTestRepository"; -import { ResourcePermissionsRepository } from "./domain/repositories/ResourcePermissionsRepository"; import { GetResourceUserPermissionsUseCase } from "./domain/usecases/GetResourceUserPermissionsUseCase"; export type CompositionRoot = ReturnType; @@ -110,7 +107,6 @@ type Repositories = { userGroupRepository: UserGroupRepository; resourceRepository: ResourceRepository; resourceFileRepository: ResourceFileRepository; - resourcePermissionsRepository: ResourcePermissionsRepository; }; function getCompositionRoot(repositories: Repositories) { @@ -172,9 +168,7 @@ function getCompositionRoot(repositories: Repositories) { repositories.resourceFileRepository ), deleteResourceFile: new DeleteResourceFileUseCase(repositories), - getPermissions: new GetResourceUserPermissionsUseCase( - repositories.resourcePermissionsRepository - ), + getPermissions: new GetResourceUserPermissionsUseCase(repositories), }, }; } @@ -201,7 +195,6 @@ export function getWebappCompositionRoot(api: D2Api) { userGroupRepository: new UserGroupD2Repository(api), resourceRepository: new ResourceD2Repository(api), resourceFileRepository: new ResourceFileD2Repository(api), - resourcePermissionsRepository: new ResourcePermissionsD2Repository(api), }; return getCompositionRoot(repositories); @@ -228,7 +221,6 @@ export function getTestCompositionRoot() { userGroupRepository: new UserGroupTestRepository(), resourceRepository: new ResourceTestRepository(), resourceFileRepository: new ResourceFileTestRepository(), - resourcePermissionsRepository: new ResourcePermissionsTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/ResourcePermissionsD2Repository.ts b/src/data/repositories/ResourcePermissionsD2Repository.ts deleted file mode 100644 index ec67e71f..00000000 --- a/src/data/repositories/ResourcePermissionsD2Repository.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Future } from "../../domain/entities/generic/Future"; -import { - ResourcePermissions, - RTSL_ZEBRA_ACCESS_RESOURCES, - RTSL_ZEBRA_ADMIN_RESOURCES, - RTSL_ZEBRA_DATA_CAPTURE_RESOURCES, -} from "../../domain/entities/resources/ResourcePermissions"; -import { User } from "../../domain/entities/User"; -import { ResourcePermissionsRepository } from "../../domain/repositories/ResourcePermissionsRepository"; -import { D2Api } from "../../types/d2-api"; -import { FutureData } from "../api-futures"; -import { getUserGroupByCode } from "./utils/MetadataHelper"; - -export class ResourcePermissionsD2Repository implements ResourcePermissionsRepository { - constructor(private api: D2Api) {} - - getPermissions(currentUser: User): FutureData { - return Future.joinObj({ - adminUserGroup: getUserGroupByCode(this.api, RTSL_ZEBRA_ADMIN_RESOURCES), - dataCaptureUserGroup: getUserGroupByCode(this.api, RTSL_ZEBRA_DATA_CAPTURE_RESOURCES), - accessUserGroup: getUserGroupByCode(this.api, 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/data/repositories/test/ResourcePermissionsTestRepository.ts b/src/data/repositories/test/ResourcePermissionsTestRepository.ts deleted file mode 100644 index 0ad69d75..00000000 --- a/src/data/repositories/test/ResourcePermissionsTestRepository.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Future } from "../../../domain/entities/generic/Future"; -import { ResourcePermissions } from "../../../domain/entities/resources/ResourcePermissions"; -import { User } from "../../../domain/entities/User"; -import { ResourcePermissionsRepository } from "../../../domain/repositories/ResourcePermissionsRepository"; -import { FutureData } from "../../api-futures"; - -export class ResourcePermissionsTestRepository implements ResourcePermissionsRepository { - getPermissions(_currentUser: User): FutureData { - return Future.success({ - isAdmin: true, - isDataCapture: false, - isAccess: true, - }); - } -} diff --git a/src/domain/repositories/ResourcePermissionsRepository.ts b/src/domain/repositories/ResourcePermissionsRepository.ts deleted file mode 100644 index 69365cca..00000000 --- a/src/domain/repositories/ResourcePermissionsRepository.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { FutureData } from "../../data/api-futures"; -import { ResourcePermissions } from "../entities/resources/ResourcePermissions"; -import { User } from "../entities/User"; - -export interface ResourcePermissionsRepository { - getPermissions(currentUser: User): FutureData; -} diff --git a/src/domain/usecases/GetResourceUserPermissionsUseCase.ts b/src/domain/usecases/GetResourceUserPermissionsUseCase.ts index 0ff81042..f634af2b 100644 --- a/src/domain/usecases/GetResourceUserPermissionsUseCase.ts +++ b/src/domain/usecases/GetResourceUserPermissionsUseCase.ts @@ -1,12 +1,38 @@ import { FutureData } from "../../data/api-futures"; -import { ResourcePermissions } from "../entities/resources/ResourcePermissions"; +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 { ResourcePermissionsRepository } from "../repositories/ResourcePermissionsRepository"; +import { UserGroupRepository } from "../repositories/UserGroupRepository"; export class GetResourceUserPermissionsUseCase { - constructor(private resourcePermissionsRepository: ResourcePermissionsRepository) {} + constructor( + private options: { + userGroupRepository: UserGroupRepository; + } + ) {} public execute(currentUser: User): FutureData { - return this.resourcePermissionsRepository.getPermissions(currentUser); + 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 }; + }); } } From bb82dc2a7a1045761b23f910a11614f029548e50 Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Tue, 21 Jan 2025 12:36:30 +0100 Subject: [PATCH 26/27] refactor: use resource entity in resource saving logic --- src/data/repositories/ResourceD2Repository.ts | 23 ++++--------------- .../test/ResourceTestRepository.ts | 3 +-- src/domain/entities/ConfigurableForm.ts | 1 - src/domain/repositories/ResourceRepository.ts | 3 +-- src/domain/usecases/SaveEntityUseCase.ts | 14 +++++++---- .../resources/GetResourceConfigurableForm.ts | 1 - .../form-page/mapFormStateToEntityData.ts | 2 -- .../mapResourceToInitialFormState.ts | 4 ++-- 8 files changed, 19 insertions(+), 32 deletions(-) diff --git a/src/data/repositories/ResourceD2Repository.ts b/src/data/repositories/ResourceD2Repository.ts index 9f383a7c..22735bd9 100644 --- a/src/data/repositories/ResourceD2Repository.ts +++ b/src/data/repositories/ResourceD2Repository.ts @@ -4,7 +4,6 @@ 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 { ResourceFormData } from "../../domain/entities/ConfigurableForm"; import { Id } from "../../domain/entities/Ref"; const RESOURCES_KEY = "resources"; @@ -22,36 +21,24 @@ export class ResourceD2Repository implements ResourceRepository { .flatMap(resources => Future.success(resources ?? [])); } - saveResource(formData: ResourceFormData): FutureData { - const { entity: resource, uploadedResourceFileId } = formData; - + saveResource(resource: Resource): FutureData { if (!resource) return Future.error(new Error("No resource form data found")); - if (!uploadedResourceFileId) return Future.error(new Error("No resource file id found")); return this.getAllResources().flatMap(resourcesInDataStore => { - const updatedResources = this.getResourcesToSave( - resourcesInDataStore, - resource, - uploadedResourceFileId - ); + const updatedResources = this.getResourcesToSave(resourcesInDataStore, resource); return this.dataStoreClient.saveObject(RESOURCES_KEY, updatedResources); }); } - private getResourcesToSave( - resourcesInDataStore: Resource[], - resource: Resource, - resourceFileId: string - ) { + private getResourcesToSave(resourcesInDataStore: Resource[], resource: Resource) { const isResourceExisting = isExistingResource(resourcesInDataStore, resource); - const resourceWithFileId = { ...resource, resourceFileId: resourceFileId }; const updatedResources = isResourceExisting ? resourcesInDataStore.map(resourceInDataStore => - isResourceExisting ? resourceWithFileId : resourceInDataStore + isResourceExisting ? resource : resourceInDataStore ) - : [...resourcesInDataStore, resourceWithFileId]; + : [...resourcesInDataStore, resource]; return updatedResources; } diff --git a/src/data/repositories/test/ResourceTestRepository.ts b/src/data/repositories/test/ResourceTestRepository.ts index 5f34630f..bded4adb 100644 --- a/src/data/repositories/test/ResourceTestRepository.ts +++ b/src/data/repositories/test/ResourceTestRepository.ts @@ -1,5 +1,4 @@ import { Id } from "@eyeseetea/d2-api"; -import { ResourceFormData } from "../../../domain/entities/ConfigurableForm"; import { Future } from "../../../domain/entities/generic/Future"; import { Resource, ResourceType } from "../../../domain/entities/resources/Resource"; import { ResourceRepository } from "../../../domain/repositories/ResourceRepository"; @@ -24,7 +23,7 @@ export class ResourceTestRepository implements ResourceRepository { return Future.success(resources); } - saveResource(_formData: ResourceFormData): FutureData { + saveResource(_resource: Resource): FutureData { return Future.success(undefined); } diff --git a/src/domain/entities/ConfigurableForm.ts b/src/domain/entities/ConfigurableForm.ts index 79e6081d..636224b4 100644 --- a/src/domain/entities/ConfigurableForm.ts +++ b/src/domain/entities/ConfigurableForm.ts @@ -134,7 +134,6 @@ export type ResourceFormData = BaseFormData & { type: "resource"; entity: Maybe; uploadedResourceFile: Maybe; - uploadedResourceFileId: Maybe; options: ResourceOptions; }; diff --git a/src/domain/repositories/ResourceRepository.ts b/src/domain/repositories/ResourceRepository.ts index e232d271..a64681ad 100644 --- a/src/domain/repositories/ResourceRepository.ts +++ b/src/domain/repositories/ResourceRepository.ts @@ -1,10 +1,9 @@ import { FutureData } from "../../data/api-futures"; -import { ResourceFormData } from "../entities/ConfigurableForm"; import { Id } from "../entities/Ref"; import { Resource } from "../entities/resources/Resource"; export interface ResourceRepository { getAllResources(): FutureData; - saveResource(formData: ResourceFormData): FutureData; + saveResource(resource: Resource): FutureData; deleteResource(fileId: Id): FutureData; } diff --git a/src/domain/usecases/SaveEntityUseCase.ts b/src/domain/usecases/SaveEntityUseCase.ts index 8d53d0e1..5ed6cc77 100644 --- a/src/domain/usecases/SaveEntityUseCase.ts +++ b/src/domain/usecases/SaveEntityUseCase.ts @@ -159,10 +159,16 @@ export class SaveEntityUseCase { return this.options.resourceFileRepository .uploadFile(uploadedResourceFile) .flatMap(resourceFileId => { - return this.options.resourceRepository.saveResource({ - ...formData, - uploadedResourceFileId: 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: diff --git a/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts b/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts index 53288791..6a8b89c5 100644 --- a/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts +++ b/src/domain/usecases/utils/resources/GetResourceConfigurableForm.ts @@ -35,7 +35,6 @@ export function getResourceConfigurableForm(props: { resourceFolder: resourceFolderOptions, }, uploadedResourceFile: undefined, - uploadedResourceFileId: undefined, labels: labels, rules: rules, }; diff --git a/src/webapp/pages/form-page/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/mapFormStateToEntityData.ts index 00118ad3..d8dff913 100644 --- a/src/webapp/pages/form-page/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/mapFormStateToEntityData.ts @@ -150,13 +150,11 @@ export function mapFormStateToEntityData( const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); const uploadedResourceFileValue = getFileFieldValue("resourceFile", allFields); - const uploadedResourceFileId = getFieldFileIdById("resourceFile", allFields); const resourceForm: ResourceFormData = { ...formData, entity: resource, uploadedResourceFile: uploadedResourceFileValue, - uploadedResourceFileId: uploadedResourceFileId, }; return resourceForm; diff --git a/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts b/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts index 1acda6c8..7a418c7c 100644 --- a/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts +++ b/src/webapp/pages/form-page/resources/mapResourceToInitialFormState.ts @@ -17,7 +17,7 @@ export function mapResourceToInitialFormState( formData: ResourceFormData, resourcePermissions: ResourcePermissions ): FormState { - const { entity: resource, uploadedResourceFile, uploadedResourceFileId, options } = formData; + const { entity: resource, uploadedResourceFile, options } = formData; const isResourceDocument = resource?.resourceType === ResourceType.RESPONSE_DOCUMENT; const { resourceType, resourceFolder } = options; @@ -114,7 +114,7 @@ export function mapResourceToInitialFormState( errors: [], type: "file", value: uploadedResourceFile || undefined, - fileId: uploadedResourceFileId || undefined, + fileId: resource?.resourceFileId || undefined, required: true, data: undefined, fileTemplate: undefined, From 22e9e65177e2377ed1dc4600bae7868dc08e821b Mon Sep 17 00:00:00 2001 From: Chukwudumebi Onwuli <37223065+deeonwuli@users.noreply.github.com> Date: Thu, 23 Jan 2025 13:39:14 +0100 Subject: [PATCH 27/27] fix: update file type in ResourceFileD2Repository --- src/data/repositories/ResourceFileD2Repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/repositories/ResourceFileD2Repository.ts b/src/data/repositories/ResourceFileD2Repository.ts index 3d789033..bd4eef18 100644 --- a/src/data/repositories/ResourceFileD2Repository.ts +++ b/src/data/repositories/ResourceFileD2Repository.ts @@ -22,7 +22,7 @@ export class ResourceFileD2Repository implements ResourceFileRepository { return apiToFuture(this.api.files.get(fileId)) .map(blob => { - return new File([blob], "file", { type: "application/pdf" }); + return new File([blob], "file", { type: blob.type }); }) .flatMap(file => Future.success({