From ca035d670e9cc401273c407468f1a8521f8fb119 Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Thu, 17 Sep 2026 03:50:09 +0200 Subject: [PATCH] fix: Restore two files committed empty by the list filter merge 75fcddcd wrote `client/src/models/Board.js` and the board filter bar out as zero bytes. `BoardActions` imports `Filters` from that module, so the client could not render a board at all. The conflict resolution there was scripted, and the script opened each file for writing before reading it, which truncates it first and then reads back nothing. ESLint accepts an empty module, so lint stayed green and the commit looked fine. Both files are rebuilt from the two merge parents. All six conflicts resolve to keeping both sides, which is what the merge intended: excluded labels, cards without a member and lists are three independent filters. One behaviour fix rides along, because the union merge would otherwise have carried it in. The no-member branch ended its block with `return cardModels`, which was correct while it was the last filter and wrong once the list filter followed it: combining the two would have dropped the list filter without a word. The three now compose. --- .../boards/BoardActions/Filters.jsx | 323 +++++++++++ client/src/models/Board.js | 540 ++++++++++++++++++ 2 files changed, 863 insertions(+) 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(); + } +}