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 {
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],
);
@@ -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,
};
+1
View File
@@ -247,6 +247,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
@@ -171,6 +171,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
@@ -64,6 +64,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,10 +7,11 @@ 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 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,
@@ -63,6 +64,7 @@ export default class extends BaseModel {
}),
filterUsers: many('User', 'filterBoards'),
filterLabels: many('Label', 'filterBoards'),
filterExcludedLabels: many('Label', 'filterExcludedBoards'),
};
static reducer({ type, payload }, Board) {
@@ -254,6 +256,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,
@@ -381,12 +406,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;
@@ -444,6 +467,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]]);
});
});