Skip to content

Commit 61b5002

Browse files
committed
Add search and sorting to ObjectListView, update API filters
Enhanced the ObjectListView component with search and sorting capabilities, including debounced search input and multi-column sorting. Updated the API request to support filter and sort query parameters. Modified the Knex driver to handle 'contains' operator and nested filter groups correctly. Added 'concurrently' as a dev dependency and updated npm scripts for improved development workflow.
1 parent 8231117 commit 61b5002

File tree

4 files changed

+145
-34
lines changed

4 files changed

+145
-34
lines changed

package-lock.json

Lines changed: 55 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
"examples/*"
77
],
88
"scripts": {
9+
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\" --kill-others --names \"SERVER,CLIENT\" -c \"magenta,blue\"",
910
"start": "npm run start --workspace=@objectql/server",
10-
"server": "npm run dev --workspace=@objectql/server",
11-
"client": "npm run dev --workspace=@objectql/client",
11+
"dev:server": "npm run dev --workspace=@objectql/server",
12+
"dev:client": "npm run dev --workspace=@objectql/client",
1213
"build": "tsc -b && npm run build --workspaces",
1314
"test": "npm run test --workspaces",
1415
"changeset": "changeset",
@@ -24,6 +25,7 @@
2425
"@types/js-yaml": "^4.0.9",
2526
"@types/node": "^20.10.0",
2627
"@types/supertest": "^6.0.3",
28+
"concurrently": "^9.2.1",
2729
"jest": "^30.2.0",
2830
"js-yaml": "^4.1.1",
2931
"supertest": "^7.2.2",

packages/client/src/components/dashboard/ObjectListView.tsx

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect } from 'react';
2-
import { Button, Badge, Modal, Spinner, GridView } from '@objectql/ui';
2+
import { Button, Badge, Modal, Spinner, GridView, Input } from '@objectql/ui';
33
import { ObjectForm } from './ObjectForm';
44
import { cn } from '../../lib/utils';
55
// import { useRouter } from ... passed as prop
@@ -12,13 +12,27 @@ interface ObjectListViewProps {
1212
objectSchema: any;
1313
}
1414

