Files
wekan/client/components/lists/listHeader.js
T
Lauri Ojansivu 46924985f8 Soft delete for lists: delete = mark, restorable + undoable (#1023).
Deleting a list no longer destroys it. The list and its cards are marked
(deletedAt/deletedBy/deleteBatchId), hidden from the board, recorded as a
reversible delete in history, and restorable via lists.restore or Ctrl+Z.
This is the first slice of "no permanent delete in ordinary use": physical
removal is limited to GDPR/account erasure and a Global-Admin purge gated by
a new Admin Panel / Features / Delete "Enable permanent delete" toggle
(off by default).

- models/lib/softDelete.js: pure notDeleted/softDeleteSet/restoreModifier/
  canPurge helpers (+ tests/softDelete.test.cjs, wired into test:unit:node).
  softDeleteSet omits falsy actor/batch and restore uses $unset so no null is
  ever written to a Date/String schema field.
- enablePermanentDelete feature flag (models/lib/featureFlags.js) + Settings
  schema field.
- deletedAt/deletedBy/deleteBatchId schema fields on Lists and Cards
  (optional, no null default -> absent means live).
- board/swimlane list render paths filter deletedAt: null.
- server/models/lists.js: shared softRemoveList() + lists.softRemove /
  lists.restore / lists.purge (purge gated on canPurge = isAdmin && flag);
  client delete handler and REST DELETE now soft-remove.
- userPositionHistory undo()/redo() handle actionType delete/restore via a
  batch-consistent _applyDeleted() helper.

Design + planned rollout (other entities, Recycle Bin, admin Delete category
table with GDPR data classes) in docs/Features/Undo/Undo.md.
2026-07-17 20:20:06 +03:00

848 lines
28 KiB
JavaScript

import { ReactiveCache } from '/imports/reactiveCache';
import Lists from '../../../models/lists';
import { TAPi18n } from '/imports/i18n';
import dragscroll from '@wekanteam/dragscroll';
import { BoardSwimlaneListDialog } from '/client/lib/dialogWithBoardSwimlaneList';
import Cards from '/models/cards';
import { LIST_COLORS } from '/models/metadata/colors';
import { isHexColor, toHex } from '/models/lib/contrastColor';
import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import { lazyListCardCount } from '/client/lib/lazyCards';
// #5659: single source of truth for the default/minimum list width, shared
// with client/components/lists/list.js and models/users.js.
import {
DEFAULT_LIST_WIDTH,
MIN_LIST_WIDTH,
normalizeListWidth,
} from '/models/lib/listWidth';
let listsColors;
Meteor.startup(() => {
listsColors = LIST_COLORS;
});
Template.listHeader.helpers({
canSeeAddCard() {
const list = Template.currentData();
return (
(!list.getWipLimit('enabled') ||
list.getWipLimit('soft') ||
!Template.instance().reachedWipLimit()) &&
!ReactiveCache.getCurrentUser()?.isWorker()
);
},
isBoardAdmin() {
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
},
starred() {
const list = Template.currentData();
return list.isStarred();
},
collapsed() {
const list = Template.currentData();
return Utils.getListCollapseState(list);
},
isWatching() {
const list = Template.currentData();
return list.findWatcher(Meteor.userId());
},
limitToShowCardsCount() {
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser) {
return currentUser.getLimitToShowCardsCount();
} else {
return false;
}
},
cardsCount() {
const list = Template.currentData();
let swimlaneId = '';
if (Utils.boardView() === 'board-view-swimlanes') {
swimlaneId = list.swimlaneId || '';
}
// In lazy mode minimongo holds only the loaded window, so use the accurate
// server count for the same selector; fall back to the loaded length.
const lazyCount = lazyListCardCount(list, swimlaneId);
return lazyCount !== null ? lazyCount : list.cards(swimlaneId).length;
},
reachedWipLimit() {
const list = Template.currentData();
const lazyCount = lazyListCardCount(list, undefined);
const count = lazyCount !== null ? lazyCount : list.cards().length;
return list.getWipLimit('enabled') && list.getWipLimit('value') <= count;
},
exceededWipLimit() {
const list = Template.currentData();
const lazyCount = lazyListCardCount(list, undefined);
const count = lazyCount !== null ? lazyCount : list.cards().length;
return list.getWipLimit('enabled') && list.getWipLimit('value') < count;
},
// Accurate whole-list card count (used by the badge visibility, WIP-limit
// number and pluralization in the template). In lazy mode `cards.length`
// (= list.cards().length) is only the loaded window, so prefer the server count.
cardsCountWhole() {
const list = Template.currentData();
const lazyCount = lazyListCardCount(list, undefined);
return lazyCount !== null ? lazyCount : list.cards().length;
},
showCardsCountForList(count) {
const currentUser = ReactiveCache.getCurrentUser();
const limit = currentUser ? currentUser.getLimitToShowCardsCount() : false;
return limit >= 0 && count >= limit;
},
cardsCountForListIsOne(count) {
if (count === 1) {
return TAPi18n.__('cards-count-one');
} else {
return TAPi18n.__('cards-count');
}
},
numberFieldsSum() {
const list = Template.currentData();
if (!list) return 0;
const boardId = Session.get('currentBoard');
const fields = ReactiveCache.getCustomFields({
boardIds: { $in: [boardId] },
showSumAtTopOfList: true,
type: 'number',
});
if (!fields || !fields.length) return 0;
const cards = ReactiveCache.getCards({ listId: list._id, archived: false });
let total = 0;
if (cards && cards.length) {
cards.forEach(card => {
const cfs = (card.customFields || []);
fields.forEach(field => {
const cf = cfs.find(f => f && f._id === field._id);
if (!cf || cf.value === null || cf.value === undefined) return;
let v = cf.value;
if (typeof v === 'string') {
const parsed = parseFloat(v.replace(',', '.'));
if (isNaN(parsed)) return;
v = parsed;
}
if (typeof v === 'number' && isFinite(v)) {
total += v;
}
});
});
}
return total;
},
hasNumberFieldsSum() {
const boardId = Session.get('currentBoard');
const fields = ReactiveCache.getCustomFields({
boardIds: { $in: [boardId] },
showSumAtTopOfList: true,
type: 'number',
});
return !!(fields && fields.length);
},
});
// Helper function on template instance for reachedWipLimit check
Template.listHeader.onCreated(function () {
this.reachedWipLimit = function () {
const list = Template.currentData();
const lazyCount = lazyListCardCount(list, undefined);
const count = lazyCount !== null ? lazyCount : list.cards().length;
return list.getWipLimit('enabled') && list.getWipLimit('value') <= count;
};
});
// #459: accessible reordering — move a list left/right via sr-only buttons by
// swapping its sort value with the adjacent list (no drag-and-drop required).
function moveListBy(list, delta) {
const siblings = ReactiveCache.getLists(
{ boardId: list.boardId, archived: false },
{ sort: { sort: 1 } },
);
const idx = siblings.findIndex(l => l._id === list._id);
const target = siblings[idx + delta];
if (idx < 0 || !target) return;
// Capture both sort values before either update; the docs are reactive and
// list.sort would otherwise change after the first update.
const listSort = list.sort;
const targetSort = target.sort;
// Lists are a server-restricted collection; persist the swap through the
// updateListSort method — the same path the drag-and-drop reorder uses. A raw
// client Lists.update of `sort` is reverted by the server.
Meteor.call('updateListSort', list._id, list.boardId, { sort: targetSort });
Meteor.call('updateListSort', target._id, target.boardId, { sort: listSort });
}
Template.listHeader.events({
'click .js-list-move-left'(event) {
event.preventDefault();
moveListBy(Template.currentData(), -1);
},
'click .js-list-move-right'(event) {
event.preventDefault();
moveListBy(Template.currentData(), 1);
},
async 'click .js-list-star'(event) {
event.preventDefault();
const list = Template.currentData();
const status = list.isStarred();
await list.star(!status);
},
'click .js-collapse'(event) {
event.preventDefault();
const list = Template.currentData();
const status = Utils.getListCollapseState(list);
Utils.setListCollapseState(list, !status);
},
'click .js-open-list-menu': Popup.open('listAction'),
// #6465: open the inline Add List composer after this list. Record the
// swimlane it is displayed in (board-wide lists are not bound to one) so the
// new list is created in the right swimlane; the composer renders after this
// list in the DOM (right in LTR / left in RTL).
'click .js-add-list-here'(event) {
event.preventDefault();
const list = Template.currentData();
const swimlaneDom = $(event.currentTarget).closest('.js-swimlane');
const swimlaneId = swimlaneDom.length
? swimlaneDom.attr('id').replace('swimlane-', '')
: null;
Session.set('wekan-add-list-swimlane', swimlaneId);
Session.set('wekan-add-list-after', list._id);
},
'click .js-add-card.list-header-plus-top'(event) {
const listDom = $(event.target).parents(
`#js-list-${Template.currentData()._id}`,
)[0];
const view = Blaze.getView(listDom, 'Template.list');
const listComponent = view?.templateInstance?.();
if (listComponent) {
listComponent.openForm({
position: 'top',
});
}
},
'click .js-unselect-list'() {
Session.set('currentList', null);
},
async 'submit'(event, tpl) {
event.preventDefault();
const newTitle = tpl.$('textarea,input[type=text]').val()?.trim();
const list = Template.currentData();
if (newTitle) {
await list.rename(newTitle.trim());
}
},
});
Template.listActionPopup.helpers({
isBoardAdmin() {
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
},
isWipLimitEnabled() {
return Template.currentData().getWipLimit('enabled');
},
isWatching() {
return this.findWatcher(Meteor.userId());
}
});
Template.listActionPopup.events({
'click .js-list-subscribe'() {},
'click .js-add-card.list-header-plus-top'(event) {
const listDom = $(`#js-list-${this._id}`)[0];
const view = Blaze.getView(listDom, 'Template.list');
const listComponent = view?.templateInstance?.();
if (listComponent) {
listComponent.openForm({
position: 'top',
});
}
Popup.back();
},
'click .js-add-card.list-header-plus-bottom'(event) {
const listDom = $(`#js-list-${this._id}`)[0];
const view = Blaze.getView(listDom, 'Template.list');
const listComponent = view?.templateInstance?.();
if (listComponent) {
listComponent.openForm({
position: 'bottom',
});
}
Popup.back();
},
'click .js-add-list': Popup.open('addList'),
'click .js-set-list-width': Popup.open('setListWidth'),
'click .js-set-color-list': Popup.open('setListColor'),
'click .js-select-cards'() {
// Scope "select all cards" to the current swimlane when invoked from a
// swimlane context (#5623). In swimlanes board view the list carries its
// swimlaneId; otherwise there is genuinely no swimlane context and we keep
// the historical list-wide selection (swimlaneId stays undefined).
let swimlaneId;
if (Utils.boardView() === 'board-view-swimlanes' && this.swimlaneId) {
swimlaneId = this.swimlaneId;
}
const cardIds = this.allCards(swimlaneId).map(card => card._id);
MultiSelection.add(cardIds);
Popup.back();
},
'click .js-toggle-watch-list'() {
const currentList = this;
const level = currentList.findWatcher(Meteor.userId()) ? null : 'watching';
Meteor.call('watch', 'list', currentList._id, level, (err, ret) => {
if (!err && ret) Popup.back();
});
},
'click .js-close-list': Popup.afterConfirm('listArchive', async function() {
await this.archive();
Popup.close();
}),
'click .js-set-wip-limit': Popup.open('setWipLimit'),
'click .js-copy-list': Popup.open('copyList'),
'click .js-move-list': Popup.open('moveList'),
'click .js-more': Popup.open('listMore'),
});
Template.setWipLimitPopup.helpers({
isWipLimitSoft() {
return Template.currentData().getWipLimit('soft');
},
isWipLimitEnabled() {
return Template.currentData().getWipLimit('enabled');
},
wipLimitValue() {
return Template.currentData().getWipLimit('value');
},
});
Template.setWipLimitPopup.events({
async 'click .js-enable-wip-limit'() {
const list = Template.currentData();
// Prevent user from using previously stored wipLimit.value if it is less than the current number of cards in the list
const lazyCount = lazyListCardCount(list, undefined);
const count = lazyCount !== null ? lazyCount : list.cards().length;
if (!list.getWipLimit('enabled') && list.getWipLimit('value') < count) {
await list.setWipLimit(count);
}
Meteor.call('enableWipLimit', list._id);
},
'click .wip-limit-apply'(event, tpl) {
const list = Template.currentData();
const limit = parseInt(
tpl.$('.wip-limit-value').val(),
10,
);
if (limit < list.cards().length && !list.getWipLimit('soft')) {
tpl.$('.wip-limit-error').click();
} else {
Meteor.call('applyWipLimit', list._id, limit);
Popup.back();
}
},
'click .wip-limit-error': Popup.open('wipLimitError'),
async 'click .materialCheckBox'() {
const list = Template.currentData();
if (
list.getWipLimit('soft') &&
list.getWipLimit('value') < list.cards().length
) {
await list.setWipLimit(list.cards().length);
}
Meteor.call('enableSoftLimit', Template.currentData()._id);
},
});
Template.listMorePopup.events({
'click .js-delete': Popup.afterConfirm('listDelete', function() {
Popup.back();
const list = Lists.findOne(this._id);
if (!list) return;
const allCards = list.allCards();
const allCardIds = allCards.map(c => c._id);
// it's okay if the linked cards are on the same list
if (
ReactiveCache.getCards({
$and: [
{ listId: { $ne: list._id } },
{ linkedId: { $in: allCardIds } },
],
}).length === 0
) {
// Soft delete (docs/Features/Undo/Undo.md): mark the list + its cards deleted
// (server cascades) instead of destroying them, so the delete is restorable
// and undoable (#1023). Replaces the old hard Cards.remove / Lists.remove.
Meteor.call('lists.softRemove', list._id);
} else {
const message = `${TAPi18n.__(
'delete-linked-cards-before-this-list',
)} linkedId: ${
list._id
} at client/components/lists/listHeader.js and https://github.com/wekan/wekan/issues/2785`;
alert(message);
}
Utils.goBoardId(list.boardId);
}),
});
function registerListDialogTemplate(templateName) {
Template[templateName].helpers({
boards() {
return Template.instance().dialog.boards();
},
swimlanes() {
return Template.instance().dialog.swimlanes();
},
lists() {
return Template.instance().dialog.lists();
},
isDialogOptionBoardId(boardId) {
return Template.instance().dialog.isDialogOptionBoardId(boardId);
},
isDialogOptionSwimlaneId(swimlaneId) {
return Template.instance().dialog.isDialogOptionSwimlaneId(swimlaneId);
},
isDialogOptionListId(listId) {
return Template.instance().dialog.isDialogOptionListId(listId);
},
isTitleDefault(title) {
return Template.instance().dialog.isTitleDefault(title);
},
});
Template[templateName].events({
async 'click .js-done'(event, tpl) {
const dialog = tpl.dialog;
const boardSelect = tpl.$('.js-select-boards')[0];
const boardId = boardSelect?.options[boardSelect?.selectedIndex]?.value;
const swimlaneSelect = tpl.$('.js-select-swimlanes')[0];
const swimlaneId = swimlaneSelect?.options[swimlaneSelect?.selectedIndex]?.value;
const listSelect = tpl.$('.js-select-lists')[0];
const listId = listSelect?.options[listSelect?.selectedIndex]?.value || null;
const position = tpl.$('input[name="list-position"]:checked').val() || 'right';
try {
await dialog.setDone({ boardId, swimlaneId, listId, position });
} catch (e) {
console.error('Error in list dialog operation:', e);
}
Popup.back(2);
},
'change .js-select-boards'(event, tpl) {
tpl.dialog.getBoardData($(event.currentTarget).val());
},
'change .js-select-swimlanes'(event, tpl) {
tpl.dialog.selectedSwimlaneId.set($(event.currentTarget).val());
tpl.dialog.setFirstListId();
},
'change .js-select-lists'(event, tpl) {
tpl.dialog.selectedListId.set($(event.currentTarget).val());
},
});
}
Template.copyListPopup.onCreated(function () {
this.dialog = new BoardSwimlaneListDialog(this, {
getDialogOptions() {
return null;
},
async setDone(options) {
const tpl = Template.instance();
const title = tpl.$('#copy-list-title').val().trim();
if (!title) return;
const list = Template.currentData();
await Meteor.callAsync('copyList', list._id, options.boardId, options.swimlaneId, title, options.listId, options.position);
},
});
});
registerListDialogTemplate('copyListPopup');
Template.moveListPopup.onCreated(function () {
this.dialog = new BoardSwimlaneListDialog(this, {
getDialogOptions() {
return null;
},
async setDone(options) {
const tpl = Template.instance();
const title = tpl.$('#move-list-title').val().trim();
const list = Template.currentData();
await Meteor.callAsync('moveList', list._id, options.boardId, options.swimlaneId, options.listId, options.position, title);
},
});
});
registerListDialogTemplate('moveListPopup');
Template.setListColorPopup.onCreated(function () {
const data = Template.currentData();
this.currentList = Lists.findOne(data._id) || data;
this.currentColor = new ReactiveVar(this.currentList.color);
});
Template.setListColorPopup.helpers({
colors() {
return listsColors.map(color => ({ color, name: '' }));
},
isSelected(color) {
const tpl = Template.instance();
if (tpl.currentColor.get() === null) {
return color === 'white';
} else {
return tpl.currentColor.get() === color;
}
},
// #5514: current color as a '#rrggbb' hex for the native color wheel <input>.
currentColorHex() {
return toHex(Template.instance().currentColor.get()) || '#0079bf';
},
});
Template.setListColorPopup.events({
'click .js-palette-color'(event, tpl) {
const paletteData = Blaze.getData(event.currentTarget);
tpl.currentColor.set(paletteData?.color);
},
// #5514: picking from the native color wheel stores a custom hex.
'input .js-list-color-wheel'(event, tpl) {
const value = event.currentTarget.value;
if (isHexColor(value)) {
tpl.currentColor.set(value);
}
},
async 'submit form'(event, tpl) {
event.preventDefault();
try {
await tpl.currentList.setColor(tpl.currentColor.get());
} catch (err) {
console.error('[ListColor] submit form setColor error:', err);
}
Popup.close();
},
async 'click .js-submit'(event, tpl) {
event.preventDefault();
try {
await tpl.currentList.setColor(tpl.currentColor.get());
} catch (err) {
console.error('[ListColor] click submit setColor error:', err);
}
Popup.close();
},
async 'click .js-remove-color'(event, tpl) {
event.preventDefault();
try {
await tpl.currentList.setColor(null);
} catch (err) {
console.error('[ListColor] remove color error:', err);
}
Popup.close();
},
});
// #6409: the per-list width popup is now a single fixed-width value. Whether it
// affects everyone (shared) or just the current user (personal) follows the
// board setting `allowsPersonalListWidth`.
function isPersonalListWidth(boardId) {
const board = ReactiveCache.getBoard(boardId);
return !!(board && board.allowsPersonalListWidth);
}
// #5729 Fixed (same) width for all lists is a per-viewer/per-board setting.
// Logged-in users store it in their profile; anonymous (public board) users in
// localStorage (mirrors the per-list anon storage in list.js).
function readAnonFixedListWidthEnabled(boardId) {
try {
const stored = localStorage.getItem('wekan-fixed-list-width-enabled');
if (stored) {
const flags = JSON.parse(stored);
return flags[boardId] === true;
}
} catch (e) {
console.warn('Error reading fixed list width flag from localStorage:', e);
}
return false;
}
function readAnonFixedListWidth(boardId) {
try {
const stored = localStorage.getItem('wekan-fixed-list-width');
if (stored) {
const widths = JSON.parse(stored);
const w = widths[boardId];
if (typeof w === 'number' && w >= MIN_LIST_WIDTH) return w;
}
} catch (e) {
console.warn('Error reading fixed list width from localStorage:', e);
}
return DEFAULT_LIST_WIDTH;
}
function isFixedListWidth(boardId) {
const user = ReactiveCache.getCurrentUser();
if (user) return !!user.isFixedListWidth(boardId);
return readAnonFixedListWidthEnabled(boardId);
}
function fixedListWidthValue(boardId) {
const user = ReactiveCache.getCurrentUser();
if (user) return user.getFixedListWidth(boardId);
return readAnonFixedListWidth(boardId);
}
function setAnonFixedListWidthEnabled(boardId, enabled) {
try {
const stored = localStorage.getItem('wekan-fixed-list-width-enabled');
const flags = stored ? JSON.parse(stored) : {};
flags[boardId] = !!enabled;
localStorage.setItem('wekan-fixed-list-width-enabled', JSON.stringify(flags));
} catch (e) {
console.warn('Error saving fixed list width flag to localStorage:', e);
}
}
function setAnonFixedListWidth(boardId, width) {
try {
const stored = localStorage.getItem('wekan-fixed-list-width');
const widths = stored ? JSON.parse(stored) : {};
widths[boardId] = width;
localStorage.setItem('wekan-fixed-list-width', JSON.stringify(widths));
} catch (e) {
console.warn('Error saving fixed list width to localStorage:', e);
}
}
Template.setListWidthPopup.helpers({
listWidthValue() {
const list = Template.currentData();
// #5729 In fixed width mode the input edits the single per-board value.
if (isFixedListWidth(list.boardId)) {
return fixedListWidthValue(list.boardId);
}
const shared = normalizeListWidth(list.width);
if (!isPersonalListWidth(list.boardId)) {
return shared;
}
const user = ReactiveCache.getCurrentUser();
if (user) {
const widths = user.getListWidths();
const w = widths[list.boardId] && widths[list.boardId][list._id];
return normalizeListWidth(w, shared);
}
return shared;
},
listWidthScopeNote() {
const list = Template.currentData();
// #5729 In fixed width mode the note explains the all-lists behaviour.
if (isFixedListWidth(list.boardId)) {
return TAPi18n.__('fixed-list-width-note');
}
return isPersonalListWidth(list.boardId)
? TAPi18n.__('list-width-personal-note')
: TAPi18n.__('list-width-shared-note');
},
// #6409: auto-width follows the same scope as fixed widths.
isAutoWidth() {
const list = Template.currentData();
if (isPersonalListWidth(list.boardId)) {
const user = ReactiveCache.getCurrentUser();
return !!(user && user.isAutoWidth(list.boardId));
}
const board = ReactiveCache.getBoard(list.boardId);
return !!(board && board.autoWidth);
},
// In shared mode only members with board write access may toggle the shared
// auto-width; in personal mode any logged-in user may toggle their own.
canChangeAutoWidth() {
const list = Template.currentData();
if (isPersonalListWidth(list.boardId)) {
return !!ReactiveCache.getCurrentUser();
}
return Utils.canModifyBoard();
},
// #5729 Whether "same width for all lists" mode is on for the current viewer.
isFixedListWidth() {
const list = Template.currentData();
return isFixedListWidth(list.boardId);
},
});
Template.setListWidthPopup.events({
'click .js-toggle-auto-width'(event) {
event.preventDefault();
const list = Template.currentData();
const boardId = list.boardId;
if (isPersonalListWidth(boardId)) {
const user = ReactiveCache.getCurrentUser();
if (user) user.toggleAutoWidth(boardId);
} else {
const board = ReactiveCache.getBoard(boardId);
const current = !!(board && board.autoWidth);
Meteor.call('setBoardAutoWidth', boardId, !current);
}
Popup.back();
},
// #5729 Toggle "same width for all lists" (fixed width) for the current
// viewer. Enabling it turns off auto-width (the two modes are exclusive).
'click .js-toggle-fixed-list-width'(event) {
event.preventDefault();
const list = Template.currentData();
const boardId = list.boardId;
const enabled = !isFixedListWidth(boardId);
const user = ReactiveCache.getCurrentUser();
if (user) {
if (enabled && isPersonalListWidth(boardId) && user.isAutoWidth(boardId)) {
user.toggleAutoWidth(boardId);
}
Meteor.call('setFixedListWidthEnabled', boardId, enabled);
} else {
setAnonFixedListWidthEnabled(boardId, enabled);
}
Popup.back();
},
'click .list-width-apply'(event, tpl) {
const list = Template.currentData();
const boardId = list.boardId;
const width = parseInt(tpl.$('.list-width-value').val(), 10);
if (!width || width < MIN_LIST_WIDTH) {
tpl.$('.list-width-error').click();
return;
}
const user = ReactiveCache.getCurrentUser();
// #5729 In fixed width mode the input applies to the single per-board value.
if (isFixedListWidth(boardId)) {
if (user) {
Meteor.call('setFixedListWidth', boardId, width);
} else {
setAnonFixedListWidth(boardId, width);
}
Popup.back();
return;
}
if (isPersonalListWidth(boardId)) {
if (user) {
Meteor.call('applyListWidthToStorage', boardId, list._id, width, width);
}
} else if (user) {
Meteor.call('applyListWidth', boardId, list._id, width, width);
}
Popup.back();
},
'click .list-width-error': Popup.open('listWidthError'),
});
Template.addListPopup.onCreated(function () {
this.currentBoard = Utils.getCurrentBoard();
this.currentSwimlaneId = new ReactiveVar(null);
this.currentListId = new ReactiveVar(null);
// Get the swimlane context from opener
const openerComponent = Popup.getOpenerComponent();
const openerData = openerComponent?.data || Popup._getTopStack()?.dataContext;
// If opened from swimlane menu, openerData is the swimlane
if (openerData?.type === 'swimlane' || openerData?.type === 'template-swimlane') {
this.currentSwimlane = openerData;
this.currentSwimlaneId.set(openerData._id);
} else if (openerData?._id) {
// If opened from list menu, get swimlane from the list
const list = ReactiveCache.getList({ _id: openerData._id });
if (list) {
this.currentSwimlane = list.swimlaneId
? ReactiveCache.getSwimlane({ _id: list.swimlaneId })
: null;
this.currentSwimlaneId.set(this.currentSwimlane?._id || null);
this.currentListId.set(openerData._id);
}
}
if (!this.currentSwimlaneId.get()) {
const defaultSwimlane = this.currentBoard.getDefaultSwimline?.();
if (defaultSwimlane?._id) {
this.currentSwimlane = defaultSwimlane;
this.currentSwimlaneId.set(defaultSwimlane._id);
}
}
});
Template.addListPopup.helpers({
currentSwimlaneData() {
const tpl = Template.instance();
const swimlaneId = tpl.currentSwimlaneId.get();
return swimlaneId ? ReactiveCache.getSwimlane({ _id: swimlaneId }) : null;
},
currentListIdValue() {
return Template.instance().currentListId.get();
},
swimlaneLists() {
const tpl = Template.instance();
const swimlaneId = tpl.currentSwimlaneId.get();
if (swimlaneId) {
return ReactiveCache.getLists({ swimlaneId, archived: false }).sort((a, b) => a.sort - b.sort);
}
return tpl.currentBoard.lists;
},
});
Template.addListPopup.events({
async 'submit .js-add-list-form'(evt, tpl) {
evt.preventDefault();
const titleInput = tpl.find('.list-name-input');
const title = titleInput?.value.trim();
if (!title) return;
const positionInput = tpl.find('.list-position-input');
const afterListId =
positionInput && positionInput.value ? positionInput.value.trim() : null;
const nextListId =
positionInput &&
positionInput.selectedIndex >= 0 &&
positionInput.options[positionInput.selectedIndex + 1]
? positionInput.options[positionInput.selectedIndex + 1].value
: null;
const targetSwimlaneId =
(tpl.currentSwimlaneId && tpl.currentSwimlaneId.get && tpl.currentSwimlaneId.get()) ||
(tpl.currentSwimlane && tpl.currentSwimlane._id) ||
null;
try {
await Meteor.callAsync('createListAfter', {
title,
boardId: Session.get('currentBoard'),
swimlaneId: targetSwimlaneId,
afterListId,
nextListId,
type: 'list',
});
Popup.back();
} catch (error) {
console.error('Failed to create list after selected list:', error);
}
},
'click .js-list-template': Popup.open('searchElement'),
});