UX Standards — 09B: Advanced Filter Popup
Governs: The Advanced Filter floating dialog triggered by the Filter toolbar button.
Parent rules: See 09-COLUMN-FILTER-POPUP.md and 00-OVERVIEW-AND-CSS-RULES.md first.
Overview
The Advanced Filter is a floating, draggable, resizable dialog that provides multi-condition query building with:
- Boolean logic groups (AND + OR, with NOT negation)
- Saved named queries (personal and shareable)
- Multi-field condition rows with operator selection
- Persistence: filters apply to the table, count shown in toolbar badge
It is triggered by the Filter icon button in the UDT toolbar.
Visual Anatomy
┌─────────────────────────────────────────────────────────────┐
│ ⠿⠿ Advanced Filter [↺] [✕] │ ← Draggable title bar
├─────────────────────────────────────────────────────────────┤
│ [🔍 Load saved query... ▼] [💾 Save]│ ← Query management
├─────────────────────────────────────────────────────────────┤
│ Filter Conditions Clear all │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ [NOT] Click NOT to negate this group │ │ ← Group with NOT toggle
│ │ │ │
│ │ ⠿ [Select field ▼] [Contains ▼] [value ] │ │ ← Condition row
│ │ │ │
│ │ + Add condition │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [+ Add OR group] │
│ │
│ [Cancel] [Apply Filters] │
└─────────────────────────────────────────────────────────────┘
Container / Dialog Spec
// The Advanced Filter renders as a floating panel, not a blocking Dialog
// It uses a draggable, resizable implementation
<div
className="fixed z-50 bg-card rounded-lg border shadow-lg flex flex-col"
style={{
left: position.x,
top: position.y,
width: size.width, // resizable
height: size.height, // resizable
minWidth: '480px',
minHeight: '300px',
resize: 'both', // CSS resize for manual resizing
overflow: 'hidden',
}}
>
| Property | Value |
|---|---|
| Position | fixed — floats over the table, does not block interaction |
| Z-index | z-50 |
| Background | bg-card |
| Border | rounded-lg border shadow-lg |
| Min width | 480px |
| Min height | 300px |
| Resizable | resize: 'both' — CSS resize handle in bottom-right corner |
| Draggable | Drag by the title bar (⠿⠿ handle area) |
Critical: The Advanced Filter is NON-BLOCKING — the table remains interactive behind it. Users can drag it to the side and browse the table while the filter is open.
Title Bar (Draggable Handle)
<div
className="flex items-center justify-between px-4 py-3 border-b cursor-move select-none"
onMouseDown={handleDragStart} // enables dragging
>
<div className="flex items-center gap-2">
<GripHorizontal className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Advanced Filter</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleReset} title="Reset filters">
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose} title="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
| Element | Spec |
|---|---|
| Drag handle icon | GripHorizontal h-4 w-4 text-muted-foreground (⠿⠿ dots) |
| Title | text-sm font-semibold |
| Cursor | cursor-move on the entire title bar |
| User-select | select-none to prevent text selection during drag |
| Reset button | RotateCcw icon — clears all conditions, keeps dialog open |
| Close button | X icon — closes without applying changes |
Drag Implementation
const [position, setPosition] = useState({ x: 100, y: 200 }); // initial position
const dragRef = useRef<{ startX: number; startY: number; startPosX: number; startPosY: number }>();
const handleDragStart = (e: React.MouseEvent) => {
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
startPosX: position.x,
startPosY: position.y,
};
window.addEventListener('mousemove', handleDragMove);
window.addEventListener('mouseup', handleDragEnd);
};
const handleDragMove = (e: MouseEvent) => {
if (!dragRef.current) return;
setPosition({
x: dragRef.current.startPosX + (e.clientX - dragRef.current.startX),
y: dragRef.current.startPosY + (e.clientY - dragRef.current.startY),
});
};
const handleDragEnd = () => {
dragRef.current = undefined;
window.removeEventListener('mousemove', handleDragMove);
window.removeEventListener('mouseup', handleDragEnd);
};
Saved Query Management
Load Saved Query Bar
<div className="flex items-center gap-2 px-4 py-3 border-b">
{/* Combobox to select a saved query */}
<Popover open={queryPopoverOpen} onOpenChange={setQueryPopoverOpen}>
<PopoverTrigger asChild>
<button className="flex-1 flex items-center gap-2 h-9 px-3 rounded-md border bg-background text-sm text-muted-foreground hover:border-primary/50 transition-colors">
<Search className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left truncate">
{activeQuery?.name ?? 'Load saved query...'}
</span>
<ChevronDown className="h-4 w-4 shrink-0" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[380px] p-0" align="start">
{/* Saved query list */}
<div className="p-2">
<button className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm hover:bg-accent text-left">
<Check className="h-4 w-4 text-primary" />
<FolderOpen className="h-4 w-4 text-muted-foreground" />
New Query
</button>
<Separator className="my-1" />
{savedQueries.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground text-center">
No saved queries found
</p>
) : (
savedQueries.map(query => (
<SavedQueryItem key={query.id} query={query} onLoad={handleLoadQuery} onDelete={handleDeleteQuery} />
))
)}
</div>
</PopoverContent>
</Popover>
{/* Save button */}
<Button variant="outline" size="sm" className="h-9 gap-1.5" onClick={handleSaveQuery}>
<Save className="h-4 w-4" />
Save
</Button>
</div>
Save Query Flow
When the user clicks Save:
1. A small inline dialog appears asking for a query name:
┌──────────────────────────────────────┐
│ Save Query │
│ │
│ Name: [Active Users in Q1 ] │
│ │
│ Share with team? ☐ │
│ │
│ [Cancel] [Save Query] │
└──────────────────────────────────────┘
2. Query is saved to the user's profile (or team-shared if checked)
3. It appears in the "Load saved query..." dropdown
4. Active query name shown in the trigger button
Saved Query Data Model
interface SavedFilterQuery {
id: string;
name: string; // User-defined name
doctype: string; // Which page/table this applies to
conditions: FilterCondition[]; // The filter groups
created_by: string; // User who created it
is_shared: boolean; // Shared with entire team/tenant
created_at: string;
}
Shared queries appear in all team members' "Load saved query" dropdowns for the same doctype. Private queries appear only for the creator.
Filter Conditions Structure
The filter is organized into groups. Each group is either AND-connected (default) or can be OR-connected via "Add OR group".
Visual Structure
Group 1 (AND logic between conditions within):
NOT [ ] Click NOT to negate this group
├── Condition: [field] [op] [value]
├── Condition: [field] [op] [value]
└── + Add condition
(AND between groups)
Group 2 (OR group):
NOT [ ] Click NOT to negate this group
├── Condition: [field] [op] [value]
└── + Add condition
+ Add OR group
Logic evaluation:
(Group1_Condition1 AND Group1_Condition2) AND (Group2_Condition1)
With NOT on Group 1:
NOT(Group1_Condition1 AND Group1_Condition2) AND (Group2_Condition1)
Condition Group
<div className="rounded-md border p-3 space-y-2">
{/* NOT toggle */}
<div className="flex items-center gap-2">
<button
onClick={() => toggleNot(groupId)}
className={cn(
'px-2 py-0.5 rounded text-xs font-semibold border transition-colors',
isNegated
? 'bg-destructive/10 border-destructive/30 text-destructive'
: 'bg-muted border-border text-muted-foreground hover:border-primary/50'
)}
>
NOT
</button>
<span className="text-xs text-muted-foreground">Click NOT to negate this group</span>
</div>
{/* Condition rows */}
{conditions.map((condition) => (
<FilterConditionRow
key={condition.id}
condition={condition}
fields={availableFields}
onChange={handleConditionChange}
onRemove={handleConditionRemove}
/>
))}
{/* Add condition */}
<button
onClick={() => addCondition(groupId)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-primary transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Add condition
</button>
</div>
| Element | State | Classes |
|---|---|---|
| NOT button (inactive) | bg-muted border-border text-muted-foreground | Default gray |
| NOT button (active) | bg-destructive/10 border-destructive/30 text-destructive | Red tint |
| Group border | rounded-md border p-3 | Card-like group |
| Drag handle | ⠿ GripVertical h-3.5 w-3.5 | For reordering groups |
Condition Row
<div className="flex items-center gap-2">
{/* Drag handle */}
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/40 cursor-grab shrink-0" />
{/* Field selector */}
<Select value={condition.field} onValueChange={(f) => updateCondition(id, 'field', f)}>
<SelectTrigger className="h-8 w-[160px] text-xs">
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent>
{availableFields.map(field => (
<SelectItem key={field.key} value={field.key} className="text-xs">
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Operator selector */}
<Select value={condition.operator} onValueChange={(op) => updateCondition(id, 'operator', op)}>
<SelectTrigger className="h-8 w-[130px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getOperatorsForField(condition.field).map(op => (
<SelectItem key={op.value} value={op.value} className="text-xs">
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value input (type varies by operator) */}
{requiresValue(condition.operator) && (
<Input
value={condition.value ?? ''}
onChange={(e) => updateCondition(id, 'value', e.target.value)}
className="h-8 text-xs flex-1"
placeholder="Value..."
/>
)}
{/* Remove condition */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => removeCondition(id)}
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
Available Operators by Field Type
| Field Type | Operators |
|---|---|
| Text | Contains, Does not contain, Equals, Does not equal, Starts with, Ends with, Is empty, Is not empty |
| Number | Equals, Does not equal, Greater than, Less than, Greater than or equal, Less than or equal, Is empty, Is not empty |
| Date | On, Before, After, Between, In the last N days, Is empty, Is not empty |
| Boolean | Is true, Is false |
| Select/Enum | Equals, Does not equal, Is one of, Is not one of |
Operators that don't need a value ("Is empty", "Is not empty", "Is true", "Is false") hide the value input.
Add OR Group
<Button
variant="outline"
size="sm"
className="w-full h-9 border-dashed text-muted-foreground hover:text-foreground hover:border-primary/50"
onClick={handleAddOrGroup}
>
<Plus className="mr-2 h-4 w-4" />
Add OR group
</Button>
Each OR group is separated from others with a visual OR label:
[Group 1 box]
──── OR ────
[Group 2 box]
<Separator className="my-2">
<span className="px-2 text-xs text-muted-foreground bg-card">OR</span>
</Separator>
Footer Actions
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t">
<Button variant="outline" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" onClick={handleApply} disabled={!hasValidConditions}>
Apply Filters
</Button>
</div>
Cancel: Closes dialog without applying or changing the current active filter. Apply Filters: Applies all conditions to the table, closes dialog, updates toolbar badge count.
After applying, the toolbar Filter button shows a badge with the count of active advanced filter conditions.
Toolbar Badge Count
// Toolbar Filter button shows badge with active condition count
<ToolbarIconButton
icon={Filter}
tooltip={advancedFilterCount > 0
? `Advanced Filters (${advancedFilterCount} active)`
: 'Advanced Filters'}
badge={advancedFilterCount} // red badge when > 0
onClick={onFilterClick}
/>
When advanced filters are active and the user opens the dialog, the current conditions are pre-populated.
Shared Query Permissions
| Action | Who Can |
|---|---|
| Create saved query (private) | Any user |
| Create shared query | Users with manage_saved_filters permission |
| Load any query | Any user (private: own only; shared: all) |
| Delete private query | Query owner only |
| Delete shared query | Query owner + admin |
| Edit shared query | Query owner + admin |
Violation Checklist
- Advanced Filter is a non-blocking floating panel (
fixed z-50notDialog) - Title bar has
GripHorizontalicon andcursor-move - Dialog is draggable by title bar
- Dialog is resizable (
resize: both+ min dimensions) - Reset (
RotateCcw) and Close (X) buttons in title bar header - "Load saved query..." is a combobox Popover, not a Select
- NOT toggle shows red tint when active
- Condition groups use
rounded-md border p-3 - Field selector is
w-[160px], operator isw-[130px], bothh-8 text-xs - Value input hidden for operators that don't require a value
- OR groups separated by
ORlabeled separator - "Add OR group" uses dashed border
border-dashed - Footer: Cancel (outline) left, Apply Filters (primary) right
- Toolbar badge count updates after Apply Filters
- Shared queries visible to all team members for the same doctype