From 6d7fda1421ff82bc7f731b0bc40119da1b16702e Mon Sep 17 00:00:00 2001
From: Eleazar Resendez
Date: Tue, 30 Jun 2026 10:58:06 -0600
Subject: [PATCH 1/2] FOUR-31727: Fix DevLink bundle listing and selector
search
---
.../admin/devlink/components/LocalBundles.vue | 40 +++++++++--
.../js/components/shared/AddToBundle.vue | 13 +++-
.../js/components/shared/BackendSelect.vue | 56 ++++++++++++---
tests/Feature/Api/DevLinkTest.php | 72 +++++++++++++++++++
4 files changed, 166 insertions(+), 15 deletions(-)
diff --git a/resources/js/admin/devlink/components/LocalBundles.vue b/resources/js/admin/devlink/components/LocalBundles.vue
index 6a57f68285..d97d748e4a 100644
--- a/resources/js/admin/devlink/components/LocalBundles.vue
+++ b/resources/js/admin/devlink/components/LocalBundles.vue
@@ -8,11 +8,15 @@ import BundleModal from './BundleModal.vue';
import DeleteModal from './DeleteModal.vue';
import { useRouter, useRoute } from 'vue-router/composables';
import UpdateBundle from './UpdateBundle.vue';
+import PaginationTable from '../../../components/shared/PaginationTable.vue';
const vue = getCurrentInstance().proxy;
const router = useRouter();
const route = useRoute();
const bundles = ref([]);
+const meta = ref({});
+const page = ref(1);
+const perPage = ref(15);
const editModal = ref(null);
const confirmDeleteModal = ref(null);
const confirmPublishNewVersion = ref(null);
@@ -50,9 +54,18 @@ onMounted(() => {
const load = () => {
ProcessMaker.apiClient
- .get(`/devlink/local-bundles?filter=${filter.value}`)
+ .get('/devlink/local-bundles', {
+ params: {
+ filter: filter.value,
+ page: page.value,
+ per_page: perPage.value,
+ order_by: 'created_at',
+ order_direction: 'desc',
+ }
+ })
.then((result) => {
bundles.value = result.data.data;
+ meta.value = result.data.meta;
refreshKey.value++;
});
};
@@ -131,6 +144,7 @@ const create = () => {
ProcessMaker.apiClient
.post('/devlink/local-bundles', selected.value)
.then((result) => {
+ page.value = 1;
load();
});
};
@@ -197,9 +211,21 @@ const debouncedLoad = debounce(load, 300);
// Function called on change
const handleFilterChange = () => {
+ page.value = 1;
debouncedLoad();
};
+const handlePageChange = (newPage) => {
+ page.value = newPage;
+ load();
+};
+
+const handlePerPageChange = (newPerPage) => {
+ page.value = 1;
+ perPage.value = newPerPage;
+ load();
+};
+
const canEdit = (bundle) => {
return bundle.dev_link === null;
}
@@ -280,9 +306,9 @@ const handleInstallationComplete = () => {
- {{ data.item.version }}
@@ -301,6 +327,12 @@ const handleInstallationComplete = () => {
{{ $t("Create a bundle to easily share assets and settings between ProcessMaker instances.") }}
+
diff --git a/resources/js/components/shared/AddToBundle.vue b/resources/js/components/shared/AddToBundle.vue
index d3b47dc218..c909acddcb 100644
--- a/resources/js/components/shared/AddToBundle.vue
+++ b/resources/js/components/shared/AddToBundle.vue
@@ -102,8 +102,8 @@ const save = (event) => {
{{ vue.$t('Add asset to bundle') }}
@@ -119,7 +119,14 @@ const save = (event) => {
{{ vue.$t('Bundles') }}
import { defineProps, onMounted, ref, watch, defineEmits, getCurrentInstance } from 'vue';
+import debounce from 'lodash/debounce';
const vue = getCurrentInstance().proxy;
const options = ref([]);
-const value = ref(props.value);
const emit = defineEmits(['input']);
// get component props
@@ -21,27 +21,67 @@ const props = defineProps({
required: true
},
value: {
- type: Array,
- }
+ type: [Array, Object],
+ default: null
+ },
+ queryParams: {
+ type: Object,
+ default: () => ({})
+ },
+ remoteSearch: {
+ type: Boolean,
+ default: false
+ },
});
+const value = ref(props.value);
+const loading = ref(false);
watch(value, () => {
emit('input', value.value);
});
-onMounted(() => {
- window.ProcessMaker.apiClient.get(props.url).then((response) => {
+watch(() => props.value, () => {
+ value.value = props.value;
+});
+
+const loadOptions = (filter = '') => {
+ loading.value = true;
+ const params = { ...props.queryParams };
+ if (props.remoteSearch) {
+ params.filter = typeof filter === 'string' ? filter : '';
+ }
+ globalThis.ProcessMaker.apiClient.get(props.url, { params }).then((response) => {
options.value = response.data.data;
+ }).finally(() => {
+ loading.value = false;
});
-})
+};
+
+const debouncedLoadOptions = debounce(loadOptions, 300);
+
+const search = (filter) => {
+ if (props.remoteSearch) {
+ debouncedLoadOptions(filter);
+ }
+};
+
+onMounted(() => {
+ loadOptions();
+});
+ :placeholder="vue.$t('Type here to search')" :options="options" :searchable="true" :allow-empty="false"
+ :multiple="true" :loading="loading" :internal-search="!props.remoteSearch" @open="loadOptions" @search-change="search">
+
+ {{ vue.$t('No elements found. Consider changing the search query.') }}
+
+
+ {{ vue.$t('No Data Available') }}
+
diff --git a/tests/Feature/Api/DevLinkTest.php b/tests/Feature/Api/DevLinkTest.php
index 9bed587b88..0299deb6ab 100644
--- a/tests/Feature/Api/DevLinkTest.php
+++ b/tests/Feature/Api/DevLinkTest.php
@@ -23,6 +23,78 @@ public function testShowBundle()
$this->assertEquals($bundle->id, $response->json()['id']);
}
+ public function testLocalBundlesCanOrderNewestBundlesFirst()
+ {
+ $oldBundle = Bundle::factory()->create([
+ 'created_at' => now()->subDays(2),
+ ]);
+ $newBundle = Bundle::factory()->create([
+ 'created_at' => now(),
+ ]);
+
+ $response = $this->apiCall('GET', route('api.devlink.local-bundles', [
+ 'order_by' => 'created_at',
+ 'order_direction' => 'desc',
+ ]));
+
+ $response->assertStatus(200);
+ $this->assertEquals($newBundle->id, $response->json('data.0.id'));
+ $this->assertNotEquals($oldBundle->id, $response->json('data.0.id'));
+ }
+
+ public function testLocalBundlesCanReturnOneHundredRecords()
+ {
+ Bundle::factory()->count(101)->create();
+
+ $response = $this->apiCall('GET', route('api.devlink.local-bundles', [
+ 'per_page' => 100,
+ ]));
+
+ $response->assertStatus(200);
+ $this->assertCount(100, $response->json('data'));
+ $this->assertEquals(100, $response->json('meta.per_page'));
+ }
+
+ public function testLocalBundlesFilterFindsBundleOutsideFirstPage()
+ {
+ $targetBundle = Bundle::factory()->create([
+ 'name' => 'FOUR-31727 Search Target',
+ 'created_at' => now()->subDays(2),
+ ]);
+ Bundle::factory()->count(15)->create([
+ 'created_at' => now(),
+ ]);
+
+ $response = $this->apiCall('GET', route('api.devlink.local-bundles', [
+ 'filter' => 'FOUR-31727 Search Target',
+ ]));
+
+ $response->assertStatus(200);
+ $this->assertEquals($targetBundle->id, $response->json('data.0.id'));
+ $this->assertCount(1, $response->json('data'));
+ }
+
+ public function testLocalBundlesEditableFilterExcludesRemoteBundles()
+ {
+ $devLink = DevLink::factory()->create();
+ $localBundle = Bundle::factory()->create([
+ 'dev_link_id' => null,
+ ]);
+ $remoteBundle = Bundle::factory()->create([
+ 'dev_link_id' => $devLink->id,
+ ]);
+
+ $response = $this->apiCall('GET', route('api.devlink.local-bundles', [
+ 'editable' => true,
+ 'per_page' => 100,
+ ]));
+
+ $response->assertStatus(200);
+ $bundleIds = collect($response->json('data'))->pluck('id');
+ $this->assertTrue($bundleIds->contains($localBundle->id));
+ $this->assertFalse($bundleIds->contains($remoteBundle->id));
+ }
+
public function testAddAssets()
{
$screen1 = Screen::factory()->create();
From 7614008aa8d40abeb21c1b0bf1b1160e72070f15 Mon Sep 17 00:00:00 2001
From: Eleazar Resendez
Date: Wed, 1 Jul 2026 12:25:38 -0600
Subject: [PATCH 2/2] FOUR-31727: Stabilize remote backend select search
---
.../js/components/shared/BackendSelect.vue | 39 ++++++++++++++++---
1 file changed, 33 insertions(+), 6 deletions(-)
diff --git a/resources/js/components/shared/BackendSelect.vue b/resources/js/components/shared/BackendSelect.vue
index 0c685d594a..f0cea82848 100644
--- a/resources/js/components/shared/BackendSelect.vue
+++ b/resources/js/components/shared/BackendSelect.vue
@@ -1,5 +1,5 @@
+ :multiple="true" :loading="loading" :internal-search="!props.remoteSearch" @open="handleOpen" @search-change="search">
{{ vue.$t('No elements found. Consider changing the search query.') }}