diff --git a/client/src/components/boards/BoardActions/Filters.jsx b/client/src/components/boards/BoardActions/Filters.jsx index e69de29b..528150ad 100644 --- a/client/src/components/boards/BoardActions/Filters.jsx +++ b/client/src/components/boards/BoardActions/Filters.jsx @@ -0,0 +1,323 @@ +/*! + * 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 PropTypes from 'prop-types'; +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 { BoardViews, LabelFilterModes } 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 excludedLabelIds = useSelector(selectors.selectFilterExcludedLabelIdsForCurrentBoard); + const listIds = useSelector(selectors.selectFilterListIdsForCurrentBoard); + 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 handleListSelect = useCallback( + (listId) => { + dispatch(entryActions.addListToFilterInCurrentBoard(listId)); + }, + [dispatch], + ); + + const handleListDeselect = useCallback( + (listId) => { + dispatch(entryActions.removeListFromFilterInCurrentBoard(listId)); + }, + [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 ListsFilterPopup = usePopup(ListsFilterStep); + + const isSearchActive = search || isSearchFocused; + const isListView = board.view === BoardViews.LIST; + + 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) => ( + + + + ))} + + {isListView && ( + + + + + {listIds.map((listId) => ( + + ))} + + )} + + + ) : ( + 'search' + ) + } + className={classNames(styles.search, !isSearchActive && styles.searchInactive)} + onFocus={handleSearchFocus} + onKeyDown={handleSearchKeyDown} + onChange={handleSearchChange} + onBlur={handleSearchBlur} + /> + + + ); +}); + +export default Filters; diff --git a/client/src/models/Board.js b/client/src/models/Board.js index e69de29b..0acea83b 100755 --- a/client/src/models/Board.js +++ b/client/src/models/Board.js @@ -0,0 +1,540 @@ +/*! + * 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, + }), + filterLists: many('List', 'filterBoards'), + }; + + 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.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({ + 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); + } + + // The three filters below are independent of one another and compose: a + // board can be narrowed to unassigned cards AND to a set of lists at the + // same time, so neither one may return early. + if (this.filterNoMember) { + cardModels = cardModels.filter((cardModel) => cardModel.users.toRefArray().length === 0); + } + + const filterListIds = this.filterLists.toRefArray().map((list) => list.id); + + if (filterListIds.length > 0) { + cardModels = cardModels.filter((cardModel) => filterListIds.includes(cardModel.listId)); + } + + 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(); + this.filterLists.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(); + } +}