A board level switch, off by default. With it on, each list puts its card count on the add-card button. Useful to a board that is being kept to a size and noise to one that is not, which is why it is a choice rather than always on. The count is every card in the list, not the ones a filter leaves visible. Those answer different questions: how full is this list, versus how many match what I am looking at right now. The first is the one a counter is for, and it is also what the same switch counts in Pro, so a board keeps its meaning across an upgrade. The column matches Pro's name, type and default, so the setting survives that upgrade rather than being dropped on the way.
542 lines
15 KiB
JavaScript
Executable File
542 lines
15 KiB
JavaScript
Executable File
/*!
|
|
* 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(),
|
|
showCardCounter: 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,
|
|
}),
|
|
filterLists: many('List', 'filterBoards'),
|
|
};
|
|
|
|
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.LIST_TO_BOARD_FILTER_ADD:
|
|
Board.withId(payload.boardId).filterLists.add(payload.id);
|
|
|
|
break;
|
|
case ActionTypes.LIST_FROM_BOARD_FILTER_REMOVE:
|
|
Board.withId(payload.boardId).filterLists.remove(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);
|
|
}
|
|
|
|
// The three filters below are independent of one another and compose: a
|
|
// board can be narrowed to unassigned cards AND to a set of lists at the
|
|
// same time, so neither one may return early.
|
|
if (this.filterNoMember) {
|
|
cardModels = cardModels.filter((cardModel) => cardModel.users.toRefArray().length === 0);
|
|
}
|
|
|
|
const filterListIds = this.filterLists.toRefArray().map((list) => list.id);
|
|
|
|
if (filterListIds.length > 0) {
|
|
cardModels = cardModels.filter((cardModel) => filterListIds.includes(cardModel.listId));
|
|
}
|
|
|
|
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();
|
|
this.filterLists.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();
|
|
}
|
|
}
|