From d44b5a689ac09d0c8e03db850127889f10867a5e Mon Sep 17 00:00:00 2001 From: Emmanuel Guyot Date: Wed, 10 Apr 2024 23:37:31 +0200 Subject: [PATCH] Filter cards with shortcuts to users, labels and creator --- client/src/actions/cards.js | 9 ++++ .../components/BoardActions/BoardActions.jsx | 6 +++ .../src/components/BoardActions/Filters.jsx | 24 +++++++++- client/src/constants/ActionTypes.js | 1 + client/src/constants/EntryActionTypes.js | 1 + .../src/containers/BoardActionsContainer.js | 3 ++ client/src/entry-actions/cards.js | 8 ++++ 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 ++++++++ 17 files changed, 195 insertions(+), 1 deletion(-) diff --git a/client/src/actions/cards.js b/client/src/actions/cards.js index 2bbe194..0b0be6b 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 0f1d552..c925b9f 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 c4e61c6..e9083bc 100644 --- a/client/src/components/BoardActions/Filters.jsx +++ b/client/src/components/BoardActions/Filters.jsx @@ -1,7 +1,8 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; import { usePopup } from '../../lib/popup'; +import { Input } from '../../lib/custom-ui'; import User from '../User'; import Label from '../Label'; @@ -14,6 +15,7 @@ const Filters = React.memo( ({ users, labels, + filterText, allBoardMemberships, allLabels, canEdit, @@ -25,8 +27,10 @@ const Filters = React.memo( onLabelUpdate, onLabelMove, onLabelDelete, + onTextFilterUpdate, }) => { const [t] = useTranslation(); + const searchField = useRef(null); const handleRemoveUserClick = useCallback( (id) => { @@ -42,6 +46,14 @@ const Filters = React.memo( [onLabelRemove], ); + const handleKeyUp = useCallback(() => { + onTextFilterUpdate(searchField.current.inputRef.current.value); + }, [onTextFilterUpdate]); + + useEffect(() => { + searchField.current.inputRef.current.value = filterText; + }, [filterText]); + const BoardMembershipsPopup = usePopup(BoardMembershipsStep); const LabelsPopup = usePopup(LabelsStep); @@ -100,6 +112,14 @@ const Filters = React.memo( ))} + + handleKeyUp()} + /> + ); }, @@ -109,6 +129,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 +142,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/constants/ActionTypes.js b/client/src/constants/ActionTypes.js index d5724e1..0eaf502 100644 --- a/client/src/constants/ActionTypes.js +++ b/client/src/constants/ActionTypes.js @@ -201,6 +201,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 9cd0694..ab62754 100755 --- a/client/src/constants/EntryActionTypes.js +++ b/client/src/constants/EntryActionTypes.js @@ -137,6 +137,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 40170cb..c839f78 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 d3dd849..1abe5bf 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/locales/en/core.js b/client/src/locales/en/core.js index 41e4494..f3a52f4 100644 --- a/client/src/locales/en/core.js +++ b/client/src/locales/en/core.js @@ -135,6 +135,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 1b9296f..c9cd88e 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 3b23503..8e9fef2 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 3c45256..779d124 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 2399ce3..62096da 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 b634a66..9c4946a 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 { @@ -87,6 +88,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 9bf8208..b2e99cc 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 7232c3f..a5bc725 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 3fcb599..10b0255 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 c649787..e4e435c 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, };