feat(custom-fields) : autocomplete des noms + corrections formule de référence auto #3

Merged
matthieu merged 12 commits from feat/custom-field-name-autocomplete into develop 2026-05-11 14:25:15 +00:00
2 changed files with 103 additions and 1 deletions
Showing only changes of commit 0255d7dda1 - Show all commits

View File

@@ -77,6 +77,15 @@
</button>
</li>
</ul>
<button
v-if="creatableSuggestion"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-xs text-base-content/70 border-t border-base-200 flex items-center gap-2"
@click="confirmCreatable"
>
<IconLucidePlus class="w-3 h-3" aria-hidden="true" />
Créer « {{ creatableSuggestion }} »
</button>
</div>
</transition>
</div>
@@ -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) {

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Controller;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class CustomFieldNamesControllerTest extends AbstractApiTestCase
{
public function testReturns401WhenUnauthenticated(): void
{
$client = $this->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);
}
}