diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/FacadeProvider.java b/sormas-api/src/main/java/de/symeda/sormas/api/FacadeProvider.java index 1b2e5edcb41..6dc5c60b1e5 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/FacadeProvider.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/FacadeProvider.java @@ -39,6 +39,8 @@ import de.symeda.sormas.api.clinicalcourse.ClinicalVisitFacade; import de.symeda.sormas.api.contact.ContactFacade; import de.symeda.sormas.api.customizableenum.CustomizableEnumFacade; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataFacade; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueFacade; import de.symeda.sormas.api.dashboard.DashboardFacade; import de.symeda.sormas.api.dashboard.adverseeventsfollowingimmunization.AefiDashboardFacade; import de.symeda.sormas.api.dashboard.sample.SampleDashboardFacade; @@ -508,6 +510,14 @@ public static CustomizableEnumFacade getCustomizableEnumFacade() { return get().lookupEjbRemote(CustomizableEnumFacade.class); } + public static CustomizableFieldMetadataFacade getCustomizableFieldMetadataFacade() { + return get().lookupEjbRemote(CustomizableFieldMetadataFacade.class); + } + + public static CustomizableFieldValueFacade getCustomizableFieldValueFacade() { + return get().lookupEjbRemote(CustomizableFieldValueFacade.class); + } + public static InfoFacade getInfoFacade() { return get().lookupEjbRemote(InfoFacade.class); } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/common/DeletableEntityType.java b/sormas-api/src/main/java/de/symeda/sormas/api/common/DeletableEntityType.java index 0e3a694ec31..fc5910a23c1 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/common/DeletableEntityType.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/common/DeletableEntityType.java @@ -30,5 +30,7 @@ public enum DeletableEntityType { PATHOGEN_TEST, ENVIRONMENT, ENVIRONMENT_SAMPLE, - SELF_REPORT; + SELF_REPORT, + CUSTOMIZABLE_FIELD_METADATA, + CUSTOMIZABLE_FIELD_VALUE; } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldContext.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldContext.java new file mode 100644 index 00000000000..76f1a6fb3c1 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldContext.java @@ -0,0 +1,67 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import de.symeda.sormas.api.EntityDto; +import de.symeda.sormas.api.caze.CaseDataDto; +import de.symeda.sormas.api.epidata.EpiDataDto; +import de.symeda.sormas.api.exposure.ExposureDto; + +/** + * Defines supported customizable field contexts and links them to existing + * SORMAS DTO classes. Contexts must reference real DTOs; defining a custom + * field for a non-existent DTO does not make sense. + */ +public enum CustomizableFieldContext { + + CASE(CaseDataDto.class), + EPIDATA(EpiDataDto.class), + EXPOSURE(ExposureDto.class); + + // add other contexts here + + private final String contextClassName; + + CustomizableFieldContext(Class dtoClass) { + this.contextClassName = dtoClass.getName(); + } + + public String getContextClassName() { + return contextClassName; + } + + public static CustomizableFieldContext fromDtoClass(Class dtoClass) { + return fromDtoClassName(dtoClass.getName()); + } + + public static CustomizableFieldContext fromDtoClassName(String dtoClassName) { + Class rawClass; + try { + rawClass = Class.forName(dtoClassName); + } catch (ClassNotFoundException e) { + throw new IllegalArgumentException("Unknown context DTO: " + dtoClassName, e); + } + if (!EntityDto.class.isAssignableFrom(rawClass)) { + throw new IllegalArgumentException("Context class is not an EntityDto: " + dtoClassName); + } + for (CustomizableFieldContext context : values()) { + if (context.contextClassName.equals(dtoClassName)) { + return context; + } + } + throw new IllegalArgumentException("Unknown context DTO: " + dtoClassName); + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldCustomProperties.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldCustomProperties.java new file mode 100644 index 00000000000..eb9b9dfa1ff --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldCustomProperties.java @@ -0,0 +1,58 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import java.io.Serializable; +import java.util.List; + +/** + * Typed representation of the {@code customProperties} JSON column on + * {@link CustomizableFieldMetadataDto}. + *

+ * Field-type-specific configuration lives here: + *

+ * Additional properties can be added here as the feature grows without + * touching the database schema (the whole object is stored as a single + * {@code jsonb} column). + */ +public class CustomizableFieldCustomProperties implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * The list of selectable option values for list-type fields + * ({@code COMBOBOX}, {@code CHECKBOX_LIST}, {@code RADIO_BUTTON_LIST}). + * Each entry is the raw stored string value (not a display label). + */ + private List options; + + public CustomizableFieldCustomProperties() { + // Required for JSON deserialization. + } + + public List getOptions() { + return options; + } + + public void setOptions(List options) { + this.options = options; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldGroup.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldGroup.java new file mode 100644 index 00000000000..5dc6cf6db97 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldGroup.java @@ -0,0 +1,117 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import java.util.ArrayList; +import java.util.List; + +/** + * Defines UI groups for customizable fields, scoped to a specific {@link CustomizableFieldContext}. + *

+ * Each group has a stable string {@link #key} that is used as: + *

    + *
  • the value stored in the database;
  • + *
  • the Vaadin layout location ID when embedding a {@code CustomizableFieldsGroup} component.
  • + *
+ *

+ * To add a new group, append an enum value with the owning context and a unique, stable key. + * The key must not be changed after data has been stored against it. + */ +public enum CustomizableFieldGroup { + + // ---- CASE groups -------------------------------------------------------- + CASE_DATA_GENERAL(CustomizableFieldContext.CASE, "caseDataGeneral"), + CASE_DATA_CLASSIFICATION(CustomizableFieldContext.CASE, "caseDataClassification"), + CASE_DATA_INVESTIGATION(CustomizableFieldContext.CASE, "caseDataInvestigation"), + CASE_DATA_IDENTIFIERS(CustomizableFieldContext.CASE, "caseDataIdentifiers"), + CASE_DATA_DISEASE(CustomizableFieldContext.CASE, "caseDataDisease"), + CASE_DATA_REINFECTION(CustomizableFieldContext.CASE, "caseDataReinfection"), + CASE_DATA_OUTCOME(CustomizableFieldContext.CASE, "caseDataOutcome"), + CASE_DATA_SEQUELAE(CustomizableFieldContext.CASE, "caseDataSequelae"), + CASE_DATA_JURISDICTION(CustomizableFieldContext.CASE, "caseDataJurisdiction"), + CASE_DATA_PLACE_OF_STAY(CustomizableFieldContext.CASE, "caseDataPlaceOfStay"), + CASE_DATA_QUARANTINE(CustomizableFieldContext.CASE, "caseDataQuarantine"), + CASE_DATA_REPORT_GEO(CustomizableFieldContext.CASE, "caseDataReportGeo"), + CASE_DATA_HEALTH_CONDITIONS(CustomizableFieldContext.CASE, "caseDataHealthConditions"), + CASE_DATA_DIAGNOSTIC(CustomizableFieldContext.CASE, "caseDataDiagnostic"), + CASE_DATA_MEDICAL_INFORMATION(CustomizableFieldContext.CASE, "caseDataMedicalInformation"), + CASE_DATA_VACCINATION(CustomizableFieldContext.CASE, "caseDataVaccination"), + CASE_DATA_CLINICIAN_NOTIFICATION(CustomizableFieldContext.CASE, "caseDataClinicianNotification"), + CASE_DATA_CONTACT_TRACING(CustomizableFieldContext.CASE, "caseDataContactTracing"), + + // ---- EPIDATA groups ----------------------------------------------------- + EPIDATA_EXPOSURE_INVESTIGATION(CustomizableFieldContext.EPIDATA, "exposureInvestigation"), + EPIDATA_ACTIVITY_AS_CASE(CustomizableFieldContext.EPIDATA, "activityAsCase"), + EPIDATA_CONTACT_WITH_SOURCE_CASE(CustomizableFieldContext.EPIDATA, "contactWithSourceCase"), + + // ---- EXPOSURE groups ---------------------------------------------------- + EXPOSURE_DETAILS(CustomizableFieldContext.EXPOSURE, "exposureDetails"), + EXPOSURES_GENERAL(CustomizableFieldContext.EXPOSURE, "exposuresGeneral"), + LOCATION_GENERAL(CustomizableFieldContext.EXPOSURE, "locationGeneral"); + + private final CustomizableFieldContext context; + /** + * Stable key stored in the database and used as the Vaadin layout location ID. + */ + private final String key; + + CustomizableFieldGroup(CustomizableFieldContext context, String key) { + this.context = context; + this.key = key; + } + + /** + * The {@link CustomizableFieldContext} this group belongs to. + */ + public CustomizableFieldContext getContext() { + return context; + } + + /** + * Stable string key used as the database-stored value and as the Vaadin layout location ID. + */ + public String getKey() { + return key; + } + + /** + * Returns all groups that belong to the given context. + */ + public static List getGroupsForContext(CustomizableFieldContext context) { + List result = new ArrayList<>(); + for (CustomizableFieldGroup group : values()) { + if (group.context == context) { + result.add(group); + } + } + return result; + } + + /** + * Looks up a group by its stable {@link #key}, returning {@code null} if not found. + */ + public static CustomizableFieldGroup fromKey(String key) { + if (key == null) { + return null; + } + for (CustomizableFieldGroup group : values()) { + if (group.key.equals(key)) { + return group; + } + } + return null; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataCriteria.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataCriteria.java new file mode 100644 index 00000000000..09f4e8fc9a7 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataCriteria.java @@ -0,0 +1,68 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import de.symeda.sormas.api.utils.IgnoreForUrl; +import de.symeda.sormas.api.utils.criteria.BaseCriteria; + +/** + * Criteria for filtering customizable field metadata in admin views. + */ +public class CustomizableFieldMetadataCriteria extends BaseCriteria { + + private static final long serialVersionUID = 1L; + + private String freeTextFilter; + private CustomizableFieldContext contextClass; + private CustomizableFieldType fieldType; + private Boolean active; + + public String getFreeTextFilter() { + return freeTextFilter; + } + + public CustomizableFieldMetadataCriteria freeTextFilter(String freeTextFilter) { + this.freeTextFilter = freeTextFilter; + return this; + } + + @IgnoreForUrl + public CustomizableFieldContext getContextClass() { + return contextClass; + } + + public void setContextClass(CustomizableFieldContext contextClass) { + this.contextClass = contextClass; + } + + @IgnoreForUrl + public CustomizableFieldType getFieldType() { + return fieldType; + } + + public void setFieldType(CustomizableFieldType fieldType) { + this.fieldType = fieldType; + } + + @IgnoreForUrl + public Boolean getActive() { + return active; + } + + public void setActive(Boolean active) { + this.active = active; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataDto.java new file mode 100644 index 00000000000..3015345ff55 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataDto.java @@ -0,0 +1,201 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import java.util.Map; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +import de.symeda.sormas.api.EntityDto; +import de.symeda.sormas.api.i18n.Validations; +import de.symeda.sormas.api.utils.FieldConstraints; + +/** + * DTO for customizable field metadata. + * Contains configuration for a custom field that can be added to entities. + */ +@SuppressWarnings({ + "java:S1845", // suppress sonar field name clash warning + "java:S2160" // suppress missing equals handled in EnityDto +}) +public class CustomizableFieldMetadataDto extends EntityDto { + + private static final long serialVersionUID = 1L; + + public static final String I18N_PREFIX = "CustomizableFieldMetadata"; + + public static final String NAME = "name"; + public static final String DESCRIPTION = "description"; + public static final String FIELD_TYPE = "fieldType"; + public static final String CONTEXT_CLASS = "contextClass"; + public static final String UI_GROUP = "uiGroup"; + public static final String UI_LINE_POSITION = "uiLinePosition"; + public static final String UI_LINE_WEIGHT = "uiLineWeight"; + public static final String ACTIVE = "active"; + public static final String MANDATORY = "mandatory"; + public static final String READ_ONLY = "readOnly"; + public static final String DEFAULT_VALUE = "defaultValue"; + public static final String VISIBILITY_RESTRICTIONS = "visibilityRestrictions"; + public static final String CUSTOM_PROPERTIES = "customProperties"; + public static final String TRANSLATIONS = "translations"; + + @NotBlank(message = Validations.required) + @Size(max = FieldConstraints.CHARACTER_LIMIT_SMALL, message = Validations.textTooLong) + private String name; + + @Size(max = FieldConstraints.CHARACTER_LIMIT_DEFAULT, message = Validations.textTooLong) + private String description; + + @NotNull(message = Validations.required) + private CustomizableFieldType fieldType; + + @NotNull(message = Validations.required) + private CustomizableFieldContext contextClass; + + private CustomizableFieldGroup uiGroup; + + private Integer uiLinePosition; + private Float uiLineWeight; + + private boolean active = true; + private boolean mandatory = false; + private boolean readOnly = false; + + @Size(max = FieldConstraints.CHARACTER_LIMIT_DEFAULT) + private String defaultValue; + + private CustomizableFieldVisibilityRestrictions visibilityRestrictions; + private CustomizableFieldCustomProperties customProperties; + private Map> translations; + + public CustomizableFieldMetadataDto() { + // Required for deserialization frameworks like Jackson and REST clients + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public CustomizableFieldType getFieldType() { + return fieldType; + } + + public void setFieldType(CustomizableFieldType fieldType) { + this.fieldType = fieldType; + } + + public CustomizableFieldContext getContextClass() { + return contextClass; + } + + public void setContextClass(CustomizableFieldContext contextClass) { + this.contextClass = contextClass; + } + + public CustomizableFieldGroup getUiGroup() { + return uiGroup; + } + + public void setUiGroup(CustomizableFieldGroup uiGroup) { + this.uiGroup = uiGroup; + } + + public Integer getUiLinePosition() { + return uiLinePosition; + } + + public void setUiLinePosition(Integer uiLinePosition) { + this.uiLinePosition = uiLinePosition; + } + + public Float getUiLineWeight() { + return uiLineWeight; + } + + public void setUiLineWeight(Float uiLineWeight) { + this.uiLineWeight = uiLineWeight; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public boolean isMandatory() { + return mandatory; + } + + public void setMandatory(boolean mandatory) { + this.mandatory = mandatory; + } + + public boolean isReadOnly() { + return readOnly; + } + + public void setReadOnly(boolean readOnly) { + this.readOnly = readOnly; + } + + public String getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + } + + public CustomizableFieldVisibilityRestrictions getVisibilityRestrictions() { + return visibilityRestrictions; + } + + public void setVisibilityRestrictions(CustomizableFieldVisibilityRestrictions visibilityRestrictions) { + this.visibilityRestrictions = visibilityRestrictions; + } + + public CustomizableFieldCustomProperties getCustomProperties() { + return customProperties; + } + + public void setCustomProperties(CustomizableFieldCustomProperties customProperties) { + this.customProperties = customProperties; + } + + public Map> getTranslations() { + return translations; + } + + public void setTranslations(Map> translations) { + this.translations = translations; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataFacade.java new file mode 100644 index 00000000000..375243f85ae --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataFacade.java @@ -0,0 +1,66 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import java.util.List; + +import javax.ejb.Remote; + +import de.symeda.sormas.api.CoreFacade; + +/** + * Facade interface for managing customizable field metadata. + */ +@Remote +public interface CustomizableFieldMetadataFacade + extends + CoreFacade { + + /** + * Get all active custom fields for a specific context class + */ + List getActiveFieldsForContext(CustomizableFieldContext contextClass); + + /** + * Get custom fields grouped in a specific UI group + */ + List getFieldsForUIGroup(CustomizableFieldGroup uiGroup); + + /** + * Get custom fields ordered by UI line position + */ + List getFieldsOrderedByUIPosition(CustomizableFieldGroup uiGroup); + + /** + * Find field by name within a specific context + */ + CustomizableFieldMetadataDto getByNameAndContext(String name, CustomizableFieldContext contextClass); + + /** + * Clone an existing field with a new name + */ + CustomizableFieldMetadataDto cloneField(String sourceUuid, String newName); + + /** + * Set the active state of a field + */ + void setFieldActive(String uuid, boolean active); + + /** + * Get all field metadata + */ + List getAll(); +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataReferenceDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataReferenceDto.java new file mode 100644 index 00000000000..18bef8f5520 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataReferenceDto.java @@ -0,0 +1,27 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import de.symeda.sormas.api.ReferenceDto; + +public class CustomizableFieldMetadataReferenceDto extends ReferenceDto { + + private static final long serialVersionUID = 1L; + + public CustomizableFieldMetadataReferenceDto(String uuid) { + super(uuid); + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldType.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldType.java new file mode 100644 index 00000000000..d07c346cd1a --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldType.java @@ -0,0 +1,41 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import de.symeda.sormas.api.i18n.I18nProperties; + +/** + * Defines the types of customizable fields that can be created. + */ +public enum CustomizableFieldType { + + TEXT, + TEXTAREA, + NUMBER, + DECIMAL, + DATE, + DATE_TIME, + COMBOBOX, + CHECKBOX, + YES_NO_UNKNOWN, + CHECKBOX_LIST, + RADIO_BUTTON_LIST; + + @Override + public String toString() { + return I18nProperties.getEnumCaption(this); + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueCriteria.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueCriteria.java new file mode 100644 index 00000000000..29c9de85b6b --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueCriteria.java @@ -0,0 +1,23 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import de.symeda.sormas.api.utils.criteria.BaseCriteria; + +public class CustomizableFieldValueCriteria extends BaseCriteria { + + private static final long serialVersionUID = 1L; +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueDto.java new file mode 100644 index 00000000000..f2d2d33f6b5 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueDto.java @@ -0,0 +1,290 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashSet; +import java.util.Set; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.symeda.sormas.api.EntityDto; +import de.symeda.sormas.api.i18n.Validations; +import de.symeda.sormas.api.utils.YesNoUnknown; + +/** + * DTO for customizable field values. + * Stores the actual value for a custom field for a specific entity instance. + */ +public class CustomizableFieldValueDto extends EntityDto { + + private static final long serialVersionUID = 1L; + + /** Shared mapper for {@link #getValueAsStringSet()} / {@link #setValueAsStringSet(Set)}. */ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public static final String I18N_PREFIX = "CustomizableFieldValue"; + + public static final String CUSTOMIZABLE_FIELD_METADATA_UUID = "customizableFieldMetadataUuid"; + public static final String ENTITY_UUID = "entityUuid"; + public static final String CONTEXT_CLASS = "contextClass"; + public static final String VALUE = "value"; + + @NotBlank(message = Validations.required) + private String customizableFieldMetadataUuid; + + @NotBlank(message = Validations.required) + private String entityUuid; + + @NotNull(message = Validations.required) + private CustomizableFieldContext contextClass; + + private String value; + + public CustomizableFieldValueDto() { + } + + public String getCustomizableFieldMetadataUuid() { + return customizableFieldMetadataUuid; + } + + public void setCustomizableFieldMetadataUuid(String customizableFieldMetadataUuid) { + this.customizableFieldMetadataUuid = customizableFieldMetadataUuid; + } + + public String getEntityUuid() { + return entityUuid; + } + + public void setEntityUuid(String entityUuid) { + this.entityUuid = entityUuid; + } + + public CustomizableFieldContext getContextClass() { + return contextClass; + } + + public void setContextClass(CustomizableFieldContext contextClass) { + this.contextClass = contextClass; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + // ------------------------------------------------------------------------- + // Typed accessors – parse from the raw ISO string stored in {@link #value}. + // Setters serialise back to the canonical ISO representation. + // All getters return {@code null} when the value is absent or unparseable. + // ------------------------------------------------------------------------- + + /** + * Parses {@link #value} as an ISO date ({@code yyyy-MM-dd}). + * + * @return the date, or {@code null} if the value is absent or not a valid ISO date + */ + public LocalDate getValueAsDate() { + if (value == null || value.isEmpty()) { + return null; + } + try { + return LocalDate.parse(value); + } catch (DateTimeParseException e) { + return null; + } + } + + /** + * Stores a {@link LocalDate} as an ISO date string ({@code yyyy-MM-dd}). + * Passing {@code null} clears the value. + */ + public void setValueAsDate(LocalDate date) { + this.value = date != null ? date.toString() : null; + } + + /** + * Parses {@link #value} as an ISO date-time ({@code yyyy-MM-ddTHH:mm} or longer). + * + * @return the date-time, or {@code null} if the value is absent or not a valid ISO date-time + */ + public LocalDateTime getValueAsDateTime() { + if (value == null || value.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(value); + } catch (DateTimeParseException e) { + return null; + } + } + + /** + * Stores a {@link LocalDateTime} as an ISO date-time string, truncated to minutes + * ({@code yyyy-MM-ddTHH:mm}). + * Passing {@code null} clears the value. + */ + public void setValueAsDateTime(LocalDateTime dateTime) { + this.value = dateTime != null ? dateTime.withSecond(0).withNano(0).toString() : null; + } + + /** + * Parses {@link #value} as a whole number. + * + * @return the integer value, or {@code null} if the value is absent or not a valid integer + */ + public Integer getValueAsInteger() { + if (value == null || value.isEmpty()) { + return null; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Stores an {@link Integer} as a plain decimal string. + * Passing {@code null} clears the value. + */ + public void setValueAsInteger(Integer number) { + this.value = number != null ? number.toString() : null; + } + + /** + * Parses {@link #value} as a decimal number. + * + * @return the decimal value, or {@code null} if the value is absent or not a valid number + */ + public BigDecimal getValueAsDecimal() { + if (value == null || value.isEmpty()) { + return null; + } + try { + return new BigDecimal(value.trim().replace(',', '.')); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Stores a {@link BigDecimal} as a plain decimal string. + * Passing {@code null} clears the value. + */ + public void setValueAsDecimal(BigDecimal decimal) { + this.value = decimal != null ? decimal.toPlainString() : null; + } + + /** + * Parses {@link #value} as a boolean ({@code "true"} / {@code "false"}, case-insensitive). + * + * @return {@link Boolean#TRUE} if the stored value equals {@code "true"} (ignoring case), + * {@link Boolean#FALSE} if it equals {@code "false"} (ignoring case), + * or {@code null} if the value is absent or does not match either token + */ + public Boolean getValueAsBoolean() { + if (value == null || value.isEmpty()) { + return null; + } + if (Boolean.TRUE.toString().equalsIgnoreCase(value.trim())) { + return Boolean.TRUE; + } + if (Boolean.FALSE.toString().equalsIgnoreCase(value.trim())) { + return Boolean.FALSE; + } + return null; + } + + /** + * Stores a {@link Boolean} as {@code "true"} or {@code "false"}. + * Passing {@code null} clears the value. + */ + public void setValueAsBoolean(Boolean bool) { + this.value = bool != null ? bool.toString() : null; + } + + /** + * Parses {@link #value} as a {@link YesNoUnknown} enum constant (case-insensitive name). + * + * @return the matching constant, or {@code null} if the value is absent or not a valid name + */ + public YesNoUnknown getValueAsYesNoUnknown() { + if (value == null || value.isEmpty()) { + return null; + } + try { + return YesNoUnknown.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * Stores a {@link YesNoUnknown} constant as its {@link Enum#name()} ({@code "YES"}, {@code "NO"} or {@code "UNKNOWN"}). + * Passing {@code null} clears the value. + */ + public void setValueAsYesNoUnknown(YesNoUnknown yesNoUnknown) { + this.value = yesNoUnknown != null ? yesNoUnknown.name() : null; + } + + /** + * Parses {@link #value} as a JSON array of strings, as used by + * {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#CHECKBOX_LIST}. + * + * @return a mutable {@link LinkedHashSet} of the stored strings (preserving insertion order), + * or an empty set if the value is absent or cannot be parsed + */ + public Set getValueAsStringSet() { + if (value == null || value.isEmpty()) { + return new LinkedHashSet<>(); + } + try { + return MAPPER.readValue(value, new TypeReference>() { + }); + } catch (IOException e) { + return new LinkedHashSet<>(); + } + } + + /** + * Serialises a set of strings to a JSON array and stores it in {@link #value}. + * Passing {@code null} or an empty set clears the value. + */ + public void setValueAsStringSet(Set set) { + if (set == null || set.isEmpty()) { + this.value = null; + return; + } + try { + this.value = MAPPER.writeValueAsString(set); + } catch (JsonProcessingException e) { + this.value = null; + } + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueFacade.java new file mode 100644 index 00000000000..3b411c1c0b0 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueFacade.java @@ -0,0 +1,62 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import java.util.List; +import java.util.Map; + +import javax.ejb.Remote; + +import de.symeda.sormas.api.CoreFacade; + +/** + * Facade interface for managing customizable field values. + */ +@Remote +public interface CustomizableFieldValueFacade + extends CoreFacade { + + /** + * Load all custom field values for a specific entity. + * Returns a map of field metadata DTO -> field value DTO. + */ + Map getValuesForEntity(String entityUuid, CustomizableFieldContext contextClass); + + /** + * Save all custom field values for an entity in a single context. + * fieldValues: map of field metadata DTO -> value DTO (the raw string is read from {@link CustomizableFieldValueDto#getValue()}) + */ + void saveEntityCustomFields( + String entityUuid, + CustomizableFieldContext contextClass, + Map fieldValues); + + /** + * Save all custom field values for an entity across multiple contexts in one call. + * fieldsByContext: map of context -> (field metadata DTO -> value DTO) + */ + void saveEntityCustomFields(String entityUuid, Map> fieldsByContext); + + /** + * Delete all custom field values for an entity + */ + void deleteValuesForEntity(String entityUuid, CustomizableFieldContext contextClass); + + /** + * Get all field values + */ + List getAll(); +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueReferenceDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueReferenceDto.java new file mode 100644 index 00000000000..740a11a4e36 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueReferenceDto.java @@ -0,0 +1,27 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import de.symeda.sormas.api.ReferenceDto; + +public class CustomizableFieldValueReferenceDto extends ReferenceDto { + + private static final long serialVersionUID = 1L; + + public CustomizableFieldValueReferenceDto(String uuid) { + super(uuid); + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldVisibilityContext.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldVisibilityContext.java new file mode 100644 index 00000000000..95a175a966c --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldVisibilityContext.java @@ -0,0 +1,82 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import java.io.Serializable; +import java.util.Objects; +import java.util.Optional; + +import de.symeda.sormas.api.Disease; + +/** + * Runtime context against which {@link CustomizableFieldVisibilityRestrictions} are evaluated. + *

+ * Where {@link CustomizableFieldVisibilityRestrictions} represents the stored what should + * restrict this field configuration, this class carries the current runtime values + * (e.g. the disease of the case being viewed) that are tested against those restrictions. + *

+ * New criteria can be added as additional fields without changing the evaluation contract; + * {@link CustomizableFieldVisibilityRestrictions#matches(CustomizableFieldVisibilityContext)} + * handles all criteria in one place. + *

+ * Builder-style fluent setters allow convenient one-liner construction: + * + *

+ * 
+ * CustomizableFieldVisibilityContext ctx = new CustomizableFieldVisibilityContext().withDisease(caze.getDisease());
+ * 
+ */ +public class CustomizableFieldVisibilityContext implements Serializable { + + private static final long serialVersionUID = 1L; + + private Disease disease; + + public CustomizableFieldVisibilityContext() { + // no-arg constructor required by deserialization frameworks + } + + public Optional getDisease() { + return Optional.ofNullable(disease); + } + + public void setDisease(Disease disease) { + this.disease = disease; + } + + public CustomizableFieldVisibilityContext withDisease(Disease disease) { + this.disease = disease; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CustomizableFieldVisibilityContext that = (CustomizableFieldVisibilityContext) o; + return Objects.equals(disease, that.disease); + } + + @Override + public int hashCode() { + return Objects.hash(disease); + } + +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldVisibilityRestrictions.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldVisibilityRestrictions.java new file mode 100644 index 00000000000..2c14c489989 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldVisibilityRestrictions.java @@ -0,0 +1,76 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.api.customizablefield; + +import java.io.Serializable; +import java.util.List; + +import org.apache.commons.collections4.CollectionUtils; + +import de.symeda.sormas.api.Disease; + +/** + * Typed representation of the {@code visibilityRestrictions} JSON column on + * {@link CustomizableFieldMetadataDto}. + *

+ * Controls when a customizable field is visible to the user: + *

    + *
  • diseases – when set, the field is only visible for cases/entities + * associated with one of the listed {@link Disease} values. When {@code null} + * or empty, the field is visible regardless of disease.
  • + *
+ * Additional visibility criteria can be added here without touching the + * database schema (the whole object is stored as a single {@code jsonb} column). + */ +public class CustomizableFieldVisibilityRestrictions implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * The diseases for which this field should be visible. + * When {@code null} or empty, the field is visible for all diseases. + */ + private List diseases; + + public CustomizableFieldVisibilityRestrictions() { + // Required for JSON deserialization. + } + + public List getDiseases() { + return diseases; + } + + public void setDiseases(List diseases) { + this.diseases = diseases; + } + + /** + * Returns {@code true} when the given runtime context satisfies all restrictions defined + * on this object. A restriction criterion is considered satisfied when either: + *
    + *
  • the restriction list for that criterion is {@code null} or empty (= no restriction), or
  • + *
  • the context's value for that criterion is contained in the restriction list.
  • + *
+ * + * @param context + * the runtime context to evaluate; {@code null} is treated as an empty context + * (all criteria with non-empty restriction lists will fail) + * @return {@code true} if the field should be visible given this context + */ + public boolean matches(CustomizableFieldVisibilityContext context) { + return CollectionUtils.isEmpty(diseases) || (context != null && context.getDisease().filter(diseases::contains).isPresent()); + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Captions.java b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Captions.java index 1efeef0a8a1..9b238b2e334 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Captions.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Captions.java @@ -53,6 +53,7 @@ public interface Captions { String actionCancel = "actionCancel"; String actionClear = "actionClear"; String actionClearAll = "actionClearAll"; + String actionClone = "actionClone"; String actionClose = "actionClose"; String actionCompare = "actionCompare"; String actionConfirm = "actionConfirm"; @@ -1214,6 +1215,46 @@ public interface Captions { String customizableEnumValueDiseaseCount = "customizableEnumValueDiseaseCount"; String customizableEnumValueInactiveValues = "customizableEnumValueInactiveValues"; String customizableEnumValueNoProperties = "customizableEnumValueNoProperties"; + String CustomizableFieldContext_CASE = "CustomizableFieldContext.CASE"; + String CustomizableFieldContext_EPIDATA = "CustomizableFieldContext.EPIDATA"; + String CustomizableFieldGroup_CASE_DATA_CLASSIFICATION = "CustomizableFieldGroup.CASE_DATA_CLASSIFICATION"; + String CustomizableFieldGroup_CASE_DATA_CLINICIAN_NOTIFICATION = "CustomizableFieldGroup.CASE_DATA_CLINICIAN_NOTIFICATION"; + String CustomizableFieldGroup_CASE_DATA_CONTACT_TRACING = "CustomizableFieldGroup.CASE_DATA_CONTACT_TRACING"; + String CustomizableFieldGroup_CASE_DATA_DIAGNOSTIC = "CustomizableFieldGroup.CASE_DATA_DIAGNOSTIC"; + String CustomizableFieldGroup_CASE_DATA_DISEASE = "CustomizableFieldGroup.CASE_DATA_DISEASE"; + String CustomizableFieldGroup_CASE_DATA_GENERAL = "CustomizableFieldGroup.CASE_DATA_GENERAL"; + String CustomizableFieldGroup_CASE_DATA_HEALTH_CONDITIONS = "CustomizableFieldGroup.CASE_DATA_HEALTH_CONDITIONS"; + String CustomizableFieldGroup_CASE_DATA_IDENTIFIERS = "CustomizableFieldGroup.CASE_DATA_IDENTIFIERS"; + String CustomizableFieldGroup_CASE_DATA_INVESTIGATION = "CustomizableFieldGroup.CASE_DATA_INVESTIGATION"; + String CustomizableFieldGroup_CASE_DATA_JURISDICTION = "CustomizableFieldGroup.CASE_DATA_JURISDICTION"; + String CustomizableFieldGroup_CASE_DATA_MEDICAL_INFORMATION = "CustomizableFieldGroup.CASE_DATA_MEDICAL_INFORMATION"; + String CustomizableFieldGroup_CASE_DATA_OUTCOME = "CustomizableFieldGroup.CASE_DATA_OUTCOME"; + String CustomizableFieldGroup_CASE_DATA_PLACE_OF_STAY = "CustomizableFieldGroup.CASE_DATA_PLACE_OF_STAY"; + String CustomizableFieldGroup_CASE_DATA_QUARANTINE = "CustomizableFieldGroup.CASE_DATA_QUARANTINE"; + String CustomizableFieldGroup_CASE_DATA_REINFECTION = "CustomizableFieldGroup.CASE_DATA_REINFECTION"; + String CustomizableFieldGroup_CASE_DATA_REPORT_GEO = "CustomizableFieldGroup.CASE_DATA_REPORT_GEO"; + String CustomizableFieldGroup_CASE_DATA_SEQUELAE = "CustomizableFieldGroup.CASE_DATA_SEQUELAE"; + String CustomizableFieldGroup_CASE_DATA_VACCINATION = "CustomizableFieldGroup.CASE_DATA_VACCINATION"; + String CustomizableFieldGroup_EPIDATA_ACTIVITY_AS_CASE = "CustomizableFieldGroup.EPIDATA_ACTIVITY_AS_CASE"; + String CustomizableFieldGroup_EPIDATA_CONTACT_WITH_SOURCE_CASE = "CustomizableFieldGroup.EPIDATA_CONTACT_WITH_SOURCE_CASE"; + String CustomizableFieldGroup_EPIDATA_EXPOSURE_INVESTIGATION = "CustomizableFieldGroup.EPIDATA_EXPOSURE_INVESTIGATION"; + String CustomizableFieldMetadata_active = "CustomizableFieldMetadata.active"; + String CustomizableFieldMetadata_contextClass = "CustomizableFieldMetadata.contextClass"; + String CustomizableFieldMetadata_defaultValue = "CustomizableFieldMetadata.defaultValue"; + String CustomizableFieldMetadata_description = "CustomizableFieldMetadata.description"; + String CustomizableFieldMetadata_fieldType = "CustomizableFieldMetadata.fieldType"; + String CustomizableFieldMetadata_mandatory = "CustomizableFieldMetadata.mandatory"; + String CustomizableFieldMetadata_name = "CustomizableFieldMetadata.name"; + String CustomizableFieldMetadata_readOnly = "CustomizableFieldMetadata.readOnly"; + String CustomizableFieldMetadata_uiGroup = "CustomizableFieldMetadata.uiGroup"; + String CustomizableFieldMetadata_uiLinePosition = "CustomizableFieldMetadata.uiLinePosition"; + String CustomizableFieldMetadata_uiLineWeight = "CustomizableFieldMetadata.uiLineWeight"; + String CustomizableFieldMetadata_visibilityRestrictions = "CustomizableFieldMetadata.visibilityRestrictions"; + String CustomizableFieldMetadata_visibilityRestrictionsDiseases = "CustomizableFieldMetadata.visibilityRestrictionsDiseases"; + String customizableFieldsActiveOnly = "customizableFieldsActiveOnly"; + String customizableFieldsAllActive = "customizableFieldsAllActive"; + String customizableFieldsInactiveOnly = "customizableFieldsInactiveOnly"; + String customizableFieldsNewLabel = "customizableFieldsNewLabel"; String dashboardAggregatedNumber = "dashboardAggregatedNumber"; String dashboardAlive = "dashboardAlive"; String dashboardApplyCustomFilter = "dashboardApplyCustomFilter"; @@ -1841,6 +1882,7 @@ public interface Captions { String Exposure_animalVaccinated = "Exposure.animalVaccinated"; String Exposure_bodyOfWater = "Exposure.bodyOfWater"; String Exposure_childcareFacilityDetails = "Exposure.childcareFacilityDetails"; + String Exposure_conditionOfAnimal = "Exposure.conditionOfAnimal"; String Exposure_connectionNumber = "Exposure.connectionNumber"; String Exposure_contactFactorDetails = "Exposure.contactFactorDetails"; String Exposure_contactFactors = "Exposure.contactFactors"; @@ -3490,6 +3532,8 @@ public interface Captions { String View_configuration_countries_short = "View.configuration.countries.short"; String View_configuration_customizableEnums = "View.configuration.customizableEnums"; String View_configuration_customizableEnums_short = "View.configuration.customizableEnums.short"; + String View_configuration_customizableFields = "View.configuration.customizableFields"; + String View_configuration_customizableFields_short = "View.configuration.customizableFields.short"; String View_configuration_devMode = "View.configuration.devMode"; String View_configuration_devMode_short = "View.configuration.devMode.short"; String View_configuration_diseaseconfiguration = "View.configuration.diseaseconfiguration"; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java index 2d7df0d4e69..ebc166eab95 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java @@ -506,6 +506,7 @@ public interface Strings { String headingClinicalMeasurements = "headingClinicalMeasurements"; String headingClinicalPresentation = "headingClinicalPresentation"; String headingClinicalVisitsDeleted = "headingClinicalVisitsDeleted"; + String headingCloneCustomizableField = "headingCloneCustomizableField"; String headingClusterType = "headingClusterType"; String headingComparisonCase = "headingComparisonCase"; String headingCompleteness = "headingCompleteness"; @@ -514,6 +515,7 @@ public interface Strings { String headingConfirmBulkGrantSpecialAccess = "headingConfirmBulkGrantSpecialAccess"; String headingConfirmChoice = "headingConfirmChoice"; String headingConfirmDearchiving = "headingConfirmDearchiving"; + String headingConfirmDeleteCustomizableField = "headingConfirmDeleteCustomizableField"; String headingConfirmDeletion = "headingConfirmDeletion"; String headingConfirmDisabling = "headingConfirmDisabling"; String headingConfirmEnabling = "headingConfirmEnabling"; @@ -546,6 +548,7 @@ public interface Strings { String headingCorrectSample = "headingCorrectSample"; String headingCreateAdditionalTest = "headingCreateAdditionalTest"; String headingCreateCampaignDataForm = "headingCreateCampaignDataForm"; + String headingCreateCustomizableField = "headingCreateCustomizableField"; String headingCreateEntry = "headingCreateEntry"; String headingCreateNewAction = "headingCreateNewAction"; String headingCreateNewAggregateReport = "headingCreateNewAggregateReport"; @@ -579,6 +582,11 @@ public interface Strings { String headingCreateSurveillanceReport = "headingCreateSurveillanceReport"; String headingCurrentHospitalization = "headingCurrentHospitalization"; String headingCustomizableEnumConfigurationInfo = "headingCustomizableEnumConfigurationInfo"; + String headingCustomizableFieldBasics = "headingCustomizableFieldBasics"; + String headingCustomizableFieldBehavior = "headingCustomizableFieldBehavior"; + String headingCustomizableFieldPlacement = "headingCustomizableFieldPlacement"; + String headingCustomizableFieldTranslations = "headingCustomizableFieldTranslations"; + String headingCustomizableFieldVisibility = "headingCustomizableFieldVisibility"; String headingDatabaseExportFailed = "headingDatabaseExportFailed"; String headingDataImport = "headingDataImport"; String headingDearchiveAdverseEvent = "headingDearchiveAdverseEvent"; @@ -620,6 +628,7 @@ public interface Strings { String headingEditContacts = "headingEditContacts"; String headingEditContinent = "headingEditContinent"; String headingEditCountry = "headingEditCountry"; + String headingEditCustomizableField = "headingEditCustomizableField"; String headingEditEventParticipant = "headingEditEventParticipant"; String headingEditEvents = "headingEditEvents"; String headingEditLineListing = "headingEditLineListing"; @@ -1086,6 +1095,8 @@ public interface Strings { String infoNoAefiInvestigations = "infoNoAefiInvestigations"; String infoNoCasesFoundStatistics = "infoNoCasesFoundStatistics"; String infoNoCustomizableEnumTranslations = "infoNoCustomizableEnumTranslations"; + String infoNoCustomizableFieldOptions = "infoNoCustomizableFieldOptions"; + String infoNoCustomizableFieldTranslations = "infoNoCustomizableFieldTranslations"; String infoNoDiseaseConfigurationAgeGroups = "infoNoDiseaseConfigurationAgeGroups"; String infoNoDiseaseSelected = "infoNoDiseaseSelected"; String infoNoEnvironmentSamples = "infoNoEnvironmentSamples"; @@ -1175,6 +1186,7 @@ public interface Strings { String infoVaccinationDoseCount = "infoVaccinationDoseCount"; String infoWeeklyReportsView = "infoWeeklyReportsView"; String labelActualLongSeed = "labelActualLongSeed"; + String labelCustomizableFieldOptions = "labelCustomizableFieldOptions"; String labelNoVaccinationDate = "labelNoVaccinationDate"; String labelNoVaccineName = "labelNoVaccineName"; String labelNumberOfAreas = "labelNumberOfAreas"; @@ -1383,6 +1395,10 @@ public interface Strings { String messageCountVisitsNotSetToLostAccessDeniedReason = "messageCountVisitsNotSetToLostAccessDeniedReason"; String messageCreateCollectionTask = "messageCreateCollectionTask"; String messageCustomizableEnumValueSaved = "messageCustomizableEnumValueSaved"; + String messageCustomizableFieldCloned = "messageCustomizableFieldCloned"; + String messageCustomizableFieldCreated = "messageCustomizableFieldCreated"; + String messageCustomizableFieldDeleted = "messageCustomizableFieldDeleted"; + String messageCustomizableFieldSaved = "messageCustomizableFieldSaved"; String messageDatabaseExportFailed = "messageDatabaseExportFailed"; String messageDeleteImmunizationVaccinations = "messageDeleteImmunizationVaccinations"; String messageDeleteReasonNotFilled = "messageDeleteReasonNotFilled"; @@ -1797,6 +1813,7 @@ public interface Strings { String promptCasesEpiWeekTo = "promptCasesEpiWeekTo"; String promptCaseSex = "promptCaseSex"; String promptCasesSearchField = "promptCasesSearchField"; + String promptConfirmDeleteCustomizableField = "promptConfirmDeleteCustomizableField"; String promptContactDateFrom = "promptContactDateFrom"; String promptContactDateTo = "promptContactDateTo"; String promptContactDateType = "promptContactDateType"; @@ -1807,6 +1824,8 @@ public interface Strings { String promptCustomizableEnumSearchField = "promptCustomizableEnumSearchField"; String promptCustomizableEnumTranslationCaption = "promptCustomizableEnumTranslationCaption"; String promptCustomizableEnumTranslationLanguage = "promptCustomizableEnumTranslationLanguage"; + String promptCustomizableFieldOption = "promptCustomizableFieldOption"; + String promptCustomizableFieldSearchField = "promptCustomizableFieldSearchField"; String promptDateTo = "promptDateTo"; String promptDisease = "promptDisease"; String promptDiseaseConfigurationAgeFrom = "promptDiseaseConfigurationAgeFrom"; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/importexport/DatabaseTable.java b/sormas-api/src/main/java/de/symeda/sormas/api/importexport/DatabaseTable.java index 69dcc8310c8..bd934480758 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/importexport/DatabaseTable.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/importexport/DatabaseTable.java @@ -105,6 +105,8 @@ public enum DatabaseTable { FACILITIES(DatabaseTableType.INFRASTRUCTURE, "facilities", null), POINTS_OF_ENTRY(DatabaseTableType.INFRASTRUCTURE, "points_of_entry", null), CUSTOMIZABLE_ENUM_VALUES(DatabaseTableType.CONFIGURATION, "customizable_enum_values", null), + CUSTOMIZABLE_FIELD_METADATA(DatabaseTableType.CONFIGURATION, "customizable_field_metadata", null), + CUSTOMIZABLE_FIELD_VALUE(DatabaseTableType.CONFIGURATION, CUSTOMIZABLE_FIELD_METADATA, "customizable_field_value"), CAMPAIGNS(DatabaseTableType.SORMAS, "campaigns", dependingOnFeature(FeatureType.CAMPAIGNS)), CAMPAIGN_CAMPAIGNFORMMETA(DatabaseTableType.SORMAS, CAMPAIGNS, "campaign_campaignformmeta"), diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/user/DefaultUserRole.java b/sormas-api/src/main/java/de/symeda/sormas/api/user/DefaultUserRole.java index dfe51b3d861..2951456bcb2 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/user/DefaultUserRole.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/user/DefaultUserRole.java @@ -484,6 +484,7 @@ public Set getDefaultUserRights() { SORMAS_UI, DEV_MODE, CUSTOMIZABLE_ENUM_MANAGEMENT, + CUSTOMIZABLE_FIELD_MANAGEMENT, SYSTEM_CONFIGURATION)); break; case ADMIN_SUPERVISOR: diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/user/UserRight.java b/sormas-api/src/main/java/de/symeda/sormas/api/user/UserRight.java index d86c6fc3ebd..e2ab2daf826 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/user/UserRight.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/user/UserRight.java @@ -321,6 +321,7 @@ public enum UserRight { EXTERNAL_EMAIL_SEND(UserRightGroup.EXTERNAL_EMAILS), EXTERNAL_EMAIL_ATTACH_DOCUMENTS(UserRightGroup.EXTERNAL_EMAILS, UserRight._EXTERNAL_EMAIL_SEND), CUSTOMIZABLE_ENUM_MANAGEMENT(UserRightGroup.CONFIGURATION), + CUSTOMIZABLE_FIELD_MANAGEMENT(UserRightGroup.CONFIGURATION), SYSTEM_CONFIGURATION(UserRightGroup.CONFIGURATION), DISEASE_MANAGEMENT(UserRightGroup.CONFIGURATION), EPIPULSE_EXPORT_VIEW(UserRightGroup.EPIPULSE), @@ -572,6 +573,7 @@ public enum UserRight { public static final String _EXTERNAL_EMAIL_SEND = "EXTERNAL_EMAIL_SEND"; public static final String _EXTERNAL_EMAIL_ATTACH_DOCUMENTS = "EXTERNAL_EMAIL_ATTACH_DOCUMENTS"; public static final String _CUSTOMIZABLE_ENUM_MANAGEMENT = "CUSTOMIZABLE_ENUM_MANAGEMENT"; + public static final String _CUSTOMIZABLE_FIELD_MANAGEMENT = "CUSTOMIZABLE_FIELD_MANAGEMENT"; public static final String _SYSTEM_CONFIGURATION = "SYSTEM_CONFIGURATION"; public static final String _DISEASE_MANAGEMENT = "DISEASE_MANAGEMENT"; diff --git a/sormas-api/src/main/resources/captions.properties b/sormas-api/src/main/resources/captions.properties index f9ed2b2e7a3..0477e6e5986 100644 --- a/sormas-api/src/main/resources/captions.properties +++ b/sormas-api/src/main/resources/captions.properties @@ -144,6 +144,7 @@ actionImportAllSubcontinents=Import default subcontinents actionLogout=Logout actionNewEntry=New entry actionOkay=Okay +actionClone=Clone actionConfirmFilters=Confirm filters actionResetFilters=Reset filters actionApplyFilters=Apply filters @@ -3334,6 +3335,8 @@ View.configuration.emailTemplates=Email Templates View.configuration.emailTemplates.short=Email Templates View.configuration.customizableEnums=Customizable Enum Configuration View.configuration.customizableEnums.short=Customizable Enums +View.configuration.customizableFields=Customizable Fields +View.configuration.customizableFields.short=Customizable Fields View.configuration.diseaseconfiguration=Disease Configuration View.configuration.diseaseconfiguration.short=Disease Configuration View.contacts=Contact Directory @@ -3709,6 +3712,52 @@ SystemConfigurationValue.pattern = Pattern SystemConfigurationValue.value = Value SystemConfigurationValue.General = General +# CustomizableFieldMetadata +CustomizableFieldMetadata.name = Internal Name +CustomizableFieldMetadata.description = Description +CustomizableFieldMetadata.fieldType = Field Type +CustomizableFieldMetadata.contextClass = Context / Entity +CustomizableFieldMetadata.uiGroup = UI Group +CustomizableFieldMetadata.uiLinePosition = Line Position +CustomizableFieldMetadata.uiLineWeight = Line Weight +CustomizableFieldMetadata.active = Active +CustomizableFieldMetadata.mandatory = Mandatory +CustomizableFieldMetadata.readOnly = Read Only +CustomizableFieldMetadata.defaultValue = Default Value +CustomizableFieldMetadata.visibilityRestrictions = Visibility Restrictions +CustomizableFieldMetadata.visibilityRestrictionsDiseases = Visible for Diseases +customizableFieldsAllActive = All +customizableFieldsActiveOnly = Active only +customizableFieldsInactiveOnly = Inactive only +customizableFieldsNewLabel = New name for clone + +# CustomizableFieldGroup +CustomizableFieldGroup.CASE_DATA_GENERAL = Case Data General +CustomizableFieldGroup.CASE_DATA_CLASSIFICATION = Case Data Classification +CustomizableFieldGroup.CASE_DATA_INVESTIGATION = Case Data Investigation +CustomizableFieldGroup.CASE_DATA_IDENTIFIERS = Case Data Identifiers +CustomizableFieldGroup.CASE_DATA_DISEASE = Case Data Disease +CustomizableFieldGroup.CASE_DATA_REINFECTION = Case Data Reinfection +CustomizableFieldGroup.CASE_DATA_OUTCOME = Case Data Outcome +CustomizableFieldGroup.CASE_DATA_SEQUELAE = Case Data Sequelae +CustomizableFieldGroup.CASE_DATA_JURISDICTION = Case Data Jurisdiction +CustomizableFieldGroup.CASE_DATA_PLACE_OF_STAY = Case Data Place Of Stay +CustomizableFieldGroup.CASE_DATA_QUARANTINE = Case Data Quarantine +CustomizableFieldGroup.CASE_DATA_REPORT_GEO = Case Data Report Geo +CustomizableFieldGroup.CASE_DATA_HEALTH_CONDITIONS = Case Data Health Conditions +CustomizableFieldGroup.CASE_DATA_DIAGNOSTIC = Case Data Diagnostic +CustomizableFieldGroup.CASE_DATA_MEDICAL_INFORMATION = Case Data Medical Information +CustomizableFieldGroup.CASE_DATA_VACCINATION = Case Data Vaccination +CustomizableFieldGroup.CASE_DATA_CLINICIAN_NOTIFICATION = Case Data Clinician Notification +CustomizableFieldGroup.CASE_DATA_CONTACT_TRACING = Case Data Contact Tracing +CustomizableFieldGroup.EPIDATA_EXPOSURE_INVESTIGATION = Exposure Investigation +CustomizableFieldGroup.EPIDATA_ACTIVITY_AS_CASE = Activity as Case +CustomizableFieldGroup.EPIDATA_CONTACT_WITH_SOURCE_CASE = Contact with Source Case + +# CustomizableFieldContext +CustomizableFieldContext.CASE = Case +CustomizableFieldContext.EPIDATA = Epidemiological Data + # Notifier Notifier.notification = Notification Notification.dateOfNotification = Date of notification diff --git a/sormas-api/src/main/resources/enum.properties b/sormas-api/src/main/resources/enum.properties index cbc929e9ca0..c52494fdb13 100644 --- a/sormas-api/src/main/resources/enum.properties +++ b/sormas-api/src/main/resources/enum.properties @@ -402,6 +402,8 @@ DatabaseTable.CAMPAIGN_CAMPAIGNFORMMETA = Campaigns → Campaign Form Meta DatabaseTable.CAMPAIGN_FORM_META = Campaign form meta DatabaseTable.CAMPAIGN_FORM_DATA = Campaign form data DatabaseTable.CAMPAIGN_DIAGRAM_DEFINITIONS = Campaign diagram definitions +DatabaseTable.CUSTOMIZABLE_FIELD_METADATA = Customizable field metadata +DatabaseTable.CUSTOMIZABLE_FIELD_VALUE = Customizable field value DatabaseTable.POPULATION_DATA = Population data DatabaseTable.SURVEILLANCE_REPORTS = Surveillance reports DatabaseTable.AGGREGATE_REPORTS = Aggregate reports @@ -1899,6 +1901,7 @@ UserRight.EXTERNAL_VISITS = External visits UserRight.SORMAS_UI = Access Sormas UI UserRight.DEV_MODE = Access developer options UserRight.CUSTOMIZABLE_ENUM_MANAGEMENT = Manage customizable enums +UserRight.CUSTOMIZABLE_FIELD_MANAGEMENT = Manage customizable fields UserRight.DISEASE_MANAGEMENT = Disease management UserRight.DOCUMENT_VIEW = View existing documents UserRight.DOCUMENT_UPLOAD = Upload documents diff --git a/sormas-api/src/main/resources/strings.properties b/sormas-api/src/main/resources/strings.properties index 811a9abee24..3d394e17124 100644 --- a/sormas-api/src/main/resources/strings.properties +++ b/sormas-api/src/main/resources/strings.properties @@ -511,6 +511,15 @@ headingContactsNotLinked = None of the contacts were linked headingCasesNotLinked = None of the cases were linked headingContactsNotRestored = = None of the contacts were restored headingCreateAdditionalTest = Create new additional test results +headingCreateCustomizableField = Create Customizable Field +headingEditCustomizableField = Edit Customizable Field +headingCloneCustomizableField = Clone Customizable Field +headingConfirmDeleteCustomizableField = Confirm Delete +headingCustomizableFieldBasics = Basics +headingCustomizableFieldPlacement = Placement +headingCustomizableFieldBehavior = Behavior +headingCustomizableFieldVisibility = Visibility +headingCustomizableFieldTranslations = Translations headingCreateEntry = Create entry headingCreateNewAction = Create new action headingCreateNewAggregateReport = Create a new aggregated report @@ -1163,6 +1172,8 @@ infoNoEnvironmentSamples = No samples have been created for this environment infoRecoveryNaturalImmunity = Natural immunity from recovering from the disease infoRestrictDiseasesDescription=Mark all diseases that the user is supposed to have access to infoNoCustomizableEnumTranslations = Click on the + button below to add translations to this customizable enum value. +infoNoCustomizableFieldTranslations = Click on the + button below to add translations for this field. +infoNoCustomizableFieldOptions = Click on the + button below to add options for this field. infoCustomizableEnumConfigurationInfo = Customizable enums are value sets that can be customized in order to react to the individual needs of your country or a specific epidemiological situation. The table on this screen contains all customizable enum values in the database. Each value is associated with a data type, e.g. disease variants or occupation types. Some of these data types have default values that are automatically added to the database when SORMAS is set up or new data types are added to the system.

You can add new enum values or edit existing ones, add translations for languages supported by SORMAS, select the diseases that the value should be visible for (by default, customizable enum values are visible for all diseases), and configure additional properties.

Properties are used to further control the behaviour of customizable enum values. E.g. the "has details" property that is supported by most enum values toggles whether selecting this enum value would bring up an additional text field that users can add more information to. infoNoImmunizationAdverseEvents = No adverse events have been created for this immunization infoAefiSelectPrimarySuspectVaccine = The list below contains all vaccinations of the immunization. Please select the suspect vaccination related to this adverse event. @@ -1539,6 +1550,11 @@ messageContactCaseRemoved = The source case has been removed from this contact messageContactCaseChanged = The source case of the contact has been changed messageSampleOpened = Opened sample found for search string messageSystemConfigurationValueSaved = System configuration value saved +messageCustomizableFieldSaved = Customizable field saved +messageCustomizableFieldCreated = Customizable field created +messageCustomizableFieldCloned = Customizable field cloned +messageCustomizableFieldDeleted = Customizable field deleted +promptCustomizableFieldSearchField = Search by name, description or group messageSystemFollowUpCanceled = [System] Follow-up automatically canceled because contact was converted to a case messageSystemFollowUpCanceledByDropping = [System] Follow-up automatically canceled because contact was dropped messageSetContactRegionAndDistrict = Please choose a responsible region and responsible district and save the contact before removing the source case. @@ -1707,6 +1723,7 @@ labelNumberOfTemplates = No. of Templates labelNoVaccinationDate = No vaccination date labelNoVaccineName = No vaccine name labelNumberOfDiseaseConfigurations = No. of diseases +labelCustomizableFieldOptions = Field options # Numbers numberEight = eight @@ -1840,6 +1857,7 @@ promptDiseaseConfigurationStartAgeType = Start age unit promptDiseaseConfigurationEndAge = End age promptDiseaseConfigurationEndAgeType = End age unit promptCaseSex=Case sex +promptConfirmDeleteCustomizableField = Are you sure you want to delete this customizable field? All field values linked to it will also be deleted. #DiseaseNetworkDiagram DiseaseNetworkDiagram.Classification.HEALTHY = Healthy @@ -1886,6 +1904,7 @@ promptEnvironmentSampleLonTo= ... to promptCustomizableEnumTranslationLanguage = Language promptCustomizableEnumTranslationCaption = Translated caption promptCustomizableEnumSearchField = Search by value or caption... +promptCustomizableFieldOption = Option value promptSurveyFreeTextSearch = Name promptSurveyTokenFreeTextSearch = Token diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/caze/CaseFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/caze/CaseFacadeEjb.java index d40289a68d4..66fe1227262 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/caze/CaseFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/caze/CaseFacadeEjb.java @@ -137,6 +137,7 @@ import de.symeda.sormas.api.contact.ContactDto; import de.symeda.sormas.api.contact.ContactReferenceDto; import de.symeda.sormas.api.customizableenum.CustomizableEnumType; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; import de.symeda.sormas.api.disease.DiseaseVariant; import de.symeda.sormas.api.document.DocumentRelatedEntityType; import de.symeda.sormas.api.epidata.EpiDataDto; @@ -254,6 +255,7 @@ import de.symeda.sormas.backend.contact.ContactService; import de.symeda.sormas.backend.contact.VisitSummaryExportDetails; import de.symeda.sormas.backend.customizableenum.CustomizableEnumFacadeEjb.CustomizableEnumFacadeEjbLocal; +import de.symeda.sormas.backend.customizablefield.CustomizableFieldValueService; import de.symeda.sormas.backend.disease.DiseaseConfigurationFacadeEjb.DiseaseConfigurationFacadeEjbLocal; import de.symeda.sormas.backend.document.Document; import de.symeda.sormas.backend.document.DocumentRelatedEntityService; @@ -491,6 +493,8 @@ public class CaseFacadeEjb extends AbstractCoreFacadeEjb tokens = surveyService.findBy(new SurveyTokenCriteria().caseAssignedTo(otherCase.toReference())); tokens.forEach(s -> { diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/contact/ContactFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/contact/ContactFacadeEjb.java index b16b793b29b..6cf38ade92a 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/contact/ContactFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/contact/ContactFacadeEjb.java @@ -659,6 +659,7 @@ private void deleteContact(Contact contact, DeletionDetails deletionDetails) { } service.delete(contact, deletionDetails); + epiDataFacade.softDeleteCustomizableFieldValues(contact.getEpiData(), deletionDetails); if (contact.getCaze() != null) { caseFacade.onCaseChanged(caseFacade.toDto(contact.getCaze()), contact.getCaze()); } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadata.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadata.java new file mode 100644 index 00000000000..70c49a28b8d --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadata.java @@ -0,0 +1,210 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.customizablefield; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; + +import org.hibernate.annotations.Type; +import org.hibernate.annotations.TypeDef; + +import com.vladmihalcea.hibernate.type.json.JsonBinaryType; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup; +import de.symeda.sormas.api.customizablefield.CustomizableFieldType; +import de.symeda.sormas.backend.common.CoreAdo; + +/** + * Entity class for customizable field metadata. + * Stores the configuration for custom fields that can be added to entities. + */ +@Entity(name = "customizablefieldmetadata") +@TypeDef(name = "jsonb", typeClass = JsonBinaryType.class) +@SuppressWarnings({ + "java:S1845", // suppress sonar field name clash warning + "java:S2160" // suppress missing equals handled in AbstractDomainObject +}) +public class CustomizableFieldMetadata extends CoreAdo { + + private static final long serialVersionUID = 1L; + + public static final String TABLE_NAME = "customizablefieldmetadata"; + + public static final String NAME = "name"; + public static final String DESCRIPTION = "description"; + public static final String FIELD_TYPE = "fieldType"; + public static final String CONTEXT_CLASS = "contextClass"; + public static final String UI_GROUP = "uiGroup"; + public static final String UI_LINE_POSITION = "uiLinePosition"; + public static final String UI_LINE_WEIGHT = "uiLineWeight"; + public static final String ACTIVE = "active"; + public static final String MANDATORY = "mandatory"; + public static final String READ_ONLY = "readOnly"; + public static final String DEFAULT_VALUE = "defaultValue"; + + private String name; + private String description; + private CustomizableFieldType fieldType; + private CustomizableFieldContext contextClass; + private CustomizableFieldGroup uiGroup; + private Integer uiLinePosition; + private Float uiLineWeight; + private boolean active = true; + private boolean mandatory = false; + private boolean readOnly = false; + private String defaultValue; + + // JSON-serialized complex properties + private String visibilityRestrictions; + private String customProperties; + private String translations; + + @Column(length = 512, nullable = false) + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Column(columnDefinition = "text") + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + public CustomizableFieldType getFieldType() { + return fieldType; + } + + public void setFieldType(CustomizableFieldType fieldType) { + this.fieldType = fieldType; + } + + @Column(length = 256, nullable = false) + @Enumerated(EnumType.STRING) + public CustomizableFieldContext getContextClass() { + return contextClass; + } + + public void setContextClass(CustomizableFieldContext contextClass) { + this.contextClass = contextClass; + } + + @Column(length = 256) + @Enumerated(EnumType.STRING) + public CustomizableFieldGroup getUiGroup() { + return uiGroup; + } + + public void setUiGroup(CustomizableFieldGroup uiGroup) { + this.uiGroup = uiGroup; + } + + @Column + public Integer getUiLinePosition() { + return uiLinePosition; + } + + public void setUiLinePosition(Integer uiLinePosition) { + this.uiLinePosition = uiLinePosition; + } + + @Column + public Float getUiLineWeight() { + return uiLineWeight; + } + + public void setUiLineWeight(Float uiLineWeight) { + this.uiLineWeight = uiLineWeight; + } + + @Column(nullable = false) + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + @Column + public boolean isMandatory() { + return mandatory; + } + + public void setMandatory(boolean mandatory) { + this.mandatory = mandatory; + } + + @Column + public boolean isReadOnly() { + return readOnly; + } + + public void setReadOnly(boolean readOnly) { + this.readOnly = readOnly; + } + + @Column(columnDefinition = "text") + public String getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + } + + // JSON-serialized properties + @Column(columnDefinition = "jsonb") + @Type(type = "jsonb") + public String getVisibilityRestrictions() { + return visibilityRestrictions; + } + + public void setVisibilityRestrictions(String visibilityRestrictions) { + this.visibilityRestrictions = visibilityRestrictions; + } + + @Column(columnDefinition = "jsonb") + @Type(type = "jsonb") + public String getCustomProperties() { + return customProperties; + } + + public void setCustomProperties(String customProperties) { + this.customProperties = customProperties; + } + + @Column(columnDefinition = "jsonb") + @Type(type = "jsonb") + public String getTranslations() { + return translations; + } + + public void setTranslations(String translations) { + this.translations = translations; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataFacadeEjb.java new file mode 100644 index 00000000000..04b5d98af3e --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataFacadeEjb.java @@ -0,0 +1,337 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.customizablefield; + +import java.io.IOException; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.ejb.EJB; +import javax.ejb.LocalBean; +import javax.ejb.Stateless; +import javax.inject.Inject; +import javax.validation.Valid; +import javax.validation.constraints.NotNull; + +import org.apache.commons.lang3.NotImplementedException; +import org.apache.commons.lang3.StringUtils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.symeda.sormas.api.common.DeletableEntityType; +import de.symeda.sormas.api.common.DeletionDetails; +import de.symeda.sormas.api.common.progress.ProcessedEntity; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldCustomProperties; +import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataCriteria; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataFacade; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataReferenceDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldVisibilityRestrictions; +import de.symeda.sormas.api.utils.SortProperty; +import de.symeda.sormas.api.utils.ValidationRuntimeException; +import de.symeda.sormas.backend.common.AbstractCoreFacadeEjb; +import de.symeda.sormas.backend.util.DtoHelper; +import de.symeda.sormas.backend.util.Pseudonymizer; + +/** + * Facade EJB implementation for customizable field metadata. + */ +@Stateless(name = "CustomizableFieldMetadataFacade") +public class CustomizableFieldMetadataFacadeEjb + extends + AbstractCoreFacadeEjb + implements CustomizableFieldMetadataFacade { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @EJB + private CustomizableFieldValueService customizableFieldValueService; + + public CustomizableFieldMetadataFacadeEjb() { + } + + @Inject + public CustomizableFieldMetadataFacadeEjb(CustomizableFieldMetadataService service) { + super(CustomizableFieldMetadata.class, CustomizableFieldMetadataDto.class, service); + } + + @Override + public List getActiveFieldsForContext(CustomizableFieldContext contextClass) { + return service.getActiveFieldsForContext(contextClass).stream().map(this::toDto).collect(Collectors.toList()); + } + + @Override + public List getFieldsForUIGroup(CustomizableFieldGroup uiGroup) { + return service.getFieldsForUIGroup(uiGroup).stream().map(this::toDto).collect(Collectors.toList()); + } + + @Override + public List getFieldsOrderedByUIPosition(CustomizableFieldGroup uiGroup) { + return getFieldsForUIGroup(uiGroup); // Already ordered in service + } + + @Override + public CustomizableFieldMetadataDto getByNameAndContext(String name, CustomizableFieldContext contextClass) { + CustomizableFieldMetadata entity = service.getByNameAndContext(name, contextClass); + return entity != null ? toDto(entity) : null; + } + + @Override + public CustomizableFieldMetadataDto cloneField(String sourceUuid, String newName) { + CustomizableFieldMetadata cloned = service.cloneField(sourceUuid, newName); + return toDto(cloned); + } + + @Override + public void setFieldActive(String uuid, boolean active) { + CustomizableFieldMetadata field = service.getByUuid(uuid); + if (field != null) { + field.setActive(active); + service.ensurePersisted(field); + } + } + + @Override + public List getAll() { + return service.getAll().stream().map(this::toDto).collect(Collectors.toList()); + } + + @Override + public List getIndexList( + CustomizableFieldMetadataCriteria criteria, + Integer first, + Integer max, + List sortProperties) { + + return service.getIndexList(criteria, first, max, sortProperties).stream().map(this::toDto).collect(Collectors.toList()); + } + + @Override + public long count(CustomizableFieldMetadataCriteria criteria) { + return service.count(criteria); + } + + @Override + public CustomizableFieldMetadataDto save(@Valid @NotNull CustomizableFieldMetadataDto dto) { + CustomizableFieldMetadata entity = service.getByUuid(dto.getUuid()); + entity = fillOrBuildEntity(dto, entity, true); + service.ensurePersisted(entity); + return toDto(entity); + } + + @Override + public void delete(String uuid, DeletionDetails deletionDetails) { + CustomizableFieldMetadata entity = service.getByUuid(uuid); + if (entity != null) { + customizableFieldValueService.softDeleteValuesForMetadata(uuid, deletionDetails); + service.delete(entity, deletionDetails); + } + } + + @Override + public List delete(List uuids, DeletionDetails deletionDetails) { + throw new NotImplementedException(); + } + + @Override + public void restore(String uuid) { + super.restore(uuid); + } + + @Override + public List restore(List uuids) { + throw new NotImplementedException(); + } + + @Override + public List getArchivedUuidsSince(Date since) { + throw new NotImplementedException(); + } + + @Override + public void validate(@Valid CustomizableFieldMetadataDto dto) throws ValidationRuntimeException { + // no validation required for customizable field metadata + } + + @Override + protected DeletableEntityType getDeletableEntityType() { + return DeletableEntityType.CUSTOMIZABLE_FIELD_METADATA; + } + + @Override + protected CustomizableFieldMetadataReferenceDto toRefDto(CustomizableFieldMetadata entity) { + return new CustomizableFieldMetadataReferenceDto(entity.getUuid()); + } + + @Override + protected void pseudonymizeDto( + CustomizableFieldMetadata source, + CustomizableFieldMetadataDto dto, + Pseudonymizer pseudonymizer, + boolean inJurisdiction) { + // no sensitive fields to pseudonymize + } + + @Override + protected void restorePseudonymizedDto( + CustomizableFieldMetadataDto dto, + CustomizableFieldMetadataDto existingDto, + CustomizableFieldMetadata entity, + Pseudonymizer pseudonymizer) { + // no sensitive fields to restore + } + + @Override + protected CustomizableFieldMetadata fillOrBuildEntity( + @NotNull CustomizableFieldMetadataDto source, + CustomizableFieldMetadata target, + boolean checkChangeDate) { + if (source == null) { + return null; + } + + target = DtoHelper.fillOrBuildEntity(source, target, CustomizableFieldMetadata::new, checkChangeDate); + + target.setName(source.getName()); + target.setDescription(source.getDescription()); + target.setFieldType(source.getFieldType()); + target.setContextClass(source.getContextClass()); + target.setUiGroup(source.getUiGroup()); + target.setUiLinePosition(source.getUiLinePosition()); + target.setUiLineWeight(source.getUiLineWeight()); + target.setActive(source.isActive()); + target.setMandatory(source.isMandatory()); + target.setReadOnly(source.isReadOnly()); + target.setDefaultValue(source.getDefaultValue()); + + // Serialize complex properties to JSON + if (source.getVisibilityRestrictions() != null) { + try { + target.setVisibilityRestrictions(mapper.writeValueAsString(source.getVisibilityRestrictions())); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize visibilityRestrictions", e); + } + } else { + target.setVisibilityRestrictions(null); + } + + if (source.getCustomProperties() != null) { + try { + target.setCustomProperties(mapper.writeValueAsString(source.getCustomProperties())); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize customProperties", e); + } + } else { + target.setCustomProperties(null); + } + + if (source.getTranslations() != null) { + try { + target.setTranslations(mapper.writeValueAsString(source.getTranslations())); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize translations", e); + } + } else { + target.setTranslations(null); + } + + return target; + } + + @Override + protected CustomizableFieldMetadataDto toDto(CustomizableFieldMetadata source) { + if (source == null) { + return null; + } + + CustomizableFieldMetadataDto target = new CustomizableFieldMetadataDto(); + DtoHelper.fillDto(target, source); + + target.setName(source.getName()); + target.setDescription(source.getDescription()); + target.setFieldType(source.getFieldType()); + target.setContextClass(source.getContextClass()); + target.setUiGroup(source.getUiGroup()); + target.setUiLinePosition(source.getUiLinePosition()); + target.setUiLineWeight(source.getUiLineWeight()); + target.setActive(source.isActive()); + target.setMandatory(source.isMandatory()); + target.setReadOnly(source.isReadOnly()); + target.setDefaultValue(source.getDefaultValue()); + + // Deserialize JSON to objects + target.setVisibilityRestrictions(parseVisibilityRestrictions(source.getVisibilityRestrictions())); + target.setCustomProperties(parseCustomProperties(source.getCustomProperties())); + target.setTranslations(parseTranslations(source.getTranslations())); + + return target; + } + + private static CustomizableFieldVisibilityRestrictions parseVisibilityRestrictions(String json) { + if (StringUtils.isBlank(json)) { + return null; + } + try { + return mapper.readValue(json, CustomizableFieldVisibilityRestrictions.class); + } catch (IOException e) { + throw new IllegalStateException("Failed to parse visibilityRestrictions JSON", e); + } + } + + private static CustomizableFieldCustomProperties parseCustomProperties(String json) { + if (StringUtils.isBlank(json)) { + return null; + } + try { + return mapper.readValue(json, CustomizableFieldCustomProperties.class); + } catch (IOException e) { + throw new IllegalStateException("Failed to parse customProperties JSON", e); + } + } + + private static Map> parseTranslations(String json) { + if (StringUtils.isBlank(json)) { + // we could return an empty map but it might be interpreted as no translation and postgres might create a '{}' jsonb entry + return Collections.emptyMap(); + } + try { + return mapper.readValue(json, new TypeReference>>() { + }); + } catch (IOException e) { + throw new IllegalStateException("Failed to parse translations JSON", e); + } + } + + @LocalBean + @Stateless + public static class CustomizableFieldMetadataFacadeEjbLocal extends CustomizableFieldMetadataFacadeEjb { + + public CustomizableFieldMetadataFacadeEjbLocal() { + } + + @Inject + public CustomizableFieldMetadataFacadeEjbLocal(CustomizableFieldMetadataService service) { + super(service); + } + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataJoins.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataJoins.java new file mode 100644 index 00000000000..9f3201825c3 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataJoins.java @@ -0,0 +1,27 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.customizablefield; + +import javax.persistence.criteria.From; + +import de.symeda.sormas.backend.common.QueryJoins; + +public class CustomizableFieldMetadataJoins extends QueryJoins { + + public CustomizableFieldMetadataJoins(From root) { + super(root); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataQueryContext.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataQueryContext.java new file mode 100644 index 00000000000..c24e0c9512f --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataQueryContext.java @@ -0,0 +1,35 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.customizablefield; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Expression; +import javax.persistence.criteria.From; + +import de.symeda.sormas.backend.common.QueryContext; + +public class CustomizableFieldMetadataQueryContext extends QueryContext { + + public CustomizableFieldMetadataQueryContext(CriteriaBuilder cb, CriteriaQuery query, From root) { + super(cb, query, root, new CustomizableFieldMetadataJoins(root)); + } + + @Override + protected Expression createExpression(String name) { + return null; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataService.java new file mode 100644 index 00000000000..251e2547675 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataService.java @@ -0,0 +1,227 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.customizablefield; + +import java.util.ArrayList; +import java.util.List; + +import javax.ejb.LocalBean; +import javax.ejb.Stateless; +import javax.persistence.TypedQuery; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.From; +import javax.persistence.criteria.Order; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; + +import org.apache.commons.lang3.StringUtils; + +import de.symeda.sormas.api.common.DeletableEntityType; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataCriteria; +import de.symeda.sormas.api.utils.SortProperty; +import de.symeda.sormas.backend.common.AbstractCoreAdoService; +import de.symeda.sormas.backend.common.DeletableAdo; + +/** + * Service class for customizable field metadata. + */ +@Stateless +@LocalBean +public class CustomizableFieldMetadataService extends AbstractCoreAdoService { + + public CustomizableFieldMetadataService() { + super(CustomizableFieldMetadata.class, DeletableEntityType.CUSTOMIZABLE_FIELD_METADATA); + } + + @Override + @SuppressWarnings("rawtypes") + protected Predicate createUserFilterInternal(CriteriaBuilder cb, CriteriaQuery cq, From from) { + // No jurisdiction filtering for customizable fields + return null; + } + + @Override + protected CustomizableFieldMetadataJoins toJoins(From adoPath) { + return new CustomizableFieldMetadataJoins(adoPath); + } + + @Override + public Predicate inJurisdictionOrOwned(CriteriaBuilder cb, CriteriaQuery query, From from) { + return cb.conjunction(); + } + + public List getActiveFieldsForContext(CustomizableFieldContext contextClass) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(CustomizableFieldMetadata.class); + Root root = cq.from(CustomizableFieldMetadata.class); + + Predicate predicate = cb.and( + cb.equal(root.get(CustomizableFieldMetadata.CONTEXT_CLASS), contextClass), + cb.equal(root.get(CustomizableFieldMetadata.ACTIVE), true), + cb.isFalse(root.get(DeletableAdo.DELETED))); + + cq.where(predicate); + cq.orderBy(cb.asc(root.get(CustomizableFieldMetadata.UI_LINE_POSITION))); + + return em.createQuery(cq).getResultList(); + } + + public List getFieldsForUIGroup(CustomizableFieldGroup uiGroup) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(CustomizableFieldMetadata.class); + Root root = cq.from(CustomizableFieldMetadata.class); + + Predicate predicate = cb.and(cb.equal(root.get(CustomizableFieldMetadata.UI_GROUP), uiGroup), cb.isFalse(root.get(DeletableAdo.DELETED))); + + cq.where(predicate); + cq.orderBy(cb.asc(root.get(CustomizableFieldMetadata.UI_LINE_POSITION))); + + return em.createQuery(cq).getResultList(); + } + + public CustomizableFieldMetadata getByNameAndContext(String name, CustomizableFieldContext contextClass) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(CustomizableFieldMetadata.class); + Root root = cq.from(CustomizableFieldMetadata.class); + + Predicate predicate = cb.and( + cb.equal(root.get(CustomizableFieldMetadata.NAME), name), + cb.equal(root.get(CustomizableFieldMetadata.CONTEXT_CLASS), contextClass), + cb.isFalse(root.get(DeletableAdo.DELETED))); + + cq.where(predicate); + + return em.createQuery(cq).getResultStream().findFirst().orElse(null); + } + + public CustomizableFieldMetadata cloneField(String sourceUuid, String newName) { + CustomizableFieldMetadata source = getByUuid(sourceUuid); + if (source == null) { + throw new IllegalArgumentException("Source field not found: " + sourceUuid); + } + + // Validate new name is unique within context + if (getByNameAndContext(newName, source.getContextClass()) != null) { + throw new IllegalArgumentException("Field name already exists in this context: " + newName); + } + + CustomizableFieldMetadata clone = new CustomizableFieldMetadata(); + clone.setName(newName); + clone.setDescription(source.getDescription()); + clone.setFieldType(source.getFieldType()); + clone.setContextClass(source.getContextClass()); + clone.setUiGroup(source.getUiGroup()); + clone.setUiLinePosition(source.getUiLinePosition()); + clone.setUiLineWeight(source.getUiLineWeight()); + clone.setActive(source.isActive()); + clone.setMandatory(source.isMandatory()); + clone.setReadOnly(source.isReadOnly()); + clone.setDefaultValue(source.getDefaultValue()); + clone.setVisibilityRestrictions(source.getVisibilityRestrictions()); + clone.setCustomProperties(source.getCustomProperties()); + clone.setTranslations(source.getTranslations()); + + ensurePersisted(clone); + return clone; + } + + public List getIndexList( + CustomizableFieldMetadataCriteria criteria, + Integer first, + Integer max, + List sortProperties) { + + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(CustomizableFieldMetadata.class); + Root root = cq.from(CustomizableFieldMetadata.class); + + Predicate filter = buildCriteriaFilter(criteria, cb, root); + if (filter != null) { + cq.where(filter); + } + + if (sortProperties != null && !sortProperties.isEmpty()) { + List orders = new ArrayList<>(); + for (SortProperty sortProperty : sortProperties) { + orders.add(sortProperty.ascending ? cb.asc(root.get(sortProperty.propertyName)) : cb.desc(root.get(sortProperty.propertyName))); + } + cq.orderBy(orders); + } else { + cq.orderBy(cb.asc(root.get(CustomizableFieldMetadata.NAME))); + } + + TypedQuery query = em.createQuery(cq); + if (first != null) { + query.setFirstResult(first); + } + if (max != null) { + query.setMaxResults(max); + } + return query.getResultList(); + } + + public long count(CustomizableFieldMetadataCriteria criteria) { + + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(CustomizableFieldMetadata.class); + cq.select(cb.count(root)); + + Predicate filter = buildCriteriaFilter(criteria, cb, root); + if (filter != null) { + cq.where(filter); + } + + return em.createQuery(cq).getSingleResult(); + } + + private Predicate buildCriteriaFilter(CustomizableFieldMetadataCriteria criteria, CriteriaBuilder cb, Root root) { + + if (criteria == null) { + return null; + } + + List predicates = new ArrayList<>(); + + if (criteria.getContextClass() != null) { + predicates.add(cb.equal(root.get(CustomizableFieldMetadata.CONTEXT_CLASS), criteria.getContextClass())); + } + + if (criteria.getFieldType() != null) { + predicates.add(cb.equal(root.get(CustomizableFieldMetadata.FIELD_TYPE), criteria.getFieldType())); + } + + if (criteria.getActive() != null) { + predicates.add(cb.equal(root.get(CustomizableFieldMetadata.ACTIVE), criteria.getActive())); + } + + predicates.add(cb.isFalse(root.get(DeletableAdo.DELETED))); + + if (!StringUtils.isBlank(criteria.getFreeTextFilter())) { + String likePattern = "%" + criteria.getFreeTextFilter().toLowerCase() + "%"; + predicates.add( + cb.or( + cb.like(cb.lower(root.get(CustomizableFieldMetadata.NAME)), likePattern), + cb.like(cb.lower(root.get(CustomizableFieldMetadata.DESCRIPTION)), likePattern), + cb.like(cb.lower(root.get(CustomizableFieldMetadata.UI_GROUP)), likePattern))); + } + + return predicates.isEmpty() ? null : cb.and(predicates.toArray(new Predicate[0])); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValue.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValue.java new file mode 100644 index 00000000000..129af834315 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValue.java @@ -0,0 +1,91 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.customizablefield; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.FetchType; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.backend.common.CoreAdo; + +/** + * Entity class for customizable field values. + * Stores the actual value for a custom field for a specific entity instance. + */ +@Entity(name = "customizablefieldvalue") +@SuppressWarnings({ + "java:S1845", // suppress sonar field name clash warning + "java:S2160" // suppress missing equals handled in AbstractDomainObject +}) +public class CustomizableFieldValue extends CoreAdo { + + private static final long serialVersionUID = 1L; + + public static final String TABLE_NAME = "customizablefieldvalue"; + + public static final String CUSTOMIZABLE_FIELD_METADATA = "customizableFieldMetadata"; + public static final String ENTITY_UUID = "entityUuid"; + public static final String CONTEXT_CLASS = "contextClass"; + public static final String VALUE = "value"; + + private CustomizableFieldMetadata customizableFieldMetadata; + private String entityUuid; + private CustomizableFieldContext contextClass; + private String value; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "customizablefieldmetadata_id", nullable = false) + public CustomizableFieldMetadata getCustomizableFieldMetadata() { + return customizableFieldMetadata; + } + + public void setCustomizableFieldMetadata(CustomizableFieldMetadata customizableFieldMetadata) { + this.customizableFieldMetadata = customizableFieldMetadata; + } + + @Column(length = 36, nullable = false) + public String getEntityUuid() { + return entityUuid; + } + + public void setEntityUuid(String entityUuid) { + this.entityUuid = entityUuid; + } + + @Column(length = 256, nullable = false) + @Enumerated(EnumType.STRING) + public CustomizableFieldContext getContextClass() { + return contextClass; + } + + public void setContextClass(CustomizableFieldContext contextClass) { + this.contextClass = contextClass; + } + + @Column(columnDefinition = "text") + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValueFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValueFacadeEjb.java new file mode 100644 index 00000000000..81d4fa152c9 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValueFacadeEjb.java @@ -0,0 +1,254 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.customizablefield; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.ejb.EJB; +import javax.ejb.LocalBean; +import javax.ejb.Stateless; +import javax.inject.Inject; +import javax.validation.Valid; +import javax.validation.constraints.NotNull; + +import org.apache.commons.lang3.NotImplementedException; + +import de.symeda.sormas.api.common.DeletableEntityType; +import de.symeda.sormas.api.common.DeletionDetails; +import de.symeda.sormas.api.common.progress.ProcessedEntity; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueCriteria; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueFacade; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueReferenceDto; +import de.symeda.sormas.api.utils.ValidationRuntimeException; +import de.symeda.sormas.backend.common.AbstractCoreFacadeEjb; +import de.symeda.sormas.backend.customizablefield.CustomizableFieldMetadataFacadeEjb.CustomizableFieldMetadataFacadeEjbLocal; +import de.symeda.sormas.backend.util.DtoHelper; +import de.symeda.sormas.backend.util.Pseudonymizer; + +/** + * Facade EJB implementation for customizable field values. + */ +@Stateless(name = "CustomizableFieldValueFacade") +public class CustomizableFieldValueFacadeEjb + extends + AbstractCoreFacadeEjb + implements CustomizableFieldValueFacade { + + @EJB + private CustomizableFieldMetadataService customizableFieldMetadataService; + @EJB + private CustomizableFieldMetadataFacadeEjbLocal customizableFieldMetadataFacade; + + public CustomizableFieldValueFacadeEjb() { + } + + @Inject + public CustomizableFieldValueFacadeEjb(CustomizableFieldValueService service) { + super(CustomizableFieldValue.class, CustomizableFieldValueDto.class, service); + } + + @Override + public void saveEntityCustomFields( + String entityUuid, + CustomizableFieldContext contextClass, + Map fieldValues) { + saveEntityCustomFields(entityUuid, Map.of(contextClass, fieldValues)); + } + + @Override + public Map getValuesForEntity(String entityUuid, CustomizableFieldContext contextClass) { + Map entities = service.getValuesForEntity(entityUuid, contextClass); + + Map result = new HashMap<>(); + for (Map.Entry entry : entities.entrySet()) { + CustomizableFieldMetadataDto metadataDto = customizableFieldMetadataFacade.getByUuid(entry.getKey().getUuid()); + result.put(metadataDto, toDto(entry.getValue())); + } + return result; + } + + @Override + public void saveEntityCustomFields( + String entityUuid, + Map> fieldsByContext) { + fieldsByContext.forEach((context, fields) -> { + Map entityFields = new HashMap<>(); + for (Map.Entry entry : fields.entrySet()) { + CustomizableFieldMetadata metadataEntity = customizableFieldMetadataService.getByUuid(entry.getKey().getUuid()); + if (metadataEntity == null) { + throw new IllegalArgumentException("Field metadata not found: " + entry.getKey().getUuid()); + } + entityFields.put(metadataEntity, entry.getValue()); + } + service.saveEntityValues(entityUuid, context, entityFields); + }); + } + + @Override + public void deleteValuesForEntity(String entityUuid, CustomizableFieldContext contextClass) { + service.deleteValuesForEntity(entityUuid, contextClass); + } + + @Override + public List getAll() { + return service.getAll().stream().map(this::toDto).collect(Collectors.toList()); + } + + @Override + public CustomizableFieldValueDto save(@Valid @NotNull CustomizableFieldValueDto dto) { + CustomizableFieldValue entity = service.getByUuid(dto.getUuid()); + entity = fillOrBuildEntity(dto, entity, true); + service.ensurePersisted(entity); + return toDto(entity); + } + + @Override + public void delete(String uuid, DeletionDetails deletionDetails) { + CustomizableFieldValue entity = service.getByUuid(uuid); + if (entity != null) { + service.delete(entity, deletionDetails); + } + } + + @Override + public List delete(List uuids, DeletionDetails deletionDetails) { + throw new NotImplementedException(); + } + + @Override + public void restore(String uuid) { + super.restore(uuid); + } + + @Override + public List restore(List uuids) { + throw new NotImplementedException(); + } + + @Override + public List getArchivedUuidsSince(Date since) { + throw new NotImplementedException(); + } + + @Override + public long count(CustomizableFieldValueCriteria criteria) { + throw new NotImplementedException(); + } + + @Override + public List getIndexList( + CustomizableFieldValueCriteria criteria, + Integer first, + Integer max, + List sortProperties) { + throw new NotImplementedException(); + } + + @Override + public void validate(@Valid CustomizableFieldValueDto dto) throws ValidationRuntimeException { + // no validation required for customizable field values + } + + @Override + protected DeletableEntityType getDeletableEntityType() { + return DeletableEntityType.CUSTOMIZABLE_FIELD_VALUE; + } + + @Override + protected CustomizableFieldValueReferenceDto toRefDto(CustomizableFieldValue entity) { + return new CustomizableFieldValueReferenceDto(entity.getUuid()); + } + + @Override + protected void pseudonymizeDto( + CustomizableFieldValue source, + CustomizableFieldValueDto dto, + Pseudonymizer pseudonymizer, + boolean inJurisdiction) { + // no sensitive fields to pseudonymize + } + + @Override + protected void restorePseudonymizedDto( + CustomizableFieldValueDto dto, + CustomizableFieldValueDto existingDto, + CustomizableFieldValue entity, + Pseudonymizer pseudonymizer) { + // no sensitive fields to restore + } + + @Override + protected CustomizableFieldValue fillOrBuildEntity( + @NotNull CustomizableFieldValueDto source, + CustomizableFieldValue target, + boolean checkChangeDate) { + if (source == null) { + return null; + } + + target = DtoHelper.fillOrBuildEntity(source, target, CustomizableFieldValue::new, checkChangeDate); + + // Load the metadata reference + CustomizableFieldMetadata metadata = customizableFieldMetadataService.getByUuid(source.getCustomizableFieldMetadataUuid()); + if (metadata == null) { + throw new IllegalArgumentException("Field metadata not found: " + source.getCustomizableFieldMetadataUuid()); + } + + target.setCustomizableFieldMetadata(metadata); + target.setEntityUuid(source.getEntityUuid()); + target.setContextClass(source.getContextClass()); + target.setValue(source.getValue()); + + return target; + } + + @Override + protected CustomizableFieldValueDto toDto(CustomizableFieldValue source) { + if (source == null) { + return null; + } + + CustomizableFieldValueDto target = new CustomizableFieldValueDto(); + DtoHelper.fillDto(target, source); + + target.setCustomizableFieldMetadataUuid(source.getCustomizableFieldMetadata().getUuid()); + target.setEntityUuid(source.getEntityUuid()); + target.setContextClass(source.getContextClass()); + target.setValue(source.getValue()); + + return target; + } + + @LocalBean + @Stateless + public static class CustomizableFieldValueFacadeEjbLocal extends CustomizableFieldValueFacadeEjb { + + public CustomizableFieldValueFacadeEjbLocal() { + } + + @Inject + public CustomizableFieldValueFacadeEjbLocal(CustomizableFieldValueService service) { + super(service); + } + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValueJoins.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValueJoins.java new file mode 100644 index 00000000000..2e0453c456a --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValueJoins.java @@ -0,0 +1,27 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.customizablefield; + +import javax.persistence.criteria.From; + +import de.symeda.sormas.backend.common.QueryJoins; + +public class CustomizableFieldValueJoins extends QueryJoins { + + public CustomizableFieldValueJoins(From root) { + super(root); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValueQueryContext.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValueQueryContext.java new file mode 100644 index 00000000000..1ec4676f3a8 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValueQueryContext.java @@ -0,0 +1,35 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.customizablefield; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Expression; +import javax.persistence.criteria.From; + +import de.symeda.sormas.backend.common.QueryContext; + +public class CustomizableFieldValueQueryContext extends QueryContext { + + public CustomizableFieldValueQueryContext(CriteriaBuilder cb, CriteriaQuery query, From root) { + super(cb, query, root, new CustomizableFieldValueJoins(root)); + } + + @Override + protected Expression createExpression(String name) { + return null; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValueService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValueService.java new file mode 100644 index 00000000000..a4eaa9d834e --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldValueService.java @@ -0,0 +1,142 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.customizablefield; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.ejb.LocalBean; +import javax.ejb.Stateless; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.From; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; + +import de.symeda.sormas.api.common.DeletableEntityType; +import de.symeda.sormas.api.common.DeletionDetails; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.backend.common.AbstractCoreAdoService; +import de.symeda.sormas.backend.common.AbstractDomainObject; +import de.symeda.sormas.backend.common.DeletableAdo; + +/** + * Service class for customizable field values. + */ +@Stateless +@LocalBean +public class CustomizableFieldValueService extends AbstractCoreAdoService { + + public CustomizableFieldValueService() { + super(CustomizableFieldValue.class, DeletableEntityType.CUSTOMIZABLE_FIELD_VALUE); + } + + @Override + @SuppressWarnings("rawtypes") + protected Predicate createUserFilterInternal(CriteriaBuilder cb, CriteriaQuery cq, From from) { + // No jurisdiction filtering for customizable field values + return null; + } + + @Override + protected CustomizableFieldValueJoins toJoins(From adoPath) { + return new CustomizableFieldValueJoins(adoPath); + } + + @Override + public Predicate inJurisdictionOrOwned(CriteriaBuilder cb, CriteriaQuery query, From from) { + return cb.conjunction(); + } + + public Map getValuesForEntity(String entityUuid, CustomizableFieldContext contextClass) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(CustomizableFieldValue.class); + Root root = cq.from(CustomizableFieldValue.class); + + Predicate predicate = cb.and( + cb.equal(root.get(CustomizableFieldValue.ENTITY_UUID), entityUuid), + cb.equal(root.get(CustomizableFieldValue.CONTEXT_CLASS), contextClass), + cb.isFalse(root.get(DeletableAdo.DELETED))); + + cq.where(predicate); + + List values = em.createQuery(cq).getResultList(); + + Map result = new HashMap<>(); + for (CustomizableFieldValue value : values) { + result.put(value.getCustomizableFieldMetadata(), value); + } + return result; + } + + public void saveEntityValues( + String entityUuid, + CustomizableFieldContext contextClass, + Map metadataToValue) { + Map existing = getValuesForEntity(entityUuid, contextClass); + + for (Map.Entry entry : metadataToValue.entrySet()) { + CustomizableFieldMetadata metadata = entry.getKey(); + CustomizableFieldValue value = existing.getOrDefault(metadata, createNewValue(metadata, entityUuid, contextClass)); + value.setValue(entry.getValue().getValue()); + ensurePersisted(value); + } + } + + public void deleteValuesForEntity(String entityUuid, CustomizableFieldContext contextClass) { + Map values = getValuesForEntity(entityUuid, contextClass); + for (CustomizableFieldValue value : values.values()) { + em.remove(value); + } + } + + public void softDeleteValuesForEntity(String entityUuid, CustomizableFieldContext contextClass, DeletionDetails deletionDetails) { + Map values = getValuesForEntity(entityUuid, contextClass); + for (CustomizableFieldValue value : values.values()) { + delete(value, deletionDetails); + } + } + + public List getValuesForMetadata(String metadataUuid) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(CustomizableFieldValue.class); + Root root = cq.from(CustomizableFieldValue.class); + + cq.where( + cb.and( + cb.equal(root.get(CustomizableFieldValue.CUSTOMIZABLE_FIELD_METADATA).get(AbstractDomainObject.UUID), metadataUuid), + cb.isFalse(root.get(DeletableAdo.DELETED)))); + + return em.createQuery(cq).getResultList(); + } + + public void softDeleteValuesForMetadata(String metadataUuid, DeletionDetails deletionDetails) { + List values = getValuesForMetadata(metadataUuid); + for (CustomizableFieldValue value : values) { + delete(value, deletionDetails); + } + } + + private CustomizableFieldValue createNewValue(CustomizableFieldMetadata metadata, String entityUuid, CustomizableFieldContext contextClass) { + CustomizableFieldValue value = new CustomizableFieldValue(); + value.setCustomizableFieldMetadata(metadata); + value.setEntityUuid(entityUuid); + value.setContextClass(contextClass); + return value; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/deletionconfiguration/CoreEntityDeletionService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/deletionconfiguration/CoreEntityDeletionService.java index 97222dac771..374b131ed15 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/deletionconfiguration/CoreEntityDeletionService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/deletionconfiguration/CoreEntityDeletionService.java @@ -18,6 +18,8 @@ import de.symeda.sormas.backend.caze.CaseFacadeEjb; import de.symeda.sormas.backend.common.AbstractCoreFacadeEjb; import de.symeda.sormas.backend.contact.ContactFacadeEjb; +import de.symeda.sormas.backend.customizablefield.CustomizableFieldMetadataFacadeEjb.CustomizableFieldMetadataFacadeEjbLocal; +import de.symeda.sormas.backend.customizablefield.CustomizableFieldValueFacadeEjb.CustomizableFieldValueFacadeEjbLocal; import de.symeda.sormas.backend.event.EventFacadeEjb; import de.symeda.sormas.backend.event.EventParticipantFacadeEjb; import de.symeda.sormas.backend.feature.FeatureConfigurationFacadeEjb; @@ -70,7 +72,9 @@ public CoreEntityDeletionService( EventParticipantFacadeEjb.EventParticipantFacadeEjbLocal eventParticipantFacadeEjb, ImmunizationFacadeEjb.ImmunizationFacadeEjbLocal immunizationFacadeEjb, TravelEntryFacadeEjb.TravelEntryFacadeEjbLocal travelEntryFacadeEjb, - SelfReportFacadeEjb.SelfReportFacadeEjbLocal selfReportFacadeEjb) { + SelfReportFacadeEjb.SelfReportFacadeEjbLocal selfReportFacadeEjb, + CustomizableFieldMetadataFacadeEjbLocal customizableFieldMetadataFacadeEjb, + CustomizableFieldValueFacadeEjbLocal customizableFieldValueFacadeEjb) { coreEntityFacades.add(EntityTypeFacadePair.of(DeletableEntityType.CASE, caseFacadeEjb)); coreEntityFacades.add(EntityTypeFacadePair.of(DeletableEntityType.CONTACT, contactFacadeEjb)); coreEntityFacades.add(EntityTypeFacadePair.of(DeletableEntityType.EVENT, eventFacadeEjb)); @@ -78,6 +82,8 @@ public CoreEntityDeletionService( coreEntityFacades.add(EntityTypeFacadePair.of(DeletableEntityType.IMMUNIZATION, immunizationFacadeEjb)); coreEntityFacades.add(EntityTypeFacadePair.of(DeletableEntityType.TRAVEL_ENTRY, travelEntryFacadeEjb)); coreEntityFacades.add(EntityTypeFacadePair.of(DeletableEntityType.SELF_REPORT, selfReportFacadeEjb)); + coreEntityFacades.add(EntityTypeFacadePair.of(DeletableEntityType.CUSTOMIZABLE_FIELD_METADATA, customizableFieldMetadataFacadeEjb)); + coreEntityFacades.add(EntityTypeFacadePair.of(DeletableEntityType.CUSTOMIZABLE_FIELD_VALUE, customizableFieldValueFacadeEjb)); } @SuppressWarnings("unchecked") @@ -150,7 +156,9 @@ private boolean supportsPermanentDeletion(DeletableEntityType deletableEntityTyp || deletableEntityType == DeletableEntityType.CONTACT || deletableEntityType == DeletableEntityType.EVENT || deletableEntityType == DeletableEntityType.EVENT_PARTICIPANT - || deletableEntityType == DeletableEntityType.SELF_REPORT; + || deletableEntityType == DeletableEntityType.SELF_REPORT + || deletableEntityType == DeletableEntityType.CUSTOMIZABLE_FIELD_METADATA + || deletableEntityType == DeletableEntityType.CUSTOMIZABLE_FIELD_VALUE; } @SuppressWarnings("rawtypes") diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/epidata/EpiDataFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/epidata/EpiDataFacadeEjb.java index 8c031994b65..2305c2de11e 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/epidata/EpiDataFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/epidata/EpiDataFacadeEjb.java @@ -29,6 +29,8 @@ import javax.ejb.Stateless; import de.symeda.sormas.api.activityascase.ActivityAsCaseDto; +import de.symeda.sormas.api.common.DeletionDetails; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; import de.symeda.sormas.api.epidata.EpiDataDto; import de.symeda.sormas.api.epidata.EpiDataFacade; import de.symeda.sormas.api.exposure.ExposureDto; @@ -38,6 +40,7 @@ import de.symeda.sormas.backend.activityascase.ActivityAsCaseService; import de.symeda.sormas.backend.contact.ContactFacadeEjb; import de.symeda.sormas.backend.contact.ContactService; +import de.symeda.sormas.backend.customizablefield.CustomizableFieldValueService; import de.symeda.sormas.backend.exposure.Exposure; import de.symeda.sormas.backend.exposure.ExposureService; import de.symeda.sormas.backend.infrastructure.country.CountryFacadeEjb; @@ -63,6 +66,18 @@ public class EpiDataFacadeEjb implements EpiDataFacade { private UserService userService; @EJB private CountryService countryService; + @EJB + private CustomizableFieldValueService customizableFieldValueService; + + public void softDeleteCustomizableFieldValues(EpiData epiData, DeletionDetails deletionDetails) { + if (epiData == null || epiData.getUuid() == null) { + return; + } + customizableFieldValueService.softDeleteValuesForEntity(epiData.getUuid(), CustomizableFieldContext.EPIDATA, deletionDetails); + for (Exposure exposure : epiData.getExposures()) { + customizableFieldValueService.softDeleteValuesForEntity(exposure.getUuid(), CustomizableFieldContext.EXPOSURE, deletionDetails); + } + } public EpiData fillOrBuildEntity(EpiDataDto source, EpiData target, boolean checkChangeDate) { if (source == null) { diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/importexport/DatabaseExportService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/importexport/DatabaseExportService.java index 757f8d28940..8844d90b608 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/importexport/DatabaseExportService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/importexport/DatabaseExportService.java @@ -42,7 +42,6 @@ import de.symeda.sormas.api.feature.FeatureConfigurationDto; import de.symeda.sormas.api.importexport.DatabaseTable; -import de.symeda.sormas.api.therapy.Drug; import de.symeda.sormas.backend.action.Action; import de.symeda.sormas.backend.activityascase.ActivityAsCase; import de.symeda.sormas.backend.campaign.Campaign; @@ -59,6 +58,8 @@ import de.symeda.sormas.backend.common.ConfigFacadeEjb.ConfigFacadeEjbLocal; import de.symeda.sormas.backend.contact.Contact; import de.symeda.sormas.backend.customizableenum.CustomizableEnumValue; +import de.symeda.sormas.backend.customizablefield.CustomizableFieldMetadata; +import de.symeda.sormas.backend.customizablefield.CustomizableFieldValue; import de.symeda.sormas.backend.deletionconfiguration.DeletionConfiguration; import de.symeda.sormas.backend.disease.DiseaseConfiguration; import de.symeda.sormas.backend.document.Document; @@ -179,6 +180,8 @@ public class DatabaseExportService { EXPORT_CONFIGS.put(DatabaseTable.POINTS_OF_ENTRY, PointOfEntry.TABLE_NAME); EXPORT_CONFIGS.put(DatabaseTable.OUTBREAKS, Outbreak.TABLE_NAME); EXPORT_CONFIGS.put(DatabaseTable.CUSTOMIZABLE_ENUM_VALUES, CustomizableEnumValue.TABLE_NAME); + EXPORT_CONFIGS.put(DatabaseTable.CUSTOMIZABLE_FIELD_METADATA, CustomizableFieldMetadata.TABLE_NAME); + EXPORT_CONFIGS.put(DatabaseTable.CUSTOMIZABLE_FIELD_VALUE, CustomizableFieldValue.TABLE_NAME); EXPORT_CONFIGS.put(DatabaseTable.SYMPTOMS, Symptoms.TABLE_NAME); EXPORT_CONFIGS.put(DatabaseTable.CAMPAIGNS, Campaign.TABLE_NAME); EXPORT_CONFIGS.put(DatabaseTable.CAMPAIGN_CAMPAIGNFORMMETA, Campaign.CAMPAIGN_CAMPAIGNFORMMETA_TABLE_NAME); diff --git a/sormas-backend/src/main/resources/META-INF/glassfish-ejb-jar.xml b/sormas-backend/src/main/resources/META-INF/glassfish-ejb-jar.xml index 063e3e46c57..1590d53287f 100644 --- a/sormas-backend/src/main/resources/META-INF/glassfish-ejb-jar.xml +++ b/sormas-backend/src/main/resources/META-INF/glassfish-ejb-jar.xml @@ -1193,6 +1193,11 @@ CUSTOMIZABLE_ENUM_MANAGEMENT + + CUSTOMIZABLE_FIELD_MANAGEMENT + CUSTOMIZABLE_FIELD_MANAGEMENT + + SYSTEM_CONFIGURATION SYSTEM_CONFIGURATION @@ -1218,12 +1223,12 @@ EPIPULSE_EXPORT_DELETE - - - SYSTEM - SYSTEM - + + + SYSTEM + SYSTEM + - - true - + + true + \ No newline at end of file diff --git a/sormas-backend/src/main/resources/META-INF/persistence.xml b/sormas-backend/src/main/resources/META-INF/persistence.xml index c403dbcdcf0..c48dbc5ee34 100644 --- a/sormas-backend/src/main/resources/META-INF/persistence.xml +++ b/sormas-backend/src/main/resources/META-INF/persistence.xml @@ -75,6 +75,8 @@ de.symeda.sormas.backend.infrastructure.subcontinent.Subcontinent de.symeda.sormas.backend.sormastosormas.share.incoming.SormasToSormasShareRequest de.symeda.sormas.backend.customizableenum.CustomizableEnumValue + de.symeda.sormas.backend.customizablefield.CustomizableFieldMetadata + de.symeda.sormas.backend.customizablefield.CustomizableFieldValue de.symeda.sormas.backend.immunization.entity.BaseImmunization de.symeda.sormas.backend.immunization.entity.Immunization de.symeda.sormas.backend.immunization.entity.DirectoryImmunization diff --git a/sormas-backend/src/main/resources/sql/sormas_schema.sql b/sormas-backend/src/main/resources/sql/sormas_schema.sql index 3067a93bcab..4095230d62c 100644 --- a/sormas-backend/src/main/resources/sql/sormas_schema.sql +++ b/sormas-backend/src/main/resources/sql/sormas_schema.sql @@ -15560,4 +15560,146 @@ alter table diseaseconfiguration_history add exposurecategories varchar(255); INSERT INTO schema_version (version_number, comment) VALUES (618, '#13887 add exposure categories to disease configuration'); +-- #13828 - Customizable Fields + +CREATE TABLE IF NOT EXISTS customizablefieldmetadata ( + id bigint NOT NULL, + uuid character varying(36) NOT NULL UNIQUE, + changeDate timestamp without time zone NOT NULL DEFAULT NOW(), + creationDate timestamp without time zone NOT NULL DEFAULT NOW(), + deleted boolean DEFAULT false, + deletionreason varchar(255), + otherdeletionreason text, + archived boolean DEFAULT false, + archiveundonereason varchar(512), + endofprocessingdate timestamp without time zone, + + -- Core field metadata + name character varying(512) NOT NULL, + description text, + fieldType character varying(50) NOT NULL, -- TEXT, DATE, COMBOBOX, YES_NO_UNKNOWN, CHECKBOX_LIST, RADIO_BUTTON_LIST + defaultValue text, + + -- Constraints + mandatory boolean NOT NULL DEFAULT false, + readOnly boolean NOT NULL DEFAULT false, + active boolean NOT NULL DEFAULT true, + + -- Context and UI placement + contextClass character varying(256) NOT NULL, + uiGroup character varying(256), + uiLinePosition integer, + uiLineWeight float4, + + -- Complex JSON-serialized properties + visibilityRestrictions jsonb, + customProperties jsonb, + translations jsonb, + + change_user_id bigint, + sys_period tstzrange NOT NULL, + + PRIMARY KEY (id), + UNIQUE(name, contextClass) +); + +CREATE INDEX idx_customizablefieldmetadata_uuid + ON customizablefieldmetadata (uuid); +CREATE INDEX idx_customizablefieldmetadata_contextClass + ON customizablefieldmetadata (contextClass); +CREATE INDEX idx_customizablefieldmetadata_uiGroup + ON customizablefieldmetadata (uiGroup); +CREATE INDEX idx_customizablefieldmetadata_active + ON customizablefieldmetadata (active); +CREATE INDEX idx_customizablefieldmetadata_deleted + ON customizablefieldmetadata (deleted); + +ALTER TABLE customizablefieldmetadata OWNER TO sormas_user; +ALTER TABLE customizablefieldmetadata ADD CONSTRAINT fk_change_user_id FOREIGN KEY (change_user_id) REFERENCES users (id); +ALTER INDEX idx_customizablefieldmetadata_uuid OWNER TO sormas_user; +ALTER INDEX idx_customizablefieldmetadata_contextClass OWNER TO sormas_user; +ALTER INDEX idx_customizablefieldmetadata_uiGroup OWNER TO sormas_user; +ALTER INDEX idx_customizablefieldmetadata_active OWNER TO sormas_user; +ALTER INDEX idx_customizablefieldmetadata_deleted OWNER TO sormas_user; + +-- CustomizableFieldMetadata history tables +CREATE TABLE customizablefieldmetadata_history (LIKE customizablefieldmetadata); + +DROP TRIGGER IF EXISTS versioning_trigger ON customizablefieldmetadata; +CREATE TRIGGER versioning_trigger +BEFORE INSERT OR UPDATE ON customizablefieldmetadata +FOR EACH ROW EXECUTE PROCEDURE versioning('sys_period', 'customizablefieldmetadata_history', true); + +DROP TRIGGER IF EXISTS delete_history_trigger ON customizablefieldmetadata; +CREATE TRIGGER delete_history_trigger + AFTER DELETE ON customizablefieldmetadata + FOR EACH ROW EXECUTE PROCEDURE delete_history_trigger('customizablefieldmetadata_history', 'id'); + +ALTER TABLE customizablefieldmetadata_history OWNER TO sormas_user; + +-- Create CustomizableFieldValue table +CREATE TABLE IF NOT EXISTS customizablefieldvalue ( + id bigint NOT NULL, + uuid character varying(36) NOT NULL UNIQUE, + changeDate timestamp(3) NOT NULL DEFAULT NOW(), + creationDate timestamp(3) NOT NULL DEFAULT NOW(), + deleted boolean DEFAULT false, + deletionreason varchar(255), + otherdeletionreason text, + archived boolean DEFAULT false, + archiveundonereason varchar(512), + endofprocessingdate timestamp without time zone, + + -- Fkey to metadata + customizablefieldmetadata_id bigint NOT NULL, + + -- Generic entity reference + entityUuid character varying(36) NOT NULL, + contextClass character varying(256) NOT NULL, + + -- Value storage (text for all types, type conversion happens in service) + value text, + + change_user_id bigint, + sys_period tstzrange NOT NULL, + + PRIMARY KEY (id), + UNIQUE(customizablefieldmetadata_id, entityUuid, contextClass) +); + +CREATE INDEX idx_customizablefieldvalue_uuid + ON customizablefieldvalue (uuid); +CREATE INDEX idx_customizablefieldvalue_entityUuid + ON customizablefieldvalue (entityUuid); +CREATE INDEX idx_customizablefieldvalue_contextEntity + ON customizablefieldvalue (contextClass, entityUuid); +CREATE INDEX idx_customizablefieldvalue_fieldMetadata + ON customizablefieldvalue (customizablefieldmetadata_id); +CREATE INDEX idx_customizablefieldvalue_deleted + ON customizablefieldvalue (deleted); + +ALTER TABLE customizablefieldvalue ADD CONSTRAINT fk_change_user_id FOREIGN KEY (change_user_id) REFERENCES users (id); +ALTER TABLE customizablefieldvalue OWNER TO sormas_user; + +-- CustomizableFieldValue history tables +CREATE TABLE customizablefieldvalue_history (LIKE customizablefieldvalue); + +DROP TRIGGER IF EXISTS versioning_trigger ON customizablefieldvalue; +CREATE TRIGGER versioning_trigger +BEFORE INSERT OR UPDATE ON customizablefieldvalue +FOR EACH ROW EXECUTE PROCEDURE versioning('sys_period', 'customizablefieldvalue_history', true); + +DROP TRIGGER IF EXISTS delete_history_trigger ON customizablefieldvalue; +CREATE TRIGGER delete_history_trigger + AFTER DELETE ON customizablefieldvalue + FOR EACH ROW EXECUTE PROCEDURE delete_history_trigger('customizablefieldvalue_history', 'id'); + +ALTER TABLE customizablefieldvalue_history OWNER TO sormas_user; +INSERT INTO schema_version (version_number, comment) VALUES (619, '#13828 - Add history tables for customizable fields'); + +-- #13828 - Customizable Fields - Admin user rights +INSERT INTO userroles_userrights (userrole_id, userright) SELECT id, 'CUSTOMIZABLE_FIELD_MANAGEMENT' FROM public.userroles WHERE userroles.linkeddefaultuserrole in ('ADMIN'); + +INSERT INTO schema_version (version_number, comment) VALUES (620, '#13828 - Add system configuration rights for admin user'); + -- *** Insert new sql commands BEFORE this line. Remember to always consider _history tables. *** \ No newline at end of file diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/EntityMappingTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/EntityMappingTest.java index ef78357b0bf..1af94ad9b68 100644 --- a/sormas-backend/src/test/java/de/symeda/sormas/backend/EntityMappingTest.java +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/EntityMappingTest.java @@ -52,6 +52,8 @@ import de.symeda.sormas.api.clinicalcourse.HealthConditionsDto; import de.symeda.sormas.api.contact.ContactDto; import de.symeda.sormas.api.customizableenum.CustomizableEnumValueDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; import de.symeda.sormas.api.disease.DiseaseConfigurationDto; import de.symeda.sormas.api.document.DocumentDto; import de.symeda.sormas.api.epidata.EpiDataDto; @@ -121,6 +123,8 @@ import de.symeda.sormas.backend.common.NotExposedToApi; import de.symeda.sormas.backend.contact.Contact; import de.symeda.sormas.backend.customizableenum.CustomizableEnumValue; +import de.symeda.sormas.backend.customizablefield.CustomizableFieldMetadata; +import de.symeda.sormas.backend.customizablefield.CustomizableFieldValue; import de.symeda.sormas.backend.disease.DiseaseConfiguration; import de.symeda.sormas.backend.document.Document; import de.symeda.sormas.backend.epidata.EpiData; @@ -197,6 +201,8 @@ public class EntityMappingTest { mappings.put(Continent.class, ContinentDto.class); mappings.put(Country.class, CountryDto.class); mappings.put(CustomizableEnumValue.class, CustomizableEnumValueDto.class); + mappings.put(CustomizableFieldMetadata.class, CustomizableFieldMetadataDto.class); + mappings.put(CustomizableFieldValue.class, CustomizableFieldValueDto.class); mappings.put(DiseaseConfiguration.class, DiseaseConfigurationDto.class); mappings.put(District.class, DistrictDto.class); mappings.put(Document.class, DocumentDto.class); diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldFacadeEjbTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldFacadeEjbTest.java new file mode 100644 index 00000000000..e7cd856b5b0 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldFacadeEjbTest.java @@ -0,0 +1,131 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.customizablefield; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import de.symeda.sormas.api.Disease; +import de.symeda.sormas.api.caze.CaseClassification; +import de.symeda.sormas.api.caze.CaseDataDto; +import de.symeda.sormas.api.caze.InvestigationStatus; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataFacade; +import de.symeda.sormas.api.customizablefield.CustomizableFieldType; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueFacade; +import de.symeda.sormas.api.exposure.ExposureDto; +import de.symeda.sormas.api.exposure.ExposureType; +import de.symeda.sormas.api.person.PersonDto; +import de.symeda.sormas.api.person.Sex; +import de.symeda.sormas.api.user.UserDto; +import de.symeda.sormas.backend.AbstractBeanTest; +import de.symeda.sormas.backend.TestDataCreator.RDCF; + +class CustomizableFieldFacadeEjbTest extends AbstractBeanTest { + + private RDCF rdcf; + private UserDto surveillanceSupervisor; + + @Override + public void init() { + super.init(); + rdcf = creator.createRDCF("Region", "District", "Community", "Facility"); + surveillanceSupervisor = creator.createSurveillanceSupervisor(rdcf); + } + + @ParameterizedTest + @EnumSource(value = CustomizableFieldContext.class, + names = { + "CASE", + "EPIDATA", + "EXPOSURE" }) + void testCaseRelatedCustomizableFields(CustomizableFieldContext context) { + CustomizableFieldMetadataFacade metadataFacade = getBean(CustomizableFieldMetadataFacadeEjb.CustomizableFieldMetadataFacadeEjbLocal.class); + CustomizableFieldValueFacade valueFacade = getBean(CustomizableFieldValueFacadeEjb.CustomizableFieldValueFacadeEjbLocal.class); + + CustomizableFieldMetadataDto metadata = new CustomizableFieldMetadataDto(); + metadata.setName("customField_" + context.name().toLowerCase()); + metadata.setFieldType(CustomizableFieldType.TEXT); + metadata.setContextClass(context); + metadata.setUiGroup(CustomizableFieldGroup.getGroupsForContext(context).stream().findFirst().orElse(null)); + metadata.setUiLinePosition(1); + + CustomizableFieldMetadataDto savedMetadata = metadataFacade.save(metadata); + assertThat(savedMetadata.getUuid(), is(notNullValue())); + + PersonDto person = creator.createPerson("Case", "Person", Sex.MALE, 1980, 1, 1); + CaseDataDto caze = creator.createCase( + surveillanceSupervisor.toReference(), + person.toReference(), + Disease.EVD, + CaseClassification.PROBABLE, + InvestigationStatus.PENDING, + new java.util.Date(), + rdcf); + + caze.getEpiData().getExposures().add(ExposureDto.build(ExposureType.TRAVEL)); + caze = getCaseFacade().save(caze); + + String entityUuid = getEntityUuidForContext(context, caze); + + // in case other contexts are added and no corresponding case data is created this should fail + // in case of failure update the creation of the case and add the aditional necessary data + assertThat("Unexpected entity UUID null for context " + context, entityUuid, notNullValue()); + + CustomizableFieldValueDto valueDto = new CustomizableFieldValueDto(); + valueDto.setValue("custom-value"); + Map fieldValues = Map.of(savedMetadata, valueDto); + valueFacade.saveEntityCustomFields(entityUuid, context, fieldValues); + + List activeFields = metadataFacade.getActiveFieldsForContext(context); + Map values = valueFacade.getValuesForEntity(entityUuid, context); + + assertThat(activeFields, hasSize(1)); + assertThat(activeFields.get(0).getUuid(), is(savedMetadata.getUuid())); + assertThat(activeFields.get(0).getName(), is(equalTo("customField_" + context.name().toLowerCase()))); + + assertThat(values, hasKey(savedMetadata)); + assertThat(values.get(savedMetadata).getValue(), is(equalTo("custom-value"))); + } + + private String getEntityUuidForContext(CustomizableFieldContext context, CaseDataDto caze) { + switch (context) { + case CASE: + return caze.getUuid(); + case EPIDATA: + return caze.getEpiData().getUuid(); + case EXPOSURE: + return caze.getEpiData().getExposures() != null && !caze.getEpiData().getExposures().isEmpty() + ? caze.getEpiData().getExposures().get(0).getUuid() + : null; + default: + throw new IllegalArgumentException("Unhandled context: " + context); + } + } +} diff --git a/sormas-backend/src/test/resources/META-INF/persistence.xml b/sormas-backend/src/test/resources/META-INF/persistence.xml index ad44e72739e..99c650ceb7e 100644 --- a/sormas-backend/src/test/resources/META-INF/persistence.xml +++ b/sormas-backend/src/test/resources/META-INF/persistence.xml @@ -76,6 +76,8 @@ de.symeda.sormas.backend.infrastructure.subcontinent.Subcontinent de.symeda.sormas.backend.sormastosormas.share.incoming.SormasToSormasShareRequest de.symeda.sormas.backend.customizableenum.CustomizableEnumValue + de.symeda.sormas.backend.customizablefield.CustomizableFieldMetadata + de.symeda.sormas.backend.customizablefield.CustomizableFieldValue de.symeda.sormas.backend.immunization.entity.BaseImmunization de.symeda.sormas.backend.immunization.entity.Immunization de.symeda.sormas.backend.immunization.entity.DirectoryImmunization diff --git a/sormas-rest/src/main/java/de/symeda/sormas/rest/resources/CustomizableFieldMetadataResource.java b/sormas-rest/src/main/java/de/symeda/sormas/rest/resources/CustomizableFieldMetadataResource.java new file mode 100644 index 00000000000..740376195ad --- /dev/null +++ b/sormas-rest/src/main/java/de/symeda/sormas/rest/resources/CustomizableFieldMetadataResource.java @@ -0,0 +1,132 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.rest.resources; + +import java.util.List; + +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import de.symeda.sormas.api.FacadeProvider; +import de.symeda.sormas.api.common.DeletionDetails; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataFacade; + +/** + * REST resource for managing customizable field metadata. + */ +@Path("/customizablefieldmetadata") +@Produces(MediaType.APPLICATION_JSON + "; charset=UTF-8") +@Consumes(MediaType.APPLICATION_JSON + "; charset=UTF-8") +public class CustomizableFieldMetadataResource { + + private CustomizableFieldMetadataFacade getFacade() { + return FacadeProvider.getCustomizableFieldMetadataFacade(); + } + + @GET + @Path("/all") + public List getAll() { + return getFacade().getAll(); + } + + @GET + @Path("/{uuid}") + public CustomizableFieldMetadataDto getByUuid(@PathParam("uuid") String uuid) { + return getFacade().getByUuid(uuid); + } + + @GET + @Path("/context/{contextClass}") + public List getActiveFieldsForContext(@PathParam("contextClass") CustomizableFieldContext contextClass) { + return getFacade().getActiveFieldsForContext(contextClass); + } + + @GET + @Path("/uigroup/{uiGroup}") + public List getFieldsForUIGroup(@PathParam("uiGroup") String uiGroupKey) { + CustomizableFieldGroup uiGroup = CustomizableFieldGroup.fromKey(uiGroupKey); + if (uiGroup == null) { + return java.util.Collections.emptyList(); + } + return getFacade().getFieldsOrderedByUIPosition(uiGroup); + } + + @GET + @Path("/byname") + public CustomizableFieldMetadataDto getByNameAndContext( + @QueryParam("name") String name, + @QueryParam("contextClass") CustomizableFieldContext contextClass) { + return getFacade().getByNameAndContext(name, contextClass); + } + + @POST + @Path("/save") + public CustomizableFieldMetadataDto save(CustomizableFieldMetadataDto dto) { + return getFacade().save(dto); + } + + @PUT + @Path("/{uuid}") + public CustomizableFieldMetadataDto update(@PathParam("uuid") String uuid, CustomizableFieldMetadataDto dto) { + dto.setUuid(uuid); + return getFacade().save(dto); + } + + @DELETE + @Path("/{uuid}") + public Response delete(@PathParam("uuid") String uuid) { + getFacade().delete(uuid, new DeletionDetails()); + return Response.ok().build(); + } + + @POST + @Path("/{uuid}/clone") + public CustomizableFieldMetadataDto cloneField(@PathParam("uuid") String uuid, @QueryParam("newName") String newName) { + return getFacade().cloneField(uuid, newName); + } + + @POST + @Path("/{uuid}/activate") + public Response activate(@PathParam("uuid") String uuid) { + getFacade().setFieldActive(uuid, true); + return Response.ok().build(); + } + + @POST + @Path("/{uuid}/deactivate") + public Response deactivate(@PathParam("uuid") String uuid) { + getFacade().setFieldActive(uuid, false); + return Response.ok().build(); + } + + @GET + @Path("/{uuid}/exists") + public boolean exists(@PathParam("uuid") String uuid) { + return getFacade().exists(uuid); + } +} diff --git a/sormas-rest/src/main/java/de/symeda/sormas/rest/resources/CustomizableFieldValueResource.java b/sormas-rest/src/main/java/de/symeda/sormas/rest/resources/CustomizableFieldValueResource.java new file mode 100644 index 00000000000..62c371d5b96 --- /dev/null +++ b/sormas-rest/src/main/java/de/symeda/sormas/rest/resources/CustomizableFieldValueResource.java @@ -0,0 +1,121 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.rest.resources; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import de.symeda.sormas.api.FacadeProvider; +import de.symeda.sormas.api.common.DeletionDetails; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueFacade; + +/** + * REST resource for managing customizable field values. + */ +@Path("/customizablefieldvalue") +@Produces(MediaType.APPLICATION_JSON + "; charset=UTF-8") +@Consumes(MediaType.APPLICATION_JSON + "; charset=UTF-8") +public class CustomizableFieldValueResource { + + private CustomizableFieldValueFacade getFacade() { + return FacadeProvider.getCustomizableFieldValueFacade(); + } + + @GET + @Path("/all") + public List getAll() { + return getFacade().getAll(); + } + + @GET + @Path("/{uuid}") + public CustomizableFieldValueDto getByUuid(@PathParam("uuid") String uuid) { + return getFacade().getByUuid(uuid); + } + + @GET + @Path("/entity/{entityUuid}") + public Map getValuesForEntity( + @PathParam("entityUuid") String entityUuid, + @QueryParam("contextClass") CustomizableFieldContext contextClass) { + Map typed = getFacade().getValuesForEntity(entityUuid, contextClass); + Map result = new HashMap<>(); + typed.forEach((metadata, value) -> result.put(metadata.getUuid(), value)); + return result; + } + + @POST + @Path("/entity/{entityUuid}/save") + public Response saveEntityCustomFields( + @PathParam("entityUuid") String entityUuid, + @QueryParam("contextClass") CustomizableFieldContext contextClass, + Map fieldValues) { + Map typed = new HashMap<>(); + fieldValues.forEach((metadataUuid, value) -> { + CustomizableFieldMetadataDto metadataDto = FacadeProvider.getCustomizableFieldMetadataFacade().getByUuid(metadataUuid); + if (metadataDto != null) { + typed.put(metadataDto, value); + } + }); + getFacade().saveEntityCustomFields(entityUuid, contextClass, typed); + return Response.ok().build(); + } + + @DELETE + @Path("/entity/{entityUuid}") + public Response deleteValuesForEntity( + @PathParam("entityUuid") String entityUuid, + @QueryParam("contextClass") CustomizableFieldContext contextClass) { + getFacade().deleteValuesForEntity(entityUuid, contextClass); + return Response.ok().build(); + } + + @POST + @Path("/save") + public CustomizableFieldValueDto save(CustomizableFieldValueDto dto) { + return getFacade().save(dto); + } + + @PUT + @Path("/{uuid}") + public CustomizableFieldValueDto update(@PathParam("uuid") String uuid, CustomizableFieldValueDto dto) { + dto.setUuid(uuid); + return getFacade().save(dto); + } + + @DELETE + @Path("/{uuid}") + public Response delete(@PathParam("uuid") String uuid) { + getFacade().delete(uuid, new DeletionDetails()); + return Response.ok().build(); + } +} diff --git a/sormas-rest/src/main/webapp/WEB-INF/glassfish-web.xml b/sormas-rest/src/main/webapp/WEB-INF/glassfish-web.xml index 1db1a1dd272..6097973ad7e 100644 --- a/sormas-rest/src/main/webapp/WEB-INF/glassfish-web.xml +++ b/sormas-rest/src/main/webapp/WEB-INF/glassfish-web.xml @@ -1,9 +1,7 @@ - + - + CASE_CREATE @@ -1222,4 +1220,9 @@ EPIPULSE_EXPORT_DELETE - + + CUSTOMIZABLE_FIELD_MANAGEMENT + CUSTOMIZABLE_FIELD_MANAGEMENT + + + \ No newline at end of file diff --git a/sormas-rest/src/main/webapp/WEB-INF/web.xml b/sormas-rest/src/main/webapp/WEB-INF/web.xml index 3bbfb22034a..36e8b47bf06 100644 --- a/sormas-rest/src/main/webapp/WEB-INF/web.xml +++ b/sormas-rest/src/main/webapp/WEB-INF/web.xml @@ -1,9 +1,9 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xmlns="http://xmlns.jcp.org/xml/ns/javaee" + xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" + version="3.1"> SORMAS Web Interface @@ -20,9 +20,9 @@ CASE_VIEW - - CASE_VIEW_ARCHIVED - + + CASE_VIEW_ARCHIVED + CASE_EDIT @@ -84,9 +84,9 @@ IMMUNIZATION_VIEW - - IMMUNIZATION_VIEW_ARCHIVED - + + IMMUNIZATION_VIEW_ARCHIVED + IMMUNIZATION_CREATE @@ -220,11 +220,11 @@ CONTACT_VIEW - - CONTACT_VIEW_ARCHIVED - + + CONTACT_VIEW_ARCHIVED + - + CONTACT_ARCHIVE @@ -300,9 +300,9 @@ TASK_ARCHIVE - - TASK_VIEW_ARCHIVED - + + TASK_VIEW_ARCHIVED + ACTION_CREATE @@ -324,11 +324,11 @@ EVENT_VIEW - - EVENT_VIEW_ARCHIVED - + + EVENT_VIEW_ARCHIVED + - + EVENT_EDIT @@ -372,9 +372,9 @@ EVENTPARTICIPANT_VIEW - - EVENTPARTICIPANT_VIEW_ARCHIVED - + + EVENTPARTICIPANT_VIEW_ARCHIVED + EVENTGROUP_CREATE @@ -396,9 +396,9 @@ EVENTGROUP_DELETE - - EVENTGROUP_VIEW_ARCHIVED - + + EVENTGROUP_VIEW_ARCHIVED + WEEKLYREPORT_CREATE @@ -428,11 +428,11 @@ USER_ROLE_DELETE - - USER_ROLE_VIEW - + + USER_ROLE_VIEW + - + SEND_MANUAL_EXTERNAL_MESSAGES @@ -472,9 +472,9 @@ INFRASTRUCTURE_VIEW - - INFRASTRUCTURE_VIEW_ARCHIVED - + + INFRASTRUCTURE_VIEW_ARCHIVED + INFRASTRUCTURE_EXPORT @@ -620,11 +620,11 @@ CAMPAIGN_VIEW - - CAMPAIGN_VIEW_ARCHIVED - + + CAMPAIGN_VIEW_ARCHIVED + - + CAMPAIGN_EDIT @@ -640,9 +640,9 @@ CAMPAIGN_FORM_DATA_VIEW - - CAMPAIGN_FORM_DATA_VIEW_ARCHIVED - + + CAMPAIGN_FORM_DATA_VIEW_ARCHIVED + CAMPAIGN_FORM_DATA_EDIT @@ -720,11 +720,11 @@ TRAVEL_ENTRY_VIEW - - TRAVEL_ENTRY_VIEW_ARCHIVED - + + TRAVEL_ENTRY_VIEW_ARCHIVED + - + TRAVEL_ENTRY_CREATE @@ -752,9 +752,9 @@ ENVIRONMENT_ARCHIVE - - ENVIRONMENT_VIEW_ARCHIVED - + + ENVIRONMENT_VIEW_ARCHIVED + ENVIRONMENT_DELETE @@ -832,13 +832,13 @@ SELF_REPORT_DELETE - - SELF_REPORT_EXPORT - + + SELF_REPORT_EXPORT + - - SELF_REPORT_IMPORT - + + SELF_REPORT_IMPORT + SELF_REPORT_ARCHIVE @@ -968,20 +968,24 @@ SYSTEM_CONFIGURATION - - EPIPULSE_EXPORT_VIEW - + + EPIPULSE_EXPORT_VIEW + - - EPIPULSE_EXPORT_CREATE - + + EPIPULSE_EXPORT_CREATE + - - EPIPULSE_EXPORT_DOWNLOAD - + + EPIPULSE_EXPORT_DOWNLOAD + - - EPIPULSE_EXPORT_DELETE - + + EPIPULSE_EXPORT_DELETE + + + + CUSTOMIZABLE_FIELD_MANAGEMENT + - + \ No newline at end of file diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/ControllerProvider.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/ControllerProvider.java index aeb174772a2..62be04b6384 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/ControllerProvider.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/ControllerProvider.java @@ -26,6 +26,7 @@ import de.symeda.sormas.ui.caze.surveillancereport.SurveillanceReportController; import de.symeda.sormas.ui.clinicalcourse.ClinicalCourseController; import de.symeda.sormas.ui.configuration.customizableenum.CustomizableEnumController; +import de.symeda.sormas.ui.configuration.customizablefield.CustomizableFieldsController; import de.symeda.sormas.ui.configuration.disease.DiseaseConfigurationController; import de.symeda.sormas.ui.configuration.infrastructure.InfrastructureController; import de.symeda.sormas.ui.configuration.outbreak.OutbreakController; @@ -108,6 +109,7 @@ public class ControllerProvider extends BaseControllerProvider { private final EnvironmentSampleController environmentSampleController; private final ExternalEmailController externalEmailController; private final CustomizableEnumController customizableEnumController; + private final CustomizableFieldsController customizableFieldsController; private final DiseaseConfigurationController diseaseConfigurationController; private final SpecialCaseAccessController specialCaseAccessController; private final SelfReportController selfReportController; @@ -160,6 +162,7 @@ public ControllerProvider() { environmentSampleController = new EnvironmentSampleController(); externalEmailController = new ExternalEmailController(); customizableEnumController = new CustomizableEnumController(); + customizableFieldsController = new CustomizableFieldsController(); diseaseConfigurationController = new DiseaseConfigurationController(); specialCaseAccessController = new SpecialCaseAccessController(); selfReportController = new SelfReportController(); @@ -331,6 +334,10 @@ public static CustomizableEnumController getCustomizableEnumController() { return get().customizableEnumController; } + public static CustomizableFieldsController getCustomizableFieldsController() { + return get().customizableFieldsController; + } + public static DiseaseConfigurationController getDiseaseConfirgurationController() { return get().diseaseConfigurationController; } diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/caze/CaseController.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/caze/CaseController.java index 8a442f49e00..78d1a39b0dc 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/caze/CaseController.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/caze/CaseController.java @@ -21,7 +21,9 @@ import java.util.Date; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -77,6 +79,9 @@ import de.symeda.sormas.api.contact.ContactSimilarityCriteria; import de.symeda.sormas.api.contact.ContactStatus; import de.symeda.sormas.api.contact.SimilarContactDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; import de.symeda.sormas.api.deletionconfiguration.DeletionInfoDto; import de.symeda.sormas.api.docgeneneration.DocumentWorkflow; import de.symeda.sormas.api.docgeneneration.RootEntityType; @@ -112,6 +117,7 @@ import de.symeda.sormas.api.user.UserReferenceDto; import de.symeda.sormas.api.user.UserRight; import de.symeda.sormas.api.utils.DataHelper; +import de.symeda.sormas.api.utils.DateFormatHelper; import de.symeda.sormas.api.utils.DateHelper; import de.symeda.sormas.api.utils.ValidationRuntimeException; import de.symeda.sormas.api.utils.YesNoUnknown; @@ -145,7 +151,6 @@ import de.symeda.sormas.ui.utils.ButtonHelper; import de.symeda.sormas.ui.utils.CommitDiscardWrapperComponent; import de.symeda.sormas.ui.utils.CssStyles; -import de.symeda.sormas.ui.utils.DateFormatHelper; import de.symeda.sormas.ui.utils.DeleteRestoreHandlers; import de.symeda.sormas.ui.utils.DetailSubComponentWrapper; import de.symeda.sormas.ui.utils.FieldAccessHelper; @@ -160,7 +165,6 @@ public class CaseController { private CommitDiscardWrapperComponent caseCreateComponent; - private boolean caseSaveTriggered; public CaseController() { @@ -222,9 +226,9 @@ public void createFromEventParticipant(EventParticipantDto eventParticipant) { selectOrCreateCase(dto, FacadeProvider.getPersonFacade().getByUuid(eventParticipant.getPerson().getUuid()), uuid -> { if (uuid == null) { - CommitDiscardWrapperComponent caseCreateComponent = + CommitDiscardWrapperComponent caseCreateComp = getCaseCreateComponent(null, eventParticipant, null, null, null, false); - caseCreateComponent.addCommitListener(() -> { + caseCreateComp.addCommitListener(() -> { EventParticipantDto updatedEventparticipant = FacadeProvider.getEventParticipantFacade().getByUuid(eventParticipant.getUuid()); if (updatedEventparticipant.getResultingCase() != null) { String caseUuid = updatedEventparticipant.getResultingCase().getUuid(); @@ -232,7 +236,7 @@ public void createFromEventParticipant(EventParticipantDto eventParticipant) { convertSamePersonContactsAndEventParticipants(caze, null); } }); - VaadinUiUtil.showModalPopupWindow(caseCreateComponent, I18nProperties.getString(Strings.headingCreateNewCase)); + VaadinUiUtil.showModalPopupWindow(caseCreateComp, I18nProperties.getString(Strings.headingCreateNewCase)); } else { CaseDataDto selectedCase = FacadeProvider.getCaseFacade().getCaseDataByUuid(uuid); EventParticipantDto updatedEventParticipant = FacadeProvider.getEventParticipantFacade().getByUuid(eventParticipant.getUuid()); @@ -258,9 +262,8 @@ public void createFromEventParticipantDifferentDisease(EventParticipantDto event return; } - CommitDiscardWrapperComponent caseCreateComponent = - getCaseCreateComponent(null, eventParticipant, null, null, disease, false); - VaadinUiUtil.showModalPopupWindow(caseCreateComponent, I18nProperties.getString(Strings.headingCreateNewCase)); + CommitDiscardWrapperComponent caseCreateComp = getCaseCreateComponent(null, eventParticipant, null, null, disease, false); + VaadinUiUtil.showModalPopupWindow(caseCreateComp, I18nProperties.getString(Strings.headingCreateNewCase)); } public void createFromContact(ContactDto contact) { @@ -276,8 +279,8 @@ public void createFromContact(ContactDto contact) { selectOrCreateCase(dto, FacadeProvider.getPersonFacade().getByUuid(selectedPerson.getUuid()), uuid -> { if (uuid == null) { - CommitDiscardWrapperComponent caseCreateComponent = getCaseCreateComponent(contact, null, null, null, null, false); - caseCreateComponent.addCommitListener(() -> { + CommitDiscardWrapperComponent caseCreateComp = getCaseCreateComponent(contact, null, null, null, null, false); + caseCreateComp.addCommitListener(() -> { ContactDto contactDto = FacadeProvider.getContactFacade().getByUuid(contact.getUuid()); if (contactDto.getResultingCase() != null) { String caseUuid = contactDto.getResultingCase().getUuid(); @@ -285,8 +288,8 @@ public void createFromContact(ContactDto contact) { convertSamePersonContactsAndEventParticipants(caze, null); } }); - caseCreateComponent.addDiscardListener(() -> SormasUI.refreshView()); - VaadinUiUtil.showModalPopupWindow(caseCreateComponent, I18nProperties.getString(Strings.headingCreateNewCase)); + caseCreateComp.addDiscardListener(SormasUI::refreshView); + VaadinUiUtil.showModalPopupWindow(caseCreateComp, I18nProperties.getString(Strings.headingCreateNewCase)); } else { CaseDataDto selectedCase = FacadeProvider.getCaseFacade().getCaseDataByUuid(uuid); selectedCase.getEpiData().setContactWithSourceCaseKnown(YesNoUnknown.YES); @@ -308,8 +311,8 @@ public void createFromContact(ContactDto contact) { } public void createFromUnrelatedContact(ContactDto contact, Disease disease) { - CommitDiscardWrapperComponent caseCreateComponent = getCaseCreateComponent(contact, null, null, null, disease, false); - VaadinUiUtil.showModalPopupWindow(caseCreateComponent, I18nProperties.getString(Strings.headingCreateNewCase)); + CommitDiscardWrapperComponent caseCreateComp = getCaseCreateComponent(contact, null, null, null, disease, false); + VaadinUiUtil.showModalPopupWindow(caseCreateComp, I18nProperties.getString(Strings.headingCreateNewCase)); } public void createFromTravelEntry(TravelEntryDto travelEntryDto) { @@ -320,9 +323,8 @@ public void createFromTravelEntry(TravelEntryDto travelEntryDto) { selectOrCreateCase(dto, FacadeProvider.getPersonFacade().getByUuid(selectedPerson.getUuid()), uuid -> { if (uuid == null) { - CommitDiscardWrapperComponent caseCreateComponent = - getCaseCreateComponent(null, null, travelEntryDto, null, null, false); - VaadinUiUtil.showModalPopupWindow(caseCreateComponent, I18nProperties.getString(Strings.headingCreateNewCase)); + CommitDiscardWrapperComponent caseCreateComp = getCaseCreateComponent(null, null, travelEntryDto, null, null, false); + VaadinUiUtil.showModalPopupWindow(caseCreateComp, I18nProperties.getString(Strings.headingCreateNewCase)); } else { TravelEntryDto updatedTravelEntry = FacadeProvider.getTravelEntryFacade().getByUuid(travelEntryDto.getUuid()); updatedTravelEntry.setResultingCase(FacadeProvider.getCaseFacade().getCaseDataByUuid(uuid).toReference()); @@ -338,9 +340,9 @@ public void createFromPersonReference(PersonReferenceDto personReference) { dto.setReportingUser(UiUtil.getUserReference()); - CommitDiscardWrapperComponent caseCreateComponent = getCaseCreateComponent(null, null, null, person, null, false); - caseCreateComponent.getWrappedComponent().setSearchedPerson(person); - VaadinUiUtil.showModalPopupWindow(caseCreateComponent, I18nProperties.getString(Strings.headingCreateNewCase)); + CommitDiscardWrapperComponent caseCreateComp = getCaseCreateComponent(null, null, null, person, null, false); + caseCreateComp.getWrappedComponent().setSearchedPerson(person); + VaadinUiUtil.showModalPopupWindow(caseCreateComp, I18nProperties.getString(Strings.headingCreateNewCase)); } public void convertSamePersonContactsAndEventParticipants(CaseDataDto caze, Runnable callback) { @@ -369,7 +371,7 @@ public void convertSamePersonContactsAndEventParticipants(CaseDataDto caze, Runn matchingEventParticipants = Collections.emptyList(); } - if (matchingContacts.size() > 0 || matchingEventParticipants.size() > 0) { + if (!matchingContacts.isEmpty() || !matchingEventParticipants.isEmpty()) { String infoText = matchingEventParticipants.isEmpty() ? String.format(I18nProperties.getString(Strings.infoConvertToCaseContacts), matchingContacts.size(), caze.getDisease()) : (matchingContacts.isEmpty() @@ -744,12 +746,12 @@ public CommitDiscardWrapperComponent getCaseCreateComponent( } final CommitDiscardWrapperComponent editView = - new CommitDiscardWrapperComponent(createForm, UiUtil.permitted(UserRight.CASE_CREATE), createForm.getFieldGroup()); + new CommitDiscardWrapperComponent<>(createForm, UiUtil.permitted(UserRight.CASE_CREATE), createForm.getFieldGroup()); if (createForm.getHomeAddressForm() != null) { editView.addFieldGroups(createForm.getHomeAddressForm().getFieldGroup()); } - caseSaveTriggered = false; + final AtomicBoolean caseSaveTriggered = new AtomicBoolean(false); editView.addCommitListener(() -> { if (!createForm.getFieldGroup().isModified()) { final CaseDataDto dto = createForm.getValue(); @@ -869,6 +871,7 @@ public CommitDiscardWrapperComponent getCaseCreateComponent( final PersonDto duplicatePerson = PersonDto.build(); if (createForm.getWarningSimilarPersons() != null) { + @SuppressWarnings("unchecked") CommitDiscardWrapperComponent warningPopUpDuplicatePerson = (CommitDiscardWrapperComponent) editView.getWrappedComponent() .getWarningSimilarPersons() @@ -877,12 +880,12 @@ public CommitDiscardWrapperComponent getCaseCreateComponent( warningPopUpDuplicatePerson.getDiscardButton().setVisible(true); warningPopUpDuplicatePerson.getCommitButton().setCaption(I18nProperties.getCaption(Captions.actionContinue)); warningPopUpDuplicatePerson.addDoneListener(() -> { - if (!caseSaveTriggered) { + if (!caseSaveTriggered.get()) { VaadinUiUtil.showModalPopupWindow(caseCreateComponent, I18nProperties.getString(Strings.headingCreateNewCase)); } }); warningPopUpDuplicatePerson.addCommitListener(() -> { - caseSaveTriggered = true; + caseSaveTriggered.set(true); transferDataToPerson(createForm, duplicatePerson); ControllerProvider.getPersonController() .selectOrCreatePerson( @@ -946,7 +949,7 @@ public void selectOrCreateCase(CaseDataDto caseDto, PersonDto person, Consumer similarCases = FacadeProvider.getCaseFacade().getSimilarCases(criteria); - if (similarCases.size() > 0) { + if (!similarCases.isEmpty()) { CasePickOrCreateField pickOrCreateField = new CasePickOrCreateField(caseDto, person, similarCases); pickOrCreateField.setWidth(1280, Unit.PIXELS); @@ -962,9 +965,7 @@ public void selectOrCreateCase(CaseDataDto caseDto, PersonDto person, Consumer { - component.getCommitButton().setEnabled(commitAllowed); - }); + pickOrCreateField.setSelectionChangeCallback(commitAllowed -> component.getCommitButton().setEnabled(commitAllowed)); VaadinUiUtil.showModalPopupWindow(component, I18nProperties.getString(Strings.headingPickOrCreateCase)); } else { @@ -978,6 +979,10 @@ public CommitDiscardWrapperComponent getCaseDataEditComponent(fina DeletionInfoDto automaticDeletionInfoDto = FacadeProvider.getCaseFacade().getAutomaticDeletionInfo(caseUuid); DeletionInfoDto manuallyDeletionInfoDto = FacadeProvider.getCaseFacade().getManuallyDeletionInfo(caseUuid); + List caseMetadata = + FacadeProvider.getCustomizableFieldMetadataFacade().getActiveFieldsForContext(CustomizableFieldContext.CASE); + Map caseFieldValues = + FacadeProvider.getCustomizableFieldValueFacade().getValuesForEntity(caze.getUuid(), CustomizableFieldContext.CASE); CaseDataForm caseEditForm = new CaseDataForm( caseUuid, FacadeProvider.getPersonFacade().getByUuid(caze.getPerson().getUuid()), @@ -985,11 +990,15 @@ public CommitDiscardWrapperComponent getCaseDataEditComponent(fina caze.getSymptoms(), viewMode, caze.isPseudonymized(), - caze.isInJurisdiction()); + caze.isInJurisdiction(), + caseMetadata, + caseFieldValues); caseEditForm.setValue(caze); - CommitDiscardWrapperComponent editView = - new CommitDiscardWrapperComponent(caseEditForm, true, caseEditForm.getFieldGroup()); + CommitDiscardWrapperComponent editView = new CommitDiscardWrapperComponent<>(caseEditForm, true, caseEditForm.getFieldGroup()); + + caseEditForm.addCustomizableFieldValueChangeListener(e -> editView.setDirty(true)); + editView.addDiscardListener(caseEditForm::resetCustomizableFieldValues); editView.getButtonsPanel() .addComponentAsFirst(new DeletionLabel(automaticDeletionInfoDto, manuallyDeletionInfoDto, caze.isDeleted(), CaseDataDto.I18N_PREFIX)); @@ -1004,10 +1013,15 @@ public CommitDiscardWrapperComponent getCaseDataEditComponent(fina editView.addCommitListener(() -> { CaseDataDto oldCase = findCase(caseUuid); CaseDataDto cazeDto = caseEditForm.getValue(); - saveCaseWithFacilityChangedPrompt(cazeDto, oldCase); + Map updatedCaseFieldValues = caseEditForm.collectCurrentFieldValues(); + saveCaseWithFacilityChangedPrompt( + cazeDto, + oldCase, + () -> FacadeProvider.getCustomizableFieldValueFacade() + .saveEntityCustomFields(cazeDto.getUuid(), CustomizableFieldContext.CASE, updatedCaseFieldValues)); }); - editView.addDiscardListener(() -> caseEditForm.onDiscard()); + editView.addDiscardListener(caseEditForm::onDiscard); if (UiUtil.permitted(UserRight.CASE_REFER_FROM_POE) && caze.checkIsUnreferredPortHealthCase()) { @@ -1163,7 +1177,7 @@ public Consumer> bulkOperationCallback(Abstract private void appendSpecialCommands(CaseDataDto caze, CommitDiscardWrapperComponent editView) { if (UiUtil.permitted(UserRight.CASE_DELETE)) { - editView.addDeleteWithReasonOrRestoreListener((deleteDetails) -> { + editView.addDeleteWithReasonOrRestoreListener(deleteDetails -> { if (UiUtil.permitted(UserRight.CONTACT_VIEW)) { long contactCount = FacadeProvider.getContactFacade().getContactCount(caze.toReference()); if (contactCount > 0) { @@ -1188,7 +1202,7 @@ private void appendSpecialCommands(CaseDataDto caze, CommitDiscardWrapperCompone } else { deleteCase(caze, false, deleteDetails); } - }, getDeleteConfirmationDetails(Collections.singletonList(caze.getUuid())), (deleteDetails) -> { + }, getDeleteConfirmationDetails(Collections.singletonList(caze.getUuid())), deleteDetails -> { FacadeProvider.getCaseFacade().restore(caze.getUuid()); UI.getCurrent().getNavigator().navigateTo(CasesView.VIEW_NAME); }, I18nProperties.getString(Strings.entityCase), caze.getUuid(), FacadeProvider.getCaseFacade()); @@ -1246,7 +1260,7 @@ public CommitDiscardWrapperComponent getHospitalizationComp hospitalizationForm.setValue(caze.getHospitalization()); final CommitDiscardWrapperComponent editView = - new CommitDiscardWrapperComponent(hospitalizationForm, hospitalizationForm.getFieldGroup()); + new CommitDiscardWrapperComponent<>(hospitalizationForm, hospitalizationForm.getFieldGroup()); final JurisdictionValues jurisdictionValues = new JurisdictionValues(); @@ -1280,7 +1294,7 @@ public CommitDiscardWrapperComponent getHospitalizationComp VaadinUiUtil .showModalPopupWindow(wrapperComponent, I18nProperties.getString(Strings.headingPlaceOfStayInHospital), preCommitSuccessful -> { - if (preCommitSuccessful) { + if (Boolean.TRUE.equals(preCommitSuccessful)) { successCallback.run(); } }); @@ -1315,7 +1329,7 @@ public CommitDiscardWrapperComponent getMaternalHistoryComp form.setValue(maternalHistory); final CommitDiscardWrapperComponent component = - new CommitDiscardWrapperComponent(form, UiUtil.permitted(UserRight.CASE_EDIT), form.getFieldGroup()); + new CommitDiscardWrapperComponent<>(form, UiUtil.permitted(UserRight.CASE_EDIT), form.getFieldGroup()); component.addCommitListener(() -> { CaseDataDto caze1 = FacadeProvider.getCaseFacade().getCaseDataByUuid(caseUuid); caze1.setMaternalHistory(form.getValue()); @@ -1339,7 +1353,7 @@ public CommitDiscardWrapperComponent getPortHealthInfoCompon form.setValue(getPortHealthInfo(caze)); final CommitDiscardWrapperComponent component = - new CommitDiscardWrapperComponent(form, UiUtil.permitted(UserRight.PORT_HEALTH_INFO_EDIT), form.getFieldGroup()); + new CommitDiscardWrapperComponent<>(form, UiUtil.permitted(UserRight.PORT_HEALTH_INFO_EDIT), form.getFieldGroup()); component.addCommitListener(() -> { CaseDataDto caze1 = FacadeProvider.getCaseFacade().getCaseDataByUuid(caseUuid); caze1.setPortHealthInfo(form.getValue()); @@ -1369,7 +1383,7 @@ public CommitDiscardWrapperComponent getSymptomsEditComponent( symptomsForm.setValue(caseDataDto.getSymptoms()); CommitDiscardWrapperComponent editView = - new CommitDiscardWrapperComponent(symptomsForm, UiUtil.permitted(UserRight.CASE_EDIT), symptomsForm.getFieldGroup()); + new CommitDiscardWrapperComponent<>(symptomsForm, UiUtil.permitted(UserRight.CASE_EDIT), symptomsForm.getFieldGroup()); editView.addCommitListener(() -> { CaseDataDto cazeDto = FacadeProvider.getCaseFacade().getCaseDataByUuid(caseUuid); @@ -1393,6 +1407,11 @@ public CommitDiscardWrapperComponent getEpiDataComponent( boolean isEditAllowed) { CaseDataDto caze = findCase(caseUuid); + + List epiDataMetadata = + FacadeProvider.getCustomizableFieldMetadataFacade().getActiveFieldsForContext(CustomizableFieldContext.EPIDATA); + Map epiDataFieldValues = + FacadeProvider.getCustomizableFieldValueFacade().getValuesForEntity(caze.getEpiData().getUuid(), CustomizableFieldContext.EPIDATA); EpiDataDto epiDataDto = caze.getEpiData(); // Exposure start date and end date should be calculated based on symptom onsetDate and incubation start periods Date symptomOnsetDate = caze.getSymptoms().getOnsetDate(); @@ -1404,16 +1423,26 @@ public CommitDiscardWrapperComponent getEpiDataComponent( caze.isInJurisdiction(), sourceContactsToggleCallback, isEditAllowed, - symptomOnsetDate); + symptomOnsetDate, + epiDataMetadata, + epiDataFieldValues); epiDataForm.setValue(epiDataDto); - final CommitDiscardWrapperComponent editView = - new CommitDiscardWrapperComponent(epiDataForm, epiDataForm.getFieldGroup()); + final CommitDiscardWrapperComponent editView = new CommitDiscardWrapperComponent<>(epiDataForm, epiDataForm.getFieldGroup()); + + epiDataForm.addCustomizableFieldValueChangeListener(e -> editView.setDirty(true)); + editView.addDiscardListener(epiDataForm::resetCustomizableFieldValues); editView.addCommitListener(() -> { CaseDataDto cazeDto = FacadeProvider.getCaseFacade().getCaseDataByUuid(caseUuid); cazeDto.setEpiData(epiDataForm.getValue()); saveCase(cazeDto); + FacadeProvider.getCustomizableFieldValueFacade() + .saveEntityCustomFields(cazeDto.getEpiData().getUuid(), CustomizableFieldContext.EPIDATA, epiDataForm.collectCurrentFieldValues()); + epiDataForm.collectExposureCustomizableFieldValues() + .forEach( + (exposureUuid, fields) -> FacadeProvider.getCustomizableFieldValueFacade() + .saveEntityCustomFields(exposureUuid, CustomizableFieldContext.EXPOSURE, fields)); }); if (UiUtil.permitted(UserRight.CONTACT_VIEW)) { @@ -1480,7 +1509,7 @@ public DetailSubComponentWrapper getExternalDataComponent(final String caseUuid, return wrapper; } - public void saveCaseWithFacilityChangedPrompt(CaseDataDto caze, CaseDataDto oldCase) { + public void saveCaseWithFacilityChangedPrompt(CaseDataDto caze, CaseDataDto oldCase, Runnable afterSave) { if (FacilityType.HOSPITAL == caze.getFacilityType() && oldCase.getFacilityType() != FacilityType.HOSPITAL) { CurrentHospitalizationForm currentHospitalizationForm = new CurrentHospitalizationForm(); @@ -1498,13 +1527,13 @@ public void saveCaseWithFacilityChangedPrompt(CaseDataDto caze, CaseDataDto oldC switch (option) { case OPTION1: { caze.getHospitalization().setAdmittedToHealthFacility((YesNoUnknown) admittedToHealthFacilityField.getNullableValue()); - saveCaseWithOutcomeChangedWarning(caze, oldCase); + saveCaseWithOutcomeChangedWarning(caze, oldCase, afterSave); ControllerProvider.getCaseController().navigateToView(HospitalizationView.VIEW_NAME, caze.getUuid(), null); } break; case OPTION2: { caze.getHospitalization().setAdmittedToHealthFacility((YesNoUnknown) admittedToHealthFacilityField.getNullableValue()); - saveCaseWithOutcomeChangedWarning(caze, oldCase); + saveCaseWithOutcomeChangedWarning(caze, oldCase, afterSave); ControllerProvider.getCaseController().navigateToView(CaseDataView.VIEW_NAME, caze.getUuid(), null); } break; @@ -1524,15 +1553,19 @@ public void saveCaseWithFacilityChangedPrompt(CaseDataDto caze, CaseDataDto oldC 500, e -> { CaseLogic.handleHospitalization(caze, oldCase, e.booleanValue()); - saveCaseWithOutcomeChangedWarning(caze, oldCase); + saveCaseWithOutcomeChangedWarning(caze, oldCase, afterSave); SormasUI.refreshView(); }); } else { - saveCaseWithOutcomeChangedWarning(caze, oldCase); + saveCaseWithOutcomeChangedWarning(caze, oldCase, afterSave); } } - private void saveCaseWithOutcomeChangedWarning(CaseDataDto caze, CaseDataDto oldCase) { + public void saveCaseWithFacilityChangedPrompt(CaseDataDto caze, CaseDataDto oldCase) { + saveCaseWithFacilityChangedPrompt(caze, oldCase, null); + } + + private void saveCaseWithOutcomeChangedWarning(CaseDataDto caze, CaseDataDto oldCase, Runnable afterSave) { PersonDto person = FacadeProvider.getPersonFacade().getByUuid(caze.getPerson().getUuid()); PresentCondition presentCondition = person.getPresentCondition(); if (caze.getOutcome() == CaseOutcome.DECEASED @@ -1605,9 +1638,11 @@ private void saveCaseWithOutcomeChangedWarning(CaseDataDto caze, CaseDataDto old // actions warningComponent.addCommitListener(() -> { saveCase(caze); + if (afterSave != null) + afterSave.run(); popupWindow.close(); }); - warningComponent.addDiscardListener(() -> popupWindow.close()); + warningComponent.addDiscardListener(popupWindow::close); // popup configuration popupWindow.addCloseListener(e -> popupWindow.close()); @@ -1617,6 +1652,8 @@ private void saveCaseWithOutcomeChangedWarning(CaseDataDto caze, CaseDataDto old } } saveCase(caze); + if (afterSave != null) + afterSave.run(); } private PresentCondition getNewPresentConditionFromOutcome(CaseOutcome outcome) { @@ -1640,7 +1677,7 @@ public void referFromPointOfEntry(CaseDataDto caze) { CaseFacilityChangeForm form = new CaseFacilityChangeForm(); form.setValue(caze); CommitDiscardWrapperComponent view = - new CommitDiscardWrapperComponent(form, UiUtil.permitted(UserRight.CASE_REFER_FROM_POE), form.getFieldGroup()); + new CommitDiscardWrapperComponent<>(form, UiUtil.permitted(UserRight.CASE_REFER_FROM_POE), form.getFieldGroup()); view.getCommitButton().setCaption(I18nProperties.getCaption(Captions.caseReferFromPointOfEntry)); Window window = VaadinUiUtil.showPopupWindow(view); @@ -1657,9 +1694,7 @@ public void referFromPointOfEntry(CaseDataDto caze) { } }); - Button btnCancel = ButtonHelper.createButton(Captions.actionCancel, e -> { - window.close(); - }); + Button btnCancel = ButtonHelper.createButton(Captions.actionCancel, e -> window.close()); view.getButtonsPanel().replaceComponent(view.getDiscardButton(), btnCancel); } @@ -1722,12 +1757,12 @@ public void openClassificationRulesPopup(DiseaseClassificationCriteriaDto diseas } Window popupWindow = VaadinUiUtil.showPopupWindow(classificationRulesLayout); - popupWindow.addCloseListener(e -> { - popupWindow.close(); - }); + popupWindow.addCloseListener(e -> popupWindow.close()); popupWindow.setWidth(860, Unit.PIXELS); popupWindow.setHeight(80, Unit.PERCENTAGE); - popupWindow.setCaption(I18nProperties.getString(Strings.classificationRulesFor) + " " + diseaseCriteria.getDisease().toString()); + popupWindow.setCaption( + I18nProperties.getString(Strings.classificationRulesFor) + " " + + (diseaseCriteria != null ? diseaseCriteria.getDisease().toString() : "")); } public void deleteAllSelectedItems(Collection selectedRows, AbstractCaseGrid caseGrid) { @@ -1743,14 +1778,14 @@ public void restoreSelectedCases(Collection selectedRows public void sendSmsToAllSelectedItems(Collection selectedRows, Runnable callback) { - if (selectedRows.size() == 0) { + if (selectedRows.isEmpty()) { new Notification( I18nProperties.getString(Strings.headingNoCasesSelected), I18nProperties.getString(Strings.messageNoCasesSelected), Type.WARNING_MESSAGE, false).show(Page.getCurrent()); } else { - final List caseUuids = selectedRows.stream().map(caseIndexDto -> caseIndexDto.getUuid()).collect(Collectors.toList()); + final List caseUuids = selectedRows.stream().map(HasUuid::getUuid).collect(Collectors.toList()); final SmsComponent smsComponent = new SmsComponent(FacadeProvider.getCaseFacade().countCasesWithMissingContactInformation(caseUuids, MessageType.SMS)); VaadinUiUtil.showConfirmationPopup( @@ -1769,7 +1804,7 @@ public void sendSmsToAllSelectedItems(Collection selecte } public void sendEmailsToAllSelectedItems(Collection selectedRows, AbstractCaseGrid caseGrid) { - if (selectedRows.size() == 0) { + if (selectedRows.isEmpty()) { new Notification( I18nProperties.getString(Strings.headingNoCasesSelected), I18nProperties.getString(Strings.messageNoCasesSelected), @@ -1905,8 +1940,8 @@ public void sendCasesToExternalSurveillanceTool(Collect selectedCases.size()), I18nProperties.getCaption(Captions.actionCancel), 800, - (confirmed) -> { - if (confirmed) { + confirmed -> { + if (Boolean.TRUE.equals(confirmed)) { ExternalSurveillanceServiceGateway .sendCasesToExternalSurveillanceTool(withoutNotShareable, false, bulkOperationCallback(caseGrid, null)); } diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/caze/CaseDataForm.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/caze/CaseDataForm.java index 16c549d30a5..a78072d44da 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/caze/CaseDataForm.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/caze/CaseDataForm.java @@ -36,7 +36,9 @@ import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; @@ -97,6 +99,10 @@ import de.symeda.sormas.api.contact.FollowUpStatus; import de.symeda.sormas.api.contact.QuarantineType; import de.symeda.sormas.api.customizableenum.CustomizableEnumType; +import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldVisibilityContext; import de.symeda.sormas.api.disease.DiseaseVariant; import de.symeda.sormas.api.event.TypeOfPlace; import de.symeda.sormas.api.feature.FeatureType; @@ -154,12 +160,36 @@ import de.symeda.sormas.ui.utils.VaadinUiUtil; import de.symeda.sormas.ui.utils.ValidationUtils; import de.symeda.sormas.ui.utils.ViewMode; +import de.symeda.sormas.ui.utils.components.CustomizableFieldsGroup; +@SuppressWarnings({ + "java:S110", // suppress sonar too many parents warning + "java:S2160" // suppress missing equals not relevant for Vaadin components +}) public class CaseDataForm extends AbstractEditForm { private static final long serialVersionUID = 1L; private static final String CASE_DATA_HEADING_LOC = "caseDataHeadingLoc"; + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_GENERAL = CustomizableFieldGroup.CASE_DATA_GENERAL.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_CLASSIFICATION = CustomizableFieldGroup.CASE_DATA_CLASSIFICATION.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_IDENTIFIERS = CustomizableFieldGroup.CASE_DATA_IDENTIFIERS.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_INVESTIGATION = CustomizableFieldGroup.CASE_DATA_INVESTIGATION.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_DISEASE = CustomizableFieldGroup.CASE_DATA_DISEASE.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_REINFECTION = CustomizableFieldGroup.CASE_DATA_REINFECTION.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_OUTCOME = CustomizableFieldGroup.CASE_DATA_OUTCOME.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_SEQUELAE = CustomizableFieldGroup.CASE_DATA_SEQUELAE.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_JURISDICTION = CustomizableFieldGroup.CASE_DATA_JURISDICTION.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_PLACE_OF_STAY = CustomizableFieldGroup.CASE_DATA_PLACE_OF_STAY.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_QUARANTINE = CustomizableFieldGroup.CASE_DATA_QUARANTINE.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_REPORT_GEO = CustomizableFieldGroup.CASE_DATA_REPORT_GEO.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_HEALTH_CONDITIONS = CustomizableFieldGroup.CASE_DATA_HEALTH_CONDITIONS.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_DIAGNOSTIC = CustomizableFieldGroup.CASE_DATA_DIAGNOSTIC.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_MEDICAL_INFORMATION = CustomizableFieldGroup.CASE_DATA_MEDICAL_INFORMATION.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_VACCINATION = CustomizableFieldGroup.CASE_DATA_VACCINATION.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_CLINICIAN_NOTIFICATION = + CustomizableFieldGroup.CASE_DATA_CLINICIAN_NOTIFICATION.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_CONTACT_TRACING = CustomizableFieldGroup.CASE_DATA_CONTACT_TRACING.getKey(); private static final String MEDICAL_INFORMATION_LOC = "medicalInformationLoc"; private static final String PAPER_FORM_DATES_LOC = "paperFormDatesLoc"; private static final String SMALLPOX_VACCINATION_SCAR_IMG = "smallpoxVaccinationScarImg"; @@ -183,8 +213,6 @@ public class CaseDataForm extends AbstractEditForm { private static final String DONT_SHARE_WARNING_LOC = "dontShareWarning"; private static final String CASE_CLASSIFICATION_CALCULATE_BTN_LOC = "caseClassificationCalculateBtnLoc"; private static final String REINFECTION_INFO_LOC = "reinfectionInfoLoc"; - private static final String REINFECTION_DETAILS_COL_1_LOC = "reinfectionDetailsCol1Loc"; - private static final String REINFECTION_DETAILS_COL_2_LOC = "reinfectionDetailsCol2Loc"; private static final String VACCINATION_STATUS_INFO_LOC = "vaccinationStatusInfoLoc"; private static final String VACCINATION_STATUS_DETAILS_LOC = "vaccinationStatusDetailsLoc"; public static final String CASE_REFER_POINT_OF_ENTRY_BTN_LOC = "caseReferFromPointOfEntryBtnLoc"; @@ -196,6 +224,7 @@ public class CaseDataForm extends AbstractEditForm { private static final String MAIN_HTML_LAYOUT = loc(CASE_DATA_HEADING_LOC) + fluidRowLocs(4, CaseDataDto.UUID, 3, CaseDataDto.REPORT_DATE, 3, CaseDataDto.REPORTING_USER, 2, "") + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_GENERAL) + inlineLocs(CaseDataDto.CASE_CLASSIFICATION, CLASSIFICATION_RULES_LOC, CASE_CONFIRMATION_BASIS, CASE_CLASSIFICATION_CALCULATE_BTN_LOC) + fluidRow(fluidColumnLoc(3, 0, CaseDataDto.CASE_REFERENCE_DEFINITION)) + fluidRowLocs(4, CaseDataDto.CLINICAL_CONFIRMATION, 4, CaseDataDto.EPIDEMIOLOGICAL_CONFIRMATION, 4, CaseDataDto.LABORATORY_DIAGNOSTIC_CONFIRMATION) + @@ -206,13 +235,16 @@ public class CaseDataForm extends AbstractEditForm { fluidColumnLoc(3, 0, CaseDataDto.CLASSIFICATION_DATE), fluidColumnLocCss(LAYOUT_COL_HIDE_INVSIBLE, 5, 0, CaseDataDto.CLASSIFICATION_USER), fluidColumnLocCss(LAYOUT_COL_HIDE_INVSIBLE, 4, 0, CLASSIFIED_BY_SYSTEM_LOC)) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_CLASSIFICATION) + fluidRowLocs(9, CaseDataDto.INVESTIGATION_STATUS, 3, CaseDataDto.INVESTIGATED_DATE) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_INVESTIGATION) + fluidRowLocs(6, CaseDataDto.EPID_NUMBER, 3, ASSIGN_NEW_EPID_NUMBER_LOC) + loc(EPID_NUMBER_WARNING_LOC) + fluidRowLocs(CaseDataDto.EXTERNAL_ID, CaseDataDto.EXTERNAL_TOKEN) + fluidRowLocs("", EXTERNAL_TOKEN_WARNING_LOC) + fluidRowLocs(6, CaseDataDto.CASE_ID_ISM, 6, CaseDataDto.INTERNAL_TOKEN) + fluidRowLocs(CaseDataDto.CASE_REFERENCE_NUMBER, "") + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_IDENTIFIERS) + fluidRow( fluidColumnLoc(6, 0, CaseDataDto.DISEASE), fluidColumn(6, 0, locs( @@ -221,6 +253,7 @@ public class CaseDataForm extends AbstractEditForm { CaseDataDto.DENGUE_FEVER_TYPE, CaseDataDto.RABIES_TYPE))) + fluidRowLocs(CaseDataDto.DISEASE_VARIANT, CaseDataDto.DISEASE_VARIANT_DETAILS) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_DISEASE) + fluidRow( fluidColumnLoc(4, 0, CaseDataDto.RE_INFECTION), fluidColumnLoc(1, 0, REINFECTION_INFO_LOC), @@ -228,12 +261,16 @@ public class CaseDataForm extends AbstractEditForm { fluidColumnLoc(4, 0, CaseDataDto.PREVIOUS_INFECTION_DATE) ) + fluidRowLocs(CaseDataDto.REINFECTION_DETAILS) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_REINFECTION) + fluidRowLocs(6, CaseDataDto.OUTCOME, 3, CaseDataDto.OUTCOME_DATE, 3, CaseDataDto.POST_MORTEM) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_OUTCOME) + fluidRowLocs(3, CaseDataDto.SEQUELAE, 9, CaseDataDto.SEQUELAE_DETAILS) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_SEQUELAE) + fluidRowLocs(CaseDataDto.CASE_IDENTIFICATION_SOURCE, CaseDataDto.SCREENING_TYPE) + fluidRowLocs(CaseDataDto.CASE_ORIGIN, "") + fluidRowLocs(RESPONSIBLE_JURISDICTION_HEADING_LOC) + fluidRowLocs(CaseDataDto.RESPONSIBLE_REGION, CaseDataDto.RESPONSIBLE_DISTRICT, CaseDataDto.RESPONSIBLE_COMMUNITY) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_JURISDICTION) + fluidRowLocs(CaseDataDto.DONT_SHARE_WITH_REPORTING_TOOL) + fluidRowLocs(DONT_SHARE_WARNING_LOC) + fluidRowLocs(DIFFERENT_PLACE_OF_STAY_JURISDICTION) + @@ -243,6 +280,7 @@ public class CaseDataForm extends AbstractEditForm { fluidRowLocs(TYPE_GROUP_LOC, CaseDataDto.FACILITY_TYPE) + fluidRowLocs(CaseDataDto.HEALTH_FACILITY, CaseDataDto.HEALTH_FACILITY_DETAILS) + fluidRow(fluidColumnLoc(6, 0,CaseDataDto.DEPARTMENT)) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_PLACE_OF_STAY) + inlineLocs(CaseDataDto.POINT_OF_ENTRY, CaseDataDto.POINT_OF_ENTRY_DETAILS, CASE_REFER_POINT_OF_ENTRY_BTN_LOC) + fluidRowLocs(CaseDataDto.NOSOCOMIAL_OUTBREAK, CaseDataDto.INFECTION_SETTING) + locCss(VSPACE_3, CaseDataDto.SHARED_TO_COUNTRY) + @@ -261,26 +299,35 @@ public class CaseDataForm extends AbstractEditForm { fluidRowLocs(CaseDataDto.WAS_IN_QUARANTINE_BEFORE_ISOLATION) + fluidRowLocs(CaseDataDto.QUARANTINE_REASON_BEFORE_ISOLATION, CaseDataDto.QUARANTINE_REASON_BEFORE_ISOLATION_DETAILS) + fluidRowLocs(CaseDataDto.END_OF_ISOLATION_REASON, CaseDataDto.END_OF_ISOLATION_REASON_DETAILS) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_QUARANTINE) + fluidRowLocs(CaseDataDto.REPORT_LAT, CaseDataDto.REPORT_LON, CaseDataDto.REPORT_LAT_LON_ACCURACY) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_REPORT_GEO) + fluidRowLocs(CaseDataDto.HEALTH_CONDITIONS) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_HEALTH_CONDITIONS) + loc(DIAGNOSIS_CRITERIA_HEADING_LOC) + loc(DIAGNOSIS_CRITERIA_SUBHEADING_LOC) + fluidRowLocs(DIAGNOSIS_CRITERIA_LAB_TEST_PANEL_LOC) + fluidRowLocs(8, CaseDataDto.RADIOGRAPHY_COMPATIBILITY) + fluidRowLocs(CaseDataDto.OTHER_DIAGNOSTIC_CRITERIA) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_DIAGNOSTIC) + loc(MEDICAL_INFORMATION_LOC) + fluidRowLocs(CaseDataDto.BLOOD_ORGAN_OR_TISSUE_DONATED) + fluidRowLocs(CaseDataDto.PREGNANT, CaseDataDto.POSTPARTUM) + fluidRowLocs(CaseDataDto.TRIMESTER, "") + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_MEDICAL_INFORMATION) + inlineLocs(CaseDataDto.VACCINATION_STATUS, VACCINATION_STATUS_INFO_LOC) + fluidRowLocs(VACCINATION_STATUS_DETAILS_LOC) + fluidRowLocs(CaseDataDto.SMALLPOX_VACCINATION_RECEIVED, CaseDataDto.SMALLPOX_VACCINATION_SCAR) + fluidRowLocs(CaseDataDto.SMALLPOX_LAST_VACCINATION_DATE, "") + fluidRowLocs(SMALLPOX_VACCINATION_SCAR_IMG) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_VACCINATION) + fluidRowLocs(6, CaseDataDto.CLINICIAN_NAME) + fluidRowLocs(CaseDataDto.NOTIFYING_CLINIC, CaseDataDto.NOTIFYING_CLINIC_DETAILS) + fluidRowLocs(CaseDataDto.CLINICIAN_PHONE, CaseDataDto.CLINICIAN_EMAIL) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_CLINICIAN_NOTIFICATION) + loc(CONTACT_TRACING_FIRST_CONTACT_HEADER_LOC) + - fluidRowLocs(CaseDataDto.CONTACT_TRACING_FIRST_CONTACT_TYPE, CaseDataDto.CONTACT_TRACING_FIRST_CONTACT_DATE); + fluidRowLocs(CaseDataDto.CONTACT_TRACING_FIRST_CONTACT_TYPE, CaseDataDto.CONTACT_TRACING_FIRST_CONTACT_DATE) + + loc(LOC_CUSTOMIZABLE_FIELDS_CASE_DATA_CONTACT_TRACING); + private static final String FOLLOWUP_LAYOUT = loc(FOLLOW_UP_STATUS_HEADING_LOC) + @@ -298,6 +345,25 @@ public class CaseDataForm extends AbstractEditForm { fluidRowLocs(CaseDataDto.OTHER_DELETION_REASON); //@formatter:on + private CustomizableFieldsGroup caseDataGeneralPanel; + private CustomizableFieldsGroup caseDataClassificationPanel; + private CustomizableFieldsGroup caseDataIdentifiersPanel; + private CustomizableFieldsGroup caseDataInvestigationPanel; + private CustomizableFieldsGroup caseDataDiseasePanel; + private CustomizableFieldsGroup caseDataReinfectionPanel; + private CustomizableFieldsGroup caseDataOutcomePanel; + private CustomizableFieldsGroup caseDataSequelaePanel; + private CustomizableFieldsGroup caseDataJurisdictionPanel; + private CustomizableFieldsGroup caseDataPlaceOfStayPanel; + private CustomizableFieldsGroup caseDataQuarantinePanel; + private CustomizableFieldsGroup caseDataReportGeoPanel; + private CustomizableFieldsGroup caseDataHealthConditionsPanel; + private CustomizableFieldsGroup caseDataDiagnosticPanel; + private CustomizableFieldsGroup caseDataMedicalInformationPanel; + private CustomizableFieldsGroup caseDataVaccinationPanel; + private CustomizableFieldsGroup caseDataClinicianNotificationPanel; + private CustomizableFieldsGroup caseDataContactTracingPanel; + private final String caseUuid; private final PersonDto person; private final Disease disease; @@ -312,8 +378,6 @@ public class CaseDataForm extends AbstractEditForm { private DateField dfPreviousQuarantineTo; private CheckBox cbQuarantineExtended; private CheckBox cbQuarantineReduced; - private CheckBox quarantineOrderedVerbally; - private CheckBox quarantineOrderedOfficialDocument; private CheckBox differentPlaceOfStayJurisdiction; private ComboBox responsibleRegion; private ComboBox responsibleDistrict; @@ -326,7 +390,6 @@ public class CaseDataForm extends AbstractEditForm { private ComboBoxWithPlaceholder facilityTypeCombo; private ComboBox facilityCombo; private TextField facilityDetails; - private TextField tfDepartment; private boolean quarantineChangedByFollowUpUntilChange = false; private TextField tfExpectedFollowUpUntilDate; private FollowUpPeriodDto expectedFollowUpPeriodDto; @@ -334,6 +397,7 @@ public class CaseDataForm extends AbstractEditForm { private CheckBox postMortemCB; private Label vaccinationStatusInfoLabel; + @SuppressWarnings("java:S107") // sonar: constructor too many parameters public CaseDataForm( String caseUuid, PersonDto person, @@ -341,7 +405,9 @@ public CaseDataForm( SymptomsDto symptoms, ViewMode viewMode, boolean isPseudonymized, - boolean inJurisdiction) { + boolean inJurisdiction, + List customizableFieldsMetadata, + Map customizableFieldsValues) { super( CaseDataDto.class, @@ -359,6 +425,8 @@ public CaseDataForm( this.disease = disease; this.symptoms = symptoms; this.caseFollowUpEnabled = UiUtil.enabled(FeatureType.CASE_FOLLOWUP); + setCustomizableFieldsMetadata(customizableFieldsMetadata); + setCustomizableFieldsValues(customizableFieldsValues); addFields(); } @@ -388,7 +456,6 @@ public static void updateFacilityDetails(ComboBox cbFacility, TextField tfFacili } } - @SuppressWarnings("deprecation") @Override protected void addFields() { @@ -458,7 +525,7 @@ protected void addFields() { externalTokenWarningLabel.addStyleNames(VSPACE_3, LABEL_WHITE_SPACE_NORMAL); getContent().addComponent(externalTokenWarningLabel, EXTERNAL_TOKEN_WARNING_LOC); - tfDepartment = addField(CaseDataDto.DEPARTMENT, TextField.class); + TextField tfDepartment = addField(CaseDataDto.DEPARTMENT, TextField.class); tfDepartment.setCaption(I18nProperties.getPrefixCaption(CaseDataDto.I18N_PREFIX, CaseDataDto.DEPARTMENT)); addField(CaseDataDto.INTERNAL_TOKEN, TextField.class); addField(CaseDataDto.CASE_REFERENCE_NUMBER, TextField.class); @@ -469,6 +536,7 @@ protected void addFields() { addField(CaseDataDto.SEQUELAE, NullableOptionGroup.class); addFields(CaseDataDto.INVESTIGATED_DATE, CaseDataDto.OUTCOME_DATE, CaseDataDto.SEQUELAE_DETAILS); + postMortemCB = addField(CaseDataDto.POST_MORTEM, CheckBox.class); postMortemCB.setValue(false); addField(CaseDataDto.CASE_IDENTIFICATION_SOURCE); @@ -605,10 +673,10 @@ protected void addFields() { CaseDataDto.LABORATORY_DIAGNOSTIC_CONFIRMATION); } - quarantineOrderedVerbally = addField(CaseDataDto.QUARANTINE_ORDERED_VERBALLY, CheckBox.class); + CheckBox quarantineOrderedVerbally = addField(CaseDataDto.QUARANTINE_ORDERED_VERBALLY, CheckBox.class); CssStyles.style(quarantineOrderedVerbally, CssStyles.FORCE_CAPTION); addField(CaseDataDto.QUARANTINE_ORDERED_VERBALLY_DATE, DateField.class); - quarantineOrderedOfficialDocument = addField(CaseDataDto.QUARANTINE_ORDERED_OFFICIAL_DOCUMENT, CheckBox.class); + CheckBox quarantineOrderedOfficialDocument = addField(CaseDataDto.QUARANTINE_ORDERED_OFFICIAL_DOCUMENT, CheckBox.class); CssStyles.style(quarantineOrderedOfficialDocument, CssStyles.FORCE_CAPTION); addField(CaseDataDto.QUARANTINE_ORDERED_OFFICIAL_DOCUMENT_DATE, DateField.class); @@ -642,68 +710,7 @@ protected void addFields() { FieldHelper.setVisibleWhen(getFieldGroup(), CaseDataDto.INFECTION_SETTING, CaseDataDto.NOSOCOMIAL_OUTBREAK, true, true); // Reinfection - { - NullableOptionGroup ogReinfection = addField(CaseDataDto.RE_INFECTION, NullableOptionGroup.class); - - addField(CaseDataDto.PREVIOUS_INFECTION_DATE); - ComboBox tfReinfectionStatus = addField(CaseDataDto.REINFECTION_STATUS, ComboBox.class); - tfReinfectionStatus.setReadOnly(true); - FieldHelper.setVisibleWhen(getFieldGroup(), CaseDataDto.PREVIOUS_INFECTION_DATE, CaseDataDto.RE_INFECTION, YesNoUnknown.YES, false); - FieldHelper.setVisibleWhen(getFieldGroup(), CaseDataDto.REINFECTION_STATUS, CaseDataDto.RE_INFECTION, YesNoUnknown.YES, false); - - final Label reinfectionInfoLabel = new Label(VaadinIcons.EYE.getHtml(), ContentMode.HTML); - CssStyles.style(reinfectionInfoLabel, CssStyles.LABEL_XLARGE, CssStyles.VSPACE_TOP_3); - getContent().addComponent(reinfectionInfoLabel, REINFECTION_INFO_LOC); - reinfectionInfoLabel.setVisible(false); - - CheckBoxTree reinfectionDetailGroupCheckBoxTree = addField(CaseDataDto.REINFECTION_DETAILS, CheckBoxTree.class); - reinfectionDetailGroupCheckBoxTree.setEnumType(ReinfectionDetail.class, ReinfectionDetail::getGroup, ReinfectionDetailGroup.class, 2); - - tfReinfectionStatus.setReadOnly(false); - tfReinfectionStatus.setValue(CaseLogic.calculateReinfectionStatus(reinfectionDetailGroupCheckBoxTree.getValue())); - tfReinfectionStatus.setReadOnly(true); - - reinfectionDetailGroupCheckBoxTree.addValueChangeListener(e -> { - tfReinfectionStatus.setReadOnly(false); - tfReinfectionStatus.setValue(CaseLogic.calculateReinfectionStatus(reinfectionDetailGroupCheckBoxTree.getValue())); - tfReinfectionStatus.setReadOnly(true); - }); - - ogReinfection.addValueChangeListener(e -> { - if (((NullableOptionGroup) e.getProperty()).getNullableValue() == YesNoUnknown.YES) { - PreviousCaseDto previousCase = FacadeProvider.getCaseFacade() - .getMostRecentPreviousCase(getValue().getPerson(), getValue().getDisease(), CaseLogic.getStartDate(getValue())); - - if (previousCase != null) { - String reinfectionInfoTemplate = "Previous case:

%s: %s
%s: %s
%s: %s
%s: %s
%s: %s"; - String reinfectionInfo = String.format( - reinfectionInfoTemplate, - I18nProperties.getPrefixCaption(CaseDataDto.I18N_PREFIX, EntityDto.UUID), - DataHelper.getShortUuid(previousCase.getUuid()), - I18nProperties.getPrefixCaption(CaseDataDto.I18N_PREFIX, CaseDataDto.REPORT_DATE), - DateHelper.formatLocalDate(previousCase.getReportDate(), I18nProperties.getUserLanguage()), - I18nProperties.getPrefixCaption(CaseDataDto.I18N_PREFIX, CaseDataDto.EXTERNAL_TOKEN), - DataHelper.toStringNullable(previousCase.getExternalToken()), - I18nProperties.getPrefixCaption(CaseDataDto.I18N_PREFIX, CaseDataDto.DISEASE_VARIANT), - DataHelper.toStringNullable(previousCase.getDiseaseVariant()), - I18nProperties.getPrefixCaption(SymptomsDto.I18N_PREFIX, SymptomsDto.ONSET_DATE), - previousCase.getOnsetDate() != null - ? DateHelper.formatLocalDate(previousCase.getOnsetDate(), I18nProperties.getUserLanguage()) - : ""); - reinfectionInfoLabel.setDescription(reinfectionInfo, ContentMode.HTML); - reinfectionInfoLabel.setVisible(isVisibleAllowed(CaseDataDto.RE_INFECTION)); - } else { - reinfectionInfoLabel.setDescription(null); - reinfectionInfoLabel.setVisible(false); - } - reinfectionDetailGroupCheckBoxTree.setVisible(isVisibleAllowed(CaseDataDto.RE_INFECTION)); - } else { - reinfectionInfoLabel.setDescription(null); - reinfectionInfoLabel.setVisible(false); - reinfectionDetailGroupCheckBoxTree.setVisible(false); - } - }); - } + addReinfectionFields(); addField(CaseDataDto.QUARANTINE_HOME_POSSIBLE, NullableOptionGroup.class); addField(CaseDataDto.QUARANTINE_HOME_POSSIBLE_COMMENT, TextField.class); @@ -1093,7 +1100,7 @@ protected void addFields() { updateFacility(); } }); - responsibleCommunity.addValueChangeListener((e) -> { + responsibleCommunity.addValueChangeListener(e -> { Boolean differentPlaceOfStay = differentPlaceOfStayJurisdiction.getValue(); if (differentPlaceOfStay == null || Boolean.FALSE.equals(differentPlaceOfStay)) { updateFacility(); @@ -1113,8 +1120,7 @@ protected void addFields() { FieldVisibilityCheckers.withDisease(disease) .add(new CountryFieldVisibilityChecker(FacadeProvider.getConfigFacade().getCountryLocale())), UiFieldAccessCheckers.getDefault(true, FacadeProvider.getConfigFacade().getCountryLocale()), - new PersonReferenceDto(person.getUuid()))) - .setCaption(null); + new PersonReferenceDto(person.getUuid()))).setCaption(null); //diagnosis criteria if ((FacadeProvider.getConfigFacade().isConfiguredCountry(CountryHelper.COUNTRY_CODE_LUXEMBOURG)) && disease == Disease.TUBERCULOSIS) { @@ -1158,6 +1164,7 @@ protected void addFields() { if (diseaseClassificationExists() && FacadeProvider.getConfigFacade().getCaseClassificationCalculationMode(disease).isManualEnabled() && isVisibleAllowed(CaseDataDto.CASE_CLASSIFICATION)) { + @SuppressWarnings("unchecked") Button caseClassificationCalculationButton = ButtonHelper.createButton(Captions.caseClassificationCalculationButton, e -> { CaseClassification classification = FacadeProvider.getCaseClassificationFacade().getClassification(getValue()); ((Field) getField(CaseDataDto.CASE_CLASSIFICATION)).setValue(classification); @@ -1262,15 +1269,13 @@ && isVisibleAllowed(CaseDataDto.CASE_CLASSIFICATION)) { true); } - if (isVisibleAllowed(CaseDataDto.SMALLPOX_LAST_VACCINATION_DATE)) { - if (isVisibleAllowed(CaseDataDto.SMALLPOX_VACCINATION_RECEIVED)) { - FieldHelper.setVisibleWhen( - getFieldGroup(), - CaseDataDto.SMALLPOX_LAST_VACCINATION_DATE, - CaseDataDto.SMALLPOX_VACCINATION_RECEIVED, - Collections.singletonList(YesNoUnknown.YES), - true); - } + if (isVisibleAllowed(CaseDataDto.SMALLPOX_LAST_VACCINATION_DATE) && isVisibleAllowed(CaseDataDto.SMALLPOX_VACCINATION_RECEIVED)) { + FieldHelper.setVisibleWhen( + getFieldGroup(), + CaseDataDto.SMALLPOX_LAST_VACCINATION_DATE, + CaseDataDto.SMALLPOX_VACCINATION_RECEIVED, + Collections.singletonList(YesNoUnknown.YES), + true); } // Sync visibility of info label with vaccination status field @@ -1409,16 +1414,15 @@ && diseaseClassificationExists()) { setEpidNumberError(epidField, assignNewEpidNumberButton, epidNumberWarningLabel, getValue().getEpidNumber()); - epidField.addValueChangeListener(f -> { - setEpidNumberError(epidField, assignNewEpidNumberButton, epidNumberWarningLabel, (String) f.getProperty().getValue()); - }); + epidField.addValueChangeListener( + f -> setEpidNumberError(epidField, assignNewEpidNumberButton, epidNumberWarningLabel, (String) f.getProperty().getValue())); ValidationUtils.initComponentErrorValidator( externalTokenField, getValue().getExternalToken(), Validations.duplicateExternalToken, externalTokenWarningLabel, - (externalToken) -> FacadeProvider.getCaseFacade().doesExternalTokenExist(externalToken, getValue().getUuid())); + externalToken -> FacadeProvider.getCaseFacade().doesExternalTokenExist(externalToken, getValue().getUuid())); updateFacilityOrHome(); @@ -1536,6 +1540,73 @@ public String getFormattedHtmlMessage() { CaseDataDto.CLINICIAN_PHONE, CaseDataDto.CLINICIAN_EMAIL, CaseDataDto.ADDITIONAL_DETAILS); + + // Customizable fields group panels + initializeCustomizableFieldPanels(); + } + + private void addReinfectionFields() { + NullableOptionGroup ogReinfection = addField(CaseDataDto.RE_INFECTION, NullableOptionGroup.class); + + addField(CaseDataDto.PREVIOUS_INFECTION_DATE); + ComboBox tfReinfectionStatus = addField(CaseDataDto.REINFECTION_STATUS, ComboBox.class); + tfReinfectionStatus.setReadOnly(true); + FieldHelper.setVisibleWhen(getFieldGroup(), CaseDataDto.PREVIOUS_INFECTION_DATE, CaseDataDto.RE_INFECTION, YesNoUnknown.YES, false); + FieldHelper.setVisibleWhen(getFieldGroup(), CaseDataDto.REINFECTION_STATUS, CaseDataDto.RE_INFECTION, YesNoUnknown.YES, false); + + final Label reinfectionInfoLabel = new Label(VaadinIcons.EYE.getHtml(), ContentMode.HTML); + CssStyles.style(reinfectionInfoLabel, CssStyles.LABEL_XLARGE, CssStyles.VSPACE_TOP_3); + getContent().addComponent(reinfectionInfoLabel, REINFECTION_INFO_LOC); + reinfectionInfoLabel.setVisible(false); + + @SuppressWarnings("unchecked") + CheckBoxTree reinfectionDetailGroupCheckBoxTree = addField(CaseDataDto.REINFECTION_DETAILS, CheckBoxTree.class); + reinfectionDetailGroupCheckBoxTree.setEnumType(ReinfectionDetail.class, ReinfectionDetail::getGroup, ReinfectionDetailGroup.class, 2); + + tfReinfectionStatus.setReadOnly(false); + tfReinfectionStatus.setValue(CaseLogic.calculateReinfectionStatus(reinfectionDetailGroupCheckBoxTree.getValue())); + tfReinfectionStatus.setReadOnly(true); + + reinfectionDetailGroupCheckBoxTree.addValueChangeListener(e -> { + tfReinfectionStatus.setReadOnly(false); + tfReinfectionStatus.setValue(CaseLogic.calculateReinfectionStatus(reinfectionDetailGroupCheckBoxTree.getValue())); + tfReinfectionStatus.setReadOnly(true); + }); + + ogReinfection.addValueChangeListener(e -> { + if (((NullableOptionGroup) e.getProperty()).getNullableValue() == YesNoUnknown.YES) { + PreviousCaseDto previousCase = FacadeProvider.getCaseFacade() + .getMostRecentPreviousCase(getValue().getPerson(), getValue().getDisease(), CaseLogic.getStartDate(getValue())); + + if (previousCase != null) { + String reinfectionInfoTemplate = "Previous case:

%s: %s
%s: %s
%s: %s
%s: %s
%s: %s"; + String reinfectionInfo = String.format( + reinfectionInfoTemplate, + I18nProperties.getPrefixCaption(CaseDataDto.I18N_PREFIX, EntityDto.UUID), + DataHelper.getShortUuid(previousCase.getUuid()), + I18nProperties.getPrefixCaption(CaseDataDto.I18N_PREFIX, CaseDataDto.REPORT_DATE), + DateHelper.formatLocalDate(previousCase.getReportDate(), I18nProperties.getUserLanguage()), + I18nProperties.getPrefixCaption(CaseDataDto.I18N_PREFIX, CaseDataDto.EXTERNAL_TOKEN), + DataHelper.toStringNullable(previousCase.getExternalToken()), + I18nProperties.getPrefixCaption(CaseDataDto.I18N_PREFIX, CaseDataDto.DISEASE_VARIANT), + DataHelper.toStringNullable(previousCase.getDiseaseVariant()), + I18nProperties.getPrefixCaption(SymptomsDto.I18N_PREFIX, SymptomsDto.ONSET_DATE), + previousCase.getOnsetDate() != null + ? DateHelper.formatLocalDate(previousCase.getOnsetDate(), I18nProperties.getUserLanguage()) + : ""); + reinfectionInfoLabel.setDescription(reinfectionInfo, ContentMode.HTML); + reinfectionInfoLabel.setVisible(isVisibleAllowed(CaseDataDto.RE_INFECTION)); + } else { + reinfectionInfoLabel.setDescription(null); + reinfectionInfoLabel.setVisible(false); + } + reinfectionDetailGroupCheckBoxTree.setVisible(isVisibleAllowed(CaseDataDto.RE_INFECTION)); + } else { + reinfectionInfoLabel.setDescription(null); + reinfectionInfoLabel.setVisible(false); + reinfectionDetailGroupCheckBoxTree.setVisible(false); + } + }); } /** @@ -1558,9 +1629,7 @@ private void getManualCaseDefinition() { suspectContent.setValue(caseDefinitionText); classificationRulesLayout.addComponent(suspectContent); Window popupWindow = VaadinUiUtil.showPopupWindow(classificationRulesLayout); - popupWindow.addCloseListener(e1 -> { - popupWindow.close(); - }); + popupWindow.addCloseListener(e1 -> popupWindow.close()); popupWindow.setWidth(860, Unit.PIXELS); popupWindow.setHeight(80, Unit.PERCENTAGE); popupWindow.setCaption(I18nProperties.getString(Strings.caseDefinitionForDisease) + " " + disease); @@ -1642,7 +1711,7 @@ private void onFollowUpUntilChanged() { I18nProperties.getString(Strings.no), 640, confirmed -> { - if (confirmed) { + if (Boolean.TRUE.equals(confirmed)) { quarantineChangedByFollowUpUntilChange = true; dfQuarantineTo.setValue(newFollowUpUntil); if (oldQuarantineEnd != null) { @@ -1707,7 +1776,7 @@ private void confirmQuarantineEndChanged(ExtendedReduced changeType, CaseDataDto 640, confirmed -> { Date quarantineTo = originalCase.getQuarantineTo(); - if (confirmed) { + if (Boolean.TRUE.equals(confirmed)) { dfPreviousQuarantineTo.setReadOnly(false); dfPreviousQuarantineTo.setValue(quarantineTo); dfPreviousQuarantineTo.setReadOnly(true); @@ -1751,7 +1820,7 @@ private void confirmExtendFollowUpPeriod(CaseDataDto originalCase) { I18nProperties.getString(Strings.no), 640, confirmed -> { - if (confirmed) { + if (Boolean.TRUE.equals(confirmed)) { cbOverwriteFollowUpUntil.setValue(true); dfFollowUpUntil.setValue(quarantineEnd); } @@ -1923,6 +1992,84 @@ protected String createHtmlLayout() { return MAIN_HTML_LAYOUT + (caseFollowUpEnabled ? FOLLOWUP_LAYOUT : "") + PAPER_FORM_DATES_AND_HEALTH_CONDITIONS_HTML_LAYOUT; } + private CustomizableFieldsGroup createAndAddCustomizablePanel(CustomizableFieldGroup group) { + CustomizableFieldsGroup panel = new CustomizableFieldsGroup(group); + panel.setVisibilityContext(new CustomizableFieldVisibilityContext().withDisease(disease)); + panel.setFieldsMetadata(getCustomizableFieldsMetadata()); + panel.setFieldsValues(getCustomizableFieldsValues()); + panel.updateFieldsDisplay(); + getContent().addComponent(panel, group.getKey()); + return panel; + } + + private void initializeCustomizableFieldPanels() { + caseDataGeneralPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_GENERAL); + caseDataClassificationPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_CLASSIFICATION); + caseDataInvestigationPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_INVESTIGATION); + caseDataIdentifiersPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_IDENTIFIERS); + caseDataDiseasePanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_DISEASE); + caseDataReinfectionPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_REINFECTION); + caseDataOutcomePanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_OUTCOME); + caseDataSequelaePanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_SEQUELAE); + caseDataJurisdictionPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_JURISDICTION); + caseDataPlaceOfStayPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_PLACE_OF_STAY); + caseDataQuarantinePanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_QUARANTINE); + caseDataReportGeoPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_REPORT_GEO); + caseDataHealthConditionsPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_HEALTH_CONDITIONS); + caseDataDiagnosticPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_DIAGNOSTIC); + caseDataMedicalInformationPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_MEDICAL_INFORMATION); + caseDataVaccinationPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_VACCINATION); + caseDataClinicianNotificationPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_CLINICIAN_NOTIFICATION); + caseDataContactTracingPanel = createAndAddCustomizablePanel(CustomizableFieldGroup.CASE_DATA_CONTACT_TRACING); + } + + private List getCustomizableFieldPanels() { + return Arrays + .asList( + caseDataGeneralPanel, + caseDataClassificationPanel, + caseDataInvestigationPanel, + caseDataIdentifiersPanel, + caseDataDiseasePanel, + caseDataReinfectionPanel, + caseDataOutcomePanel, + caseDataSequelaePanel, + caseDataJurisdictionPanel, + caseDataPlaceOfStayPanel, + caseDataQuarantinePanel, + caseDataReportGeoPanel, + caseDataHealthConditionsPanel, + caseDataDiagnosticPanel, + caseDataMedicalInformationPanel, + caseDataVaccinationPanel, + caseDataClinicianNotificationPanel, + caseDataContactTracingPanel) + .stream() + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + public Map collectCurrentFieldValues() { + Map result = new HashMap<>(); + getCustomizableFieldPanels().forEach(panel -> panel.getFieldsValues().forEach((metadata, valueDto) -> { + if (valueDto != null) { + result.put(metadata, valueDto); + } + })); + return result; + } + + public void addCustomizableFieldValueChangeListener(com.vaadin.data.HasValue.ValueChangeListener listener) { + getCustomizableFieldPanels().forEach(panel -> panel.addValueChangeListener(listener)); + } + + public void resetCustomizableFieldValues() { + getCustomizableFieldPanels().forEach(panel -> { + panel.setFieldsValues(getCustomizableFieldsValues()); + panel.updateFieldsDisplay(); + }); + } + public void addButtonListener(String componentId, Button.ClickListener listener) { Button button = (Button) getContent().getComponent(componentId); button.addClickListener(listener); @@ -1963,9 +2110,9 @@ private static class DiseaseChangeListener implements ValueChangeListener { private final AbstractSelect diseaseField; private final Disease currentDisease; - private final List fields; + private final List> fields; - DiseaseChangeListener(AbstractSelect diseaseField, Disease currentDisease, Field... fields) { + DiseaseChangeListener(AbstractSelect diseaseField, Disease currentDisease, Field... fields) { this.diseaseField = diseaseField; this.currentDisease = currentDisease; this.fields = Arrays.asList(fields); @@ -1984,11 +2131,9 @@ protected void onConfirm() { diseaseField.removeValueChangeListener(DiseaseChangeListener.this); fields.stream().forEach(field -> { if (FacadeProvider.getConfigFacade().isConfiguredCountry(CountryHelper.COUNTRY_CODE_LUXEMBOURG)) { - if (diseaseField.getValue().equals(Disease.TUBERCULOSIS) && field.getId().equals(CaseDataDto.POST_MORTEM)) { - field.setVisible(true); - } else { - field.setVisible(false); - } + final boolean isTuberculosisPostMortem = + diseaseField.getValue().equals(Disease.TUBERCULOSIS) && field.getId().equals(CaseDataDto.POST_MORTEM); + field.setVisible(isTuberculosisPostMortem); } }); } diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/AbstractConfigurationView.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/AbstractConfigurationView.java index 6b7391e6764..0fa78b1c6c4 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/AbstractConfigurationView.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/AbstractConfigurationView.java @@ -30,6 +30,7 @@ import de.symeda.sormas.ui.SubMenu; import de.symeda.sormas.ui.UiUtil; import de.symeda.sormas.ui.configuration.customizableenum.CustomizableEnumValuesView; +import de.symeda.sormas.ui.configuration.customizablefield.CustomizableFieldsView; import de.symeda.sormas.ui.configuration.disease.DiseaseConfigurationView; import de.symeda.sormas.ui.configuration.docgeneration.DocumentTemplatesView; import de.symeda.sormas.ui.configuration.docgeneration.emailtemplate.EmailTemplatesView; @@ -52,6 +53,7 @@ import de.symeda.sormas.ui.utils.DirtyStateComponent; import de.symeda.sormas.ui.utils.FieldHelper; +@SuppressWarnings("java:S110") // suppress sonar too many parents warning public abstract class AbstractConfigurationView extends AbstractSubNavigationView { private static final long serialVersionUID = 3193505016439327054L; @@ -123,12 +125,17 @@ public static Class registerViews(Navigator firstAccessibleView = firstAccessibleView != null ? firstAccessibleView : CustomizableEnumValuesView.class; } + if (UiUtil.permitted(UserRight.CUSTOMIZABLE_FIELD_MANAGEMENT)) { + navigator.addView(CustomizableFieldsView.VIEW_NAME, CustomizableFieldsView.class); + firstAccessibleView = firstAccessibleView != null ? firstAccessibleView : CustomizableFieldsView.class; + } + if (UiUtil.permitted(UserRight.DISEASE_MANAGEMENT)) { navigator.addView(DiseaseConfigurationView.VIEW_NAME, DiseaseConfigurationView.class); firstAccessibleView = firstAccessibleView != null ? firstAccessibleView : DiseaseConfigurationView.class; } - if(UiUtil.permitted(UserRight.SYSTEM_CONFIGURATION)) { + if (UiUtil.permitted(UserRight.SYSTEM_CONFIGURATION)) { navigator.addView(SystemConfigurationView.VIEW_NAME, SystemConfigurationView.class); firstAccessibleView = firstAccessibleView != null ? firstAccessibleView : SystemConfigurationView.class; } @@ -256,6 +263,14 @@ public void refreshMenu(SubMenu menu, String params) { false); } + if (UiUtil.permitted(UserRight.CUSTOMIZABLE_FIELD_MANAGEMENT)) { + menu.addView( + CustomizableFieldsView.VIEW_NAME, + I18nProperties.getPrefixCaption("View", CustomizableFieldsView.VIEW_NAME.replaceAll("/", ".") + ".short", ""), + null, + false); + } + if (UiUtil.permitted(UserRight.DISEASE_MANAGEMENT)) { menu.addView( DiseaseConfigurationView.VIEW_NAME, @@ -288,7 +303,7 @@ protected ComboBox addCountryFilter(Layout layout, Consumer changeHandler.accept(country); if (regionFilter != null) { - if (isServerCountry) { + if (Boolean.TRUE.equals(isServerCountry)) { FieldHelper.updateItems(regionFilter, FacadeProvider.getRegionFacade().getAllActiveByServerCountry()); } else { FieldHelper.updateItems(regionFilter, FacadeProvider.getRegionFacade().getAllActiveByCountry(country.getUuid())); diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldEditForm.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldEditForm.java new file mode 100644 index 00000000000..e7c346365ce --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldEditForm.java @@ -0,0 +1,407 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.configuration.customizablefield; + +import static de.symeda.sormas.ui.utils.CssStyles.H3; +import static de.symeda.sormas.ui.utils.LayoutUtil.fluidRowLocs; +import static de.symeda.sormas.ui.utils.LayoutUtil.loc; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.vaadin.data.Binder; +import com.vaadin.data.BinderValidationStatus; +import com.vaadin.data.Converter; +import com.vaadin.data.Result; +import com.vaadin.data.ValidationException; +import com.vaadin.data.converter.StringToIntegerConverter; +import com.vaadin.data.validator.StringLengthValidator; +import com.vaadin.ui.CheckBox; +import com.vaadin.ui.ComboBox; +import com.vaadin.ui.CustomLayout; +import com.vaadin.ui.Label; +import com.vaadin.ui.TextField; + +import de.symeda.sormas.api.Disease; +import de.symeda.sormas.api.FacadeProvider; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldCustomProperties; +import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldType; +import de.symeda.sormas.api.customizablefield.CustomizableFieldVisibilityRestrictions; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.Strings; +import de.symeda.sormas.api.i18n.Validations; +import de.symeda.sormas.ui.utils.CssStyles; +import de.symeda.sormas.ui.utils.components.CheckboxSet; + +/** + * Edit form for creating and editing customizable field metadata. + */ +@SuppressWarnings({ + "java:S110", // suppress sonar too many parents warning + "java:S2160" // suppress missing equals +}) +public class CustomizableFieldEditForm extends CustomLayout { + + private static final long serialVersionUID = 1L; + + private static final String DISEASES_LOC = "diseasesLoc"; + private static final String BASICS_HEADING_LOC = "basicsHeadingLoc"; + private static final String PLACEMENT_HEADING_LOC = "placementHeadingLoc"; + private static final String BEHAVIOR_HEADING_LOC = "behaviorHeadingLoc"; + private static final String VISIBILITY_HEADING_LOC = "visibilityHeadingLoc"; + private static final String OPTIONS_HEADING_LOC = "optionsHeadingLoc"; + private static final String OPTIONS_LOC = "optionsLoc"; + private static final String TRANSLATIONS_HEADING_LOC = "translationsHeadingLoc"; + private static final String TRANSLATIONS_LOC = "translationsLoc"; + + private static final Set OPTIONS_TYPES = + EnumSet.of(CustomizableFieldType.COMBOBOX, CustomizableFieldType.CHECKBOX_LIST, CustomizableFieldType.RADIO_BUTTON_LIST); + + //@formatter:off + private static final String HTML_LAYOUT = + loc(BASICS_HEADING_LOC) + + fluidRowLocs(CustomizableFieldMetadataDto.NAME, CustomizableFieldMetadataDto.FIELD_TYPE) + + fluidRowLocs(CustomizableFieldMetadataDto.DESCRIPTION) + + fluidRowLocs(CustomizableFieldMetadataDto.DEFAULT_VALUE) + + loc(OPTIONS_HEADING_LOC) + + fluidRowLocs(OPTIONS_LOC) + + loc(PLACEMENT_HEADING_LOC) + + fluidRowLocs(CustomizableFieldMetadataDto.CONTEXT_CLASS, CustomizableFieldMetadataDto.UI_GROUP) + + fluidRowLocs(CustomizableFieldMetadataDto.UI_LINE_POSITION, CustomizableFieldMetadataDto.UI_LINE_WEIGHT) + + loc(BEHAVIOR_HEADING_LOC) + + fluidRowLocs(CustomizableFieldMetadataDto.ACTIVE, CustomizableFieldMetadataDto.READ_ONLY) + + fluidRowLocs(CustomizableFieldMetadataDto.MANDATORY) + + loc(VISIBILITY_HEADING_LOC) + + fluidRowLocs(DISEASES_LOC) + + loc(TRANSLATIONS_HEADING_LOC) + + fluidRowLocs(TRANSLATIONS_LOC); + //@formatter:on + + private final Binder binder = new Binder<>(CustomizableFieldMetadataDto.class); + private final ComboBox groupCombo; + private final CheckboxSet cbsDiseases; + private final TextField defaultValueField; + private final ComboBox defaultValueCombo; + private final CustomizableFieldOptionsComponent optionsComponent; + private final Label optionsHeading; + private final CustomizableFieldTranslationsComponent translationsComponent; + + private CustomizableFieldMetadataDto dto; + + public CustomizableFieldEditForm(boolean isEdit) { + + setTemplateContents(HTML_LAYOUT); + setWidth(840, Unit.PIXELS); + + // Section headings + Label basicsHeading = new Label(I18nProperties.getString(Strings.headingCustomizableFieldBasics)); + basicsHeading.addStyleName(H3); + addComponent(basicsHeading, BASICS_HEADING_LOC); + + Label placementHeading = new Label(I18nProperties.getString(Strings.headingCustomizableFieldPlacement)); + placementHeading.addStyleName(H3); + addComponent(placementHeading, PLACEMENT_HEADING_LOC); + + Label behaviorHeading = new Label(I18nProperties.getString(Strings.headingCustomizableFieldBehavior)); + behaviorHeading.addStyleName(H3); + addComponent(behaviorHeading, BEHAVIOR_HEADING_LOC); + + // --- BASICS --- + + TextField nameField = new TextField(caption(CustomizableFieldMetadataDto.NAME)); + nameField.setWidth(100, Unit.PERCENTAGE); + nameField.setRequiredIndicatorVisible(true); + if (isEdit) { + nameField.setPlaceholder(caption(CustomizableFieldMetadataDto.NAME)); + } + addComponent(nameField, CustomizableFieldMetadataDto.NAME); + binder.forField(nameField) + .asRequired(I18nProperties.getValidationError(Validations.required, caption(CustomizableFieldMetadataDto.NAME))) + .withValidator(new StringLengthValidator(I18nProperties.getValidationError(Validations.textTooLong, 512), null, 512)) + .bind(CustomizableFieldMetadataDto::getName, CustomizableFieldMetadataDto::setName); + + ComboBox fieldTypeCombo = new ComboBox<>(caption(CustomizableFieldMetadataDto.FIELD_TYPE)); + fieldTypeCombo.setWidth(100, Unit.PERCENTAGE); + fieldTypeCombo.setEmptySelectionAllowed(false); + fieldTypeCombo.setItems(CustomizableFieldType.values()); + fieldTypeCombo.setItemCaptionGenerator(I18nProperties::getEnumCaption); + fieldTypeCombo.setRequiredIndicatorVisible(true); + if (isEdit) { + fieldTypeCombo.setEnabled(false); + } + addComponent(fieldTypeCombo, CustomizableFieldMetadataDto.FIELD_TYPE); + binder.forField(fieldTypeCombo) + .asRequired(I18nProperties.getValidationError(Validations.required, caption(CustomizableFieldMetadataDto.FIELD_TYPE))) + .bind(CustomizableFieldMetadataDto::getFieldType, CustomizableFieldMetadataDto::setFieldType); + fieldTypeCombo.addValueChangeListener(e -> updateOptionsVisibility(e.getValue())); + + TextField descriptionField = new TextField(caption(CustomizableFieldMetadataDto.DESCRIPTION)); + descriptionField.setWidth(100, Unit.PERCENTAGE); + addComponent(descriptionField, CustomizableFieldMetadataDto.DESCRIPTION); + binder.forField(descriptionField) + .withNullRepresentation("") + .bind(CustomizableFieldMetadataDto::getDescription, CustomizableFieldMetadataDto::setDescription); + + String defaultValueCaption = caption(CustomizableFieldMetadataDto.DEFAULT_VALUE); + defaultValueField = new TextField(defaultValueCaption); + defaultValueField.setWidth(100, Unit.PERCENTAGE); + addComponent(defaultValueField, CustomizableFieldMetadataDto.DEFAULT_VALUE); + binder.forField(defaultValueField) + .withNullRepresentation("") + .bind(CustomizableFieldMetadataDto::getDefaultValue, CustomizableFieldMetadataDto::setDefaultValue); + + defaultValueCombo = new ComboBox<>(defaultValueCaption); + defaultValueCombo.setWidth(100, Unit.PERCENTAGE); + defaultValueCombo.setEmptySelectionAllowed(true); + + optionsHeading = new Label(I18nProperties.getString(Strings.labelCustomizableFieldOptions)); + optionsHeading.addStyleName(CssStyles.VAADIN_CAPTION); + optionsHeading.setVisible(false); + addComponent(optionsHeading, OPTIONS_HEADING_LOC); + + optionsComponent = new CustomizableFieldOptionsComponent(); + optionsComponent.setVisible(false); + addComponent(optionsComponent, OPTIONS_LOC); + optionsComponent.addOptionsChangeListener(() -> { + List current = optionsComponent.getValue(); + String selected = defaultValueCombo.getValue(); + defaultValueCombo.setItems(current != null ? current : Collections.emptyList()); + defaultValueCombo.setValue(current != null && current.contains(selected) ? selected : null); + }); + + // --- PLACEMENT --- + + ComboBox contextCombo = new ComboBox<>(caption(CustomizableFieldMetadataDto.CONTEXT_CLASS)); + contextCombo.setWidth(100, Unit.PERCENTAGE); + contextCombo.setEmptySelectionAllowed(false); + contextCombo.setItems(CustomizableFieldContext.values()); + contextCombo.setItemCaptionGenerator(I18nProperties::getEnumCaption); + contextCombo.setRequiredIndicatorVisible(true); + addComponent(contextCombo, CustomizableFieldMetadataDto.CONTEXT_CLASS); + binder.forField(contextCombo) + .asRequired(I18nProperties.getValidationError(Validations.required, caption(CustomizableFieldMetadataDto.CONTEXT_CLASS))) + .bind(CustomizableFieldMetadataDto::getContextClass, CustomizableFieldMetadataDto::setContextClass); + + groupCombo = new ComboBox<>(caption(CustomizableFieldMetadataDto.UI_GROUP)); + groupCombo.setWidth(100, Unit.PERCENTAGE); + groupCombo.setEmptySelectionAllowed(true); + groupCombo.setItemCaptionGenerator(I18nProperties::getEnumCaption); + addComponent(groupCombo, CustomizableFieldMetadataDto.UI_GROUP); + binder.forField(groupCombo).bind(CustomizableFieldMetadataDto::getUiGroup, CustomizableFieldMetadataDto::setUiGroup); + + // Repopulate group items when context changes; clear value first, then retain only if still valid + contextCombo.addValueChangeListener(e -> { + CustomizableFieldContext ctx = e.getValue(); + CustomizableFieldGroup current = groupCombo.getValue(); + groupCombo.setValue(null); + if (ctx != null) { + groupCombo.setItems(CustomizableFieldGroup.getGroupsForContext(ctx)); + if (current != null && current.getContext() == ctx) { + groupCombo.setValue(current); + } + } else { + groupCombo.setItems(); + } + }); + + String posCaption = caption(CustomizableFieldMetadataDto.UI_LINE_POSITION); + TextField positionField = new TextField(posCaption); + positionField.setWidth(100, Unit.PERCENTAGE); + addComponent(positionField, CustomizableFieldMetadataDto.UI_LINE_POSITION); + binder.forField(positionField) + .withNullRepresentation("") + .withConverter(new StringToIntegerConverter(I18nProperties.getValidationError(Validations.onlyIntegerNumbersAllowed, posCaption))) + .bind(CustomizableFieldMetadataDto::getUiLinePosition, CustomizableFieldMetadataDto::setUiLinePosition); + + String weightCaption = caption(CustomizableFieldMetadataDto.UI_LINE_WEIGHT); + TextField weightField = new TextField(weightCaption); + weightField.setWidth(100, Unit.PERCENTAGE); + addComponent(weightField, CustomizableFieldMetadataDto.UI_LINE_WEIGHT); + binder.forField(weightField) + .withNullRepresentation("") + .withConverter(stringToFloatConverter(I18nProperties.getValidationError(Validations.onlyNumbersAllowed, weightCaption))) + .bind(CustomizableFieldMetadataDto::getUiLineWeight, CustomizableFieldMetadataDto::setUiLineWeight); + + // --- BEHAVIOR --- + + CheckBox activeBox = new CheckBox(caption(CustomizableFieldMetadataDto.ACTIVE)); + addComponent(activeBox, CustomizableFieldMetadataDto.ACTIVE); + binder.forField(activeBox).bind(d -> d.isActive(), (d, v) -> d.setActive(Boolean.TRUE.equals(v))); + + CheckBox readOnlyBox = new CheckBox(caption(CustomizableFieldMetadataDto.READ_ONLY)); + addComponent(readOnlyBox, CustomizableFieldMetadataDto.READ_ONLY); + binder.forField(readOnlyBox).bind(d -> d.isReadOnly(), (d, v) -> d.setReadOnly(Boolean.TRUE.equals(v))); + + CheckBox mandatoryBox = new CheckBox(caption(CustomizableFieldMetadataDto.MANDATORY)); + addComponent(mandatoryBox, CustomizableFieldMetadataDto.MANDATORY); + binder.forField(mandatoryBox).bind(d -> d.isMandatory(), (d, v) -> d.setMandatory(Boolean.TRUE.equals(v))); + + // --- VISIBILITY --- + + Label visibilityHeading = new Label(I18nProperties.getString(Strings.headingCustomizableFieldVisibility)); + visibilityHeading.addStyleName(H3); + addComponent(visibilityHeading, VISIBILITY_HEADING_LOC); + + cbsDiseases = new CheckboxSet<>(); + cbsDiseases.setCaption( + I18nProperties + .getPrefixCaption(CustomizableFieldMetadataDto.I18N_PREFIX, CustomizableFieldMetadataDto.VISIBILITY_RESTRICTIONS + "Diseases")); + cbsDiseases.setItems(FacadeProvider.getDiseaseConfigurationFacade().getAllDiseases(true, true, true), null, null); + cbsDiseases.setColumnCount(3); + addComponent(cbsDiseases, DISEASES_LOC); + + // --- TRANSLATIONS --- + + Label translationsHeading = new Label(I18nProperties.getString(Strings.headingCustomizableFieldTranslations)); + translationsHeading.addStyleName(H3); + addComponent(translationsHeading, TRANSLATIONS_HEADING_LOC); + + translationsComponent = new CustomizableFieldTranslationsComponent(); + addComponent(translationsComponent, TRANSLATIONS_LOC); + } + + private void updateOptionsVisibility(CustomizableFieldType type) { + boolean visible = OPTIONS_TYPES.contains(type); + optionsHeading.setVisible(visible); + optionsComponent.setVisible(visible); + if (visible) { + addComponent(defaultValueCombo, CustomizableFieldMetadataDto.DEFAULT_VALUE); + } else { + optionsComponent.setValue(null); + defaultValueCombo.clear(); + addComponent(defaultValueField, CustomizableFieldMetadataDto.DEFAULT_VALUE); + } + } + + @SuppressWarnings("java:S3776") + public void setValue(CustomizableFieldMetadataDto newDto) { + + this.dto = newDto; + + // Pre-populate group items before readBean so the binder can find the bound value + CustomizableFieldContext ctx = newDto != null ? newDto.getContextClass() : null; + if (ctx != null) { + groupCombo.setItems(CustomizableFieldGroup.getGroupsForContext(ctx)); + } else { + groupCombo.setItems(); + } + + binder.readBean(newDto); + + Set diseases = + newDto != null && newDto.getVisibilityRestrictions() != null && newDto.getVisibilityRestrictions().getDiseases() != null + ? new HashSet<>(newDto.getVisibilityRestrictions().getDiseases()) + : null; + cbsDiseases.setValue(diseases); + + CustomizableFieldType type = newDto != null ? newDto.getFieldType() : null; + updateOptionsVisibility(type); + if (newDto != null && OPTIONS_TYPES.contains(type)) { + List options = newDto.getCustomProperties() != null ? newDto.getCustomProperties().getOptions() : null; + optionsComponent.setValue(options); + defaultValueCombo.setItems(options != null ? options : Collections.emptyList()); + defaultValueCombo.setValue(options != null && options.contains(newDto.getDefaultValue()) ? newDto.getDefaultValue() : null); + } + + translationsComponent.setValue(newDto != null ? newDto.getTranslations() : null); + } + + /** + * Writes the binder values to the DTO and returns it. + * Returns {@code null} if binder validation fails (errors are shown inline on the fields). + */ + @SuppressWarnings("java:S3776") + public CustomizableFieldMetadataDto getValue() { + + if (dto == null) { + return null; + } + try { + binder.writeBean(dto); + } catch (ValidationException e) { + return null; + } + // Options, diseases and translations are outside the binder — write manually + if (OPTIONS_TYPES.contains(dto.getFieldType())) { + dto.setDefaultValue(defaultValueCombo.getValue()); + List options = optionsComponent.getValue(); + CustomizableFieldCustomProperties props = dto.getCustomProperties(); + if (options != null && !options.isEmpty()) { + if (props == null) { + props = new CustomizableFieldCustomProperties(); + dto.setCustomProperties(props); + } + props.setOptions(options); + } else if (props != null) { + props.setOptions(null); + } + } + dto.setTranslations(translationsComponent.getValue()); + + Set selectedDiseases = cbsDiseases.getValue(); + if (selectedDiseases != null && !selectedDiseases.isEmpty()) { + CustomizableFieldVisibilityRestrictions restrictions = dto.getVisibilityRestrictions(); + if (restrictions == null) { + restrictions = new CustomizableFieldVisibilityRestrictions(); + } + restrictions.setDiseases(new ArrayList<>(selectedDiseases)); + dto.setVisibilityRestrictions(restrictions); + } else { + if (dto.getVisibilityRestrictions() != null) { + dto.getVisibilityRestrictions().setDiseases(null); + } + } + return dto; + } + + /** Returns the binder validation status without writing to the DTO. */ + public BinderValidationStatus validate() { + return binder.validate(); + } + + private String caption(String propertyId) { + return I18nProperties.getPrefixCaption(CustomizableFieldMetadataDto.I18N_PREFIX, propertyId); + } + + private static Converter stringToFloatConverter(String errorMessage) { + return new Converter() { + + @Override + public Result convertToModel(String value, com.vaadin.data.ValueContext context) { + if (value == null || value.trim().isEmpty()) { + return Result.ok(null); + } + try { + return Result.ok(Float.parseFloat(value.trim())); + } catch (NumberFormatException e) { + return Result.error(errorMessage); + } + } + + @Override + public String convertToPresentation(Float value, com.vaadin.data.ValueContext context) { + return value == null ? "" : value.toString(); + } + }; + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldOptionsComponent.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldOptionsComponent.java new file mode 100644 index 00000000000..1546964e66a --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldOptionsComponent.java @@ -0,0 +1,195 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.configuration.customizablefield; + +import java.util.ArrayList; +import java.util.List; + +import com.vaadin.icons.VaadinIcons; +import com.vaadin.shared.ui.MarginInfo; +import com.vaadin.ui.Button; +import com.vaadin.ui.HorizontalLayout; +import com.vaadin.ui.Label; +import com.vaadin.ui.TextField; +import com.vaadin.ui.VerticalLayout; + +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.Strings; +import de.symeda.sormas.ui.utils.ButtonHelper; +import de.symeda.sormas.ui.utils.CssStyles; + +/** + * Vaadin 8 component for editing the selectable options of list-type customizable fields + * ({@code COMBOBOX}, {@code CHECKBOX_LIST}, {@code RADIO_BUTTON_LIST}). + *

+ * Renders one row per option: [option value text field] [🗑] + * Reads/writes {@code List}. + */ +@SuppressWarnings({ + "java:S110", // suppress sonar too many parents warning + "java:S2160" // suppress missing equals +}) +public class CustomizableFieldOptionsComponent extends VerticalLayout { + + private static final long serialVersionUID = 1L; + + private final VerticalLayout rowsLayout; + private final List rows = new ArrayList<>(); + private final List changeListeners = new ArrayList<>(); + private final Label lblNoOptions; + + public CustomizableFieldOptionsComponent() { + + setWidthFull(); + setMargin(new MarginInfo(false, false, true, false)); + setSpacing(false); + CssStyles.style(this, CssStyles.VSPACE_TOP_4); + + lblNoOptions = new Label(I18nProperties.getString(Strings.infoNoCustomizableFieldOptions)); + addComponent(lblNoOptions); + + rowsLayout = new VerticalLayout(); + rowsLayout.setWidthFull(); + rowsLayout.setMargin(false); + rowsLayout.setSpacing(false); + addComponent(rowsLayout); + + Button btnAdd = ButtonHelper.createIconButtonWithCaption(null, null, VaadinIcons.PLUS, e -> addRow(null, true), CssStyles.VSPACE_TOP_5); + btnAdd.setHeight(25, Unit.PIXELS); + btnAdd.setWidthFull(); + addComponent(btnAdd); + } + + public void setValue(List options) { + + rows.clear(); + rowsLayout.removeAllComponents(); + + if (options != null) { + options.forEach(option -> addRow(option, false)); + rows.forEach(rowsLayout::addComponent); + } + + updateNoOptionsLabelVisibility(); + } + + /** + * Returns the current list of options, or {@code null} if there are none. + * Blank entries are skipped. + */ + @SuppressWarnings("java:S1168") + public List getValue() { + + if (rows.isEmpty()) { + return null; + } + List result = new ArrayList<>(); + for (OptionRow row : rows) { + String value = row.getValue(); + if (value != null && !value.trim().isEmpty()) { + result.add(value.trim()); + } + } + return result.isEmpty() ? null : result; + } + + /** + * Registers a listener that is called whenever the options list changes + * (row added, deleted, or option text edited). + */ + public void addOptionsChangeListener(Runnable listener) { + changeListeners.add(listener); + } + + private void fireChangeListeners() { + changeListeners.forEach(Runnable::run); + } + + private void addRow(String value, boolean render) { + + OptionRow row = new OptionRow(value); + row.setDeleteCallback(() -> { + rows.remove(row); + rowsLayout.removeComponent(row); + updateNoOptionsLabelVisibility(); + fireChangeListeners(); + }); + row.setChangeCallback(this::fireChangeListeners); + rows.add(row); + updateNoOptionsLabelVisibility(); + if (render) { + rowsLayout.addComponent(row); + fireChangeListeners(); + } + } + + private void updateNoOptionsLabelVisibility() { + + if (lblNoOptions != null) { + lblNoOptions.setVisible(rows.isEmpty()); + } + } + + @SuppressWarnings({ + "java:S110", // suppress sonar too many parents warning + "java:S2160" // suppress missing equals + }) + private static final class OptionRow extends HorizontalLayout { + + private static final long serialVersionUID = 1L; + + private final TextField tfValue; + private transient Runnable deleteCallback; + private transient Runnable changeCallback; + + public OptionRow(String value) { + + tfValue = new TextField(); + tfValue.setWidthFull(); + tfValue.setPlaceholder(I18nProperties.getString(Strings.promptCustomizableFieldOption)); + if (value != null) { + tfValue.setValue(value); + } + tfValue.addValueChangeListener(e -> { + if (changeCallback != null) { + changeCallback.run(); + } + }); + + CssStyles.style(CssStyles.VSPACE_NONE, tfValue); + + Button btnDelete = ButtonHelper.createIconButtonWithCaption(null, null, VaadinIcons.TRASH, e -> deleteCallback.run()); + + addComponent(tfValue); + addComponent(btnDelete); + setExpandRatio(tfValue, 1); + setWidthFull(); + setMargin(false); + } + + public String getValue() { + return tfValue.getValue(); + } + + public void setDeleteCallback(Runnable callback) { + this.deleteCallback = callback; + } + + public void setChangeCallback(Runnable callback) { + this.changeCallback = callback; + } + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldTranslationsComponent.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldTranslationsComponent.java new file mode 100644 index 00000000000..17f7070a0a7 --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldTranslationsComponent.java @@ -0,0 +1,228 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.configuration.customizablefield; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.vaadin.icons.VaadinIcons; +import com.vaadin.shared.ui.MarginInfo; +import com.vaadin.ui.Button; +import com.vaadin.ui.ComboBox; +import com.vaadin.ui.HorizontalLayout; +import com.vaadin.ui.Label; +import com.vaadin.ui.TextField; +import com.vaadin.ui.VerticalLayout; + +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.Strings; +import de.symeda.sormas.ui.utils.ButtonHelper; +import de.symeda.sormas.ui.utils.CssStyles; + +/** + * Vaadin 8 component for editing per-language translations of a customizable field's name and description. + * Renders one row per language: [Language ▾] [Name] [Description] [🗑] + * Reads/writes {@code Map>}. + */ +@SuppressWarnings({ + "java:S110", // suppress sonar too many parents warning + "java:S2160" // suppress sonar missing equals +}) +public class CustomizableFieldTranslationsComponent extends VerticalLayout { + + private static final long serialVersionUID = 1L; + + private final VerticalLayout rowsLayout; + private final List rows = new ArrayList<>(); + private final Label lblNoTranslations; + + public CustomizableFieldTranslationsComponent() { + + setWidthFull(); + setMargin(new MarginInfo(false, false, true, false)); + setSpacing(false); + CssStyles.style(this, CssStyles.VSPACE_TOP_4); + + lblNoTranslations = new Label(I18nProperties.getString(Strings.infoNoCustomizableFieldTranslations)); + addComponent(lblNoTranslations); + + rowsLayout = new VerticalLayout(); + rowsLayout.setWidthFull(); + rowsLayout.setMargin(false); + rowsLayout.setSpacing(false); + addComponent(rowsLayout); + + Button btnAdd = + ButtonHelper.createIconButtonWithCaption(null, null, VaadinIcons.PLUS, e -> addRow(null, null, null, true), CssStyles.VSPACE_TOP_5); + btnAdd.setHeight(25, Unit.PIXELS); + btnAdd.setWidthFull(); + addComponent(btnAdd); + } + + public void setValue(Map> translations) { + + rows.clear(); + rowsLayout.removeAllComponents(); + + if (translations != null) { + translations.forEach((localeStr, translationMap) -> { + Language lang = Language.fromLocaleString(localeStr); + String name = translationMap != null ? translationMap.get(CustomizableFieldMetadataDto.NAME) : null; + String description = translationMap != null ? translationMap.get(CustomizableFieldMetadataDto.DESCRIPTION) : null; + addRow(lang, name, description, false); + }); + rows.forEach(rowsLayout::addComponent); + } + + updateNoTranslationsLabelVisibility(); + } + + /** + * Returns the current translations, or {@code null} if there are none. + * Rows with no language selected or no values filled in are skipped. + */ + @SuppressWarnings("java:S1168") // suppress "return empty map instead of null" warning + public Map> getValue() { + + if (rows.isEmpty()) { + // we could return a empty map but it might be interpreted as no translaton and postgres might create a '{}' jsonb entry + return null; + } + Map> result = new HashMap<>(); + for (TranslationRow row : rows) { + Language lang = row.getLanguage(); + if (lang == null) { + continue; + } + Map entry = new HashMap<>(); + String name = row.getName(); + String description = row.getDescription(); + if (name != null && !name.trim().isEmpty()) { + entry.put(CustomizableFieldMetadataDto.NAME, name.trim()); + } + if (description != null && !description.trim().isEmpty()) { + entry.put(CustomizableFieldMetadataDto.DESCRIPTION, description.trim()); + } + if (!entry.isEmpty()) { + result.put(lang.getLocale().toString(), entry); + } + } + return result.isEmpty() ? null : result; + } + + /** Returns {@code true} if any two rows share the same language. */ + public boolean hasDuplicateLanguages() { + + Set seen = new HashSet<>(); + return rows.stream().map(TranslationRow::getLanguage).filter(l -> l != null).anyMatch(l -> !seen.add(l)); + } + + private void addRow(Language language, String name, String description, boolean render) { + + TranslationRow row = new TranslationRow(language, name, description); + row.setDeleteCallback(() -> { + rows.remove(row); + rowsLayout.removeComponent(row); + updateNoTranslationsLabelVisibility(); + }); + rows.add(row); + updateNoTranslationsLabelVisibility(); + if (render) { + rowsLayout.addComponent(row); + } + } + + private void updateNoTranslationsLabelVisibility() { + + if (lblNoTranslations != null) { + lblNoTranslations.setVisible(rows.isEmpty()); + } + } + + private static final class TranslationRow extends HorizontalLayout { + + private static final long serialVersionUID = 1L; + + private final ComboBox cbLanguage; + private final TextField tfName; + private final TextField tfDescription; + private transient Runnable deleteCallback; + + public TranslationRow(Language language, String name, String description) { + + cbLanguage = new ComboBox<>(); + cbLanguage.setItems(Language.values()); + cbLanguage.setItemCaptionGenerator(Language::toString); + cbLanguage.setWidth(220, Unit.PIXELS); + cbLanguage.setPlaceholder(I18nProperties.getString(Strings.promptCustomizableEnumTranslationLanguage)); + if (language != null) { + cbLanguage.setValue(language); + } + + tfName = new TextField(); + tfName.setWidthFull(); + tfName.setPlaceholder(I18nProperties.getPrefixCaption(CustomizableFieldMetadataDto.I18N_PREFIX, CustomizableFieldMetadataDto.NAME)); + if (name != null) { + tfName.setValue(name); + } + + tfDescription = new TextField(); + tfDescription.setWidthFull(); + tfDescription + .setPlaceholder(I18nProperties.getPrefixCaption(CustomizableFieldMetadataDto.I18N_PREFIX, CustomizableFieldMetadataDto.DESCRIPTION)); + if (description != null) { + tfDescription.setValue(description); + } + + CssStyles.style(CssStyles.VSPACE_NONE, cbLanguage, tfName, tfDescription); + + Button btnDelete = ButtonHelper.createIconButtonWithCaption(null, null, VaadinIcons.TRASH, e -> deleteCallback.run()); + + addComponent(cbLanguage); + addComponent(tfName); + addComponent(tfDescription); + addComponent(btnDelete); + setExpandRatio(tfName, 1); + setExpandRatio(tfDescription, 1); + setWidthFull(); + setMargin(false); + CssStyles.style(this, CssStyles.VSPACE_4); + } + + public Language getLanguage() { + return cbLanguage.getValue(); + } + + public String getName() { + return tfName.getValue(); + } + + @Override + public String getDescription() { + return tfDescription.getValue(); + } + + public void setDeleteCallback(Runnable deleteCallback) { + this.deleteCallback = deleteCallback; + } + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldsController.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldsController.java new file mode 100644 index 00000000000..e676a8c8801 --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldsController.java @@ -0,0 +1,148 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.configuration.customizablefield; + +import com.vaadin.ui.Notification; +import com.vaadin.ui.TextField; +import com.vaadin.ui.VerticalLayout; + +import de.symeda.sormas.api.FacadeProvider; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.i18n.Captions; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.Strings; +import de.symeda.sormas.ui.SormasUI; +import de.symeda.sormas.ui.utils.CommitDiscardWrapperComponent; +import de.symeda.sormas.ui.utils.DeletableUtils; +import de.symeda.sormas.ui.utils.VaadinUiUtil; + +/** + * Controller for customizable field metadata CRUD operations. + */ +public class CustomizableFieldsController { + + /** + * Open the create dialog for a new customizable field. + */ + public void createField() { + + CustomizableFieldMetadataDto dto = new CustomizableFieldMetadataDto(); + dto.setActive(true); + + CustomizableFieldEditForm form = new CustomizableFieldEditForm(false); + form.setValue(dto); + + final CommitDiscardWrapperComponent cdw = new CommitDiscardWrapperComponent<>(form); + + // Validate via Vaadin 8 Binder before allowing commit to proceed + cdw.setPreCommitListener(successCallback -> { + if (form.validate().isOk()) { + successCallback.run(); + } + }); + + cdw.addCommitListener(() -> { + FacadeProvider.getCustomizableFieldMetadataFacade().save(form.getValue()); + Notification.show(I18nProperties.getString(Strings.messageCustomizableFieldCreated), Notification.Type.ASSISTIVE_NOTIFICATION); + SormasUI.get().getNavigator().navigateTo(CustomizableFieldsView.VIEW_NAME); + }); + + VaadinUiUtil.showModalPopupWindow(cdw, I18nProperties.getString(Strings.headingCreateCustomizableField)); + } + + /** + * Open the edit dialog for an existing customizable field. + */ + public void editField(String uuid) { + + CustomizableFieldMetadataDto dto = FacadeProvider.getCustomizableFieldMetadataFacade().getByUuid(uuid); + + CustomizableFieldEditForm form = new CustomizableFieldEditForm(true); + form.setValue(dto); + + final CommitDiscardWrapperComponent cdw = new CommitDiscardWrapperComponent<>(form); + + // Validate via Vaadin 8 Binder before allowing commit to proceed + cdw.setPreCommitListener(successCallback -> { + if (form.validate().isOk()) { + successCallback.run(); + } + }); + + cdw.addCommitListener(() -> { + FacadeProvider.getCustomizableFieldMetadataFacade().save(form.getValue()); + Notification.show(I18nProperties.getString(Strings.messageCustomizableFieldSaved), Notification.Type.ASSISTIVE_NOTIFICATION); + SormasUI.get().getNavigator().navigateTo(CustomizableFieldsView.VIEW_NAME); + }); + + VaadinUiUtil.showModalPopupWindow(cdw, I18nProperties.getString(Strings.headingEditCustomizableField) + " - " + dto.getName()); + } + + /** + * Show a dialog to clone the given field with a new name. + */ + public void cloneField(CustomizableFieldMetadataDto sourceDto) { + + TextField nameField = + new TextField(I18nProperties.getPrefixCaption(CustomizableFieldMetadataDto.I18N_PREFIX, CustomizableFieldMetadataDto.NAME)); + nameField.setWidth(280, com.vaadin.server.Sizeable.Unit.PIXELS); + nameField.setRequiredIndicatorVisible(true); + + VerticalLayout content = new VerticalLayout(); + content.setMargin(true); + content.setSpacing(true); + content.addComponent(nameField); + + VaadinUiUtil.showConfirmationPopup( + I18nProperties.getString(Strings.headingCloneCustomizableField), + content, + I18nProperties.getCaption(Captions.actionClone), + I18nProperties.getCaption(Captions.actionCancel), + result -> { + if (Boolean.TRUE.equals(result)) { + String newName = nameField.getValue(); + if (newName == null || newName.trim().isEmpty()) { + Notification.show(I18nProperties.getCaption(Captions.actionClone) + " - name required", Notification.Type.WARNING_MESSAGE); + return; + } + FacadeProvider.getCustomizableFieldMetadataFacade().cloneField(sourceDto.getUuid(), newName.trim()); + Notification.show(I18nProperties.getString(Strings.messageCustomizableFieldCloned), Notification.Type.ASSISTIVE_NOTIFICATION); + SormasUI.get().getNavigator().navigateTo(CustomizableFieldsView.VIEW_NAME); + } + }); + } + + /** + * Toggle the active state of a customizable field. + */ + public void toggleActive(CustomizableFieldMetadataDto dto) { + + FacadeProvider.getCustomizableFieldMetadataFacade().setFieldActive(dto.getUuid(), !dto.isActive()); + SormasUI.get().getNavigator().navigateTo(CustomizableFieldsView.VIEW_NAME); + } + + /** + * Show a delete confirmation and delete if confirmed. + */ + public void deleteField(String uuid, CustomizableFieldsGrid grid) { + + DeletableUtils.showDeleteWithReasonPopup(I18nProperties.getString(Strings.promptConfirmDeleteCustomizableField), deletionDetails -> { + FacadeProvider.getCustomizableFieldMetadataFacade().delete(uuid, deletionDetails); + Notification.show(I18nProperties.getString(Strings.messageCustomizableFieldDeleted), Notification.Type.ASSISTIVE_NOTIFICATION); + grid.reload(); + }); + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldsGrid.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldsGrid.java new file mode 100644 index 00000000000..279ac3bd7b8 --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldsGrid.java @@ -0,0 +1,94 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.configuration.customizablefield; + +import com.vaadin.icons.VaadinIcons; +import com.vaadin.ui.renderers.HtmlRenderer; + +import de.symeda.sormas.api.FacadeProvider; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataCriteria; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.i18n.Captions; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.ui.ControllerProvider; +import de.symeda.sormas.ui.utils.FilteredGrid; +import de.symeda.sormas.ui.utils.ShowDetailsListener; + +/** + * Grid for displaying and managing customizable field metadata. + */ +@SuppressWarnings({ + "java:S110", // suppress sonar too many parents warning + "java:S2160" // suppress missing equals +}) +public class CustomizableFieldsGrid extends FilteredGrid { + + private static final long serialVersionUID = 1L; + + private static final String CLONE_COLUMN_ID = "clone"; + private static final String TOGGLE_ACTIVE_COLUMN_ID = "toggleActive"; + private static final String DELETE_COLUMN_ID = "delete"; + + public CustomizableFieldsGrid(CustomizableFieldMetadataCriteria criteria) { + + super(CustomizableFieldMetadataDto.class); + setSizeFull(); + + setLazyDataProvider( + FacadeProvider.getCustomizableFieldMetadataFacade()::getIndexList, + FacadeProvider.getCustomizableFieldMetadataFacade()::count); + setCriteria(criteria); + + setColumns( + CustomizableFieldMetadataDto.NAME, + CustomizableFieldMetadataDto.CONTEXT_CLASS, + CustomizableFieldMetadataDto.UI_GROUP, + CustomizableFieldMetadataDto.FIELD_TYPE, + CustomizableFieldMetadataDto.ACTIVE, + CustomizableFieldMetadataDto.READ_ONLY); + + addEditColumn(e -> ControllerProvider.getCustomizableFieldsController().editField(e.getUuid())); + + addColumn(e -> VaadinIcons.COPY.getHtml(), new HtmlRenderer()).setId(CLONE_COLUMN_ID) + .setCaption(I18nProperties.getCaption(Captions.actionClone)) + .setSortable(false) + .setWidth(40); + + addColumn(e -> e.isActive() ? VaadinIcons.CLOSE.getHtml() : VaadinIcons.CHECK.getHtml(), new HtmlRenderer()).setId(TOGGLE_ACTIVE_COLUMN_ID) + .setCaption(I18nProperties.getCaption(Captions.actionEnable) + "/" + I18nProperties.getCaption(Captions.actionDisable)) + .setSortable(false) + .setWidth(55); + + addColumn(e -> VaadinIcons.TRASH.getHtml(), new HtmlRenderer()).setId(DELETE_COLUMN_ID) + .setCaption(I18nProperties.getCaption(Captions.actionDelete)) + .setSortable(false) + .setWidth(40); + + addItemClickListener(new ShowDetailsListener<>(CLONE_COLUMN_ID, e -> ControllerProvider.getCustomizableFieldsController().cloneField(e))); + addItemClickListener( + new ShowDetailsListener<>(TOGGLE_ACTIVE_COLUMN_ID, e -> ControllerProvider.getCustomizableFieldsController().toggleActive(e))); + addItemClickListener( + new ShowDetailsListener<>(DELETE_COLUMN_ID, e -> ControllerProvider.getCustomizableFieldsController().deleteField(e.getUuid(), this))); + + for (Column column : getColumns()) { + column.setCaption(I18nProperties.getPrefixCaption(CustomizableFieldMetadataDto.I18N_PREFIX, column.getId(), column.getCaption())); + } + } + + public void reload() { + getDataProvider().refreshAll(); + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldsView.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldsView.java new file mode 100644 index 00000000000..17fdcb76991 --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/configuration/customizablefield/CustomizableFieldsView.java @@ -0,0 +1,185 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.configuration.customizablefield; + +import java.util.Arrays; +import java.util.Objects; + +import com.vaadin.navigator.ViewChangeListener; +import com.vaadin.ui.ComboBox; +import com.vaadin.ui.HorizontalLayout; +import com.vaadin.ui.VerticalLayout; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataCriteria; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldType; +import de.symeda.sormas.api.i18n.Captions; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.Strings; +import de.symeda.sormas.api.utils.criteria.BaseCriteria; +import de.symeda.sormas.ui.ControllerProvider; +import de.symeda.sormas.ui.ViewModelProviders; +import de.symeda.sormas.ui.configuration.AbstractConfigurationView; +import de.symeda.sormas.ui.configuration.infrastructure.components.SearchField; +import de.symeda.sormas.ui.utils.ButtonHelper; +import de.symeda.sormas.ui.utils.CssStyles; + +/** + * Admin view for managing customizable field metadata. + */ +@SuppressWarnings({ + "java:S110", // suppress sonar too many parents warning + "java:S2160" // suppress sonar missing equals +}) +public class CustomizableFieldsView extends AbstractConfigurationView { + + public static final String VIEW_NAME = ROOT_VIEW_NAME + "/customizableFields"; + + private final CustomizableFieldMetadataCriteria criteria; + private final CustomizableFieldsGrid grid; + + private SearchField searchField; + private ComboBox contextFilter; + private ComboBox fieldTypeFilter; + private ComboBox activeFilter; + + public CustomizableFieldsView() { + + super(VIEW_NAME); + + criteria = + ViewModelProviders.of(CustomizableFieldsView.class).get(CustomizableFieldMetadataCriteria.class, new CustomizableFieldMetadataCriteria()); + grid = new CustomizableFieldsGrid(criteria); + + final VerticalLayout gridLayout = new VerticalLayout(); + gridLayout.addComponent(createFilterBar()); + gridLayout.addComponent(grid); + gridLayout.setMargin(true); + gridLayout.setSpacing(false); + gridLayout.setExpandRatio(grid, 1); + gridLayout.setSizeFull(); + gridLayout.setStyleName("crud-main-layout"); + + addComponent(gridLayout); + } + + private HorizontalLayout createFilterBar() { + + final HorizontalLayout filterLayout = new HorizontalLayout(); + filterLayout.setMargin(false); + filterLayout.setSpacing(true); + + searchField = new SearchField(); + searchField.setInputPrompt(I18nProperties.getString(Strings.promptCustomizableFieldSearchField)); + searchField.addTextChangeListener(e -> { + criteria.freeTextFilter(e.getText()); + grid.reload(); + }); + filterLayout.addComponent(searchField); + + contextFilter = new ComboBox<>( + I18nProperties.getPrefixCaption(CustomizableFieldMetadataDto.I18N_PREFIX, CustomizableFieldMetadataDto.CONTEXT_CLASS), + Arrays.asList(CustomizableFieldContext.values())); + contextFilter.setItemCaptionGenerator(Objects::toString); + contextFilter.addValueChangeListener(e -> { + criteria.setContextClass(e.getValue()); + grid.reload(); + }); + filterLayout.addComponent(contextFilter); + + fieldTypeFilter = new ComboBox<>( + I18nProperties.getPrefixCaption(CustomizableFieldMetadataDto.I18N_PREFIX, CustomizableFieldMetadataDto.FIELD_TYPE), + Arrays.asList(CustomizableFieldType.values())); + fieldTypeFilter.setItemCaptionGenerator(Objects::toString); + fieldTypeFilter.addValueChangeListener(e -> { + criteria.setFieldType(e.getValue()); + grid.reload(); + }); + filterLayout.addComponent(fieldTypeFilter); + + activeFilter = new ComboBox<>(I18nProperties.getPrefixCaption(CustomizableFieldMetadataDto.I18N_PREFIX, CustomizableFieldMetadataDto.ACTIVE)); + activeFilter.setItems( + I18nProperties.getCaption(Captions.customizableFieldsAllActive), + I18nProperties.getCaption(Captions.customizableFieldsActiveOnly), + I18nProperties.getCaption(Captions.customizableFieldsInactiveOnly)); + activeFilter.addValueChangeListener(e -> { + String val = e.getValue(); + if (val == null || val.equals(I18nProperties.getCaption(Captions.customizableFieldsAllActive))) { + criteria.setActive(null); + } else if (val.equals(I18nProperties.getCaption(Captions.customizableFieldsActiveOnly))) { + criteria.setActive(Boolean.TRUE); + } else { + criteria.setActive(Boolean.FALSE); + } + grid.reload(); + }); + filterLayout.addComponent(activeFilter); + + filterLayout.addComponent(ButtonHelper.createButton(Captions.actionResetFilters, event -> { + ViewModelProviders.of(CustomizableFieldsView.class).remove(CustomizableFieldMetadataCriteria.class); + navigateTo((BaseCriteria[]) null); + }, CssStyles.FORCE_CAPTION)); + + filterLayout.addComponent( + ButtonHelper.createButton( + Captions.actionCreate, + event -> ControllerProvider.getCustomizableFieldsController().createField(), + CssStyles.FORCE_CAPTION)); + + return filterLayout; + } + + @Override + public void enter(ViewChangeListener.ViewChangeEvent event) { + + super.enter(event); + String params = event.getParameters().trim(); + if (params.startsWith("?")) { + params = params.substring(1); + criteria.fromUrlParams(params); + } + updateFilterComponents(); + grid.reload(); + } + + public void updateFilterComponents() { + + applyingCriteria = true; + searchField.setValue(criteria.getFreeTextFilter()); + contextFilter.setValue(criteria.getContextClass()); + fieldTypeFilter.setValue(criteria.getFieldType()); + Boolean active = criteria.getActive(); + if (active == null) { + activeFilter.setValue(I18nProperties.getCaption(Captions.customizableFieldsAllActive)); + } else if (Boolean.TRUE.equals(active)) { + activeFilter.setValue(I18nProperties.getCaption(Captions.customizableFieldsActiveOnly)); + } else { + activeFilter.setValue(I18nProperties.getCaption(Captions.customizableFieldsInactiveOnly)); + } + applyingCriteria = false; + } + + @Override + public boolean equals(Object obj) { + return super.equals(obj); + } + + @Override + public int hashCode() { + return super.hashCode(); + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/contact/ContactController.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/contact/ContactController.java index 394fd77934c..a2ca8232157 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/contact/ContactController.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/contact/ContactController.java @@ -20,6 +20,7 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -58,6 +59,9 @@ import de.symeda.sormas.api.contact.ContactStatus; import de.symeda.sormas.api.contact.FollowUpStatus; import de.symeda.sormas.api.contact.SimilarContactDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; import de.symeda.sormas.api.deletionconfiguration.DeletionInfoDto; import de.symeda.sormas.api.docgeneneration.DocumentWorkflow; import de.symeda.sormas.api.docgeneneration.RootEntityType; @@ -159,7 +163,7 @@ private void saveContactsFromLineListing(LineListingLayout lineListingForm, Link } AdoptAddressLayout adoptAddressLayout = lineListingForm.getSharedInfoField().getCaseSelector().getAdoptAddressLayout(); - boolean adoptHomeAddress = adoptAddressLayout != null ? adoptAddressLayout.isAdoptAddress() : false; + boolean adoptHomeAddress = adoptAddressLayout != null && adoptAddressLayout.isAdoptAddress(); while (!contacts.isEmpty()) { LineDto contactLineDto = contacts.pop(); @@ -194,7 +198,7 @@ private void saveContactsFromLineListing(LineListingLayout lineListingForm, Link } public void openLineListingWindow(EventDto eventDto, Set eventParticipantIndexDtos) { - if (eventParticipantIndexDtos.size() == 0) { + if (eventParticipantIndexDtos.isEmpty()) { new Notification( I18nProperties.getString(Strings.headingNoEventParticipantsSelected), I18nProperties.getString(Strings.messageNoEventParticipantsSelected), @@ -452,7 +456,7 @@ public CommitDiscardWrapperComponent getContactCreateComponen createForm.setPerson(casePerson); } final CommitDiscardWrapperComponent createComponent = - new CommitDiscardWrapperComponent(createForm, UiUtil.permitted(UserRight.CONTACT_CREATE), createForm.getFieldGroup()); + new CommitDiscardWrapperComponent<>(createForm, UiUtil.permitted(UserRight.CONTACT_CREATE), createForm.getFieldGroup()); contactSaveTriggered = false; createComponent.addCommitListener(() -> { @@ -677,9 +681,7 @@ public void selectOrCreateContact(final ContactDto contact, final PersonReferenc } }); - contactSelect.setSelectionChangeCallback((commitAllowed) -> { - component.getCommitButton().setEnabled(commitAllowed); - }); + contactSelect.setSelectionChangeCallback(commitAllowed -> component.getCommitButton().setEnabled(commitAllowed)); VaadinUiUtil.showModalPopupWindow(component, I18nProperties.getString(Strings.headingPickOrCreateContact)); contactSelect.selectBestMatch(); @@ -707,7 +709,7 @@ public CommitDiscardWrapperComponent getContactDataEditComponen ContactDataForm editForm = new ContactDataForm(contact.getDisease(), viewMode, isPsuedonymized, contact.isInJurisdiction()); editForm.setValue(contact); final CommitDiscardWrapperComponent editComponent = - new CommitDiscardWrapperComponent(editForm, true, editForm.getFieldGroup()); + new CommitDiscardWrapperComponent<>(editForm, true, editForm.getFieldGroup()); editComponent.getButtonsPanel() .addComponentAsFirst(new DeletionLabel(automaticDeletionInfoDto, manuallyDeletionInfoDto, contact.isDeleted(), ContactDto.I18N_PREFIX)); @@ -773,7 +775,7 @@ public void showBulkContactDataEditComponent( Collection selectedContacts, String caseUuid, AbstractContactGrid contactGrid) { - if (selectedContacts.size() == 0) { + if (selectedContacts.isEmpty()) { new Notification( I18nProperties.getString(Strings.headingNoContactsSelected), I18nProperties.getString(Strings.messageNoContactsSelected), @@ -809,7 +811,7 @@ public void showBulkContactDataEditComponent( ContactFacade contactFacade = FacadeProvider.getContactFacade(); boolean classificationChange = form.getClassificationCheckBox().getValue(); - boolean contactOfficerChange = district != null ? form.getContactOfficerCheckBox().getValue() : false; + boolean contactOfficerChange = district != null && form.getContactOfficerCheckBox().getValue(); List selectedContactsCpy = new ArrayList<>(selectedContacts); @@ -853,7 +855,7 @@ private Consumer> bulkOperationCallback( } public void sendEmailsToAllSelectedItems(Collection selectedRows, AbstractContactGrid contactGrid) { - if (selectedRows.size() == 0) { + if (selectedRows.isEmpty()) { new Notification( I18nProperties.getString(Strings.headingNoContactsSelected), I18nProperties.getString(Strings.messageNoContactsSelected), @@ -1012,13 +1014,9 @@ public void openSelectCaseForContactWindow(Disease disease, Consumer component = new CommitDiscardWrapperComponent<>(selectionField); component.getCommitButton().setCaption(I18nProperties.getCaption(Captions.actionConfirm)); component.getCommitButton().setEnabled(false); - component.addCommitListener(() -> { - selectedCaseCallback.accept(selectionField.getValue()); - }); + component.addCommitListener(() -> selectedCaseCallback.accept(selectionField.getValue())); - selectionField.setSelectionChangeCallback((commitAllowed) -> { - component.getCommitButton().setEnabled(commitAllowed); - }); + selectionField.setSelectionChangeCallback(commitAllowed -> component.getCommitButton().setEnabled(commitAllowed)); VaadinUiUtil.showModalPopupWindow(component, I18nProperties.getString(Strings.headingSelectSourceCase)); } @@ -1027,17 +1025,39 @@ public CommitDiscardWrapperComponent getEpiDataComponent(final Stri ContactDto contact = FacadeProvider.getContactFacade().getByUuid(contactUuid); EpiDataDto epiData = contact.getEpiData(); - EpiDataForm epiDataForm = - new EpiDataForm(contact.getDisease(), ContactDto.class, epiData.isPseudonymized(), epiData.isInJurisdiction(), null, isEditAllowed, null); + + List epiDataMetadata = + FacadeProvider.getCustomizableFieldMetadataFacade().getActiveFieldsForContext(CustomizableFieldContext.EPIDATA); + Map epiDataFieldValues = + FacadeProvider.getCustomizableFieldValueFacade().getValuesForEntity(epiData.getUuid(), CustomizableFieldContext.EPIDATA); + + EpiDataForm epiDataForm = new EpiDataForm( + contact.getDisease(), + ContactDto.class, + epiData.isPseudonymized(), + epiData.isInJurisdiction(), + null, + isEditAllowed, + null, + epiDataMetadata, + epiDataFieldValues); epiDataForm.setValue(epiData); - final CommitDiscardWrapperComponent editView = - new CommitDiscardWrapperComponent(epiDataForm, epiDataForm.getFieldGroup()); + final CommitDiscardWrapperComponent editView = new CommitDiscardWrapperComponent<>(epiDataForm, epiDataForm.getFieldGroup()); + + epiDataForm.addCustomizableFieldValueChangeListener(e -> editView.setDirty(true)); + editView.addDiscardListener(epiDataForm::resetCustomizableFieldValues); editView.addCommitListener(() -> { ContactDto contactDto = FacadeProvider.getContactFacade().getByUuid(contactUuid); contactDto.setEpiData(epiDataForm.getValue()); FacadeProvider.getContactFacade().save(contactDto); + FacadeProvider.getCustomizableFieldValueFacade() + .saveEntityCustomFields(contactDto.getEpiData().getUuid(), CustomizableFieldContext.EPIDATA, epiDataForm.collectCurrentFieldValues()); + epiDataForm.collectExposureCustomizableFieldValues() + .forEach( + (exposureUuid, fields) -> FacadeProvider.getCustomizableFieldValueFacade() + .saveEntityCustomFields(exposureUuid, CustomizableFieldContext.EXPOSURE, fields)); Notification.show(I18nProperties.getString(Strings.messageContactSaved), Type.WARNING_MESSAGE); SormasUI.refreshView(); }); @@ -1048,7 +1068,7 @@ public CommitDiscardWrapperComponent getEpiDataComponent(final Stri public void deleteContact(ContactIndexDto contact, Runnable callback) { DeletableUtils.showDeleteWithReasonPopup( String.format(I18nProperties.getString(Strings.confirmationDeleteEntity), I18nProperties.getString(Strings.entityContact)), - (deleteDetails) -> { + deleteDetails -> { FacadeProvider.getContactFacade().delete(contact.getUuid(), deleteDetails); callback.run(); }); @@ -1118,11 +1138,11 @@ public void linkSelectedContactsToEvent(Collection se } ControllerProvider.getEventController() - .selectOrCreateEventForContactList(selectedRows.stream().map(ContactIndexDto::toReference).collect(Collectors.toList()), remaining -> { - bulkOperationCallback(null, contactGrid, null).accept( + .selectOrCreateEventForContactList( + selectedRows.stream().map(ContactIndexDto::toReference).collect(Collectors.toList()), + remaining -> bulkOperationCallback(null, contactGrid, null).accept( selectedRows.stream() .filter(s -> remaining.stream().anyMatch(r -> r.getUuid().equals(s.getUuid()))) - .collect(Collectors.toList())); - }); + .collect(Collectors.toList()))); } } diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/epidata/EpiDataForm.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/epidata/EpiDataForm.java index 6fb20a3a375..48f3ad7b828 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/epidata/EpiDataForm.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/epidata/EpiDataForm.java @@ -29,7 +29,9 @@ import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.function.Consumer; import java.util.function.Supplier; @@ -52,6 +54,10 @@ import de.symeda.sormas.api.caze.CaseDataDto; import de.symeda.sormas.api.contact.ContactDto; import de.symeda.sormas.api.contact.ContactReferenceDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldVisibilityContext; import de.symeda.sormas.api.disease.DiseaseConfigurationDto; import de.symeda.sormas.api.epidata.ClusterType; import de.symeda.sormas.api.epidata.EpiDataDto; @@ -73,8 +79,13 @@ import de.symeda.sormas.ui.utils.FieldAccessHelper; import de.symeda.sormas.ui.utils.FieldHelper; import de.symeda.sormas.ui.utils.NullableOptionGroup; +import de.symeda.sormas.ui.utils.components.CustomizableFieldsGroup; import de.symeda.sormas.ui.utils.components.MultilineLabel; +@SuppressWarnings({ + "java:S110", // suppress sonar too many parents warning + "java:S2160" // suppress missing equals not relevant for Vaadin components +}) public class EpiDataForm extends AbstractEditForm { private static final long serialVersionUID = 1L; @@ -86,6 +97,10 @@ public class EpiDataForm extends AbstractEditForm { private static final String LOC_SOURCE_CASE_CONTACTS_HEADING = "locSourceCaseContactsHeading"; private static final String LOC_EPI_DATA_FIELDS_HINT = "locEpiDataFieldsHint"; private static final String LOC_EXP_PERIOD_HEADING = "locExpPeriodHeading"; + + private static final String LOC_CUSTOMIZABLE_FIELDS_EXPOSURE_INVESTIGATION = CustomizableFieldGroup.EPIDATA_EXPOSURE_INVESTIGATION.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_ACTIVITY_AS_CASE = CustomizableFieldGroup.EPIDATA_ACTIVITY_AS_CASE.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_CONTACT_WITH_SOURCE_CASE = CustomizableFieldGroup.EPIDATA_CONTACT_WITH_SOURCE_CASE.getKey(); private static final String EXPOSURE_DATES_LAYOUT = fluidRowLocs(3, "EXPOSURE_START_DATE_LABEL", 3, "EXPOSURE_START_DATE_VALUE", 3, "EXPOSURE_END_DATE_LABEL", 3, "EXPOSURE_END_DATE_VALUE"); private static final String LOC_OTHER_INFORMATION_HEADING = "locOtherInformationHeading"; @@ -98,6 +113,7 @@ public class EpiDataForm extends AbstractEditForm { loc(LOC_EXP_PERIOD_HEADING) + loc(EpiDataDto.EXPOSURE_DETAILS_KNOWN) + loc(EpiDataDto.EXPOSURES) + + loc(LOC_CUSTOMIZABLE_FIELDS_EXPOSURE_INVESTIGATION) + loc(LOC_CONCLUSION_HEADING) + fluidRowLocs(6,EpiDataDto.CASE_IMPORTED_STATUS,6,"") + fluidRowLocs(6, EpiDataDto.IMPORTED_CASE, 6, EpiDataDto.COUNTRY)+ @@ -106,6 +122,7 @@ public class EpiDataForm extends AbstractEditForm { loc(LOC_ACTIVITY_AS_CASE_INVESTIGATION_HEADING) + loc(EpiDataDto.ACTIVITY_AS_CASE_DETAILS_KNOWN)+ loc(EpiDataDto.ACTIVITIES_AS_CASE) + + loc(LOC_CUSTOMIZABLE_FIELDS_ACTIVITY_AS_CASE) + loc(LOC_CLUSTER_TYPE_HEADING)+ fluidRowLocs(3, EpiDataDto.CLUSTER_RELATED,5,EpiDataDto.CLUSTER_TYPE,4,EpiDataDto.CLUSTER_TYPE_TEXT) + locCss(VSPACE_TOP_3, LOC_EPI_DATA_FIELDS_HINT) + @@ -115,7 +132,8 @@ public class EpiDataForm extends AbstractEditForm { private static final String SOURCE_CONTACTS_HTML_LAYOUT = locCss(VSPACE_TOP_3, LOC_SOURCE_CASE_CONTACTS_HEADING) + - loc(EpiDataDto.CONTACT_WITH_SOURCE_CASE_KNOWN); + loc(EpiDataDto.CONTACT_WITH_SOURCE_CASE_KNOWN) + + loc(LOC_CUSTOMIZABLE_FIELDS_CONTACT_WITH_SOURCE_CASE); private static final String OTHER_INFORMATION_HTML_LAYOUT = loc(LOC_OTHER_INFORMATION_HEADING) + fluidRowLocs(EpiDataDto.OTHER_DETAILS); @@ -123,10 +141,14 @@ public class EpiDataForm extends AbstractEditForm { private final Disease disease; private final Class parentClass; - private final Consumer sourceContactsToggleCallback; + private final transient Consumer sourceContactsToggleCallback; private final boolean isPseudonymized; private final Date symptomOnsetDate; + private CustomizableFieldsGroup exposureInvestigationPanel; + private CustomizableFieldsGroup activityAsCasePanel; + private CustomizableFieldsGroup contactWithSourceCasePanel; + public EpiDataForm( Disease disease, Class parentClass, @@ -134,7 +156,9 @@ public EpiDataForm( boolean inJurisdiction, Consumer sourceContactsToggleCallback, boolean isEditAllowed, - Date date) { + Date date, + List customizableFieldsMetadata, + Map customizableFieldsValues) { super( EpiDataDto.class, EpiDataDto.I18N_PREFIX, @@ -147,6 +171,8 @@ public EpiDataForm( this.sourceContactsToggleCallback = sourceContactsToggleCallback; this.isPseudonymized = isPseudonymized; this.symptomOnsetDate = date; + setCustomizableFieldsMetadata(customizableFieldsMetadata); + setCustomizableFieldsValues(customizableFieldsValues); addFields(); } @@ -158,6 +184,13 @@ protected void addFields() { addHeadingsAndInfoTexts(); + exposureInvestigationPanel = new CustomizableFieldsGroup(CustomizableFieldGroup.EPIDATA_EXPOSURE_INVESTIGATION); + exposureInvestigationPanel.setVisibilityContext(new CustomizableFieldVisibilityContext().withDisease(disease)); + exposureInvestigationPanel.setFieldsMetadata(getCustomizableFieldsMetadata()); + exposureInvestigationPanel.setFieldsValues(getCustomizableFieldsValues()); + exposureInvestigationPanel.updateFieldsDisplay(); + getContent().addComponent(exposureInvestigationPanel, LOC_CUSTOMIZABLE_FIELDS_EXPOSURE_INVESTIGATION); + NullableOptionGroup ogExposureDetailsKnown = addField(EpiDataDto.EXPOSURE_DETAILS_KNOWN, NullableOptionGroup.class); ExposuresField exposuresField = addField( EpiDataDto.EXPOSURES, @@ -176,6 +209,13 @@ protected void addFields() { addActivityAsCaseFields(); } + activityAsCasePanel = new CustomizableFieldsGroup(CustomizableFieldGroup.EPIDATA_ACTIVITY_AS_CASE); + activityAsCasePanel.setVisibilityContext(new CustomizableFieldVisibilityContext().withDisease(disease)); + activityAsCasePanel.setFieldsMetadata(getCustomizableFieldsMetadata()); + activityAsCasePanel.setFieldsValues(getCustomizableFieldsValues()); + activityAsCasePanel.updateFieldsDisplay(); + getContent().addComponent(activityAsCasePanel, LOC_CUSTOMIZABLE_FIELDS_ACTIVITY_AS_CASE); + addField(EpiDataDto.HIGH_TRANSMISSION_RISK_AREA, NullableOptionGroup.class); addField(EpiDataDto.LARGE_OUTBREAKS_AREA, NullableOptionGroup.class); addField(EpiDataDto.AREA_INFECTED_ANIMALS, NullableOptionGroup.class); @@ -183,7 +223,7 @@ protected void addFields() { if (sourceContactsToggleCallback != null) { ogContactWithSourceCaseKnown.addValueChangeListener(e -> { - YesNoUnknown sourceContactsKnown = (YesNoUnknown) FieldHelper.getNullableSourceFieldValue((Field) e.getProperty()); + YesNoUnknown sourceContactsKnown = (YesNoUnknown) FieldHelper.getNullableSourceFieldValue((Field) e.getProperty()); sourceContactsToggleCallback.accept(YesNoUnknown.YES == sourceContactsKnown); }); } @@ -217,6 +257,14 @@ protected void addFields() { .setVisibleWhen(getFieldGroup(), EpiDataDto.MODE_OF_TRANSMISSION_TYPE, EpiDataDto.MODE_OF_TRANSMISSION, ModeOfTransmission.OTHER, true); FieldHelper.setVisibleWhen(getFieldGroup(), EpiDataDto.INFECTION_SOURCE_TEXT, EpiDataDto.INFECTION_SOURCE, InfectionSource.OTHER, true); FieldHelper.setVisibleWhen(getFieldGroup(), EpiDataDto.COUNTRY, EpiDataDto.IMPORTED_CASE, YesNoUnknown.YES, true); + + contactWithSourceCasePanel = new CustomizableFieldsGroup(CustomizableFieldGroup.EPIDATA_CONTACT_WITH_SOURCE_CASE); + contactWithSourceCasePanel.setVisibilityContext(new CustomizableFieldVisibilityContext().withDisease(disease)); + contactWithSourceCasePanel.setFieldsMetadata(getCustomizableFieldsMetadata()); + contactWithSourceCasePanel.setFieldsValues(getCustomizableFieldsValues()); + contactWithSourceCasePanel.updateFieldsDisplay(); + getContent().addComponent(contactWithSourceCasePanel, LOC_CUSTOMIZABLE_FIELDS_CONTACT_WITH_SOURCE_CASE); + initializeVisibilitiesAndAllowedVisibilities(); initializeAccessAndAllowedAccesses(); @@ -300,9 +348,8 @@ private void addActivityAsCaseFields() { Collections.singletonList(YesNoUnknown.YES), true); - activityAsCaseField.addValueChangeListener(e -> { - ogActivityAsCaseDetailsKnown.setEnabled(CollectionUtils.isEmpty(activityAsCaseField.getValue())); - }); + activityAsCaseField + .addValueChangeListener(e -> ogActivityAsCaseDetailsKnown.setEnabled(CollectionUtils.isEmpty(activityAsCaseField.getValue()))); } private void addHeadingsAndInfoTexts() { @@ -344,6 +391,66 @@ private void addHeadingsAndInfoTexts() { getContent().addComponent(otherInformationLabel, LOC_OTHER_INFORMATION_HEADING); } + /** + * Collects the current values from all customizable field panels. + * + * @return map of metadata DTO to value DTO, suitable for + * {@link de.symeda.sormas.api.customizablefield.CustomizableFieldValueFacade#saveEntityCustomFields} + */ + public Map collectCurrentFieldValues() { + Map result = new HashMap<>(); + for (CustomizableFieldsGroup panel : new CustomizableFieldsGroup[] { + exposureInvestigationPanel, + activityAsCasePanel, + contactWithSourceCasePanel }) { + if (panel != null) { + panel.getFieldsValues().forEach((metadata, valueDto) -> { + if (valueDto != null) { + result.put(metadata, valueDto); + } + }); + } + } + return result; + } + + /** + * Registers a listener that fires whenever any customizable field in any of this form's + * groups changes its value. Used by the controller to drive + * {@link de.symeda.sormas.ui.utils.CommitDiscardWrapperComponent#setDirty(boolean)}. + * + * @param listener + * the listener to register on all panels + */ + public void addCustomizableFieldValueChangeListener(com.vaadin.data.HasValue.ValueChangeListener listener) { + for (CustomizableFieldsGroup panel : new CustomizableFieldsGroup[] { + exposureInvestigationPanel, + activityAsCasePanel, + contactWithSourceCasePanel }) { + if (panel != null) { + panel.addValueChangeListener(listener); + } + } + } + + /** + * Resets all customizable field panels to the original values that were loaded when the form + * was opened. Call this from a + * {@link de.symeda.sormas.ui.utils.CommitDiscardWrapperComponent.DiscardListener} to keep + * customizable fields in sync with the regular field discard. + */ + public void resetCustomizableFieldValues() { + for (CustomizableFieldsGroup panel : new CustomizableFieldsGroup[] { + exposureInvestigationPanel, + activityAsCasePanel, + contactWithSourceCasePanel }) { + if (panel != null) { + panel.setFieldsValues(getCustomizableFieldsValues()); + panel.updateFieldsDisplay(); + } + } + } + public void disableContactWithSourceCaseKnownField() { setEnabled(false, EpiDataDto.CONTACT_WITH_SOURCE_CASE_KNOWN); } @@ -352,6 +459,10 @@ public void setGetSourceContactsCallback(Supplier> cal ((ExposuresField) getField(EpiDataDto.EXPOSURES)).setGetSourceContactsCallback(callback); } + public Map> collectExposureCustomizableFieldValues() { + return ((ExposuresField) getField(EpiDataDto.EXPOSURES)).collectCustomizableFieldValues(); + } + @Override protected String createHtmlLayout() { String layout = parentClass == CaseDataDto.class ? MAIN_HTML_LAYOUT + SOURCE_CONTACTS_HTML_LAYOUT : MAIN_HTML_LAYOUT; diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/exposure/ExposureForm.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/exposure/ExposureForm.java index ef309738be9..c4a9b2eec3d 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/exposure/ExposureForm.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/exposure/ExposureForm.java @@ -27,8 +27,10 @@ import java.util.Arrays; import java.util.Collections; import java.util.EnumSet; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import com.vaadin.icons.VaadinIcons; @@ -50,6 +52,10 @@ import de.symeda.sormas.api.FacadeProvider; import de.symeda.sormas.api.caze.CaseDataDto; import de.symeda.sormas.api.contact.ContactReferenceDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldVisibilityContext; import de.symeda.sormas.api.disease.DiseaseConfigurationDto; import de.symeda.sormas.api.epidata.AnimalCondition; import de.symeda.sormas.api.event.MeansOfTransport; @@ -87,12 +93,16 @@ import de.symeda.sormas.ui.utils.DateTimeField; import de.symeda.sormas.ui.utils.FieldHelper; import de.symeda.sormas.ui.utils.NullableOptionGroup; +import de.symeda.sormas.ui.utils.components.CustomizableFieldsGroup; public class ExposureForm extends AbstractEditForm { private static final long serialVersionUID = 8262753698264714832L; public static final String MAIN_ACCORDION_LOC = "mainAccordionLoc"; + private static final String LOC_CUSTOMIZABLE_FIELDS_EXPOSURE_DETAILS = CustomizableFieldGroup.EXPOSURE_DETAILS.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_EXPOSURES_GENERAL = CustomizableFieldGroup.EXPOSURES_GENERAL.getKey(); + private static final String LOC_CUSTOMIZABLE_FIELDS_LOCATION_GENERAL = CustomizableFieldGroup.LOCATION_GENERAL.getKey(); private static final String LOC_EXPOSURES_HEADING = "locExposuresHeading"; private static final String LOC_EXPOSURE_DETAILS_HEADING = "locExposureDetailsHeading"; private static final String LOC_LOCATION_HEADING = "locLocationHeading"; @@ -100,13 +110,14 @@ public class ExposureForm extends AbstractEditForm { private static final String LOC_BURIAL_DETAILS_HEADING = "locBurialDetailsHeading"; private static final String LOC_CONCLUSION_HEADING = "locConclusionHeading"; - //@formatter:off public static final String MAIN_ACCORDION_LAYOUT = fluidRowLocs(MAIN_ACCORDION_LOC); private static final String UUID_REPORTING_USER = fluidRowLocs(ExposureDto.UUID, ExposureDto.REPORTING_USER); + //@formatter:off private static final String EXPOSURE_DETAILS_LAYOUT = fluidRowLocs(ExposureDto.START_DATE, ExposureDto.END_DATE) + + loc(LOC_CUSTOMIZABLE_FIELDS_EXPOSURE_DETAILS) + loc(LOC_EXPOSURES_HEADING) + fluidRowLocs(ExposureDto.EXPOSURE_CATEGORY, ExposureDto.EXPOSURE_SETTING, ExposureDto.EXPOSURE_SETTING_DETAILS) + fluidRow( @@ -135,6 +146,7 @@ public class ExposureForm extends AbstractEditForm { ExposureDto.PROTECTIVE_MEASURE_DETAILS )) ) + + loc(LOC_CUSTOMIZABLE_FIELDS_EXPOSURES_GENERAL) + loc(ExposureDto.DESCRIPTION); private static final String ACTIVITY_DETAILS_LAYOUT = @@ -203,7 +215,8 @@ public class ExposureForm extends AbstractEditForm { ) + loc(ExposureDto.MEANS_OF_TRANSPORT_DETAILS) + fluidRowLocs(ExposureDto.CONNECTION_NUMBER, ExposureDto.SEAT_NUMBER) + - loc(ExposureDto.LOCATION); + loc(ExposureDto.LOCATION)+ + loc(LOC_CUSTOMIZABLE_FIELDS_LOCATION_GENERAL); //@formatter:on private final Class epiDataParentClass; @@ -237,13 +250,19 @@ public class ExposureForm extends AbstractEditForm { private TextField animalCategoryDetailsField; private NullableOptionGroup fomiteTransmissionLocationField; + private CustomizableFieldsGroup exposureDetailsPanel; + private CustomizableFieldsGroup exposuresGeneralPanel; + private CustomizableFieldsGroup locationGeneralPanel; + public ExposureForm( boolean create, Class epiDataParentClass, List sourceContacts, FieldVisibilityCheckers fieldVisibilityCheckers, - UiFieldAccessCheckers fieldAccessCheckers, - Disease disease) { + UiFieldAccessCheckers fieldAccessCheckers, + Disease disease, + List customizableFieldsMetadata, + Map customizableFieldsValues) { super(ExposureDto.class, ExposureDto.I18N_PREFIX, false, fieldVisibilityCheckers, fieldAccessCheckers); setWidth(960, Unit.PIXELS); @@ -252,6 +271,9 @@ public ExposureForm( this.epiDataParentClass = epiDataParentClass; this.disease = disease; + setCustomizableFieldsMetadata(customizableFieldsMetadata); + setCustomizableFieldsValues(customizableFieldsValues); + if (create) { hideValidationUntilNextCommit(); } @@ -275,13 +297,35 @@ protected void addFields() { locationDetailsLayout.setTemplateContents(LOCATION_DETAILS_LAYOUT); addHeadingsAndInfoTexts(); + + exposureDetailsPanel = new CustomizableFieldsGroup(CustomizableFieldGroup.EXPOSURE_DETAILS); + exposureDetailsPanel.setVisibilityContext(new CustomizableFieldVisibilityContext().withDisease(disease)); + exposureDetailsPanel.setFieldsMetadata(getCustomizableFieldsMetadata()); + exposureDetailsPanel.setFieldsValues(getCustomizableFieldsValues()); + exposureDetailsPanel.updateFieldsDisplay(); + exposureDetailsLayout.addComponent(exposureDetailsPanel, LOC_CUSTOMIZABLE_FIELDS_EXPOSURE_DETAILS); + addBasicFields(); + exposuresGeneralPanel = new CustomizableFieldsGroup(CustomizableFieldGroup.EXPOSURES_GENERAL); + exposuresGeneralPanel.setVisibilityContext(new CustomizableFieldVisibilityContext().withDisease(disease)); + exposuresGeneralPanel.setFieldsMetadata(getCustomizableFieldsMetadata()); + exposuresGeneralPanel.setFieldsValues(getCustomizableFieldsValues()); + exposuresGeneralPanel.updateFieldsDisplay(); + exposureDetailsLayout.addComponent(exposuresGeneralPanel, LOC_CUSTOMIZABLE_FIELDS_EXPOSURES_GENERAL); + addField(exposureDetailsLayout, ExposureDto.DESCRIPTION, TextArea.class).setRows(5); locationForm = addField(locationDetailsLayout, ExposureDto.LOCATION, LocationEditForm.class); locationForm.setCaption(null); addField(locationDetailsLayout, ExposureDto.CONNECTION_NUMBER, TextField.class); + + locationGeneralPanel = new CustomizableFieldsGroup(CustomizableFieldGroup.LOCATION_GENERAL); + locationGeneralPanel.setVisibilityContext(new CustomizableFieldVisibilityContext().withDisease(disease)); + locationGeneralPanel.setFieldsMetadata(getCustomizableFieldsMetadata()); + locationGeneralPanel.setFieldsValues(getCustomizableFieldsValues()); + locationGeneralPanel.updateFieldsDisplay(); + locationDetailsLayout.addComponent(locationGeneralPanel, LOC_CUSTOMIZABLE_FIELDS_LOCATION_GENERAL); getField(ExposureDto.MEANS_OF_TRANSPORT).addValueChangeListener(e -> { if (e.getProperty().getValue() == MeansOfTransport.PLANE) { getField(ExposureDto.CONNECTION_NUMBER).setCaption(I18nProperties.getCaption(Captions.exposureFlightNumber)); @@ -915,6 +959,23 @@ private void addFieldsWithCssToLayout(CustomLayout layout, Class collectCurrentFieldValues() { + Map result = new HashMap<>(); + for (CustomizableFieldsGroup panel : new CustomizableFieldsGroup[] { + exposureDetailsPanel, + exposuresGeneralPanel, + locationGeneralPanel }) { + if (panel != null) { + panel.getFieldsValues().forEach((metadata, valueDto) -> { + if (valueDto != null) { + result.put(metadata, valueDto); + } + }); + } + } + return result; + } + @Override protected String createHtmlLayout() { //@formatter:off diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/exposure/ExposuresField.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/exposure/ExposuresField.java index ab7460b3278..7562f13347e 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/exposure/ExposuresField.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/exposure/ExposuresField.java @@ -15,7 +15,10 @@ package de.symeda.sormas.ui.exposure; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -36,6 +39,9 @@ import de.symeda.sormas.api.caze.CaseDataDto; import de.symeda.sormas.api.contact.ContactDto; import de.symeda.sormas.api.contact.ContactReferenceDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; import de.symeda.sormas.api.exposure.ExposureCategory; import de.symeda.sormas.api.exposure.ExposureDto; import de.symeda.sormas.api.i18n.Captions; @@ -57,7 +63,10 @@ import de.symeda.sormas.ui.utils.FieldAccessCellStyleGenerator; import de.symeda.sormas.ui.utils.VaadinUiUtil; -@SuppressWarnings("serial") +@SuppressWarnings({ + "java:S110", // suppress sonar too many parents warning + "java:S2160" // suppress missing equals not relevant for Vaadin components +}) public class ExposuresField extends AbstractTableField { private static final String COLUMN_EXPOSURE_CATEGORY = ExposureDto.EXPOSURE_CATEGORY; @@ -72,11 +81,12 @@ public class ExposuresField extends AbstractTableField { private boolean isPseudonymized; private boolean isEditAllowed; private Disease disease; + private final Map> pendingCustomizableFieldValues = new HashMap<>(); public ExposuresField( Disease disease, FieldVisibilityCheckers fieldVisibilityCheckers, - UiFieldAccessCheckers fieldAccessCheckers, + UiFieldAccessCheckers fieldAccessCheckers, boolean isEditAllowed) { super(fieldAccessCheckers, isEditAllowed); @@ -215,13 +225,21 @@ protected void editEntry(ExposureDto entry, boolean create, Consumer exposureMetadata = + FacadeProvider.getCustomizableFieldMetadataFacade().getActiveFieldsForContext(CustomizableFieldContext.EXPOSURE); + Map exposureFieldValues = pendingCustomizableFieldValues.containsKey(entry.getUuid()) + ? pendingCustomizableFieldValues.get(entry.getUuid()) + : FacadeProvider.getCustomizableFieldValueFacade().getValuesForEntity(entry.getUuid(), CustomizableFieldContext.EXPOSURE); + ExposureForm exposureForm = new ExposureForm( create, epiDataParentClass, getSourceContactsCallback != null ? getSourceContactsCallback.get() : null, fieldVisibilityCheckers, fieldAccessCheckers, - disease); + disease, + exposureMetadata, + exposureFieldValues); exposureForm.setValue(entry); final CommitDiscardWrapperComponent component = @@ -245,6 +263,7 @@ protected void editEntry(ExposureDto entry, boolean create, Consumer { if (!exposureForm.getFieldGroup().isModified()) { + pendingCustomizableFieldValues.put(entry.getUuid(), exposureForm.collectCurrentFieldValues()); commitCallback.accept(exposureForm.getValue()); updateAddButtonVisibility(getValue().size()); @@ -255,6 +274,7 @@ protected void editEntry(ExposureDto entry, boolean create, Consumer { popupWindow.close(); ExposuresField.this.removeEntry(entry); + pendingCustomizableFieldValues.remove(entry.getUuid()); updateAddButtonVisibility(getValue().size()); }, I18nProperties.getCaption(ExposureDto.I18N_PREFIX)); @@ -285,6 +305,10 @@ public void setPropertyDataSource(Property newDataSource) { } } + public Map> collectCustomizableFieldValues() { + return Collections.unmodifiableMap(pendingCustomizableFieldValues); + } + public void setGetSourceContactsCallback(Supplier> callback) { getSourceContactsCallback = callback; } diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/AbstractEditForm.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/AbstractEditForm.java index 1d75e388707..b771dd42186 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/AbstractEditForm.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/AbstractEditForm.java @@ -21,8 +21,10 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -46,6 +48,8 @@ import de.symeda.sormas.api.FacadeProvider; import de.symeda.sormas.api.InfrastructureDataReferenceDto; import de.symeda.sormas.api.customizableenum.CustomizableEnum; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; import de.symeda.sormas.api.i18n.Captions; import de.symeda.sormas.api.i18n.I18nProperties; import de.symeda.sormas.api.i18n.Strings; @@ -70,6 +74,9 @@ public abstract class AbstractEditForm extends AbstractForm implements private ComboBox diseaseField; private boolean setServerDiseaseAsDefault; + private List customizableFieldsMetadata; + private Map customizableFieldsValues; + protected AbstractEditForm(Class type, String propertyI18nPrefix) { this(type, propertyI18nPrefix, true, null, null); } @@ -134,6 +141,48 @@ public void setValue(DTO newFieldValue) throws com.vaadin.v7.data.Property.ReadO } } + /** + * Set customizable field metadata for this form. + * This data should be pre-loaded by the controller and passed to the form. + * The form will use this metadata to create and configure customizable field components. + * + * @param metadata + * List of customizable field metadata DTOs pre-loaded by the controller + */ + public void setCustomizableFieldsMetadata(List metadata) { + this.customizableFieldsMetadata = metadata; + } + + /** + * Get customizable field metadata. + * + * @return List of customizable field metadata, or null if not set + */ + protected List getCustomizableFieldsMetadata() { + return customizableFieldsMetadata; + } + + /** + * Set customizable field values for this form. + * This data should be pre-loaded by the controller and passed to the form. + * The form will use this data to populate customizable field components. + * + * @param values + * Map of customizable field values keyed by field metadata DTO, pre-loaded by the controller + */ + public void setCustomizableFieldsValues(Map values) { + this.customizableFieldsValues = values; + } + + /** + * Get customizable field values. + * + * @return Map of customizable field values, or empty map if not set + */ + protected Map getCustomizableFieldsValues() { + return customizableFieldsValues != null ? customizableFieldsValues : new HashMap<>(); + } + @Override public boolean isModified() { if (getFieldGroup().isModified()) { diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/CssStyles.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/CssStyles.java index 73c4ab36c99..a97990c4eb7 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/CssStyles.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/CssStyles.java @@ -169,6 +169,9 @@ private CssStyles() { public static final String BUTTON_CAPTION_OVERFLOW = "caption-overflow-label"; public static final String GEOCODE_BUTTON_HIGHLIGHT = "geocode-button-highlight"; + public static final String YES_NO_UNKNOWN_GROUP = "yes-no-unknown-group"; + public static final String YES_NO_UNKNOWN_OPTION_SELECTED = "yes-no-unknown-selected"; + // Link styles public static final String LINK_BUTTON = "button"; public static final String LINK_BUTTON_PRIMARY = "button-primary"; diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/CustomizableFieldsGroup.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/CustomizableFieldsGroup.java new file mode 100644 index 00000000000..a6082e5545b --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/CustomizableFieldsGroup.java @@ -0,0 +1,323 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.vaadin.data.HasValue; +import com.vaadin.ui.HorizontalLayout; +import com.vaadin.ui.Label; +import com.vaadin.ui.VerticalLayout; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldVisibilityContext; +import de.symeda.sormas.api.customizablefield.CustomizableFieldVisibilityRestrictions; +import de.symeda.sormas.ui.utils.components.customizablefield.CustomizableFieldInput; +import de.symeda.sormas.ui.utils.components.customizablefield.CustomizableFieldInputFactory; + +/** + * Container component for managing multiple customizable field value fields belonging to a specific UI group. + * + * Each group is bound to one {@code uiGroup} value (from {@link CustomizableFieldMetadataDto#UI_GROUP}). + * When rendering, only fields whose metadata {@code uiGroup} matches this group's value are displayed. + * + * Usage: + * + *

+ * CustomizableFieldsGroup group = new CustomizableFieldsGroup(CustomizableFieldGroup.EPIDATA_EXPOSURE_INVESTIGATION);
+ * group.setFieldsMetadata(fieldsList);   // full list – group filters by its own group
+ * group.setFieldsValues(valuesMap);
+ * group.updateFieldsDisplay();
+ * form.addComponent(group, CustomizableFieldGroup.EPIDATA_EXPOSURE_INVESTIGATION.getKey());
+ * 
+ */ +@SuppressWarnings({ + "java:S110", // suppress sonar too many parents warning + "java:S2160", // suppress sonar missing equals +}) +public class CustomizableFieldsGroup extends VerticalLayout { + + private static final long serialVersionUID = 1L; + + private static final String STYLE_NAME_CUSTOMIZABLE_FIELDS_GROUP = "customizable-fields-group"; + + private final CustomizableFieldGroup group; + private final Map> fieldComponents; + private final List> valueChangeListeners = new ArrayList<>(); + private List fieldsMetadata; + private Map fieldsValues; + + @SuppressWarnings("java:S1948") // CustomizableFieldVisibilityContext implements Serializable; Sonar can't verify it + private CustomizableFieldVisibilityContext visibilityContext; + + /** + * Creates a group scoped to the given UI group. + * Only fields whose metadata {@code uiGroup} equals this group will be rendered. + * + * @param group + * the UI group, must not be null + */ + public CustomizableFieldsGroup(CustomizableFieldGroup group) { + if (group == null) { + throw new IllegalArgumentException("group must not be null"); + } + this.group = group; + this.fieldComponents = new HashMap<>(); + this.setSpacing(true); + this.setMargin(false); + this.setWidth(100, Unit.PERCENTAGE); + this.addStyleName(STYLE_NAME_CUSTOMIZABLE_FIELDS_GROUP); + this.addStyleName(group.getKey()); + + this.setId(group.getKey()); + } + + /** + * Returns the UI group this group is scoped to. + * + * @return the UI group + */ + public CustomizableFieldGroup getGroup() { + return group; + } + + /** + * Registers a listener that is called whenever any field in this group changes its value. + * The listener is also applied retroactively to any fields already rendered. + * + * @param listener + * the listener to register + */ + @SuppressWarnings("unchecked") + public void addValueChangeListener(HasValue.ValueChangeListener listener) { + HasValue.ValueChangeListener typed = (HasValue.ValueChangeListener) listener; + valueChangeListeners.add(typed); + for (CustomizableFieldInput field : fieldComponents.values()) { + ((CustomizableFieldInput) field).addValueChangeListener(typed); + } + } + + /** + * Sets the runtime visibility context used to evaluate + * {@link CustomizableFieldVisibilityRestrictions} during {@link #updateFieldsDisplay()}. + * Fields whose restrictions do not match the context are omitted from the rendered output. + * When {@code null}, all restriction checks are skipped (fields are shown regardless). + * + * @param visibilityContext + * the current runtime values (e.g. the active disease) + */ + public void setVisibilityContext(CustomizableFieldVisibilityContext visibilityContext) { + this.visibilityContext = visibilityContext; + } + + /** + * Sets the metadata for customizable fields that can be displayed in this panel. + * After setting metadata, you can call {@link #updateFieldsDisplay()} to render the fields. + * + * @param metadata + * the list of field metadata DTOs + */ + public void setFieldsMetadata(List metadata) { + this.fieldsMetadata = metadata; + } + + /** + * Sets the values for customizable fields to be displayed. + * Key should be the field metadata UUID. + * + * @param values + * map of field values keyed by metadata UUID + */ + public void setFieldsValues(Map values) { + this.fieldsValues = values; + } + + /** + * Updates the display of all fields based on current metadata and values. + *

+ * Active fields whose {@code uiGroup} matches this group are: + *

    + *
  1. sorted by {@link CustomizableFieldMetadataDto#getUiLinePosition()} ascending + * (fields with {@code null} position are placed last, each on its own row);
  2. + *
  3. grouped by {@code uiLinePosition} – fields sharing the same (non-null) position + * are placed side-by-side in a {@link HorizontalLayout};
  4. + *
  5. sized within their row proportionally to + * {@link CustomizableFieldMetadataDto#getUiLineWeight()} (Vaadin expand ratio; + * defaults to {@code 1.0} when the weight is {@code null}).
  6. + *
+ */ + public void updateFieldsDisplay() { + removeAllComponents(); + fieldComponents.clear(); + + if (fieldsMetadata == null || fieldsMetadata.isEmpty()) { + setVisible(false); + return; + } + + // Collect active fields for this group that match the current visibility context + List groupFields = new ArrayList<>(); + for (CustomizableFieldMetadataDto metadata : fieldsMetadata) { + boolean visibilityContextMatch = visibilityContext == null + || metadata.getVisibilityRestrictions() == null + || metadata.getVisibilityRestrictions().matches(visibilityContext); + if (metadata.isActive() && metadata.getUiGroup() == group && visibilityContextMatch) { + groupFields.add(metadata); + } + } + + if (groupFields.isEmpty()) { + setVisible(false); + return; + } + + setVisible(true); + + // Sort: non-null positions first (ascending), null positions last (stable) + groupFields.sort(Comparator.comparing(CustomizableFieldMetadataDto::getUiLinePosition, Comparator.nullsLast(Comparator.naturalOrder()))); + + // Group by uiLinePosition; null-positioned fields each get their own synthetic key + // using a LinkedHashMap to preserve insertion (sorted) order. + LinkedHashMap> lines = new LinkedHashMap<>(); + int nullKeyCounter = 0; + for (CustomizableFieldMetadataDto metadata : groupFields) { + Object lineKey = metadata.getUiLinePosition() != null ? metadata.getUiLinePosition() : "__null_" + nullKeyCounter++; + lines.computeIfAbsent(lineKey, k -> new ArrayList<>()).add(metadata); + } + + // Render each line as a HorizontalLayout row + for (List lineFields : lines.values()) { + addLineRow(lineFields); + } + } + + /** + * Renders a single horizontal row for the given list of fields. + * Each field's width within the row is controlled by its {@code uiLineWeight} + * (used as a Vaadin expand ratio; defaults to {@code 1.0} when absent). + * + * @param lineFields + * fields to place side-by-side on this row + */ + @SuppressWarnings("unchecked") + private void addLineRow(List lineFields) { + HorizontalLayout row = new HorizontalLayout(); + row.setWidth(100, Unit.PERCENTAGE); + row.setSpacing(true); + row.setMargin(false); + + for (CustomizableFieldMetadataDto metadata : lineFields) { + CustomizableFieldInput field = CustomizableFieldInputFactory.create(metadata); + + CustomizableFieldValueDto dto = + (fieldsValues != null && fieldsValues.containsKey(metadata)) ? fieldsValues.get(metadata) : new CustomizableFieldValueDto(); + field.setFieldValue(dto); + + field.setWidth(100, Unit.PERCENTAGE); + row.addComponent(field); + + float expandRatio = metadata.getUiLineWeight() != null ? metadata.getUiLineWeight() : 1.0f; + row.setExpandRatio(field, expandRatio); + + for (HasValue.ValueChangeListener listener : valueChangeListeners) { + ((CustomizableFieldInput) field).addValueChangeListener(listener); + } + + fieldComponents.put(metadata, field); + } + + // When a single field has a weight < 1.0, expand ratio has no effect on a lone component. + // Add an invisible filler with the complementary ratio so the field takes only its proportional share. + if (lineFields.size() == 1) { + CustomizableFieldMetadataDto sole = lineFields.get(0); + if (sole.getUiLineWeight() != null && sole.getUiLineWeight() < 1.0f) { + Label filler = new Label(); + row.addComponent(filler); + row.setExpandRatio(filler, 1.0f - sole.getUiLineWeight()); + } + } + + addComponent(row); + } + + /** + * Gets a specific field component by metadata UUID. + * + * @param metadataUuid + * the UUID of the field metadata + * @return the field component, or null if not found + */ + @SuppressWarnings("java:S1452") // wildcard return type is intentional for CustomizableFieldInput + public CustomizableFieldInput getFieldByMetadataUuid(String metadataUuid) { + return fieldComponents.entrySet() + .stream() + .filter(e -> metadataUuid.equals(e.getKey().getUuid())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + } + + /** + * Gets all field value components. + * + * @return map of field components keyed by metadata UUID + */ + public Map> getAllFields() { + return new HashMap<>(fieldComponents); + } + + /** + * Gets all current field values as a map. + * Key is the metadata UUID, value is the CustomizableFieldValueDto. + * + * @return map of all current field values + */ + public Map getFieldsValues() { + Map result = new HashMap<>(); + for (Map.Entry> entry : fieldComponents.entrySet()) { + CustomizableFieldValueDto value = entry.getValue().getFieldValue(); + if (value != null && value.getValue() != null) { + result.put(entry.getKey(), value); + } + } + return result; + } + + /** + * Clears all field values. + */ + public void clearAllValues() { + for (CustomizableFieldInput field : fieldComponents.values()) { + field.clear(); + } + } + + /** + * Checks if any of the customizable fields have values. + * + * @return true if at least one field has a non-empty value + */ + public boolean hasAnyValues() { + return fieldComponents.values().stream().anyMatch(field -> !field.isEmpty()); + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInput.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInput.java new file mode 100644 index 00000000000..91822a0ba99 --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInput.java @@ -0,0 +1,276 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; + +import com.vaadin.data.Binder; +import com.vaadin.data.ValueProvider; +import com.vaadin.server.Setter; +import com.vaadin.ui.Component; +import com.vaadin.ui.CustomField; + +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.i18n.I18nProperties; + +/** + * Abstract base for editable customizable field input components (Vaadin v8). + *

+ * Each concrete subclass is responsible for a specific {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType}. + * The base class handles metadata-driven configuration (caption, mandatory indicator, read-only state) + * and owns a {@link Binder} that keeps {@link CustomizableFieldValueDto#getValue() dto.value} in sync + * with the widget in both directions: + *

    + *
  • DTO → widget: {@link #setFieldValue(CustomizableFieldValueDto)} calls {@link Binder#setBean}, + * which reads the DTO and pushes the value into this {@code CustomField} via {@link #doSetValue}.
  • + *
  • widget → DTO: subclasses must call {@link #setValue(Object)} whenever the inner widget + * changes (typically via a value-change listener); the binder immediately writes the new value + * into the bean so {@link #getFieldValue()} never needs a manual flush.
  • + *
+ *

+ * Subclass contract: + *

    + *
  • {@link #buildInputComponent()} – called once by {@link #initContent()}; return the inner editable widget + * and wire a value-change listener that calls {@link #setValue(Object)}.
  • + *
  • {@link #applyValueToWidget(Object)} – propagates the value down to the inner widget; + * called by the final {@link #doSetValue(Object)} after storing the new value internally.
  • + *
  • {@link #configureBinding(Binder.BindingBuilder)} – optional; override to attach + * {@link com.vaadin.data.Validator}s to the binding before it is finalised.
  • + *
+ */ +@SuppressWarnings("java:S2160") // sonar missing equals, ok for Vaadin UI components +public abstract class CustomizableFieldInput extends CustomField { + + private static final long serialVersionUID = 1L; + + private final CustomizableFieldMetadataDto fieldMetadata; + private final Binder binder; + /** + * Stores the current logical value of this field. + *

+ * {@link #getValue()} reads from here instead of from the inner widget. + * This ensures that {@link com.vaadin.ui.AbstractField#setValue(Object)} can + * correctly detect value changes: when the inner widget fires a change event + * and the listener calls {@link #setValue(Object)}, the old logical value is + * still present here, so the equality check succeeds and a + * {@link com.vaadin.data.HasValue.ValueChangeEvent} is fired to notify the binder. + * The field is updated inside {@link #doSetValue(Object)}, which is invoked by + * Vaadin only after the equality check has already passed. + */ + private transient T currentValue; + + /** + * Constructs the input and wires a {@link Binder} that keeps the DTO's {@code value} field + * continuously in sync with the widget state. + * + * @param metadata + * field metadata; must not be {@code null} + */ + protected CustomizableFieldInput(CustomizableFieldMetadataDto metadata) { + this.fieldMetadata = Objects.requireNonNull(metadata, "fieldMetadata must not be null"); + + binder = new Binder<>(CustomizableFieldValueDto.class); + Binder.BindingBuilder bindingBuilder = binder.forField(this); + if (metadata.isMandatory()) { + bindingBuilder = bindingBuilder.asRequired(); + } + bindingBuilder = configureBinding(bindingBuilder); + bindingBuilder.bind(getValueGetter(), getValueSetter()); + + applyMetadata(); + } + + /** + * Optional hook called during construction, just before the binder binding is finalised. + * Subclasses can override this to attach additional {@link com.vaadin.data.Validator}s + * (e.g. pattern validators for numeric types). + *

+ * Note: this method is called from the superclass constructor. Overrides must + * only reference compile-time constants or static members, never uninitialized instance fields. + * + * @param builder + * the in-progress binding builder; never {@code null} + * @return the (possibly augmented) builder; must not be {@code null} + */ + protected Binder.BindingBuilder configureBinding(Binder.BindingBuilder builder) { + return builder; + } + + /** + * Build and return the inner editable UI component. + * Called exactly once during {@link #initContent()}. + *

+ * Implementations should wire a value-change listener on the inner widget that + * calls {@link #setValue(Object)} so that the value stored in the field's internal + * state stays in sync. + * + * @return the inner component; never {@code null} + */ + protected abstract Component buildInputComponent(); + + @Override + protected final Component initContent() { + return buildInputComponent(); + } + + /** + * Returns the current logical value of this field. + *

+ * Reads from the internal {@link #currentValue} store rather than from the inner widget, + * so that {@link com.vaadin.ui.AbstractField#setValue(Object)} can detect changes correctly: + * when the inner widget fires a change and the listener calls {@link #setValue(Object)}, + * the old value is still present here, allowing the equality check to pass and a + * {@link com.vaadin.data.HasValue.ValueChangeEvent} to be fired to the binder. + */ + @Override + public T getValue() { + return currentValue; + } + + /** + * Called by Vaadin when a new value is set on this field (via {@link #setValue(Object)}). + * Stores the new value in {@link #currentValue}, then delegates widget propagation to + * {@link #applyValueToWidget(Object)}. + *

+ * Made {@code final} so that subclasses cannot accidentally bypass the value-storage step. + */ + @Override + protected final void doSetValue(T value) { + this.currentValue = value; + applyValueToWidget(value); + } + + /** + * Propagates {@code value} down to the inner UI widget. + * Called by {@link #doSetValue(Object)} after the new value has been stored. + *

+ * If the inner widget has not been created yet (component not yet rendered), + * implementations should stash the value as a pending value and apply it in + * {@link #buildInputComponent()}. + * + * @param value + * the new value to display; may be {@code null} + */ + protected abstract void applyValueToWidget(T value); + + /** + * Returns a {@link ValueProvider} that reads the typed value from a {@link CustomizableFieldValueDto}. + * Called once during construction; implementations must only reference compile-time constants. + */ + protected abstract ValueProvider getValueGetter(); + + /** + * Returns a {@link Setter} that writes the typed value back to a {@link CustomizableFieldValueDto}. + * Called once during construction; implementations must only reference compile-time constants. + */ + protected abstract Setter getValueSetter(); + + /** + * Sets the {@link CustomizableFieldValueDto} as the binder's active bean. + * The binder reads {@code dto.getValue()} and pushes it into the widget immediately. + * From this point on every user edit automatically writes back to the same DTO instance. + * Pass {@code null} to detach the current bean and clear the widget. + * + * @param fieldValue + * the value DTO, or {@code null} to clear + */ + public void setFieldValue(CustomizableFieldValueDto fieldValue) { + binder.setBean(fieldValue); + } + + /** + * Returns the currently bound {@link CustomizableFieldValueDto}. + * Because the binder writes user edits to the bean immediately, the returned DTO + * always reflects the current widget state — no manual flush required. + * + * @return the live bean, or {@code null} if none was set + */ + public CustomizableFieldValueDto getFieldValue() { + return binder.getBean(); + } + + /** + * Returns the metadata that configures this input. + * + * @return field metadata; never {@code null} + */ + public CustomizableFieldMetadataDto getFieldMetadata() { + return fieldMetadata; + } + + /** + * Applies caption, mandatory indicator and read-only state from metadata. + * The caption and description are resolved from the translations map for the current user language, + * falling back to the stored name/description when no matching translation exists. + * Called once in the constructor, before the inner component is built. + */ + private void applyMetadata() { + String caption = resolveTranslation(CustomizableFieldMetadataDto.NAME, fieldMetadata.getName()); + if (StringUtils.isNotBlank(caption)) { + setCaption(caption); + } + String description = resolveTranslation(CustomizableFieldMetadataDto.DESCRIPTION, fieldMetadata.getDescription()); + if (StringUtils.isNotBlank(description)) { + setDescription(description); + } + setRequiredIndicatorVisible(fieldMetadata.isMandatory()); + setReadOnly(fieldMetadata.isReadOnly()); + } + + /** + * Looks up {@code key} (e.g. "name" or "description") in the metadata's translations map + * for the current user language. Tries the full locale string first (e.g. "de_DE"), then + * falls back to the language-only prefix (e.g. "de"), then to {@code fallback}. + */ + private String resolveTranslation(String key, String fallback) { + Map> translations = fieldMetadata.getTranslations(); + if (translations != null) { + Language userLanguage = I18nProperties.getUserLanguage(); + if (userLanguage != null) { + String localeStr = userLanguage.getLocale().toString(); + String translated = getTranslationFromMap(translations, localeStr, key); + if (translated != null) { + return translated; + } + // Try language-only prefix (e.g. "en" from "en_GB") + int underscore = localeStr.indexOf('_'); + if (underscore > 0) { + translated = getTranslationFromMap(translations, localeStr.substring(0, underscore), key); + if (translated != null) { + return translated; + } + } + } + } + return fallback; + } + + private static String getTranslationFromMap(Map> translations, String localeKey, String key) { + Map langMap = translations.get(localeKey); + if (langMap != null) { + String value = langMap.get(key); + if (StringUtils.isNotBlank(value)) { + return value; + } + } + return null; + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputCheckbox.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputCheckbox.java new file mode 100644 index 00000000000..522bc9ddb6c --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputCheckbox.java @@ -0,0 +1,95 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import com.vaadin.data.ValueProvider; +import com.vaadin.server.Setter; +import com.vaadin.ui.CheckBox; +import com.vaadin.ui.Component; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; + +/** + * Concrete {@link CustomizableFieldInput} for {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#CHECKBOX}. + *

+ * Renders a Vaadin v8 {@link CheckBox}. The checked state is serialised to/from the DTO's + * {@code value} field as {@code "true"} / {@code "false"} via + * {@link CustomizableFieldValueDto#getValueAsBoolean()} and + * {@link CustomizableFieldValueDto#setValueAsBoolean(Boolean)}. + * An absent/unrecognised raw value is treated as {@code false}. + */ +public class CustomizableFieldInputCheckbox extends CustomizableFieldInput { + + private static final long serialVersionUID = 1L; + + private CheckBox checkBox; + /** + * Holds a value that was pushed via {@link #doSetValue(Boolean)} before {@link #buildInputComponent()} + * had a chance to create the {@link CheckBox}. Applied to the check box on first render. + */ + private Boolean pendingValue; + + public CustomizableFieldInputCheckbox(CustomizableFieldMetadataDto metadata) { + super(metadata); + } + + @Override + protected ValueProvider getValueGetter() { + return CustomizableFieldValueDto::getValueAsBoolean; + } + + @Override + protected Setter getValueSetter() { + return CustomizableFieldValueDto::setValueAsBoolean; + } + + public Class getType() { + return Boolean.class; + } + + @Override + protected Component buildInputComponent() { + checkBox = new CheckBox(); + + // Apply any value that arrived before the component was first rendered. + if (pendingValue != null) { + checkBox.setValue(pendingValue); + pendingValue = null; + } + + // Propagate user edits back to the field's internal value state. + checkBox.addValueChangeListener(e -> setValue(e.getValue())); + + return checkBox; + } + + /** + * Called by Vaadin when {@link #setValue(Object)} is invoked programmatically. + * Pushes the new state into the {@link CheckBox}, defaulting {@code null} to {@code false}. + * If the {@link CheckBox} has not been created yet (component not yet rendered), + * the value is stored as a pending value and applied in {@link #buildInputComponent()}. + */ + @Override + protected void applyValueToWidget(Boolean value) { + boolean checked = Boolean.TRUE.equals(value); + if (checkBox != null) { + checkBox.setValue(checked); + } else { + pendingValue = checked; + } + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputCheckboxList.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputCheckboxList.java new file mode 100644 index 00000000000..f30af1f11fe --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputCheckboxList.java @@ -0,0 +1,117 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import com.vaadin.data.ValueProvider; +import com.vaadin.server.Setter; +import com.vaadin.ui.CheckBoxGroup; +import com.vaadin.ui.Component; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldCustomProperties; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; + +/** + * Concrete {@link CustomizableFieldInput} for + * {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#CHECKBOX_LIST}. + *

+ * Renders a Vaadin v8 {@link CheckBoxGroup} populated with the string options defined in the + * field's {@link CustomizableFieldMetadataDto#getCustomProperties() customProperties} under + * {@link CustomizableFieldCustomProperties#getOptions()}. + *

+ * The selected set is serialised to/from the DTO's {@code value} field as a JSON array via + * {@link CustomizableFieldValueDto#getValueAsStringSet()} and + * {@link CustomizableFieldValueDto#setValueAsStringSet(Set)}. + */ +public class CustomizableFieldInputCheckboxList extends CustomizableFieldInput> { + + private static final long serialVersionUID = 1L; + + private CheckBoxGroup checkBoxGroup; + /** + * Holds a value that was pushed via {@link #doSetValue(Set)} before + * {@link #buildInputComponent()} had a chance to create the {@link CheckBoxGroup}. + * Applied to the group on first render. + */ + private Set pendingValue; + + public CustomizableFieldInputCheckboxList(CustomizableFieldMetadataDto metadata) { + super(metadata); + } + + @Override + protected ValueProvider> getValueGetter() { + return CustomizableFieldValueDto::getValueAsStringSet; + } + + @Override + protected Setter> getValueSetter() { + return CustomizableFieldValueDto::setValueAsStringSet; + } + + @SuppressWarnings("unchecked") + public Class> getType() { + return (Class>) (Class) Set.class; + } + + @Override + protected Component buildInputComponent() { + checkBoxGroup = new CheckBoxGroup<>(); + checkBoxGroup.setItems(resolveOptions()); + + // Apply any value that arrived before the component was first rendered. + if (pendingValue != null) { + checkBoxGroup.setValue(pendingValue); + pendingValue = null; + } + + // Propagate user edits back to the field's internal value state. + checkBoxGroup.addValueChangeListener(e -> setValue(e.getValue())); + + return checkBoxGroup; + } + + /** + * Called by Vaadin when {@link #setValue(Object)} is invoked programmatically. + * Pushes the value into the {@link CheckBoxGroup}; {@code null} clears the selection. + * If the {@link CheckBoxGroup} has not been created yet (component not yet rendered), + * the value is stored as a pending value and applied in {@link #buildInputComponent()}. + */ + @Override + protected void applyValueToWidget(Set value) { + if (checkBoxGroup != null) { + checkBoxGroup.setValue(value != null ? value : Collections.emptySet()); + } else { + pendingValue = value; + } + } + + /** + * Reads the options list from {@link CustomizableFieldMetadataDto#getCustomProperties()}. + * Returns an empty list when no custom properties are set or the options are absent. + */ + private List resolveOptions() { + CustomizableFieldCustomProperties props = getFieldMetadata().getCustomProperties(); + if (props == null || props.getOptions() == null) { + return Collections.emptyList(); + } + return props.getOptions(); + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputCombobox.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputCombobox.java new file mode 100644 index 00000000000..ad123b13a6e --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputCombobox.java @@ -0,0 +1,113 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import java.util.Collections; +import java.util.List; + +import com.vaadin.data.ValueProvider; +import com.vaadin.server.Setter; +import com.vaadin.ui.ComboBox; +import com.vaadin.ui.Component; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldCustomProperties; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; + +/** + * Concrete {@link CustomizableFieldInput} for {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#COMBOBOX}. + *

+ * Renders a Vaadin v8 {@link ComboBox} populated with the string options defined in the field's + * {@link CustomizableFieldMetadataDto#getCustomProperties() customProperties} map under the key + * {@code "options"} (expected to be a {@link List} of {@link String}s). The selected value is + * stored directly as a {@link String} in {@link CustomizableFieldValueDto#getValue()}. + */ +public class CustomizableFieldInputCombobox extends CustomizableFieldInput { + + private static final long serialVersionUID = 1L; + + private ComboBox comboBox; + /** + * Holds a value that was pushed via {@link #doSetValue(String)} before + * {@link #buildInputComponent()} had a chance to create the {@link ComboBox}. + * Applied to the combo box on first render. + */ + private String pendingValue; + + public CustomizableFieldInputCombobox(CustomizableFieldMetadataDto metadata) { + super(metadata); + } + + @Override + protected ValueProvider getValueGetter() { + return CustomizableFieldValueDto::getValue; + } + + @Override + protected Setter getValueSetter() { + return CustomizableFieldValueDto::setValue; + } + + public Class getType() { + return String.class; + } + + @Override + protected Component buildInputComponent() { + comboBox = new ComboBox<>(); + comboBox.setWidth(100, Unit.PERCENTAGE); + comboBox.setItems(resolveOptions()); + comboBox.setEmptySelectionAllowed(true); + + // Apply any value that arrived before the component was first rendered. + if (pendingValue != null) { + comboBox.setValue(pendingValue); + pendingValue = null; + } + + // Propagate user edits back to the field's internal value state. + comboBox.addValueChangeListener(e -> setValue(e.getValue())); + + return comboBox; + } + + /** + * Called by Vaadin when {@link #setValue(Object)} is invoked programmatically. + * Pushes the value into the {@link ComboBox}; {@code null} clears the selection. + * If the {@link ComboBox} has not been created yet (component not yet rendered), + * the value is stored as a pending value and applied in {@link #buildInputComponent()}. + */ + @Override + protected void applyValueToWidget(String value) { + if (comboBox != null) { + comboBox.setValue(value); + } else { + pendingValue = value; + } + } + + /** + * Reads the options list from {@link CustomizableFieldMetadataDto#getCustomProperties()}. + * Returns an empty list when no custom properties are set or the options are absent. + */ + private List resolveOptions() { + CustomizableFieldCustomProperties props = getFieldMetadata().getCustomProperties(); + if (props == null || props.getOptions() == null) { + return Collections.emptyList(); + } + return props.getOptions(); + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputDate.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputDate.java new file mode 100644 index 00000000000..013d74e4237 --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputDate.java @@ -0,0 +1,104 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import java.time.LocalDate; + +import com.vaadin.data.ValueProvider; +import com.vaadin.server.Setter; +import com.vaadin.ui.Component; +import com.vaadin.ui.DateField; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.utils.DateFormatHelper; + +/** + * Concrete {@link CustomizableFieldInput} for {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#DATE}. + *

+ * Renders a Vaadin 8 {@link DateField} (date-only picker, value type {@link LocalDate}). + * The value is stored internally as an ISO date string ({@code yyyy-MM-dd}) consistent with + * the {@link String} contract of the base class. The display format follows the locale + * configured in SORMAS via {@link DateFormatHelper#getDateFormatPattern()}. + *

+ * Value round-trip: + *

    + *
  • widget → string: {@link LocalDate#toString()} ({@code yyyy-MM-dd})
  • + *
  • string → widget: {@link LocalDate#parse(CharSequence)}
  • + *
+ */ +public class CustomizableFieldInputDate extends CustomizableFieldInput { + + private static final long serialVersionUID = 1L; + + private DateField dateField; + /** + * Holds a value that was pushed via {@link #doSetValue(LocalDate)} before + * {@link #buildInputComponent()} had a chance to create the {@link DateField}. + * Applied to the date field on first render. + */ + private LocalDate pendingValue; + + public CustomizableFieldInputDate(CustomizableFieldMetadataDto metadata) { + super(metadata); + } + + @Override + protected ValueProvider getValueGetter() { + return CustomizableFieldValueDto::getValueAsDate; + } + + @Override + protected Setter getValueSetter() { + return CustomizableFieldValueDto::setValueAsDate; + } + + public Class getType() { + return LocalDate.class; + } + + @Override + protected Component buildInputComponent() { + dateField = new DateField(); + dateField.setWidth(100, Unit.PERCENTAGE); + dateField.setDateFormat(DateFormatHelper.getDateFormatPattern()); + + // Apply any value that arrived before the component was first rendered. + if (pendingValue != null) { + dateField.setValue(pendingValue); + pendingValue = null; + } + + // Propagate user edits back to the field's internal value state. + dateField.addValueChangeListener(e -> setValue(e.getValue())); + + return dateField; + } + + /** + * Propagates {@code value} into the {@link DateField}; {@code null} clears it. + * If the {@link DateField} has not been created yet (component not yet rendered), + * the value is stored as a pending value and applied in {@link #buildInputComponent()}. + */ + @Override + protected void applyValueToWidget(LocalDate value) { + if (dateField != null) { + dateField.setValue(value); + } else { + pendingValue = value; + } + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputDateTime.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputDateTime.java new file mode 100644 index 00000000000..674581df0ed --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputDateTime.java @@ -0,0 +1,106 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import java.time.LocalDateTime; + +import com.vaadin.data.ValueProvider; +import com.vaadin.server.Setter; +import com.vaadin.ui.Component; +import com.vaadin.ui.DateTimeField; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.utils.DateHelper; + +/** + * Concrete {@link CustomizableFieldInput} for {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#DATE_TIME}. + *

+ * Renders a Vaadin 8 {@link DateTimeField} (date + time picker, value type {@link LocalDateTime}). + * The value is stored internally as an ISO-8601 date-time string ({@code yyyy-MM-ddTHH:mm}) + * consistent with the {@link String} contract of the base class. The display format follows + * the locale configured in SORMAS via {@link DateHelper#getLocalDateTimeFormat}. + *

+ * Value round-trip: + *

    + *
  • widget → string: {@link LocalDateTime} truncated to minutes, formatted as + * {@code yyyy-MM-ddTHH:mm} via {@link java.time.format.DateTimeFormatter#ISO_LOCAL_DATE_TIME}
  • + *
  • string → widget: {@link LocalDateTime#parse(CharSequence)}
  • + *
+ */ +public class CustomizableFieldInputDateTime extends CustomizableFieldInput { + + private static final long serialVersionUID = 1L; + + private DateTimeField dateTimeField; + /** + * Holds a value that was pushed via {@link #doSetValue(LocalDateTime)} before + * {@link #buildInputComponent()} had a chance to create the {@link DateTimeField}. + * Applied to the date-time field on first render. + */ + private LocalDateTime pendingValue; + + public CustomizableFieldInputDateTime(CustomizableFieldMetadataDto metadata) { + super(metadata); + } + + @Override + protected ValueProvider getValueGetter() { + return CustomizableFieldValueDto::getValueAsDateTime; + } + + @Override + protected Setter getValueSetter() { + return CustomizableFieldValueDto::setValueAsDateTime; + } + + public Class getType() { + return LocalDateTime.class; + } + + @Override + protected Component buildInputComponent() { + dateTimeField = new DateTimeField(); + dateTimeField.setWidth(100, Unit.PERCENTAGE); + dateTimeField.setDateFormat(DateHelper.getLocalDateTimeFormat(I18nProperties.getUserLanguage()).toPattern()); + + // Apply any value that arrived before the component was first rendered. + if (pendingValue != null) { + dateTimeField.setValue(pendingValue); + pendingValue = null; + } + + // Propagate user edits back to the field's internal value state. + dateTimeField.addValueChangeListener(e -> setValue(e.getValue())); + + return dateTimeField; + } + + /** + * Propagates {@code value} into the {@link DateTimeField}; {@code null} clears it. + * If the {@link DateTimeField} has not been created yet (component not yet rendered), + * the value is stored as a pending value and applied in {@link #buildInputComponent()}. + */ + @Override + protected void applyValueToWidget(LocalDateTime value) { + if (dateTimeField != null) { + dateTimeField.setValue(value); + } else { + pendingValue = value; + } + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputDecimal.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputDecimal.java new file mode 100644 index 00000000000..994edb7290a --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputDecimal.java @@ -0,0 +1,110 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import com.vaadin.data.Binder; +import com.vaadin.data.ValueProvider; +import com.vaadin.server.Setter; +import com.vaadin.ui.Component; +import com.vaadin.ui.TextField; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.Validations; + +/** + * Concrete {@link CustomizableFieldInput} for {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#DECIMAL}. + *

+ * Renders a Vaadin v8 {@link TextField} restricted to decimal-number input via a + * binder validator. The value is stored as a {@link String} consistent with the + * base class contract. User edits are propagated via a value-change listener; + * programmatic changes pushed through {@link #setValue(Object)} reach the widget via + * {@link #doSetValue(String)}. + */ +public class CustomizableFieldInputDecimal extends CustomizableFieldInput { + + private static final long serialVersionUID = 1L; + + /** Matches an optional leading minus, one or more digits, and an optional decimal part. */ + private static final String DECIMAL_PATTERN = "-?\\d*([.,]\\d*)?"; + + private TextField textField; + /** + * Holds a value that was pushed via {@link #doSetValue(String)} before {@link #buildInputComponent()} + * had a chance to create the {@link TextField}. Applied to the text field on first render. + */ + private String pendingValue; + + public CustomizableFieldInputDecimal(CustomizableFieldMetadataDto metadata) { + super(metadata); + } + + @Override + protected ValueProvider getValueGetter() { + return CustomizableFieldValueDto::getValue; + } + + @Override + protected Setter getValueSetter() { + return CustomizableFieldValueDto::setValue; + } + + public Class getType() { + return String.class; + } + + @Override + protected Binder.BindingBuilder configureBinding( + Binder.BindingBuilder builder) { + return builder.withValidator( + value -> value == null || value.isEmpty() || value.matches(DECIMAL_PATTERN), + I18nProperties.getValidationError(Validations.onlyDecimalNumbersAllowed, getFieldMetadata().getName())); + } + + @Override + protected Component buildInputComponent() { + textField = new TextField(); + textField.setWidth(100, Unit.PERCENTAGE); + + // Apply any value that arrived before the component was first rendered. + if (pendingValue != null) { + textField.setValue(pendingValue); + pendingValue = null; + } + + // Propagate user edits back to the field's internal value state. + textField.addValueChangeListener(e -> setValue(e.getValue())); + + return textField; + } + + /** + * Called by Vaadin when {@link #setValue(Object)} is invoked programmatically. + * Pushes the new value into the {@link TextField}, converting {@code null} to an + * empty string because {@code TextField.setValue(null)} throws {@link NullPointerException}. + * If the {@link TextField} has not been created yet (component not yet rendered), + * the value is stored as a pending value and applied in {@link #buildInputComponent()}. + */ + @Override + protected void applyValueToWidget(String value) { + if (textField != null) { + textField.setValue(value != null ? value : ""); + } else { + pendingValue = value; + } + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputFactory.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputFactory.java new file mode 100644 index 00000000000..a00a47ea78c --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputFactory.java @@ -0,0 +1,84 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldType; + +/** + * Factory for creating {@link CustomizableFieldInput} instances based on + * {@link CustomizableFieldMetadataDto#getFieldType()}. + *

+ * To add support for a new type, add the corresponding {@link CustomizableFieldType} case + * and return the appropriate {@link CustomizableFieldInput} subclass. + */ +public final class CustomizableFieldInputFactory { + + private CustomizableFieldInputFactory() { + // utility class + } + + /** + * Creates the appropriate {@link CustomizableFieldInput} for the given metadata. + * + * @param metadata + * field metadata; must not be {@code null} + * @return a new input component wired to the given metadata + * @throws UnsupportedOperationException + * when the {@link CustomizableFieldType} has no implementation yet + */ + public static CustomizableFieldInput create(CustomizableFieldMetadataDto metadata) { + CustomizableFieldType type = metadata.getFieldType(); + + switch (type) { + case TEXT: + return new CustomizableFieldInputText(metadata); + + case TEXTAREA: + return new CustomizableFieldInputTextArea(metadata); + + case NUMBER: + return new CustomizableFieldInputNumber(metadata); + + case DECIMAL: + return new CustomizableFieldInputDecimal(metadata); + + case DATE: + return new CustomizableFieldInputDate(metadata); + + case DATE_TIME: + return new CustomizableFieldInputDateTime(metadata); + + case COMBOBOX: + return new CustomizableFieldInputCombobox(metadata); + + case CHECKBOX: + return new CustomizableFieldInputCheckbox(metadata); + + case YES_NO_UNKNOWN: + return new CustomizableFieldInputYesNoUnknown(metadata); + + case CHECKBOX_LIST: + return new CustomizableFieldInputCheckboxList(metadata); + + case RADIO_BUTTON_LIST: + return new CustomizableFieldInputRadioButtonList(metadata); + + default: + throw new UnsupportedOperationException("No CustomizableFieldInput implementation for field type: " + type); + } + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputNumber.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputNumber.java new file mode 100644 index 00000000000..b4b20a310ac --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputNumber.java @@ -0,0 +1,110 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import com.vaadin.data.Binder; +import com.vaadin.data.ValueProvider; +import com.vaadin.server.Setter; +import com.vaadin.ui.Component; +import com.vaadin.ui.TextField; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.Validations; + +/** + * Concrete {@link CustomizableFieldInput} for {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#NUMBER}. + *

+ * Renders a Vaadin v8 {@link TextField} restricted to whole-number input via a + * binder validator. The value is stored as a {@link String} consistent with the + * base class contract. User edits are propagated via a value-change listener; + * programmatic changes pushed through {@link #setValue(Object)} reach the widget via + * {@link #doSetValue(String)}. + */ +public class CustomizableFieldInputNumber extends CustomizableFieldInput { + + private static final long serialVersionUID = 1L; + + /** Matches an optional leading minus followed by one or more digits (or empty string). */ + private static final String INTEGER_PATTERN = "-?\\d*"; + + private TextField textField; + /** + * Holds a value that was pushed via {@link #doSetValue(String)} before {@link #buildInputComponent()} + * had a chance to create the {@link TextField}. Applied to the text field on first render. + */ + private String pendingValue; + + public CustomizableFieldInputNumber(CustomizableFieldMetadataDto metadata) { + super(metadata); + } + + @Override + protected ValueProvider getValueGetter() { + return CustomizableFieldValueDto::getValue; + } + + @Override + protected Setter getValueSetter() { + return CustomizableFieldValueDto::setValue; + } + + public Class getType() { + return String.class; + } + + @Override + protected Binder.BindingBuilder configureBinding( + Binder.BindingBuilder builder) { + return builder.withValidator( + value -> value == null || value.isEmpty() || value.matches(INTEGER_PATTERN), + I18nProperties.getValidationError(Validations.onlyIntegerNumbersAllowed, getFieldMetadata().getName())); + } + + @Override + protected Component buildInputComponent() { + textField = new TextField(); + textField.setWidth(100, Unit.PERCENTAGE); + + // Apply any value that arrived before the component was first rendered. + if (pendingValue != null) { + textField.setValue(pendingValue); + pendingValue = null; + } + + // Propagate user edits back to the field's internal value state. + textField.addValueChangeListener(e -> setValue(e.getValue())); + + return textField; + } + + /** + * Called by Vaadin when {@link #setValue(Object)} is invoked programmatically. + * Pushes the new value into the {@link TextField}, converting {@code null} to an + * empty string because {@code TextField.setValue(null)} throws {@link NullPointerException}. + * If the {@link TextField} has not been created yet (component not yet rendered), + * the value is stored as a pending value and applied in {@link #buildInputComponent()}. + */ + @Override + protected void applyValueToWidget(String value) { + if (textField != null) { + textField.setValue(value != null ? value : ""); + } else { + pendingValue = value; + } + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputRadioButtonList.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputRadioButtonList.java new file mode 100644 index 00000000000..87cf101ed9e --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputRadioButtonList.java @@ -0,0 +1,118 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import java.util.Collections; +import java.util.List; + +import com.vaadin.data.ValueProvider; +import com.vaadin.server.Setter; +import com.vaadin.ui.Component; +import com.vaadin.ui.RadioButtonGroup; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldCustomProperties; +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; + +/** + * Concrete {@link CustomizableFieldInput} for + * {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#RADIO_BUTTON_LIST}. + *

+ * Renders a Vaadin v8 {@link RadioButtonGroup} populated with the string options defined in the + * field's {@link CustomizableFieldMetadataDto#getCustomProperties() customProperties} under + * {@link CustomizableFieldCustomProperties#getOptions()}. + *

+ * The selected value is stored directly as a {@link String} in + * {@link CustomizableFieldValueDto#getValue()}. + */ +@SuppressWarnings({ + "java:S110", // suppress sonar too many parents warning + "java:S2160" // suppress missing equals +}) +public class CustomizableFieldInputRadioButtonList extends CustomizableFieldInput { + + private static final long serialVersionUID = 1L; + + private RadioButtonGroup radioButtonGroup; + /** + * Holds a value that was pushed via {@link #doSetValue(String)} before + * {@link #buildInputComponent()} had a chance to create the {@link RadioButtonGroup}. + * Applied to the group on first render. + */ + private String pendingValue; + + public CustomizableFieldInputRadioButtonList(CustomizableFieldMetadataDto metadata) { + super(metadata); + } + + @Override + protected ValueProvider getValueGetter() { + return CustomizableFieldValueDto::getValue; + } + + @Override + protected Setter getValueSetter() { + return CustomizableFieldValueDto::setValue; + } + + public Class getType() { + return String.class; + } + + @Override + protected Component buildInputComponent() { + radioButtonGroup = new RadioButtonGroup<>(); + radioButtonGroup.setItems(resolveOptions()); + + // Apply any value that arrived before the component was first rendered. + if (pendingValue != null) { + radioButtonGroup.setValue(pendingValue); + pendingValue = null; + } + + // Propagate user edits back to the field's internal value state. + radioButtonGroup.addValueChangeListener(e -> setValue(e.getValue())); + + return radioButtonGroup; + } + + /** + * Called by Vaadin when {@link #setValue(Object)} is invoked programmatically. + * Pushes the value into the {@link RadioButtonGroup}; {@code null} clears the selection. + * If the {@link RadioButtonGroup} has not been created yet (component not yet rendered), + * the value is stored as a pending value and applied in {@link #buildInputComponent()}. + */ + @Override + protected void applyValueToWidget(String value) { + if (radioButtonGroup != null) { + radioButtonGroup.setValue(value); + } else { + pendingValue = value; + } + } + + /** + * Reads the options list from {@link CustomizableFieldMetadataDto#getCustomProperties()}. + * Returns an empty list when no custom properties are set or the options are absent. + */ + private List resolveOptions() { + CustomizableFieldCustomProperties props = getFieldMetadata().getCustomProperties(); + if (props == null || props.getOptions() == null) { + return Collections.emptyList(); + } + return props.getOptions(); + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputText.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputText.java new file mode 100644 index 00000000000..a86dbbf7b58 --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputText.java @@ -0,0 +1,95 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import com.vaadin.data.ValueProvider; +import com.vaadin.server.Setter; +import com.vaadin.ui.Component; +import com.vaadin.ui.TextField; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; + +/** + * Concrete {@link CustomizableFieldInput} for {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#TEXT} + * (and, by extension, any single-line free-text field type). + *

+ * Renders a Vaadin v8 {@link TextField}. User edits are propagated to the parent field's + * internal state via a value-change listener; programmatic changes pushed through + * {@link #setValue(Object)} are reflected in the {@link TextField} via {@link #doSetValue(String)}. + */ +public class CustomizableFieldInputText extends CustomizableFieldInput { + + private static final long serialVersionUID = 1L; + + private TextField textField; + /** + * Holds a value that was pushed via {@link #doSetValue(String)} before {@link #buildInputComponent()} + * had a chance to create the {@link TextField}. Applied to the text field on first render. + */ + private String pendingValue; + + public CustomizableFieldInputText(CustomizableFieldMetadataDto metadata) { + super(metadata); + } + + @Override + protected ValueProvider getValueGetter() { + return CustomizableFieldValueDto::getValue; + } + + @Override + protected Setter getValueSetter() { + return CustomizableFieldValueDto::setValue; + } + + public Class getType() { + return String.class; + } + + @Override + protected Component buildInputComponent() { + textField = new TextField(); + textField.setWidth(100, Unit.PERCENTAGE); + + // Apply any value that arrived before the component was first rendered. + if (pendingValue != null) { + textField.setValue(pendingValue); + pendingValue = null; + } + + // Propagate user edits back to the field's internal value state. + textField.addValueChangeListener(e -> setValue(e.getValue())); + + return textField; + } + + /** + * Called by Vaadin when {@link #setValue(Object)} is invoked programmatically. + * Pushes the new value into the {@link TextField}, converting {@code null} to an + * empty string because {@code TextField.setValue(null)} throws {@link NullPointerException}. + * If the {@link TextField} has not been created yet (component not yet rendered), + * the value is stored as a pending value and applied in {@link #buildInputComponent()}. + */ + @Override + protected void applyValueToWidget(String value) { + if (textField != null) { + textField.setValue(value != null ? value : ""); + } else { + pendingValue = value; + } + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputTextArea.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputTextArea.java new file mode 100644 index 00000000000..dcc3dc0259e --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputTextArea.java @@ -0,0 +1,94 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import com.vaadin.data.ValueProvider; +import com.vaadin.server.Setter; +import com.vaadin.ui.Component; +import com.vaadin.ui.TextArea; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; + +/** + * Concrete {@link CustomizableFieldInput} for {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#TEXTAREA}. + *

+ * Renders a Vaadin v8 {@link TextArea}. User edits are propagated to the parent field's + * internal state via a value-change listener; programmatic changes pushed through + * {@link #setValue(Object)} are reflected in the {@link TextArea} via {@link #doSetValue(String)}. + */ +public class CustomizableFieldInputTextArea extends CustomizableFieldInput { + + private static final long serialVersionUID = 1L; + + private TextArea textArea; + /** + * Holds a value that was pushed via {@link #doSetValue(String)} before {@link #buildInputComponent()} + * had a chance to create the {@link TextArea}. Applied to the text area on first render. + */ + private String pendingValue; + + public CustomizableFieldInputTextArea(CustomizableFieldMetadataDto metadata) { + super(metadata); + } + + @Override + protected ValueProvider getValueGetter() { + return CustomizableFieldValueDto::getValue; + } + + @Override + protected Setter getValueSetter() { + return CustomizableFieldValueDto::setValue; + } + + public Class getType() { + return String.class; + } + + @Override + protected Component buildInputComponent() { + textArea = new TextArea(); + textArea.setWidth(100, Unit.PERCENTAGE); + + // Apply any value that arrived before the component was first rendered. + if (pendingValue != null) { + textArea.setValue(pendingValue); + pendingValue = null; + } + + // Propagate user edits back to the field's internal value state. + textArea.addValueChangeListener(e -> setValue(e.getValue())); + + return textArea; + } + + /** + * Called by Vaadin when {@link #setValue(Object)} is invoked programmatically. + * Pushes the new value into the {@link TextArea}, converting {@code null} to an + * empty string because {@code TextArea.setValue(null)} throws {@link NullPointerException}. + * If the {@link TextArea} has not been created yet (component not yet rendered), + * the value is stored as a pending value and applied in {@link #buildInputComponent()}. + */ + @Override + protected void applyValueToWidget(String value) { + if (textArea != null) { + textArea.setValue(value != null ? value : ""); + } else { + pendingValue = value; + } + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputYesNoUnknown.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputYesNoUnknown.java new file mode 100644 index 00000000000..5b32617e639 --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/utils/components/customizablefield/CustomizableFieldInputYesNoUnknown.java @@ -0,0 +1,125 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.ui.utils.components.customizablefield; + +import java.util.EnumMap; +import java.util.Map; + +import com.vaadin.data.ValueProvider; +import com.vaadin.server.Setter; +import com.vaadin.ui.Button; +import com.vaadin.ui.Component; +import com.vaadin.ui.HorizontalLayout; + +import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.utils.YesNoUnknown; +import de.symeda.sormas.ui.utils.CssStyles; + +/** + * Concrete {@link CustomizableFieldInput} for + * {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#YES_NO_UNKNOWN}. + *

+ * Renders three horizontal toggle {@link Button}s (Yes / No / Unknown). Clicking the active + * button deselects it (nullable); clicking another button selects it. The selected value is + * serialised to/from the DTO's {@code value} field as the enum name via + * {@link CustomizableFieldValueDto#getValueAsYesNoUnknown()} and + * {@link CustomizableFieldValueDto#setValueAsYesNoUnknown(YesNoUnknown)}. + */ +@SuppressWarnings({ + "java:S110", // suppress sonar too many parents warning + "java:S2160" // suppress missing equals +}) +public class CustomizableFieldInputYesNoUnknown extends CustomizableFieldInput { + + private static final long serialVersionUID = 1L; + + private final Map buttons = new EnumMap<>(YesNoUnknown.class); + /** + * Holds a value that was pushed via {@link #doSetValue(YesNoUnknown)} before + * {@link #buildInputComponent()} had a chance to create the buttons. + * Applied on first render. + */ + private YesNoUnknown pendingValue; + + public CustomizableFieldInputYesNoUnknown(CustomizableFieldMetadataDto metadata) { + super(metadata); + } + + @Override + protected ValueProvider getValueGetter() { + return CustomizableFieldValueDto::getValueAsYesNoUnknown; + } + + @Override + protected Setter getValueSetter() { + return CustomizableFieldValueDto::setValueAsYesNoUnknown; + } + + public Class getType() { + return YesNoUnknown.class; + } + + @Override + protected Component buildInputComponent() { + HorizontalLayout layout = new HorizontalLayout(); + layout.setSpacing(false); + layout.setMargin(false); + CssStyles.style(layout, CssStyles.YES_NO_UNKNOWN_GROUP); + + for (YesNoUnknown option : YesNoUnknown.values()) { + Button btn = new Button(I18nProperties.getEnumCaption(option)); + btn.addClickListener(e -> { + YesNoUnknown current = getValue(); + setValue(option == current ? null : option); + }); + buttons.put(option, btn); + layout.addComponent(btn); + } + + if (pendingValue != null) { + applyButtonStyles(pendingValue); + pendingValue = null; + } + + return layout; + } + + /** + * Called by Vaadin when {@link #setValue(Object)} is invoked programmatically. + * Updates button highlighting to reflect the new selection. + * If the buttons have not been created yet, stores the value as pending. + */ + @Override + protected void applyValueToWidget(YesNoUnknown value) { + if (buttons.isEmpty()) { + pendingValue = value; + } else { + applyButtonStyles(value); + } + } + + private void applyButtonStyles(YesNoUnknown selected) { + for (Map.Entry entry : buttons.entrySet()) { + if (entry.getKey() == selected) { + entry.getValue().addStyleName(CssStyles.YES_NO_UNKNOWN_OPTION_SELECTED); + } else { + entry.getValue().removeStyleName(CssStyles.YES_NO_UNKNOWN_OPTION_SELECTED); + } + } + } +} diff --git a/sormas-ui/src/main/webapp/VAADIN/themes/sormas/components/button.scss b/sormas-ui/src/main/webapp/VAADIN/themes/sormas/components/button.scss index 79bde226a5a..915866295e8 100644 --- a/sormas-ui/src/main/webapp/VAADIN/themes/sormas/components/button.scss +++ b/sormas-ui/src/main/webapp/VAADIN/themes/sormas/components/button.scss @@ -1,96 +1,150 @@ - @mixin sormas-button { .v-button { text-transform: uppercase; border-color: $v-selection-color; box-shadow: none; - - &.v-button-link { - text-transform: none; - } + + &.v-button-link { + text-transform: none; + } &.v-button-caption-overflow-label { text-overflow: ellipsis; overflow: hidden; } - - &.v-button-font-size-large { - font-size: 1.5em; - } - + + &.v-button-font-size-large { + font-size: 1.5em; + } + &.v-button-subtle { - background-color: inherit; + background-color: inherit; } - + &.v-button-border-neutral { - border-color: #000000; + border-color: #000000; } &.v-button-compact { - padding: 0px; - height: auto; + padding: 0px; + height: auto; } - + &.v-button-danger, &.v-button-critical { @include valo-button-style($background-color: $s-critical-color); border-color: darken($s-critical-color, 25%) } - + &.v-button-warning { @include valo-button-style($background-color: $s-warning-color); border-color: darken($s-warning-color, 25%) } - + &.v-button-filter { background-color: $v-focus-color; text-decoration: none; text-transform: none; - font-weight: bold; + font-weight: bold; color: $v-font-color; - + &.v-button-filter-light { background-color: $v-focus-light-color; - + &:hover { background-color: $v-focus-color; } - + &.v-disabled { - background-color: $v-focus-light-color; + background-color: $v-focus-light-color; } } - + &.v-button-filter-dark { - background-color: $s-primary-color; - color: #FFFFFF; + background-color: $s-primary-color; + color: #FFFFFF; } - + &.v-button-filter-enabled { - background-color: #2eb82e; - color: #FFFFFF; - - &:hover { - background-color: #248f24; - } + background-color: #2eb82e; + color: #FFFFFF; + + &:hover { + background-color: #248f24; + } } - + &.v-button-filter-disabled { - background-color: #C8C8C8; - - &:hover { - background-color: #B8B8B8; - } + background-color: #C8C8C8; + + &:hover { + background-color: #B8B8B8; + } } - + &.v-button-filter-small { - height: 26px; - padding: 0px 7px; + height: 26px; + padding: 0px 7px; } } - &.v-button-vertical-align-top, .vertical-align-top { + + &.v-button-vertical-align-top, + .vertical-align-top { vertical-align: top; } } + + // Segmented button group used for YesNoUnknown fields — matches the NullableOptionGroup horizontal style. + .v-horizontallayout.yes-no-unknown-group { + display: flex; + align-items: center; + margin-bottom: 1rem; + + .v-slot { + margin-right: -2px; + } + + .v-button { + border: $v-border; + border-radius: 0; + background: $v-app-background-color; + box-shadow: none; + font-size: 11px; + text-transform: uppercase; + height: 28px; + padding: 0; + min-width: 0; + + .v-button-wrap { + padding: 4px 8px; + height: 28px; + line-height: 20px; + } + + &:first-child { + border-radius: 4px 0 0 4px; + } + + &:last-child { + border-radius: 0 4px 4px 0; + } + + &.yes-no-unknown-selected { + background: $v-focus-color; + + .v-button-wrap { + padding: 4px 8px; + } + } + + &:hover:not(.v-disabled) { + background: darken($v-focus-color, 8%); + } + + &.v-disabled { + opacity: $v-disabled-opacity; + } + } + } } \ No newline at end of file diff --git a/sormas-ui/src/main/webapp/VAADIN/themes/sormas/components/customizablefields.scss b/sormas-ui/src/main/webapp/VAADIN/themes/sormas/components/customizablefields.scss new file mode 100644 index 00000000000..c638ecb1664 --- /dev/null +++ b/sormas-ui/src/main/webapp/VAADIN/themes/sormas/components/customizablefields.scss @@ -0,0 +1,15 @@ +@mixin sormas-customizablefields { + + .customizable-fields-group { + background-color: #fffcf6; + border-radius: 0 4px 4px 0; + padding-bottom: 8px; + margin-bottom: 0.5rem; + font-size: 0.95em; + color: #5a6a78; + + .v-caption { + font-style: italic; + } + } +} \ No newline at end of file diff --git a/sormas-ui/src/main/webapp/VAADIN/themes/sormas/sormas.scss b/sormas-ui/src/main/webapp/VAADIN/themes/sormas/sormas.scss index 2da49bdb443..103dcb40410 100644 --- a/sormas-ui/src/main/webapp/VAADIN/themes/sormas/sormas.scss +++ b/sormas-ui/src/main/webapp/VAADIN/themes/sormas/sormas.scss @@ -50,7 +50,7 @@ $v-gradient: false; $v-border: 2px solid $v-focus-color; $v-border-subtle: 2px solid $v-selection-color; $v-border-primary: 2px solid $v-primary-color; -$v-caption-font-weight: 600 !default; +$v-caption-font-weight: 600 !default; $v-layout-margin-top: round($v-unit-size / 1.5) !default; $v-layout-margin-right: $v-layout-margin-top !default; @@ -60,7 +60,7 @@ $v-layout-spacing-vertical: round($v-unit-size / 1.8) !default; $v-layout-spacing-horizontal: round($v-unit-size / 1.8) !default; -$editor-shadow: 0 0 10px 10px rgba(0,0,0,.1) !default; +$editor-shadow: 0 0 10px 10px rgba(0, 0, 0, .1) !default; $editor-background-color: #6691C4 !default; $editor-embed-background-color: darken($editor-background-color, 5%) !default; $editor-raised-background-color: lighten($editor-background-color, 10%) !default; @@ -75,6 +75,7 @@ $editor-caption-font-color: valo-font-color($editor-background-color, 0.5) !defa @import "components/combobox.scss"; @import "components/countelement.scss"; @import "components/customcomponent.scss"; +@import "components/customizablefields.scss"; @import "components/datefield.scss"; @import "components/exttokenfield.scss"; @import "components/grid.scss"; @@ -119,13 +120,16 @@ $editor-caption-font-color: valo-font-color($editor-background-color, 0.5) !defa } @function valo-font-color ($bg-color, $contrast: $v-font-color-contrast) { - @if type-of($bg-color) == color { + @if type-of($bg-color)==color { @if is-dark-color($bg-color) { @return $v-background-color; - } @else { + } + + @else { @return #374B59; } } + @return #374B59; } @@ -133,12 +137,13 @@ $editor-caption-font-color: valo-font-color($editor-background-color, 0.5) !defa @include valo; @include sormas-global; - + @include sormas-button; - @include sormas-checkbox; + @include sormas-checkbox; @include sormas-combobox; @include sormas-countelement; @include sormas-customcomponent; + @include sormas-customizablefields; @include sormas-datefield; @include sormas-exttokenfield; @include sormas-grid; @@ -172,11 +177,11 @@ $editor-caption-font-color: valo-font-color($editor-background-color, 0.5) !defa @include sormas-login-view; @include sormas-statistics-view; @include sormas-view; - + @include sormas-bootstrap-grid; @include sormas-deprecated; - + .sormas-content { overflow: auto; } -} +} \ No newline at end of file diff --git a/sormas-ui/src/main/webapp/WEB-INF/glassfish-web.xml b/sormas-ui/src/main/webapp/WEB-INF/glassfish-web.xml index 58b3520520b..9970219c1f3 100644 --- a/sormas-ui/src/main/webapp/WEB-INF/glassfish-web.xml +++ b/sormas-ui/src/main/webapp/WEB-INF/glassfish-web.xml @@ -1,9 +1,7 @@ - + - + CASE_CREATE @@ -1221,4 +1219,9 @@ EPIPULSE_EXPORT_DELETE - + + CUSTOMIZABLE_FIELD_MANAGEMENT + CUSTOMIZABLE_FIELD_MANAGEMENT + + + \ No newline at end of file diff --git a/sormas-ui/src/main/webapp/WEB-INF/web.xml b/sormas-ui/src/main/webapp/WEB-INF/web.xml index bcdff354bf7..a9f10d30120 100644 --- a/sormas-ui/src/main/webapp/WEB-INF/web.xml +++ b/sormas-ui/src/main/webapp/WEB-INF/web.xml @@ -1,9 +1,9 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xmlns="http://xmlns.jcp.org/xml/ns/javaee" + xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" + version="3.1"> SORMAS Web Interface @@ -25,9 +25,9 @@ CASE_VIEW - - CASE_VIEW_ARCHIVED - + + CASE_VIEW_ARCHIVED + CASE_EDIT @@ -89,9 +89,9 @@ IMMUNIZATION_VIEW - - IMMUNIZATION_VIEW_ARCHIVED - + + IMMUNIZATION_VIEW_ARCHIVED + IMMUNIZATION_CREATE @@ -225,9 +225,9 @@ CONTACT_VIEW - - CONTACT_VIEW_ARCHIVED - + + CONTACT_VIEW_ARCHIVED + CONTACT_ARCHIVE @@ -305,9 +305,9 @@ TASK_ARCHIVE - - TASK_VIEW_ARCHIVED - + + TASK_VIEW_ARCHIVED + ACTION_CREATE @@ -329,9 +329,9 @@ EVENT_VIEW - - EVENT_VIEW_ARCHIVED - + + EVENT_VIEW_ARCHIVED + EVENT_EDIT @@ -377,9 +377,9 @@ EVENTPARTICIPANT_VIEW - - EVENTPARTICIPANT_VIEW_ARCHIVED - + + EVENTPARTICIPANT_VIEW_ARCHIVED + EVENTGROUP_CREATE @@ -401,9 +401,9 @@ EVENTGROUP_DELETE - - EVENTGROUP_VIEW_ARCHIVED - + + EVENTGROUP_VIEW_ARCHIVED + WEEKLYREPORT_CREATE @@ -425,9 +425,9 @@ USER_VIEW - - USER_ROLE_VIEW - + + USER_ROLE_VIEW + USER_ROLE_EDIT @@ -477,9 +477,9 @@ INFRASTRUCTURE_VIEW - - INFRASTRUCTURE_VIEW_ARCHIVED - + + INFRASTRUCTURE_VIEW_ARCHIVED + INFRASTRUCTURE_EXPORT @@ -625,9 +625,9 @@ CAMPAIGN_VIEW - - CAMPAIGN_VIEW_ARCHIVED - + + CAMPAIGN_VIEW_ARCHIVED + CAMPAIGN_EDIT @@ -645,9 +645,9 @@ CAMPAIGN_FORM_DATA_VIEW - - CAMPAIGN_FORM_DATA_VIEW_ARCHIVED - + + CAMPAIGN_FORM_DATA_VIEW_ARCHIVED + CAMPAIGN_FORM_DATA_EDIT @@ -725,9 +725,9 @@ TRAVEL_ENTRY_VIEW - - TRAVEL_ENTRY_VIEW_ARCHIVED - + + TRAVEL_ENTRY_VIEW_ARCHIVED + TRAVEL_ENTRY_CREATE @@ -761,9 +761,9 @@ ENVIRONMENT_ARCHIVE - - ENVIRONMENT_VIEW_ARCHIVED - + + ENVIRONMENT_VIEW_ARCHIVED + ENVIRONMENT_DELETE @@ -841,13 +841,13 @@ SELF_REPORT_DELETE - - SELF_REPORT_EXPORT - + + SELF_REPORT_EXPORT + - - SELF_REPORT_IMPORT - + + SELF_REPORT_IMPORT + SELF_REPORT_ARCHIVE @@ -989,4 +989,8 @@ EPIPULSE_EXPORT_DELETE - + + CUSTOMIZABLE_FIELD_MANAGEMENT + + + \ No newline at end of file diff --git a/sormas-ui/src/test/resources/META-INF/persistence.xml b/sormas-ui/src/test/resources/META-INF/persistence.xml index 448d702fd4e..3a4842187be 100644 --- a/sormas-ui/src/test/resources/META-INF/persistence.xml +++ b/sormas-ui/src/test/resources/META-INF/persistence.xml @@ -74,6 +74,8 @@ de.symeda.sormas.backend.infrastructure.subcontinent.Subcontinent de.symeda.sormas.backend.sormastosormas.share.incoming.SormasToSormasShareRequest de.symeda.sormas.backend.customizableenum.CustomizableEnumValue + de.symeda.sormas.backend.customizablefield.CustomizableFieldMetadata + de.symeda.sormas.backend.customizablefield.CustomizableFieldValue de.symeda.sormas.backend.immunization.entity.BaseImmunization de.symeda.sormas.backend.immunization.entity.Immunization de.symeda.sormas.backend.immunization.entity.DirectoryImmunization