Skip to content

Commit 89811c9

Browse files
jonathanlabclaude
andauthored
feat: task filtering (#72)
Co-authored-by: Claude <[email protected]>
1 parent a9bfa19 commit 89811c9

21 files changed

+1540
-292
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type {
2+
FilterCategory,
3+
FilterOperator,
4+
} from "@features/tasks/stores/taskStore";
5+
import { useTaskStore } from "@features/tasks/stores/taskStore";
6+
import { DropdownMenu, Text } from "@radix-ui/themes";
7+
8+
interface FilterOperatorToggleProps {
9+
category: FilterCategory;
10+
value: string;
11+
operator: FilterOperator;
12+
}
13+
14+
export function FilterOperatorToggle({
15+
category,
16+
value,
17+
operator,
18+
}: FilterOperatorToggleProps) {
19+
const toggleFilterOperator = useTaskStore(
20+
(state) => state.toggleFilterOperator,
21+
);
22+
23+
const isDateCategory = category === "created_at";
24+
25+
const getOperatorLabel = (op: FilterOperator) => {
26+
switch (op) {
27+
case "is":
28+
return "is";
29+
case "is_not":
30+
return "is not";
31+
case "before":
32+
return "before";
33+
case "after":
34+
return "after";
35+
}
36+
};
37+
38+
const setOperator = (newOperator: FilterOperator) => {
39+
if (operator !== newOperator) {
40+
toggleFilterOperator(category, value);
41+
}
42+
};
43+
44+
return (
45+
<DropdownMenu.Root>
46+
<DropdownMenu.Trigger>
47+
<button
48+
type="button"
49+
className="cursor-pointer rounded px-1 py-0.5 opacity-50 hover:bg-gray-5 hover:opacity-100"
50+
>
51+
{getOperatorLabel(operator)}
52+
</button>
53+
</DropdownMenu.Trigger>
54+
<DropdownMenu.Content>
55+
{isDateCategory ? (
56+
<>
57+
<DropdownMenu.Item
58+
className="hover:bg-gray-5"
59+
onSelect={(e) => {
60+
e.preventDefault();
61+
setOperator("before");
62+
}}
63+
>
64+
<Text size="1">before</Text>
65+
</DropdownMenu.Item>
66+
<DropdownMenu.Item
67+
className="hover:bg-gray-5"
68+
onSelect={(e) => {
69+
e.preventDefault();
70+
setOperator("after");
71+
}}
72+
>
73+
<Text size="1">after</Text>
74+
</DropdownMenu.Item>
75+
</>
76+
) : (
77+
<>
78+
<DropdownMenu.Item
79+
className="hover:bg-gray-5"
80+
onSelect={(e) => {
81+
e.preventDefault();
82+
setOperator("is");
83+
}}
84+
>
85+
<Text size="1">is</Text>
86+
</DropdownMenu.Item>
87+
<DropdownMenu.Item
88+
className="hover:bg-gray-5"
89+
onSelect={(e) => {
90+
e.preventDefault();
91+
setOperator("is_not");
92+
}}
93+
>
94+
<Text size="1">is not</Text>
95+
</DropdownMenu.Item>
96+
</>
97+
)}
98+
</DropdownMenu.Content>
99+
</DropdownMenu.Root>
100+
);
101+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { FilterOperatorToggle } from "@features/tasks/components/FilterOperatorToggle";
2+
import { TaskFilterValueEditor } from "@features/tasks/components/TaskFilterValueEditor";
3+
import type {
4+
FilterCategory,
5+
FilterOperator,
6+
} from "@features/tasks/stores/taskStore";
7+
import { useTaskStore } from "@features/tasks/stores/taskStore";
8+
import type { FilterCategoryConfig } from "@features/tasks/utils/filterCategories";
9+
import { Cross2Icon } from "@radix-ui/react-icons";
10+
import { Badge, DropdownMenu, Flex, Separator } from "@radix-ui/themes";
11+
12+
interface TaskFilterBadgeProps {
13+
category: FilterCategory;
14+
categoryLabel: string;
15+
value: string;
16+
valueLabel: string;
17+
operator: FilterOperator;
18+
badgeKey: string;
19+
categoryConfig: FilterCategoryConfig | undefined;
20+
onRemoveFilter: (category: FilterCategory, value: string) => void;
21+
onToggleFilter: (category: FilterCategory, value: string) => void;
22+
}
23+
24+
export function TaskFilterBadge({
25+
category,
26+
categoryLabel,
27+
value,
28+
valueLabel,
29+
operator,
30+
badgeKey,
31+
categoryConfig,
32+
onRemoveFilter,
33+
onToggleFilter,
34+
}: TaskFilterBadgeProps) {
35+
const editingBadgeKey = useTaskStore((state) => state.editingFilterBadgeKey);
36+
const setEditingBadgeKey = useTaskStore(
37+
(state) => state.setEditingFilterBadgeKey,
38+
);
39+
const isEditing = editingBadgeKey === badgeKey;
40+
41+
return (
42+
<DropdownMenu.Root
43+
open={isEditing}
44+
onOpenChange={(open) => {
45+
setEditingBadgeKey(open ? badgeKey : null);
46+
}}
47+
>
48+
<Badge size="1" color="gray" variant="soft">
49+
<Flex align="center" gap="0">
50+
<span className="font-medium">{categoryLabel}</span>
51+
<Separator orientation="vertical" mx="1" />
52+
<FilterOperatorToggle
53+
category={category}
54+
value={value}
55+
operator={operator}
56+
/>
57+
<Separator orientation="vertical" mx="1" />
58+
<DropdownMenu.Trigger>
59+
<button
60+
type="button"
61+
className="cursor-pointer rounded px-1 py-0.5 font-medium hover:bg-gray-5"
62+
>
63+
{valueLabel}
64+
</button>
65+
</DropdownMenu.Trigger>
66+
<Separator orientation="vertical" mx="1" />
67+
<button
68+
type="button"
69+
className="flex cursor-pointer items-center justify-center rounded p-0.5 hover:bg-gray-5"
70+
onClick={(e) => {
71+
e.preventDefault();
72+
e.stopPropagation();
73+
onRemoveFilter(category, value);
74+
setEditingBadgeKey(null);
75+
}}
76+
>
77+
<Cross2Icon width="10" height="10" />
78+
</button>
79+
</Flex>
80+
</Badge>
81+
<DropdownMenu.Content className="min-w-[200px]">
82+
{categoryConfig && (
83+
<TaskFilterValueEditor
84+
config={categoryConfig}
85+
onToggleFilter={onToggleFilter}
86+
/>
87+
)}
88+
</DropdownMenu.Content>
89+
</DropdownMenu.Root>
90+
);
91+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { TaskFilterBadge } from "@features/tasks/components/TaskFilterBadge";
2+
import type {
3+
ActiveFilters,
4+
FilterCategory,
5+
FilterOperator,
6+
} from "@features/tasks/stores/taskStore";
7+
import { useTaskStore } from "@features/tasks/stores/taskStore";
8+
import type { FilterCategoryConfig } from "@features/tasks/utils/filterCategories";
9+
import { Flex } from "@radix-ui/themes";
10+
import type { ReactNode } from "react";
11+
12+
interface TaskFilterBadgesProps {
13+
activeFilters: ActiveFilters;
14+
filterCategories: FilterCategoryConfig[];
15+
onRemoveFilter: (category: FilterCategory, value: string) => void;
16+
onUpdateFilter: (
17+
category: FilterCategory,
18+
oldValue: string,
19+
newValue: string,
20+
) => void;
21+
children?: ReactNode;
22+
}
23+
24+
export function TaskFilterBadges({
25+
activeFilters,
26+
filterCategories,
27+
onRemoveFilter,
28+
onUpdateFilter,
29+
children,
30+
}: TaskFilterBadgesProps) {
31+
const setEditingBadgeKey = useTaskStore(
32+
(state) => state.setEditingFilterBadgeKey,
33+
);
34+
const badges: Array<{
35+
category: FilterCategory;
36+
categoryLabel: string;
37+
value: string;
38+
valueLabel: string;
39+
operator: FilterOperator;
40+
}> = [];
41+
42+
for (const [category, filterValues] of Object.entries(activeFilters)) {
43+
if (!filterValues || filterValues.length === 0) continue;
44+
45+
const categoryConfig = filterCategories.find(
46+
(c) => c.category === category,
47+
);
48+
if (!categoryConfig) continue;
49+
50+
for (const filterValue of filterValues) {
51+
const option = categoryConfig.options.find(
52+
(o) => o.value === filterValue.value,
53+
);
54+
badges.push({
55+
category: category as FilterCategory,
56+
categoryLabel: categoryConfig.label,
57+
value: filterValue.value,
58+
valueLabel: option?.label || filterValue.value,
59+
operator: filterValue.operator,
60+
});
61+
}
62+
}
63+
64+
const handleToggleFilterFromBadge = (
65+
category: FilterCategory,
66+
oldValue: string,
67+
newValue: string,
68+
) => {
69+
onUpdateFilter(category, oldValue, newValue);
70+
setEditingBadgeKey(null);
71+
};
72+
73+
return (
74+
<Flex gap="1" wrap="wrap" align="center" style={{ maxWidth: "60%" }}>
75+
{badges.map((badge) => {
76+
const categoryConfig = filterCategories.find(
77+
(c) => c.category === badge.category,
78+
);
79+
const badgeKey = `${badge.category}-${badge.value}`;
80+
81+
return (
82+
<TaskFilterBadge
83+
key={badgeKey}
84+
category={badge.category}
85+
categoryLabel={badge.categoryLabel}
86+
value={badge.value}
87+
valueLabel={badge.valueLabel}
88+
operator={badge.operator}
89+
badgeKey={badgeKey}
90+
categoryConfig={categoryConfig}
91+
onRemoveFilter={onRemoveFilter}
92+
onToggleFilter={(category, newValue) =>
93+
handleToggleFilterFromBadge(category, badge.value, newValue)
94+
}
95+
/>
96+
);
97+
})}
98+
{children}
99+
</Flex>
100+
);
101+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { TaskFilterOption } from "@features/tasks/components/TaskFilterOption";
2+
import type { FilterCategory } from "@features/tasks/stores/taskStore";
3+
import type { FilterCategoryConfig } from "@features/tasks/utils/filterCategories";
4+
import { DropdownMenu, Text } from "@radix-ui/themes";
5+
6+
interface TaskFilterCategoryProps {
7+
config: FilterCategoryConfig;
8+
onToggleFilter: (category: FilterCategory, value: string) => void;
9+
defaultOpen?: boolean;
10+
}
11+
12+
export function TaskFilterCategory({
13+
config,
14+
onToggleFilter,
15+
defaultOpen = false,
16+
}: TaskFilterCategoryProps) {
17+
return (
18+
<DropdownMenu.Sub defaultOpen={defaultOpen}>
19+
<DropdownMenu.SubTrigger>
20+
<Text size="1">{config.label}</Text>
21+
</DropdownMenu.SubTrigger>
22+
<DropdownMenu.SubContent>
23+
{config.options.map((option) => (
24+
<TaskFilterOption
25+
key={option.value}
26+
category={config.category}
27+
label={option.label}
28+
value={option.value}
29+
onToggle={onToggleFilter}
30+
/>
31+
))}
32+
</DropdownMenu.SubContent>
33+
</DropdownMenu.Sub>
34+
);
35+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useTaskStore } from "@features/tasks/stores/taskStore";
2+
import { Button } from "@radix-ui/themes";
3+
4+
export function TaskFilterClearButton() {
5+
const clearActiveFilters = useTaskStore((state) => state.clearActiveFilters);
6+
const totalActiveFilterCount = useTaskStore((state) => {
7+
return Object.values(state.activeFilters).reduce(
8+
(sum, filters) => sum + (filters?.length || 0),
9+
0,
10+
);
11+
});
12+
13+
if (totalActiveFilterCount === 0) return null;
14+
15+
return (
16+
<Button
17+
size="1"
18+
variant="ghost"
19+
color="gray"
20+
onClick={clearActiveFilters}
21+
style={{ cursor: "pointer" }}
22+
>
23+
Clear
24+
</Button>
25+
);
26+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useTaskStore } from "@features/tasks/stores/taskStore";
2+
import { Button } from "@radix-ui/themes";
3+
4+
export function TaskFilterMatchToggle() {
5+
const filterMatchMode = useTaskStore((state) => state.filterMatchMode);
6+
const setFilterMatchMode = useTaskStore((state) => state.setFilterMatchMode);
7+
const totalActiveFilterCount = useTaskStore((state) => {
8+
return Object.values(state.activeFilters).reduce(
9+
(sum, filters) => sum + (filters?.length || 0),
10+
0,
11+
);
12+
});
13+
14+
if (totalActiveFilterCount <= 1) return null;
15+
16+
const toggleMode = () => {
17+
setFilterMatchMode(filterMatchMode === "all" ? "any" : "all");
18+
};
19+
20+
return (
21+
<Button
22+
size="1"
23+
variant="ghost"
24+
color="gray"
25+
onClick={toggleMode}
26+
style={{ cursor: "pointer" }}
27+
>
28+
Match {filterMatchMode === "all" ? "all filters" : "any filter"}
29+
</Button>
30+
);
31+
}

0 commit comments

Comments
 (0)