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,
};