Compare commits
1 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9f3ec21f |
@@ -1,6 +1,7 @@
|
||||
import { useId } from 'react'
|
||||
import { Plus, X } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
@@ -15,6 +16,7 @@ const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
]
|
||||
|
||||
const NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
|
||||
const DATE_OPS = new Set<FilterOp>(['before', 'after'])
|
||||
|
||||
function isFilterGroup(node: FilterNode): node is FilterGroup {
|
||||
return 'all' in node || 'any' in node
|
||||
@@ -32,49 +34,97 @@ function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGr
|
||||
return mode === 'all' ? { all: children } : { any: children }
|
||||
}
|
||||
|
||||
/** Combobox-style field input with datalist autocomplete. */
|
||||
function FieldInput({ value, fields, onChange }: {
|
||||
function FieldSelect({ value, fields, onChange }: {
|
||||
value: string
|
||||
fields: string[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const id = useId()
|
||||
const isCustom = value !== '' && !fields.includes(value)
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
list={id}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="field"
|
||||
/>
|
||||
<datalist id={id}>
|
||||
{fields.map((f) => <option key={f} value={f} />)}
|
||||
</datalist>
|
||||
</>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 min-w-[100px] flex-1 gap-1 border-input bg-background px-2 text-sm shadow-none"
|
||||
>
|
||||
<SelectValue placeholder="field" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{isCustom && <SelectItem value={value}>{value}</SelectItem>}
|
||||
{fields.map((f) => (
|
||||
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
/** Combobox-style value input with autocomplete for known values. */
|
||||
function ValueInput({ value, suggestions, onChange }: {
|
||||
function OperatorSelect({ value, onChange }: {
|
||||
value: FilterOp
|
||||
onChange: (v: FilterOp) => void
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onChange(v as FilterOp)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1 border-input bg-background px-2 text-sm shadow-none"
|
||||
style={{ minWidth: 120 }}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{OPERATORS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
function ValueInput({ value, suggestions, isDateOp, onChange }: {
|
||||
value: string
|
||||
suggestions: string[]
|
||||
isDateOp: boolean
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const id = useId()
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
list={id}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
placeholder="value"
|
||||
if (isDateOp) {
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<datalist id={id}>
|
||||
{suggestions.map((s) => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 flex-1 min-w-0 gap-1 border-input bg-background px-2 text-sm shadow-none"
|
||||
>
|
||||
<SelectValue placeholder="value" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{value !== '' && !suggestions.includes(value) && (
|
||||
<SelectItem value={value}>{value}</SelectItem>
|
||||
)}
|
||||
{suggestions.map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
placeholder="value"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,37 +136,36 @@ function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }:
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const suggestions = valueSuggestions(condition.field)
|
||||
const isDateOp = DATE_OPS.has(condition.op)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FieldInput
|
||||
<FieldSelect
|
||||
value={condition.field}
|
||||
fields={fields}
|
||||
onChange={(v) => onUpdate({ ...condition, field: v })}
|
||||
/>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm shrink-0"
|
||||
<OperatorSelect
|
||||
value={condition.op}
|
||||
onChange={(e) => onUpdate({ ...condition, op: e.target.value as FilterOp })}
|
||||
>
|
||||
{OPERATORS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(op) => onUpdate({ ...condition, op })}
|
||||
/>
|
||||
{!NO_VALUE_OPS.has(condition.op) && (
|
||||
<ValueInput
|
||||
value={String(condition.value ?? '')}
|
||||
suggestions={suggestions}
|
||||
isDateOp={isDateOp}
|
||||
onChange={(v) => onUpdate({ ...condition, value: v })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-shrink-0 rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 shrink-0 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
title="Remove filter"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -159,26 +208,30 @@ function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onR
|
||||
return (
|
||||
<div className={depth > 0 ? 'ml-3 border-l-2 border-border pl-3 py-1' : ''}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded-full border border-input bg-muted px-2.5 py-0.5 text-[11px] font-medium text-foreground cursor-pointer hover:bg-accent transition-colors"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 rounded-full px-2.5 text-[11px] font-medium"
|
||||
onClick={toggleMode}
|
||||
title={`Switch to ${mode === 'all' ? 'OR' : 'AND'}`}
|
||||
>
|
||||
{mode === 'all' ? 'AND' : 'OR'}
|
||||
</button>
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{mode === 'all' ? 'Match all conditions' : 'Match any condition'}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="ml-auto rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
title="Remove group"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user