From 55d0e2f87786c116d19fe687946cb29825d7dad3 Mon Sep 17 00:00:00 2001 From: Symon Date: Fri, 5 Jun 2026 16:43:45 +0300 Subject: [PATCH] Add negative label filtering for cards --- client/src/actions/labels.js | 11 + .../boards/BoardActions/Filters.jsx | 61 ++-- .../components/labels/LabelChip/LabelChip.jsx | 5 +- .../labels/LabelChip/LabelChip.module.scss | 5 + .../src/components/labels/LabelsStep/Item.jsx | 153 ++++++--- .../labels/LabelsStep/Item.module.scss | 25 ++ .../labels/LabelsStep/LabelsStep.jsx | 317 ++++++++++-------- client/src/constants/ActionTypes.js | 1 + client/src/constants/EntryActionTypes.js | 1 + client/src/constants/Enums.js | 6 + client/src/entry-actions/labels.js | 9 + client/src/models/Board.js | 36 +- client/src/models/Label.js | 6 + client/src/models/List.js | 12 +- client/src/sagas/core/services/cards.js | 5 + client/src/sagas/core/services/labels.js | 14 + client/src/sagas/core/watchers/labels.js | 3 + client/src/selectors/boards.js | 19 ++ client/src/utils/filter-card-labels.js | 20 ++ client/src/utils/filter-card-labels.test.js | 34 ++ server/api/controllers/cards/index.js | 25 ++ server/api/hooks/query-methods/models/Card.js | 23 +- .../models/CardLabelFilters.test.js | 82 +++++ 23 files changed, 638 insertions(+), 235 deletions(-) create mode 100644 client/src/utils/filter-card-labels.js create mode 100644 client/src/utils/filter-card-labels.test.js create mode 100644 server/test/integration/models/CardLabelFilters.test.js diff --git a/client/src/actions/labels.js b/client/src/actions/labels.js index 01b14ce0..a9543d1d 100644 --- a/client/src/actions/labels.js +++ b/client/src/actions/labels.js @@ -200,6 +200,16 @@ const removeLabelFromBoardFilter = (id, boardId, currentListId) => ({ }, }); +const updateLabelFilterInBoard = (id, boardId, mode, currentListId) => ({ + type: ActionTypes.LABEL_FILTER_IN_BOARD_UPDATE, + payload: { + id, + boardId, + mode, + currentListId, + }, +}); + export default { createLabel, createLabelFromCard, @@ -214,4 +224,5 @@ export default { handleLabelFromCardRemove, addLabelToBoardFilter, removeLabelFromBoardFilter, + updateLabelFilterInBoard, }; diff --git a/client/src/components/boards/BoardActions/Filters.jsx b/client/src/components/boards/BoardActions/Filters.jsx index b193a7ed..6995e3f4 100644 --- a/client/src/components/boards/BoardActions/Filters.jsx +++ b/client/src/components/boards/BoardActions/Filters.jsx @@ -20,6 +20,7 @@ import UserAvatar from '../../users/UserAvatar'; import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep'; import LabelChip from '../../labels/LabelChip'; import LabelsStep from '../../labels/LabelsStep'; +import { LabelFilterModes } from '../../../constants/Enums'; import styles from './Filters.module.scss'; @@ -27,6 +28,7 @@ const Filters = React.memo(() => { const board = useSelector(selectors.selectCurrentBoard); const userIds = useSelector(selectors.selectFilterUserIdsForCurrentBoard); const labelIds = useSelector(selectors.selectFilterLabelIdsForCurrentBoard); + const excludedLabelIds = useSelector(selectors.selectFilterExcludedLabelIdsForCurrentBoard); const currentUserId = useSelector(selectors.selectCurrentUserId); const withCurrentUserSelector = useSelector( @@ -48,6 +50,26 @@ const Filters = React.memo(() => { const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef'); + const labelModes = useMemo( + () => ({ + ...labelIds.reduce( + (result, labelId) => ({ + ...result, + [labelId]: LabelFilterModes.INCLUDE, + }), + {}, + ), + ...excludedLabelIds.reduce( + (result, labelId) => ({ + ...result, + [labelId]: LabelFilterModes.EXCLUDE, + }), + {}, + ), + }), + [labelIds, excludedLabelIds], + ); + const cancelSearch = useCallback(() => { debouncedSearch.cancel(); setSearch(''); @@ -84,27 +106,20 @@ const Filters = React.memo(() => { [dispatch], ); - const handleLabelSelect = useCallback( - (labelId) => { - dispatch(entryActions.addLabelToFilterInCurrentBoard(labelId)); - }, - [dispatch], - ); - - const handleLabelDeselect = useCallback( - (labelId) => { - dispatch(entryActions.removeLabelFromFilterInCurrentBoard(labelId)); - }, - [dispatch], - ); - const handleLabelClick = useCallback( ({ currentTarget: { dataset: { id: labelId }, }, }) => { - dispatch(entryActions.removeLabelFromFilterInCurrentBoard(labelId)); + dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.NONE)); + }, + [dispatch], + ); + + const handleLabelModeChange = useCallback( + (labelId, mode) => { + dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, mode)); }, [dispatch], ); @@ -176,14 +191,17 @@ const Filters = React.memo(() => { {labelIds.map((labelId) => ( @@ -191,6 +209,11 @@ const Filters = React.memo(() => { ))} + {excludedLabelIds.map((labelId) => ( + + + + ))} { +const LabelChip = React.memo(({ id, size, isExcluded, onClick }) => { const selectLabelById = useMemo(() => selectors.makeSelectLabelById(), []); const label = useSelector((state) => selectLabelById(state, id)); @@ -33,6 +33,7 @@ const LabelChip = React.memo(({ id, size, onClick }) => { styles.wrapper, !label.name && styles.wrapperNameless, styles[`wrapper${upperFirst(size)}`], + isExcluded && styles.wrapperExcluded, onClick && styles.wrapperHoverable, globalStyles[`background${upperFirst(camelCase(label.color))}`], )} @@ -59,11 +60,13 @@ const LabelChip = React.memo(({ id, size, onClick }) => { LabelChip.propTypes = { id: PropTypes.string.isRequired, size: PropTypes.oneOf(Object.values(Sizes)), + isExcluded: PropTypes.bool, onClick: PropTypes.func, }; LabelChip.defaultProps = { size: Sizes.MEDIUM, + isExcluded: false, onClick: undefined, }; diff --git a/client/src/components/labels/LabelChip/LabelChip.module.scss b/client/src/components/labels/LabelChip/LabelChip.module.scss index 104d59d0..2e12fe18 100644 --- a/client/src/components/labels/LabelChip/LabelChip.module.scss +++ b/client/src/components/labels/LabelChip/LabelChip.module.scss @@ -34,6 +34,11 @@ opacity: 0.75; } + .wrapperExcluded { + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.75); + text-decoration: line-through; + } + /* Sizes */ .wrapperTiny { diff --git a/client/src/components/labels/LabelsStep/Item.jsx b/client/src/components/labels/LabelsStep/Item.jsx index 281cda06..45720f42 100755 --- a/client/src/components/labels/LabelsStep/Item.jsx +++ b/client/src/components/labels/LabelsStep/Item.jsx @@ -14,80 +14,123 @@ import { Draggable } from 'react-beautiful-dnd'; import { Button } from 'semantic-ui-react'; import selectors from '../../../selectors'; -import { BoardMembershipRoles } from '../../../constants/Enums'; +import { BoardMembershipRoles, LabelFilterModes } from '../../../constants/Enums'; import styles from './Item.module.scss'; import globalStyles from '../../../styles.module.scss'; -const Item = React.memo(({ id, index, isActive, onSelect, onDeselect, onEdit }) => { - const selectLabelById = useMemo(() => selectors.makeSelectLabelById(), []); +const NEXT_FILTER_MODE_BY_MODE = { + [LabelFilterModes.NONE]: LabelFilterModes.INCLUDE, + [LabelFilterModes.INCLUDE]: LabelFilterModes.EXCLUDE, + [LabelFilterModes.EXCLUDE]: LabelFilterModes.NONE, +}; - const label = useSelector((state) => selectLabelById(state, id)); +const Item = React.memo( + ({ + id, + index, + isActive, + mode, + isFilterModeEnabled, + onSelect, + onDeselect, + onModeChange, + onEdit, + }) => { + const selectLabelById = useMemo(() => selectors.makeSelectLabelById(), []); - const canEdit = useSelector((state) => { - const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state); - return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR; - }); + const label = useSelector((state) => selectLabelById(state, id)); - const handleToggleClick = useCallback(() => { - if (label.isPersisted) { - if (isActive) { - onDeselect(id); - } else { - onSelect(id); + const canEdit = useSelector((state) => { + const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state); + return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR; + }); + + const handleToggleClick = useCallback(() => { + if (label.isPersisted) { + if (isFilterModeEnabled) { + onModeChange(id, NEXT_FILTER_MODE_BY_MODE[mode]); + } else if (isActive) { + onDeselect(id); + } else { + onSelect(id); + } } - } - }, [id, isActive, onSelect, onDeselect, label.isPersisted]); + }, [ + id, + isActive, + mode, + isFilterModeEnabled, + onSelect, + onDeselect, + onModeChange, + label.isPersisted, + ]); - const handleEditClick = useCallback(() => { - onEdit(id); - }, [id, onEdit]); + const handleEditClick = useCallback(() => { + onEdit(id); + }, [id, onEdit]); - return ( - - {({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => { - const contentNode = ( - // eslint-disable-next-line react/jsx-props-no-spreading -
- {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, + return ( + + {({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => { + const contentNode = ( + // eslint-disable-next-line react/jsx-props-no-spreading +
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} - + {label.name} + + {canEdit && ( +
- ); +
+ ); - return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode; - }} -
- ); -}); + return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode; + }} + + ); + }, +); Item.propTypes = { id: PropTypes.string.isRequired, index: PropTypes.number.isRequired, isActive: PropTypes.bool.isRequired, - onSelect: PropTypes.func.isRequired, - onDeselect: PropTypes.func.isRequired, + mode: PropTypes.oneOf(Object.values(LabelFilterModes)), + isFilterModeEnabled: PropTypes.bool, + onSelect: PropTypes.func, + onDeselect: PropTypes.func, + onModeChange: PropTypes.func, onEdit: PropTypes.func.isRequired, }; +Item.defaultProps = { + mode: LabelFilterModes.NONE, + isFilterModeEnabled: false, + onSelect: undefined, + onDeselect: undefined, + onModeChange: undefined, +}; + export default Item; diff --git a/client/src/components/labels/LabelsStep/Item.module.scss b/client/src/components/labels/LabelsStep/Item.module.scss index e722e01c..de579976 100644 --- a/client/src/components/labels/LabelsStep/Item.module.scss +++ b/client/src/components/labels/LabelsStep/Item.module.scss @@ -34,6 +34,19 @@ } } + .nameFilterMode:before { + bottom: 1px; + content: "○"; + font-size: 18px; + font-weight: normal; + line-height: 36px; + position: absolute; + right: 2px; + text-align: center; + text-shadow: none; + width: 36px; + } + .nameActive:before { bottom: 1px; content: "Г"; @@ -47,6 +60,18 @@ width: 36px; } + .nameExcluded:before { + bottom: 1px; + content: "−"; + font-size: 22px; + font-weight: normal; + line-height: 36px; + position: absolute; + right: 2px; + text-align: center; + width: 36px; + } + .wrapper { display: flex; margin-bottom: 4px; diff --git a/client/src/components/labels/LabelsStep/LabelsStep.jsx b/client/src/components/labels/LabelsStep/LabelsStep.jsx index b6973188..cf4ada92 100755 --- a/client/src/components/labels/LabelsStep/LabelsStep.jsx +++ b/client/src/components/labels/LabelsStep/LabelsStep.jsx @@ -15,7 +15,7 @@ import selectors from '../../../selectors'; import entryActions from '../../../entry-actions'; import { useField, useNestedRef, useSteps } from '../../../hooks'; import DroppableTypes from '../../../constants/DroppableTypes'; -import { BoardMembershipRoles } from '../../../constants/Enums'; +import { BoardMembershipRoles, LabelFilterModes } from '../../../constants/Enums'; import Item from './Item'; import AddStep from './AddStep'; import EditStep from './EditStep'; @@ -28,175 +28,198 @@ const StepTypes = { EDIT: 'EDIT', }; -const LabelsStep = React.memo(({ currentIds, cardId, title, onSelect, onDeselect, onBack }) => { - const labels = useSelector(selectors.selectLabelsForCurrentBoard); +const LabelsStep = React.memo( + ({ + currentIds, + currentModes, + isFilterModeEnabled, + cardId, + title, + onSelect, + onDeselect, + onModeChange, + onBack, + }) => { + const labels = useSelector(selectors.selectLabelsForCurrentBoard); - const canAdd = useSelector((state) => { - const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state); - return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR; - }); - - const dispatch = useDispatch(); - const [t] = useTranslation(); - const [step, openStep, handleBack] = useSteps(); - const [search, handleSearchChange] = useField(''); - const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]); - - const filteredLabels = useMemo( - () => - labels.filter( - (label) => - (label.name && label.name.toLowerCase().includes(cleanSearch)) || - label.color.includes(cleanSearch), - ), - [labels, cleanSearch], - ); - - const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef'); - - const handleDragStart = useCallback(() => { - document.body.classList.add(globalStyles.dragging); - }, []); - - const handleDragEnd = useCallback( - ({ draggableId, source, destination }) => { - document.body.classList.remove(globalStyles.dragging); - - if (!destination || source.index === destination.index) { - return; - } - - dispatch(entryActions.moveLabel(draggableId, destination.index)); - }, - [dispatch], - ); - - const handleAddClick = useCallback(() => { - openStep(StepTypes.ADD); - }, [openStep]); - - const handleEdit = useCallback( - (id) => { - openStep(StepTypes.EDIT, { - id, - }); - }, - [openStep], - ); - - useEffect(() => { - searchFieldRef.current.focus({ - preventScroll: true, + const canAdd = useSelector((state) => { + const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state); + return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR; }); - }, [searchFieldRef]); - if (step) { - switch (step.type) { - case StepTypes.ADD: - return ( - - ); - case StepTypes.EDIT: { - const currentLabel = labels.find((label) => label.id === step.params.id); + const dispatch = useDispatch(); + const [t] = useTranslation(); + const [step, openStep, handleBack] = useSteps(); + const [search, handleSearchChange] = useField(''); + const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]); - if (currentLabel) { - return ; + const filteredLabels = useMemo( + () => + labels.filter( + (label) => + (label.name && label.name.toLowerCase().includes(cleanSearch)) || + label.color.includes(cleanSearch), + ), + [labels, cleanSearch], + ); + + const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef'); + + const handleDragStart = useCallback(() => { + document.body.classList.add(globalStyles.dragging); + }, []); + + const handleDragEnd = useCallback( + ({ draggableId, source, destination }) => { + document.body.classList.remove(globalStyles.dragging); + + if (!destination || source.index === destination.index) { + return; } - openStep(null); + dispatch(entryActions.moveLabel(draggableId, destination.index)); + }, + [dispatch], + ); - break; + const handleAddClick = useCallback(() => { + openStep(StepTypes.ADD); + }, [openStep]); + + const handleEdit = useCallback( + (id) => { + openStep(StepTypes.EDIT, { + id, + }); + }, + [openStep], + ); + + useEffect(() => { + searchFieldRef.current.focus({ + preventScroll: true, + }); + }, [searchFieldRef]); + + if (step) { + switch (step.type) { + case StepTypes.ADD: + return ( + + ); + case StepTypes.EDIT: { + const currentLabel = labels.find((label) => label.id === step.params.id); + + if (currentLabel) { + return ; + } + + openStep(null); + + break; + } + default: } - default: } - } - return ( - <> - - {t(title, { - context: 'title', - })} - - - - {filteredLabels.length > 0 && ( - - - {({ innerRef, droppableProps, placeholder }) => ( -
- {filteredLabels.map((item, index) => ( - - ))} - {placeholder} -
- )} -
- - {({ innerRef, droppableProps, placeholder }) => ( -
- {placeholder} -
- )} -
-
- )} - {canAdd && ( -