boardBody.js 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016
  1. import { ReactiveCache } from '/imports/reactiveCache';
  2. import { TAPi18n } from '/imports/i18n';
  3. import dragscroll from '@wekanteam/dragscroll';
  4. import { boardConverter } from '/client/lib/boardConverter';
  5. import { migrationManager } from '/client/lib/migrationManager';
  6. import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
  7. import { Swimlanes } from '/models/swimlanes';
  8. import Lists from '/models/lists';
  9. const subManager = new SubsManager();
  10. const { calculateIndex } = Utils;
  11. const swimlaneWhileSortingHeight = 150;
  12. BlazeComponent.extendComponent({
  13. onCreated() {
  14. this.isBoardReady = new ReactiveVar(false);
  15. this.isConverting = new ReactiveVar(false);
  16. this.isMigrating = new ReactiveVar(false);
  17. this._swimlaneCreated = new Set(); // Track boards where we've created swimlanes
  18. // The pattern we use to manually handle data loading is described here:
  19. // https://kadira.io/academy/meteor-routing-guide/content/subscriptions-and-data-management/using-subs-manager
  20. // XXX The boardId should be readed from some sort the component "props",
  21. // unfortunatly, Blaze doesn't have this notion.
  22. this.autorun(() => {
  23. const currentBoardId = Session.get('currentBoard');
  24. if (!currentBoardId) return;
  25. const handle = subManager.subscribe('board', currentBoardId, false);
  26. Tracker.nonreactive(() => {
  27. Tracker.autorun(() => {
  28. if (handle.ready()) {
  29. // Ensure default swimlane exists (only once per board)
  30. this.ensureDefaultSwimlane(currentBoardId);
  31. // Check if board needs conversion
  32. this.checkAndConvertBoard(currentBoardId);
  33. } else {
  34. this.isBoardReady.set(false);
  35. }
  36. });
  37. });
  38. });
  39. },
  40. ensureDefaultSwimlane(boardId) {
  41. // Only create swimlane once per board
  42. if (this._swimlaneCreated.has(boardId)) {
  43. return;
  44. }
  45. try {
  46. const board = ReactiveCache.getBoard(boardId);
  47. if (!board) return;
  48. const swimlanes = board.swimlanes();
  49. if (swimlanes.length === 0) {
  50. const swimlaneId = Swimlanes.insert({
  51. title: 'Default',
  52. boardId: boardId,
  53. });
  54. this._swimlaneCreated.add(boardId);
  55. } else {
  56. this._swimlaneCreated.add(boardId);
  57. }
  58. } catch (error) {
  59. console.error('Error creating default swimlane:', error);
  60. }
  61. },
  62. async checkAndConvertBoard(boardId) {
  63. try {
  64. const board = ReactiveCache.getBoard(boardId);
  65. if (!board) {
  66. this.isBoardReady.set(true);
  67. return;
  68. }
  69. // Check if board needs migration based on migration version
  70. const needsMigration = !board.migrationVersion || board.migrationVersion < 1;
  71. if (needsMigration) {
  72. // Start background migration for old boards
  73. this.isMigrating.set(true);
  74. await this.startBackgroundMigration(boardId);
  75. this.isMigrating.set(false);
  76. }
  77. // Check if board needs conversion (for old structure)
  78. if (boardConverter.isBoardConverted(boardId)) {
  79. console.log(`Board ${boardId} has already been converted, skipping conversion`);
  80. this.isBoardReady.set(true);
  81. } else {
  82. const needsConversion = boardConverter.needsConversion(boardId);
  83. if (needsConversion) {
  84. this.isConverting.set(true);
  85. const success = await boardConverter.convertBoard(boardId);
  86. this.isConverting.set(false);
  87. if (success) {
  88. this.isBoardReady.set(true);
  89. } else {
  90. console.error('Board conversion failed, setting ready to true anyway');
  91. this.isBoardReady.set(true); // Still show board even if conversion failed
  92. }
  93. } else {
  94. this.isBoardReady.set(true);
  95. }
  96. }
  97. // Start attachment migration in background if needed
  98. this.startAttachmentMigrationIfNeeded(boardId);
  99. } catch (error) {
  100. console.error('Error during board conversion check:', error);
  101. this.isConverting.set(false);
  102. this.isMigrating.set(false);
  103. this.isBoardReady.set(true); // Show board even if conversion check failed
  104. }
  105. },
  106. async startBackgroundMigration(boardId) {
  107. try {
  108. // Start background migration using the cron system
  109. Meteor.call('boardMigration.startBoardMigration', boardId, (error, result) => {
  110. if (error) {
  111. console.error('Failed to start background migration:', error);
  112. } else {
  113. console.log('Background migration started for board:', boardId);
  114. }
  115. });
  116. } catch (error) {
  117. console.error('Error starting background migration:', error);
  118. }
  119. },
  120. async startAttachmentMigrationIfNeeded(boardId) {
  121. try {
  122. // Check if board has already been migrated
  123. if (attachmentMigrationManager.isBoardMigrated(boardId)) {
  124. console.log(`Board ${boardId} has already been migrated, skipping`);
  125. return;
  126. }
  127. // Check if there are unconverted attachments
  128. const unconvertedAttachments = attachmentMigrationManager.getUnconvertedAttachments(boardId);
  129. if (unconvertedAttachments.length > 0) {
  130. console.log(`Starting attachment migration for ${unconvertedAttachments.length} attachments in board ${boardId}`);
  131. await attachmentMigrationManager.startAttachmentMigration(boardId);
  132. } else {
  133. // No attachments to migrate, mark board as migrated
  134. // This will be handled by the migration manager itself
  135. console.log(`Board ${boardId} has no attachments to migrate`);
  136. }
  137. } catch (error) {
  138. console.error('Error starting attachment migration:', error);
  139. }
  140. },
  141. onlyShowCurrentCard() {
  142. const isMiniScreen = Utils.isMiniScreen();
  143. const currentCardId = Utils.getCurrentCardId(true);
  144. return isMiniScreen && currentCardId;
  145. },
  146. goHome() {
  147. FlowRouter.go('home');
  148. },
  149. isConverting() {
  150. return this.isConverting.get();
  151. },
  152. isMigrating() {
  153. return this.isMigrating.get();
  154. },
  155. isBoardReady() {
  156. return this.isBoardReady.get();
  157. },
  158. currentBoard() {
  159. return Utils.getCurrentBoard();
  160. },
  161. }).register('board');
  162. BlazeComponent.extendComponent({
  163. onCreated() {
  164. Meteor.subscribe('tableVisibilityModeSettings');
  165. this.showOverlay = new ReactiveVar(false);
  166. this.draggingActive = new ReactiveVar(false);
  167. this._isDragging = false;
  168. // Used to set the overlay
  169. this.mouseHasEnterCardDetails = false;
  170. // fix swimlanes sort field if there are null values
  171. const currentBoardData = Utils.getCurrentBoard();
  172. if (currentBoardData && Swimlanes) {
  173. const nullSortSwimlanes = currentBoardData.nullSortSwimlanes();
  174. if (nullSortSwimlanes.length > 0) {
  175. const swimlanes = currentBoardData.swimlanes();
  176. let count = 0;
  177. swimlanes.forEach(s => {
  178. Swimlanes.update(s._id, {
  179. $set: {
  180. sort: count,
  181. },
  182. });
  183. count += 1;
  184. });
  185. }
  186. }
  187. // fix lists sort field if there are null values
  188. if (currentBoardData && Lists) {
  189. const nullSortLists = currentBoardData.nullSortLists();
  190. if (nullSortLists.length > 0) {
  191. const lists = currentBoardData.lists();
  192. let count = 0;
  193. lists.forEach(l => {
  194. Lists.update(l._id, {
  195. $set: {
  196. sort: count,
  197. },
  198. });
  199. count += 1;
  200. });
  201. }
  202. }
  203. },
  204. onRendered() {
  205. // Initialize user settings (zoom and mobile mode)
  206. Utils.initializeUserSettings();
  207. // Detect iPhone devices and add class for better CSS targeting
  208. const isIPhone = /iPhone|iPod/.test(navigator.userAgent);
  209. if (isIPhone) {
  210. document.body.classList.add('iphone-device');
  211. }
  212. // Accessibility: Focus management for popups and menus
  213. function focusFirstInteractive(container) {
  214. if (!container) return;
  215. // Find first focusable element
  216. const focusable = container.querySelectorAll('button, [role="button"], a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
  217. for (let i = 0; i < focusable.length; i++) {
  218. if (!focusable[i].disabled && focusable[i].offsetParent !== null) {
  219. focusable[i].focus();
  220. break;
  221. }
  222. }
  223. }
  224. // Observe for new popups/menus and set focus (but exclude swimlane content)
  225. const popupObserver = new MutationObserver(function(mutations) {
  226. mutations.forEach(function(mutation) {
  227. mutation.addedNodes.forEach(function(node) {
  228. if (node.nodeType === 1 &&
  229. (node.classList.contains('popup') || node.classList.contains('modal') || node.classList.contains('menu')) &&
  230. !node.closest('.js-swimlanes') &&
  231. !node.closest('.swimlane') &&
  232. !node.closest('.list') &&
  233. !node.closest('.minicard')) {
  234. setTimeout(function() { focusFirstInteractive(node); }, 10);
  235. }
  236. });
  237. });
  238. });
  239. popupObserver.observe(document.body, { childList: true, subtree: true });
  240. // Remove tabindex from non-interactive elements (e.g., user abbreviations, labels)
  241. document.querySelectorAll('.user-abbreviation, .user-label, .card-header-label, .edit-label, .private-label').forEach(function(el) {
  242. if (el.hasAttribute('tabindex')) {
  243. el.removeAttribute('tabindex');
  244. }
  245. });
  246. /*
  247. // Add a toggle button for keyboard shortcuts accessibility
  248. if (!document.getElementById('wekan-shortcuts-toggle')) {
  249. const toggleContainer = document.createElement('div');
  250. toggleContainer.id = 'wekan-shortcuts-toggle';
  251. toggleContainer.style.position = 'fixed';
  252. toggleContainer.style.top = '10px';
  253. toggleContainer.style.right = '10px';
  254. toggleContainer.style.zIndex = '1000';
  255. toggleContainer.style.background = '#fff';
  256. toggleContainer.style.border = '2px solid #005fcc';
  257. toggleContainer.style.borderRadius = '6px';
  258. toggleContainer.style.padding = '8px 12px';
  259. toggleContainer.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
  260. toggleContainer.style.fontSize = '16px';
  261. toggleContainer.style.color = '#005fcc';
  262. toggleContainer.setAttribute('role', 'region');
  263. toggleContainer.setAttribute('aria-label', 'Keyboard Shortcuts Settings');
  264. toggleContainer.innerHTML = `
  265. <label for="shortcuts-toggle-checkbox" style="cursor:pointer;">
  266. <input type="checkbox" id="shortcuts-toggle-checkbox" ${window.wekanShortcutsEnabled ? 'checked' : ''} style="margin-right:8px;" />
  267. Enable keyboard shortcuts
  268. </label>
  269. `;
  270. document.body.appendChild(toggleContainer);
  271. const checkbox = document.getElementById('shortcuts-toggle-checkbox');
  272. checkbox.addEventListener('change', function(e) {
  273. window.toggleWekanShortcuts(e.target.checked);
  274. });
  275. }
  276. */
  277. // Ensure toggle-buttons, color choices, reactions, renaming, and calendar controls are focusable and have ARIA roles
  278. document.querySelectorAll('.js-toggle').forEach(function(el) {
  279. el.setAttribute('tabindex', '0');
  280. el.setAttribute('role', 'button');
  281. // Short, descriptive label for favorite/star toggle
  282. if (el.classList.contains('js-favorite-toggle')) {
  283. el.setAttribute('aria-label', TAPi18n.__('favorite-toggle-label'));
  284. } else {
  285. el.setAttribute('aria-label', 'Toggle');
  286. }
  287. });
  288. document.querySelectorAll('.js-color-choice').forEach(function(el) {
  289. el.setAttribute('tabindex', '0');
  290. el.setAttribute('role', 'button');
  291. el.setAttribute('aria-label', 'Choose color');
  292. });
  293. document.querySelectorAll('.js-reaction').forEach(function(el) {
  294. el.setAttribute('tabindex', '0');
  295. el.setAttribute('role', 'button');
  296. el.setAttribute('aria-label', 'React');
  297. });
  298. document.querySelectorAll('.js-rename-swimlane').forEach(function(el) {
  299. el.setAttribute('tabindex', '0');
  300. el.setAttribute('role', 'button');
  301. el.setAttribute('aria-label', 'Rename swimlane');
  302. });
  303. document.querySelectorAll('.js-rename-list').forEach(function(el) {
  304. el.setAttribute('tabindex', '0');
  305. el.setAttribute('role', 'button');
  306. el.setAttribute('aria-label', 'Rename list');
  307. });
  308. document.querySelectorAll('.fc-button').forEach(function(el) {
  309. el.setAttribute('tabindex', '0');
  310. el.setAttribute('role', 'button');
  311. });
  312. // Set the language attribute on the <html> element for accessibility
  313. document.documentElement.lang = TAPi18n.getLanguage();
  314. // Ensure the accessible name for the board view switcher matches the visible label "Swimlanes"
  315. // This fixes WCAG 2.5.3: Label in Name
  316. const swimlanesSwitcher = this.$('.js-board-view-swimlanes');
  317. if (swimlanesSwitcher.length) {
  318. swimlanesSwitcher.attr('aria-label', swimlanesSwitcher.text().trim() || 'Swimlanes');
  319. }
  320. // Add a highly visible focus indicator and improve contrast for interactive elements
  321. if (!document.getElementById('wekan-accessible-focus-style')) {
  322. const style = document.createElement('style');
  323. style.id = 'wekan-accessible-focus-style';
  324. style.innerHTML = `
  325. /* Focus indicator */
  326. button:focus, [role="button"]:focus, a:focus, input:focus, select:focus, textarea:focus, .dropdown-menu:focus, .js-board-view-swimlanes:focus, .js-add-card:focus {
  327. outline: 3px solid #005fcc !important;
  328. outline-offset: 2px !important;
  329. }
  330. /* Input borders */
  331. input, textarea, select {
  332. border: 2px solid #222 !important;
  333. }
  334. /* Plus icon for adding a new card */
  335. .js-add-card {
  336. color: #005fcc !important; /* dark blue for contrast */
  337. cursor: pointer;
  338. outline: none;
  339. }
  340. .js-add-card[tabindex] {
  341. outline: none;
  342. }
  343. /* Hamburger menu */
  344. .fa-bars, .icon-hamburger {
  345. color: #222 !important;
  346. }
  347. /* Grey icons in card detail header */
  348. .card-detail-header .fa, .card-detail-header .icon {
  349. color: #444 !important;
  350. }
  351. /* Grey operating elements in card detail */
  352. .card-detail .fa, .card-detail .icon {
  353. color: #444 !important;
  354. }
  355. /* Blue bar in checklists */
  356. .checklist-progress-bar {
  357. background-color: #005fcc !important;
  358. }
  359. /* Green checkmark in checklists */
  360. .checklist .fa-check {
  361. color: #007a33 !important;
  362. }
  363. /* X-Button and arrow button in menus */
  364. .close, .fa-arrow-left, .icon-arrow-left {
  365. color: #005fcc !important;
  366. }
  367. /* Cross icon to move boards */
  368. .js-move-board {
  369. color: #005fcc !important;
  370. }
  371. /* Current date background */
  372. .current-date {
  373. background-color: #005fcc !important;
  374. color: #fff !important;
  375. }
  376. `;
  377. document.head.appendChild(style);
  378. }
  379. // Ensure plus/add elements are focusable and have ARIA roles
  380. document.querySelectorAll('.js-add-card').forEach(function(el) {
  381. el.setAttribute('tabindex', '0');
  382. el.setAttribute('role', 'button');
  383. el.setAttribute('aria-label', 'Add new card');
  384. });
  385. const boardComponent = this;
  386. const $swimlanesDom = boardComponent.$('.js-swimlanes');
  387. $swimlanesDom.sortable({
  388. tolerance: 'pointer',
  389. appendTo: '.board-canvas',
  390. helper(evt, item) {
  391. const helper = $(`<div class="swimlane"
  392. style="flex-direction: column;
  393. height: ${swimlaneWhileSortingHeight}px;
  394. width: $(boardComponent.width)px;
  395. overflow: hidden;"/>`);
  396. helper.append(item.clone());
  397. // Also grab the list of lists of cards
  398. const list = item.next();
  399. helper.append(list.clone());
  400. return helper;
  401. },
  402. items: '.swimlane:not(.placeholder)',
  403. placeholder: 'swimlane placeholder',
  404. distance: 7,
  405. start(evt, ui) {
  406. const listDom = ui.placeholder.next('.js-swimlane');
  407. const parentOffset = ui.item.parent().offset();
  408. ui.placeholder.height(ui.helper.height());
  409. EscapeActions.executeUpTo('popup-close');
  410. listDom.addClass('moving-swimlane');
  411. boardComponent.setIsDragging(true);
  412. ui.placeholder.insertAfter(ui.placeholder.next());
  413. boardComponent.origPlaceholderIndex = ui.placeholder.index();
  414. // resize all swimlanes + headers to be a total of 150 px per row
  415. // this could be achieved by setIsDragging(true) but we want immediate
  416. // result
  417. ui.item
  418. .siblings('.js-swimlane')
  419. .css('height', `${swimlaneWhileSortingHeight - 26}px`);
  420. // set the new scroll height after the resize and insertion of
  421. // the placeholder. We want the element under the cursor to stay
  422. // at the same place on the screen
  423. ui.item.parent().get(0).scrollTop =
  424. ui.placeholder.get(0).offsetTop + parentOffset.top - evt.pageY;
  425. },
  426. beforeStop(evt, ui) {
  427. const parentOffset = ui.item.parent().offset();
  428. const siblings = ui.item.siblings('.js-swimlane');
  429. siblings.css('height', '');
  430. // compute the new scroll height after the resize and removal of
  431. // the placeholder
  432. const scrollTop =
  433. ui.placeholder.get(0).offsetTop + parentOffset.top - evt.pageY;
  434. // then reset the original view of the swimlane
  435. siblings.removeClass('moving-swimlane');
  436. // and apply the computed scrollheight
  437. ui.item.parent().get(0).scrollTop = scrollTop;
  438. },
  439. stop(evt, ui) {
  440. // To attribute the new index number, we need to get the DOM element
  441. // of the previous and the following card -- if any.
  442. const prevSwimlaneDom = ui.item.prevAll('.js-swimlane').get(0);
  443. const nextSwimlaneDom = ui.item.nextAll('.js-swimlane').get(0);
  444. const sortIndex = calculateIndex(prevSwimlaneDom, nextSwimlaneDom, 1);
  445. $swimlanesDom.sortable('cancel');
  446. const swimlaneDomElement = ui.item.get(0);
  447. const swimlane = Blaze.getData(swimlaneDomElement);
  448. Swimlanes.update(swimlane._id, {
  449. $set: {
  450. sort: sortIndex.base,
  451. },
  452. });
  453. boardComponent.setIsDragging(false);
  454. },
  455. sort(evt, ui) {
  456. // get the mouse position in the sortable
  457. const parentOffset = ui.item.parent().offset();
  458. const cursorY =
  459. evt.pageY - parentOffset.top + ui.item.parent().scrollTop();
  460. // compute the intended index of the placeholder (we need to skip the
  461. // slots between the headers and the list of cards)
  462. const newplaceholderIndex = Math.floor(
  463. cursorY / swimlaneWhileSortingHeight,
  464. );
  465. let destPlaceholderIndex = (newplaceholderIndex + 1) * 2;
  466. // if we are scrolling far away from the bottom of the list
  467. if (destPlaceholderIndex >= ui.item.parent().get(0).childElementCount) {
  468. destPlaceholderIndex = ui.item.parent().get(0).childElementCount - 1;
  469. }
  470. // update the placeholder position in the DOM tree
  471. if (destPlaceholderIndex !== ui.placeholder.index()) {
  472. if (destPlaceholderIndex < boardComponent.origPlaceholderIndex) {
  473. ui.placeholder.insertBefore(
  474. ui.placeholder
  475. .siblings()
  476. .slice(destPlaceholderIndex - 2, destPlaceholderIndex - 1),
  477. );
  478. } else {
  479. ui.placeholder.insertAfter(
  480. ui.placeholder
  481. .siblings()
  482. .slice(destPlaceholderIndex - 1, destPlaceholderIndex),
  483. );
  484. }
  485. }
  486. },
  487. });
  488. this.autorun(() => {
  489. // Always reset dragscroll on view switch
  490. dragscroll.reset();
  491. if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
  492. $swimlanesDom.sortable({
  493. handle: '.js-swimlane-header-handle',
  494. });
  495. } else {
  496. $swimlanesDom.sortable({
  497. handle: '.swimlane-header',
  498. });
  499. }
  500. // Disable drag-dropping if the current user is not a board member
  501. $swimlanesDom.sortable(
  502. 'option',
  503. 'disabled',
  504. !ReactiveCache.getCurrentUser()?.isBoardAdmin(),
  505. );
  506. });
  507. // If there is no data in the board (ie, no lists) we autofocus the list
  508. // creation form by clicking on the corresponding element.
  509. const currentBoard = Utils.getCurrentBoard();
  510. if (Utils.canModifyBoard() && currentBoard.lists().length === 0) {
  511. boardComponent.openNewListForm();
  512. }
  513. dragscroll.reset();
  514. Utils.setBackgroundImage();
  515. },
  516. notDisplayThisBoard() {
  517. let allowPrivateVisibilityOnly = TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly');
  518. let currentBoard = Utils.getCurrentBoard();
  519. return allowPrivateVisibilityOnly !== undefined && allowPrivateVisibilityOnly.booleanValue && currentBoard && currentBoard.permission == 'public';
  520. },
  521. isViewSwimlanes() {
  522. const currentUser = ReactiveCache.getCurrentUser();
  523. let boardView;
  524. if (currentUser) {
  525. boardView = (currentUser.profile || {}).boardView;
  526. } else {
  527. boardView = window.localStorage.getItem('boardView');
  528. }
  529. // If no board view is set, default to swimlanes
  530. if (!boardView) {
  531. boardView = 'board-view-swimlanes';
  532. }
  533. return boardView === 'board-view-swimlanes';
  534. },
  535. isViewLists() {
  536. const currentUser = ReactiveCache.getCurrentUser();
  537. let boardView;
  538. if (currentUser) {
  539. boardView = (currentUser.profile || {}).boardView;
  540. } else {
  541. boardView = window.localStorage.getItem('boardView');
  542. }
  543. return boardView === 'board-view-lists';
  544. },
  545. isViewCalendar() {
  546. const currentUser = ReactiveCache.getCurrentUser();
  547. let boardView;
  548. if (currentUser) {
  549. boardView = (currentUser.profile || {}).boardView;
  550. } else {
  551. boardView = window.localStorage.getItem('boardView');
  552. }
  553. return boardView === 'board-view-cal';
  554. },
  555. hasSwimlanes() {
  556. const currentBoard = Utils.getCurrentBoard();
  557. if (!currentBoard) {
  558. console.log('hasSwimlanes: No current board');
  559. return false;
  560. }
  561. try {
  562. const swimlanes = currentBoard.swimlanes();
  563. const hasSwimlanes = swimlanes && swimlanes.length > 0;
  564. console.log('hasSwimlanes: Board has', swimlanes ? swimlanes.length : 0, 'swimlanes');
  565. return hasSwimlanes;
  566. } catch (error) {
  567. console.error('hasSwimlanes: Error getting swimlanes:', error);
  568. return false;
  569. }
  570. },
  571. isVerticalScrollbars() {
  572. const user = ReactiveCache.getCurrentUser();
  573. return user && user.isVerticalScrollbars();
  574. },
  575. boardView() {
  576. return Utils.boardView();
  577. },
  578. debugBoardState() {
  579. // Enable debug mode by setting ?debug=1 in URL
  580. const urlParams = new URLSearchParams(window.location.search);
  581. return urlParams.get('debug') === '1';
  582. },
  583. debugBoardStateData() {
  584. const currentBoard = Utils.getCurrentBoard();
  585. const currentBoardId = Session.get('currentBoard');
  586. const isBoardReady = this.isBoardReady.get();
  587. const isConverting = this.isConverting.get();
  588. const isMigrating = this.isMigrating.get();
  589. const boardView = Utils.boardView();
  590. console.log('=== BOARD DEBUG STATE ===');
  591. console.log('currentBoardId:', currentBoardId);
  592. console.log('currentBoard:', !!currentBoard, currentBoard ? currentBoard.title : 'none');
  593. console.log('isBoardReady:', isBoardReady);
  594. console.log('isConverting:', isConverting);
  595. console.log('isMigrating:', isMigrating);
  596. console.log('boardView:', boardView);
  597. console.log('========================');
  598. return {
  599. currentBoardId,
  600. hasCurrentBoard: !!currentBoard,
  601. currentBoardTitle: currentBoard ? currentBoard.title : 'none',
  602. isBoardReady,
  603. isConverting,
  604. isMigrating,
  605. boardView
  606. };
  607. },
  608. openNewListForm() {
  609. if (this.isViewSwimlanes()) {
  610. // The form had been removed in 416b17062e57f215206e93a85b02ef9eb1ab4902
  611. // this.childComponents('swimlane')[0]
  612. // .childComponents('addListAndSwimlaneForm')[0]
  613. // .open();
  614. } else if (this.isViewLists()) {
  615. this.childComponents('listsGroup')[0]
  616. .childComponents('addListForm')[0]
  617. .open();
  618. }
  619. },
  620. events() {
  621. return [
  622. {
  623. // XXX The board-overlay div should probably be moved to the parent
  624. // component.
  625. mouseup() {
  626. if (this._isDragging) {
  627. this._isDragging = false;
  628. }
  629. },
  630. 'click .js-empty-board-add-swimlane': Popup.open('swimlaneAdd'),
  631. },
  632. ];
  633. },
  634. // XXX Flow components allow us to avoid creating these two setter methods by
  635. // exposing a public API to modify the component state. We need to investigate
  636. // best practices here.
  637. setIsDragging(bool) {
  638. this.draggingActive.set(bool);
  639. },
  640. scrollLeft(position = 0) {
  641. const swimlanes = this.$('.js-swimlanes');
  642. swimlanes &&
  643. swimlanes.animate({
  644. scrollLeft: position,
  645. });
  646. },
  647. scrollTop(position = 0) {
  648. const swimlanes = this.$('.js-swimlanes');
  649. swimlanes &&
  650. swimlanes.animate({
  651. scrollTop: position,
  652. });
  653. },
  654. }).register('boardBody');
  655. // Accessibility: Allow users to enable/disable keyboard shortcuts
  656. window.wekanShortcutsEnabled = true;
  657. window.toggleWekanShortcuts = function(enabled) {
  658. window.wekanShortcutsEnabled = !!enabled;
  659. };
  660. // Example: Wrap your character key shortcut handler like this
  661. document.addEventListener('keydown', function(e) {
  662. // Example: "W" key shortcut (replace with your actual shortcut logic)
  663. if (!window.wekanShortcutsEnabled) return;
  664. if (e.key === 'w' || e.key === 'W') {
  665. // ...existing shortcut logic...
  666. // e.g. open swimlanes view, etc.
  667. }
  668. });
  669. // Keyboard accessibility for card actions (favorite, archive, duplicate, etc.)
  670. document.addEventListener('keydown', function(e) {
  671. if (!window.wekanShortcutsEnabled) return;
  672. // Only proceed if focus is on a card action element
  673. const active = document.activeElement;
  674. if (active && active.classList.contains('js-card-action')) {
  675. if (e.key === 'Enter' || e.key === ' ') {
  676. e.preventDefault();
  677. active.click();
  678. }
  679. // Move card up/down with arrow keys
  680. if (e.key === 'ArrowUp') {
  681. e.preventDefault();
  682. if (active.dataset.cardId) {
  683. Meteor.call('moveCardUp', active.dataset.cardId);
  684. }
  685. }
  686. if (e.key === 'ArrowDown') {
  687. e.preventDefault();
  688. if (active.dataset.cardId) {
  689. Meteor.call('moveCardDown', active.dataset.cardId);
  690. }
  691. }
  692. }
  693. // Make plus/add elements keyboard accessible
  694. if (active && active.classList.contains('js-add-card')) {
  695. if (e.key === 'Enter' || e.key === ' ') {
  696. e.preventDefault();
  697. active.click();
  698. }
  699. }
  700. // Keyboard move for cards (alternative to drag & drop)
  701. if (active && active.classList.contains('js-move-card')) {
  702. if (e.key === 'ArrowUp') {
  703. e.preventDefault();
  704. if (active.dataset.cardId) {
  705. Meteor.call('moveCardUp', active.dataset.cardId);
  706. }
  707. }
  708. if (e.key === 'ArrowDown') {
  709. e.preventDefault();
  710. if (active.dataset.cardId) {
  711. Meteor.call('moveCardDown', active.dataset.cardId);
  712. }
  713. }
  714. }
  715. // Ensure move card buttons are focusable and have ARIA roles
  716. document.querySelectorAll('.js-move-card').forEach(function(el) {
  717. el.setAttribute('tabindex', '0');
  718. el.setAttribute('role', 'button');
  719. el.setAttribute('aria-label', 'Move card');
  720. });
  721. // Make toggle-buttons, color choices, reactions, and X-buttons keyboard accessible
  722. if (active && (active.classList.contains('js-toggle') || active.classList.contains('js-color-choice') || active.classList.contains('js-reaction') || active.classList.contains('close'))) {
  723. if (e.key === 'Enter' || e.key === ' ') {
  724. e.preventDefault();
  725. active.click();
  726. }
  727. }
  728. // Prevent scripts from removing focus when received
  729. if (active) {
  730. active.addEventListener('focus', function(e) {
  731. // Do not remove focus
  732. // No-op: This prevents F55 failure
  733. }, { once: true });
  734. }
  735. // Make swimlane/list renaming keyboard accessible
  736. if (active && (active.classList.contains('js-rename-swimlane') || active.classList.contains('js-rename-list'))) {
  737. if (e.key === 'Enter') {
  738. e.preventDefault();
  739. active.click();
  740. }
  741. }
  742. // Calendar navigation buttons
  743. if (active && active.classList.contains('fc-button')) {
  744. if (e.key === 'Enter' || e.key === ' ') {
  745. e.preventDefault();
  746. active.click();
  747. }
  748. }
  749. });
  750. BlazeComponent.extendComponent({
  751. onRendered() {
  752. // Set the language attribute on the <html> element for accessibility
  753. document.documentElement.lang = TAPi18n.getLanguage();
  754. this.autorun(function () {
  755. $('#calendar-view').fullCalendar('refetchEvents');
  756. });
  757. },
  758. calendarOptions() {
  759. return {
  760. id: 'calendar-view',
  761. defaultView: 'month',
  762. editable: true,
  763. selectable: true,
  764. timezone: 'local',
  765. weekNumbers: true,
  766. header: {
  767. left: 'title today prev,next',
  768. center:
  769. 'agendaDay,listDay,timelineDay agendaWeek,listWeek,timelineWeek month,listMonth',
  770. right: '',
  771. },
  772. buttonText: {
  773. prev: TAPi18n.__('calendar-previous-month-label'), // e.g. "Previous month"
  774. next: TAPi18n.__('calendar-next-month-label'), // e.g. "Next month"
  775. },
  776. ariaLabel: {
  777. prev: TAPi18n.__('calendar-previous-month-label'),
  778. next: TAPi18n.__('calendar-next-month-label'),
  779. },
  780. // height: 'parent', nope, doesn't work as the parent might be small
  781. height: 'auto',
  782. /* TODO: lists as resources: https://fullcalendar.io/docs/vertical-resource-view */
  783. navLinks: true,
  784. nowIndicator: true,
  785. businessHours: {
  786. // days of week. an array of zero-based day of week integers (0=Sunday)
  787. dow: [1, 2, 3, 4, 5], // Monday - Friday
  788. start: '8:00',
  789. end: '18:00',
  790. },
  791. locale: TAPi18n.getLanguage(),
  792. events(start, end, timezone, callback) {
  793. const currentBoard = Utils.getCurrentBoard();
  794. const events = [];
  795. const pushEvent = function (card, title, start, end, extraCls) {
  796. start = start || card.startAt;
  797. end = end || card.endAt;
  798. title = title || card.title;
  799. const className =
  800. (extraCls ? `${extraCls} ` : '') +
  801. (card.color ? `calendar-event-${card.color}` : '');
  802. events.push({
  803. id: card._id,
  804. title,
  805. start,
  806. end: end || card.endAt,
  807. allDay:
  808. Math.abs(end.getTime() - start.getTime()) / 1000 === 24 * 3600,
  809. url: FlowRouter.path('card', {
  810. boardId: currentBoard._id,
  811. slug: currentBoard.slug,
  812. cardId: card._id,
  813. }),
  814. className,
  815. });
  816. };
  817. currentBoard
  818. .cardsInInterval(start.toDate(), end.toDate())
  819. .forEach(function (card) {
  820. pushEvent(card);
  821. });
  822. currentBoard
  823. .cardsDueInBetween(start.toDate(), end.toDate())
  824. .forEach(function (card) {
  825. pushEvent(
  826. card,
  827. `${card.title} ${TAPi18n.__('card-due')}`,
  828. card.dueAt,
  829. new Date(card.dueAt.getTime() + 36e5),
  830. );
  831. });
  832. events.sort(function (first, second) {
  833. return first.id > second.id ? 1 : -1;
  834. });
  835. callback(events);
  836. },
  837. eventResize(event, delta, revertFunc) {
  838. let isOk = false;
  839. const card = ReactiveCache.getCard(event.id);
  840. if (card) {
  841. card.setEnd(event.end.toDate());
  842. isOk = true;
  843. }
  844. if (!isOk) {
  845. revertFunc();
  846. }
  847. },
  848. eventDrop(event, delta, revertFunc) {
  849. let isOk = false;
  850. const card = ReactiveCache.getCard(event.id);
  851. if (card) {
  852. // TODO: add a flag for allDay events
  853. if (!event.allDay) {
  854. // https://github.com/wekan/wekan/issues/2917#issuecomment-1236753962
  855. //card.setStart(event.start.toDate());
  856. //card.setEnd(event.end.toDate());
  857. card.setDue(event.start.toDate());
  858. isOk = true;
  859. }
  860. }
  861. if (!isOk) {
  862. revertFunc();
  863. }
  864. },
  865. select: function (startDate) {
  866. const currentBoard = Utils.getCurrentBoard();
  867. const currentUser = ReactiveCache.getCurrentUser();
  868. const modalElement = document.createElement('div');
  869. modalElement.classList.add('modal', 'fade');
  870. modalElement.setAttribute('tabindex', '-1');
  871. modalElement.setAttribute('role', 'dialog');
  872. modalElement.innerHTML = `
  873. <div class="modal-dialog justify-content-center align-items-center" role="document">
  874. <div class="modal-content">
  875. <div class="modal-header">
  876. <h5 class="modal-title">${TAPi18n.__('r-create-card')}</h5>
  877. <button type="button" class="close" data-dismiss="modal" aria-label="Close">
  878. <span aria-hidden="true">&times;</span>
  879. </button>
  880. </div>
  881. <div class="modal-body text-center">
  882. <input type="text" class="form-control" id="card-title-input" placeholder="">
  883. </div>
  884. <div class="modal-footer">
  885. <button type="button" class="btn btn-primary" id="create-card-button">${TAPi18n.__('add-card')}</button>
  886. </div>
  887. </div>
  888. </div>
  889. `;
  890. const createCardButton = modalElement.querySelector('#create-card-button');
  891. createCardButton.addEventListener('click', function () {
  892. const myTitle = modalElement.querySelector('#card-title-input').value;
  893. if (myTitle) {
  894. const firstList = currentBoard.draggableLists()[0];
  895. const firstSwimlane = currentBoard.swimlanes()[0];
  896. Meteor.call('createCardWithDueDate', currentBoard._id, firstList._id, myTitle, startDate.toDate(), firstSwimlane._id, function(error, result) {
  897. if (error) {
  898. console.log(error);
  899. } else {
  900. console.log("Card Created", result);
  901. }
  902. });
  903. closeModal();
  904. }
  905. });
  906. document.body.appendChild(modalElement);
  907. const openModal = function() {
  908. modalElement.style.display = 'flex';
  909. // Set focus to the input field for better keyboard accessibility
  910. const input = modalElement.querySelector('#card-title-input');
  911. if (input) input.focus();
  912. };
  913. const closeModal = function() {
  914. modalElement.style.display = 'none';
  915. };
  916. const closeButton = modalElement.querySelector('[data-dismiss="modal"]');
  917. closeButton.addEventListener('click', closeModal);
  918. openModal();
  919. }
  920. };
  921. },
  922. isViewCalendar() {
  923. const currentUser = ReactiveCache.getCurrentUser();
  924. if (currentUser) {
  925. return (currentUser.profile || {}).boardView === 'board-view-cal';
  926. } else {
  927. return window.localStorage.getItem('boardView') === 'board-view-cal';
  928. }
  929. },
  930. }).register('calendarView');