15+
interface SortConfig {
16+
columnId: string;
17+
direction: 'asc' | 'desc';
18+
}
19+
1520
export function ObjectListView({ objectName, user, isCreating, navigate, objectSchema }: ObjectListViewProps) {
1621
const [data, setData] = useState<any[]>([]);
1722
const [loading, setLoading] = useState(false);
1823
const [error, setError] = useState<string | null>(null);
1924
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
25+
const [sortConfig, setSortConfig] = useState<SortConfig[]>([]);
26+
const [searchTerm, setSearchTerm] = useState('');
2027

2128
const label = objectSchema?.label || objectSchema?.title || objectName;
29+
30+
// Debounce search term
31+
const [debouncedSearch, setDebouncedSearch] = useState(searchTerm);
32+
useEffect(() => {
33+
const timer = setTimeout(() => setDebouncedSearch(searchTerm), 500);
34+
return () => clearTimeout(timer);
35+
}, [searchTerm]);
2236

2337
const getFieldLabel = (key: string) => {
2438
if (!objectSchema || !objectSchema.fields) return key;
@@ -49,7 +63,35 @@ export function ObjectListView({ objectName, user, isCreating, navigate, objectS
4963
setLoading(true);
5064
setError(null);
5165

52-
fetch(`/api/object/${objectName}`, { headers: getHeaders() })
66+
const params = new URLSearchParams();
67+
if (sortConfig.length > 0) {
68+
// API expects sort=field:order or multiple
69+
const sortParam = sortConfig.map(s => `${s.columnId}:${s.direction}`).join(',');
70+
params.append('sort', sortParam);
71+
}
72+
73+
if (debouncedSearch) {
74+
// Simple search implementation: try to search in name or title or description fields
75+
// Or search in all text fields
76+
const textFields = objectSchema?.fields ?
77+
Object.entries(objectSchema.fields)
78+
.filter(([_, field]: [string, any]) => !field.type || field.type === 'string')
79+
.map(([key]) => key)
80+
: ['name', 'title', 'description', 'email'];
81+
82+
if (textFields.length > 0) {
83+
// Construct array-based filters: [['field', 'contains', 'val'], 'or', ['field2', ...]]
84+
const searchFilters: any[] = [];
85+
textFields.forEach((field, index) => {
86+
if (index > 0) searchFilters.push('or');
87+
searchFilters.push([field, 'contains', debouncedSearch]);
88+
});
89+
// If sending strict JSON array for unified query
90+
params.append('filters', JSON.stringify(searchFilters));
91+
}
92+
}
93+
94+
fetch(`/api/object/${objectName}?${params.toString()}`, { headers: getHeaders() })
5395
.then(async res => {
5496
if (!res.ok) {
5597
const contentType = res.headers.get("content-type");
@@ -75,7 +117,7 @@ export function ObjectListView({ objectName, user, isCreating, navigate, objectS
75117

76118
useEffect(() => {
77119
if (user && objectName) fetchData();
78-
}, [objectName, user]);
120+
}, [objectName, user, sortConfig, debouncedSearch]);
79121

80122
const handleCreate = (formData: any) => {
81123
fetch(`/api/object/${objectName}`, {
@@ -171,6 +213,14 @@ export function ObjectListView({ objectName, user, isCreating, navigate, objectS
171213
</h3>
172214
</div>
173215
<div className="flex items-center gap-2">
216+
<div className="w-64">
217+
<Input
218+
placeholder="Search..."
219+
value={searchTerm}
220+
onChange={(e) => setSearchTerm(e.target.value)}
221+
className="h-9"
222+
/>
223+
</div>
174224
<div className="inline-flex rounded-lg border border-stone-200 bg-stone-50 p-1">
175225
<button
176226
onClick={() => setViewMode('table')}
@@ -246,6 +296,8 @@ export function ObjectListView({ objectName, user, isCreating, navigate, objectS
246296
onCellEdit={handleCellEdit}
247297
onDelete={handleDelete}
248298
emptyMessage={`No ${label.toLowerCase()} found`}
299+
enableSorting={true}
300+
onSortChange={setSortConfig}
249301
/>
250302
) : (
251303
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">

packages/driver-knex/src/index.ts

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ export class KnexDriver implements Driver {
2222
private applyFilters(builder: Knex.QueryBuilder, filters: any) {
2323
if (!filters || filters.length === 0) return;
2424

25-
// Simple linear parser handling [cond, 'or', cond, 'and', cond]
26-
// Default join is AND.
2725
let nextJoin = 'and';
2826

2927
for (const item of filters) {
@@ -34,36 +32,40 @@ export class KnexDriver implements Driver {
3432
}
3533

3634
if (Array.isArray(item)) {
35+
// Heuristic to detect if it is a criterion [field, op, value] or a nested group
3736
const [field, op, value] = item;
38-
39-
// Handle specific operators that map to different knex methods
40-
const apply = (b: any) => {
41-
// b is the builder to apply on (could be root or a where clause)
42-
// But here we call directly on builder using 'where' or 'orWhere'
43-
44-
// Method selection
45-
let method = nextJoin === 'or' ? 'orWhere' : 'where';
46-
let methodIn = nextJoin === 'or' ? 'orWhereIn' : 'whereIn';
47-
let methodNotIn = nextJoin === 'or' ? 'orWhereNotIn' : 'whereNotIn';
48-
49-
switch (op) {
50-
case '=': b[method](field, value); break;
51-
case '!=': b[method](field, '<>', value); break;
52-
case 'in': b[methodIn](field, value); break;
53-
case 'nin': b[methodNotIn](field, value); break;
54-
case 'contains': b[method](field, 'like', `%${value}%`); break; // Simple LIKE
55-
default: b[method](field, op, value);
56-
}
57-
};
37+
const isCriterion = typeof field === 'string' && typeof op === 'string';
38+
39+
if (isCriterion) {
40+
// Handle specific operators that map to different knex methods
41+
const apply = (b: any) => {
42+
let method = nextJoin === 'or' ? 'orWhere' : 'where';
43+
let methodIn = nextJoin === 'or' ? 'orWhereIn' : 'whereIn';
44+
let methodNotIn = nextJoin === 'or' ? 'orWhereNotIn' : 'whereNotIn';
45+
46+
// Fix for 'contains' mapping
47+
if (op === 'contains') {
48+
b[method](field, 'like', `%${value}%`);
49+
return;
50+
}
5851

59-
apply(builder);
52+
switch (op) {
53+
case '=': b[method](field, value); break;
54+
case '!=': b[method](field, '<>', value); break;
55+
case 'in': b[methodIn](field, value); break;
56+
case 'nin': b[methodNotIn](field, value); break;
57+
default: b[method](field, op, value);
58+
}
59+
};
60+
apply(builder);
61+
} else {
62+
// Recursive Group
63+
const method = nextJoin === 'or' ? 'orWhere' : 'where';
64+
builder[method]((qb) => {
65+
this.applyFilters(qb, item);
66+
});
67+
}
6068

61-
// Reset join to 'and' for subsequent terms unless strictly specified?
62-
// In SQL `A or B and C` means `A or (B and C)`.
63-
// If we chain `.where(A).orWhere(B).where(C)` in Knex:
64-
// It produces `... WHERE A OR B AND C`.
65-
// So linear application matches SQL precedence usually if implicit validation is ok.
66-
// But explicit AND after OR is necessary in our array format.
6769
nextJoin = 'and';
6870
}
6971
}

0 commit comments

Comments
 (0)