diff options
Diffstat (limited to 'app/assets/javascripts/pipelines/components')
17 files changed, 1294 insertions, 0 deletions
diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue new file mode 100644 index 00000000000..37a6f02d8fd --- /dev/null +++ b/app/assets/javascripts/pipelines/components/async_button.vue @@ -0,0 +1,104 @@ +<script> +/* eslint-disable no-new, no-alert */ +/* global Flash */ +import '~/flash'; +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + props: { + endpoint: { + type: String, + required: true, + }, + + service: { + type: Object, + required: true, + }, + + title: { + type: String, + required: true, + }, + + icon: { + type: String, + required: true, + }, + + cssClass: { + type: String, + required: true, + }, + + confirmActionMessage: { + type: String, + required: false, + }, + }, + + components: { + loadingIcon, + }, + + data() { + return { + isLoading: false, + }; + }, + + computed: { + iconClass() { + return `fa fa-${this.icon}`; + }, + + buttonClass() { + return `btn has-tooltip ${this.cssClass}`; + }, + }, + + methods: { + onClick() { + if (this.confirmActionMessage && confirm(this.confirmActionMessage)) { + this.makeRequest(); + } else if (!this.confirmActionMessage) { + this.makeRequest(); + } + }, + + makeRequest() { + this.isLoading = true; + + $(this.$el).tooltip('destroy'); + + this.service.postAction(this.endpoint) + .then(() => { + this.isLoading = false; + eventHub.$emit('refreshPipelines'); + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occured while making the request.'); + }); + }, + }, +}; +</script> + +<template> + <button + type="button" + @click="onClick" + :class="buttonClass" + :title="title" + :aria-label="title" + data-container="body" + data-placement="top" + :disabled="isLoading"> + <i + :class="iconClass" + aria-hidden="true" /> + <loading-icon v-if="isLoading" /> + </button> +</template> diff --git a/app/assets/javascripts/pipelines/components/empty_state.vue b/app/assets/javascripts/pipelines/components/empty_state.vue new file mode 100644 index 00000000000..3db64339a62 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/empty_state.vue @@ -0,0 +1,34 @@ +<script> +import pipelinesEmptyStateSVG from 'empty_states/icons/_pipelines_empty.svg'; + +export default { + props: { + helpPagePath: { + type: String, + required: true, + }, + }, + data: () => ({ pipelinesEmptyStateSVG }), +}; +</script> + +<template> + <div class="row empty-state js-empty-state"> + <div class="col-xs-12"> + <div class="svg-content" v-html="pipelinesEmptyStateSVG" /> + </div> + + <div class="col-xs-12 text-center"> + <div class="text-content"> + <h4>Build with confidence</h4> + <p> + Continous Integration can help catch bugs by running your tests automatically, + while Continuous Deployment can help you deliver code to your product environment. + </p> + <a :href="helpPagePath" class="btn btn-info"> + Get started with Pipelines + </a> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/error_state.vue b/app/assets/javascripts/pipelines/components/error_state.vue new file mode 100644 index 00000000000..90cee68163e --- /dev/null +++ b/app/assets/javascripts/pipelines/components/error_state.vue @@ -0,0 +1,21 @@ +<script> +import pipelinesErrorStateSVG from 'empty_states/icons/_pipelines_failed.svg'; + +export default { + data: () => ({ pipelinesErrorStateSVG }), +}; +</script> + +<template> + <div class="row empty-state js-pipelines-error-state"> + <div class="col-xs-12"> + <div class="svg-content" v-html="pipelinesErrorStateSVG" /> + </div> + + <div class="col-xs-12 text-center"> + <div class="text-content"> + <h4>The API failed to fetch the pipelines.</h4> + </div> + </div> + </div> +</template> 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..1f9e3d39779 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -0,0 +1,64 @@ +<script> + import getActionIcon from '../../../vue_shared/ci_action_icons'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders either a cancel, retry or play icon pointing to the given path. + * TODO: Remove UJS from here and use an async request instead. + */ + export default { + props: { + tooltipText: { + type: String, + required: true, + }, + + link: { + type: String, + required: true, + }, + + actionMethod: { + type: String, + required: true, + }, + + actionIcon: { + type: String, + required: true, + }, + }, + + mixins: [ + tooltipMixin, + ], + + computed: { + actionIconSvg() { + return getActionIcon(this.actionIcon); + }, + + cssClass() { + return `js-${gl.text.dasherize(this.actionIcon)}`; + }, + }, + }; +</script> +<template> + <a + :data-method="actionMethod" + :title="tooltipText" + :href="link" + ref="tooltip" + class="ci-action-icon-container" + data-toggle="tooltip" + data-container="body"> + + <i + class="ci-action-icon-wrapper" + :class="cssClass" + v-html="actionIconSvg" + aria-hidden="true" + /> + </a> +</template> 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 @@ +<script> + import getActionIcon from '../../../vue_shared/ci_action_icons'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders either a cancel, retry or play icon pointing to the given path. + * TODO: Remove UJS from here and use an async request instead. + */ + export default { + props: { + tooltipText: { + type: String, + required: true, + }, + + link: { + type: String, + required: true, + }, + + actionMethod: { + type: String, + required: true, + }, + + actionIcon: { + type: String, + required: true, + }, + }, + + mixins: [ + tooltipMixin, + ], + + computed: { + actionIconSvg() { + return getActionIcon(this.actionIcon); + }, + }, + }; +</script> +<template> + <a + :data-method="actionMethod" + :title="tooltipText" + :href="link" + ref="tooltip" + rel="nofollow" + class="ci-action-icon-wrapper js-ci-status-icon" + data-toggle="tooltip" + data-container="body" + v-html="actionIconSvg" + aria-label="Job's action"> + </a> +</template> 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 @@ +<script> + import jobNameComponent from './job_name_component.vue'; + import jobComponent from './job_component.vue'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders the dropdown for the pipeline graph. + * + * The following object should be provided as `job`: + * + * { + * "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" + * } + * } + * } + */ + export default { + props: { + job: { + type: Object, + required: true, + }, + }, + + mixins: [ + tooltipMixin, + ], + + components: { + jobComponent, + jobNameComponent, + }, + + computed: { + tooltipText() { + return `${this.job.name} - ${this.job.status.label}`; + }, + }, + }; +</script> +<template> + <div> + <button + type="button" + data-toggle="dropdown" + data-container="body" + class="dropdown-menu-toggle build-content" + :title="tooltipText" + ref="tooltip"> + + <job-name-component + :name="job.name" + :status="job.status" /> + + <span class="dropdown-counter-badge"> + {{job.size}} + </span> + </button> + + <ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown"> + <li class="scrollable-menu"> + <ul> + <li v-for="item in job.jobs"> + <job-component + :job="item" + :is-dropdown="true" + css-class-job-name="mini-pipeline-graph-dropdown-item" + /> + </li> + </ul> + </li> + </ul> + </div> +</template> 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..14c98847d93 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -0,0 +1,113 @@ +<script> + /* global Flash */ + import Visibility from 'visibilityjs'; + import Poll from '../../../lib/utils/poll'; + import PipelineService from '../../services/pipeline_service'; + import PipelineStore from '../../stores/pipeline_store'; + import stageColumnComponent from './stage_column_component.vue'; + import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; + import '../../../flash'; + + export default { + components: { + stageColumnComponent, + loadingIcon, + }, + + data() { + const DOMdata = document.getElementById('js-pipeline-graph-vue').dataset; + const store = new PipelineStore(); + + return { + isLoading: false, + endpoint: DOMdata.endpoint, + store, + state: store.state, + }; + }, + + created() { + this.service = new PipelineService(this.endpoint); + + const poll = new Poll({ + resource: this.service, + method: 'getPipeline', + successCallback: this.successCallback, + errorCallback: this.errorCallback, + }); + + if (!Visibility.hidden()) { + this.isLoading = true; + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); + }, + + methods: { + successCallback(response) { + const data = response.json(); + + this.isLoading = false; + this.store.storeGraph(data.details.stages); + }, + + errorCallback() { + this.isLoading = false; + return new Flash('An error occurred while fetching the pipeline.'); + }, + + capitalizeStageName(name) { + return name.charAt(0).toUpperCase() + name.slice(1); + }, + + isFirstColumn(index) { + return index === 0; + }, + + stageConnectorClass(index, stage) { + let className; + + // If it's the first stage column and only has one job + if (index === 0 && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } + + return className; + }, + }, + }; +</script> +<template> + <div class="build-content middle-block js-pipeline-graph"> + <div class="pipeline-visualization pipeline-graph"> + <div class="text-center"> + <loading-icon + v-if="isLoading" + size="3" + /> + </div> + + <ul + v-if="!isLoading" + class="stage-column-list"> + <stage-column-component + v-for="(stage, index) in state.graph" + :title="capitalizeStageName(stage.name)" + :jobs="stage.groups" + :key="stage.name" + :stage-connector-class="stageConnectorClass(index, stage)" + :is-first-column="isFirstColumn(index)"/> + </ul> + </div> + </div> +</template> 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 @@ +<script> + import actionComponent from './action_component.vue'; + import dropdownActionComponent from './dropdown_action_component.vue'; + import jobNameComponent from './job_name_component.vue'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders the badge for the pipeline graph and the job's dropdown. + * + * The following object should be provided as `job`: + * + * { + * "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" + * } + * } + * } + */ + + export default { + props: { + job: { + type: Object, + required: true, + }, + + cssClassJobName: { + type: String, + required: false, + default: '', + }, + + isDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, + + components: { + actionComponent, + dropdownActionComponent, + jobNameComponent, + }, + + mixins: [ + tooltipMixin, + ], + + computed: { + tooltipText() { + return `${this.job.name} - ${this.job.status.label}`; + }, + + /** + * Verifies if the provided job has an action path + * + * @return {Boolean} + */ + hasAction() { + return this.job.status && this.job.status.action && this.job.status.action.path; + }, + }, + }; +</script> +<template> + <div> + <a + v-if="job.status.details_path" + :href="job.status.details_path" + :title="tooltipText" + :class="cssClassJobName" + ref="tooltip" + data-toggle="tooltip" + data-container="body"> + + <job-name-component + :name="job.name" + :status="job.status" + /> + </a> + + <div + v-else + :title="tooltipText" + :class="cssClassJobName" + ref="tooltip" + data-toggle="tooltip" + data-container="body"> + + <job-name-component + :name="job.name" + :status="job.status" + /> + </div> + + <action-component + v-if="hasAction && !isDropdown" + :tooltip-text="job.status.action.title" + :link="job.status.action.path" + :action-icon="job.status.action.icon" + :action-method="job.status.action.method" + /> + + <dropdown-action-component + v-if="hasAction && isDropdown" + :tooltip-text="job.status.action.title" + :link="job.status.action.path" + :action-icon="job.status.action.icon" + :action-method="job.status.action.method" + /> + </div> +</template> 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 @@ +<script> + import ciIcon from '../../../vue_shared/components/ci_icon.vue'; + + /** + * Component that renders both the CI icon status and the job name. + * Used in + * - Badge component + * - Dropdown badge components + */ + export default { + props: { + name: { + type: String, + required: true, + }, + + status: { + type: Object, + required: true, + }, + }, + + components: { + ciIcon, + }, + }; +</script> +<template> + <span> + <ci-icon + :status="status" /> + + <span class="ci-status-text"> + {{name}} + </span> + </span> +</template> 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..9b1bbb0906f --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -0,0 +1,83 @@ +<script> +import jobComponent from './job_component.vue'; +import dropdownJobComponent from './dropdown_job_component.vue'; + +export default { + props: { + title: { + type: String, + required: true, + }, + + jobs: { + type: Array, + required: true, + }, + + isFirstColumn: { + type: Boolean, + required: false, + default: false, + }, + + stageConnectorClass: { + type: String, + required: false, + default: '', + }, + }, + + components: { + jobComponent, + dropdownJobComponent, + }, + + methods: { + firstJob(list) { + return list[0]; + }, + + jobId(job) { + return `ci-badge-${job.name}`; + }, + + buildConnnectorClass(index) { + return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; + }, + }, +}; +</script> +<template> + <li + class="stage-column" + :class="stageConnectorClass"> + <div class="stage-name"> + {{title}} + </div> + <div class="builds-container"> + <ul> + <li + v-for="(job, index) in jobs" + :key="job.id" + class="build" + :class="buildConnnectorClass(index)" + :id="jobId(job)"> + + <div class="curve"></div> + + <job-component + v-if="job.size === 1" + :job="job" + css-class-job-name="build-content" + /> + + <dropdown-job-component + v-if="job.size > 1" + :job="job" + /> + + </li> + </ul> + </div> + </li> +</template> diff --git a/app/assets/javascripts/pipelines/components/nav_controls.js b/app/assets/javascripts/pipelines/components/nav_controls.js new file mode 100644 index 00000000000..6aa10531034 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/nav_controls.js @@ -0,0 +1,52 @@ +export default { + props: { + newPipelinePath: { + type: String, + required: true, + }, + + hasCiEnabled: { + type: Boolean, + required: true, + }, + + helpPagePath: { + type: String, + required: true, + }, + + ciLintPath: { + type: String, + required: true, + }, + + canCreatePipeline: { + type: Boolean, + required: true, + }, + }, + + template: ` + <div class="nav-controls"> + <a + v-if="canCreatePipeline" + :href="newPipelinePath" + class="btn btn-create"> + Run Pipeline + </a> + + <a + v-if="!hasCiEnabled" + :href="helpPagePath" + class="btn btn-info"> + Get started with Pipelines + </a> + + <a + :href="ciLintPath" + class="btn btn-default"> + CI Lint + </a> + </div> + `, +}; diff --git a/app/assets/javascripts/pipelines/components/navigation_tabs.js b/app/assets/javascripts/pipelines/components/navigation_tabs.js new file mode 100644 index 00000000000..1626ae17a30 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/navigation_tabs.js @@ -0,0 +1,72 @@ +export default { + props: { + scope: { + type: String, + required: true, + }, + + count: { + type: Object, + required: true, + }, + + paths: { + type: Object, + required: true, + }, + }, + + mounted() { + $(document).trigger('init.scrolling-tabs'); + }, + + template: ` + <ul class="nav-links scrolling-tabs"> + <li + class="js-pipelines-tab-all" + :class="{ 'active': scope === 'all'}"> + <a :href="paths.allPath"> + All + <span class="badge js-totalbuilds-count"> + {{count.all}} + </span> + </a> + </li> + <li class="js-pipelines-tab-pending" + :class="{ 'active': scope === 'pending'}"> + <a :href="paths.pendingPath"> + Pending + <span class="badge"> + {{count.pending}} + </span> + </a> + </li> + <li class="js-pipelines-tab-running" + :class="{ 'active': scope === 'running'}"> + <a :href="paths.runningPath"> + Running + <span class="badge"> + {{count.running}} + </span> + </a> + </li> + <li class="js-pipelines-tab-finished" + :class="{ 'active': scope === 'finished'}"> + <a :href="paths.finishedPath"> + Finished + <span class="badge"> + {{count.finished}} + </span> + </a> + </li> + <li class="js-pipelines-tab-branches" + :class="{ 'active': scope === 'branches'}"> + <a :href="paths.branchesPath">Branches</a> + </li> + <li class="js-pipelines-tab-tags" + :class="{ 'active': scope === 'tags'}"> + <a :href="paths.tagsPath">Tags</a> + </li> + </ul> + `, +}; diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.js b/app/assets/javascripts/pipelines/components/pipeline_url.js new file mode 100644 index 00000000000..7cd2e0f9366 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_url.js @@ -0,0 +1,56 @@ +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + +export default { + props: [ + 'pipeline', + ], + computed: { + user() { + return !!this.pipeline.user; + }, + }, + components: { + userAvatarLink, + }, + template: ` + <td> + <a + :href="pipeline.path" + class="js-pipeline-url-link"> + <span class="pipeline-id">#{{pipeline.id}}</span> + </a> + <span>by</span> + <user-avatar-link + v-if="user" + class="js-pipeline-url-user" + :link-href="pipeline.user.web_url" + :img-src="pipeline.user.avatar_url" + :tooltip-text="pipeline.user.name" + /> + <span + v-if="!user" + class="js-pipeline-url-api api"> + API + </span> + <span + v-if="pipeline.flags.latest" + class="js-pipeline-url-lastest label label-success has-tooltip" + title="Latest pipeline for this branch" + data-original-title="Latest pipeline for this branch"> + latest + </span> + <span + v-if="pipeline.flags.yaml_errors" + class="js-pipeline-url-yaml label label-danger has-tooltip" + :title="pipeline.yaml_errors" + :data-original-title="pipeline.yaml_errors"> + yaml invalid + </span> + <span + v-if="pipeline.flags.stuck" + class="js-pipeline-url-stuck label label-warning"> + stuck + </span> + </td> + `, +}; diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.js b/app/assets/javascripts/pipelines/components/pipelines_actions.js new file mode 100644 index 00000000000..b9e066c5db1 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.js @@ -0,0 +1,91 @@ +/* eslint-disable no-new */ +/* global Flash */ +import '~/flash'; +import playIconSvg from 'icons/_icon_play.svg'; +import eventHub from '../event_hub'; +import loadingIconComponent from '../../vue_shared/components/loading_icon.vue'; + +export default { + props: { + actions: { + type: Array, + required: true, + }, + + service: { + type: Object, + required: true, + }, + }, + + components: { + loadingIconComponent, + }, + + data() { + return { + playIconSvg, + isLoading: false, + }; + }, + + methods: { + onClickAction(endpoint) { + this.isLoading = true; + + $(this.$refs.tooltip).tooltip('destroy'); + + this.service.postAction(endpoint) + .then(() => { + this.isLoading = false; + eventHub.$emit('refreshPipelines'); + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occured while making the request.'); + }); + }, + + isActionDisabled(action) { + if (action.playable === undefined) { + return false; + } + + return !action.playable; + }, + }, + + template: ` + <div class="btn-group" v-if="actions"> + <button + type="button" + class="dropdown-toggle btn btn-default has-tooltip js-pipeline-dropdown-manual-actions" + title="Manual job" + data-toggle="dropdown" + data-placement="top" + aria-label="Manual job" + ref="tooltip" + :disabled="isLoading"> + ${playIconSvg} + <i + class="fa fa-caret-down" + aria-hidden="true" /> + <loading-icon v-if="isLoading" /> + </button> + + <ul class="dropdown-menu dropdown-menu-align-right"> + <li v-for="action in actions"> + <button + type="button" + class="js-pipeline-action-link no-btn btn" + @click="onClickAction(action.path)" + :class="{ 'disabled': isActionDisabled(action) }" + :disabled="isActionDisabled(action)"> + ${playIconSvg} + <span>{{action.name}}</span> + </button> + </li> + </ul> + </div> + `, +}; diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.js b/app/assets/javascripts/pipelines/components/pipelines_artifacts.js new file mode 100644 index 00000000000..f18e2dfadaf --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.js @@ -0,0 +1,33 @@ +export default { + props: { + artifacts: { + type: Array, + required: true, + }, + }, + + template: ` + <div class="btn-group" role="group"> + <button + class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download" + title="Artifacts" + data-placement="top" + data-toggle="dropdown" + aria-label="Artifacts"> + <i class="fa fa-download" aria-hidden="true"></i> + <i class="fa fa-caret-down" aria-hidden="true"></i> + </button> + <ul class="dropdown-menu dropdown-menu-align-right"> + <li v-for="artifact in artifacts"> + <a + rel="nofollow" + download + :href="artifact.path"> + <i class="fa fa-download" aria-hidden="true"></i> + <span>Download {{artifact.name}} artifacts</span> + </a> + </li> + </ul> + </div> + `, +}; diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue new file mode 100644 index 00000000000..7fc19fce1ff --- /dev/null +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -0,0 +1,170 @@ +<script> + +/** + * Renders each stage of the pipeline mini graph. + * + * Given the provided endpoint will make a request to + * fetch the dropdown data when the stage is clicked. + * + * Request is made inside this component to make it reusable between: + * 1. Pipelines main table + * 2. Pipelines table in commit and Merge request views + * 3. Merge request widget + * 4. Commit widget + */ + +/* global Flash */ +import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + props: { + stage: { + type: Object, + required: true, + }, + + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + isLoading: false, + dropdownContent: '', + endpoint: this.stage.dropdown_path, + }; + }, + + components: { + loadingIcon, + }, + + updated() { + if (this.dropdownContent.length > 0) { + this.stopDropdownClickPropagation(); + } + }, + + watch: { + updateDropdown() { + if (this.updateDropdown && + this.isDropdownOpen() && + !this.isLoading) { + this.fetchJobs(); + } + }, + }, + + methods: { + onClickStage() { + if (!this.isDropdownOpen()) { + this.isLoading = true; + this.fetchJobs(); + } + }, + + fetchJobs() { + this.$http.get(this.endpoint) + .then((response) => { + this.dropdownContent = response.json().html; + this.isLoading = false; + }) + .catch(() => { + this.closeDropdown(); + this.isLoading = false; + + const flash = new Flash('Something went wrong on our end.'); + return flash; + }); + }, + + /** + * When the user right clicks or cmd/ctrl + click in the job name + * the dropdown should not be closed and the link should open in another tab, + * so we stop propagation of the click event inside the dropdown. + * + * Since this component is rendered multiple times per page we need to guarantee we only + * target the click event of this component. + */ + stopDropdownClickPropagation() { + $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')) + .on('click', (e) => { + e.stopPropagation(); + }); + }, + + closeDropdown() { + if (this.isDropdownOpen()) { + $(this.$refs.dropdown).dropdown('toggle'); + } + }, + + isDropdownOpen() { + return this.$el.classList.contains('open'); + }, + }, + + computed: { + dropdownClass() { + return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading'; + }, + + triggerButtonClass() { + return `ci-status-icon-${this.stage.status.group}`; + }, + + svgIcon() { + return borderlessStatusIconEntityMap[this.stage.status.icon]; + }, + }, +}; +</script> + +<template> + <div class="dropdown"> + <button + :class="triggerButtonClass" + @click="onClickStage" + class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button" + :title="stage.title" + data-placement="top" + data-toggle="dropdown" + type="button" + id="stageDropdown" + aria-haspopup="true" + aria-expanded="false"> + + <span + v-html="svgIcon" + aria-hidden="true" + :aria-label="stage.title"> + </span> + + <i + class="fa fa-caret-down" + aria-hidden="true"> + </i> + </button> + + <ul + class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container" + aria-labelledby="stageDropdown"> + + <li + :class="dropdownClass" + class="js-builds-dropdown-list scrollable-menu"> + + <loading-icon v-if="isLoading"/> + + <ul + v-else + v-html="dropdownContent"> + </ul> + </li> + </ul> + </div> +</script> diff --git a/app/assets/javascripts/pipelines/components/time_ago.js b/app/assets/javascripts/pipelines/components/time_ago.js new file mode 100644 index 00000000000..188f74cc705 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/time_ago.js @@ -0,0 +1,98 @@ +import iconTimerSvg from 'icons/_icon_timer.svg'; +import '../../lib/utils/datetime_utility'; + +export default { + props: { + finishedTime: { + type: String, + required: true, + }, + + duration: { + type: Number, + required: true, + }, + }, + + data() { + return { + iconTimerSvg, + }; + }, + + updated() { + $(this.$refs.tooltip).tooltip('fixTitle'); + }, + + computed: { + hasDuration() { + return this.duration > 0; + }, + + hasFinishedTime() { + return this.finishedTime !== ''; + }, + + localTimeFinished() { + return gl.utils.formatDate(this.finishedTime); + }, + + durationFormated() { + const date = new Date(this.duration * 1000); + + let hh = date.getUTCHours(); + let mm = date.getUTCMinutes(); + let ss = date.getSeconds(); + + // left pad + if (hh < 10) { + hh = `0${hh}`; + } + if (mm < 10) { + mm = `0${mm}`; + } + if (ss < 10) { + ss = `0${ss}`; + } + + return `${hh}:${mm}:${ss}`; + }, + + finishedTimeFormated() { + const timeAgo = gl.utils.getTimeago(); + + return timeAgo.format(this.finishedTime); + }, + }, + + template: ` + <td class="pipelines-time-ago"> + <p + class="duration" + v-if="hasDuration"> + <span + v-html="iconTimerSvg"> + </span> + {{durationFormated}} + </p> + + <p + class="finished-at" + v-if="hasFinishedTime"> + + <i + class="fa fa-calendar" + aria-hidden="true" /> + + <time + ref="tooltip" + data-toggle="tooltip" + data-placement="top" + data-container="body" + :title="localTimeFinished"> + {{finishedTimeFormated}} + </time> + </p> + </td> + `, +}; |