Add negative label filtering for cards
This commit is contained in:
@@ -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,12 +14,29 @@ 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 NEXT_FILTER_MODE_BY_MODE = {
|
||||
[LabelFilterModes.NONE]: LabelFilterModes.INCLUDE,
|
||||
[LabelFilterModes.INCLUDE]: LabelFilterModes.EXCLUDE,
|
||||
[LabelFilterModes.EXCLUDE]: LabelFilterModes.NONE,
|
||||
};
|
||||
|
||||
const Item = React.memo(
|
||||
({
|
||||
id,
|
||||
index,
|
||||
isActive,
|
||||
mode,
|
||||
isFilterModeEnabled,
|
||||
onSelect,
|
||||
onDeselect,
|
||||
onModeChange,
|
||||
onEdit,
|
||||
}) => {
|
||||
const selectLabelById = useMemo(() => selectors.makeSelectLabelById(), []);
|
||||
|
||||
const label = useSelector((state) => selectLabelById(state, id));
|
||||
@@ -31,13 +48,24 @@ const Item = React.memo(({ id, index, isActive, onSelect, onDeselect, onEdit })
|
||||
|
||||
const handleToggleClick = useCallback(() => {
|
||||
if (label.isPersisted) {
|
||||
if (isActive) {
|
||||
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);
|
||||
@@ -55,7 +83,10 @@ const Item = React.memo(({ id, index, isActive, onSelect, onDeselect, onEdit })
|
||||
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
className={classNames(
|
||||
styles.name,
|
||||
isActive && styles.nameActive,
|
||||
isFilterModeEnabled && styles.nameFilterMode,
|
||||
((!isFilterModeEnabled && isActive) || mode === LabelFilterModes.INCLUDE) &&
|
||||
styles.nameActive,
|
||||
mode === LabelFilterModes.EXCLUDE && styles.nameExcluded,
|
||||
globalStyles[`background${upperFirst(camelCase(label.color))}`],
|
||||
)}
|
||||
onClick={handleToggleClick}
|
||||
@@ -79,15 +110,27 @@ const Item = React.memo(({ id, index, isActive, onSelect, onDeselect, onEdit })
|
||||
}}
|
||||
</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,7 +28,18 @@ const StepTypes = {
|
||||
EDIT: 'EDIT',
|
||||
};
|
||||
|
||||
const LabelsStep = React.memo(({ currentIds, cardId, title, onSelect, onDeselect, onBack }) => {
|
||||
const LabelsStep = React.memo(
|
||||
({
|
||||
currentIds,
|
||||
currentModes,
|
||||
isFilterModeEnabled,
|
||||
cardId,
|
||||
title,
|
||||
onSelect,
|
||||
onDeselect,
|
||||
onModeChange,
|
||||
onBack,
|
||||
}) => {
|
||||
const labels = useSelector(selectors.selectLabelsForCurrentBoard);
|
||||
|
||||
const canAdd = useSelector((state) => {
|
||||
@@ -150,8 +161,11 @@ const LabelsStep = React.memo(({ currentIds, cardId, title, onSelect, onDeselect
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
@@ -183,20 +197,29 @@ const LabelsStep = React.memo(({ currentIds, cardId, title, onSelect, onDeselect
|
||||
</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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -109,6 +109,12 @@ export default class extends BaseModel {
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
try {
|
||||
this.board.filterExcludedLabels.remove(this.id);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
deleteWithRelated() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,13 @@
|
||||
* schema:
|
||||
* type: string
|
||||
* 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:
|
||||
* 200:
|
||||
* description: Cards retrieved successfully
|
||||
@@ -189,6 +196,7 @@ module.exports = {
|
||||
},
|
||||
userIds: idsInput,
|
||||
labelIds: idsInput,
|
||||
excludedLabelIds: idsInput,
|
||||
},
|
||||
|
||||
exits: {
|
||||
@@ -235,12 +243,28 @@ module.exports = {
|
||||
}
|
||||
|
||||
let filterLabelIds;
|
||||
let filterExcludedLabelIds;
|
||||
if (inputs.labelIds) {
|
||||
const labels = await Label.qm.getByBoardId(list.boardId);
|
||||
const availableLabelIdsSet = new Set(sails.helpers.utils.mapRecords(labels));
|
||||
|
||||
filterLabelIds = _.uniq(inputs.labelIds.split(','));
|
||||
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, {
|
||||
@@ -248,6 +272,7 @@ module.exports = {
|
||||
search: inputs.search,
|
||||
userIds: filterUserIds,
|
||||
labelIds: filterLabelIds,
|
||||
excludedLabelIds: filterExcludedLabelIds,
|
||||
});
|
||||
|
||||
const cardIds = sails.helpers.utils.mapRecords(cards);
|
||||
|
||||
@@ -38,8 +38,13 @@ const getByListId = async (listId, { exceptIdOrIds, sort = ['position', 'id'] }
|
||||
return defaultFind(criteria, { sort });
|
||||
};
|
||||
|
||||
const getByEndlessListId = async (listId, { before, search, userIds, labelIds }) => {
|
||||
if (search || userIds || labelIds) {
|
||||
const getByEndlessListId = async (
|
||||
listId,
|
||||
{ before, search, userIds, labelIds, excludedLabelIds },
|
||||
) => {
|
||||
const hasExcludedLabelIds = excludedLabelIds && excludedLabelIds.length > 0;
|
||||
|
||||
if (search || userIds || labelIds || hasExcludedLabelIds) {
|
||||
if (userIds && userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -108,6 +113,20 @@ const getByEndlessListId = async (listId, { before, search, userIds, labelIds })
|
||||
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}`;
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user