diff --git a/client/src/actions/lists.js b/client/src/actions/lists.js index 1c705d27..78900f16 100644 --- a/client/src/actions/lists.js +++ b/client/src/actions/lists.js @@ -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, }; diff --git a/client/src/components/boards/BoardActions/Filters.jsx b/client/src/components/boards/BoardActions/Filters.jsx index b193a7ed..931053b3 100644 --- a/client/src/components/boards/BoardActions/Filters.jsx +++ b/client/src/components/boards/BoardActions/Filters.jsx @@ -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 ( + + ); +}); + +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(() => { ))} + {isListView && ( + + + + + {listIds.map((listId) => ( + + ))} + + )} { + 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 ( +
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, + jsx-a11y/no-static-element-interactions */} + + {list.type !== ListTypes.ACTIVE && ( + + )} + {list.name || t(`common.${list.type}`)} + +
+ ); +}); + +Item.propTypes = { + id: PropTypes.string.isRequired, + isActive: PropTypes.bool.isRequired, + onSelect: PropTypes.func.isRequired, + onDeselect: PropTypes.func.isRequired, +}; + +export default Item; diff --git a/client/src/components/lists/ListsFilterStep/Item.module.scss b/client/src/components/lists/ListsFilterStep/Item.module.scss new file mode 100644 index 00000000..9329e733 --- /dev/null +++ b/client/src/components/lists/ListsFilterStep/Item.module.scss @@ -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; + } +} diff --git a/client/src/components/lists/ListsFilterStep/ListsFilterStep.jsx b/client/src/components/lists/ListsFilterStep/ListsFilterStep.jsx new file mode 100644 index 00000000..2d7f3ac6 --- /dev/null +++ b/client/src/components/lists/ListsFilterStep/ListsFilterStep.jsx @@ -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 ( + <> + + {t(title, { + context: 'title', + })} + + + + {filteredLists.length > 0 && ( +
+ {filteredLists.map((list) => ( + + ))} +
+ )} +
+ + ); +}); + +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; diff --git a/client/src/components/lists/ListsFilterStep/ListsFilterStep.module.scss b/client/src/components/lists/ListsFilterStep/ListsFilterStep.module.scss new file mode 100644 index 00000000..7487283c --- /dev/null +++ b/client/src/components/lists/ListsFilterStep/ListsFilterStep.module.scss @@ -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; + } + } +} diff --git a/client/src/components/lists/ListsFilterStep/index.js b/client/src/components/lists/ListsFilterStep/index.js new file mode 100644 index 00000000..26267e68 --- /dev/null +++ b/client/src/components/lists/ListsFilterStep/index.js @@ -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; diff --git a/client/src/constants/ActionTypes.js b/client/src/constants/ActionTypes.js index a947e279..1475c7e4 100644 --- a/client/src/constants/ActionTypes.js +++ b/client/src/constants/ActionTypes.js @@ -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 */ diff --git a/client/src/constants/EntryActionTypes.js b/client/src/constants/EntryActionTypes.js index 3a99a4f0..2d39803e 100755 --- a/client/src/constants/EntryActionTypes.js +++ b/client/src/constants/EntryActionTypes.js @@ -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 */ diff --git a/client/src/entry-actions/lists.js b/client/src/entry-actions/lists.js index 13d3e7cc..e1e3753f 100755 --- a/client/src/entry-actions/lists.js +++ b/client/src/entry-actions/lists.js @@ -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, }; diff --git a/client/src/locales/en-US/core.js b/client/src/locales/en-US/core.js index bf906854..00b984ee 100644 --- a/client/src/locales/en-US/core.js +++ b/client/src/locales/en-US/core.js @@ -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.', diff --git a/client/src/models/Board.js b/client/src/models/Board.js index a4697fe5..71890113 100755 --- a/client/src/models/Board.js +++ b/client/src/models/Board.js @@ -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) { diff --git a/client/src/models/List.js b/client/src/models/List.js index 2c8d72a0..a4ad68a3 100755 --- a/client/src/models/List.js +++ b/client/src/models/List.js @@ -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(); } } diff --git a/client/src/sagas/core/services/lists.js b/client/src/sagas/core/services/lists.js index b38e6b6c..deda6a8d 100644 --- a/client/src/sagas/core/services/lists.js +++ b/client/src/sagas/core/services/lists.js @@ -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, }; diff --git a/client/src/sagas/core/watchers/lists.js b/client/src/sagas/core/watchers/lists.js index 9481e2b9..0ee4a7b7 100644 --- a/client/src/sagas/core/watchers/lists.js +++ b/client/src/sagas/core/watchers/lists.js @@ -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), + ), ]); } diff --git a/client/src/selectors/boards.js b/client/src/selectors/boards.js index 4d038494..485b7a33 100644 --- a/client/src/selectors/boards.js +++ b/client/src/selectors/boards.js @@ -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, };