mirror of
https://github.com/plankanban/planka.git
synced 2025-07-18 12:49:43 +02:00
feat: Filter cards by keyword with advanced capabilities (#713)
Closes #706
This commit is contained in:
parent
0830db210c
commit
25a7a3bd3b
19 changed files with 267 additions and 1 deletions
|
@ -121,6 +121,14 @@ const handleCardDelete = (card) => ({
|
|||
},
|
||||
});
|
||||
|
||||
const filterText = (boardId, text) => ({
|
||||
type: ActionTypes.TEXT_FILTER_IN_CURRENT_BOARD,
|
||||
payload: {
|
||||
boardId,
|
||||
text,
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
createCard,
|
||||
handleCardCreate,
|
||||
|
@ -129,4 +137,5 @@ export default {
|
|||
duplicateCard,
|
||||
deleteCard,
|
||||
handleCardDelete,
|
||||
filterText,
|
||||
};
|
||||
|
|
|
@ -13,6 +13,7 @@ const BoardActions = React.memo(
|
|||
labels,
|
||||
filterUsers,
|
||||
filterLabels,
|
||||
filterText,
|
||||
allUsers,
|
||||
canEdit,
|
||||
canEditMemberships,
|
||||
|
@ -27,6 +28,7 @@ const BoardActions = React.memo(
|
|||
onLabelUpdate,
|
||||
onLabelMove,
|
||||
onLabelDelete,
|
||||
onTextFilterUpdate,
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
|
@ -46,6 +48,7 @@ const BoardActions = React.memo(
|
|||
<Filters
|
||||
users={filterUsers}
|
||||
labels={filterLabels}
|
||||
filterText={filterText}
|
||||
allBoardMemberships={memberships}
|
||||
allLabels={labels}
|
||||
canEdit={canEdit}
|
||||
|
@ -57,6 +60,7 @@ const BoardActions = React.memo(
|
|||
onLabelUpdate={onLabelUpdate}
|
||||
onLabelMove={onLabelMove}
|
||||
onLabelDelete={onLabelDelete}
|
||||
onTextFilterUpdate={onTextFilterUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -71,6 +75,7 @@ BoardActions.propTypes = {
|
|||
labels: PropTypes.array.isRequired,
|
||||
filterUsers: PropTypes.array.isRequired,
|
||||
filterLabels: PropTypes.array.isRequired,
|
||||
filterText: PropTypes.string.isRequired,
|
||||
allUsers: PropTypes.array.isRequired,
|
||||
/* eslint-enable react/forbid-prop-types */
|
||||
canEdit: PropTypes.bool.isRequired,
|
||||
|
@ -86,6 +91,7 @@ BoardActions.propTypes = {
|
|||
onLabelUpdate: PropTypes.func.isRequired,
|
||||
onLabelMove: PropTypes.func.isRequired,
|
||||
onLabelDelete: PropTypes.func.isRequired,
|
||||
onTextFilterUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default BoardActions;
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from 'semantic-ui-react';
|
||||
import { usePopup } from '../../lib/popup';
|
||||
import { Input } from '../../lib/custom-ui';
|
||||
|
||||
import User from '../User';
|
||||
import Label from '../Label';
|
||||
|
@ -14,6 +17,7 @@ const Filters = React.memo(
|
|||
({
|
||||
users,
|
||||
labels,
|
||||
filterText,
|
||||
allBoardMemberships,
|
||||
allLabels,
|
||||
canEdit,
|
||||
|
@ -25,8 +29,17 @@ const Filters = React.memo(
|
|||
onLabelUpdate,
|
||||
onLabelMove,
|
||||
onLabelDelete,
|
||||
onTextFilterUpdate,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
|
||||
const searchFieldRef = useRef(null);
|
||||
|
||||
const cancelSearch = useCallback(() => {
|
||||
onTextFilterUpdate('');
|
||||
searchFieldRef.current.blur();
|
||||
}, [onTextFilterUpdate]);
|
||||
|
||||
const handleRemoveUserClick = useCallback(
|
||||
(id) => {
|
||||
|
@ -42,9 +55,39 @@ const Filters = React.memo(
|
|||
[onLabelRemove],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(_, { value }) => {
|
||||
onTextFilterUpdate(value);
|
||||
},
|
||||
[onTextFilterUpdate],
|
||||
);
|
||||
|
||||
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]);
|
||||
|
||||
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
|
||||
const LabelsPopup = usePopup(LabelsStep);
|
||||
|
||||
const isSearchActive = filterText || isSearchFocused;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={styles.filter}>
|
||||
|
@ -100,6 +143,25 @@ const Filters = React.memo(
|
|||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className={styles.filter}>
|
||||
<Input
|
||||
ref={searchFieldRef}
|
||||
value={filterText}
|
||||
placeholder={t('common.searchCards')}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
@ -109,6 +171,7 @@ Filters.propTypes = {
|
|||
/* eslint-disable react/forbid-prop-types */
|
||||
users: PropTypes.array.isRequired,
|
||||
labels: PropTypes.array.isRequired,
|
||||
filterText: PropTypes.string.isRequired,
|
||||
allBoardMemberships: PropTypes.array.isRequired,
|
||||
allLabels: PropTypes.array.isRequired,
|
||||
/* eslint-enable react/forbid-prop-types */
|
||||
|
@ -121,6 +184,7 @@ Filters.propTypes = {
|
|||
onLabelUpdate: PropTypes.func.isRequired,
|
||||
onLabelMove: PropTypes.func.isRequired,
|
||||
onLabelDelete: PropTypes.func.isRequired,
|
||||
onTextFilterUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
|
|
|
@ -43,4 +43,32 @@
|
|||
line-height: 20px;
|
||||
padding: 2px 12px;
|
||||
}
|
||||
|
||||
.search {
|
||||
height: 30px;
|
||||
margin: 0 12px;
|
||||
transition: width 0.2s ease;
|
||||
width: 280px;
|
||||
|
||||
input {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.searchInactive {
|
||||
color: #fff;
|
||||
height: 24px;
|
||||
width: 220px;
|
||||
|
||||
input {
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
border: none;
|
||||
color: #fff !important;
|
||||
font-size: 12px;
|
||||
|
||||
&::placeholder {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -205,6 +205,7 @@ export default {
|
|||
CARD_DELETE__SUCCESS: 'CARD_DELETE__SUCCESS',
|
||||
CARD_DELETE__FAILURE: 'CARD_DELETE__FAILURE',
|
||||
CARD_DELETE_HANDLE: 'CARD_DELETE_HANDLE',
|
||||
TEXT_FILTER_IN_CURRENT_BOARD: 'TEXT_FILTER_IN_CURRENT_BOARD',
|
||||
|
||||
/* Tasks */
|
||||
|
||||
|
|
|
@ -139,6 +139,7 @@ export default {
|
|||
CARD_DELETE: `${PREFIX}/CARD_DELETE`,
|
||||
CURRENT_CARD_DELETE: `${PREFIX}/CURRENT_CARD_DELETE`,
|
||||
CARD_DELETE_HANDLE: `${PREFIX}/CARD_DELETE_HANDLE`,
|
||||
TEXT_FILTER_IN_CURRENT_BOARD: `${PREFIX}/FILTER_TEXT_HANDLE`,
|
||||
|
||||
/* Tasks */
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ const mapStateToProps = (state) => {
|
|||
const labels = selectors.selectLabelsForCurrentBoard(state);
|
||||
const filterUsers = selectors.selectFilterUsersForCurrentBoard(state);
|
||||
const filterLabels = selectors.selectFilterLabelsForCurrentBoard(state);
|
||||
const filterText = selectors.selectFilterTextForCurrentBoard(state);
|
||||
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
|
||||
const isCurrentUserEditor =
|
||||
|
@ -23,6 +24,7 @@ const mapStateToProps = (state) => {
|
|||
labels,
|
||||
filterUsers,
|
||||
filterLabels,
|
||||
filterText,
|
||||
allUsers,
|
||||
canEdit: isCurrentUserEditor,
|
||||
canEditMemberships: isCurrentUserManager,
|
||||
|
@ -43,6 +45,7 @@ const mapDispatchToProps = (dispatch) =>
|
|||
onLabelUpdate: entryActions.updateLabel,
|
||||
onLabelMove: entryActions.moveLabel,
|
||||
onLabelDelete: entryActions.deleteLabel,
|
||||
onTextFilterUpdate: entryActions.filterText,
|
||||
},
|
||||
dispatch,
|
||||
);
|
||||
|
|
|
@ -105,6 +105,13 @@ const handleCardDelete = (card) => ({
|
|||
},
|
||||
});
|
||||
|
||||
const filterText = (text) => ({
|
||||
type: EntryActionTypes.TEXT_FILTER_IN_CURRENT_BOARD,
|
||||
payload: {
|
||||
text,
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
createCard,
|
||||
handleCardCreate,
|
||||
|
@ -120,4 +127,5 @@ export default {
|
|||
deleteCard,
|
||||
deleteCurrentCard,
|
||||
handleCardDelete,
|
||||
filterText,
|
||||
};
|
||||
|
|
|
@ -9,4 +9,6 @@ export default class Input extends SemanticUIInput {
|
|||
static Mask = InputMask;
|
||||
|
||||
focus = (options) => this.inputRef.current.focus(options);
|
||||
|
||||
blur = () => this.inputRef.current.blur();
|
||||
}
|
||||
|
|
|
@ -139,6 +139,7 @@ export default {
|
|||
searchLabels: 'Search labels...',
|
||||
searchMembers: 'Search members...',
|
||||
searchUsers: 'Search users...',
|
||||
searchCards: 'Search cards...',
|
||||
seconds: 'Seconds',
|
||||
selectBoard: 'Select board',
|
||||
selectList: 'Select list',
|
||||
|
|
|
@ -111,6 +111,10 @@ export default {
|
|||
project: 'Projet',
|
||||
projectNotFound_title: 'Projet introuvable',
|
||||
removeMember_title: 'Supprimer le membre',
|
||||
searchLabels: 'Chercher une étiquette...',
|
||||
searchMembers: 'Chercher un membre...',
|
||||
searchUsers: 'Chercher un utilisateur...',
|
||||
searchCards: 'Chercher une carte...',
|
||||
seconds: 'Secondes',
|
||||
selectBoard: 'Sélectionner une carte',
|
||||
selectList: 'Sélectionner une liste',
|
||||
|
|
|
@ -3,6 +3,9 @@ import { attr, fk, many } from 'redux-orm';
|
|||
import BaseModel from './BaseModel';
|
||||
import ActionTypes from '../constants/ActionTypes';
|
||||
|
||||
import User from './User';
|
||||
import Label from './Label';
|
||||
|
||||
export default class extends BaseModel {
|
||||
static modelName = 'Board';
|
||||
|
||||
|
@ -25,6 +28,9 @@ export default class extends BaseModel {
|
|||
}),
|
||||
filterUsers: many('User', 'filterBoards'),
|
||||
filterLabels: many('Label', 'filterBoards'),
|
||||
filterText: attr({
|
||||
getDefault: () => '',
|
||||
}),
|
||||
};
|
||||
|
||||
static reducer({ type, payload }, Board) {
|
||||
|
@ -167,6 +173,47 @@ export default class extends BaseModel {
|
|||
Board.withId(payload.boardId).filterLabels.remove(payload.id);
|
||||
|
||||
break;
|
||||
case ActionTypes.TEXT_FILTER_IN_CURRENT_BOARD: {
|
||||
const board = Board.withId(payload.boardId);
|
||||
let filterText = payload.text;
|
||||
const posSpace = filterText.indexOf(' ');
|
||||
|
||||
// Shortcut to user filters
|
||||
const posAT = filterText.indexOf('@');
|
||||
if (posAT >= 0 && posSpace > 0 && posAT < posSpace) {
|
||||
const userId = User.findUsersFromText(
|
||||
filterText.substring(posAT + 1, posSpace),
|
||||
board.memberships.toModelArray().map((membership) => membership.user),
|
||||
);
|
||||
if (
|
||||
userId &&
|
||||
board.filterUsers.toModelArray().filter((user) => user.id === userId).length === 0
|
||||
) {
|
||||
board.filterUsers.add(userId);
|
||||
filterText = filterText.substring(0, posAT);
|
||||
}
|
||||
}
|
||||
|
||||
// Shortcut to label filters
|
||||
const posSharp = filterText.indexOf('#');
|
||||
if (posSharp >= 0 && posSpace > 0 && posSharp < posSpace) {
|
||||
const labelId = Label.findLabelsFromText(
|
||||
filterText.substring(posSharp + 1, posSpace),
|
||||
board.labels.toModelArray(),
|
||||
);
|
||||
if (
|
||||
labelId &&
|
||||
board.filterLabels.toModelArray().filter((label) => label.id === labelId).length === 0
|
||||
) {
|
||||
board.filterLabels.add(labelId);
|
||||
filterText = filterText.substring(0, posSharp);
|
||||
}
|
||||
}
|
||||
|
||||
board.update({ filterText });
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,11 @@ export default class extends BaseModel {
|
|||
position: attr(),
|
||||
name: attr(),
|
||||
description: attr(),
|
||||
creatorUserId: oneToOne({
|
||||
to: 'User',
|
||||
as: 'creatorUser',
|
||||
relatedName: 'ownCards',
|
||||
}),
|
||||
dueDate: attr(),
|
||||
stopwatch: attr(),
|
||||
isSubscribed: attr({
|
||||
|
|
|
@ -80,4 +80,16 @@ export default class extends BaseModel {
|
|||
default:
|
||||
}
|
||||
}
|
||||
|
||||
static findLabelsFromText(filterText, labels) {
|
||||
const selectLabel = filterText.toLocaleLowerCase();
|
||||
const matchingLabels = labels.filter((label) =>
|
||||
label.name ? label.name.toLocaleLowerCase().startsWith(selectLabel) : false,
|
||||
);
|
||||
if (matchingLabels.length === 1) {
|
||||
// Appens the user to the filter
|
||||
return matchingLabels[0].id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { attr, fk } from 'redux-orm';
|
||||
|
||||
import BaseModel from './BaseModel';
|
||||
import User from './User';
|
||||
import ActionTypes from '../constants/ActionTypes';
|
||||
|
||||
export default class extends BaseModel {
|
||||
|
@ -89,6 +90,37 @@ export default class extends BaseModel {
|
|||
getFilteredOrderedCardsModelArray() {
|
||||
let cardModels = this.getOrderedCardsQuerySet().toModelArray();
|
||||
|
||||
const { filterText } = this.board;
|
||||
|
||||
if (filterText !== '') {
|
||||
let re = null;
|
||||
const posSpace = filterText.indexOf(' ');
|
||||
|
||||
if (filterText.startsWith('/')) {
|
||||
re = new RegExp(filterText.substring(1), 'i');
|
||||
}
|
||||
let doRegularSearch = true;
|
||||
if (re) {
|
||||
cardModels = cardModels.filter((cardModel) => re.test(cardModel.name));
|
||||
doRegularSearch = false;
|
||||
} else if (filterText.startsWith('!') && posSpace > 0) {
|
||||
const creatorUserId = User.findUsersFromText(
|
||||
filterText.substring(1, posSpace),
|
||||
this.board.memberships.toModelArray().map((membership) => membership.user),
|
||||
);
|
||||
if (creatorUserId != null) {
|
||||
doRegularSearch = false;
|
||||
cardModels = cardModels.filter((cardModel) => cardModel.creatorUser.id === creatorUserId);
|
||||
}
|
||||
}
|
||||
if (doRegularSearch) {
|
||||
const lowerCasedFilter = filterText.toLocaleLowerCase();
|
||||
cardModels = cardModels.filter(
|
||||
(cardModel) => cardModel.name.toLocaleLowerCase().indexOf(lowerCasedFilter) >= 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const filterUserIds = this.board.filterUsers.toRefArray().map((user) => user.id);
|
||||
const filterLabelIds = this.board.filterLabels.toRefArray().map((label) => label.id);
|
||||
|
||||
|
|
|
@ -359,4 +359,18 @@ export default class extends BaseModel {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
static findUsersFromText(filterText, users) {
|
||||
const selectUser = filterText.toLocaleLowerCase();
|
||||
const matchingUsers = users.filter(
|
||||
(user) =>
|
||||
user.name.toLocaleLowerCase().startsWith(selectUser) ||
|
||||
user.username.toLocaleLowerCase().startsWith(selectUser),
|
||||
);
|
||||
if (matchingUsers.length === 1) {
|
||||
// Appens the user to the filter
|
||||
return matchingUsers[0].id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -207,6 +207,12 @@ export function* handleCardDelete(card) {
|
|||
yield put(actions.handleCardDelete(card));
|
||||
}
|
||||
|
||||
export function* handleTextFilter(text) {
|
||||
const { boardId } = yield select(selectors.selectPath);
|
||||
|
||||
yield put(actions.filterText(boardId, text));
|
||||
}
|
||||
|
||||
export default {
|
||||
createCard,
|
||||
handleCardCreate,
|
||||
|
@ -222,4 +228,5 @@ export default {
|
|||
deleteCard,
|
||||
deleteCurrentCard,
|
||||
handleCardDelete,
|
||||
handleTextFilter,
|
||||
};
|
||||
|
|
|
@ -39,5 +39,8 @@ export default function* cardsWatchers() {
|
|||
takeEvery(EntryActionTypes.CARD_DELETE_HANDLE, ({ payload: { card } }) =>
|
||||
services.handleCardDelete(card),
|
||||
),
|
||||
takeEvery(EntryActionTypes.TEXT_FILTER_IN_CURRENT_BOARD, ({ payload: { text } }) =>
|
||||
services.handleTextFilter(text),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -175,6 +175,24 @@ export const selectFilterLabelsForCurrentBoard = createSelector(
|
|||
},
|
||||
);
|
||||
|
||||
export const selectFilterTextForCurrentBoard = createSelector(
|
||||
orm,
|
||||
(state) => selectPath(state).boardId,
|
||||
({ Board }, id) => {
|
||||
if (!id) {
|
||||
return id;
|
||||
}
|
||||
|
||||
const boardModel = Board.withId(id);
|
||||
|
||||
if (!boardModel) {
|
||||
return boardModel;
|
||||
}
|
||||
|
||||
return boardModel.filterText;
|
||||
},
|
||||
);
|
||||
|
||||
export const selectIsBoardWithIdExists = createSelector(
|
||||
orm,
|
||||
(_, id) => id,
|
||||
|
@ -191,5 +209,6 @@ export default {
|
|||
selectListIdsForCurrentBoard,
|
||||
selectFilterUsersForCurrentBoard,
|
||||
selectFilterLabelsForCurrentBoard,
|
||||
selectFilterTextForCurrentBoard,
|
||||
selectIsBoardWithIdExists,
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue