Commit ef9ed6a2 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch 'delete-deploy-freeze' into 'master'

Add ability to Delete Freeze Periods

See merge request gitlab-org/gitlab!66331
parents 331a0a08 a28b5a4a
...@@ -870,6 +870,14 @@ const Api = { ...@@ -870,6 +870,14 @@ const Api = {
return axios.put(url, freezePeriod); return axios.put(url, freezePeriod);
}, },
deleteFreezePeriod(id, freezePeriodId) {
const url = Api.buildUrl(this.freezePeriodPath)
.replace(':id', encodeURIComponent(id))
.replace(':freeze_period_id', encodeURIComponent(freezePeriodId));
return axios.delete(url);
},
trackRedisCounterEvent(event) { trackRedisCounterEvent(event) {
if (!gon.features?.usageDataApi) { if (!gon.features?.usageDataApi) {
return null; return null;
......
<script> <script>
import { GlTable, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui'; import { GlTable, GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
...@@ -21,21 +21,42 @@ export default { ...@@ -21,21 +21,42 @@ export default {
key: 'edit', key: 'edit',
label: s__('DeployFreeze|Edit'), label: s__('DeployFreeze|Edit'),
}, },
{
key: 'delete',
label: s__('DeployFreeze|Delete'),
},
], ],
translations: { translations: {
addDeployFreeze: s__('DeployFreeze|Add deploy freeze'), addDeployFreeze: s__('DeployFreeze|Add deploy freeze'),
deleteDeployFreezeTitle: s__('DeployFreeze|Delete deploy freeze?'),
deleteDeployFreezeMessage: s__(
'DeployFreeze|Deploy freeze from %{start} to %{end} in %{timezone} will be removed. Are you sure?',
),
emptyStateText: s__( emptyStateText: s__(
'DeployFreeze|No deploy freezes exist for this project. To add one, select %{strongStart}Add deploy freeze%{strongEnd}', 'DeployFreeze|No deploy freezes exist for this project. To add one, select %{strongStart}Add deploy freeze%{strongEnd}',
), ),
}, },
modal: {
id: 'deleteFreezePeriodModal',
actionPrimary: {
text: s__('DeployFreeze|Delete freeze period'),
attributes: { variant: 'danger', 'data-testid': 'modal-confirm' },
},
},
components: { components: {
GlTable, GlTable,
GlButton, GlButton,
GlModal,
GlSprintf, GlSprintf,
}, },
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
}, },
data() {
return {
freezePeriodToDelete: null,
};
},
computed: { computed: {
...mapState(['freezePeriods']), ...mapState(['freezePeriods']),
tableIsNotEmpty() { tableIsNotEmpty() {
...@@ -46,7 +67,14 @@ export default { ...@@ -46,7 +67,14 @@ export default {
this.fetchFreezePeriods(); this.fetchFreezePeriods();
}, },
methods: { methods: {
...mapActions(['fetchFreezePeriods', 'setFreezePeriod']), ...mapActions(['fetchFreezePeriods', 'setFreezePeriod', 'deleteFreezePeriod']),
handleDeleteFreezePeriod(freezePeriod) {
this.freezePeriodToDelete = freezePeriod;
},
confirmDeleteFreezePeriod() {
this.deleteFreezePeriod(this.freezePeriodToDelete);
this.freezePeriodToDelete = null;
},
}, },
}; };
</script> </script>
...@@ -72,6 +100,18 @@ export default { ...@@ -72,6 +100,18 @@ export default {
@click="setFreezePeriod(item)" @click="setFreezePeriod(item)"
/> />
</template> </template>
<template #cell(delete)="{ item }">
<gl-button
v-gl-modal="$options.modal.id"
category="secondary"
variant="danger"
icon="remove"
:aria-label="$options.modal.actionPrimary.text"
:loading="item.isDeleting"
data-testid="delete-deploy-freeze"
@click="handleDeleteFreezePeriod(item)"
/>
</template>
<template #empty> <template #empty>
<p data-testid="empty-freeze-periods" class="gl-text-center text-plain"> <p data-testid="empty-freeze-periods" class="gl-text-center text-plain">
<gl-sprintf :message="$options.translations.emptyStateText"> <gl-sprintf :message="$options.translations.emptyStateText">
...@@ -90,5 +130,24 @@ export default { ...@@ -90,5 +130,24 @@ export default {
> >
{{ $options.translations.addDeployFreeze }} {{ $options.translations.addDeployFreeze }}
</gl-button> </gl-button>
<gl-modal
:title="$options.translations.deleteDeployFreezeTitle"
:modal-id="$options.modal.id"
:action-primary="$options.modal.actionPrimary"
static
@primary="confirmDeleteFreezePeriod"
>
<template v-if="freezePeriodToDelete">
<gl-sprintf :message="$options.translations.deleteDeployFreezeMessage">
<template #start>
<code>{{ freezePeriodToDelete.freezeStart }}</code>
</template>
<template #end>
<code>{{ freezePeriodToDelete.freezeEnd }}</code>
</template>
<template #timezone>{{ freezePeriodToDelete.cronTimezone.formattedTimezone }}</template>
</gl-sprintf>
</template>
</gl-modal>
</div> </div>
</template> </template>
...@@ -52,6 +52,22 @@ export const updateFreezePeriod = (store) => ...@@ -52,6 +52,22 @@ export const updateFreezePeriod = (store) =>
}), }),
); );
export const deleteFreezePeriod = ({ state, commit }, { id }) => {
commit(types.REQUEST_DELETE_FREEZE_PERIOD, id);
return Api.deleteFreezePeriod(state.projectId, id)
.then(() => commit(types.RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS, id))
.catch((e) => {
createFlash({
message: __('Error: Unable to delete deploy freeze'),
});
commit(types.RECEIVE_DELETE_FREEZE_PERIOD_ERROR, id);
// eslint-disable-next-line no-console
console.error('[gitlab] Unable to delete deploy freeze:', e);
});
};
export const fetchFreezePeriods = ({ commit, state }) => { export const fetchFreezePeriods = ({ commit, state }) => {
commit(types.REQUEST_FREEZE_PERIODS); commit(types.REQUEST_FREEZE_PERIODS);
......
...@@ -10,4 +10,8 @@ export const SET_SELECTED_ID = 'SET_SELECTED_ID'; ...@@ -10,4 +10,8 @@ export const SET_SELECTED_ID = 'SET_SELECTED_ID';
export const SET_FREEZE_START_CRON = 'SET_FREEZE_START_CRON'; export const SET_FREEZE_START_CRON = 'SET_FREEZE_START_CRON';
export const SET_FREEZE_END_CRON = 'SET_FREEZE_END_CRON'; export const SET_FREEZE_END_CRON = 'SET_FREEZE_END_CRON';
export const REQUEST_DELETE_FREEZE_PERIOD = 'REQUEST_DELETE_FREEZE_PERIOD';
export const RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS = 'RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS';
export const RECEIVE_DELETE_FREEZE_PERIOD_ERROR = 'RECEIVE_DELETE_FREEZE_PERIOD_ERROR';
export const RESET_MODAL = 'RESET_MODAL'; export const RESET_MODAL = 'RESET_MODAL';
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { secondsToHours } from '~/lib/utils/datetime_utility';
import * as types from './mutation_types'; import * as types from './mutation_types';
const formatTimezoneName = (freezePeriod, timezoneList) => const formatTimezoneName = (freezePeriod, timezoneList) => {
convertObjectPropsToCamelCase({ const tz = timezoneList.find((timezone) => timezone.identifier === freezePeriod.cron_timezone);
return convertObjectPropsToCamelCase({
...freezePeriod, ...freezePeriod,
cron_timezone: { cron_timezone: {
formattedTimezone: timezoneList.find((tz) => tz.identifier === freezePeriod.cron_timezone) formattedTimezone: tz && `[UTC ${secondsToHours(tz.offset)}] ${tz.name}`,
?.name,
identifier: freezePeriod.cron_timezone, identifier: freezePeriod.cron_timezone,
}, },
}); });
};
const setFreezePeriodIsDeleting = (state, id, isDeleting) => {
const freezePeriod = state.freezePeriods.find((f) => f.id === id);
if (!freezePeriod) {
return;
}
Vue.set(freezePeriod, 'isDeleting', isDeleting);
};
export default { export default {
[types.REQUEST_FREEZE_PERIODS](state) { [types.REQUEST_FREEZE_PERIODS](state) {
...@@ -53,6 +66,18 @@ export default { ...@@ -53,6 +66,18 @@ export default {
state.selectedId = id; state.selectedId = id;
}, },
[types.REQUEST_DELETE_FREEZE_PERIOD](state, id) {
setFreezePeriodIsDeleting(state, id, true);
},
[types.RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS](state, id) {
state.freezePeriods = state.freezePeriods.filter((f) => f.id !== id);
},
[types.RECEIVE_DELETE_FREEZE_PERIOD_ERROR](state, id) {
setFreezePeriodIsDeleting(state, id, false);
},
[types.RESET_MODAL](state) { [types.RESET_MODAL](state) {
state.freezeStartCron = ''; state.freezeStartCron = '';
state.freezeEndCron = ''; state.freezeEndCron = '';
......
...@@ -186,7 +186,8 @@ To subscribe to notifications for releases: ...@@ -186,7 +186,8 @@ To subscribe to notifications for releases:
## Prevent unintentional releases by setting a deploy freeze ## Prevent unintentional releases by setting a deploy freeze
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29382) in GitLab 13.0. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29382) in GitLab 13.0.
> - The ability to delete freeze periods through the UI was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/212451) in GitLab 14.3.
Prevent unintended production releases during a period of time you specify by Prevent unintended production releases during a period of time you specify by
setting a [*deploy freeze* period](../../../ci/environments/deployment_safety.md). setting a [*deploy freeze* period](../../../ci/environments/deployment_safety.md).
...@@ -219,11 +220,8 @@ To set a deploy freeze window in the UI, complete these steps: ...@@ -219,11 +220,8 @@ To set a deploy freeze window in the UI, complete these steps:
1. Click **Add deploy freeze** to open the deploy freeze modal. 1. Click **Add deploy freeze** to open the deploy freeze modal.
1. Enter the start time, end time, and timezone of the desired deploy freeze period. 1. Enter the start time, end time, and timezone of the desired deploy freeze period.
1. Click **Add deploy freeze** in the modal. 1. Click **Add deploy freeze** in the modal.
1. After the deploy freeze is saved, you can edit it by selecting the edit button (**{pencil}**). 1. After the deploy freeze is saved, you can edit it by selecting the edit button (**{pencil}**) and remove it by selecting the delete button (**{remove}**).
![Deploy freeze modal for setting a deploy freeze period](img/deploy_freeze_v13_10.png) ![Deploy freeze modal for setting a deploy freeze period](img/deploy_freeze_v14_3.png)
WARNING:
To delete a deploy freeze, use the [Freeze Periods API](../../../api/freeze_periods.md).
If a project contains multiple freeze periods, all periods apply. If they overlap, the freeze covers the If a project contains multiple freeze periods, all periods apply. If they overlap, the freeze covers the
complete overlapping period. complete overlapping period.
......
...@@ -11068,6 +11068,18 @@ msgstr "" ...@@ -11068,6 +11068,18 @@ msgstr ""
msgid "DeployFreeze|Add deploy freeze" msgid "DeployFreeze|Add deploy freeze"
msgstr "" msgstr ""
msgid "DeployFreeze|Delete"
msgstr ""
msgid "DeployFreeze|Delete deploy freeze?"
msgstr ""
msgid "DeployFreeze|Delete freeze period"
msgstr ""
msgid "DeployFreeze|Deploy freeze from %{start} to %{end} in %{timezone} will be removed. Are you sure?"
msgstr ""
msgid "DeployFreeze|Edit" msgid "DeployFreeze|Edit"
msgstr "" msgstr ""
...@@ -13262,6 +13274,9 @@ msgstr "" ...@@ -13262,6 +13274,9 @@ msgstr ""
msgid "Error: Unable to create deploy freeze" msgid "Error: Unable to create deploy freeze"
msgstr "" msgstr ""
msgid "Error: Unable to delete deploy freeze"
msgstr ""
msgid "Error: Unable to find AWS role for current user" msgid "Error: Unable to find AWS role for current user"
msgstr "" msgstr ""
......
import { GlModal } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils'; import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue'; import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue';
...@@ -29,6 +30,8 @@ describe('Deploy freeze table', () => { ...@@ -29,6 +30,8 @@ describe('Deploy freeze table', () => {
const findAddDeployFreezeButton = () => wrapper.find('[data-testid="add-deploy-freeze"]'); const findAddDeployFreezeButton = () => wrapper.find('[data-testid="add-deploy-freeze"]');
const findEditDeployFreezeButton = () => wrapper.find('[data-testid="edit-deploy-freeze"]'); const findEditDeployFreezeButton = () => wrapper.find('[data-testid="edit-deploy-freeze"]');
const findDeployFreezeTable = () => wrapper.find('[data-testid="deploy-freeze-table"]'); const findDeployFreezeTable = () => wrapper.find('[data-testid="deploy-freeze-table"]');
const findDeleteDeployFreezeButton = () => wrapper.find('[data-testid="delete-deploy-freeze"]');
const findDeleteDeployFreezeModal = () => wrapper.findComponent(GlModal);
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -73,6 +76,29 @@ describe('Deploy freeze table', () => { ...@@ -73,6 +76,29 @@ describe('Deploy freeze table', () => {
store.state.freezePeriods[0], store.state.freezePeriods[0],
); );
}); });
it('displays delete deploy freeze button', () => {
expect(findDeleteDeployFreezeButton().exists()).toBe(true);
});
it('confirms a user wants to delete a deploy freeze', async () => {
const [{ freezeStart, freezeEnd, cronTimezone }] = store.state.freezePeriods;
await findDeleteDeployFreezeButton().trigger('click');
const modal = findDeleteDeployFreezeModal();
expect(modal.text()).toContain(
`Deploy freeze from ${freezeStart} to ${freezeEnd} in ${cronTimezone.formattedTimezone} will be removed.`,
);
});
it('deletes the freeze period on confirmation', async () => {
await findDeleteDeployFreezeButton().trigger('click');
const modal = findDeleteDeployFreezeModal();
modal.vm.$emit('primary');
expect(store.dispatch).toHaveBeenCalledWith(
'deleteFreezePeriod',
store.state.freezePeriods[0],
);
});
}); });
}); });
......
...@@ -12,6 +12,7 @@ jest.mock('~/api.js'); ...@@ -12,6 +12,7 @@ jest.mock('~/api.js');
jest.mock('~/flash.js'); jest.mock('~/flash.js');
describe('deploy freeze store actions', () => { describe('deploy freeze store actions', () => {
const freezePeriodFixture = freezePeriodsFixture[0];
let mock; let mock;
let state; let state;
...@@ -24,6 +25,7 @@ describe('deploy freeze store actions', () => { ...@@ -24,6 +25,7 @@ describe('deploy freeze store actions', () => {
Api.freezePeriods.mockResolvedValue({ data: freezePeriodsFixture }); Api.freezePeriods.mockResolvedValue({ data: freezePeriodsFixture });
Api.createFreezePeriod.mockResolvedValue(); Api.createFreezePeriod.mockResolvedValue();
Api.updateFreezePeriod.mockResolvedValue(); Api.updateFreezePeriod.mockResolvedValue();
Api.deleteFreezePeriod.mockResolvedValue();
}); });
afterEach(() => { afterEach(() => {
...@@ -195,4 +197,46 @@ describe('deploy freeze store actions', () => { ...@@ -195,4 +197,46 @@ describe('deploy freeze store actions', () => {
); );
}); });
}); });
describe('deleteFreezePeriod', () => {
it('dispatch correct actions on deleting a freeze period', () => {
testAction(
actions.deleteFreezePeriod,
freezePeriodFixture,
state,
[
{ type: 'REQUEST_DELETE_FREEZE_PERIOD', payload: freezePeriodFixture.id },
{ type: 'RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS', payload: freezePeriodFixture.id },
],
[],
() =>
expect(Api.deleteFreezePeriod).toHaveBeenCalledWith(
state.projectId,
freezePeriodFixture.id,
),
);
});
it('should show flash error and set error in state on delete failure', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
const error = new Error();
Api.deleteFreezePeriod.mockRejectedValue(error);
testAction(
actions.deleteFreezePeriod,
freezePeriodFixture,
state,
[
{ type: 'REQUEST_DELETE_FREEZE_PERIOD', payload: freezePeriodFixture.id },
{ type: 'RECEIVE_DELETE_FREEZE_PERIOD_ERROR', payload: freezePeriodFixture.id },
],
[],
() => {
expect(createFlash).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith('[gitlab] Unable to delete deploy freeze:', error);
},
);
});
});
}); });
...@@ -28,9 +28,9 @@ describe('Deploy freeze mutations', () => { ...@@ -28,9 +28,9 @@ describe('Deploy freeze mutations', () => {
describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => { describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => {
it('should set freeze periods and format timezones from identifiers to names', () => { it('should set freeze periods and format timezones from identifiers to names', () => {
const timezoneNames = { const timezoneNames = {
'Europe/Berlin': 'Berlin', 'Europe/Berlin': '[UTC 2] Berlin',
'Etc/UTC': 'UTC', 'Etc/UTC': '[UTC 0] UTC',
'America/New_York': 'Eastern Time (US & Canada)', 'America/New_York': '[UTC -4] Eastern Time (US & Canada)',
}; };
mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, freezePeriodsFixture); mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, freezePeriodsFixture);
......
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