Skip to content
This repository was archived by the owner on Oct 20, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/resources/views/form/components/selectAsyncDependent.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@
<div dusk="all">@{{ form.$all }}</div>
<x-splade-submit />
</x-splade-form>

<x-splade-form dusk="select-first" :default="['country' => 'BE']" :action="route('form.components.selectAsync')" class="space-y-4" >
<x-splade-select label="Country" dusk="country" name="country" :options="app('countries.keyValue')" />
<x-splade-select label="Region" dusk="province" name="province" placeholder="Pick a region" remote-url="`/api/provinces/${form.country}`" select-first-remote-option />
<div dusk="all">@{{ form.$all }}</div>
<x-splade-submit />
</x-splade-form>

<x-splade-form dusk="select-reset" :default="['country' => 'BE']" :action="route('form.components.selectAsync')" class="space-y-4" >
<x-splade-select label="Country" dusk="country" name="country" :options="app('countries.keyValue')" />
<x-splade-select label="Region" dusk="province" name="province" placeholder="Pick a region" remote-url="`/api/provinces/${form.country}`" reset-on-new-remote-url />
<div dusk="all">@{{ form.$all }}</div>
<x-splade-submit />
</x-splade-form>

</div>

@endsection
14 changes: 14 additions & 0 deletions app/resources/views/form/components/selectAsyncNested.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@
<div dusk="all">@{{ form.$all }}</div>
<x-splade-submit />
</x-splade-form>

<x-splade-form dusk="select-first" :default="['country' => 'BE']" :action="route('form.components.selectAsync')" class="space-y-4" >
<x-splade-select label="Country" dusk="country" name="country" :options="app('countries.keyValue')" />
<x-splade-select label="Region" dusk="province" name="province" placeholder="Pick a region" remote-url="`/api/provinces/${form.country}?nested=1`" remote-root="data.nested" select-first-remote-option />
<div dusk="all">@{{ form.$all }}</div>
<x-splade-submit />
</x-splade-form>

<x-splade-form dusk="select-reset" :default="['country' => 'BE']" :action="route('form.components.selectAsync')" class="space-y-4" >
<x-splade-select label="Country" dusk="country" name="country" :options="app('countries.keyValue')" />
<x-splade-select label="Region" dusk="province" name="province" placeholder="Pick a region" remote-url="`/api/provinces/${form.country}?nested=1`" remote-root="data.nested" reset-on-new-remote-url />
<div dusk="all">@{{ form.$all }}</div>
<x-splade-submit />
</x-splade-form>
</div>

@endsection
1 change: 1 addition & 0 deletions app/resources/views/navigation/nav.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<Link dusk="awayViaFacade" href="/navigation/awayViaFacade">AwayViaFacade</Link>
<Link away dusk="awayViaLink" href="https://splade.dev">AwayViaLink</Link>
<Link dusk="lazy" href="/lazy">Lazy</Link>
<Link dusk="auth" href="{{ route('navigation.one.auth') }}">Auth</Link>

<Link confirm dusk="confirm" href="/navigation/two">Confirm to two</Link>
<Link
Expand Down
21 changes: 21 additions & 0 deletions app/tests/Browser/AuthTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Tests\Browser;

use Laravel\Dusk\Browser;
use Tests\DuskTestCase;

class AuthTest extends DuskTestCase
{
/** @test */
public function it_handles_the_login_redirect()
{
$this->browse(function (Browser $browser) {
$browser->visit('/navigation/two')
->waitForText('NavigationTwo')
->click('@auth')
->waitForRoute('navigation.one')
->assertSee('NavigationOne');
});
}
}
45 changes: 45 additions & 0 deletions app/tests/Browser/Form/SelectDependentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,49 @@ public function it_restores_the_placeholder_on_choices_multiple_select($url)
});
});
}

