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) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
56
tests/Api/Controller/CustomFieldNamesControllerTest.php
Normal file
56
tests/Api/Controller/CustomFieldNamesControllerTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user