Merge pull request #1648 from symonbaikov/feat/list-filter

feat: add filter by list (status) in list view

Three filters landed in the same places while this was open: excluded labels,
cards without a member, and now lists. They are independent of each other, so
every conflict resolves to keeping both sides.

The selector was the one exception worth naming. Both branches had written the
same body under a different name, so the merge put one header on one body; it
is now two complete selectors.
This commit is contained in:
Daniel Hiller
2026-09-17 02:03:04 +02:00
16 changed files with 340 additions and 781 deletions
+18
View File
@@ -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 { export default {
createList, createList,
handleListCreate, handleListCreate,
@@ -212,4 +228,6 @@ export default {
handleListClear, handleListClear,
deleteList, deleteList,
handleListDelete, handleListDelete,
addListToBoardFilter,
removeListFromBoardFilter,
}; };
@@ -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 (
<>
<span className={styles.filter}>
<BoardMembershipsPopup
currentUserIds={userIds}
title="common.filterByMembers"
onUserSelect={handleUserSelect}
onUserDeselect={handleUserDeselect}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.members')}:`}</span>
{userIds.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
</button>
</BoardMembershipsPopup>
<button
type="button"
className={classNames(styles.filterButton, styles.filterLabel)}
onClick={handleNoMemberClick}
>
{t('common.noMember')}
</button>
{userIds.length === 0 && withCurrentUserSelector && (
<Tooltip content={t('action.filterByCurrentUser')}>
<button type="button" className={styles.filterButton} onClick={handleCurrentUserSelect}>
<span className={styles.filterLabel}>
<Icon fitted name="target" className={styles.filterLabelIcon} />
</span>
</button>
</Tooltip>
)}
{userIds.map((userId) => (
<span key={userId} className={styles.filterItem}>
<UserAvatar id={userId} size="tiny" onClick={handleUserClick} />
</span>
))}
</span>
<span className={styles.filter}>
<LabelsPopup
currentIds={[]}
currentModes={labelModes}
isFilterModeEnabled
title="common.filterByLabels"
onModeChange={handleLabelModeChange}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.labels')}:`}</span>
{labelIds.length === 0 && excludedLabelIds.length === 0 && (
<span className={styles.filterLabel}>{t('common.all')}</span>
)}
</button>
</LabelsPopup>
{labelIds.map((labelId) => (
<span key={labelId} className={styles.filterItem}>
<LabelChip id={labelId} size="small" onClick={handleLabelClick} />
</span>
))}
{excludedLabelIds.map((labelId) => (
<span key={labelId} className={styles.filterItem}>
<LabelChip id={labelId} size="small" isExcluded onClick={handleLabelClick} />
</span>
))}
</span>
<span className={styles.filter}>
<Input
ref={handleSearchFieldRef}
value={search}
placeholder={t('common.searchCards')}
maxLength={128}
icon={
isSearchActive ? (
<Icon link name="cancel" onClick={handleCancelSearchClick} />
) : (
'search'
)
}
className={classNames(styles.search, !isSearchActive && styles.searchInactive)}
onFocus={handleSearchFocus}
onKeyDown={handleSearchKeyDown}
onChange={handleSearchChange}
onBlur={handleSearchBlur}
/>
</span>
</>
);
});
export default Filters;
@@ -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;
+2
View File
@@ -300,6 +300,8 @@ export default {
LIST_DELETE__SUCCESS: 'LIST_DELETE__SUCCESS', LIST_DELETE__SUCCESS: 'LIST_DELETE__SUCCESS',
LIST_DELETE__FAILURE: 'LIST_DELETE__FAILURE', LIST_DELETE__FAILURE: 'LIST_DELETE__FAILURE',
LIST_DELETE_HANDLE: 'LIST_DELETE_HANDLE', 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 */ /* Cards */
+2
View File
@@ -198,6 +198,8 @@ export default {
LIST_CLEAR_HANDLE: `${PREFIX}/LIST_CLEAR_HANDLE`, LIST_CLEAR_HANDLE: `${PREFIX}/LIST_CLEAR_HANDLE`,
LIST_DELETE: `${PREFIX}/LIST_DELETE`, LIST_DELETE: `${PREFIX}/LIST_DELETE`,
LIST_DELETE_HANDLE: `${PREFIX}/LIST_DELETE_HANDLE`, 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 */ /* Cards */
+16
View File
@@ -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 { export default {
createListInCurrentBoard, createListInCurrentBoard,
handleListCreate, handleListCreate,
@@ -108,4 +122,6 @@ export default {
handleListClear, handleListClear,
deleteList, deleteList,
handleListDelete, handleListDelete,
addListToFilterInCurrentBoard,
removeListFromFilterInCurrentBoard,
}; };
+1
View File
@@ -262,6 +262,7 @@ export default {
excludedEvents: 'Excluded events', excludedEvents: 'Excluded events',
expandTaskListsByDefault: 'Expand task lists by default', expandTaskListsByDefault: 'Expand task lists by default',
filterByLabels_title: 'Filter By Labels', filterByLabels_title: 'Filter By Labels',
filterByLists_title: 'Filter By Lists',
filterByMembers_title: 'Filter By Members', filterByMembers_title: 'Filter By Members',
forPersonalProjects: 'For personal projects.', forPersonalProjects: 'For personal projects.',
forTeamBasedProjects: 'For team-based projects.', forTeamBasedProjects: 'For team-based projects.',
-522
View File
@@ -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();
}
}
+7
View File
@@ -450,6 +450,13 @@ export default class extends BaseModel {
deleteWithRelated(soft) { deleteWithRelated(soft) {
this.deleteRelated(soft); this.deleteRelated(soft);
try {
this.board.filterLists.remove(this.id);
} catch {
/* empty */
}
this.delete(); this.delete();
} }
} }
+24
View File
@@ -261,6 +261,26 @@ export function* handleListDelete(list, cards) {
yield put(actions.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 { export default {
createList, createList,
createListInCurrentBoard, createListInCurrentBoard,
@@ -275,4 +295,8 @@ export default {
handleListClear, handleListClear,
deleteList, deleteList,
handleListDelete, handleListDelete,
addListToBoardFilter,
addListToFilterInCurrentBoard,
removeListFromBoardFilter,
removeListFromFilterInCurrentBoard,
}; };
+6
View File
@@ -44,5 +44,11 @@ export default function* listsWatchers() {
takeEvery(EntryActionTypes.LIST_DELETE_HANDLE, ({ payload: { list, cards } }) => takeEvery(EntryActionTypes.LIST_DELETE_HANDLE, ({ payload: { list, cards } }) =>
services.handleListDelete(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),
),
]); ]);
} }
+19
View File
@@ -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( export const selectIsBoardWithIdExists = createSelector(
orm, orm,
(_, id) => id, (_, id) => id,
@@ -513,5 +531,6 @@ export default {
selectFilterUserIdsForCurrentBoard, selectFilterUserIdsForCurrentBoard,
selectFilterLabelIdsForCurrentBoard, selectFilterLabelIdsForCurrentBoard,
selectFilterExcludedLabelIdsForCurrentBoard, selectFilterExcludedLabelIdsForCurrentBoard,
selectFilterListIdsForCurrentBoard,
selectIsBoardWithIdExists, selectIsBoardWithIdExists,
}; };