boardsList.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839
  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. list = list.filter(b =>
  270. !assignments[b._id] &&
  271. b.type !== 'template-container' &&
  272. !(currentUser && currentUser.hasStarred(b._id))
  273. );
  274. } else {
  275. // assume sel is a workspaceId
  276. list = list.filter(b => assignments[b._id] === sel);
  277. }
  278. if (currentUser && typeof currentUser.sortBoardsForUser === 'function') {
  279. return currentUser.sortBoardsForUser(list);
  280. }
  281. return list.slice().sort((a, b) => (a.title || '').localeCompare(b.title || ''));
  282. },
  283. boardLists(boardId) {
  284. /* Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
  285. const lists = ReactiveCache.getLists({ 'boardId': boardId, 'archived': false },{sort: ['sort','asc']});
  286. const ret = lists.map(list => {
  287. let cardCount = ReactiveCache.getCards({ 'boardId': boardId, 'listId': list._id }).length;
  288. return `${list.title}: ${cardCount}`;
  289. });
  290. return ret;
  291. */
  292. return [];
  293. },
  294. boardMembers(boardId) {
  295. /* Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
  296. const lists = ReactiveCache.getBoard(boardId)
  297. const boardMembers = lists?.members.map(member => member.userId);
  298. return boardMembers;
  299. */
  300. return [];
  301. },
  302. isStarred() {
  303. const user = ReactiveCache.getCurrentUser();
  304. return user && user.hasStarred(this.currentData()._id);
  305. },
  306. isAdministrable() {
  307. const user = ReactiveCache.getCurrentUser();
  308. return user && user.isBoardAdmin(this.currentData()._id);
  309. },
  310. hasOvertimeCards() {
  311. return this.currentData().hasOvertimeCards();
  312. },
  313. hasSpentTimeCards() {
  314. return this.currentData().hasSpentTimeCards();
  315. },
  316. isInvited() {
  317. const user = ReactiveCache.getCurrentUser();
  318. return user && user.isInvitedTo(this.currentData()._id);
  319. },
  320. events() {
  321. return [
  322. {
  323. 'click .js-select-menu'(evt) {
  324. const type = evt.currentTarget.getAttribute('data-type');
  325. this.selectedWorkspaceIdVar.set(null);
  326. this.selectedMenu.set(type);
  327. },
  328. 'click .js-select-workspace'(evt) {
  329. const id = evt.currentTarget.getAttribute('data-id');
  330. this.selectedWorkspaceIdVar.set(id);
  331. this.selectedMenu.set(id);
  332. },
  333. 'click .js-add-workspace'(evt) {
  334. evt.preventDefault();
  335. const name = prompt(TAPi18n.__('allboards.add-workspace-prompt') || 'New Space name');
  336. if (name && name.trim()) {
  337. Meteor.call('createWorkspace', { parentId: null, name: name.trim() }, (err, res) => {
  338. if (err) console.error(err);
  339. });
  340. }
  341. },
  342. 'click .js-add-board'(evt) {
  343. // Store the currently selected workspace/menu for board creation
  344. const selectedWorkspaceId = this.selectedWorkspaceIdVar.get();
  345. const selectedMenu = this.selectedMenu.get();
  346. if (selectedWorkspaceId) {
  347. Session.set('createBoardInWorkspace', selectedWorkspaceId);
  348. } else {
  349. Session.set('createBoardInWorkspace', null);
  350. }
  351. // Open different popup based on context
  352. if (selectedMenu === 'templates') {
  353. Popup.open('createTemplateContainer')(evt);
  354. } else {
  355. Popup.open('createBoard')(evt);
  356. }
  357. },
  358. 'click .js-star-board'(evt) {
  359. evt.preventDefault();
  360. evt.stopPropagation();
  361. const boardId = this.currentData()._id;
  362. if (boardId) {
  363. Meteor.call('toggleBoardStar', boardId);
  364. }
  365. },
  366. // HTML5 DnD from boards to spaces
  367. 'dragstart .js-board'(evt) {
  368. const boardId = this.currentData()._id;
  369. // Support multi-drag
  370. if (BoardMultiSelection.isActive() && BoardMultiSelection.isSelected(boardId)) {
  371. const selectedIds = BoardMultiSelection.getSelectedBoardIds();
  372. try {
  373. evt.originalEvent.dataTransfer.setData('text/plain', JSON.stringify(selectedIds));
  374. evt.originalEvent.dataTransfer.setData('application/x-board-multi', 'true');
  375. } catch (e) {}
  376. } else {
  377. try { evt.originalEvent.dataTransfer.setData('text/plain', boardId); } catch (e) {}
  378. }
  379. },
  380. 'click .js-clone-board'(evt) {
  381. if (confirm(TAPi18n.__('duplicate-board-confirm'))) {
  382. let title =
  383. getSlug(ReactiveCache.getBoard(this.currentData()._id).title) ||
  384. 'cloned-board';
  385. Meteor.call(
  386. 'copyBoard',
  387. this.currentData()._id,
  388. {
  389. sort: ReactiveCache.getBoards({ archived: false }).length,
  390. type: 'board',
  391. title: ReactiveCache.getBoard(this.currentData()._id).title,
  392. },
  393. (err, res) => {
  394. if (err) {
  395. console.error(err);
  396. } else {
  397. Session.set('fromBoard', null);
  398. subManager.subscribe('board', res, false);
  399. FlowRouter.go('board', {
  400. id: res,
  401. slug: title,
  402. });
  403. }
  404. },
  405. );
  406. evt.preventDefault();
  407. }
  408. },
  409. 'click .js-archive-board'(evt) {
  410. if (confirm(TAPi18n.__('archive-board-confirm'))) {
  411. const boardId = this.currentData()._id;
  412. Meteor.call('archiveBoard', boardId);
  413. evt.preventDefault();
  414. }
  415. },
  416. 'click .js-accept-invite'() {
  417. const boardId = this.currentData()._id;
  418. Meteor.call('acceptInvite', boardId);
  419. },
  420. 'click .js-decline-invite'() {
  421. const boardId = this.currentData()._id;
  422. Meteor.call('quitBoard', boardId, (err, ret) => {
  423. if (!err && ret) {
  424. Meteor.call('acceptInvite', boardId);
  425. FlowRouter.go('home');
  426. }
  427. });
  428. },
  429. 'click .js-multiselection-activate'(evt) {
  430. evt.preventDefault();
  431. if (BoardMultiSelection.isActive()) {
  432. BoardMultiSelection.disable();
  433. } else {
  434. BoardMultiSelection.activate();
  435. }
  436. },
  437. 'click .js-multiselection-reset'(evt) {
  438. evt.preventDefault();
  439. BoardMultiSelection.disable();
  440. },
  441. 'click .js-toggle-board-multi-selection'(evt) {
  442. evt.preventDefault();
  443. evt.stopPropagation();
  444. const boardId = this.currentData()._id;
  445. BoardMultiSelection.toogle(boardId);
  446. },
  447. 'click .js-archive-selected-boards'(evt) {
  448. evt.preventDefault();
  449. const selectedBoards = BoardMultiSelection.getSelectedBoardIds();
  450. if (selectedBoards.length > 0 && confirm(TAPi18n.__('archive-board-confirm'))) {
  451. selectedBoards.forEach(boardId => {
  452. Meteor.call('archiveBoard', boardId);
  453. });
  454. BoardMultiSelection.reset();
  455. }
  456. },
  457. 'click .js-duplicate-selected-boards'(evt) {
  458. evt.preventDefault();
  459. const selectedBoards = BoardMultiSelection.getSelectedBoardIds();
  460. if (selectedBoards.length > 0 && confirm(TAPi18n.__('duplicate-board-confirm'))) {
  461. selectedBoards.forEach(boardId => {
  462. const board = ReactiveCache.getBoard(boardId);
  463. if (board) {
  464. Meteor.call(
  465. 'copyBoard',
  466. boardId,
  467. {
  468. sort: ReactiveCache.getBoards({ archived: false }).length,
  469. type: 'board',
  470. title: board.title,
  471. },
  472. (err, res) => {
  473. if (err) console.error(err);
  474. }
  475. );
  476. }
  477. });
  478. BoardMultiSelection.reset();
  479. }
  480. },
  481. 'click #resetBtn'(event) {
  482. let allBoards = document.getElementsByClassName("js-board");
  483. let currBoard;
  484. for (let i = 0; i < allBoards.length; i++) {
  485. currBoard = allBoards[i];
  486. currBoard.style.display = "block";
  487. }
  488. },
  489. 'click #filterBtn'(event) {
  490. event.preventDefault();
  491. let selectedTeams = document.querySelectorAll('#jsAllBoardTeams option:checked');
  492. let selectedTeamsValues = Array.from(selectedTeams).map(function (elt) { return elt.value });
  493. let index = selectedTeamsValues.indexOf("-1");
  494. if (index > -1) {
  495. selectedTeamsValues.splice(index, 1);
  496. }
  497. let selectedOrgs = document.querySelectorAll('#jsAllBoardOrgs option:checked');
  498. let selectedOrgsValues = Array.from(selectedOrgs).map(function (elt) { return elt.value });
  499. index = selectedOrgsValues.indexOf("-1");
  500. if (index > -1) {
  501. selectedOrgsValues.splice(index, 1);
  502. }
  503. if (selectedTeamsValues.length > 0 || selectedOrgsValues.length > 0) {
  504. const query = {
  505. $and: [
  506. { archived: false },
  507. { type: 'board' },
  508. { $or: [] }
  509. ]
  510. };
  511. if (selectedTeamsValues.length > 0) {
  512. query.$and[2].$or.push({ 'teams.teamId': { $in: selectedTeamsValues } });
  513. }
  514. if (selectedOrgsValues.length > 0) {
  515. query.$and[2].$or.push({ 'orgs.orgId': { $in: selectedOrgsValues } });
  516. }
  517. let filteredBoards = ReactiveCache.getBoards(query, {});
  518. let allBoards = document.getElementsByClassName("js-board");
  519. let currBoard;
  520. if (filteredBoards.length > 0) {
  521. let currBoardId;
  522. let found;
  523. for (let i = 0; i < allBoards.length; i++) {
  524. currBoard = allBoards[i];
  525. currBoardId = currBoard.classList[0];
  526. found = filteredBoards.find(function (board) {
  527. return board._id == currBoardId;
  528. });
  529. if (found !== undefined)
  530. currBoard.style.display = "block";
  531. else
  532. currBoard.style.display = "none";
  533. }
  534. }
  535. else {
  536. for (let i = 0; i < allBoards.length; i++) {
  537. currBoard = allBoards[i];
  538. currBoard.style.display = "none";
  539. }
  540. }
  541. }
  542. },
  543. 'click .js-edit-workspace'(evt) {
  544. evt.preventDefault();
  545. evt.stopPropagation();
  546. const workspaceId = evt.currentTarget.getAttribute('data-id');
  547. // Find the space in the tree
  548. const findSpace = (nodes, id) => {
  549. for (const node of nodes) {
  550. if (node.id === id) return node;
  551. if (node.children) {
  552. const found = findSpace(node.children, id);
  553. if (found) return found;
  554. }
  555. }
  556. return null;
  557. };
  558. const tree = this.workspacesTreeVar.get();
  559. const space = findSpace(tree, workspaceId);
  560. if (space) {
  561. const newName = prompt(TAPi18n.__('allboards.edit-workspace-name') || 'Space name:', space.name);
  562. const newIcon = prompt(TAPi18n.__('allboards.edit-workspace-icon') || 'Space icon (markdown):', space.icon || '📁');
  563. if (newName !== null && newName.trim()) {
  564. // Update space in tree
  565. const updateSpaceInTree = (nodes, id, updates) => {
  566. return nodes.map(node => {
  567. if (node.id === id) {
  568. return { ...node, ...updates };
  569. }
  570. if (node.children) {
  571. return { ...node, children: updateSpaceInTree(node.children, id, updates) };
  572. }
  573. return node;
  574. });
  575. };
  576. const updatedTree = updateSpaceInTree(tree, workspaceId, {
  577. name: newName.trim(),
  578. icon: newIcon || '📁'
  579. });
  580. Meteor.call('setWorkspacesTree', updatedTree, (err) => {
  581. if (err) console.error(err);
  582. });
  583. }
  584. }
  585. },
  586. 'click .js-add-subworkspace'(evt) {
  587. evt.preventDefault();
  588. evt.stopPropagation();
  589. const parentId = evt.currentTarget.getAttribute('data-id');
  590. const name = prompt(TAPi18n.__('allboards.add-subworkspace-prompt') || 'Subspace name:');
  591. if (name && name.trim()) {
  592. Meteor.call('createWorkspace', { parentId, name: name.trim() }, (err) => {
  593. if (err) console.error(err);
  594. });
  595. }
  596. },
  597. 'dragstart .workspace-node'(evt) {
  598. const workspaceId = evt.currentTarget.getAttribute('data-workspace-id');
  599. evt.originalEvent.dataTransfer.effectAllowed = 'move';
  600. evt.originalEvent.dataTransfer.setData('application/x-workspace-id', workspaceId);
  601. // Create a better drag image
  602. const dragImage = evt.currentTarget.cloneNode(true);
  603. dragImage.style.position = 'absolute';
  604. dragImage.style.top = '-9999px';
  605. dragImage.style.opacity = '0.8';
  606. document.body.appendChild(dragImage);
  607. evt.originalEvent.dataTransfer.setDragImage(dragImage, 0, 0);
  608. setTimeout(() => document.body.removeChild(dragImage), 0);
  609. evt.currentTarget.classList.add('dragging');
  610. },
  611. 'dragend .workspace-node'(evt) {
  612. evt.currentTarget.classList.remove('dragging');
  613. document.querySelectorAll('.workspace-node').forEach(el => {
  614. el.classList.remove('drag-over');
  615. });
  616. },
  617. 'dragover .workspace-node'(evt) {
  618. evt.preventDefault();
  619. evt.stopPropagation();
  620. const draggingEl = document.querySelector('.workspace-node.dragging');
  621. const targetEl = evt.currentTarget;
  622. // Allow dropping boards on any space
  623. // Or allow dropping spaces on other spaces (but not on itself or descendants)
  624. if (!draggingEl || (targetEl !== draggingEl && !draggingEl.contains(targetEl))) {
  625. evt.originalEvent.dataTransfer.dropEffect = 'move';
  626. targetEl.classList.add('drag-over');
  627. }
  628. },
  629. 'dragleave .workspace-node'(evt) {
  630. evt.currentTarget.classList.remove('drag-over');
  631. },
  632. 'drop .workspace-node'(evt) {
  633. evt.preventDefault();
  634. evt.stopPropagation();
  635. const targetEl = evt.currentTarget;
  636. targetEl.classList.remove('drag-over');
  637. // Check what's being dropped - board or workspace
  638. const draggedWorkspaceId = evt.originalEvent.dataTransfer.getData('application/x-workspace-id');
  639. const isMultiBoard = evt.originalEvent.dataTransfer.getData('application/x-board-multi');
  640. const boardData = evt.originalEvent.dataTransfer.getData('text/plain');
  641. if (draggedWorkspaceId && !boardData) {
  642. // This is a workspace reorder operation
  643. const targetWorkspaceId = targetEl.getAttribute('data-workspace-id');
  644. if (draggedWorkspaceId !== targetWorkspaceId) {
  645. this.reorderWorkspaces(draggedWorkspaceId, targetWorkspaceId);
  646. }
  647. } else if (boardData) {
  648. // This is a board assignment operation
  649. // Get the workspace ID directly from the dropped workspace-node's data-workspace-id attribute
  650. const workspaceId = targetEl.getAttribute('data-workspace-id');
  651. if (workspaceId) {
  652. if (isMultiBoard) {
  653. // Multi-board drag
  654. try {
  655. const boardIds = JSON.parse(boardData);
  656. boardIds.forEach(boardId => {
  657. Meteor.call('assignBoardToWorkspace', boardId, workspaceId);
  658. });
  659. } catch (e) {
  660. // Error parsing multi-board data
  661. }
  662. } else {
  663. // Single board drag
  664. Meteor.call('assignBoardToWorkspace', boardData, workspaceId);
  665. }
  666. }
  667. }
  668. },
  669. 'dragover .js-select-menu'(evt) {
  670. evt.preventDefault();
  671. evt.stopPropagation();
  672. const menuType = evt.currentTarget.getAttribute('data-type');
  673. // Only allow drop on "remaining" menu to unassign boards from spaces
  674. if (menuType === 'remaining') {
  675. evt.originalEvent.dataTransfer.dropEffect = 'move';
  676. evt.currentTarget.classList.add('drag-over');
  677. }
  678. },
  679. 'dragleave .js-select-menu'(evt) {
  680. evt.currentTarget.classList.remove('drag-over');
  681. },
  682. 'drop .js-select-menu'(evt) {
  683. evt.preventDefault();
  684. evt.stopPropagation();
  685. const menuType = evt.currentTarget.getAttribute('data-type');
  686. evt.currentTarget.classList.remove('drag-over');
  687. // Only handle drops on "remaining" menu
  688. if (menuType !== 'remaining') return;
  689. const isMultiBoard = evt.originalEvent.dataTransfer.getData('application/x-board-multi');
  690. const boardData = evt.originalEvent.dataTransfer.getData('text/plain');
  691. if (boardData) {
  692. if (isMultiBoard) {
  693. // Multi-board drag - unassign all from workspaces
  694. try {
  695. const boardIds = JSON.parse(boardData);
  696. boardIds.forEach(boardId => {
  697. Meteor.call('unassignBoardFromWorkspace', boardId);
  698. });
  699. } catch (e) {
  700. // Error parsing multi-board data
  701. }
  702. } else {
  703. // Single board drag - unassign from workspace
  704. Meteor.call('unassignBoardFromWorkspace', boardData);
  705. }
  706. }
  707. },
  708. },
  709. ];
  710. },
  711. // Helpers for templates
  712. workspacesTree() {
  713. return this.workspacesTreeVar.get();
  714. },
  715. selectedWorkspaceId() {
  716. return this.selectedWorkspaceIdVar.get();
  717. },
  718. isSelectedMenu(type) {
  719. return this.selectedMenu.get() === type;
  720. },
  721. isSpaceSelected(id) {
  722. return this.selectedWorkspaceIdVar.get() === id;
  723. },
  724. menuItemCount(type) {
  725. const currentUser = ReactiveCache.getCurrentUser();
  726. const assignments = (currentUser && currentUser.profile && currentUser.profile.boardWorkspaceAssignments) || {};
  727. // Get all boards for counting
  728. let query = {
  729. $and: [
  730. { archived: false },
  731. { type: { $in: ['board', 'template-container'] } },
  732. { $or: [{ 'members.userId': Meteor.userId() }] },
  733. { title: { $not: { $regex: /^\^.*\^$/ } } }
  734. ]
  735. };
  736. const allBoards = ReactiveCache.getBoards(query, {});
  737. if (type === 'starred') {
  738. return allBoards.filter(b => currentUser && currentUser.hasStarred(b._id)).length;
  739. } else if (type === 'templates') {
  740. return allBoards.filter(b => b.type === 'template-container').length;
  741. } else if (type === 'remaining') {
  742. return allBoards.filter(b =>
  743. !assignments[b._id] &&
  744. b.type !== 'template-container' &&
  745. !(currentUser && currentUser.hasStarred(b._id))
  746. ).length;
  747. }
  748. return 0;
  749. },
  750. workspaceCount(workspaceId) {
  751. const currentUser = ReactiveCache.getCurrentUser();
  752. const assignments = (currentUser && currentUser.profile && currentUser.profile.boardWorkspaceAssignments) || {};
  753. // Get all boards for counting
  754. let query = {
  755. $and: [
  756. { archived: false },
  757. { type: { $in: ['board', 'template-container'] } },
  758. { $or: [{ 'members.userId': Meteor.userId() }] },
  759. { title: { $not: { $regex: /^\^.*\^$/ } } }
  760. ]
  761. };
  762. const allBoards = ReactiveCache.getBoards(query, {});
  763. // Count boards directly assigned to this space (not including children)
  764. return allBoards.filter(b => assignments[b._id] === workspaceId).length;
  765. },
  766. canModifyBoards() {
  767. const currentUser = ReactiveCache.getCurrentUser();
  768. return currentUser && !currentUser.isCommentOnly();
  769. },
  770. hasBoardsSelected() {
  771. return BoardMultiSelection.count() > 0;
  772. },
  773. }).register('boardList');