/**
* @dataProvider dependentUrls
*
* @test
*/
public function it_can_select_the_first_remote_option($url)
{
$this->browse(function (Browser $browser) use ($url) {
$browser->visit($url)
->waitForText('FormComponents')
->waitUntilMissing('svg')
->within('@select-first', function (Browser $browser) {
$browser
->waitUntilMissing('svg')
->assertSeeIn('@all', '{ "country": "BE", "province": "BE-VAN" }')
->select('@country', 'NL')
->waitUntilMissing('svg')
->assertSeeIn('@all', '{ "country": "NL", "province": "NL-AW" }');
});
});
}

/**
* @dataProvider dependentUrls
*
* @test
*/
public function it_can_reset_the_select_option_on_a_remote_url_change($url)
{
$this->browse(function (Browser $browser) use ($url) {
$browser->visit($url)
->waitForText('FormComponents')
->waitUntilMissing('svg')
->within('@select-reset', function (Browser $browser) {
$browser
->waitUntilMissing('svg')
->select('@province', 'BE-VAN')
->assertSeeIn('@all', '{ "country": "BE", "province": "BE-VAN" }')
->select('@country', 'NL')
->waitUntilMissing('svg')
->assertSeeIn('@all', '{ "country": "NL", "province": "" }');
});
});
}
}
179 changes: 108 additions & 71 deletions lib/Components/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ export default {
required: false,
default: null,
},

selectFirstRemoteOption: {
type: Boolean,
required: false,
default: false,
},

resetOnNewRemoteUrl: {
type: Boolean,
required: false,
default: false,
},
},

emits: ["update:modelValue"],
Expand Down Expand Up @@ -167,100 +179,121 @@ export default {
},

