feat(custom-fields) : autocomplete des noms + corrections formule de référence auto #3
@@ -77,6 +77,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,6 +96,7 @@
|
|||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||||
import IconLucideX from '~icons/lucide/x'
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -137,10 +147,14 @@ const props = defineProps({
|
|||||||
serverSearch: {
|
serverSearch: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
creatable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'search'])
|
const emit = defineEmits(['update:modelValue', 'search', 'focus'])
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const openDropdown = ref(false)
|
const openDropdown = ref(false)
|
||||||
@@ -172,6 +186,18 @@ const displayedOptions = computed(() => {
|
|||||||
return filtered
|
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 inputClasses = computed(() => {
|
||||||
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
|
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
|
||||||
const base = ['input', 'input-bordered', 'w-full', pr]
|
const base = ['input', 'input-bordered', 'w-full', pr]
|
||||||
@@ -194,6 +220,12 @@ const toggleButtonClasses = computed(() => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
() => {
|
() => {
|
||||||
|
if (props.creatable) {
|
||||||
|
if (searchTerm.value !== props.modelValue) {
|
||||||
|
searchTerm.value = String(props.modelValue ?? '')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!openDropdown.value) {
|
if (!openDropdown.value) {
|
||||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||||
}
|
}
|
||||||
@@ -269,6 +301,7 @@ function handleFocus () {
|
|||||||
if (searchTerm.value === '' && selectedOption.value) {
|
if (searchTerm.value === '' && selectedOption.value) {
|
||||||
searchTerm.value = resolveLabel(selectedOption.value)
|
searchTerm.value = resolveLabel(selectedOption.value)
|
||||||
}
|
}
|
||||||
|
emit('focus')
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDropdown () {
|
function toggleDropdown () {
|
||||||
@@ -285,6 +318,9 @@ function handleInput () {
|
|||||||
if (!openDropdown.value) {
|
if (!openDropdown.value) {
|
||||||
openDropdown.value = true
|
openDropdown.value = true
|
||||||
}
|
}
|
||||||
|
if (props.creatable) {
|
||||||
|
emit('update:modelValue', searchTerm.value)
|
||||||
|
}
|
||||||
emit('search', searchTerm.value)
|
emit('search', searchTerm.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,8 +330,18 @@ function clearSelection () {
|
|||||||
openDropdown.value = false
|
openDropdown.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmCreatable () {
|
||||||
|
if (creatableSuggestion.value) {
|
||||||
|
emit('update:modelValue', creatableSuggestion.value)
|
||||||
|
}
|
||||||
|
openDropdown.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function closeDropdown () {
|
function closeDropdown () {
|
||||||
openDropdown.value = false
|
openDropdown.value = false
|
||||||
|
if (props.creatable) {
|
||||||
|
return // keep the typed text as-is
|
||||||
|
}
|
||||||
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
||||||
emit('update:modelValue', '')
|
emit('update:modelValue', '')
|
||||||
} else if (selectedOption.value) {
|
} else if (selectedOption.value) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user