Rework tickets filter bar into two-row layout with consistent CTI styling
Build & Push / Test (client) (push) Successful in 27s
Build & Push / Test (server) (push) Successful in 31s
Build & Push / Build Client (push) Successful in 1m5s
Build & Push / Build Server (push) Successful in 1m43s

Split the dense single-row filter bar into two rows: search + saved views on top,
filter selectors below. Fix CTI selectors to use design system tokens instead of
hardcoded dark classes, and upgrade the saved views button with an icon and badge count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 22:20:29 -04:00
parent 2177162300
commit cfe7ad56ff
2 changed files with 117 additions and 61 deletions
+52 -5
View File
@@ -4,9 +4,10 @@ interface CTISelectProps {
value: { categoryId: string; typeId: string; itemId: string }; value: { categoryId: string; typeId: string; itemId: string };
onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void; onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void;
disabled?: boolean; disabled?: boolean;
compact?: boolean;
} }
export default function CTISelect({ value, onChange, disabled }: CTISelectProps) { export default function CTISelect({ value, onChange, disabled, compact }: CTISelectProps) {
const { data: categories = [] } = useCategories(); const { data: categories = [] } = useCategories();
const { data: types = [] } = useTypes(value.categoryId || undefined); const { data: types = [] } = useTypes(value.categoryId || undefined);
const { data: items = [] } = useItems(value.typeId || undefined); const { data: items = [] } = useItems(value.typeId || undefined);
@@ -24,12 +25,58 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
}; };
const selectClass = const selectClass =
'block w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'; 'block w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50';
if (compact) {
return (
<div className="flex gap-2">
<select
value={value.categoryId}
onChange={(e) => handleCategory(e.target.value)}
disabled={disabled}
className={selectClass}
>
<option value="">Category...</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<select
value={value.typeId}
onChange={(e) => handleType(e.target.value)}
disabled={disabled || !value.categoryId}
className={selectClass}
>
<option value="">Type...</option>
{types.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
<select
value={value.itemId}
onChange={(e) => handleItem(e.target.value)}
disabled={disabled || !value.typeId}
className={selectClass}
>
<option value="">Item...</option>
{items.map((i) => (
<option key={i.id} value={i.id}>
{i.name}
</option>
))}
</select>
</div>
);
}
return ( return (
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="block text-xs font-medium text-gray-400 mb-1">Category</label> <label className="block text-xs font-medium text-muted-foreground mb-1">Category</label>
<select <select
value={value.categoryId} value={value.categoryId}
onChange={(e) => handleCategory(e.target.value)} onChange={(e) => handleCategory(e.target.value)}
@@ -46,7 +93,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-400 mb-1">Type</label> <label className="block text-xs font-medium text-muted-foreground mb-1">Type</label>
<select <select
value={value.typeId} value={value.typeId}
onChange={(e) => handleType(e.target.value)} onChange={(e) => handleType(e.target.value)}
@@ -63,7 +110,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-400 mb-1">Item</label> <label className="block text-xs font-medium text-muted-foreground mb-1">Item</label>
<select <select
value={value.itemId} value={value.itemId}
onChange={(e) => handleItem(e.target.value)} onChange={(e) => handleItem(e.target.value)}
+65 -56
View File
@@ -10,6 +10,8 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
const STATUS_TABS: { value: TicketStatus | ''; label: string }[] = [ const STATUS_TABS: { value: TicketStatus | ''; label: string }[] = [
{ value: '', label: 'All' }, { value: '', label: 'All' },
@@ -81,72 +83,27 @@ export default function TicketFilters({
))} ))}
</div> </div>
{/* Filter bar */} {/* Row 1: Search + saved views + result count */}
<div className="flex gap-2 mb-4 flex-wrap items-center"> <div className="flex gap-2 mb-2 items-center">
<input <input
type="search" type="search"
value={searchInput} value={searchInput}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search title, overview, ID…" placeholder="Search title, overview, ID…"
className="flex-1 min-w-48 max-w-xs px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring" className="flex-1 min-w-48 max-w-sm px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/> />
<select
value={severity}
onChange={(e) => onUpdateParam('severity', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All severities</option>
{[1, 2, 3, 4, 5].map((s) => (
<option key={s} value={s}>
SEV {s}
</option>
))}
</select>
<select
value={assigneeId}
onChange={(e) => onUpdateParam('assigneeId', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All assignees</option>
{authUser && (
<option value={authUser.id}>Me ({authUser.displayName})</option>
)}
{users
.filter((u) => u.id !== authUser?.id)
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
<div className="min-w-56">
<CTISelect
value={{ categoryId, typeId, itemId }}
onChange={(cti) => {
onSetParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('page');
if (cti.categoryId) next.set('categoryId', cti.categoryId);
else next.delete('categoryId');
if (cti.typeId) next.set('typeId', cti.typeId);
else next.delete('typeId');
if (cti.itemId) next.set('itemId', cti.itemId);
else next.delete('itemId');
return next;
});
}}
/>
</div>
{/* Saved views */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="px-3 py-1.5 rounded-md border border-input bg-background text-sm hover:bg-accent"> <Button variant="outline" size="sm" className="gap-1.5">
<Save size={14} />
Saved views Saved views
</button> {savedViews.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px] leading-4">
{savedViews.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64"> <DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>Saved views</DropdownMenuLabel> <DropdownMenuLabel>Saved views</DropdownMenuLabel>
@@ -191,6 +148,58 @@ export default function TicketFilters({
{isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`} {isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`}
</div> </div>
</div> </div>
{/* Row 2: Filter selectors */}
<div className="flex gap-2 mb-4 items-center flex-wrap">
<select
value={severity}
onChange={(e) => onUpdateParam('severity', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All severities</option>
{[1, 2, 3, 4, 5].map((s) => (
<option key={s} value={s}>
SEV {s}
</option>
))}
</select>
<select
value={assigneeId}
onChange={(e) => onUpdateParam('assigneeId', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All assignees</option>
{authUser && (
<option value={authUser.id}>Me ({authUser.displayName})</option>
)}
{users
.filter((u) => u.id !== authUser?.id)
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
<CTISelect
compact
value={{ categoryId, typeId, itemId }}
onChange={(cti) => {
onSetParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('page');
if (cti.categoryId) next.set('categoryId', cti.categoryId);
else next.delete('categoryId');
if (cti.typeId) next.set('typeId', cti.typeId);
else next.delete('typeId');
if (cti.itemId) next.set('itemId', cti.itemId);
else next.delete('itemId');
return next;
});
}}
/>
</div>
</> </>
); );
} }