From aa440eb1c0947d2dc551c61abbd9d271b9002050 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Sat, 6 May 2017 19:02:06 +0200 Subject: [PATCH] Single commit squash of all changes for https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10878 It's needed due to https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10777 being merged with squash. --- app/assets/javascripts/ci_status_icons.js | 34 ----- app/assets/javascripts/dispatcher.js | 3 +- .../lib/utils/bootstrap_linked_tabs.js | 122 ++++++++-------- app/assets/javascripts/pipelines.js | 46 ++----- .../components/graph/action_component.vue | 59 ++++++++ .../graph/dropdown_action_component.vue | 56 ++++++++ .../graph/dropdown_job_component.vue | 86 ++++++++++++ .../components/graph/graph_component.vue | 92 +++++++++++++ .../components/graph/job_component.vue | 124 +++++++++++++++++ .../components/graph/job_name_component.vue | 37 +++++ .../graph/stage_column_component.vue | 64 +++++++++ .../pipelines/components/stage.vue | 6 +- .../javascripts/pipelines/graph_bundle.js | 10 ++ .../pipelines/services/pipeline_service.js | 14 ++ .../pipelines/stores/pipeline_store.js | 11 ++ .../javascripts/vue_shared/ci_action_icons.js | 22 +++ .../javascripts/vue_shared/ci_status_icons.js | 55 ++++++++ .../vue_shared/components/ci_icon.vue | 29 ++++ .../javascripts/vue_shared/mixins/tooltip.js | 9 ++ app/assets/stylesheets/pages/pipelines.scss | 69 +++++----- app/views/ci/status/_graph_badge.html.haml | 20 --- app/views/projects/pipelines/_graph.html.haml | 4 - .../projects/pipelines/_with_tabs.html.haml | 7 +- app/views/projects/stage/_graph.html.haml | 19 --- .../projects/stage/_in_stage_group.html.haml | 14 -- .../25226-realtime-pipelines-fe.yml | 4 + config/webpack.config.js | 2 + .../javascripts/bootstrap_linked_tabs_spec.js | 8 +- spec/javascripts/ci_status_icon_spec.js | 44 ------ spec/javascripts/fixtures/graph.html.haml | 1 + .../pipelines/graph/action_component_spec.js | 40 ++++++ .../graph/dropdown_action_component_spec.js | 30 ++++ .../pipelines/graph/graph_component_spec.js | 83 +++++++++++ .../pipelines/graph/job_component_spec.js | 117 ++++++++++++++++ .../graph/job_name_component_spec.js | 27 ++++ .../graph/stage_column_component_spec.js | 42 ++++++ spec/javascripts/pipelines_spec.js | 36 ++--- .../vue_shared/ci_action_icons_spec.js | 22 +++ .../vue_shared/ci_status_icon_spec.js | 27 ++++ .../vue_shared/components/ci_icon_spec.js | 130 ++++++++++++++++++ 40 files changed, 1326 insertions(+), 299 deletions(-) delete mode 100644 app/assets/javascripts/ci_status_icons.js create mode 100644 app/assets/javascripts/pipelines/components/graph/action_component.vue create mode 100644 app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue create mode 100644 app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue create mode 100644 app/assets/javascripts/pipelines/components/graph/graph_component.vue create mode 100644 app/assets/javascripts/pipelines/components/graph/job_component.vue create mode 100644 app/assets/javascripts/pipelines/components/graph/job_name_component.vue create mode 100644 app/assets/javascripts/pipelines/components/graph/stage_column_component.vue create mode 100644 app/assets/javascripts/pipelines/graph_bundle.js create mode 100644 app/assets/javascripts/pipelines/services/pipeline_service.js create mode 100644 app/assets/javascripts/pipelines/stores/pipeline_store.js create mode 100644 app/assets/javascripts/vue_shared/ci_action_icons.js create mode 100644 app/assets/javascripts/vue_shared/ci_status_icons.js create mode 100644 app/assets/javascripts/vue_shared/components/ci_icon.vue create mode 100644 app/assets/javascripts/vue_shared/mixins/tooltip.js delete mode 100644 app/views/ci/status/_graph_badge.html.haml delete mode 100644 app/views/projects/pipelines/_graph.html.haml delete mode 100644 app/views/projects/stage/_graph.html.haml delete mode 100644 app/views/projects/stage/_in_stage_group.html.haml create mode 100644 changelogs/unreleased/25226-realtime-pipelines-fe.yml delete mode 100644 spec/javascripts/ci_status_icon_spec.js create mode 100644 spec/javascripts/fixtures/graph.html.haml create mode 100644 spec/javascripts/pipelines/graph/action_component_spec.js create mode 100644 spec/javascripts/pipelines/graph/dropdown_action_component_spec.js create mode 100644 spec/javascripts/pipelines/graph/graph_component_spec.js create mode 100644 spec/javascripts/pipelines/graph/job_component_spec.js create mode 100644 spec/javascripts/pipelines/graph/job_name_component_spec.js create mode 100644 spec/javascripts/pipelines/graph/stage_column_component_spec.js create mode 100644 spec/javascripts/vue_shared/ci_action_icons_spec.js create mode 100644 spec/javascripts/vue_shared/ci_status_icon_spec.js create mode 100644 spec/javascripts/vue_shared/components/ci_icon_spec.js diff --git a/app/assets/javascripts/ci_status_icons.js b/app/assets/javascripts/ci_status_icons.js deleted file mode 100644 index f16616873b2..00000000000 --- a/app/assets/javascripts/ci_status_icons.js +++ /dev/null @@ -1,34 +0,0 @@ -import CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg'; -import CREATED_SVG from 'icons/_icon_status_created_borderless.svg'; -import FAILED_SVG from 'icons/_icon_status_failed_borderless.svg'; -import MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg'; -import PENDING_SVG from 'icons/_icon_status_pending_borderless.svg'; -import RUNNING_SVG from 'icons/_icon_status_running_borderless.svg'; -import SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg'; -import SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg'; -import WARNING_SVG from 'icons/_icon_status_warning_borderless.svg'; - -const StatusIconEntityMap = { - icon_status_canceled: CANCELED_SVG, - icon_status_created: CREATED_SVG, - icon_status_failed: FAILED_SVG, - icon_status_manual: MANUAL_SVG, - icon_status_pending: PENDING_SVG, - icon_status_running: RUNNING_SVG, - icon_status_skipped: SKIPPED_SVG, - icon_status_success: SUCCESS_SVG, - icon_status_warning: WARNING_SVG, -}; - -export { - CANCELED_SVG, - CREATED_SVG, - FAILED_SVG, - MANUAL_SVG, - PENDING_SVG, - RUNNING_SVG, - SKIPPED_SVG, - SUCCESS_SVG, - WARNING_SVG, - StatusIconEntityMap as default, -}; diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index b16ff2a0221..d27d89cf91d 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -49,6 +49,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; import UserCallout from './user_callout'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import ShortcutsWiki from './shortcuts_wiki'; +import Pipelines from './pipelines'; import BlobViewer from './blob/viewer/index'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; @@ -257,7 +258,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`; - new gl.Pipelines({ + new Pipelines({ initTabs: true, pipelineStatusUrl, tabsOptions: { diff --git a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js index 2955bda1a36..0bf2ba6acc2 100644 --- a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js +++ b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js @@ -31,82 +31,78 @@ * * ### How to use * - * new window.gl.LinkedTabs({ + * new LinkedTabs({ * action: "#{controller.action_name}", * defaultAction: 'tab1', * parentEl: '.tab-links' * }); */ -(() => { - window.gl = window.gl || {}; +export default class LinkedTabs { + /** + * Binds the events and activates de default tab. + * + * @param {Object} options + */ + constructor(options = {}) { + this.options = options; - window.gl.LinkedTabs = class LinkedTabs { - /** - * Binds the events and activates de default tab. - * - * @param {Object} options - */ - constructor(options) { - this.options = options || {}; + this.defaultAction = this.options.defaultAction; + this.action = this.options.action || this.defaultAction; - this.defaultAction = this.options.defaultAction; - this.action = this.options.action || this.defaultAction; - - if (this.action === 'show') { - this.action = this.defaultAction; - } - - this.currentLocation = window.location; - - const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`; - - // since this is a custom event we need jQuery :( - $(document) - .off('shown.bs.tab', tabSelector) - .on('shown.bs.tab', tabSelector, e => this.tabShown(e)); - - this.activateTab(this.action); + if (this.action === 'show') { + this.action = this.defaultAction; } - /** - * Handles the `shown.bs.tab` event to set the currect url action. - * - * @param {type} evt - * @return {Function} - */ - tabShown(evt) { - const source = evt.target.getAttribute('href'); + this.currentLocation = window.location; - return this.setCurrentAction(source); - } + const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`; - /** - * Updates the URL with the path that matched the given action. - * - * @param {String} source - * @return {String} - */ - setCurrentAction(source) { - const copySource = source; + // since this is a custom event we need jQuery :( + $(document) + .off('shown.bs.tab', tabSelector) + .on('shown.bs.tab', tabSelector, e => this.tabShown(e)); - copySource.replace(/\/+$/, ''); + this.activateTab(this.action); + } - const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`; + /** + * Handles the `shown.bs.tab` event to set the currect url action. + * + * @param {type} evt + * @return {Function} + */ + tabShown(evt) { + const source = evt.target.getAttribute('href'); - history.replaceState({ - url: newState, - }, document.title, newState); - return newState; - } + return this.setCurrentAction(source); + } - /** - * Given the current action activates the correct tab. - * http://getbootstrap.com/javascript/#tab-show - * Note: Will trigger `shown.bs.tab` - */ - activateTab() { - return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show'); - } - }; -})(); + /** + * Updates the URL with the path that matched the given action. + * + * @param {String} source + * @return {String} + */ + setCurrentAction(source) { + const copySource = source; + + copySource.replace(/\/+$/, ''); + + const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`; + + history.replaceState({ + url: newState, + }, document.title, newState); + return newState; + } + + /** + * Given the current action activates the correct tab. + * http://getbootstrap.com/javascript/#tab-show + * Note: Will trigger `shown.bs.tab` + */ + activateTab() { + return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show'); + } +} diff --git a/app/assets/javascripts/pipelines.js b/app/assets/javascripts/pipelines.js index 4252b615887..26a36ad54d1 100644 --- a/app/assets/javascripts/pipelines.js +++ b/app/assets/javascripts/pipelines.js @@ -1,42 +1,14 @@ -/* eslint-disable no-new, guard-for-in, no-restricted-syntax, no-continue, no-param-reassign, max-len */ +import LinkedTabs from './lib/utils/bootstrap_linked_tabs'; -require('./lib/utils/bootstrap_linked_tabs'); - -((global) => { - class Pipelines { - constructor(options = {}) { - if (options.initTabs && options.tabsOptions) { - new global.LinkedTabs(options.tabsOptions); - } - - if (options.pipelineStatusUrl) { - gl.utils.setCiStatusFavicon(options.pipelineStatusUrl); - } - - this.addMarginToBuildColumns(); +export default class Pipelines { + constructor(options = {}) { + if (options.initTabs && options.tabsOptions) { + // eslint-disable-next-line no-new + new LinkedTabs(options.tabsOptions); } - addMarginToBuildColumns() { - this.pipelineGraph = document.querySelector('.js-pipeline-graph'); - - const secondChildBuildNodes = this.pipelineGraph.querySelectorAll('.build:nth-child(2)'); - - for (const buildNodeIndex in secondChildBuildNodes) { - const buildNode = secondChildBuildNodes[buildNodeIndex]; - const firstChildBuildNode = buildNode.previousElementSibling; - if (!firstChildBuildNode || !firstChildBuildNode.matches('.build')) continue; - const multiBuildColumn = buildNode.closest('.stage-column'); - const previousColumn = multiBuildColumn.previousElementSibling; - if (!previousColumn || !previousColumn.matches('.stage-column')) continue; - multiBuildColumn.classList.add('left-margin'); - firstChildBuildNode.classList.add('left-connector'); - const columnBuilds = previousColumn.querySelectorAll('.build'); - if (columnBuilds.length === 1) previousColumn.classList.add('no-margin'); - } - - this.pipelineGraph.classList.remove('hidden'); + if (options.pipelineStatusUrl) { + gl.utils.setCiStatusFavicon(options.pipelineStatusUrl); } } - - global.Pipelines = Pipelines; -})(window.gl || (window.gl = {})); +} diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue new file mode 100644 index 00000000000..14e485791ea --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -0,0 +1,59 @@ + + diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue new file mode 100644 index 00000000000..19cafff4e1c --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue @@ -0,0 +1,56 @@ + + diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue new file mode 100644 index 00000000000..d597af8dfb5 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -0,0 +1,86 @@ + + diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue new file mode 100644 index 00000000000..a84161ef5e7 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -0,0 +1,92 @@ + + diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue new file mode 100644 index 00000000000..b39c936101e --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -0,0 +1,124 @@ + + diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue new file mode 100644 index 00000000000..d8856e10668 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -0,0 +1,37 @@ + + diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue new file mode 100644 index 00000000000..b7da185e280 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -0,0 +1,64 @@ + + diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index 2e485f951a1..dc42223269d 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -14,7 +14,7 @@ */ /* global Flash */ -import StatusIconEntityMap from '../../ci_status_icons'; +import { statusCssClasses, borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; export default { props: { @@ -109,11 +109,11 @@ export default { }, triggerButtonClass() { - return `ci-status-icon-${this.stage.status.group}`; + return `ci-status-icon-${statusCssClasses[this.stage.status.icon]}`; }, svgIcon() { - return StatusIconEntityMap[this.stage.status.icon]; + return borderlessStatusIconEntityMap[this.stage.status.icon]; }, }, }; diff --git a/app/assets/javascripts/pipelines/graph_bundle.js b/app/assets/javascripts/pipelines/graph_bundle.js new file mode 100644 index 00000000000..b7a6b5d8479 --- /dev/null +++ b/app/assets/javascripts/pipelines/graph_bundle.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; +import pipelineGraph from './components/graph/graph_component.vue'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#js-pipeline-graph-vue', + components: { + pipelineGraph, + }, + render: createElement => createElement('pipeline-graph'), +})); diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js new file mode 100644 index 00000000000..f1cc60c1ee0 --- /dev/null +++ b/app/assets/javascripts/pipelines/services/pipeline_service.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class PipelineService { + constructor(endpoint) { + this.pipeline = Vue.resource(endpoint); + } + + getPipeline() { + return this.pipeline.get(); + } +} diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js new file mode 100644 index 00000000000..86ab50d8f1e --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js @@ -0,0 +1,11 @@ +export default class PipelineStore { + constructor() { + this.state = {}; + + this.state.graph = []; + } + + storeGraph(graph = []) { + this.state.graph = graph; + } +} diff --git a/app/assets/javascripts/vue_shared/ci_action_icons.js b/app/assets/javascripts/vue_shared/ci_action_icons.js new file mode 100644 index 00000000000..734b3c6c45e --- /dev/null +++ b/app/assets/javascripts/vue_shared/ci_action_icons.js @@ -0,0 +1,22 @@ +import cancelSVG from 'icons/_icon_action_cancel.svg'; +import retrySVG from 'icons/_icon_action_retry.svg'; +import playSVG from 'icons/_icon_action_play.svg'; + +export default function getActionIcon(action) { + let icon; + switch (action) { + case 'icon_action_cancel': + icon = cancelSVG; + break; + case 'icon_action_retry': + icon = retrySVG; + break; + case 'icon_action_play': + icon = playSVG; + break; + default: + icon = ''; + } + + return icon; +} diff --git a/app/assets/javascripts/vue_shared/ci_status_icons.js b/app/assets/javascripts/vue_shared/ci_status_icons.js new file mode 100644 index 00000000000..48ad9214ac8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/ci_status_icons.js @@ -0,0 +1,55 @@ +import BORDERLESS_CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg'; +import BORDERLESS_CREATED_SVG from 'icons/_icon_status_created_borderless.svg'; +import BORDERLESS_FAILED_SVG from 'icons/_icon_status_failed_borderless.svg'; +import BORDERLESS_MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg'; +import BORDERLESS_PENDING_SVG from 'icons/_icon_status_pending_borderless.svg'; +import BORDERLESS_RUNNING_SVG from 'icons/_icon_status_running_borderless.svg'; +import BORDERLESS_SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg'; +import BORDERLESS_SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg'; +import BORDERLESS_WARNING_SVG from 'icons/_icon_status_warning_borderless.svg'; + +import CANCELED_SVG from 'icons/_icon_status_canceled.svg'; +import CREATED_SVG from 'icons/_icon_status_created.svg'; +import FAILED_SVG from 'icons/_icon_status_failed.svg'; +import MANUAL_SVG from 'icons/_icon_status_manual.svg'; +import PENDING_SVG from 'icons/_icon_status_pending.svg'; +import RUNNING_SVG from 'icons/_icon_status_running.svg'; +import SKIPPED_SVG from 'icons/_icon_status_skipped.svg'; +import SUCCESS_SVG from 'icons/_icon_status_success.svg'; +import WARNING_SVG from 'icons/_icon_status_warning.svg'; + +export const borderlessStatusIconEntityMap = { + icon_status_canceled: BORDERLESS_CANCELED_SVG, + icon_status_created: BORDERLESS_CREATED_SVG, + icon_status_failed: BORDERLESS_FAILED_SVG, + icon_status_manual: BORDERLESS_MANUAL_SVG, + icon_status_pending: BORDERLESS_PENDING_SVG, + icon_status_running: BORDERLESS_RUNNING_SVG, + icon_status_skipped: BORDERLESS_SKIPPED_SVG, + icon_status_success: BORDERLESS_SUCCESS_SVG, + icon_status_warning: BORDERLESS_WARNING_SVG, +}; + +export const statusIconEntityMap = { + icon_status_canceled: CANCELED_SVG, + icon_status_created: CREATED_SVG, + icon_status_failed: FAILED_SVG, + icon_status_manual: MANUAL_SVG, + icon_status_pending: PENDING_SVG, + icon_status_running: RUNNING_SVG, + icon_status_skipped: SKIPPED_SVG, + icon_status_success: SUCCESS_SVG, + icon_status_warning: WARNING_SVG, +}; + +export const statusCssClasses = { + icon_status_canceled: 'canceled', + icon_status_created: 'created', + icon_status_failed: 'failed', + icon_status_manual: 'manual', + icon_status_pending: 'pending', + icon_status_running: 'running', + icon_status_skipped: 'skipped', + icon_status_success: 'success', + icon_status_warning: 'warning', +}; diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue new file mode 100644 index 00000000000..4d44baaa3c4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -0,0 +1,29 @@ + + diff --git a/app/assets/javascripts/vue_shared/mixins/tooltip.js b/app/assets/javascripts/vue_shared/mixins/tooltip.js new file mode 100644 index 00000000000..9bb948bff66 --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/tooltip.js @@ -0,0 +1,9 @@ +export default { + mounted() { + $(this.$refs.tooltip).tooltip(); + }, + + updated() { + $(this.$refs.tooltip).tooltip('fixTitle'); + }, +}; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 530a6f3c6a1..eaf3dd49567 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -486,7 +486,7 @@ color: $gl-text-color-secondary; // Action Icons in big pipeline-graph nodes - > .ci-action-icon-container .ci-action-icon-wrapper { + > div > .ci-action-icon-container .ci-action-icon-wrapper { height: 30px; width: 30px; background: $white-light; @@ -511,7 +511,7 @@ } } - > .ci-action-icon-container { + > div > .ci-action-icon-container { position: absolute; right: 5px; top: 5px; @@ -541,7 +541,7 @@ } } - > .build-content { + > div > .build-content { display: inline-block; padding: 8px 10px 9px; width: 100%; @@ -557,34 +557,6 @@ } - .arrow { - &::before, - &::after { - content: ''; - display: inline-block; - position: absolute; - width: 0; - height: 0; - border-color: transparent; - border-style: solid; - top: 18px; - } - - &::before { - left: -5px; - margin-top: -6px; - border-width: 7px 5px 7px 0; - border-right-color: $border-color; - } - - &::after { - left: -4px; - margin-top: -9px; - border-width: 10px 7px 10px 0; - border-right-color: $white-light; - } - } - // Connect first build in each stage with right horizontal line &:first-child { &::after { @@ -859,7 +831,8 @@ border-radius: 3px; // build name - .ci-build-text { + .ci-build-text, + .ci-status-text { font-weight: 200; overflow: hidden; white-space: nowrap; @@ -911,6 +884,38 @@ } } +/** + * Top arrow in the dropdown in the big pipeline graph + */ +.big-pipeline-graph-dropdown-menu { + + &::before, + &::after { + content: ''; + display: inline-block; + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + top: 18px; + } + + &::before { + left: -5px; + margin-top: -6px; + border-width: 7px 5px 7px 0; + border-right-color: $border-color; + } + + &::after { + left: -4px; + margin-top: -9px; + border-width: 10px 7px 10px 0; + border-right-color: $white-light; + } +} + /** * Top arrow in the dropdown in the mini pipeline graph */ diff --git a/app/views/ci/status/_graph_badge.html.haml b/app/views/ci/status/_graph_badge.html.haml deleted file mode 100644 index 128b418090f..00000000000 --- a/app/views/ci/status/_graph_badge.html.haml +++ /dev/null @@ -1,20 +0,0 @@ --# Renders the graph node with both the status icon, status name and action icon - -- subject = local_assigns.fetch(:subject) -- status = subject.detailed_status(current_user) -- klass = "ci-status-icon ci-status-icon-#{status.group} js-ci-status-icon-#{status.group}" -- tooltip = "#{subject.name} - #{status.label}" - -- if status.has_details? - = link_to status.details_path, class: 'build-content has-tooltip', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do - %span{ class: klass }= custom_icon(status.icon) - .ci-status-text= subject.name -- else - .build-content.has-tooltip{ data: { toggle: 'tooltip', title: tooltip } } - %span{ class: klass }= custom_icon(status.icon) - .ci-status-text= subject.name - -- if status.has_action? - = link_to status.action_path, class: 'ci-action-icon-container has-tooltip', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do - %i.ci-action-icon-wrapper{ class: "js-#{status.action_icon.dasherize}" } - = custom_icon(status.action_icon) diff --git a/app/views/projects/pipelines/_graph.html.haml b/app/views/projects/pipelines/_graph.html.haml deleted file mode 100644 index 0202833c0bf..00000000000 --- a/app/views/projects/pipelines/_graph.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- pipeline = local_assigns.fetch(:pipeline) -.pipeline-visualization.pipeline-graph - %ul.stage-column-list - = render partial: "projects/stage/graph", collection: pipeline.stages, as: :stage diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 1aa48bf9813..1c7d1768aa5 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -17,8 +17,11 @@ .tab-content #js-tab-pipeline.tab-pane - .build-content.middle-block.js-pipeline-graph - = render "projects/pipelines/graph", pipeline: pipeline + #js-pipeline-graph-vue{ data: { endpoint: namespace_project_pipeline_path(@project.namespace, @project, @pipeline, format: :json) } } + + - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('pipelines_graph') #js-tab-builds.tab-pane - if pipeline.yaml_errors.present? diff --git a/app/views/projects/stage/_graph.html.haml b/app/views/projects/stage/_graph.html.haml deleted file mode 100644 index 4ee30b023ac..00000000000 --- a/app/views/projects/stage/_graph.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -- stage = local_assigns.fetch(:stage) -- statuses = stage.statuses.latest -- status_groups = statuses.sort_by(&:sortable_name).group_by(&:group_name) -%li.stage-column - .stage-name - %a{ name: stage.name } - = stage.name.titleize - .builds-container - %ul - - status_groups.each do |group_name, grouped_statuses| - - if grouped_statuses.one? - - status = grouped_statuses.first - %li.build{ 'id' => "ci-badge-#{group_name}" } - .curve - = render 'ci/status/graph_badge', subject: status - - else - %li.build{ 'id' => "ci-badge-#{group_name}" } - .curve - = render 'projects/stage/in_stage_group', name: group_name, subject: grouped_statuses diff --git a/app/views/projects/stage/_in_stage_group.html.haml b/app/views/projects/stage/_in_stage_group.html.haml deleted file mode 100644 index 671a3ef481c..00000000000 --- a/app/views/projects/stage/_in_stage_group.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -- group_status = CommitStatus.where(id: subject).status -%button.dropdown-menu-toggle.build-content.has-tooltip{ type: 'button', data: { toggle: 'dropdown', title: "#{name} - #{group_status}", container: 'body' } } - %span{ class: "ci-status-icon ci-status-icon-#{group_status}" } - = ci_icon_for_status(group_status) - %span.ci-status-text - = name - %span.dropdown-counter-badge= subject.size - -%ul.dropdown-menu.big-pipeline-graph-dropdown-menu.js-grouped-pipeline-dropdown - .arrow - .scrollable-menu - - subject.each do |status| - %li - = render 'ci/status/dropdown_graph_badge', subject: status diff --git a/changelogs/unreleased/25226-realtime-pipelines-fe.yml b/changelogs/unreleased/25226-realtime-pipelines-fe.yml new file mode 100644 index 00000000000..1149c8f0eac --- /dev/null +++ b/changelogs/unreleased/25226-realtime-pipelines-fe.yml @@ -0,0 +1,4 @@ +--- +title: Re-rewrites pipeline graph in vue to support realtime data updates +merge_request: +author: diff --git a/config/webpack.config.js b/config/webpack.config.js index 119b1ea9d2e..a3dae6b2e13 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -49,6 +49,7 @@ var config = { pdf_viewer: './blob/pdf_viewer.js', pipelines: './pipelines/index.js', balsamiq_viewer: './blob/balsamiq_viewer.js', + pipelines_graph: './pipelines/graph_bundle.js', profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', protected_tags: './protected_tags', @@ -145,6 +146,7 @@ var config = { 'pdf_viewer', 'pipelines', 'balsamiq_viewer', + 'pipelines_graph', ], minChunks: function(module, count) { return module.resource && (/vue_shared/).test(module.resource); diff --git a/spec/javascripts/bootstrap_linked_tabs_spec.js b/spec/javascripts/bootstrap_linked_tabs_spec.js index fa9f95e16cd..a27dc48b3fd 100644 --- a/spec/javascripts/bootstrap_linked_tabs_spec.js +++ b/spec/javascripts/bootstrap_linked_tabs_spec.js @@ -1,4 +1,4 @@ -require('~/lib/utils/bootstrap_linked_tabs'); +import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; (() => { // TODO: remove this hack! @@ -25,7 +25,7 @@ require('~/lib/utils/bootstrap_linked_tabs'); }); it('should activate the tab correspondent to the given action', () => { - const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line + const linkedTabs = new LinkedTabs({ // eslint-disable-line action: 'tab1', defaultAction: 'tab1', parentEl: '.linked-tabs', @@ -35,7 +35,7 @@ require('~/lib/utils/bootstrap_linked_tabs'); }); it('should active the default tab action when the action is show', () => { - const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line + const linkedTabs = new LinkedTabs({ // eslint-disable-line action: 'show', defaultAction: 'tab1', parentEl: '.linked-tabs', @@ -49,7 +49,7 @@ require('~/lib/utils/bootstrap_linked_tabs'); it('should change the url according to the clicked tab', () => { const historySpy = !phantomjs && spyOn(history, 'replaceState').and.callFake(() => {}); - const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line + const linkedTabs = new LinkedTabs({ action: 'show', defaultAction: 'tab1', parentEl: '.linked-tabs', diff --git a/spec/javascripts/ci_status_icon_spec.js b/spec/javascripts/ci_status_icon_spec.js deleted file mode 100644 index c83416c15ef..00000000000 --- a/spec/javascripts/ci_status_icon_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import * as icons from '~/ci_status_icons'; - -describe('CI status icons', () => { - const statuses = [ - 'canceled', - 'created', - 'failed', - 'manual', - 'pending', - 'running', - 'skipped', - 'success', - 'warning', - ]; - - statuses.forEach((status) => { - it(`should export a ${status} svg`, () => { - const key = `${status.toUpperCase()}_SVG`; - - expect(Object.hasOwnProperty.call(icons, key)).toBe(true); - expect(icons[key]).toMatch(/^ { - const entityIconNames = [ - 'icon_status_canceled', - 'icon_status_created', - 'icon_status_failed', - 'icon_status_manual', - 'icon_status_pending', - 'icon_status_running', - 'icon_status_skipped', - 'icon_status_success', - 'icon_status_warning', - ]; - - entityIconNames.forEach((iconName) => { - it(`should have a '${iconName}' key`, () => { - expect(Object.hasOwnProperty.call(icons.default, iconName)).toBe(true); - }); - }); - }); -}); diff --git a/spec/javascripts/fixtures/graph.html.haml b/spec/javascripts/fixtures/graph.html.haml new file mode 100644 index 00000000000..4fedb0f1ded --- /dev/null +++ b/spec/javascripts/fixtures/graph.html.haml @@ -0,0 +1 @@ +#js-pipeline-graph-vue{ data: { endpoint: "foo" } } diff --git a/spec/javascripts/pipelines/graph/action_component_spec.js b/spec/javascripts/pipelines/graph/action_component_spec.js new file mode 100644 index 00000000000..f033956c071 --- /dev/null +++ b/spec/javascripts/pipelines/graph/action_component_spec.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; +import actionComponent from '~/pipelines/components/graph/action_component.vue'; + +describe('pipeline graph action component', () => { + let component; + + beforeEach(() => { + const ActionComponent = Vue.extend(actionComponent); + component = new ActionComponent({ + propsData: { + tooltipText: 'bar', + link: 'foo', + actionMethod: 'post', + actionIcon: 'icon_action_cancel', + }, + }).$mount(); + }); + + it('should render a link', () => { + expect(component.$el.getAttribute('href')).toEqual('foo'); + }); + + it('should render the provided title as a bootstrap tooltip', () => { + expect(component.$el.getAttribute('data-original-title')).toEqual('bar'); + }); + + it('should update bootstrap tooltip when title changes', (done) => { + component.tooltipText = 'changed'; + + Vue.nextTick(() => { + expect(component.$el.getAttribute('data-original-title')).toBe('changed'); + done(); + }); + }); + + it('should render an svg', () => { + expect(component.$el.querySelector('.ci-action-icon-wrapper')).toBeDefined(); + expect(component.$el.querySelector('svg')).toBeDefined(); + }); +}); diff --git a/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js new file mode 100644 index 00000000000..14ff1b0d25c --- /dev/null +++ b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import dropdownActionComponent from '~/pipelines/components/graph/dropdown_action_component.vue'; + +describe('action component', () => { + let component; + + beforeEach(() => { + const DropdownActionComponent = Vue.extend(dropdownActionComponent); + component = new DropdownActionComponent({ + propsData: { + tooltipText: 'bar', + link: 'foo', + actionMethod: 'post', + actionIcon: 'icon_action_cancel', + }, + }).$mount(); + }); + + it('should render a link', () => { + expect(component.$el.getAttribute('href')).toEqual('foo'); + }); + + it('should render the provided title as a bootstrap tooltip', () => { + expect(component.$el.getAttribute('data-original-title')).toEqual('bar'); + }); + + it('should render an svg', () => { + expect(component.$el.querySelector('svg')).toBeDefined(); + }); +}); diff --git a/spec/javascripts/pipelines/graph/graph_component_spec.js b/spec/javascripts/pipelines/graph/graph_component_spec.js new file mode 100644 index 00000000000..a756617e65e --- /dev/null +++ b/spec/javascripts/pipelines/graph/graph_component_spec.js @@ -0,0 +1,83 @@ +import Vue from 'vue'; +import graphComponent from '~/pipelines/components/graph/graph_component.vue'; + +describe('graph component', () => { + preloadFixtures('static/graph.html.raw'); + + let GraphComponent; + + beforeEach(() => { + loadFixtures('static/graph.html.raw'); + GraphComponent = Vue.extend(graphComponent); + }); + + describe('while is loading', () => { + it('should render a loading icon', () => { + const component = new GraphComponent().$mount('#js-pipeline-graph-vue'); + expect(component.$el.querySelector('.loading-icon')).toBeDefined(); + }); + }); + + describe('with a successfull response', () => { + const interceptor = (request, next) => { + next(request.respondWith(JSON.stringify({ + details: { + stages: [{ + name: 'test', + title: 'test: passed', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + details_path: '/root/ci-mock/pipelines/123#test', + }, + path: '/root/ci-mock/pipelines/123#test', + groups: [{ + name: 'test', + size: 1, + jobs: [{ + id: 4153, + name: 'test', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + details_path: '/root/ci-mock/builds/4153', + action: { + icon: 'icon_action_retry', + title: 'Retry', + path: '/root/ci-mock/builds/4153/retry', + method: 'post', + }, + }, + }], + }], + }], + }, + }), { + status: 200, + })); + }; + + beforeEach(() => { + Vue.http.interceptors.push(interceptor); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + }); + + it('should render the graph', (done) => { + const component = new GraphComponent().$mount('#js-pipeline-graph-vue'); + + setTimeout(() => { + expect(component.$el.classList.contains('js-pipeline-graph')).toEqual(true); + + expect(component.$el.querySelector('loading-icon')).toBe(null); + + expect(component.$el.querySelector('.stage-column-list')).toBeDefined(); + done(); + }, 0); + }); + }); +}); diff --git a/spec/javascripts/pipelines/graph/job_component_spec.js b/spec/javascripts/pipelines/graph/job_component_spec.js new file mode 100644 index 00000000000..63986b6c0db --- /dev/null +++ b/spec/javascripts/pipelines/graph/job_component_spec.js @@ -0,0 +1,117 @@ +import Vue from 'vue'; +import jobComponent from '~/pipelines/components/graph/job_component.vue'; + +describe('pipeline graph job component', () => { + let JobComponent; + + const mockJob = { + id: 4256, + name: 'test', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4256', + action: { + icon: 'icon_action_retry', + title: 'Retry', + path: '/root/ci-mock/builds/4256/retry', + method: 'post', + }, + }, + }; + + beforeEach(() => { + JobComponent = Vue.extend(jobComponent); + }); + + describe('name with link', () => { + it('should render the job name and status with a link', () => { + const component = new JobComponent({ + propsData: { + job: mockJob, + }, + }).$mount(); + + const link = component.$el.querySelector('a'); + + expect(link.getAttribute('href')).toEqual(mockJob.status.details_path); + + expect( + link.getAttribute('data-original-title'), + ).toEqual(`${mockJob.name} - ${mockJob.status.label}`); + + expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined(); + + expect( + component.$el.querySelector('.ci-status-text').textContent.trim(), + ).toEqual(mockJob.name); + }); + }); + + describe('name without link', () => { + it('it should render status and name', () => { + const component = new JobComponent({ + propsData: { + job: { + id: 4256, + name: 'test', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4256', + }, + }, + }, + }).$mount(); + + expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined(); + + expect( + component.$el.querySelector('.ci-status-text').textContent.trim(), + ).toEqual(mockJob.name); + }); + }); + + describe('action icon', () => { + it('it should render the action icon', () => { + const component = new JobComponent({ + propsData: { + job: mockJob, + }, + }).$mount(); + + expect(component.$el.querySelector('a.ci-action-icon-container')).toBeDefined(); + expect(component.$el.querySelector('i.ci-action-icon-wrapper')).toBeDefined(); + }); + }); + + describe('dropdown', () => { + it('should render the dropdown action icon', () => { + const component = new JobComponent({ + propsData: { + job: mockJob, + isDropdown: true, + }, + }).$mount(); + + expect(component.$el.querySelector('a.ci-action-icon-wrapper')).toBeDefined(); + }); + }); + + it('should render provided class name', () => { + const component = new JobComponent({ + propsData: { + job: mockJob, + cssClassJobName: 'css-class-job-name', + }, + }).$mount(); + + expect( + component.$el.querySelector('a').classList.contains('css-class-job-name'), + ).toBe(true); + }); +}); diff --git a/spec/javascripts/pipelines/graph/job_name_component_spec.js b/spec/javascripts/pipelines/graph/job_name_component_spec.js new file mode 100644 index 00000000000..8e2071ba0b3 --- /dev/null +++ b/spec/javascripts/pipelines/graph/job_name_component_spec.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import jobNameComponent from '~/pipelines/components/graph/job_name_component.vue'; + +describe('job name component', () => { + let component; + + beforeEach(() => { + const JobNameComponent = Vue.extend(jobNameComponent); + component = new JobNameComponent({ + propsData: { + name: 'foo', + status: { + icon: 'icon_status_success', + }, + }, + }).$mount(); + }); + + it('should render the provided name', () => { + expect(component.$el.querySelector('.ci-status-text').textContent.trim()).toEqual('foo'); + }); + + it('should render an icon with the provided status', () => { + expect(component.$el.querySelector('.ci-status-icon-success')).toBeDefined(); + expect(component.$el.querySelector('.ci-status-icon-success svg')).toBeDefined(); + }); +}); diff --git a/spec/javascripts/pipelines/graph/stage_column_component_spec.js b/spec/javascripts/pipelines/graph/stage_column_component_spec.js new file mode 100644 index 00000000000..aa4d6eedaf4 --- /dev/null +++ b/spec/javascripts/pipelines/graph/stage_column_component_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; + +describe('stage column component', () => { + let component; + const mockJob = { + id: 4256, + name: 'test', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4256', + action: { + icon: 'icon_action_retry', + title: 'Retry', + path: '/root/ci-mock/builds/4256/retry', + method: 'post', + }, + }, + }; + + beforeEach(() => { + const StageColumnComponent = Vue.extend(stageColumnComponent); + + component = new StageColumnComponent({ + propsData: { + title: 'foo', + jobs: [mockJob, mockJob, mockJob], + }, + }).$mount(); + }); + + it('should render provided title', () => { + expect(component.$el.querySelector('.stage-name').textContent.trim()).toEqual('foo'); + }); + + it('should render the provided jobs', () => { + expect(component.$el.querySelectorAll('.builds-container > ul > li').length).toEqual(3); + }); +}); diff --git a/spec/javascripts/pipelines_spec.js b/spec/javascripts/pipelines_spec.js index 72770a702d3..81ac589f4e6 100644 --- a/spec/javascripts/pipelines_spec.js +++ b/spec/javascripts/pipelines_spec.js @@ -1,30 +1,22 @@ -require('~/pipelines'); +import Pipelines from '~/pipelines'; // Fix for phantomJS if (!Element.prototype.matches && Element.prototype.webkitMatchesSelector) { Element.prototype.matches = Element.prototype.webkitMatchesSelector; } -(() => { - describe('Pipelines', () => { - preloadFixtures('static/pipeline_graph.html.raw'); +describe('Pipelines', () => { + preloadFixtures('static/pipeline_graph.html.raw'); - beforeEach(() => { - loadFixtures('static/pipeline_graph.html.raw'); - }); - - it('should be defined', () => { - expect(window.gl.Pipelines).toBeDefined(); - }); - - it('should create a `Pipelines` instance without options', () => { - expect(() => { new window.gl.Pipelines(); }).not.toThrow(); //eslint-disable-line - }); - - it('should create a `Pipelines` instance with options', () => { - const pipelines = new window.gl.Pipelines({ foo: 'bar' }); - - expect(pipelines.pipelineGraph).toBeDefined(); - }); + beforeEach(() => { + loadFixtures('static/pipeline_graph.html.raw'); }); -})(); + + it('should be defined', () => { + expect(Pipelines).toBeDefined(); + }); + + it('should create a `Pipelines` instance without options', () => { + expect(() => { new Pipelines(); }).not.toThrow(); //eslint-disable-line + }); +}); diff --git a/spec/javascripts/vue_shared/ci_action_icons_spec.js b/spec/javascripts/vue_shared/ci_action_icons_spec.js new file mode 100644 index 00000000000..2e89a07e76e --- /dev/null +++ b/spec/javascripts/vue_shared/ci_action_icons_spec.js @@ -0,0 +1,22 @@ +import getActionIcon from '~/vue_shared/ci_action_icons'; +import cancelSVG from 'icons/_icon_action_cancel.svg'; +import retrySVG from 'icons/_icon_action_retry.svg'; +import playSVG from 'icons/_icon_action_play.svg'; + +describe('getActionIcon', () => { + it('should return an empty string', () => { + expect(getActionIcon()).toEqual(''); + }); + + it('should return cancel svg', () => { + expect(getActionIcon('icon_action_cancel')).toEqual(cancelSVG); + }); + + it('should return retry svg', () => { + expect(getActionIcon('icon_action_retry')).toEqual(retrySVG); + }); + + it('should return play svg', () => { + expect(getActionIcon('icon_action_play')).toEqual(playSVG); + }); +}); diff --git a/spec/javascripts/vue_shared/ci_status_icon_spec.js b/spec/javascripts/vue_shared/ci_status_icon_spec.js new file mode 100644 index 00000000000..b6621d6054d --- /dev/null +++ b/spec/javascripts/vue_shared/ci_status_icon_spec.js @@ -0,0 +1,27 @@ +import { borderlessStatusIconEntityMap, statusIconEntityMap } from '~/vue_shared/ci_status_icons'; + +describe('CI status icons', () => { + const statuses = [ + 'icon_status_canceled', + 'icon_status_created', + 'icon_status_failed', + 'icon_status_manual', + 'icon_status_pending', + 'icon_status_running', + 'icon_status_skipped', + 'icon_status_success', + 'icon_status_warning', + ]; + + it('should have a dictionary for borderless icons', () => { + statuses.forEach((status) => { + expect(borderlessStatusIconEntityMap[status]).toBeDefined(); + }); + }); + + it('should have a dictionary for icons', () => { + statuses.forEach((status) => { + expect(statusIconEntityMap[status]).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/ci_icon_spec.js b/spec/javascripts/vue_shared/components/ci_icon_spec.js new file mode 100644 index 00000000000..98dc6caa622 --- /dev/null +++ b/spec/javascripts/vue_shared/components/ci_icon_spec.js @@ -0,0 +1,130 @@ +import Vue from 'vue'; +import ciIcon from '~/vue_shared/components/ci_icon.vue'; + +describe('CI Icon component', () => { + let CiIcon; + beforeEach(() => { + CiIcon = Vue.extend(ciIcon); + }); + + it('should render a span element with an svg', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_success', + }, + }, + }).$mount(); + + expect(component.$el.tagName).toEqual('SPAN'); + expect(component.$el.querySelector('span > svg')).toBeDefined(); + }); + + it('should render a success status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_success', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-success')).toEqual(true); + }); + + it('should render a failed status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_failed', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-failed')).toEqual(true); + }); + + it('should render success with warnings status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_warning', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-warning')).toEqual(true); + }); + + it('should render pending status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_pending', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-pending')).toEqual(true); + }); + + it('should render running status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_running', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-running')).toEqual(true); + }); + + it('should render created status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_created', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-created')).toEqual(true); + }); + + it('should render skipped status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_skipped', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-skipped')).toEqual(true); + }); + + it('should render canceled status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_canceled', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-canceled')).toEqual(true); + }); + + it('should render status for manual action', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_manual', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-manual')).toEqual(true); + }); +});