Commit 0e4f2e01 authored by Alfredo Sumaran's avatar Alfredo Sumaran

Merge branch 'fl-remove-ujs-pipelines' into 'master'

Remove UJS actions from pipelines tables

Closes #20450, #28535, and #5580

See merge request !9929
parents bb1620aa b0f2cbce
/* eslint-disable no-new, no-param-reassign */ /* eslint-disable no-param-reassign */
/* global Vue, CommitsPipelineStore, PipelinesService, Flash */ import CommitPipelinesTable from './pipelines_table';
window.Vue = require('vue'); window.Vue = require('vue');
require('./pipelines_table'); window.Vue.use(require('vue-resource'));
/** /**
* Commits View > Pipelines Tab > Pipelines Table. * Commits View > Pipelines Tab > Pipelines Table.
* Merge Request View > Pipelines Tab > Pipelines Table. * Merge Request View > Pipelines Tab > Pipelines Table.
...@@ -21,7 +22,7 @@ $(() => { ...@@ -21,7 +22,7 @@ $(() => {
} }
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
gl.commits.pipelines.PipelinesTableBundle = new gl.commits.pipelines.PipelinesTableView(); gl.commits.pipelines.PipelinesTableBundle = new CommitPipelinesTable();
if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) { if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) {
gl.commits.pipelines.PipelinesTableBundle.$mount(pipelineTableViewEl); gl.commits.pipelines.PipelinesTableBundle.$mount(pipelineTableViewEl);
......
/* globals Vue */
/* eslint-disable no-unused-vars, no-param-reassign */
/**
* Pipelines service.
*
* Used to fetch the data used to render the pipelines table.
* Uses Vue.Resource
*/
class PipelinesService {
/**
* FIXME: The url provided to request the pipelines in the new merge request
* page already has `.json`.
* This should be fixed when the endpoint is improved.
*
* @param {String} root
*/
constructor(root) {
let endpoint;
if (root.indexOf('.json') === -1) {
endpoint = `${root}.json`;
} else {
endpoint = root;
}
this.pipelines = Vue.resource(endpoint);
}
/**
* Given the root param provided when the class is initialized, will
* make a GET request.
*
* @return {Promise}
*/
all() {
return this.pipelines.get();
}
}
window.gl = window.gl || {};
gl.commits = gl.commits || {};
gl.commits.pipelines = gl.commits.pipelines || {};
gl.commits.pipelines.PipelinesService = PipelinesService;
/* eslint-disable no-new, no-param-reassign */ /* eslint-disable no-new*/
/* global Vue, CommitsPipelineStore, PipelinesService, Flash */ /* global Flash */
import Vue from 'vue';
window.Vue = require('vue'); import PipelinesTableComponent from '../../vue_shared/components/pipelines_table';
window.Vue.use(require('vue-resource')); import PipelinesService from '../../vue_pipelines_index/services/pipelines_service';
require('../../lib/utils/common_utils'); import PipelineStore from '../../vue_pipelines_index/stores/pipelines_store';
require('../../vue_shared/vue_resource_interceptor'); import eventHub from '../../vue_pipelines_index/event_hub';
require('../../vue_shared/components/pipelines_table'); import '../../lib/utils/common_utils';
require('./pipelines_service'); import '../../vue_shared/vue_resource_interceptor';
const PipelineStore = require('./pipelines_store');
/** /**
* *
...@@ -20,48 +19,59 @@ const PipelineStore = require('./pipelines_store'); ...@@ -20,48 +19,59 @@ const PipelineStore = require('./pipelines_store');
* as soon as we have Webpack and can load them directly into JS files. * as soon as we have Webpack and can load them directly into JS files.
*/ */
(() => { export default Vue.component('pipelines-table', {
window.gl = window.gl || {}; components: {
gl.commits = gl.commits || {}; 'pipelines-table-component': PipelinesTableComponent,
gl.commits.pipelines = gl.commits.pipelines || {}; },
gl.commits.pipelines.PipelinesTableView = Vue.component('pipelines-table', { /**
* Accesses the DOM to provide the needed data.
* Returns the necessary props to render `pipelines-table-component` component.
*
* @return {Object}
*/
data() {
const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset;
const store = new PipelineStore();
components: { return {
'pipelines-table-component': gl.pipelines.PipelinesTableComponent, endpoint: pipelinesTableData.endpoint,
}, store,
state: store.state,
isLoading: false,
};
},
/** /**
* Accesses the DOM to provide the needed data. * When the component is about to be mounted, tell the service to fetch the data
* Returns the necessary props to render `pipelines-table-component` component. *
* * A request to fetch the pipelines will be made.
* @return {Object} * In case of a successfull response we will store the data in the provided
*/ * store, in case of a failed response we need to warn the user.
data() { *
const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset; */
const store = new PipelineStore(); beforeMount() {
this.service = new PipelinesService(this.endpoint);
return { this.fetchPipelines();
endpoint: pipelinesTableData.endpoint,
store, eventHub.$on('refreshPipelines', this.fetchPipelines);
state: store.state, },
isLoading: false,
}; beforeUpdate() {
}, if (this.state.pipelines.length && this.$children) {
this.store.startTimeAgoLoops.call(this, Vue);
}
},
/** beforeDestroyed() {
* When the component is about to be mounted, tell the service to fetch the data eventHub.$off('refreshPipelines');
* },
* A request to fetch the pipelines will be made.
* In case of a successfull response we will store the data in the provided
* store, in case of a failed response we need to warn the user.
*
*/
beforeMount() {
const pipelinesService = new gl.commits.pipelines.PipelinesService(this.endpoint);
methods: {
fetchPipelines() {
this.isLoading = true; this.isLoading = true;
return pipelinesService.all() return this.service.getPipelines()
.then(response => response.json()) .then(response => response.json())
.then((json) => { .then((json) => {
// depending of the endpoint the response can either bring a `pipelines` key or not. // depending of the endpoint the response can either bring a `pipelines` key or not.
...@@ -71,34 +81,30 @@ const PipelineStore = require('./pipelines_store'); ...@@ -71,34 +81,30 @@ const PipelineStore = require('./pipelines_store');
}) })
.catch(() => { .catch(() => {
this.isLoading = false; this.isLoading = false;
new Flash('An error occurred while fetching the pipelines, please reload the page again.', 'alert'); new Flash('An error occurred while fetching the pipelines, please reload the page again.');
}); });
}, },
},
beforeUpdate() { template: `
if (this.state.pipelines.length && this.$children) { <div class="pipelines">
PipelineStore.startTimeAgoLoops.call(this, Vue); <div class="realtime-loading" v-if="isLoading">
} <i class="fa fa-spinner fa-spin"></i>
}, </div>
template: `
<div class="pipelines">
<div class="realtime-loading" v-if="isLoading">
<i class="fa fa-spinner fa-spin"></i>
</div>
<div class="blank-state blank-state-no-icon" <div class="blank-state blank-state-no-icon"
v-if="!isLoading && state.pipelines.length === 0"> v-if="!isLoading && state.pipelines.length === 0">
<h2 class="blank-state-title js-blank-state-title"> <h2 class="blank-state-title js-blank-state-title">
No pipelines to show No pipelines to show
</h2> </h2>
</div> </div>
<div class="table-holder pipelines" <div class="table-holder pipelines"
v-if="!isLoading && state.pipelines.length > 0"> v-if="!isLoading && state.pipelines.length > 0">
<pipelines-table-component :pipelines="state.pipelines"/> <pipelines-table-component
</div> :pipelines="state.pipelines"
:service="service" />
</div> </div>
`, </div>
}); `,
})(); });
/* eslint-disable no-param-reassign, no-new */ /* eslint-disable no-new */
/* global Flash */ /* global Flash */
import Vue from 'vue';
import EnvironmentsService from '../services/environments_service'; import EnvironmentsService from '../services/environments_service';
import EnvironmentTable from './environments_table'; import EnvironmentTable from './environments_table';
import EnvironmentsStore from '../stores/environments_store'; import EnvironmentsStore from '../stores/environments_store';
import TablePaginationComponent from '../../vue_shared/components/table_pagination';
import '../../lib/utils/common_utils';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
const Vue = window.Vue = require('vue');
window.Vue.use(require('vue-resource'));
require('../../vue_shared/components/table_pagination');
require('../../lib/utils/common_utils');
require('../../vue_shared/vue_resource_interceptor');
export default Vue.component('environment-component', { export default Vue.component('environment-component', {
components: { components: {
'environment-table': EnvironmentTable, 'environment-table': EnvironmentTable,
'table-pagination': gl.VueGlPagination, 'table-pagination': TablePaginationComponent,
}, },
data() { data() {
...@@ -59,7 +56,6 @@ export default Vue.component('environment-component', { ...@@ -59,7 +56,6 @@ export default Vue.component('environment-component', {
canCreateEnvironmentParsed() { canCreateEnvironmentParsed() {
return gl.utils.convertPermissionToBoolean(this.canCreateEnvironment); return gl.utils.convertPermissionToBoolean(this.canCreateEnvironment);
}, },
}, },
/** /**
......
import Timeago from 'timeago.js'; import Timeago from 'timeago.js';
import '../../lib/utils/text_utility';
import ActionsComponent from './environment_actions'; import ActionsComponent from './environment_actions';
import ExternalUrlComponent from './environment_external_url'; import ExternalUrlComponent from './environment_external_url';
import StopComponent from './environment_stop'; import StopComponent from './environment_stop';
import RollbackComponent from './environment_rollback'; import RollbackComponent from './environment_rollback';
import TerminalButtonComponent from './environment_terminal_button'; import TerminalButtonComponent from './environment_terminal_button';
import '../../lib/utils/text_utility'; import CommitComponent from '../../vue_shared/components/commit';
import '../../vue_shared/components/commit';
/** /**
* Envrionment Item Component * Envrionment Item Component
* *
* Renders a table row for each environment. * Renders a table row for each environment.
*/ */
const timeagoInstance = new Timeago(); const timeagoInstance = new Timeago();
export default { export default {
components: { components: {
'commit-component': gl.CommitComponent, 'commit-component': CommitComponent,
'actions-component': ActionsComponent, 'actions-component': ActionsComponent,
'external-url-component': ExternalUrlComponent, 'external-url-component': ExternalUrlComponent,
'stop-component': StopComponent, 'stop-component': StopComponent,
......
/** /**
* Render environments table. * Render environments table.
*/ */
import EnvironmentItem from './environment_item'; import EnvironmentTableRowComponent from './environment_item';
export default { export default {
components: { components: {
'environment-item': EnvironmentItem, 'environment-item': EnvironmentTableRowComponent,
}, },
props: { props: {
......
/* eslint-disable no-param-reassign, no-new */ /* eslint-disable no-new */
/* global Flash */ /* global Flash */
import Vue from 'vue';
import EnvironmentsService from '../services/environments_service'; import EnvironmentsService from '../services/environments_service';
import EnvironmentTable from '../components/environments_table'; import EnvironmentTable from '../components/environments_table';
import EnvironmentsStore from '../stores/environments_store'; import EnvironmentsStore from '../stores/environments_store';
import TablePaginationComponent from '../../vue_shared/components/table_pagination';
const Vue = window.Vue = require('vue'); import '../../lib/utils/common_utils';
window.Vue.use(require('vue-resource')); import '../../vue_shared/vue_resource_interceptor';
require('../../vue_shared/components/table_pagination');
require('../../lib/utils/common_utils');
require('../../vue_shared/vue_resource_interceptor');
export default Vue.component('environment-folder-view', { export default Vue.component('environment-folder-view', {
components: { components: {
'environment-table': EnvironmentTable, 'environment-table': EnvironmentTable,
'table-pagination': gl.VueGlPagination, 'table-pagination': TablePaginationComponent,
}, },
data() { data() {
......
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
import Vue from 'vue'; import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class EnvironmentsService { export default class EnvironmentsService {
constructor(endpoint) { constructor(endpoint) {
......
import '~/lib/utils/common_utils'; import '~/lib/utils/common_utils';
/** /**
* Environments Store. * Environments Store.
* *
......
/* eslint-disable no-new, no-alert */
/* global Flash */
import '~/flash';
import eventHub from '../event_hub';
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,
},
},
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.service.postAction(this.endpoint)
.then(() => {
this.isLoading = false;
eventHub.$emit('refreshPipelines');
})
.catch(() => {
this.isLoading = false;
new Flash('An error occured while making the request.');
});
},
},
template: `
<button
type="button"
@click="onClick"
:class="buttonClass"
:title="title"
:aria-label="title"
data-placement="top"
:disabled="isLoading">
<i :class="iconClass" aria-hidden="true"/>
<i class="fa fa-spinner fa-spin" aria-hidden="true" v-if="isLoading" />
</button>
`,
};
export default {
props: [
'pipeline',
],
computed: {
user() {
return !!this.pipeline.user;
},
},
template: `
<td>
<a
:href="pipeline.path"
class="js-pipeline-url-link">
<span class="pipeline-id">#{{pipeline.id}}</span>
</a>
<span>by</span>
<a
class="js-pipeline-url-user"
v-if="user"
:href="pipeline.user.web_url">
<img
v-if="user"
class="avatar has-tooltip s20 "
:title="pipeline.user.name"
data-container="body"
:src="pipeline.user.avatar_url"
>
</a>
<span
v-if="!user"
class="js-pipeline-url-api api monospace">
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>
`,
};
/* eslint-disable no-new */
/* global Flash */
import '~/flash';
import playIconSvg from 'icons/_icon_play.svg';
import eventHub from '../event_hub';
export default {
props: {
actions: {
type: Array,
required: true,
},
service: {
type: Object,
required: true,
},
},
data() {
return {
playIconSvg,
isLoading: false,
};
},
methods: {
onClickAction(endpoint) {
this.isLoading = true;
this.service.postAction(endpoint)
.then(() => {
this.isLoading = false;
eventHub.$emit('refreshPipelines');
})
.catch(() => {
this.isLoading = false;
new Flash('An error occured while making the request.');
});
},
},
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"
:disabled="isLoading">
${playIconSvg}
<i class="fa fa-caret-down" aria-hidden="true"></i>
<i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i>
</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"
@click="onClickAction(action.path)">
${playIconSvg}
<span>{{action.name}}</span>
</button>
</li>
</ul>
</div>
`,
};
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"
:href="artifact.path">
<i class="fa fa-download" aria-hidden="true"></i>
<span>Download {{artifact.name}} artifacts</span>
</a>
</li>
</ul>
</div>
`,
};
/* global Flash */
import canceledSvg from 'icons/_icon_status_canceled_borderless.svg';
import createdSvg from 'icons/_icon_status_created_borderless.svg';
import failedSvg from 'icons/_icon_status_failed_borderless.svg';
import manualSvg from 'icons/_icon_status_manual_borderless.svg';
import pendingSvg from 'icons/_icon_status_pending_borderless.svg';
import runningSvg from 'icons/_icon_status_running_borderless.svg';
import skippedSvg from 'icons/_icon_status_skipped_borderless.svg';
import successSvg from 'icons/_icon_status_success_borderless.svg';
import warningSvg from 'icons/_icon_status_warning_borderless.svg';
export default {
data() {
const svgsDictionary = {
icon_status_canceled: canceledSvg,
icon_status_created: createdSvg,
icon_status_failed: failedSvg,
icon_status_manual: manualSvg,
icon_status_pending: pendingSvg,
icon_status_running: runningSvg,
icon_status_skipped: skippedSvg,
icon_status_success: successSvg,
icon_status_warning: warningSvg,
};
return {
builds: '',
spinner: '<span class="fa fa-spinner fa-spin"></span>',
svg: svgsDictionary[this.stage.status.icon],
};
},
props: {
stage: {
type: Object,
required: true,
},
},
updated() {
if (this.builds) {
this.stopDropdownClickPropagation();
}
},
methods: {
fetchBuilds(e) {
const ariaExpanded = e.currentTarget.attributes['aria-expanded'];
if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null;
return this.$http.get(this.stage.dropdown_path)
.then((response) => {
this.builds = JSON.parse(response.body).html;
}, () => {
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();
});
},
},
computed: {
buildsOrSpinner() {
return this.builds ? this.builds : this.spinner;
},
dropdownClass() {
if (this.builds) return 'js-builds-dropdown-container';
return 'js-builds-dropdown-loading builds-dropdown-loading';
},
buildStatus() {
return `Build: ${this.stage.status.label}`;
},
tooltip() {
return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
},
triggerButtonClass() {
return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`;
},
},
template: `
<div>
<button
@click="fetchBuilds($event)"
:class="triggerButtonClass"
:title="stage.title"
data-placement="top"
data-toggle="dropdown"
type="button"
:aria-label="stage.title">
<span v-html="svg" aria-hidden="true"></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">
<div class="arrow-up" aria-hidden="true"></div>
<div
:class="dropdownClass"
class="js-builds-dropdown-list scrollable-menu"
v-html="buildsOrSpinner">
</div>
</ul>
</div>
`,
};
import canceledSvg from 'icons/_icon_status_canceled.svg';
import createdSvg from 'icons/_icon_status_created.svg';
import failedSvg from 'icons/_icon_status_failed.svg';
import manualSvg from 'icons/_icon_status_manual.svg';
import pendingSvg from 'icons/_icon_status_pending.svg';
import runningSvg from 'icons/_icon_status_running.svg';
import skippedSvg from 'icons/_icon_status_skipped.svg';
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
export default {
props: {
pipeline: {
type: Object,
required: true,
},
},
data() {
const svgsDictionary = {
icon_status_canceled: canceledSvg,
icon_status_created: createdSvg,
icon_status_failed: failedSvg,
icon_status_manual: manualSvg,
icon_status_pending: pendingSvg,
icon_status_running: runningSvg,
icon_status_skipped: skippedSvg,
icon_status_success: successSvg,
icon_status_warning: warningSvg,
};
return {
svg: svgsDictionary[this.pipeline.details.status.icon],
};
},
computed: {
cssClasses() {
return `ci-status ci-${this.pipeline.details.status.group}`;
},
detailsPath() {
const { status } = this.pipeline.details;
return status.has_details ? status.details_path : false;
},
content() {
return `${this.svg} ${this.pipeline.details.status.text}`;
},
},
template: `
<td class="commit-link">
<a
:class="cssClasses"
:href="detailsPath"
v-html="content">
</a>
</td>
`,
};
import iconTimerSvg from 'icons/_icon_timer.svg';
import '../../lib/utils/datetime_utility';
export default {
data() {
return {
currentTime: new Date(),
iconTimerSvg,
};
},
props: ['pipeline'],
computed: {
timeAgo() {
return gl.utils.getTimeago();
},
localTimeFinished() {
return gl.utils.formatDate(this.pipeline.details.finished_at);
},
timeStopped() {
const changeTime = this.currentTime;
const options = {
weekday: 'long',
year: 'numeric',
month: 'short',
day: 'numeric',
};
options.timeZoneName = 'short';
const finished = this.pipeline.details.finished_at;
if (!finished && changeTime) return false;
return ({ words: this.timeAgo.format(finished) });
},
duration() {
const { duration } = this.pipeline.details;
const date = new Date(duration * 1000);
let hh = date.getUTCHours();
let mm = date.getUTCMinutes();
let ss = date.getSeconds();
if (hh < 10) hh = `0${hh}`;
if (mm < 10) mm = `0${mm}`;
if (ss < 10) ss = `0${ss}`;
if (duration !== null) return `${hh}:${mm}:${ss}`;
return false;
},
},
methods: {
changeTime() {
this.currentTime = new Date();
},
},
template: `
<td class="pipelines-time-ago">
<p class="duration" v-if='duration'>
<span v-html="iconTimerSvg"></span>
{{duration}}
</p>
<p class="finished-at" v-if='timeStopped'>
<i class="fa fa-calendar"></i>
<time
data-toggle="tooltip"
data-placement="top"
data-container="body"
:data-original-title='localTimeFinished'>
{{timeStopped.words}}
</time>
</p>
</td>
`,
};
import Vue from 'vue';
export default new Vue();
/* eslint-disable no-param-reassign */ import PipelinesStore from './stores/pipelines_store';
/* global Vue, VueResource, gl */ import PipelinesComponent from './pipelines';
window.Vue = require('vue'); import '../vue_shared/vue_resource_interceptor';
const Vue = window.Vue = require('vue');
window.Vue.use(require('vue-resource')); window.Vue.use(require('vue-resource'));
require('../lib/utils/common_utils');
require('../vue_shared/vue_resource_interceptor');
require('./pipelines');
$(() => new Vue({ $(() => new Vue({
el: document.querySelector('.vue-pipelines-index'), el: document.querySelector('.vue-pipelines-index'),
data() { data() {
const project = document.querySelector('.pipelines'); const project = document.querySelector('.pipelines');
const store = new PipelinesStore();
return { return {
scope: project.dataset.url, store,
store: new gl.PipelineStore(), endpoint: project.dataset.url,
}; };
}, },
components: { components: {
'vue-pipelines': gl.VuePipelines, 'vue-pipelines': PipelinesComponent,
}, },
template: ` template: `
<vue-pipelines <vue-pipelines
:scope="scope" :endpoint="endpoint"
:store="store"> :store="store" />
</vue-pipelines>
`, `,
})); }));
/* global Vue, Flash, gl */
/* eslint-disable no-param-reassign, no-alert */
const playIconSvg = require('icons/_icon_play.svg');
((gl) => {
gl.VuePipelineActions = Vue.extend({
props: ['pipeline'],
computed: {
actions() {
return this.pipeline.details.manual_actions.length > 0;
},
artifacts() {
return this.pipeline.details.artifacts.length > 0;
},
},
methods: {
download(name) {
return `Download ${name} artifacts`;
},
/**
* Shows a dialog when the user clicks in the cancel button.
* We need to prevent the default behavior and stop propagation because the
* link relies on UJS.
*
* @param {Event} event
*/
confirmAction(event) {
if (!confirm('Are you sure you want to cancel this pipeline?')) {
event.preventDefault();
event.stopPropagation();
}
},
},
data() {
return { playIconSvg };
},
template: `
<td class="pipeline-actions">
<div class="pull-right">
<div class="btn-group">
<div class="btn-group" v-if="actions">
<button
class="dropdown-toggle btn btn-default has-tooltip js-pipeline-dropdown-manual-actions"
data-toggle="dropdown"
title="Manual job"
data-placement="top"
data-container="body"
aria-label="Manual job">
<span v-html="playIconSvg" aria-hidden="true"></span>
<i class="fa fa-caret-down" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-align-right">
<li v-for='action in pipeline.details.manual_actions'>
<a
rel="nofollow"
data-method="post"
:href="action.path" >
<span v-html="playIconSvg" aria-hidden="true"></span>
<span>{{action.name}}</span>
</a>
</li>
</ul>
</div>
<div class="btn-group" v-if="artifacts">
<button
class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download"
title="Artifacts"
data-placement="top"
data-container="body"
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 pipeline.details.artifacts'>
<a
rel="nofollow"
:href="artifact.path">
<i class="fa fa-download" aria-hidden="true"></i>
<span>{{download(artifact.name)}}</span>
</a>
</li>
</ul>
</div>
<div class="btn-group" v-if="pipeline.flags.retryable">
<a
class="btn btn-default btn-retry has-tooltip"
title="Retry"
rel="nofollow"
data-method="post"
data-placement="top"
data-container="body"
data-toggle="dropdown"
:href='pipeline.retry_path'
aria-label="Retry">
<i class="fa fa-repeat" aria-hidden="true"></i>
</a>
</div>
<div class="btn-group" v-if="pipeline.flags.cancelable">
<a
class="btn btn-remove has-tooltip"
title="Cancel"
rel="nofollow"
data-method="post"
data-placement="top"
data-container="body"
data-toggle="dropdown"
:href='pipeline.cancel_path'
aria-label="Cancel">
<i class="fa fa-remove" aria-hidden="true"></i>
</a>
</div>
</div>
</div>
</td>
`,
});
})(window.gl || (window.gl = {}));
/* global Vue, gl */
/* eslint-disable no-param-reassign */
((gl) => {
gl.VuePipelineUrl = Vue.extend({
props: [
'pipeline',
],
computed: {
user() {
return !!this.pipeline.user;
},
},
template: `
<td>
<a :href='pipeline.path'>
<span class="pipeline-id">#{{pipeline.id}}</span>
</a>
<span>by</span>
<a
v-if='user'
:href='pipeline.user.web_url'
>
<img
v-if='user'
class="avatar has-tooltip s20 "
:title='pipeline.user.name'
data-container="body"
:src='pipeline.user.avatar_url'
>
</a>
<span
v-if='!user'
class="api monospace"
>
API
</span>
<span
v-if='pipeline.flags.latest'
class="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="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="label label-warning"
>
stuck
</span>
</td>
`,
});
})(window.gl || (window.gl = {}));
/* global Vue, gl */ /* global Flash */
/* eslint-disable no-param-reassign */ /* eslint-disable no-new */
import '~/flash';
import Vue from 'vue';
import PipelinesService from './services/pipelines_service';
import eventHub from './event_hub';
import PipelinesTableComponent from '../vue_shared/components/pipelines_table';
import TablePaginationComponent from '../vue_shared/components/table_pagination';
window.Vue = require('vue'); export default {
require('../vue_shared/components/table_pagination'); props: {
require('./store'); endpoint: {
require('../vue_shared/components/pipelines_table'); type: String,
const CommitPipelinesStoreWithTimeAgo = require('../commit/pipelines/pipelines_store'); required: true,
((gl) => {
gl.VuePipelines = Vue.extend({
components: {
'gl-pagination': gl.VueGlPagination,
'pipelines-table-component': gl.pipelines.PipelinesTableComponent,
}, },
data() { store: {
return { type: Object,
pipelines: [], required: true,
timeLoopInterval: '',
intervalId: '',
apiScope: 'all',
pageInfo: {},
pagenum: 1,
count: {},
pageRequest: false,
};
},
props: ['scope', 'store'],
created() {
const pagenum = gl.utils.getParameterByName('page');
const scope = gl.utils.getParameterByName('scope');
if (pagenum) this.pagenum = pagenum;
if (scope) this.apiScope = scope;
this.store.fetchDataLoop.call(this, Vue, this.pagenum, this.scope, this.apiScope);
}, },
},
components: {
'gl-pagination': TablePaginationComponent,
'pipelines-table-component': PipelinesTableComponent,
},
data() {
return {
state: this.store.state,
apiScope: 'all',
pagenum: 1,
pageRequest: false,
};
},
created() {
this.service = new PipelinesService(this.endpoint);
this.fetchPipelines();
eventHub.$on('refreshPipelines', this.fetchPipelines);
},
beforeUpdate() {
if (this.state.pipelines.length && this.$children) {
this.store.startTimeAgoLoops.call(this, Vue);
}
},
beforeUpdate() { beforeDestroyed() {
if (this.pipelines.length && this.$children) { eventHub.$off('refreshPipelines');
CommitPipelinesStoreWithTimeAgo.startTimeAgoLoops.call(this, Vue); },
}
methods: {
/**
* Will change the page number and update the URL.
*
* @param {Number} pageNumber desired page to go to.
*/
change(pageNumber) {
const param = gl.utils.setParamInURL('page', pageNumber);
gl.utils.visitUrl(param);
return param;
}, },
methods: { fetchPipelines() {
/** const pageNumber = gl.utils.getParameterByName('page') || this.pagenum;
* Will change the page number and update the URL. const scope = gl.utils.getParameterByName('scope') || this.apiScope;
*
* @param {Number} pageNumber desired page to go to. this.pageRequest = true;
*/ return this.service.getPipelines(scope, pageNumber)
change(pageNumber) { .then(resp => ({
const param = gl.utils.setParamInURL('page', pageNumber); headers: resp.headers,
body: resp.json(),
gl.utils.visitUrl(param); }))
return param; .then((response) => {
}, this.store.storeCount(response.body.count);
this.store.storePipelines(response.body.pipelines);
this.store.storePagination(response.headers);
})
.then(() => {
this.pageRequest = false;
})
.catch(() => {
this.pageRequest = false;
new Flash('An error occurred while fetching the pipelines, please reload the page again.');
});
}, },
template: ` },
<div> template: `
<div class="pipelines realtime-loading" v-if='pageRequest'> <div>
<i class="fa fa-spinner fa-spin"></i> <div class="pipelines realtime-loading" v-if="pageRequest">
</div> <i class="fa fa-spinner fa-spin" aria-hidden="true"></i>
</div>
<div class="blank-state blank-state-no-icon"
v-if="!pageRequest && pipelines.length === 0"> <div class="blank-state blank-state-no-icon"
<h2 class="blank-state-title js-blank-state-title"> v-if="!pageRequest && state.pipelines.length === 0">
No pipelines to show <h2 class="blank-state-title js-blank-state-title">
</h2> No pipelines to show
</div> </h2>
</div>
<div class="table-holder" v-if='!pageRequest && pipelines.length'>
<pipelines-table-component :pipelines='pipelines'/> <div class="table-holder" v-if="!pageRequest && state.pipelines.length">
</div> <pipelines-table-component
:pipelines="state.pipelines"
<gl-pagination :service="service"/>
v-if='!pageRequest && pipelines.length && pageInfo.total > pageInfo.perPage'
:pagenum='pagenum'
:change='change'
:count='count.all'
:pageInfo='pageInfo'
>
</gl-pagination>
</div> </div>
`,
}); <gl-pagination
})(window.gl || (window.gl = {})); v-if="!pageRequest && state.pipelines.length && state.pageInfo.total > state.pageInfo.perPage"
:pagenum="pagenum"
:change="change"
:count="state.count.all"
:pageInfo="state.pageInfo"
>
</gl-pagination>
</div>
`,
};
/* eslint-disable class-methods-use-this */
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class PipelinesService {
/**
* Commits and merge request endpoints need to be requested with `.json`.
*
* The url provided to request the pipelines in the new merge request
* page already has `.json`.
*
* @param {String} root
*/
constructor(root) {
let endpoint;
if (root.indexOf('.json') === -1) {
endpoint = `${root}.json`;
} else {
endpoint = root;
}
this.pipelines = Vue.resource(endpoint);
}
getPipelines(scope, page) {
return this.pipelines.get({ scope, page });
}
/**
* Post request for all pipelines actions.
* Endpoint content type needs to be:
* `Content-Type:application/x-www-form-urlencoded`
*
* @param {String} endpoint
* @return {Promise}
*/
postAction(endpoint) {
return Vue.http.post(endpoint, {}, { emulateJSON: true });
}
}
/* global Vue, Flash, gl */
/* eslint-disable no-param-reassign */
import canceledSvg from 'icons/_icon_status_canceled_borderless.svg';
import createdSvg from 'icons/_icon_status_created_borderless.svg';
import failedSvg from 'icons/_icon_status_failed_borderless.svg';
import manualSvg from 'icons/_icon_status_manual_borderless.svg';
import pendingSvg from 'icons/_icon_status_pending_borderless.svg';
import runningSvg from 'icons/_icon_status_running_borderless.svg';
import skippedSvg from 'icons/_icon_status_skipped_borderless.svg';
import successSvg from 'icons/_icon_status_success_borderless.svg';
import warningSvg from 'icons/_icon_status_warning_borderless.svg';
((gl) => {
gl.VueStage = Vue.extend({
data() {
const svgsDictionary = {
icon_status_canceled: canceledSvg,
icon_status_created: createdSvg,
icon_status_failed: failedSvg,
icon_status_manual: manualSvg,
icon_status_pending: pendingSvg,
icon_status_running: runningSvg,
icon_status_skipped: skippedSvg,
icon_status_success: successSvg,
icon_status_warning: warningSvg,
};
return {
builds: '',
spinner: '<span class="fa fa-spinner fa-spin"></span>',
svg: svgsDictionary[this.stage.status.icon],
};
},
props: {
stage: {
type: Object,
required: true,
},
},
updated() {
if (this.builds) {
this.stopDropdownClickPropagation();
}
},
methods: {
fetchBuilds(e) {
const areaExpanded = e.currentTarget.attributes['aria-expanded'];
if (areaExpanded && (areaExpanded.textContent === 'true')) return null;
return this.$http.get(this.stage.dropdown_path)
.then((response) => {
this.builds = JSON.parse(response.body).html;
}, () => {
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).on('click', '.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item', (e) => {
e.stopPropagation();
});
},
},
computed: {
buildsOrSpinner() {
return this.builds ? this.builds : this.spinner;
},
dropdownClass() {
if (this.builds) return 'js-builds-dropdown-container';
return 'js-builds-dropdown-loading builds-dropdown-loading';
},
buildStatus() {
return `Build: ${this.stage.status.label}`;
},
tooltip() {
return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
},
triggerButtonClass() {
return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`;
},
},
template: `
<div>
<button
@click="fetchBuilds($event)"
:class="triggerButtonClass"
:title="stage.title"
data-placement="top"
data-toggle="dropdown"
type="button"
:aria-label="stage.title">
<span v-html="svg" aria-hidden="true"></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">
<div class="arrow-up" aria-hidden="true"></div>
<div
:class="dropdownClass"
class="js-builds-dropdown-list scrollable-menu"
v-html="buildsOrSpinner">
</div>
</ul>
</div>
`,
});
})(window.gl || (window.gl = {}));
/* global Vue, gl */
/* eslint-disable no-param-reassign */
import canceledSvg from 'icons/_icon_status_canceled.svg';
import createdSvg from 'icons/_icon_status_created.svg';
import failedSvg from 'icons/_icon_status_failed.svg';
import manualSvg from 'icons/_icon_status_manual.svg';
import pendingSvg from 'icons/_icon_status_pending.svg';
import runningSvg from 'icons/_icon_status_running.svg';
import skippedSvg from 'icons/_icon_status_skipped.svg';
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
((gl) => {
gl.VueStatusScope = Vue.extend({
props: [
'pipeline',
],
data() {
const svgsDictionary = {
icon_status_canceled: canceledSvg,
icon_status_created: createdSvg,
icon_status_failed: failedSvg,
icon_status_manual: manualSvg,
icon_status_pending: pendingSvg,
icon_status_running: runningSvg,
icon_status_skipped: skippedSvg,
icon_status_success: successSvg,
icon_status_warning: warningSvg,
};
return {
svg: svgsDictionary[this.pipeline.details.status.icon],
};
},
computed: {
cssClasses() {
const cssObject = { 'ci-status': true };
cssObject[`ci-${this.pipeline.details.status.group}`] = true;
return cssObject;
},
detailsPath() {
const { status } = this.pipeline.details;
return status.has_details ? status.details_path : false;
},
content() {
return `${this.svg} ${this.pipeline.details.status.text}`;
},
},
template: `
<td class="commit-link">
<a
:class="cssClasses"
:href="detailsPath"
v-html="content">
</a>
</td>
`,
});
})(window.gl || (window.gl = {}));
/* global gl, Flash */
/* eslint-disable no-param-reassign */
((gl) => {
const pageValues = (headers) => {
const normalized = gl.utils.normalizeHeaders(headers);
const paginationInfo = gl.utils.parseIntPagination(normalized);
return paginationInfo;
};
gl.PipelineStore = class {
fetchDataLoop(Vue, pageNum, url, apiScope) {
this.pageRequest = true;
return this.$http.get(`${url}?scope=${apiScope}&page=${pageNum}`)
.then((response) => {
const pageInfo = pageValues(response.headers);
this.pageInfo = Object.assign({}, this.pageInfo, pageInfo);
const res = JSON.parse(response.body);
this.count = Object.assign({}, this.count, res.count);
this.pipelines = Object.assign([], this.pipelines, res.pipelines);
this.pageRequest = false;
}, () => {
this.pageRequest = false;
return new Flash('An error occurred while fetching the pipelines, please reload the page again.');
});
}
};
})(window.gl || (window.gl = {}));
/* eslint-disable no-underscore-dangle*/ /* eslint-disable no-underscore-dangle*/
/** import '../../vue_realtime_listener';
* Pipelines' Store for commits view.
* export default class PipelinesStore {
* Used to store the Pipelines rendered in the commit view in the pipelines table.
*/
require('../../vue_realtime_listener');
class PipelinesStore {
constructor() { constructor() {
this.state = {}; this.state = {};
this.state.pipelines = []; this.state.pipelines = [];
this.state.count = {};
this.state.pageInfo = {};
} }
storePipelines(pipelines = []) { storePipelines(pipelines = []) {
this.state.pipelines = pipelines; this.state.pipelines = pipelines;
}
return pipelines; storeCount(count = {}) {
this.state.count = count;
}
storePagination(pagination = {}) {
let paginationInfo;
if (Object.keys(pagination).length) {
const normalizedHeaders = gl.utils.normalizeHeaders(pagination);
paginationInfo = gl.utils.parseIntPagination(normalizedHeaders);
} else {
paginationInfo = pagination;
}
this.state.pageInfo = paginationInfo;
} }
/** /**
* FIXME: Move this inside the component.
*
* Once the data is received we will start the time ago loops. * Once the data is received we will start the time ago loops.
* *
* Everytime a request is made like retry or cancel a pipeline, every 10 seconds we * Everytime a request is made like retry or cancel a pipeline, every 10 seconds we
* update the time to show how long as passed. * update the time to show how long as passed.
* *
*/ */
static startTimeAgoLoops() { startTimeAgoLoops() {
const startTimeLoops = () => { const startTimeLoops = () => {
this.timeLoopInterval = setInterval(() => { this.timeLoopInterval = setInterval(() => {
this.$children[0].$children.reduce((acc, component) => { this.$children[0].$children.reduce((acc, component) => {
...@@ -44,5 +59,3 @@ class PipelinesStore { ...@@ -44,5 +59,3 @@ class PipelinesStore {
gl.VueRealtimeListener(removeIntervals, startIntervals); gl.VueRealtimeListener(removeIntervals, startIntervals);
} }
} }
module.exports = PipelinesStore;
/* global Vue, gl */
/* eslint-disable no-param-reassign */
window.Vue = require('vue');
require('../lib/utils/datetime_utility');
const iconTimerSvg = require('../../../views/shared/icons/_icon_timer.svg');
((gl) => {
gl.VueTimeAgo = Vue.extend({
data() {
return {
currentTime: new Date(),
iconTimerSvg,
};
},
props: ['pipeline'],
computed: {
timeAgo() {
return gl.utils.getTimeago();
},
localTimeFinished() {
return gl.utils.formatDate(this.pipeline.details.finished_at);
},
timeStopped() {
const changeTime = this.currentTime;
const options = {
weekday: 'long',
year: 'numeric',
month: 'short',
day: 'numeric',
};
options.timeZoneName = 'short';
const finished = this.pipeline.details.finished_at;
if (!finished && changeTime) return false;
return ({ words: this.timeAgo.format(finished) });
},
duration() {
const { duration } = this.pipeline.details;
const date = new Date(duration * 1000);
let hh = date.getUTCHours();
let mm = date.getUTCMinutes();
let ss = date.getSeconds();
if (hh < 10) hh = `0${hh}`;
if (mm < 10) mm = `0${mm}`;
if (ss < 10) ss = `0${ss}`;
if (duration !== null) return `${hh}:${mm}:${ss}`;
return false;
},
},
methods: {
changeTime() {
this.currentTime = new Date();
},
},
template: `
<td class="pipelines-time-ago">
<p class="duration" v-if='duration'>
<span v-html="iconTimerSvg"></span>
{{duration}}
</p>
<p class="finished-at" v-if='timeStopped'>
<i class="fa fa-calendar"></i>
<time
data-toggle="tooltip"
data-placement="top"
data-container="body"
:data-original-title='localTimeFinished'>
{{timeStopped.words}}
</time>
</p>
</td>
`,
});
})(window.gl || (window.gl = {}));
/* global Vue */ import commitIconSvg from 'icons/_icon_commit.svg';
window.Vue = require('vue');
const commitIconSvg = require('icons/_icon_commit.svg'); export default {
props: {
(() => { /**
window.gl = window.gl || {}; * Indicates the existance of a tag.
* Used to render the correct icon, if true will render `fa-tag` icon,
window.gl.CommitComponent = Vue.component('commit-component', { * if false will render `fa-code-fork` icon.
*/
props: { tag: {
/** type: Boolean,
* Indicates the existance of a tag. required: false,
* Used to render the correct icon, if true will render `fa-tag` icon, default: false,
* if false will render `fa-code-fork` icon.
*/
tag: {
type: Boolean,
required: false,
default: false,
},
/**
* If provided is used to render the branch name and url.
* Should contain the following properties:
* name
* ref_url
*/
commitRef: {
type: Object,
required: false,
default: () => ({}),
},
/**
* Used to link to the commit sha.
*/
commitUrl: {
type: String,
required: false,
default: '',
},
/**
* Used to show the commit short sha that links to the commit url.
*/
shortSha: {
type: String,
required: false,
default: '',
},
/**
* If provided shows the commit tile.
*/
title: {
type: String,
required: false,
default: '',
},
/**
* If provided renders information about the author of the commit.
* When provided should include:
* `avatar_url` to render the avatar icon
* `web_url` to link to user profile
* `username` to render alt and title tags
*/
author: {
type: Object,
required: false,
default: () => ({}),
},
}, },
computed: { /**
/** * If provided is used to render the branch name and url.
* Used to verify if all the properties needed to render the commit * Should contain the following properties:
* ref section were provided. * name
* * ref_url
* TODO: Improve this! Use lodash _.has when we have it. */
* commitRef: {
* @returns {Boolean} type: Object,
*/ required: false,
hasCommitRef() { default: () => ({}),
return this.commitRef && this.commitRef.name && this.commitRef.ref_url;
},
/**
* Used to verify if all the properties needed to render the commit
* author section were provided.
*
* TODO: Improve this! Use lodash _.has when we have it.
*
* @returns {Boolean}
*/
hasAuthor() {
return this.author &&
this.author.avatar_url &&
this.author.web_url &&
this.author.username;
},
/**
* If information about the author is provided will return a string
* to be rendered as the alt attribute of the img tag.
*
* @returns {String}
*/
userImageAltDescription() {
return this.author &&
this.author.username ? `${this.author.username}'s avatar` : null;
},
}, },
data() { /**
return { commitIconSvg }; * Used to link to the commit sha.
*/
commitUrl: {
type: String,
required: false,
default: '',
}, },
template: ` /**
<div class="branch-commit"> * Used to show the commit short sha that links to the commit url.
*/
<div v-if="hasCommitRef" class="icon-container"> shortSha: {
<i v-if="tag" class="fa fa-tag"></i> type: String,
<i v-if="!tag" class="fa fa-code-fork"></i> required: false,
</div> default: '',
},
<a v-if="hasCommitRef"
class="monospace branch-name" /**
:href="commitRef.ref_url"> * If provided shows the commit tile.
{{commitRef.name}} */
</a> title: {
type: String,
<div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div> required: false,
default: '',
<a class="commit-id monospace" },
:href="commitUrl">
{{shortSha}} /**
</a> * If provided renders information about the author of the commit.
* When provided should include:
<p class="commit-title"> * `avatar_url` to render the avatar icon
<span v-if="title"> * `web_url` to link to user profile
<a v-if="hasAuthor" * `username` to render alt and title tags
class="avatar-image-container" */
:href="author.web_url"> author: {
<img type: Object,
class="avatar has-tooltip s20" required: false,
:src="author.avatar_url" default: () => ({}),
:alt="userImageAltDescription" },
:title="author.username" /> },
</a>
computed: {
<a class="commit-row-message" /**
:href="commitUrl"> * Used to verify if all the properties needed to render the commit
{{title}} * ref section were provided.
</a> *
</span> * TODO: Improve this! Use lodash _.has when we have it.
<span v-else> *
Cant find HEAD commit for this branch * @returns {Boolean}
</span> */
</p> hasCommitRef() {
return this.commitRef && this.commitRef.name && this.commitRef.ref_url;
},
/**
* Used to verify if all the properties needed to render the commit
* author section were provided.
*
* TODO: Improve this! Use lodash _.has when we have it.
*
* @returns {Boolean}
*/
hasAuthor() {
return this.author &&
this.author.avatar_url &&
this.author.web_url &&
this.author.username;
},
/**
* If information about the author is provided will return a string
* to be rendered as the alt attribute of the img tag.
*
* @returns {String}
*/
userImageAltDescription() {
return this.author &&
this.author.username ? `${this.author.username}'s avatar` : null;
},
},
data() {
return { commitIconSvg };
},
template: `
<div class="branch-commit">
<div v-if="hasCommitRef" class="icon-container">
<i v-if="tag" class="fa fa-tag"></i>
<i v-if="!tag" class="fa fa-code-fork"></i>
</div> </div>
`,
}); <a v-if="hasCommitRef"
})(); class="monospace branch-name"
:href="commitRef.ref_url">
{{commitRef.name}}
</a>
<div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div>
<a class="commit-id monospace"
:href="commitUrl">
{{shortSha}}
</a>
<p class="commit-title">
<span v-if="title">
<a v-if="hasAuthor"
class="avatar-image-container"
:href="author.web_url">
<img
class="avatar has-tooltip s20"
:src="author.avatar_url"
:alt="userImageAltDescription"
:title="author.username" />
</a>
<a class="commit-row-message"
:href="commitUrl">
{{title}}
</a>
</span>
<span v-else>
Cant find HEAD commit for this branch
</span>
</p>
</div>
`,
};
/* eslint-disable no-param-reassign */ import PipelinesTableRowComponent from './pipelines_table_row';
/* global Vue */
require('./pipelines_table_row');
/** /**
* Pipelines Table Component. * Pipelines Table Component.
* *
* Given an array of objects, renders a table. * Given an array of objects, renders a table.
*/ */
export default {
(() => { props: {
window.gl = window.gl || {}; pipelines: {
gl.pipelines = gl.pipelines || {}; type: Array,
required: true,
gl.pipelines.PipelinesTableComponent = Vue.component('pipelines-table-component', { default: () => ([]),
props: {
pipelines: {
type: Array,
required: true,
default: () => ([]),
},
}, },
components: { service: {
'pipelines-table-row-component': gl.pipelines.PipelinesTableRowComponent, type: Object,
required: true,
}, },
},
components: {
'pipelines-table-row-component': PipelinesTableRowComponent,
},
template: ` template: `
<table class="table ci-table"> <table class="table ci-table">
<thead> <thead>
<tr> <tr>
<th class="js-pipeline-status pipeline-status">Status</th> <th class="js-pipeline-status pipeline-status">Status</th>
<th class="js-pipeline-info pipeline-info">Pipeline</th> <th class="js-pipeline-info pipeline-info">Pipeline</th>
<th class="js-pipeline-commit pipeline-commit">Commit</th> <th class="js-pipeline-commit pipeline-commit">Commit</th>
<th class="js-pipeline-stages pipeline-stages">Stages</th> <th class="js-pipeline-stages pipeline-stages">Stages</th>
<th class="js-pipeline-date pipeline-date"></th> <th class="js-pipeline-date pipeline-date"></th>
<th class="js-pipeline-actions pipeline-actions"></th> <th class="js-pipeline-actions pipeline-actions"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="model in pipelines" <template v-for="model in pipelines"
v-bind:model="model"> v-bind:model="model">
<tr is="pipelines-table-row-component" <tr is="pipelines-table-row-component"
:pipeline="model"></tr> :pipeline="model"
</template> :service="service"></tr>
</tbody> </template>
</table> </tbody>
`, </table>
}); `,
})(); };
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
/* global Vue */
import AsyncButtonComponent from '../../vue_pipelines_index/components/async_button';
require('../../vue_pipelines_index/status'); import PipelinesActionsComponent from '../../vue_pipelines_index/components/pipelines_actions';
require('../../vue_pipelines_index/pipeline_url'); import PipelinesArtifactsComponent from '../../vue_pipelines_index/components/pipelines_artifacts';
require('../../vue_pipelines_index/stage'); import PipelinesStatusComponent from '../../vue_pipelines_index/components/status';
require('../../vue_pipelines_index/pipeline_actions'); import PipelinesStageComponent from '../../vue_pipelines_index/components/stage';
require('../../vue_pipelines_index/time_ago'); import PipelinesUrlComponent from '../../vue_pipelines_index/components/pipeline_url';
require('./commit'); import PipelinesTimeagoComponent from '../../vue_pipelines_index/components/time_ago';
import CommitComponent from './commit';
/** /**
* Pipeline table row. * Pipeline table row.
* *
* Given the received object renders a table row in the pipelines' table. * Given the received object renders a table row in the pipelines' table.
*/ */
(() => { export default {
window.gl = window.gl || {}; props: {
gl.pipelines = gl.pipelines || {}; pipeline: {
type: Object,
gl.pipelines.PipelinesTableRowComponent = Vue.component('pipelines-table-row-component', { required: true,
},
props: {
pipeline: {
type: Object,
required: true,
default: () => ({}),
},
service: {
type: Object,
required: true,
},
},
components: {
'async-button-component': AsyncButtonComponent,
'pipelines-actions-component': PipelinesActionsComponent,
'pipelines-artifacts-component': PipelinesArtifactsComponent,
'commit-component': CommitComponent,
'dropdown-stage': PipelinesStageComponent,
'pipeline-url': PipelinesUrlComponent,
'status-scope': PipelinesStatusComponent,
'time-ago': PipelinesTimeagoComponent,
},
computed: {
/**
* If provided, returns the commit tag.
* Needed to render the commit component column.
*
* This field needs a lot of verification, because of different possible cases:
*
* 1. person who is an author of a commit might be a GitLab user
* 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar
* 3. If GitLab user does not have avatar he/she might have a Gravatar
* 4. If committer is not a GitLab User he/she can have a Gravatar
* 5. We do not have consistent API object in this case
* 6. We should improve API and the code
*
* @returns {Object|Undefined}
*/
commitAuthor() {
let commitAuthorInformation;
// 1. person who is an author of a commit might be a GitLab user
if (this.pipeline &&
this.pipeline.commit &&
this.pipeline.commit.author) {
// 2. if person who is an author of a commit is a GitLab user
// he/she can have a GitLab avatar
if (this.pipeline.commit.author.avatar_url) {
commitAuthorInformation = this.pipeline.commit.author;
// 3. If GitLab user does not have avatar he/she might have a Gravatar
} else if (this.pipeline.commit.author_gravatar_url) {
commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
avatar_url: this.pipeline.commit.author_gravatar_url,
});
}
}
// 4. If committer is not a GitLab User he/she can have a Gravatar
if (this.pipeline &&
this.pipeline.commit) {
commitAuthorInformation = {
avatar_url: this.pipeline.commit.author_gravatar_url,
web_url: `mailto:${this.pipeline.commit.author_email}`,
username: this.pipeline.commit.author_name,
};
}
return commitAuthorInformation;
}, },
components: { /**
'commit-component': gl.CommitComponent, * If provided, returns the commit tag.
'pipeline-actions': gl.VuePipelineActions, * Needed to render the commit component column.
'dropdown-stage': gl.VueStage, *
'pipeline-url': gl.VuePipelineUrl, * @returns {String|Undefined}
'status-scope': gl.VueStatusScope, */
'time-ago': gl.VueTimeAgo, commitTag() {
if (this.pipeline.ref &&
this.pipeline.ref.tag) {
return this.pipeline.ref.tag;
}
return undefined;
}, },
computed: { /**
/** * If provided, returns the commit ref.
* If provided, returns the commit tag. * Needed to render the commit component column.
* Needed to render the commit component column. *
* * Matches `path` prop sent in the API to `ref_url` prop needed
* This field needs a lot of verification, because of different possible cases: * in the commit component.
* *
* 1. person who is an author of a commit might be a GitLab user * @returns {Object|Undefined}
* 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar */
* 3. If GitLab user does not have avatar he/she might have a Gravatar commitRef() {
* 4. If committer is not a GitLab User he/she can have a Gravatar if (this.pipeline.ref) {
* 5. We do not have consistent API object in this case return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => {
* 6. We should improve API and the code if (prop === 'path') {
* accumulator.ref_url = this.pipeline.ref[prop];
* @returns {Object|Undefined} } else {
*/ accumulator[prop] = this.pipeline.ref[prop];
commitAuthor() {
let commitAuthorInformation;
// 1. person who is an author of a commit might be a GitLab user
if (this.pipeline &&
this.pipeline.commit &&
this.pipeline.commit.author) {
// 2. if person who is an author of a commit is a GitLab user
// he/she can have a GitLab avatar
if (this.pipeline.commit.author.avatar_url) {
commitAuthorInformation = this.pipeline.commit.author;
// 3. If GitLab user does not have avatar he/she might have a Gravatar
} else if (this.pipeline.commit.author_gravatar_url) {
commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
avatar_url: this.pipeline.commit.author_gravatar_url,
});
} }
} return accumulator;
}, {});
}
// 4. If committer is not a GitLab User he/she can have a Gravatar return undefined;
if (this.pipeline && },
this.pipeline.commit) {
commitAuthorInformation = {
avatar_url: this.pipeline.commit.author_gravatar_url,
web_url: `mailto:${this.pipeline.commit.author_email}`,
username: this.pipeline.commit.author_name,
};
}
return commitAuthorInformation; /**
}, * If provided, returns the commit url.
* Needed to render the commit component column.
/** *
* If provided, returns the commit tag. * @returns {String|Undefined}
* Needed to render the commit component column. */
* commitUrl() {
* @returns {String|Undefined} if (this.pipeline.commit &&
*/ this.pipeline.commit.commit_path) {
commitTag() { return this.pipeline.commit.commit_path;
if (this.pipeline.ref && }
this.pipeline.ref.tag) { return undefined;
return this.pipeline.ref.tag; },
}
return undefined;
},
/**
* If provided, returns the commit ref.
* Needed to render the commit component column.
*
* Matches `path` prop sent in the API to `ref_url` prop needed
* in the commit component.
*
* @returns {Object|Undefined}
*/
commitRef() {
if (this.pipeline.ref) {
return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => {
if (prop === 'path') {
accumulator.ref_url = this.pipeline.ref[prop];
} else {
accumulator[prop] = this.pipeline.ref[prop];
}
return accumulator;
}, {});
}
return undefined; /**
}, * If provided, returns the commit short sha.
* Needed to render the commit component column.
/** *
* If provided, returns the commit url. * @returns {String|Undefined}
* Needed to render the commit component column. */
* commitShortSha() {
* @returns {String|Undefined} if (this.pipeline.commit &&
*/ this.pipeline.commit.short_id) {
commitUrl() { return this.pipeline.commit.short_id;
if (this.pipeline.commit && }
this.pipeline.commit.commit_path) { return undefined;
return this.pipeline.commit.commit_path;
}
return undefined;
},
/**
* If provided, returns the commit short sha.
* Needed to render the commit component column.
*
* @returns {String|Undefined}
*/
commitShortSha() {
if (this.pipeline.commit &&
this.pipeline.commit.short_id) {
return this.pipeline.commit.short_id;
}
return undefined;
},
/**
* If provided, returns the commit title.
* Needed to render the commit component column.
*
* @returns {String|Undefined}
*/
commitTitle() {
if (this.pipeline.commit &&
this.pipeline.commit.title) {
return this.pipeline.commit.title;
}
return undefined;
},
}, },
template: ` /**
<tr class="commit"> * If provided, returns the commit title.
<status-scope :pipeline="pipeline"/> * Needed to render the commit component column.
*
<pipeline-url :pipeline="pipeline"></pipeline-url> * @returns {String|Undefined}
*/
<td> commitTitle() {
<commit-component if (this.pipeline.commit &&
:tag="commitTag" this.pipeline.commit.title) {
:commit-ref="commitRef" return this.pipeline.commit.title;
:commit-url="commitUrl" }
:short-sha="commitShortSha" return undefined;
:title="commitTitle" },
:author="commitAuthor"/> },
</td>
template: `
<td class="stage-cell"> <tr class="commit">
<div class="stage-container dropdown js-mini-pipeline-graph" <status-scope :pipeline="pipeline"/>
v-if="pipeline.details.stages.length > 0"
v-for="stage in pipeline.details.stages"> <pipeline-url :pipeline="pipeline"></pipeline-url>
<dropdown-stage :stage="stage"/>
</div> <td>
</td> <commit-component
:tag="commitTag"
<time-ago :pipeline="pipeline"/> :commit-ref="commitRef"
:commit-url="commitUrl"
<pipeline-actions :pipeline="pipeline" /> :short-sha="commitShortSha"
</tr> :title="commitTitle"
`, :author="commitAuthor"/>
}); </td>
})();
<td class="stage-cell">
<div class="stage-container dropdown js-mini-pipeline-graph"
v-if="pipeline.details.stages.length > 0"
v-for="stage in pipeline.details.stages">
<dropdown-stage :stage="stage"/>
</div>
</td>
<time-ago :pipeline="pipeline"/>
<td class="pipeline-actions">
<div class="pull-right btn-group">
<pipelines-actions-component
v-if="pipeline.details.manual_actions.length"
:actions="pipeline.details.manual_actions"
:service="service" />
<pipelines-artifacts-component
v-if="pipeline.details.artifacts.length"
:artifacts="pipeline.details.artifacts" />
<async-button-component
v-if="pipeline.flags.retryable"
:service="service"
:endpoint="pipeline.retry_path"
css-class="js-pipelines-retry-button btn-default btn-retry"
title="Retry"
icon="repeat" />
<async-button-component
v-if="pipeline.flags.cancelable"
:service="service"
:endpoint="pipeline.cancel_path"
css-class="js-pipelines-cancel-button btn-remove"
title="Cancel"
icon="remove"
confirm-action-message="Are you sure you want to cancel this pipeline?" />
</div>
</td>
</tr>
`,
};
/* global Vue, gl */ const PAGINATION_UI_BUTTON_LIMIT = 4;
/* eslint-disable no-param-reassign, no-plusplus */ const UI_LIMIT = 6;
const SPREAD = '...';
window.Vue = require('vue'); const PREV = 'Prev';
const NEXT = 'Next';
((gl) => { const FIRST = '<< First';
const PAGINATION_UI_BUTTON_LIMIT = 4; const LAST = 'Last >>';
const UI_LIMIT = 6;
const SPREAD = '...'; export default {
const PREV = 'Prev'; props: {
const NEXT = 'Next'; /**
const FIRST = '<< First'; This function will take the information given by the pagination component
const LAST = 'Last >>';
Here is an example `change` method:
gl.VueGlPagination = Vue.extend({
props: { change(pagenum) {
gl.utils.visitUrl(`?page=${pagenum}`);
// TODO: Consider refactoring in light of turbolinks removal.
/**
This function will take the information given by the pagination component
Here is an example `change` method:
change(pagenum) {
gl.utils.visitUrl(`?page=${pagenum}`);
},
*/
change: {
type: Function,
required: true,
}, },
*/
change: {
type: Function,
required: true,
},
/** /**
pageInfo will come from the headers of the API call pageInfo will come from the headers of the API call
in the `.then` clause of the VueResource API call in the `.then` clause of the VueResource API call
there should be a function that contructs the pageInfo for this component there should be a function that contructs the pageInfo for this component
This is an example: This is an example:
const pageInfo = headers => ({ const pageInfo = headers => ({
perPage: +headers['X-Per-Page'], perPage: +headers['X-Per-Page'],
page: +headers['X-Page'], page: +headers['X-Page'],
total: +headers['X-Total'], total: +headers['X-Total'],
totalPages: +headers['X-Total-Pages'], totalPages: +headers['X-Total-Pages'],
nextPage: +headers['X-Next-Page'], nextPage: +headers['X-Next-Page'],
previousPage: +headers['X-Prev-Page'], previousPage: +headers['X-Prev-Page'],
}); });
*/ */
pageInfo: {
pageInfo: { type: Object,
type: Object, required: true,
required: true,
},
}, },
methods: { },
changePage(e) { methods: {
const text = e.target.innerText; changePage(e) {
const { totalPages, nextPage, previousPage } = this.pageInfo; const text = e.target.innerText;
const { totalPages, nextPage, previousPage } = this.pageInfo;
switch (text) {
case SPREAD: switch (text) {
break; case SPREAD:
case LAST: break;
this.change(totalPages); case LAST:
break; this.change(totalPages);
case NEXT: break;
this.change(nextPage); case NEXT:
break; this.change(nextPage);
case PREV: break;
this.change(previousPage); case PREV:
break; this.change(previousPage);
case FIRST: break;
this.change(1); case FIRST:
break; this.change(1);
default: break;
this.change(+text); default:
break; this.change(+text);
} break;
}, }
}, },
computed: { },
prev() { computed: {
return this.pageInfo.previousPage; prev() {
}, return this.pageInfo.previousPage;
next() { },
return this.pageInfo.nextPage; next() {
}, return this.pageInfo.nextPage;
getItems() { },
const total = this.pageInfo.totalPages; getItems() {
const page = this.pageInfo.page; const total = this.pageInfo.totalPages;
const items = []; const page = this.pageInfo.page;
const items = [];
if (page > 1) items.push({ title: FIRST }); if (page > 1) items.push({ title: FIRST });
if (page > 1) { if (page > 1) {
items.push({ title: PREV, prev: true }); items.push({ title: PREV, prev: true });
} else { } else {
items.push({ title: PREV, disabled: true, prev: true }); items.push({ title: PREV, disabled: true, prev: true });
} }
if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true }); if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true });
const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1); const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1);
const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total); const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total);
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i += 1) {
const isActive = i === page; const isActive = i === page;
items.push({ title: i, active: isActive, page: true }); items.push({ title: i, active: isActive, page: true });
} }
if (total - page > PAGINATION_UI_BUTTON_LIMIT) { if (total - page > PAGINATION_UI_BUTTON_LIMIT) {
items.push({ title: SPREAD, separator: true, page: true }); items.push({ title: SPREAD, separator: true, page: true });
} }
if (page === total) { if (page === total) {
items.push({ title: NEXT, disabled: true, next: true }); items.push({ title: NEXT, disabled: true, next: true });
} else if (total - page >= 1) { } else if (total - page >= 1) {
items.push({ title: NEXT, next: true }); items.push({ title: NEXT, next: true });
} }
if (total - page >= 1) items.push({ title: LAST, last: true }); if (total - page >= 1) items.push({ title: LAST, last: true });
return items; return items;
},
}, },
template: ` },
<div class="gl-pagination"> template: `
<ul class="pagination clearfix"> <div class="gl-pagination">
<li v-for='item in getItems' <ul class="pagination clearfix">
:class='{ <li v-for='item in getItems'
page: item.page, :class='{
prev: item.prev, page: item.page,
next: item.next, prev: item.prev,
separator: item.separator, next: item.next,
active: item.active, separator: item.separator,
disabled: item.disabled active: item.active,
}' disabled: item.disabled
> }'
<a @click="changePage($event)">{{item.title}}</a> >
</li> <a @click="changePage($event)">{{item.title}}</a>
</ul> </li>
</div> </ul>
`, </div>
}); `,
})(window.gl || (window.gl = {})); };
/* eslint-disable func-names, prefer-arrow-callback, no-unused-vars, /* eslint-disable no-param-reassign, no-plusplus */
no-param-reassign, no-plusplus */ import Vue from 'vue';
/* global Vue */ import VueResource from 'vue-resource';
Vue.use(VueResource);
Vue.http.interceptors.push((request, next) => { Vue.http.interceptors.push((request, next) => {
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
next((response) => { next(() => {
Vue.activeResources--; Vue.activeResources--;
}); });
}); });
......
...@@ -72,11 +72,6 @@ ...@@ -72,11 +72,6 @@
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
font-size: 14px; font-size: 14px;
} }
svg,
.fa {
margin-right: 0;
}
} }
.btn-group { .btn-group {
...@@ -921,3 +916,22 @@ ...@@ -921,3 +916,22 @@
} }
} }
} }
/**
* Play button with icon in dropdowns
*/
.ci-table .no-btn {
border: none;
background: none;
outline: none;
width: 100%;
text-align: left;
.icon-play {
position: relative;
top: 2px;
margin-right: 5px;
height: 13px;
width: 12px;
}
}
---
title: 'Removes UJS from pipelines tables'
merge_request: 9929
author:
...@@ -60,9 +60,6 @@ feature 'Merge request created from fork' do ...@@ -60,9 +60,6 @@ feature 'Merge request created from fork' do
expect(page).to have_content pipeline.status expect(page).to have_content pipeline.status
expect(page).to have_content pipeline.id expect(page).to have_content pipeline.id
end end
expect(page.find('a.btn-remove')[:href])
.to include fork_project.path_with_namespace
end end
end end
......
...@@ -99,15 +99,18 @@ describe 'Pipelines', :feature, :js do ...@@ -99,15 +99,18 @@ describe 'Pipelines', :feature, :js do
end end
it 'indicates that pipeline can be canceled' do it 'indicates that pipeline can be canceled' do
expect(page).to have_link('Cancel') expect(page).to have_selector('.js-pipelines-cancel-button')
expect(page).to have_selector('.ci-running') expect(page).to have_selector('.ci-running')
end end
context 'when canceling' do context 'when canceling' do
before { click_link('Cancel') } before do
find('.js-pipelines-cancel-button').click
wait_for_vue_resource
end
it 'indicated that pipelines was canceled' do it 'indicated that pipelines was canceled' do
expect(page).not_to have_link('Cancel') expect(page).not_to have_selector('.js-pipelines-cancel-button')
expect(page).to have_selector('.ci-canceled') expect(page).to have_selector('.ci-canceled')
end end
end end
...@@ -126,15 +129,18 @@ describe 'Pipelines', :feature, :js do ...@@ -126,15 +129,18 @@ describe 'Pipelines', :feature, :js do
end end
it 'indicates that pipeline can be retried' do it 'indicates that pipeline can be retried' do
expect(page).to have_link('Retry') expect(page).to have_selector('.js-pipelines-retry-button')
expect(page).to have_selector('.ci-failed') expect(page).to have_selector('.ci-failed')
end end
context 'when retrying' do context 'when retrying' do
before { click_link('Retry') } before do
find('.js-pipelines-retry-button').click
wait_for_vue_resource
end
it 'shows running pipeline that is not retryable' do it 'shows running pipeline that is not retryable' do
expect(page).not_to have_link('Retry') expect(page).not_to have_selector('.js-pipelines-retry-button')
expect(page).to have_selector('.ci-running') expect(page).to have_selector('.ci-running')
end end
end end
...@@ -176,17 +182,17 @@ describe 'Pipelines', :feature, :js do ...@@ -176,17 +182,17 @@ describe 'Pipelines', :feature, :js do
it 'has link to the manual action' do it 'has link to the manual action' do
find('.js-pipeline-dropdown-manual-actions').click find('.js-pipeline-dropdown-manual-actions').click
expect(page).to have_link('manual build') expect(page).to have_button('manual build')
end end
context 'when manual action was played' do context 'when manual action was played' do
before do before do
find('.js-pipeline-dropdown-manual-actions').click find('.js-pipeline-dropdown-manual-actions').click
click_link('manual build') click_button('manual build')
end end
it 'enqueues manual action job' do it 'enqueues manual action job' do
expect(manual.reload).to be_pending expect(page).to have_selector('.js-pipeline-dropdown-manual-actions:disabled')
end end
end end
end end
...@@ -203,7 +209,7 @@ describe 'Pipelines', :feature, :js do ...@@ -203,7 +209,7 @@ describe 'Pipelines', :feature, :js do
before { visit_project_pipelines } before { visit_project_pipelines }
it 'is cancelable' do it 'is cancelable' do
expect(page).to have_link('Cancel') expect(page).to have_selector('.js-pipelines-cancel-button')
end end
it 'has pipeline running' do it 'has pipeline running' do
...@@ -211,10 +217,10 @@ describe 'Pipelines', :feature, :js do ...@@ -211,10 +217,10 @@ describe 'Pipelines', :feature, :js do
end end
context 'when canceling' do context 'when canceling' do
before { click_link('Cancel') } before { find('.js-pipelines-cancel-button').trigger('click') }
it 'indicates that pipeline was canceled' do it 'indicates that pipeline was canceled' do
expect(page).not_to have_link('Cancel') expect(page).not_to have_selector('.js-pipelines-cancel-button')
expect(page).to have_selector('.ci-canceled') expect(page).to have_selector('.ci-canceled')
end end
end end
...@@ -233,7 +239,7 @@ describe 'Pipelines', :feature, :js do ...@@ -233,7 +239,7 @@ describe 'Pipelines', :feature, :js do
end end
it 'is not retryable' do it 'is not retryable' do
expect(page).not_to have_link('Retry') expect(page).not_to have_selector('.js-pipelines-retry-button')
end end
it 'has failed pipeline' do it 'has failed pipeline' do
......
/* eslint-disable no-unused-vars */ export default {
const pipeline = {
id: 73, id: 73,
user: { user: {
name: 'Administrator', name: 'Administrator',
...@@ -88,5 +87,3 @@ const pipeline = { ...@@ -88,5 +87,3 @@ const pipeline = {
created_at: '2017-01-16T17:13:59.800Z', created_at: '2017-01-16T17:13:59.800Z',
updated_at: '2017-01-25T00:00:17.132Z', updated_at: '2017-01-25T00:00:17.132Z',
}; };
module.exports = pipeline;
/* global pipeline, Vue */ import Vue from 'vue';
import PipelinesTable from '~/commit/pipelines/pipelines_table';
require('~/flash'); import pipeline from './mock_data';
require('~/commit/pipelines/pipelines_store');
require('~/commit/pipelines/pipelines_service');
require('~/commit/pipelines/pipelines_table');
require('~/vue_shared/vue_resource_interceptor');
const pipeline = require('./mock_data');
describe('Pipelines table in Commits and Merge requests', () => { describe('Pipelines table in Commits and Merge requests', () => {
preloadFixtures('static/pipelines_table.html.raw'); preloadFixtures('static/pipelines_table.html.raw');
...@@ -33,7 +28,7 @@ describe('Pipelines table in Commits and Merge requests', () => { ...@@ -33,7 +28,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
}); });
it('should render the empty state', (done) => { it('should render the empty state', (done) => {
const component = new gl.commits.pipelines.PipelinesTableView({ const component = new PipelinesTable({
el: document.querySelector('#commit-pipeline-table-view'), el: document.querySelector('#commit-pipeline-table-view'),
}); });
...@@ -62,7 +57,7 @@ describe('Pipelines table in Commits and Merge requests', () => { ...@@ -62,7 +57,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
}); });
it('should render a table with the received pipelines', (done) => { it('should render a table with the received pipelines', (done) => {
const component = new gl.commits.pipelines.PipelinesTableView({ const component = new PipelinesTable({
el: document.querySelector('#commit-pipeline-table-view'), el: document.querySelector('#commit-pipeline-table-view'),
}); });
...@@ -92,7 +87,7 @@ describe('Pipelines table in Commits and Merge requests', () => { ...@@ -92,7 +87,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
}); });
it('should render empty state', (done) => { it('should render empty state', (done) => {
const component = new gl.commits.pipelines.PipelinesTableView({ const component = new PipelinesTable({
el: document.querySelector('#commit-pipeline-table-view'), el: document.querySelector('#commit-pipeline-table-view'),
}); });
......
const PipelinesStore = require('~/commit/pipelines/pipelines_store');
describe('Store', () => {
let store;
beforeEach(() => {
store = new PipelinesStore();
});
// unregister intervals and event handlers
afterEach(() => gl.VueRealtimeListener.reset());
it('should start with a blank state', () => {
expect(store.state.pipelines.length).toBe(0);
});
it('should store an array of pipelines', () => {
const pipelines = [
{
id: '1',
name: 'pipeline',
},
{
id: '2',
name: 'pipeline_2',
},
];
store.storePipelines(pipelines);
expect(store.state.pipelines.length).toBe(pipelines.length);
});
});
import Vue from 'vue';
import asyncButtonComp from '~/vue_pipelines_index/components/async_button';
describe('Pipelines Async Button', () => {
let component;
let spy;
let AsyncButtonComponent;
beforeEach(() => {
AsyncButtonComponent = Vue.extend(asyncButtonComp);
spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
component = new AsyncButtonComponent({
propsData: {
endpoint: '/foo',
title: 'Foo',
icon: 'fa fa-foo',
cssClass: 'bar',
service: {
postAction: spy,
},
},
}).$mount();
});
it('should render a button', () => {
expect(component.$el.tagName).toEqual('BUTTON');
});
it('should render the provided icon', () => {
expect(component.$el.querySelector('i').getAttribute('class')).toContain('fa fa-foo');
});
it('should render the provided title', () => {
expect(component.$el.getAttribute('title')).toContain('Foo');
expect(component.$el.getAttribute('aria-label')).toContain('Foo');
});
it('should render the provided cssClass', () => {
expect(component.$el.getAttribute('class')).toContain('bar');
});
it('should call the service when it is clicked with the provided endpoint', () => {
component.$el.click();
expect(spy).toHaveBeenCalledWith('/foo');
});
it('should hide loading if request fails', () => {
spy = jasmine.createSpy('spy').and.returnValue(Promise.reject());
component = new AsyncButtonComponent({
propsData: {
endpoint: '/foo',
title: 'Foo',
icon: 'fa fa-foo',
cssClass: 'bar',
dataAttributes: {
'data-foo': 'foo',
},
service: {
postAction: spy,
},
},
}).$mount();
component.$el.click();
expect(component.$el.querySelector('.fa-spinner')).toBe(null);
});
describe('With confirm dialog', () => {
it('should call the service when confimation is positive', () => {
spyOn(window, 'confirm').and.returnValue(true);
spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
component = new AsyncButtonComponent({
propsData: {
endpoint: '/foo',
title: 'Foo',
icon: 'fa fa-foo',
cssClass: 'bar',
service: {
postAction: spy,
},
confirmActionMessage: 'bar',
},
}).$mount();
component.$el.click();
expect(spy).toHaveBeenCalledWith('/foo');
});
});
});
import Vue from 'vue';
import pipelineUrlComp from '~/vue_pipelines_index/components/pipeline_url';
describe('Pipeline Url Component', () => {
let PipelineUrlComponent;
beforeEach(() => {
PipelineUrlComponent = Vue.extend(pipelineUrlComp);
});
it('should render a table cell', () => {
const component = new PipelineUrlComponent({
propsData: {
pipeline: {
id: 1,
path: 'foo',
flags: {},
},
},
}).$mount();
expect(component.$el.tagName).toEqual('TD');
});
it('should render a link the provided path and id', () => {
const component = new PipelineUrlComponent({
propsData: {
pipeline: {
id: 1,
path: 'foo',
flags: {},
},
},
}).$mount();
expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual('foo');
expect(component.$el.querySelector('.js-pipeline-url-link span').textContent).toEqual('#1');
});
it('should render user information when a user is provided', () => {
const mockData = {
pipeline: {
id: 1,
path: 'foo',
flags: {},
user: {
web_url: '/',
name: 'foo',
avatar_url: '/',
},
},
};
const component = new PipelineUrlComponent({
propsData: mockData,
}).$mount();
const image = component.$el.querySelector('.js-pipeline-url-user img');
expect(
component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'),
).toEqual(mockData.pipeline.user.web_url);
expect(image.getAttribute('title')).toEqual(mockData.pipeline.user.name);
expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url);
});
it('should render "API" when no user is provided', () => {
const component = new PipelineUrlComponent({
propsData: {
pipeline: {
id: 1,
path: 'foo',
flags: {},
},
},
}).$mount();
expect(component.$el.querySelector('.js-pipeline-url-api').textContent).toContain('API');
});
it('should render latest, yaml invalid and stuck flags when provided', () => {
const component = new PipelineUrlComponent({
propsData: {
pipeline: {
id: 1,
path: 'foo',
flags: {
latest: true,
yaml_errors: true,
stuck: true,
},
},
},
}).$mount();
expect(component.$el.querySelector('.js-pipeline-url-lastest').textContent).toContain('latest');
expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain('yaml invalid');
expect(component.$el.querySelector('.js-pipeline-url-stuck').textContent).toContain('stuck');
});
});
import Vue from 'vue';
import pipelinesActionsComp from '~/vue_pipelines_index/components/pipelines_actions';
describe('Pipelines Actions dropdown', () => {
let component;
let spy;
let actions;
let ActionsComponent;
beforeEach(() => {
ActionsComponent = Vue.extend(pipelinesActionsComp);
actions = [
{
name: 'stop_review',
path: '/root/review-app/builds/1893/play',
},
];
spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
component = new ActionsComponent({
propsData: {
actions,
service: {
postAction: spy,
},
},
}).$mount();
});
it('should render a dropdown with the provided actions', () => {
expect(
component.$el.querySelectorAll('.dropdown-menu li').length,
).toEqual(actions.length);
});
it('should call the service when an action is clicked', () => {
component.$el.querySelector('.js-pipeline-dropdown-manual-actions').click();
component.$el.querySelector('.js-pipeline-action-link').click();
expect(spy).toHaveBeenCalledWith(actions[0].path);
});
it('should hide loading if request fails', () => {
spy = jasmine.createSpy('spy').and.returnValue(Promise.reject());
component = new ActionsComponent({
propsData: {
actions,
service: {
postAction: spy,
},
},
}).$mount();
component.$el.querySelector('.js-pipeline-dropdown-manual-actions').click();
component.$el.querySelector('.js-pipeline-action-link').click();
expect(component.$el.querySelector('.fa-spinner')).toEqual(null);
});
});
import Vue from 'vue';
import artifactsComp from '~/vue_pipelines_index/components/pipelines_artifacts';
describe('Pipelines Artifacts dropdown', () => {
let component;
let artifacts;
beforeEach(() => {
const ArtifactsComponent = Vue.extend(artifactsComp);
artifacts = [
{
name: 'artifact',
path: '/download/path',
},
];
component = new ArtifactsComponent({
propsData: {
artifacts,
},
}).$mount();
});
it('should render a dropdown with the provided artifacts', () => {
expect(
component.$el.querySelectorAll('.dropdown-menu li').length,
).toEqual(artifacts.length);
});
it('should render a link with the provided path', () => {
expect(
component.$el.querySelector('.dropdown-menu li a').getAttribute('href'),
).toEqual(artifacts[0].path);
expect(
component.$el.querySelector('.dropdown-menu li a span').textContent,
).toContain(artifacts[0].name);
});
});
import PipelineStore from '~/vue_pipelines_index/stores/pipelines_store';
describe('Pipelines Store', () => {
let store;
beforeEach(() => {
store = new PipelineStore();
});
it('should be initialized with an empty state', () => {
expect(store.state.pipelines).toEqual([]);
expect(store.state.count).toEqual({});
expect(store.state.pageInfo).toEqual({});
});
describe('storePipelines', () => {
it('should use the default parameter if none is provided', () => {
store.storePipelines();
expect(store.state.pipelines).toEqual([]);
});
it('should store the provided array', () => {
const array = [{ id: 1, status: 'running' }, { id: 2, status: 'success' }];
store.storePipelines(array);
expect(store.state.pipelines).toEqual(array);
});
});
describe('storeCount', () => {
it('should use the default parameter if none is provided', () => {
store.storeCount();
expect(store.state.count).toEqual({});
});
it('should store the provided count', () => {
const count = { all: 20, finished: 10 };
store.storeCount(count);
expect(store.state.count).toEqual(count);
});
});
describe('storePagination', () => {
it('should use the default parameter if none is provided', () => {
store.storePagination();
expect(store.state.pageInfo).toEqual({});
});
it('should store pagination information normalized and parsed', () => {
const pagination = {
'X-nExt-pAge': '2',
'X-page': '1',
'X-Per-Page': '1',
'X-Prev-Page': '2',
'X-TOTAL': '37',
'X-Total-Pages': '2',
};
const expectedResult = {
perPage: 1,
page: 1,
total: 37,
totalPages: 2,
nextPage: 2,
previousPage: 2,
};
store.storePagination(pagination);
expect(store.state.pageInfo).toEqual(expectedResult);
});
});
});
require('~/vue_shared/components/commit'); import Vue from 'vue';
import commitComp from '~/vue_shared/components/commit';
describe('Commit component', () => { describe('Commit component', () => {
let props; let props;
let component; let component;
let CommitComponent;
beforeEach(() => {
CommitComponent = Vue.extend(commitComp);
});
it('should render a code-fork icon if it does not represent a tag', () => { it('should render a code-fork icon if it does not represent a tag', () => {
setFixtures('<div class="test-commit-container"></div>'); component = new CommitComponent({
component = new window.gl.CommitComponent({
el: document.querySelector('.test-commit-container'),
propsData: { propsData: {
tag: false, tag: false,
commitRef: { commitRef: {
...@@ -23,15 +27,13 @@ describe('Commit component', () => { ...@@ -23,15 +27,13 @@ describe('Commit component', () => {
username: 'jschatz1', username: 'jschatz1',
}, },
}, },
}); }).$mount();
expect(component.$el.querySelector('.icon-container i').classList).toContain('fa-code-fork'); expect(component.$el.querySelector('.icon-container i').classList).toContain('fa-code-fork');
}); });
describe('Given all the props', () => { describe('Given all the props', () => {
beforeEach(() => { beforeEach(() => {
setFixtures('<div class="test-commit-container"></div>');
props = { props = {
tag: true, tag: true,
commitRef: { commitRef: {
...@@ -49,10 +51,9 @@ describe('Commit component', () => { ...@@ -49,10 +51,9 @@ describe('Commit component', () => {
commitIconSvg: '<svg></svg>', commitIconSvg: '<svg></svg>',
}; };
component = new window.gl.CommitComponent({ component = new CommitComponent({
el: document.querySelector('.test-commit-container'),
propsData: props, propsData: props,
}); }).$mount();
}); });
it('should render a tag icon if it represents a tag', () => { it('should render a tag icon if it represents a tag', () => {
...@@ -105,7 +106,6 @@ describe('Commit component', () => { ...@@ -105,7 +106,6 @@ describe('Commit component', () => {
describe('When commit title is not provided', () => { describe('When commit title is not provided', () => {
it('should render default message', () => { it('should render default message', () => {
setFixtures('<div class="test-commit-container"></div>');
props = { props = {
tag: false, tag: false,
commitRef: { commitRef: {
...@@ -118,10 +118,9 @@ describe('Commit component', () => { ...@@ -118,10 +118,9 @@ describe('Commit component', () => {
author: {}, author: {},
}; };
component = new window.gl.CommitComponent({ component = new CommitComponent({
el: document.querySelector('.test-commit-container'),
propsData: props, propsData: props,
}); }).$mount();
expect( expect(
component.$el.querySelector('.commit-title span').textContent, component.$el.querySelector('.commit-title span').textContent,
......
require('~/vue_shared/components/pipelines_table_row'); import Vue from 'vue';
const pipeline = require('../../commit/pipelines/mock_data'); import tableRowComp from '~/vue_shared/components/pipelines_table_row';
import pipeline from '../../commit/pipelines/mock_data';
describe('Pipelines Table Row', () => { describe('Pipelines Table Row', () => {
let component; let component;
preloadFixtures('static/environments/element.html.raw');
beforeEach(() => { beforeEach(() => {
loadFixtures('static/environments/element.html.raw'); const PipelinesTableRowComponent = Vue.extend(tableRowComp);
component = new gl.pipelines.PipelinesTableRowComponent({ component = new PipelinesTableRowComponent({
el: document.querySelector('.test-dom-element'), el: document.querySelector('.test-dom-element'),
propsData: { propsData: {
pipeline, pipeline,
svgs: {}, service: {},
}, },
}); }).$mount();
}); });
it('should render a table row', () => { it('should render a table row', () => {
......
require('~/vue_shared/components/pipelines_table'); import Vue from 'vue';
require('~/lib/utils/datetime_utility'); import pipelinesTableComp from '~/vue_shared/components/pipelines_table';
const pipeline = require('../../commit/pipelines/mock_data'); import '~/lib/utils/datetime_utility';
import pipeline from '../../commit/pipelines/mock_data';
describe('Pipelines Table', () => { describe('Pipelines Table', () => {
preloadFixtures('static/environments/element.html.raw'); let PipelinesTableComponent;
beforeEach(() => { beforeEach(() => {
loadFixtures('static/environments/element.html.raw'); PipelinesTableComponent = Vue.extend(pipelinesTableComp);
}); });
describe('table', () => { describe('table', () => {
let component; let component;
beforeEach(() => { beforeEach(() => {
component = new gl.pipelines.PipelinesTableComponent({ component = new PipelinesTableComponent({
el: document.querySelector('.test-dom-element'),
propsData: { propsData: {
pipelines: [], pipelines: [],
svgs: {}, service: {},
}, },
}); }).$mount();
}); });
it('should render a table', () => { it('should render a table', () => {
...@@ -37,26 +37,25 @@ describe('Pipelines Table', () => { ...@@ -37,26 +37,25 @@ describe('Pipelines Table', () => {
describe('without data', () => { describe('without data', () => {
it('should render an empty table', () => { it('should render an empty table', () => {
const component = new gl.pipelines.PipelinesTableComponent({ const component = new PipelinesTableComponent({
el: document.querySelector('.test-dom-element'),
propsData: { propsData: {
pipelines: [], pipelines: [],
svgs: {}, service: {},
}, },
}); }).$mount();
expect(component.$el.querySelectorAll('tbody tr').length).toEqual(0); expect(component.$el.querySelectorAll('tbody tr').length).toEqual(0);
}); });
}); });
describe('with data', () => { describe('with data', () => {
it('should render rows', () => { it('should render rows', () => {
const component = new gl.pipelines.PipelinesTableComponent({ const component = new PipelinesTableComponent({
el: document.querySelector('.test-dom-element'), el: document.querySelector('.test-dom-element'),
propsData: { propsData: {
pipelines: [pipeline], pipelines: [pipeline],
svgs: {}, service: {},
}, },
}); }).$mount();
expect(component.$el.querySelectorAll('tbody tr').length).toEqual(1); expect(component.$el.querySelectorAll('tbody tr').length).toEqual(1);
}); });
......
require('~/lib/utils/common_utils'); import Vue from 'vue';
require('~/vue_shared/components/table_pagination'); import paginationComp from '~/vue_shared/components/table_pagination';
import '~/lib/utils/common_utils';
describe('Pagination component', () => { describe('Pagination component', () => {
let component; let component;
let PaginationComponent;
const changeChanges = { const changeChanges = {
one: '', one: '',
...@@ -12,11 +14,12 @@ describe('Pagination component', () => { ...@@ -12,11 +14,12 @@ describe('Pagination component', () => {
changeChanges.one = one; changeChanges.one = one;
}; };
it('should render and start at page 1', () => { beforeEach(() => {
setFixtures('<div class="test-pagination-container"></div>'); PaginationComponent = Vue.extend(paginationComp);
});
component = new window.gl.VueGlPagination({ it('should render and start at page 1', () => {
el: document.querySelector('.test-pagination-container'), component = new PaginationComponent({
propsData: { propsData: {
pageInfo: { pageInfo: {
totalPages: 10, totalPages: 10,
...@@ -25,7 +28,7 @@ describe('Pagination component', () => { ...@@ -25,7 +28,7 @@ describe('Pagination component', () => {
}, },
change, change,
}, },
}); }).$mount();
expect(component.$el.classList).toContain('gl-pagination'); expect(component.$el.classList).toContain('gl-pagination');
...@@ -35,10 +38,7 @@ describe('Pagination component', () => { ...@@ -35,10 +38,7 @@ describe('Pagination component', () => {
}); });
it('should go to the previous page', () => { it('should go to the previous page', () => {
setFixtures('<div class="test-pagination-container"></div>'); component = new PaginationComponent({
component = new window.gl.VueGlPagination({
el: document.querySelector('.test-pagination-container'),
propsData: { propsData: {
pageInfo: { pageInfo: {
totalPages: 10, totalPages: 10,
...@@ -47,7 +47,7 @@ describe('Pagination component', () => { ...@@ -47,7 +47,7 @@ describe('Pagination component', () => {
}, },
change, change,
}, },
}); }).$mount();
component.changePage({ target: { innerText: 'Prev' } }); component.changePage({ target: { innerText: 'Prev' } });
...@@ -55,10 +55,7 @@ describe('Pagination component', () => { ...@@ -55,10 +55,7 @@ describe('Pagination component', () => {
}); });
it('should go to the next page', () => { it('should go to the next page', () => {
setFixtures('<div class="test-pagination-container"></div>'); component = new PaginationComponent({
component = new window.gl.VueGlPagination({
el: document.querySelector('.test-pagination-container'),
propsData: { propsData: {
pageInfo: { pageInfo: {
totalPages: 10, totalPages: 10,
...@@ -67,7 +64,7 @@ describe('Pagination component', () => { ...@@ -67,7 +64,7 @@ describe('Pagination component', () => {
}, },
change, change,
}, },
}); }).$mount();
component.changePage({ target: { innerText: 'Next' } }); component.changePage({ target: { innerText: 'Next' } });
...@@ -75,10 +72,7 @@ describe('Pagination component', () => { ...@@ -75,10 +72,7 @@ describe('Pagination component', () => {
}); });
it('should go to the last page', () => { it('should go to the last page', () => {
setFixtures('<div class="test-pagination-container"></div>'); component = new PaginationComponent({
component = new window.gl.VueGlPagination({
el: document.querySelector('.test-pagination-container'),
propsData: { propsData: {
pageInfo: { pageInfo: {
totalPages: 10, totalPages: 10,
...@@ -87,7 +81,7 @@ describe('Pagination component', () => { ...@@ -87,7 +81,7 @@ describe('Pagination component', () => {
}, },
change, change,
}, },
}); }).$mount();
component.changePage({ target: { innerText: 'Last >>' } }); component.changePage({ target: { innerText: 'Last >>' } });
...@@ -95,10 +89,7 @@ describe('Pagination component', () => { ...@@ -95,10 +89,7 @@ describe('Pagination component', () => {
}); });
it('should go to the first page', () => { it('should go to the first page', () => {
setFixtures('<div class="test-pagination-container"></div>'); component = new PaginationComponent({
component = new window.gl.VueGlPagination({
el: document.querySelector('.test-pagination-container'),
propsData: { propsData: {
pageInfo: { pageInfo: {
totalPages: 10, totalPages: 10,
...@@ -107,7 +98,7 @@ describe('Pagination component', () => { ...@@ -107,7 +98,7 @@ describe('Pagination component', () => {
}, },
change, change,
}, },
}); }).$mount();
component.changePage({ target: { innerText: '<< First' } }); component.changePage({ target: { innerText: '<< First' } });
...@@ -115,10 +106,7 @@ describe('Pagination component', () => { ...@@ -115,10 +106,7 @@ describe('Pagination component', () => {
}); });
it('should do nothing', () => { it('should do nothing', () => {
setFixtures('<div class="test-pagination-container"></div>'); component = new PaginationComponent({
component = new window.gl.VueGlPagination({
el: document.querySelector('.test-pagination-container'),
propsData: { propsData: {
pageInfo: { pageInfo: {
totalPages: 10, totalPages: 10,
...@@ -127,7 +115,7 @@ describe('Pagination component', () => { ...@@ -127,7 +115,7 @@ describe('Pagination component', () => {
}, },
change, change,
}, },
}); }).$mount();
component.changePage({ target: { innerText: '...' } }); component.changePage({ target: { innerText: '...' } });
......
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