cardDetails.js 34 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178
  1. const subManager = new SubsManager();
  2. const { calculateIndexData } = Utils;
  3. let cardColors;
  4. Meteor.startup(() => {
  5. cardColors = Cards.simpleSchema()._schema.color.allowedValues;
  6. });
  7. BlazeComponent.extendComponent({
  8. mixins() {
  9. return [Mixins.InfiniteScrolling, Mixins.PerfectScrollbar];
  10. },
  11. calculateNextPeak() {
  12. const cardElement = this.find('.js-card-details');
  13. if (cardElement) {
  14. const altitude = cardElement.scrollHeight;
  15. this.callFirstWith(this, 'setNextPeak', altitude);
  16. }
  17. },
  18. reachNextPeak() {
  19. const activitiesComponent = this.childComponents('activities')[0];
  20. activitiesComponent.loadNextPage();
  21. },
  22. onCreated() {
  23. this.currentBoard = Boards.findOne(Session.get('currentBoard'));
  24. this.isLoaded = new ReactiveVar(false);
  25. const boardBody = this.parentComponent().parentComponent();
  26. //in Miniview parent is Board, not BoardBody.
  27. if (boardBody !== null) {
  28. boardBody.showOverlay.set(true);
  29. boardBody.mouseHasEnterCardDetails = false;
  30. }
  31. this.calculateNextPeak();
  32. Meteor.subscribe('unsaved-edits');
  33. },
  34. isWatching() {
  35. const card = this.currentData();
  36. return card.findWatcher(Meteor.userId());
  37. },
  38. hiddenSystemMessages() {
  39. return Meteor.user().hasHiddenSystemMessages();
  40. },
  41. canModifyCard() {
  42. return (
  43. Meteor.user() &&
  44. Meteor.user().isBoardMember() &&
  45. !Meteor.user().isCommentOnly() &&
  46. !Meteor.user().isWorker()
  47. );
  48. },
  49. scrollParentContainer() {
  50. const cardPanelWidth = 510;
  51. const parentComponent = this.parentComponent();
  52. // TODO sometimes parentComponent is not available, maybe because it's not
  53. // yet created?!
  54. if (!parentComponent) return;
  55. const bodyBoardComponent = parentComponent.parentComponent();
  56. //On Mobile View Parent is Board, Not Board Body. I cant see how this funciton should work then.
  57. if (bodyBoardComponent === null) return;
  58. const $cardView = this.$(this.firstNode());
  59. const $cardContainer = bodyBoardComponent.$('.js-swimlanes');
  60. // TODO sometimes cardContainer is not available, maybe because it's not yet
  61. // created?!
  62. if (!$cardContainer) return;
  63. const cardContainerScroll = $cardContainer.scrollLeft();
  64. const cardContainerWidth = $cardContainer.width();
  65. const cardViewStart = $cardView.offset().left;
  66. const cardViewEnd = cardViewStart + cardPanelWidth;
  67. let offset = false;
  68. if (cardViewStart < 0) {
  69. offset = cardViewStart;
  70. } else if (cardViewEnd > cardContainerWidth) {
  71. offset = cardViewEnd - cardContainerWidth;
  72. }
  73. if (offset) {
  74. bodyBoardComponent.scrollLeft(cardContainerScroll + offset);
  75. }
  76. //Scroll top
  77. const cardViewStartTop = $cardView.offset().top;
  78. const cardContainerScrollTop = $cardContainer.scrollTop();
  79. let topOffset = false;
  80. if (cardViewStartTop !== 100) {
  81. topOffset = cardViewStartTop - 100;
  82. }
  83. if (topOffset !== false) {
  84. bodyBoardComponent.scrollTop(cardContainerScrollTop + topOffset);
  85. }
  86. },
  87. presentParentTask() {
  88. let result = this.currentBoard.presentParentTask;
  89. if (result === null || result === undefined) {
  90. result = 'no-parent';
  91. }
  92. return result;
  93. },
  94. linkForCard() {
  95. const card = this.currentData();
  96. let result = '#';
  97. if (card) {
  98. const board = Boards.findOne(card.boardId);
  99. if (board) {
  100. result = FlowRouter.url('card', {
  101. boardId: card.boardId,
  102. slug: board.slug,
  103. cardId: card._id,
  104. });
  105. }
  106. }
  107. return result;
  108. },
  109. showVotingButtons() {
  110. const card = this.currentData();
  111. return (
  112. (currentUser.isBoardMember() ||
  113. (currentUser && card.voteAllowNonBoardMembers())) &&
  114. !card.expiredVote()
  115. );
  116. },
  117. onRendered() {
  118. if (Meteor.settings.public.CARD_OPENED_WEBHOOK_ENABLED) {
  119. // Send Webhook but not create Activities records ---
  120. const card = this.currentData();
  121. const userId = Meteor.userId();
  122. const params = {
  123. userId,
  124. cardId: card._id,
  125. boardId: card.boardId,
  126. listId: card.listId,
  127. user: Meteor.user().username,
  128. url: '',
  129. };
  130. const integrations = Integrations.find({
  131. boardId: { $in: [card.boardId, Integrations.Const.GLOBAL_WEBHOOK_ID] },
  132. enabled: true,
  133. activities: { $in: ['CardDetailsRendered', 'all'] },
  134. }).fetch();
  135. if (integrations.length > 0) {
  136. integrations.forEach(integration => {
  137. Meteor.call(
  138. 'outgoingWebhooks',
  139. integration,
  140. 'CardSelected',
  141. params,
  142. () => {
  143. return;
  144. },
  145. );
  146. });
  147. }
  148. //-------------
  149. }
  150. if (!Utils.isMiniScreen()) {
  151. Meteor.setTimeout(() => {
  152. $('.card-details').mCustomScrollbar({
  153. theme: 'minimal-dark',
  154. setWidth: false,
  155. setLeft: 0,
  156. scrollbarPosition: 'outside',
  157. mouseWheel: true,
  158. });
  159. this.scrollParentContainer();
  160. }, 500);
  161. }
  162. const $checklistsDom = this.$('.card-checklist-items');
  163. $checklistsDom.sortable({
  164. tolerance: 'pointer',
  165. helper: 'clone',
  166. handle: '.checklist-title',
  167. items: '.js-checklist',
  168. placeholder: 'checklist placeholder',
  169. distance: 7,
  170. start(evt, ui) {
  171. ui.placeholder.height(ui.helper.height());
  172. EscapeActions.executeUpTo('popup-close');
  173. },
  174. stop(evt, ui) {
  175. let prevChecklist = ui.item.prev('.js-checklist').get(0);
  176. if (prevChecklist) {
  177. prevChecklist = Blaze.getData(prevChecklist).checklist;
  178. }
  179. let nextChecklist = ui.item.next('.js-checklist').get(0);
  180. if (nextChecklist) {
  181. nextChecklist = Blaze.getData(nextChecklist).checklist;
  182. }
  183. const sortIndex = calculateIndexData(prevChecklist, nextChecklist, 1);
  184. $checklistsDom.sortable('cancel');
  185. const checklist = Blaze.getData(ui.item.get(0)).checklist;
  186. Checklists.update(checklist._id, {
  187. $set: {
  188. sort: sortIndex.base,
  189. },
  190. });
  191. },
  192. });
  193. const $subtasksDom = this.$('.card-subtasks-items');
  194. $subtasksDom.sortable({
  195. tolerance: 'pointer',
  196. helper: 'clone',
  197. handle: '.subtask-title',
  198. items: '.js-subtasks',
  199. placeholder: 'subtasks placeholder',
  200. distance: 7,
  201. start(evt, ui) {
  202. ui.placeholder.height(ui.helper.height());
  203. EscapeActions.executeUpTo('popup-close');
  204. },
  205. stop(evt, ui) {
  206. let prevChecklist = ui.item.prev('.js-subtasks').get(0);
  207. if (prevChecklist) {
  208. prevChecklist = Blaze.getData(prevChecklist).subtask;
  209. }
  210. let nextChecklist = ui.item.next('.js-subtasks').get(0);
  211. if (nextChecklist) {
  212. nextChecklist = Blaze.getData(nextChecklist).subtask;
  213. }
  214. const sortIndex = calculateIndexData(prevChecklist, nextChecklist, 1);
  215. $subtasksDom.sortable('cancel');
  216. const subtask = Blaze.getData(ui.item.get(0)).subtask;
  217. Subtasks.update(subtask._id, {
  218. $set: {
  219. subtaskSort: sortIndex.base,
  220. },
  221. });
  222. },
  223. });
  224. function userIsMember() {
  225. return Meteor.user() && Meteor.user().isBoardMember();
  226. }
  227. // Disable sorting if the current user is not a board member
  228. this.autorun(() => {
  229. const disabled = !userIsMember() || Utils.isMiniScreen();
  230. if (
  231. $checklistsDom.data('uiSortable') ||
  232. $checklistsDom.data('sortable')
  233. ) {
  234. $checklistsDom.sortable('option', 'disabled', disabled);
  235. }
  236. if ($subtasksDom.data('uiSortable') || $subtasksDom.data('sortable')) {
  237. $subtasksDom.sortable('option', 'disabled', disabled);
  238. }
  239. });
  240. },
  241. onDestroyed() {
  242. const parentComponent = this.parentComponent().parentComponent();
  243. //on mobile view parent is Board, not board body.
  244. if (parentComponent === null) return;
  245. parentComponent.showOverlay.set(false);
  246. },
  247. events() {
  248. const events = {
  249. [`${CSSEvents.transitionend} .js-card-details`]() {
  250. this.isLoaded.set(true);
  251. },
  252. [`${CSSEvents.animationend} .js-card-details`]() {
  253. this.isLoaded.set(true);
  254. },
  255. };
  256. return [
  257. {
  258. ...events,
  259. 'click .js-close-card-details'() {
  260. Utils.goBoardId(this.data().boardId);
  261. },
  262. 'click .js-copy-link'() {
  263. StringToCopyElement = document.getElementById('cardURL_copy');
  264. StringToCopyElement.select();
  265. if (document.execCommand('copy')) {
  266. StringToCopyElement.blur();
  267. } else {
  268. document.getElementById('cardURL_copy').selectionStart = 0;
  269. document.getElementById('cardURL_copy').selectionEnd = 999;
  270. document.execCommand('copy');
  271. if (window.getSelection) {
  272. if (window.getSelection().empty) {
  273. // Chrome
  274. window.getSelection().empty();
  275. } else if (window.getSelection().removeAllRanges) {
  276. // Firefox
  277. window.getSelection().removeAllRanges();
  278. }
  279. } else if (document.selection) {
  280. // IE?
  281. document.selection.empty();
  282. }
  283. }
  284. },
  285. 'click .js-open-card-details-menu': Popup.open('cardDetailsActions'),
  286. 'submit .js-card-description'(event) {
  287. event.preventDefault();
  288. const description = this.currentComponent().getValue();
  289. this.data().setDescription(description);
  290. },
  291. 'submit .js-card-details-title'(event) {
  292. event.preventDefault();
  293. const title = this.currentComponent()
  294. .getValue()
  295. .trim();
  296. if (title) {
  297. this.data().setTitle(title);
  298. } else {
  299. this.data().setTitle('');
  300. }
  301. },
  302. 'submit .js-card-details-assigner'(event) {
  303. event.preventDefault();
  304. const assigner = this.currentComponent()
  305. .getValue()
  306. .trim();
  307. if (assigner) {
  308. this.data().setAssignedBy(assigner);
  309. } else {
  310. this.data().setAssignedBy('');
  311. }
  312. },
  313. 'submit .js-card-details-requester'(event) {
  314. event.preventDefault();
  315. const requester = this.currentComponent()
  316. .getValue()
  317. .trim();
  318. if (requester) {
  319. this.data().setRequestedBy(requester);
  320. } else {
  321. this.data().setRequestedBy('');
  322. }
  323. },
  324. 'click .js-go-to-linked-card'() {
  325. Utils.goCardId(this.data().linkedId);
  326. },
  327. 'click .js-member': Popup.open('cardMember'),
  328. 'click .js-add-members': Popup.open('cardMembers'),
  329. 'click .js-assignee': Popup.open('cardAssignee'),
  330. 'click .js-add-assignees': Popup.open('cardAssignees'),
  331. 'click .js-add-labels': Popup.open('cardLabels'),
  332. 'click .js-received-date': Popup.open('editCardReceivedDate'),
  333. 'click .js-start-date': Popup.open('editCardStartDate'),
  334. 'click .js-due-date': Popup.open('editCardDueDate'),
  335. 'click .js-end-date': Popup.open('editCardEndDate'),
  336. 'click .js-show-positive-votes': Popup.open('positiveVoteMembers'),
  337. 'click .js-show-negative-votes': Popup.open('negativeVoteMembers'),
  338. 'mouseenter .js-card-details'() {
  339. const parentComponent = this.parentComponent().parentComponent();
  340. //on mobile view parent is Board, not BoardBody.
  341. if (parentComponent === null) return;
  342. parentComponent.showOverlay.set(true);
  343. parentComponent.mouseHasEnterCardDetails = true;
  344. },
  345. 'mousedown .js-card-details'() {
  346. Session.set('cardDetailsIsDragging', false);
  347. Session.set('cardDetailsIsMouseDown', true);
  348. },
  349. 'mousemove .js-card-details'() {
  350. if (Session.get('cardDetailsIsMouseDown')) {
  351. Session.set('cardDetailsIsDragging', true);
  352. }
  353. },
  354. 'mouseup .js-card-details'() {
  355. Session.set('cardDetailsIsDragging', false);
  356. Session.set('cardDetailsIsMouseDown', false);
  357. },
  358. 'click #toggleButton'() {
  359. Meteor.call('toggleSystemMessages');
  360. },
  361. 'click .js-vote'(e) {
  362. const forIt = $(e.target).hasClass('js-vote-positive');
  363. let newState = null;
  364. if (
  365. this.data().voteState() === null ||
  366. (this.data().voteState() === false && forIt) ||
  367. (this.data().voteState() === true && !forIt)
  368. ) {
  369. newState = forIt;
  370. }
  371. this.data().setVote(Meteor.userId(), newState);
  372. },
  373. },
  374. ];
  375. },
  376. }).register('cardDetails');
  377. Template.cardDetails.helpers({
  378. userData() {
  379. // We need to handle a special case for the search results provided by the
  380. // `matteodem:easy-search` package. Since these results gets published in a
  381. // separate collection, and not in the standard Meteor.Users collection as
  382. // expected, we use a component parameter ("property") to distinguish the
  383. // two cases.
  384. const userCollection = this.esSearch ? ESSearchResults : Users;
  385. return userCollection.findOne(this.userId, {
  386. fields: {
  387. profile: 1,
  388. username: 1,
  389. },
  390. });
  391. },
  392. receivedSelected() {
  393. if (this.getReceived().length === 0) {
  394. return false;
  395. } else {
  396. return true;
  397. }
  398. },
  399. startSelected() {
  400. if (this.getStart().length === 0) {
  401. return false;
  402. } else {
  403. return true;
  404. }
  405. },
  406. endSelected() {
  407. if (this.getEnd().length === 0) {
  408. return false;
  409. } else {
  410. return true;
  411. }
  412. },
  413. dueSelected() {
  414. if (this.getDue().length === 0) {
  415. return false;
  416. } else {
  417. return true;
  418. }
  419. },
  420. memberSelected() {
  421. if (this.getMembers().length === 0) {
  422. return false;
  423. } else {
  424. return true;
  425. }
  426. },
  427. labelSelected() {
  428. if (this.getLabels().length === 0) {
  429. return false;
  430. } else {
  431. return true;
  432. }
  433. },
  434. assigneeSelected() {
  435. if (this.getAssignees().length === 0) {
  436. return false;
  437. } else {
  438. return true;
  439. }
  440. },
  441. requestBySelected() {
  442. if (this.getRequestBy().length === 0) {
  443. return false;
  444. } else {
  445. return true;
  446. }
  447. },
  448. assigneeBySelected() {
  449. if (this.getAssigneeBy().length === 0) {
  450. return false;
  451. } else {
  452. return true;
  453. }
  454. },
  455. memberType() {
  456. const user = Users.findOne(this.userId);
  457. return user && user.isBoardAdmin() ? 'admin' : 'normal';
  458. },
  459. presenceStatusClassName() {
  460. const user = Users.findOne(this.userId);
  461. const userPresence = presences.findOne({ userId: this.userId });
  462. if (user && user.isInvitedTo(Session.get('currentBoard'))) return 'pending';
  463. else if (!userPresence) return 'disconnected';
  464. else if (Session.equals('currentBoard', userPresence.state.currentBoardId))
  465. return 'active';
  466. else return 'idle';
  467. },
  468. });
  469. Template.userAvatarAssigneeInitials.helpers({
  470. initials() {
  471. const user = Users.findOne(this.userId);
  472. return user && user.getInitials();
  473. },
  474. viewPortWidth() {
  475. const user = Users.findOne(this.userId);
  476. return ((user && user.getInitials().length) || 1) * 12;
  477. },
  478. });
  479. // We extends the normal InlinedForm component to support UnsavedEdits draft
  480. // feature.
  481. (class extends InlinedForm {
  482. _getUnsavedEditKey() {
  483. return {
  484. fieldName: 'cardDescription',
  485. // XXX Recovering the currentCard identifier form a session variable is
  486. // fragile because this variable may change for instance if the route
  487. // change. We should use some component props instead.
  488. docId: Session.get('currentCard'),
  489. };
  490. }
  491. close(isReset = false) {
  492. if (this.isOpen.get() && !isReset) {
  493. const draft = this.getValue().trim();
  494. if (
  495. draft !== Cards.findOne(Session.get('currentCard')).getDescription()
  496. ) {
  497. UnsavedEdits.set(this._getUnsavedEditKey(), this.getValue());
  498. }
  499. }
  500. super.close();
  501. }
  502. reset() {
  503. UnsavedEdits.reset(this._getUnsavedEditKey());
  504. this.close(true);
  505. }
  506. events() {
  507. const parentEvents = InlinedForm.prototype.events()[0];
  508. return [
  509. {
  510. ...parentEvents,
  511. 'click .js-close-inlined-form': this.reset,
  512. },
  513. ];
  514. }
  515. }.register('inlinedCardDescription'));
  516. Template.cardDetailsActionsPopup.helpers({
  517. isWatching() {
  518. return this.findWatcher(Meteor.userId());
  519. },
  520. canModifyCard() {
  521. return (
  522. Meteor.user() &&
  523. Meteor.user().isBoardMember() &&
  524. !Meteor.user().isCommentOnly()
  525. );
  526. },
  527. });
  528. Template.cardDetailsActionsPopup.events({
  529. 'click .js-members': Popup.open('cardMembers'),
  530. 'click .js-assignees': Popup.open('cardAssignees'),
  531. 'click .js-labels': Popup.open('cardLabels'),
  532. 'click .js-attachments': Popup.open('cardAttachments'),
  533. 'click .js-start-voting': Popup.open('cardStartVoting'),
  534. 'click .js-custom-fields': Popup.open('cardCustomFields'),
  535. 'click .js-received-date': Popup.open('editCardReceivedDate'),
  536. 'click .js-start-date': Popup.open('editCardStartDate'),
  537. 'click .js-due-date': Popup.open('editCardDueDate'),
  538. 'click .js-end-date': Popup.open('editCardEndDate'),
  539. 'click .js-spent-time': Popup.open('editCardSpentTime'),
  540. 'click .js-move-card': Popup.open('moveCard'),
  541. 'click .js-copy-card': Popup.open('copyCard'),
  542. 'click .js-copy-checklist-cards': Popup.open('copyChecklistToManyCards'),
  543. 'click .js-set-card-color': Popup.open('setCardColor'),
  544. 'click .js-move-card-to-top'(event) {
  545. event.preventDefault();
  546. const minOrder = _.min(
  547. this.list()
  548. .cards(this.swimlaneId)
  549. .map(c => c.sort),
  550. );
  551. this.move(this.boardId, this.swimlaneId, this.listId, minOrder - 1);
  552. },
  553. 'click .js-move-card-to-bottom'(event) {
  554. event.preventDefault();
  555. const maxOrder = _.max(
  556. this.list()
  557. .cards(this.swimlaneId)
  558. .map(c => c.sort),
  559. );
  560. this.move(this.boardId, this.swimlaneId, this.listId, maxOrder + 1);
  561. },
  562. 'click .js-archive'(event) {
  563. event.preventDefault();
  564. this.archive();
  565. Popup.close();
  566. },
  567. 'click .js-more': Popup.open('cardMore'),
  568. 'click .js-toggle-watch-card'() {
  569. const currentCard = this;
  570. const level = currentCard.findWatcher(Meteor.userId()) ? null : 'watching';
  571. Meteor.call('watch', 'card', currentCard._id, level, (err, ret) => {
  572. if (!err && ret) Popup.close();
  573. });
  574. },
  575. });
  576. Template.editCardTitleForm.onRendered(function() {
  577. autosize(this.$('.js-edit-card-title'));
  578. });
  579. Template.editCardTitleForm.events({
  580. 'keydown .js-edit-card-title'(event) {
  581. // If enter key was pressed, submit the data
  582. // Unless the shift key is also being pressed
  583. if (event.keyCode === 13 && !event.shiftKey) {
  584. $('.js-submit-edit-card-title-form').click();
  585. }
  586. },
  587. });
  588. Template.editCardRequesterForm.onRendered(function() {
  589. autosize(this.$('.js-edit-card-requester'));
  590. });
  591. Template.editCardRequesterForm.events({
  592. 'keydown .js-edit-card-requester'(event) {
  593. // If enter key was pressed, submit the data
  594. if (event.keyCode === 13) {
  595. $('.js-submit-edit-card-requester-form').click();
  596. }
  597. },
  598. });
  599. Template.editCardAssignerForm.onRendered(function() {
  600. autosize(this.$('.js-edit-card-assigner'));
  601. });
  602. Template.editCardAssignerForm.events({
  603. 'keydown .js-edit-card-assigner'(event) {
  604. // If enter key was pressed, submit the data
  605. if (event.keyCode === 13) {
  606. $('.js-submit-edit-card-assigner-form').click();
  607. }
  608. },
  609. });
  610. Template.moveCardPopup.events({
  611. 'click .js-done'() {
  612. // XXX We should *not* get the currentCard from the global state, but
  613. // instead from a “component” state.
  614. const card = Cards.findOne(Session.get('currentCard'));
  615. const bSelect = $('.js-select-boards')[0];
  616. const boardId = bSelect.options[bSelect.selectedIndex].value;
  617. const lSelect = $('.js-select-lists')[0];
  618. const listId = lSelect.options[lSelect.selectedIndex].value;
  619. const slSelect = $('.js-select-swimlanes')[0];
  620. const swimlaneId = slSelect.options[slSelect.selectedIndex].value;
  621. card.move(boardId, swimlaneId, listId, 0);
  622. Popup.close();
  623. },
  624. });
  625. BlazeComponent.extendComponent({
  626. onCreated() {
  627. subManager.subscribe('board', Session.get('currentBoard'), false);
  628. this.selectedBoardId = new ReactiveVar(Session.get('currentBoard'));
  629. },
  630. boards() {
  631. const boards = Boards.find(
  632. {
  633. archived: false,
  634. 'members.userId': Meteor.userId(),
  635. _id: { $ne: Meteor.user().getTemplatesBoardId() },
  636. },
  637. {
  638. sort: { sort: 1 /* boards default sorting */ },
  639. },
  640. );
  641. return boards;
  642. },
  643. swimlanes() {
  644. const board = Boards.findOne(this.selectedBoardId.get());
  645. return board.swimlanes();
  646. },
  647. aBoardLists() {
  648. const board = Boards.findOne(this.selectedBoardId.get());
  649. return board.lists();
  650. },
  651. events() {
  652. return [
  653. {
  654. 'change .js-select-boards'(event) {
  655. this.selectedBoardId.set($(event.currentTarget).val());
  656. subManager.subscribe('board', this.selectedBoardId.get(), false);
  657. },
  658. },
  659. ];
  660. },
  661. }).register('boardsAndLists');
  662. Template.copyCardPopup.events({
  663. 'click .js-done'() {
  664. const card = Cards.findOne(Session.get('currentCard'));
  665. const lSelect = $('.js-select-lists')[0];
  666. listId = lSelect.options[lSelect.selectedIndex].value;
  667. const slSelect = $('.js-select-swimlanes')[0];
  668. const swimlaneId = slSelect.options[slSelect.selectedIndex].value;
  669. const bSelect = $('.js-select-boards')[0];
  670. const boardId = bSelect.options[bSelect.selectedIndex].value;
  671. const textarea = $('#copy-card-title');
  672. const title = textarea.val().trim();
  673. // insert new card to the bottom of new list
  674. card.sort = Lists.findOne(card.listId)
  675. .cards()
  676. .count();
  677. if (title) {
  678. card.title = title;
  679. card.coverId = '';
  680. const _id = card.copy(boardId, swimlaneId, listId);
  681. // In case the filter is active we need to add the newly inserted card in
  682. // the list of exceptions -- cards that are not filtered. Otherwise the
  683. // card will disappear instantly.
  684. // See https://github.com/wekan/wekan/issues/80
  685. Filter.addException(_id);
  686. Popup.close();
  687. }
  688. },
  689. });
  690. Template.copyChecklistToManyCardsPopup.events({
  691. 'click .js-done'() {
  692. const card = Cards.findOne(Session.get('currentCard'));
  693. const oldId = card._id;
  694. card._id = null;
  695. const lSelect = $('.js-select-lists')[0];
  696. card.listId = lSelect.options[lSelect.selectedIndex].value;
  697. const slSelect = $('.js-select-swimlanes')[0];
  698. card.swimlaneId = slSelect.options[slSelect.selectedIndex].value;
  699. const bSelect = $('.js-select-boards')[0];
  700. card.boardId = bSelect.options[bSelect.selectedIndex].value;
  701. const textarea = $('#copy-card-title');
  702. const titleEntry = textarea.val().trim();
  703. // insert new card to the bottom of new list
  704. card.sort = Lists.findOne(card.listId)
  705. .cards()
  706. .count();
  707. if (titleEntry) {
  708. const titleList = JSON.parse(titleEntry);
  709. for (let i = 0; i < titleList.length; i++) {
  710. const obj = titleList[i];
  711. card.title = obj.title;
  712. card.description = obj.description;
  713. card.coverId = '';
  714. const _id = Cards.insert(card);
  715. // In case the filter is active we need to add the newly inserted card in
  716. // the list of exceptions -- cards that are not filtered. Otherwise the
  717. // card will disappear instantly.
  718. // See https://github.com/wekan/wekan/issues/80
  719. Filter.addException(_id);
  720. // copy checklists
  721. Checklists.find({ cardId: oldId }).forEach(ch => {
  722. ch.copy(_id);
  723. });
  724. // copy subtasks
  725. cursor = Cards.find({ parentId: oldId });
  726. cursor.forEach(function() {
  727. 'use strict';
  728. const subtask = arguments[0];
  729. subtask.parentId = _id;
  730. subtask._id = null;
  731. /* const newSubtaskId = */ Cards.insert(subtask);
  732. });
  733. // copy card comments
  734. CardComments.find({ cardId: oldId }).forEach(cmt => {
  735. cmt.copy(_id);
  736. });
  737. }
  738. Popup.close();
  739. }
  740. },
  741. });
  742. BlazeComponent.extendComponent({
  743. onCreated() {
  744. this.currentCard = this.currentData();
  745. this.currentColor = new ReactiveVar(this.currentCard.color);
  746. },
  747. colors() {
  748. return cardColors.map(color => ({ color, name: '' }));
  749. },
  750. isSelected(color) {
  751. if (this.currentColor.get() === null) {
  752. return color === 'white';
  753. }
  754. return this.currentColor.get() === color;
  755. },
  756. events() {
  757. return [
  758. {
  759. 'click .js-palette-color'() {
  760. this.currentColor.set(this.currentData().color);
  761. },
  762. 'click .js-submit'() {
  763. this.currentCard.setColor(this.currentColor.get());
  764. Popup.close();
  765. },
  766. 'click .js-remove-color'() {
  767. this.currentCard.setColor(null);
  768. Popup.close();
  769. },
  770. },
  771. ];
  772. },
  773. }).register('setCardColorPopup');
  774. BlazeComponent.extendComponent({
  775. onCreated() {
  776. this.currentCard = this.currentData();
  777. this.parentBoard = new ReactiveVar(null);
  778. this.parentCard = this.currentCard.parentCard();
  779. if (this.parentCard) {
  780. const list = $('.js-field-parent-card');
  781. list.val(this.parentCard._id);
  782. this.parentBoard.set(this.parentCard.board()._id);
  783. } else {
  784. this.parentBoard.set(null);
  785. }
  786. },
  787. boards() {
  788. const boards = Boards.find(
  789. {
  790. archived: false,
  791. 'members.userId': Meteor.userId(),
  792. _id: {
  793. $ne: Meteor.user().getTemplatesBoardId(),
  794. },
  795. },
  796. {
  797. sort: { sort: 1 /* boards default sorting */ },
  798. },
  799. );
  800. return boards;
  801. },
  802. cards() {
  803. const currentId = Session.get('currentCard');
  804. if (this.parentBoard.get()) {
  805. return Cards.find({
  806. boardId: this.parentBoard.get(),
  807. _id: { $ne: currentId },
  808. });
  809. } else {
  810. return [];
  811. }
  812. },
  813. isParentBoard() {
  814. const board = this.currentData();
  815. if (this.parentBoard.get()) {
  816. return board._id === this.parentBoard.get();
  817. }
  818. return false;
  819. },
  820. isParentCard() {
  821. const card = this.currentData();
  822. if (this.parentCard) {
  823. return card._id === this.parentCard;
  824. }
  825. return false;
  826. },
  827. setParentCardId(cardId) {
  828. if (cardId) {
  829. this.parentCard = Cards.findOne(cardId);
  830. } else {
  831. this.parentCard = null;
  832. }
  833. this.currentCard.setParentId(cardId);
  834. },
  835. events() {
  836. return [
  837. {
  838. 'click .js-copy-card-link-to-clipboard'() {
  839. // Clipboard code from:
  840. // https://stackoverflow.com/questions/6300213/copy-selected-text-to-the-clipboard-without-using-flash-must-be-cross-browser
  841. const StringToCopyElement = document.getElementById('cardURL');
  842. StringToCopyElement.select();
  843. if (document.execCommand('copy')) {
  844. StringToCopyElement.blur();
  845. } else {
  846. document.getElementById('cardURL').selectionStart = 0;
  847. document.getElementById('cardURL').selectionEnd = 999;
  848. document.execCommand('copy');
  849. if (window.getSelection) {
  850. if (window.getSelection().empty) {
  851. // Chrome
  852. window.getSelection().empty();
  853. } else if (window.getSelection().removeAllRanges) {
  854. // Firefox
  855. window.getSelection().removeAllRanges();
  856. }
  857. } else if (document.selection) {
  858. // IE?
  859. document.selection.empty();
  860. }
  861. }
  862. },
  863. 'click .js-delete': Popup.afterConfirm('cardDelete', function() {
  864. Popup.close();
  865. // verify that there are no linked cards
  866. if (Cards.find({ linkedId: this._id }).count() === 0) {
  867. Cards.remove(this._id);
  868. } else {
  869. // TODO: Maybe later we can list where the linked cards are.
  870. // Now here is popup with a hint that the card cannot be deleted
  871. // as there are linked cards.
  872. // Related:
  873. // client/components/lists/listHeader.js about line 248
  874. // https://github.com/wekan/wekan/issues/2785
  875. const message = `${TAPi18n.__(
  876. 'delete-linked-card-before-this-card',
  877. )} linkedId: ${
  878. this._id
  879. } at client/components/cards/cardDetails.js and https://github.com/wekan/wekan/issues/2785`;
  880. alert(message);
  881. }
  882. Utils.goBoardId(this.boardId);
  883. }),
  884. 'change .js-field-parent-board'(event) {
  885. const selection = $(event.currentTarget).val();
  886. const list = $('.js-field-parent-card');
  887. if (selection === 'none') {
  888. this.parentBoard.set(null);
  889. } else {
  890. subManager.subscribe('board', $(event.currentTarget).val(), false);
  891. this.parentBoard.set(selection);
  892. list.prop('disabled', false);
  893. }
  894. this.setParentCardId(null);
  895. },
  896. 'change .js-field-parent-card'(event) {
  897. const selection = $(event.currentTarget).val();
  898. this.setParentCardId(selection);
  899. },
  900. },
  901. ];
  902. },
  903. }).register('cardMorePopup');
  904. BlazeComponent.extendComponent({
  905. onCreated() {
  906. this.currentCard = this.currentData();
  907. this.voteQuestion = new ReactiveVar(this.currentCard.voteQuestion);
  908. },
  909. events() {
  910. return [
  911. {
  912. 'click .js-end-date': Popup.open('editVoteEndDate'),
  913. 'submit .edit-vote-question'(evt) {
  914. evt.preventDefault();
  915. const voteQuestion = evt.target.vote.value;
  916. const publicVote = $('#vote-public').hasClass('is-checked');
  917. const allowNonBoardMembers = $('#vote-allow-non-members').hasClass(
  918. 'is-checked',
  919. );
  920. const endString = this.currentCard.getVoteEnd();
  921. this.currentCard.setVoteQuestion(
  922. voteQuestion,
  923. publicVote,
  924. allowNonBoardMembers,
  925. );
  926. if (endString) {
  927. this.currentCard.setVoteEnd(endString);
  928. }
  929. Popup.close();
  930. },
  931. 'click .js-remove-vote': Popup.afterConfirm('deleteVote', () => {
  932. event.preventDefault();
  933. this.currentCard.unsetVote();
  934. Popup.close();
  935. }),
  936. 'click a.js-toggle-vote-public'(event) {
  937. event.preventDefault();
  938. $('#vote-public').toggleClass('is-checked');
  939. },
  940. 'click a.js-toggle-vote-allow-non-members'(event) {
  941. event.preventDefault();
  942. $('#vote-allow-non-members').toggleClass('is-checked');
  943. },
  944. },
  945. ];
  946. },
  947. }).register('cardStartVotingPopup');
  948. // editVoteEndDatePopup
  949. (class extends DatePicker {
  950. onCreated() {
  951. super.onCreated(moment().format('YYYY-MM-DD HH:mm'));
  952. this.data().getVoteEnd() && this.date.set(moment(this.data().getVoteEnd()));
  953. }
  954. events() {
  955. return [
  956. {
  957. 'submit .edit-date'(evt) {
  958. evt.preventDefault();
  959. // if no time was given, init with 12:00
  960. const time =
  961. evt.target.time.value ||
  962. moment(new Date().setHours(12, 0, 0)).format('LT');
  963. const dateString = `${evt.target.date.value} ${time}`;
  964. const newDate = moment(dateString, 'L LT', true);
  965. if (newDate.isValid()) {
  966. // if active vote - store it
  967. if (this.currentData().getVoteQuestion()) {
  968. this._storeDate(newDate.toDate());
  969. Popup.close();
  970. } else {
  971. this.currentData().vote = { end: newDate.toDate() }; // set vote end temp
  972. Popup.back();
  973. }
  974. } else {
  975. this.error.set('invalid-date');
  976. evt.target.date.focus();
  977. }
  978. },
  979. 'click .js-delete-date'(evt) {
  980. evt.preventDefault();
  981. this._deleteDate();
  982. Popup.close();
  983. },
  984. },
  985. ];
  986. }
  987. _storeDate(newDate) {
  988. this.card.setVoteEnd(newDate);
  989. }
  990. _deleteDate() {
  991. this.card.unsetVoteEnd();
  992. }
  993. }.register('editVoteEndDatePopup'));
  994. // Close the card details pane by pressing escape
  995. EscapeActions.register(
  996. 'detailsPane',
  997. () => {
  998. if (Session.get('cardDetailsIsDragging')) {
  999. // Reset dragging status as the mouse landed outside the cardDetails template area and this will prevent a mousedown event from firing
  1000. Session.set('cardDetailsIsDragging', false);
  1001. Session.set('cardDetailsIsMouseDown', false);
  1002. } else {
  1003. // Prevent close card when the user is selecting text and moves the mouse cursor outside the card detail area
  1004. Utils.goBoardId(Session.get('currentBoard'));
  1005. }
  1006. },
  1007. () => {
  1008. return !Session.equals('currentCard', null);
  1009. },
  1010. {
  1011. noClickEscapeOn: '.js-card-details,.board-sidebar,#header',
  1012. },
  1013. );
  1014. Template.cardAssigneesPopup.events({
  1015. 'click .js-select-assignee'(event) {
  1016. const card = Cards.findOne(Session.get('currentCard'));
  1017. const assigneeId = this.userId;
  1018. card.toggleAssignee(assigneeId);
  1019. event.preventDefault();
  1020. },
  1021. });
  1022. Template.cardAssigneesPopup.helpers({
  1023. isCardAssignee() {
  1024. const card = Template.parentData();
  1025. const cardAssignees = card.getAssignees();
  1026. return _.contains(cardAssignees, this.userId);
  1027. },
  1028. user() {
  1029. return Users.findOne(this.userId);
  1030. },
  1031. });
  1032. Template.cardAssigneePopup.helpers({
  1033. userData() {
  1034. // We need to handle a special case for the search results provided by the
  1035. // `matteodem:easy-search` package. Since these results gets published in a
  1036. // separate collection, and not in the standard Meteor.Users collection as
  1037. // expected, we use a component parameter ("property") to distinguish the
  1038. // two cases.
  1039. const userCollection = this.esSearch ? ESSearchResults : Users;
  1040. return userCollection.findOne(this.userId, {
  1041. fields: {
  1042. profile: 1,
  1043. username: 1,
  1044. },
  1045. });
  1046. },
  1047. memberType() {
  1048. const user = Users.findOne(this.userId);
  1049. return user && user.isBoardAdmin() ? 'admin' : 'normal';
  1050. },
  1051. presenceStatusClassName() {
  1052. const user = Users.findOne(this.userId);
  1053. const userPresence = presences.findOne({ userId: this.userId });
  1054. if (user && user.isInvitedTo(Session.get('currentBoard'))) return 'pending';
  1055. else if (!userPresence) return 'disconnected';
  1056. else if (Session.equals('currentBoard', userPresence.state.currentBoardId))
  1057. return 'active';
  1058. else return 'idle';
  1059. },
  1060. isCardAssignee() {
  1061. const card = Template.parentData();
  1062. const cardAssignees = card.getAssignees();
  1063. return _.contains(cardAssignees, this.userId);
  1064. },
  1065. user() {
  1066. return Users.findOne(this.userId);
  1067. },
  1068. });
  1069. Template.cardAssigneePopup.events({
  1070. 'click .js-remove-assignee'() {
  1071. Cards.findOne(this.cardId).unassignAssignee(this.userId);
  1072. Popup.close();
  1073. },
  1074. 'click .js-edit-profile': Popup.open('editProfile'),
  1075. });