Skip to content

Commit 3c56a52

Browse files
committed
Support sorting in data-tables
1 parent ff60fd7 commit 3c56a52

File tree

6 files changed

+112
-4
lines changed

6 files changed

+112
-4
lines changed

.ai/guidelines/core.blade.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Core Guidelines
22

3+
**This is the core guidelines. These are very important and high prio and have precedence over other guidelines in this file.**
4+
35
## General Rules
46

57
- If you find yourself in a loop of failing, ask the user before continuing.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ AGENTS.md
3333
.cursor
3434
.github/copilot-instructions.md
3535
boost.json
36+
opencode.json

app/Helpers/QueryBuilder.php

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ class QueryBuilder
99
{
1010
protected array $searchableFields = [];
1111

12+
protected ?string $sortBy = null;
13+
14+
protected ?string $sortDir = null;
15+
1216
public function __construct(public Builder|Relation $query) {}
1317

1418
public static function for(Builder|Relation $query): self
@@ -23,9 +27,27 @@ public function searchableFields(array $fields): self
2327
return $this;
2428
}
2529

30+
public function sortable(?string $defaultSortBy, ?string $defaultSortDir): self
31+
{
32+
if (request()->has('sort_by') && request()->has('sort_dir')) {
33+
$sortBy = request('sort_by');
34+
$sortDir = request('sort_dir');
35+
36+
$dir = strtolower($sortDir) === 'asc' ? 'asc' : 'desc';
37+
38+
$this->sortBy = $sortBy;
39+
$this->sortDir = $dir;
40+
} elseif ($defaultSortBy && $defaultSortDir) {
41+
$this->sortBy = $defaultSortBy;
42+
$this->sortDir = strtolower($defaultSortDir) === 'asc' ? 'asc' : 'desc';
43+
}
44+
45+
return $this;
46+
}
47+
2648
public function query(): Builder|Relation
2749
{
28-
return $this->query->where(function ($query) {
50+
$this->query->where(function ($query) {
2951
if (request()->has('search') && ! empty(request('search'))) {
3052
$search = request('search');
3153
$query->where(function ($q) use ($search) {
@@ -34,6 +56,13 @@ public function query(): Builder|Relation
3456
}
3557
});
3658
}
59+
3760
});
61+
62+
if ($this->sortBy && $this->sortDir) {
63+
$this->query->orderBy($this->sortBy, $this->sortDir);
64+
}
65+
66+
return $this->query;
3867
}
3968
}

