Skip to content

Commit 8f0db65

Browse files
[5.x] Add support for batch searching (#1714)
* feat: add search to batches view * formatting --------- Co-authored-by: Taylor Otwell <taylor@laravel.com>
1 parent 7e22960 commit 8f0db65

File tree

4 files changed

+251
-17
lines changed

4 files changed

+251
-17
lines changed

resources/js/screens/batches/index.vue

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
ready: false,
99
loadingNewEntries: false,
1010
hasNewEntries: false,
11-
page: 1,
12-
previousFirstId: null,
11+
page: parseInt(this.$route.query.page) || 1,
12+
previousFirstId: this.$route.query.previous_first_id || null,
1313
batches: [],
14+
searchQuery: this.$route.query.query || '',
15+
searchTimeout: null,
1416
};
1517
},
1618
@@ -19,17 +21,27 @@
1921
*/
2022
mounted() {
2123
document.title = "Horizon - Batches";
24+
25+
this.loadBatches(this.$route.query.before_id || '');
2226
},
2327
2428
2529
/**
2630
* Watch these properties for changes.
2731
*/
2832
watch: {
29-
'$route'() {
30-
this.page = 1;
33+
searchQuery(newVal, oldVal) {
34+
if (!oldVal) return;
35+
36+
clearTimeout(this.searchTimeout);
37+
38+
this.searchTimeout = setTimeout(() => {
39+
this.page = 1;
40+
this.previousFirstId = null;
3141
32-
this.loadBatches();
42+
this.loadBatches();
43+
this.updateQueryParams();
44+
}, 500);
3345
},
3446
3547
'$root.autoLoadsNewEntries'(autoLoadsNewEntries) {
@@ -49,7 +61,9 @@
4961
this.ready = false;
5062
}
5163
52-
this.$http.get(Horizon.basePath + '/api/batches?before_id=' + beforeId)
64+
var searchQuery = this.searchQuery ? 'query=' + encodeURIComponent(this.searchQuery) + '&' : '';
65+
66+
this.$http.get(Horizon.basePath + '/api/batches?' + searchQuery + 'before_id=' + beforeId)
5367
.then(response => {
5468
if (!this.$root.autoLoadsNewEntries && refreshing && !response.data.batches.length) {
5569
this.ready = true;
@@ -70,9 +84,14 @@
7084
loadNewEntries() {
7185
this.batches = [];
7286
73-
this.loadBatches(0, false);
87+
this.page = 1;
88+
this.previousFirstId = null;
89+
90+
this.loadBatches('', false);
7491
7592
this.hasNewEntries = false;
93+
94+
this.updateQueryParams();
7695
},
7796
7897
@@ -82,6 +101,8 @@
82101
refreshBatchesPeriodically() {
83102
if (this.page != 1) return;
84103
104+
if (this.searchQuery) return;
105+
85106
this.loadBatches('', true);
86107
},
87108
@@ -90,13 +111,15 @@
90111
* Load the batches for the previous page.
91112
*/
92113
previous() {
93-
this.loadBatches(
94-
this.page == 2 ? '' : this.previousFirstId
95-
);
114+
var beforeId = this.page == 2 ? '' : this.previousFirstId;
115+
116+
this.loadBatches(beforeId);
96117
97118
this.page -= 1;
98119
99120
this.hasNewEntries = false;
121+
122+
this.updateQueryParams(beforeId);
100123
},
101124
102125
@@ -106,14 +129,39 @@
106129
next() {
107130
this.previousFirstId = this.batches[0]?.id + '0';
108131
109-
this.loadBatches(
110-
this.batches.slice(-1)[0]?.id
111-
);
132+
var beforeId = this.batches.slice(-1)[0]?.id;
133+
134+
this.loadBatches(beforeId);
112135
113136
this.page += 1;
114137
115138
this.hasNewEntries = false;
116-
}
139+
140+
this.updateQueryParams(beforeId);
141+
},
142+
143+
144+
/**
145+
* Clear the search query and reset the table state.
146+
*/
147+
clearSearch() {
148+
this.searchQuery = '';
149+
},
150+
151+
152+
/**
153+
* Sync pagination and search state to URL query params.
154+
*/
155+
updateQueryParams(beforeId) {
156+
var query = {};
157+
158+
if (this.searchQuery) query.query = this.searchQuery;
159+
if (this.page > 1) query.page = this.page;
160+
if (beforeId) query.before_id = beforeId;
161+
if (this.previousFirstId && this.page > 1) query.previous_first_id = this.previousFirstId;
162+
163+
this.$router.replace({ query }).catch(() => {});
164+
},
117165
}
118166
}
119167
</script>
@@ -125,6 +173,22 @@
125173
<div class="card overflow-hidden">
126174
<div class="card-header d-flex align-items-center justify-content-between">
127175
<h2 class="h6 m-0">Batches</h2>
176+
177+
<div class="form-control-with-icon">
178+
<div class="icon-wrapper">
179+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon">
180+
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
181+
</svg>
182+
</div>
183+
184+
<input type="text" class="form-control w-100" :style="searchQuery ? 'padding-right: 2rem' : ''" v-model="searchQuery" placeholder="Search Batches">
185+
186+
<a v-if="searchQuery" href="#" @click.prevent="clearSearch" class="clear-search">
187+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon">
188+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
189+
</svg>
190+
</a>
191+
</div>
128192
</div>
129193
130194
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">

resources/sass/base.scss

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,24 @@ svg.icon {
110110
font-size: 0.875rem;
111111
border-radius: 9999px;
112112
}
113+
114+
.clear-search {
115+
display: flex;
116+
align-items: center;
117+
justify-content: center;
118+
position: absolute;
119+
top: 0;
120+
right: 0.5rem;
121+
bottom: 0;
122+
123+
.icon {
124+
fill: $text-muted;
125+
}
126+
127+
&:hover .icon {
128+
fill: $body-color;
129+
}
130+
}
113131
}
114132
}
115133

