Merge pull request #1236 from symonbaikov/feat/no-member-filter

Add filter for cards without members ("No member" filter)

Conflicted with the negative label filter, which had added its own field to
the board model next to this one. Both stay; they filter on different things.
This commit is contained in:
Daniel Hiller
2026-09-17 02:01:58 +02:00
9 changed files with 84 additions and 0 deletions
@@ -162,6 +162,14 @@ const Filters = React.memo(() => {
const isSearchActive = search || isSearchFocused; const isSearchActive = search || isSearchFocused;
const handleNoMemberClick = useCallback(() => {
if (board.filterNoMember) {
dispatch(entryActions.removeNoMemberFromFilterInCurrentBoard());
} else {
dispatch(entryActions.setNoMemberToFilterInCurrentBoard());
}
}, [dispatch, board.filterNoMember]);
return ( return (
<> <>
<span className={styles.filter}> <span className={styles.filter}>
@@ -176,6 +184,13 @@ const Filters = React.memo(() => {
{userIds.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>} {userIds.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
</button> </button>
</BoardMembershipsPopup> </BoardMembershipsPopup>
<button
type="button"
className={classNames(styles.filterButton, styles.filterLabel)}
onClick={handleNoMemberClick}
>
{t('common.noMember')}
</button>
{userIds.length === 0 && withCurrentUserSelector && ( {userIds.length === 0 && withCurrentUserSelector && (
<Tooltip content={t('action.filterByCurrentUser')}> <Tooltip content={t('action.filterByCurrentUser')}>
<button type="button" className={styles.filterButton} onClick={handleCurrentUserSelect}> <button type="button" className={styles.filterButton} onClick={handleCurrentUserSelect}>
+3
View File
@@ -273,6 +273,9 @@ export default {
LABEL_FROM_BOARD_FILTER_REMOVE: 'LABEL_FROM_BOARD_FILTER_REMOVE', LABEL_FROM_BOARD_FILTER_REMOVE: 'LABEL_FROM_BOARD_FILTER_REMOVE',
LABEL_FILTER_IN_BOARD_UPDATE: 'LABEL_FILTER_IN_BOARD_UPDATE', LABEL_FILTER_IN_BOARD_UPDATE: 'LABEL_FILTER_IN_BOARD_UPDATE',
NO_MEMBER_TO_BOARD_FILTER_SET: 'NO_MEMBER_TO_BOARD_FILTER_SET',
NO_MEMBER_FROM_BOARD_FILTER_REMOVE: 'NO_MEMBER_FROM_BOARD_FILTER_REMOVE',
/* Lists */ /* Lists */
LIST_CREATE: 'LIST_CREATE', LIST_CREATE: 'LIST_CREATE',
+5
View File
@@ -321,4 +321,9 @@ export default {
NOTIFICATION_SERVICE_TEST: `${PREFIX}/NOTIFICATION_SERVICE_TEST`, NOTIFICATION_SERVICE_TEST: `${PREFIX}/NOTIFICATION_SERVICE_TEST`,
NOTIFICATION_SERVICE_DELETE: `${PREFIX}/NOTIFICATION_SERVICE_DELETE`, NOTIFICATION_SERVICE_DELETE: `${PREFIX}/NOTIFICATION_SERVICE_DELETE`,
NOTIFICATION_SERVICE_DELETE_HANDLE: `${PREFIX}/NOTIFICATION_SERVICE_DELETE_HANDLE`, NOTIFICATION_SERVICE_DELETE_HANDLE: `${PREFIX}/NOTIFICATION_SERVICE_DELETE_HANDLE`,
/* New member types */
NO_MEMBER_TO_FILTER_IN_CURRENT_BOARD_SET: `${PREFIX}/NO_MEMBER_TO_FILTER_IN_CURRENT_BOARD_SET`,
NO_MEMBER_FROM_FILTER_IN_CURRENT_BOARD_REMOVE: `${PREFIX}/NO_MEMBER_FROM_FILTER_IN_CURRENT_BOARD_REMOVE`,
}; };
+10
View File
@@ -93,6 +93,14 @@ const handleBoardDelete = (board) => ({
}, },
}); });
const setNoMemberToFilterInCurrentBoard = () => ({
type: EntryActionTypes.NO_MEMBER_TO_FILTER_IN_CURRENT_BOARD_SET,
});
const removeNoMemberFromFilterInCurrentBoard = () => ({
type: EntryActionTypes.NO_MEMBER_FROM_FILTER_IN_CURRENT_BOARD_REMOVE,
});
export default { export default {
createBoardInCurrentProject, createBoardInCurrentProject,
handleBoardCreate, handleBoardCreate,
@@ -106,4 +114,6 @@ export default {
searchInCurrentBoard, searchInCurrentBoard,
deleteBoard, deleteBoard,
handleBoardDelete, handleBoardDelete,
setNoMemberToFilterInCurrentBoard,
removeNoMemberFromFilterInCurrentBoard,
}; };
+1
View File
@@ -312,6 +312,7 @@ export default {
noCardsFound: 'No cards found.', noCardsFound: 'No cards found.',
noConnectionToServer: 'No connection to server', noConnectionToServer: 'No connection to server',
noLists: 'No lists', noLists: 'No lists',
noMember: 'No member',
noProjects: 'No projects', noProjects: 'No projects',
noUnreadNotifications: 'No unread notifications.', noUnreadNotifications: 'No unread notifications.',
notifications: 'Notifications', notifications: 'Notifications',
+19
View File
@@ -69,6 +69,9 @@ export default class extends BaseModel {
filterUsers: many('User', 'filterBoards'), filterUsers: many('User', 'filterBoards'),
filterLabels: many('Label', 'filterBoards'), filterLabels: many('Label', 'filterBoards'),
filterExcludedLabels: many('Label', 'filterExcludedBoards'), filterExcludedLabels: many('Label', 'filterExcludedBoards'),
filterNoMember: attr({
getDefault: () => false,
}),
}; };
static reducer({ type, payload }, Board) { static reducer({ type, payload }, Board) {
@@ -299,6 +302,17 @@ export default class extends BaseModel {
}); });
break; break;
case ActionTypes.NO_MEMBER_TO_BOARD_FILTER_SET: {
const boardModel = Board.withId(payload.boardId);
boardModel.filterUsers.clear();
boardModel.update({ filterNoMember: true });
break;
}
case ActionTypes.NO_MEMBER_FROM_BOARD_FILTER_REMOVE: {
const boardModel = Board.withId(payload.boardId);
boardModel.update({ filterNoMember: false });
break;
}
default: default:
} }
} }
@@ -416,6 +430,11 @@ export default class extends BaseModel {
cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds); cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds);
} }
if (this.filterNoMember) {
cardModels = cardModels.filter((cardModel) => cardModel.users.toRefArray().length === 0);
return cardModels;
}
return cardModels; return cardModels;
} }
+5
View File
@@ -389,6 +389,11 @@ export default class extends BaseModel {
cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds); cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds);
} }
if (this.board.filterNoMember) {
cardModels = cardModels.filter((cardModel) => cardModel.users.toRefArray().length === 0);
return cardModels;
}
return cardModels; return cardModels;
} }
+18
View File
@@ -255,6 +255,22 @@ export function* handleBoardDelete(board) {
} }
} }
export function* setNoMemberToFilterInCurrentBoard() {
const { boardId } = yield select(selectors.selectPath);
yield put({
type: ActionTypes.NO_MEMBER_TO_BOARD_FILTER_SET,
payload: { boardId },
});
}
export function* removeNoMemberFromFilterInCurrentBoard() {
const { boardId } = yield select(selectors.selectPath);
yield put({
type: ActionTypes.NO_MEMBER_FROM_BOARD_FILTER_REMOVE,
payload: { boardId },
});
}
export default { export default {
createBoard, createBoard,
createBoardInCurrentProject, createBoardInCurrentProject,
@@ -271,4 +287,6 @@ export default {
searchInCurrentBoard, searchInCurrentBoard,
deleteBoard, deleteBoard,
handleBoardDelete, handleBoardDelete,
setNoMemberToFilterInCurrentBoard,
removeNoMemberFromFilterInCurrentBoard,
}; };
+8
View File
@@ -44,5 +44,13 @@ export default function* boardsWatchers() {
takeEvery(EntryActionTypes.BOARD_DELETE_HANDLE, ({ payload: { board } }) => takeEvery(EntryActionTypes.BOARD_DELETE_HANDLE, ({ payload: { board } }) =>
services.handleBoardDelete(board), services.handleBoardDelete(board),
), ),
takeEvery(
EntryActionTypes.NO_MEMBER_TO_FILTER_IN_CURRENT_BOARD_SET,
services.setNoMemberToFilterInCurrentBoard,
),
takeEvery(
EntryActionTypes.NO_MEMBER_FROM_FILTER_IN_CURRENT_BOARD_REMOVE,
services.removeNoMemberFromFilterInCurrentBoard,
),
]); ]);
} }