swimlanes.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. import { ReactiveCache } from '/imports/reactiveCache';
  2. import { ALLOWED_COLORS } from '/config/const';
  3. import PositionHistory from './positionHistory';
  4. Swimlanes = new Mongo.Collection('swimlanes');
  5. /**
  6. * A swimlane is an line in the kaban board.
  7. */
  8. Swimlanes.attachSchema(
  9. new SimpleSchema({
  10. title: {
  11. /**
  12. * the title of the swimlane
  13. */
  14. type: String,
  15. },
  16. archived: {
  17. /**
  18. * is the swimlane archived?
  19. */
  20. type: Boolean,
  21. // eslint-disable-next-line consistent-return
  22. autoValue() {
  23. if (this.isInsert && !this.isSet) {
  24. return false;
  25. }
  26. },
  27. },
  28. archivedAt: {
  29. /**
  30. * latest archiving date of the swimlane
  31. */
  32. type: Date,
  33. optional: true,
  34. },
  35. boardId: {
  36. /**
  37. * the ID of the board the swimlane is attached to
  38. */
  39. type: String,
  40. },
  41. createdAt: {
  42. /**
  43. * creation date of the swimlane
  44. */
  45. type: Date,
  46. // eslint-disable-next-line consistent-return
  47. autoValue() {
  48. if (this.isInsert) {
  49. return new Date();
  50. } else if (this.isUpsert) {
  51. return { $setOnInsert: new Date() };
  52. } else {
  53. this.unset();
  54. }
  55. },
  56. },
  57. sort: {
  58. /**
  59. * the sort value of the swimlane
  60. */
  61. type: Number,
  62. decimal: true,
  63. // XXX We should probably provide a default
  64. optional: true,
  65. },
  66. color: {
  67. /**
  68. * the color of the swimlane
  69. */
  70. type: String,
  71. optional: true,
  72. // silver is the default
  73. allowedValues: ALLOWED_COLORS,
  74. },
  75. updatedAt: {
  76. /**
  77. * when was the swimlane last edited
  78. */
  79. type: Date,
  80. optional: true,
  81. // eslint-disable-next-line consistent-return
  82. autoValue() {
  83. if (this.isUpdate || this.isUpsert || this.isInsert) {
  84. return new Date();
  85. } else {
  86. this.unset();
  87. }
  88. },
  89. },
  90. modifiedAt: {
  91. type: Date,
  92. denyUpdate: false,
  93. // eslint-disable-next-line consistent-return
  94. autoValue() {
  95. if (this.isInsert || this.isUpsert || this.isUpdate) {
  96. return new Date();
  97. } else {
  98. this.unset();
  99. }
  100. },
  101. },
  102. type: {
  103. /**
  104. * The type of swimlane
  105. */
  106. type: String,
  107. defaultValue: 'swimlane',
  108. },
  109. collapsed: {
  110. /**
  111. * is the swimlane collapsed
  112. */
  113. type: Boolean,
  114. defaultValue: false,
  115. },
  116. }),
  117. );
  118. Swimlanes.allow({
  119. insert(userId, doc) {
  120. return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId));
  121. },
  122. update(userId, doc) {
  123. return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId));
  124. },
  125. remove(userId, doc) {
  126. return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId));
  127. },
  128. fetch: ['boardId'],
  129. });
  130. Swimlanes.helpers({
  131. copy(boardId) {
  132. const oldId = this._id;
  133. const oldBoardId = this.boardId;
  134. this.boardId = boardId;
  135. delete this._id;
  136. const _id = Swimlanes.insert(this);
  137. const query = {
  138. swimlaneId: { $in: [oldId, ''] },
  139. archived: false,
  140. };
  141. if (oldBoardId) {
  142. query.boardId = oldBoardId;
  143. }
  144. // Copy all lists in swimlane
  145. ReactiveCache.getLists(query).forEach(list => {
  146. list.type = 'list';
  147. list.swimlaneId = oldId;
  148. list.boardId = boardId;
  149. list.copy(boardId, _id);
  150. });
  151. },
  152. move(toBoardId) {
  153. this.lists().forEach(list => {
  154. const toList = ReactiveCache.getList({
  155. boardId: toBoardId,
  156. title: list.title,
  157. archived: false,
  158. });
  159. let toListId;
  160. if (toList) {
  161. toListId = toList._id;
  162. } else {
  163. toListId = Lists.insert({
  164. title: list.title,
  165. boardId: toBoardId,
  166. type: list.type,
  167. archived: false,
  168. wipLimit: list.wipLimit,
  169. swimlaneId: toSwimlaneId, // Set the target swimlane for the copied list
  170. });
  171. }
  172. ReactiveCache.getCards({
  173. listId: list._id,
  174. swimlaneId: this._id,
  175. }).forEach(card => {
  176. card.move(toBoardId, this._id, toListId);
  177. });
  178. });
  179. Swimlanes.update(this._id, {
  180. $set: {
  181. boardId: toBoardId,
  182. },
  183. });
  184. // make sure there is a default swimlane
  185. this.board().getDefaultSwimline();
  186. },
  187. cards() {
  188. const ret = ReactiveCache.getCards(
  189. Filter.mongoSelector({
  190. swimlaneId: this._id,
  191. archived: false,
  192. }),
  193. { sort: ['sort'] },
  194. );
  195. return ret;
  196. },
  197. lists() {
  198. return this.draggableLists();
  199. },
  200. newestLists() {
  201. // Revert to shared lists across swimlanes: filter by board only
  202. return ReactiveCache.getLists(
  203. {
  204. boardId: this.boardId,
  205. archived: false,
  206. },
  207. { sort: { modifiedAt: -1 } },
  208. );
  209. },
  210. draggableLists() {
  211. // Revert to shared lists across swimlanes: filter by board only
  212. return ReactiveCache.getLists(
  213. {
  214. boardId: this.boardId,
  215. //archived: false,
  216. },
  217. { sort: ['sort'] },
  218. );
  219. },
  220. myLists() {
  221. // Revert to shared lists: provide lists by board for this swimlane's board
  222. return ReactiveCache.getLists({ boardId: this.boardId });
  223. },
  224. allCards() {
  225. const ret = ReactiveCache.getCards({ swimlaneId: this._id });
  226. return ret;
  227. },
  228. isCollapsed() {
  229. return this.collapsed === true;
  230. },
  231. board() {
  232. return ReactiveCache.getBoard(this.boardId);
  233. },
  234. colorClass() {
  235. if (this.color) return `swimlane-${this.color}`;
  236. return '';
  237. },
  238. isTemplateSwimlane() {
  239. return this.type === 'template-swimlane';
  240. },
  241. isTemplateContainer() {
  242. return this.type === 'template-container';
  243. },
  244. isListTemplatesSwimlane() {
  245. const user = ReactiveCache.getCurrentUser();
  246. return (user.profile || {}).listTemplatesSwimlaneId === this._id;
  247. },
  248. isCardTemplatesSwimlane() {
  249. const user = ReactiveCache.getCurrentUser();
  250. return (user.profile || {}).cardTemplatesSwimlaneId === this._id;
  251. },
  252. isBoardTemplatesSwimlane() {
  253. const user = ReactiveCache.getCurrentUser();
  254. return (user.profile || {}).boardTemplatesSwimlaneId === this._id;
  255. },
  256. remove() {
  257. Swimlanes.remove({ _id: this._id });
  258. },
  259. });
  260. Swimlanes.mutations({
  261. rename(title) {
  262. return { $set: { title } };
  263. },
  264. collapse(enable = true) {
  265. return { $set: { collapsed: !!enable } };
  266. },
  267. archive() {
  268. if (this.isTemplateSwimlane()) {
  269. this.myLists().forEach(list => {
  270. return list.archive();
  271. });
  272. }
  273. return { $set: { archived: true, archivedAt: new Date() } };
  274. },
  275. restore() {
  276. if (this.isTemplateSwimlane()) {
  277. this.myLists().forEach(list => {
  278. return list.restore();
  279. });
  280. }
  281. return { $set: { archived: false } };
  282. },
  283. setColor(newColor) {
  284. return {
  285. $set: {
  286. color: newColor,
  287. },
  288. };
  289. },
  290. });
  291. Swimlanes.userArchivedSwimlanes = userId => {
  292. return ReactiveCache.getSwimlanes({
  293. boardId: { $in: Boards.userBoardIds(userId, null) },
  294. archived: true,
  295. })
  296. };
  297. Swimlanes.userArchivedSwimlaneIds = () => {
  298. return Swimlanes.userArchivedSwimlanes().map(swim => { return swim._id; });
  299. };
  300. Swimlanes.archivedSwimlanes = () => {
  301. return ReactiveCache.getSwimlanes({ archived: true });
  302. };
  303. Swimlanes.archivedSwimlaneIds = () => {
  304. return Swimlanes.archivedSwimlanes().map(swim => {
  305. return swim._id;
  306. });
  307. };
  308. Swimlanes.hookOptions.after.update = { fetchPrevious: false };
  309. if (Meteor.isServer) {
  310. Meteor.startup(() => {
  311. Swimlanes._collection.createIndex({ modifiedAt: -1 });
  312. Swimlanes._collection.createIndex({ boardId: 1 });
  313. });
  314. Swimlanes.after.insert((userId, doc) => {
  315. Activities.insert({
  316. userId,
  317. type: 'swimlane',
  318. activityType: 'createSwimlane',
  319. boardId: doc.boardId,
  320. swimlaneId: doc._id,
  321. });
  322. // Track original position for new swimlanes
  323. Meteor.setTimeout(() => {
  324. const swimlane = Swimlanes.findOne(doc._id);
  325. if (swimlane) {
  326. swimlane.trackOriginalPosition();
  327. }
  328. }, 100);
  329. });
  330. Swimlanes.before.remove(function(userId, doc) {
  331. const lists = ReactiveCache.getLists(
  332. {
  333. boardId: doc.boardId,
  334. swimlaneId: { $in: [doc._id, ''] },
  335. archived: false,
  336. },
  337. { sort: ['sort'] },
  338. );
  339. if (lists.length < 2) {
  340. lists.forEach(list => {
  341. list.remove();
  342. });
  343. } else {
  344. Cards.remove({ swimlaneId: doc._id });
  345. }
  346. Activities.insert({
  347. userId,
  348. type: 'swimlane',
  349. activityType: 'removeSwimlane',
  350. boardId: doc.boardId,
  351. swimlaneId: doc._id,
  352. title: doc.title,
  353. });
  354. });
  355. Swimlanes.after.update((userId, doc, fieldNames) => {
  356. if (fieldNames.includes('title')) {
  357. Activities.insert({
  358. userId,
  359. type: 'swimlane',
  360. activityType: 'changedSwimlaneTitle',
  361. listId: doc._id,
  362. boardId: doc.boardId,
  363. // this preserves the name so that the activity can be useful after the
  364. // list is deleted
  365. title: doc.title,
  366. });
  367. } else if (doc.archived) {
  368. Activities.insert({
  369. userId,
  370. type: 'swimlane',
  371. activityType: 'archivedSwimlane',
  372. listId: doc._id,
  373. boardId: doc.boardId,
  374. // this preserves the name so that the activity can be useful after the
  375. // list is deleted
  376. title: doc.title,
  377. });
  378. } else if (fieldNames.includes('archived')) {
  379. Activities.insert({
  380. userId,
  381. type: 'swimlane',
  382. activityType: 'restoredSwimlane',
  383. listId: doc._id,
  384. boardId: doc.boardId,
  385. // this preserves the name so that the activity can be useful after the
  386. // list is deleted
  387. title: doc.title,
  388. });
  389. }
  390. });
  391. }
  392. //SWIMLANE REST API
  393. if (Meteor.isServer) {
  394. /**
  395. * @operation get_all_swimlanes
  396. *
  397. * @summary Get the list of swimlanes attached to a board
  398. *
  399. * @param {string} boardId the ID of the board
  400. * @return_type [{_id: string,
  401. * title: string}]
  402. */
  403. JsonRoutes.add('GET', '/api/boards/:boardId/swimlanes', function(req, res) {
  404. try {
  405. const paramBoardId = req.params.boardId;
  406. Authentication.checkBoardAccess(req.userId, paramBoardId);
  407. JsonRoutes.sendResult(res, {
  408. code: 200,
  409. data: ReactiveCache.getSwimlanes({ boardId: paramBoardId, archived: false }).map(
  410. function(doc) {
  411. return {
  412. _id: doc._id,
  413. title: doc.title,
  414. };
  415. },
  416. ),
  417. });
  418. } catch (error) {
  419. JsonRoutes.sendResult(res, {
  420. code: 200,
  421. data: error,
  422. });
  423. }
  424. });
  425. /**
  426. * @operation get_swimlane
  427. *
  428. * @summary Get a swimlane
  429. *
  430. * @param {string} boardId the ID of the board
  431. * @param {string} swimlaneId the ID of the swimlane
  432. * @return_type Swimlanes
  433. */
  434. JsonRoutes.add('GET', '/api/boards/:boardId/swimlanes/:swimlaneId', function(
  435. req,
  436. res,
  437. ) {
  438. try {
  439. const paramBoardId = req.params.boardId;
  440. const paramSwimlaneId = req.params.swimlaneId;
  441. Authentication.checkBoardAccess(req.userId, paramBoardId);
  442. JsonRoutes.sendResult(res, {
  443. code: 200,
  444. data: ReactiveCache.getSwimlane({
  445. _id: paramSwimlaneId,
  446. boardId: paramBoardId,
  447. archived: false,
  448. }),
  449. });
  450. } catch (error) {
  451. JsonRoutes.sendResult(res, {
  452. code: 200,
  453. data: error,
  454. });
  455. }
  456. });
  457. /**
  458. * @operation new_swimlane
  459. *
  460. * @summary Add a swimlane to a board
  461. *
  462. * @param {string} boardId the ID of the board
  463. * @param {string} title the new title of the swimlane
  464. * @return_type {_id: string}
  465. */
  466. JsonRoutes.add('POST', '/api/boards/:boardId/swimlanes', function(req, res) {
  467. try {
  468. const paramBoardId = req.params.boardId;
  469. Authentication.checkBoardAccess(req.userId, paramBoardId);
  470. const board = ReactiveCache.getBoard(paramBoardId);
  471. const id = Swimlanes.insert({
  472. title: req.body.title,
  473. boardId: paramBoardId,
  474. sort: board.swimlanes().length,
  475. });
  476. JsonRoutes.sendResult(res, {
  477. code: 200,
  478. data: {
  479. _id: id,
  480. },
  481. });
  482. } catch (error) {
  483. JsonRoutes.sendResult(res, {
  484. code: 200,
  485. data: error,
  486. });
  487. }
  488. });
  489. /**
  490. * @operation edit_swimlane
  491. *
  492. * @summary Edit the title of a swimlane
  493. *
  494. * @param {string} boardId the ID of the board
  495. * @param {string} swimlaneId the ID of the swimlane to edit
  496. * @param {string} title the new title of the swimlane
  497. * @return_type {_id: string}
  498. */
  499. JsonRoutes.add('PUT', '/api/boards/:boardId/swimlanes/:swimlaneId', function(req, res) {
  500. try {
  501. const paramBoardId = req.params.boardId;
  502. const paramSwimlaneId = req.params.swimlaneId;
  503. Authentication.checkBoardAccess(req.userId, paramBoardId);
  504. const board = ReactiveCache.getBoard(paramBoardId);
  505. const swimlane = ReactiveCache.getSwimlane({
  506. _id: paramSwimlaneId,
  507. boardId: paramBoardId,
  508. });
  509. if (!swimlane) {
  510. throw new Meteor.Error('not-found', 'Swimlane not found');
  511. }
  512. Swimlanes.direct.update(
  513. { _id: paramSwimlaneId },
  514. { $set: { title: req.body.title } }
  515. );
  516. JsonRoutes.sendResult(res, {
  517. code: 200,
  518. data: {
  519. _id: paramSwimlaneId,
  520. },
  521. });
  522. } catch (error) {
  523. JsonRoutes.sendResult(res, {
  524. code: 200,
  525. data: error,
  526. });
  527. }
  528. });
  529. /**
  530. * @operation delete_swimlane
  531. *
  532. * @summary Delete a swimlane
  533. *
  534. * @description The swimlane will be deleted, not moved to the recycle bin
  535. *
  536. * @param {string} boardId the ID of the board
  537. * @param {string} swimlaneId the ID of the swimlane
  538. * @return_type {_id: string}
  539. */
  540. JsonRoutes.add(
  541. 'DELETE',
  542. '/api/boards/:boardId/swimlanes/:swimlaneId',
  543. function(req, res) {
  544. try {
  545. const paramBoardId = req.params.boardId;
  546. const paramSwimlaneId = req.params.swimlaneId;
  547. Authentication.checkBoardAccess(req.userId, paramBoardId);
  548. Swimlanes.remove({ _id: paramSwimlaneId, boardId: paramBoardId });
  549. JsonRoutes.sendResult(res, {
  550. code: 200,
  551. data: {
  552. _id: paramSwimlaneId,
  553. },
  554. });
  555. } catch (error) {
  556. JsonRoutes.sendResult(res, {
  557. code: 200,
  558. data: error,
  559. });
  560. }
  561. },
  562. );
  563. }
  564. // Position history tracking methods
  565. Swimlanes.helpers({
  566. /**
  567. * Track the original position of this swimlane
  568. */
  569. trackOriginalPosition() {
  570. const existingHistory = PositionHistory.findOne({
  571. boardId: this.boardId,
  572. entityType: 'swimlane',
  573. entityId: this._id,
  574. });
  575. if (!existingHistory) {
  576. PositionHistory.insert({
  577. boardId: this.boardId,
  578. entityType: 'swimlane',
  579. entityId: this._id,
  580. originalPosition: {
  581. sort: this.sort,
  582. title: this.title,
  583. },
  584. originalTitle: this.title,
  585. createdAt: new Date(),
  586. updatedAt: new Date(),
  587. });
  588. }
  589. },
  590. /**
  591. * Get the original position history for this swimlane
  592. */
  593. getOriginalPosition() {
  594. return PositionHistory.findOne({
  595. boardId: this.boardId,
  596. entityType: 'swimlane',
  597. entityId: this._id,
  598. });
  599. },
  600. /**
  601. * Check if this swimlane has moved from its original position
  602. */
  603. hasMovedFromOriginalPosition() {
  604. const history = this.getOriginalPosition();
  605. if (!history) return false;
  606. return history.originalPosition.sort !== this.sort;
  607. },
  608. /**
  609. * Get a description of the original position
  610. */
  611. getOriginalPositionDescription() {
  612. const history = this.getOriginalPosition();
  613. if (!history) return 'No original position data';
  614. return `Original position: ${history.originalPosition.sort || 0}`;
  615. },
  616. });
  617. export default Swimlanes;