From a9557dda7dd0fc31a5eb50ef5928516c8182ebdc Mon Sep 17 00:00:00 2001 From: Morris Jencen Chavez Date: Fri, 18 Sep 2026 19:19:27 +0000 Subject: [PATCH] fix: search and filter endpoints across all pages, not just the current one Endpoints/Index.vue filtered the search box and status dropdown purely client-side over props.endpoints.data, which is only the current paginated page (15 records). Any account with more than one page of endpoints would see "No endpoints found" for a search term or status that existed on another page, even though a real match existed. DashboardController::endpoints() now applies the search/status filters server-side before paginating. The Vue page submits filters as query params via Inertia's router.get, mirroring the pattern already used on the Events and Deliveries pages. Fixes #111 --- app/Http/Controllers/DashboardController.php | 20 ++++- resources/js/Pages/Endpoints/Index.vue | 42 +++++------ .../DashboardEndpointsSearchFilterTest.php | 73 +++++++++++++++++++ 3 files changed, 107 insertions(+), 28 deletions(-) create mode 100644 tests/Feature/DashboardEndpointsSearchFilterTest.php diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index e4d55f1..adf791b 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -37,12 +37,26 @@ public function index(Request $request): Response public function endpoints(Request $request): Response { - $endpoints = $request->user()->endpoints() - ->with('events') - ->paginate(15); + $query = $request->user()->endpoints()->with('events'); + + if ($request->filled('search')) { + $search = $request->string('search'); + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('url', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + if ($request->filled('status')) { + $query->where('is_active', $request->string('status') === 'active'); + } + + $endpoints = $query->paginate(15)->withQueryString(); return Inertia::render('Endpoints/Index', [ 'endpoints' => $endpoints, + 'filters' => $request->only(['search', 'status']), ]); } diff --git a/resources/js/Pages/Endpoints/Index.vue b/resources/js/Pages/Endpoints/Index.vue index f9e820f..151ec76 100644 --- a/resources/js/Pages/Endpoints/Index.vue +++ b/resources/js/Pages/Endpoints/Index.vue @@ -48,10 +48,11 @@ v-model="search" placeholder="Search by name, URL, or description" class="w-full pl-9" + @keyup.enter="applyFilters" />
- @@ -312,10 +313,11 @@ import Pagination from '@/Components/Pagination.vue' const props = defineProps({ endpoints: Object, + filters: Object, }) -const search = ref('') -const statusFilter = ref('') +const search = ref(props.filters?.search || '') +const statusFilter = ref(props.filters?.status || '') const showCreateModal = ref(false) const showEditModal = ref(false) const showTestModal = ref(false) @@ -333,28 +335,7 @@ const form = useForm({ is_active: true, }) -const filteredEndpoints = computed(() => { - let filtered = props.endpoints.data || [] - - if (search.value) { - const searchLower = search.value.toLowerCase() - filtered = filtered.filter(endpoint => - endpoint.name.toLowerCase().includes(searchLower) || - endpoint.url.toLowerCase().includes(searchLower) || - (endpoint.description && endpoint.description.toLowerCase().includes(searchLower)) - ) - } - - if (statusFilter.value) { - filtered = filtered.filter(endpoint => { - if (statusFilter.value === 'active') return endpoint.is_active - if (statusFilter.value === 'inactive') return !endpoint.is_active - return true - }) - } - - return filtered -}) +const filteredEndpoints = computed(() => props.endpoints.data || []) const endpointStats = computed(() => { const all = props.endpoints.data || [] @@ -477,9 +458,20 @@ async function regenerateSecret() { } } +function applyFilters() { + router.get(route('endpoints'), { + search: search.value, + status: statusFilter.value, + }, { + preserveState: true, + replace: true, + }) +} + function clearFilters() { search.value = '' statusFilter.value = '' + applyFilters() } function formatDate(dateString) { diff --git a/tests/Feature/DashboardEndpointsSearchFilterTest.php b/tests/Feature/DashboardEndpointsSearchFilterTest.php new file mode 100644 index 0000000..df27dbe --- /dev/null +++ b/tests/Feature/DashboardEndpointsSearchFilterTest.php @@ -0,0 +1,73 @@ +withPersonalTeam()->create(); + + // Fill more than a full page (15) with endpoints that don't match the search term. + for ($i = 0; $i < 15; $i++) { + Endpoint::factory()->for($user)->create(['name' => "Noise Endpoint {$i}"]); + } + $target = Endpoint::factory()->for($user)->create(['name' => 'Unique Billing Receiver']); + + $response = $this->actingAs($user)->get(route('endpoints', ['search' => 'Unique Billing Receiver'])); + + $response->assertOk(); + $response->assertInertia( + fn ($page) => $page + ->component('Endpoints/Index') + ->has('endpoints.data', 1) + ->where('endpoints.data.0.id', $target->id) + ->where('filters.search', 'Unique Billing Receiver') + ); + } + + public function test_status_filter_finds_a_matching_endpoint_beyond_the_first_page(): void + { + $user = User::factory()->withPersonalTeam()->create(); + + for ($i = 0; $i < 15; $i++) { + Endpoint::factory()->for($user)->create(['is_active' => true]); + } + $target = Endpoint::factory()->for($user)->create(['is_active' => false]); + + $response = $this->actingAs($user)->get(route('endpoints', ['status' => 'inactive'])); + + $response->assertOk(); + $response->assertInertia( + fn ($page) => $page + ->component('Endpoints/Index') + ->has('endpoints.data', 1) + ->where('endpoints.data.0.id', $target->id) + ->where('filters.status', 'inactive') + ); + } + + public function test_search_cannot_be_used_to_view_another_users_endpoints(): void + { + $user = User::factory()->withPersonalTeam()->create(); + + $otherUser = User::factory()->withPersonalTeam()->create(); + Endpoint::factory()->for($otherUser)->create(['name' => 'Other Users Endpoint']); + + $response = $this->actingAs($user)->get(route('endpoints', ['search' => 'Other Users Endpoint'])); + + $response->assertOk(); + $response->assertInertia( + fn ($page) => $page + ->component('Endpoints/Index') + ->has('endpoints.data', 0) + ); + } +}