swimlanes.js 14 KB

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