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