Repository: urlaubsverwaltung/urlaubsverwaltung (java)

Issue:
eindeutiger Name der Abwesenheitsart
Mit #2730 wurden individuelle Abwesenheitsarten eingeführt.

Aktuell können noch mehrere Abwesenheitsarten mit dem gleiche Namen erstellt werden.  
Z. B. bei Auswahlelementen ist dann nicht mehr ersichtlich um welche es sich genau handelt.

Ein Name darf für ein Locale nur einmal existieren.

Pull request description:
add unique vacation type name validation
closes #4265 

Patch:
diff --git a/src/main/css/components/form.css b/src/main/css/components/form.css
index b5047b393..3c5fc7427 100755
--- a/src/main/css/components/form.css
+++ b/src/main/css/components/form.css
@@ -146,9 +146,19 @@ label.is-required:after,
   @apply dark:tw-text-red-400;
 }
 
+input,
+select,
+textarea,
+.details-dropdown-input .details-dropdown_summary {
+  @apply dark:tw-border-zinc-600 !important;
+  @apply dark:tw-bg-zinc-700 !important;
+  @apply dark:tw-text-zinc-50 !important;
+}
+
 input.error,
 textarea.error {
-  @apply tw-border-red-700;
+  @apply tw-border-red-700 !important;
+  @apply dark:tw-border-red-500 !important;
 }
 
 .info {
@@ -241,15 +251,6 @@ textarea.error {
   @apply tw-text-zinc-700;
 }
 
-input,
-select,
-textarea,
-.details-dropdown-input .details-dropdown_summary {
-  @apply dark:tw-border-zinc-600 !important;
-  @apply dark:tw-bg-zinc-700 !important;
-  @apply dark:tw-text-zinc-50 !important;
-}
-
 .select-caret {
   @apply dark:tw-text-zinc-50;
 }
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/application/vacationtype/ProvidedVacationType.java b/src/main/java/org/synyx/urlaubsverwaltung/application/vacationtype/ProvidedVacationType.java
index ca92e9f20..78238f10e 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/application/vacationtype/ProvidedVacationType.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/application/vacationtype/ProvidedVacationType.java
@@ -14,7 +14,7 @@ public final class ProvidedVacationType extends VacationType<ProvidedVacationTyp
         this.messageKey = builder.messageKey;
     }
 
