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

Adds a per-list filter to the board action bar, visible only in
List View. Mirrors the existing transient label/member filter
pattern: filterLists is a many-relation on the Board ORM model,
toggled via entry actions and reset on board navigation. Empty
selection keeps the current behavior; non-empty selection shows
only cards whose listId is in the chosen set. Filter UI is hidden
in Kanban/Grid views where columns already convey the same info.

Closes #1524
This commit is contained in:
Symon
2026-05-02 09:24:48 +03:00
parent a8dcd7cef3
commit 401c0412cb
16 changed files with 421 additions and 0 deletions
+18
View File
@@ -201,6 +201,22 @@ const handleListDelete = (list, cards) => ({
},
});
const addListToBoardFilter = (id, boardId) => ({
type: ActionTypes.LIST_TO_BOARD_FILTER_ADD,
payload: {
id,
boardId,
},
});
const removeListFromBoardFilter = (id, boardId) => ({
type: ActionTypes.LIST_FROM_BOARD_FILTER_REMOVE,
payload: {
id,
boardId,
},
});
export default {
createList,
handleListCreate,
@@ -212,4 +228,6 @@ export default {
handleListClear,
deleteList,
handleListDelete,
addListToBoardFilter,
removeListFromBoardFilter,
};
@@ -5,6 +5,7 @@
import debounce from 'lodash/debounce';
import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
@@ -16,17 +17,45 @@ import { Input } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useNestedRef } from '../../../hooks';
import { BoardViews } from '../../../constants/Enums';
import UserAvatar from '../../users/UserAvatar';
import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
import LabelChip from '../../labels/LabelChip';
import LabelsStep from '../../labels/LabelsStep';
import ListsFilterStep from '../../lists/ListsFilterStep';
import styles from './Filters.module.scss';
const FilterListChip = React.memo(({ id, onClick }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const list = useSelector((state) => selectListById(state, id));
const [t] = useTranslation();
const handleClick = useCallback(() => {
onClick(id);
}, [id, onClick]);
if (!list) {
return null;
}
return (
<button type="button" className={styles.filterButton} onClick={handleClick}>
<span className={styles.filterLabel}>{list.name || t(`common.${list.type}`)}</span>
</button>
);
});
FilterListChip.propTypes = {
id: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
const Filters = React.memo(() => {
const board = useSelector(selectors.selectCurrentBoard);
const userIds = useSelector(selectors.selectFilterUserIdsForCurrentBoard);
const labelIds = useSelector(selectors.selectFilterLabelIdsForCurrentBoard);
const listIds = useSelector(selectors.selectFilterListIdsForCurrentBoard);
const currentUserId = useSelector(selectors.selectCurrentUserId);
const withCurrentUserSelector = useSelector(
@@ -98,6 +127,20 @@ const Filters = React.memo(() => {
[dispatch],
);
const handleListSelect = useCallback(
(listId) => {
dispatch(entryActions.addListToFilterInCurrentBoard(listId));
},
[dispatch],
);
const handleListDeselect = useCallback(
(listId) => {
dispatch(entryActions.removeListFromFilterInCurrentBoard(listId));
},
[dispatch],
);
const handleLabelClick = useCallback(
({
currentTarget: {
@@ -144,8 +187,10 @@ const Filters = React.memo(() => {
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
const LabelsPopup = usePopup(LabelsStep);
const ListsFilterPopup = usePopup(ListsFilterStep);
const isSearchActive = search || isSearchFocused;
const isListView = board.view === BoardViews.LIST;
return (
<>
@@ -192,6 +237,26 @@ const Filters = React.memo(() => {
</span>
))}
</span>
{isListView && (
<span className={styles.filter}>
<ListsFilterPopup
currentIds={listIds}
title="common.filterByLists"
onSelect={handleListSelect}
onDeselect={handleListDeselect}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.lists')}:`}</span>
{listIds.length === 0 && (
<span className={styles.filterLabel}>{t('common.all')}</span>
)}
</button>
</ListsFilterPopup>
{listIds.map((listId) => (
<FilterListChip key={listId} id={listId} onClick={handleListDeselect} />
))}
</span>
)}
<span className={styles.filter}>
<Input
ref={handleSearchFieldRef}
@@ -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;
+2
View File
@@ -272,6 +272,8 @@ export default {
LIST_DELETE__SUCCESS: 'LIST_DELETE__SUCCESS',
LIST_DELETE__FAILURE: 'LIST_DELETE__FAILURE',
LIST_DELETE_HANDLE: 'LIST_DELETE_HANDLE',
LIST_TO_BOARD_FILTER_ADD: 'LIST_TO_BOARD_FILTER_ADD',
LIST_FROM_BOARD_FILTER_REMOVE: 'LIST_FROM_BOARD_FILTER_REMOVE',
/* Cards */
+2
View File
@@ -186,6 +186,8 @@ export default {
LIST_CLEAR_HANDLE: `${PREFIX}/LIST_CLEAR_HANDLE`,
LIST_DELETE: `${PREFIX}/LIST_DELETE`,
LIST_DELETE_HANDLE: `${PREFIX}/LIST_DELETE_HANDLE`,
LIST_TO_FILTER_IN_CURRENT_BOARD_ADD: `${PREFIX}/LIST_TO_FILTER_IN_CURRENT_BOARD_ADD`,
LIST_FROM_FILTER_IN_CURRENT_BOARD_REMOVE: `${PREFIX}/LIST_FROM_FILTER_IN_CURRENT_BOARD_REMOVE`,
/* Cards */
+16
View File
@@ -95,6 +95,20 @@ const handleListDelete = (list, cards) => ({
},
});
const addListToFilterInCurrentBoard = (id) => ({
type: EntryActionTypes.LIST_TO_FILTER_IN_CURRENT_BOARD_ADD,
payload: {
id,
},
});
const removeListFromFilterInCurrentBoard = (id) => ({
type: EntryActionTypes.LIST_FROM_FILTER_IN_CURRENT_BOARD_REMOVE,
payload: {
id,
},
});
export default {
createListInCurrentBoard,
handleListCreate,
@@ -108,4 +122,6 @@ export default {
handleListClear,
deleteList,
handleListDelete,
addListToFilterInCurrentBoard,
removeListFromFilterInCurrentBoard,
};
+1
View File
@@ -206,6 +206,7 @@ export default {
excludedEvents: 'Excluded events',
expandTaskListsByDefault: 'Expand task lists by default',
filterByLabels_title: 'Filter By Labels',
filterByLists_title: 'Filter By Lists',
filterByMembers_title: 'Filter By Members',
forPersonalProjects: 'For personal projects.',
forTeamBasedProjects: 'For team-based projects.',
+16
View File
@@ -63,6 +63,7 @@ export default class extends BaseModel {
}),
filterUsers: many('User', 'filterBoards'),
filterLabels: many('Label', 'filterBoards'),
filterLists: many('List', 'filterBoards'),
};
static reducer({ type, payload }, Board) {
@@ -253,6 +254,14 @@ export default class extends BaseModel {
case ActionTypes.LABEL_FROM_BOARD_FILTER_REMOVE:
Board.withId(payload.boardId).filterLabels.remove(payload.id);
break;
case ActionTypes.LIST_TO_BOARD_FILTER_ADD:
Board.withId(payload.boardId).filterLists.add(payload.id);
break;
case ActionTypes.LIST_FROM_BOARD_FILTER_REMOVE:
Board.withId(payload.boardId).filterLists.remove(payload.id);
break;
case ActionTypes.ACTIVITIES_IN_BOARD_FETCH:
Board.withId(payload.boardId).update({
@@ -389,6 +398,12 @@ export default class extends BaseModel {
});
}
const filterListIds = this.filterLists.toRefArray().map((list) => list.id);
if (filterListIds.length > 0) {
cardModels = cardModels.filter((cardModel) => filterListIds.includes(cardModel.listId));
}
return cardModels;
}
@@ -444,6 +459,7 @@ export default class extends BaseModel {
deleteClearable() {
this.filterUsers.clear();
this.filterLabels.clear();
this.filterLists.clear();
}
deleteRelated(exceptMemberUserId, soft) {
+7
View File
@@ -443,6 +443,13 @@ export default class extends BaseModel {
deleteWithRelated(soft) {
this.deleteRelated(soft);
try {
this.board.filterLists.remove(this.id);
} catch {
/* empty */
}
this.delete();
}
}
+24
View File
@@ -261,6 +261,26 @@ export function* handleListDelete(list, cards) {
yield put(actions.handleListDelete(list, cards));
}
export function* addListToBoardFilter(id, boardId) {
yield put(actions.addListToBoardFilter(id, boardId));
}
export function* addListToFilterInCurrentBoard(id) {
const { boardId } = yield select(selectors.selectPath);
yield call(addListToBoardFilter, id, boardId);
}
export function* removeListFromBoardFilter(id, boardId) {
yield put(actions.removeListFromBoardFilter(id, boardId));
}
export function* removeListFromFilterInCurrentBoard(id) {
const { boardId } = yield select(selectors.selectPath);
yield call(removeListFromBoardFilter, id, boardId);
}
export default {
createList,
createListInCurrentBoard,
@@ -275,4 +295,8 @@ export default {
handleListClear,
deleteList,
handleListDelete,
addListToBoardFilter,
addListToFilterInCurrentBoard,
removeListFromBoardFilter,
removeListFromFilterInCurrentBoard,
};
+6
View File
@@ -44,5 +44,11 @@ export default function* listsWatchers() {
takeEvery(EntryActionTypes.LIST_DELETE_HANDLE, ({ payload: { list, cards } }) =>
services.handleListDelete(list, cards),
),
takeEvery(EntryActionTypes.LIST_TO_FILTER_IN_CURRENT_BOARD_ADD, ({ payload: { id } }) =>
services.addListToFilterInCurrentBoard(id),
),
takeEvery(EntryActionTypes.LIST_FROM_FILTER_IN_CURRENT_BOARD_REMOVE, ({ payload: { id } }) =>
services.removeListFromFilterInCurrentBoard(id),
),
]);
}
+19
View File
@@ -459,6 +459,24 @@ export const selectFilterLabelIdsForCurrentBoard = createSelector(
},
);
export const selectFilterListIdsForCurrentBoard = createSelector(
orm,
(state) => selectPath(state).boardId,
({ Board }, id) => {
if (!id) {
return id;
}
const boardModel = Board.withId(id);
if (!boardModel) {
return boardModel;
}
return boardModel.filterLists.toRefArray().map((list) => list.id);
},
);
export const selectIsBoardWithIdExists = createSelector(
orm,
(_, id) => id,
@@ -491,5 +509,6 @@ export default {
selectActivityIdsForCurrentBoard,
selectFilterUserIdsForCurrentBoard,
selectFilterLabelIdsForCurrentBoard,
selectFilterListIdsForCurrentBoard,
selectIsBoardWithIdExists,
};