feat(filter): "is one of" — so a real question can be asked
Every condition was joined by ONE global AND or OR. "2 m or 70 cm, in FT8, since January" therefore had no expression at all: AND killed the two bands, OR let every FT8 QSO through. The filter could ask simple questions and nothing else. Rather than grow nested groups — a tree in the UI to answer something that is nearly always "this field, any of these values" — the OR lives INSIDE one condition and everything else keeps ANDing. Two operators, a comma-separated value, no change to how the rest of the filter behaves. Two edges that matter more than they look. An empty list matches NOTHING rather than being dropped: dropping it would widen the result set, the opposite of what someone typing a filter expects. And "is none of" wraps the column in IFNULL, because raw SQL NOT IN discards NULL rows — a QSO with no band recorded is not one of the listed bands, so it belongs in the answer.
This commit is contained in:
@@ -13,7 +13,8 @@ import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type FilterOp =
|
||||
| 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'
|
||||
| 'contains' | 'startswith' | 'endswith' | 'empty' | 'notempty';
|
||||
| 'contains' | 'startswith' | 'endswith' | 'empty' | 'notempty'
|
||||
| 'in' | 'notin';
|
||||
|
||||
export interface FilterCondition { field: string; op: FilterOp; value: string }
|
||||
export interface QueryFilter {
|
||||
@@ -121,9 +122,15 @@ const OPS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'le', label: 'fltb.opLe' },
|
||||
{ value: 'empty', label: 'fltb.opEmpty' },
|
||||
{ value: 'notempty', label: 'fltb.opNotEmpty' },
|
||||
// 'in' is what makes a real question askable. Every condition is joined by ONE
|
||||
// AND or OR, so "2 m or 70 cm, in FT8, since January" had no expression: AND
|
||||
// killed the two bands, OR let every FT8 QSO through. The OR lives inside the
|
||||
// condition instead, and the rest keeps ANDing.
|
||||
{ value: 'in', label: 'fltb.opIn' },
|
||||
{ value: 'notin', label: 'fltb.opNotIn' },
|
||||
];
|
||||
|
||||
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'empty', 'notempty'];
|
||||
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'in', 'notin', 'empty', 'notempty'];
|
||||
const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notempty'];
|
||||
|
||||
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
||||
@@ -257,6 +264,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
)}
|
||||
{conditions.map((c, i) => {
|
||||
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
||||
const isList = c.op === 'in' || c.op === 'notin';
|
||||
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -281,19 +289,28 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
// An ADIF date is picked with a calendar but STORED as the
|
||||
// 8-digit form the column holds, so the comparison stays a
|
||||
// plain string one on both sides.
|
||||
type={fieldType === 'date' || fieldType === 'adifdate' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
// A list is typed by hand, commas and all, so it stays a text
|
||||
// box even on a date or number field: a calendar cannot express
|
||||
// "any one of these".
|
||||
type={isList ? 'text'
|
||||
: fieldType === 'date' || fieldType === 'adifdate' ? 'date'
|
||||
: fieldType === 'number' ? 'number' : 'text'}
|
||||
className="h-8 flex-1 text-xs"
|
||||
disabled={!needsValue}
|
||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
||||
placeholder={!needsValue ? '—'
|
||||
: isList ? t('fltb.listPh')
|
||||
: fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')}
|
||||
// \d, not d: the escape was missing, so the test never matched
|
||||
// an 8-digit ADIF date and the calendar input was handed
|
||||
// "20260728" — which type=date rejects, showing an empty box
|
||||
// over a value that was really there.
|
||||
value={fieldType === 'adifdate' && /^\d{8}$/.test(c.value)
|
||||
// The ADIF reshaping is skipped for a list: it would eat the
|
||||
// commas and leave one unreadable run of digits.
|
||||
value={!isList && fieldType === 'adifdate' && /^\d{8}$/.test(c.value)
|
||||
? `${c.value.slice(0, 4)}-${c.value.slice(4, 6)}-${c.value.slice(6, 8)}`
|
||||
: c.value}
|
||||
onChange={(e) => setCond(i, {
|
||||
value: fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
|
||||
value: !isList && fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
|
||||
})}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user