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> <script>
import { s__, sprintf } from '~/locale';
import { formatTime } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
...@@ -28,10 +30,24 @@ export default { ...@@ -28,10 +30,24 @@ export default {
}, },
}, },
methods: { 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; this.isLoading = true;
eventHub.$emit('postAction', { endpoint }); eventHub.$emit('postAction', { endpoint: action.playPath });
}, },
isActionDisabled(action) { isActionDisabled(action) {
...@@ -41,6 +57,11 @@ export default { ...@@ -41,6 +57,11 @@ export default {
return !action.playable; return !action.playable;
}, },
remainingTime(action) {
const remainingMilliseconds = new Date(action.scheduledAt).getTime() - Date.now();
return formatTime(Math.max(0, remainingMilliseconds));
},
}, },
}; };
</script> </script>
...@@ -54,7 +75,7 @@ export default { ...@@ -54,7 +75,7 @@ export default {
:aria-label="title" :aria-label="title"
:disabled="isLoading" :disabled="isLoading"
type="button" 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-container="body"
data-toggle="dropdown" data-toggle="dropdown"
> >
...@@ -75,12 +96,19 @@ export default { ...@@ -75,12 +96,19 @@ export default {
:class="{ disabled: isActionDisabled(action) }" :class="{ disabled: isActionDisabled(action) }"
:disabled="isActionDisabled(action)" :disabled="isActionDisabled(action)"
type="button" type="button"
class="js-manual-action-link no-btn btn" class="js-manual-action-link no-btn btn d-flex align-items-center"
@click="onClickAction(action.play_path)" @click="onClickAction(action)"
> >
<span> <span class="flex-fill">
{{ action.name }} {{ action.name }}
</span> </span>
<span
v-if="action.scheduledAt"
class="text-secondary"
>
<icon name="clock" />
{{ remainingTime(action) }}
</span>
</button> </button>
</li> </li>
</ul> </ul>
......
...@@ -13,6 +13,7 @@ import TerminalButtonComponent from './environment_terminal_button.vue'; ...@@ -13,6 +13,7 @@ import TerminalButtonComponent from './environment_terminal_button.vue';
import MonitoringButtonComponent from './environment_monitoring.vue'; import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue'; import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
/** /**
* Environment Item Component * Environment Item Component
...@@ -73,21 +74,6 @@ export default { ...@@ -73,21 +74,6 @@ export default {
return false; 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. * Checkes whether the environment is protected.
* (`is_protected` currently only set in EE) * (`is_protected` currently only set in EE)
...@@ -154,23 +140,20 @@ export default { ...@@ -154,23 +140,20 @@ export default {
return ''; return '';
}, },
/** actions() {
* Returns the manual actions with the name parsed. if (!this.model || !this.model.last_deployment || !this.canCreateDeployment) {
* return [];
* @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;
});
} }
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 { ...@@ -443,7 +426,7 @@ export default {
displayEnvironmentActions() { displayEnvironmentActions() {
return ( return (
this.hasManualActions || this.actions.length > 0 ||
this.externalURL || this.externalURL ||
this.monitoringUrl || this.monitoringUrl ||
this.canStopEnvironment || this.canStopEnvironment ||
...@@ -619,8 +602,8 @@ export default { ...@@ -619,8 +602,8 @@ export default {
/> />
<actions-component <actions-component
v-if="hasManualActions && canCreateDeployment" v-if="actions.length > 0"
:actions="manualActions" :actions="actions"
/> />
<terminal-button-component <terminal-button-component
......
...@@ -29,7 +29,7 @@ export default { ...@@ -29,7 +29,7 @@ export default {
if (action.scheduled_at) { if (action.scheduled_at) {
const confirmationMessage = sprintf( const confirmationMessage = sprintf(
s__( 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 }, { jobName: action.name },
); );
......
...@@ -44,11 +44,6 @@ ...@@ -44,11 +44,6 @@
margin: 0; margin: 0;
} }
.icon-play {
height: 13px;
width: 12px;
}
.external-url, .external-url,
.dropdown-new { .dropdown-new {
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
...@@ -366,7 +361,7 @@ ...@@ -366,7 +361,7 @@
} }
.arrow-shadow { .arrow-shadow {
content: ""; content: '';
position: absolute; position: absolute;
width: 7px; width: 7px;
height: 7px; height: 7px;
......
...@@ -245,10 +245,14 @@ module Ci ...@@ -245,10 +245,14 @@ module Ci
.fabricate! .fabricate!
end end
def other_actions def other_manual_actions
pipeline.manual_actions.where.not(name: name) pipeline.manual_actions.where.not(name: name)
end end
def other_scheduled_actions
pipeline.scheduled_actions.where.not(name: name)
end
def pages_generator? def pages_generator?
Gitlab.config.pages.enabled && Gitlab.config.pages.enabled &&
self.name == 'pages' self.name == 'pages'
......
...@@ -55,7 +55,11 @@ class Deployment < ActiveRecord::Base ...@@ -55,7 +55,11 @@ class Deployment < ActiveRecord::Base
end end
def manual_actions 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 end
def includes_commit?(commit) def includes_commit?(commit)
......
...@@ -25,4 +25,5 @@ class DeploymentEntity < Grape::Entity ...@@ -25,4 +25,5 @@ class DeploymentEntity < Grape::Entity
expose :commit, using: CommitEntity expose :commit, using: CommitEntity
expose :deployable, using: JobEntity expose :deployable, using: JobEntity
expose :manual_actions, using: JobEntity expose :manual_actions, using: JobEntity
expose :scheduled_actions, using: JobEntity
end end
---
title: Add the Play button for delayed jobs in environment page
merge_request: 22106
author:
type: added
...@@ -2172,7 +2172,7 @@ msgstr "" ...@@ -2172,7 +2172,7 @@ msgstr ""
msgid "Define a custom pattern with cron syntax" msgid "Define a custom pattern with cron syntax"
msgstr "" 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 "" msgstr ""
msgid "DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after it's timer finishes." 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 ...@@ -162,7 +162,7 @@ describe 'Environments page', :js do
end end
it 'shows a play button' do 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) expect(page).to have_content(action.name.humanize)
end end
...@@ -170,7 +170,7 @@ describe 'Environments page', :js do ...@@ -170,7 +170,7 @@ describe 'Environments page', :js do
it 'allows to play a manual action', :js do it 'allows to play a manual action', :js do
expect(action).to be_manual 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(page).to have_content(action.name.humanize)
expect { find('.js-manual-action-link').click } expect { find('.js-manual-action-link').click }
...@@ -260,6 +260,69 @@ describe 'Environments page', :js do ...@@ -260,6 +260,69 @@ describe 'Environments page', :js do
end end
end 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
end end
......
...@@ -48,6 +48,10 @@ ...@@ -48,6 +48,10 @@
"manual_actions": { "manual_actions": {
"type": "array", "type": "array",
"items": { "$ref": "job/job.json" } "items": { "$ref": "job/job.json" }
},
"scheduled_actions": {
"type": "array",
"items": { "$ref": "job/job.json" }
} }
}, },
"additionalProperties": false "additionalProperties": false
......
import Vue from 'vue'; 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', () => { describe('EnvironmentActions Component', () => {
let ActionsComponent; const Component = Vue.extend(EnvironmentActions);
let actionsMock; let vm;
let component;
beforeEach(() => { afterEach(() => {
ActionsComponent = Vue.extend(actionsComp); vm.$destroy();
});
actionsMock = [ describe('manual actions', () => {
const actions = [
{ {
name: 'bar', name: 'bar',
play_path: 'https://gitlab.com/play', play_path: 'https://gitlab.com/play',
...@@ -25,43 +29,89 @@ describe('Actions Component', () => { ...@@ -25,43 +29,89 @@ describe('Actions Component', () => {
}, },
]; ];
component = new ActionsComponent({ beforeEach(() => {
propsData: { vm = mountComponent(Component, { actions });
actions: actionsMock, });
},
}).$mount(); it('should render a dropdown button with icon and title attribute', () => {
}); expect(vm.$el.querySelector('.fa-caret-down')).toBeDefined();
expect(vm.$el.querySelector('.dropdown-new').getAttribute('data-original-title')).toEqual(
'Deploy to...',
);
describe('computed', () => { expect(vm.$el.querySelector('.dropdown-new').getAttribute('aria-label')).toEqual(
it('title', () => { 'Deploy to...',
expect(component.title).toEqual('Deploy to...'); );
}); });
});
it('should render a dropdown button with icon and title attribute', () => { it('should render a dropdown with the provided list of actions', () => {
expect(component.$el.querySelector('.fa-caret-down')).toBeDefined(); expect(vm.$el.querySelectorAll('.dropdown-menu li').length).toEqual(actions.length);
expect( });
component.$el.querySelector('.dropdown-new').getAttribute('data-original-title'),
).toEqual('Deploy to...');
expect(component.$el.querySelector('.dropdown-new').getAttribute('aria-label')).toEqual( it("should render a disabled action when it's not playable", () => {
'Deploy to...', expect(
); vm.$el.querySelector('.dropdown-menu li:last-child button').getAttribute('disabled'),
}); ).toEqual('disabled');
it('should render a dropdown with the provided list of actions', () => { expect(
expect(component.$el.querySelectorAll('.dropdown-menu li').length).toEqual(actionsMock.length); vm.$el.querySelector('.dropdown-menu li:last-child button').classList.contains('disabled'),
).toEqual(true);
});
}); });
it("should render a disabled action when it's not playable", () => { describe('scheduled jobs', () => {
expect( const scheduledJobAction = {
component.$el.querySelector('.dropdown-menu li:last-child button').getAttribute('disabled'), name: 'scheduled action',
).toEqual('disabled'); 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( expect(window.confirm).toHaveBeenCalled();
component.$el expect(emitSpy).not.toHaveBeenCalled();
.querySelector('.dropdown-menu li:last-child button') });
.classList.contains('disabled'),
).toEqual(true); 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 ...@@ -1511,11 +1511,11 @@ describe Ci::Build do
end end
end end
describe '#other_actions' do describe '#other_manual_actions' do
let(:build) { create(:ci_build, :manual, pipeline: pipeline) } let(:build) { create(:ci_build, :manual, pipeline: pipeline) }
let!(:other_build) { create(:ci_build, :manual, pipeline: pipeline, name: 'other action') } let!(:other_build) { create(:ci_build, :manual, pipeline: pipeline, name: 'other action') }
subject { build.other_actions } subject { build.other_manual_actions }
before do before do
project.add_developer(user) project.add_developer(user)
...@@ -1546,6 +1546,48 @@ describe Ci::Build do ...@@ -1546,6 +1546,48 @@ describe Ci::Build do
end end
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 describe '#persisted_environment' do
let!(:environment) do let!(:environment) do
create(:environment, project: project, name: "foo-#{project.default_branch}") create(:environment, project: project, name: "foo-#{project.default_branch}")
......
...@@ -16,6 +16,22 @@ describe Deployment do ...@@ -16,6 +16,22 @@ describe Deployment do
it { is_expected.to validate_presence_of(:ref) } it { is_expected.to validate_presence_of(:ref) }
it { is_expected.to validate_presence_of(:sha) } 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 describe 'modules' do
it_behaves_like 'AtomicInternalId' do it_behaves_like 'AtomicInternalId' do
let(:internal_id_attribute) { :iid } let(:internal_id_attribute) { :iid }
......
...@@ -22,4 +22,26 @@ describe DeploymentEntity do ...@@ -22,4 +22,26 @@ describe DeploymentEntity do
it 'exposes creation date' do it 'exposes creation date' do
expect(subject).to include(:created_at) expect(subject).to include(:created_at)
end 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 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