listBody.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. const subManager = new SubsManager();
  2. const InfiniteScrollIter = 10;
  3. BlazeComponent.extendComponent({
  4. onCreated() {
  5. // for infinite scrolling
  6. this.cardlimit = new ReactiveVar(InfiniteScrollIter);
  7. },
  8. onRendered() {
  9. const domElement = this.find('.js-perfect-scrollbar');
  10. this.$(domElement).on('scroll', () => this.updateList(domElement));
  11. $(window).on(`resize.${this.data().listId}`, () => this.updateList(domElement));
  12. // we add a Mutation Observer to allow propagations of cardlimit
  13. // when the spinner stays in the current view (infinite scrolling)
  14. this.mutationObserver = new MutationObserver(() => this.updateList(domElement));
  15. this.mutationObserver.observe(domElement, {
  16. childList: true,
  17. });
  18. this.updateList(domElement);
  19. },
  20. onDestroyed() {
  21. $(window).off(`resize.${this.data().listId}`);
  22. this.mutationObserver.disconnect();
  23. },
  24. mixins() {
  25. return [Mixins.PerfectScrollbar];
  26. },
  27. openForm(options) {
  28. options = options || {};
  29. options.position = options.position || 'top';
  30. const forms = this.childComponents('inlinedForm');
  31. let form = forms.find((component) => {
  32. return component.data().position === options.position;
  33. });
  34. if (!form && forms.length > 0) {
  35. form = forms[0];
  36. }
  37. form.open();
  38. },
  39. addCard(evt) {
  40. evt.preventDefault();
  41. const firstCardDom = this.find('.js-minicard:first');
  42. const lastCardDom = this.find('.js-minicard:last');
  43. const textarea = $(evt.currentTarget).find('textarea');
  44. const position = this.currentData().position;
  45. const title = textarea.val().trim();
  46. const formComponent = this.childComponents('addCardForm')[0];
  47. let sortIndex;
  48. if (position === 'top') {
  49. sortIndex = Utils.calculateIndex(null, firstCardDom).base;
  50. } else if (position === 'bottom') {
  51. sortIndex = Utils.calculateIndex(lastCardDom, null).base;
  52. }
  53. const members = formComponent.members.get();
  54. const labelIds = formComponent.labels.get();
  55. const customFields = formComponent.customFields.get();
  56. const boardId = this.data().board();
  57. let swimlaneId = '';
  58. const boardView = Meteor.user().profile.boardView;
  59. if (boardView === 'board-view-swimlanes')
  60. swimlaneId = this.parentComponent().parentComponent().data()._id;
  61. else if ((boardView === 'board-view-lists') || (boardView === 'board-view-cal'))
  62. swimlaneId = boardId.getDefaultSwimline()._id;
  63. if (title) {
  64. const _id = Cards.insert({
  65. title,
  66. members,
  67. labelIds,
  68. customFields,
  69. listId: this.data()._id,
  70. boardId: boardId._id,
  71. sort: sortIndex,
  72. swimlaneId,
  73. type: 'cardType-card',
  74. });
  75. // if the displayed card count is less than the total cards in the list,
  76. // we need to increment the displayed card count to prevent the spinner
  77. // to appear
  78. const cardCount = this.data().cards(this.idOrNull(swimlaneId)).count();
  79. if (this.cardlimit.get() < cardCount) {
  80. this.cardlimit.set(this.cardlimit.get() + InfiniteScrollIter);
  81. }
  82. // In case the filter is active we need to add the newly inserted card in
  83. // the list of exceptions -- cards that are not filtered. Otherwise the
  84. // card will disappear instantly.
  85. // See https://github.com/wekan/wekan/issues/80
  86. Filter.addException(_id);
  87. // We keep the form opened, empty it, and scroll to it.
  88. textarea.val('').focus();
  89. autosize.update(textarea);
  90. if (position === 'bottom') {
  91. this.scrollToBottom();
  92. }
  93. formComponent.reset();
  94. }
  95. },
  96. scrollToBottom() {
  97. const container = this.firstNode();
  98. $(container).animate({
  99. scrollTop: container.scrollHeight,
  100. });
  101. },
  102. clickOnMiniCard(evt) {
  103. if (MultiSelection.isActive() || evt.shiftKey) {
  104. evt.stopImmediatePropagation();
  105. evt.preventDefault();
  106. const methodName = evt.shiftKey ? 'toggleRange' : 'toggle';
  107. MultiSelection[methodName](this.currentData()._id);
  108. // If the card is already selected, we want to de-select it.
  109. // XXX We should probably modify the minicard href attribute instead of
  110. // overwriting the event in case the card is already selected.
  111. } else if (Session.equals('currentCard', this.currentData()._id)) {
  112. evt.stopImmediatePropagation();
  113. evt.preventDefault();
  114. Utils.goBoardId(Session.get('currentBoard'));
  115. }
  116. },
  117. cardIsSelected() {
  118. return Session.equals('currentCard', this.currentData()._id);
  119. },
  120. toggleMultiSelection(evt) {
  121. evt.stopPropagation();
  122. evt.preventDefault();
  123. MultiSelection.toggle(this.currentData()._id);
  124. },
  125. idOrNull(swimlaneId) {
  126. const currentUser = Meteor.user();
  127. if (currentUser.profile.boardView === 'board-view-swimlanes')
  128. return swimlaneId;
  129. return undefined;
  130. },
  131. cardsWithLimit(swimlaneId) {
  132. const limit = this.cardlimit.get();
  133. const selector = {
  134. listId: this.currentData()._id,
  135. archived: false,
  136. };
  137. if (swimlaneId)
  138. selector.swimlaneId = swimlaneId;
  139. return Cards.find(Filter.mongoSelector(selector), {
  140. sort: ['sort'],
  141. limit,
  142. });
  143. },
  144. spinnerInView(container) {
  145. const parentViewHeight = container.clientHeight;
  146. const bottomViewPosition = container.scrollTop + parentViewHeight;
  147. const spinner = this.find('.sk-spinner-list');
  148. const threshold = spinner.offsetTop;
  149. return bottomViewPosition > threshold;
  150. },
  151. showSpinner(swimlaneId) {
  152. const list = Template.currentData();
  153. return list.cards(swimlaneId).count() > this.cardlimit.get();
  154. },
  155. updateList(container) {
  156. // first, if the spinner is not rendered, we have reached the end of
  157. // the list of cards, so skip and disable firing the events
  158. const target = this.find('.sk-spinner-list');
  159. if (!target) {
  160. this.$(container).off('scroll');
  161. $(window).off(`resize.${this.data().listId}`);
  162. return;
  163. }
  164. if (this.spinnerInView(container)) {
  165. this.cardlimit.set(this.cardlimit.get() + InfiniteScrollIter);
  166. Ps.update(container);
  167. }
  168. },
  169. canSeeAddCard() {
  170. return !this.reachedWipLimit() && Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly();
  171. },
  172. reachedWipLimit() {
  173. const list = Template.currentData();
  174. return !list.getWipLimit('soft') && list.getWipLimit('enabled') && list.getWipLimit('value') <= list.cards().count();
  175. },
  176. events() {
  177. return [{
  178. 'click .js-minicard': this.clickOnMiniCard,
  179. 'click .js-toggle-multi-selection': this.toggleMultiSelection,
  180. 'click .open-minicard-composer': this.scrollToBottom,
  181. submit: this.addCard,
  182. }];
  183. },
  184. }).register('listBody');
  185. function toggleValueInReactiveArray(reactiveValue, value) {
  186. const array = reactiveValue.get();
  187. const valueIndex = array.indexOf(value);
  188. if (valueIndex === -1) {
  189. array.push(value);
  190. } else {
  191. array.splice(valueIndex, 1);
  192. }
  193. reactiveValue.set(array);
  194. }
  195. BlazeComponent.extendComponent({
  196. onCreated() {
  197. this.labels = new ReactiveVar([]);
  198. this.members = new ReactiveVar([]);
  199. this.customFields = new ReactiveVar([]);
  200. const currentBoardId = Session.get('currentBoard');
  201. arr = [];
  202. _.forEach(Boards.findOne(currentBoardId).customFields().fetch(), function(field){
  203. if(field.automaticallyOnCard)
  204. arr.push({_id: field._id, value: null});
  205. });
  206. this.customFields.set(arr);
  207. },
  208. reset() {
  209. this.labels.set([]);
  210. this.members.set([]);
  211. this.customFields.set([]);
  212. },
  213. getLabels() {
  214. const currentBoardId = Session.get('currentBoard');
  215. return Boards.findOne(currentBoardId).labels.filter((label) => {
  216. return this.labels.get().indexOf(label._id) > -1;
  217. });
  218. },
  219. pressKey(evt) {
  220. // Pressing Enter should submit the card
  221. if (evt.keyCode === 13 && !evt.shiftKey) {
  222. evt.preventDefault();
  223. const $form = $(evt.currentTarget).closest('form');
  224. // XXX For some reason $form.submit() does not work (it's probably a bug
  225. // of blaze-component related to the fact that the submit event is non-
  226. // bubbling). This is why we click on the submit button instead -- which
  227. // work.
  228. $form.find('button[type=submit]').click();
  229. // Pressing Tab should open the form of the next column, and Maj+Tab go
  230. // in the reverse order
  231. } else if (evt.keyCode === 9) {
  232. evt.preventDefault();
  233. const isReverse = evt.shiftKey;
  234. const list = $(`#js-list-${this.data().listId}`);
  235. const listSelector = '.js-list:not(.js-list-composer)';
  236. let nextList = list[isReverse ? 'prev' : 'next'](listSelector).get(0);
  237. // If there is no next list, loop back to the beginning.
  238. if (!nextList) {
  239. nextList = $(listSelector + (isReverse ? ':last' : ':first')).get(0);
  240. }
  241. BlazeComponent.getComponentForElement(nextList).openForm({
  242. position:this.data().position,
  243. });
  244. }
  245. },
  246. events() {
  247. return [{
  248. keydown: this.pressKey,
  249. 'click .js-link': Popup.open('linkCard'),
  250. 'click .js-search': Popup.open('searchCard'),
  251. }];
  252. },
  253. onRendered() {
  254. const editor = this;
  255. const $textarea = this.$('textarea');
  256. autosize($textarea);
  257. $textarea.escapeableTextComplete([
  258. // User mentions
  259. {
  260. match: /\B@([\w.]*)$/,
  261. search(term, callback) {
  262. const currentBoard = Boards.findOne(Session.get('currentBoard'));
  263. callback($.map(currentBoard.activeMembers(), (member) => {
  264. const user = Users.findOne(member.userId);
  265. return user.username.indexOf(term) === 0 ? user : null;
  266. }));
  267. },
  268. template(user) {
  269. return user.username;
  270. },
  271. replace(user) {
  272. toggleValueInReactiveArray(editor.members, user._id);
  273. return '';
  274. },
  275. index: 1,
  276. },
  277. // Labels
  278. {
  279. match: /\B#(\w*)$/,
  280. search(term, callback) {
  281. const currentBoard = Boards.findOne(Session.get('currentBoard'));
  282. callback($.map(currentBoard.labels, (label) => {
  283. if (label.name.indexOf(term) > -1 ||
  284. label.color.indexOf(term) > -1) {
  285. return label;
  286. }
  287. return null;
  288. }));
  289. },
  290. template(label) {
  291. return Blaze.toHTMLWithData(Template.autocompleteLabelLine, {
  292. hasNoName: !label.name,
  293. colorName: label.color,
  294. labelName: label.name || label.color,
  295. });
  296. },
  297. replace(label) {
  298. toggleValueInReactiveArray(editor.labels, label._id);
  299. return '';
  300. },
  301. index: 1,
  302. },
  303. ], {
  304. // When the autocomplete menu is shown we want both a press of both `Tab`
  305. // or `Enter` to validation the auto-completion. We also need to stop the
  306. // event propagation to prevent the card from submitting (on `Enter`) or
  307. // going on the next column (on `Tab`).
  308. onKeydown(evt, commands) {
  309. if (evt.keyCode === 9 || evt.keyCode === 13) {
  310. evt.stopPropagation();
  311. return commands.KEY_ENTER;
  312. }
  313. return null;
  314. },
  315. });
  316. },
  317. }).register('addCardForm');
  318. BlazeComponent.extendComponent({
  319. onCreated() {
  320. // Prefetch first non-current board id
  321. const boardId = Boards.findOne({
  322. archived: false,
  323. 'members.userId': Meteor.userId(),
  324. _id: {$ne: Session.get('currentBoard')},
  325. }, {
  326. sort: ['title'],
  327. })._id;
  328. // Subscribe to this board
  329. subManager.subscribe('board', boardId);
  330. this.selectedBoardId = new ReactiveVar(boardId);
  331. this.selectedSwimlaneId = new ReactiveVar('');
  332. this.selectedListId = new ReactiveVar('');
  333. this.boardId = Session.get('currentBoard');
  334. // In order to get current board info
  335. subManager.subscribe('board', this.boardId);
  336. this.board = Boards.findOne(this.boardId);
  337. // List where to insert card
  338. const list = $(Popup._getTopStack().openerElement).closest('.js-list');
  339. this.listId = Blaze.getData(list[0])._id;
  340. // Swimlane where to insert card
  341. const swimlane = $(Popup._getTopStack().openerElement).closest('.js-swimlane');
  342. this.swimlaneId = '';
  343. const boardView = Meteor.user().profile.boardView;
  344. if (boardView === 'board-view-swimlanes')
  345. this.swimlaneId = Blaze.getData(swimlane[0])._id;
  346. else if (boardView === 'board-view-lists')
  347. this.swimlaneId = Swimlanes.findOne({boardId: this.boardId})._id;
  348. },
  349. boards() {
  350. const boards = Boards.find({
  351. archived: false,
  352. 'members.userId': Meteor.userId(),
  353. _id: {$ne: Session.get('currentBoard')},
  354. }, {
  355. sort: ['title'],
  356. });
  357. return boards;
  358. },
  359. swimlanes() {
  360. if (!this.selectedBoardId) {
  361. return [];
  362. }
  363. const swimlanes = Swimlanes.find({boardId: this.selectedBoardId.get()});
  364. if (swimlanes.count())
  365. this.selectedSwimlaneId.set(swimlanes.fetch()[0]._id);
  366. return swimlanes;
  367. },
  368. lists() {
  369. if (!this.selectedBoardId) {
  370. return [];
  371. }
  372. const lists = Lists.find({boardId: this.selectedBoardId.get()});
  373. if (lists.count())
  374. this.selectedListId.set(lists.fetch()[0]._id);
  375. return lists;
  376. },
  377. cards() {
  378. if (!this.board) {
  379. return [];
  380. }
  381. const ownCardsIds = this.board.cards().map((card) => { return card.linkedId || card._id; });
  382. return Cards.find({
  383. boardId: this.selectedBoardId.get(),
  384. swimlaneId: this.selectedSwimlaneId.get(),
  385. listId: this.selectedListId.get(),
  386. archived: false,
  387. linkedId: {$nin: ownCardsIds},
  388. _id: {$nin: ownCardsIds},
  389. });
  390. },
  391. events() {
  392. return [{
  393. 'change .js-select-boards'(evt) {
  394. subManager.subscribe('board', $(evt.currentTarget).val());
  395. this.selectedBoardId.set($(evt.currentTarget).val());
  396. },
  397. 'change .js-select-swimlanes'(evt) {
  398. this.selectedSwimlaneId.set($(evt.currentTarget).val());
  399. },
  400. 'change .js-select-lists'(evt) {
  401. this.selectedListId.set($(evt.currentTarget).val());
  402. },
  403. 'click .js-done' (evt) {
  404. // LINK CARD
  405. evt.stopPropagation();
  406. evt.preventDefault();
  407. const linkedId = $('.js-select-cards option:selected').val();
  408. if (!linkedId) {
  409. Popup.close();
  410. return;
  411. }
  412. const _id = Cards.insert({
  413. title: $('.js-select-cards option:selected').text(), //dummy
  414. listId: this.listId,
  415. swimlaneId: this.swimlaneId,
  416. boardId: this.boardId,
  417. sort: Lists.findOne(this.listId).cards().count(),
  418. type: 'cardType-linkedCard',
  419. linkedId,
  420. });
  421. Filter.addException(_id);
  422. Popup.close();
  423. },
  424. 'click .js-link-board' (evt) {
  425. //LINK BOARD
  426. evt.stopPropagation();
  427. evt.preventDefault();
  428. const impBoardId = $('.js-select-boards option:selected').val();
  429. if (!impBoardId || Cards.findOne({linkedId: impBoardId, archived: false})) {
  430. Popup.close();
  431. return;
  432. }
  433. const _id = Cards.insert({
  434. title: $('.js-select-boards option:selected').text(), //dummy
  435. listId: this.listId,
  436. swimlaneId: this.swimlaneId,
  437. boardId: this.boardId,
  438. sort: Lists.findOne(this.listId).cards().count(),
  439. type: 'cardType-linkedBoard',
  440. linkedId: impBoardId,
  441. });
  442. Filter.addException(_id);
  443. Popup.close();
  444. },
  445. }];
  446. },
  447. }).register('linkCardPopup');
  448. BlazeComponent.extendComponent({
  449. mixins() {
  450. return [Mixins.PerfectScrollbar];
  451. },
  452. onCreated() {
  453. // Prefetch first non-current board id
  454. let board = Boards.findOne({
  455. archived: false,
  456. 'members.userId': Meteor.userId(),
  457. _id: {$ne: Session.get('currentBoard')},
  458. });
  459. if (!board) {
  460. Popup.close();
  461. return;
  462. }
  463. const boardId = board._id;
  464. // Subscribe to this board
  465. subManager.subscribe('board', boardId);
  466. this.selectedBoardId = new ReactiveVar(boardId);
  467. this.boardId = Session.get('currentBoard');
  468. // In order to get current board info
  469. subManager.subscribe('board', this.boardId);
  470. board = Boards.findOne(this.boardId);
  471. // List where to insert card
  472. const list = $(Popup._getTopStack().openerElement).closest('.js-list');
  473. this.listId = Blaze.getData(list[0])._id;
  474. // Swimlane where to insert card
  475. const swimlane = $(Popup._getTopStack().openerElement).closest('.js-swimlane');
  476. this.swimlaneId = '';
  477. if (board.view === 'board-view-swimlanes')
  478. this.swimlaneId = Blaze.getData(swimlane[0])._id;
  479. else
  480. this.swimlaneId = Swimlanes.findOne({boardId: this.boardId})._id;
  481. this.term = new ReactiveVar('');
  482. },
  483. boards() {
  484. const boards = Boards.find({
  485. archived: false,
  486. 'members.userId': Meteor.userId(),
  487. _id: {$ne: Session.get('currentBoard')},
  488. }, {
  489. sort: ['title'],
  490. });
  491. return boards;
  492. },
  493. results() {
  494. if (!this.selectedBoardId) {
  495. return [];
  496. }
  497. const board = Boards.findOne(this.selectedBoardId.get());
  498. return board.searchCards(this.term.get(), false);
  499. },
  500. events() {
  501. return [{
  502. 'change .js-select-boards'(evt) {
  503. subManager.subscribe('board', $(evt.currentTarget).val());
  504. this.selectedBoardId.set($(evt.currentTarget).val());
  505. },
  506. 'submit .js-search-term-form'(evt) {
  507. evt.preventDefault();
  508. this.term.set(evt.target.searchTerm.value);
  509. },
  510. 'click .js-minicard'(evt) {
  511. // LINK CARD
  512. const card = Blaze.getData(evt.currentTarget);
  513. const _id = Cards.insert({
  514. title: card.title, //dummy
  515. listId: this.listId,
  516. swimlaneId: this.swimlaneId,
  517. boardId: this.boardId,
  518. sort: Lists.findOne(this.listId).cards().count(),
  519. type: 'cardType-linkedCard',
  520. linkedId: card.linkedId || card._id,
  521. });
  522. Filter.addException(_id);
  523. Popup.close();
  524. },
  525. }];
  526. },
  527. }).register('searchCardPopup');