Skip to content

Commit

Permalink
Fix filters
Browse files Browse the repository at this point in the history
  • Loading branch information
josepjaume committed Jan 29, 2025
1 parent 2c0930e commit 5245efc
Show file tree
Hide file tree
Showing 3 changed files with 207 additions and 209 deletions.
97 changes: 68 additions & 29 deletions lib/experimental/Collections/Filters/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react"
import { useState } from "react"
import { Filters } from "."
import type { FiltersDefinition } from "../types"
import type { FiltersDefinition, FiltersState } from "../types"

const meta = {
title: "Experimental/Collections/Filters",
Expand All @@ -11,48 +12,86 @@ const meta = {
} satisfies Meta<typeof Filters>

export default meta
type Story = StoryObj<typeof meta>

const sampleDefinition: FiltersDefinition = {
status: {
department: {
type: "in",
label: "Status",
label: "Department",
options: [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" },
{ value: "pending", label: "Pending" },
{ value: "engineering", label: "Engineering" },
{ value: "marketing", label: "Marketing" },
{ value: "sales", label: "Sales" },
{ value: "hr", label: "Human Resources" },
{ value: "finance", label: "Finance" },
],
},
search: {
name: {
type: "search",
label: "Search",
label: "Employee name",
},
department: {
manager: {
type: "in",
label: "Department",
label: "Manager",
options: [
{ value: "engineering", label: "Engineering" },
{ value: "marketing", label: "Marketing" },
{ value: "sales", label: "Sales" },
{ value: "alice", label: "Alice Johnson" },
{ value: "bob", label: "Bob Smith" },
{ value: "carol", label: "Carol Williams" },
{ value: "dave", label: "Dave Brown" },
],
},
location: {
type: "in",
label: "Office location",
options: [
{ value: "london", label: "London" },
{ value: "new_york", label: "New York" },
{ value: "tokyo", label: "Tokyo" },
{ value: "remote", label: "Remote" },
],
},
}

export const Default: Story = {
args: {
definition: sampleDefinition,
value: {},
onChange: () => {},
},
const FiltersWithState = () => {
const [filters, setFilters] = useState<FiltersState<typeof sampleDefinition>>(
{}
)

return (
<div className="w-[800px] p-4">
<Filters
definition={sampleDefinition}
value={filters}
onChange={setFilters}
/>
</div>
)
}

export const WithFilters: Story = {
args: {
definition: sampleDefinition,
value: {
status: ["active", "pending"],
search: "John",
},
onChange: () => {},
},
export const Interactive: StoryObj = {
render: () => <FiltersWithState />,
}

// Example of pre-populated filters
const FiltersWithInitialState = () => {
const [filters, setFilters] = useState<FiltersState<typeof sampleDefinition>>(
{
department: ["engineering", "marketing"],
name: "John",
manager: ["alice"],
}
)

return (
<div className="w-[800px] p-4">
<Filters
definition={sampleDefinition}
value={filters}
onChange={setFilters}
/>
</div>
)
}

export const WithInitialFilters: StoryObj = {
render: () => <FiltersWithInitialState />,
}
221 changes: 139 additions & 82 deletions lib/experimental/Collections/Filters/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

import { Badge } from "@/ui/badge"
import { Button } from "@/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/ui/dropdown-menu"
import { Input } from "@/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover"
import { X } from "lucide-react"
import { Plus, X } from "lucide-react"
import * as React from "react"
import type {
FiltersDefinition,
Expand All @@ -24,6 +31,10 @@ export function Filters<Definition extends FiltersDefinition>({
value,
onChange,
}: FiltersProps<Definition>) {
const [selectedFilterKey, setSelectedFilterKey] = React.useState<
keyof Definition | null
>(null)

const handleRemoveFilter = (key: keyof Definition) => {
const newValue = { ...value }
delete newValue[key]
Expand Down Expand Up @@ -66,7 +77,10 @@ export function Filters<Definition extends FiltersDefinition>({
key={String(key)}
filter={filter}
value={currentValue}
onChange={(newValue) => handleInFilterChange(key, filter, newValue)}
onChange={(newValue) => {
handleInFilterChange(key, filter, newValue)
setSelectedFilterKey(null)
}}
/>
)
}
Expand All @@ -81,7 +95,10 @@ export function Filters<Definition extends FiltersDefinition>({
key={String(key)}
filter={filter}
value={currentValue}
onChange={(newValue) => handleSearchFilterChange(key, filter, newValue)}
onChange={(newValue) => {
handleSearchFilterChange(key, filter, newValue)
if (newValue) setSelectedFilterKey(null)
}}
/>
)
}
Expand All @@ -98,39 +115,97 @@ export function Filters<Definition extends FiltersDefinition>({
}
}

// Get available filters (ones that aren't currently active)
const availableFilters = Object.entries(definition).filter(
([key]) => !value[key]
)

return (
<div className="flex flex-wrap gap-2">
{Object.entries(definition).map(([key, filter]) => {
const typedKey = key as keyof Definition
const typedFilter = filter as Definition[typeof typedKey]
return (
<React.Fragment key={key}>
{value[typedKey] ? (
<Badge variant="default" className="h-7 rounded-full">
<span className="mr-1">{filter.label}:</span>
{filter.type === "in" && (
<span>
{((value[typedKey] ?? []) as unknown[]).length} selected
</span>
)}
{filter.type === "search" && (
<span>{(value[typedKey] ?? "") as string}</span>
)}
<Button
variant="ghost"
size="sm"
className="ml-1 h-4 w-4 rounded-full p-0"
onClick={() => handleRemoveFilter(typedKey)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
{/* Active Filters */}
{Object.entries(definition).map(([key, filter]) => {
const typedKey = key as keyof Definition
if (!value[typedKey]) return null

return (
<Badge key={key} variant="default" className="h-7 rounded-full">
<span className="mr-1">{filter.label}:</span>
{filter.type === "in" && (
<span>
{((value[typedKey] ?? []) as unknown[]).length} selected
</span>
)}
{filter.type === "search" && (
<span>{(value[typedKey] ?? "") as string}</span>
)}
<Button
variant="ghost"
size="sm"
className="ml-1 h-4 w-4 rounded-full p-0"
onClick={() => handleRemoveFilter(typedKey)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
)
})}

{/* Add Filter Button */}
{availableFilters.length > 0 && (
<>
{selectedFilterKey === null ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 rounded-full"
>
<Plus className="mr-1 h-3 w-3" />
Add filter
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[200px]">
<DropdownMenuGroup>
{availableFilters.map(([key, filter]) => (
<DropdownMenuItem
key={key}
onClick={() =>
setSelectedFilterKey(key as keyof Definition)
}
>
{filter.label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
) : (
renderFilterInput(typedKey, typedFilter)
<Popover
open={true}
onOpenChange={(open) => !open && setSelectedFilterKey(null)}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 rounded-full"
>
{definition[selectedFilterKey].label}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-2" align="start">
{renderFilterInput(
selectedFilterKey,
definition[selectedFilterKey]
)}
</PopoverContent>
</Popover>
)}
</React.Fragment>
)
})}
</>
)}
</div>
</div>
)
}
Expand All @@ -142,40 +217,30 @@ interface InFilterProps<T> {
}

function InFilter<T>({ filter, value, onChange }: InFilterProps<T>) {
const [open, setOpen] = React.useState(false)

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 rounded-full">
{filter.label}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-2" align="start">
<div className="space-y-2">
{filter.options.map((option) => {
const isSelected = value.includes(option.value)
return (
<Button
key={String(option.value)}
variant={isSelected ? "default" : "ghost"}
className="w-full justify-start"
onClick={() => {
onChange(
isSelected
? value.filter((v) => v !== option.value)
: [...value, option.value]
)
}}
>
<div className="mr-2">{isSelected ? "✓" : " "}</div>
{option.label}
</Button>
)
})}
</div>
</PopoverContent>
</Popover>
<div className="space-y-2">
<div className="text-sm font-medium">{filter.label}</div>
{filter.options.map((option) => {
const isSelected = value.includes(option.value)
return (
<Button
key={String(option.value)}
variant={isSelected ? "default" : "ghost"}
className="w-full justify-start"
onClick={() => {
onChange(
isSelected
? value.filter((v) => v !== option.value)
: [...value, option.value]
)
}}
>
<div className="mr-2">{isSelected ? "✓" : " "}</div>
{option.label}
</Button>
)
})}
</div>
)
}

Expand All @@ -186,22 +251,14 @@ interface SearchFilterProps {
}

function SearchFilter({ filter, value, onChange }: SearchFilterProps) {
const [open, setOpen] = React.useState(false)

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 rounded-full">
{filter.label}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-2" align="start">
<Input
placeholder={`Search ${filter.label.toLowerCase()}...`}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</PopoverContent>
</Popover>
<div className="space-y-2">
<div className="text-sm font-medium">{filter.label}</div>
<Input
placeholder={`Search ${filter.label.toLowerCase()}...`}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
)
}
Loading

0 comments on commit 5245efc

Please sign in to comment.