From 0255d7dda18dd73bddfba426686ef24921077617 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 11 May 2026 14:32:06 +0200 Subject: [PATCH] feat(search-select) : ajoute prop creatable pour autoriser la saisie libre En mode creatable=true, le composant emit le texte tape en temps reel et ne reset plus au blur. Une ligne 'Creer XYZ' apparait quand le texte ne matche aucune option. Mode strict (defaut) inchange. Le composant emit aussi 'focus' pour permettre au parent de charger les donnees au premier focus. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/components/common/SearchSelect.vue | 48 +++++++++++++++- .../CustomFieldNamesControllerTest.php | 56 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 tests/Api/Controller/CustomFieldNamesControllerTest.php 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); + } +}