Resolve merge conflicts by accepting PR #6131 changes

Co-authored-by: xet7 <15545+xet7@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-02-07 16:30:08 +00:00
parent dc0b68ee80
commit 97dd5d2064
257 changed files with 9483 additions and 14103 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,13 @@
template(name='list')
.list.js-list(id="js-list-{{_id}}"
style="{{#unless collapsed}}min-width:{{listWidth}}px;max-width:{{listConstraint}}px;{{/unless}}"
class="{{#if collapsed}}list-collapsed{{/if}} {{#if autoWidth}}list-auto-width{{/if}} {{#if isMiniScreen}}mobile-view{{/if}}")
+listHeader
unless collapsed
+listBody
.list-resize-handle.js-list-resize-handle.nodragscroll
.list-resize-handle.js-list-resize-handle.nodragscroll
template(name='miniList')
a.mini-list.js-select-list.js-list(id="js-list-{{_id}}" class="{{#if isMiniScreen}}mobile-view{{/if}}")
+listHeader
if isCurrentList
+listBody

View file

@ -4,6 +4,8 @@ require('/client/lib/jquery-ui.js')
const { calculateIndex } = Utils;
export const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)';
BlazeComponent.extendComponent({
// Proxy
openForm(options) {
@ -12,6 +14,7 @@ BlazeComponent.extendComponent({
onCreated() {
this.newCardFormIsVisible = new ReactiveVar(true);
this.collapse = new ReactiveVar(Utils.getListCollapseState(this.data()));
},
// The jquery UI sortable library is the best solution I've found so far. I
@ -22,183 +25,37 @@ BlazeComponent.extendComponent({
// callback, we basically solve all issues related to reactive updates. A
// comment below provides further details.
onRendered() {
const boardComponent = this.parentComponent().parentComponent();
// Initialize list resize functionality immediately
this.list = this.firstNode();
this.resizeHandle = this.find('.js-list-resize-handle');
this.initializeListResize();
const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)';
const $cards = this.$('.js-minicards');
$cards.sortable({
connectWith: '.js-minicards:not(.js-list-full)',
tolerance: 'pointer',
appendTo: '.board-canvas',
helper(evt, item) {
const helper = item.clone();
if (MultiSelection.isActive()) {
const andNOthers = $cards.find('.js-minicard.is-checked').length - 1;
if (andNOthers > 0) {
helper.append(
$(
Blaze.toHTML(
HTML.DIV(
{ class: 'and-n-other' },
TAPi18n.__('and-n-other-card', { count: andNOthers }),
),
),
),
);
}
}
return helper;
},
distance: 7,
items: itemsSelector,
placeholder: 'minicard-wrapper placeholder',
scrollSpeed: 10,
start(evt, ui) {
ui.helper.css('z-index', 1000);
ui.placeholder.height(ui.helper.height());
EscapeActions.executeUpTo('popup-close');
boardComponent.setIsDragging(true);
},
stop(evt, ui) {
// To attribute the new index number, we need to get the DOM element
// of the previous and the following card -- if any.
const prevCardDom = ui.item.prev('.js-minicard').get(0);
const nextCardDom = ui.item.next('.js-minicard').get(0);
const nCards = MultiSelection.isActive() ? MultiSelection.count() : 1;
const sortIndex = calculateIndex(prevCardDom, nextCardDom, nCards);
const listId = Blaze.getData(ui.item.parents('.list').get(0))._id;
const currentBoard = Utils.getCurrentBoard();
const defaultSwimlaneId = currentBoard.getDefaultSwimline()._id;
let targetSwimlaneId = null;
// only set a new swimelane ID if the swimlanes view is active
if (
Utils.boardView() === 'board-view-swimlanes' ||
currentBoard.isTemplatesBoard()
)
targetSwimlaneId = Blaze.getData(ui.item.parents('.swimlane').get(0))
._id;
// Normally the jquery-ui sortable library moves the dragged DOM element
// to its new position, which disrupts Blaze reactive updates mechanism
// (especially when we move the last card of a list, or when multiple
// users move some cards at the same time). To prevent these UX glitches
// we ask sortable to gracefully cancel the move, and to put back the
// DOM in its initial state. The card move is then handled reactively by
// Blaze with the below query.
$cards.sortable('cancel');
if (MultiSelection.isActive()) {
ReactiveCache.getCards(MultiSelection.getMongoSelector(), { sort: ['sort'] }).forEach((card, i) => {
const newSwimlaneId = targetSwimlaneId
? targetSwimlaneId
: card.swimlaneId || defaultSwimlaneId;
card.move(
currentBoard._id,
newSwimlaneId,
listId,
sortIndex.base + i * sortIndex.increment,
);
});
} else {
const cardDomElement = ui.item.get(0);
const card = Blaze.getData(cardDomElement);
const newSwimlaneId = targetSwimlaneId
? targetSwimlaneId
: card.swimlaneId || defaultSwimlaneId;
card.move(currentBoard._id, newSwimlaneId, listId, sortIndex.base);
}
boardComponent.setIsDragging(false);
},
sort(event, ui) {
const $boardCanvas = $('.board-canvas');
const boardCanvas = $boardCanvas[0];
if (event.pageX < 10) { // scroll to the left
boardCanvas.scrollLeft -= 15;
ui.helper[0].offsetLeft -= 15;
}
if (
event.pageX > boardCanvas.offsetWidth - 10 &&
boardCanvas.scrollLeft < $boardCanvas.data('scrollLeftMax') // don't scroll more than possible
) { // scroll to the right
boardCanvas.scrollLeft += 15;
}
if (
event.pageY > boardCanvas.offsetHeight - 10 &&
event.pageY + boardCanvas.scrollTop < $boardCanvas.data('scrollTopMax') // don't scroll more than possible
) { // scroll to the bottom
boardCanvas.scrollTop += 15;
}
if (event.pageY < 10) { // scroll to the top
boardCanvas.scrollTop -= 15;
}
},
activate(event, ui) {
const $boardCanvas = $('.board-canvas');
const boardCanvas = $boardCanvas[0];
// scrollTopMax and scrollLeftMax only available at Firefox (https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTopMax)
// https://www.it-swarm.com.de/de/javascript/so-erhalten-sie-den-maximalen-dokument-scrolltop-wert/1069126844/
$boardCanvas.data('scrollTopMax', boardCanvas.scrollHeight - boardCanvas.clientTop);
// https://stackoverflow.com/questions/5138373/how-do-i-get-the-max-value-of-scrollleft/5704386#5704386
$boardCanvas.data('scrollLeftMax', boardCanvas.scrollWidth - boardCanvas.clientWidth);
},
});
this.autorun(() => {
if ($cards.data('uiSortable') || $cards.data('sortable')) {
if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
$cards.sortable('option', 'handle', '.handle');
} else {
$cards.sortable('option', 'handle', '.minicard');
}
$cards.sortable(
'option',
'disabled',
// Disable drag-dropping when user is not member
!Utils.canModifyBoard(),
// Not disable drag-dropping while in multi-selection mode
// MultiSelection.isActive() || !Utils.canModifyBoard(),
);
const ensureCollapseState = (collapsed) => {
if (this.collapse.get() === collapsed) return;
if (this.autoWidth() || collapsed) {
$(this.resizeHandle).hide();
} else {
$(this.resizeHandle).show();
}
});
this.collapse.set(collapsed);
this.initializeListResize();
}
// We want to re-run this function any time a card is added.
// Reactively update collapse appearance and resize handle visibility when auto-width or collapse changes
this.autorun(() => {
const currentBoardId = Tracker.nonreactive(() => {
return Session.get('currentBoard');
});
Tracker.afterFlush(() => {
$cards.find(itemsSelector).droppable({
hoverClass: 'draggable-hover-card',
accept: '.js-member,.js-label',
drop(event, ui) {
const cardId = Blaze.getData(this)._id;
const card = ReactiveCache.getCard(cardId);
if (ui.draggable.hasClass('js-member')) {
const memberId = Blaze.getData(ui.draggable.get(0)).userId;
card.assignMember(memberId);
} else {
const labelId = Blaze.getData(ui.draggable.get(0))._id;
card.addLabel(labelId);
}
},
});
});
ensureCollapseState(Utils.getListCollapseState(this.data()));
});
},
collapsed() {
return this.collapse.get();
},
listWidth() {
const user = ReactiveCache.getCurrentUser();
const list = Template.currentData();
if (!list) return 270; // Return default width if list is not available
if (user) {
// For logged-in users, get from user profile
return user.getListWidthFromStorage(list.boardId, list._id);
@ -222,8 +79,8 @@ BlazeComponent.extendComponent({
listConstraint() {
const user = ReactiveCache.getCurrentUser();
const list = Template.currentData();
if (!list) return 550; // Return default constraint if list is not available
if (!list) return 0;
if (user) {
// For logged-in users, get from user profile
return user.getListConstraintFromStorage(list.boardId, list._id);
@ -240,7 +97,7 @@ BlazeComponent.extendComponent({
} catch (e) {
console.warn('Error reading list constraint from localStorage:', e);
}
return 550; // Return default constraint if not found
return 0;
}
},
@ -256,18 +113,14 @@ BlazeComponent.extendComponent({
initializeListResize() {
// Check if we're still in a valid template context
if (!Template.currentData()) {
if (!this.data()) {
console.warn('No current template data available for list resize initialization');
return;
}
const list = Template.currentData();
const $list = this.$('.js-list');
const $resizeHandle = this.$('.js-list-resize-handle');
// Check if elements exist
if (!$list.length || !$resizeHandle.length) {
console.warn('List or resize handle not found, retrying in 100ms');
if (!this.list || !this.resizeHandle) {
console.info('List or resize handle not found, retrying in 100ms');
Meteor.setTimeout(() => {
if (!this.isDestroyed) {
this.initializeListResize();
@ -275,107 +128,129 @@ BlazeComponent.extendComponent({
}, 100);
return;
}
// Reactively show/hide resize handle based on collapse and auto-width state
this.autorun(() => {
const isAutoWidth = this.autoWidth();
const isCollapsed = Utils.getListCollapseState(list);
if (isCollapsed || isAutoWidth) {
$resizeHandle.hide();
} else {
$resizeHandle.show();
}
});
let isResizing = false;
let startX = 0;
let startWidth = 0;
let minWidth = 270; // Minimum width matching system default
let listConstraint = this.listConstraint(); // Store constraint value for use in event handlers
const component = this; // Store reference to component for use in event handlers
let previousLimit = false;
// seems reasonable; better let user shrink too much that too little
const minWidth = 280;
// stored width
const width = this.listWidth();
// min-width is initially min-content; a good start
let maxWidth = this.listConstraint() || parseInt(this.list.style.getProperty('--list-min-width', `${(minWidth)}px`), 10) || width + 100;
if (!width || width > maxWidth) {
width = (maxWidth + minWidth) / 2;
}
this.list.style.setProperty('--list-min-width', `${Math.round(minWidth)}px`);
// actual size before fitting (usually max-content equivalent)
this.list.style.setProperty('--list-max-width', `${Math.round(maxWidth)}px`);
// avoid jump effect and ensure width stays consistent
this.list.style.setProperty('--list-width', `${Math.round(width)}px`);
const component = this;
// wait for click to add other events
const startResize = (e) => {
isResizing = true;
startX = e.pageX || e.originalEvent.touches[0].pageX;
startWidth = $list.outerWidth();
// Add visual feedback
$list.addClass('list-resizing');
$('body').addClass('list-resizing-active');
// Prevent text selection during resize
$('body').css('user-select', 'none');
// gain access to modern attributes e.g. isPrimary
e = e.originalEvent;
if (isResizing || Utils.shouldIgnorePointer(e)) {
return;
}
e.preventDefault();
e.stopPropagation();
$(document).on('pointermove', doResize);
// e.g. debugger can cancel event without pointerup being fired
$(document).on('pointercancel', stopResize);
$(document).on('pointerup', stopResize);
// --list-width can be either a stored size or "auto"; get actual computed size
component.currentWidth = component.list.offsetWidth;
component.list.classList.add('list-resizing');
document.body.classList.add('list-resizing-active');
isResizing = true;
};
const doResize = (e) => {
if (!isResizing) {
return;
}
const currentX = e.pageX || e.originalEvent.touches[0].pageX;
const deltaX = currentX - startX;
const newWidth = Math.max(minWidth, startWidth + deltaX);
// Apply the new width immediately for real-time feedback
$list[0].style.setProperty('--list-width', `${newWidth}px`);
$list[0].style.setProperty('width', `${newWidth}px`);
$list[0].style.setProperty('min-width', `${newWidth}px`);
$list[0].style.setProperty('max-width', `${newWidth}px`);
$list[0].style.setProperty('flex', 'none');
$list[0].style.setProperty('flex-basis', 'auto');
$list[0].style.setProperty('flex-grow', '0');
$list[0].style.setProperty('flex-shrink', '0');
e = e.originalEvent;
e.preventDefault();
e.stopPropagation();
if (!isResizing || !e.isPrimary) {
return;
}
if (!previousLimit && component.collapsed()) {
previousLimit = true;
component.list.classList.add('cannot-resize');
return;
}
// relative to document, always >0 because pointer sticks to the right of list
const deltaX = e.clientX - component.list.getBoundingClientRect().right;
const candidateWidth = component.currentWidth + deltaX;
component.currentWidth = Math.max(minWidth, Math.min(maxWidth, candidateWidth));
const reachingMax = (maxWidth - component.currentWidth - 20) <= 0
const reachingMin = (component.currentWidth - 20 - minWidth) <= 0
// visual indicator to avoid trying too hard; try not to apply each tick
if (!previousLimit && (reachingMax && deltaX > 0 || reachingMin && deltaX < 0)) {
component.list.classList.add('cannot-resize');
previousLimit = true;
} else if (previousLimit && !reachingMax && !reachingMin) {
component.list.classList.remove('cannot-resize');
previousLimit = false;
}
// Apply the new width immediately for real-time feedback
component.list.style.setProperty('--list-width', `${component.currentWidth}px`);
};
const stopResize = (e) => {
if (!isResizing) return;
e = e.originalEvent;
e.preventDefault();
e.stopPropagation();
if (!isResizing || !e.isPrimary) {
return;
}
// hopefully be gentler on cpu
$(document).off('pointermove', doResize);
$(document).off('pointercancel', stopResize);
$(document).off('pointerup', stopResize);
isResizing = false;
// Calculate final width
const currentX = e.pageX || e.originalEvent.touches[0].pageX;
const deltaX = currentX - startX;
const finalWidth = Math.max(minWidth, startWidth + deltaX);
// Ensure the final width is applied
$list[0].style.setProperty('--list-width', `${finalWidth}px`);
$list[0].style.setProperty('width', `${finalWidth}px`);
$list[0].style.setProperty('min-width', `${finalWidth}px`);
$list[0].style.setProperty('max-width', `${finalWidth}px`);
$list[0].style.setProperty('flex', 'none');
$list[0].style.setProperty('flex-basis', 'auto');
$list[0].style.setProperty('flex-grow', '0');
$list[0].style.setProperty('flex-shrink', '0');
// Remove visual feedback but keep the width
$list.removeClass('list-resizing');
$('body').removeClass('list-resizing-active');
$('body').css('user-select', '');
// Keep the CSS custom property for persistent width
// The CSS custom property will remain on the element to maintain the width
if (previousLimit) {
component.list.classList.remove('cannot-resize');
}
const finalWidth = parseInt(component.list.style.getPropertyValue('--list-width'), 10);
// Remove visual feedback but keep the height
component.list.classList.remove('list-resizing');
document.body.classList.remove('list-resizing-active');
if (component.collapse.get()) {
return;
}
// Save the new width using the existing system
const list = component.data();
const boardId = list.boardId;
const listId = list._id;
// Use the new storage method that handles both logged-in and non-logged-in users
if (process.env.DEBUG === 'true') {
}
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser) {
// For logged-in users, use server method
Meteor.call('applyListWidthToStorage', boardId, listId, finalWidth, listConstraint, (error, result) => {
Meteor.call('applyListWidthToStorage', boardId, listId, finalWidth, maxWidth, (error, result) => {
if (error) {
console.error('Error saving list width:', error);
} else {
@ -389,61 +264,37 @@ BlazeComponent.extendComponent({
// Save list width
const storedWidths = localStorage.getItem('wekan-list-widths');
let widths = storedWidths ? JSON.parse(storedWidths) : {};
if (!widths[boardId]) {
widths[boardId] = {};
}
widths[boardId][listId] = finalWidth;
localStorage.setItem('wekan-list-widths', JSON.stringify(widths));
// Save list constraint
const storedConstraints = localStorage.getItem('wekan-list-constraints');
let constraints = storedConstraints ? JSON.parse(storedConstraints) : {};
if (!constraints[boardId]) {
constraints[boardId] = {};
}
constraints[boardId][listId] = listConstraint;
localStorage.setItem('wekan-list-constraints', JSON.stringify(constraints));
if (process.env.DEBUG === 'true') {
}
} catch (e) {
console.warn('Error saving list width/constraint to localStorage:', e);
}
}
e.preventDefault();
};
// Mouse events
$resizeHandle.on('mousedown', startResize);
$(document).on('mousemove', doResize);
$(document).on('mouseup', stopResize);
// Touch events for mobile
$resizeHandle.on('touchstart', startResize, { passive: false });
$(document).on('touchmove', doResize, { passive: false });
$(document).on('touchend', stopResize, { passive: false });
// Prevent dragscroll interference
$resizeHandle.on('mousedown', (e) => {
e.stopPropagation();
});
// Reactively update resize handle visibility when auto-width or collapse changes
component.autorun(() => {
const collapsed = Utils.getListCollapseState(list);
if (component.autoWidth() || collapsed) {
$resizeHandle.hide();
} else {
$resizeHandle.show();
}
});
// handle both pointer and touch
$(this.resizeHandle).on("pointerdown", startResize);
// Clean up on component destruction
component.onDestroyed(() => {
@ -455,12 +306,6 @@ BlazeComponent.extendComponent({
},
}).register('list');
Template.list.helpers({
collapsed() {
return Utils.getListCollapseState(this);
},
});
Template.miniList.events({
'click .js-select-list'() {
const listId = this._id;
@ -468,15 +313,10 @@ Template.miniList.events({
},
});
// Enable drag-reorder for collapsed lists from .js-collapsed-list-drag area
this.$('.js-collapsed-list-drag').draggable({
axis: 'x',
helper: 'clone',
revert: 'invalid',
start(evt, ui) {
boardComponent.setIsDragging(true);
},
stop(evt, ui) {
boardComponent.setIsDragging(false);
}
});
Template.miniList.helpers({
isCurrentList() {
const currentList = Utils.getCurrentList();
const list = Template.currentData();
return currentList && currentList._id == list._id;
},
});

View file

@ -4,17 +4,18 @@ template(name="listBody")
.minicards.clearfix.js-minicards(class="{{#if reachedWipLimit}}js-list-full{{/if}}")
+inlinedForm(autoclose=false position="top")
+addCardForm(listId=_id position="top")
ul.sidebar-list
each customFieldsSum
li
+viewer
= name
if $eq customFieldsSum.type "number"
if customFieldSum.lenght
ul.sidebar-list
each customFieldsSum
li
+viewer
= value
if $eq customFieldsSum.type "currency"
+viewer
= formattedCurrencyCustomFieldValue(value)
= name
if $eq customFieldsSum.type "number"
+viewer
= value
if $eq customFieldsSum.type "currency"
+viewer
= formattedCurrencyCustomFieldValue(value)
each (cardsWithLimit (idOrNull ../../_id))
a.minicard-wrapper.js-minicard(href=originRelativeUrl
class="{{#if cardIsSelected}}is-selected{{/if}}"
@ -25,15 +26,15 @@ template(name="listBody")
+minicard(this)
if (showSpinner (idOrNull ../../_id))
+spinnerList
if canSeeAddCard
+inlinedForm(autoclose=false position="bottom")
+addCardForm(listId=_id position="bottom")
else
a.open-minicard-composer.js-card-composer.js-open-inlined-form(title="{{_ 'add-card-to-bottom-of-list'}}")
i.fa.fa-plus
| {{_ 'add-card'}}
+inlinedForm(autoclose=false position="bottom")
+addCardForm(listId=_id position="bottom")
a.minicard-wrapper.minicard-add-form
+inlinedForm(autoclose=false position="bottom")
+addCardForm(listId=_id position="bottom")
else
.add-card-wrapper
a.open-minicard-composer.js-card-composer.js-open-inlined-form(title="{{_ 'add-card-to-bottom-of-list'}}")
i.fa.fa-plus
template(name="spinnerList")
.sk-spinner.sk-spinner-list(
@ -43,33 +44,30 @@ template(name="spinnerList")
template(name="addCardForm")
.minicard.minicard-composer.js-composer
if getLabels
.minicard-labels
each getLabels
.minicard-label(class="card-label-{{color}}" title="{{name}}")
textarea.minicard-composer-textarea.js-card-title(autofocus dir="auto")
if members.get
.minicard-members.js-minicard-composer-members
each members.get
+userAvatar(userId=this)
.minicard-bottom
.minicard-composer-icons
if getLabels
each getLabels
.minicard-label(class="card-label-{{color}}" title="{{name}}")
if members.get
each members.get
+userAvatar(userId=this)
.add-controls.clearfix
a.js-close-inlined-form
i.fa.fa-times-thin
.add-controls.clearfix
button.primary.confirm(type="submit") {{_ 'add'}}
a.js-close-inlined-form
i.fa.fa-times-thin
.add-controls.clearfix
button.primary.confirm(type="submit") {{_ 'add'}}
.links-controls.clearfix
unless currentBoard.isTemplatesBoard
unless currentBoard.isTemplateBoard
span.quiet
| {{_ 'or'}}
a.js-link {{_ 'link'}}
span.quiet
| &nbsp;
| /
a.js-search {{_ 'search'}}
span.quiet
| &nbsp;
| /
a.js-card-template {{_ 'template'}}
template(name="autocompleteLabelLine")
@ -77,70 +75,73 @@ template(name="autocompleteLabelLine")
span(class="{{#if hasNoName}}quiet{{/if}}")= labelName
template(name="linkCardPopup")
label {{_ 'boards'}}:
.link-board-wrapper
select.js-select-boards
option(value="")
each boards
option(value="{{_id}}") {{isTitleDefault title}}
input.primary.confirm.js-link-board(type="button" value="{{_ 'link'}}")
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
option(value="") {{_ 'custom-field-dropdown-none'}}
each swimlanes
option(value="{{_id}}") {{isTitleDefault title}}
label {{_ 'lists'}}:
select.js-select-lists
option(value="") {{_ 'custom-field-dropdown-none'}}
each lists
option(value="{{_id}}") {{isTitleDefault title}}
label {{_ 'cards'}}:
select.js-select-cards
option(value="") {{_ 'custom-field-dropdown-none'}}
each cards
option(value="{{getRealId}}") {{getTitle}}
.edit-controls.clearfix
input.primary.confirm.js-done(type="button" value="{{_ 'link'}}")
template(name="searchElementPopup")
form
label
| {{_ 'title'}}
input.js-element-title(type="text" placeholder="{{_ 'title'}}" autofocus required dir="auto")
unless isTemplateSearch
label {{_ 'boards'}}:
.link-board-wrapper
.link-board-dropdown
label {{_ 'boards'}}:
select.js-select-boards
option(value="")
each boards
option(value="{{_id}}") {{title}}
form.js-search-term-form
label
| {{_ 'template'}}
input(type="text" name="searchTerm" placeholder="{{_ 'search-example'}}" autofocus dir="auto")
.list-body.search-card-results
.minicards.clearfix.js-minicards
if isBoardTemplateSearch
each results
a.minicard-wrapper.js-minicard
+miniboard(this)
if isListTemplateSearch
each results
a.minicard-wrapper.js-minicard
+minilist(this)
if isSwimlaneTemplateSearch
each results
a.minicard-wrapper.js-minicard
+miniswimlane(this)
if isCardTemplateSearch
each results
a.minicard-wrapper.js-minicard
+minicard(this)
unless isTemplateSearch
each results
a.minicard-wrapper.js-minicard
+minicard(this)
option(value="{{_id}}") {{isTitleDefault title}}
input.primary.confirm.js-link-board(type="button" value="{{_ 'link'}}")
.link-board-dropdown
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
option(value="") {{_ 'custom-field-dropdown-none'}}
each swimlanes
option(value="{{_id}}") {{isTitleDefault title}}
.link-board-dropdown
label {{_ 'lists'}}:
select.js-select-lists
option(value="") {{_ 'custom-field-dropdown-none'}}
each lists
option(value="{{_id}}") {{isTitleDefault title}}
.link-board-dropdown
label {{_ 'cards'}}:
select.js-select-cards
option(value="") {{_ 'custom-field-dropdown-none'}}
each cards
option(value="{{getRealId}}") {{getTitle}}
.edit-controls.clearfix
input.primary.confirm.js-done(type="button" value="{{_ 'link'}}")
template(name="searchElementPopup")
.link-board-wrapper
form
label
| {{_ 'title'}}
input.js-element-title(type="text" placeholder="{{_ 'title'}}" autofocus required dir="auto")
unless isTemplateSearch
label {{_ 'boards'}}:
select.js-select-boards
option(value="")
each (boards)
option(value="{{_id}}") {{title}}
form.js-search-term-form
label
| {{_ 'template'}}
input(type="text" name="searchTerm" placeholder="{{_ 'search-example'}}" autofocus dir="auto")
.list-body.search-card-results
.minicards.clearfix.js-minicards
if isBoardTemplateSearch
each (results)
a.minicard-wrapper.js-minicard
+miniboard(this)
if isListTemplateSearch
each (results)
a.minicard-wrapper.js-minicard
+minilist(this)
if isSwimlaneTemplateSearch
each (results)
a.minicard-wrapper.js-minicard
+miniswimlane(this)
if isCardTemplateSearch
each (results)
a.minicard-wrapper.js-minicard
+minicard(this)
unless isTemplateSearch
each (results)
a.minicard-wrapper.js-minicard
+minicard(this)

View file

@ -3,16 +3,168 @@ import { TAPi18n } from '/imports/i18n';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { Spinner } from '/client/lib/spinner';
import getSlug from 'limax';
import { itemsSelector } from './list';
const subManager = new SubsManager();
const InfiniteScrollIter = 10;
function sortableCards(boardComponent, $cards) {
return {
connectWith: '.js-minicards:not(.js-list-full)',
tolerance: 'pointer',
appendTo: '.board-canvas',
helper(evt, item) {
const helper = item.clone();
const cardHeight = item.height();
const cardWidth = item.width();
helper[0].setAttribute('style', `height: ${cardHeight}px !important; width: ${cardWidth}px !important;`);
if (MultiSelection.isActive()) {
const andNOthers = $cards.find('.js-minicard.is-checked').length - 1;
if (andNOthers > 0) {
helper.append(
$(
Blaze.toHTML(
HTML.DIV(
{ class: 'and-n-other' },
TAPi18n.__('and-n-other-card', { count: andNOthers }),
),
),
),
);
}
}
return helper;
},
distance: 7,
items: itemsSelector,
placeholder: 'minicard-wrapper placeholder',
/* cursor must be tied to smaller objects, position approximately from the button
(can be computed if visually confusing) */
cursorAt: { right: 20, top: 30 },
start(evt, ui) {
const cardHeight = ui.helper.height();
ui.placeholder[0].setAttribute('style', `height: ${cardHeight}px !important;`);
EscapeActions.executeUpTo('popup-close');
boardComponent.setIsDragging(true);
},
stop(evt, ui) {
// To attribute the new index number, we need to get the DOM element
// of the previous and the following card -- if any.
const prevCardDom = ui.item.prev('.js-minicard').get(0);
const nextCardDom = ui.item.next('.js-minicard').get(0);
const nCards = MultiSelection.isActive() ? MultiSelection.count() : 1;
const sortIndex = Utils.calculateIndex(prevCardDom, nextCardDom, nCards);
const listId = Blaze.getData(ui.item.parents('.list-body').get(0))._id;
const currentBoard = Utils.getCurrentBoard();
const defaultSwimlaneId = currentBoard.getDefaultSwimline()._id;
let targetSwimlaneId = null;
// only set a new swimelane ID if the swimlanes view is active
if (
Utils.boardView() === 'board-view-swimlanes' ||
currentBoard.isTemplatesBoard()
)
targetSwimlaneId = Blaze.getData(ui.item.parents('.swimlane').get(0))
._id;
// Normally the jquery-ui sortable library moves the dragged DOM element
// to its new position, which disrupts Blaze reactive updates mechanism
// (especially when we move the last card of a list, or when multiple
// users move some cards at the same time). To prevent these UX glitches
// we ask sortable to gracefully cancel the move, and to put back the
// DOM in its initial state. The card move is then handled reactively by
// Blaze with the below query.
$cards.sortable('cancel');
if (MultiSelection.isActive()) {
ReactiveCache.getCards(MultiSelection.getMongoSelector(), { sort: ['sort'] }).forEach((card, i) => {
const newSwimlaneId = targetSwimlaneId
? targetSwimlaneId
: card.swimlaneId || defaultSwimlaneId;
card.move(
currentBoard._id,
newSwimlaneId,
listId,
sortIndex.base + i * sortIndex.increment,
);
});
} else {
const cardDomElement = ui.item.get(0);
const card = Blaze.getData(cardDomElement);
const newSwimlaneId = targetSwimlaneId
? targetSwimlaneId
: card.swimlaneId || defaultSwimlaneId;
card.move(currentBoard._id, newSwimlaneId, listId, sortIndex.base);
}
boardComponent.setIsDragging(false);
},
sort(event, ui) {
Utils.scrollIfNeeded(event);
},
};
};
BlazeComponent.extendComponent({
onCreated() {
// for infinite scrolling
this.cardlimit = new ReactiveVar(InfiniteScrollIter);
},
onRendered() {
// Prefer handling drag/sort in listBody rather than list as
// it is shared between mobile and desktop view
const boardComponent = BlazeComponent.getComponentForElement(document.getElementsByClassName('board-canvas')[0]);
const $cards = this.$('.js-minicards');
$cards.sortable(sortableCards(boardComponent, $cards));
this.autorun(() => {
if ($cards.data('uiSortable') || $cards.data('sortable')) {
// Use handle button on mobile, classic move otherwise
if (Utils.isMiniScreen()) {
$cards.sortable('option', 'handle', '.handle');
} else {
$cards.sortable('option', 'handle', '.minicard');
}
$cards.sortable(
'option',
'disabled',
// Disable drag-dropping when user is not member
!Utils.canModifyBoard(),
// Not disable drag-dropping while in multi-selection mode
// MultiSelection.isActive() || !Utils.canModifyBoard(),
);
}
});
// We want to re-run this function any time a card is added.
this.autorun(() => {
const currentBoardId = Tracker.nonreactive(() => {
return Session.get('currentBoard');
});
Tracker.afterFlush(() => {
$cards.find(itemsSelector).droppable({
hoverClass: 'draggable-hover-card',
accept: '.js-member,.js-label',
drop(event, ui) {
const cardId = Blaze.getData(this)._id;
const card = ReactiveCache.getCard(cardId);
if (ui.draggable.hasClass('js-member')) {
const memberId = Blaze.getData(ui.draggable.get(0)).userId;
card.assignMember(memberId);
} else {
const labelId = Blaze.getData(ui.draggable.get(0))._id;
card.addLabel(labelId);
}
},
});
});
});
},
mixins() {
return [];
},
@ -82,9 +234,10 @@ BlazeComponent.extendComponent({
evt.preventDefault();
const firstCardDom = this.find('.js-minicard:first');
const lastCardDom = this.find('.js-minicard:last');
const textarea = $(evt.currentTarget).find('textarea');
// more robust to start from the form
const textarea = $(evt.currentTarget).closest('.inlined-form').find('textarea');
const position = this.currentData().position;
const title = textarea.val().trim();
const title = $(textarea).val().trim();
let sortIndex;
if (position === 'top') {
@ -168,7 +321,6 @@ BlazeComponent.extendComponent({
// We keep the form opened, empty it, and scroll to it.
textarea.val('').focus();
autosize.update(textarea);
if (position === 'bottom') {
this.scrollToBottom();
}
@ -194,21 +346,19 @@ BlazeComponent.extendComponent({
clickOnMiniCard(evt) {
if (MultiSelection.isActive() || evt.shiftKey) {
evt.stopImmediatePropagation();
evt.preventDefault();
const methodName = evt.shiftKey ? 'toggleRange' : 'toggle';
MultiSelection[methodName](this.currentData()._id);
// If the card is already selected, we want to de-select it.
// XXX We should probably modify the minicard href attribute instead of
// overwriting the event in case the card is already selected.
} else if (Utils.isMiniScreen()) {
evt.preventDefault();
Session.set('popupCardId', this.currentData()._id);
this.cardDetailsPopup(evt);
} else if (Session.equals('currentCard', this.currentData()._id)) {
evt.stopImmediatePropagation();
evt.preventDefault();
// We need to wait a little because router gets called first,
// we probably need a level of indirection
// #FIXME remove if it works with commits we rebased on,
// which change the route declaration order
Meteor.setTimeout(() => {
Session.set('currentCard', null)
}, 50);
Utils.goBoardId(Session.get('currentBoard'));
} else {
// Allow normal href navigation, but if it's the same card URL,
@ -283,12 +433,6 @@ BlazeComponent.extendComponent({
return user && user.isVerticalScrollbars();
},
cardDetailsPopup(event) {
if (!Popup.isOpen()) {
Popup.open("cardDetails")(event);
}
},
events() {
return [
{
@ -296,6 +440,8 @@ BlazeComponent.extendComponent({
'click .js-toggle-multi-selection': this.toggleMultiSelection,
'click .open-minicard-composer': this.scrollToBottom,
submit: this.addCard,
// #FIXME remove in final MR if it works
'click .confirm': this.addCard
},
];
},
@ -401,6 +547,17 @@ BlazeComponent.extendComponent({
'click .js-link': Popup.open('linkCard'),
'click .js-search': Popup.open('searchElement'),
'click .js-card-template': Popup.open('searchElement'),
submit: this.addCard,
'click .minicard-label': (event) => {
const clickedData = BlazeComponent.getComponentForElement(event.target).currentData?.()
this.labels.set(this.labels.get().filter(e => e !== clickedData?._id));
},
'click .member': (event) => {
const clickedData = BlazeComponent.getComponentForElement(event.target).currentData?.()
this.members.set(this.members.get().filter(e => e !== clickedData?.userId));
e.preventDefault();
e.stopPropagation();
},
},
];
},
@ -409,8 +566,6 @@ BlazeComponent.extendComponent({
const editor = this;
const $textarea = this.$('textarea');
autosize($textarea);
$textarea.escapeableTextComplete(
[
// User mentions
@ -421,7 +576,9 @@ BlazeComponent.extendComponent({
callback(
$.map(currentBoard.activeMembers(), member => {
const user = ReactiveCache.getUser(member.userId);
return user.username.indexOf(term) === 0 ? user : null;
return user.username.indexOf(term) === 0 &&
// don't show already selected members
!editor.members.get().find((e) => e === member.userId) ? user : null;
}),
);
},
@ -445,8 +602,12 @@ BlazeComponent.extendComponent({
const currentBoard = Utils.getCurrentBoard();
callback(
$.map(currentBoard.labels, label => {
if (label.name == undefined) {
label.name = "";
if (
label.name == undefined ||
// don't show already selected labels
editor.getLabels().find((e) => e._id === label._id)
) {
return null;
}
if (
label.name.indexOf(term) > -1 ||
@ -503,10 +664,10 @@ BlazeComponent.extendComponent({
subManager.subscribe('board', this.boardId, false);
this.board = ReactiveCache.getBoard(this.boardId);
// List where to insert card
this.list = $(Popup._getTopStack().openerElement).closest('.js-list');
this.list = $(PopupComponent.stack[0].openerElement).closest('.js-list');
this.listId = Blaze.getData(this.list[0])._id;
// Swimlane where to insert card
const swimlane = $(Popup._getTopStack().openerElement).closest(
const swimlane = $(PopupComponent.stack[0].openerElement).closest(
'.js-swimlane',
);
this.swimlaneId = '';
@ -539,10 +700,10 @@ BlazeComponent.extendComponent({
if (!board) {
return [];
}
// Ensure default swimlane exists
board.getDefaultSwimline();
const swimlanes = ReactiveCache.getSwimlanes(
{
boardId: this.selectedBoardId.get()
@ -559,7 +720,8 @@ BlazeComponent.extendComponent({
}
const lists = ReactiveCache.getLists(
{
boardId: this.selectedBoardId.get()
boardId: this.selectedBoardId.get(),
swimlaneId: this.selectedSwimlaneId?.get?.()
},
{
sort: { sort: 1 },
@ -703,16 +865,16 @@ BlazeComponent.extendComponent({
},
onCreated() {
this.isCardTemplateSearch = $(Popup._getTopStack().openerElement).hasClass(
this.isCardTemplateSearch = $(PopupComponent.stack[0].openerElement).hasClass(
'js-card-template',
);
this.isListTemplateSearch = $(Popup._getTopStack().openerElement).hasClass(
this.isListTemplateSearch = $(PopupComponent.stack[0].openerElement).hasClass(
'js-list-template',
);
this.isSwimlaneTemplateSearch = $(
Popup._getTopStack().openerElement,
PopupComponent.stack[0].openerElement,
).hasClass('js-open-add-swimlane-menu');
this.isBoardTemplateSearch = $(Popup._getTopStack().openerElement).hasClass(
this.isBoardTemplateSearch = $(PopupComponent.stack[0].openerElement).hasClass(
'js-add-board',
);
this.isTemplateSearch =
@ -731,20 +893,16 @@ BlazeComponent.extendComponent({
} else {
this.board = Utils.getCurrentBoard();
}
if (!this.board) {
Popup.back();
return;
}
this.boardId = this.board._id;
this.boardId = this.board?._id;
// Subscribe to this board
subManager.subscribe('board', this.boardId, false);
this.selectedBoardId = new ReactiveVar(this.boardId);
this.list = $(Popup._getTopStack().openerElement).closest('.js-list');
if (!this.isBoardTemplateSearch) {
this.list = $(PopupComponent.stack[0].openerElement).closest('.js-list');
this.swimlaneId = '';
// Swimlane where to insert card
const swimlane = $(Popup._getTopStack().openerElement).parents(
const swimlane = $(PopupComponent.stack[0].openerElement).parents(
'.js-swimlane',
);
if (Utils.boardView() === 'board-view-swimlanes')
@ -783,11 +941,7 @@ BlazeComponent.extendComponent({
} else if (this.isSwimlaneTemplateSearch) {
return board.searchSwimlanes(this.term.get());
} else if (this.isBoardTemplateSearch) {
const boards = board.searchBoards(this.term.get());
boards.forEach(board => {
subManager.subscribe('board', board.linkedId, false);
});
return boards;
return board.searchBoards(this.term.get());
} else {
return [];
}

View file

@ -9,66 +9,68 @@ template(name="listHeader")
if currentList
a.list-header-left-icon.js-unselect-list
i.fa.fa-caret-left
else
if collapsed
if showCardsCountForList cards.length
br
span.cardCount {{cardsCount}}
if isMiniScreen
h2.list-header-name(
title="{{ moment modifiedAt 'LLL' }}"
class="{{#if currentUser.isBoardMember}}{{#unless currentUser.isCommentOnly}}{{#unless currentUser.isWorker}}js-open-inlined-form is-editable{{/unless}}{{/unless}}{{/if}}")
+viewer
= title
if wipLimit.enabled
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
|/#{wipLimit.value})
if showCardsCountForList cards.length
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cards.length}}
if hasNumberFieldsSum
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
else
a.list-collapse-indicator.js-collapse(title="{{_ 'collapse'}}")
if collapsed
else
//- start by this on mobile to have cohesion with other views
a.list-header-menu-icon.js-select-list
i.fa.fa-caret-right
else
i.fa.fa-caret-down
div(class="{{#if collapsed}}list-rotated{{/if}}")
.list-header-name-container
h2.list-header-name(
title="{{ moment modifiedAt 'LLL' }}"
class="{{#unless collapsed}}{{#if currentUser.isBoardMember}}{{#unless currentUser.isCommentOnly}}{{#unless currentUser.isWorker}}js-open-inlined-form is-editable{{/unless}}{{/unless}}{{/if}}{{/unless}}")
class="{{#if currentUser.isBoardMember}}{{#unless currentUser.isCommentOnly}}{{#unless currentUser.isWorker}}js-open-inlined-form is-editable{{/unless}}{{/unless}}{{/if}}")
+viewer
= title
if wipLimit.enabled
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
|/#{wipLimit.value})
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
|/#{wipLimit.value})
if showCardsCountForList cards.length
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cards.length}}
if hasNumberFieldsSum
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
else
div.list-header-name-container
unless isMiniScreen
a.list-collapse-indicator.js-collapse(title="{{_ 'collapse'}}")
if collapsed
i.fa.fa-caret-right
else
i.fa.fa-caret-down
div(class="{{#if collapsed}}list-rotated{{/if}}").list-header-wrap
h2.list-header-name(
title="{{ moment modifiedAt 'LLL' }}"
class="{{#if currentUser.isBoardMember}}{{#unless currentUser.isCommentOnly}}{{#unless currentUser.isWorker}}js-open-inlined-form is-editable{{/unless}}{{/unless}}{{/if}}")
+viewer
= title
if wipLimit.enabled
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
|/#{wipLimit.value})
unless collapsed
if showCardsCountForList cards.length
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cards.length}}
if hasNumberFieldsSum
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
div.list-header-menu
unless currentUser.isCommentOnly
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}")
i.fa.fa-bars
if isMiniScreen
if currentList
if isWatching
i.list-header-watch-icon i.fa.fa-eye
i.list-header-watch-icon.i.fa.fa-eye
div.list-header-menu
unless currentUser.isCommentOnly
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
if canSeeAddCard
a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}")
i.fa.fa-plus
a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}")
i.fa.fa-bars
else
a.list-header-menu-icon.js-select-list
i.fa.fa-caret-right
unless currentUser.isWorker
if isTouchScreenOrShowDesktopDragHandles
if isMiniScreen
a.list-header-handle.handle.js-list-handle
i.fa.fa-arrows
else if currentUser.isBoardMember
@ -77,24 +79,13 @@ template(name="listHeader")
unless currentUser.isCommentOnly
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
if isTouchScreenOrShowDesktopDragHandles
if isMiniScreen
a.list-header-handle-desktop.handle.js-list-handle(title="{{_ 'drag-list'}}")
i.fa.fa-arrows
unless collapsed
div.list-header-menu
unless currentUser.isCommentOnly
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
//if isBoardAdmin
// a.fa.js-list-star.list-header-plus-top(class="fa-star{{#unless starred}}-o{{/unless}}")
if isTouchScreenOrShowDesktopDragHandles
a.list-header-handle-desktop.handle.js-list-handle(title="{{_ 'drag-list'}}")
i.fa.fa-arrows
if canSeeAddCard
a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}")
i.fa.fa-plus
a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}")
i.fa.fa-bars
unless isMiniScreen
if collapsed
if showCardsCountForList cards.length
span.cardCount {{cardsCount}}
template(name="editListTitleForm")
.list-composer
@ -224,14 +215,14 @@ template(name="wipLimitErrorPopup")
.wip-limit-invalid
p {{_ 'wipLimitErrorPopup-dialog-pt1'}}
p {{_ 'wipLimitErrorPopup-dialog-pt2'}}
button.full.js-back-view(type="submit") {{_ 'cancel'}}
button.negate.js-back-view(type="submit") {{_ 'cancel'}}
template(name="setListWidthPopup")
#js-list-width-edit
label {{_ 'set-list-width-value'}}
p
input.list-width-value(type="number" value="{{ listWidthValue }}" min="270")
input.list-constraint-value(type="number" value="{{ listConstraintValue }}" min="270")
input.list-width-value(type="number" value="{{ listWidthValue }}" min="100")
input.list-constraint-value(type="number" value="{{ listConstraintValue }}" min="100")
input.list-width-apply(type="submit" value="{{_ 'apply'}}")
input.list-width-error
br
@ -242,8 +233,8 @@ template(name="setListWidthPopup")
template(name="listWidthErrorPopup")
.list-width-invalid
p {{_ 'list-width-error-message'}} '&gt;=270'
button.full.js-back-view(type="submit") {{_ 'cancel'}}
p {{_ 'list-width-error-message'}} '&gt;=100'
button.negate.js-back-view(type="submit") {{_ 'cancel'}}
template(name="setListColorPopup")
form.edit-label

View file

@ -9,6 +9,15 @@ Meteor.startup(() => {
});
BlazeComponent.extendComponent({
onRendered() {
/* #FIXME I have no idea why this exact same
event won't fire when in event maps */
$(this.find('.js-collapse')).on('click', (e) => {
e.preventDefault();
this.collapsed(!this.collapsed());
});
},
canSeeAddCard() {
const list = Template.currentData();
return (
@ -34,7 +43,7 @@ BlazeComponent.extendComponent({
}
},
collapsed(check = undefined) {
const list = Template.currentData();
const list = this.data();
const status = Utils.getListCollapseState(list);
if (check === undefined) {
// just check
@ -110,7 +119,11 @@ BlazeComponent.extendComponent({
return TAPi18n.__('cards-count');
}
},
currentList() {
const currentList = Utils.getCurrentList();
const list = Template.currentData();
return currentList && currentList._id == list._id;
},
events() {
return [
{
@ -118,10 +131,6 @@ BlazeComponent.extendComponent({
event.preventDefault();
this.starred(!this.starred());
},
'click .js-collapse'(event) {
event.preventDefault();
this.collapsed(!this.collapsed());
},
'click .js-open-list-menu': Popup.open('listAction'),
'click .js-add-card.list-header-plus-top'(event) {
const listDom = $(event.target).parents(
@ -459,10 +468,10 @@ BlazeComponent.extendComponent({
this.currentBoard = Utils.getCurrentBoard();
this.currentSwimlaneId = new ReactiveVar(null);
this.currentListId = new ReactiveVar(null);
// Get the swimlane context from opener
const openerData = Popup.getOpenerComponent()?.data();
// If opened from swimlane menu, openerData is the swimlane
if (openerData?.type === 'swimlane' || openerData?.type === 'template-swimlane') {
this.currentSwimlane = openerData;
@ -554,4 +563,3 @@ BlazeComponent.extendComponent({
];
},
}).register('addListPopup');