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

feat: Add filter by Keyword

This commit is contained in:
Felipe Freitas 2024-04-12 10:54:15 -03:00
parent 16499052f7
commit ab11dc9f1b
18 changed files with 375 additions and 112 deletions

View file

@ -134,6 +134,14 @@ const handleBoardDelete = (board) => ({
},
});
const updateKeywordToBoardFilter = (keyword, boardId) => ({
type: ActionTypes.KEYWORD_TO_BOARD_FILTER_UPDATE,
payload: {
keyword,
boardId,
},
});
export default {
createBoard,
handleBoardCreate,
@ -142,4 +150,5 @@ export default {
handleBoardUpdate,
deleteBoard,
handleBoardDelete,
updateKeywordToBoardFilter,
};

View file

@ -11,6 +11,7 @@ const BoardActions = React.memo(
({
memberships,
labels,
filterKeyword,
filterUsers,
filterLabels,
allUsers,
@ -19,6 +20,7 @@ const BoardActions = React.memo(
onMembershipCreate,
onMembershipUpdate,
onMembershipDelete,
onKeywordToFilterUpdate,
onUserToFilterAdd,
onUserFromFilterRemove,
onLabelToFilterAdd,
@ -44,11 +46,13 @@ const BoardActions = React.memo(
</div>
<div className={styles.action}>
<Filters
keyword={filterKeyword}
users={filterUsers}
labels={filterLabels}
allBoardMemberships={memberships}
allLabels={labels}
canEdit={canEdit}
onKeywordUpdate={onKeywordToFilterUpdate}
onUserAdd={onUserToFilterAdd}
onUserRemove={onUserFromFilterRemove}
onLabelAdd={onLabelToFilterAdd}
@ -69,6 +73,7 @@ BoardActions.propTypes = {
/* eslint-disable react/forbid-prop-types */
memberships: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
filterKeyword: PropTypes.string.isRequired,
filterUsers: PropTypes.array.isRequired,
filterLabels: PropTypes.array.isRequired,
allUsers: PropTypes.array.isRequired,
@ -78,6 +83,7 @@ BoardActions.propTypes = {
onMembershipCreate: PropTypes.func.isRequired,
onMembershipUpdate: PropTypes.func.isRequired,
onMembershipDelete: PropTypes.func.isRequired,
onKeywordToFilterUpdate: PropTypes.func.isRequired,
onUserToFilterAdd: PropTypes.func.isRequired,
onUserFromFilterRemove: PropTypes.func.isRequired,
onLabelToFilterAdd: PropTypes.func.isRequired,

View file

@ -1,22 +1,22 @@
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, ButtonGroup, Icon } from 'semantic-ui-react';
import { usePopup } from '../../lib/popup';
import User from '../User';
import Label from '../Label';
import BoardMembershipsStep from '../BoardMembershipsStep';
import LabelsStep from '../LabelsStep';
import FiltersStep from '../FiltersStep';
import styles from './Filters.module.scss';
const Filters = React.memo(
({
keyword,
users,
labels,
allBoardMemberships,
allLabels,
canEdit,
onKeywordUpdate,
onUserAdd,
onUserRemove,
onLabelAdd,
@ -28,91 +28,61 @@ const Filters = React.memo(
}) => {
const [t] = useTranslation();
const handleRemoveUserClick = useCallback(
(id) => {
onUserRemove(id);
},
[onUserRemove],
const isFiltering = useMemo(
() => !!(keyword || users.length || labels.length),
[keyword, users, labels],
);
const handleRemoveLabelClick = useCallback(
(id) => {
onLabelRemove(id);
},
[onLabelRemove],
);
const handleClickClear = useCallback(() => {
onKeywordUpdate('');
users.forEach((user) => onUserRemove(user.id));
labels.forEach((label) => onLabelRemove(label.id));
}, [users, labels, onKeywordUpdate, onUserRemove, onLabelRemove]);
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
const LabelsPopup = usePopup(LabelsStep);
const FiltersPopup = usePopup(FiltersStep);
return (
<>
<span className={styles.filter}>
<BoardMembershipsPopup
items={allBoardMemberships}
currentUserIds={users.map((user) => user.id)}
title="common.filterByMembers"
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.members')}:`}</span>
{users.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
</button>
</BoardMembershipsPopup>
{users.map((user) => (
<span key={user.id} className={styles.filterItem}>
<User
name={user.name}
avatarUrl={user.avatarUrl}
size="tiny"
onClick={() => handleRemoveUserClick(user.id)}
/>
</span>
))}
</span>
<span className={styles.filter}>
<LabelsPopup
items={allLabels}
currentIds={labels.map((label) => label.id)}
title="common.filterByLabels"
<span className={styles.filter}>
<ButtonGroup>
<FiltersPopup
keyword={keyword}
users={users}
labels={labels}
allBoardMemberships={allBoardMemberships}
allLabels={allLabels}
canEdit={canEdit}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onMove={onLabelMove}
onDelete={onLabelDelete}
onKeywordUpdate={onKeywordUpdate}
onUserAdd={onUserAdd}
onUserRemove={onUserRemove}
onLabelAdd={onLabelAdd}
onLabelRemove={onLabelRemove}
onLabelCreate={onLabelCreate}
onLabelUpdate={onLabelUpdate}
onLabelMove={onLabelMove}
onLabelDelete={onLabelDelete}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.labels')}:`}</span>
{labels.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
</button>
</LabelsPopup>
{labels.map((label) => (
<span key={label.id} className={styles.filterItem}>
<Label
name={label.name}
color={label.color}
size="small"
onClick={() => handleRemoveLabelClick(label.id)}
/>
</span>
))}
</span>
</>
<Button icon labelPosition="left">
<Icon name="filter" />
{t('common.filters')}
</Button>
</FiltersPopup>
{isFiltering && <Button onClick={handleClickClear}>{t('common.clear')}</Button>}
</ButtonGroup>
</span>
);
},
);
Filters.propTypes = {
/* eslint-disable react/forbid-prop-types */
keyword: PropTypes.string.isRequired,
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
allBoardMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
canEdit: PropTypes.bool.isRequired,
onKeywordUpdate: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired,

View file

@ -2,45 +2,4 @@
.filter {
margin-right: 10px;
}
.filterButton {
background: transparent;
border: none;
cursor: pointer;
display: inline-block;
outline: none;
padding: 0;
}
.filterItem {
display: inline-block;
font-size: 0;
line-height: 0;
margin-right: 4px;
max-width: 190px;
vertical-align: top;
}
.filterLabel {
background: rgba(0, 0, 0, 0.24);
border-radius: 3px;
color: #fff;
display: inline-block;
font-size: 12px;
line-height: 20px;
padding: 2px 8px;
&:hover {
background: rgba(0, 0, 0, 0.32);
}
}
.filterTitle {
border-radius: 3px;
color: #fff;
display: inline-block;
font-size: 12px;
line-height: 20px;
padding: 2px 12px;
}
}

View file

@ -0,0 +1,192 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import { Input, Popup } from '../../lib/custom-ui';
import { useSteps } from '../../hooks';
import User from '../User';
import Label from '../Label';
import BoardMembershipsStep from '../BoardMembershipsStep';
import LabelsStep from '../LabelsStep';
import styles from './FiltersStep.module.scss';
const StepTypes = {
MEMBERS: 'MEMBERS',
LABELS: 'LABELS',
};
const FiltersStep = React.memo(
({
keyword,
users,
labels,
allBoardMemberships,
allLabels,
title,
canEdit,
onKeywordUpdate,
onUserAdd,
onUserRemove,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelMove,
onLabelDelete,
onBack,
}) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleKeywordChange = useCallback(
(newValue) => {
onKeywordUpdate(newValue);
},
[onKeywordUpdate],
);
const handleRemoveUserClick = useCallback(
(id) => {
onUserRemove(id);
},
[onUserRemove],
);
const handleRemoveLabelClick = useCallback(
(id) => {
onLabelRemove(id);
},
[onLabelRemove],
);
if (step) {
switch (step.type) {
case StepTypes.MEMBERS:
return (
<BoardMembershipsStep
items={allBoardMemberships}
currentUserIds={users.map((user) => user.id)}
title="common.filterByMembers"
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
onBack={handleBack}
/>
);
case StepTypes.LABELS:
return (
<LabelsStep
items={allLabels}
currentIds={labels.map((label) => label.id)}
title="common.filterByLabels"
canEdit={canEdit}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onMove={onLabelMove}
onDelete={onLabelDelete}
onBack={handleBack}
/>
);
default:
}
}
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content className={styles.container}>
<span className={styles.filter}>
<div className={styles.filterTitle}>{t('common.keyword')}</div>
<Input
fluid
placeholder={t('common.enterKeyword')}
icon="search"
value={keyword}
onChange={(e) => handleKeywordChange(e.target.value)}
/>
</span>
<span className={styles.filter}>
<div className={styles.filterTitle}>{t('common.members')}</div>
{users.slice(0, 5).map((user) => (
<span key={user.id} className={styles.filterItem}>
<User
name={user.name}
avatarUrl={user.avatarUrl}
size="tiny"
onClick={() => handleRemoveUserClick(user.id)}
/>
</span>
))}
<button
type="button"
className={styles.filterButton}
onClick={() => openStep(StepTypes.MEMBERS)}
>
<span className={styles.filterLabel}>
{users.length === 0 ? t('common.all') : <Icon name="plus" />}
</span>
</button>
</span>
<span className={styles.filter}>
<div className={styles.filterTitle}>{t('common.labels')}</div>
{labels.slice(0, 5).map((label) => (
<span key={label.id} className={styles.filterItem}>
<Label
name={label.name}
color={label.color}
size="small"
onClick={() => handleRemoveLabelClick(label.id)}
/>
</span>
))}
<button
type="button"
className={styles.filterButton}
onClick={() => openStep(StepTypes.LABELS)}
>
<span className={styles.filterLabel}>
{labels.length === 0 ? t('common.all') : <Icon name="plus" />}
</span>
</button>
</span>
</Popup.Content>
</>
);
},
);
FiltersStep.propTypes = {
/* eslint-disable react/forbid-prop-types */
keyword: PropTypes.string.isRequired,
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
allBoardMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
title: PropTypes.string,
canEdit: PropTypes.bool.isRequired,
onKeywordUpdate: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired,
onLabelRemove: PropTypes.func.isRequired,
onLabelCreate: PropTypes.func.isRequired,
onLabelUpdate: PropTypes.func.isRequired,
onLabelMove: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
onBack: PropTypes.func,
};
FiltersStep.defaultProps = {
title: 'common.filters_title',
onBack: undefined,
};
export default FiltersStep;

View file

@ -0,0 +1,50 @@
:global(#app) {
.container {
display: flex;
flex-direction: column;
row-gap: 10px;
}
.filter {
margin-right: 10px;
}
.filterButton {
background: transparent;
border: none;
cursor: pointer;
display: inline-block;
outline: none;
padding: 0;
}
.filterItem {
display: inline-block;
font-size: 0;
line-height: 0;
margin-right: 4px;
max-width: 190px;
vertical-align: top;
}
.filterLabel {
background: rgba(0, 0, 0, 0.24);
border-radius: 3px;
color: #fff;
display: inline-block;
font-size: 12px;
line-height: 20px;
padding: 2px 8px;
&:hover {
background: rgba(0, 0, 0, 0.32);
}
}
.filterTitle {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}
}

View file

@ -0,0 +1,3 @@
import FiltersStep from './FiltersStep';
export default FiltersStep;

View file

@ -121,6 +121,7 @@ export default {
BOARD_DELETE__SUCCESS: 'BOARD_DELETE__SUCCESS',
BOARD_DELETE__FAILURE: 'BOARD_DELETE__FAILURE',
BOARD_DELETE_HANDLE: 'BOARD_DELETE_HANDLE',
KEYWORD_TO_BOARD_FILTER_UPDATE: 'KEYWORD_TO_BOARD_FILTER_UPDATE',
/* Board memberships */

View file

@ -83,6 +83,7 @@ export default {
BOARD_MOVE: `${PREFIX}/BOARD_MOVE`,
BOARD_DELETE: `${PREFIX}/BOARD_DELETE`,
BOARD_DELETE_HANDLE: `${PREFIX}/BOARD_DELETE_HANDLE`,
KEYWORD_TO_FILTER_IN_CURRENT_BOARD_UPDATE: `${PREFIX}/KEYWORD_TO_FILTER_IN_CURRENT_BOARD_UPDATE`,
/* Board memberships */

View file

@ -11,6 +11,7 @@ const mapStateToProps = (state) => {
const isCurrentUserManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
const memberships = selectors.selectMembershipsForCurrentBoard(state);
const labels = selectors.selectLabelsForCurrentBoard(state);
const filterKeyword = selectors.selectFilterKeywordForCurrentBoard(state);
const filterUsers = selectors.selectFilterUsersForCurrentBoard(state);
const filterLabels = selectors.selectFilterLabelsForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
@ -21,6 +22,7 @@ const mapStateToProps = (state) => {
return {
memberships,
labels,
filterKeyword,
filterUsers,
filterLabels,
allUsers,
@ -35,6 +37,7 @@ const mapDispatchToProps = (dispatch) =>
onMembershipCreate: entryActions.createMembershipInCurrentBoard,
onMembershipUpdate: entryActions.updateBoardMembership,
onMembershipDelete: entryActions.deleteBoardMembership,
onKeywordToFilterUpdate: entryActions.updateKeywordToFilterInCurrentBoard,
onUserToFilterAdd: entryActions.addUserToFilterInCurrentBoard,
onUserFromFilterRemove: entryActions.removeUserFromFilterInCurrentBoard,
onLabelToFilterAdd: entryActions.addLabelToFilterInCurrentBoard,

View file

@ -59,6 +59,13 @@ const handleBoardDelete = (board) => ({
},
});
const updateKeywordToFilterInCurrentBoard = (keyword) => ({
type: EntryActionTypes.KEYWORD_TO_FILTER_IN_CURRENT_BOARD_UPDATE,
payload: {
keyword,
},
});
export default {
createBoardInCurrentProject,
handleBoardCreate,
@ -68,4 +75,5 @@ export default {
moveBoard,
deleteBoard,
handleBoardDelete,
updateKeywordToFilterInCurrentBoard,
};

View file

@ -50,6 +50,7 @@ export default {
cardActions_title: 'Card Actions',
cardNotFound_title: 'Card Not Found',
cardOrActionAreDeleted: 'Card or action are deleted.',
clear: 'Clear',
color: 'Color',
copy_inline: 'copy',
createBoard_title: 'Create Board',
@ -90,17 +91,21 @@ export default {
enterCardTitle: 'Enter card title... [Ctrl+Enter] to auto-open.',
enterDescription: 'Enter description...',
enterFilename: 'Enter filename',
enterKeyword: 'Enter a keyword...',
enterListTitle: 'Enter list title...',
enterProjectTitle: 'Enter project title',
enterTaskDescription: 'Enter task description...',
filterByLabels_title: 'Filter By Labels',
filterByMembers_title: 'Filter By Members',
filters: 'Filters',
filters_title: 'Filter',
fromComputer_title: 'From Computer',
fromTrello: 'From Trello',
general: 'General',
hours: 'Hours',
importBoard_title: 'Import Board',
invalidCurrentPassword: 'Invalid current password',
keyword: 'Keyword',
labels: 'Labels',
language: 'Language',
leaveBoard_title: 'Leave Board',

View file

@ -54,6 +54,7 @@ export default {
cardActions_title: 'Ações do Cartão',
cardNotFound_title: 'Cartão não encontrado',
cardOrActionAreDeleted: 'Cartão ou ação foram excluídos.',
clear: 'Limpar',
color: 'Cor',
createBoard_title: 'Criar Quadro',
createLabel_title: 'Criar Rótulo',
@ -93,17 +94,21 @@ export default {
enterCardTitle: 'Digite o título do cartão... [Ctrl+Enter] para abrir automaticamente.',
enterDescription: 'Digite a descrição...',
enterFilename: 'Digite o nome do arquivo',
enterKeyword: 'Informe uma palavra-chave...',
enterListTitle: 'Digite o título da lista...',
enterProjectTitle: 'Digite o título do projeto',
enterTaskDescription: 'Digite a descrição da tarefa...',
filterByLabels_title: 'Filtrar por Rótulos',
filterByMembers_title: 'Filtrar por Membros',
filters: 'Filtros',
filters_title: 'Filtro',
fromComputer_title: 'Do Computador',
fromTrello: 'Do Trello',
general: 'Geral',
hours: 'Horas',
importBoard_title: 'Importar Quadro',
invalidCurrentPassword: 'Senha atual inválida',
keyword: 'Palavra-chave',
labels: 'Rótulos',
language: 'Idioma',
leaveBoard_title: 'Sair do Quadro',

View file

@ -23,6 +23,9 @@ export default class extends BaseModel {
through: 'BoardMembership',
relatedName: 'boards',
}),
filterKeyword: attr({
getDefault: () => '',
}),
filterUsers: many('User', 'filterBoards'),
filterLabels: many('Label', 'filterBoards'),
};
@ -166,6 +169,12 @@ export default class extends BaseModel {
case ActionTypes.LABEL_FROM_BOARD_FILTER_REMOVE:
Board.withId(payload.boardId).filterLabels.remove(payload.id);
break;
case ActionTypes.KEYWORD_TO_BOARD_FILTER_UPDATE:
Board.withId(payload.boardId).update({
filterKeyword: payload.keyword,
});
break;
default:
}

View file

@ -87,9 +87,16 @@ export default class extends BaseModel {
getFilteredOrderedCardsModelArray() {
let cardModels = this.getOrderedCardsQuerySet().toModelArray();
const { filterKeyword } = this.board;
const filterUserIds = this.board.filterUsers.toRefArray().map((user) => user.id);
const filterLabelIds = this.board.filterLabels.toRefArray().map((label) => label.id);
if (filterKeyword) {
cardModels = cardModels.filter((cardModel) => {
return cardModel.name.toLowerCase().includes(filterKeyword.toLowerCase());
});
}
if (filterUserIds.length > 0) {
cardModels = cardModels.filter((cardModel) => {
const users = cardModel.users.toRefArray();

View file

@ -176,6 +176,16 @@ export function* handleBoardDelete(board) {
yield put(actions.handleBoardDelete(board));
}
export function* updateKeywordToBoardFilter(keyword, boardId) {
yield put(actions.updateKeywordToBoardFilter(keyword, boardId));
}
export function* updateKeywordToFilterInCurrentBoard(keyword) {
const { boardId } = yield select(selectors.selectPath);
yield call(updateKeywordToBoardFilter, keyword, boardId);
}
export default {
createBoard,
createBoardInCurrentProject,
@ -186,4 +196,6 @@ export default {
moveBoard,
deleteBoard,
handleBoardDelete,
updateKeywordToBoardFilter,
updateKeywordToFilterInCurrentBoard,
};

View file

@ -25,5 +25,9 @@ export default function* boardsWatchers() {
takeEvery(EntryActionTypes.BOARD_DELETE_HANDLE, ({ payload: { board } }) =>
services.handleBoardDelete(board),
),
takeEvery(
EntryActionTypes.KEYWORD_TO_FILTER_IN_CURRENT_BOARD_UPDATE,
({ payload: { keyword } }) => services.updateKeywordToFilterInCurrentBoard(keyword),
),
]);
}

View file

@ -139,6 +139,24 @@ export const selectListIdsForCurrentBoard = createSelector(
},
);
export const selectFilterKeywordForCurrentBoard = createSelector(
orm,
(state) => selectPath(state).boardId,
({ Board }, id) => {
if (!id) {
return id;
}
const boardModel = Board.withId(id);
if (!boardModel) {
return boardModel;
}
return boardModel.filterKeyword;
},
);
export const selectFilterUsersForCurrentBoard = createSelector(
orm,
(state) => selectPath(state).boardId,
@ -189,6 +207,7 @@ export default {
selectCurrentUserMembershipForCurrentBoard,
selectLabelsForCurrentBoard,
selectListIdsForCurrentBoard,
selectFilterKeywordForCurrentBoard,
selectFilterUsersForCurrentBoard,
selectFilterLabelsForCurrentBoard,
selectIsBoardWithIdExists,