Add negative label filtering for cards

This commit is contained in:
Symon
2026-06-05 16:43:45 +03:00
parent 856768c45e
commit 55d0e2f877
23 changed files with 638 additions and 235 deletions
@@ -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(() => {
</span>
<span className={styles.filter}>
<LabelsPopup
currentIds={labelIds}
currentIds={[]}
currentModes={labelModes}
isFilterModeEnabled
title="common.filterByLabels"
onSelect={handleLabelSelect}
onDeselect={handleLabelDeselect}
onModeChange={handleLabelModeChange}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.labels')}:`}</span>
{labelIds.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
{labelIds.length === 0 && excludedLabelIds.length === 0 && (
<span className={styles.filterLabel}>{t('common.all')}</span>
)}
</button>
</LabelsPopup>
{labelIds.map((labelId) => (
@@ -191,6 +209,11 @@ const Filters = React.memo(() => {
<LabelChip id={labelId} size="small" onClick={handleLabelClick} />
</span>
))}
{excludedLabelIds.map((labelId) => (
<span key={labelId} className={styles.filterItem}>
<LabelChip id={labelId} size="small" isExcluded onClick={handleLabelClick} />
</span>
))}
</span>
<span className={styles.filter}>
<Input
@@ -21,7 +21,7 @@ const Sizes = {
MEDIUM: 'medium',
};
const LabelChip = React.memo(({ id, size, onClick }) => {
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,
};
@@ -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 {
@@ -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 (
<Draggable draggableId={id} index={index} isDragDisabled={!label.isPersisted || !canEdit}>
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
const contentNode = (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...draggableProps} ref={innerRef} className={styles.wrapper}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
return (
<Draggable draggableId={id} index={index} isDragDisabled={!label.isPersisted || !canEdit}>
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
const contentNode = (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...draggableProps} ref={innerRef} className={styles.wrapper}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<span
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
className={classNames(
styles.name,
isActive && styles.nameActive,
globalStyles[`background${upperFirst(camelCase(label.color))}`],
<span
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
className={classNames(
styles.name,
isFilterModeEnabled && styles.nameFilterMode,
((!isFilterModeEnabled && isActive) || mode === LabelFilterModes.INCLUDE) &&
styles.nameActive,
mode === LabelFilterModes.EXCLUDE && styles.nameExcluded,
globalStyles[`background${upperFirst(camelCase(label.color))}`],
)}
onClick={handleToggleClick}
>
{label.name}
</span>
{canEdit && (
<Button
icon="pencil"
size="small"
floated="right"
disabled={!label.isPersisted}
className={styles.editButton}
onClick={handleEditClick}
/>
)}
onClick={handleToggleClick}
>
{label.name}
</span>
{canEdit && (
<Button
icon="pencil"
size="small"
floated="right"
disabled={!label.isPersisted}
className={styles.editButton}
onClick={handleEditClick}
/>
)}
</div>
);
</div>
);
return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode;
}}
</Draggable>
);
});
return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode;
}}
</Draggable>
);
},
);
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;
@@ -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;
@@ -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 (
<AddStep
cardId={cardId}
// TODO: memoize?
defaultData={{
name: search,
}}
onBack={handleBack}
/>
);
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 <EditStep labelId={currentLabel.id} onBack={handleBack} />;
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 (
<AddStep
cardId={cardId}
// TODO: memoize?
defaultData={{
name: search,
}}
onBack={handleBack}
/>
);
case StepTypes.EDIT: {
const currentLabel = labels.find((label) => label.id === step.params.id);
if (currentLabel) {
return <EditStep labelId={currentLabel.id} onBack={handleBack} />;
}
openStep(null);
break;
}
default:
}
default:
}
}
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Input
fluid
ref={handleSearchFieldRef}
value={search}
placeholder={t('common.searchLabels')}
maxLength={128}
icon="search"
onChange={handleSearchChange}
/>
{filteredLabels.length > 0 && (
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="labels" type={DroppableTypes.LABEL}>
{({ innerRef, droppableProps, placeholder }) => (
<div
{...droppableProps} // eslint-disable-line react/jsx-props-no-spreading
ref={innerRef}
className={styles.items}
>
{filteredLabels.map((item, index) => (
<Item
key={item.id}
id={item.id}
index={index}
isActive={currentIds.includes(item.id)}
onSelect={onSelect}
onDeselect={onDeselect}
onEdit={handleEdit}
/>
))}
{placeholder}
</div>
)}
</Droppable>
<Droppable droppableId="labels:hack" type={DroppableTypes.LABEL}>
{({ innerRef, droppableProps, placeholder }) => (
<div
{...droppableProps} // eslint-disable-line react/jsx-props-no-spreading
ref={innerRef}
className={styles.droppableHack}
>
{placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
{canAdd && (
<Button
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Input
fluid
content={t('action.createNewLabel')}
className={styles.addButton}
onClick={handleAddClick}
ref={handleSearchFieldRef}
value={search}
placeholder={t('common.searchLabels')}
maxLength={128}
icon="search"
onChange={handleSearchChange}
/>
)}
</Popup.Content>
</>
);
});
{filteredLabels.length > 0 && (
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="labels" type={DroppableTypes.LABEL}>
{({ innerRef, droppableProps, placeholder }) => (
<div
{...droppableProps} // eslint-disable-line react/jsx-props-no-spreading
ref={innerRef}
className={styles.items}
>
{filteredLabels.map((item, index) => (
<Item
key={item.id}
id={item.id}
index={index}
isActive={currentIds.includes(item.id)}
mode={currentModes[item.id] || LabelFilterModes.NONE}
isFilterModeEnabled={isFilterModeEnabled}
onSelect={onSelect}
onDeselect={onDeselect}
onModeChange={onModeChange}
onEdit={handleEdit}
/>
))}
{placeholder}
</div>
)}
</Droppable>
<Droppable droppableId="labels:hack" type={DroppableTypes.LABEL}>
{({ innerRef, droppableProps, placeholder }) => (
<div
{...droppableProps} // eslint-disable-line react/jsx-props-no-spreading
ref={innerRef}
className={styles.droppableHack}
>
{placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
{canAdd && (
<Button
fluid
content={t('action.createNewLabel')}
className={styles.addButton}
onClick={handleAddClick}
/>
)}
</Popup.Content>
</>
);
},
);
LabelsStep.propTypes = {
currentIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
currentModes: PropTypes.object, // eslint-disable-line react/forbid-prop-types
isFilterModeEnabled: PropTypes.bool,
cardId: PropTypes.string,
title: PropTypes.string,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
onSelect: PropTypes.func,
onDeselect: PropTypes.func,
onModeChange: PropTypes.func,
onBack: PropTypes.func,
};
LabelsStep.defaultProps = {
currentModes: {},
isFilterModeEnabled: false,
cardId: undefined,
title: 'common.labels',
onSelect: undefined,
onDeselect: undefined,
onModeChange: undefined,
onBack: undefined,
};