diff --git a/src/components/common/FilterBar.jsx b/src/components/common/FilterBar.jsx index 65f49d8..6f221e1 100644 --- a/src/components/common/FilterBar.jsx +++ b/src/components/common/FilterBar.jsx @@ -1,4 +1,4 @@ -import { Check, Close, FilterList, Tune } from '@mui/icons-material' +import { Check, FilterList, Tune } from '@mui/icons-material' import { Avatar, Badge, @@ -11,6 +11,7 @@ import { } from '@mui/joy' import { useState } from 'react' import BottomSheetModal from './BottomSheetModal' +import ActiveFilterChips from './filter/ActiveFilterChips' /** * Reusable filter bar component. @@ -134,6 +135,47 @@ const FilterBar = ({ const hasActive = activeFilterCount > 0 + const selectableChipSx = { + cursor: 'pointer', + transition: 'all 0.15s ease', + userSelect: 'none', + alignItems: 'center', + '& .MuiChip-startDecorator': { + display: 'flex', + alignItems: 'center', + mr: 0.5, + }, + '& .MuiChip-label': { + lineHeight: 1.2, + }, + '&:hover': { opacity: 0.85 }, + } + + const sectionBadgeChipSx = { + ml: 'auto', + fontSize: '0.7rem', + minHeight: 22, + py: 0.25, + px: 0.75, + alignItems: 'center', + '& .MuiChip-label': { + lineHeight: 1.2, + px: 0, + }, + } + + const modalCountChipSx = { + ml: 0.5, + minHeight: 22, + py: 0.25, + px: 0.75, + alignItems: 'center', + '& .MuiChip-label': { + lineHeight: 1.2, + px: 0, + }, + } + // ── Chip labels for inline bar ───────────────────────────────────────────── const getActiveChipLabel = def => { @@ -223,85 +265,47 @@ const FilterBar = ({ color='primary' size='sm' anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + sx={{ display: 'flex', alignItems: 'center' }} > - {(() => { - const activeChips = filterDefs + ({ def, label: getActiveChipLabel(def) })) .filter(({ label }) => !!label) - const MAX_VISIBLE = 2 - const visible = activeChips.slice(0, MAX_VISIBLE) - const overflow = activeChips.length - MAX_VISIBLE - - return ( - <> - {visible.map(({ def, label }) => ( - { - e.stopPropagation() - onSetFilter(def.id, null) - }} - /> - } - onClick={() => setIsOpen(true)} - sx={{ - py: 0.64, - cursor: 'pointer', transition: 'all 0.15s ease', '&:hover': { opacity: 0.85 } }} - > - {label} - - ))} - - {overflow > 0 && ( - setIsOpen(true)} - sx={{ py: 0.64, cursor: 'pointer', transition: 'all 0.15s ease', '&:hover': { opacity: 0.85 } }} - > - +{overflow} more - - )} - - ) - })()} - - {hasActive && ( - - )} - - {hasActive && resultCount !== undefined && totalCount !== undefined && ( - - {resultCount} / {totalCount} - - )} + .map(({ def, label }) => ({ + key: def.id, + label, + onClear: () => onSetFilter(def.id, null), + }))} + onOpen={() => setIsOpen(true)} + onClearAll={hasActive ? onClearAll : undefined} + resultCount={hasActive ? resultCount : undefined} + totalCount={hasActive ? totalCount : undefined} + maxVisible={2} + chipSize='md' + /> {/* ── Bottom sheet ────────────────────────────────────── */} @@ -313,7 +317,7 @@ const FilterBar = ({ Filters {hasActive && ( - + {activeFilterCount} )} @@ -356,20 +360,20 @@ const FilterBar = ({ {/* active badge in header */} {def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && ( - + {activeFilters[def.id].length} selected )} {def.type === 'single-select' && activeFilters[def.id] != null && (() => { const opt = def.options?.find(o => o.value === activeFilters[def.id]) return opt ? ( - + {opt.label} ) : null })()} {def.type === 'date-range' && getActiveChipLabel(def) && ( - + {getActiveChipLabel(def)} )} @@ -393,7 +397,7 @@ const FilterBar = ({ ) : (opt.icon ?? null) } onClick={() => handleMultiToggle(def.id, opt.value)} - sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }} + sx={selectableChipSx} > {opt.label} @@ -420,7 +424,7 @@ const FilterBar = ({ ) : (opt.icon ?? null) } onClick={() => handleSingleToggle(def.id, opt.value)} - sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }} + sx={selectableChipSx} > {opt.label} @@ -436,7 +440,7 @@ const FilterBar = ({ color={activeFilters[def.id] ? 'primary' : 'neutral'} startDecorator={activeFilters[def.id] ? : null} onClick={() => handleBoolToggle(def.id)} - sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }} + sx={selectableChipSx} > {def.label} @@ -458,7 +462,7 @@ const FilterBar = ({ color={isSelected ? 'primary' : 'neutral'} startDecorator={isSelected ? : null} onClick={() => handleDateRangePreset(def.id, preset.value)} - sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }} + sx={selectableChipSx} > {preset.label} diff --git a/src/components/common/filter/ActiveFilterChips.jsx b/src/components/common/filter/ActiveFilterChips.jsx new file mode 100644 index 0000000..4e06220 --- /dev/null +++ b/src/components/common/filter/ActiveFilterChips.jsx @@ -0,0 +1,131 @@ +import { Close } from '@mui/icons-material' +import { Box, Button, Chip, Typography } from '@mui/joy' + +const ActiveFilterChips = ({ + chips = [], + onOpen, + onClearAll, + resultCount, + totalCount, + maxVisible = 2, + chipSize = 'md', + clearButtonSize = 'sm', + clearButtonSx, + containerSx, + chipSx, + overflowChipSx, + resultSx, +}) => { + if (!chips.length) { + return null + } + + const visible = chips.slice(0, maxVisible) + const overflow = chips.length - maxVisible + + return ( + + {visible.map(({ key, label, onClear, color = 'primary' }) => ( + { + e.stopPropagation() + onClear?.() + }} + /> + } + onClick={onOpen} + sx={{ + cursor: 'pointer', + flexShrink: 0, + transition: 'all 0.15s ease', + alignItems: 'center', + '& .MuiChip-endDecorator': { + display: 'flex', + alignItems: 'center', + ml: 0.5, + }, + '& .MuiChip-label': { + lineHeight: 1.2, + }, + '&:hover': { opacity: 0.85 }, + ...chipSx, + }} + > + {label} + + ))} + + {overflow > 0 && ( + + +{overflow} more + + )} + + {resultCount != null && totalCount != null && ( + + {resultCount} / {totalCount} + + )} + + {onClearAll && ( + + )} + + ) +} + +export default ActiveFilterChips diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 3ec6ae8..a0f63f1 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -951,9 +951,11 @@ const MyChores = () => { labels={userLabels || []} projects={projectsWithDefault} tempFilter={tempFilter} + tempFilterMeta={tempFilterMeta} applyTempFilter={applyTempFilter} clearTempFilter={clearTempFilter} saveFilter={saveFilter} + updateFilter={updateFilter} onFilterSaved={name => showSuccess({ title: 'Filter Saved', diff --git a/src/views/Chores/components/ChoreToolbarPrototype.jsx b/src/views/Chores/components/ChoreToolbarPrototype.jsx index 42a7fe4..9b2c3d1 100644 --- a/src/views/Chores/components/ChoreToolbarPrototype.jsx +++ b/src/views/Chores/components/ChoreToolbarPrototype.jsx @@ -23,9 +23,7 @@ import { Check, CheckBox, CheckBoxOutlineBlank, - Close, FilterList, - FolderOpen, Save, Sort, Tune, @@ -46,17 +44,23 @@ import { MenuItem, Typography, } from '@mui/joy' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import BottomSheetModal from '../../../components/common/BottomSheetModal' +import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips' import { Z_INDEX } from '../../../constants/zIndex' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import { FILTER_COLORS } from '../../../utils/Colors' +import Priorities from '../../../utils/Priorities' import FilterBuilderContent, { + CHORE_STATUSES, + DUE_DATE_OPTIONS, + POINTS_OPERATORS, conditionsToSelections, defaultSelections, selectionsToConditions, } from './FilterBuilderContent' import SearchBar from './SearchBar' +import ProjectSelector from '../../components/ProjectSelector' // ─── sub-components for the Display sheet ──────────────────────────────────── @@ -133,9 +137,11 @@ const OptionChips = ({ options, selected, multi, onToggle }) => ( * labels – user labels for Labels section * projects – projects list (projectsWithDefault) for Projects section + Display sheet * tempFilter – current temp filter object { conditions, operator } or null + * tempFilterMeta – metadata for temp filter, including saved-filter edit source when applicable * applyTempFilter – (filter) => void — called immediately as selections change * clearTempFilter – () => void * saveFilter – (filterData) => Promise — saves as a named filter + * updateFilter – (filterId, filterData) => Promise — updates an existing saved filter * onFilterSaved – (name) => void — called after successful save (for notifications) * * -- Result counts -- @@ -176,9 +182,11 @@ const ChoreToolbar = ({ labels = [], projects = [], tempFilter, + tempFilterMeta, applyTempFilter, clearTempFilter, saveFilter, + updateFilter, onFilterSaved, // result counts resultCount, @@ -219,7 +227,9 @@ const ChoreToolbar = ({ const [savingFilter, setSavingFilter] = useState(false) const [saveFilterName, setSaveFilterName] = useState('') const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null) + const [editingSavedFilter, setEditingSavedFilter] = useState(null) const saveMenuRef = useRef(null) + const activeConditions = selectionsToConditions(localSelections) // ── badge counts ───────────────────────────────────────────────────────────── @@ -232,46 +242,166 @@ const ChoreToolbar = ({ const inlineChips = [] - if (savedFilterActive) { - const sf = savedFilters.find(f => f.id === activeFilterId) - if (sf) { - inlineChips.push({ - key: '__saved', - label: sf.name, - onClear: () => onSavedFilterClick?.(activeFilterId), - }) + const getConditionChipLabel = condition => { + if (!condition?.type) return 'Filter' + + const typeLabels = { + assignee: 'Assignee', + createdBy: 'Created By', + status: 'Status', + priority: 'Priority', + label: 'Labels', + project: 'Project', + dueDate: 'Due Date', + points: 'Points', } - } else if (tempConditionCount > 0) { + + const typeLabel = typeLabels[condition.type] || 'Filter' + const prefix = condition.operator === 'isNot' ? 'Not ' : '' + + if (condition.type === 'dueDate') { + const dueDateLabel = + DUE_DATE_OPTIONS.find(o => o.value === condition.operator)?.label || + 'Custom' + return `${typeLabel}: ${dueDateLabel}` + } + + if (condition.type === 'points') { + const pointsOp = + POINTS_OPERATORS.find(o => o.value === condition.operator)?.label || + condition.operator || + '' + return `${typeLabel} ${pointsOp} ${condition.value ?? 0}` + } + + const rawValues = Array.isArray(condition.value) + ? condition.value + : condition.value != null + ? [condition.value] + : [] + + const resolveLabel = value => { + if (condition.type === 'assignee' || condition.type === 'createdBy') { + const member = members.find(m => m.userId === value) + return member?.displayName || member?.username || String(value) + } + if (condition.type === 'status') { + return CHORE_STATUSES.find(s => s.value === value)?.label || String(value) + } + if (condition.type === 'priority') { + return Priorities.find(p => p.value === value)?.name || String(value) + } + if (condition.type === 'label') { + return labels.find(l => l.id === value)?.name || String(value) + } + if (condition.type === 'project') { + if (value === 'default') return 'Default Project' + return projects.find(p => p.id === value)?.name || String(value) + } + return String(value) + } + + if (rawValues.length === 0) { + return `${prefix}${typeLabel}` + } + + if (rawValues.length === 1) { + return `${prefix}${typeLabel}: ${resolveLabel(rawValues[0])}` + } + + return `${prefix}${typeLabel} (${rawValues.length})` + } + + const clearConditionAtIndex = index => { + const nextConditions = (tempFilter?.conditions || []).filter( + (_condition, conditionIndex) => conditionIndex !== index, + ) + + if (nextConditions.length === 0) { + setLocalSelections(defaultSelections()) + clearTempFilter?.() + return + } + + const nextFilter = { + ...tempFilter, + operator: tempFilter?.operator || 'AND', + conditions: nextConditions, + } + + setLocalSelections(conditionsToSelections(nextConditions)) + applyTempFilter?.(nextFilter) + } + + const activeSavedFilter = savedFilterActive + ? savedFilters.find(f => f.id === activeFilterId) + : null + + const activeChipConditions = savedFilterActive + ? activeSavedFilter?.conditions || [] + : tempFilter?.conditions || [] + + activeChipConditions.forEach((condition, index) => { inlineChips.push({ - key: '__temp', - label: `${tempConditionCount} condition${tempConditionCount !== 1 ? 's' : ''}`, + key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`, + label: getConditionChipLabel(condition), onClear: () => { - setLocalSelections(defaultSelections()) - clearTempFilter?.() + if (savedFilterActive) { + onSavedFilterClick?.(activeFilterId) + return + } + clearConditionAtIndex(index) }, }) - } + }) // ── open filter sheet ──────────────────────────────────────────────────────── const openFilterSheet = () => { if (tempFilter?.conditions?.length > 0) { setLocalSelections(conditionsToSelections(tempFilter.conditions)) + if (tempFilterMeta?.sourceFilterId) { + const sourceFilter = + savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) || + null + setEditingSavedFilter( + sourceFilter || + (tempFilterMeta.sourceFilterId + ? { + id: tempFilterMeta.sourceFilterId, + name: tempFilterMeta.sourceFilterName, + description: tempFilterMeta.sourceFilterDescription, + color: tempFilterMeta.sourceFilterColor, + } + : null), + ) + } else { + setEditingSavedFilter(null) + } } else if (activeFilterId) { const sf = savedFilters.find(f => f.id === activeFilterId) + setEditingSavedFilter(sf || null) setLocalSelections( sf?.conditions ? conditionsToSelections(sf.conditions) : defaultSelections(), ) } else { + setEditingSavedFilter(null) setLocalSelections(defaultSelections()) } setSavingFilter(false) setSaveFilterName('') + setSaveMenuAnchorEl(null) setFilterSheetOpen(true) } + useEffect(() => { + if (!filterSheetOpen || savingFilter || activeConditions.length === 0) { + setSaveMenuAnchorEl(null) + } + }, [filterSheetOpen, savingFilter, activeConditions.length]) + // ── selection changes → apply temp filter immediately ──────────────────────── const handleSelectionsChange = updater => { @@ -279,7 +409,20 @@ const ChoreToolbar = ({ const next = typeof updater === 'function' ? updater(prev) : updater const conditions = selectionsToConditions(next) if (conditions.length > 0) { - applyTempFilter?.({ conditions, operator: 'AND' }) + applyTempFilter?.( + { conditions, operator: 'AND' }, + editingSavedFilter + ? { + name: editingSavedFilter.name, + description: editingSavedFilter.description, + sourceFilterId: editingSavedFilter.id, + sourceFilterName: editingSavedFilter.name, + sourceFilterDescription: editingSavedFilter.description, + sourceFilterColor: editingSavedFilter.color, + isEditingSavedFilter: true, + } + : null, + ) } else { clearTempFilter?.() } @@ -310,6 +453,31 @@ const ChoreToolbar = ({ setFilterSheetOpen(false) } + const handleUpdateFilter = () => { + if (!editingSavedFilter?.id || !updateFilter) return + + const conditions = selectionsToConditions(localSelections) + if (conditions.length === 0) return + + updateFilter( + editingSavedFilter.id, + { + name: editingSavedFilter.name, + description: editingSavedFilter.description || '', + color: editingSavedFilter.color, + conditions, + operator: 'AND', + }, + )?.then?.(() => { + clearTempFilter?.() + onSavedFilterClick?.(editingSavedFilter.id) + onFilterSaved?.(editingSavedFilter.name) + }) + + setSaveMenuAnchorEl(null) + setFilterSheetOpen(false) + } + // ── display sheet helpers ──────────────────────────────────────────────────── const filterActive = activeFilterId != null || tempConditionCount > 0 @@ -341,13 +509,6 @@ const ChoreToolbar = ({ { value: 'calendar', label: 'Calendar', icon: }, ] - // Only show project in Display sheet when no advanced filter is active - const showProjectInDisplay = - !filterActive && - projects.filter(p => p.id !== 'default').length > 0 - - const activeConditions = selectionsToConditions(localSelections) - return ( <> {/* ── Row 1: main toolbar ─────────────────────────────────────────────── */} @@ -387,6 +548,15 @@ const ChoreToolbar = ({ + {/* Project selector */} + {!filterActive && projects.filter(p => p.id !== 'default').length > 0 && ( + + )} + {/* Display button — View + Group combined */} { + setLocalSelections(defaultSelections()) + onClearAllFilters?.() }} - > - {inlineChips.map(({ key, label, onClear }) => ( - { - e.stopPropagation() - onClear() - }} - /> - } - onClick={openFilterSheet} - sx={{ cursor: 'pointer', flexShrink: 0 }} - > - {label} - - ))} - - {resultCount != null && totalCount != null && ( - - {resultCount} / {totalCount} - - )} - - - + resultCount={resultCount} + totalCount={totalCount} + maxVisible={2} + chipSize='md' + clearButtonSize='sm' + clearButtonSx={{ color: 'text.secondary' }} + /> )} {/* ── Unified Filter bottom sheet ─────────────────────────────────────── */} setFilterSheetOpen(false)} + onClose={() => { + setSaveMenuAnchorEl(null) + setFilterSheetOpen(false) + }} maxHeight='92vh' title={ @@ -557,6 +682,7 @@ const ChoreToolbar = ({ disabled={!hasAnyActive && activeConditions.length === 0} onClick={() => { setLocalSelections(defaultSelections()) + setSaveMenuAnchorEl(null) onClearAllFilters?.() setFilterSheetOpen(false) }} @@ -568,7 +694,10 @@ const ChoreToolbar = ({ <>