1"use client";
2
3
13import { useMemo, useState } from "react";
14import { ChevronDown, Plus, Trash2, X } from "lucide-react";
15import { cn } from "@/lib/utils";
16import {
17 DatabaseProperty,
18 FilterCondition,
19 FilterGroup,
20 FilterTree,
21 isCondition,
22 SelectOption
23} from "@/hooks/use-database";
24
25
26
27
28
29export interface OpDef {
30 op: string;
31 label: string;
32
33 needs: "none" | "text" | "number" | "option" | "options" | "date" | "dateRange" | "days" | "boolean" | "person";
34}
35
36const TEXT_OPS: OpDef[] = [
37 { op: "contains", label: "Contains", needs: "text" },
38 { op: "does_not_contain", label: "Does not contain", needs: "text" },
39 { op: "starts_with", label: "Starts with", needs: "text" },
40 { op: "ends_with", label: "Ends with", needs: "text" },
41 { op: "is", label: "Is", needs: "text" },
42 { op: "is_not", label: "Is not", needs: "text" }
43];
44const NUMBER_OPS: OpDef[] = [
45 { op: "equals", label: "Equals", needs: "number" },
46 { op: "not_equals", label: "Does not equal", needs: "number" },
47 { op: "greater_than", label: "Greater than", needs: "number" },
48 { op: "less_than", label: "Less than", needs: "number" },
49 { op: "greater_or_equal", label: "Greater than or equal to", needs: "number" },
50 { op: "less_or_equal", label: "Less than or equal to", needs: "number" }
51];
52const OPTION_OPS: OpDef[] = [
53 { op: "equals", label: "Is", needs: "option" },
54 { op: "not_equals", label: "Is not", needs: "option" },
55 { op: "contains_any", label: "Contains any", needs: "options" },
56 { op: "contains_all", label: "Contains all", needs: "options" }
57];
58const DATE_OPS: OpDef[] = [
59 { op: "equals", label: "Is on date", needs: "date" },
60 { op: "before", label: "Is before", needs: "date" },
61 { op: "after", label: "Is after", needs: "date" },
62 { op: "between", label: "Is between", needs: "dateRange" },
63 { op: "on_or_before", label: "Is on or before", needs: "date" },
64 { op: "on_or_after", label: "Is on or after", needs: "date" },
65 { op: "is_this_week", label: "Is this week", needs: "none" },
66 { op: "is_this_month", label: "Is this month", needs: "none" },
67 { op: "within_next_days", label: "Is within next", needs: "days" },
68 { op: "is_past", label: "Is in the past", needs: "none" },
69 { op: "is_future", label: "Is in the future", needs: "none" }
70];
71const PERSON_OPS: OpDef[] = [
72 { op: "contains_any", label: "Contains any", needs: "person" },
73 { op: "contains_all", label: "Contains all", needs: "person" },
74 { op: "is_current_user", label: "Is me", needs: "none" }
75];
76const CHECKBOX_OPS: OpDef[] = [
77 { op: "is", label: "Is", needs: "boolean" },
78 { op: "is_not", label: "Is not", needs: "boolean" }
79];
80const EMPTY_OPS: OpDef[] = [
81 { op: "is_empty", label: "Is empty", needs: "none" },
82 { op: "is_not_empty", label: "Is not empty", needs: "none" }
83];
84
85export function opsForProperty(p: DatabaseProperty): OpDef[] {
86 const empties: OpDef[] = [{ op: "is_empty", label: "Is empty", needs: "none" }, { op: "is_not_empty", label: "Is not empty", needs: "none" }];
87 switch (p.type) {
88 case "title":
89 case "text":
90 case "rich_text":
91 case "email":
92 case "url":
93 return [...TEXT_OPS, ...empties];
94 case "number":
95 case "rollup":
96 case "formula":
97 return [...NUMBER_OPS, ...empties];
98 case "select":
99 case "status":
100 return [...OPTION_OPS, ...empties];
101 case "multi_select":
102 case "relation":
103 return [...OPTION_OPS, ...empties].map((o) => (o.needs === "option" ? { ...o, needs: "options" as const } : o));
104 case "date":
105 case "date_range":
106 return [...DATE_OPS, ...empties];
107 case "person":
108 return [...PERSON_OPS, ...empties];
109 case "checkbox":
110 return CHECKBOX_OPS;
111 case "files":
112 return EMPTY_OPS;
113 default:
114 return empties;
115 }
116}
117
118
119export function filterableProperties(properties: DatabaseProperty[]): DatabaseProperty[] {
120 return properties;
121}
122
123
124
125
126
127function defaultValue(needs: OpDef["needs"]): Record<string, unknown> | undefined {
128 switch (needs) {
129 case "text":
130 return { text: "" };
131 case "number":
132 return { value: 0 };
133 case "option":
134 return { optionId: "" };
135 case "options":
136 return { optionIds: [] };
137 case "person":
138 return { userIds: [] };
139 case "date":
140 return { value: new Date().toISOString() };
141 case "dateRange":
142 return { start: "", end: "" };
143 case "days":
144 return { days: 7 };
145 case "boolean":
146 return { value: true };
147 default:
148 return undefined;
149 }
150}
151
152
153
154
155
156const inputCls =
157 "rounded border border-input bg-background px-1.5 py-0.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring";
158
159function ValueInput({
160 needs,
161 property,
162 value,
163 onChange
164}: {
165 needs: OpDef["needs"];
166 property: DatabaseProperty;
167 value?: Record<string, unknown>;
168 onChange: (v: Record<string, unknown> | undefined) => void;
169}) {
170 if (needs === "none") return <span className="text-xs text-muted-foreground">—</span>;
171 if (needs === "text") {
172 return (
173 <input
174 className={cn(inputCls, "w-32")}
175 value={String(value?.text ?? "")}
176 placeholder="Value"
177 onChange={(e) => onChange({ text: e.target.value })}
178 />
179 );
180 }
181 if (needs === "number" || needs === "days") {
182 const key = needs === "days" ? "days" : "value";
183 return (
184 <input
185 type="number"
186 className={cn(inputCls, "w-20")}
187 value={Number(value?.[key] ?? 0)}
188 onChange={(e) => onChange({ [key]: Number(e.target.value) })}
189 />
190 );
191 }
192 if (needs === "option") {
193 const options = property.config.options ?? [];
194 return (
195 <select
196 className={cn(inputCls, "w-36")}
197 value={String(value?.optionId ?? "")}
198 onChange={(e) => onChange({ optionId: e.target.value || undefined })}
199 >
200 <option value="">Select…</option>
201 {options.map((o: SelectOption) => (
202 <option key={o.id} value={o.id}>
203 {o.name}
204 </option>
205 ))}
206 </select>
207 );
208 }
209 if (needs === "options") {
210 const options = property.config.options ?? [];
211 const selected = (value?.optionIds as string[] | undefined) ?? [];
212 return (
213 <select
214 multiple
215 className={cn(inputCls, "h-16 w-36")}
216 value={selected}
217 onChange={(e) => {
218 const ids = Array.from(e.target.selectedOptions).map((o) => o.value);
219 onChange({ optionIds: ids });
220 }}
221 >
222 {options.map((o) => (
223 <option key={o.id} value={o.id}>
224 {o.name}
225 </option>
226 ))}
227 </select>
228 );
229 }
230 if (needs === "date" || needs === "dateRange") {
231 const type = property.config.includeTime ? "datetime-local" : "date";
232 if (needs === "dateRange") {
233 const toLocal = (iso?: string) => (iso ? String(iso).slice(0, 10) : "");
234 return (
235 <span className="inline-flex items-center gap-1">
236 <input
237 type="date"
238 className={inputCls}
239 value={toLocal(String(value?.start ?? ""))}
240 onChange={(e) => onChange({ ...value, start: e.target.value })}
241 />
242 <span className="text-xs">–</span>
243 <input
244 type="date"
245 className={inputCls}
246 value={toLocal(String(value?.end ?? ""))}
247 onChange={(e) => onChange({ ...value, end: e.target.value })}
248 />
249 </span>
250 );
251 }
252 return (
253 <input
254 type={type}
255 className={inputCls}
256 value={String(value?.value ?? "").slice(0, type === "datetime-local" ? 16 : 10)}
257 onChange={(e) => onChange({ value: e.target.value })}
258 />
259 );
260 }
261 if (needs === "boolean") {
262 return (
263 <select
264 className={cn(inputCls, "w-20")}
265 value={value?.value ? "true" : "false"}
266 onChange={(e) => onChange({ value: e.target.value === "true" })}
267 >
268 <option value="true">True</option>
269 <option value="false">False</option>
270 </select>
271 );
272 }
273 if (needs === "person") {
274
275
276
277 const ids = (value?.userIds as string[] | undefined) ?? [];
278 return (
279 <input
280 className={cn(inputCls, "w-40")}
281 placeholder="member id(s), comma-separated"
282 value={ids.join(", ")}
283 onChange={(e) =>
284 onChange({
285 userIds: e.target.value.split(",").map((s) => s.trim()).filter(Boolean)
286 })
287 }
288 />
289 );
290 }
291 return null;
292}
293
294
295
296
297
298type Node = FilterGroup | FilterCondition;
299
300function isGroupNode(n: Node): n is FilterGroup {
301 return !isCondition(n);
302}
303
304function updateGroup(
305 group: FilterGroup,
306 predicate: (node: Node) => boolean,
307 update: (node: Node) => Node | null
308): FilterGroup | null {
309 const next: Node[] = [];
310 for (const child of group.conditions) {
311 if (predicate(child)) {
312 const u = update(child);
313 if (u != null) next.push(u);
314 } else if (isGroupNode(child)) {
315 const u = updateGroup(child, predicate, update);
316 if (u != null) next.push(u);
317 } else {
318 next.push(child);
319 }
320 }
321 if (next.length === 0 && group.op !== "not") return null;
322 return { ...group, conditions: next };
323}
324
325function addNode(tree: FilterTree | null, newNode: Node): FilterTree {
326 if (!tree) {
327 return { op: "and", conditions: [newNode] };
328 }
329 if (isCondition(tree)) {
330 return { op: "and", conditions: [tree, newNode] };
331 }
332 return { ...tree, conditions: [...tree.conditions, newNode] };
333}
334
335function nodeKey(node: Node, index: number): string {
336 if (isCondition(node)) return `c${index}:${node.propertyId}:${node.op}`;
337 return `g${index}:${node.op}`;
338}
339
340
341
342
343
344function ConditionRow({
345 condition,
346 properties,
347 onChange,
348 onRemove
349}: {
350 condition: FilterCondition;
351 properties: DatabaseProperty[];
352 onChange: (c: FilterCondition | null) => void;
353 onRemove: () => void;
354}) {
355 const property = properties.find((p) => p.id === condition.propertyId) ?? properties[0];
356 const ops = useMemo(() => (property ? opsForProperty(property) : []), [property]);
357 const opDef = ops.find((o) => o.op === condition.op) ?? ops[0];
358
359 if (!property) return null;
360
361 return (
362 <div className="flex flex-wrap items-center gap-1.5 py-1">
363 <select
364 className={cn(inputCls, "w-32")}
365 value={property.id}
366 onChange={(e) => {
367 const p = properties.find((x) => x.id === e.target.value);
368 const firstOp = p ? opsForProperty(p)[0] : undefined;
369 onChange({
370 propertyId: e.target.value,
371 op: firstOp?.op ?? "is_empty",
372 value: firstOp ? defaultValue(firstOp.needs) : undefined
373 });
374 }}
375 aria-label="Property"
376 >
377 {properties.map((p) => (
378 <option key={p.id} value={p.id}>
379 {p.name}
380 </option>
381 ))}
382 </select>
383 <select
384 className={cn(inputCls, "w-40")}
385 value={opDef?.op ?? ""}
386 onChange={(e) => {
387 const d = ops.find((o) => o.op === e.target.value);
388 onChange({ ...condition, op: e.target.value, value: d ? defaultValue(d.needs) : undefined });
389 }}
390 aria-label="Condition"
391 >
392 {ops.map((o) => (
393 <option key={o.op} value={o.op}>
394 {o.label}
395 </option>
396 ))}
397 </select>
398 <ValueInput
399 needs={opDef?.needs ?? "none"}
400 property={property}
401 value={condition.value}
402 onChange={(v) => onChange({ ...condition, value: v })}
403 />
404 <button
405 type="button"
406 className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-destructive"
407 onClick={onRemove}
408 aria-label="Remove condition"
409 >
410 <X className="size-3.5" />
411 </button>
412 </div>
413 );
414}
415
416
417
418
419
420function GroupBlock({
421 group,
422 depth,
423 properties,
424 onChange,
425 onRemove
426}: {
427 group: FilterGroup;
428 depth: number;
429 properties: DatabaseProperty[];
430 onChange: (g: FilterGroup | null) => void;
431 onRemove?: () => void;
432}) {
433 const [collapsed, setCollapsed] = useState(depth > 0);
434 const addCondition = () => {
435 const p = properties[0];
436 if (!p) return;
437 const op = opsForProperty(p)[0];
438 onChange({
439 ...group,
440 conditions: [
441 ...group.conditions,
442 { propertyId: p.id, op: op.op, value: defaultValue(op.needs) }
443 ]
444 });
445 };
446 const addGroup = () => {
447 onChange({
448 ...group,
449 conditions: [...group.conditions, { op: "or", conditions: [] }]
450 });
451 };
452
453 return (
454 <div className={cn("rounded-md border", depth > 0 && "bg-muted/30 p-1.5")}>
455 <div className="flex items-center gap-2 py-1">
456 <button
457 type="button"
458 className="text-muted-foreground hover:text-foreground"
459 onClick={() => setCollapsed((c) => !c)}
460 aria-label={collapsed ? "Expand group" : "Collapse group"}
461 >
462 <ChevronDown className={cn("size-3.5 transition-transform", collapsed && "-rotate-90")} />
463 </button>
464 <select
465 className={cn(inputCls, "w-16 font-medium")}
466 value={group.op}
467 onChange={(e) => onChange({ ...group, op: e.target.value as FilterGroup["op"] })}
468 aria-label="Group operator"
469 >
470 <option value="and">AND</option>
471 <option value="or">OR</option>
472 {depth > 0 && <option value="not">NOT</option>}
473 </select>
474 <span className="text-xs text-muted-foreground">
475 {group.conditions.filter(isCondition).length} condition(s)
476 </span>
477 {onRemove && (
478 <button
479 type="button"
480 className="ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-destructive"
481 onClick={onRemove}
482 aria-label="Remove group"
483 >
484 <Trash2 className="size-3.5" />
485 </button>
486 )}
487 </div>
488 {!collapsed && (
489 <div className="pl-5">
490 {group.conditions.map((node, i) =>
491 isGroupNode(node) ? (
492 <GroupBlock
493 key={nodeKey(node, i)}
494 group={node}
495 depth={depth + 1}
496 properties={properties}
497 onChange={(g) =>
498 onChange(
499 updateGroup(group, (n) => n === node, () => g) ?? group
500 )
501 }
502 onRemove={() =>
503 onChange(
504 updateGroup(group, (n) => n === node, () => null) ?? group
505 )
506 }
507 />
508 ) : (
509 <ConditionRow
510 key={nodeKey(node, i)}
511 condition={node}
512 properties={properties}
513 onChange={(c) =>
514 onChange(
515 updateGroup(group, (n) => n === node, () => c) ?? group
516 )
517 }
518 onRemove={() =>
519 onChange(
520 updateGroup(group, (n) => n === node, () => null) ?? group
521 )
522 }
523 />
524 )
525 )}
526 <div className="flex items-center gap-2 py-1">
527 <button type="button" className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-primary hover:bg-muted" onClick={addCondition}>
528 <Plus className="size-3" /> Add condition
529 </button>
530 <button type="button" className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-primary hover:bg-muted" onClick={addGroup}>
531 <Plus className="size-3" /> Add group
532 </button>
533 </div>
534 </div>
535 )}
536 </div>
537 );
538}
539
540
541
542
543
544export interface FilterBuilderProps {
545 value: FilterTree | null;
546 onChange: (tree: FilterTree | null) => void;
547 properties: DatabaseProperty[];
548
549 className?: string;
550}
551
552export function FilterBuilder({ value, onChange, properties, className }: FilterBuilderProps) {
553 if (properties.length === 0) {
554 return <p className="px-1 py-2 text-xs text-muted-foreground">Add properties before filtering.</p>;
555 }
556 return (
557 <div className={cn("min-h-16", className)}>
558 {value ? (
559 isGroupNode(value) ? (
560 <GroupBlock group={value} depth={0} properties={properties} onChange={(g) => onChange(g ?? null)} />
561 ) : (
562 <div className="space-y-1">
563 <ConditionRow
564 condition={value}
565 properties={properties}
566 onChange={(c) => onChange(c)}
567 onRemove={() => onChange(null)}
568 />
569 <button
570 type="button"
571 className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-primary hover:bg-muted"
572 onClick={() => {
573 const p = properties[0];
574 const op = opsForProperty(p)[0];
575 onChange(addNode(value, { propertyId: p.id, op: op.op, value: defaultValue(op.needs) }));
576 }}
577 >
578 <Plus className="size-3" /> Add condition
579 </button>
580 </div>
581 )
582 ) : (
583 <button
584 type="button"
585 className="inline-flex items-center gap-1 rounded px-1.5 py-1 text-xs text-primary hover:bg-muted"
586 onClick={() => {
587 const p = properties[0];
588 const op = opsForProperty(p)[0];
589 onChange({ propertyId: p.id, op: op.op, value: defaultValue(op.needs) });
590 }}
591 >
592 <Plus className="size-3" /> Add filter condition
593 </button>
594 )}
595 {value && (
596 <div className="mt-2 border-t pt-1">
597 <button type="button" className="rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-destructive" onClick={() => onChange(null)}>
598 Clear all filters
599 </button>
600 </div>
601 )}
602 </div>
603 );
604}
605