boardBody.js 43 KB

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