src/Http/Controllers/BatchesController.php

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
use Illuminate\Bus\BatchRepository;
66
use Illuminate\Database\QueryException;
77
use Illuminate\Http\Request;
8+
use Illuminate\Support\Facades\DB;
89
use Laravel\Horizon\Contracts\JobRepository;
910
use Laravel\Horizon\Jobs\RetryFailedJob;
1011

1112
class BatchesController extends Controller
1213
{
1314
/**
14-
* The job repository implementation.
15+
* The batch repository implementation.
1516
*
1617
* @var \Illuminate\Bus\BatchRepository
1718
*/
@@ -39,7 +40,9 @@ public function __construct(BatchRepository $batches)
3940
public function index(Request $request)
4041
{
4142
try {
42-
$batches = $this->batches->get(50, $request->query('before_id') ?: null);
43+
$batches = $request->query('query')
44+
? $this->searchBatches($request)
45+
: $this->batches->get(50, $request->query('before_id'));
4346
} catch (QueryException $e) {
4447
$batches = [];
4548
}
@@ -61,7 +64,7 @@ public function show($id)
6164

6265
if ($batch) {
6366
$failedJobs = app(JobRepository::class)
64-
->getJobs($batch->failedJobIds);
67+
->getJobs($batch->failedJobIds);
6568
}
6669

6770
return [
@@ -70,6 +73,32 @@ public function show($id)
7073
];
7174
}
7275

76+
/**
77+
* Search the batches by name or ID.
78+
*
79+
* @param \Illuminate\Http\Request $request
80+
* @return array
81+
*/
82+
private function searchBatches(Request $request)
83+
{
84+
$query = str_replace(['%', '_'], ['\%', '\_'], $request->query('query'));
85+
86+
return DB::connection(config('queue.batching.database'))
87+
->table(config('queue.batching.table', 'job_batches'))
88+
->where(function ($q) use ($query) {
89+
$q->where('name', 'like', "%{$query}%")
90+
->orWhere('id', 'like', "%{$query}%");
91+
})
92+
->orderByDesc('id')
93+
->limit(50)
94+
->when($request->query('before_id'), fn ($q, $beforeId) => $q->where('id', '<', $beforeId))
95+
->pluck('id')
96+
->map(fn ($id) => $this->batches->find($id))
97+
->filter()
98+
->values()
99+
->all();
100+
}
101+
73102
/**
74103
* Retry the given batch.
75104
*
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
3+
namespace Laravel\Horizon\Tests\Controller;
4+
5+
use Illuminate\Support\Facades\DB;
6+
use Illuminate\Support\Facades\Schema;
7+
use Laravel\Horizon\Tests\ControllerTest;
8+
9+
class BatchesControllerTest extends ControllerTest
10+
{
11+
public function test_batches_can_be_searched_by_name()
12+
{
13+
$this->setupBatchTable();
14+
$this->seedBatches();
15+
16+
$response = $this->actingAs(new Fakes\User)
17+
->get('/horizon/api/batches?query=Import');
18+
19+
$response->assertOk();
20+
21+
$batches = $response->original['batches'];
22+
23+
$this->assertCount(1, $batches);
24+
$this->assertSame('Import Users', $batches[0]->name);
25+
}
26+
27+
public function test_batches_can_be_searched_by_id()
28+
{
29+
$this->setupBatchTable();
30+
$this->seedBatches();
31+
32+
$response = $this->actingAs(new Fakes\User)
33+
->get('/horizon/api/batches?query=batch-2');
34+
35+
$response->assertOk();
36+
37+
$batches = $response->original['batches'];
38+
39+
$this->assertCount(1, $batches);
40+
$this->assertSame('Send Emails', $batches[0]->name);
41+
}
42+
43+
public function test_search_escapes_like_wildcards()
44+
{
45+
$this->setupBatchTable();
46+
$this->seedBatches();
47+
48+
$response = $this->actingAs(new Fakes\User)
49+
->get('/horizon/api/batches?query=%25');
50+
51+
$response->assertOk();
52+
53+
$this->assertEmpty($response->original['batches']);
54+
}
55+
56+
public function test_search_supports_cursor_pagination()
57+
{
58+
$this->setupBatchTable();
59+
60+
for ($i = 1; $i <= 3; $i++) {
61+
$this->insertBatch("batch-{$i}", 'Import Chunk '.$i);
62+
}
63+
64+
$response = $this->actingAs(new Fakes\User)
65+
->get('/horizon/api/batches?query=Import&before_id=batch-3');
66+
67+
$response->assertOk();
68+
69+
$batches = $response->original['batches'];
70+
71+
$this->assertCount(2, $batches);
72+
$this->assertSame('batch-2', $batches[0]->id);
73+
$this->assertSame('batch-1', $batches[1]->id);
74+
}
75+
76+
private function setupBatchTable()
77+
{
78+
$this->app['config']->set('queue.batching.database', 'testing');
79+
$this->app['config']->set('queue.batching.table', 'job_batches');
80+
$this->app['config']->set('database.connections.testing', [
81+
'driver' => 'sqlite',
82+
'database' => ':memory:',
83+
]);
84+
85+
Schema::connection('testing')->create('job_batches', static function ($table) {
86+
$table->string('id')->primary();
87+
$table->string('name');
88+
$table->integer('total_jobs');
89+
$table->integer('pending_jobs');
90+
$table->integer('failed_jobs');
91+
$table->longText('failed_job_ids');
92+
$table->mediumText('options')->nullable();
93+
$table->integer('cancelled_at')->nullable();
94+
$table->integer('created_at');
95+
$table->integer('finished_at')->nullable();
96+
});
97+
}
98+
99+
private function seedBatches()
100+
{
101+
$this->insertBatch('batch-1', 'Import Users');
102+
$this->insertBatch('batch-2', 'Send Emails');
103+
$this->insertBatch('batch-3', 'Process Orders');
104+
}
105+
106+
private function insertBatch($id, $name)
107+
{
108+
DB::connection('testing')
109+
->table('job_batches')
110+
->insert([
111+
'id' => $id,
112+
'name' => $name,
113+
'total_jobs' => 10,
114+
'pending_jobs' => 0,
115+
'failed_jobs' => 0,
116+
'failed_job_ids' => '[]',
117+
'options' => serialize([]),
118+
'created_at' => time(),
119+
'cancelled_at' => null,
120+
'finished_at' => null,
121+
]);
122+
}
123+
}

0 commit comments

Comments
 (0)