Merge pull request #1683 from symonbaikov/feat/negative-label-filter

Add negative label filtering for cards

Two conflicts, both from work that landed while this branch was open.

In the endless list query, master had added an ORDER BY so the cursor and the
limit agree. The exclusion clause belongs in the WHERE part, so it is placed
before it rather than after; the other way round the statement does not parse.

The label item had been restructured here for the tri-state filter and had
gained a tooltip on master. The restructured version is kept and the tooltip
put back on top of it, which also brings back the translation hook this branch
had dropped.
This commit is contained in:
Daniel Hiller
2026-09-17 00:46:31 +02:00
23 changed files with 641 additions and 238 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 {
createLabel,
createLabelFromCard,
@@ -214,4 +224,5 @@ export default {
handleLabelFromCardRemove,
addLabelToBoardFilter,
removeLabelFromBoardFilter,
updateLabelFilterInBoard,
};
@@ -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],
);
@@ -178,14 +193,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) => (
@@ -193,6 +211,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 {
+101 -58
View File
@@ -16,83 +16,126 @@ import { Button } from 'semantic-ui-react';
import { Tooltip } from '../../../lib/custom-ui';
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 [t] = useTranslation();
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 [t] = useTranslation();
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 && (
<Tooltip content={t('action.edit', { context: 'title' })}>
<Button
icon="pencil"
size="small"
floated="right"
disabled={!label.isPersisted}
className={styles.editButton}
onClick={handleEditClick}
/>
</Tooltip>
)}
onClick={handleToggleClick}
>
{label.name}
</span>
{canEdit && (
<Tooltip content={t('action.edit', { context: 'title' })}>
<Button
icon="pencil"
size="small"
floated="right"
disabled={!label.isPersisted}
className={styles.editButton}
onClick={handleEditClick}
/>
</Tooltip>
)}
</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,
};
+1
View File
@@ -271,6 +271,7 @@ export default {
LABEL_FROM_CARD_REMOVE_HANDLE: 'LABEL_FROM_CARD_REMOVE_HANDLE',
LABEL_TO_BOARD_FILTER_ADD: 'LABEL_TO_BOARD_FILTER_ADD',
LABEL_FROM_BOARD_FILTER_REMOVE: 'LABEL_FROM_BOARD_FILTER_REMOVE',
LABEL_FILTER_IN_BOARD_UPDATE: 'LABEL_FILTER_IN_BOARD_UPDATE',
/* Lists */
+1
View File
@@ -182,6 +182,7 @@ export default {
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_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 */
+6
View File
@@ -73,6 +73,12 @@ export const BoardMembershipRoles = {
VIEWER: 'viewer',
};
export const LabelFilterModes = {
NONE: 'none',
INCLUDE: 'include',
EXCLUDE: 'exclude',
};
export const ListTypes = {
ACTIVE: 'active',
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 {
createLabelInCurrentBoard,
createLabelFromCard,
@@ -139,4 +147,5 @@ export default {
handleLabelFromCardRemove,
addLabelToFilterInCurrentBoard,
removeLabelFromFilterInCurrentBoard,
updateLabelFilterInCurrentBoard,
};
+30 -6
View File
@@ -7,11 +7,12 @@ import { attr, fk, many } from 'redux-orm';
import BaseModel from './BaseModel';
import buildSearchParts from '../utils/build-search-parts';
import filterCardLabels from '../utils/filter-card-labels';
import { isListKanban } from '../utils/record-helpers';
import { recallBoardView } from '../utils/board-view-memory';
import ActionTypes from '../constants/ActionTypes';
import Config from '../constants/Config';
import { BoardContexts, BoardViews } from '../constants/Enums';
import { BoardContexts, BoardViews, LabelFilterModes } from '../constants/Enums';
const prepareFetchedBoard = (board) => ({
...board,
@@ -67,6 +68,7 @@ export default class extends BaseModel {
}),
filterUsers: many('User', 'filterBoards'),
filterLabels: many('Label', 'filterBoards'),
filterExcludedLabels: many('Label', 'filterExcludedBoards'),
};
static reducer({ type, payload }, Board) {
@@ -258,6 +260,29 @@ export default class extends BaseModel {
Board.withId(payload.boardId).filterLabels.remove(payload.id);
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:
Board.withId(payload.boardId).update({
isActivitiesFetching: true,
@@ -385,12 +410,10 @@ export default class extends BaseModel {
}
const filterLabelIds = this.filterLabels.toRefArray().map((label) => label.id);
const filterExcludedLabelIds = this.filterExcludedLabels.toRefArray().map((label) => label.id);
if (filterLabelIds.length > 0) {
cardModels = cardModels.filter((cardModel) => {
const labels = cardModel.labels.toRefArray();
return labels.some((label) => filterLabelIds.includes(label.id));
});
if (filterLabelIds.length > 0 || filterExcludedLabelIds.length > 0) {
cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds);
}
return cardModels;
@@ -448,6 +471,7 @@ export default class extends BaseModel {
deleteClearable() {
this.filterUsers.clear();
this.filterLabels.clear();
this.filterExcludedLabels.clear();
}
deleteRelated(exceptMemberUserId, soft) {
+6
View File
@@ -109,6 +109,12 @@ export default class extends BaseModel {
} catch {
/* empty */
}
try {
this.board.filterExcludedLabels.remove(this.id);
} catch {
/* empty */
}
}
deleteWithRelated() {
+7 -5
View File
@@ -7,6 +7,7 @@ import { attr, fk } from 'redux-orm';
import BaseModel from './BaseModel';
import buildSearchParts from '../utils/build-search-parts';
import filterCardLabels from '../utils/filter-card-labels';
import { isListFinite } from '../utils/record-helpers';
import ActionTypes from '../constants/ActionTypes';
import Config from '../constants/Config';
@@ -99,6 +100,7 @@ export default class extends BaseModel {
case ActionTypes.IN_BOARD_SEARCH:
case ActionTypes.LABEL_TO_BOARD_FILTER_ADD:
case ActionTypes.LABEL_FROM_BOARD_FILTER_REMOVE:
case ActionTypes.LABEL_FILTER_IN_BOARD_UPDATE:
if (payload.currentListId) {
List.withId(payload.currentListId).update({
lastCard: null,
@@ -379,12 +381,12 @@ export default class extends BaseModel {
}
const filterLabelIds = this.board.filterLabels.toRefArray().map((label) => label.id);
const filterExcludedLabelIds = this.board.filterExcludedLabels
.toRefArray()
.map((label) => label.id);
if (filterLabelIds.length > 0) {
cardModels = cardModels.filter((cardModel) => {
const labels = cardModel.labels.toRefArray();
return labels.some((label) => filterLabelIds.includes(label.id));
});
if (filterLabelIds.length > 0 || filterExcludedLabelIds.length > 0) {
cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds);
}
return cardModels;
+5
View File
@@ -37,6 +37,9 @@ export function* fetchCards(listId) {
const { search } = yield select(selectors.selectBoardById, boardId);
const filterUserIds = yield select(selectors.selectFilterUserIdsForCurrentBoard);
const filterLabelIds = yield select(selectors.selectFilterLabelIdsForCurrentBoard);
const filterExcludedLabelIds = yield select(
selectors.selectFilterExcludedLabelIdsForCurrentBoard,
);
function* getCardsRequest() {
const response = {};
@@ -46,6 +49,8 @@ export function* fetchCards(listId) {
search: (search && search.trim()) || undefined,
userIds: filterUserIds.length > 0 ? filterUserIds.join(',') : undefined,
labelIds: filterLabelIds.length > 0 ? filterLabelIds.join(',') : undefined,
excludedLabelIds:
filterExcludedLabelIds.length > 0 ? filterExcludedLabelIds.join(',') : undefined,
before: lastCard || undefined,
});
} catch (error) {
+14
View File
@@ -205,6 +205,18 @@ export function* removeLabelFromFilterInCurrentBoard(id) {
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 {
createLabel,
createLabelInCurrentBoard,
@@ -225,4 +237,6 @@ export default {
addLabelToFilterInCurrentBoard,
removeLabelFromBoardFilter,
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 } }) =>
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(
orm,
(_, id) => id,
@@ -491,5 +509,6 @@ export default {
selectActivityIdsForCurrentBoard,
selectFilterUserIdsForCurrentBoard,
selectFilterLabelIdsForCurrentBoard,
selectFilterExcludedLabelIdsForCurrentBoard,
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]]);
});
});