-    String getMessageKey() {
+    public String getMessageKey() {
         return messageKey;
     }
 
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/settings/AbsenceTypeSettingsDtoMapper.java b/src/main/java/org/synyx/urlaubsverwaltung/settings/AbsenceTypeSettingsDtoMapper.java
index ed506e697..7287727f4 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/settings/AbsenceTypeSettingsDtoMapper.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/settings/AbsenceTypeSettingsDtoMapper.java
@@ -1,6 +1,7 @@
 package org.synyx.urlaubsverwaltung.settings;
 
 import org.synyx.urlaubsverwaltung.application.vacationtype.CustomVacationType;
+import org.synyx.urlaubsverwaltung.application.vacationtype.ProvidedVacationType;
 import org.synyx.urlaubsverwaltung.application.vacationtype.VacationType;
 import org.synyx.urlaubsverwaltung.application.vacationtype.VacationTypeLabel;
 
@@ -31,6 +32,7 @@ public class AbsenceTypeSettingsDtoMapper {
         return AbsenceTypeSettingsItemDto.builder()
             .setId(vacationType.getId())
             .setLabel(vacationType.getLabel(locale))
+            .setMessageKey(messageKey(vacationType))
             .setCategory(vacationType.getCategory())
             .setActive(vacationType.isActive())
             .setRequiresApprovalToApply(vacationType.isRequiresApprovalToApply())
@@ -41,6 +43,14 @@ public class AbsenceTypeSettingsDtoMapper {
             .build();
     }
 
+    private static String messageKey(VacationType<?> vacationType) {
+        if (vacationType instanceof ProvidedVacationType providedVacationType) {
+            return providedVacationType.getMessageKey();
+        } else {
+            return null;
+        }
+    }
+
     private static List<AbsenceTypeSettingsItemLabelDto> vacationTypeLabelDtos(VacationType<?> vacationType) {
         // setting label is only supported by CustomVacationType for now
         if (vacationType instanceof CustomVacationType customVacationType) {
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/settings/AbsenceTypeSettingsItemDto.java b/src/main/java/org/synyx/urlaubsverwaltung/settings/AbsenceTypeSettingsItemDto.java
index e4d5ee772..b7848b353 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/settings/AbsenceTypeSettingsItemDto.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/settings/AbsenceTypeSettingsItemDto.java
@@ -11,6 +11,8 @@ public class AbsenceTypeSettingsItemDto {
     private Long id;
     private boolean active;
     private String label;
+    // messageKey is required until there is no distinction between Provided- and CustomVacationType anymore
+    private String messageKey;
     private VacationCategory category;
     private boolean requiresApprovalToApply;
     private boolean requiresApprovalToCancel;
@@ -26,6 +28,7 @@ public class AbsenceTypeSettingsItemDto {
         this.id = builder.id;
         this.active = builder.active;
         this.label = builder.label;
+        this.messageKey = builder.messageKey;
         this.category = builder.category;
         this.requiresApprovalToApply = builder.requiresApprovalToApply;
         this.requiresApprovalToCancel = builder.requiresApprovalToCancel;
@@ -58,6 +61,14 @@ public class AbsenceTypeSettingsItemDto {
         this.label = label;
     }
 
+    public String getMessageKey() {
+        return messageKey;
+    }
+
+    public void setMessageKey(String messageKey) {
+        this.messageKey = messageKey;
+    }
+
     public VacationCategory getCategory() {
         return category;
     }
@@ -116,6 +127,7 @@ public class AbsenceTypeSettingsItemDto {
             "id=" + id +
             ", active=" + active +
             ", label='" + label + '\'' +
+            ", messageKey='" + messageKey + '\'' +
             ", category='" + category + '\'' +
             ", requiresApprovalToApply=" + requiresApprovalToApply +
             ", requiresApprovalToCancel=" + requiresApprovalToCancel +
@@ -136,6 +148,7 @@ public class AbsenceTypeSettingsItemDto {
             && visibleToEveryone == dto.visibleToEveryone
             && Objects.equals(id, dto.id)
             && Objects.equals(label, dto.label)
+            && Objects.equals(messageKey, dto.messageKey)
             && category == dto.category
             && color == dto.color
             && Objects.equals(labels, dto.labels);
@@ -143,14 +156,15 @@ public class AbsenceTypeSettingsItemDto {
 
     @Override
     public int hashCode() {
-        return Objects.hash(id, active, label, category, requiresApprovalToApply, requiresApprovalToCancel, color,
-            visibleToEveryone, labels);
+        return Objects.hash(id, active, label, messageKey, category, requiresApprovalToApply, requiresApprovalToCancel,
+            color, visibleToEveryone, labels);
     }
 
     public static class Builder {
         private Long id;
         private boolean active;
         private String label;
+        private String messageKey;
         private VacationCategory category;
         private boolean requiresApprovalToApply;
         private boolean requiresApprovalToCancel;
@@ -173,6 +187,11 @@ public class AbsenceTypeSettingsItemDto {
             return this;
         }
 
+        Builder setMessageKey(String messageKey) {
+            this.messageKey = messageKey;
+            return this;
+        }
+
         Builder setCategory(VacationCategory category) {
             this.category = category;
             return this;
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/settings/SettingsAbsenceTypesDtoValidator.java b/src/main/java/org/synyx/urlaubsverwaltung/settings/SettingsAbsenceTypesDtoValidator.java
index 7b1d6795c..9af7bb86f 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/settings/SettingsAbsenceTypesDtoValidator.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/settings/SettingsAbsenceTypesDtoValidator.java
@@ -1,17 +1,32 @@
 package org.synyx.urlaubsverwaltung.settings;
 
 
+import org.springframework.context.MessageSource;
 import org.springframework.stereotype.Component;
 import org.springframework.validation.Errors;
 import org.springframework.validation.Validator;
 
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
 
+import static java.util.stream.Collectors.toMap;
 import static org.springframework.util.StringUtils.hasText;
 
 @Component
 class SettingsAbsenceTypesDtoValidator implements Validator {
 
+    private final MessageSource messageSource;
+
+    SettingsAbsenceTypesDtoValidator(MessageSource messageSource) {
+        this.messageSource = messageSource;
+    }
+
     @Override
     public boolean supports(Class<?> clazz) {
         return SettingsAbsenceTypesDto.class.isAssignableFrom(clazz);
@@ -29,20 +44,129 @@ class SettingsAbsenceTypesDtoValidator implements Validator {
         final AbsenceTypeSettingsDto absenceTypeSettings = dto.getAbsenceTypeSettings();
         errors.pushNestedPath("absenceTypeSettings");
 
+        validateUniqueLabel(absenceTypeSettings, errors);
+        validateAtLeastOneLabelTranslation(absenceTypeSettings, errors);
+
+        errors.popNestedPath();
+    }
+
+    private void validateUniqueLabel(AbsenceTypeSettingsDto absenceTypeSettings, Errors errors) {
+
+        // map the itemDtos to a projection view.
+        // this is obsolete as soon as there are no ProvidedVacationTypes anymore (and the user has to maintain all labels)
+        final List<ItemProjection> itemProjections = absenceTypeSettings.getItems()
+            .stream()
+            .map(this::toProjection)
+            .toList();
+
+        final Map<Locale, List<String>> allLabelsByLocale = itemProjections.stream()
+            .map(ItemProjection::getLabels)
+            .flatMap(Collection::stream)
+            .collect(toMap(
+                LabelProjection::getLocale,
+                dto -> List.of(dto.getLabel()),
+                (strings, strings2) -> Stream.concat(strings.stream(), strings2.stream()).toList()
+            ));
+
+        final Predicate<LabelProjection> isNotUniqueLabel = dto ->
+            // `dto.custom` is obsolete as soon as there are no ProvidedVacationTypes anymore.
+            dto.custom && hasText(dto.getLabel()) && allLabelsByLocale.get(dto.getLocale())
+                .stream()
+                .filter(label -> label.strip().equalsIgnoreCase(dto.getLabel().strip()))
+                .toList()
+                .size() > 1;
+
+        for (int index = 0; index < itemProjections.size(); index++) {
+            final ItemProjection itemDto = itemProjections.get(index);
+
+            final List<NotUniqueLabelDto> notUniqueLabels = notUniqueLabelTranslations(itemDto, isNotUniqueLabel);
+            for (NotUniqueLabelDto notUniqueLabelDto : notUniqueLabels) {
+                final String label = itemDto.getLabels().stream()
+                    .filter(labelDto -> labelDto.getLocale().equals(notUniqueLabelDto.locale))
+                    .findFirst()
+                    .orElseThrow(() -> new IllegalStateException("expected a label text for locale=%s".formatted(notUniqueLabelDto.locale)))
+                    .getLabel();
+                errors.rejectValue(
+                    "items[%d].labels[%d].label".formatted(index, notUniqueLabelDto.index),
+                    "vacationtype.validation.constraints.labels.NotUnique.message",
+                    new Object[]{label},
+                    null
+                );
+                // used to detect label translation error at the labels element
+                // no code and no defaultMessage to skip error messages in ui in this case
+                errors.rejectValue("items[%d].labels".formatted(index), "", "");
+            }
+        }
+    }
+
+    private record NotUniqueLabelDto(int index, Locale locale) {}
+
+    // obsolete as soon as there are no ProvidedVacationTypes anymore
+    private ItemProjection toProjection(AbsenceTypeSettingsItemDto dto) {
+
+        final List<LabelProjection> labels;
+        final boolean customType = isCustomVacationType(dto);
+
+        if (customType) {
+            labels = dto.getLabels().stream()
+                .map(labelDto -> new LabelProjection(labelDto.getLocale(), labelDto.getLabel(), true))
+                .toList();
+        } else {
+            labels = Arrays.stream(SupportedLanguages.values())
+                .map(supportedLanguage -> {
+                    final Locale supportedLanguageLocale = supportedLanguage.getLocale();
+                    final String label = msg(dto.getMessageKey(), supportedLanguageLocale);
+                    return new LabelProjection(supportedLanguageLocale, label, false);
+                })
+                .toList();
+        }
+
+        return new ItemProjection(labels, customType);
+    }
+
+    private static boolean isCustomVacationType(AbsenceTypeSettingsItemDto item) {
+        return item.getLabels() != null && !item.getLabels().isEmpty();
+    }
+
+    private String msg(String code, Locale locale) {
+        return messageSource.getMessage(code, new Object[]{}, locale);
+    }
+
+    private record ItemProjection(List<LabelProjection> labels, boolean custom) {
+        List<LabelProjection> getLabels() { return labels; }
+    }
+
+    private record LabelProjection(Locale locale, String label, boolean custom) {
+        Locale getLocale() { return locale; }
+        String getLabel() { return label; }
+    }
+
+    private List<NotUniqueLabelDto> notUniqueLabelTranslations(ItemProjection itemProjection, Predicate<LabelProjection> checkNotUnique) {
+        final List<NotUniqueLabelDto> notUniqueIndexes = new ArrayList<>();
+
+        for (int index = 0; index < itemProjection.labels().size(); index++) {
+            final LabelProjection labelDto = itemProjection.labels().get(index);
+            if (checkNotUnique.test(labelDto)) {
+                notUniqueIndexes.add(new NotUniqueLabelDto(index, labelDto.getLocale()));
+            }
+        }
+
+        return notUniqueIndexes;
+    }
+
+    private void validateAtLeastOneLabelTranslation(AbsenceTypeSettingsDto absenceTypeSettings, Errors errors) {
         final List<AbsenceTypeSettingsItemDto> items = absenceTypeSettings.getItems();
         for (int i = 0; i < items.size(); i++) {
             final AbsenceTypeSettingsItemDto item = items.get(i);
             errors.pushNestedPath("items[%d]".formatted(i));
-            validateLabels(item, errors);
+            validateLabelsHaveText(item, errors);
             errors.popNestedPath();
         }
-
-        errors.popNestedPath();
     }
 
-    private void validateLabels(AbsenceTypeSettingsItemDto item, Errors errors) {
+    private void validateLabelsHaveText(AbsenceTypeSettingsItemDto item, Errors errors) {
         final List<AbsenceTypeSettingsItemLabelDto> labels = item.getLabels();
-        if (labels == null) {
+        if (labels == null || labels.isEmpty()) {
             // label translation list is null for ProvidedVacationType
             return;
         }
diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties
index 8fc6d6469..853bc9f07 100644
--- a/src/main/resources/messages.properties
+++ b/src/main/resources/messages.properties
@@ -342,6 +342,7 @@ OTHER=Sonstiges
 
 vacationtype.label.fallback=<Name Abwesenheitsart>
 vacationtype.validation.constraints.labels.NotEmpty.message=Mindestens eine Übersetzung muss angegeben werden.
+vacationtype.validation.constraints.labels.NotUnique.message={0} existiert bereits als Name.
 
 # VACATION STATES
 WAITING=wartend
diff --git a/src/main/resources/messages_de_AT.properties b/src/main/resources/messages_de_AT.properties
index 162d63bfd..cff45e426 100644
--- a/src/main/resources/messages_de_AT.properties
+++ b/src/main/resources/messages_de_AT.properties
@@ -334,6 +334,7 @@ OTHER=Sonstiges
 
 vacationtype.label.fallback=<Abwesenheitsart>
 vacationtype.validation.constraints.labels.NotEmpty.message=Mindestens eine Übersetzung muss angegeben werden.
+vacationtype.validation.constraints.labels.NotUnique.message={0} existiert bereits als Name.
 
 # VACATION STATES
 WAITING=wartend
diff --git a/src/main/resources/messages_el.properties b/src/main/resources/messages_el.properties
index f2026f34c..01b404245 100644
--- a/src/main/resources/messages_el.properties
+++ b/src/main/resources/messages_el.properties
@@ -87,7 +87,7 @@ application.data.vacationType.cure=Θεραπεία
 application.data.vacationType.education=Εκπαίδευση
 application.data.vacationType.homeOffice=Τηλε-εργασία
 application.data.vacationType.outOfOffice=Εκτός γραφείου
-application.data.vacationType.training=Εκπαίδευση
+application.data.vacationType.training=Περαιτέρω κατάρτιση
 application.data.vacationType.businessTrip=Επιχειρησιακό ταξίδι
 application.data.vacationType.lab=Εργαστήριο
 application.data.vacationType.conference=Συνέδριο
@@ -303,6 +303,7 @@ OTHER=Άλλο
 
 vacationtype.label.fallback=<τύπος απουσίας>
 vacationtype.validation.constraints.labels.NotEmpty.message=Πρέπει να καθοριστεί τουλάχιστον μία μετάφραση.
+vacationtype.validation.constraints.labels.NotUnique.message={0} υπάρχει ήδη ως όνομα.
 
 # VACATION STATES
 WAITING=Εκκρεμής
diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties
index 6791a8671..d15cc8849 100644
--- a/src/main/resources/messages_en.properties
+++ b/src/main/resources/messages_en.properties
@@ -339,6 +339,7 @@ OTHER=Other
 
 vacationtype.label.fallback=<absence type>
 vacationtype.validation.constraints.labels.NotEmpty.message=At least one translation must be specified.
+vacationtype.validation.constraints.labels.NotUnique.message={0} already exists as a name.
 
 # VACATION STATES
 WAITING=Pending
diff --git a/src/main/resources/templates/settings/absence-types/absence-types.html b/src/main/resources/templates/settings/absence-types/absence-types.html
index 79876a39c..c432b52ce 100644
--- a/src/main/resources/templates/settings/absence-types/absence-types.html
+++ b/src/main/resources/templates/settings/absence-types/absence-types.html
@@ -93,6 +93,11 @@
                   </span>
                 </div>
                 <div class="absence-type-card__label">
+                  <input
+                    type="hidden"
+                    th:name="|absenceTypeSettings.items[${index}].messageKey|"
+                    th:value="${absenceType.messageKey}"
+                  />
                   <input
                     type="hidden"
                     th:name="|absenceTypeSettings.items[${index}].label|"
@@ -125,31 +130,45 @@
                       <div class="absence-type-translation-inputs tw-ml-7 tw-mt-4 md:tw-ml-6 md:tw-mt-2">
                         <div
                           th:each="label : ${absenceType.labels}"
-                          class="absence-type-translation-inputs__element"
-                          th:with="id=|absenceType-label-translation-${index}-${labelStat.index}|"
+                          th:with="
+                            id=|absenceType-label-translation-${index}-${labelStat.index}|,
+                            labelFieldname=${'absenceTypeSettings.items[' + index + '].labels[' + labelStat.index + '].label'},
+                            hasLocaleErrors=${#bools.isFalse(frameNewAbsenceTypeRequested) and #fields.hasErrors(labelFieldname)}
+                          "
                         >
-                          <input
-                            type="hidden"
-                            th:name="${labelsFieldname + '[' + labelStat.index + '].locale'}"
-                            th:value="${label.locale}"
-                          />
-                          <input
-                            type="text"
-                            th:id="${id}"
-                            th:name="${labelsFieldname + '[' + labelStat.index + '].label'}"
-                            th:value="${label.label}"
-                            class="absence-type-translation-inputs__element__input"
-                            th:data-test-id="${'vacation-type-label-translation-' + label.locale}"
-                          />
-                          <label th:for="${id}" class="absence-type-translation-inputs__element__label">
-                            <img
-                              th:src="@{/images/flags/{locale}.svg (locale=${label.locale})}"
-                              alt=""
-                              width="16"
-                              height="16"
+                          <div th:class="absence-type-translation-inputs__element">
+                            <input
+                              type="hidden"
+                              th:name="${labelsFieldname + '[' + labelStat.index + '].locale'}"
+                              th:value="${label.locale}"
+                            />
+                            <input
+                              type="text"
+                              th:id="${id}"
+                              th:name="${labelsFieldname + '[' + labelStat.index + '].label'}"
+                              th:value="${label.label}"
+                              class="absence-type-translation-inputs__element__input"
+                              th:classappend="${hasLocaleErrors ? 'error' : ''}"
+                              th:data-test-id="${'vacation-type-label-translation-' + label.locale}"
                             />
-                            <span th:text="${#messages.msg('locale.' + label.locale)}"></span>
-                          </label>
+                            <label th:for="${id}" class="absence-type-translation-inputs__element__label">
+                              <img
+                                th:src="@{/images/flags/{locale}.svg (locale=${label.locale})}"
+                                alt=""
+                                width="16"
+                                height="16"
+                              />
+                              <span th:text="${#messages.msg('locale.' + label.locale)}"></span>
+                            </label>
+                          </div>
+                          <th:block th:if="${hasLocaleErrors}">
+                            <p
+                              th:each="error : ${#fields.errors(labelFieldname)}"
+                              th:text="${error}"
+                              class="error"
+                              th:data-test-id="${'vacation-type-unique-translation-error-' + label.locale}"
+                            ></p>
+                          </th:block>
                         </div>
                       </div>
                     </details>
@@ -158,7 +177,7 @@
                         th:each="error : ${#fields.errors(labelsFieldname)}"
                         th:text="${error}"
                         class="error tw-ml-7 md:tw-ml-6"
-                        data-test-id="vacation-type-translation-error"
+                        data-test-id="vacation-type-missing-translation-error"
                       ></p>
                     </th:block>
                   </th:block>


Test patch:
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/settings/SettingsAbsenceTypesDtoValidatorTest.java b/src/test/java/org/synyx/urlaubsverwaltung/settings/SettingsAbsenceTypesDtoValidatorTest.java
new file mode 100644
index 000000000..c7d18132e
--- /dev/null
+++ b/src/test/java/org/synyx/urlaubsverwaltung/settings/SettingsAbsenceTypesDtoValidatorTest.java
@@ -0,0 +1,216 @@
+package org.synyx.urlaubsverwaltung.settings;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.MessageSource;
+import org.springframework.validation.BeanPropertyBindingResult;
+import org.springframework.validation.Errors;
+
+import java.util.List;
+import java.util.Locale;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SettingsAbsenceTypesDtoValidatorTest {
+
+    private SettingsAbsenceTypesDtoValidator sut;
+
+    @Mock
+    private MessageSource messageSource;
+
+    @BeforeEach
+    void setUp() {
+        sut = new SettingsAbsenceTypesDtoValidator(messageSource);
+    }
+
+    @Test
+    void ensureSupportsIsTrueForDto() {
+        assertThat(sut.supports(SettingsAbsenceTypesDto.class)).isTrue();
+    }
+
+    @Test
+    void ensureSupportsIsFalse() {
+        assertThat(sut.supports(Object.class)).isFalse();
+    }
+
+    @Test
+    void ensureValidationFailsWhenNoLabelIsSet() {
+
+        final AbsenceTypeSettingsItemDto validAbsenceTypeDto = new AbsenceTypeSettingsItemDto();
+        validAbsenceTypeDto.setLabels(List.of(
+            new AbsenceTypeSettingsItemLabelDto(Locale.GERMAN, ""),
+            new AbsenceTypeSettingsItemLabelDto(Locale.ENGLISH, "some text")
+        ));
+
+        final AbsenceTypeSettingsItemDto invalidAbsenceTypeDto = new AbsenceTypeSettingsItemDto();
+        invalidAbsenceTypeDto.setLabels(List.of(
+            new AbsenceTypeSettingsItemLabelDto(Locale.GERMAN, ""),
+            new AbsenceTypeSettingsItemLabelDto(Locale.ENGLISH, "")
+        ));
+
+        final AbsenceTypeSettingsDto absenceTypeSettingsDto = new AbsenceTypeSettingsDto();
+        absenceTypeSettingsDto.setItems(List.of(validAbsenceTypeDto, invalidAbsenceTypeDto));
+
+        final SettingsAbsenceTypesDto dto = new SettingsAbsenceTypesDto();
+        dto.setAbsenceTypeSettings(absenceTypeSettingsDto);
+
+        final Errors errors = new BeanPropertyBindingResult(dto, "settingsAbsenceTypesDto");
+        sut.validate(dto, errors);
+
+        assertThat(errors.getFieldError("absenceTypeSettings.items[0].labels")).isNull();
+        assertThat(errors.getFieldError("absenceTypeSettings.items[1].labels")).satisfies(error -> {
+            assertThat(error.getCode()).isEqualTo("vacationtype.validation.constraints.labels.NotEmpty.message");
+            assertThat(error.getArguments()).isNull();
+        });
+    }
+
+    @Test
+    void ensureValidationFailsWhenLabelIsNotUnique() {
+
+        final AbsenceTypeSettingsItemDto absenceTypeDto_1 = new AbsenceTypeSettingsItemDto();
+        absenceTypeDto_1.setLabels(List.of(
+            new AbsenceTypeSettingsItemLabelDto(Locale.GERMAN, "irgendein text"),
+            new AbsenceTypeSettingsItemLabelDto(Locale.ENGLISH, "")
+        ));
+
+        final AbsenceTypeSettingsItemDto absenceTypeDto_2 = new AbsenceTypeSettingsItemDto();
+        absenceTypeDto_2.setLabels(List.of(
+            new AbsenceTypeSettingsItemLabelDto(Locale.GERMAN, "irgendein text"),
+            new AbsenceTypeSettingsItemLabelDto(Locale.ENGLISH, "some text")
+        ));
+
+        final AbsenceTypeSettingsItemDto absenceTypeDto_3 = new AbsenceTypeSettingsItemDto();
+        absenceTypeDto_3.setLabels(List.of(
+            new AbsenceTypeSettingsItemLabelDto(Locale.GERMAN, ""),
+            new AbsenceTypeSettingsItemLabelDto(Locale.ENGLISH, "some text")
+        ));
+
+        final AbsenceTypeSettingsItemDto absenceTypeDto_4 = new AbsenceTypeSettingsItemDto();
+        absenceTypeDto_4.setLabels(List.of(
+            new AbsenceTypeSettingsItemLabelDto(Locale.GERMAN, "das hier ist ok"),
+            new AbsenceTypeSettingsItemLabelDto(Locale.ENGLISH, "")
+        ));
+
+        final AbsenceTypeSettingsDto absenceTypeSettingsDto = new AbsenceTypeSettingsDto();
+        absenceTypeSettingsDto.setItems(List.of(absenceTypeDto_1, absenceTypeDto_2, absenceTypeDto_3, absenceTypeDto_4));
+
+        final SettingsAbsenceTypesDto dto = new SettingsAbsenceTypesDto();
+        dto.setAbsenceTypeSettings(absenceTypeSettingsDto);
+
+        final Errors errors = new BeanPropertyBindingResult(dto, "settingsAbsenceTypesDto");
+        sut.validate(dto, errors);
+
+        assertThat(errors.getFieldError("absenceTypeSettings.items[0].labels[0].label")).satisfies(error -> {
+            assertThat(error.getCode()).isEqualTo("vacationtype.validation.constraints.labels.NotUnique.message");
+            assertThat(error.getArguments()).isEqualTo(new Object[]{"irgendein text"});
+        });
+        assertThat(errors.getFieldError("absenceTypeSettings.items[0].labels[1].label")).isNull();
+
+        assertThat(errors.getFieldError("absenceTypeSettings.items[1].labels[0].label")).satisfies(error -> {
+            assertThat(error.getCode()).isEqualTo("vacationtype.validation.constraints.labels.NotUnique.message");
+            assertThat(error.getArguments()).isEqualTo(new Object[]{"irgendein text"});
+        });
+        assertThat(errors.getFieldError("absenceTypeSettings.items[1].labels[1].label")).satisfies(error -> {
+            assertThat(error.getCode()).isEqualTo("vacationtype.validation.constraints.labels.NotUnique.message");
+            assertThat(error.getArguments()).isEqualTo(new Object[]{"some text"});
+        });
+
+        assertThat(errors.getFieldError("absenceTypeSettings.items[2].labels[0].label")).isNull();
+        assertThat(errors.getFieldError("absenceTypeSettings.items[2].labels[1].label")).satisfies(error -> {
+            assertThat(error.getCode()).isEqualTo("vacationtype.validation.constraints.labels.NotUnique.message");
+            assertThat(error.getArguments()).isEqualTo(new Object[]{"some text"});
+        });
+
+        assertThat(errors.getFieldError("absenceTypeSettings.items[3].labels[0].label")).isNull();
+        assertThat(errors.getFieldError("absenceTypeSettings.items[3].labels[1].label")).isNull();
+    }
+
+    // delete me as soon as there are no ProvidedVacationTypes anymore (and the user has to maintain all labels)
+    @Test
+    void ensureValidationFailsWhenLabelClashesWithProvidedVacationTypeLabelForCurrentContextLocale() {
+
+        for (SupportedLanguages supportedLanguage : SupportedLanguages.values()) {
+            final String message = switch (supportedLanguage) {
+                case GERMAN -> "Erholungsurlaub";
+                case GERMAN_AUSTRIA -> "Erholungsurlaub";
+                case ENGLISH -> "Holiday";
+                case GREEK -> "Διακοπές";
+            };
+            when(messageSource.getMessage("provided.messagekey", new Object[]{}, supportedLanguage.getLocale())).thenReturn(message);
+        }
+
+        final AbsenceTypeSettingsItemDto providedAbsenceTypeDto = new AbsenceTypeSettingsItemDto();
+        providedAbsenceTypeDto.setLabel("Holiday");
+        providedAbsenceTypeDto.setMessageKey("provided.messagekey");
+        providedAbsenceTypeDto.setLabels(List.of());
+
+        final AbsenceTypeSettingsItemDto customAbsenceTypeDto = new AbsenceTypeSettingsItemDto();
+        customAbsenceTypeDto.setLabels(List.of(
+            new AbsenceTypeSettingsItemLabelDto(Locale.GERMAN, ""),
+            new AbsenceTypeSettingsItemLabelDto(Locale.ENGLISH, "Holiday")
+        ));
+
+        final AbsenceTypeSettingsDto absenceTypeSettingsDto = new AbsenceTypeSettingsDto();
+        absenceTypeSettingsDto.setItems(List.of(providedAbsenceTypeDto, customAbsenceTypeDto));
+
+        final SettingsAbsenceTypesDto dto = new SettingsAbsenceTypesDto();
+        dto.setAbsenceTypeSettings(absenceTypeSettingsDto);
+
+        final Errors errors = new BeanPropertyBindingResult(dto, "settingsAbsenceTypesDto");
+        sut.validate(dto, errors);
+
+        assertThat(errors.getFieldError("absenceTypeSettings.items[0]")).isNull();
+        assertThat(errors.getFieldError("absenceTypeSettings.items[1].labels[0].label")).isNull();
+        assertThat(errors.getFieldError("absenceTypeSettings.items[1].labels[1].label")).satisfies(error -> {
+            assertThat(error.getCode()).isEqualTo("vacationtype.validation.constraints.labels.NotUnique.message");
+            assertThat(error.getArguments()).isEqualTo(new Object[]{"Holiday"});
+        });
+    }
+
+    @Test
+    void ensureValidationSucceeds() {
+
+        for (SupportedLanguages supportedLanguage : SupportedLanguages.values()) {
+            final String message = switch (supportedLanguage) {
+                case GERMAN -> "Erholungsurlaub";
+                case GERMAN_AUSTRIA -> "Erholungsurlaub";
+                case ENGLISH -> "Holiday";
+                case GREEK -> "Διακοπές";
+            };
+            when(messageSource.getMessage("provided.messagekey", new Object[]{}, supportedLanguage.getLocale())).thenReturn(message);
+        }
+
+        final AbsenceTypeSettingsItemDto providedAbsenceTypeDto = new AbsenceTypeSettingsItemDto();
+        providedAbsenceTypeDto.setLabel("Erholungsurlaub");
+        providedAbsenceTypeDto.setMessageKey("provided.messagekey");
+        providedAbsenceTypeDto.setLabels(List.of());
+
+        final AbsenceTypeSettingsItemDto customAbsenceTypeDto_1 = new AbsenceTypeSettingsItemDto();
+        customAbsenceTypeDto_1.setLabels(List.of(
+            new AbsenceTypeSettingsItemLabelDto(Locale.GERMAN, "Jokertag"),
+            new AbsenceTypeSettingsItemLabelDto(Locale.ENGLISH, "")
+        ));
+
+        final AbsenceTypeSettingsItemDto customAbsenceTypeDto_2 = new AbsenceTypeSettingsItemDto();
+        customAbsenceTypeDto_2.setLabels(List.of(
+            new AbsenceTypeSettingsItemLabelDto(Locale.GERMAN, "Familientag"),
+            new AbsenceTypeSettingsItemLabelDto(Locale.ENGLISH, "")
+        ));
+
+        final AbsenceTypeSettingsDto absenceTypeSettingsDto = new AbsenceTypeSettingsDto();
+        absenceTypeSettingsDto.setItems(List.of(providedAbsenceTypeDto, customAbsenceTypeDto_1, customAbsenceTypeDto_2));
+
+        final SettingsAbsenceTypesDto dto = new SettingsAbsenceTypesDto();
+        dto.setAbsenceTypeSettings(absenceTypeSettingsDto);
+
+        final Errors errors = new BeanPropertyBindingResult(dto, "settingsAbsenceTypesDto");
+        sut.validate(dto, errors);
+
+        assertThat(errors.hasErrors()).isFalse();
+    }
+}
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/settings/SettingsAbsenceTypesViewControllerTest.java b/src/test/java/org/synyx/urlaubsverwaltung/settings/SettingsAbsenceTypesViewControllerTest.java
index 9723acc2f..d058bdf5e 100644
--- a/src/test/java/org/synyx/urlaubsverwaltung/settings/SettingsAbsenceTypesViewControllerTest.java
+++ b/src/test/java/org/synyx/urlaubsverwaltung/settings/SettingsAbsenceTypesViewControllerTest.java
@@ -97,6 +97,7 @@ class SettingsAbsenceTypesViewControllerTest {
                 .setId(1L)
                 .setActive(true)
                 .setLabel("label-1")
+                .setMessageKey("message-key-1")
                 .setCategory(HOLIDAY)
                 .setRequiresApprovalToApply(true)
                 .setRequiresApprovalToCancel(true)
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/ui/CustomAbsenceTypeUIIT.java b/src/test/java/org/synyx/urlaubsverwaltung/ui/CustomAbsenceTypeUIIT.java
index e49e3d9fc..a51079eb3 100644
--- a/src/test/java/org/synyx/urlaubsverwaltung/ui/CustomAbsenceTypeUIIT.java
+++ b/src/test/java/org/synyx/urlaubsverwaltung/ui/CustomAbsenceTypeUIIT.java
@@ -96,19 +96,28 @@ class CustomAbsenceTypeUIIT {
         settingsPage.clickAbsenceTypesTab();
         settingsPage.addNewVacationType();
 
+        // ensure at least one translation required error hint
         settingsPage.saveSettings();
-        assertThat(settingsPage.vacationTypeTranslationError(settingsPage.lastVacationType())).isVisible();
+        assertThat(settingsPage.vacationTypeMissingTranslationError(settingsPage.lastVacationType())).isVisible();
 
+        // ensure saving valid vacation type succeeds
         settingsPage.setVacationTypeLabel(settingsPage.lastVacationType(), GERMAN, "Biertag");
         settingsPage.saveSettings();
-        assertThat(settingsPage.vacationTypeTranslationError(settingsPage.lastVacationType())).not().isVisible();
+        assertThat(settingsPage.vacationTypeMissingTranslationError(settingsPage.lastVacationType())).not().isVisible();
 
+        // enable vacation type to ensure selecting it later creating a new application for leave
         final Locator status = settingsPage.vacationTypeStatusCheckbox(settingsPage.lastVacationType());
         assertThat(status).not().isChecked();
         status.check();
+        settingsPage.saveSettings();
 
+        // ensure unique vacation type name error hint
+        settingsPage.addNewVacationType();
+        settingsPage.setVacationTypeLabel(settingsPage.lastVacationType(), GERMAN, "Biertag");
         settingsPage.saveSettings();
+        assertThat(settingsPage.vacationTypeUniqueTranslationError(settingsPage.lastVacationType(), GERMAN)).isVisible();
 
+        // ensure vacation type is selectable creating a new application for leave
         navigationPage.quickAdd.click();
         navigationPage.quickAdd.newApplication();
         page.context().waitForCondition(applicationPage::isVisible);
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/ui/pages/SettingsPage.java b/src/test/java/org/synyx/urlaubsverwaltung/ui/pages/SettingsPage.java
index 9771fb6bc..2c9d234af 100644
--- a/src/test/java/org/synyx/urlaubsverwaltung/ui/pages/SettingsPage.java
+++ b/src/test/java/org/synyx/urlaubsverwaltung/ui/pages/SettingsPage.java
@@ -55,8 +55,12 @@ public class SettingsPage {
         absenceTypeLocator.locator(selector).fill(value);
     }
 
-    public Locator vacationTypeTranslationError(Locator absencetypeLocator) {
-        return absencetypeLocator.locator("[data-test-id=vacation-type-translation-error]");
+    public Locator vacationTypeMissingTranslationError(Locator absencetypeLocator) {
+        return absencetypeLocator.locator("[data-test-id=vacation-type-missing-translation-error]");
+    }
+
+    public Locator vacationTypeUniqueTranslationError(Locator absencetypeLocator, Locale locale) {
+        return absencetypeLocator.locator("[data-test-id=vacation-type-unique-translation-error-%s]".formatted(locale));
     }
 
     public Locator vacationTypeStatusCheckbox(Locator absenceTypeLocator) {