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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user