diff --git a/src/components/filter/CategorySelect.js b/src/components/filter/CategorySelect.js
new file mode 100644
index 000000000..bfd400d61
--- /dev/null
+++ b/src/components/filter/CategorySelect.js
@@ -0,0 +1,54 @@
+import styled from 'styled-components/macro'
+
+const TagContainer = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 12px;
+`
+
+const Tag = styled.div`
+ display: inline-flex;
+ align-items: center;
+ padding: 6px 12px;
+ background: ${({ theme, selected }) =>
+ selected ? theme.transparentOrange : ''};
+ border: 1px solid;
+ color: ${({ theme, selected }) =>
+ selected ? theme.secondaryText : theme.text};
+ border-radius: 16px;
+ cursor: pointer;
+ transition: all 0.2s;
+
+ &:hover {
+ background: ${({ theme }) => theme.primaryLight};
+ color: ${({ theme }) => theme.white};
+ border-color: ${({ theme }) => theme.primaryLight};
+ }
+`
+
+const CategorySelect = ({ categories, onChange }) => {
+ const toggleCategory = (category) => {
+ const newCategories = { ...categories }
+ newCategories[category] = !newCategories[category]
+ onChange(newCategories)
+ }
+
+ return (
+
+ {Object.entries(categories).map(([category, enabled]) => (
+ toggleCategory(category)}
+ >
+ {category === 'noCategory'
+ ? 'No Category'
+ : category.charAt(0).toUpperCase() + category.slice(1)}
+
+ ))}
+
+ )
+}
+
+export default CategorySelect
diff --git a/src/components/filter/Filter.js b/src/components/filter/Filter.js
index c2fd3ea6f..5b49b6655 100644
--- a/src/components/filter/Filter.js
+++ b/src/components/filter/Filter.js
@@ -5,12 +5,14 @@ import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components/macro'
import {
+ categoryChanged,
invasiveChanged,
muniChanged,
selectionChanged,
} from '../../redux/viewChange'
import buildSelectTree from '../../utils/buildSelectTree'
import Input from '../ui/Input'
+import CategorySelect from './CategorySelect'
import FilterButtons from './FilterButtons'
import LabeledCheckbox from './LabeledCheckbox'
import RCTreeSelectSkeleton from './RCTreeSelectSkeleton'
@@ -58,7 +60,7 @@ const Filter = () => {
)
const dispatch = useDispatch()
- const { countsById, types, muni, invasive } = useSelector(
+ const { countsById, types, muni, invasive, categories } = useSelector(
(state) => state.filter,
)
@@ -71,8 +73,11 @@ const Filter = () => {
showOnlyOnMap,
searchValue,
types,
+ Object.entries(categories)
+ .filter(([_, enabled]) => enabled)
+ .map(([category]) => category),
),
- [typesAccess, countsById, showOnlyOnMap, searchValue, types],
+ [typesAccess, countsById, showOnlyOnMap, searchValue, types, categories],
)
const { t } = useTranslation()
@@ -80,6 +85,16 @@ const Filter = () => {
<>
{t('glossary.types')}
+
{
+ Object.entries(newCategories).forEach(([category, enabled]) => {
+ if (categories[category] !== enabled) {
+ dispatch(categoryChanged(category, enabled))
+ }
+ })
+ }}
+ />
setSearchValueDebounced(e.target.value)}
placeholder={t('type')}
diff --git a/src/components/filter/TreeSelectView.js b/src/components/filter/TreeSelectView.js
index 97404ef8c..75af64751 100644
--- a/src/components/filter/TreeSelectView.js
+++ b/src/components/filter/TreeSelectView.js
@@ -113,54 +113,58 @@ const TreeSelectView = ({
const isExpanded = Boolean(expandedNodes.has(node.id) | isDisabled)
return (
-
-
-
- {node.children.length > 0 ? (
- handleToggle(node.id)}
+ node.isVisible && (
+
+
+
+ {node.children.some((c) => c.isVisible) ? (
+ handleToggle(node.id)}
+ disabled={isDisabled}
+ >
+
+
+ ) : (
+
+ )}
+ {
+ if (el) {
+ el.indeterminate = node.isIndeterminate
+ }
+ }}
+ onChange={() => !isDisabled && handleCheckboxChange(node)}
disabled={isDisabled}
- >
-
-
- ) : (
-
- )}
- {
- if (el) {
- el.indeterminate = node.isIndeterminate
- }
- }}
- onChange={() => !isDisabled && handleCheckboxChange(node)}
- disabled={isDisabled}
- />
-
-
- {node.commonName && (
- {node.commonName}
- )}
- {node.scientificName && (
-
- {node.scientificName}
-
- )}
- ({node.count})
-
-
- {isExpanded &&
- node.children.map((child) => renderNode(child, level + 1))}
-
+ />
+
+
+ {node.commonName && (
+
+ {node.commonName}
+
+ )}
+ {node.scientificName && (
+
+ {node.scientificName}
+
+ )}
+ ({node.count})
+
+
+ {isExpanded &&
+ node.children.map((child) => renderNode(child, level + 1))}
+
+ )
)
}
diff --git a/src/components/filter/components/filter/CategorySelect.js b/src/components/filter/components/filter/CategorySelect.js
new file mode 100644
index 000000000..fe3f8c4a0
--- /dev/null
+++ b/src/components/filter/components/filter/CategorySelect.js
@@ -0,0 +1,50 @@
+import styled from 'styled-components/macro'
+
+const TagContainer = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 12px;
+`
+
+const Tag = styled.div`
+ display: inline-flex;
+ align-items: center;
+ padding: 6px 12px;
+ background: ${({ theme, selected }) =>
+ selected ? theme.primary : theme.background};
+ color: ${({ theme, selected }) => (selected ? theme.white : theme.text)};
+ border: 1px solid ${({ theme }) => theme.border};
+ border-radius: 16px;
+ cursor: pointer;
+ transition: all 0.2s;
+
+ &:hover {
+ background: ${({ theme }) => theme.primaryLight};
+ color: ${({ theme }) => theme.white};
+ }
+`
+
+const CategorySelect = ({ categories, onChange }) => {
+ const toggleCategory = (category) => {
+ const newCategories = { ...categories }
+ newCategories[category] = !newCategories[category]
+ onChange(newCategories)
+ }
+
+ return (
+
+ {Object.entries(categories).map(([category, enabled]) => (
+ toggleCategory(category)}
+ >
+ {category.charAt(0).toUpperCase() + category.slice(1)}
+
+ ))}
+
+ )
+}
+
+export default CategorySelect
diff --git a/src/redux/filterSlice.js b/src/redux/filterSlice.js
index 1bd63fc8f..e9181e8e6 100644
--- a/src/redux/filterSlice.js
+++ b/src/redux/filterSlice.js
@@ -47,6 +47,13 @@ export const filterSlice = createSlice({
invasive: false,
isLoading: false,
countsById: {},
+ categories: {
+ forager: true,
+ freegan: true,
+ grafter: false,
+ honeybee: false,
+ noCategory: false,
+ },
},
reducers: {
openFilter: (state) => {
@@ -55,6 +62,10 @@ export const filterSlice = createSlice({
closeFilter: (state) => {
state.isOpenInMobileLayout = false
},
+ categoryChanged: (state, action) => {
+ const { category, value } = action.payload
+ state.categories[category] = value
+ },
},
extraReducers: {
[fetchFilterCounts.pending]: (state) => {
@@ -79,13 +90,11 @@ export const filterSlice = createSlice({
[fetchAndLocalizeTypes.fulfilled]: (state, action) => {
const typesAccess = action.payload
- state.types = typesAccess
- .selectableTypesWithCategories('forager', 'freegan')
- .map((t) => t.id)
+ state.types = typesAccess.selectableTypes().map((t) => t.id)
},
},
})
-export const { openFilter, closeFilter } = filterSlice.actions
+export const { openFilter, closeFilter, categoryChanged } = filterSlice.actions
export default filterSlice.reducer
diff --git a/src/redux/mapSlice.js b/src/redux/mapSlice.js
index f9666119d..3748667ab 100644
--- a/src/redux/mapSlice.js
+++ b/src/redux/mapSlice.js
@@ -12,13 +12,30 @@ export const fetchMapLocations = createAsyncThunk(
'map/fetchMapLocations',
async (_, { getState }) => {
const state = getState()
- const { types, muni, invasive } = state.filter
+ const { types, muni, invasive, categories } = state.filter
const { lastMapView } = state.viewport
+ const { typesAccess } = state.type
+
if (lastMapView) {
const { bounds, zoom, center: _ } = lastMapView
+
+ const filteredTypes = types?.filter((typeId) => {
+ const type = typesAccess.getType(typeId)
+ return type.categories.length === 0
+ ? categories.noCategory
+ : type.categories.some((cat) => categories[cat])
+ })
+
return await getLocations(
selectParams(
- { types, muni, invasive, bounds, zoom, center: undefined },
+ {
+ types: filteredTypes,
+ muni,
+ invasive,
+ bounds,
+ zoom,
+ center: undefined,
+ },
{ limit: 250 },
),
)
@@ -32,13 +49,21 @@ export const fetchMapClusters = createAsyncThunk(
'map/fetchMapClusters',
async (_, { getState }) => {
const state = getState()
- const { types, muni, invasive } = state.filter
+ const { types, muni, invasive, categories } = state.filter
const { lastMapView } = state.viewport
+ const { typesAccess } = state.type
if (lastMapView) {
const { bounds, zoom, center: _ } = lastMapView
+
+ const filteredTypes = types?.filter((typeId) => {
+ const type = typesAccess.getType(typeId)
+ return type.categories.length === 0
+ ? categories.noCategory
+ : type.categories.some((cat) => categories[cat])
+ })
return await getClusters(
selectParams({
- types,
+ types: filteredTypes,
muni,
invasive,
bounds,
diff --git a/src/redux/viewChange.js b/src/redux/viewChange.js
index 16d2a6eb1..a68b39435 100644
--- a/src/redux/viewChange.js
+++ b/src/redux/viewChange.js
@@ -37,3 +37,16 @@ export const selectionChanged = (types) => (dispatch) => {
dispatch(updateSelection({ types }))
dispatch(fetchLocations())
}
+
+export const categoryChanged = (category, value) => (dispatch, getState) => {
+ const currentCategories = getState().filter.categories
+ dispatch(
+ updateSelection({
+ categories: {
+ ...currentCategories,
+ [category]: value,
+ },
+ }),
+ )
+ dispatch(fetchLocations())
+}
diff --git a/src/utils/buildSelectTree.ts b/src/utils/buildSelectTree.ts
index 6144109f2..cfe178037 100644
--- a/src/utils/buildSelectTree.ts
+++ b/src/utils/buildSelectTree.ts
@@ -9,9 +9,11 @@ interface RenderTreeNode {
count: number
searchLabel: string
children: RenderTreeNode[]
+ categories: string[]
isSelected: boolean
isIndeterminate: boolean
isDisabled: boolean
+ isVisible: boolean
}
class SelectTreeBuilder {
@@ -21,6 +23,7 @@ class SelectTreeBuilder {
private searchValue: string
private selectedTypes: number[]
private visibleTypeIds: Set
+ private enabledCategories: string[]
constructor(
typesAccess: TypesAccess,
@@ -28,6 +31,7 @@ class SelectTreeBuilder {
showOnlyOnMap: boolean,
searchValue: string,
selectedTypes: number[],
+ enabledCategories: string[],
) {
this.typesAccess = typesAccess
this.countsById = countsById
@@ -35,6 +39,7 @@ class SelectTreeBuilder {
this.searchValue = searchValue.toLowerCase()
this.selectedTypes = selectedTypes
this.visibleTypeIds = new Set()
+ this.enabledCategories = enabledCategories
}
private isCultivarWithParentInSelection(
@@ -56,24 +61,37 @@ class SelectTreeBuilder {
)
}
+ private matchesEnabledCategories(type: LocalizedType): boolean {
+ return type.categories.length === 0
+ ? this.enabledCategories.includes('noCategory')
+ : type.categories.some((cat) => this.enabledCategories.includes(cat))
+ }
+
buildRenderTree(): RenderTreeNode[] {
const rootNodes = this.typesAccess.localizedTypes.filter(
(type) => type.parentId === 0,
)
return rootNodes
.map((node) => this.buildNode(node, null, false))
- .filter((node): node is RenderTreeNode => node !== null)
+ .filter((node) => node.isVisible)
}
private buildNode(
type: LocalizedType,
parent: RenderTreeNode | null = null,
parentMatchesSearch: boolean = false,
- ): RenderTreeNode | null {
+ ): RenderTreeNode {
+ const countInEnabledCategories = this.getAggregatedCountInEnabledCategories(
+ type.id,
+ )
+ const matchesCategories = this.matchesEnabledCategories(type)
+
+ const isVisible = this.showOnlyOnMap
+ ? countInEnabledCategories > 0
+ : matchesCategories
const count = this.getAggregatedCount(type.id)
- if (this.showOnlyOnMap && count === 0) {
- return null
- }
+ const ownCount = this.getCount(type.id)
+
const searchLabel = `${type.commonName} ${type.scientificName}`.trim()
const matchesSearch =
!this.searchValue || searchLabel.toLowerCase().includes(this.searchValue)
@@ -82,27 +100,34 @@ class SelectTreeBuilder {
id: type.id,
parent,
commonName: type.commonName,
+ isVisible,
scientificName: type.scientificName,
count,
searchLabel,
children: [],
+ categories: type.categories,
isSelected: this.selectedTypes.includes(type.id),
isIndeterminate: false,
isDisabled:
- this.searchValue !== '' && !matchesSearch && !parentMatchesSearch,
+ (this.searchValue !== '' && !matchesSearch && !parentMatchesSearch) ||
+ (ownCount < count && countInEnabledCategories < count),
}
- const children = (this.typesAccess.childrenById[type.id] || [])
- .map((childId) =>
+ const children = (this.typesAccess.childrenById[type.id] || []).map(
+ (childId) =>
this.buildNode(this.typesAccess.getType(childId), node, matchesSearch),
- )
- .filter((child): child is RenderTreeNode => child !== null)
+ )
- if (!matchesSearch && !parentMatchesSearch && children.length === 0) {
- return null
+ if (
+ !matchesSearch &&
+ !parentMatchesSearch &&
+ children.every((c) => !c.isVisible)
+ ) {
+ node.isVisible = false
+ return node
}
- if (!node.isDisabled) {
+ if (!node.isDisabled && node.isVisible) {
this.visibleTypeIds.add(type.id)
}
@@ -118,30 +143,36 @@ class SelectTreeBuilder {
? type.scientificName?.substring(cultivarIndex ?? -1)
: type.scientificName
- const ownCount = this.getCount(type.id)
- if (children.length && ownCount > 0 && matchesSearch) {
+ if (
+ children.some((c) => c.isVisible) &&
+ ownCount > 0 &&
+ matchesSearch &&
+ matchesCategories
+ ) {
const childNode: RenderTreeNode = {
...node,
- id: -type.id, // Use negative ID to ensure uniqueness
+ id: -type.id,
parent: node,
count: ownCount,
value: type.id,
children: [],
isSelected: this.selectedTypes.includes(type.id),
isIndeterminate: false,
- isDisabled: !matchesSearch,
+ isDisabled: false,
}
node.children.unshift(childNode)
- } else if (children.length === 0) {
+ this.visibleTypeIds.add(type.id)
+ } else if (!children.some((c) => c.isVisible)) {
node.value = type.id
}
- // Calculate isSelected and isIndeterminate
- if (node.children.length > 0) {
- const allChildrenSelected = node.children.every(
+ const visibleChildren = node.children.filter((c) => c.isVisible)
+
+ if (visibleChildren.length > 0) {
+ const allChildrenSelected = visibleChildren.every(
(child) => child.isSelected,
)
- const someChildrenSelected = node.children.some(
+ const someChildrenSelected = visibleChildren.some(
(child) => child.isSelected || child.isIndeterminate,
)
node.isSelected = allChildrenSelected
@@ -166,6 +197,24 @@ class SelectTreeBuilder {
getVisibleTypes(): number[] {
return Array.from(this.visibleTypeIds)
}
+
+ private getAggregatedCountInEnabledCategories(id: number): number {
+ const type = this.typesAccess.getType(id)
+ let count = 0
+
+ // Only include count if type matches enabled categories
+ if (this.matchesEnabledCategories(type)) {
+ count = this.getCount(id)
+ }
+
+ // Recursively add counts from children
+ const children = this.typesAccess.childrenById[id] || []
+ for (const childId of children) {
+ count += this.getAggregatedCountInEnabledCategories(childId)
+ }
+
+ return count
+ }
}
interface SelectTreeResult {
@@ -179,6 +228,7 @@ function buildSelectTree(
showOnlyOnMap: boolean,
searchValue: string,
selectedTypes: number[],
+ enabledCategories: string[] = [],
): SelectTreeResult {
const builder = new SelectTreeBuilder(
typesAccess,
@@ -186,9 +236,11 @@ function buildSelectTree(
showOnlyOnMap,
searchValue,
selectedTypes,
+ enabledCategories,
)
const tree = builder.buildRenderTree()
const visibleTypeIds = builder.getVisibleTypes()
+
return { tree, visibleTypeIds }
}