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 adc14657665..1bf47487576 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 @@ -1784,12 +1784,24 @@ public void syncSharesAsync(ShareTreeCriteria criteria) { private void doSave(Case caze, boolean handleChanges, CaseDataDto existingCaseDto, boolean syncShares) { service.ensurePersisted(caze); + ensureDefaultCustomizableFieldValues(caze, existingCaseDto); if (handleChanges) { updateCaseVisitAssociations(existingCaseDto, caze); onCaseChanged(existingCaseDto, caze, syncShares); } } + private void ensureDefaultCustomizableFieldValues(Case caze, CaseDataDto existingCaseDto) { + if (existingCaseDto == null || existingCaseDto.getUuid() == null) { + customizableFieldValueService.ensureDefaultValuesForEntity(caze.getUuid(), CustomizableFieldContext.CASE); + } + + epiDataFacade.ensureDefaultCustomizableFieldValues( + caze.getEpiData(), + existingCaseDto != null ? existingCaseDto.getEpiData() : null, + caze.getDisease()); + } + private void updateCaseVisitAssociations(CaseDataDto existingCase, Case caze) { if (existingCase != null 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 9e61f594299..588829f5108 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 @@ -419,6 +419,10 @@ public ContactDto save(ContactDto dto, boolean handleChanges, boolean handleCase Contact entity = fillOrBuildEntity(dto, existingContact, checkChangeDate); service.ensurePersisted(entity); + epiDataFacade.ensureDefaultCustomizableFieldValues( + entity.getEpiData(), + existingContactDto != null ? existingContactDto.getEpiData() : null, + entity.getDisease()); if (existingContact == null && featureConfigurationFacade.isTaskGenerationFeatureEnabled(TaskType.CONTACT_INVESTIGATION)) { createInvestigationTask(entity); 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 index 04b5d98af3e..8dc1a4a0051 100644 --- 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 @@ -287,7 +287,7 @@ protected CustomizableFieldMetadataDto toDto(CustomizableFieldMetadata source) { return target; } - private static CustomizableFieldVisibilityRestrictions parseVisibilityRestrictions(String json) { + public static CustomizableFieldVisibilityRestrictions parseVisibilityRestrictions(String json) { if (StringUtils.isBlank(json)) { 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 index a4eaa9d834e..f55937c944e 100644 --- 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 @@ -16,9 +16,12 @@ package de.symeda.sormas.backend.customizablefield; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import javax.ejb.EJB; import javax.ejb.LocalBean; import javax.ejb.Stateless; import javax.persistence.criteria.CriteriaBuilder; @@ -27,10 +30,14 @@ 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.common.DeletionDetails; import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; 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.backend.common.AbstractCoreAdoService; import de.symeda.sormas.backend.common.AbstractDomainObject; import de.symeda.sormas.backend.common.DeletableAdo; @@ -42,6 +49,9 @@ @LocalBean public class CustomizableFieldValueService extends AbstractCoreAdoService { + @EJB + private CustomizableFieldMetadataService customizableFieldMetadataService; + public CustomizableFieldValueService() { super(CustomizableFieldValue.class, DeletableEntityType.CUSTOMIZABLE_FIELD_VALUE); } @@ -98,6 +108,59 @@ public void saveEntityValues( } } + public void ensureDefaultValuesForEntity(String entityUuid, CustomizableFieldContext contextClass) { + ensureDefaultValuesForEntity(entityUuid, contextClass, null); + } + + /** + * Ensures default values for customizable fields for the given entity. + *

+ * Only fields that are visible for the given visibility context will have their default values + * persisted. Fields with specific visibility restrictions will be skipped + * if the visiblity context is not matched. + * + * @param entityUuid + * the UUID of the entity + * @param contextClass + * the context class for the customizable fields + * @param visibilityContext + * the visibility context to check against field restrictions; may be null to skip + * visibility checks + */ + public void ensureDefaultValuesForEntity( + String entityUuid, + CustomizableFieldContext contextClass, + CustomizableFieldVisibilityContext visibilityContext) { + if (StringUtils.isBlank(entityUuid) || contextClass == null) { + return; + } + + Map existing = getValuesForEntity(entityUuid, contextClass); + Set existingMetadataUuids = new HashSet<>(); + for (CustomizableFieldMetadata metadata : existing.keySet()) { + existingMetadataUuids.add(metadata.getUuid()); + } + + for (CustomizableFieldMetadata metadata : customizableFieldMetadataService.getActiveFieldsForContext(contextClass)) { + if (existingMetadataUuids.contains(metadata.getUuid()) || StringUtils.isBlank(metadata.getDefaultValue())) { + continue; + } + + // Skip fields that are not visible for the given context + if (visibilityContext != null && metadata.getVisibilityRestrictions() != null && !metadata.getVisibilityRestrictions().isBlank()) { + CustomizableFieldVisibilityRestrictions metadataVisibilityRestrictions = + CustomizableFieldMetadataFacadeEjb.parseVisibilityRestrictions(metadata.getVisibilityRestrictions()); + if (!metadataVisibilityRestrictions.matches(visibilityContext)) { + continue; + } + } + + CustomizableFieldValue value = createNewValue(metadata, entityUuid, contextClass); + value.setValue(metadata.getDefaultValue()); + ensurePersisted(value); + } + } + public void deleteValuesForEntity(String entityUuid, CustomizableFieldContext contextClass) { Map values = getValuesForEntity(entityUuid, contextClass); for (CustomizableFieldValue value : values.values()) { 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 dbba6e2e82d..67f48556d4c 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 @@ -28,9 +28,11 @@ import javax.ejb.LocalBean; import javax.ejb.Stateless; +import de.symeda.sormas.api.Disease; 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.customizablefield.CustomizableFieldVisibilityContext; import de.symeda.sormas.api.epidata.EpiDataDto; import de.symeda.sormas.api.epidata.EpiDataFacade; import de.symeda.sormas.api.exposure.ExposureDto; @@ -79,6 +81,35 @@ public void softDeleteCustomizableFieldValues(EpiData epiData, DeletionDetails d } } + public void ensureDefaultCustomizableFieldValues(EpiData epiData, EpiDataDto existingEpiData, Disease disease) { + if (epiData == null || epiData.getUuid() == null) { + return; + } + + // Build visibility context from the provided disease + CustomizableFieldVisibilityContext visibilityContext = disease != null ? new CustomizableFieldVisibilityContext().withDisease(disease) : null; + + if (existingEpiData == null || existingEpiData.getUuid() == null) { + customizableFieldValueService.ensureDefaultValuesForEntity(epiData.getUuid(), CustomizableFieldContext.EPIDATA, visibilityContext); + } + + HashSet existingExposureUuids = new HashSet<>(); + if (existingEpiData != null && existingEpiData.getExposures() != null) { + for (ExposureDto existingExposure : existingEpiData.getExposures()) { + if (existingExposure.getUuid() != null) { + existingExposureUuids.add(existingExposure.getUuid()); + } + } + } + + for (Exposure exposure : epiData.getExposures()) { + if (exposure.getUuid() != null && !existingExposureUuids.contains(exposure.getUuid())) { + // Exposure inherits disease from parent EpiData's disease + customizableFieldValueService.ensureDefaultValuesForEntity(exposure.getUuid(), CustomizableFieldContext.EXPOSURE, visibilityContext); + } + } + } + public EpiData fillOrBuildEntity(EpiDataDto source, EpiData target, boolean checkChangeDate) { if (source == null) { return null; 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 index e7cd856b5b0..b9ff48f54ec 100644 --- 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 @@ -32,6 +32,7 @@ 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.contact.ContactDto; import de.symeda.sormas.api.customizablefield.CustomizableFieldContext; import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup; import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto; @@ -69,12 +70,7 @@ 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 metadata = buildMetadata(context, "customField_" + context.name().toLowerCase()); CustomizableFieldMetadataDto savedMetadata = metadataFacade.save(metadata); assertThat(savedMetadata.getUuid(), is(notNullValue())); @@ -114,6 +110,79 @@ void testCaseRelatedCustomizableFields(CustomizableFieldContext context) { assertThat(values.get(savedMetadata).getValue(), is(equalTo("custom-value"))); } + @ParameterizedTest + @EnumSource(value = CustomizableFieldContext.class, + names = { + "CASE", + "EPIDATA", + "EXPOSURE" }) + void testCaseRelatedDefaultValuesArePersistedOnCreate(CustomizableFieldContext context) { + CustomizableFieldMetadataFacade metadataFacade = getBean(CustomizableFieldMetadataFacadeEjb.CustomizableFieldMetadataFacadeEjbLocal.class); + CustomizableFieldValueFacade valueFacade = getBean(CustomizableFieldValueFacadeEjb.CustomizableFieldValueFacadeEjbLocal.class); + + CustomizableFieldMetadataDto metadata = buildMetadata(context, "defaultField_" + context.name().toLowerCase()); + metadata.setDefaultValue("default-" + context.name().toLowerCase()); + CustomizableFieldMetadataDto savedMetadata = metadataFacade.save(metadata); + + PersonDto person = creator.createPerson("Default", "Case", Sex.MALE, 1981, 2, 2); + CaseDataDto caze = creator.createCase( + surveillanceSupervisor.toReference(), + person.toReference(), + Disease.EVD, + CaseClassification.PROBABLE, + InvestigationStatus.PENDING, + new java.util.Date(), + rdcf); + + if (context == CustomizableFieldContext.EXPOSURE) { + caze.getEpiData().getExposures().add(ExposureDto.build(ExposureType.TRAVEL)); + caze = getCaseFacade().save(caze); + } + + Map values = + valueFacade.getValuesForEntity(getEntityUuidForContext(context, caze), context); + + assertThat(values, hasKey(savedMetadata)); + assertThat(values.get(savedMetadata).getValue(), is(equalTo(savedMetadata.getDefaultValue()))); + } + + @ParameterizedTest + @EnumSource(value = CustomizableFieldContext.class, + names = { + "EPIDATA", + "EXPOSURE" }) + void testContactRelatedDefaultValuesArePersistedOnCreate(CustomizableFieldContext context) { + CustomizableFieldMetadataFacade metadataFacade = getBean(CustomizableFieldMetadataFacadeEjb.CustomizableFieldMetadataFacadeEjbLocal.class); + CustomizableFieldValueFacade valueFacade = getBean(CustomizableFieldValueFacadeEjb.CustomizableFieldValueFacadeEjbLocal.class); + + CustomizableFieldMetadataDto metadata = buildMetadata(context, "contactDefaultField_" + context.name().toLowerCase()); + metadata.setDefaultValue("contact-default-" + context.name().toLowerCase()); + CustomizableFieldMetadataDto savedMetadata = metadataFacade.save(metadata); + + PersonDto person = creator.createPerson("Default", "Contact", Sex.FEMALE, 1982, 3, 3); + ContactDto contact = creator.createContact(surveillanceSupervisor.toReference(), person.toReference(), Disease.EVD, dto -> { + if (context == CustomizableFieldContext.EXPOSURE) { + dto.getEpiData().getExposures().add(ExposureDto.build(ExposureType.TRAVEL)); + } + }); + + Map values = + valueFacade.getValuesForEntity(getEntityUuidForContext(context, contact), context); + + assertThat(values, hasKey(savedMetadata)); + assertThat(values.get(savedMetadata).getValue(), is(equalTo(savedMetadata.getDefaultValue()))); + } + + private CustomizableFieldMetadataDto buildMetadata(CustomizableFieldContext context, String name) { + CustomizableFieldMetadataDto metadata = new CustomizableFieldMetadataDto(); + metadata.setName(name); + metadata.setFieldType(CustomizableFieldType.TEXT); + metadata.setContextClass(context); + metadata.setUiGroup(CustomizableFieldGroup.getGroupsForContext(context).stream().findFirst().orElse(null)); + metadata.setUiLinePosition(1); + return metadata; + } + private String getEntityUuidForContext(CustomizableFieldContext context, CaseDataDto caze) { switch (context) { case CASE: @@ -128,4 +197,17 @@ private String getEntityUuidForContext(CustomizableFieldContext context, CaseDat throw new IllegalArgumentException("Unhandled context: " + context); } } + + private String getEntityUuidForContext(CustomizableFieldContext context, ContactDto contact) { + switch (context) { + case EPIDATA: + return contact.getEpiData().getUuid(); + case EXPOSURE: + return contact.getEpiData().getExposures() != null && !contact.getEpiData().getExposures().isEmpty() + ? contact.getEpiData().getExposures().get(0).getUuid() + : null; + default: + throw new IllegalArgumentException("Unhandled context: " + context); + } + } } 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 index e7c346365ce..22a5d3dd02e 100644 --- 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 @@ -19,6 +19,8 @@ import static de.symeda.sormas.ui.utils.LayoutUtil.fluidRowLocs; import static de.symeda.sormas.ui.utils.LayoutUtil.loc; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; @@ -26,6 +28,8 @@ import java.util.List; import java.util.Set; +import javax.annotation.Nullable; + import com.vaadin.data.Binder; import com.vaadin.data.BinderValidationStatus; import com.vaadin.data.Converter; @@ -36,6 +40,8 @@ import com.vaadin.ui.CheckBox; import com.vaadin.ui.ComboBox; import com.vaadin.ui.CustomLayout; +import com.vaadin.ui.DateField; +import com.vaadin.ui.DateTimeField; import com.vaadin.ui.Label; import com.vaadin.ui.TextField; @@ -50,6 +56,9 @@ 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.api.utils.DateFormatHelper; +import de.symeda.sormas.api.utils.DateHelper; +import de.symeda.sormas.api.utils.YesNoUnknown; import de.symeda.sormas.ui.utils.CssStyles; import de.symeda.sormas.ui.utils.components.CheckboxSet; @@ -102,6 +111,10 @@ public class CustomizableFieldEditForm extends CustomLayout { private final CheckboxSet cbsDiseases; private final TextField defaultValueField; private final ComboBox defaultValueCombo; + private final ComboBox defaultValueCheckboxCombo; + private final ComboBox defaultValueYesNoUnknownCombo; + private final DateField defaultValueDateField; + private final DateTimeField defaultValueDateTimeField; private final CustomizableFieldOptionsComponent optionsComponent; private final Label optionsHeading; private final CustomizableFieldTranslationsComponent translationsComponent; @@ -168,12 +181,35 @@ public CustomizableFieldEditForm(boolean isEdit) { addComponent(defaultValueField, CustomizableFieldMetadataDto.DEFAULT_VALUE); binder.forField(defaultValueField) .withNullRepresentation("") + .withValidator( + value -> value == null || value.isEmpty() || isValidDefaultValueForType(value, fieldTypeCombo.getValue()), + errorMessage -> getDefaultValueErrorMessage(fieldTypeCombo.getValue())) .bind(CustomizableFieldMetadataDto::getDefaultValue, CustomizableFieldMetadataDto::setDefaultValue); defaultValueCombo = new ComboBox<>(defaultValueCaption); defaultValueCombo.setWidth(100, Unit.PERCENTAGE); defaultValueCombo.setEmptySelectionAllowed(true); + defaultValueCheckboxCombo = new ComboBox<>(defaultValueCaption); + defaultValueCheckboxCombo.setWidth(100, Unit.PERCENTAGE); + defaultValueCheckboxCombo.setEmptySelectionAllowed(true); + defaultValueCheckboxCombo.setItems(Boolean.TRUE, Boolean.FALSE); + defaultValueCheckboxCombo.setItemCaptionGenerator(value -> Boolean.TRUE.equals(value) ? "True" : "False"); + + defaultValueYesNoUnknownCombo = new ComboBox<>(defaultValueCaption); + defaultValueYesNoUnknownCombo.setWidth(100, Unit.PERCENTAGE); + defaultValueYesNoUnknownCombo.setEmptySelectionAllowed(true); + defaultValueYesNoUnknownCombo.setItems(YesNoUnknown.values()); + defaultValueYesNoUnknownCombo.setItemCaptionGenerator(I18nProperties::getEnumCaption); + + defaultValueDateField = new DateField(defaultValueCaption); + defaultValueDateField.setWidth(100, Unit.PERCENTAGE); + defaultValueDateField.setDateFormat(DateFormatHelper.getDateFormatPattern()); + + defaultValueDateTimeField = new DateTimeField(defaultValueCaption); + defaultValueDateTimeField.setWidth(100, Unit.PERCENTAGE); + defaultValueDateTimeField.setDateFormat(DateHelper.getLocalDateTimeFormat(I18nProperties.getUserLanguage()).toPattern()); + optionsHeading = new Label(I18nProperties.getString(Strings.labelCustomizableFieldOptions)); optionsHeading.addStyleName(CssStyles.VAADIN_CAPTION); optionsHeading.setVisible(false); @@ -284,12 +320,38 @@ 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 { + + if (!visible) { optionsComponent.setValue(null); defaultValueCombo.clear(); + } + + if (type == null) { + addComponent(defaultValueField, CustomizableFieldMetadataDto.DEFAULT_VALUE); + return; + } + + switch (type) { + case COMBOBOX: + case CHECKBOX_LIST: + case RADIO_BUTTON_LIST: + addComponent(defaultValueCombo, CustomizableFieldMetadataDto.DEFAULT_VALUE); + break; + case CHECKBOX: + addComponent(defaultValueCheckboxCombo, CustomizableFieldMetadataDto.DEFAULT_VALUE); + break; + case YES_NO_UNKNOWN: + addComponent(defaultValueYesNoUnknownCombo, CustomizableFieldMetadataDto.DEFAULT_VALUE); + break; + case DATE: + addComponent(defaultValueDateField, CustomizableFieldMetadataDto.DEFAULT_VALUE); + break; + case DATE_TIME: + addComponent(defaultValueDateTimeField, CustomizableFieldMetadataDto.DEFAULT_VALUE); + break; + default: addComponent(defaultValueField, CustomizableFieldMetadataDto.DEFAULT_VALUE); + break; } } @@ -316,6 +378,7 @@ public void setValue(CustomizableFieldMetadataDto newDto) { CustomizableFieldType type = newDto != null ? newDto.getFieldType() : null; updateOptionsVisibility(type); + setTypeSpecificDefaultValue(type, newDto != null ? newDto.getDefaultValue() : null); if (newDto != null && OPTIONS_TYPES.contains(type)) { List options = newDto.getCustomProperties() != null ? newDto.getCustomProperties().getOptions() : null; optionsComponent.setValue(options); @@ -355,6 +418,14 @@ public CustomizableFieldMetadataDto getValue() { } else if (props != null) { props.setOptions(null); } + } else if (dto.getFieldType() == CustomizableFieldType.CHECKBOX) { + dto.setDefaultValue(formatCheckboxDefaultValue(defaultValueCheckboxCombo.getValue())); + } else if (dto.getFieldType() == CustomizableFieldType.YES_NO_UNKNOWN) { + dto.setDefaultValue(formatYesNoUnknownDefaultValue(defaultValueYesNoUnknownCombo.getValue())); + } else if (dto.getFieldType() == CustomizableFieldType.DATE) { + dto.setDefaultValue(formatDateDefaultValue(defaultValueDateField.getValue())); + } else if (dto.getFieldType() == CustomizableFieldType.DATE_TIME) { + dto.setDefaultValue(formatDateTimeDefaultValue(defaultValueDateTimeField.getValue())); } dto.setTranslations(translationsComponent.getValue()); @@ -383,6 +454,107 @@ private String caption(String propertyId) { return I18nProperties.getPrefixCaption(CustomizableFieldMetadataDto.I18N_PREFIX, propertyId); } + private void setTypeSpecificDefaultValue(CustomizableFieldType type, String defaultValue) { + if (type == null) { + defaultValueCheckboxCombo.clear(); + defaultValueYesNoUnknownCombo.clear(); + defaultValueDateField.clear(); + defaultValueDateTimeField.clear(); + return; + } + + switch (type) { + case CHECKBOX: + defaultValueCheckboxCombo.setValue(parseCheckboxDefaultValue(defaultValue)); + break; + case YES_NO_UNKNOWN: + defaultValueYesNoUnknownCombo.setValue(parseYesNoUnknownDefaultValue(defaultValue)); + break; + case DATE: + defaultValueDateField.setValue(parseDateDefaultValue(defaultValue)); + break; + case DATE_TIME: + defaultValueDateTimeField.setValue(parseDateTimeDefaultValue(defaultValue)); + break; + default: + defaultValueCheckboxCombo.clear(); + defaultValueYesNoUnknownCombo.clear(); + defaultValueDateField.clear(); + defaultValueDateTimeField.clear(); + break; + } + } + + @Nullable + private static Boolean parseCheckboxDefaultValue(String defaultValue) { + if (defaultValue == null || defaultValue.trim().isEmpty()) { + return null; + } + if (Boolean.TRUE.toString().equalsIgnoreCase(defaultValue.trim())) { + return Boolean.TRUE; + } + if (Boolean.FALSE.toString().equalsIgnoreCase(defaultValue.trim())) { + return Boolean.FALSE; + } + return null; + } + + @Nullable + private static String formatCheckboxDefaultValue(Boolean value) { + return value != null ? value.toString() : null; + } + + @Nullable + private static YesNoUnknown parseYesNoUnknownDefaultValue(String defaultValue) { + if (defaultValue == null || defaultValue.trim().isEmpty()) { + return null; + } + try { + return YesNoUnknown.valueOf(defaultValue.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } + + @Nullable + private static String formatYesNoUnknownDefaultValue(YesNoUnknown value) { + return value != null ? value.name() : null; + } + + @Nullable + private static LocalDate parseDateDefaultValue(String defaultValue) { + if (defaultValue == null || defaultValue.trim().isEmpty()) { + return null; + } + try { + return LocalDate.parse(defaultValue.trim()); + } catch (RuntimeException e) { + return null; + } + } + + @Nullable + private static String formatDateDefaultValue(LocalDate value) { + return value != null ? value.toString() : null; + } + + @Nullable + private static LocalDateTime parseDateTimeDefaultValue(String defaultValue) { + if (defaultValue == null || defaultValue.trim().isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(defaultValue.trim()); + } catch (RuntimeException e) { + return null; + } + } + + @Nullable + private static String formatDateTimeDefaultValue(LocalDateTime value) { + return value != null ? value.withSecond(0).withNano(0).toString() : null; + } + private static Converter stringToFloatConverter(String errorMessage) { return new Converter() { @@ -404,4 +576,41 @@ public String convertToPresentation(Float value, com.vaadin.data.ValueContext co } }; } + + /** + * Checks if the given default value is valid for the specified field type. + */ + private boolean isValidDefaultValueForType(String value, CustomizableFieldType type) { + if (value == null || value.isEmpty()) { + return true; + } + if (type == CustomizableFieldType.NUMBER) { + try { + Integer.parseInt(value.trim()); + return true; + } catch (NumberFormatException e) { + return false; + } + } else if (type == CustomizableFieldType.DECIMAL) { + try { + Float.parseFloat(value.trim()); + return true; + } catch (NumberFormatException e) { + return false; + } + } + return true; + } + + /** + * Returns the appropriate error message for an invalid default value based on the field type. + */ + private String getDefaultValueErrorMessage(CustomizableFieldType type) { + if (type == CustomizableFieldType.NUMBER) { + return I18nProperties.getValidationError(Validations.onlyIntegerNumbersAllowed, caption(CustomizableFieldMetadataDto.DEFAULT_VALUE)); + } else if (type == CustomizableFieldType.DECIMAL) { + return I18nProperties.getValidationError(Validations.onlyDecimalNumbersAllowed, caption(CustomizableFieldMetadataDto.DEFAULT_VALUE)); + } + return I18nProperties.getValidationError(Validations.onlyNumbersAllowed, caption(CustomizableFieldMetadataDto.DEFAULT_VALUE)); + } } 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 index a6082e5545b..b9f6a7e7b9c 100644 --- 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 @@ -22,6 +22,8 @@ import java.util.List; import java.util.Map; +import org.apache.commons.lang3.StringUtils; + import com.vaadin.data.HasValue; import com.vaadin.ui.HorizontalLayout; import com.vaadin.ui.Label; @@ -194,7 +196,7 @@ public void updateFieldsDisplay() { setVisible(true); // Sort: non-null positions first (ascending), null positions last (stable) - groupFields.sort(Comparator.comparing(CustomizableFieldMetadataDto::getUiLinePosition, Comparator.nullsLast(Comparator.naturalOrder()))); + groupFields.sort(Comparator.comparing(metadata -> metadata.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. @@ -229,8 +231,7 @@ private void addLineRow(List lineFields) { for (CustomizableFieldMetadataDto metadata : lineFields) { CustomizableFieldInput field = CustomizableFieldInputFactory.create(metadata); - CustomizableFieldValueDto dto = - (fieldsValues != null && fieldsValues.containsKey(metadata)) ? fieldsValues.get(metadata) : new CustomizableFieldValueDto(); + CustomizableFieldValueDto dto = createInitialFieldValue(metadata); field.setFieldValue(dto); field.setWidth(100, Unit.PERCENTAGE); @@ -269,12 +270,12 @@ private void addLineRow(List lineFields) { */ @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); + for (Map.Entry> entry : fieldComponents.entrySet()) { + if (metadataUuid.equals(entry.getKey().getUuid())) { + return entry.getValue(); + } + } + return null; } /** @@ -296,13 +297,33 @@ public Map getFieldsVal Map result = new HashMap<>(); for (Map.Entry> entry : fieldComponents.entrySet()) { CustomizableFieldValueDto value = entry.getValue().getFieldValue(); - if (value != null && value.getValue() != null) { + if (shouldIncludeFieldValue(entry.getKey(), value)) { result.put(entry.getKey(), value); } } return result; } + private CustomizableFieldValueDto createInitialFieldValue(CustomizableFieldMetadataDto metadata) { + if (fieldsValues != null && fieldsValues.containsKey(metadata)) { + CustomizableFieldValueDto existingValue = fieldsValues.get(metadata); + return existingValue != null ? existingValue : new CustomizableFieldValueDto(); + } + + CustomizableFieldValueDto defaultValue = new CustomizableFieldValueDto(); + if (StringUtils.isNotBlank(metadata.getDefaultValue())) { + defaultValue.setValue(metadata.getDefaultValue()); + } + return defaultValue; + } + + private boolean shouldIncludeFieldValue(CustomizableFieldMetadataDto metadata, CustomizableFieldValueDto value) { + return value != null + && (value.getValue() != null + || (fieldsValues != null && fieldsValues.containsKey(metadata)) + || StringUtils.isNotBlank(metadata.getDefaultValue())); + } + /** * Clears all field values. */ diff --git a/sormas-ui/src/test/java/de/symeda/sormas/ui/utils/components/CustomizableFieldsGroupTest.java b/sormas-ui/src/test/java/de/symeda/sormas/ui/utils/components/CustomizableFieldsGroupTest.java new file mode 100644 index 00000000000..98c6f947c81 --- /dev/null +++ b/sormas-ui/src/test/java/de/symeda/sormas/ui/utils/components/CustomizableFieldsGroupTest.java @@ -0,0 +1,96 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +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.CustomizableFieldType; +import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto; +import de.symeda.sormas.ui.utils.components.customizablefield.CustomizableFieldInput; + +class CustomizableFieldsGroupTest { + + @Test + void testUsesDefaultValueWhenNoStoredValueExists() { + CustomizableFieldMetadataDto metadata = buildMetadata("field-default", "default-value"); + + CustomizableFieldsGroup group = new CustomizableFieldsGroup(CustomizableFieldGroup.CASE_DATA_GENERAL); + group.setFieldsMetadata(List.of(metadata)); + group.setFieldsValues(Collections.emptyMap()); + group.updateFieldsDisplay(); + + Map values = group.getFieldsValues(); + assertTrue(values.containsKey(metadata)); + assertEquals("default-value", values.get(metadata).getValue()); + } + + @Test + void testKeepsExplicitClearOfDefaultValue() { + CustomizableFieldMetadataDto metadata = buildMetadata("field-clear", "default-value"); + + CustomizableFieldsGroup group = new CustomizableFieldsGroup(CustomizableFieldGroup.CASE_DATA_GENERAL); + group.setFieldsMetadata(List.of(metadata)); + group.setFieldsValues(Collections.emptyMap()); + group.updateFieldsDisplay(); + + CustomizableFieldInput field = group.getFieldByMetadataUuid(metadata.getUuid()); + assertNotNull(field); + field.clear(); + + Map values = group.getFieldsValues(); + assertTrue(values.containsKey(metadata)); + assertNull(values.get(metadata).getValue()); + } + + @Test + void testDoesNotCreateValueForEmptyFieldWithoutDefault() { + CustomizableFieldMetadataDto metadata = buildMetadata("field-empty", null); + + CustomizableFieldsGroup group = new CustomizableFieldsGroup(CustomizableFieldGroup.CASE_DATA_GENERAL); + group.setFieldsMetadata(List.of(metadata)); + group.setFieldsValues(Collections.emptyMap()); + group.updateFieldsDisplay(); + + Map values = group.getFieldsValues(); + assertFalse(values.containsKey(metadata)); + } + + private CustomizableFieldMetadataDto buildMetadata(String uuid, String defaultValue) { + CustomizableFieldMetadataDto metadata = new CustomizableFieldMetadataDto(); + metadata.setUuid(uuid); + metadata.setName(uuid); + metadata.setFieldType(CustomizableFieldType.TEXT); + metadata.setContextClass(CustomizableFieldContext.CASE); + metadata.setUiGroup(CustomizableFieldGroup.CASE_DATA_GENERAL); + metadata.setUiLinePosition(1); + metadata.setDefaultValue(defaultValue); + metadata.setActive(true); + return metadata; + } +}