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:
@@ -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;
|
||||
Reference in New Issue
Block a user