boardBody.js 45 KB

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