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 9d0e04f9..e69de29b 100644 --- a/client/src/components/boards/BoardActions/Filters.jsx +++ b/client/src/components/boards/BoardActions/Filters.jsx @@ -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 ( - <> - - - - - - {userIds.length === 0 && withCurrentUserSelector && ( - - - - )} - {userIds.map((userId) => ( - - - - ))} - - - - - - {labelIds.map((labelId) => ( - - - - ))} - {excludedLabelIds.map((labelId) => ( - - - - ))} - - - - ) : ( - 'search' - ) - } - className={classNames(styles.search, !isSearchActive && styles.searchInactive)} - onFocus={handleSearchFocus} - onKeyDown={handleSearchKeyDown} - onChange={handleSearchChange} - onBlur={handleSearchBlur} - /> - - - ); -}); - -export default Filters; diff --git a/client/src/components/lists/ListsFilterStep/Item.jsx b/client/src/components/lists/ListsFilterStep/Item.jsx new file mode 100644 index 00000000..5f1cb393 --- /dev/null +++ b/client/src/components/lists/ListsFilterStep/Item.jsx @@ -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 ( +
+ {/* 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 9dab996f..3a0e0f95 100644 --- a/client/src/constants/ActionTypes.js +++ b/client/src/constants/ActionTypes.js @@ -300,6 +300,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 1e767bb5..0de8801c 100755 --- a/client/src/constants/EntryActionTypes.js +++ b/client/src/constants/EntryActionTypes.js @@ -198,6 +198,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 c236b747..09433d58 100644 --- a/client/src/locales/en-US/core.js +++ b/client/src/locales/en-US/core.js @@ -262,6 +262,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 06d07601..e69de29b 100755 --- a/client/src/models/Board.js +++ b/client/src/models/Board.js @@ -1,522 +0,0 @@ -/*! - * Copyright (c) 2024 PLANKA Software GmbH - * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md - */ - -import { attr, fk, many } from 'redux-orm'; - -import BaseModel from './BaseModel'; -import buildSearchParts from '../utils/build-search-parts'; -import filterCardLabels from '../utils/filter-card-labels'; -import { isListKanban } from '../utils/record-helpers'; -import { recallBoardView } from '../utils/board-view-memory'; -import ActionTypes from '../constants/ActionTypes'; -import Config from '../constants/Config'; -import { BoardContexts, BoardViews, LabelFilterModes } from '../constants/Enums'; - -const prepareFetchedBoard = (board) => ({ - ...board, - isFetching: false, - context: BoardContexts.BOARD, - // Whatever this window was last looking at, where that choice still applies — - // see `utils/board-view-memory`. A board's configured default is where it - // starts, not where it has to stay. - view: recallBoardView(board.id, BoardContexts.BOARD) || board.defaultView, - search: '', -}); - -export default class extends BaseModel { - static modelName = 'Board'; - - static fields = { - id: attr(), - position: attr(), - name: attr(), - defaultView: attr(), - defaultCardType: attr(), - limitCardTypesToDefaultOne: attr(), - alwaysDisplayCardCreator: attr(), - displayCardAges: attr(), - expandTaskListsByDefault: attr(), - context: attr(), - view: attr(), - search: attr(), - isSubscribed: attr({ - getDefault: () => false, - }), - isFetching: attr({ - getDefault: () => null, - }), - lastActivityId: attr({ - getDefault: () => null, - }), - isActivitiesFetching: attr({ - getDefault: () => false, - }), - isAllActivitiesFetched: attr({ - getDefault: () => null, - }), - projectId: fk({ - to: 'Project', - as: 'project', - relatedName: 'boards', - }), - memberUsers: many({ - to: 'User', - through: 'BoardMembership', - relatedName: 'boards', - }), - filterUsers: many('User', 'filterBoards'), - filterLabels: many('Label', 'filterBoards'), - filterExcludedLabels: many('Label', 'filterExcludedBoards'), - filterNoMember: attr({ - getDefault: () => false, - }), - }; - - static reducer({ type, payload }, Board) { - switch (type) { - case ActionTypes.LOCATION_CHANGE_HANDLE: - if (payload.board) { - Board.upsert(prepareFetchedBoard(payload.board)); - } - - break; - case ActionTypes.LOCATION_CHANGE_HANDLE__BOARD_FETCH: - case ActionTypes.BOARD_FETCH: - Board.withId(payload.id).update({ - isFetching: true, - }); - - break; - case ActionTypes.SOCKET_RECONNECT_HANDLE: { - const boardIds = payload.boards.map(({ id }) => id); - - Board.all() - .toModelArray() - .forEach((boardModel) => { - if (boardModel.isFetching === null || !boardIds.includes(boardModel.id)) { - boardModel.deleteWithClearable(); - } - }); - - if (payload.board) { - const boardModel = Board.withId(payload.board.id); - - if (boardModel) { - boardModel.update(payload.board); - } else { - Board.upsert(prepareFetchedBoard(payload.board)); - } - } - - payload.boards.forEach((board) => { - Board.upsert(board); - }); - - break; - } - case ActionTypes.SOCKET_RECONNECT_HANDLE__CORE_FETCH: - Board.all() - .toModelArray() - .forEach((boardModel) => { - if (boardModel.id !== payload.currentBoardId) { - boardModel.update({ - isFetching: null, - }); - - boardModel.deleteRelated(payload.currentUserId, true); - } - }); - - break; - case ActionTypes.CORE_INITIALIZE: - if (payload.board) { - Board.upsert(prepareFetchedBoard(payload.board)); - } - - payload.boards.forEach((board) => { - Board.upsert(board); - }); - - break; - case ActionTypes.USER_UPDATE_HANDLE: - Board.all() - .toModelArray() - .forEach((boardModel) => { - if (!payload.boardIds.includes(boardModel.id)) { - boardModel.deleteWithRelated(true); - } - }); - - if (payload.board) { - Board.upsert(prepareFetchedBoard(payload.board)); - } - - if (payload.boards) { - payload.boards.forEach((board) => { - Board.upsert(board); - }); - } - - break; - case ActionTypes.USER_TO_BOARD_FILTER_ADD: { - const boardModel = Board.withId(payload.boardId); - - if (payload.replace) { - boardModel.filterUsers.clear(); - } - - boardModel.filterUsers.add(payload.id); - - break; - } - case ActionTypes.USER_FROM_BOARD_FILTER_REMOVE: - Board.withId(payload.boardId).filterUsers.remove(payload.id); - - break; - case ActionTypes.PROJECT_CREATE_HANDLE: - payload.boards.forEach((board) => { - Board.upsert(board); - }); - - break; - case ActionTypes.PROJECT_UPDATE_HANDLE: - case ActionTypes.PROJECT_MANAGER_CREATE_HANDLE: - case ActionTypes.BOARD_MEMBERSHIP_CREATE_HANDLE: - if (payload.board) { - Board.upsert(prepareFetchedBoard(payload.board)); - } - - if (payload.boards) { - payload.boards.forEach((board) => { - Board.upsert(board); - }); - } - - break; - case ActionTypes.BOARD_CREATE: - case ActionTypes.BOARD_CREATE_HANDLE: - case ActionTypes.BOARD_UPDATE__SUCCESS: - case ActionTypes.BOARD_UPDATE_HANDLE: - Board.upsert(payload.board); - - break; - case ActionTypes.BOARD_CREATE__SUCCESS: - Board.withId(payload.localId).delete(); - Board.upsert(payload.board); - - break; - case ActionTypes.BOARD_CREATE__FAILURE: - Board.withId(payload.localId).delete(); - - break; - case ActionTypes.BOARD_FETCH__SUCCESS: - Board.upsert(prepareFetchedBoard(payload.board)); - - break; - case ActionTypes.BOARD_FETCH__FAILURE: - Board.withId(payload.id).update({ - isFetching: null, - }); - - break; - case ActionTypes.BOARD_UPDATE: - Board.withId(payload.id).update(payload.data); - - break; - case ActionTypes.BOARD_CONTEXT_UPDATE: { - const boardModel = Board.withId(payload.id); - - boardModel.update({ - context: payload.value, - view: payload.value === BoardContexts.BOARD ? boardModel.defaultView : BoardViews.LIST, - }); - - break; - } - case ActionTypes.IN_BOARD_SEARCH: - Board.withId(payload.id).update({ - search: payload.value, - }); - - break; - case ActionTypes.BOARD_DELETE: - Board.withId(payload.id).deleteWithRelated(); - - break; - case ActionTypes.BOARD_DELETE__SUCCESS: - case ActionTypes.BOARD_DELETE_HANDLE: { - const boardModel = Board.withId(payload.board.id); - - if (boardModel) { - boardModel.deleteWithRelated(); - } - - break; - } - case ActionTypes.LABEL_TO_BOARD_FILTER_ADD: - Board.withId(payload.boardId).filterLabels.add(payload.id); - - break; - case ActionTypes.LABEL_FROM_BOARD_FILTER_REMOVE: - Board.withId(payload.boardId).filterLabels.remove(payload.id); - - 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: - Board.withId(payload.boardId).update({ - isActivitiesFetching: true, - }); - - break; - case ActionTypes.ACTIVITIES_IN_BOARD_FETCH__SUCCESS: - Board.withId(payload.boardId).update({ - isActivitiesFetching: false, - isAllActivitiesFetched: payload.activities.length < Config.ACTIVITIES_LIMIT, - ...(payload.activities.length > 0 && { - lastActivityId: payload.activities[payload.activities.length - 1].id, - }), - }); - - break; - case ActionTypes.NO_MEMBER_TO_BOARD_FILTER_SET: { - const boardModel = Board.withId(payload.boardId); - boardModel.filterUsers.clear(); - boardModel.update({ filterNoMember: true }); - break; - } - case ActionTypes.NO_MEMBER_FROM_BOARD_FILTER_REMOVE: { - const boardModel = Board.withId(payload.boardId); - boardModel.update({ filterNoMember: false }); - break; - } - default: - } - } - - getMembershipsQuerySet() { - return this.memberships.orderBy(['id.length', 'id']); - } - - getLabelsQuerySet() { - return this.labels.orderBy(['position', 'id.length', 'id']); - } - - getListsQuerySet() { - return this.lists.orderBy(['position', 'id.length', 'id']); - } - - getKanbanListsQuerySet() { - return this.getListsQuerySet().filter((list) => isListKanban(list)); - } - - getCustomFieldGroupsQuerySet() { - return this.customFieldGroups.orderBy(['position', 'id.length', 'id']); - } - - getActivitiesQuerySet() { - return this.activities.orderBy(['id.length', 'id'], ['desc', 'desc']); - } - - getUnreadNotificationsQuerySet() { - return this.notifications.filter({ - isRead: false, - }); - } - - getNotificationServicesQuerySet() { - return this.notificationServices.orderBy(['id.length', 'id']); - } - - getMembershipModelByUserId(userId) { - return this.memberships - .filter({ - userId, - }) - .first(); - } - - getCardsModelArray() { - return this.getKanbanListsQuerySet() - .toModelArray() - .flatMap((listModel) => listModel.getCardsModelArray()); - } - - getFilteredCardsModelArray() { - let cardModels = this.getCardsModelArray(); - - if (cardModels.length === 0) { - return cardModels; - } - - if (this.search) { - if (this.search.startsWith('/')) { - let searchRegex; - try { - searchRegex = new RegExp(this.search.substring(1), 'i'); - } catch { - return []; - } - - cardModels = cardModels.filter( - (cardModel) => - searchRegex.test(cardModel.name) || - (cardModel.description && searchRegex.test(cardModel.description)), - ); - } else { - const searchParts = buildSearchParts(this.search); - - cardModels = cardModels.filter((cardModel) => { - const name = cardModel.name.toLowerCase(); - const description = cardModel.description && cardModel.description.toLowerCase(); - - return searchParts.every( - (searchPart) => - name.includes(searchPart) || (description && description.includes(searchPart)), - ); - }); - } - } - - const filterUserIds = this.filterUsers.toRefArray().map((user) => user.id); - - if (filterUserIds.length > 0) { - cardModels = cardModels.filter((cardModel) => { - const users = cardModel.users.toRefArray(); - - if (users.some((user) => filterUserIds.includes(user.id))) { - return true; - } - - return cardModel - .getTaskListsQuerySet() - .toModelArray() - .some((taskListModel) => - taskListModel - .getTasksQuerySet() - .toRefArray() - .some((task) => task.assigneeUserId && filterUserIds.includes(task.assigneeUserId)), - ); - }); - } - - const filterLabelIds = this.filterLabels.toRefArray().map((label) => label.id); - const filterExcludedLabelIds = this.filterExcludedLabels.toRefArray().map((label) => label.id); - - if (filterLabelIds.length > 0 || filterExcludedLabelIds.length > 0) { - cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds); - } - - if (this.filterNoMember) { - cardModels = cardModels.filter((cardModel) => cardModel.users.toRefArray().length === 0); - return cardModels; - } - - return cardModels; - } - - getActivitiesModelArray() { - if (this.isAllActivitiesFetched === null) { - return []; - } - - const activityModels = this.getActivitiesQuerySet().toModelArray(); - - if (this.lastActivityId && this.isAllActivitiesFetched === false) { - return activityModels.filter((activityModel) => { - if (activityModel.id.length > this.lastActivityId.length) { - return true; - } - - if (activityModel.id.length < this.lastActivityId.length) { - return false; - } - - return activityModel.id >= this.lastActivityId; - }); - } - - return activityModels; - } - - hasMembershipWithUserId(userId) { - return this.memberships - .filter({ - userId, - }) - .exists(); - } - - isAvailableForUser(userModel) { - if (!this.project) { - return false; - } - - return ( - this.project.isExternalAccessibleForUser(userModel) || - this.hasMembershipWithUserId(userModel.id) - ); - } - - deleteListsWithRelated(soft) { - this.lists.toModelArray().forEach((listModel) => { - listModel.deleteWithRelated(soft); - }); - } - - deleteClearable() { - this.filterUsers.clear(); - this.filterLabels.clear(); - this.filterExcludedLabels.clear(); - } - - deleteRelated(exceptMemberUserId, soft) { - this.deleteClearable(); - - this.memberships.toModelArray().forEach((boardMembershipModel) => { - if (boardMembershipModel.userId !== exceptMemberUserId) { - boardMembershipModel.deleteWithRelated(); - } - }); - - this.labels.toModelArray().forEach((labelModel) => { - labelModel.deleteWithRelated(); - }); - - this.deleteListsWithRelated(soft); - this.notificationServices.delete(); - } - - deleteWithClearable() { - this.deleteClearable(); - this.delete(); - } - - deleteWithRelated(soft) { - this.deleteRelated(undefined, soft); - this.delete(); - } -} diff --git a/client/src/models/List.js b/client/src/models/List.js index 49dda9f3..c28b2161 100755 --- a/client/src/models/List.js +++ b/client/src/models/List.js @@ -450,6 +450,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 2eac7163..160dd644 100644 --- a/client/src/selectors/boards.js +++ b/client/src/selectors/boards.js @@ -479,6 +479,24 @@ export const selectFilterExcludedLabelIdsForCurrentBoard = 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, @@ -513,5 +531,6 @@ export default { selectFilterUserIdsForCurrentBoard, selectFilterLabelIdsForCurrentBoard, selectFilterExcludedLabelIdsForCurrentBoard, + selectFilterListIdsForCurrentBoard, selectIsBoardWithIdExists, };