Merge pull request #1648 from symonbaikov/feat/list-filter

feat: add filter by list (status) in list view

Three filters landed in the same places while this was open: excluded labels,
cards without a member, and now lists. They are independent of each other, so
every conflict resolves to keeping both sides.

The selector was the one exception worth naming. Both branches had written the
same body under a different name, so the merge put one header on one body; it
is now two complete selectors.
This commit is contained in:
Daniel Hiller
2026-09-17 02:03:04 +02:00
16 changed files with 340 additions and 781 deletions
-522
View File
@@ -1,522 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import { attr, fk, many } from 'redux-orm';
import BaseModel from './BaseModel';
import buildSearchParts from '../utils/build-search-parts';
import filterCardLabels from '../utils/filter-card-labels';
import { isListKanban } from '../utils/record-helpers';
import { recallBoardView } from '../utils/board-view-memory';
import ActionTypes from '../constants/ActionTypes';
import Config from '../constants/Config';
import { BoardContexts, BoardViews, LabelFilterModes } from '../constants/Enums';
const prepareFetchedBoard = (board) => ({
...board,
isFetching: false,
context: BoardContexts.BOARD,
// Whatever this window was last looking at, where that choice still applies —
// see `utils/board-view-memory`. A board's configured default is where it
// starts, not where it has to stay.
view: recallBoardView(board.id, BoardContexts.BOARD) || board.defaultView,
search: '',
});
export default class extends BaseModel {
static modelName = 'Board';
static fields = {
id: attr(),
position: attr(),
name: attr(),
defaultView: attr(),
defaultCardType: attr(),
limitCardTypesToDefaultOne: attr(),
alwaysDisplayCardCreator: attr(),
displayCardAges: attr(),
expandTaskListsByDefault: attr(),
context: attr(),
view: attr(),
search: attr(),
isSubscribed: attr({
getDefault: () => false,
}),
isFetching: attr({
getDefault: () => null,
}),
lastActivityId: attr({
getDefault: () => null,
}),
isActivitiesFetching: attr({
getDefault: () => false,
}),
isAllActivitiesFetched: attr({
getDefault: () => null,
}),
projectId: fk({
to: 'Project',
as: 'project',
relatedName: 'boards',
}),
memberUsers: many({
to: 'User',
through: 'BoardMembership',
relatedName: 'boards',
}),
filterUsers: many('User', 'filterBoards'),
filterLabels: many('Label', 'filterBoards'),
filterExcludedLabels: many('Label', 'filterExcludedBoards'),
filterNoMember: attr({
getDefault: () => false,
}),
};
static reducer({ type, payload }, Board) {
switch (type) {
case ActionTypes.LOCATION_CHANGE_HANDLE:
if (payload.board) {
Board.upsert(prepareFetchedBoard(payload.board));
}
break;
case ActionTypes.LOCATION_CHANGE_HANDLE__BOARD_FETCH:
case ActionTypes.BOARD_FETCH:
Board.withId(payload.id).update({
isFetching: true,
});
break;
case ActionTypes.SOCKET_RECONNECT_HANDLE: {
const boardIds = payload.boards.map(({ id }) => id);
Board.all()
.toModelArray()
.forEach((boardModel) => {
if (boardModel.isFetching === null || !boardIds.includes(boardModel.id)) {
boardModel.deleteWithClearable();
}
});
if (payload.board) {
const boardModel = Board.withId(payload.board.id);
if (boardModel) {
boardModel.update(payload.board);
} else {
Board.upsert(prepareFetchedBoard(payload.board));
}
}
payload.boards.forEach((board) => {
Board.upsert(board);
});
break;
}
case ActionTypes.SOCKET_RECONNECT_HANDLE__CORE_FETCH:
Board.all()
.toModelArray()
.forEach((boardModel) => {
if (boardModel.id !== payload.currentBoardId) {
boardModel.update({
isFetching: null,
});
boardModel.deleteRelated(payload.currentUserId, true);
}
});
break;
case ActionTypes.CORE_INITIALIZE:
if (payload.board) {
Board.upsert(prepareFetchedBoard(payload.board));
}
payload.boards.forEach((board) => {
Board.upsert(board);
});
break;
case ActionTypes.USER_UPDATE_HANDLE:
Board.all()
.toModelArray()
.forEach((boardModel) => {
if (!payload.boardIds.includes(boardModel.id)) {
boardModel.deleteWithRelated(true);
}
});
if (payload.board) {
Board.upsert(prepareFetchedBoard(payload.board));
}
if (payload.boards) {
payload.boards.forEach((board) => {
Board.upsert(board);
});
}
break;
case ActionTypes.USER_TO_BOARD_FILTER_ADD: {
const boardModel = Board.withId(payload.boardId);
if (payload.replace) {
boardModel.filterUsers.clear();
}
boardModel.filterUsers.add(payload.id);
break;
}
case ActionTypes.USER_FROM_BOARD_FILTER_REMOVE:
Board.withId(payload.boardId).filterUsers.remove(payload.id);
break;
case ActionTypes.PROJECT_CREATE_HANDLE:
payload.boards.forEach((board) => {
Board.upsert(board);
});
break;
case ActionTypes.PROJECT_UPDATE_HANDLE:
case ActionTypes.PROJECT_MANAGER_CREATE_HANDLE:
case ActionTypes.BOARD_MEMBERSHIP_CREATE_HANDLE:
if (payload.board) {
Board.upsert(prepareFetchedBoard(payload.board));
}
if (payload.boards) {
payload.boards.forEach((board) => {
Board.upsert(board);
});
}
break;
case ActionTypes.BOARD_CREATE:
case ActionTypes.BOARD_CREATE_HANDLE:
case ActionTypes.BOARD_UPDATE__SUCCESS:
case ActionTypes.BOARD_UPDATE_HANDLE:
Board.upsert(payload.board);
break;
case ActionTypes.BOARD_CREATE__SUCCESS:
Board.withId(payload.localId).delete();
Board.upsert(payload.board);
break;
case ActionTypes.BOARD_CREATE__FAILURE:
Board.withId(payload.localId).delete();
break;
case ActionTypes.BOARD_FETCH__SUCCESS:
Board.upsert(prepareFetchedBoard(payload.board));
break;
case ActionTypes.BOARD_FETCH__FAILURE:
Board.withId(payload.id).update({
isFetching: null,
});
break;
case ActionTypes.BOARD_UPDATE:
Board.withId(payload.id).update(payload.data);
break;
case ActionTypes.BOARD_CONTEXT_UPDATE: {
const boardModel = Board.withId(payload.id);
boardModel.update({
context: payload.value,
view: payload.value === BoardContexts.BOARD ? boardModel.defaultView : BoardViews.LIST,
});
break;
}
case ActionTypes.IN_BOARD_SEARCH:
Board.withId(payload.id).update({
search: payload.value,
});
break;
case ActionTypes.BOARD_DELETE:
Board.withId(payload.id).deleteWithRelated();
break;
case ActionTypes.BOARD_DELETE__SUCCESS:
case ActionTypes.BOARD_DELETE_HANDLE: {
const boardModel = Board.withId(payload.board.id);
if (boardModel) {
boardModel.deleteWithRelated();
}
break;
}
case ActionTypes.LABEL_TO_BOARD_FILTER_ADD:
Board.withId(payload.boardId).filterLabels.add(payload.id);
break;
case ActionTypes.LABEL_FROM_BOARD_FILTER_REMOVE:
Board.withId(payload.boardId).filterLabels.remove(payload.id);
break;
case ActionTypes.LABEL_FILTER_IN_BOARD_UPDATE: {
const boardModel = Board.withId(payload.boardId);
try {
boardModel.filterLabels.remove(payload.id);
} catch {
/* empty */
}
try {
boardModel.filterExcludedLabels.remove(payload.id);
} catch {
/* empty */
}
if (payload.mode === LabelFilterModes.INCLUDE) {
boardModel.filterLabels.add(payload.id);
} else if (payload.mode === LabelFilterModes.EXCLUDE) {
boardModel.filterExcludedLabels.add(payload.id);
}
break;
}
case ActionTypes.ACTIVITIES_IN_BOARD_FETCH:
Board.withId(payload.boardId).update({
isActivitiesFetching: true,
});
break;
case ActionTypes.ACTIVITIES_IN_BOARD_FETCH__SUCCESS:
Board.withId(payload.boardId).update({
isActivitiesFetching: false,
isAllActivitiesFetched: payload.activities.length < Config.ACTIVITIES_LIMIT,
...(payload.activities.length > 0 && {
lastActivityId: payload.activities[payload.activities.length - 1].id,
}),
});
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:
}
}
getMembershipsQuerySet() {
return this.memberships.orderBy(['id.length', 'id']);
}
getLabelsQuerySet() {
return this.labels.orderBy(['position', 'id.length', 'id']);
}
getListsQuerySet() {
return this.lists.orderBy(['position', 'id.length', 'id']);
}
getKanbanListsQuerySet() {
return this.getListsQuerySet().filter((list) => isListKanban(list));
}
getCustomFieldGroupsQuerySet() {
return this.customFieldGroups.orderBy(['position', 'id.length', 'id']);
}
getActivitiesQuerySet() {
return this.activities.orderBy(['id.length', 'id'], ['desc', 'desc']);
}
getUnreadNotificationsQuerySet() {
return this.notifications.filter({
isRead: false,
});
}
getNotificationServicesQuerySet() {
return this.notificationServices.orderBy(['id.length', 'id']);
}
getMembershipModelByUserId(userId) {
return this.memberships
.filter({
userId,
})
.first();
}
getCardsModelArray() {
return this.getKanbanListsQuerySet()
.toModelArray()
.flatMap((listModel) => listModel.getCardsModelArray());
}
getFilteredCardsModelArray() {
let cardModels = this.getCardsModelArray();
if (cardModels.length === 0) {
return cardModels;
}
if (this.search) {
if (this.search.startsWith('/')) {
let searchRegex;
try {
searchRegex = new RegExp(this.search.substring(1), 'i');
} catch {
return [];
}
cardModels = cardModels.filter(
(cardModel) =>
searchRegex.test(cardModel.name) ||
(cardModel.description && searchRegex.test(cardModel.description)),
);
} else {
const searchParts = buildSearchParts(this.search);
cardModels = cardModels.filter((cardModel) => {
const name = cardModel.name.toLowerCase();
const description = cardModel.description && cardModel.description.toLowerCase();
return searchParts.every(
(searchPart) =>
name.includes(searchPart) || (description && description.includes(searchPart)),
);
});
}
}
const filterUserIds = this.filterUsers.toRefArray().map((user) => user.id);
if (filterUserIds.length > 0) {
cardModels = cardModels.filter((cardModel) => {
const users = cardModel.users.toRefArray();
if (users.some((user) => filterUserIds.includes(user.id))) {
return true;
}
return cardModel
.getTaskListsQuerySet()
.toModelArray()
.some((taskListModel) =>
taskListModel
.getTasksQuerySet()
.toRefArray()
.some((task) => task.assigneeUserId && filterUserIds.includes(task.assigneeUserId)),
);
});
}
const filterLabelIds = this.filterLabels.toRefArray().map((label) => label.id);
const filterExcludedLabelIds = this.filterExcludedLabels.toRefArray().map((label) => label.id);
if (filterLabelIds.length > 0 || filterExcludedLabelIds.length > 0) {
cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds);
}
if (this.filterNoMember) {
cardModels = cardModels.filter((cardModel) => cardModel.users.toRefArray().length === 0);
return cardModels;
}
return cardModels;
}
getActivitiesModelArray() {
if (this.isAllActivitiesFetched === null) {
return [];
}
const activityModels = this.getActivitiesQuerySet().toModelArray();
if (this.lastActivityId && this.isAllActivitiesFetched === false) {
return activityModels.filter((activityModel) => {
if (activityModel.id.length > this.lastActivityId.length) {
return true;
}
if (activityModel.id.length < this.lastActivityId.length) {
return false;
}
return activityModel.id >= this.lastActivityId;
});
}
return activityModels;
}
hasMembershipWithUserId(userId) {
return this.memberships
.filter({
userId,
})
.exists();
}
isAvailableForUser(userModel) {
if (!this.project) {
return false;
}
return (
this.project.isExternalAccessibleForUser(userModel) ||
this.hasMembershipWithUserId(userModel.id)
);
}
deleteListsWithRelated(soft) {
this.lists.toModelArray().forEach((listModel) => {
listModel.deleteWithRelated(soft);
});
}
deleteClearable() {
this.filterUsers.clear();
this.filterLabels.clear();
this.filterExcludedLabels.clear();
}
deleteRelated(exceptMemberUserId, soft) {
this.deleteClearable();
this.memberships.toModelArray().forEach((boardMembershipModel) => {
if (boardMembershipModel.userId !== exceptMemberUserId) {
boardMembershipModel.deleteWithRelated();
}
});
this.labels.toModelArray().forEach((labelModel) => {
labelModel.deleteWithRelated();
});
this.deleteListsWithRelated(soft);
this.notificationServices.delete();
}
deleteWithClearable() {
this.deleteClearable();
this.delete();
}
deleteWithRelated(soft) {
this.deleteRelated(undefined, soft);
this.delete();
}
}