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 = {
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) {
if (!gon.features?.usageDataApi) {
return null;
......
<script>
import { GlTable, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { GlTable, GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { s__ } from '~/locale';
......@@ -21,21 +21,42 @@ export default {
key: 'edit',
label: s__('DeployFreeze|Edit'),
},
{
key: 'delete',
label: s__('DeployFreeze|Delete'),
},
],
translations: {
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__(
'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: {
GlTable,
GlButton,
GlModal,
GlSprintf,
},
directives: {
GlModal: GlModalDirective,
},
data() {
return {
freezePeriodToDelete: null,
};
},
computed: {
...mapState(['freezePeriods']),
tableIsNotEmpty() {
......@@ -46,7 +67,14 @@ export default {
this.fetchFreezePeriods();
},
methods: {
...mapActions(['fetchFreezePeriods', 'setFreezePeriod']),
...mapActions(['fetchFreezePeriods', 'setFreezePeriod', 'deleteFreezePeriod']),
handleDeleteFreezePeriod(freezePeriod) {
this.freezePeriodToDelete = freezePeriod;
},
confirmDeleteFreezePeriod() {
this.deleteFreezePeriod(this.freezePeriodToDelete);
this.freezePeriodToDelete = null;
},
},
};
</script>
......@@ -72,6 +100,18 @@ export default {
@click="setFreezePeriod(item)"
/>
</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>
<p data-testid="empty-freeze-periods" class="gl-text-center text-plain">
<gl-sprintf :message="$options.translations.emptyStateText">
......@@ -90,5 +130,24 @@ export default {
>
{{ $options.translations.addDeployFreeze }}
</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>
</template>
......@@ -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 }) => {
commit(types.REQUEST_FREEZE_PERIODS);
......
......@@ -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_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';
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { secondsToHours } from '~/lib/utils/datetime_utility';
import * as types from './mutation_types';
const formatTimezoneName = (freezePeriod, timezoneList) =>
convertObjectPropsToCamelCase({
const formatTimezoneName = (freezePeriod, timezoneList) => {
const tz = timezoneList.find((timezone) => timezone.identifier === freezePeriod.cron_timezone);
return convertObjectPropsToCamelCase({
...freezePeriod,
cron_timezone: {
formattedTimezone: timezoneList.find((tz) => tz.identifier === freezePeriod.cron_timezone)
?.name,
formattedTimezone: tz && `[UTC ${secondsToHours(tz.offset)}] ${tz.name}`,
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 {
[types.REQUEST_FREEZE_PERIODS](state) {
......@@ -53,6 +66,18 @@ export default {
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) {
state.freezeStartCron = '';
state.freezeEndCron = '';
......
......@@ -186,7 +186,8 @@ To subscribe to notifications for releases:
## 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
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:
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. Click **Add deploy freeze** in the modal.
1. After the deploy freeze is saved, you can edit it by selecting the edit button (**{pencil}**).
![Deploy freeze modal for setting a deploy freeze period](img/deploy_freeze_v13_10.png)
WARNING:
To delete a deploy freeze, use the [Freeze Periods API](../../../api/freeze_periods.md).
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_v14_3.png)
If a project contains multiple freeze periods, all periods apply. If they overlap, the freeze covers the
complete overlapping period.
......
......@@ -11068,6 +11068,18 @@ msgstr ""
msgid "DeployFreeze|Add deploy freeze"
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"
msgstr ""
......@@ -13262,6 +13274,9 @@ msgstr ""
msgid "Error: Unable to create deploy freeze"
msgstr ""
msgid "Error: Unable to delete deploy freeze"
msgstr ""
msgid "Error: Unable to find AWS role for current user"
msgstr ""
......
import { GlModal } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue';
......@@ -29,6 +30,8 @@ describe('Deploy freeze table', () => {
const findAddDeployFreezeButton = () => wrapper.find('[data-testid="add-deploy-freeze"]');
const findEditDeployFreezeButton = () => wrapper.find('[data-testid="edit-deploy-freeze"]');
const findDeployFreezeTable = () => wrapper.find('[data-testid="deploy-freeze-table"]');
const findDeleteDeployFreezeButton = () => wrapper.find('[data-testid="delete-deploy-freeze"]');
const findDeleteDeployFreezeModal = () => wrapper.findComponent(GlModal);
beforeEach(() => {
createComponent();
......@@ -73,6 +76,29 @@ describe('Deploy freeze table', () => {
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');
jest.mock('~/flash.js');
describe('deploy freeze store actions', () => {
const freezePeriodFixture = freezePeriodsFixture[0];
let mock;
let state;
......@@ -24,6 +25,7 @@ describe('deploy freeze store actions', () => {
Api.freezePeriods.mockResolvedValue({ data: freezePeriodsFixture });
Api.createFreezePeriod.mockResolvedValue();
Api.updateFreezePeriod.mockResolvedValue();
Api.deleteFreezePeriod.mockResolvedValue();
});
afterEach(() => {
......@@ -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', () => {
describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => {
it('should set freeze periods and format timezones from identifiers to names', () => {
const timezoneNames = {
'Europe/Berlin': 'Berlin',
'Etc/UTC': 'UTC',
'America/New_York': 'Eastern Time (US & Canada)',
'Europe/Berlin': '[UTC 2] Berlin',
'Etc/UTC': '[UTC 0] UTC',
'America/New_York': '[UTC -4] Eastern Time (US & Canada)',
};
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