Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add boolean operator filters #85

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/StatisticsFilterBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DB } from '@/types'
import React from 'react'
import AddFilter from './filters/AddFilter'
import { filterDispatchType, filterType } from '@/pages/StatsDashboard'
import { filterType, filterDispatchType } from './filters/filterReducer'

type statsFilterProps = {
data: DB
Expand Down
77 changes: 60 additions & 17 deletions src/components/filters/AddFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useRef, useState } from 'react'
import DateFilter from './DateFilter'
import LatLongFilter from './LatLongFilter'
import { filterDispatchType } from '@/pages/StatsDashboard'
import { LucideCalendar, LucideGlobe, LucideMapPin, LucidePlus, LucideTags, LucideText } from 'lucide-react'
import { filterDispatchType, filterProps } from './filterReducer'
import { LucideCalendar, LucideGlobe, LucideMapPin, LucidePlus, LucideTags, LucideText, LucideMerge } from 'lucide-react'
import {
useFloating,
offset,
Expand All @@ -17,35 +17,79 @@ import {
arrow,
} from '@floating-ui/react'
import CountryFilter from './CountryFilter'
import BoolORFilter from './BoolORFilter'
import BoolNOTFilter from './BoolNOTFilter'

const possibleFilters = [
interface FilterInfo {
name: string
icon: any
component: React.FC<filterProps>
description: string
}

const possibleFilters: FilterInfo[] = [
{
name: 'Actividades',
icon: LucideTags,
component: DateFilter, // TODO: Change this to the actual component
description: 'Filtrar por actividades y tipos de eventos',
},
{
name: 'Fecha',
icon: LucideCalendar,
component: DateFilter,
description: 'Filtrar por fecha',
},
{
name: 'Latitud/Longitud',
icon: LucideMapPin,
component: LatLongFilter,
description: 'Filtrar por ubicación',
},
{
name: 'Áreas',
icon: LucideGlobe,
component: CountryFilter,
description: 'Filtrar por áreas geográficas',
},
{
name: 'Descripción',
icon: LucideText,
component: DateFilter, // TODO: Change this to the actual component
description: 'Filtrar por palabras clave en la descripción',
},
]

const advancedFilters: FilterInfo[] = [
{
name: 'O (combinar)',
icon: LucideMerge,
component: BoolORFilter,
description: 'Combinar filtros con la operación OR',
},
{
name: 'NO (combinar)',
icon: LucideMerge,
component: BoolNOTFilter,
description: 'Combinar filtros con la operación NOT',
},
]

function NewFilterButton({ filter, dispatch }: { filter: FilterInfo; dispatch: React.Dispatch<filterDispatchType> }) {
return (
<button
className="block w-full rounded-md px-2 py-1 text-left hover:bg-black/5"
onClick={() => dispatch({ type: 'ADD_FILTER', payload: { component: filter.component } })}
>
<span className="flex items-center">
{filter.icon && <filter.icon size={12} className="mr-1" />}
{filter.name}
</span>
<p className="text-xs text-neutral-700">{filter.description}</p>
</button>
)
}

const AddFilter = ({ dispatch }: { dispatch: React.Dispatch<filterDispatchType> }) => {
const [isOpen, setIsOpen] = useState(false)
const arrowRef = useRef(null)
Expand All @@ -65,28 +109,27 @@ const AddFilter = ({ dispatch }: { dispatch: React.Dispatch<filterDispatchType>
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role])

return (
<div onClick={() => setIsOpen(true)} className="flex cursor-pointer items-center text-sm" ref={refs.setReference} {...getReferenceProps()}>
<LucidePlus size={16} strokeWidth={1} />
<span className="text-gray-600">Añadir filtro</span>
<div onClick={() => setIsOpen(true)} className="text-sm" ref={refs.setReference} {...getReferenceProps()}>
<span className="flex cursor-pointer items-center text-gray-600">
<LucidePlus size={16} strokeWidth={1} />
Añadir filtro
</span>
{isOpen && (
<FloatingFocusManager context={context} modal={false}>
<div
ref={refs.setFloating}
style={floatingStyles}
className="z-50 min-w-48 rounded-md border border-gray-300 bg-white px-1 py-2 shadow-lg focus-visible:outline-none"
className="z-50 w-64 rounded-md border border-gray-300 bg-white p-2 shadow-lg focus-visible:outline-none"
{...getFloatingProps()}
>
<FloatingArrow fill="white" strokeWidth={1} stroke="#d1d5db" ref={arrowRef} context={context} />
<h2 className="p-1 text-sm font-semibold">Añadir filtro</h2>
<h2 className="p-1 text-sm font-semibold">Filtros básicos</h2>
{possibleFilters.map((filter) => (
<button
key={filter.name}
className="flex w-full items-center gap-2 rounded-md p-1 hover:bg-black/5"
onClick={() => dispatch({ type: 'ADD_FILTER', payload: { component: filter.component } })}
>
{filter.icon && <filter.icon size={12} />}
<span>{filter.name}</span>
</button>
<NewFilterButton key={filter.name} filter={filter} dispatch={dispatch} />
))}
<h2 className="p-1 text-sm font-semibold">Filtros avanzados</h2>
{advancedFilters.map((filter) => (
<NewFilterButton key={filter.name} filter={filter} dispatch={dispatch} />
))}
</div>
</FloatingFocusManager>
Expand All @@ -95,4 +138,4 @@ const AddFilter = ({ dispatch }: { dispatch: React.Dispatch<filterDispatchType>
)
}

export default AddFilter
export default AddFilter
62 changes: 62 additions & 0 deletions src/components/filters/BoolNOTFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, { useEffect, useReducer } from 'react'
import { filterProps, filterReducer, filterType } from './filterReducer'
import BaseFilter from './BaseFilter'
import { LucideMerge, LucideTrash2 } from 'lucide-react'
import { Incident } from '@/types'
import AddFilter from './AddFilter'

/**
* Represents a filter that itself contains filters.
* It manipulates the operations of the filters it contains using a NOT operation, and
* passes that operation to the parent dispatcher.
*/
const BoolNOTFilter: React.FC<filterProps> = ({ id, data, dispatch }) => {
const [internalFilters, dispatchInternalFilters] = useReducer(filterReducer, { index: 0, filters: [] })
const updateParent = (state: { filters: filterType[] }) => {
let notOperation = (incident: Incident) => state.filters.every((filter) => (filter.operation ? !filter.operation(incident) : true))
if (state.filters.length === 0) {
notOperation = () => true
}

dispatch({
type: 'UPDATE_FILTER',
payload: {
id: id,
operation: notOperation,
},
})
}
useEffect(() => {
updateParent(internalFilters)
}, [internalFilters])
const removeThisFilter = () => {
dispatch({ type: 'REMOVE_FILTER', payload: { id } })
}

return (
<BaseFilter icon={<LucideMerge />} text={`NO (${internalFilters.filters.length} filtros)`}>
<button onClick={removeThisFilter} className="absolute right-2 top-1 h-4 w-4 text-red-600" title="Eliminar Filtro">
<LucideTrash2 size={20} />
</button>
<br />
<div className="flex max-w-[50vw] flex-wrap gap-2 px-2">
{internalFilters.filters.map((filter) => {
const FilterComponent = filter.component
return (
<FilterComponent
key={filter.id}
id={filter.id}
data={data}
dispatch={dispatchInternalFilters}
operation={filter.operation}
state={filter.state}
/>
)
})}
<AddFilter dispatch={dispatchInternalFilters} />
</div>
</BaseFilter>
)
}

export default BoolNOTFilter
62 changes: 62 additions & 0 deletions src/components/filters/BoolORFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, { useEffect, useReducer } from 'react'
import { filterProps, filterReducer, filterType } from './filterReducer'
import BaseFilter from './BaseFilter'
import { LucideMerge, LucideTrash2 } from 'lucide-react'
import { Incident } from '@/types'
import AddFilter from './AddFilter'

/**
* Represents a filter that itself contains filters.
* It manipulates the operations of the filters it contains using an OR operation, and
* passes that operation to the parent dispatcher.
*/
const BoolORFilter: React.FC<filterProps> = ({ id, data, dispatch }) => {
const [internalFilters, dispatchInternalFilters] = useReducer(filterReducer, { index: 0, filters: [] })
const updateParent = (state: { filters: filterType[] }) => {
let orOperation = (incident: Incident) => state.filters.some((filter) => (filter.operation ? filter.operation(incident) : false))
if (state.filters.length === 0) {
orOperation = () => true
}

dispatch({
type: 'UPDATE_FILTER',
payload: {
id: id,
operation: orOperation,
},
})
}
const removeThisFilter = () => {
dispatch({ type: 'REMOVE_FILTER', payload: { id } })
}
useEffect(() => {
updateParent(internalFilters)
}, [internalFilters])

return (
<BaseFilter icon={<LucideMerge />} text={`O (${internalFilters.filters.length} filtros)`}>
<button onClick={removeThisFilter} className="absolute right-2 top-1 h-4 w-4 text-red-600" title="Eliminar Filtro">
<LucideTrash2 size={20} />
</button>
<br />
<div className="flex max-w-[50vw] flex-wrap gap-2 px-2">
{internalFilters.filters.map((filter) => {
const FilterComponent = filter.component
return (
<FilterComponent
key={filter.id}
id={filter.id}
data={data}
dispatch={dispatchInternalFilters}
operation={filter.operation}
state={filter.state}
/>
)
})}
<AddFilter dispatch={dispatchInternalFilters} />
</div>
</BaseFilter>
)
}

export default BoolORFilter
21 changes: 15 additions & 6 deletions src/components/filters/CountryFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { filterProps } from '@/pages/StatsDashboard'
import { filterProps } from './filterReducer'
import BaseFilter from './BaseFilter'
import { LucideGlobe, LucideTrash2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { Incident } from '@/types'

const CountryFilter = ({ id, data, dispatch }: filterProps) => {
const [hiddenCountries, setHiddenCountries] = useState<string[]>([])
const [hiddenDepartments, setHiddenDepartments] = useState<string[]>([])
const [hiddenMunicipalities, setHiddenMunicipalities] = useState<string[]>([])
const CountryFilter = ({ id, data, state, dispatch }: filterProps) => {
function stateWrapper<T>(key: string, defaultValue: T): [T, (func: (state: T) => T) => void] {
const value = state?.[key] ?? defaultValue
const setValue = (func: (state: T) => T) => {
const prev = state[key] ?? defaultValue
const newState = func(prev)
dispatch({ type: 'UPDATE_FILTER', payload: { id, update: (filter) => ({ ...filter, state: { ...filter.state, [key]: newState } }) } })
}
return [value, setValue]
}
const [hiddenCountries, setHiddenCountries] = stateWrapper<string[]>('hiddenCountries', [])
const [hiddenDepartments, setHiddenDepartments] = stateWrapper<string[]>('hiddenDepartments', [])
const [hiddenMunicipalities, setHiddenMunicipalities] = stateWrapper<string[]>('hiddenMunicipalities', [])

const departmentsByCountry = useMemo(() => {
return Object.entries(data.filterBounds.locations).reduce(
Expand Down
2 changes: 1 addition & 1 deletion src/components/filters/DateFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { filterProps } from '@/pages/StatsDashboard'
import { filterProps } from './filterReducer'
import BaseFilter from './BaseFilter'
import { LucideCalendar, LucideChevronDown, LucideChevronRight, LucideTrash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
Expand Down
2 changes: 1 addition & 1 deletion src/components/filters/LatLongFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { filterProps } from '@/pages/StatsDashboard'
import { filterProps } from './filterReducer'
import BaseFilter from './BaseFilter'
import { LucideMapPin, LucideTrash2 } from 'lucide-react'

Expand Down
59 changes: 59 additions & 0 deletions src/components/filters/filterReducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { DB, Incident } from "@/types";

export type filterDispatchType = { type: 'ADD_FILTER'; payload: Partial<filterType> } |
{ type: 'REMOVE_FILTER'; payload: { id: number } } |
{ type: 'UPDATE_FILTER'; payload: Partial<filterType> | { id: number, update: ((curState: filterType) => filterType) } }

export type filterProps = {
id: number
data: DB
dispatch: React.Dispatch<filterDispatchType>
operation?: (incident: Incident) => boolean
state?: any
}

export type filterType = {
id: number
component: React.FC<filterProps>
state: { [key: string]: any }
operation?: (incident: Incident) => boolean
}

export type filterState = {
index: number
filters: filterType[]
}

/**
* The idea is that the user can add and layer filters on top of each other to filter the incidents.
* Each filter has...
* - an id - a unique identifier for the filter used to remove or update it
* - a component that is displayed in the filter bar, displays the state of its filter, and can be clicked on to modify or remove the filter.
* - a state - some filters might need to store some state, maybe.
* - an operation - a function that takes an incident and returns a boolean. If the incident passes the filter, the function should return true.
*
* The filter bar is a horizontal bar that displays all the filters that have been added. It also has a button to add a new filter from a list.
*/
export const filterReducer = (state: filterState, action: filterDispatchType) => {
let newState = state
switch (action.type) {
case 'ADD_FILTER':
const id = state.index
const newFilter = { id, state: {}, ...action.payload } as filterType
newState = { index: state.index + 1, filters: [...state.filters, newFilter] }
break
case 'REMOVE_FILTER':
newState = { ...state, filters: state.filters.filter((filter) => filter.id !== action.payload.id) }
break
case 'UPDATE_FILTER':
if ('update' in action.payload) {
// @ts-expect-error - For some reason, TypeScript doesn't like this
newState = { ...state, filters: state.filters.map((filter) => filter.id === action.payload.id ? action.payload.update(filter) : filter) }
} else {
newState = { ...state, filters: state.filters.map((filter) => filter.id === action.payload.id ? { ...filter, ...action.payload } : filter) }
}
break
}
console.log(newState);
return newState
}
Loading