app/Http/Controllers/ServerLogController.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ public function index(Server $server): Response
3232
{
3333
$this->authorize('viewAny', [ServerLog::class, $server]);
3434

35-
$logs = QueryBuilder::for($server->logs()->where('is_remote', 0)->latest())
35+
$logs = QueryBuilder::for($server->logs()->where('is_remote', 0))
3636
->searchableFields(['name'])
37+
->sortable('created_at', 'desc')
3738
->query()
3839
->simplePaginate(config('web.pagination_size'));
3940

resources/js/components/data-table.tsx

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ import { PaginatedData } from '@/types';
99
import { Input } from './ui/input';
1010
import { useEffect, useState } from 'react';
1111

12+
function SortIndicator({ sortKey }: { sortKey: string }) {
13+
if (typeof window === 'undefined') {
14+
return null;
15+
}
16+
17+
const params = new URLSearchParams(window.location.search);
18+
const current = params.get('sort_by');
19+
const dir = params.get('sort_dir') || 'desc';
20+
21+
if (current !== sortKey) {
22+
return <span className="text-muted-foreground"></span>;
23+
}
24+
25+
return <span className="text-muted-foreground">{dir === 'asc' ? '↑' : '↓'}</span>;
26+
}
27+
1228
interface DataTableProps<TData, TValue> {
1329
columns: ColumnDef<TData, TValue>[];
1430
paginatedData?: PaginatedData<TData>;
@@ -19,6 +35,7 @@ interface DataTableProps<TData, TValue> {
1935
isFetching?: boolean;
2036
isLoading?: boolean;
2137
searchable?: boolean;
38+
sortable?: boolean;
2239
onRowClick?: (row: TData) => void;
2340
}
2441

@@ -32,6 +49,7 @@ export function DataTable<TData, TValue>({
3249
isFetching,
3350
isLoading,
3451
searchable,
52+
sortable = false,
3553
onRowClick,
3654
}: DataTableProps<TData, TValue>) {
3755
// Use paginatedData.data if available, otherwise fall back to data prop
@@ -76,6 +94,18 @@ export function DataTable<TData, TValue>({
7694
urlObj.searchParams.set('search', searchQuery);
7795
}
7896

97+
// Preserve the current sort parameters
98+
const currentParams = new URLSearchParams(window.location.search);
99+
const sortBy = currentParams.get('sort_by');
100+
const sortDir = currentParams.get('sort_dir');
101+
102+
if (sortBy) {
103+
urlObj.searchParams.set('sort_by', sortBy);
104+
}
105+
if (sortDir) {
106+
urlObj.searchParams.set('sort_dir', sortDir);
107+
}
108+
79109
router.get(urlObj.toString(), {}, { preserveState: true, preserveScroll: true });
80110
}
81111
};
@@ -98,6 +128,19 @@ export function DataTable<TData, TValue>({
98128
if (searchQuery.length > 0) {
99129
url.searchParams.set('search', searchQuery);
100130
}
131+
132+
// Preserve the current sort parameters
133+
const currentParams = new URLSearchParams(window.location.search);
134+
const sortBy = currentParams.get('sort_by');
135+
const sortDir = currentParams.get('sort_dir');
136+
137+
if (sortBy) {
138+
url.searchParams.set('sort_by', sortBy);
139+
}
140+
if (sortDir) {
141+
url.searchParams.set('sort_dir', sortDir);
142+
}
143+
101144
router.get(
102145
url.toString(),
103146
{},
@@ -141,9 +184,41 @@ export function DataTable<TData, TValue>({
141184
{table.getHeaderGroups().map((headerGroup) => (
142185
<TableRow key={headerGroup.id}>
143186
{headerGroup.headers.map((header) => {
187+
const canSort = sortable && header.column.getCanSort();
188+
189+
// determine unique key to use for sorting: use the column id provided by the table
190+
const sortKey = header.id;
191+
144192
return (
145193
<TableHead key={header.id}>
146-
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
194+
{header.isPlaceholder ? null : canSort ? (
195+
<button
196+
type="button"
197+
className="flex cursor-pointer items-center gap-2"
198+
onClick={() => {
199+
// Build new URL preserving all existing params
200+
const url = new URL(window.location.href);
201+
const params = url.searchParams;
202+
203+
const current = params.get('sort_by');
204+
const currentDir = params.get('sort_dir') || 'desc';
205+
206+
if (current !== sortKey) {
207+
params.set('sort_by', sortKey);
208+
params.set('sort_dir', 'asc');
209+
} else {
210+
params.set('sort_dir', currentDir === 'asc' ? 'desc' : 'asc');
211+
}
212+
213+
router.get(url.toString(), {}, { preserveState: true, preserveScroll: true });
214+
}}
215+
>
216+
{flexRender(header.column.columnDef.header, header.getContext())}
217+
<SortIndicator sortKey={sortKey} />
218+
</button>
219+
) : (
220+
flexRender(header.column.columnDef.header, header.getContext())
221+
)}
147222
</TableHead>
148223
);
149224
})}

resources/js/pages/server-logs/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export default function ServerLogs() {
4545
</div>
4646
</HeaderContainer>
4747

48-
<DataTable columns={columns} paginatedData={page.props.logs} searchable />
48+
<DataTable columns={columns} paginatedData={page.props.logs} searchable sortable />
4949
</Container>
5050
</ServerLayout>
5151
);

0 commit comments

Comments
 (0)