Skip to content

Commit 7486d57

Browse files
borisno2claude
andcommitted
Add search/filtering and confirmation dialogs to admin UI
- Implement search functionality across text fields in list views - Add search bar UI with clear button and URL state management - Fix SQLite compatibility by removing mode: 'insensitive' parameter - Create reusable ConfirmDialog component for destructive actions - Add delete functionality with confirmation dialog to ItemFormClient - Preserve search state in pagination URLs - Export ConfirmDialog component from UI package 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8416f54 commit 7486d57

7 files changed

Lines changed: 193 additions & 49 deletions

File tree

examples/blog/app/admin/[[...admin]]/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,23 @@ async function serverAction(props: ServerActionInput) {
2626

2727
interface AdminPageProps {
2828
params: Promise<{ admin?: string[] }>;
29+
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
2930
}
3031

3132
/**
3233
* Main admin interface using catch-all route
3334
* Handles all admin routes: /admin, /admin/Post, /admin/Post/create, /admin/Post/[id]
3435
*/
35-
export default async function AdminPage({ params }: AdminPageProps) {
36+
export default async function AdminPage({ params, searchParams }: AdminPageProps) {
3637
const resolvedParams = await params;
38+
const resolvedSearchParams = await searchParams;
3739
const adminContext = await getAdminContext(config, prisma);
3840

3941
return (
4042
<AdminUI
4143
context={adminContext}
4244
params={resolvedParams.admin}
45+
searchParams={resolvedSearchParams}
4346
basePath="/admin"
4447
serverAction={serverAction}
4548
/>

packages/ui/src/components/AdminUI.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getListKeyFromUrl } from "@opensaas/core";
88
export interface AdminUIProps {
99
context: AdminContext;
1010
params?: string[];
11+
searchParams?: { [key: string]: string | string[] | undefined };
1112
basePath?: string;
1213
// Generic server action
1314
serverAction: (input: ServerActionInput) => Promise<any>;
@@ -26,6 +27,7 @@ export interface AdminUIProps {
2627
export function AdminUI({
2728
context,
2829
params = [],
30+
searchParams = {},
2931
basePath = "/admin",
3032
serverAction,
3133
}: AdminUIProps) {
@@ -69,8 +71,17 @@ export function AdminUI({
6971
);
7072
} else {
7173
// List view
74+
const search = typeof searchParams.search === 'string' ? searchParams.search : undefined;
75+
const page = typeof searchParams.page === 'string' ? parseInt(searchParams.page, 10) : 1;
76+
7277
content = (
73-
<ListView context={context} listKey={listKey} basePath={basePath} />
78+
<ListView
79+
context={context}
80+
listKey={listKey}
81+
basePath={basePath}
82+
search={search}
83+
page={page}
84+
/>
7485
);
7586
}
7687

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
5+
export interface ConfirmDialogProps {
6+
isOpen: boolean;
7+
title: string;
8+
message: string;
9+
confirmLabel?: string;
10+
cancelLabel?: string;
11+
onConfirm: () => void;
12+
onCancel: () => void;
13+
variant?: "danger" | "warning";
14+
}
15+
16+
/**
17+
* Reusable confirmation dialog component
18+
* Used for destructive actions like delete
19+
*/
20+
export function ConfirmDialog({
21+
isOpen,
22+
title,
23+
message,
24+
confirmLabel = "Confirm",
25+
cancelLabel = "Cancel",
26+
onConfirm,
27+
onCancel,
28+
variant = "danger",
29+
}: ConfirmDialogProps) {
30+
if (!isOpen) return null;
31+
32+
const confirmButtonClass =
33+
variant === "danger"
34+
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
35+
: "bg-yellow-600 text-white hover:bg-yellow-700";
36+
37+
return (
38+
<div className="fixed inset-0 z-50 flex items-center justify-center">
39+
{/* Backdrop */}
40+
<div
41+
className="absolute inset-0 bg-black/50"
42+
onClick={onCancel}
43+
/>
44+
45+
{/* Dialog */}
46+
<div className="relative bg-card border border-border rounded-lg shadow-lg max-w-md w-full mx-4 p-6">
47+
<h2 className="text-xl font-semibold mb-2">{title}</h2>
48+
<p className="text-muted-foreground mb-6">{message}</p>
49+
50+
<div className="flex justify-end gap-3">
51+
<button
52+
onClick={onCancel}
53+
className="px-4 py-2 text-sm font-medium rounded-md border border-border bg-background hover:bg-accent transition-colors"
54+
>
55+
{cancelLabel}
56+
</button>
57+
<button
58+
onClick={onConfirm}
59+
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${confirmButtonClass}`}
60+
>
61+
{confirmLabel}
62+
</button>
63+
</div>
64+
</div>
65+
</div>
66+
);
67+
}

packages/ui/src/components/ItemFormClient.tsx

Lines changed: 25 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState, useTransition } from "react";
44
import { useRouter } from "next/navigation";
55
import { FieldRenderer } from "./fields/FieldRenderer.js";
6+
import { ConfirmDialog } from "./ConfirmDialog.js";
67
import { cn } from "../lib/utils.js";
78
import type { FieldConfig } from "@opensaas/core";
89
import type { ServerActionInput } from "../server/types.js";
@@ -112,6 +113,7 @@ export function ItemFormClient({
112113
if (!itemId) return;
113114

114115
setGeneralError(null);
116+
setShowDeleteConfirm(false);
115117

116118
startTransition(async () => {
117119
try {
@@ -126,11 +128,9 @@ export function ItemFormClient({
126128
router.refresh();
127129
} else {
128130
setGeneralError("Access denied or failed to delete item");
129-
setShowDeleteConfirm(false);
130131
}
131132
} catch (error: any) {
132133
setGeneralError(error.message || "Failed to delete item");
133-
setShowDeleteConfirm(false);
134134
}
135135
});
136136
};
@@ -197,46 +197,32 @@ export function ItemFormClient({
197197

198198
{/* Delete Button (Edit Mode Only) */}
199199
{mode === "edit" && itemId && (
200-
<div>
201-
{!showDeleteConfirm ? (
202-
<button
203-
type="button"
204-
onClick={() => setShowDeleteConfirm(true)}
205-
disabled={isPending}
206-
className={cn(
207-
"px-4 py-2 bg-destructive text-destructive-foreground rounded-md font-medium",
208-
"hover:bg-destructive/90 transition-colors",
209-
"disabled:opacity-50 disabled:cursor-not-allowed",
210-
)}
211-
>
212-
Delete
213-
</button>
214-
) : (
215-
<div className="flex items-center space-x-2">
216-
<span className="text-sm text-muted-foreground">
217-
Are you sure?
218-
</span>
219-
<button
220-
type="button"
221-
onClick={handleDelete}
222-
disabled={isPending}
223-
className="px-3 py-1 bg-destructive text-destructive-foreground rounded text-sm font-medium hover:bg-destructive/90"
224-
>
225-
Yes, delete
226-
</button>
227-
<button
228-
type="button"
229-
onClick={() => setShowDeleteConfirm(false)}
230-
disabled={isPending}
231-
className="px-3 py-1 bg-secondary text-secondary-foreground rounded text-sm font-medium hover:bg-secondary/90"
232-
>
233-
Cancel
234-
</button>
235-
</div>
200+
<button
201+
type="button"
202+
onClick={() => setShowDeleteConfirm(true)}
203+
disabled={isPending}
204+
className={cn(
205+
"px-4 py-2 bg-destructive text-destructive-foreground rounded-md font-medium",
206+
"hover:bg-destructive/90 transition-colors",
207+
"disabled:opacity-50 disabled:cursor-not-allowed",
236208
)}
237-
</div>
209+
>
210+
Delete
211+
</button>
238212
)}
239213
</div>
214+
215+
{/* Delete Confirmation Dialog */}
216+
<ConfirmDialog
217+
isOpen={showDeleteConfirm}
218+
title="Delete Item"
219+
message="Are you sure you want to delete this item? This action cannot be undone."
220+
confirmLabel="Delete"
221+
cancelLabel="Cancel"
222+
variant="danger"
223+
onConfirm={handleDelete}
224+
onCancel={() => setShowDeleteConfirm(false)}
225+
/>
240226
</form>
241227
);
242228
}

packages/ui/src/components/ListView.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface ListViewProps {
1111
columns?: string[];
1212
page?: number;
1313
pageSize?: number;
14+
search?: string;
1415
}
1516

1617
/**
@@ -24,6 +25,7 @@ export async function ListView({
2425
columns,
2526
page = 1,
2627
pageSize = 50,
28+
search,
2729
}: ListViewProps) {
2830
const key = getDbKey(listKey);
2931
const urlKey = getUrlKey(listKey);
@@ -47,17 +49,36 @@ export async function ListView({
4749

4850
try {
4951
const dbContext = context.context.db;
50-
console.log({ dbContext });
5152
if (!dbContext || !dbContext[key]) {
5253
throw new Error(`Context for ${listKey} not found`);
5354
}
5455

56+
// Build search filter if search term provided
57+
let where: any = undefined;
58+
if (search && search.trim()) {
59+
// Find all text fields to search across
60+
const searchableFields = Object.entries(listConfig.fields)
61+
.filter(([_, field]) => (field as any).type === 'text')
62+
.map(([fieldName]) => fieldName);
63+
64+
if (searchableFields.length > 0) {
65+
where = {
66+
OR: searchableFields.map(fieldName => ({
67+
[fieldName]: {
68+
contains: search.trim(),
69+
},
70+
})),
71+
};
72+
}
73+
}
74+
5575
[items, total] = await Promise.all([
5676
dbContext[key].findMany({
77+
where,
5778
skip,
5879
take: pageSize,
5980
}),
60-
dbContext[key].count(),
81+
dbContext[key].count({ where }),
6182
]);
6283
} catch (error) {
6384
console.error(`Failed to fetch ${listKey}:`, error);
@@ -98,6 +119,7 @@ export async function ListView({
98119
page={page}
99120
pageSize={pageSize}
100121
total={total || 0}
122+
search={search}
101123
/>
102124
</div>
103125
);

packages/ui/src/components/ListViewClient.tsx

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface ListViewClientProps {
1515
page: number;
1616
pageSize: number;
1717
total: number;
18+
search?: string;
1819
}
1920

2021
/**
@@ -31,10 +32,12 @@ export function ListViewClient({
3132
page,
3233
pageSize,
3334
total,
35+
search: initialSearch,
3436
}: ListViewClientProps) {
3537
const router = useRouter();
3638
const [sortBy, setSortBy] = useState<string | null>(null);
3739
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
40+
const [searchInput, setSearchInput] = useState(initialSearch || "");
3841

3942
// Determine which columns to show
4043
const displayColumns =
@@ -68,8 +71,62 @@ export function ListViewClient({
6871
}
6972
};
7073

74+
const handleSearch = (e: React.FormEvent) => {
75+
e.preventDefault();
76+
const params = new URLSearchParams();
77+
if (searchInput.trim()) {
78+
params.set('search', searchInput.trim());
79+
}
80+
params.set('page', '1'); // Reset to page 1 on new search
81+
router.push(`${basePath}/${urlKey}?${params.toString()}`);
82+
};
83+
84+
const handleClearSearch = () => {
85+
setSearchInput('');
86+
router.push(`${basePath}/${urlKey}`);
87+
};
88+
89+
const buildPaginationUrl = (newPage: number) => {
90+
const params = new URLSearchParams();
91+
if (initialSearch) {
92+
params.set('search', initialSearch);
93+
}
94+
params.set('page', newPage.toString());
95+
return `${basePath}/${urlKey}?${params.toString()}`;
96+
};
97+
7198
return (
7299
<div className="space-y-4">
100+
{/* Search Bar */}
101+
<div className="bg-card border border-border rounded-lg p-4">
102+
<form onSubmit={handleSearch} className="flex gap-2">
103+
<div className="flex-1 relative">
104+
<input
105+
type="text"
106+
value={searchInput}
107+
onChange={(e) => setSearchInput(e.target.value)}
108+
placeholder="Search..."
109+
className="w-full h-10 px-4 pr-10 rounded-md border border-input bg-background text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
110+
/>
111+
{searchInput && (
112+
<button
113+
type="button"
114+
onClick={handleClearSearch}
115+
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
116+
>
117+
118+
</button>
119+
)}
120+
</div>
121+
<button
122+
type="submit"
123+
className="px-4 h-10 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors"
124+
>
125+
Search
126+
</button>
127+
</form>
128+
</div>
129+
73130
{/* Table */}
74131
<div className="bg-card border border-border rounded-lg overflow-hidden">
75132
<div className="overflow-x-auto">
@@ -146,9 +203,7 @@ export function ListViewClient({
146203
</p>
147204
<div className="flex items-center space-x-2">
148205
<button
149-
onClick={() =>
150-
router.push(`${basePath}/${urlKey}?page=${page - 1}`)
151-
}
206+
onClick={() => router.push(buildPaginationUrl(page - 1))}
152207
disabled={!hasPrevPage}
153208
className={cn(
154209
"px-4 py-2 text-sm font-medium rounded-md border border-border",
@@ -163,9 +218,7 @@ export function ListViewClient({
163218
Page {page} of {totalPages}
164219
</span>
165220
<button
166-
onClick={() =>
167-
router.push(`${basePath}/${urlKey}?page=${page + 1}`)
168-
}
221+
onClick={() => router.push(buildPaginationUrl(page + 1))}
169222
disabled={!hasNextPage}
170223
className={cn(
171224
"px-4 py-2 text-sm font-medium rounded-md border border-border",

0 commit comments

Comments
 (0)