1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-08-10 16:05:35 +02:00

Filter cards with shortcuts to users, labels and creator

This commit is contained in:
Emmanuel Guyot 2024-04-10 23:37:31 +02:00 committed by Emmanuel Guyot
parent 200ea3bbc0
commit d44b5a689a
17 changed files with 195 additions and 1 deletions

View file

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

View file

@ -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;

View file

@ -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(
</span>
))}
</span>
<span className={styles.filter}>
<Input
ref={searchField}
placeholder={t('common.searchCards')}
icon="search"
onKeyUp={() => handleKeyUp()}
/>
</span>
</>
);
},
@ -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;

View file

@ -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 */

View file

@ -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 */

View file

@ -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,
);

View file

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

View file

@ -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',

View file

@ -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',

View file

@ -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:
}
}

View file

@ -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({

View file

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

View file

@ -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);

View file

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

View file

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

View file

@ -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),
),
]);
}

View file

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