Commit 36a242fc authored by Robert Speicher's avatar Robert Speicher

Merge branch '3559-group-level-roadmap' into 'master'

Show Group level Roadmap

Closes #3559

See merge request gitlab-org/gitlab-ee!4361
parents ac16a3f7 05abde35
{"iconCount":191,"spriteSize":86607,"icons":["abuse","account","admin","angle-double-left","angle-double-right","angle-down","angle-left","angle-right","angle-up","appearance","applications","approval","arrow-down","arrow-right","assignee","bold","book","bookmark","branch","bullhorn","calendar","cancel","chart","chevron-down","chevron-left","chevron-right","chevron-up","clock","close","code","collapse","comment-dots","comment-next","comment","comments","commit","credit-card","cut","dashboard","disk","doc_code","doc_image","doc_text","double-headed-arrow","download","duplicate","earth","ellipsis_v","emoji_slightly_smiling_face","emoji_smile","emoji_smiley","epic","external-link","eye-slash","eye","file-addition","file-deletion","file-modified","filter","folder-o","folder-open","folder","fork","geo-nodes","git-merge","group","history","home","hook","hourglass","image-comment-dark","image-comment-light","import","issue-block","issue-child","issue-close","issue-duplicate","issue-external","issue-new","issue-open-m","issue-open","issue-parent","issues","italic","key-2","key","label","labels","leave","level-up","license","link","list-bulleted","list-numbered","location-dot","location","lock-open","lock","log","mail","menu","merge-request-close","messages","mobile-issue-close","monitor","more","notifications-off","notifications","overview","pencil-square","pencil","pipeline","play","plus-square-o","plus-square","plus","podcast","preferences","profile","project","push-rules","question-o","question","quote","redo","remove","repeat","retry","scale","screen-full","screen-normal","scroll_down","scroll_up","search","settings","shield","slight-frown","slight-smile","smile","smiley","snippet","soft-unwrap","soft-wrap","spam","spinner","staged","star-o","star","status_canceled_borderless","status_canceled","status_closed","status_created_borderless","status_created","status_failed_borderless","status_failed","status_manual_borderless","status_manual","status_notfound_borderless","status_notfound","status_open","status_pending_borderless","status_pending","status_running_borderless","status_running","status_skipped_borderless","status_skipped","status_success_borderless","status_success_solid","status_success","status_warning_borderless","status_warning","stop","task-done","template","terminal","thumb-down","thumb-up","thumbtack","timer","todo-add","todo-done","token","unapproval","unassignee","unlink","unstaged","user","users","volume-up","warning","work"]} {"iconCount":194,"spriteSize":87772,"icons":["abuse","account","admin","angle-double-left","angle-double-right","angle-down","angle-left","angle-right","angle-up","appearance","applications","approval","arrow-down","arrow-right","assignee","bold","book","bookmark","branch","bullhorn","calendar","cancel","chart","chevron-down","chevron-left","chevron-right","chevron-up","clock","close","code","collapse","comment-dots","comment-next","comment","comments","commit","compress","credit-card","cut","dashboard","disk","doc_code","doc_image","doc_text","double-headed-arrow","download","duplicate","earth","ellipsis_v","emoji_slightly_smiling_face","emoji_smile","emoji_smiley","epic","error","expand","external-link","eye-slash","eye","file-addition","file-deletion","file-modified","filter","folder-o","folder-open","folder","fork","geo-nodes","git-merge","group","history","home","hook","hourglass","image-comment-dark","image-comment-light","import","issue-block","issue-child","issue-close","issue-duplicate","issue-external","issue-new","issue-open-m","issue-open","issue-parent","issues","italic","key-2","key","label","labels","leave","level-up","license","link","list-bulleted","list-numbered","location-dot","location","lock-open","lock","log","mail","menu","merge-request-close","messages","mobile-issue-close","monitor","more","notifications-off","notifications","overview","pencil-square","pencil","pipeline","play","plus-square-o","plus-square","plus","podcast","preferences","profile","project","push-rules","question-o","question","quote","redo","remove","repeat","retry","scale","screen-full","screen-normal","scroll_down","scroll_up","search","settings","shield","slight-frown","slight-smile","smile","smiley","snippet","soft-unwrap","soft-wrap","spam","spinner","staged","star-o","star","status_canceled_borderless","status_canceled","status_closed","status_created_borderless","status_created","status_failed_borderless","status_failed","status_manual_borderless","status_manual","status_notfound_borderless","status_notfound","status_open","status_pending_borderless","status_pending","status_running_borderless","status_running","status_skipped_borderless","status_skipped","status_success_borderless","status_success_solid","status_success","status_warning_borderless","status_warning","stop","task-done","template","terminal","thumb-down","thumb-up","thumbtack","timer","todo-add","todo-done","token","unapproval","unassignee","unlink","unstaged","user","users","volume-up","warning","work"]}
\ No newline at end of file \ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
<svg xmlns="http://www.w3.org/2000/svg" width="430" height="300" viewBox="0 0 430 300"><g fill="none" fill-rule="evenodd"><g transform="translate(75 53)"><rect width="284" height="208" y="5" fill="#F9F9F9" rx="10"/><rect width="284" height="208" fill="#FFF" rx="10"/><path fill="#EEE" fill-rule="nonzero" d="M10 4a6 6 0 0 0-6 6v188a6 6 0 0 0 6 6h264a6 6 0 0 0 6-6V10a6 6 0 0 0-6-6H10zm0-4h264c5.523 0 10 4.477 10 10v188c0 5.523-4.477 10-10 10H10c-5.523 0-10-4.477-10-10V10C0 4.477 4.477 0 10 0z"/><path fill="#EEE" fill-rule="nonzero" d="M25.168 153.995c3.837-.215 7.173.028 10.119.691a3 3 0 1 0 1.318-5.853c-3.509-.79-7.4-1.074-11.773-.828a3 3 0 1 0 .336 5.99zm19.043 4.66c2.401 1.704 4.388 3.61 7.569 7.083a3 3 0 0 0 4.424-4.054c-3.448-3.763-5.686-5.911-8.522-7.923a3 3 0 1 0-3.471 4.894zm15.575 15.173c3.181 2.675 6.52 4.665 10.397 6.039a3 3 0 0 0 2.004-5.655c-3.162-1.121-5.884-2.743-8.54-4.976a3 3 0 1 0-3.861 4.592zm22.133 8.148c1.02.037 2.067.045 3.143.023a72.664 72.664 0 0 0 8.346-.638 3 3 0 1 0-.812-5.945c-2.442.334-4.996.53-7.658.585a48.55 48.55 0 0 1-2.796-.021 3 3 0 0 0-.223 5.996zm22.778-3.286c3.9-1.37 7.427-3.15 10.54-5.305a3 3 0 0 0-3.415-4.933c-2.665 1.845-5.712 3.382-9.114 4.578a3 3 0 0 0 1.989 5.66zm19.156-13.62a33.752 33.752 0 0 0 5.276-10.817 3 3 0 1 0-5.773-1.633 27.753 27.753 0 0 1-4.341 8.9 3 3 0 1 0 4.838 3.55zm6.577-22.657c-.187-3.817-.926-7.71-2.204-11.596a3 3 0 0 0-5.7 1.874c1.113 3.384 1.75 6.745 1.91 10.016a3 3 0 1 0 5.994-.294zm-7.097-22.26c-1.897-3.2-4.152-6.325-6.748-9.344a3 3 0 0 0-4.55 3.913c2.372 2.756 4.421 5.597 6.136 8.49a3 3 0 0 0 5.162-3.06zm-11.546-17.793c-.938-3.025-1.402-6.42-1.365-9.976a3 3 0 0 0-6-.063c-.043 4.163.506 8.177 1.634 11.816a3 3 0 1 0 5.731-1.777zm.053-20.107c.905-3.341 2.22-6.538 3.904-9.448a3 3 0 0 0-5.194-3.004c-1.948 3.368-3.463 7.048-4.501 10.884a3 3 0 1 0 5.791 1.568zm10.134-17.305c2.475-2.28 5.265-4.09 8.335-5.374a3 3 0 1 0-2.314-5.536c-3.725 1.558-7.105 3.75-10.086 6.497a3 3 0 1 0 4.065 4.413zm18.177-7.586c3.202-.18 6.599.092 10.18.843a3 3 0 0 0 1.23-5.872c-4.086-.857-8.009-1.172-11.747-.962a3 3 0 1 0 .337 5.99zm20.047 3.95c3.068 1.268 6.232 2.842 9.487 4.728a3 3 0 0 0 3.009-5.191c-3.48-2.017-6.883-3.71-10.204-5.083a3 3 0 1 0-2.292 5.545zm19.578 9.955c3.711 1.586 7.376 2.77 10.997 3.565a3 3 0 0 0 1.286-5.86c-3.248-.713-6.555-1.782-9.925-3.222a3 3 0 1 0-2.358 5.517zm22.591 4.789c3.94-.04 7.808-.553 11.61-1.513a3 3 0 1 0-1.468-5.817 43.358 43.358 0 0 1-10.203 1.33 3 3 0 0 0 .061 6zm22.52-5.558c3.335-1.637 6.607-3.613 9.845-5.916a3 3 0 1 0-3.477-4.89c-2.984 2.122-5.98 3.931-9.011 5.42a3 3 0 1 0 2.643 5.386zm18.678-13.054a3 3 0 0 1-4.02-4.454 130.547 130.547 0 0 0 5.31-5.088 3 3 0 1 1 4.265 4.22 136.507 136.507 0 0 1-5.555 5.322zm-48.722 25.641a3 3 0 1 1 4.314-4.17c3.056 3.16 5.075 6.744 6.172 10.754a3 3 0 0 1-5.787 1.584c-.834-3.047-2.35-5.739-4.699-8.168zm5.347 18.049a3 3 0 1 1 5.978.52c-.282 3.232-.805 6.273-1.832 11.206a3 3 0 0 1-5.874-1.222c.981-4.717 1.473-7.572 1.728-10.504zm-3.777 21.555a3 3 0 0 1 5.953.747c-.5 3.988-.397 7.09.399 9.67a3 3 0 1 1-5.733 1.769c-1.087-3.52-1.217-7.426-.62-12.186zm7.393 22.444a3 3 0 0 1 4.461-4.013c2.703 3.005 5.224 5.296 7.594 6.947a3 3 0 0 1-3.429 4.924c-2.775-1.932-5.632-4.53-8.626-7.858zm20.352 12.28a3 3 0 1 1 .334-5.99c2.77.154 5.453-.554 9.224-2.254a3 3 0 0 1 2.466 5.47c-4.57 2.06-8.103 2.993-12.024 2.775zm21.784-7.058a3 3 0 0 1-1.815-5.719c4.227-1.342 8.24-1.61 12.496-.572a3 3 0 0 1-1.421 5.83c-3.116-.76-6.025-.566-9.26.46zM106.53 56.038a3 3 0 1 1-3.45 4.909c-1.074-.755-6.723-6.044-8.083-7.204a68.019 68.019 0 0 0-.332-.281 3 3 0 1 1 3.865-4.59l.362.306c1.643 1.402 6.971 6.391 7.638 6.86zM88.536 42.422a3 3 0 0 1-2.285 5.548c-3.14-1.293-5.78-1.34-8.105-.05a3 3 0 0 1-2.91-5.247c4.087-2.266 8.597-2.187 13.3-.25zM66.698 48.73a3 3 0 0 1 2.029 5.647c-4.432 1.592-8.786.835-13.166-1.88a3 3 0 1 1 3.16-5.1c2.93 1.816 5.425 2.25 7.977 1.333zm-15.636-8.038a3 3 0 0 1-4.352 4.13c-.911-.96-1.85-1.98-3.061-3.32-.295-.325-2.437-2.703-3.07-3.4-.47-.518-.9-.988-1.313-1.436a3 3 0 0 1 4.41-4.068c.425.46.866.942 1.346 1.47.642.709 2.79 3.092 3.076 3.41a180.865 180.865 0 0 0 2.964 3.214z"/><path fill="#E1DBF1" d="M254.66 72.196l2-3.464a2 2 0 1 0-3.464-2l-2 3.464-3.464-2a2 2 0 0 0-2 3.464l3.464 2-2 3.464a2 2 0 0 0 3.464 2l2-3.464 3.464 2a2 2 0 1 0 2-3.464l-3.464-2zm-151.904 78.732l2.829-2.828a2 2 0 0 0-2.829-2.829l-2.828 2.829-2.828-2.829a2 2 0 0 0-2.829 2.829l2.829 2.828-2.829 2.829a2 2 0 1 0 2.829 2.828l2.828-2.828 2.828 2.828a2 2 0 1 0 2.829-2.828l-2.829-2.829z"/><path fill="#6B4FBB" d="M210.66 173.66l3.464-2a2 2 0 1 0-2-3.464l-3.464 2-2-3.464a2 2 0 0 0-3.464 2l2 3.464-3.464 2a2 2 0 1 0 2 3.464l3.464-2 2 3.464a2 2 0 1 0 3.464-2l-2-3.464z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M27 181a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm0-4a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/><path fill="#C3B8E3" fill-rule="nonzero" d="M138 85a7 7 0 1 1 0-14 7 7 0 0 1 0 14zm0-4a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M200 57a7 7 0 1 1 0-14 7 7 0 0 1 0 14zm0-4a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path fill="#FC6D26" fill-rule="nonzero" d="M222.647 121.647v5h5v-5h-5zm0-4h5a4 4 0 0 1 4 4v5a4 4 0 0 1-4 4h-5a4 4 0 0 1-4-4v-5a4 4 0 0 1 4-4z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M103.647 28.647v5h5v-5h-5zm0-4h5a4 4 0 0 1 4 4v5a4 4 0 0 1-4 4h-5a4 4 0 0 1-4-4v-5a4 4 0 0 1 4-4z"/><path fill="#FC6D26" fill-rule="nonzero" d="M85 103.488L81.841 108h6.318L85 103.488zm6.436 2.218A4 4 0 0 1 88.159 112H81.84a4 4 0 0 1-3.277-6.294l3.16-4.512a4 4 0 0 1 6.553 0l3.159 4.512z"/></g><path fill="#F9F9F9" d="M334.376 99.43A48.805 48.805 0 0 0 366 111c27.062 0 49-21.938 49-49s-21.938-49-49-49-49 21.938-49 49c0 9.454 2.677 18.283 7.315 25.77l-3.05 11.306a2.5 2.5 0 0 0 3.064 3.065l10.047-2.71z"/><path fill="#FFF" d="M339.376 94.43A48.805 48.805 0 0 0 371 106c27.062 0 49-21.938 49-49S398.062 8 371 8s-49 21.938-49 49c0 9.454 2.677 18.283 7.315 25.77l-3.05 11.306a2.5 2.5 0 0 0 3.064 3.065l10.047-2.71z"/><path fill="#EEE" fill-rule="nonzero" d="M329.85 99.072a4.5 4.5 0 0 1-5.516-5.517l2.827-10.48C322.501 75.258 320 66.31 320 57c0-28.167 22.833-51 51-51s51 22.833 51 51-22.833 51-51 51c-11.859 0-23.096-4.064-32.102-11.37l-9.048 2.442zm10.817-6.169C349.091 100.027 359.737 104 371 104c25.957 0 47-21.043 47-47s-21.043-47-47-47-47 21.043-47 47c0 8.859 2.453 17.351 7.016 24.716l.456.737-3.277 12.144c.072.527.347.685.613.613l11.059-2.984.8.677z"/><g transform="translate(354 34)"><path fill="#E1DBF1" fill-rule="nonzero" d="M13 4a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-8zm0-4h8a5 5 0 0 1 5 5v1a5 5 0 0 1-5 5h-8a5 5 0 0 1-5-5V5a5 5 0 0 1 5-5z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M5 11a1 1 0 0 0 0 2h24a1 1 0 0 0 0-2H5zm0-4h24a5 5 0 0 1 0 10H5A5 5 0 0 1 5 7z"/><rect width="12" height="4" x="11" y="31" fill="#C3B8E3" rx="2"/><rect width="12" height="4" x="11" y="19" fill="#C3B8E3" rx="2"/><rect width="12" height="4" x="11" y="37" fill="#E1DBF1" rx="2"/><rect width="12" height="4" x="11" y="43" fill="#C3B8E3" rx="2"/><rect width="12" height="4" x="11" y="25" fill="#E1DBF1" rx="2"/></g><path fill="#F9F9F9" d="M344.238 225.072A38.83 38.83 0 0 1 368 217c21.54 0 39 17.46 39 39s-17.46 39-39 39-39-17.46-39-39a38.84 38.84 0 0 1 4.001-17.227l-3.737-13.85a2.5 2.5 0 0 1 3.065-3.064l11.91 3.213z"/><path fill="#FFF" d="M348.238 221.072A38.83 38.83 0 0 1 372 213c21.54 0 39 17.46 39 39s-17.46 39-39 39-39-17.46-39-39a38.84 38.84 0 0 1 4.001-17.227l-3.737-13.85a2.5 2.5 0 0 1 3.065-3.064l11.91 3.213z"/><path fill="#EEE" fill-rule="nonzero" d="M336.85 215.928a4.5 4.5 0 0 0-5.516 5.517l3.543 13.13A40.848 40.848 0 0 0 331 252c0 22.644 18.356 41 41 41s41-18.356 41-41-18.356-41-41-41a40.82 40.82 0 0 0-24.182 7.887l-10.968-2.96zm12.608 6.73A36.824 36.824 0 0 1 372 215c20.435 0 37 16.565 37 37s-16.565 37-37 37-37-16.565-37-37c0-5.747 1.31-11.304 3.795-16.343l.334-.677-3.934-14.577a.5.5 0 0 1 .613-.613l12.865 3.471.785-.604z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M356.097 255.962a7 7 0 0 0 8.81 10.88l1.093-.885v1.454a7 7 0 1 0 14 0v-1.454l1.092.885a7 7 0 1 0 8.81-10.88l-1.185-.96 1.455-.337a7 7 0 1 0-3.15-13.64l-1.4.323.623-1.278a7 7 0 0 0-12.583-6.137l-.662 1.356-.662-1.356a7 7 0 0 0-12.583 6.137l.623 1.278-1.4-.324a7 7 0 1 0-3.15 13.641l1.455.336-1.186.96zm5.464-.913a11.914 11.914 0 0 1-.444-1.95l-.19-1.362-4.2-.97a3 3 0 0 1 1.35-5.845l4.178.964.768-1.145c.373-.557.793-1.082 1.254-1.57l.95-1.006-1.877-3.849a3 3 0 0 1 5.393-2.63l1.892 3.879 1.363-.113a12.188 12.188 0 0 1 2.004 0l1.363.113 1.892-3.879a3 3 0 0 1 5.393 2.63l-1.877 3.849.95 1.006c.461.488.88 1.013 1.254 1.57l.768 1.145 4.178-.964a3 3 0 1 1 1.35 5.846l-4.2.97-.19 1.36a11.914 11.914 0 0 1-.444 1.95l-.413 1.302 3.36 2.72a3 3 0 1 1-3.776 4.663l-3.32-2.688-1.196.706a11.94 11.94 0 0 1-1.808.873l-1.286.492v4.295a3 3 0 1 1-6 0v-4.295l-1.286-.492a11.94 11.94 0 0 1-1.808-.873l-1.196-.706-3.32 2.688a3 3 0 1 1-3.776-4.663l3.36-2.72-.413-1.301z"/><path fill="#FC6D26" fill-rule="nonzero" d="M373 245.411a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm0 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/><g><path fill="#F9F9F9" d="M94.624 162.43A48.805 48.805 0 0 1 63 174c-27.062 0-49-21.938-49-49s21.938-49 49-49 49 21.938 49 49c0 9.454-2.677 18.283-7.315 25.77l3.05 11.306a2.5 2.5 0 0 1-3.064 3.065l-10.047-2.71z"/><path fill="#FFF" stroke="#EEE" stroke-width="4" d="M89.624 157.43A48.805 48.805 0 0 1 58 169c-27.062 0-49-21.938-49-49s21.938-49 49-49 49 21.938 49 49c0 9.454-2.677 18.283-7.315 25.77l3.05 11.306a2.5 2.5 0 0 1-3.064 3.065l-10.047-2.71z"/><path fill="#EEE" fill-rule="nonzero" d="M99.15 162.072a4.5 4.5 0 0 0 5.516-5.517l-2.827-10.48C106.499 138.258 109 129.31 109 120c0-28.167-22.833-51-51-51S7 91.833 7 120s22.833 51 51 51c11.859 0 23.096-4.064 32.102-11.37l9.048 2.442zm-10.817-6.169C79.909 163.027 69.263 167 58 167c-25.957 0-47-21.043-47-47s21.043-47 47-47 47 21.043 47 47c0 8.859-2.453 17.351-7.016 24.716l-.456.737 3.277 12.144c-.072.527-.347.685-.613.613l-11.059-2.984-.8.677z"/><g fill-rule="nonzero"><path fill="#FEE1D3" d="M55.47 94.47l-16.148 6.688a4 4 0 0 0-2.164 2.164l-6.689 16.147a4 4 0 0 0 0 3.062l6.689 16.147a4 4 0 0 0 2.164 2.164l16.147 6.689a4 4 0 0 0 3.062 0l16.147-6.689a4 4 0 0 0 2.164-2.164l6.689-16.147a4 4 0 0 0 0-3.062l-6.689-16.147a4 4 0 0 0-2.164-2.164L58.53 94.469a4 4 0 0 0-3.062 0zM57 98.164l16.147 6.688L79.835 121l-6.688 16.147L57 143.835l-16.147-6.688L34.165 121l6.688-16.147L57 98.165zM57 107a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm12 4a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm4 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-4 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-12 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-12-6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-4-11a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm4-11a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm12 20c6.075 0 11-4.925 11-11s-4.925-11-11-11-11 4.925-11 11 4.925 11 11 11zm0-4a7 7 0 1 1 0-14 7 7 0 0 1 0 14z"/><path fill="#FC6D26" d="M57 126.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0-3a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5z"/></g></g></g></svg> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="312" height="240" viewBox="0 0 312 240"><defs><rect id="a" width="280" height="180" x="77" y="60" rx="10"/><rect id="c" width="100" height="48" rx="10"/><filter id="b" width="105%" height="120.8%" x="-2.5%" y="-5.2%" filterUnits="objectBoundingBox"><feOffset dy="5" in="SourceAlpha" result="shadowOffsetOuter1"/><feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 1 0"/></filter><rect id="e" width="84" height="76" rx="10"/><filter id="d" width="106%" height="113.2%" x="-3%" y="-3.3%" filterUnits="objectBoundingBox"><feOffset dy="5" in="SourceAlpha" result="shadowOffsetOuter1"/><feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 1 0"/></filter><rect id="g" width="52" height="76" rx="10"/><filter id="f" width="109.6%" height="113.2%" x="-4.8%" y="-3.3%" filterUnits="objectBoundingBox"><feOffset dy="5" in="SourceAlpha" result="shadowOffsetOuter1"/><feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 1 0"/></filter><rect id="i" width="36" height="132" rx="10"/><filter id="h" width="113.9%" height="107.6%" x="-6.9%" y="-1.9%" filterUnits="objectBoundingBox"><feOffset dy="5" in="SourceAlpha" result="shadowOffsetOuter1"/><feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 1 0"/></filter><rect id="k" width="52" height="48" rx="10"/><filter id="j" width="109.6%" height="120.8%" x="-4.8%" y="-5.2%" filterUnits="objectBoundingBox"><feOffset dy="5" in="SourceAlpha" result="shadowOffsetOuter1"/><feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 1 0"/></filter></defs><g fill="none" fill-rule="evenodd" transform="translate(-57 -30)"><path d="M0 0h430v300H0z"/><use fill="#FFF" xlink:href="#a"/><rect width="276" height="176" x="79" y="62" stroke="#EEE" stroke-width="4" rx="10"/><rect width="8" height="20" x="244" y="141" fill="#EEE" rx="4"/><rect width="8" height="20" x="311" y="103" fill="#EEE" rx="4"/><rect width="8" height="20" x="272" y="166" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="257" y="203" fill="#EEE" rx="4"/><rect width="8" height="20" x="169" y="77" fill="#EEE" rx="4"/><rect width="8" height="20" x="191" y="104" fill="#FFF" rx="4"/><rect width="8" height="20" x="140" y="157" fill="#E1DBF1" rx="4"/><rect width="8" height="20" x="154" y="119" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="163" y="205" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="294" y="186" fill="#E1DBF1" rx="4"/><rect width="8" height="20" x="327" y="73" fill="#FEE1D3" rx="4"/><g transform="translate(197 79)"><use fill="#000" filter="url(#b)" xlink:href="#c"/><use fill="#FFF" xlink:href="#c"/><rect width="96" height="44" x="2" y="2" stroke="#FEE1D3" stroke-width="4" rx="10"/><rect width="8" height="20" x="62" y="14" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="78" y="14" fill="#FDC4A8" rx="4"/><rect width="8" height="20" x="46" y="14" fill="#FC6D26" rx="4"/><rect width="8" height="20" x="30" y="14" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="14" y="14" fill="#EEE" rx="4"/></g><g transform="translate(57 30)"><use fill="#000" filter="url(#d)" xlink:href="#e"/><use fill="#FFF" xlink:href="#e"/><rect width="80" height="72" x="2" y="2" stroke="#E1DBF1" stroke-width="4" rx="10"/><rect width="8" height="20" x="62" y="42" fill="#6B4FBB" rx="4"/><rect width="8" height="20" x="46" y="42" fill="#E1DBF1" rx="4"/><rect width="8" height="20" x="30" y="42" fill="#C3B8E3" rx="4"/><rect width="8" height="20" x="14" y="42" fill="#E1DBF1" rx="4"/><rect width="8" height="20" x="62" y="14" fill="#E1DBF1" rx="4"/><rect width="8" height="20" x="46" y="14" fill="#C3B8E3" rx="4"/><rect width="8" height="20" x="30" y="14" fill="#EEE" rx="4"/><rect width="8" height="20" x="14" y="14" fill="#C3B8E3" rx="4"/></g><g transform="translate(317 133)"><use fill="#000" filter="url(#f)" xlink:href="#g"/><use fill="#FFF" xlink:href="#g"/><rect width="48" height="72" x="2" y="2" stroke="#FEE1D3" stroke-width="4" rx="10"/><rect width="8" height="20" x="14" y="14" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="14" y="42" fill="#FC6D26" rx="4"/><rect width="8" height="20" x="30" y="14" fill="#EEE" rx="4"/><rect width="8" height="20" x="30" y="42" fill="#FDC4A8" rx="4"/></g><g transform="translate(89 133)"><use fill="#000" filter="url(#h)" xlink:href="#i"/><use fill="#FFF" xlink:href="#i"/><rect width="32" height="128" x="2" y="2" stroke="#FEE1D3" stroke-width="4" rx="10"/><rect width="8" height="20" x="14" y="14" fill="#FDC4A8" rx="4"/><rect width="8" height="20" x="14" y="42" fill="#EEE" rx="4"/><rect width="8" height="20" x="14" y="70" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="14" y="98" fill="#FC6D26" rx="4"/></g><g transform="translate(181 161)"><use fill="#000" filter="url(#j)" xlink:href="#k"/><use fill="#FFF" xlink:href="#k"/><rect width="48" height="44" x="2" y="2" stroke="#E1DBF1" stroke-width="4" rx="10"/><rect width="8" height="20" x="30" y="14" fill="#6B4FBB" rx="4"/><rect width="8" height="20" x="14" y="14" fill="#C3B8E3" rx="4"/></g></g></svg>
\ No newline at end of file \ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="312" height="240" viewBox="0 0 312 240"><defs><rect id="a" width="280" height="180" x="77" y="60" rx="10"/><rect id="c" width="100" height="48" rx="10"/><filter id="b" width="105%" height="120.8%" x="-2.5%" y="-5.2%" filterUnits="objectBoundingBox"><feOffset dy="5" in="SourceAlpha" result="shadowOffsetOuter1"/><feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 1 0"/></filter><rect id="e" width="84" height="76" rx="10"/><filter id="d" width="106%" height="113.2%" x="-3%" y="-3.3%" filterUnits="objectBoundingBox"><feOffset dy="5" in="SourceAlpha" result="shadowOffsetOuter1"/><feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 1 0"/></filter><rect id="g" width="52" height="76" rx="10"/><filter id="f" width="109.6%" height="113.2%" x="-4.8%" y="-3.3%" filterUnits="objectBoundingBox"><feOffset dy="5" in="SourceAlpha" result="shadowOffsetOuter1"/><feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 1 0"/></filter><rect id="i" width="36" height="132" rx="10"/><filter id="h" width="113.9%" height="107.6%" x="-6.9%" y="-1.9%" filterUnits="objectBoundingBox"><feOffset dy="5" in="SourceAlpha" result="shadowOffsetOuter1"/><feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 1 0"/></filter><rect id="k" width="52" height="48" rx="10"/><filter id="j" width="109.6%" height="120.8%" x="-4.8%" y="-5.2%" filterUnits="objectBoundingBox"><feOffset dy="5" in="SourceAlpha" result="shadowOffsetOuter1"/><feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 0 0.976470588 0 0 0 1 0"/></filter></defs><g fill="none" fill-rule="evenodd" transform="translate(-57 -30)"><path d="M0 0h430v300H0z"/><use fill="#FFF" xlink:href="#a"/><rect width="276" height="176" x="79" y="62" stroke="#EEE" stroke-width="4" rx="10"/><rect width="8" height="20" x="244" y="141" fill="#EEE" rx="4"/><rect width="8" height="20" x="311" y="103" fill="#EEE" rx="4"/><rect width="8" height="20" x="272" y="166" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="257" y="203" fill="#EEE" rx="4"/><rect width="8" height="20" x="169" y="77" fill="#EEE" rx="4"/><rect width="8" height="20" x="191" y="104" fill="#FFF" rx="4"/><rect width="8" height="20" x="140" y="157" fill="#E1DBF1" rx="4"/><rect width="8" height="20" x="154" y="119" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="163" y="205" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="294" y="186" fill="#E1DBF1" rx="4"/><rect width="8" height="20" x="327" y="73" fill="#FEE1D3" rx="4"/><g transform="translate(197 79)"><use fill="#000" filter="url(#b)" xlink:href="#c"/><use fill="#FFF" xlink:href="#c"/><rect width="96" height="44" x="2" y="2" stroke="#FEE1D3" stroke-width="4" rx="10"/><rect width="8" height="20" x="62" y="14" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="78" y="14" fill="#FDC4A8" rx="4"/><rect width="8" height="20" x="46" y="14" fill="#FC6D26" rx="4"/><rect width="8" height="20" x="30" y="14" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="14" y="14" fill="#EEE" rx="4"/></g><g transform="translate(57 30)"><use fill="#000" filter="url(#d)" xlink:href="#e"/><use fill="#FFF" xlink:href="#e"/><rect width="80" height="72" x="2" y="2" stroke="#E1DBF1" stroke-width="4" rx="10"/><rect width="8" height="20" x="62" y="42" fill="#6B4FBB" rx="4"/><rect width="8" height="20" x="46" y="42" fill="#E1DBF1" rx="4"/><rect width="8" height="20" x="30" y="42" fill="#C3B8E3" rx="4"/><rect width="8" height="20" x="14" y="42" fill="#E1DBF1" rx="4"/><rect width="8" height="20" x="62" y="14" fill="#E1DBF1" rx="4"/><rect width="8" height="20" x="46" y="14" fill="#C3B8E3" rx="4"/><rect width="8" height="20" x="30" y="14" fill="#EEE" rx="4"/><rect width="8" height="20" x="14" y="14" fill="#C3B8E3" rx="4"/></g><g transform="translate(317 133)"><use fill="#000" filter="url(#f)" xlink:href="#g"/><use fill="#FFF" xlink:href="#g"/><rect width="48" height="72" x="2" y="2" stroke="#FEE1D3" stroke-width="4" rx="10"/><rect width="8" height="20" x="14" y="14" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="14" y="42" fill="#FC6D26" rx="4"/><rect width="8" height="20" x="30" y="14" fill="#EEE" rx="4"/><rect width="8" height="20" x="30" y="42" fill="#FDC4A8" rx="4"/></g><g transform="translate(89 133)"><use fill="#000" filter="url(#h)" xlink:href="#i"/><use fill="#FFF" xlink:href="#i"/><rect width="32" height="128" x="2" y="2" stroke="#FEE1D3" stroke-width="4" rx="10"/><rect width="8" height="20" x="14" y="14" fill="#FDC4A8" rx="4"/><rect width="8" height="20" x="14" y="42" fill="#EEE" rx="4"/><rect width="8" height="20" x="14" y="70" fill="#FEE1D3" rx="4"/><rect width="8" height="20" x="14" y="98" fill="#FC6D26" rx="4"/></g><g transform="translate(181 161)"><use fill="#000" filter="url(#j)" xlink:href="#k"/><use fill="#FFF" xlink:href="#k"/><rect width="48" height="44" x="2" y="2" stroke="#E1DBF1" stroke-width="4" rx="10"/><rect width="8" height="20" x="30" y="14" fill="#6B4FBB" rx="4"/><rect width="8" height="20" x="14" y="14" fill="#C3B8E3" rx="4"/></g></g></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" width="415" height="289" viewBox="0 0 415 289"><g fill="none" fill-rule="evenodd"><path d="M-9-5h430v300H-9z"/><g transform="translate(68 47)"><rect width="284" height="208" y="5" fill="#F9F9F9" fill-rule="nonzero" rx="10"/><rect width="284" height="208" fill="#FFF" fill-rule="nonzero" rx="10"/><path fill="#EEE" fill-rule="nonzero" d="M10 4a6 6 0 0 0-6 6v188a6 6 0 0 0 6 6h264a6 6 0 0 0 6-6V10a6 6 0 0 0-6-6H10zm0-4h264c5.523 0 10 4.477 10 10v188c0 5.523-4.477 10-10 10H10c-5.523 0-10-4.477-10-10V10C0 4.477 4.477 0 10 0z"/><path fill="#E1DBF1" d="M25.168 153.995c3.837-.215 7.173.028 10.119.691a3 3 0 1 0 1.318-5.853c-3.509-.79-7.4-1.074-11.773-.828a3 3 0 1 0 .336 5.99z"/><path fill="#C3B8E3" d="M44.211 158.655c2.401 1.704 4.388 3.61 7.569 7.083a3 3 0 1 0 4.424-4.054c-3.448-3.763-5.686-5.911-8.522-7.923a3 3 0 1 0-3.471 4.894z"/><path fill="#E1DBF1" d="M59.786 173.828c3.181 2.675 6.52 4.665 10.397 6.039a3 3 0 0 0 2.004-5.655c-3.162-1.121-5.884-2.743-8.54-4.976a3 3 0 1 0-3.861 4.592zm22.133 8.148c1.02.037 2.067.045 3.143.023a72.664 72.664 0 0 0 8.346-.638 3 3 0 0 0-.812-5.945c-2.442.334-4.996.53-7.658.585a48.55 48.55 0 0 1-2.796-.021 3 3 0 1 0-.223 5.996z"/><path fill="#C3B8E3" d="M104.697 178.69c3.9-1.37 7.427-3.15 10.54-5.305a3 3 0 0 0-3.415-4.933c-2.665 1.845-5.712 3.382-9.114 4.578a3 3 0 0 0 1.989 5.66z"/><path fill="#E1DBF1" d="M123.853 165.07a33.752 33.752 0 0 0 5.276-10.817 3 3 0 1 0-5.773-1.633 27.753 27.753 0 0 1-4.341 8.9 3 3 0 0 0 4.838 3.55zm6.577-22.657c-.187-3.817-.926-7.71-2.204-11.596a3 3 0 1 0-5.7 1.874c1.113 3.384 1.75 6.745 1.91 10.016a3 3 0 1 0 5.994-.294z"/><path fill="#C3B8E3" d="M123.333 120.153c-1.897-3.2-4.152-6.325-6.748-9.344a3 3 0 0 0-4.55 3.913c2.372 2.756 4.421 5.597 6.136 8.49a3 3 0 1 0 5.162-3.06v.001z"/><path fill="#E1DBF1" d="M111.787 102.36c-.938-3.025-1.402-6.42-1.365-9.976a3 3 0 1 0-6-.063c-.043 4.163.506 8.177 1.634 11.816a3 3 0 1 0 5.731-1.777z"/><path fill="#C3B8E3" d="M111.84 82.253c.905-3.341 2.22-6.538 3.904-9.448a3 3 0 0 0-5.194-3.004c-1.948 3.368-3.463 7.048-4.501 10.884a3 3 0 1 0 5.791 1.568z"/><path fill="#E1DBF1" d="M121.974 64.948c2.475-2.28 5.265-4.09 8.335-5.374a3 3 0 1 0-2.314-5.536c-3.725 1.558-7.105 3.75-10.086 6.497a3 3 0 1 0 4.065 4.413zm18.177-7.586c3.202-.18 6.599.092 10.18.843a3 3 0 0 0 1.23-5.872c-4.086-.857-8.009-1.172-11.747-.962a3 3 0 1 0 .337 5.99v.001z"/><path fill="#C3B8E3" d="M160.198 61.312c3.068 1.268 6.232 2.842 9.487 4.728a3 3 0 1 0 3.009-5.191c-3.48-2.017-6.883-3.71-10.204-5.083a3 3 0 0 0-2.292 5.545v.001z"/><path fill="#E1DBF1" d="M179.776 71.267c3.711 1.586 7.376 2.77 10.997 3.565a3 3 0 0 0 1.286-5.86c-3.248-.713-6.555-1.782-9.925-3.222a3 3 0 1 0-2.358 5.517zm22.591 4.789c3.94-.04 7.808-.553 11.61-1.513a3 3 0 1 0-1.468-5.817 43.358 43.358 0 0 1-10.203 1.33 3 3 0 0 0 .061 6z"/><path fill="#C3B8E3" d="M224.887 70.498c3.335-1.637 6.607-3.613 9.845-5.916a3 3 0 0 0-3.477-4.89c-2.984 2.122-5.98 3.931-9.011 5.42a3 3 0 1 0 2.643 5.386z"/><path fill="#E1DBF1" d="M243.565 57.444a3 3 0 0 1-4.02-4.454 130.547 130.547 0 0 0 5.31-5.088 3 3 0 1 1 4.265 4.22 136.506 136.506 0 0 1-5.555 5.322z"/><path fill="#FDC4A8" d="M194.843 83.085a3 3 0 1 1 4.314-4.17c3.056 3.16 5.075 6.744 6.172 10.754a3 3 0 0 1-5.787 1.584c-.834-3.047-2.35-5.739-4.699-8.168z"/><path fill="#FEE1D3" d="M200.19 101.134a3 3 0 1 1 5.978.52c-.282 3.232-.805 6.273-1.832 11.206a3 3 0 0 1-5.874-1.222c.981-4.717 1.473-7.572 1.728-10.504z"/><path fill="#FDC4A8" d="M196.413 122.689a3 3 0 0 1 5.953.747c-.5 3.988-.397 7.09.399 9.67a3 3 0 1 1-5.733 1.769c-1.087-3.52-1.217-7.426-.62-12.186h.001z"/><path fill="#FEE1D3" d="M203.806 145.133a3 3 0 0 1 4.461-4.013c2.703 3.005 5.224 5.296 7.594 6.947a3 3 0 0 1-3.429 4.924c-2.775-1.932-5.632-4.53-8.626-7.858zm20.352 12.28a3 3 0 1 1 .334-5.99c2.77.154 5.453-.554 9.224-2.254a3 3 0 0 1 2.466 5.47c-4.57 2.06-8.103 2.993-12.024 2.775v-.001z"/><path fill="#FDC4A8" d="M245.942 150.355a3 3 0 0 1-1.815-5.719c4.227-1.342 8.24-1.61 12.496-.572a3 3 0 0 1-1.421 5.83c-3.116-.76-6.025-.566-9.26.46v.001zM106.53 56.038a3 3 0 0 1-3.45 4.909c-1.074-.755-6.723-6.044-8.083-7.204l-.332-.281a3 3 0 1 1 3.865-4.59l.362.306c1.643 1.402 6.971 6.391 7.638 6.86z"/><path fill="#FEE1D3" d="M88.536 42.422a3 3 0 0 1-2.285 5.548c-3.14-1.293-5.78-1.34-8.105-.05a3 3 0 0 1-2.91-5.247c4.087-2.266 8.597-2.187 13.3-.25v-.001z"/><path fill="#FDC4A8" d="M66.698 48.73a3 3 0 0 1 2.029 5.647c-4.432 1.592-8.786.835-13.166-1.88a3 3 0 1 1 3.16-5.1c2.93 1.816 5.425 2.25 7.977 1.333z"/><path fill="#FEE1D3" d="M51.062 40.692a3 3 0 0 1-4.352 4.13c-.911-.96-1.85-1.98-3.061-3.32-.295-.325-2.437-2.703-3.07-3.4-.47-.518-.9-.988-1.313-1.436a3 3 0 0 1 4.41-4.068c.425.46.866.942 1.346 1.47.642.709 2.79 3.092 3.076 3.41a180.865 180.865 0 0 0 2.964 3.214z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M254.66 72.196l2-3.464a2 2 0 1 0-3.464-2l-2 3.464-3.464-2a2 2 0 0 0-2 3.464l3.464 2-2 3.464a2 2 0 0 0 3.464 2l2-3.464 3.464 2a2 2 0 1 0 2-3.464l-3.464-2zm-151.904 78.732l2.829-2.828a2 2 0 1 0-2.829-2.829l-2.828 2.829-2.828-2.829a2 2 0 1 0-2.829 2.829l2.829 2.828-2.829 2.829a2 2 0 0 0 2.829 2.828l2.828-2.828 2.828 2.828a2 2 0 1 0 2.829-2.828l-2.829-2.829z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M210.66 173.66l3.464-2a2 2 0 1 0-2-3.464l-3.464 2-2-3.464a2 2 0 0 0-3.464 2l2 3.464-3.464 2a2 2 0 1 0 2 3.464l3.464-2 2 3.464a2 2 0 1 0 3.464-2l-2-3.464z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M27 181a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm0-4a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/><path fill="#C3B8E3" fill-rule="nonzero" d="M138 85a7 7 0 1 1 0-14 7 7 0 0 1 0 14zm0-4a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M200 57a7 7 0 1 1 0-14 7 7 0 0 1 0 14zm0-4a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path fill="#FC6D26" fill-rule="nonzero" d="M222.647 121.647v5h5v-5h-5zm0-4h5a4 4 0 0 1 4 4v5a4 4 0 0 1-4 4h-5a4 4 0 0 1-4-4v-5a4 4 0 0 1 4-4z"/><path fill="#FEE1D3" fill-rule="nonzero" d="M103.647 28.647v5h5v-5h-5zm0-4h5a4 4 0 0 1 4 4v5a4 4 0 0 1-4 4h-5a4 4 0 0 1-4-4v-5a4 4 0 0 1 4-4z"/><path fill="#FC6D26" fill-rule="nonzero" d="M85 103.488L81.841 108h6.318L85 103.488zm6.436 2.218A4 4 0 0 1 88.159 112H81.84a4 4 0 0 1-3.277-6.294l3.16-4.512a4 4 0 0 1 6.553 0l3.159 4.512h.001z"/></g><path fill="#F9F9F9" fill-rule="nonzero" d="M327.376 93.43A48.805 48.805 0 0 0 359 105c27.062 0 49-21.938 49-49S386.062 7 359 7s-49 21.938-49 49c0 9.454 2.677 18.283 7.315 25.77l-3.05 11.306a2.5 2.5 0 0 0 3.064 3.065l10.047-2.71v-.001z"/><path fill="#FFF" fill-rule="nonzero" d="M332.376 88.43A48.805 48.805 0 0 0 364 100c27.062 0 49-21.938 49-49S391.062 2 364 2s-49 21.938-49 49c0 9.454 2.677 18.283 7.315 25.77l-3.05 11.306a2.5 2.5 0 0 0 3.064 3.065l10.047-2.71v-.001z"/><path fill="#EEE" fill-rule="nonzero" d="M322.85 93.072a4.5 4.5 0 0 1-5.516-5.517l2.827-10.48C315.501 69.258 313 60.31 313 51c0-28.167 22.833-51 51-51s51 22.833 51 51-22.833 51-51 51c-11.859 0-23.096-4.064-32.102-11.37l-9.048 2.442zm10.817-6.169C342.091 94.027 352.737 98 364 98c25.957 0 47-21.043 47-47S389.957 4 364 4s-47 21.043-47 47c0 8.859 2.453 17.351 7.016 24.716l.456.737-3.277 12.144c.072.527.347.685.613.613l11.059-2.984.8.677z"/><g fill-rule="nonzero" transform="translate(347 28)"><path fill="#E1DBF1" d="M13 4a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-8zm0-4h8a5 5 0 0 1 5 5v1a5 5 0 0 1-5 5h-8a5 5 0 0 1-5-5V5a5 5 0 0 1 5-5z"/><path fill="#6B4FBB" d="M5 11a1 1 0 0 0 0 2h24a1 1 0 0 0 0-2H5zm0-4h24a5 5 0 0 1 0 10H5A5 5 0 0 1 5 7z"/><rect width="12" height="4" x="11" y="31" fill="#C3B8E3" rx="2"/><rect width="12" height="4" x="11" y="19" fill="#C3B8E3" rx="2"/><rect width="12" height="4" x="11" y="37" fill="#E1DBF1" rx="2"/><rect width="12" height="4" x="11" y="43" fill="#C3B8E3" rx="2"/><rect width="12" height="4" x="11" y="25" fill="#E1DBF1" rx="2"/></g><g fill-rule="nonzero"><path fill="#F9F9F9" d="M337.238 219.072A38.83 38.83 0 0 1 361 211c21.54 0 39 17.46 39 39s-17.46 39-39 39-39-17.46-39-39a38.84 38.84 0 0 1 4.001-17.227l-3.737-13.85a2.5 2.5 0 0 1 3.065-3.064l11.91 3.213h-.001z"/><path fill="#FFF" d="M341.238 215.072A38.83 38.83 0 0 1 365 207c21.54 0 39 17.46 39 39s-17.46 39-39 39-39-17.46-39-39a38.84 38.84 0 0 1 4.001-17.227l-3.737-13.85a2.5 2.5 0 0 1 3.065-3.064l11.91 3.213h-.001z"/><path fill="#EEE" d="M329.85 209.928a4.5 4.5 0 0 0-5.516 5.517l3.543 13.13A40.848 40.848 0 0 0 324 246c0 22.644 18.356 41 41 41s41-18.356 41-41-18.356-41-41-41a40.82 40.82 0 0 0-24.182 7.887l-10.968-2.96v.001zm12.608 6.73A36.824 36.824 0 0 1 365 209c20.435 0 37 16.565 37 37s-16.565 37-37 37-37-16.565-37-37c0-5.747 1.31-11.304 3.795-16.343l.334-.677-3.934-14.577a.5.5 0 0 1 .613-.613l12.865 3.471.785-.604v.001z"/><path fill="#FEE1D3" d="M348.097 250.962a7 7 0 0 0 8.81 10.88l1.093-.885v1.454a7 7 0 0 0 14 0v-1.454l1.092.885a7 7 0 1 0 8.81-10.88l-1.185-.96 1.455-.337a7 7 0 1 0-3.15-13.64l-1.4.323.623-1.278a7 7 0 0 0-12.583-6.137l-.662 1.356-.662-1.356a7 7 0 0 0-12.583 6.137l.623 1.278-1.4-.324a7 7 0 1 0-3.15 13.641l1.455.336-1.186.96v.001zm5.464-.913a11.914 11.914 0 0 1-.444-1.95l-.19-1.362-4.2-.97a3 3 0 0 1 1.35-5.845l4.178.964.768-1.145c.373-.557.793-1.082 1.254-1.57l.95-1.006-1.877-3.849a3 3 0 0 1 5.393-2.63l1.892 3.879 1.363-.113a12.188 12.188 0 0 1 2.004 0l1.363.113 1.892-3.879a3 3 0 0 1 5.393 2.63l-1.877 3.849.95 1.006c.461.488.88 1.013 1.254 1.57l.768 1.145 4.178-.964a3 3 0 1 1 1.35 5.846l-4.2.97-.19 1.36a11.914 11.914 0 0 1-.444 1.95l-.413 1.302 3.36 2.72a3 3 0 1 1-3.776 4.663l-3.32-2.688-1.196.706a11.94 11.94 0 0 1-1.808.873l-1.286.492v4.295a3 3 0 0 1-6 0v-4.295l-1.286-.492a11.94 11.94 0 0 1-1.808-.873l-1.196-.706-3.32 2.688a3 3 0 0 1-3.776-4.663l3.36-2.72-.413-1.301z"/><path fill="#FC6D26" d="M365 240.411a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm0 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/></g><g fill-rule="nonzero"><path fill="#F9F9F9" d="M87.624 156.43A48.805 48.805 0 0 1 56 168c-27.062 0-49-21.938-49-49s21.938-49 49-49 49 21.938 49 49c0 9.454-2.677 18.283-7.315 25.77l3.05 11.306a2.5 2.5 0 0 1-3.064 3.065l-10.047-2.71v-.001z"/><path fill="#FFF" stroke="#EEE" stroke-width="4" d="M82.624 151.43A48.805 48.805 0 0 1 51 163c-27.062 0-49-21.938-49-49s21.938-49 49-49 49 21.938 49 49c0 9.454-2.677 18.283-7.315 25.77l3.05 11.306a2.5 2.5 0 0 1-3.064 3.065l-10.047-2.71v-.001z"/><path fill="#EEE" d="M92.15 156.072a4.5 4.5 0 0 0 5.516-5.517l-2.827-10.48C99.499 132.258 102 123.31 102 114c0-28.167-22.833-51-51-51S0 85.833 0 114s22.833 51 51 51c11.859 0 23.096-4.064 32.102-11.37l9.048 2.442zm-10.817-6.169C72.909 157.027 62.263 161 51 161c-25.957 0-47-21.043-47-47s21.043-47 47-47 47 21.043 47 47c0 8.859-2.453 17.351-7.016 24.716l-.456.737 3.277 12.144c-.072.527-.347.685-.613.613l-11.059-2.984-.8.677z"/><path fill="#FEE1D3" d="M48.47 88.47l-16.148 6.688a4 4 0 0 0-2.164 2.164l-6.689 16.147a4 4 0 0 0 0 3.062l6.689 16.147a4 4 0 0 0 2.164 2.164l16.147 6.689a4 4 0 0 0 3.062 0l16.147-6.689a4 4 0 0 0 2.164-2.164l6.689-16.147a4 4 0 0 0 0-3.062l-6.689-16.147a4 4 0 0 0-2.164-2.164L51.53 88.469a4 4 0 0 0-3.062 0l.002.001zM50 92.164l16.147 6.688L72.835 115l-6.688 16.147L50 137.835l-16.147-6.688L27.165 115l6.688-16.147L50 92.165v-.001zM50 101a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm12 4a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm4 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-4 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-12 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-12-6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-4-11a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm4-11a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm12 20c6.075 0 11-4.925 11-11s-4.925-11-11-11-11 4.925-11 11 4.925 11 11 11zm0-4a7 7 0 1 1 0-14 7 7 0 0 1 0 14z"/><path fill="#FC6D26" d="M50 120.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0-3a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5z"/></g></g></svg>
\ No newline at end of file
<svg width="430" height="267" viewBox="0 0 430 267" xmlns="http://www.w3.org/2000/svg"><title>prometheus-graphs_empty</title><g fill="none" fill-rule="evenodd"><path d="M187 43a2 2 0 0 1 0-4h8a2 2 0 0 1 0 4h-8zm18 0a2 2 0 0 1 0-4h8a2 2 0 0 1 0 4h-8zm18 0a2 2 0 0 1 0-4h8a2 2 0 0 1 0 4h-8zm18 0a2 2 0 0 1 0-4h8a2 2 0 0 1 0 4h-8zm18 0a2 2 0 0 1 0-4h8a2 2 0 0 1 0 4h-8zm18 0a2 2 0 0 1 0-4h8a2 2 0 0 1 0 4h-8zm18 0a2 2 0 0 1 0-4h8a2 2 0 0 1 0 4h-8zm18 0a2 2 0 0 1 0-4h8a2 2 0 0 1 0 4h-8zm15.769 2.457a2 2 0 0 1 2.883-2.772A11.964 11.964 0 0 1 335 51v.39a2 2 0 0 1-4 0V51a7.965 7.965 0 0 0-2.231-5.543zm2.225 159.857a2 2 0 0 1 3.997.154 11.973 11.973 0 0 1-4.02 8.503 2 2 0 0 1-2.658-2.99 7.974 7.974 0 0 0 2.681-5.667zM320.218 213a2 2 0 1 1 0 4h-8a2 2 0 0 1 0-4h8zm-18 0a2 2 0 1 1 0 4h-8a2 2 0 0 1 0-4h8zm-18 0a2 2 0 1 1 0 4h-8a2 2 0 0 1 0-4h8zm-18 0a2 2 0 1 1 0 4h-8a2 2 0 0 1 0-4h8zm-18 0a2 2 0 1 1 0 4h-8a2 2 0 0 1 0-4h8zm-18 0a2 2 0 1 1 0 4h-8a2 2 0 0 1 0-4h8zm-18 0a2 2 0 1 1 0 4h-8a2 2 0 0 1 0-4h8zm-18 0a2 2 0 1 1 0 4h-8a2 2 0 0 1 0-4h8zm-18 0a2 2 0 1 1 0 4h-8a2 2 0 0 1 0-4h8zm-18 0a2 2 0 1 1 0 4h-8a2 2 0 0 1 0-4h8zM89 155.827a2 2 0 1 1-4 0v-8a2 2 0 0 1 4 0v8zm0-18a2 2 0 1 1-4 0v-8a2 2 0 0 1 4 0v8z" fill="#EEE" fill-rule="nonzero"/><path d="M175.116 125.116A10.002 10.002 0 0 1 166 131H54c-5.523 0-10-4.477-10-10V31a10 10 0 0 1 5.884-9.116A9.964 9.964 0 0 0 49 26v90c0 5.523 4.477 10 10 10h112c1.467 0 2.86-.316 4.116-.884z" fill="#F9F9F9"/><path d="M59 20a6 6 0 0 0-6 6v90a6 6 0 0 0 6 6h112a6 6 0 0 0 6-6V26a6 6 0 0 0-6-6H59zm0-4h112c5.523 0 10 4.477 10 10v90c0 5.523-4.477 10-10 10H59c-5.523 0-10-4.477-10-10V26c0-5.523 4.477-10 10-10z" fill="#EEE" fill-rule="nonzero"/><path d="M73 58h4a3 3 0 0 1 3 3v45H70V61a3 3 0 0 1 3-3z" fill="#EFEDF8"/><path d="M93 70h4a3 3 0 0 1 3 3v33H90V73a3 3 0 0 1 3-3z" fill="#E1DBF1"/><path d="M153 70h4a3 3 0 0 1 3 3v33h-10V73a3 3 0 0 1 3-3z" fill="#C3B8E3"/><path d="M133 89h4a3 3 0 0 1 3 3v14h-10V92a3 3 0 0 1 3-3z" fill="#EFEDF8"/><path d="M113 46h4a3 3 0 0 1 3 3v57h-10V49a3 3 0 0 1 3-3z" fill="#6B4FBB"/><path d="M385.4 191.418A10.004 10.004 0 0 1 376 198H221c-5.523 0-10-4.477-10-10V74a9.992 9.992 0 0 1 4.6-8.418A9.981 9.981 0 0 0 215 69v114c0 5.523 4.477 10 10 10h155c1.99 0 3.843-.58 5.4-1.582z" fill="#F9F9F9" opacity=".99"/><path d="M225 63a6 6 0 0 0-6 6v114a6 6 0 0 0 6 6h155a6 6 0 0 0 6-6V69a6 6 0 0 0-6-6H225zm0-4h155c5.523 0 10 4.477 10 10v114c0 5.523-4.477 10-10 10H225c-5.523 0-10-4.477-10-10V69c0-5.523 4.477-10 10-10z" fill="#EEE" fill-rule="nonzero"/><g transform="translate(238 92)"><path d="M44.584 31.917L28.808 49.434a2 2 0 0 1-2.695.255L3.791 32.749a2 2 0 0 1 2.418-3.186l20.858 15.828 15.966-17.73a2 2 0 0 1 2.912-.064l16.09 16.344L77.35.058c.641-1.838 3.263-1.77 3.808.098l19.827 67.92 22.3-37.106a2 2 0 0 1 3.43 2.06l-24.656 41.024c-.898 1.494-3.145 1.204-3.634-.47L79.07 7.273l-14.315 41.02a2 2 0 0 1-3.314.745L44.584 31.917z" fill="#FEF0E8" fill-rule="nonzero"/><circle fill="#FEE1D3" cx="100" cy="71" r="4"/><circle fill="#FDC4A8" cx="44" cy="30" r="4"/><circle stroke="#FC6D26" stroke-width="4" fill="#FFF" cx="5" cy="32" r="5"/><circle stroke="#FC6D26" stroke-width="4" fill="#FFF" cx="125" cy="32" r="5"/></g><path d="M80.41 166.41C72.69 175.07 68 186.486 68 199c0 27.062 21.938 49 49 49 12.513 0 23.93-4.69 32.59-12.41C140.618 245.66 127.55 252 113 252c-27.062 0-49-21.938-49-49 0-14.549 6.34-27.617 16.41-36.59z" fill="#F9F9F9"/><path d="M117 244c24.853 0 45-20.147 45-45s-20.147-45-45-45-45 20.147-45 45 20.147 45 45 45zm0 4c-27.062 0-49-21.938-49-49s21.938-49 49-49 49 21.938 49 49-21.938 49-49 49z" fill="#EEE" fill-rule="nonzero"/><path d="M117 223c-13.255 0-24-10.745-24-24s10.745-24 24-24 24 10.745 24 24-10.745 24-24 24zm0-12c6.627 0 12-5.373 12-12s-5.373-12-12-12-12 5.373-12 12 5.373 12 12 12z" fill="#FEF0E8"/><path d="M117 175c13.255 0 24 10.745 24 24 0 .674-.028 1.34-.082 2h-12.084c.11-.65.166-1.319.166-2 0-6.627-5.373-12-12-12v-12z" fill="#FDC4A8"/></g></svg>
\ No newline at end of file
---
title: Show Group level Roadmap
merge_request: 4361
author:
type: added
...@@ -96,7 +96,9 @@ constraints(GroupUrlConstrainer.new) do ...@@ -96,7 +96,9 @@ constraints(GroupUrlConstrainer.new) do
path path
end end
get 'boards(/*extra_params)', as: :legacy_ee_group_boards_redirect, to: legacy_ee_group_boards_redirect get 'boards(/*extra_params)', as: :legacy_ee_group_boards_redirect, to: legacy_ee_group_boards_redirect
## EE-specific ## EE-specific
resource :roadmap, only: [:show], controller: 'roadmap'
end end
scope(path: '*id', scope(path: '*id',
......
...@@ -101,6 +101,7 @@ var config = { ...@@ -101,6 +101,7 @@ var config = {
service_desk: './projects/settings_service_desk/service_desk_bundle.js', service_desk: './projects/settings_service_desk/service_desk_bundle.js',
service_desk_issues: './service_desk_issues/index.js', service_desk_issues: './service_desk_issues/index.js',
registry_list: './registry/index.js', registry_list: './registry/index.js',
roadmap: 'ee/roadmap',
ide: './ide/index.js', ide: './ide/index.js',
sidebar: './sidebar/sidebar_bundle.js', sidebar: './sidebar/sidebar_bundle.js',
ee_sidebar: 'ee/sidebar/sidebar_bundle.js', ee_sidebar: 'ee/sidebar/sidebar_bundle.js',
......
<script>
import _ from 'underscore';
import Flash from '~/flash';
import { s__ } from '~/locale';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import epicsListEmpty from './epics_list_empty.vue';
import roadmapShell from './roadmap_shell.vue';
export default {
components: {
loadingIcon,
epicsListEmpty,
roadmapShell,
},
props: {
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
emptyStateIllustrationPath: {
type: String,
required: true,
},
},
data() {
return {
isLoading: true,
isEpicsListEmpty: false,
hasError: false,
handleResizeThrottled: {},
};
},
computed: {
epics() {
return this.store.getEpics();
},
timeframe() {
return this.store.getTimeframe();
},
timeframeStart() {
return this.timeframe[0];
},
timeframeEnd() {
const last = this.timeframe.length - 1;
return this.timeframe[last];
},
currentGroupId() {
return this.store.getCurrentGroupId();
},
showRoadmap() {
return !this.hasError && !this.isLoading && !this.isEpicsListEmpty;
},
},
mounted() {
this.fetchEpics();
this.handleResizeThrottled = _.throttle(this.handleResize, 600);
window.addEventListener('resize', this.handleResizeThrottled, false);
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResizeThrottled, false);
},
methods: {
fetchEpics() {
this.hasError = false;
this.service.getEpics()
.then(res => res.data)
.then((epics) => {
this.isLoading = false;
if (epics.length) {
this.store.setEpics(epics);
} else {
this.isEpicsListEmpty = true;
}
})
.catch(() => {
this.isLoading = false;
this.hasError = true;
Flash(s__('GroupRoadmap|Something went wrong while fetching epics'));
});
},
/**
* Roadmap view works with absolute sizing and positioning
* of following child components of RoadmapShell;
*
* - RoadmapTimelineSection
* - TimelineTodayIndicator
* - EpicItemTimeline
*
* And hence when window is resized, any size attributes passed
* down to child components are no longer valid, so best approach
* to refresh entire app is to re-render it on resize, hence
* we toggle `isLoading` variable which is bound to `RoadmapShell`.
*/
handleResize() {
this.isLoading = true;
// We need to debounce the toggle to make sure loading animation
// shows up while app is being rerendered.
_.debounce(() => {
this.isLoading = false;
}, 200)();
},
},
};
</script>
<template>
<div class="roadmap-container">
<loading-icon
class="loading-animation prepend-top-20 append-bottom-20"
size="2"
v-if="isLoading"
:label="s__('GroupRoadmap|Loading roadmap')"
/>
<roadmap-shell
v-if="showRoadmap"
:epics="epics"
:timeframe="timeframe"
:current-group-id="currentGroupId"
/>
<epics-list-empty
v-if="isEpicsListEmpty"
:timeframe-start="timeframeStart"
:timeframe-end="timeframeEnd"
:empty-state-illustration-path="emptyStateIllustrationPath"
/>
</div>
</template>
<script>
import epicItemDetails from './epic_item_details.vue';
import epicItemTimeline from './epic_item_timeline.vue';
export default {
components: {
epicItemDetails,
epicItemTimeline,
},
props: {
epic: {
type: Object,
required: true,
},
timeframe: {
type: Array,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
shellWidth: {
type: Number,
required: true,
},
},
};
</script>
<template>
<tr class="epics-list-item">
<epic-item-details
:epic="epic"
:current-group-id="currentGroupId"
/>
<epic-item-timeline
v-for="(timeframeItem, index) in timeframe"
:key="index"
:timeframe="timeframe"
:timeframe-item="timeframeItem"
:epic="epic"
:shell-width="shellWidth"
/>
</tr>
</template>
<script>
import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
props: {
epic: {
type: Object,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
},
computed: {
isEpicGroupDifferent() {
return this.currentGroupId !== this.epic.groupId;
},
/**
* In case Epic start date is out of range
* we need to use original date instead of proxy date
*/
startDate() {
if (this.epic.startDateOutOfRange) {
return this.epic.originalStartDate;
}
return this.epic.startDate;
},
/**
* In case Epic end date is out of range
* we need to use original date instead of proxy date
*/
endDate() {
if (this.epic.endDateOutOfRange) {
return this.epic.originalEndDate;
}
return this.epic.endDate;
},
/**
* Compose timeframe string to show on UI
* based on start and end date availability
*/
timeframeString() {
if (this.epic.startDateUndefined) {
return sprintf(s__('GroupRoadmap|Until %{dateWord}'), {
dateWord: dateInWords(this.endDate, true),
});
} else if (this.epic.endDateUndefined) {
return sprintf(s__('GroupRoadmap|From %{dateWord}'), {
dateWord: dateInWords(this.startDate, true),
});
}
// In case both start and end date fall in same year
// We should hide year from start date
const startDateInWords = dateInWords(
this.startDate,
true,
this.startDate.getFullYear() === this.endDate.getFullYear(),
);
return `${startDateInWords} &ndash; ${dateInWords(this.endDate, true)}`;
},
},
};
</script>
<template>
<td class="epic-details-cell">
<div class="epic-title">
<a
v-tooltip
data-container="body"
class="epic-url"
:href="epic.webUrl"
:title="epic.title"
>
{{ epic.title }}
</a>
</div>
<div class="epic-group-timeframe">
<span
v-tooltip
v-if="isEpicGroupDifferent"
class="epic-group"
data-placement="right"
data-container="body"
:title="epic.groupFullName"
>
{{ epic.groupName }} &middot;
</span>
<span
class="epic-timeframe"
v-html="timeframeString"
>
</span>
</div>
</td>
</template>
<script>
import { totalDaysInMonth } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip';
import {
EPIC_DETAILS_CELL_WIDTH,
TIMELINE_CELL_MIN_WIDTH,
TIMELINE_END_OFFSET_FULL,
TIMELINE_END_OFFSET_HALF,
} from '../constants';
export default {
directives: {
tooltip,
},
props: {
timeframe: {
type: Array,
required: true,
},
timeframeItem: {
type: Date,
required: true,
},
epic: {
type: Object,
required: true,
},
shellWidth: {
type: Number,
required: true,
},
},
data() {
return {
timelineBarReady: false,
timelineBarStyles: '',
};
},
computed: {
tdStyles() {
return `min-width: ${this.getCellWidth()}px;`;
},
showTimelineBar() {
return this.hasStartDate();
},
},
watch: {
shellWidth: function shellWidth() {
// Render timeline bar only when shellWidth is updated.
this.renderTimelineBar();
},
},
methods: {
/**
* Gets cell width based on total number cells for
* current timeframe and shellWidth.
*
* In case cell width is too narrow, we have fixed minimum
* cell width (TIMELINE_CELL_MIN_WIDTH) to obey.
*/
getCellWidth() {
const minWidth =
Math.ceil((this.shellWidth - EPIC_DETAILS_CELL_WIDTH) / this.timeframe.length);
return Math.max(minWidth, TIMELINE_CELL_MIN_WIDTH);
},
/**
* Check if current timeline cell has start date for current epic
*/
hasStartDate() {
return this.epic.startDate.getMonth() === this.timeframeItem.getMonth() &&
this.epic.startDate.getFullYear() === this.timeframeItem.getFullYear();
},
/**
* In case startDate for any epic is undefined or is out of range
* for current timeframe, we have to provide specific offset while
* positioning it to ensure that;
*
* 1. Timeline bar starts at correct position based on start date.
* 2. Bar starts exactly at the start of cell in case start date is `1`.
* 3. A "triangle" shape is shown at the beginning of timeline bar
* when startDate is out of range.
*/
getTimelineBarStartOffset() {
const daysInMonth = totalDaysInMonth(this.timeframeItem);
const startDate = this.epic.startDate.getDate();
let offset = '';
if (this.epic.startDateOutOfRange ||
(this.epic.startDateUndefined && this.epic.endDateOutOfRange)) {
// If Epic startDate is out of timeframe range
// OR
// Epic startDate is undefined AND Epic endDate is out of timeframe range
// no offset is needed.
offset = '';
} else if (startDate === 1) {
// If Epic startDate is first day of the month
// Set offset to 0.
offset = 'left: 0;';
} else {
// Calculate proportional offset based on startDate and total days in
// current month.
offset = `left: ${Math.floor((startDate / daysInMonth) * 100)}%;`;
}
return offset;
},
/**
* In case startDate or endDate for any epic is undefined or is out of range
* for current timeframe, we have to provide specific offset while
* setting width to ensure that;
*
* 1. Timeline bar ends at correct position based on end date.
* 2. A "triangle" shape is shown at the end of timeline bar
* when endDate is out of range.
*/
getTimelineBarEndOffset() {
let offset = 0;
if ((this.epic.startDateOutOfRange && this.epic.endDateOutOfRange) ||
(this.epic.startDateUndefined && this.epic.endDateOutOfRange)) {
// If Epic startDate is undefined or out of range
// AND
// endDate is out of range
// Reduce offset size from the width to compensate for fadeout of timelinebar
// and/or showing triangle at the end and beginning
offset = TIMELINE_END_OFFSET_FULL;
} else if (this.epic.endDateOutOfRange) {
// If Epic end date is out of range
// Reduce offset size from the width to compensate for triangle (which is sized at 8px)
offset = TIMELINE_END_OFFSET_HALF;
} else {
// No offset needed if all dates are defined.
offset = 0;
}
return offset;
},
/**
* Check if current timeframe is under the range of Epic endDate
*/
isTimeframeUnderEndDate(timeframeItem, epicEndDate) {
return timeframeItem.getYear() <= epicEndDate.getYear() &&
timeframeItem.getMonth() === epicEndDate.getMonth();
},
/**
* Get width for timeline bar for current cell (representing a month)
* Based on total days in the month and width of month on UI
*/
getBarWidthForMonth(cellWidth, daysInMonth, date) {
const dayWidth = cellWidth / daysInMonth;
const barWidth = date === daysInMonth ? cellWidth : dayWidth * date;
return Math.min(cellWidth, barWidth);
},
/**
* This method is externally only called when current timeframe cell has timeline
* bar to show. So when this method is called, we iterate over entire timeframe
* array starting from current timeframeItem.
*
* For eg;
* If timeframe range for 6 months is;
* 2017 Oct, 2017 Nov, 2017 Dec, 2018 Jan, 2018 Feb, 2018 Mar
*
* And if Epic starts in 2017 Dec and ends in 2018 Feb.
*
* Then this method will iterate over timeframe as;
* 2017 Dec => 2018 Feb
* And will add up width(see 1.) for timeline bar for each month in iteration
* based on provided start and end dates.
*
* 1. Width from date is calculated by totalWidthCell / totalDaysInMonth = widthOfSingleDay
* and then dateOfMonth x widthOfSingleDay = totalBarWidth
*/
getTimelineBarWidth() {
let timelineBarWidth = 0;
const indexOfCurrentMonth = this.timeframe.indexOf(this.timeframeItem);
const cellWidth = this.getCellWidth();
const offsetEnd = this.getTimelineBarEndOffset();
const epicStartDate = this.epic.startDate;
const epicEndDate = this.epic.endDate;
// Start iteration from current month
for (let i = indexOfCurrentMonth; i < this.timeframe.length; i += 1) {
// Get total days for current month
const daysInMonth = totalDaysInMonth(this.timeframe[i]);
if (i === indexOfCurrentMonth) {
// If this is current month
if (this.isTimeframeUnderEndDate(this.timeframe[i], epicEndDate)) {
// If Epic endDate falls under the range of current timeframe month
// then get width for number of days between start and end dates (inclusive)
timelineBarWidth += this.getBarWidthForMonth(
cellWidth,
daysInMonth,
((epicEndDate.getDate() - epicStartDate.getDate()) + 1),
);
// Break as Epic start and end date fall within current timeframe month itself!
break;
} else {
// Epic end date does NOT fall in current month.
// If start date is first day of the month,
// we need width of full cell (i.e. total days of month)
// otherwise, we need width only for date from total days of month.
const date = epicStartDate.getDate() === 1 ?
daysInMonth : daysInMonth - epicStartDate.getDate();
timelineBarWidth += this.getBarWidthForMonth(cellWidth, daysInMonth, date);
}
} else if (this.isTimeframeUnderEndDate(this.timeframe[i], epicEndDate)) {
// If this is NOT current month but epicEndDate falls under
// current timeframe month then calculate width
// based on date of the month
timelineBarWidth += this.getBarWidthForMonth(
cellWidth,
daysInMonth,
epicEndDate.getDate(),
);
// Break as Epic end date falls within current timeframe month!
break;
} else {
// This is neither current month,
// nor does the Epic end date fall under current timeframe month
// add width for entire cell of current timeframe.
timelineBarWidth += this.getBarWidthForMonth(cellWidth, daysInMonth, daysInMonth);
}
}
// Reduce any offset from total width and round it off.
return Math.round(timelineBarWidth - offsetEnd);
},
/**
* Renders timeline bar only if current
* timeframe item has startDate for the epic.
*/
renderTimelineBar() {
if (this.hasStartDate()) {
this.timelineBarStyles = `width: ${this.getTimelineBarWidth()}px; ${this.getTimelineBarStartOffset()}`;
this.timelineBarReady = true;
}
},
},
};
</script>
<template>
<td
class="epic-timeline-cell"
:style="tdStyles"
>
<div class="timeline-bar-wrapper">
<a
v-if="showTimelineBar"
class="timeline-bar"
:href="epic.webUrl"
:class="{
'start-date-undefined': epic.startDateUndefined,
'start-date-outside': epic.startDateOutOfRange,
'end-date-undefined': epic.endDateUndefined,
'end-date-outside': epic.endDateOutOfRange,
}"
:style="timelineBarStyles"
>
</a>
</div>
</td>
</template>
<script>
import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility';
export default {
props: {
timeframeStart: {
type: Date,
required: true,
},
timeframeEnd: {
type: Date,
required: true,
},
emptyStateIllustrationPath: {
type: String,
required: true,
},
},
computed: {
timeframeRange() {
const startDate = dateInWords(
this.timeframeStart,
true,
this.timeframeStart.getFullYear() === this.timeframeEnd.getFullYear(),
);
const endDate = dateInWords(this.timeframeEnd, true);
return {
startDate,
endDate,
};
},
message() {
return s__('GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort');
},
subMessage() {
return sprintf(s__('GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}.'), {
startDate: this.timeframeRange.startDate,
endDate: this.timeframeRange.endDate,
});
},
},
};
</script>
<template>
<div class="row empty-state">
<div class="col-xs-12">
<div class="svg-content">
<img
:src="emptyStateIllustrationPath"
/>
</div>
</div>
<div class="col-xs-12">
<div class="text-content">
<h4>{{ message }}</h4>
<p v-html="subMessage"></p>
</div>
</div>
</div>
</template>
<script>
import eventHub from '../event_hub';
import { SCROLL_BAR_SIZE } from '../constants';
import epicItem from './epic_item.vue';
export default {
components: {
epicItem,
},
props: {
epics: {
type: Array,
required: true,
},
timeframe: {
type: Array,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
shellWidth: {
type: Number,
required: true,
},
},
data() {
return {
shellHeight: 0,
emptyRowHeight: 0,
showEmptyRow: false,
};
},
computed: {
/**
* Return width after reducing scrollbar size
* such that Epic item cells do not consider
* scrollbar
*/
calcShellWidth() {
return this.shellWidth - SCROLL_BAR_SIZE;
},
/**
* Adjust tbody styles while pushing scrollbar further away
* from the view
*/
tbodyStyles() {
return `width: ${this.shellWidth + SCROLL_BAR_SIZE}px; height: ${this.shellHeight}px;`;
},
emptyRowCellStyles() {
return `height: ${this.emptyRowHeight}px;`;
},
},
watch: {
shellWidth: function shellWidth() {
// Scroll view to today indicator only when shellWidth is updated.
this.scrollToTodayIndicator();
},
},
mounted() {
this.$nextTick(() => {
this.initMounted();
});
},
methods: {
initMounted() {
// Get available shell height based on viewport height
this.shellHeight = window.innerHeight - (this.$el.offsetTop + this.$root.$el.offsetTop);
// In case there are epics present, initialize empty row
if (this.epics.length) {
this.initEmptyRow();
}
eventHub.$emit('epicsListRendered', {
width: this.$el.clientWidth,
height: this.shellHeight,
});
},
/**
* In case number of epics in the list are not sufficient
* to fill in full page height, we need to show an empty row
* at the bottom with fixed absolute height such that the
* column rulers expand to full page height
*
* This method calculates absolute height for empty column in pixels
* based on height of available list items and sets it to component
* props.
*/
initEmptyRow() {
const children = this.$children;
let approxChildrenHeight = children[0].$el.clientHeight * this.epics.length;
// Check if approximate height is greater than shell height
if (approxChildrenHeight < this.shellHeight) {
// reset approximate height and recalculate actual height
approxChildrenHeight = 0;
children.forEach((child) => {
// accumulate children height
// compensate for bottom border
approxChildrenHeight += child.$el.clientHeight;
});
// set height and show empty row reducing horizontal scrollbar size
this.emptyRowHeight = (this.shellHeight - approxChildrenHeight) - 1;
this.showEmptyRow = true;
}
},
/**
* We can easily use `eventHub` and dispatch this event
* to all sibling and child components but it adds an overhead/delay
* resulting to janky element positioning. Hence, we directly
* update raw element properties upon event via jQuery.
*/
handleScroll() {
const scrollLeft = this.$el.scrollLeft;
const tableEl = this.$el.parentElement;
if (tableEl) {
const $theadEl = $(tableEl).find('thead');
const $tbodyEl = $(tableEl).find('tbody');
$theadEl.css('left', -scrollLeft);
$theadEl.find('th:nth-child(1)').css('left', scrollLeft);
$tbodyEl.find('td:nth-child(1)').css('left', scrollLeft);
}
eventHub.$emit('epicsListScrolled', this.$el.scrollTop, this.$el.scrollLeft);
},
/**
* `clientWidth` is full width of list section, and we need to
* scroll up to 60% of the view where today indicator is present.
*
* Reason for 60% is that "today" always falls in the middle of timeframe range.
*/
scrollToTodayIndicator() {
const uptoTodayIndicator = Math.ceil((this.$el.clientWidth * 60) / 100);
this.$el.scrollTo(uptoTodayIndicator, 0);
},
},
};
</script>
<template>
<tbody
class="epics-list-section"
:style="tbodyStyles"
@scroll="handleScroll"
>
<epic-item
v-for="(epic, index) in epics"
:key="index"
:epic="epic"
:timeframe="timeframe"
:current-group-id="currentGroupId"
:shell-width="calcShellWidth"
/>
<tr
v-if="showEmptyRow"
class="epics-list-item epics-list-item-empty"
>
<td
class="epic-details-cell"
:style="emptyRowCellStyles"
>
</td>
<td
class="epic-timeline-cell"
v-for="(timeframeItem, index) in timeframe"
:key="index"
:style="emptyRowCellStyles"
>
</td>
</tr>
</tbody>
</template>
<script>
import { SCROLL_BAR_SIZE } from '../constants';
import epicsListSection from './epics_list_section.vue';
import roadmapTimelineSection from './roadmap_timeline_section.vue';
export default {
components: {
epicsListSection,
roadmapTimelineSection,
},
props: {
epics: {
type: Array,
required: true,
},
timeframe: {
type: Array,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
},
data() {
return {
shellWidth: 0,
};
},
computed: {
tableStyles() {
// return width after deducting size of vertical scrollbar
// to hide the scrollbar while preserving ability to scroll
return `width: ${this.shellWidth - SCROLL_BAR_SIZE}px;`;
},
},
mounted() {
this.$nextTick(() => {
// Client width at the time of component mount will not
// provide accurate size of viewport until child contents are
// actually loaded and rendered into the DOM, hence
// we wait for nextTick which ensures DOM update has completed
// before setting shellWidth
// see https://vuejs.org/v2/api/#Vue-nextTick
if (this.$el.parentElement) {
this.shellWidth = this.$el.parentElement.clientWidth;
}
});
},
};
</script>
<template>
<table
class="roadmap-shell"
:style="tableStyles"
>
<roadmap-timeline-section
:epics="epics"
:timeframe="timeframe"
:shell-width="shellWidth"
/>
<epics-list-section
:epics="epics"
:timeframe="timeframe"
:shell-width="shellWidth"
:current-group-id="currentGroupId"
/>
</table>
</template>
<script>
import eventHub from '../event_hub';
import { SCROLL_BAR_SIZE } from '../constants';
import timelineHeaderItem from './timeline_header_item.vue';
export default {
components: {
timelineHeaderItem,
},
props: {
epics: {
type: Array,
required: true,
},
timeframe: {
type: Array,
required: true,
},
shellWidth: {
type: Number,
required: true,
},
},
data() {
return {
scrolledHeaderClass: '',
};
},
computed: {
calcShellWidth() {
return this.shellWidth - SCROLL_BAR_SIZE;
},
theadStyles() {
return `width: ${this.calcShellWidth}px;`;
},
},
mounted() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
},
beforeDestroy() {
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
},
methods: {
handleEpicsListScroll(scrollTop) {
// Add class only when content are scrolled at half the height of header
this.scrolledHeaderClass = (scrollTop > this.$el.clientHeight / 2) ? 'scrolled-ahead' : '';
},
},
};
</script>
<template>
<thead
class="roadmap-timeline-section"
:class="scrolledHeaderClass"
:style="theadStyles"
>
<tr>
<th class="timeline-header-blank"></th>
<timeline-header-item
v-for="(timeframeItem, index) in timeframe"
:key="index"
:timeframe-index="index"
:timeframe-item="timeframeItem"
:timeframe="timeframe"
:shell-width="calcShellWidth"
/>
</tr>
</thead>
</template>
<script>
import { monthInWords } from '~/lib/utils/datetime_utility';
import { EPIC_DETAILS_CELL_WIDTH, TIMELINE_CELL_MIN_WIDTH } from '../constants';
import timelineHeaderSubItem from './timeline_header_sub_item.vue';
export default {
components: {
timelineHeaderSubItem,
},
props: {
timeframeIndex: {
type: Number,
required: true,
},
timeframeItem: {
type: Date,
required: true,
},
timeframe: {
type: Array,
required: true,
},
shellWidth: {
type: Number,
required: true,
},
},
data() {
const currentDate = new Date();
return {
currentDate,
currentYear: currentDate.getFullYear(),
currentMonth: currentDate.getMonth(),
};
},
computed: {
thStyles() {
const timeframeLength = this.timeframe.length;
// Calculate minimum width for single cell
// based on total number of months in current timeframe
// and available shellWidth
const minWidth =
Math.ceil((this.shellWidth - EPIC_DETAILS_CELL_WIDTH) / timeframeLength);
// When shellWidth is too low, we need to obey global
// minimum cell width.
if (minWidth < TIMELINE_CELL_MIN_WIDTH) {
return `min-width: ${TIMELINE_CELL_MIN_WIDTH}px;`;
}
return `min-width: ${minWidth}px;`;
},
timelineHeaderLabel() {
const year = this.timeframeItem.getFullYear();
const month = monthInWords(this.timeframeItem, true);
// Show Year only if current timeframe has months between
// two years and current timeframe item is first month
// from one of the two years.
//
// End result of doing this is;
// 2017 Nov, Dec, 2018 Jan, Feb, Mar
if (this.timeframeIndex !== 0 &&
this.timeframe[this.timeframeIndex - 1].getFullYear() === year) {
return month;
}
return `${year} ${month}`;
},
timelineHeaderClass() {
let itemLabelClass = '';
const timeframeYear = this.timeframeItem.getFullYear();
const timeframeMonth = this.timeframeItem.getMonth();
// Show dark color text only if timeframe item year & month
// are greater than current year.
if (timeframeYear >= this.currentYear &&
timeframeMonth >= this.currentMonth) {
itemLabelClass += 'label-dark';
}
// Show bold text only if timeframe item year & month
// is current year & month
if (timeframeYear === this.currentYear &&
timeframeMonth === this.currentMonth) {
itemLabelClass += ' label-bold';
}
return itemLabelClass;
},
},
};
</script>
<template>
<th
class="timeline-header-item"
:style="thStyles"
>
<div
class="item-label"
:class="timelineHeaderClass"
>
{{ timelineHeaderLabel }}
</div>
<timeline-header-sub-item
:timeframe-item="timeframeItem"
:current-date="currentDate"
/>
</th>
</template>
<script>
import { getSundays } from '~/lib/utils/datetime_utility';
import timelineTodayIndicator from './timeline_today_indicator.vue';
export default {
components: {
timelineTodayIndicator,
},
props: {
currentDate: {
type: Date,
required: true,
},
timeframeItem: {
type: Date,
required: true,
},
},
computed: {
headerSubItems() {
return getSundays(this.timeframeItem);
},
headerSubItemClass() {
const currentYear = this.currentDate.getFullYear();
const currentMonth = this.currentDate.getMonth();
const timeframeYear = this.timeframeItem.getFullYear();
const timeframeMonth = this.timeframeItem.getMonth();
// Show dark color text only for dates from current month and future months.
return timeframeYear >= currentYear && timeframeMonth >= currentMonth ? 'label-dark' : '';
},
hasToday() {
const timeframeYear = this.timeframeItem.getFullYear();
const timeframeMonth = this.timeframeItem.getMonth();
return this.currentDate.getMonth() === timeframeMonth &&
this.currentDate.getFullYear() === timeframeYear;
},
},
methods: {
getSubItemValueClass(subItem) {
// Show light color text for dates which are
// older than today
if (subItem < this.currentDate) {
return 'value-light';
}
return '';
},
},
};
</script>
<template>
<div
class="item-sublabel"
:class="headerSubItemClass"
>
<span
v-for="(subItem, index) in headerSubItems"
:key="index"
class="sublabel-value"
:class="getSubItemValueClass(subItem)"
>
{{ subItem.getDate() }}
</span>
<timeline-today-indicator
v-if="hasToday"
:timeframe-item="timeframeItem"
:current-date="currentDate"
/>
</div>
</template>
<script>
import { totalDaysInMonth } from '~/lib/utils/datetime_utility';
import eventHub from '../event_hub';
export default {
props: {
currentDate: {
type: Date,
required: true,
},
timeframeItem: {
type: Date,
required: true,
},
},
data() {
return {
todayBarStyles: '',
todayBarReady: false,
};
},
mounted() {
eventHub.$on('epicsListRendered', this.handleEpicsListRender);
},
beforeDestroy() {
eventHub.$off('epicsListRendered', this.handleEpicsListRender);
},
methods: {
/**
* This method takes height of current shell
* and renders vertical line over the area where
* today falls in current timeline
*/
handleEpicsListRender({ height }) {
// Get total days of current timeframe Item
const daysInMonth = totalDaysInMonth(this.timeframeItem);
// Get size in % from current date and days in month.
const left = Math.floor((this.currentDate.getDate() / daysInMonth) * 100);
// Set styles and reduce scrollbar height from total shell height.
this.todayBarStyles = `height: ${height}px; left: ${left}%;`;
this.todayBarReady = true;
},
},
};
</script>
<template>
<span
v-if="todayBarReady"
class="today-bar"
:style="todayBarStyles"
>
</span>
</template>
export const TIMEFRAME_LENGTH = 6;
export const EPIC_DETAILS_CELL_WIDTH = 320;
export const TIMELINE_CELL_MIN_WIDTH = 180;
export const SCROLL_BAR_SIZE = 15;
export const TIMELINE_END_OFFSET_HALF = 17;
export const TIMELINE_END_OFFSET_FULL = 26;
import Vue from 'vue';
export default new Vue();
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import { getTimeframeWindow } from '~/lib/utils/datetime_utility';
import { TIMEFRAME_LENGTH } from './constants';
import RoadmapStore from './store/roadmap_store';
import RoadmapService from './service/roadmap_service';
import roadmapApp from './components/app.vue';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('js-roadmap');
if (!el) {
return false;
}
return new Vue({
el,
components: {
roadmapApp,
},
data() {
const dataset = this.$options.el.dataset;
// Construct Epic API path to include
// `start_date` & `end_date` query params to get list of
// epics only for current timeframe.
const timeframe = getTimeframeWindow(TIMEFRAME_LENGTH);
const start = timeframe[0];
const end = timeframe[TIMEFRAME_LENGTH - 1];
const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`;
const endDate = `${end.getFullYear()}-${end.getMonth() + 1}-${end.getDate()}`;
const epicsPath = `${dataset.epicsPath}?start_date=${startDate}&end_date=${endDate}`;
const store = new RoadmapStore(parseInt(dataset.groupId, 0), timeframe);
const service = new RoadmapService(epicsPath);
return {
store,
service,
emptyStateIllustrationPath: dataset.emptyStateIllustration,
};
},
render(createElement) {
return createElement('roadmap-app', {
props: {
store: this.store,
service: this.service,
emptyStateIllustrationPath: this.emptyStateIllustrationPath,
},
});
},
});
});
import axios from '~/lib/utils/axios_utils';
export default class RoadmapService {
constructor(epicsPath) {
this.epicsPath = epicsPath;
}
getEpics() {
return axios.get(this.epicsPath);
}
}
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default class RoadmapStore {
constructor(groupId, timeframe) {
this.state = {};
this.state.epics = [];
this.state.currentGroupId = groupId;
this.state.timeframe = timeframe;
this.firstTimeframeItem = this.state.timeframe[0];
this.lastTimeframeItem = this.state.timeframe[this.state.timeframe.length - 1];
}
setEpics(epics) {
this.state.epics = epics.map(
epic => RoadmapStore.formatEpicDetails(epic, this.firstTimeframeItem, this.lastTimeframeItem),
);
}
getEpics() {
return this.state.epics;
}
getCurrentGroupId() {
return this.state.currentGroupId;
}
getTimeframe() {
return this.state.timeframe;
}
/**
* This method constructs Epic object and assigns proxy dates
* in case start or end dates are unavailable.
*
* @param {Object} rawEpic
* @param {Date} firstTimeframeItem
* @param {Date} lastTimeframeItem
*/
static formatEpicDetails(rawEpic, firstTimeframeItem, lastTimeframeItem) {
const epicItem = convertObjectPropsToCamelCase(rawEpic);
if (rawEpic.start_date) {
// If startDate is present
const startDate = new Date(rawEpic.start_date);
if (startDate <= firstTimeframeItem) {
// If startDate is less than first timeframe item
// startDate is out of range;
epicItem.startDateOutOfRange = true;
// store original start date in different object
epicItem.originalStartDate = startDate;
// Use startDate object to set a proxy date so
// that timeline bar can render it.
epicItem.startDate = new Date(firstTimeframeItem.getTime());
} else {
// startDate is within timeframe range
epicItem.startDate = startDate;
}
} else {
// Start date is not available
epicItem.startDateUndefined = true;
// Set proxy date so that timeline bar can render it.
epicItem.startDate = new Date(firstTimeframeItem.getTime());
}
// Same as above but for endDate
// This entire chunk can be moved into generic method
// but we're keeping it here for the sake of simplicity.
if (rawEpic.end_date) {
const endDate = new Date(rawEpic.end_date);
if (endDate >= lastTimeframeItem) {
epicItem.endDateOutOfRange = true;
epicItem.originalEndDate = endDate;
epicItem.endDate = new Date(lastTimeframeItem.getTime());
} else {
epicItem.endDate = endDate;
}
} else {
epicItem.endDateUndefined = true;
epicItem.endDate = new Date(lastTimeframeItem.getTime());
}
return epicItem;
}
}
$item-height: 50px;
$column-shadow: 15px 0 15px -15px rgba(0, 0, 0, 0.12) inset;
$scroll-top-shadow: 0 5px 5px 0 rgba(0, 0, 0, 0.12);
$border-style: 1px solid $border-gray-normal;
$details-cell-width: 320px;
.group-epics-roadmap-wrapper {
padding-bottom: 0;
}
.breadcrumbs.group-epics-roadmap {
border-bottom: $border-style;
.breadcrumbs-container {
border-bottom: none;
}
}
.roadmap-shell {
width: 100%;
overflow: hidden;
&,
.roadmap-timeline-section,
.epics-list-section {
display: block;
position: relative;
}
.epics-list-section .epic-details-cell:after,
.roadmap-timeline-section .timeline-header-blank:after,
.roadmap-timeline-section.scrolled-ahead .timeline-header-blank:before {
content: '';
position: absolute;
}
.epics-list-section .epic-details-cell:after,
.roadmap-timeline-section .timeline-header-blank:after {
top: 0;
right: -15px;
height: 100%;
width: 14px;
box-shadow: $column-shadow;
pointer-events: none;
}
.roadmap-timeline-section .timeline-header-blank:after {
top: -2px;
height: 60px;
}
.roadmap-timeline-section.scrolled-ahead .timeline-header-blank:before {
bottom: -1px;
left: 0;
height: 15px;
width: 100%;
box-shadow: $scroll-top-shadow;
}
.roadmap-timeline-section {
overflow: visible;
}
.epics-list-section {
overflow: auto;
.epics-list-item-empty {
.epic-details-cell {
border-bottom: none;
}
}
tr:not(.epics-list-item-empty):hover {
&,
.epic-details-cell {
background-color: $theme-gray-100;
}
}
}
}
.roadmap-timeline-section {
.timeline-header-blank {
position: relative;
display: block;
top: 2px;
height: 60px;
width: $details-cell-width;
background-color: $white-light;
border-right: $border-style;
z-index: 3;
}
.timeline-header-blank,
.timeline-header-item {
border-bottom: $border-style;
}
.timeline-header-item {
&:last-of-type .item-label {
border-right: none;
}
.item-label,
.item-sublabel {
color: $theme-gray-600;
font-weight: 400;
&.label-dark {
color: $theme-gray-900;
.value-light {
color: $theme-gray-600;
}
}
}
.item-label {
padding: $gl-padding-8 $gl-padding;
border-right: $border-style;
border-bottom: $border-style;
&.label-bold {
font-weight: 700;
}
}
.item-sublabel {
position: relative;
display: flex;
.sublabel-value {
flex: 1;
text-align: center;
font-size: $code_font_size;
padding: 2px 0;
}
.today-bar {
position: absolute;
top: 20px;
width: 2px;
background-color: $red-500;
pointer-events: none;
z-index: 1;
}
.today-bar:before {
content: '';
position: absolute;
top: -3px;
left: -3px;
height: $grid-size;
width: $grid-size;
background-color: inherit;
border-radius: 50%;
}
}
}
}
.epics-list-section {
.epics-list-item {
&.epics-list-item-empty {
.epic-details-cell,
.epic-timeline-cell {
padding: 0;
}
}
.epic-details-cell,
.epic-timeline-cell {
border-right: $border-style;
border-bottom: $border-style;
}
.epic-details-cell {
position: relative;
display: block;
top: 1px; /* Remove cell spacing */
width: $details-cell-width;
padding: $gl-padding-8 $gl-padding;
font-size: $code_font_size;
background-color: $white-light;
z-index: 2;
.epic-title {
display: table;
table-layout: fixed;
width: 100%;
}
.epic-title .epic-url {
display: table-cell;
color: $theme-gray-900;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.epic-group-timeframe {
color: $theme-gray-700;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.epic-group:hover {
cursor: pointer;
}
}
.epic-timeline-cell {
.timeline-bar-wrapper {
position: relative;
}
.timeline-bar {
position: absolute;
top: -12px;
height: 24px;
background-color: $blue-500;
border-radius: $border-radius-default;
opacity: 0.75;
&:hover {
opacity: 1;
}
&.start-date-outside:before,
&.end-date-outside:after {
content: '';
position: absolute;
top: 0;
height: 100%;
}
&.start-date-outside:before,
&.end-date-outside:after {
border-top: 12px solid transparent;
border-bottom: 12px solid transparent;
}
&.start-date-undefined {
background: linear-gradient(to right, transparent 0%, $blue-200 50%, $blue-500 100%);
}
&.end-date-undefined {
background: linear-gradient(to right, $blue-500 0%, $blue-200 50%, transparent 100%);
}
&.start-date-outside {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
&:before {
left: -8px;
border-right: 8px solid $blue-500;
}
}
&.end-date-outside {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
&:after {
right: -8px;
border-left: 8px solid $blue-500;
}
}
&.start-date-outside,
&.start-date-undefined.end-date-outside {
left: 8px;
}
}
&:last-child {
border-right: none;
}
}
}
}
...@@ -96,4 +96,8 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -96,4 +96,8 @@ class Groups::EpicsController < Groups::ApplicationController
def authorize_create_epic! def authorize_create_epic!
return render_404 unless can?(current_user, :create_epic, group) return render_404 unless can?(current_user, :create_epic, group)
end end
def filter_params
super.merge(start_date: params[:start_date], end_date: params[:end_date])
end
end end
module Groups
class RoadmapController < Groups::ApplicationController
before_action :group
def show
# show roadmap for a group
@epics_count = EpicsFinder.new(current_user, group_id: @group.id).execute.count
end
end
end
...@@ -53,9 +53,14 @@ class EpicsFinder < IssuableFinder ...@@ -53,9 +53,14 @@ class EpicsFinder < IssuableFinder
def by_timeframe(items) def by_timeframe(items)
return items unless params[:start_date] && params[:end_date] return items unless params[:start_date] && params[:end_date]
end_date = params[:end_date].to_datetime.end_of_day
start_date = params[:start_date].to_datetime.beginning_of_day
items items
.where('epics.start_date is not NULL or epics.end_date is not NULL') .where('epics.start_date is not NULL or epics.end_date is not NULL')
.where('epics.start_date is NULL or epics.start_date <= ?', params[:end_date].end_of_day) .where('epics.start_date is NULL or epics.start_date <= ?', end_date)
.where('epics.end_date is NULL or epics.end_date >= ?', params[:start_date].beginning_of_day) .where('epics.end_date is NULL or epics.end_date >= ?', start_date)
rescue ArgumentError
items
end end
end end
class EpicEntity < IssuableEntity class EpicEntity < IssuableEntity
expose :group_id expose :group_id
expose :group_name do |epic|
epic.group.name
end
expose :group_full_name do |epic|
epic.group.full_name
end
expose :start_date expose :start_date
expose :end_date expose :end_date
expose :web_url do |epic| expose :web_url do |epic|
......
- @no_breadcrumb_container = true
- @no_container = true
- @content_wrapper_class = "group-epics-roadmap-wrapper"
- @content_class = "group-epics-roadmap"
- breadcrumb_title _("Epics Roadmap")
- if @epics_count != 0
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'roadmap'
#js-roadmap{ data: { epics_path: group_epics_path(@group, format: :json), group_id: @group.id, empty_state_illustration: image_path('illustrations/epics/roadmap.svg') } }
- else
= render 'shared/empty_states/roadmap'
- return unless group.feature_available?(:epics) - return unless group.feature_available?(:epics)
- epics = EpicsFinder.new(current_user, group_id: @group.id).execute - epics = EpicsFinder.new(current_user, group_id: @group.id).execute
- epics_items = ['epics#show', 'epics#index'] - epics_items = ['epics#show', 'epics#index', 'roadmap#show']
= nav_link(path: epics_items) do = nav_link(path: epics_items) do
= link_to group_epics_path(group) do = link_to group_epics_path(group) do
...@@ -10,9 +10,16 @@ ...@@ -10,9 +10,16 @@
%span.nav-item-name %span.nav-item-name
Epics Epics
%span.badge.count= number_with_delimiter(epics.count) %span.badge.count= number_with_delimiter(epics.count)
%ul.sidebar-sub-level-items.is-fly-out-only %ul.sidebar-sub-level-items
= nav_link(path: epics_items, html_options: { class: "fly-out-top-item" } ) do = nav_link(path: epics_items, html_options: { class: "fly-out-top-item" } ) do
= link_to group_epics_path(group) do = link_to group_epics_path(group) do
%strong.fly-out-top-item-name %strong.fly-out-top-item-name= _('Epics')
#{ _('Epics') }
%span.badge.count.epic_counter.fly-out-badge= number_with_delimiter(epics.count) %span.badge.count.epic_counter.fly-out-badge= number_with_delimiter(epics.count)
%li.divider.fly-out-top-item
= nav_link(path: 'epics#index', html_options: { class: 'home' }) do
= link_to group_epics_path(group), title: 'List' do
%span= _('List')
= nav_link(path: 'roadmap#show', html_options: { class: 'home' }) do
= link_to group_roadmap_path(group), title: 'Roadmap' do
%span= _('Roadmap')
.row.empty-state .row.empty-state
.col-xs-12 .col-xs-12
.svg-content .svg-content
= image_tag('illustrations/epics.svg') = image_tag 'illustrations/epics/list.svg'
.col-xs-12.text-center .col-xs-12
.text-content .text-content
%h4 %h4
= _('Epics let you manage your portfolio of projects more efficiently and with less effort') = _('Epics let you manage your portfolio of projects more efficiently and with less effort')
......
.row.empty-state
.col-xs-12
.svg-content
= image_tag 'illustrations/epics/roadmap.svg'
.col-xs-12
.text-content
%h4
= _('The roadmap shows the progress of your epics along a timeline')
%p
= _('To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown.')
- if can?(current_user, :create_epic, @group)
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'new_epic'
#new-epic-app{ data: { endpoint: request.url } }
= link_to group_epics_path(@group), title: 'List', class: 'btn' do
%span= _('View epics list')
...@@ -92,19 +92,30 @@ describe EpicsFinder do ...@@ -92,19 +92,30 @@ describe EpicsFinder do
context 'by timeframe' do context 'by timeframe' do
it 'returns epics which start in the timeframe' do it 'returns epics which start in the timeframe' do
expect(epics(start_date: 2.days.ago, end_date: 1.day.ago)).to contain_exactly(epic2) params = {
start_date: 2.days.ago.strftime('%Y-%m-%d'),
end_date: 1.day.ago.strftime('%Y-%m-%d')
}
expect(epics(params)).to contain_exactly(epic2)
end end
it 'returns epics which end in the timeframe' do it 'returns epics which end in the timeframe' do
expect(epics(start_date: 4.days.ago, end_date: 3.days.ago)).to contain_exactly(epic3) params = {
start_date: 4.days.ago.strftime('%Y-%m-%d'),
end_date: 3.days.ago.strftime('%Y-%m-%d')
}
expect(epics(params)).to contain_exactly(epic3)
end end
it 'returns epics which start before and end after the timeframe' do it 'returns epics which start before and end after the timeframe' do
expect(epics(start_date: 4.days.ago, end_date: 4.days.ago)).to contain_exactly(epic3) params = {
end start_date: 4.days.ago.strftime('%Y-%m-%d'),
end_date: 4.days.ago.strftime('%Y-%m-%d')
}
it 'ignores epics which do not have start and end date set' do expect(epics(params)).to contain_exactly(epic3)
expect(epics(start_date: 2.days.ago, end_date: 1.day.ago)).not_to include(epic1)
end end
end end
end end
......
...@@ -14,6 +14,6 @@ describe EpicEntity do ...@@ -14,6 +14,6 @@ describe EpicEntity do
end end
it 'has epic specific attributes' do it 'has epic specific attributes' do
expect(subject).to include(:start_date, :end_date, :group_id, :web_url) expect(subject).to include(:start_date, :end_date, :group_id, :group_name, :group_full_name, :web_url)
end end
end end
...@@ -20,6 +20,8 @@ ...@@ -20,6 +20,8 @@
"web_url": { "type": "string" }, "web_url": { "type": "string" },
"milestone": { "type": ["object", "null"] }, "milestone": { "type": ["object", "null"] },
"labels": { "type": ["array", "null"] }, "labels": { "type": ["array", "null"] },
"group_name": { "type": "string" },
"group_full_name": { "type": "string" },
"group": { "group": {
"id": { "type": "integer" }, "id": { "type": "integer" },
"path": { "type": "string" } "path": { "type": "string" }
......
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import appComponent from 'ee/roadmap/components/app.vue';
import RoadmapStore from 'ee/roadmap/store/roadmap_store';
import RoadmapService from 'ee/roadmap/service/roadmap_service';
import { mockTimeframe, mockGroupId, epicsPath, rawEpics, mockSvgPath } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(appComponent);
const timeframe = mockTimeframe;
const store = new RoadmapStore(mockGroupId, timeframe);
const service = new RoadmapService(epicsPath);
return mountComponent(Component, {
store,
service,
emptyStateIllustrationPath: mockSvgPath,
});
};
describe('AppComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.isLoading).toBe(true);
expect(vm.isEpicsListEmpty).toBe(false);
expect(vm.hasError).toBe(false);
expect(vm.handleResizeThrottled).toBeDefined();
});
});
describe('computed', () => {
describe('epics', () => {
it('returns array of epics', () => {
expect(Array.isArray(vm.epics)).toBe(true);
});
});
describe('timeframe', () => {
it('returns array of timeframe', () => {
expect(Array.isArray(vm.timeframe)).toBe(true);
});
});
describe('timeframeStart', () => {
it('returns first item of timeframe array', () => {
expect(vm.timeframeStart instanceof Date).toBe(true);
});
});
describe('timeframeEnd', () => {
it('returns last item of timeframe array', () => {
expect(vm.timeframeEnd instanceof Date).toBe(true);
});
});
describe('currentGroupId', () => {
it('returns current group Id', () => {
expect(vm.currentGroupId).toBe(mockGroupId);
});
});
describe('showRoadmap', () => {
it('returns true if `isLoading`, `isEpicsListEmpty` and `hasError` are all `false`', () => {
vm.isLoading = false;
vm.isEpicsListEmpty = false;
vm.hasError = false;
expect(vm.showRoadmap).toBe(true);
});
it('returns false if either of `isLoading`, `isEpicsListEmpty` and `hasError` is `true`', () => {
vm.isLoading = true;
vm.isEpicsListEmpty = false;
vm.hasError = false;
expect(vm.showRoadmap).toBe(false);
vm.isLoading = false;
vm.isEpicsListEmpty = true;
vm.hasError = false;
expect(vm.showRoadmap).toBe(false);
vm.isLoading = false;
vm.isEpicsListEmpty = false;
vm.hasError = true;
expect(vm.showRoadmap).toBe(false);
});
});
});
describe('methods', () => {
describe('fetchEpics', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
document.body.innerHTML += '<div class="flash-container"></div>';
});
afterEach(() => {
mock.restore();
document.querySelector('.flash-container').remove();
});
it('calls service.getEpics and sets response to the store on success', (done) => {
mock.onGet(vm.service.epicsPath).reply(200, rawEpics);
spyOn(vm.store, 'setEpics');
vm.fetchEpics();
expect(vm.hasError).toBe(false);
setTimeout(() => {
expect(vm.isLoading).toBe(false);
expect(vm.store.setEpics).toHaveBeenCalledWith(rawEpics);
done();
}, 0);
});
it('calls service.getEpics and sets `isEpicsListEmpty` to true if response is empty', (done) => {
mock.onGet(vm.service.epicsPath).reply(200, []);
spyOn(vm.store, 'setEpics');
vm.fetchEpics();
expect(vm.isEpicsListEmpty).toBe(false);
setTimeout(() => {
expect(vm.isEpicsListEmpty).toBe(true);
expect(vm.store.setEpics).not.toHaveBeenCalled();
done();
}, 0);
});
it('calls service.getEpics and sets `hasError` to true and shows flash message if request failed', (done) => {
mock.onGet(vm.service.epicsPath).reply(500, {});
vm.fetchEpics();
expect(vm.hasError).toBe(false);
setTimeout(() => {
expect(vm.hasError).toBe(true);
expect(document.querySelector('.flash-text').innerText.trim()).toBe('Something went wrong while fetching epics');
done();
}, 0);
});
});
});
describe('mounted', () => {
it('binds window resize event listener', () => {
spyOn(window, 'addEventListener');
const vmX = createComponent();
expect(vmX.handleResizeThrottled).toBeDefined();
expect(window.addEventListener).toHaveBeenCalledWith('resize', vmX.handleResizeThrottled, false);
vmX.$destroy();
});
});
describe('beforeDestroy', () => {
it('unbinds window resize event listener', () => {
spyOn(window, 'removeEventListener');
const vmX = createComponent();
vmX.$destroy();
expect(window.removeEventListener).toHaveBeenCalledWith('resize', vmX.handleResizeThrottled, false);
});
});
describe('template', () => {
it('renders roadmap container with class `roadmap-container`', () => {
expect(vm.$el.classList.contains('roadmap-container')).toBe(true);
});
});
});
import Vue from 'vue';
import epicItemDetailsComponent from 'ee/roadmap/components/epic_item_details.vue';
import { mockGroupId, mockEpic } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = (epic = mockEpic, currentGroupId = mockGroupId) => {
const Component = Vue.extend(epicItemDetailsComponent);
return mountComponent(Component, {
epic,
currentGroupId,
});
};
describe('EpicItemDetailsComponent', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('isEpicGroupDifferent', () => {
it('returns true when Epic.groupId is different from currentGroupId', () => {
const mockEpicItem = Object.assign({}, mockEpic, { groupId: 1 });
vm = createComponent(mockEpicItem, 2);
expect(vm.isEpicGroupDifferent).toBe(true);
});
it('returns false when Epic.groupId is same as currentGroupId', () => {
const mockEpicItem = Object.assign({}, mockEpic, { groupId: 1 });
vm = createComponent(mockEpicItem, 1);
expect(vm.isEpicGroupDifferent).toBe(false);
});
});
describe('startDate', () => {
it('returns Epic.startDate when start date is within range', () => {
vm = createComponent(mockEpic);
expect(vm.startDate).toBe(mockEpic.startDate);
});
it('returns Epic.originalStartDate when start date is out of range', () => {
const mockStartDate = new Date(2018, 0, 1);
const mockEpicItem = Object.assign({}, mockEpic, {
startDateOutOfRange: true,
originalStartDate: mockStartDate,
});
vm = createComponent(mockEpicItem);
expect(vm.startDate).toBe(mockStartDate);
});
});
describe('endDate', () => {
it('returns Epic.endDate when end date is within range', () => {
vm = createComponent(mockEpic);
expect(vm.endDate).toBe(mockEpic.endDate);
});
it('returns Epic.originalEndDate when end date is out of range', () => {
const mockEndDate = new Date(2018, 0, 1);
const mockEpicItem = Object.assign({}, mockEpic, {
endDateOutOfRange: true,
originalEndDate: mockEndDate,
});
vm = createComponent(mockEpicItem);
expect(vm.endDate).toBe(mockEndDate);
});
});
describe('timeframeString', () => {
it('returns timeframe string correctly when both start and end dates are defined', () => {
vm = createComponent(mockEpic);
expect(vm.timeframeString).toBe('Jul 10, 2017 &ndash; Jun 2, 2018');
});
it('returns timeframe string correctly when only start date is defined', () => {
const mockEpicItem = Object.assign({}, mockEpic, {
endDateUndefined: true,
});
vm = createComponent(mockEpicItem);
expect(vm.timeframeString).toBe('From Jul 10, 2017');
});
it('returns timeframe string correctly when only end date is defined', () => {
const mockEpicItem = Object.assign({}, mockEpic, {
startDateUndefined: true,
});
vm = createComponent(mockEpicItem);
expect(vm.timeframeString).toBe('Until Jun 2, 2018');
});
it('returns timeframe string with hidden year for start date when both start and end dates are from same year', () => {
const mockEpicItem = Object.assign({}, mockEpic, {
startDate: new Date(2018, 0, 1),
endDate: new Date(2018, 3, 1),
});
vm = createComponent(mockEpicItem);
expect(vm.timeframeString).toBe('Jan 1 &ndash; Apr 1, 2018');
});
});
});
describe('template', () => {
it('renders component container element with class `epic-details-cell`', () => {
vm = createComponent();
expect(vm.$el.classList.contains('epic-details-cell')).toBe(true);
});
it('renders Epic title correctly', () => {
vm = createComponent();
const epicTitleEl = vm.$el.querySelector('.epic-title .epic-url');
expect(epicTitleEl).not.toBeNull();
expect(epicTitleEl.getAttribute('href')).toBe(mockEpic.webUrl);
expect(epicTitleEl.innerText.trim()).toBe(mockEpic.title);
});
it('renders Epic group name and tooltip', () => {
const mockEpicItem = Object.assign({}, mockEpic, {
groupId: 1,
groupName: 'Bar',
groupFullName: 'Foo / Bar',
});
vm = createComponent(mockEpicItem, 2);
const epicGroupNameEl = vm.$el.querySelector('.epic-group-timeframe .epic-group');
expect(epicGroupNameEl).not.toBeNull();
expect(epicGroupNameEl.innerText.trim()).toContain(mockEpicItem.groupName);
expect(epicGroupNameEl.dataset.originalTitle).toBe(mockEpicItem.groupFullName);
});
it('renders Epic timeframe', () => {
vm = createComponent();
const epicTimeframeEl = vm.$el.querySelector('.epic-group-timeframe .epic-timeframe');
expect(epicTimeframeEl).not.toBeNull();
expect(epicTimeframeEl.innerText.trim()).toBe('Jul 10, 2017 – Jun 2, 2018');
});
});
});
import Vue from 'vue';
import epicItemComponent from 'ee/roadmap/components/epic_item.vue';
import { mockTimeframe, mockEpic, mockGroupId, mockShellWidth } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = ({
epic = mockEpic,
timeframe = mockTimeframe,
currentGroupId = mockGroupId,
shellWidth = mockShellWidth,
}) => {
const Component = Vue.extend(epicItemComponent);
return mountComponent(Component, {
epic,
timeframe,
currentGroupId,
shellWidth,
});
};
describe('EpicItemComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent({});
});
afterEach(() => {
vm.$destroy();
});
describe('template', () => {
it('renders component container element class `epics-list-item`', () => {
expect(vm.$el.classList.contains('epics-list-item')).toBeTruthy();
});
it('renders Epic item details element with class `epic-details-cell`', () => {
expect(vm.$el.querySelector('.epic-details-cell')).not.toBeNull();
});
it('renders Epic timeline element with class `epic-timeline-cell`', () => {
expect(vm.$el.querySelector('.epic-timeline-cell')).not.toBeNull();
});
});
});
import Vue from 'vue';
import epicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
import { TIMELINE_CELL_MIN_WIDTH, TIMELINE_END_OFFSET_FULL, TIMELINE_END_OFFSET_HALF } from 'ee/roadmap/constants';
import { mockTimeframe, mockEpic, mockShellWidth } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = ({
timeframe = mockTimeframe,
timeframeItem = mockTimeframe[0],
epic = mockEpic,
shellWidth = mockShellWidth,
}) => {
const Component = Vue.extend(epicItemTimelineComponent);
return mountComponent(Component, {
timeframe,
timeframeItem,
epic,
shellWidth,
});
};
describe('EpicItemTimelineComponent', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
vm = createComponent({});
expect(vm.timelineBarReady).toBe(false);
expect(vm.timelineBarStyles).toBe('');
});
});
describe('computed', () => {
describe('tdStyles', () => {
it('returns CSS min-width based on getCellWidth() method', () => {
vm = createComponent({});
expect(vm.tdStyles).toBe('min-width: 280px;');
});
});
});
describe('methods', () => {
describe('getCellWidth', () => {
it('returns proportionate width based on timeframe length and shellWidth', () => {
vm = createComponent({});
expect(vm.getCellWidth()).toBe(280);
});
it('returns minimum fixed width when proportionate width available lower than minimum fixed width defined', () => {
vm = createComponent({
shellWidth: 1000,
});
expect(vm.getCellWidth()).toBe(TIMELINE_CELL_MIN_WIDTH);
});
});
describe('hasStartDate', () => {
it('returns true when Epic.startDate falls within timeframeItem', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframe[1] }),
timeframeItem: mockTimeframe[1],
});
expect(vm.showTimelineBar).toBe(true);
});
it('returns false when Epic.startDate does not fall within timeframeItem', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframe[0] }),
timeframeItem: mockTimeframe[1],
});
expect(vm.showTimelineBar).toBe(false);
});
});
describe('getTimelineBarStartOffset', () => {
it('returns empty string when Epic startDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDateOutOfRange: true }),
});
expect(vm.getTimelineBarStartOffset()).toBe('');
});
it('returns empty string when Epic startDate is undefined and endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDateUndefined: true,
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarStartOffset()).toBe('');
});
it('return `left: 0;` when Epic startDate is first day of the month', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: new Date(2018, 0, 1),
}),
});
expect(vm.getTimelineBarStartOffset()).toBe('left: 0;');
});
it('returns proportional `left` value based on Epic startDate and days in the month', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: new Date(2018, 0, 15),
}),
});
expect(vm.getTimelineBarStartOffset()).toBe('left: 50%;');
});
});
describe('getTimelineBarEndOffset', () => {
it('returns full offset value when both Epic startDate and endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDateOutOfRange: true,
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarEndOffset()).toBe(TIMELINE_END_OFFSET_FULL);
});
it('returns full offset value when Epic startDate is undefined and endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDateUndefined: true,
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarEndOffset()).toBe(TIMELINE_END_OFFSET_FULL);
});
it('returns half offset value when Epic endDate is out of range', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
endDateOutOfRange: true,
}),
});
expect(vm.getTimelineBarEndOffset()).toBe(TIMELINE_END_OFFSET_HALF);
});
it('returns 0 when both Epic startDate and endDate is defined and within range', () => {
vm = createComponent({});
expect(vm.getTimelineBarEndOffset()).toBe(0);
});
});
describe('isTimeframeUnderEndDate', () => {
beforeEach(() => {
vm = createComponent({});
});
it('returns true if provided timeframeItem is under epicEndDate', () => {
const timeframeItem = new Date(2018, 0, 10); // Jan 10, 2018
const epicEndDate = new Date(2018, 0, 26); // Jan 26, 2018
expect(vm.isTimeframeUnderEndDate(timeframeItem, epicEndDate)).toBe(true);
});
it('returns false if provided timeframeItem is NOT under epicEndDate', () => {
const timeframeItem = new Date(2018, 0, 10); // Jan 10, 2018
const epicEndDate = new Date(2018, 1, 26); // Feb 26, 2018
expect(vm.isTimeframeUnderEndDate(timeframeItem, epicEndDate)).toBe(false);
});
});
describe('getBarWidthForMonth', () => {
it('returns calculated bar width based on provided cellWidth, daysInMonth and date', () => {
vm = createComponent({});
expect(vm.getBarWidthForMonth(300, 30, 1)).toBe(10); // 10% size
expect(vm.getBarWidthForMonth(300, 30, 15)).toBe(150); // 50% size
expect(vm.getBarWidthForMonth(300, 30, 30)).toBe(300); // Full size
});
});
describe('getTimelineBarWidth', () => {
it('returns calculated width value based on Epic.startDate and Epic.endDate', () => {
vm = createComponent({
shellWidth: 2000,
timeframeItem: mockTimeframe[0],
epic: Object.assign({}, mockEpic, {
startDate: new Date(2017, 11, 15), // Dec 15, 2017
endDate: new Date(2018, 1, 15), // Feb 15, 2017
}),
});
expect(vm.getTimelineBarWidth()).toBe(850);
});
});
describe('renderTimelineBar', () => {
it('sets `timelineBarStyles` & `timelineBarReady` when timeframeItem has Epic.startDate', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframe[1] }),
timeframeItem: mockTimeframe[1],
});
vm.renderTimelineBar();
expect(vm.timelineBarStyles).toBe('width: 1400px; left: 0;');
expect(vm.timelineBarReady).toBe(true);
});
it('does not set `timelineBarStyles` & `timelineBarReady` when timeframeItem does NOT have Epic.startDate', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframe[0] }),
timeframeItem: mockTimeframe[1],
});
vm.renderTimelineBar();
expect(vm.timelineBarStyles).toBe('');
expect(vm.timelineBarReady).toBe(false);
});
});
});
describe('template', () => {
it('renders component container element with class `epic-timeline-cell`', () => {
vm = createComponent({});
expect(vm.$el.classList.contains('epic-timeline-cell')).toBe(true);
});
it('renders component container element with `min-width` property applied via style attribute', () => {
vm = createComponent({});
expect(vm.$el.getAttribute('style')).toBe('min-width: 280px;');
});
it('renders timeline bar element with class `timeline-bar` and class `timeline-bar-wrapper` as container element', () => {
vm = createComponent({
epic: Object.assign({}, mockEpic, { startDate: mockTimeframe[1] }),
timeframeItem: mockTimeframe[1],
});
expect(vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar')).not.toBeNull();
});
it('renders timeline bar with calculated `width` and `left` properties applied via style attribute', (done) => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframe[0],
endDate: new Date(2018, 1, 15),
}),
});
const timelineBarEl = vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar');
vm.renderTimelineBar();
vm.$nextTick(() => {
expect(timelineBarEl.getAttribute('style')).toBe('width: 990px; left: 0px;');
done();
});
});
it('renders timeline bar with `start-date-undefined` class when Epic startDate is undefined', (done) => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDateUndefined: true,
startDate: mockTimeframe[0],
}),
});
const timelineBarEl = vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar');
vm.renderTimelineBar();
vm.$nextTick(() => {
expect(timelineBarEl.classList.contains('start-date-undefined')).toBe(true);
done();
});
});
it('renders timeline bar with `start-date-outside` class when Epic startDate is out of range of timeframe', (done) => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDateOutOfRange: true,
startDate: mockTimeframe[0],
originalStartDate: new Date(2017, 0, 1),
}),
});
const timelineBarEl = vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar');
vm.renderTimelineBar();
vm.$nextTick(() => {
expect(timelineBarEl.classList.contains('start-date-outside')).toBe(true);
done();
});
});
it('renders timeline bar with `end-date-undefined` class when Epic endDate is undefined', (done) => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframe[0],
endDateUndefined: true,
endDate: mockTimeframe[mockTimeframe.length - 1],
}),
});
const timelineBarEl = vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar');
vm.renderTimelineBar();
vm.$nextTick(() => {
expect(timelineBarEl.classList.contains('end-date-undefined')).toBe(true);
done();
});
});
it('renders timeline bar with `end-date-outside` class when Epic endDate is out of range of timeframe', (done) => {
vm = createComponent({
epic: Object.assign({}, mockEpic, {
startDate: mockTimeframe[0],
endDateOutOfRange: true,
endDate: mockTimeframe[mockTimeframe.length - 1],
originalEndDate: new Date(2018, 11, 1),
}),
});
const timelineBarEl = vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar');
vm.renderTimelineBar();
vm.$nextTick(() => {
expect(timelineBarEl.classList.contains('end-date-outside')).toBe(true);
done();
});
});
});
});
import Vue from 'vue';
import epicsListEmptyComponent from 'ee/roadmap/components/epics_list_empty.vue';
import { mockTimeframe, mockSvgPath } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(epicsListEmptyComponent);
return mountComponent(Component, {
timeframeStart: mockTimeframe[0],
timeframeEnd: mockTimeframe[mockTimeframe.length - 1],
emptyStateIllustrationPath: mockSvgPath,
});
};
describe('EpicsListEmptyComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('message', () => {
it('returns correct empty state message', () => {
expect(vm.message).toBe('Epics let you manage your portfolio of projects more efficiently and with less effort');
});
});
describe('subMessage', () => {
it('returns correct empty state sub-message', () => {
expect(vm.subMessage).toBe('To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from Nov 1, 2017 to Apr 30, 2018.');
});
});
describe('timeframeRange', () => {
it('returns correct timeframe startDate and endDate in words', () => {
expect(vm.timeframeRange.startDate).toBe('Nov 1, 2017');
expect(vm.timeframeRange.endDate).toBe('Apr 30, 2018');
});
});
});
describe('template', () => {
it('renders empty state illustration in image element with provided `emptyStateIllustrationPath`', () => {
expect(vm.$el.querySelector('.svg-content img').getAttribute('src')).toBe(vm.emptyStateIllustrationPath);
});
});
});
import Vue from 'vue';
import epicsListSectionComponent from 'ee/roadmap/components/epics_list_section.vue';
import RoadmapStore from 'ee/roadmap/store/roadmap_store';
import eventHub from 'ee/roadmap/event_hub';
import { rawEpics, mockTimeframe, mockGroupId, mockShellWidth } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const store = new RoadmapStore(mockGroupId, mockTimeframe);
store.setEpics(rawEpics);
const mockEpics = store.getEpics();
const createComponent = ({
epics = mockEpics,
timeframe = mockTimeframe,
currentGroupId = mockGroupId,
shellWidth = mockShellWidth,
}) => {
const Component = Vue.extend(epicsListSectionComponent);
return mountComponent(Component, {
epics,
timeframe,
currentGroupId,
shellWidth,
});
};
describe('EpicsListSectionComponent', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
vm = createComponent({});
expect(vm.shellHeight).toBe(0);
expect(vm.emptyRowHeight).toBe(0);
expect(vm.showEmptyRow).toBe(false);
});
});
describe('computed', () => {
beforeEach(() => {
vm = createComponent({});
});
describe('calcShellWidth', () => {
it('returns shellWidth after deducting predefined scrollbar size', () => {
// shellWidth is 2000 (as defined above in mockShellWidth)
// SCROLLBAR_SIZE is 15 (as defined in app's constants.js)
// Hence, calcShellWidth = shellWidth - SCROLLBAR_SIZE
expect(vm.calcShellWidth).toBe(1985);
});
});
describe('tbodyStyles', () => {
it('returns computed style string based on shellWidth and shellHeight', () => {
expect(vm.tbodyStyles).toBe('width: 2015px; height: 0px;');
});
});
describe('emptyRowCellStyles', () => {
it('returns computed style string based on emptyRowHeight', () => {
expect(vm.emptyRowCellStyles).toBe('height: 0px;');
});
});
});
describe('methods', () => {
beforeEach(() => {
vm = createComponent({});
});
describe('initMounted', () => {
it('initializes shellHeight based on window.innerHeight and component element position', (done) => {
vm.$nextTick(() => {
expect(vm.shellHeight).toBe(600);
done();
});
});
it('calls initEmptyRow() when there are Epics to render', (done) => {
spyOn(vm, 'initEmptyRow').and.callThrough();
vm.$nextTick(() => {
expect(vm.initEmptyRow).toHaveBeenCalled();
done();
});
});
it('emits `epicsListRendered` via eventHub', (done) => {
spyOn(eventHub, '$emit');
vm.$nextTick(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('epicsListRendered', jasmine.any(Object));
done();
});
});
});
describe('initEmptyRow', () => {
it('sets `emptyRowHeight` and `showEmptyRow` props when shellHeight is greater than approximate height of epics list', (done) => {
vm.$nextTick(() => {
expect(vm.emptyRowHeight).toBe(599); // total size -1px
expect(vm.showEmptyRow).toBe(true);
done();
});
});
it('does not set `emptyRowHeight` and `showEmptyRow` props when shellHeight is less than approximate height of epics list', (done) => {
const initialHeight = window.innerHeight;
window.innerHeight = 0;
const vmMoreEpics = createComponent({
epics: mockEpics.concat(mockEpics).concat(mockEpics),
});
vmMoreEpics.$nextTick(() => {
expect(vmMoreEpics.emptyRowHeight).toBe(0);
expect(vmMoreEpics.showEmptyRow).toBe(false);
window.innerHeight = initialHeight; // reset to prevent any side effects
done();
});
});
});
describe('handleScroll', () => {
it('emits `epicsListScrolled` event via eventHub', () => {
spyOn(eventHub, '$emit');
vm.handleScroll();
expect(eventHub.$emit).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Number), jasmine.any(Number));
});
});
describe('scrollToTodayIndicator', () => {
it('scrolls table body to put timeline today indicator in focus', () => {
spyOn(vm.$el, 'scrollTo');
vm.scrollToTodayIndicator();
expect(vm.$el.scrollTo).toHaveBeenCalledWith(jasmine.any(Number), 0);
});
});
});
describe('template', () => {
beforeEach(() => {
vm = createComponent({});
});
it('renders component container element with class `epics-list-section`', (done) => {
vm.$nextTick(() => {
expect(vm.$el.classList.contains('epics-list-section')).toBe(true);
done();
});
});
it('renders component container element with `width` and `left` properties applied via style attribute', (done) => {
vm.$nextTick(() => {
expect(vm.$el.getAttribute('style')).toBe('width: 2015px; height: 0px;');
done();
});
});
});
});
import Vue from 'vue';
import roadmapShellComponent from 'ee/roadmap/components/roadmap_shell.vue';
import { mockEpic, mockTimeframe, mockGroupId } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = ({
epics = [mockEpic],
timeframe = mockTimeframe,
currentGroupId = mockGroupId,
}) => {
const Component = Vue.extend(roadmapShellComponent);
return mountComponent(Component, {
epics,
timeframe,
currentGroupId,
});
};
describe('RoadmapShellComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent({});
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.shellWidth).toBe(0);
});
});
describe('tableStyles', () => {
it('returns style string based on shellWidth and Scollbar size', () => {
// Since shellWidth is initialized on component mount
// from parentElement.clientWidth, it will always be Zero
// as parentElement is not available during tests.
// so end result is 0 - scrollbar_size = -15
expect(vm.tableStyles).toBe('width: -15px;');
});
});
describe('template', () => {
it('renders component container element with class `roadmap-shell`', () => {
expect(vm.$el.classList.contains('roadmap-shell')).toBeTruthy();
});
});
});
import Vue from 'vue';
import roadmapTimelineSectionComponent from 'ee/roadmap/components/roadmap_timeline_section.vue';
import eventHub from 'ee/roadmap/event_hub';
import { mockEpic, mockTimeframe, mockShellWidth } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = ({
epics = [mockEpic],
timeframe = mockTimeframe,
shellWidth = mockShellWidth,
}) => {
const Component = Vue.extend(roadmapTimelineSectionComponent);
return mountComponent(Component, {
epics,
timeframe,
shellWidth,
});
};
describe('RoadmapTimelineSectionComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent({});
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.scrolledHeaderClass).toBe('');
});
});
describe('computed', () => {
describe('calcShellWidth', () => {
it('returns shellWidth by deducting Scrollbar size', () => {
// shellWidth is 2000 (as defined above in mockShellWidth)
// SCROLLBAR_SIZE is 15 (as defined in app's constants.js)
// Hence, calcShellWidth = shellWidth - SCROLLBAR_SIZE
expect(vm.calcShellWidth).toBe(1985);
});
});
describe('theadStyles', () => {
it('returns style string for thead based on calcShellWidth', () => {
expect(vm.theadStyles).toBe('width: 1985px;');
});
});
});
describe('methods', () => {
describe('handleEpicsListScroll', () => {
it('sets `scrolled-ahead` class on thead element based on provided scrollTop value', () => {
// vm.$el.clientHeight is 0 during tests
// hence any value greater than 0 should
// update scrolledHeaderClass prop
vm.handleEpicsListScroll(1);
expect(vm.scrolledHeaderClass).toBe('scrolled-ahead');
vm.handleEpicsListScroll(0);
expect(vm.scrolledHeaderClass).toBe('');
});
});
});
describe('mounted', () => {
it('binds `epicsListScrolled` event listener via eventHub', () => {
spyOn(eventHub, '$on');
const vmX = createComponent({});
expect(eventHub.$on).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Function));
vmX.$destroy();
});
});
describe('beforeDestroy', () => {
it('unbinds `epicsListScrolled` event listener via eventHub', () => {
spyOn(eventHub, '$off');
const vmX = createComponent({});
vmX.$destroy();
expect(eventHub.$off).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Function));
});
});
describe('template', () => {
it('renders component container element with class `roadmap-timeline-section`', () => {
expect(vm.$el.classList.contains('roadmap-timeline-section')).toBe(true);
});
it('renders empty header cell element with class `timeline-header-blank`', () => {
expect(vm.$el.querySelector('.timeline-header-blank')).not.toBeNull();
});
});
});
import Vue from 'vue';
import timelineHeaderItemComponent from 'ee/roadmap/components/timeline_header_item.vue';
import { TIMELINE_CELL_MIN_WIDTH } from 'ee/roadmap/constants';
import { mockTimeframe, mockShellWidth } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const mockTimeframeIndex = 0;
const createComponent = ({
timeframeIndex = mockTimeframeIndex,
timeframeItem = mockTimeframe[mockTimeframeIndex],
timeframe = mockTimeframe,
shellWidth = mockShellWidth,
}) => {
const Component = Vue.extend(timelineHeaderItemComponent);
return mountComponent(Component, {
timeframeIndex,
timeframeItem,
timeframe,
shellWidth,
});
};
describe('TimelineHeaderItemComponent', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
vm = createComponent({});
const currentDate = new Date();
expect(vm.currentDate.getDate()).toBe(currentDate.getDate());
expect(vm.currentYear).toBe(currentDate.getFullYear());
expect(vm.currentMonth).toBe(currentDate.getMonth());
});
});
describe('computed', () => {
describe('thStyles', () => {
it('returns style string for th element based on shellWidth, timeframe length and Epic details cell width', () => {
vm = createComponent({});
expect(vm.thStyles).toBe('min-width: 280px;');
});
it('returns style string for th element with minimum permissible width when calculated width is lower defined minimum width', () => {
vm = createComponent({ shellWidth: 1000 });
expect(vm.thStyles).toBe(`min-width: ${TIMELINE_CELL_MIN_WIDTH}px;`);
});
});
describe('timelineHeaderLabel', () => {
it('returns string containing Year and Month for current timeline header item', () => {
vm = createComponent({});
expect(vm.timelineHeaderLabel).toBe('2017 Nov');
});
it('returns string containing only Month for current timeline header item when previous header contained Year', () => {
vm = createComponent({
timeframeIndex: mockTimeframeIndex + 1,
timeframeItem: mockTimeframe[mockTimeframeIndex + 1],
});
expect(vm.timelineHeaderLabel).toBe('Dec');
});
});
describe('timelineHeaderClass', () => {
it('returns empty string when timeframeItem year or month is less than current year or month', () => {
vm = createComponent({});
expect(vm.timelineHeaderClass).toBe('');
});
it('returns string containing `label-dark label-bold` when current year and month is same as timeframeItem year and month', () => {
vm = createComponent({
timeframeItem: new Date(),
});
expect(vm.timelineHeaderClass).toBe('label-dark label-bold');
});
it('returns string containing `label-dark` when current year and month is less than timeframeItem year and month', () => {
const timeframeIndex = 2;
const timeframeItem = new Date(
mockTimeframe[timeframeIndex].getFullYear(),
mockTimeframe[timeframeIndex].getMonth() + 2,
1,
);
vm = createComponent({
timeframeIndex,
timeframeItem,
});
expect(vm.timelineHeaderClass).toBe('label-dark');
});
});
});
describe('template', () => {
beforeEach(() => {
vm = createComponent({});
});
it('renders component container element with class `timeline-header-item`', () => {
expect(vm.$el.classList.contains('timeline-header-item')).toBeTruthy();
});
it('renders item label element class `item-label` and value as `timelineHeaderLabel`', () => {
const itemLabelEl = vm.$el.querySelector('.item-label');
expect(itemLabelEl).not.toBeNull();
expect(itemLabelEl.innerText.trim()).toBe('2017 Nov');
});
});
});
import Vue from 'vue';
import timelineHeaderSubItemComponent from 'ee/roadmap/components/timeline_header_sub_item.vue';
import { mockTimeframe } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = ({
currentDate = mockTimeframe[0],
timeframeItem = mockTimeframe[0],
}) => {
const Component = Vue.extend(timelineHeaderSubItemComponent);
return mountComponent(Component, {
currentDate,
timeframeItem,
});
};
describe('TimelineHeaderSubItemComponent', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('headerSubItems', () => {
it('returns array of dates containing Sundays from timeframeItem', () => {
vm = createComponent({});
expect(Array.isArray(vm.headerSubItems)).toBe(true);
});
});
describe('headerSubItemClass', () => {
it('returns string containing `label-dark` when timeframe year and month are greater than current year and month', () => {
vm = createComponent({});
expect(vm.headerSubItemClass).toBe('label-dark');
});
it('returns empty string when timeframe year and month are less than current year and month', () => {
vm = createComponent({
currentDate: new Date(2017, 10, 1), // Nov 1, 2017
timeframeItem: new Date(2018, 0, 1), // Jan 1, 2018
});
expect(vm.headerSubItemClass).toBe('');
});
});
describe('hasToday', () => {
it('returns true when current month and year is same as timeframe month and year', () => {
vm = createComponent({});
expect(vm.hasToday).toBe(true);
});
it('returns false when current month and year is different from timeframe month and year', () => {
vm = createComponent({
currentDate: new Date(2017, 10, 1), // Nov 1, 2017
timeframeItem: new Date(2018, 0, 1), // Jan 1, 2018
});
expect(vm.hasToday).toBe(false);
});
});
});
describe('methods', () => {
describe('getSubItemValueClass', () => {
it('returns empty string when provided subItem is greater than current date', () => {
vm = createComponent({
currentDate: new Date(2018, 0, 1), // Jan 1, 2018
});
const subItem = new Date(2018, 0, 15); // Jan 15, 2018
expect(vm.getSubItemValueClass(subItem)).toBe('');
});
it('returns string containing `value-light` when provided subItem is less than current date', () => {
vm = createComponent({
currentDate: new Date(2018, 0, 15), // Jan 15, 2018
});
const subItem = new Date(2018, 0, 1); // Jan 1, 2018
expect(vm.getSubItemValueClass(subItem)).toBe('value-light');
});
});
});
describe('template', () => {
beforeEach(() => {
vm = createComponent({});
});
it('renders component container element with class `item-sublabel`', () => {
expect(vm.$el.classList.contains('item-sublabel')).toBe(true);
});
it('renders sub item element with class `sublabel-value`', () => {
expect(vm.$el.querySelector('.sublabel-value')).not.toBeNull();
});
});
});
import Vue from 'vue';
import timelineTodayIndicatorComponent from 'ee/roadmap/components/timeline_today_indicator.vue';
import eventHub from 'ee/roadmap/event_hub';
import { mockTimeframe } from '../mock_data';
import mountComponent from '../../helpers/vue_mount_component_helper';
const mockCurrentDate = new Date(
mockTimeframe[0].getFullYear(),
mockTimeframe[0].getMonth(),
15,
);
const createComponent = ({
currentDate = mockCurrentDate,
timeframeItem = mockTimeframe[0],
}) => {
const Component = Vue.extend(timelineTodayIndicatorComponent);
return mountComponent(Component, {
currentDate,
timeframeItem,
});
};
describe('TimelineTodayIndicatorComponent', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
vm = createComponent({});
expect(vm.todayBarStyles).toBe('');
expect(vm.todayBarReady).toBe(false);
});
});
describe('methods', () => {
describe('handleEpicsListRender', () => {
it('sets `todayBarStyles` and `todayBarReady` props based on provided height param, timeframeItem and currentDate props', () => {
vm = createComponent({});
vm.handleEpicsListRender({
height: 100,
});
expect(vm.todayBarStyles).toBe('height: 100px; left: 50%;'); // Current date being 15th
expect(vm.todayBarReady).toBe(true);
});
});
});
describe('mounted', () => {
it('binds `epicsListRendered` event listener via eventHub', () => {
spyOn(eventHub, '$on');
const vmX = createComponent({});
expect(eventHub.$on).toHaveBeenCalledWith('epicsListRendered', jasmine.any(Function));
vmX.$destroy();
});
});
describe('beforeDestroy', () => {
it('unbinds `epicsListRendered` event listener via eventHub', () => {
spyOn(eventHub, '$off');
const vmX = createComponent({});
vmX.$destroy();
expect(eventHub.$off).toHaveBeenCalledWith('epicsListRendered', jasmine.any(Function));
});
});
describe('template', () => {
it('renders component container element with class `today-bar`', (done) => {
vm = createComponent({});
vm.handleEpicsListRender({
height: 100,
});
vm.$nextTick(() => {
expect(vm.$el.classList.contains('today-bar')).toBe(true);
done();
});
});
});
});
import { getTimeframeWindow } from '~/lib/utils/datetime_utility';
import { TIMEFRAME_LENGTH } from 'ee/roadmap/constants';
export const mockGroupId = 2;
export const mockShellWidth = 2000;
export const epicsPath = '/groups/gitlab-org/-/epics.json?start_date=2017-11-1&end_date=2018-4-30';
export const mockSvgPath = '/foo/bar.svg';
export const mockTimeframe = getTimeframeWindow(TIMEFRAME_LENGTH, new Date(2018, 1, 1));
export const mockEpic = {
id: 1,
iid: 1,
description: 'Explicabo et soluta minus praesentium minima ab et voluptatem. Quas architecto vero corrupti voluptatibus labore accusantium consectetur. Aliquam aut impedit voluptates illum molestias aut harum. Aut non odio praesentium aut.\n\nQuo asperiores aliquid sed nobis. Omnis sint iste provident numquam. Qui voluptatem tempore aut aut voluptas dolorem qui.\n\nEst est nemo quod est. Odit modi eos natus cum illo aut. Expedita nostrum ea est omnis magnam ut eveniet maxime. Itaque ipsam provident minima et occaecati ut. Dicta est perferendis sequi perspiciatis rerum voluptatum deserunt.',
title: 'Cupiditate exercitationem unde harum reprehenderit maxime eius velit recusandae incidunt quia.',
groupId: 2,
groupName: 'Gitlab Org',
groupFullName: 'Gitlab Org',
startDate: new Date('2017-07-10'),
endDate: new Date('2018-06-02'),
webUrl: '/groups/gitlab-org/-/epics/1',
};
export const rawEpics = [
{
id: 41,
iid: 2,
description: null,
title: 'Another marketing',
group_id: 56,
group_name: 'Marketing',
group_full_name: 'Gitlab Org / Marketing',
start_date: '2017-12-26',
end_date: '2018-03-10',
web_url: '/groups/gitlab-org/marketing/-/epics/2',
},
{
id: 40,
iid: 1,
description: null,
title: 'Marketing epic',
group_id: 56,
group_name: 'Marketing',
group_full_name: 'Gitlab Org / Marketing',
start_date: '2017-12-25',
end_date: '2018-03-09',
web_url: '/groups/gitlab-org/marketing/-/epics/1',
},
{
id: 39,
iid: 12,
description: null,
title: 'Epic with end in first timeframe month',
group_id: 2,
group_name: 'Gitlab Org',
group_full_name: 'Gitlab Org',
start_date: '2017-04-02',
end_date: '2017-11-30',
web_url: '/groups/gitlab-org/-/epics/12',
},
{
id: 38,
iid: 11,
description: null,
title: 'Epic with end date out of range',
group_id: 2,
group_name: 'Gitlab Org',
group_full_name: 'Gitlab Org',
start_date: '2018-01-15',
end_date: '2020-01-03',
web_url: '/groups/gitlab-org/-/epics/11',
},
{
id: 37,
iid: 10,
description: null,
title: 'Epic with timeline in same month',
group_id: 2,
group_name: 'Gitlab Org',
group_full_name: 'Gitlab Org',
start_date: '2018-01-01',
end_date: '2018-01-31',
web_url: '/groups/gitlab-org/-/epics/10',
},
{
id: 35,
iid: 8,
description: null,
title: 'Epic with out of range start & null end',
group_id: 2,
group_name: 'Gitlab Org',
group_full_name: 'Gitlab Org',
start_date: '2017-09-04',
end_date: null,
web_url: '/groups/gitlab-org/-/epics/8',
},
{
id: 33,
iid: 6,
description: null,
title: 'Epic with only start date',
group_id: 2,
group_name: 'Gitlab Org',
group_full_name: 'Gitlab Org',
start_date: '2017-11-27',
end_date: null,
web_url: '/groups/gitlab-org/-/epics/6',
},
{
id: 4,
iid: 4,
description: 'Animi dolorem error ipsam assumenda. Dolor reprehenderit sit soluta molestias id. Explicabo vel dolores numquam earum ut aliquid. Quisquam aliquam a totam laborum quia.\n\nEt voluptatem reiciendis qui cum. Labore ratione delectus minus et voluptates. Dolor voluptatem nisi neque fugiat ut ullam dicta odit. Aut quaerat provident ducimus aut molestiae hic esse.\n\nSuscipit non repellat laudantium quaerat. Voluptatum dolor explicabo vel illo earum. Laborum vero occaecati qui autem cumque dolorem autem. Enim voluptatibus a dolorem et.',
title: 'Et repellendus quo et laboriosam corrupti ex nisi qui.',
group_id: 2,
group_name: 'Gitlab Org',
group_full_name: 'Gitlab Org',
start_date: '2018-01-01',
end_date: '2018-02-02',
web_url: '/groups/gitlab-org/-/epics/4',
},
{
id: 3,
iid: 3,
description: 'Magnam placeat ut esse aut vel. Et sit ab soluta ut eos et et. Nesciunt expedita sit et optio maiores quas facilis. Provident ut aut et nihil. Nesciunt ipsum fuga labore dolor quia.\n\nSit suscipit impedit aut dolore non provident. Nesciunt nemo excepturi voluptatem natus veritatis. Vel ut possimus reiciendis dolorem et. Recusandae voluptatem voluptatum aut iure. Sapiente quia est iste similique quidem quia omnis et.\n\nId aut assumenda beatae iusto est dicta consequatur. Tempora voluptatem pariatur ab velit vero ut reprehenderit fuga. Dolor modi aspernatur eos atque eveniet harum sed voluptatem. Dolore iusto voluptas dolor enim labore dolorum consequatur dolores.',
title: 'Nostrum ut nisi fugiat accusantium qui velit dignissimos.',
group_id: 2,
group_name: 'Gitlab Org',
group_full_name: 'Gitlab Org',
start_date: '2017-12-01',
end_date: '2018-03-26',
web_url: '/groups/gitlab-org/-/epics/3',
},
{
id: 2,
iid: 2,
description: 'Deleniti id facere numquam cum consectetur sint ipsum consequatur. Odit nihil harum consequuntur est nemo adipisci. Incidunt suscipit voluptatem et culpa at voluptatem consequuntur. Rerum aliquam earum quia consequatur ipsam quae ut.\n\nQuod molestias ducimus quia ratione nostrum ut adipisci. Fugiat officiis reiciendis repellendus quia ut ipsa. Voluptatum ut dolor perferendis nostrum. Porro a ducimus sequi qui quos ea. Earum velit architecto necessitatibus at dicta.\n\nModi aut non fugiat autem doloribus nobis ea. Sit quam corrupti blanditiis nihil tempora ratione enim ex. Aliquam quia ut impedit ut velit reprehenderit quae amet. Unde quod at dolorum eligendi in ducimus perspiciatis accusamus.',
title: 'Sit beatae amet quaerat consequatur non repudiandae qui.',
group_id: 2,
group_name: 'Gitlab Org',
group_full_name: 'Gitlab Org',
start_date: '2017-11-26',
end_date: '2018-03-22',
web_url: '/groups/gitlab-org/-/epics/2',
},
{
id: 1,
iid: 1,
description: 'Explicabo et soluta minus praesentium minima ab et voluptatem. Quas architecto vero corrupti voluptatibus labore accusantium consectetur. Aliquam aut impedit voluptates illum molestias aut harum. Aut non odio praesentium aut.\n\nQuo asperiores aliquid sed nobis. Omnis sint iste provident numquam. Qui voluptatem tempore aut aut voluptas dolorem qui.\n\nEst est nemo quod est. Odit modi eos natus cum illo aut. Expedita nostrum ea est omnis magnam ut eveniet maxime. Itaque ipsam provident minima et occaecati ut. Dicta est perferendis sequi perspiciatis rerum voluptatum deserunt.',
title: 'Cupiditate exercitationem unde harum reprehenderit maxime eius velit recusandae incidunt quia.',
group_id: 2,
group_name: 'Gitlab Org',
group_full_name: 'Gitlab Org',
start_date: '2017-07-10',
end_date: '2018-06-02',
web_url: '/groups/gitlab-org/-/epics/1',
},
];
import axios from '~/lib/utils/axios_utils';
import RoadmapService from 'ee/roadmap/service/roadmap_service';
import { epicsPath } from '../mock_data';
describe('RoadmapService', () => {
let service;
beforeEach(() => {
service = new RoadmapService(epicsPath);
});
describe('getEpics', () => {
it('returns axios instance for Epics path', () => {
spyOn(axios, 'get').and.stub();
service.getEpics();
expect(axios.get).toHaveBeenCalledWith(service.epicsPath);
});
});
});
import RoadmapStore from 'ee/roadmap/store/roadmap_store';
import { mockGroupId, mockTimeframe, rawEpics } from '../mock_data';
describe('RoadmapStore', () => {
let store;
beforeEach(() => {
store = new RoadmapStore(mockGroupId, mockTimeframe);
});
describe('constructor', () => {
it('initializes default state', () => {
expect(store.state).toBeDefined();
expect(Array.isArray(store.state.epics)).toBe(true);
expect(store.state.currentGroupId).toBe(mockGroupId);
expect(store.state.timeframe).toBe(mockTimeframe);
expect(store.firstTimeframeItem).toBe(store.state.timeframe[0]);
expect(store.lastTimeframeItem).toBe(store.state.timeframe[store.state.timeframe.length - 1]);
});
});
describe('setEpics', () => {
it('sets Epics list to state', () => {
store.setEpics(rawEpics);
expect(store.getEpics().length).toBe(rawEpics.length);
});
});
describe('getCurrentGroupId', () => {
it('gets currentGroupId from store state', () => {
expect(store.getCurrentGroupId()).toBe(mockGroupId);
});
});
describe('getTimeframe', () => {
it('gets timeframe from store state', () => {
expect(store.getTimeframe()).toBe(mockTimeframe);
});
});
describe('formatEpicDetails', () => {
const rawEpic = rawEpics[0];
it('returns formatted Epic object from raw Epic object', () => {
const epic = RoadmapStore.formatEpicDetails(rawEpic);
expect(epic.id).toBe(rawEpic.id);
expect(epic.name).toBe(rawEpic.name);
expect(epic.groupId).toBe(rawEpic.group_id);
expect(epic.groupName).toBe(rawEpic.group_name);
});
it('returns formatted Epic object with startDateUndefined and proxy date set when start date is not available', () => {
const rawEpicWithoutSD = Object.assign({}, rawEpic, {
start_date: null,
});
const epic = RoadmapStore.formatEpicDetails(
rawEpicWithoutSD,
store.firstTimeframeItem,
store.lastTimeframeItem,
);
expect(epic.id).toBe(rawEpic.id);
expect(epic.startDateUndefined).toBe(true);
expect(epic.startDate.getTime()).toBe(store.firstTimeframeItem.getTime());
});
it('returns formatted Epic object with endDateUndefined and proxy date set when end date is not available', () => {
const rawEpicWithoutED = Object.assign({}, rawEpic, {
end_date: null,
});
const epic = RoadmapStore.formatEpicDetails(
rawEpicWithoutED,
store.firstTimeframeItem,
store.lastTimeframeItem,
);
expect(epic.id).toBe(rawEpic.id);
expect(epic.endDateUndefined).toBe(true);
expect(epic.endDate.getTime()).toBe(store.lastTimeframeItem.getTime());
});
it('returns formatted Epic object with startDateOutOfRange, proxy date and cached original start date set when start date is out of timeframe range', () => {
const rawStartDate = '2017-1-1';
const rawEpicSDOut = Object.assign({}, rawEpic, {
start_date: rawStartDate,
});
const epic = RoadmapStore.formatEpicDetails(
rawEpicSDOut,
store.firstTimeframeItem,
store.lastTimeframeItem,
);
expect(epic.id).toBe(rawEpic.id);
expect(epic.startDateOutOfRange).toBe(true);
expect(epic.startDate.getTime()).toBe(store.firstTimeframeItem.getTime());
expect(epic.originalStartDate.getTime()).toBe(new Date(rawStartDate).getTime());
});
it('returns formatted Epic object with endDateOutOfRange, proxy date and cached original end date set when end date is out of timeframe range', () => {
const rawEndDate = '2019-1-1';
const rawEpicEDOut = Object.assign({}, rawEpic, {
end_date: rawEndDate,
});
const epic = RoadmapStore.formatEpicDetails(
rawEpicEDOut,
store.firstTimeframeItem,
store.lastTimeframeItem,
);
expect(epic.id).toBe(rawEpic.id);
expect(epic.endDateOutOfRange).toBe(true);
expect(epic.endDate.getTime()).toBe(store.lastTimeframeItem.getTime());
expect(epic.originalEndDate.getTime()).toBe(new Date(rawEndDate).getTime());
});
});
});
...@@ -55,8 +55,8 @@ ...@@ -55,8 +55,8 @@
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@gitlab-org/gitlab-svgs@^1.8.0": "@gitlab-org/gitlab-svgs@^1.8.0":
version "1.8.0" version "1.9.0"
resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.8.0.tgz#95d6afa94395860699ddad60a82bd1bbbc2ba89f" resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.9.0.tgz#0ca4a901b30dc9b4c5ac839a26535940c0e42fdc"
"@types/jquery@^2.0.40": "@types/jquery@^2.0.40":
version "2.0.48" version "2.0.48"
...@@ -7103,6 +7103,12 @@ supports-color@^4.4.0: ...@@ -7103,6 +7103,12 @@ supports-color@^4.4.0:
dependencies: dependencies:
has-flag "^2.0.0" has-flag "^2.0.0"
supports-color@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.1.0.tgz#058a021d1b619f7ddf3980d712ea3590ce7de3d5"
dependencies:
has-flag "^2.0.0"
svg4everybody@2.1.9: svg4everybody@2.1.9:
version "2.1.9" version "2.1.9"
resolved "https://registry.yarnpkg.com/svg4everybody/-/svg4everybody-2.1.9.tgz#5bd9f6defc133859a044646d4743fabc28db7e2d" resolved "https://registry.yarnpkg.com/svg4everybody/-/svg4everybody-2.1.9.tgz#5bd9f6defc133859a044646d4743fabc28db7e2d"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment