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
+11
View File
@@ -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 { export default {
createLabel, createLabel,
createLabelFromCard, createLabelFromCard,
@@ -214,4 +224,5 @@ export default {
handleLabelFromCardRemove, handleLabelFromCardRemove,
addLabelToBoardFilter, addLabelToBoardFilter,
removeLabelFromBoardFilter, removeLabelFromBoardFilter,
updateLabelFilterInBoard,
}; };
@@ -20,6 +20,7 @@ import UserAvatar from '../../users/UserAvatar';
import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep'; import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
import LabelChip from '../../labels/LabelChip'; import LabelChip from '../../labels/LabelChip';
import LabelsStep from '../../labels/LabelsStep'; import LabelsStep from '../../labels/LabelsStep';
import { LabelFilterModes } from '../../../constants/Enums';
import styles from './Filters.module.scss'; import styles from './Filters.module.scss';
@@ -27,6 +28,7 @@ const Filters = React.memo(() => {
const board = useSelector(selectors.selectCurrentBoard); const board = useSelector(selectors.selectCurrentBoard);
const userIds = useSelector(selectors.selectFilterUserIdsForCurrentBoard); const userIds = useSelector(selectors.selectFilterUserIdsForCurrentBoard);
const labelIds = useSelector(selectors.selectFilterLabelIdsForCurrentBoard); const labelIds = useSelector(selectors.selectFilterLabelIdsForCurrentBoard);
const excludedLabelIds = useSelector(selectors.selectFilterExcludedLabelIdsForCurrentBoard);
const currentUserId = useSelector(selectors.selectCurrentUserId); const currentUserId = useSelector(selectors.selectCurrentUserId);
const withCurrentUserSelector = useSelector( const withCurrentUserSelector = useSelector(
@@ -48,6 +50,26 @@ const Filters = React.memo(() => {
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef'); 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(() => { const cancelSearch = useCallback(() => {
debouncedSearch.cancel(); debouncedSearch.cancel();
setSearch(''); setSearch('');
@@ -84,27 +106,20 @@ const Filters = React.memo(() => {
[dispatch], [dispatch],
); );
const handleLabelSelect = useCallback(
(labelId) => {
dispatch(entryActions.addLabelToFilterInCurrentBoard(labelId));
},
[dispatch],
);
const handleLabelDeselect = useCallback(
(labelId) => {
dispatch(entryActions.removeLabelFromFilterInCurrentBoard(labelId));
},
[dispatch],
);
const handleLabelClick = useCallback( const handleLabelClick = useCallback(
({ ({
currentTarget: { currentTarget: {
dataset: { id: labelId }, 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], [dispatch],
); );
@@ -176,14 +191,17 @@ const Filters = React.memo(() => {
</span> </span>
<span className={styles.filter}> <span className={styles.filter}>
<LabelsPopup <LabelsPopup
currentIds={labelIds} currentIds={[]}
currentModes={labelModes}
isFilterModeEnabled
title="common.filterByLabels" title="common.filterByLabels"
onSelect={handleLabelSelect} onModeChange={handleLabelModeChange}
onDeselect={handleLabelDeselect}
> >
<button type="button" className={styles.filterButton}> <button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.labels')}:`}</span> <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> </button>
</LabelsPopup> </LabelsPopup>
{labelIds.map((labelId) => ( {labelIds.map((labelId) => (
@@ -191,6 +209,11 @@ const Filters = React.memo(() => {
<LabelChip id={labelId} size="small" onClick={handleLabelClick} /> <LabelChip id={labelId} size="small" onClick={handleLabelClick} />
</span> </span>
))} ))}
{excludedLabelIds.map((labelId) => (
<span key={labelId} className={styles.filterItem}>
<LabelChip id={labelId} size="small" isExcluded onClick={handleLabelClick} />
</span>
))}
</span> </span>
<span className={styles.filter}> <span className={styles.filter}>
<Input <Input
@@ -21,7 +21,7 @@ const Sizes = {
MEDIUM: 'medium', MEDIUM: 'medium',
}; };
const LabelChip = React.memo(({ id, size, onClick }) => { const LabelChip = React.memo(({ id, size, isExcluded, onClick }) => {
const selectLabelById = useMemo(() => selectors.makeSelectLabelById(), []); const selectLabelById = useMemo(() => selectors.makeSelectLabelById(), []);
const label = useSelector((state) => selectLabelById(state, id)); const label = useSelector((state) => selectLabelById(state, id));
@@ -33,6 +33,7 @@ const LabelChip = React.memo(({ id, size, onClick }) => {
styles.wrapper, styles.wrapper,
!label.name && styles.wrapperNameless, !label.name && styles.wrapperNameless,
styles[`wrapper${upperFirst(size)}`], styles[`wrapper${upperFirst(size)}`],
isExcluded && styles.wrapperExcluded,
onClick && styles.wrapperHoverable, onClick && styles.wrapperHoverable,
globalStyles[`background${upperFirst(camelCase(label.color))}`], globalStyles[`background${upperFirst(camelCase(label.color))}`],
)} )}
@@ -59,11 +60,13 @@ const LabelChip = React.memo(({ id, size, onClick }) => {
LabelChip.propTypes = { LabelChip.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
size: PropTypes.oneOf(Object.values(Sizes)), size: PropTypes.oneOf(Object.values(Sizes)),
isExcluded: PropTypes.bool,
onClick: PropTypes.func, onClick: PropTypes.func,
}; };
LabelChip.defaultProps = { LabelChip.defaultProps = {
size: Sizes.MEDIUM, size: Sizes.MEDIUM,
isExcluded: false,
onClick: undefined, onClick: undefined,
}; };
@@ -34,6 +34,11 @@
opacity: 0.75; opacity: 0.75;
} }
.wrapperExcluded {
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.75);
text-decoration: line-through;
}
/* Sizes */ /* Sizes */
.wrapperTiny { .wrapperTiny {
@@ -14,80 +14,123 @@ import { Draggable } from 'react-beautiful-dnd';
import { Button } from 'semantic-ui-react'; import { Button } from 'semantic-ui-react';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import { BoardMembershipRoles } from '../../../constants/Enums'; import { BoardMembershipRoles, LabelFilterModes } from '../../../constants/Enums';
import styles from './Item.module.scss'; import styles from './Item.module.scss';
import globalStyles from '../../../styles.module.scss'; import globalStyles from '../../../styles.module.scss';
const Item = React.memo(({ id, index, isActive, onSelect, onDeselect, onEdit }) => { const NEXT_FILTER_MODE_BY_MODE = {
const selectLabelById = useMemo(() => selectors.makeSelectLabelById(), []); [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 label = useSelector((state) => selectLabelById(state, id));
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
});
const handleToggleClick = useCallback(() => { const canEdit = useSelector((state) => {
if (label.isPersisted) { const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
if (isActive) { return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
onDeselect(id); });
} else {
onSelect(id); 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(() => { const handleEditClick = useCallback(() => {
onEdit(id); onEdit(id);
}, [id, onEdit]); }, [id, onEdit]);
return ( return (
<Draggable draggableId={id} index={index} isDragDisabled={!label.isPersisted || !canEdit}> <Draggable draggableId={id} index={index} isDragDisabled={!label.isPersisted || !canEdit}>
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => { {({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
const contentNode = ( const contentNode = (
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
<div {...draggableProps} ref={innerRef} className={styles.wrapper}> <div {...draggableProps} ref={innerRef} className={styles.wrapper}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */} jsx-a11y/no-static-element-interactions */}
<span <span
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading {...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
className={classNames( className={classNames(
styles.name, styles.name,
isActive && styles.nameActive, isFilterModeEnabled && styles.nameFilterMode,
globalStyles[`background${upperFirst(camelCase(label.color))}`], ((!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} </div>
> );
{label.name}
</span>
{canEdit && (
<Button
icon="pencil"
size="small"
floated="right"
disabled={!label.isPersisted}
className={styles.editButton}
onClick={handleEditClick}
/>
)}
</div>
);
return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode; return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode;
}} }}
</Draggable> </Draggable>
); );
}); },
);
Item.propTypes = { Item.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
isActive: PropTypes.bool.isRequired, isActive: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired, mode: PropTypes.oneOf(Object.values(LabelFilterModes)),
onDeselect: PropTypes.func.isRequired, isFilterModeEnabled: PropTypes.bool,
onSelect: PropTypes.func,
onDeselect: PropTypes.func,
onModeChange: PropTypes.func,
onEdit: PropTypes.func.isRequired, onEdit: PropTypes.func.isRequired,
}; };
Item.defaultProps = {
mode: LabelFilterModes.NONE,
isFilterModeEnabled: false,
onSelect: undefined,
onDeselect: undefined,
onModeChange: undefined,
};
export default Item; 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 { .nameActive:before {
bottom: 1px; bottom: 1px;
content: "Г"; content: "Г";
@@ -47,6 +60,18 @@
width: 36px; 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 { .wrapper {
display: flex; display: flex;
margin-bottom: 4px; margin-bottom: 4px;
@@ -15,7 +15,7 @@ import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { useField, useNestedRef, useSteps } from '../../../hooks'; import { useField, useNestedRef, useSteps } from '../../../hooks';
import DroppableTypes from '../../../constants/DroppableTypes'; import DroppableTypes from '../../../constants/DroppableTypes';
import { BoardMembershipRoles } from '../../../constants/Enums'; import { BoardMembershipRoles, LabelFilterModes } from '../../../constants/Enums';
import Item from './Item'; import Item from './Item';
import AddStep from './AddStep'; import AddStep from './AddStep';
import EditStep from './EditStep'; import EditStep from './EditStep';
@@ -28,175 +28,198 @@ const StepTypes = {
EDIT: 'EDIT', EDIT: 'EDIT',
}; };
const LabelsStep = React.memo(({ currentIds, cardId, title, onSelect, onDeselect, onBack }) => { const LabelsStep = React.memo(
const labels = useSelector(selectors.selectLabelsForCurrentBoard); ({
currentIds,
currentModes,
isFilterModeEnabled,
cardId,
title,
onSelect,
onDeselect,
onModeChange,
onBack,
}) => {
const labels = useSelector(selectors.selectLabelsForCurrentBoard);
const canAdd = useSelector((state) => { const canAdd = useSelector((state) => {
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state); const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR; 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,
}); });
}, [searchFieldRef]);
if (step) { const dispatch = useDispatch();
switch (step.type) { const [t] = useTranslation();
case StepTypes.ADD: const [step, openStep, handleBack] = useSteps();
return ( const [search, handleSearchChange] = useField('');
<AddStep const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
cardId={cardId}
// TODO: memoize?
defaultData={{
name: search,
}}
onBack={handleBack}
/>
);
case StepTypes.EDIT: {
const currentLabel = labels.find((label) => label.id === step.params.id);
if (currentLabel) { const filteredLabels = useMemo(
return <EditStep labelId={currentLabel.id} onBack={handleBack} />; () =>
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 ( return (
<> <>
<Popup.Header onBack={onBack}> <Popup.Header onBack={onBack}>
{t(title, { {t(title, {
context: 'title', context: 'title',
})} })}
</Popup.Header> </Popup.Header>
<Popup.Content> <Popup.Content>
<Input <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
fluid fluid
content={t('action.createNewLabel')} ref={handleSearchFieldRef}
className={styles.addButton} value={search}
onClick={handleAddClick} placeholder={t('common.searchLabels')}
maxLength={128}
icon="search"
onChange={handleSearchChange}
/> />
)} {filteredLabels.length > 0 && (
</Popup.Content> <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 = { LabelsStep.propTypes = {
currentIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types 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, cardId: PropTypes.string,
title: PropTypes.string, title: PropTypes.string,
onSelect: PropTypes.func.isRequired, onSelect: PropTypes.func,
onDeselect: PropTypes.func.isRequired, onDeselect: PropTypes.func,
onModeChange: PropTypes.func,
onBack: PropTypes.func, onBack: PropTypes.func,
}; };
LabelsStep.defaultProps = { LabelsStep.defaultProps = {
currentModes: {},
isFilterModeEnabled: false,
cardId: undefined, cardId: undefined,
title: 'common.labels', title: 'common.labels',
onSelect: undefined,
onDeselect: undefined,
onModeChange: undefined,
onBack: undefined, onBack: undefined,
}; };
+1
View File
@@ -247,6 +247,7 @@ export default {
LABEL_FROM_CARD_REMOVE_HANDLE: 'LABEL_FROM_CARD_REMOVE_HANDLE', LABEL_FROM_CARD_REMOVE_HANDLE: 'LABEL_FROM_CARD_REMOVE_HANDLE',
LABEL_TO_BOARD_FILTER_ADD: 'LABEL_TO_BOARD_FILTER_ADD', LABEL_TO_BOARD_FILTER_ADD: 'LABEL_TO_BOARD_FILTER_ADD',
LABEL_FROM_BOARD_FILTER_REMOVE: 'LABEL_FROM_BOARD_FILTER_REMOVE', LABEL_FROM_BOARD_FILTER_REMOVE: 'LABEL_FROM_BOARD_FILTER_REMOVE',
LABEL_FILTER_IN_BOARD_UPDATE: 'LABEL_FILTER_IN_BOARD_UPDATE',
/* Lists */ /* Lists */
+1
View File
@@ -171,6 +171,7 @@ export default {
LABEL_FROM_CARD_REMOVE_HANDLE: `${PREFIX}/LABEL_FROM_CARD_REMOVE_HANDLE`, LABEL_FROM_CARD_REMOVE_HANDLE: `${PREFIX}/LABEL_FROM_CARD_REMOVE_HANDLE`,
LABEL_TO_FILTER_IN_CURRENT_BOARD_ADD: `${PREFIX}/LABEL_TO_FILTER_IN_CURRENT_BOARD_ADD`, LABEL_TO_FILTER_IN_CURRENT_BOARD_ADD: `${PREFIX}/LABEL_TO_FILTER_IN_CURRENT_BOARD_ADD`,
LABEL_FROM_FILTER_IN_CURRENT_BOARD_REMOVE: `${PREFIX}/LABEL_FROM_FILTER_IN_CURRENT_BOARD_REMOVE`, LABEL_FROM_FILTER_IN_CURRENT_BOARD_REMOVE: `${PREFIX}/LABEL_FROM_FILTER_IN_CURRENT_BOARD_REMOVE`,
LABEL_FILTER_IN_CURRENT_BOARD_UPDATE: `${PREFIX}/LABEL_FILTER_IN_CURRENT_BOARD_UPDATE`,
/* Lists */ /* Lists */
+6
View File
@@ -64,6 +64,12 @@ export const BoardMembershipRoles = {
VIEWER: 'viewer', VIEWER: 'viewer',
}; };
export const LabelFilterModes = {
NONE: 'none',
INCLUDE: 'include',
EXCLUDE: 'exclude',
};
export const ListTypes = { export const ListTypes = {
ACTIVE: 'active', ACTIVE: 'active',
CLOSED: 'closed', CLOSED: 'closed',
+9
View File
@@ -122,6 +122,14 @@ const removeLabelFromFilterInCurrentBoard = (id) => ({
}, },
}); });
const updateLabelFilterInCurrentBoard = (id, mode) => ({
type: EntryActionTypes.LABEL_FILTER_IN_CURRENT_BOARD_UPDATE,
payload: {
id,
mode,
},
});
export default { export default {
createLabelInCurrentBoard, createLabelInCurrentBoard,
createLabelFromCard, createLabelFromCard,
@@ -139,4 +147,5 @@ export default {
handleLabelFromCardRemove, handleLabelFromCardRemove,
addLabelToFilterInCurrentBoard, addLabelToFilterInCurrentBoard,
removeLabelFromFilterInCurrentBoard, removeLabelFromFilterInCurrentBoard,
updateLabelFilterInCurrentBoard,
}; };
+30 -6
View File
@@ -7,10 +7,11 @@ import { attr, fk, many } from 'redux-orm';
import BaseModel from './BaseModel'; import BaseModel from './BaseModel';
import buildSearchParts from '../utils/build-search-parts'; import buildSearchParts from '../utils/build-search-parts';
import filterCardLabels from '../utils/filter-card-labels';
import { isListKanban } from '../utils/record-helpers'; import { isListKanban } from '../utils/record-helpers';
import ActionTypes from '../constants/ActionTypes'; import ActionTypes from '../constants/ActionTypes';
import Config from '../constants/Config'; import Config from '../constants/Config';
import { BoardContexts, BoardViews } from '../constants/Enums'; import { BoardContexts, BoardViews, LabelFilterModes } from '../constants/Enums';
const prepareFetchedBoard = (board) => ({ const prepareFetchedBoard = (board) => ({
...board, ...board,
@@ -63,6 +64,7 @@ export default class extends BaseModel {
}), }),
filterUsers: many('User', 'filterBoards'), filterUsers: many('User', 'filterBoards'),
filterLabels: many('Label', 'filterBoards'), filterLabels: many('Label', 'filterBoards'),
filterExcludedLabels: many('Label', 'filterExcludedBoards'),
}; };
static reducer({ type, payload }, Board) { static reducer({ type, payload }, Board) {
@@ -254,6 +256,29 @@ export default class extends BaseModel {
Board.withId(payload.boardId).filterLabels.remove(payload.id); Board.withId(payload.boardId).filterLabels.remove(payload.id);
break; break;
case ActionTypes.LABEL_FILTER_IN_BOARD_UPDATE: {
const boardModel = Board.withId(payload.boardId);
try {
boardModel.filterLabels.remove(payload.id);
} catch {
/* empty */
}
try {
boardModel.filterExcludedLabels.remove(payload.id);
} catch {
/* empty */
}
if (payload.mode === LabelFilterModes.INCLUDE) {
boardModel.filterLabels.add(payload.id);
} else if (payload.mode === LabelFilterModes.EXCLUDE) {
boardModel.filterExcludedLabels.add(payload.id);
}
break;
}
case ActionTypes.ACTIVITIES_IN_BOARD_FETCH: case ActionTypes.ACTIVITIES_IN_BOARD_FETCH:
Board.withId(payload.boardId).update({ Board.withId(payload.boardId).update({
isActivitiesFetching: true, isActivitiesFetching: true,
@@ -381,12 +406,10 @@ export default class extends BaseModel {
} }
const filterLabelIds = this.filterLabels.toRefArray().map((label) => label.id); const filterLabelIds = this.filterLabels.toRefArray().map((label) => label.id);
const filterExcludedLabelIds = this.filterExcludedLabels.toRefArray().map((label) => label.id);
if (filterLabelIds.length > 0) { if (filterLabelIds.length > 0 || filterExcludedLabelIds.length > 0) {
cardModels = cardModels.filter((cardModel) => { cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds);
const labels = cardModel.labels.toRefArray();
return labels.some((label) => filterLabelIds.includes(label.id));
});
} }
return cardModels; return cardModels;
@@ -444,6 +467,7 @@ export default class extends BaseModel {
deleteClearable() { deleteClearable() {
this.filterUsers.clear(); this.filterUsers.clear();
this.filterLabels.clear(); this.filterLabels.clear();
this.filterExcludedLabels.clear();
} }
deleteRelated(exceptMemberUserId, soft) { deleteRelated(exceptMemberUserId, soft) {
+6
View File
@@ -109,6 +109,12 @@ export default class extends BaseModel {
} catch { } catch {
/* empty */ /* empty */
} }
try {
this.board.filterExcludedLabels.remove(this.id);
} catch {
/* empty */
}
} }
deleteWithRelated() { deleteWithRelated() {
+7 -5
View File
@@ -7,6 +7,7 @@ import { attr, fk } from 'redux-orm';
import BaseModel from './BaseModel'; import BaseModel from './BaseModel';
import buildSearchParts from '../utils/build-search-parts'; import buildSearchParts from '../utils/build-search-parts';
import filterCardLabels from '../utils/filter-card-labels';
import { isListFinite } from '../utils/record-helpers'; import { isListFinite } from '../utils/record-helpers';
import ActionTypes from '../constants/ActionTypes'; import ActionTypes from '../constants/ActionTypes';
import Config from '../constants/Config'; import Config from '../constants/Config';
@@ -99,6 +100,7 @@ export default class extends BaseModel {
case ActionTypes.IN_BOARD_SEARCH: case ActionTypes.IN_BOARD_SEARCH:
case ActionTypes.LABEL_TO_BOARD_FILTER_ADD: case ActionTypes.LABEL_TO_BOARD_FILTER_ADD:
case ActionTypes.LABEL_FROM_BOARD_FILTER_REMOVE: case ActionTypes.LABEL_FROM_BOARD_FILTER_REMOVE:
case ActionTypes.LABEL_FILTER_IN_BOARD_UPDATE:
if (payload.currentListId) { if (payload.currentListId) {
List.withId(payload.currentListId).update({ List.withId(payload.currentListId).update({
lastCard: null, lastCard: null,
@@ -379,12 +381,12 @@ export default class extends BaseModel {
} }
const filterLabelIds = this.board.filterLabels.toRefArray().map((label) => label.id); const filterLabelIds = this.board.filterLabels.toRefArray().map((label) => label.id);
const filterExcludedLabelIds = this.board.filterExcludedLabels
.toRefArray()
.map((label) => label.id);
if (filterLabelIds.length > 0) { if (filterLabelIds.length > 0 || filterExcludedLabelIds.length > 0) {
cardModels = cardModels.filter((cardModel) => { cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds);
const labels = cardModel.labels.toRefArray();
return labels.some((label) => filterLabelIds.includes(label.id));
});
} }
return cardModels; return cardModels;
+5
View File
@@ -37,6 +37,9 @@ export function* fetchCards(listId) {
const { search } = yield select(selectors.selectBoardById, boardId); const { search } = yield select(selectors.selectBoardById, boardId);
const filterUserIds = yield select(selectors.selectFilterUserIdsForCurrentBoard); const filterUserIds = yield select(selectors.selectFilterUserIdsForCurrentBoard);
const filterLabelIds = yield select(selectors.selectFilterLabelIdsForCurrentBoard); const filterLabelIds = yield select(selectors.selectFilterLabelIdsForCurrentBoard);
const filterExcludedLabelIds = yield select(
selectors.selectFilterExcludedLabelIdsForCurrentBoard,
);
function* getCardsRequest() { function* getCardsRequest() {
const response = {}; const response = {};
@@ -46,6 +49,8 @@ export function* fetchCards(listId) {
search: (search && search.trim()) || undefined, search: (search && search.trim()) || undefined,
userIds: filterUserIds.length > 0 ? filterUserIds.join(',') : undefined, userIds: filterUserIds.length > 0 ? filterUserIds.join(',') : undefined,
labelIds: filterLabelIds.length > 0 ? filterLabelIds.join(',') : undefined, labelIds: filterLabelIds.length > 0 ? filterLabelIds.join(',') : undefined,
excludedLabelIds:
filterExcludedLabelIds.length > 0 ? filterExcludedLabelIds.join(',') : undefined,
before: lastCard || undefined, before: lastCard || undefined,
}); });
} catch (error) { } catch (error) {
+14
View File
@@ -205,6 +205,18 @@ export function* removeLabelFromFilterInCurrentBoard(id) {
yield call(removeLabelFromBoardFilter, id, boardId); yield call(removeLabelFromBoardFilter, id, boardId);
} }
export function* updateLabelFilterInBoard(id, boardId, mode) {
const currentListId = yield select(selectors.selectCurrentListId);
yield put(actions.updateLabelFilterInBoard(id, boardId, mode, currentListId));
}
export function* updateLabelFilterInCurrentBoard(id, mode) {
const { boardId } = yield select(selectors.selectPath);
yield call(updateLabelFilterInBoard, id, boardId, mode);
}
export default { export default {
createLabel, createLabel,
createLabelInCurrentBoard, createLabelInCurrentBoard,
@@ -225,4 +237,6 @@ export default {
addLabelToFilterInCurrentBoard, addLabelToFilterInCurrentBoard,
removeLabelFromBoardFilter, removeLabelFromBoardFilter,
removeLabelFromFilterInCurrentBoard, removeLabelFromFilterInCurrentBoard,
updateLabelFilterInBoard,
updateLabelFilterInCurrentBoard,
}; };
+3
View File
@@ -56,5 +56,8 @@ export default function* labelsWatchers() {
takeEvery(EntryActionTypes.LABEL_FROM_FILTER_IN_CURRENT_BOARD_REMOVE, ({ payload: { id } }) => takeEvery(EntryActionTypes.LABEL_FROM_FILTER_IN_CURRENT_BOARD_REMOVE, ({ payload: { id } }) =>
services.removeLabelFromFilterInCurrentBoard(id), services.removeLabelFromFilterInCurrentBoard(id),
), ),
takeEvery(EntryActionTypes.LABEL_FILTER_IN_CURRENT_BOARD_UPDATE, ({ payload: { id, mode } }) =>
services.updateLabelFilterInCurrentBoard(id, mode),
),
]); ]);
} }
+19
View File
@@ -459,6 +459,24 @@ export const selectFilterLabelIdsForCurrentBoard = createSelector(
}, },
); );
export const selectFilterExcludedLabelIdsForCurrentBoard = createSelector(
orm,
(state) => selectPath(state).boardId,
({ Board }, id) => {
if (!id) {
return id;
}
const boardModel = Board.withId(id);
if (!boardModel) {
return boardModel;
}
return boardModel.filterExcludedLabels.toRefArray().map((label) => label.id);
},
);
export const selectIsBoardWithIdExists = createSelector( export const selectIsBoardWithIdExists = createSelector(
orm, orm,
(_, id) => id, (_, id) => id,
@@ -491,5 +509,6 @@ export default {
selectActivityIdsForCurrentBoard, selectActivityIdsForCurrentBoard,
selectFilterUserIdsForCurrentBoard, selectFilterUserIdsForCurrentBoard,
selectFilterLabelIdsForCurrentBoard, selectFilterLabelIdsForCurrentBoard,
selectFilterExcludedLabelIdsForCurrentBoard,
selectIsBoardWithIdExists, selectIsBoardWithIdExists,
}; };
+20
View File
@@ -0,0 +1,20 @@
const getLabelIds = (card) =>
card.labels.toRefArray
? card.labels.toRefArray().map((label) => label.id)
: card.labels.map((label) => label.id);
const filterCardLabels = (cards, includedLabelIds, excludedLabelIds) =>
cards.filter((card) => {
const labelIds = getLabelIds(card);
if (
includedLabelIds.length > 0 &&
!labelIds.some((labelId) => includedLabelIds.includes(labelId))
) {
return false;
}
return !labelIds.some((labelId) => excludedLabelIds.includes(labelId));
});
export default filterCardLabels;
@@ -0,0 +1,34 @@
import filterCardLabels from './filter-card-labels';
const makeCard = (id, labelIds) => ({
id,
labels: labelIds.map((labelId) => ({ id: labelId })),
});
describe('filterCardLabels', () => {
it('keeps cards that have any included label', () => {
const cards = [makeCard('card-1', ['frontend']), makeCard('card-2', ['backend'])];
expect(filterCardLabels(cards, ['frontend'], [])).toEqual([cards[0]]);
});
it('keeps cards that have none of the excluded labels, including unlabeled cards', () => {
const cards = [
makeCard('card-1', ['frontend']),
makeCard('card-2', ['backend']),
makeCard('card-3', []),
];
expect(filterCardLabels(cards, [], ['frontend'])).toEqual([cards[1], cards[2]]);
});
it('combines included and excluded labels with AND semantics', () => {
const cards = [
makeCard('card-1', ['frontend', 'blocked']),
makeCard('card-2', ['frontend']),
makeCard('card-3', ['backend']),
];
expect(filterCardLabels(cards, ['frontend'], ['blocked'])).toEqual([cards[1]]);
});
});
+25
View File
@@ -57,6 +57,13 @@
* schema: * schema:
* type: string * type: string
* example: 1357158568008091268,1357158568008091269 * example: 1357158568008091268,1357158568008091269
* - name: excludedLabelIds
* in: query
* required: false
* description: Comma-separated label IDs to exclude from results
* schema:
* type: string
* example: 1357158568008091268,1357158568008091269
* responses: * responses:
* 200: * 200:
* description: Cards retrieved successfully * description: Cards retrieved successfully
@@ -189,6 +196,7 @@ module.exports = {
}, },
userIds: idsInput, userIds: idsInput,
labelIds: idsInput, labelIds: idsInput,
excludedLabelIds: idsInput,
}, },
exits: { exits: {
@@ -235,12 +243,28 @@ module.exports = {
} }
let filterLabelIds; let filterLabelIds;
let filterExcludedLabelIds;
if (inputs.labelIds) { if (inputs.labelIds) {
const labels = await Label.qm.getByBoardId(list.boardId); const labels = await Label.qm.getByBoardId(list.boardId);
const availableLabelIdsSet = new Set(sails.helpers.utils.mapRecords(labels)); const availableLabelIdsSet = new Set(sails.helpers.utils.mapRecords(labels));
filterLabelIds = _.uniq(inputs.labelIds.split(',')); filterLabelIds = _.uniq(inputs.labelIds.split(','));
filterLabelIds = filterLabelIds.filter((labelId) => availableLabelIdsSet.has(labelId)); filterLabelIds = filterLabelIds.filter((labelId) => availableLabelIdsSet.has(labelId));
if (inputs.excludedLabelIds) {
filterExcludedLabelIds = _.uniq(inputs.excludedLabelIds.split(','));
filterExcludedLabelIds = filterExcludedLabelIds.filter((labelId) =>
availableLabelIdsSet.has(labelId),
);
}
} else if (inputs.excludedLabelIds) {
const labels = await Label.qm.getByBoardId(list.boardId);
const availableLabelIdsSet = new Set(sails.helpers.utils.mapRecords(labels));
filterExcludedLabelIds = _.uniq(inputs.excludedLabelIds.split(','));
filterExcludedLabelIds = filterExcludedLabelIds.filter((labelId) =>
availableLabelIdsSet.has(labelId),
);
} }
const cards = await Card.qm.getByEndlessListId(list.id, { const cards = await Card.qm.getByEndlessListId(list.id, {
@@ -248,6 +272,7 @@ module.exports = {
search: inputs.search, search: inputs.search,
userIds: filterUserIds, userIds: filterUserIds,
labelIds: filterLabelIds, labelIds: filterLabelIds,
excludedLabelIds: filterExcludedLabelIds,
}); });
const cardIds = sails.helpers.utils.mapRecords(cards); const cardIds = sails.helpers.utils.mapRecords(cards);
+21 -2
View File
@@ -38,8 +38,13 @@ const getByListId = async (listId, { exceptIdOrIds, sort = ['position', 'id'] }
return defaultFind(criteria, { sort }); return defaultFind(criteria, { sort });
}; };
const getByEndlessListId = async (listId, { before, search, userIds, labelIds }) => { const getByEndlessListId = async (
if (search || userIds || labelIds) { listId,
{ before, search, userIds, labelIds, excludedLabelIds },
) => {
const hasExcludedLabelIds = excludedLabelIds && excludedLabelIds.length > 0;
if (search || userIds || labelIds || hasExcludedLabelIds) {
if (userIds && userIds.length === 0) { if (userIds && userIds.length === 0) {
return []; return [];
} }
@@ -108,6 +113,20 @@ const getByEndlessListId = async (listId, { before, search, userIds, labelIds })
query += ` AND card_label.label_id IN (${inValues.join(', ')})`; query += ` AND card_label.label_id IN (${inValues.join(', ')})`;
} }
if (hasExcludedLabelIds) {
const inValues = excludedLabelIds.map((labelId) => {
queryValues.push(labelId);
return `$${queryValues.length}`;
});
query += ` AND NOT EXISTS (
SELECT 1
FROM card_label AS excluded_card_label
WHERE excluded_card_label.card_id = card.id
AND excluded_card_label.label_id IN (${inValues.join(', ')})
)`;
}
query += ` LIMIT ${LIMIT}`; query += ` LIMIT ${LIMIT}`;
let queryResult; let queryResult;
@@ -0,0 +1,82 @@
const { expect } = require('chai');
describe('Card label filters', () => {
let originalSendNativeQuery;
let nativeQueryCalls;
beforeEach(() => {
nativeQueryCalls = [];
originalSendNativeQuery = sails.sendNativeQuery;
sails.sendNativeQuery = async (query, values) => {
nativeQueryCalls.push({ query, values });
return {
rows: [
{
id: 'card-1',
list_id: 'list-1',
board_id: 'board-1',
type: Card.Types.PROJECT,
position: null,
name: 'Frontend task',
list_changed_at: new Date('2026-01-01T00:00:00.000Z'),
},
],
};
};
});
afterEach(() => {
sails.sendNativeQuery = originalSendNativeQuery;
});
it('uses an included label condition when labelIds are provided', async () => {
await Card.qm.getByEndlessListId('list-1', {
labelIds: ['label-1', 'label-2'],
});
expect(nativeQueryCalls).to.have.length(1);
expect(nativeQueryCalls[0].query).to.include('LEFT JOIN card_label');
expect(nativeQueryCalls[0].query).to.include('card_label.label_id IN ($2, $3)');
expect(nativeQueryCalls[0].values).to.deep.equal(['list-1', 'label-1', 'label-2']);
});
it('uses a NOT EXISTS condition when excludedLabelIds are provided', async () => {
await Card.qm.getByEndlessListId('list-1', {
excludedLabelIds: ['label-1', 'label-2'],
});
expect(nativeQueryCalls).to.have.length(1);
expect(nativeQueryCalls[0].query).to.include('NOT EXISTS');
expect(nativeQueryCalls[0].query).to.include('excluded_card_label.card_id = card.id');
expect(nativeQueryCalls[0].query).to.include('excluded_card_label.label_id IN ($2, $3)');
expect(nativeQueryCalls[0].values).to.deep.equal(['list-1', 'label-1', 'label-2']);
});
it('combines included and excluded labels with AND semantics', async () => {
await Card.qm.getByEndlessListId('list-1', {
labelIds: ['label-1'],
excludedLabelIds: ['label-2'],
});
expect(nativeQueryCalls).to.have.length(1);
expect(nativeQueryCalls[0].query).to.include('card_label.label_id IN ($2)');
expect(nativeQueryCalls[0].query).to.include('NOT EXISTS');
expect(nativeQueryCalls[0].query).to.include('excluded_card_label.label_id IN ($3)');
expect(nativeQueryCalls[0].values).to.deep.equal(['list-1', 'label-1', 'label-2']);
});
it('combines search and excluded label filters', async () => {
await Card.qm.getByEndlessListId('list-1', {
search: 'task',
excludedLabelIds: ['label-1'],
});
expect(nativeQueryCalls).to.have.length(1);
expect(nativeQueryCalls[0].query).to.include('card.name ILIKE ALL');
expect(nativeQueryCalls[0].query).to.include('NOT EXISTS');
expect(nativeQueryCalls[0].query).to.include('excluded_card_label.label_id IN ($3)');
expect(nativeQueryCalls[0].values).to.deep.equal(['list-1', 'task', 'label-1']);
});
});