diff --git a/src/domain/entities/ValidationError.ts b/src/domain/entities/ValidationError.ts index 1153cc96..8988c51a 100644 --- a/src/domain/entities/ValidationError.ts +++ b/src/domain/entities/ValidationError.ts @@ -1,6 +1,9 @@ import { Maybe } from "../../utils/ts-utils"; -export type ValidationErrorKey = "field_is_required" | "field_is_required_na"; +export type ValidationErrorKey = + | "field_is_required" + | "field_is_required_na" + | "cannot_create_cyclycal_dependency"; export type ValidationError = { property: string; diff --git a/src/domain/entities/incident-management-team/IncidentManagementTeam.ts b/src/domain/entities/incident-management-team/IncidentManagementTeam.ts index 1764456c..a47ee380 100644 --- a/src/domain/entities/incident-management-team/IncidentManagementTeam.ts +++ b/src/domain/entities/incident-management-team/IncidentManagementTeam.ts @@ -1,5 +1,7 @@ import { Maybe } from "../../../utils/ts-utils"; +import { TEAM_ROLE_FIELD_ID } from "../../../webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; import { Struct } from "../generic/Struct"; +import { ValidationError } from "../ValidationError"; import { TeamMember } from "./TeamMember"; interface IncidentManagementTeamAttrs { @@ -7,4 +9,63 @@ interface IncidentManagementTeamAttrs { teamHierarchy: TeamMember[]; } -export class IncidentManagementTeam extends Struct() {} +export class IncidentManagementTeam extends Struct() { + static validateNotCyclicalDependency( + teamMember: string | undefined, + reportsToUsername: string | undefined, + currentIncidentManagementTeamHierarchy: TeamMember[], + property: string + ): ValidationError[] { + const descendantsUsernamesByParentUsername = getAllDescendantsUsernamesByParentUsername( + currentIncidentManagementTeamHierarchy + ); + + const descendantsFromTeamMember = + descendantsUsernamesByParentUsername.get(teamMember ?? "") ?? []; + + const isCyclicalDependency = descendantsFromTeamMember.includes(reportsToUsername ?? ""); + + return property === TEAM_ROLE_FIELD_ID || !isCyclicalDependency + ? [] + : [ + { + property: property, + value: "", + errors: ["cannot_create_cyclycal_dependency"], + }, + ]; + } +} + +function getAllDescendantsUsernamesByParentUsername( + teamMembers: TeamMember[] +): Map { + const initialMap = teamMembers.reduce((acc, member) => { + const entries = (member.teamRoles ?? []).map(role => ({ + parentUsername: role.reportsToUsername ?? "PARENT_ROOT", + username: member.username, + })); + + return entries.reduce((mapAcc, { parentUsername, username }) => { + return mapAcc.set(parentUsername, [...(mapAcc.get(parentUsername) ?? []), username]); + }, acc); + }, new Map()); + + const getDescendantUsernames = ( + parent: string, + processedParents = new Set() + ): string[] => { + if (processedParents.has(parent)) return []; + processedParents.add(parent); + + return (initialMap.get(parent) ?? []).flatMap(username => [ + username, + ...getDescendantUsernames(username, processedParents), + ]); + }; + + return Array.from(initialMap.keys()).reduce((descendantsMap, parent) => { + descendantsMap.set(parent, getDescendantUsernames(parent)); + return descendantsMap; + }, new Map()); +} diff --git a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts index 4cbf362d..41aa9d3b 100644 --- a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts +++ b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts @@ -51,6 +51,8 @@ export function getIncidentManagementTeamWithOptions( labels: { errors: { field_is_required: "This field is required", + cannot_create_cyclycal_dependency: + "Cannot depend on itself in the team hierarchy", }, }, rules: [ @@ -62,6 +64,14 @@ export function getIncidentManagementTeamWithOptions( ], sectionsWithFieldsToDisableOption: [SECTION_IDS.reportsTo], }, + { + type: "disableFieldOptionWithSameFieldValue", + fieldId: incidentManagementTeamBuilderCodesWithoutRoles.reportsToUsername, + fieldIdsToDisableOption: [ + incidentManagementTeamBuilderCodesWithoutRoles.teamMemberAssigned, + ], + sectionsWithFieldsToDisableOption: [SECTION_IDS.teamMemberAssigned], + }, ], }; return Future.success(incidentManagementTeamMemberFormData); diff --git a/src/webapp/components/form/FormFieldsState.ts b/src/webapp/components/form/FormFieldsState.ts index 2ce46fd8..2b11d789 100644 --- a/src/webapp/components/form/FormFieldsState.ts +++ b/src/webapp/components/form/FormFieldsState.ts @@ -24,6 +24,7 @@ type FormFieldStateBase = { maxWidth?: string; value: T; type: FieldType; + updateAllStateWithValidationErrors?: boolean; }; export type FormTextFieldState = FormFieldStateBase & { @@ -151,15 +152,20 @@ export function updateFields( fieldValidationErrors?: ValidationError[] ): FormFieldState[] { return formFields.map(field => { + const errors = + fieldValidationErrors?.find(error => error.property === field.id)?.errors || []; if (field.id === updatedField.id) { return { ...updatedField, - errors: - fieldValidationErrors?.find(error => error.property === updatedField.id) - ?.errors || [], + errors: errors, }; } else { - return field; + return updatedField.updateAllStateWithValidationErrors + ? { + ...field, + errors: errors, + } + : field; } }); } diff --git a/src/webapp/components/form/FormSectionsState.ts b/src/webapp/components/form/FormSectionsState.ts index e006b722..43aacd6a 100644 --- a/src/webapp/components/form/FormSectionsState.ts +++ b/src/webapp/components/form/FormSectionsState.ts @@ -30,6 +30,16 @@ function hasSectionAFieldWithNotApplicable(sectionsState: FormSectionState) { return sectionsState.fields.some(field => field.hasNotApplicable); } +export function getFieldValueByIdFromSections( + sectionsState: FormSectionState[], + fieldId: string +): FormFieldState["value"] | undefined { + const section = sectionsState.find(section => + section.fields.some(field => field.id === fieldId) + ); + return section?.fields.find(field => field.id === fieldId)?.value; +} + // UPDATES: export function applyEffectNotApplicableFieldUpdatedInSections( @@ -84,8 +94,11 @@ export function updateSections( fieldValidationErrors?: ValidationError[] ): FormSectionState[] { return formSectionsState.map(section => { + const hasToUpdateSection = + isFieldInSection(section, updatedField) || + updatedField.updateAllStateWithValidationErrors; if (section.subsections?.length) { - const maybeUpdatedSection = isFieldInSection(section, updatedField) + const maybeUpdatedSection = hasToUpdateSection ? updateSectionState(section, updatedField, fieldValidationErrors) : section; return { @@ -97,7 +110,7 @@ export function updateSections( ), }; } else { - return isFieldInSection(section, updatedField) + return hasToUpdateSection ? updateSectionState(section, updatedField, fieldValidationErrors) : section; } @@ -109,7 +122,10 @@ function updateSectionState( updatedField: FormFieldState, fieldValidationErrors?: ValidationError[] ): FormSectionState { - if (isFieldInSection(formSectionState, updatedField)) { + if ( + isFieldInSection(formSectionState, updatedField) || + updatedField.updateAllStateWithValidationErrors + ) { return { ...formSectionState, fields: updateFields(formSectionState.fields, updatedField, fieldValidationErrors), diff --git a/src/webapp/components/form/FormState.ts b/src/webapp/components/form/FormState.ts index b63ca25d..d4e425de 100644 --- a/src/webapp/components/form/FormState.ts +++ b/src/webapp/components/form/FormState.ts @@ -72,8 +72,8 @@ export function isValidForm(formSections: FormSectionState[]): boolean { return allFields.every(field => { const validationErrors = validateField(field, allFields); - - return !validationErrors || validationErrors.errors.length === 0; + const hasErrorsInFields = allFields.some(f => f.errors.length > 0); + return !hasErrorsInFields && (!validationErrors || validationErrors.errors.length === 0); }); } diff --git a/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts b/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts index b2cb9f05..5ae99d1d 100644 --- a/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts +++ b/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts @@ -88,12 +88,19 @@ export function mapIncidentManagementTeamMemberToInitialFormState( multiple: false, options: teamRoleToAssing?.roleId === INCIDENT_MANAGER_ROLE - ? incidentManagerOptions - : teamMemberOptions, + ? incidentManagerOptions.map(user => ({ + ...user, + disabled: user.value === teamRoleToAssing?.reportsToUsername, + })) + : teamMemberOptions.map(user => ({ + ...user, + disabled: user.value === teamRoleToAssing?.reportsToUsername, + })), value: incidentManagementTeamMember?.username || "", required: true, showIsRequired: true, disabled: false, + updateAllStateWithValidationErrors: true, }, ], }, @@ -118,6 +125,7 @@ export function mapIncidentManagementTeamMemberToInitialFormState( required: teamRoleToAssing?.roleId !== INCIDENT_MANAGER_ROLE, showIsRequired: teamRoleToAssing?.roleId !== INCIDENT_MANAGER_ROLE, disabled: teamRoleToAssing?.roleId === INCIDENT_MANAGER_ROLE, + updateAllStateWithValidationErrors: true, }, ], }, diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index 4e60222f..1a00566a 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -6,7 +6,7 @@ import { Id } from "../../../domain/entities/Ref"; import { FormState } from "../../components/form/FormState"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { mapFormStateToEntityData } from "./mapFormStateToEntityData"; -import { updateAndValidateFormState } from "./disease-outbreak-event/utils/updateDiseaseOutbreakEventFormState"; +import { updateAndValidateFormState } from "./utils/updateDiseaseOutbreakEventFormState"; import { FormFieldState } from "../../components/form/FormFieldsState"; import { FormType } from "./FormPage"; import { ConfigurableForm, FormLables } from "../../../domain/entities/ConfigurableForm"; diff --git a/src/webapp/pages/form-page/disease-outbreak-event/utils/applyRulesInFormState.ts b/src/webapp/pages/form-page/utils/applyRulesInFormState.ts similarity index 86% rename from src/webapp/pages/form-page/disease-outbreak-event/utils/applyRulesInFormState.ts rename to src/webapp/pages/form-page/utils/applyRulesInFormState.ts index 522a297c..794eac06 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/utils/applyRulesInFormState.ts +++ b/src/webapp/pages/form-page/utils/applyRulesInFormState.ts @@ -1,11 +1,11 @@ -import { Rule } from "../../../../../domain/entities/Rule"; -import { FormFieldState } from "../../../../components/form/FormFieldsState"; +import { Rule } from "../../../../domain/entities/Rule"; +import { FormFieldState } from "../../../components/form/FormFieldsState"; import { disableFieldOptionWithSameFieldValueInSection, disableFieldsByFieldValueInSection, toggleSectionVisibilityByFieldValue, -} from "../../../../components/form/FormSectionsState"; -import { FormState } from "../../../../components/form/FormState"; +} from "../../../components/form/FormSectionsState"; +import { FormState } from "../../../components/form/FormState"; export function applyRulesInFormState( currentFormState: FormState, diff --git a/src/webapp/pages/form-page/disease-outbreak-event/utils/updateDiseaseOutbreakEventFormState.ts b/src/webapp/pages/form-page/utils/updateDiseaseOutbreakEventFormState.ts similarity index 57% rename from src/webapp/pages/form-page/disease-outbreak-event/utils/updateDiseaseOutbreakEventFormState.ts rename to src/webapp/pages/form-page/utils/updateDiseaseOutbreakEventFormState.ts index 4573a8bc..434e8835 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/utils/updateDiseaseOutbreakEventFormState.ts +++ b/src/webapp/pages/form-page/utils/updateDiseaseOutbreakEventFormState.ts @@ -1,14 +1,17 @@ -import { ConfigurableForm } from "../../../../../domain/entities/ConfigurableForm"; -import { DiseaseOutbreakEvent } from "../../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; -import { ValidationError } from "../../../../../domain/entities/ValidationError"; -import { FormFieldState } from "../../../../components/form/FormFieldsState"; +import { incidentManagementTeamBuilderCodesWithoutRoles } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { ConfigurableForm } from "../../../../domain/entities/ConfigurableForm"; +import { DiseaseOutbreakEvent } from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { IncidentManagementTeam } from "../../../../domain/entities/incident-management-team/IncidentManagementTeam"; +import { ValidationError } from "../../../../domain/entities/ValidationError"; +import { FormFieldState } from "../../../components/form/FormFieldsState"; +import { getFieldValueByIdFromSections } from "../../../components/form/FormSectionsState"; import { FormState, isValidForm, updateFormStateAndApplySideEffects, updateFormStateWithFieldErrors, validateForm, -} from "../../../../components/form/FormState"; +} from "../../../components/form/FormState"; import { applyRulesInFormState } from "./applyRulesInFormState"; export function updateAndValidateFormState( @@ -67,8 +70,24 @@ function validateFormState( break; case "incident-response-action": break; - case "incident-management-team-member-assignment": + case "incident-management-team-member-assignment": { + const reportsToUsername = getFieldValueByIdFromSections( + updatedForm.sections, + incidentManagementTeamBuilderCodesWithoutRoles.reportsToUsername + ) as string | undefined; + const teamMemberAssigned = getFieldValueByIdFromSections( + updatedForm.sections, + incidentManagementTeamBuilderCodesWithoutRoles.teamMemberAssigned + ) as string | undefined; + + return IncidentManagementTeam.validateNotCyclicalDependency( + teamMemberAssigned, + reportsToUsername, + configurableForm?.currentIncidentManagementTeam?.teamHierarchy || [], + updatedField.id + ); break; + } } return [...formValidationErrors, ...entityValidationErrors]; diff --git a/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts index 09e046fa..16fb527d 100644 --- a/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts +++ b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts @@ -210,12 +210,12 @@ function checkIfParentsAndAllChildrenSelected( })) .filter(teamMember => teamMember.teamRoles?.length); - const allTeamRoleChildrenIdsByParentId = getAllTeamRoleChildrenIdsByParentTeamRoleId( + const allTeamRoleChildrenIdsByParentUsername = getAllTeamRoleChildrenIdsByParentUsername( incidentManagementTeamHierarchy ); return selectedItemsInTeamHierarchy.every(teamMember => { - const teamRoleChildrenIds = allTeamRoleChildrenIdsByParentId.get(teamMember.username); + const teamRoleChildrenIds = allTeamRoleChildrenIdsByParentUsername.get(teamMember.username); return ( !teamRoleChildrenIds || teamRoleChildrenIds.every(childId => { @@ -225,14 +225,14 @@ function checkIfParentsAndAllChildrenSelected( }); } -function getAllTeamRoleChildrenIdsByParentTeamRoleId(teamMembers: TeamMember[]): Map { +function getAllTeamRoleChildrenIdsByParentUsername(teamMembers: TeamMember[]): Map { return teamMembers.reduce((acc, teamMember) => { return ( teamMember.teamRoles?.reduce((innerAcc, teamRole) => { - const parentTeamRoleId = teamRole.reportsToUsername; - if (parentTeamRoleId) { - const existingChildren = innerAcc.get(parentTeamRoleId) || []; - innerAcc.set(parentTeamRoleId, [...existingChildren, teamRole.id]); + const parentUsername = teamRole.reportsToUsername; + if (parentUsername) { + const existingChildren = innerAcc.get(parentUsername) || []; + innerAcc.set(parentUsername, [...existingChildren, teamRole.id]); } return innerAcc; }, acc) || acc