Skip to main content

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',
}}
>
PropertyValue
Positionfixed — floats over the table, does not block interaction
Z-indexz-50
Backgroundbg-card
Borderrounded-lg border shadow-lg
Min width480px
Min height300px
Resizableresize: 'both' — CSS resize handle in bottom-right corner
DraggableDrag 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>
ElementSpec
Drag handle iconGripHorizontal h-4 w-4 text-muted-foreground (⠿⠿ dots)
Titletext-sm font-semibold
Cursorcursor-move on the entire title bar
User-selectselect-none to prevent text selection during drag
Reset buttonRotateCcw icon — clears all conditions, keeps dialog open
Close buttonX 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>
ElementStateClasses
NOT button (inactive)bg-muted border-border text-muted-foregroundDefault gray
NOT button (active)bg-destructive/10 border-destructive/30 text-destructiveRed tint
Group borderrounded-md border p-3Card-like group
Drag handle⠿ GripVertical h-3.5 w-3.5For 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 TypeOperators
TextContains, Does not contain, Equals, Does not equal, Starts with, Ends with, Is empty, Is not empty
NumberEquals, Does not equal, Greater than, Less than, Greater than or equal, Less than or equal, Is empty, Is not empty
DateOn, Before, After, Between, In the last N days, Is empty, Is not empty
BooleanIs true, Is false
Select/EnumEquals, 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>

<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

ActionWho Can
Create saved query (private)Any user
Create shared queryUsers with manage_saved_filters permission
Load any queryAny user (private: own only; shared: all)
Delete private queryQuery owner only
Delete shared queryQuery owner + admin
Edit shared queryQuery owner + admin

Violation Checklist

  • Advanced Filter is a non-blocking floating panel (fixed z-50 not Dialog)
  • Title bar has GripHorizontal icon and cursor-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 is w-[130px], both h-8 text-xs
  • Value input hidden for operators that don't require a value
  • OR groups separated by OR labeled 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