From 25a7a3bd3b4456d14d08946ac522eb05c055e295 Mon Sep 17 00:00:00 2001 From: Emmanuel Guyot Date: Mon, 22 Apr 2024 23:15:31 +0200 Subject: [PATCH] feat: Filter cards by keyword with advanced capabilities (#713) Closes #706 --- client/src/actions/cards.js | 9 +++ .../components/BoardActions/BoardActions.jsx | 6 ++ .../src/components/BoardActions/Filters.jsx | 66 ++++++++++++++++++- .../BoardActions/Filters.module.scss | 28 ++++++++ client/src/constants/ActionTypes.js | 1 + client/src/constants/EntryActionTypes.js | 1 + .../src/containers/BoardActionsContainer.js | 3 + client/src/entry-actions/cards.js | 8 +++ .../lib/custom-ui/components/Input/Input.jsx | 2 + client/src/locales/en/core.js | 1 + client/src/locales/fr/core.js | 4 ++ client/src/models/Board.js | 47 +++++++++++++ client/src/models/Card.js | 5 ++ client/src/models/Label.js | 12 ++++ client/src/models/List.js | 32 +++++++++ client/src/models/User.js | 14 ++++ client/src/sagas/core/services/cards.js | 7 ++ client/src/sagas/core/watchers/cards.js | 3 + client/src/selectors/boards.js | 19 ++++++ 19 files changed, 267 insertions(+), 1 deletion(-) diff --git a/client/src/actions/cards.js b/client/src/actions/cards.js index 2bbe194b..0b0be6be 100644 --- a/client/src/actions/cards.js +++ b/client/src/actions/cards.js @@ -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, }; diff --git a/client/src/components/BoardActions/BoardActions.jsx b/client/src/components/BoardActions/BoardActions.jsx index 0f1d552f..c925b9f5 100644 --- a/client/src/components/BoardActions/BoardActions.jsx +++ b/client/src/components/BoardActions/BoardActions.jsx @@ -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 (
@@ -46,6 +48,7 @@ const BoardActions = React.memo(
@@ -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; diff --git a/client/src/components/BoardActions/Filters.jsx b/client/src/components/BoardActions/Filters.jsx index c4e61c6f..305bf1ed 100644 --- a/client/src/components/BoardActions/Filters.jsx +++ b/client/src/components/BoardActions/Filters.jsx @@ -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 ( <> @@ -100,6 +143,25 @@ const Filters = React.memo( ))} + + + ) : ( + 'search' + ) + } + className={classNames(styles.search, !isSearchActive && styles.searchInactive)} + onFocus={handleSearchFocus} + onKeyDown={handleSearchKeyDown} + onChange={handleSearchChange} + onBlur={handleSearchBlur} + /> + ); }, @@ -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; diff --git a/client/src/components/BoardActions/Filters.module.scss b/client/src/components/BoardActions/Filters.module.scss index 1af6dd23..12c7fc4a 100644 --- a/client/src/components/BoardActions/Filters.module.scss +++ b/client/src/components/BoardActions/Filters.module.scss @@ -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; + } + } + } } diff --git a/client/src/constants/ActionTypes.js b/client/src/constants/ActionTypes.js index de01d6d6..4b8c5455 100644 --- a/client/src/constants/ActionTypes.js +++ b/client/src/constants/ActionTypes.js @@ -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 */ diff --git a/client/src/constants/EntryActionTypes.js b/client/src/constants/EntryActionTypes.js index 60a9c465..b8e5a893 100755 --- a/client/src/constants/EntryActionTypes.js +++ b/client/src/constants/EntryActionTypes.js @@ -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 */ diff --git a/client/src/containers/BoardActionsContainer.js b/client/src/containers/BoardActionsContainer.js index 40170cb2..c839f788 100644 --- a/client/src/containers/BoardActionsContainer.js +++ b/client/src/containers/BoardActionsContainer.js @@ -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, ); diff --git a/client/src/entry-actions/cards.js b/client/src/entry-actions/cards.js index d3dd8495..1abe5bfd 100755 --- a/client/src/entry-actions/cards.js +++ b/client/src/entry-actions/cards.js @@ -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, }; diff --git a/client/src/lib/custom-ui/components/Input/Input.jsx b/client/src/lib/custom-ui/components/Input/Input.jsx index f9bf001f..4e820fbc 100755 --- a/client/src/lib/custom-ui/components/Input/Input.jsx +++ b/client/src/lib/custom-ui/components/Input/Input.jsx @@ -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(); } diff --git a/client/src/locales/en/core.js b/client/src/locales/en/core.js index d2164d96..5811560d 100644 --- a/client/src/locales/en/core.js +++ b/client/src/locales/en/core.js @@ -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', diff --git a/client/src/locales/fr/core.js b/client/src/locales/fr/core.js index 1b9296ff..c9cd88e9 100644 --- a/client/src/locales/fr/core.js +++ b/client/src/locales/fr/core.js @@ -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', diff --git a/client/src/models/Board.js b/client/src/models/Board.js index 3b235035..8e9fef2d 100755 --- a/client/src/models/Board.js +++ b/client/src/models/Board.js @@ -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: } } diff --git a/client/src/models/Card.js b/client/src/models/Card.js index af11fd5e..34b0b599 100755 --- a/client/src/models/Card.js +++ b/client/src/models/Card.js @@ -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({ diff --git a/client/src/models/Label.js b/client/src/models/Label.js index 2399ce35..62096dad 100755 --- a/client/src/models/Label.js +++ b/client/src/models/Label.js @@ -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; + } } diff --git a/client/src/models/List.js b/client/src/models/List.js index b6641291..1823c1a2 100755 --- a/client/src/models/List.js +++ b/client/src/models/List.js @@ -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); diff --git a/client/src/models/User.js b/client/src/models/User.js index 9bf8208f..b2e99ccc 100755 --- a/client/src/models/User.js +++ b/client/src/models/User.js @@ -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; + } } diff --git a/client/src/sagas/core/services/cards.js b/client/src/sagas/core/services/cards.js index 7232c3f8..a5bc725b 100644 --- a/client/src/sagas/core/services/cards.js +++ b/client/src/sagas/core/services/cards.js @@ -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, }; diff --git a/client/src/sagas/core/watchers/cards.js b/client/src/sagas/core/watchers/cards.js index 3fcb5992..10b0255a 100644 --- a/client/src/sagas/core/watchers/cards.js +++ b/client/src/sagas/core/watchers/cards.js @@ -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), + ), ]); } diff --git a/client/src/selectors/boards.js b/client/src/selectors/boards.js index c649787d..e4e435cd 100644 --- a/client/src/selectors/boards.js +++ b/client/src/selectors/boards.js @@ -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, };