Merge pull request #1648 from symonbaikov/feat/list-filter

feat: add filter by list (status) in list view

Three filters landed in the same places while this was open: excluded labels,
cards without a member, and now lists. They are independent of each other, so
every conflict resolves to keeping both sides.

The selector was the one exception worth naming. Both branches had written the
same body under a different name, so the merge put one header on one body; it
is now two complete selectors.
This commit is contained in:
Daniel Hiller
2026-09-17 02:03:04 +02:00
16 changed files with 340 additions and 781 deletions
@@ -1,259 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import debounce from 'lodash/debounce';
import React, { useCallback, useMemo, useState } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import { useDidUpdate } from '../../../lib/hooks';
import { usePopup } from '../../../lib/popup';
import { Input, Tooltip } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useNestedRef } from '../../../hooks';
import UserAvatar from '../../users/UserAvatar';
import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
import LabelChip from '../../labels/LabelChip';
import LabelsStep from '../../labels/LabelsStep';
import { LabelFilterModes } from '../../../constants/Enums';
import styles from './Filters.module.scss';
const Filters = React.memo(() => {
const board = useSelector(selectors.selectCurrentBoard);
const userIds = useSelector(selectors.selectFilterUserIdsForCurrentBoard);
const labelIds = useSelector(selectors.selectFilterLabelIdsForCurrentBoard);
const excludedLabelIds = useSelector(selectors.selectFilterExcludedLabelIdsForCurrentBoard);
const currentUserId = useSelector(selectors.selectCurrentUserId);
const withCurrentUserSelector = useSelector(
(state) => !!selectors.selectCurrentUserMembershipForCurrentBoard(state),
);
const dispatch = useDispatch();
const [t] = useTranslation();
const [search, setSearch] = useState(board.search);
const [isSearchFocused, setIsSearchFocused] = useState(false);
const debouncedSearch = useMemo(
() =>
debounce((nextSearch) => {
dispatch(entryActions.searchInCurrentBoard(nextSearch));
}, 400),
[dispatch],
);
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
const labelModes = useMemo(
() => ({
...labelIds.reduce(
(result, labelId) => ({
...result,
[labelId]: LabelFilterModes.INCLUDE,
}),
{},
),
...excludedLabelIds.reduce(
(result, labelId) => ({
...result,
[labelId]: LabelFilterModes.EXCLUDE,
}),
{},
),
}),
[labelIds, excludedLabelIds],
);
const cancelSearch = useCallback(() => {
debouncedSearch.cancel();
setSearch('');
dispatch(entryActions.searchInCurrentBoard(''));
searchFieldRef.current.blur();
}, [dispatch, debouncedSearch, searchFieldRef]);
const handleUserSelect = useCallback(
(userId) => {
dispatch(entryActions.addUserToFilterInCurrentBoard(userId));
},
[dispatch],
);
const handleCurrentUserSelect = useCallback(() => {
dispatch(entryActions.addUserToFilterInCurrentBoard(currentUserId));
}, [currentUserId, dispatch]);
const handleUserDeselect = useCallback(
(userId) => {
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
},
[dispatch],
);
const handleUserClick = useCallback(
({
currentTarget: {
dataset: { id: userId },
},
}) => {
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
},
[dispatch],
);
const handleLabelClick = useCallback(
({
currentTarget: {
dataset: { id: labelId },
},
}) => {
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.NONE));
},
[dispatch],
);
const handleLabelModeChange = useCallback(
(labelId, mode) => {
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, mode));
},
[dispatch],
);
const handleSearchChange = useCallback(
(_, { value }) => {
setSearch(value);
debouncedSearch(value);
},
[debouncedSearch],
);
const handleSearchFocus = useCallback(() => {
setIsSearchFocused(true);
}, []);
const handleSearchKeyDown = useCallback(
(event) => {
if (event.key === 'Escape') {
cancelSearch();
}
},
[cancelSearch],
);
const handleSearchBlur = useCallback(() => {
setIsSearchFocused(false);
}, []);
const handleCancelSearchClick = useCallback(() => {
cancelSearch();
}, [cancelSearch]);
useDidUpdate(() => {
setSearch(board.search);
}, [board.search]);
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
const LabelsPopup = usePopup(LabelsStep);
const isSearchActive = search || isSearchFocused;
const handleNoMemberClick = useCallback(() => {
if (board.filterNoMember) {
dispatch(entryActions.removeNoMemberFromFilterInCurrentBoard());
} else {
dispatch(entryActions.setNoMemberToFilterInCurrentBoard());
}
}, [dispatch, board.filterNoMember]);
return (
<>
<span className={styles.filter}>
<BoardMembershipsPopup
currentUserIds={userIds}
title="common.filterByMembers"
onUserSelect={handleUserSelect}
onUserDeselect={handleUserDeselect}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.members')}:`}</span>
{userIds.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
</button>
</BoardMembershipsPopup>
<button
type="button"
className={classNames(styles.filterButton, styles.filterLabel)}
onClick={handleNoMemberClick}
>
{t('common.noMember')}
</button>
{userIds.length === 0 && withCurrentUserSelector && (
<Tooltip content={t('action.filterByCurrentUser')}>
<button type="button" className={styles.filterButton} onClick={handleCurrentUserSelect}>
<span className={styles.filterLabel}>
<Icon fitted name="target" className={styles.filterLabelIcon} />
</span>
</button>
</Tooltip>
)}
{userIds.map((userId) => (
<span key={userId} className={styles.filterItem}>
<UserAvatar id={userId} size="tiny" onClick={handleUserClick} />
</span>
))}
</span>
<span className={styles.filter}>
<LabelsPopup
currentIds={[]}
currentModes={labelModes}
isFilterModeEnabled
title="common.filterByLabels"
onModeChange={handleLabelModeChange}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.labels')}:`}</span>
{labelIds.length === 0 && excludedLabelIds.length === 0 && (
<span className={styles.filterLabel}>{t('common.all')}</span>
)}
</button>
</LabelsPopup>
{labelIds.map((labelId) => (
<span key={labelId} className={styles.filterItem}>
<LabelChip id={labelId} size="small" onClick={handleLabelClick} />
</span>
))}
{excludedLabelIds.map((labelId) => (
<span key={labelId} className={styles.filterItem}>
<LabelChip id={labelId} size="small" isExcluded onClick={handleLabelClick} />
</span>
))}
</span>
<span className={styles.filter}>
<Input
ref={handleSearchFieldRef}
value={search}
placeholder={t('common.searchCards')}
maxLength={128}
icon={
isSearchActive ? (
<Icon link name="cancel" onClick={handleCancelSearchClick} />
) : (
'search'
)
}
className={classNames(styles.search, !isSearchActive && styles.searchInactive)}
onFocus={handleSearchFocus}
onKeyDown={handleSearchKeyDown}
onChange={handleSearchChange}
onBlur={handleSearchBlur}
/>
</span>
</>
);
});
export default Filters;
@@ -0,0 +1,62 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import selectors from '../../../selectors';
import { ListTypes } from '../../../constants/Enums';
import { ListTypeIcons } from '../../../constants/Icons';
import styles from './Item.module.scss';
const Item = React.memo(({ id, isActive, onSelect, onDeselect }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const list = useSelector((state) => selectListById(state, id));
const [t] = useTranslation();
const handleToggleClick = useCallback(() => {
if (!list.isPersisted) {
return;
}
if (isActive) {
onDeselect(id);
} else {
onSelect(id);
}
}, [id, isActive, onSelect, onDeselect, list.isPersisted]);
return (
<div className={styles.wrapper}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<span
className={classNames(styles.name, isActive && styles.nameActive)}
onClick={handleToggleClick}
>
{list.type !== ListTypes.ACTIVE && (
<Icon name={ListTypeIcons[list.type]} className={styles.nameIcon} />
)}
{list.name || t(`common.${list.type}`)}
</span>
</div>
);
});
Item.propTypes = {
id: PropTypes.string.isRequired,
isActive: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
};
export default Item;
@@ -0,0 +1,54 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.name {
background: rgba(9, 30, 66, 0.04);
border-radius: 3px;
color: #17394d;
cursor: pointer;
flex: 1 1 auto;
font-size: 14px;
overflow: hidden;
padding: 8px 32px 8px 10px;
position: relative;
text-overflow: ellipsis;
&:hover {
background: rgba(9, 30, 66, 0.08);
}
}
.nameActive {
opacity: 0.45;
&:before {
bottom: 1px;
content: "Г";
font-size: 18px;
font-weight: normal;
line-height: 36px;
position: absolute;
right: 2px;
text-align: center;
transform: rotate(-135deg);
width: 36px;
}
}
.nameIcon {
color: rgba(9, 30, 66, 0.24);
font-size: 12px;
margin: 0 8px 0 0;
width: 14px;
}
.wrapper {
display: flex;
margin-bottom: 4px;
max-width: 280px;
white-space: nowrap;
}
}
@@ -0,0 +1,89 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Input, Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import { useField, useNestedRef } from '../../../hooks';
import Item from './Item';
import styles from './ListsFilterStep.module.scss';
const ListsFilterStep = React.memo(({ currentIds, title, onSelect, onDeselect, onBack }) => {
const lists = useSelector(selectors.selectAvailableListsForCurrentBoard);
const [t] = useTranslation();
const [search, handleSearchChange] = useField('');
const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
const filteredLists = useMemo(
() =>
lists.filter((list) =>
(list.name ? list.name.toLowerCase() : list.type).includes(cleanSearch),
),
[lists, cleanSearch],
);
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
useEffect(() => {
searchFieldRef.current.focus({
preventScroll: true,
});
}, [searchFieldRef]);
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Input
fluid
ref={handleSearchFieldRef}
value={search}
placeholder={t('common.searchLists')}
maxLength={128}
icon="search"
onChange={handleSearchChange}
/>
{filteredLists.length > 0 && (
<div className={styles.items}>
{filteredLists.map((list) => (
<Item
key={list.id}
id={list.id}
isActive={currentIds.includes(list.id)}
onSelect={onSelect}
onDeselect={onDeselect}
/>
))}
</div>
)}
</Popup.Content>
</>
);
});
ListsFilterStep.propTypes = {
currentIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
title: PropTypes.string,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
onBack: PropTypes.func,
};
ListsFilterStep.defaultProps = {
title: 'common.filterByLists',
onBack: undefined,
};
export default ListsFilterStep;
@@ -0,0 +1,32 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.items {
margin-top: 8px;
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
@supports (-moz-appearance: none) {
scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
scrollbar-width: thin;
}
&::-webkit-scrollbar {
width: 9px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-clip: padding-box;
border-left: 0.25em transparent solid;
border-radius: 3px;
}
}
}
@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import ListsFilterStep from './ListsFilterStep';
export default ListsFilterStep;