Merge pull request #1683 from symonbaikov/feat/negative-label-filter
Add negative label filtering for cards Two conflicts, both from work that landed while this branch was open. In the endless list query, master had added an ORDER BY so the cursor and the limit agree. The exclusion clause belongs in the WHERE part, so it is placed before it rather than after; the other way round the statement does not parse. The label item had been restructured here for the tri-state filter and had gained a tooltip on master. The restructured version is kept and the tooltip put back on top of it, which also brings back the translation hook this branch had dropped.
This commit is contained in:
@@ -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],
|
||||||
);
|
);
|
||||||
@@ -178,14 +193,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) => (
|
||||||
@@ -193,6 +211,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 {
|
||||||
|
|||||||
@@ -16,83 +16,126 @@ import { Button } from 'semantic-ui-react';
|
|||||||
import { Tooltip } from '../../../lib/custom-ui';
|
import { Tooltip } from '../../../lib/custom-ui';
|
||||||
|
|
||||||
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(
|
||||||
const [t] = useTranslation();
|
({
|
||||||
|
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);
|
const [t] = useTranslation();
|
||||||
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 && (
|
||||||
|
<Tooltip content={t('action.edit', { context: 'title' })}>
|
||||||
|
<Button
|
||||||
|
icon="pencil"
|
||||||
|
size="small"
|
||||||
|
floated="right"
|
||||||
|
disabled={!label.isPersisted}
|
||||||
|
className={styles.editButton}
|
||||||
|
onClick={handleEditClick}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
onClick={handleToggleClick}
|
</div>
|
||||||
>
|
);
|
||||||
{label.name}
|
|
||||||
</span>
|
|
||||||
{canEdit && (
|
|
||||||
<Tooltip content={t('action.edit', { context: 'title' })}>
|
|
||||||
<Button
|
|
||||||
icon="pencil"
|
|
||||||
size="small"
|
|
||||||
floated="right"
|
|
||||||
disabled={!label.isPersisted}
|
|
||||||
className={styles.editButton}
|
|
||||||
onClick={handleEditClick}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,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 */
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,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 */
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,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,11 +7,12 @@ 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 { recallBoardView } from '../utils/board-view-memory';
|
import { recallBoardView } from '../utils/board-view-memory';
|
||||||
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,
|
||||||
@@ -67,6 +68,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) {
|
||||||
@@ -258,6 +260,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,
|
||||||
@@ -385,12 +410,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;
|
||||||
@@ -448,6 +471,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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISO 8601 allows forms Postgres rejects as a timestamp (`2026`, `2026-W35-3`,
|
// ISO 8601 allows forms Postgres rejects as a timestamp (`2026`, `2026-W35-3`,
|
||||||
@@ -257,6 +281,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(', ')})
|
||||||
|
)`;
|
||||||
|
}
|
||||||
|
|
||||||
// Must match the cursor built from the last returned card, otherwise the
|
// Must match the cursor built from the last returned card, otherwise the
|
||||||
// limit cuts an arbitrary slice and pages skip or repeat cards
|
// limit cuts an arbitrary slice and pages skip or repeat cards
|
||||||
query += ' ORDER BY card.list_changed_at DESC, card.id DESC';
|
query += ' ORDER BY card.list_changed_at DESC, card.id DESC';
|
||||||
|
|||||||
@@ -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