Skip to content

Commit ecff045

Browse files
authored
refactor: separate SimpleFilter values using commas (#75)
1 parent 09eab1e commit ecff045

7 files changed

Lines changed: 306 additions & 175 deletions

File tree

.claude/settings.local.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,7 @@
3939
"Bash(git rm *)",
4040
"Bash(git show *)"
4141
],
42-
"deny": [
43-
"Write(supabase/migrations/*)"
44-
],
42+
"deny": ["Write(supabase/migrations/*)"],
4543
"defaultMode": "acceptEdits"
4644
}
4745
}

src/app/filter_builder.tsx

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import { MapListStore } from 'app/map_list_presenter';
44
import {
55
SIMPLE_FIELDS,
66
SimpleField,
7-
getFieldValue,
7+
compileSimpleFilter,
8+
filterToSimpleValues,
89
isSimpleFilter,
9-
setFieldValue,
10-
toSimpleFilter,
10+
simpleFieldKey,
1111
} from 'app/filter_modes';
1212
import { X } from 'lucide-react';
1313
import { action } from 'mobx';
@@ -56,8 +56,6 @@ const FIELD_LABELS: Record<FilterableField, string> = {
5656
// dedicated UI land (next PR); today the column is never populated, so the field would match nothing.
5757
const ALL_FIELDS = filterableFields.filter((f) => f !== 'tags');
5858

59-
const simpleFieldKey = (simpleField: SimpleField) => `${simpleField.field}:${simpleField.op}`;
60-
6159
// Derive each simple field's label from the shared field/op labels so they can't drift; date slots
6260
// read as "Uploaded before/after" rather than the bare field label.
6361
const simpleFieldLabel = (simpleField: SimpleField) =>
@@ -70,12 +68,16 @@ const simpleInputType = (simpleField: SimpleField) => {
7068
if (kind === 'date') {
7169
return 'date';
7270
}
73-
if (kind === 'number' || kind === 'countable') {
71+
if (kind === 'number') {
7472
return 'number';
7573
}
74+
// `countable` (Difficulties) stays a text input so comma-separated values like "1, 2" can be typed.
7675
return 'text';
7776
};
7877

78+
const simpleFieldPlaceholder = (simpleField: SimpleField) =>
79+
simpleField.field === 'difficulties' ? 'e.g. 1, 2' : undefined;
80+
7981
export const FilterBuilder = observer((props: { store: MapListStore; onSearch: () => void }) => {
8082
const { store, onSearch } = props;
8183
return (
@@ -118,7 +120,7 @@ export const ActiveFilterPills = observer(
118120

119121
const active = SIMPLE_FIELDS.map((simpleField) => ({
120122
simpleField,
121-
value: getFieldValue(store.filter, simpleField),
123+
value: store.simpleValues.get(simpleFieldKey(simpleField)) ?? '',
122124
})).filter(({ value }) => value.trim() !== '');
123125
if (active.length === 0) {
124126
return null;
@@ -130,7 +132,7 @@ export const ActiveFilterPills = observer(
130132
key={simpleFieldKey(simpleField)}
131133
label={`${simpleFieldLabel(simpleField)}: ${value}`}
132134
onRemove={action(() => {
133-
store.filter = setFieldValue(store.filter, simpleField, '');
135+
store.simpleValues.delete(simpleFieldKey(simpleField));
134136
onSearch();
135137
})}
136138
/>
@@ -156,8 +158,10 @@ const Pill = (props: { label: string; onRemove: () => void }) => (
156158

157159
const SimpleBuilder = observer((props: { store: MapListStore; onSearch: () => void }) => {
158160
const { store, onSearch } = props;
161+
// Unidirectional: the textboxes own their raw strings in `simpleValues`; only user input writes
162+
// them, and they're compiled to the AST only on search (see `MapListStore.activeFilter`).
159163
const setField = action((simpleField: SimpleField, value: string) => {
160-
store.filter = setFieldValue(store.filter, simpleField, value);
164+
store.simpleValues.set(simpleFieldKey(simpleField), value);
161165
});
162166
return (
163167
<div className={styles.simple}>
@@ -167,7 +171,8 @@ const SimpleBuilder = observer((props: { store: MapListStore; onSearch: () => vo
167171
label={simpleFieldLabel(simpleField)}
168172
error={undefined}
169173
inputType={simpleInputType(simpleField)}
170-
value={getFieldValue(store.filter, simpleField)}
174+
placeholder={simpleFieldPlaceholder(simpleField)}
175+
value={store.simpleValues.get(simpleFieldKey(simpleField)) ?? ''}
171176
onChange={(v) => setField(simpleField, v)}
172177
onSubmit={onSearch}
173178
/>
@@ -398,22 +403,23 @@ const ToggleButton = (props: {
398403

399404
const ModeSwitch = observer((props: { store: MapListStore }) => {
400405
const { store } = props;
401-
// Simple and advanced edit the same AST, so switching to advanced needs no conversion.
402406
const toAdvanced = action(() => {
407+
// Hand the compiled simple filter to advanced mode so the AST it edits matches what was active.
408+
store.filter = compileSimpleFilter(store.simpleValues);
403409
store.filterMode = 'advanced';
404410
});
405411
const toSimple = action(() => {
406-
if (isSimpleFilter(store.filter)) {
407-
store.filterMode = 'simple';
408-
return;
409-
}
410-
const ok = window.confirm(
411-
'Switching to simple filters will discard the parts of your filter that simple mode cannot represent. Continue?'
412-
);
413-
if (ok) {
414-
store.filter = toSimpleFilter(store.filter);
415-
store.filterMode = 'simple';
412+
if (!isSimpleFilter(store.filter)) {
413+
const ok = window.confirm(
414+
'Switching to simple filters will discard the parts of your filter that simple mode cannot represent. Continue?'
415+
);
416+
if (!ok) {
417+
return;
418+
}
416419
}
420+
store.simpleValues = filterToSimpleValues(store.filter);
421+
store.filter = undefined;
422+
store.filterMode = 'simple';
417423
});
418424
return (
419425
<button

src/app/filter_modes.ts

Lines changed: 115 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,132 +1,165 @@
11
import { CmpNode, FILTER_FIELDS, FilterNode, FilterOp, FilterableField } from 'schema/map_filter';
22

33
/**
4-
* Simple and advanced filtering differ only in the UI: both edit the same {@link FilterNode}. Simple
5-
* mode is a constrained editor over a flat `and` of cmps with pre-selected fields, each
6-
* {@link SimpleField} fixes a `(field, op)` pair so the user only supplies a value. The helpers here
7-
* read and write those fields on the shared AST.
4+
* Simple and advanced filtering differ in their edit model. Advanced mode edits the shared
5+
* {@link FilterNode} AST directly. Simple mode is a constrained editor: each {@link SimpleField}
6+
* fixes a `(field, op)` pair and owns a raw string the user types, compiled to the AST only on
7+
* search ({@link compileSimpleFilter}). A `multi` field treats its value as a comma-separated list
8+
* of independent terms OR'd together (e.g. mapper `Foo,Bar`, difficulties `1,2`); comma support is a
9+
* property of the field, not the operator (so `description`, also `contains`, stays single-valued).
810
*/
9-
export type SimpleField = { field: FilterableField; op: FilterOp };
11+
export type SimpleField = { field: FilterableField; op: FilterOp; multi?: boolean };
1012

1113
export const SIMPLE_FIELDS: SimpleField[] = [
12-
{ field: 'artist', op: 'contains' },
13-
{ field: 'author', op: 'contains' },
14+
{ field: 'artist', op: 'contains', multi: true },
15+
{ field: 'author', op: 'contains', multi: true },
1416
{ field: 'description', op: 'contains' },
15-
{ field: 'difficulties', op: 'count' },
17+
{ field: 'difficulties', op: 'count', multi: true },
1618
{ field: 'submissionDate', op: 'after' },
1719
{ field: 'submissionDate', op: 'before' },
1820
];
1921

20-
const matches = (node: CmpNode, simpleField: SimpleField) =>
21-
node.field === simpleField.field && node.op === simpleField.op;
22+
export const simpleFieldKey = (sf: SimpleField) => `${sf.field}:${sf.op}`;
23+
24+
const matches = (node: CmpNode, sf: SimpleField) => node.field === sf.field && node.op === sf.op;
2225

2326
const simpleFieldOf = (node: CmpNode) => SIMPLE_FIELDS.find((sf) => matches(node, sf));
2427

25-
// The cmp children of a simple-shaped filter (a flat `and`, a bare cmp, or empty/undefined).
26-
function simpleCmps(filter: FilterNode | undefined): CmpNode[] {
27-
if (filter == null) {
28-
return [];
28+
// A simple "slot" is the node holding one field's value: a bare cmp, or - for a `multi` field whose
29+
// value lists several comma terms - an `or` of cmps that all share the slot's (field, op).
30+
function slotCmps(node: FilterNode): CmpNode[] | undefined {
31+
if (node.type === 'cmp') {
32+
return [node];
33+
}
34+
if (
35+
node.type === 'or' &&
36+
node.children.length > 0 &&
37+
node.children.every((c) => c.type === 'cmp')
38+
) {
39+
return node.children as CmpNode[];
40+
}
41+
return undefined;
42+
}
43+
44+
// The simple field a slot represents, iff all its cmps share one simple (field, op). An `or` slot is
45+
// only valid for a `multi` field, since single-valued fields never compile to an OR.
46+
function slotSimpleField(node: FilterNode): SimpleField | undefined {
47+
const cmps = slotCmps(node);
48+
if (cmps == null) {
49+
return undefined;
2950
}
30-
if (filter.type === 'cmp') {
31-
return [filter];
51+
const sf = simpleFieldOf(cmps[0]);
52+
if (sf == null || !cmps.every((c) => matches(c, sf))) {
53+
return undefined;
3254
}
33-
if (filter.type === 'and') {
34-
return filter.children.filter((c): c is CmpNode => c.type === 'cmp');
55+
if (node.type === 'or' && !sf.multi) {
56+
return undefined;
3557
}
36-
return [];
58+
return sf;
59+
}
60+
61+
// The top-level slots of a simple-shaped filter (a flat `and`, a bare slot, or empty/undefined).
62+
function simpleSlots(filter: FilterNode | undefined): FilterNode[] {
63+
if (filter == null) {
64+
return [];
65+
}
66+
return filter.type === 'and' ? filter.children : [filter];
3767
}
3868

3969
/**
40-
* True if the node fits simple mode: empty, a bare cmp, or a flat `and` whose children are all
41-
* distinct simple fields. Anything else (OR/NOT/nesting, other fields/operators, duplicates) needs
42-
* advanced mode.
70+
* True if the node fits simple mode: empty, or a flat `and` (or a bare slot) whose children are all
71+
* distinct simple slots - a cmp, or an `or` of comma terms for a `multi` field. Anything else (NOT,
72+
* nesting, other fields/operators, duplicate fields) needs advanced mode.
4373
*/
4474
export function isSimpleFilter(node: FilterNode | undefined): boolean {
4575
if (node == null) {
4676
return true;
4777
}
48-
if (node.type !== 'cmp' && node.type !== 'and') {
49-
return false;
50-
}
51-
const children: FilterNode[] = node.type === 'cmp' ? [node] : node.children;
5278
const seen = new Set<SimpleField>();
53-
for (const child of children) {
54-
if (child.type !== 'cmp') {
55-
return false;
56-
}
57-
const simpleField = simpleFieldOf(child);
58-
if (simpleField == null || seen.has(simpleField)) {
79+
for (const slot of simpleSlots(node)) {
80+
const sf = slotSimpleField(slot);
81+
if (sf == null || seen.has(sf)) {
5982
return false;
6083
}
61-
seen.add(simpleField);
84+
seen.add(sf);
6285
}
6386
return true;
6487
}
6588

66-
export function getFieldValue(filter: FilterNode | undefined, simpleField: SimpleField): string {
67-
const cmp = simpleCmps(filter).find((c) => matches(c, simpleField));
68-
return cmp == null ? '' : String(cmp.value);
69-
}
70-
7189
/**
72-
* Immutably sets (or, for a blank value, clears) a simple field on the filter, returning the rebuilt
73-
* flat `and`, or undefined when no fields remain. Other fields keep their position.
90+
* Compiles the raw simple-field strings into the {@link FilterNode} AST sent to the backend, API and
91+
* URL. Run only on search: each field splits on commas when `multi`, trims terms, drops empties,
92+
* coerces numeric/countable terms (dropping non-numbers), and is omitted entirely when nothing valid
93+
* remains. Several terms become an `or`, a single term a bare cmp. Returns undefined when empty, so
94+
* an untouched or all-blank builder behaves like no filter.
7495
*/
75-
export function setFieldValue(
76-
filter: FilterNode | undefined,
77-
simpleField: SimpleField,
78-
value: string
79-
): FilterNode | undefined {
80-
const children = [...simpleCmps(filter)];
81-
const idx = children.findIndex((c) => matches(c, simpleField));
82-
if (value.trim() === '') {
83-
if (idx >= 0) {
84-
children.splice(idx, 1);
85-
}
86-
} else {
87-
// Numeric kinds carry a `number` in the AST; the simple-mode widget hands us its raw string, so
88-
// coerce here (the field's blank state was already handled above).
89-
const kind = FILTER_FIELDS[simpleField.field].kind;
90-
const coerced = kind === 'number' || kind === 'countable' ? Number(value) : value;
91-
const cmp: CmpNode = {
92-
type: 'cmp',
93-
field: simpleField.field,
94-
op: simpleField.op,
95-
value: coerced,
96-
};
97-
if (idx >= 0) {
98-
children[idx] = cmp;
99-
} else {
100-
children.push(cmp);
96+
export function compileSimpleFilter(values: ReadonlyMap<string, string>): FilterNode | undefined {
97+
const children: FilterNode[] = [];
98+
for (const sf of SIMPLE_FIELDS) {
99+
const node = compileSlot(sf, values.get(simpleFieldKey(sf)) ?? '');
100+
if (node != null) {
101+
children.push(node);
101102
}
102103
}
103104
return children.length === 0 ? undefined : { type: 'and', children };
104105
}
105106

107+
function compileSlot(sf: SimpleField, raw: string): FilterNode | undefined {
108+
const kind = FILTER_FIELDS[sf.field].kind;
109+
const numeric = kind === 'number' || kind === 'countable';
110+
const terms = sf.multi ? raw.split(',') : [raw];
111+
const cmps: CmpNode[] = [];
112+
for (const term of terms) {
113+
const trimmed = term.trim();
114+
if (trimmed === '') {
115+
continue;
116+
}
117+
let value: string | number = trimmed;
118+
if (numeric) {
119+
const n = Number(trimmed);
120+
// The difficulties widget is a text input, so it can hold non-numeric junk; drop those terms.
121+
if (!Number.isFinite(n)) {
122+
continue;
123+
}
124+
value = n;
125+
}
126+
cmps.push({ type: 'cmp', field: sf.field, op: sf.op, value });
127+
}
128+
if (cmps.length === 0) {
129+
return undefined;
130+
}
131+
return cmps.length === 1 ? cmps[0] : { type: 'or', children: cmps };
132+
}
133+
106134
/**
107-
* Best-effort reduction of an arbitrary tree to its simple-representable parts, used when the user
108-
* switches advanced → simple and accepts discarding the incompatible parts. Keeps the first cmp per
109-
* simple field; `not` subtrees are dropped since a negated clause has no simple form.
135+
* Decompiles a filter back into raw simple-field strings, for URL rehydration and the
136+
* advanced -> simple switch. Recognized slots become their field's value (joining a `multi` field's
137+
* OR terms with commas); the first occurrence per field wins and anything not simple-representable is
138+
* dropped, matching the switch's discard prompt.
110139
*/
111-
export function toSimpleFilter(node: FilterNode | undefined): FilterNode | undefined {
112-
const children: CmpNode[] = [];
113-
const seen = new Set<SimpleField>();
140+
export function filterToSimpleValues(node: FilterNode | undefined): Map<string, string> {
141+
const values = new Map<string, string>();
114142
const visit = (n: FilterNode) => {
115-
if (n.type === 'cmp') {
116-
const simpleField = simpleFieldOf(n);
117-
if (simpleField != null && !seen.has(simpleField)) {
118-
seen.add(simpleField);
119-
children.push({ ...n });
143+
const sf = slotSimpleField(n);
144+
if (sf != null) {
145+
const key = simpleFieldKey(sf);
146+
if (!values.has(key)) {
147+
values.set(
148+
key,
149+
slotCmps(n)!
150+
.map((c) => String(c.value))
151+
.join(',')
152+
);
120153
}
121154
return;
122155
}
123-
if (n.type === 'not') {
124-
return;
156+
// Not a recognized slot: salvage simple cmps from inside groups; drop cmps/NOT we can't represent.
157+
if (n.type === 'and' || n.type === 'or') {
158+
n.children.forEach(visit);
125159
}
126-
n.children.forEach(visit);
127160
};
128-
if (node) {
161+
if (node != null) {
129162
visit(node);
130163
}
131-
return children.length === 0 ? undefined : { type: 'and', children };
164+
return values;
132165
}

0 commit comments

Comments
 (0)