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 } }