diff --git a/frontend/app/components/common/SearchSelect.vue b/frontend/app/components/common/SearchSelect.vue index f18ab98..5251374 100644 --- a/frontend/app/components/common/SearchSelect.vue +++ b/frontend/app/components/common/SearchSelect.vue @@ -77,6 +77,15 @@ + @@ -87,6 +96,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down' import IconLucideX from '~icons/lucide/x' +import IconLucidePlus from '~icons/lucide/plus' const props = defineProps({ modelValue: { @@ -137,10 +147,14 @@ const props = defineProps({ serverSearch: { type: Boolean, default: false + }, + creatable: { + type: Boolean, + default: false } }) -const emit = defineEmits(['update:modelValue', 'search']) +const emit = defineEmits(['update:modelValue', 'search', 'focus']) const searchTerm = ref('') const openDropdown = ref(false) @@ -172,6 +186,18 @@ const displayedOptions = computed(() => { return filtered }) +const creatableSuggestion = computed(() => { + if (!props.creatable) return null + const term = searchTerm.value.trim() + if (!term) return null + // Show "Créer ..." only if no option matches exactly (case-insensitive) + const exists = baseOptions.value.some(option => { + const label = resolveLabel(option).toLowerCase() + return label === term.toLowerCase() + }) + return exists ? null : term +}) + const inputClasses = computed(() => { const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10' const base = ['input', 'input-bordered', 'w-full', pr] @@ -194,6 +220,12 @@ const toggleButtonClasses = computed(() => { watch( () => props.modelValue, () => { + if (props.creatable) { + if (searchTerm.value !== props.modelValue) { + searchTerm.value = String(props.modelValue ?? '') + } + return + } if (!openDropdown.value) { searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : '' } @@ -269,6 +301,7 @@ function handleFocus () { if (searchTerm.value === '' && selectedOption.value) { searchTerm.value = resolveLabel(selectedOption.value) } + emit('focus') } function toggleDropdown () { @@ -285,6 +318,9 @@ function handleInput () { if (!openDropdown.value) { openDropdown.value = true } + if (props.creatable) { + emit('update:modelValue', searchTerm.value) + } emit('search', searchTerm.value) } @@ -294,8 +330,18 @@ function clearSelection () { openDropdown.value = false } +function confirmCreatable () { + if (creatableSuggestion.value) { + emit('update:modelValue', creatableSuggestion.value) + } + openDropdown.value = false +} + function closeDropdown () { openDropdown.value = false + if (props.creatable) { + return // keep the typed text as-is + } if (searchTerm.value.trim() === '' && selectedOption.value) { emit('update:modelValue', '') } else if (selectedOption.value) { diff --git a/tests/Api/Controller/CustomFieldNamesControllerTest.php b/tests/Api/Controller/CustomFieldNamesControllerTest.php new file mode 100644 index 0000000..d3358c1 --- /dev/null +++ b/tests/Api/Controller/CustomFieldNamesControllerTest.php @@ -0,0 +1,56 @@ +createUnauthenticatedClient(); + $client->request('GET', '/api/custom-fields/names'); + + $this->assertResponseStatusCodeSame(401); + } + + public function testReturnsArrayForAuthenticatedViewer(): void + { + $client = $this->createViewerClient(); + $client->request('GET', '/api/custom-fields/names'); + + $this->assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($data); + } + + public function testReturnsDistinctSortedNames(): void + { + $machine1 = $this->createMachine('M1'); + $this->createCustomField('Tension', 'text', $machine1); + $this->createCustomField('Numéro de série', 'text', $machine1); + + $machine2 = $this->createMachine('M2'); + $this->createCustomField('Tension', 'text', $machine2); // doublon + + $client = $this->createViewerClient(); + $client->request('GET', '/api/custom-fields/names'); + + $this->assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + + $this->assertContains('Tension', $data); + $this->assertContains('Numéro de série', $data); + // Pas de doublon + $this->assertSame(count(array_unique($data)), count($data)); + // Tri alpha + $sorted = $data; + sort($sorted, SORT_STRING); + $this->assertSame($sorted, $data); + } +}