boardsList.js 30 KB

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