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 {
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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:
|
* 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);
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user