boardsList.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842
  1. import { ReactiveCache } from '/imports/reactiveCache';
  2. import { TAPi18n } from '/imports/i18n';
  3. const subManager = new SubsManager();
  4. Template.boardList.helpers({
  5. hideCardCounterList() {
  6. /* Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
  7. return Utils.isMiniScreen() && Session.get('currentBoard'); */
  8. return true;
  9. },
  10. hideBoardMemberList() {
  11. /* Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
  12. return Utils.isMiniScreen() && Session.get('currentBoard'); */
  13. return true;
  14. },
  15. BoardMultiSelection() {
  16. return BoardMultiSelection;
  17. },
  18. })
  19. Template.boardListHeaderBar.events({
  20. 'click .js-open-archived-board'() {
  21. Modal.open('archivedBoards');
  22. },
  23. });
  24. Template.boardList.events({
  25. });
  26. Template.boardListHeaderBar.helpers({
  27. title() {
  28. //if (FlowRouter.getRouteName() === 'template-container') {
  29. // return 'template-container';
  30. //} else {
  31. return FlowRouter.getRouteName() === 'home' ? 'my-boards' : 'public';
  32. //}
  33. },
  34. templatesBoardId() {
  35. return ReactiveCache.getCurrentUser()?.getTemplatesBoardId();
  36. },
  37. templatesBoardSlug() {
  38. return ReactiveCache.getCurrentUser()?.getTemplatesBoardSlug();
  39. },
  40. });
  41. BlazeComponent.extendComponent({
  42. onCreated() {
  43. Meteor.subscribe('setting');
  44. Meteor.subscribe('tableVisibilityModeSettings');
  45. this.selectedMenu = new ReactiveVar('starred');
  46. this.selectedWorkspaceIdVar = new ReactiveVar(null);
  47. this.workspacesTreeVar = new ReactiveVar([]);
  48. let currUser = ReactiveCache.getCurrentUser();
  49. let userLanguage;
  50. if (currUser && currUser.profile) {
  51. userLanguage = currUser.profile.language
  52. }
  53. if (userLanguage) {
  54. TAPi18n.setLanguage(userLanguage);
  55. }
  56. // Load workspaces tree reactively
  57. this.autorun(() => {
  58. const u = ReactiveCache.getCurrentUser();
  59. const tree = (u && u.profile && u.profile.boardWorkspacesTree) || [];
  60. this.workspacesTreeVar.set(tree);
  61. });
  62. },
  63. reorderWorkspaces(draggedSpaceId, targetSpaceId) {
  64. const tree = this.workspacesTreeVar.get();
  65. // Helper to remove a space from tree
  66. const removeSpace = (nodes, id) => {
  67. for (let i = 0; i < nodes.length; i++) {
  68. if (nodes[i].id === id) {
  69. const removed = nodes.splice(i, 1)[0];
  70. return { tree: nodes, removed };
  71. }
  72. if (nodes[i].children) {
  73. const result = removeSpace(nodes[i].children, id);
  74. if (result.removed) {
  75. return { tree: nodes, removed: result.removed };
  76. }
  77. }
  78. }
  79. return { tree: nodes, removed: null };
  80. };
  81. // Helper to insert a space after target
  82. const insertAfter = (nodes, targetId, spaceToInsert) => {
  83. for (let i = 0; i < nodes.length; i++) {
  84. if (nodes[i].id === targetId) {
  85. nodes.splice(i + 1, 0, spaceToInsert);
  86. return true;
  87. }
  88. if (nodes[i].children) {
  89. if (insertAfter(nodes[i].children, targetId, spaceToInsert)) {
  90. return true;
  91. }
  92. }
  93. }
  94. return false;
  95. };
  96. // Clone the tree
  97. const newTree = EJSON.clone(tree);
  98. // Remove the dragged space
  99. const { tree: treeAfterRemoval, removed } = removeSpace(newTree, draggedSpaceId);
  100. if (removed) {
  101. // Insert after target
  102. insertAfter(treeAfterRemoval, targetSpaceId, removed);
  103. // Save the new tree
  104. Meteor.call('setWorkspacesTree', treeAfterRemoval, (err) => {
  105. if (err) console.error(err);
  106. });
  107. }
  108. },
  109. onRendered() {
  110. // jQuery sortable is disabled in favor of HTML5 drag-and-drop for space management
  111. // The old sortable code has been removed to prevent conflicts
  112. /* OLD SORTABLE CODE - DISABLED
  113. const itemsSelector = '.js-board:not(.placeholder)';
  114. const $boards = this.$('.js-boards');
  115. $boards.sortable({
  116. connectWith: '.js-boards',
  117. tolerance: 'pointer',
  118. appendTo: '.board-list',
  119. helper: 'clone',
  120. distance: 7,
  121. items: itemsSelector,
  122. placeholder: 'board-wrapper placeholder',
  123. start(evt, ui) {
  124. ui.helper.css('z-index', 1000);
  125. ui.placeholder.height(ui.helper.height());
  126. EscapeActions.executeUpTo('popup-close');
  127. },
  128. stop(evt, ui) {
  129. const prevBoardDom = ui.item.prev('.js-board').get(0);
  130. const nextBoardDom = ui.item.next('.js-board').get(0);
  131. const sortIndex = Utils.calculateIndex(prevBoardDom, nextBoardDom, 1);
  132. const boardDomElement = ui.item.get(0);
  133. const board = Blaze.getData(boardDomElement);
  134. $boards.sortable('cancel');
  135. const currentUser = ReactiveCache.getCurrentUser();
  136. if (currentUser && typeof currentUser.setBoardSortIndex === 'function') {
  137. currentUser.setBoardSortIndex(board._id, sortIndex.base);
  138. }
  139. },
  140. });
  141. this.autorun(() => {
  142. if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
  143. $boards.sortable({
  144. handle: '.board-handle',
  145. });
  146. }
  147. });
  148. */
  149. },
  150. userHasTeams() {
  151. if (ReactiveCache.getCurrentUser()?.teams?.length > 0)
  152. return true;
  153. else
  154. return false;
  155. },
  156. teamsDatas() {
  157. const teams = ReactiveCache.getCurrentUser()?.teams
  158. if (teams)
  159. return teams.sort((a, b) => a.teamDisplayName.localeCompare(b.teamDisplayName));
  160. else
  161. return [];
  162. },
  163. userHasOrgs() {
  164. if (ReactiveCache.getCurrentUser()?.orgs?.length > 0)
  165. return true;
  166. else
  167. return false;
  168. },
  169. orgsDatas() {
  170. const orgs = ReactiveCache.getCurrentUser()?.orgs;
  171. if (orgs)
  172. return orgs.sort((a, b) => a.orgDisplayName.localeCompare(b.orgDisplayName));
  173. else
  174. return [];
  175. },
  176. userHasOrgsOrTeams() {
  177. const ret = this.userHasOrgs() || this.userHasTeams();
  178. return ret;
  179. },
  180. currentMenuPath() {
  181. const sel = this.selectedMenu.get();
  182. const currentUser = ReactiveCache.getCurrentUser();
  183. // Helper to find space by id in tree
  184. const findSpaceById = (nodes, targetId, path = []) => {
  185. for (const node of nodes) {
  186. if (node.id === targetId) {
  187. return [...path, node];
  188. }
  189. if (node.children && node.children.length > 0) {
  190. const result = findSpaceById(node.children, targetId, [...path, node]);
  191. if (result) return result;
  192. }
  193. }
  194. return null;
  195. };
  196. if (sel === 'starred') {
  197. return { icon: '⭐', text: TAPi18n.__('allboards.starred') };
  198. } else if (sel === 'templates') {
  199. return { icon: '📋', text: TAPi18n.__('allboards.templates') };
  200. } else if (sel === 'remaining') {
  201. return { icon: '📂', text: TAPi18n.__('allboards.remaining') };
  202. } else {
  203. // sel is a workspaceId, build path
  204. const tree = this.workspacesTreeVar.get();
  205. const spacePath = findSpaceById(tree, sel);
  206. if (spacePath && spacePath.length > 0) {
  207. const pathText = spacePath.map(s => s.name).join(' / ');
  208. return { icon: '🗂️', text: `${TAPi18n.__('allboards.workspaces')} / ${pathText}` };
  209. }
  210. return { icon: '🗂️', text: TAPi18n.__('allboards.workspaces') };
  211. }
  212. },
  213. boards() {
  214. let query = {
  215. // { type: 'board' },
  216. // { type: { $in: ['board','template-container'] } },
  217. $and: [
  218. { archived: false },
  219. { type: { $in: ['board', 'template-container'] } },
  220. { $or: [] },
  221. { title: { $not: { $regex: /^\^.*\^$/ } } }
  222. ]
  223. };
  224. let allowPrivateVisibilityOnly = TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly');
  225. if (FlowRouter.getRouteName() === 'home') {
  226. query.$and[2].$or.push({ 'members.userId': Meteor.userId() });
  227. if (allowPrivateVisibilityOnly !== undefined && allowPrivateVisibilityOnly.booleanValue) {
  228. query.$and.push({ 'permission': 'private' });
  229. }
  230. const currUser = ReactiveCache.getCurrentUser();
  231. let orgIdsUserBelongs = currUser?.orgIdsUserBelongs() || '';
  232. if (orgIdsUserBelongs) {
  233. let orgsIds = orgIdsUserBelongs.split(',');
  234. // for(let i = 0; i < orgsIds.length; i++){
  235. // query.$and[2].$or.push({'orgs.orgId': orgsIds[i]});
  236. // }
  237. //query.$and[2].$or.push({'orgs': {$elemMatch : {orgId: orgsIds[0]}}});
  238. query.$and[2].$or.push({ 'orgs.orgId': { $in: orgsIds } });
  239. }
  240. let teamIdsUserBelongs = currUser?.teamIdsUserBelongs() || '';
  241. if (teamIdsUserBelongs) {
  242. let teamsIds = teamIdsUserBelongs.split(',');
  243. // for(let i = 0; i < teamsIds.length; i++){
  244. // query.$or[2].$or.push({'teams.teamId': teamsIds[i]});
  245. // }
  246. //query.$and[2].$or.push({'teams': { $elemMatch : {teamId: teamsIds[0]}}});
  247. query.$and[2].$or.push({ 'teams.teamId': { $in: teamsIds } });
  248. }
  249. }
  250. else if (allowPrivateVisibilityOnly !== undefined && !allowPrivateVisibilityOnly.booleanValue) {
  251. query = {
  252. archived: false,
  253. //type: { $in: ['board','template-container'] },
  254. type: 'board',
  255. permission: 'public',
  256. };
  257. }
  258. const boards = ReactiveCache.getBoards(query, {});
  259. const currentUser = ReactiveCache.getCurrentUser();
  260. let list = boards;
  261. // Apply left menu filtering
  262. const sel = this.selectedMenu.get();
  263. const assignments = (currentUser && currentUser.profile && currentUser.profile.boardWorkspaceAssignments) || {};
  264. if (sel === 'starred') {
  265. list = list.filter(b => currentUser && currentUser.hasStarred(b._id));
  266. } else if (sel === 'templates') {
  267. list = list.filter(b => b.type === 'template-container');
  268. } else if (sel === 'remaining') {
  269. // Show boards not in any workspace AND not templates
  270. // Keep starred boards visible in Remaining too
  271. list = list.filter(b =>
  272. !assignments[b._id] &&
  273. b.type !== 'template-container'
  274. );
  275. } else {
  276. // assume sel is a workspaceId
  277. // Keep starred boards visible in their workspace too
  278. list = list.filter(b => assignments[b._id] === sel);
  279. }
  280. if (currentUser && typeof currentUser.sortBoardsForUser === 'function') {
  281. return currentUser.sortBoardsForUser(list);
  282. }
  283. return list.slice().sort((a, b) => (a.title || '').localeCompare(b.title || ''));
  284. },
  285. boardLists(boardId) {
  286. /* Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
  287. const lists = ReactiveCache.getLists({ 'boardId': boardId, 'archived': false },{sort: ['sort','asc']});
  288. const ret = lists.map(list => {
  289. let cardCount = ReactiveCache.getCards({ 'boardId': boardId, 'listId': list._id }).length;
  290. return `${list.title}: ${cardCount}`;
  291. });
  292. return ret;
  293. */
  294. return [];
  295. },
  296. boardMembers(boardId) {
  297. /* Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
  298. const lists = ReactiveCache.getBoard(boardId)
  299. const boardMembers = lists?.members.map(member => member.userId);
  300. return boardMembers;
  301. */
  302. return [];
  303. },
  304. isStarred() {
  305. const user = ReactiveCache.getCurrentUser();
  306. return user && user.hasStarred(this.currentData()._id);
  307. },
  308. isAdministrable() {
  309. const user = ReactiveCache.getCurrentUser();
  310. return user && user.isBoardAdmin(this.currentData()._id);
  311. },
  312. hasOvertimeCards() {
  313. return this.currentData().hasOvertimeCards();
  314. },
  315. hasSpentTimeCards() {
  316. return this.currentData().hasSpentTimeCards();
  317. },
  318. isInvited() {
  319. const user = ReactiveCache.getCurrentUser();
  320. return user && user.isInvitedTo(this.currentData()._id);
  321. },
  322. events() {
  323. return [
  324. {
  325. 'click .js-select-menu'(evt) {
  326. const type = evt.currentTarget.getAttribute('data-type');
  327. this.selectedWorkspaceIdVar.set(null);
  328. this.selectedMenu.set(type);
  329. },
  330. 'click .js-select-workspace'(evt) {
  331. const id = evt.currentTarget.getAttribute('data-id');
  332. this.selectedWorkspaceIdVar.set(id);
  333. this.selectedMenu.set(id);
  334. },
  335. 'click .js-add-workspace'(evt) {
  336. evt.preventDefault();
  337. const name = prompt(TAPi18n.__('allboards.add-workspace-prompt') || 'New Space name');
  338. if (name && name.trim()) {
  339. Meteor.call('createWorkspace', { parentId: null, name: name.trim() }, (err, res) => {
  340. if (err) console.error(err);
  341. });
  342. }
  343. },
  344. 'click .js-add-board'(evt) {
  345. // Store the currently selected workspace/menu for board creation
  346. const selectedWorkspaceId = this.selectedWorkspaceIdVar.get();
  347. const selectedMenu = this.selectedMenu.get();
  348. if (selectedWorkspaceId) {
  349. Session.set('createBoardInWorkspace', selectedWorkspaceId);
  350. } else {
  351. Session.set('createBoardInWorkspace', null);
  352. }
  353. // Open different popup based on context
  354. if (selectedMenu === 'templates') {
  355. Popup.open('createTemplateContainer')(evt);
  356. } else {
  357. Popup.open('createBoard')(evt);
  358. }
  359. },
  360. 'click .js-star-board'(evt) {
  361. evt.preventDefault();
  362. evt.stopPropagation();
  363. const boardId = this.currentData()._id;
  364. if (boardId) {
  365. Meteor.call('toggleBoardStar', boardId);
  366. }
  367. },
  368. // HTML5 DnD from boards to spaces
  369. 'dragstart .js-board'(evt) {
  370. const boardId = this.currentData()._id;
  371. // Support multi-drag
  372. if (BoardMultiSelection.isActive() && BoardMultiSelection.isSelected(boardId)) {
  373. const selectedIds = BoardMultiSelection.getSelectedBoardIds();
  374. try {
  375. evt.originalEvent.dataTransfer.setData('text/plain', JSON.stringify(selectedIds));
  376. evt.originalEvent.dataTransfer.setData('application/x-board-multi', 'true');
  377. } catch (e) {}
  378. } else {
  379. try { evt.originalEvent.dataTransfer.setData('text/plain', boardId); } catch (e) {}
  380. }
  381. },
  382. 'click .js-clone-board'(evt) {
  383. if (confirm(TAPi18n.__('duplicate-board-confirm'))) {
  384. let title =
  385. getSlug(ReactiveCache.getBoard(this.currentData()._id).title) ||
  386. 'cloned-board';
  387. Meteor.call(
  388. 'copyBoard',
  389. this.currentData()._id,
  390. {
  391. sort: ReactiveCache.getBoards({ archived: false }).length,
  392. type: 'board',
  393. title: ReactiveCache.getBoard(this.currentData()._id).title,
  394. },
  395. (err, res) => {
  396. if (err) {
  397. console.error(err);
  398. } else {
  399. Session.set('fromBoard', null);
  400. subManager.subscribe('board', res, false);
  401. FlowRouter.go('board', {
  402. id: res,
  403. slug: title,
  404. });
  405. }
  406. },
  407. );
  408. evt.preventDefault();
  409. }
  410. },
  411. 'click .js-archive-board'(evt) {
  412. if (confirm(TAPi18n.__('archive-board-confirm'))) {
  413. const boardId = this.currentData()._id;
  414. Meteor.call('archiveBoard', boardId);
  415. evt.preventDefault();
  416. }
  417. },
  418. 'click .js-accept-invite'() {
  419. const boardId = this.currentData()._id;
  420. Meteor.call('acceptInvite', boardId);
  421. },
  422. 'click .js-decline-invite'() {
  423. const boardId = this.currentData()._id;
  424. Meteor.call('quitBoard', boardId, (err, ret) => {
  425. if (!err && ret) {
  426. Meteor.call('acceptInvite', boardId);
  427. FlowRouter.go('home');
  428. }
  429. });
  430. },
  431. 'click .js-multiselection-activate'(evt) {
  432. evt.preventDefault();
  433. if (BoardMultiSelection.isActive()) {
  434. BoardMultiSelection.disable();
  435. } else {
  436. BoardMultiSelection.activate();
  437. }
  438. },
  439. 'click .js-multiselection-reset'(evt) {
  440. evt.preventDefault();
  441. BoardMultiSelection.disable();
  442. },
  443. 'click .js-toggle-board-multi-selection'(evt) {
  444. evt.preventDefault();
  445. evt.stopPropagation();
  446. const boardId = this.currentData()._id;
  447. BoardMultiSelection.toogle(boardId);
  448. },
  449. 'click .js-archive-selected-boards'(evt) {
  450. evt.preventDefault();
  451. const selectedBoards = BoardMultiSelection.getSelectedBoardIds();
  452. if (selectedBoards.length > 0 && confirm(TAPi18n.__('archive-board-confirm'))) {
  453. selectedBoards.forEach(boardId => {
  454. Meteor.call('archiveBoard', boardId);
  455. });
  456. BoardMultiSelection.reset();
  457. }
  458. },
  459. 'click .js-duplicate-selected-boards'(evt) {
  460. evt.preventDefault();
  461. const selectedBoards = BoardMultiSelection.getSelectedBoardIds();
  462. if (selectedBoards.length > 0 && confirm(TAPi18n.__('duplicate-board-confirm'))) {
  463. selectedBoards.forEach(boardId => {
  464. const board = ReactiveCache.getBoard(boardId);
  465. if (board) {
  466. Meteor.call(
  467. 'copyBoard',
  468. boardId,
  469. {
  470. sort: ReactiveCache.getBoards({ archived: false }).length,
  471. type: 'board',
  472. title: board.title,
  473. },
  474. (err, res) => {
  475. if (err) console.error(err);
  476. }
  477. );
  478. }
  479. });
  480. BoardMultiSelection.reset();
  481. }
  482. },
  483. 'click #resetBtn'(event) {
  484. let allBoards = document.getElementsByClassName("js-board");
  485. let currBoard;
  486. for (let i = 0; i < allBoards.length; i++) {
  487. currBoard = allBoards[i];
  488. currBoard.style.display = "block";
  489. }
  490. },
  491. 'click #filterBtn'(event) {
  492. event.preventDefault();
  493. let selectedTeams = document.querySelectorAll('#jsAllBoardTeams option:checked');
  494. let selectedTeamsValues = Array.from(selectedTeams).map(function (elt) { return elt.value });
  495. let index = selectedTeamsValues.indexOf("-1");
  496. if (index > -1) {
  497. selectedTeamsValues.splice(index, 1);
  498. }
  499. let selectedOrgs = document.querySelectorAll('#jsAllBoardOrgs option:checked');
  500. let selectedOrgsValues = Array.from(selectedOrgs).map(function (elt) { return elt.value });
  501. index = selectedOrgsValues.indexOf("-1");
  502. if (index > -1) {
  503. selectedOrgsValues.splice(index, 1);
  504. }
  505. if (selectedTeamsValues.length > 0 || selectedOrgsValues.length > 0) {
  506. const query = {
  507. $and: [
  508. { archived: false },
  509. { type: 'board' },
  510. { $or: [] }
  511. ]
  512. };
  513. if (selectedTeamsValues.length > 0) {
  514. query.$and[2].$or.push({ 'teams.teamId': { $in: selectedTeamsValues } });
  515. }
  516. if (selectedOrgsValues.length > 0) {
  517. query.$and[2].$or.push({ 'orgs.orgId': { $in: selectedOrgsValues } });
  518. }
  519. let filteredBoards = ReactiveCache.getBoards(query, {});
  520. let allBoards = document.getElementsByClassName("js-board");
  521. let currBoard;
  522. if (filteredBoards.length > 0) {
  523. let currBoardId;
  524. let found;
  525. for (let i = 0; i < allBoards.length; i++) {
  526. currBoard = allBoards[i];
  527. currBoardId = currBoard.classList[0];
  528. found = filteredBoards.find(function (board) {
  529. return board._id == currBoardId;
  530. });
  531. if (found !== undefined)
  532. currBoard.style.display = "block";
  533. else
  534. currBoard.style.display = "none";
  535. }
  536. }
  537. else {
  538. for (let i = 0; i < allBoards.length; i++) {
  539. currBoard = allBoards[i];
  540. currBoard.style.display = "none";
  541. }
  542. }
  543. }
  544. },
  545. 'click .js-edit-workspace'(evt) {
  546. evt.preventDefault();
  547. evt.stopPropagation();
  548. const workspaceId = evt.currentTarget.getAttribute('data-id');
  549. // Find the space in the tree
  550. const findSpace = (nodes, id) => {
  551. for (const node of nodes) {
  552. if (node.id === id) return node;
  553. if (node.children) {
  554. const found = findSpace(node.children, id);
  555. if (found) return found;
  556. }
  557. }
  558. return null;
  559. };
  560. const tree = this.workspacesTreeVar.get();
  561. const space = findSpace(tree, workspaceId);
  562. if (space) {
  563. const newName = prompt(TAPi18n.__('allboards.edit-workspace-name') || 'Space name:', space.name);
  564. const newIcon = prompt(TAPi18n.__('allboards.edit-workspace-icon') || 'Space icon (markdown):', space.icon || '📁');
  565. if (newName !== null && newName.trim()) {
  566. // Update space in tree
  567. const updateSpaceInTree = (nodes, id, updates) => {
  568. return nodes.map(node => {
  569. if (node.id === id) {
  570. return { ...node, ...updates };
  571. }
  572. if (node.children) {
  573. return { ...node, children: updateSpaceInTree(node.children, id, updates) };
  574. }
  575. return node;
  576. });
  577. };
  578. const updatedTree = updateSpaceInTree(tree, workspaceId, {
  579. name: newName.trim(),
  580. icon: newIcon || '📁'
  581. });
  582. Meteor.call('setWorkspacesTree', updatedTree, (err) => {
  583. if (err) console.error(err);
  584. });
  585. }
  586. }
  587. },
  588. 'click .js-add-subworkspace'(evt) {
  589. evt.preventDefault();
  590. evt.stopPropagation();
  591. const parentId = evt.currentTarget.getAttribute('data-id');
  592. const name = prompt(TAPi18n.__('allboards.add-subworkspace-prompt') || 'Subspace name:');
  593. if (name && name.trim()) {
  594. Meteor.call('createWorkspace', { parentId, name: name.trim() }, (err) => {
  595. if (err) console.error(err);
  596. });
  597. }
  598. },
  599. 'dragstart .workspace-node'(evt) {
  600. const workspaceId = evt.currentTarget.getAttribute('data-workspace-id');
  601. evt.originalEvent.dataTransfer.effectAllowed = 'move';
  602. evt.originalEvent.dataTransfer.setData('application/x-workspace-id', workspaceId);
  603. // Create a better drag image
  604. const dragImage = evt.currentTarget.cloneNode(true);
  605. dragImage.style.position = 'absolute';
  606. dragImage.style.top = '-9999px';
  607. dragImage.style.opacity = '0.8';
  608. document.body.appendChild(dragImage);
  609. evt.originalEvent.dataTransfer.setDragImage(dragImage, 0, 0);
  610. setTimeout(() => document.body.removeChild(dragImage), 0);
  611. evt.currentTarget.classList.add('dragging');
  612. },
  613. 'dragend .workspace-node'(evt) {
  614. evt.currentTarget.classList.remove('dragging');
  615. document.querySelectorAll('.workspace-node').forEach(el => {
  616. el.classList.remove('drag-over');
  617. });
  618. },
  619. 'dragover .workspace-node'(evt) {
  620. evt.preventDefault();
  621. evt.stopPropagation();
  622. const draggingEl = document.querySelector('.workspace-node.dragging');
  623. const targetEl = evt.currentTarget;
  624. // Allow dropping boards on any space
  625. // Or allow dropping spaces on other spaces (but not on itself or descendants)
  626. if (!draggingEl || (targetEl !== draggingEl && !draggingEl.contains(targetEl))) {
  627. evt.originalEvent.dataTransfer.dropEffect = 'move';
  628. targetEl.classList.add('drag-over');
  629. }
  630. },
  631. 'dragleave .workspace-node'(evt) {
  632. evt.currentTarget.classList.remove('drag-over');
  633. },
  634. 'drop .workspace-node'(evt) {
  635. evt.preventDefault();
  636. evt.stopPropagation();
  637. const targetEl = evt.currentTarget;
  638. targetEl.classList.remove('drag-over');
  639. // Check what's being dropped - board or workspace
  640. const draggedWorkspaceId = evt.originalEvent.dataTransfer.getData('application/x-workspace-id');
  641. const isMultiBoard = evt.originalEvent.dataTransfer.getData('application/x-board-multi');
  642. const boardData = evt.originalEvent.dataTransfer.getData('text/plain');
  643. if (draggedWorkspaceId && !boardData) {
  644. // This is a workspace reorder operation
  645. const targetWorkspaceId = targetEl.getAttribute('data-workspace-id');
  646. if (draggedWorkspaceId !== targetWorkspaceId) {
  647. this.reorderWorkspaces(draggedWorkspaceId, targetWorkspaceId);
  648. }
  649. } else if (boardData) {
  650. // This is a board assignment operation
  651. // Get the workspace ID directly from the dropped workspace-node's data-workspace-id attribute
  652. const workspaceId = targetEl.getAttribute('data-workspace-id');
  653. if (workspaceId) {
  654. if (isMultiBoard) {
  655. // Multi-board drag
  656. try {
  657. const boardIds = JSON.parse(boardData);
  658. boardIds.forEach(boardId => {
  659. Meteor.call('assignBoardToWorkspace', boardId, workspaceId);
  660. });
  661. } catch (e) {
  662. // Error parsing multi-board data
  663. }
  664. } else {
  665. // Single board drag
  666. Meteor.call('assignBoardToWorkspace', boardData, workspaceId);
  667. }
  668. }
  669. }
  670. },
  671. 'dragover .js-select-menu'(evt) {
  672. evt.preventDefault();
  673. evt.stopPropagation();
  674. const menuType = evt.currentTarget.getAttribute('data-type');
  675. // Only allow drop on "remaining" menu to unassign boards from spaces
  676. if (menuType === 'remaining') {
  677. evt.originalEvent.dataTransfer.dropEffect = 'move';
  678. evt.currentTarget.classList.add('drag-over');
  679. }
  680. },
  681. 'dragleave .js-select-menu'(evt) {
  682. evt.currentTarget.classList.remove('drag-over');
  683. },
  684. 'drop .js-select-menu'(evt) {
  685. evt.preventDefault();
  686. evt.stopPropagation();
  687. const menuType = evt.currentTarget.getAttribute('data-type');
  688. evt.currentTarget.classList.remove('drag-over');
  689. // Only handle drops on "remaining" menu
  690. if (menuType !== 'remaining') return;
  691. const isMultiBoard = evt.originalEvent.dataTransfer.getData('application/x-board-multi');
  692. const boardData = evt.originalEvent.dataTransfer.getData('text/plain');
  693. if (boardData) {
  694. if (isMultiBoard) {
  695. // Multi-board drag - unassign all from workspaces
  696. try {
  697. const boardIds = JSON.parse(boardData);
  698. boardIds.forEach(boardId => {
  699. Meteor.call('unassignBoardFromWorkspace', boardId);
  700. });
  701. } catch (e) {
  702. // Error parsing multi-board data
  703. }
  704. } else {
  705. // Single board drag - unassign from workspace
  706. Meteor.call('unassignBoardFromWorkspace', boardData);
  707. }
  708. }
  709. },
  710. },
  711. ];
  712. },
  713. // Helpers for templates
  714. workspacesTree() {
  715. return this.workspacesTreeVar.get();
  716. },
  717. selectedWorkspaceId() {
  718. return this.selectedWorkspaceIdVar.get();
  719. },
  720. isSelectedMenu(type) {
  721. return this.selectedMenu.get() === type;
  722. },
  723. isSpaceSelected(id) {
  724. return this.selectedWorkspaceIdVar.get() === id;
  725. },
  726. menuItemCount(type) {
  727. const currentUser = ReactiveCache.getCurrentUser();
  728. const assignments = (currentUser && currentUser.profile && currentUser.profile.boardWorkspaceAssignments) || {};
  729. // Get all boards for counting
  730. let query = {
  731. $and: [
  732. { archived: false },
  733. { type: { $in: ['board', 'template-container'] } },
  734. { $or: [{ 'members.userId': Meteor.userId() }] },
  735. { title: { $not: { $regex: /^\^.*\^$/ } } }
  736. ]
  737. };
  738. const allBoards = ReactiveCache.getBoards(query, {});
  739. if (type === 'starred') {
  740. return allBoards.filter(b => currentUser && currentUser.hasStarred(b._id)).length;
  741. } else if (type === 'templates') {
  742. return allBoards.filter(b => b.type === 'template-container').length;
  743. } else if (type === 'remaining') {
  744. // Count boards not in any workspace AND not templates
  745. // Include starred boards (they appear in both Starred and Remaining)
  746. return allBoards.filter(b =>
  747. !assignments[b._id] &&
  748. b.type !== 'template-container'
  749. ).length;
  750. }
  751. return 0;
  752. },
  753. workspaceCount(workspaceId) {
  754. const currentUser = ReactiveCache.getCurrentUser();
  755. const assignments = (currentUser && currentUser.profile && currentUser.profile.boardWorkspaceAssignments) || {};
  756. // Get all boards for counting
  757. let query = {
  758. $and: [
  759. { archived: false },
  760. { type: { $in: ['board', 'template-container'] } },
  761. { $or: [{ 'members.userId': Meteor.userId() }] },
  762. { title: { $not: { $regex: /^\^.*\^$/ } } }
  763. ]
  764. };
  765. const allBoards = ReactiveCache.getBoards(query, {});
  766. // Count boards directly assigned to this space (not including children)
  767. return allBoards.filter(b => assignments[b._id] === workspaceId).length;
  768. },
  769. canModifyBoards() {
  770. const currentUser = ReactiveCache.getCurrentUser();
  771. return currentUser && !currentUser.isCommentOnly();
  772. },
  773. hasBoardsSelected() {
  774. return BoardMultiSelection.count() > 0;
  775. },
  776. }).register('boardList');