Rework tickets filter bar into two-row layout with consistent CTI styling
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:
@@ -4,9 +4,10 @@ interface CTISelectProps {
|
||||
value: { categoryId: string; typeId: string; itemId: string };
|
||||
onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void;
|
||||
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: types = [] } = useTypes(value.categoryId || undefined);
|
||||
const { data: items = [] } = useItems(value.typeId || undefined);
|
||||
@@ -24,12 +25,58 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<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
|
||||
value={value.categoryId}
|
||||
onChange={(e) => handleCategory(e.target.value)}
|
||||
@@ -46,7 +93,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
|
||||
</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
|
||||
value={value.typeId}
|
||||
onChange={(e) => handleType(e.target.value)}
|
||||
@@ -63,7 +110,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
|
||||
</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
|
||||
value={value.itemId}
|
||||
onChange={(e) => handleItem(e.target.value)}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
const STATUS_TABS: { value: TicketStatus | ''; label: string }[] = [
|
||||
{ value: '', label: 'All' },
|
||||
@@ -81,72 +83,27 @@ export default function TicketFilters({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap items-center">
|
||||
{/* Row 1: Search + saved views + result count */}
|
||||
<div className="flex gap-2 mb-2 items-center">
|
||||
<input
|
||||
type="search"
|
||||
value={searchInput}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
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>
|
||||
<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
|
||||
</button>
|
||||
{savedViews.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px] leading-4">
|
||||
{savedViews.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel>Saved views</DropdownMenuLabel>
|
||||
@@ -191,6 +148,58 @@ export default function TicketFilters({
|
||||
{isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user