methods: {
/*
* Loads the options from a remote URL. It removes all current options from the select
* element, and then adds the new options. If the components uses Choices.js,
* it will first destroy the instance and then re-initialize it.
*/
loadRemoteOptions() {
if(!this.remoteUrl) {
return;
async setOptionsFromRemote(data) {
// Cleanup previous choices instance.
this.destroyChoicesInstance();

if(this.resetOnNewRemoteUrl) {
this.$emit("update:modelValue", this.multiple ? [] : "");
}

this.loading = true;
let options = [];

Axios({
url: this.remoteUrl,
method: "GET",
headers: {
Accept: "application/json",
},
})
.then((response) => {
// Cleanup previous choices instance.
this.destroyChoicesInstance();
// Start with the the placeholder.
if(this.placeholder) {
options.push(this.placeholder);
}

let options = [];
// Normalize the response.
options = this.normalizeOptions(data, options);

// Start with the the placeholder.
if(this.placeholder) {
options.push(this.placeholder);
}
var index;
var currentOptionsCount = this.element.options.length - 1;

// Normalize the response.
options = this.normalizeOptions(this.remoteRoot ? get(response.data, this.remoteRoot) : response.data, options);
for(index = currentOptionsCount; index >= 0; index--) {
// Remove all current options.
this.element.remove(index);
}

var index;
var currentOptionsCount = this.element.options.length - 1;
let hasSelectedOption = false;

for(index = currentOptionsCount; index >= 0; index--) {
// Remove all current options.
this.element.remove(index);
}
forOwn(options, (option) => {
// Add the new options.
var optionElement = document.createElement("option");

let hasSelectedOption = false;
optionElement.value = option.value;
optionElement.text = option.label;

forOwn(options, (option) => {
// Add the new options.
var optionElement = document.createElement("option");
if(option.value === `${this.modelValue}` && option.value !== "") {
// The current value is in the new options, we use this later on
// to set the value on the select element and Choices instance.
hasSelectedOption = true;
}

optionElement.value = option.value;
optionElement.text = option.label;
if(option.disabled) {
optionElement.disabled = option.disabled;
}

if(option.value === `${this.modelValue}`) {
// The current value is in the new options, we use this later on
// to set the value on the select element and Choices instance.
hasSelectedOption = true;
}
if(option.placeholder) {
optionElement.placeholder = option.placeholder;
}

if(option.disabled) {
optionElement.disabled = option.disabled;
}
// Add the option to the select element.
this.element.appendChild(optionElement);
});

if(option.placeholder) {
optionElement.placeholder = option.placeholder;
}

// Add the option to the select element.
this.element.appendChild(optionElement);
});
if(!hasSelectedOption && this.selectFirstRemoteOption) {
const firstOption = this.placeholder ? options[1] : options[0];

if(!hasSelectedOption) {
// The current value is not in the new options, we set the value to null.
this.$emit("update:modelValue", this.multiple ? [] : "");
}
if(firstOption){
this.$emit("update:modelValue", this.multiple ? [firstOption.value] : firstOption.value);
await this.$nextTick();
}

if(this.choices) {
// Re-initialize the Choices instance.
return this.initChoices(this.element).then(() => {
this.loading = false;
});
}
hasSelectedOption = true;
}

if(hasSelectedOption) {
// The current value is in the new options, we set the value on the select element.
this.element.value = this.modelValue;
} else {
// The current value is not in the new options, we set the value to null.
this.$nextTick(() => {
this.element.selectedIndex = 0;
});
}
if(!hasSelectedOption) {
// The current value is not in the new options, we set the value to null.
this.$emit("update:modelValue", this.multiple ? [] : "");
}

if(this.choices) {
// Re-initialize the Choices instance.
return this.initChoices(this.element).then(() => {
this.loading = false;
});
}

if(hasSelectedOption) {
// The current value is in the new options, we set the value on the select element.
this.element.value = this.modelValue;
} else {
// The current value is not in the new options, we set the value to null.
this.$nextTick(() => {
this.element.selectedIndex = 0;
});
}
},

/*
* Loads the options from a remote URL. It removes all current options from the select
* element, and then adds the new options. If the components uses Choices.js,
* it will first destroy the instance and then re-initialize it.
*/
loadRemoteOptions() {
if(!this.remoteUrl) {
return;
}

this.loading = true;


Axios({
url: this.remoteUrl,
method: "GET",
headers: {
Accept: "application/json",
},
})
.then((response) => {
this.setOptionsFromRemote(this.remoteRoot ? get(response.data, this.remoteRoot) : response.data);
})
.catch(() => {
this.setOptionsFromRemote([]);
})
.finally(() => {
this.loading = false;
});
},
Expand Down Expand Up @@ -407,6 +440,10 @@ export default {
// The Headless UI Dialog blocks the events on the Choices.js
// instance, so we put an event listener on the portal root.
vm.headlessListener = function(e) {
if(!vm.choicesInstance) {
return;
}

const isActive = vm.choicesInstance.dropdown.isActive;

if(!isActive && e.target === selectElement) {
Expand Down
2 changes: 2 additions & 0 deletions resources/views/form/select.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
:remote-root="@js($remoteRoot ?: null)"
:option-value="@js($optionValue)"
:option-label="@js($optionLabel)"
:select-first-remote-option="@js($selectFirstRemoteOption)"
:reset-on-new-remote-url="@js($resetOnNewRemoteUrl)"
>
<template #default="{!! $scope !!}">
<label class="block" v-bind:class="{ 'pointer-events-none': select.loading }">
Expand Down
2 changes: 1 addition & 1 deletion resources/views/table/head.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class="@if($loop->first && $hasBulkActions) pr-6 @else px-6 @endif py-3 text-lef
@endif

<span class="flex flex-row items-center">
<span class="uppercase">{{ $column->label }}</span>
<span class="uppercase w-full">{{ $column->label }}</span>

@if($column->sortable)
<svg aria-hidden="true" class="w-3 h-3 ml-2 @if($column->sorted) text-green-500 @else text-gray-400 @endif" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
Expand Down
Loading