summaryrefslogtreecommitdiffstats
path: root/client/components/lists/list.js
blob: 839304f847d1c4553e31125dc1eb5c3737f4c52a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
import { Cookies } from 'meteor/ostrio:cookies';
const cookies = new Cookies();
const { calculateIndex } = Utils;

BlazeComponent.extendComponent({
  // Proxy
  openForm(options) {
    this.childComponents('listBody')[0].openForm(options);
  },

  onCreated() {
    this.newCardFormIsVisible = new ReactiveVar(true);
  },

  // The jquery UI sortable library is the best solution I've found so far. I
  // tried sortable and dragula but they were not powerful enough four our use
  // case. I also considered writing/forking a drag-and-drop + sortable library
  // but it's probably too much work.
  // By calling asking the sortable library to cancel its move on the `stop`
  // callback, we basically solve all issues related to reactive updates. A
  // comment below provides further details.
  onRendered() {
    const boardComponent = this.parentComponent().parentComponent();

    function userIsMember() {
      return (
        Meteor.user() &&
        Meteor.user().isBoardMember() &&
        !Meteor.user().isCommentOnly()
      );
    }

    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',
      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 = Boards.findOne(Session.get('currentBoard'));
        let swimlaneId = '';
        if (
          Utils.boardView() === 'board-view-swimlanes' ||
          currentBoard.isTemplatesBoard()
        )
          swimlaneId = Blaze.getData(ui.item.parents('.swimlane').get(0))._id;
        else if (
          Utils.boardView() === 'board-view-lists' ||
          Utils.boardView() === 'board-view-cal' ||
          !Utils.boardView
        )
          swimlaneId = currentBoard.getDefaultSwimline()._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()) {
          Cards.find(MultiSelection.getMongoSelector()).forEach((card, i) => {
            card.move(
              currentBoard._id,
              swimlaneId,
              listId,
              sortIndex.base + i * sortIndex.increment,
            );
          });
        } else {
          const cardDomElement = ui.item.get(0);
          const card = Blaze.getData(cardDomElement);
          card.move(currentBoard._id, swimlaneId, listId, sortIndex.base);
        }
        boardComponent.setIsDragging(false);
      },
    });

    this.autorun(() => {
      let showDesktopDragHandles = false;
      currentUser = Meteor.user();
      if (currentUser) {
        showDesktopDragHandles = (currentUser.profile || {})
          .showDesktopDragHandles;
      } else if (cookies.has('showDesktopDragHandles')) {
        showDesktopDragHandles = true;
      } else {
        showDesktopDragHandles = false;
      }

      if (Utils.isMiniScreen() || showDesktopDragHandles) {
        $cards.sortable({
          handle: '.handle',
        });
      } else if (!Utils.isMiniScreen() && !showDesktopDragHandles) {
        $cards.sortable({
          handle: '.minicard',
        });
      }

      if ($cards.data('uiSortable') || $cards.data('sortable')) {
        $cards.sortable(
          'option',
          'disabled',
          // Disable drag-dropping when user is not member
          !userIsMember(),
          // Not disable drag-dropping while in multi-selection mode
          // MultiSelection.isActive() || !userIsMember(),
        );
      }
    });

    // We want to re-run this function any time a card is added.
    this.autorun(() => {
      const currentBoardId = Tracker.nonreactive(() => {
        return Session.get('currentBoard');
      });
      Cards.find({ boardId: currentBoardId }).fetch();
      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 = Cards.findOne(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);
            }
          },
        });
      });
    });
  },
}).register('list');

Template.list.helpers({
  showDesktopDragHandles() {
    currentUser = Meteor.user();
    if (currentUser) {
      return (currentUser.profile || {}).showDesktopDragHandles;
    } else if (cookies.has('showDesktopDragHandles')) {
      return true;
    } else {
      return false;
    }
  },
});

Template.miniList.events({
  'click .js-select-list'() {
    const listId = this._id;
    Session.set('currentList', listId);
  },
});