Commit 425ddcc5 authored by Tim Zallmann's avatar Tim Zallmann

Merge branch 'scheduled-manual-jobs-environment-play-buttons' into 'master'

Add the Play button for delayed jobs in environment page

Closes #52129

See merge request gitlab-org/gitlab-ce!22106
parents 2ae6c47d 13c091c4
<script>
import { s__, sprintf } from '~/locale';
import { formatTime } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
import tooltip from '../../vue_shared/directives/tooltip';
......@@ -28,10 +30,24 @@ export default {
},
},
methods: {
onClickAction(endpoint) {
onClickAction(action) {
if (action.scheduledAt) {
const confirmationMessage = sprintf(
s__(
"DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.",
),
{ jobName: action.name },
);
// https://gitlab.com/gitlab-org/gitlab-ce/issues/52156
// eslint-disable-next-line no-alert
if (!window.confirm(confirmationMessage)) {
return;
}
}
this.isLoading = true;
eventHub.$emit('postAction', { endpoint });
eventHub.$emit('postAction', { endpoint: action.playPath });
},
isActionDisabled(action) {
......@@ -41,6 +57,11 @@ export default {
return !action.playable;
},
remainingTime(action) {
const remainingMilliseconds = new Date(action.scheduledAt).getTime() - Date.now();
return formatTime(Math.max(0, remainingMilliseconds));
},
},
};
</script>
......@@ -54,7 +75,7 @@ export default {
:aria-label="title"
:disabled="isLoading"
type="button"
class="dropdown btn btn-default dropdown-new js-dropdown-play-icon-container"
class="dropdown btn btn-default dropdown-new js-environment-actions-dropdown"
data-container="body"
data-toggle="dropdown"
>
......@@ -75,12 +96,19 @@ export default {
:class="{ disabled: isActionDisabled(action) }"
:disabled="isActionDisabled(action)"
type="button"
class="js-manual-action-link no-btn btn"
@click="onClickAction(action.play_path)"
class="js-manual-action-link no-btn btn d-flex align-items-center"
@click="onClickAction(action)"
>
<span>
<span class="flex-fill">
{{ action.name }}
</span>
<span
v-if="action.scheduledAt"
class="text-secondary"
>
<icon name="clock" />
{{ remainingTime(action) }}
</span>
</button>
</li>
</ul>
......
......@@ -13,6 +13,7 @@ import TerminalButtonComponent from './environment_terminal_button.vue';
import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
/**
* Environment Item Component
......@@ -73,21 +74,6 @@ export default {
return false;
},
/**
* Verifies is the given environment has manual actions.
* Used to verify if we should render them or nor.
*
* @returns {Boolean|Undefined}
*/
hasManualActions() {
return (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.manual_actions &&
this.model.last_deployment.manual_actions.length > 0
);
},
/**
* Checkes whether the environment is protected.
* (`is_protected` currently only set in EE)
......@@ -154,23 +140,20 @@ export default {
return '';
},
/**
* Returns the manual actions with the name parsed.
*
* @returns {Array.<Object>|Undefined}
*/
manualActions() {
if (this.hasManualActions) {
return this.model.last_deployment.manual_actions.map(action => {
const parsedAction = {
name: humanize(action.name),
play_path: action.play_path,
playable: action.playable,
};
return parsedAction;
});
}
actions() {
if (!this.model || !this.model.last_deployment || !this.canCreateDeployment) {
return [];
}
const { manualActions, scheduledActions } = convertObjectPropsToCamelCase(
this.model.last_deployment,
{ deep: true },
);
const combinedActions = (manualActions || []).concat(scheduledActions || []);
return combinedActions.map(action => ({
...action,
name: humanize(action.name),
}));
},
/**
......@@ -443,7 +426,7 @@ export default {
displayEnvironmentActions() {
return (
this.hasManualActions ||
this.actions.length > 0 ||
this.externalURL ||
this.monitoringUrl ||
this.canStopEnvironment ||
......@@ -619,8 +602,8 @@ export default {
/>
<actions-component
v-if="hasManualActions && canCreateDeployment"
:actions="manualActions"
v-if="actions.length > 0"
:actions="actions"
/>
<terminal-button-component
......
......@@ -29,7 +29,7 @@ export default {
if (action.scheduled_at) {
const confirmationMessage = sprintf(
s__(
"DelayedJobs|Are you sure you want to run %{jobName} immediately? This job will run automatically after it's timer finishes.",
"DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.",
),
{ jobName: action.name },
);
......
......@@ -44,11 +44,6 @@
margin: 0;
}
.icon-play {
height: 13px;
width: 12px;
}
.external-url,
.dropdown-new {
color: $gl-text-color-secondary;
......@@ -366,7 +361,7 @@
}
.arrow-shadow {
content: "";
content: '';
position: absolute;
width: 7px;
height: 7px;
......
......@@ -245,10 +245,14 @@ module Ci
.fabricate!
end
def other_actions
def other_manual_actions
pipeline.manual_actions.where.not(name: name)
end
def other_scheduled_actions
pipeline.scheduled_actions.where.not(name: name)
end
def pages_generator?
Gitlab.config.pages.enabled &&
self.name == 'pages'
......
......@@ -55,7 +55,11 @@ class Deployment < ActiveRecord::Base
end
def manual_actions
@manual_actions ||= deployable.try(:other_actions)
@manual_actions ||= deployable.try(:other_manual_actions)
end
def scheduled_actions
@scheduled_actions ||= deployable.try(:other_scheduled_actions)
end
def includes_commit?(commit)
......
......@@ -25,4 +25,5 @@ class DeploymentEntity < Grape::Entity
expose :commit, using: CommitEntity
expose :deployable, using: JobEntity
expose :manual_actions, using: JobEntity
expose :scheduled_actions, using: JobEntity
end
---
title: Add the Play button for delayed jobs in environment page
merge_request: 22106
author:
type: added
......@@ -2172,7 +2172,7 @@ msgstr ""
msgid "Define a custom pattern with cron syntax"
msgstr ""
msgid "DelayedJobs|Are you sure you want to run %{jobName} immediately? This job will run automatically after it's timer finishes."
msgid "DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes."
msgstr ""
msgid "DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after it's timer finishes."
......
......@@ -162,7 +162,7 @@ describe 'Environments page', :js do
end
it 'shows a play button' do
find('.js-dropdown-play-icon-container').click
find('.js-environment-actions-dropdown').click
expect(page).to have_content(action.name.humanize)
end
......@@ -170,7 +170,7 @@ describe 'Environments page', :js do
it 'allows to play a manual action', :js do
expect(action).to be_manual
find('.js-dropdown-play-icon-container').click
find('.js-environment-actions-dropdown').click
expect(page).to have_content(action.name.humanize)
expect { find('.js-manual-action-link').click }
......@@ -260,6 +260,69 @@ describe 'Environments page', :js do
end
end
end
context 'when there is a delayed job' do
let!(:pipeline) { create(:ci_pipeline, project: project) }
let!(:build) { create(:ci_build, pipeline: pipeline) }
let!(:delayed_job) do
create(:ci_build, :scheduled,
pipeline: pipeline,
name: 'delayed job',
stage: 'test',
commands: 'test')
end
let!(:deployment) do
create(:deployment,
environment: environment,
deployable: build,
sha: project.commit.id)
end
before do
visit_environments(project)
end
it 'has a dropdown for actionable jobs' do
expect(page).to have_selector('.dropdown-new.btn.btn-default .ic-play')
end
it "has link to the delayed job's action" do
find('.js-environment-actions-dropdown').click
expect(page).to have_button('Delayed job')
expect(page).to have_content(/\d{2}:\d{2}:\d{2}/)
end
context 'when delayed job is expired already' do
let!(:delayed_job) do
create(:ci_build, :expired_scheduled,
pipeline: pipeline,
name: 'delayed job',
stage: 'test',
commands: 'test')
end
it "shows 00:00:00 as the remaining time" do
find('.js-environment-actions-dropdown').click
expect(page).to have_content("00:00:00")
end
end
context 'when user played a delayed job immediately' do
before do
find('.js-environment-actions-dropdown').click
page.accept_confirm { click_button('Delayed job') }
wait_for_requests
end
it 'enqueues the delayed job', :js do
expect(delayed_job.reload).to be_pending
end
end
end
end
end
......
......@@ -48,6 +48,10 @@
"manual_actions": {
"type": "array",
"items": { "$ref": "job/job.json" }
},
"scheduled_actions": {
"type": "array",
"items": { "$ref": "job/job.json" }
}
},
"additionalProperties": false
......
import Vue from 'vue';
import actionsComp from '~/environments/components/environment_actions.vue';
import eventHub from '~/environments/event_hub';
import EnvironmentActions from '~/environments/components/environment_actions.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { TEST_HOST } from 'spec/test_constants';
describe('Actions Component', () => {
let ActionsComponent;
let actionsMock;
let component;
describe('EnvironmentActions Component', () => {
const Component = Vue.extend(EnvironmentActions);
let vm;
beforeEach(() => {
ActionsComponent = Vue.extend(actionsComp);
afterEach(() => {
vm.$destroy();
});
actionsMock = [
describe('manual actions', () => {
const actions = [
{
name: 'bar',
play_path: 'https://gitlab.com/play',
......@@ -25,43 +29,89 @@ describe('Actions Component', () => {
},
];
component = new ActionsComponent({
propsData: {
actions: actionsMock,
},
}).$mount();
});
describe('computed', () => {
it('title', () => {
expect(component.title).toEqual('Deploy to...');
});
beforeEach(() => {
vm = mountComponent(Component, { actions });
});
it('should render a dropdown button with icon and title attribute', () => {
expect(component.$el.querySelector('.fa-caret-down')).toBeDefined();
expect(
component.$el.querySelector('.dropdown-new').getAttribute('data-original-title'),
).toEqual('Deploy to...');
expect(vm.$el.querySelector('.fa-caret-down')).toBeDefined();
expect(vm.$el.querySelector('.dropdown-new').getAttribute('data-original-title')).toEqual(
'Deploy to...',
);
expect(component.$el.querySelector('.dropdown-new').getAttribute('aria-label')).toEqual(
expect(vm.$el.querySelector('.dropdown-new').getAttribute('aria-label')).toEqual(
'Deploy to...',
);
});
it('should render a dropdown with the provided list of actions', () => {
expect(component.$el.querySelectorAll('.dropdown-menu li').length).toEqual(actionsMock.length);
expect(vm.$el.querySelectorAll('.dropdown-menu li').length).toEqual(actions.length);
});
it("should render a disabled action when it's not playable", () => {
expect(
component.$el.querySelector('.dropdown-menu li:last-child button').getAttribute('disabled'),
vm.$el.querySelector('.dropdown-menu li:last-child button').getAttribute('disabled'),
).toEqual('disabled');
expect(
component.$el
.querySelector('.dropdown-menu li:last-child button')
.classList.contains('disabled'),
vm.$el.querySelector('.dropdown-menu li:last-child button').classList.contains('disabled'),
).toEqual(true);
});
});
describe('scheduled jobs', () => {
const scheduledJobAction = {
name: 'scheduled action',
playPath: `${TEST_HOST}/scheduled/job/action`,
playable: true,
scheduledAt: '2063-04-05T00:42:00Z',
};
const expiredJobAction = {
name: 'expired action',
playPath: `${TEST_HOST}/expired/job/action`,
playable: true,
scheduledAt: '2018-10-05T08:23:00Z',
};
const findDropdownItem = action => {
const buttons = vm.$el.querySelectorAll('.dropdown-menu li button');
return Array.prototype.find.call(buttons, element =>
element.innerText.trim().startsWith(action.name),
);
};
beforeEach(() => {
spyOn(Date, 'now').and.callFake(() => new Date('2063-04-04T00:42:00Z').getTime());
vm = mountComponent(Component, { actions: [scheduledJobAction, expiredJobAction] });
});
it('emits postAction event after confirming', () => {
const emitSpy = jasmine.createSpy('emit');
eventHub.$on('postAction', emitSpy);
spyOn(window, 'confirm').and.callFake(() => true);
findDropdownItem(scheduledJobAction).click();
expect(window.confirm).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith({ endpoint: scheduledJobAction.playPath });
});
it('does not emit postAction event if confirmation is cancelled', () => {
const emitSpy = jasmine.createSpy('emit');
eventHub.$on('postAction', emitSpy);
spyOn(window, 'confirm').and.callFake(() => false);
findDropdownItem(scheduledJobAction).click();
expect(window.confirm).toHaveBeenCalled();
expect(emitSpy).not.toHaveBeenCalled();
});
it('displays the remaining time in the dropdown', () => {
expect(findDropdownItem(scheduledJobAction)).toContainText('24:00:00');
});
it('displays 00:00:00 for expired jobs in the dropdown', () => {
expect(findDropdownItem(expiredJobAction)).toContainText('00:00:00');
});
});
});
......@@ -1511,11 +1511,11 @@ describe Ci::Build do
end
end
describe '#other_actions' do
describe '#other_manual_actions' do
let(:build) { create(:ci_build, :manual, pipeline: pipeline) }
let!(:other_build) { create(:ci_build, :manual, pipeline: pipeline, name: 'other action') }
subject { build.other_actions }
subject { build.other_manual_actions }
before do
project.add_developer(user)
......@@ -1546,6 +1546,48 @@ describe Ci::Build do
end
end
describe '#other_scheduled_actions' do
let(:build) { create(:ci_build, :scheduled, pipeline: pipeline) }
subject { build.other_scheduled_actions }
before do
project.add_developer(user)
end
context "when other build's status is success" do
let!(:other_build) { create(:ci_build, :schedulable, :success, pipeline: pipeline, name: 'other action') }
it 'returns other actions' do
is_expected.to contain_exactly(other_build)
end
end
context "when other build's status is failed" do
let!(:other_build) { create(:ci_build, :schedulable, :failed, pipeline: pipeline, name: 'other action') }
it 'returns other actions' do
is_expected.to contain_exactly(other_build)
end
end
context "when other build's status is running" do
let!(:other_build) { create(:ci_build, :schedulable, :running, pipeline: pipeline, name: 'other action') }
it 'does not return other actions' do
is_expected.to be_empty
end
end
context "when other build's status is scheduled" do
let!(:other_build) { create(:ci_build, :scheduled, pipeline: pipeline, name: 'other action') }
it 'does not return other actions' do
is_expected.to contain_exactly(other_build)
end
end
end
describe '#persisted_environment' do
let!(:environment) do
create(:environment, project: project, name: "foo-#{project.default_branch}")
......
......@@ -16,6 +16,22 @@ describe Deployment do
it { is_expected.to validate_presence_of(:ref) }
it { is_expected.to validate_presence_of(:sha) }
describe '#scheduled_actions' do
subject { deployment.scheduled_actions }
let(:project) { create(:project, :repository) }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, :success, pipeline: pipeline) }
let(:deployment) { create(:deployment, deployable: build) }
it 'delegates to other_scheduled_actions' do
expect_any_instance_of(Ci::Build)
.to receive(:other_scheduled_actions)
subject
end
end
describe 'modules' do
it_behaves_like 'AtomicInternalId' do
let(:internal_id_attribute) { :iid }
......
......@@ -22,4 +22,26 @@ describe DeploymentEntity do
it 'exposes creation date' do
expect(subject).to include(:created_at)
end
describe 'scheduled_actions' do
let(:project) { create(:project, :repository) }
let(:pipeline) { create(:ci_pipeline, project: project, user: user) }
let(:build) { create(:ci_build, :success, pipeline: pipeline) }
let(:deployment) { create(:deployment, deployable: build) }
context 'when the same pipeline has a scheduled action' do
let(:other_build) { create(:ci_build, :schedulable, :success, pipeline: pipeline, name: 'other build') }
let!(:other_deployment) { create(:deployment, deployable: other_build) }
it 'returns other scheduled actions' do
expect(subject[:scheduled_actions][0][:name]).to eq 'other build'
end
end
context 'when the same pipeline does not have a scheduled action' do
it 'does not return other actions' do
expect(subject[:scheduled_actions]).to be_empty
end
end
end
end
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