Commit e76cd573 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '24295-set-a-deploy-freeze-in-the-ui' into 'master'

Resolve "Set a deploy freeze in the UI"

Closes #24295

See merge request gitlab-org/gitlab!35163
parents cbe89827 befcdd8d
...@@ -59,6 +59,7 @@ const Api = { ...@@ -59,6 +59,7 @@ const Api = {
rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw', rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw',
issuePath: '/api/:version/projects/:id/issues/:issue_iid', issuePath: '/api/:version/projects/:id/issues/:issue_iid',
tagsPath: '/api/:version/projects/:id/repository/tags', tagsPath: '/api/:version/projects/:id/repository/tags',
freezePeriodsPath: '/api/:version/projects/:id/freeze_periods',
group(groupId, callback = () => {}) { group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
...@@ -616,6 +617,18 @@ const Api = { ...@@ -616,6 +617,18 @@ const Api = {
}); });
}, },
freezePeriods(id) {
const url = Api.buildUrl(this.freezePeriodsPath).replace(':id', encodeURIComponent(id));
return axios.get(url);
},
createFreezePeriod(id, freezePeriod = {}) {
const url = Api.buildUrl(this.freezePeriodsPath).replace(':id', encodeURIComponent(id));
return axios.post(url, freezePeriod);
},
buildUrl(url) { buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
}, },
......
<script>
import { GlFormGroup, GlFormInput, GlModal, GlSprintf, GlLink } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { mapComputed } from '~/vuex_shared/bindings';
import { __ } from '~/locale';
import { MODAL_ID } from '../constants';
import DeployFreezeTimezoneDropdown from './deploy_freeze_timezone_dropdown.vue';
import { isValidCron } from 'cron-validator';
export default {
components: {
GlFormGroup,
GlFormInput,
GlModal,
GlSprintf,
GlLink,
DeployFreezeTimezoneDropdown,
},
modalOptions: {
ref: 'modal',
modalId: MODAL_ID,
title: __('Add deploy freeze'),
actionCancel: {
text: __('Cancel'),
},
static: true,
lazy: true,
},
translations: {
cronPlaceholder: __('* * * * *'),
cronSyntaxInstructions: __(
'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}',
),
},
computed: {
...mapState([
'projectId',
'selectedTimezone',
'timezoneData',
'freezeStartCron',
'freezeEndCron',
'selectedTimezone',
]),
...mapComputed([
{ key: 'freezeStartCron', updateFn: 'setFreezeStartCron' },
{ key: 'freezeEndCron', updateFn: 'setFreezeEndCron' },
]),
addDeployFreezeButton() {
return {
text: __('Add deploy freeze'),
attributes: [
{ variant: 'success' },
{
disabled:
!isValidCron(this.freezeStartCron) ||
!isValidCron(this.freezeEndCron) ||
!this.selectedTimezone,
},
],
};
},
invalidFreezeStartCron() {
return this.invalidCronMessage(this.freezeStartCronState);
},
freezeStartCronState() {
return Boolean(!this.freezeStartCron || isValidCron(this.freezeStartCron));
},
invalidFreezeEndCron() {
return this.invalidCronMessage(this.freezeEndCronState);
},
freezeEndCronState() {
return Boolean(!this.freezeEndCron || isValidCron(this.freezeEndCron));
},
},
methods: {
...mapActions(['addFreezePeriod', 'setSelectedTimezone', 'resetModal']),
resetModalHandler() {
this.resetModal();
},
invalidCronMessage(validCronState) {
if (!validCronState) {
return __('This Cron pattern is invalid');
}
return '';
},
},
};
</script>
<template>
<gl-modal
v-bind="$options.modalOptions"
:action-primary="addDeployFreezeButton"
@primary="addFreezePeriod"
@canceled="resetModalHandler"
>
<p>
<gl-sprintf :message="$options.translations.cronSyntaxInstructions">
<template #cronSyntax="{ content }">
<gl-link href="https://crontab.guru/" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
<gl-form-group
:label="__('Freeze start')"
label-for="deploy-freeze-start"
:invalid-feedback="invalidFreezeStartCron"
:state="freezeStartCronState"
>
<gl-form-input
id="deploy-freeze-start"
v-model="freezeStartCron"
class="gl-font-monospace!"
data-qa-selector="deploy_freeze_start_field"
:placeholder="this.$options.translations.cronPlaceholder"
:state="freezeStartCronState"
trim
/>
</gl-form-group>
<gl-form-group
:label="__('Freeze end')"
label-for="deploy-freeze-end"
:invalid-feedback="invalidFreezeEndCron"
:state="freezeEndCronState"
>
<gl-form-input
id="deploy-freeze-end"
v-model="freezeEndCron"
class="gl-font-monospace!"
data-qa-selector="deploy_freeze_end_field"
:placeholder="this.$options.translations.cronPlaceholder"
:state="freezeEndCronState"
trim
/>
</gl-form-group>
<gl-form-group :label="__('Cron time zone')" label-for="cron-time-zone-dropdown">
<deploy-freeze-timezone-dropdown
:timezone-data="timezoneData"
:value="selectedTimezone"
@selectTimezone="setSelectedTimezone"
/>
</gl-form-group>
</gl-modal>
</template>
<script>
import DeployFreezeTable from './deploy_freeze_table.vue';
import DeployFreezeModal from './deploy_freeze_modal.vue';
export default {
components: {
DeployFreezeTable,
DeployFreezeModal,
},
};
</script>
<template>
<div>
<deploy-freeze-table />
<deploy-freeze-modal />
</div>
</template>
<script>
import { GlTable, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { mapState, mapActions } from 'vuex';
import { MODAL_ID } from '../constants';
export default {
modalId: MODAL_ID,
fields: [
{
key: 'freezeStart',
label: s__('DeployFreeze|Freeze start'),
},
{
key: 'freezeEnd',
label: s__('DeployFreeze|Freeze end'),
},
{
key: 'cronTimezone',
label: s__('DeployFreeze|Time zone'),
},
],
translations: {
addDeployFreeze: __('Add deploy freeze'),
},
components: {
GlTable,
GlButton,
GlSprintf,
},
directives: {
GlModalDirective,
},
computed: {
...mapState(['freezePeriods']),
tableIsNotEmpty() {
return this.freezePeriods?.length > 0;
},
},
mounted() {
this.fetchFreezePeriods();
},
methods: {
...mapActions(['fetchFreezePeriods']),
},
};
</script>
<template>
<div class="deploy-freeze-table">
<gl-table
data-testid="deploy-freeze-table"
:items="freezePeriods"
:fields="$options.fields"
show-empty
>
<template #empty>
<p data-testid="empty-freeze-periods" class="gl-text-center text-plain">
<gl-sprintf
:message="
s__(
'DeployFreeze|No deploy freezes exist for this project. To add one, click %{strongStart}Add deploy freeze%{strongEnd}',
)
"
>
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
</template>
</gl-table>
<div class="gl-display-flex gl-justify-content-center">
<gl-button
v-gl-modal-directive="$options.modalId"
data-testid="add-deploy-freeze"
category="primary"
variant="success"
>
{{ $options.translations.addDeployFreeze }}
</gl-button>
</div>
</div>
</template>
<script>
import { GlNewDropdown, GlDropdownItem, GlSearchBoxByType, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
export default {
name: 'DeployFreezeTimezoneDropdown',
components: {
GlNewDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlIcon,
},
directives: {
autofocusonshow,
},
props: {
value: {
type: String,
required: false,
default: '',
},
timezoneData: {
type: Array,
required: true,
default: () => [],
},
},
data() {
return {
searchTerm: this.value || '',
};
},
tranlations: {
noResultsText: __('No matching results'),
},
computed: {
timezones() {
return this.timezoneData.map(timezone => ({
formattedTimezone: this.formatTimezone(timezone),
identifier: timezone.identifier,
}));
},
filteredResults() {
const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
return this.timezones.filter(timezone =>
timezone.formattedTimezone.toLowerCase().includes(lowerCasedSearchTerm),
);
},
selectTimezoneLabel() {
return this.value || __('Select timezone');
},
},
watch: {
value(newVal) {
this.searchTerm = newVal;
},
},
methods: {
selectTimezone(selected) {
this.$emit('selectTimezone', selected);
this.searchTerm = '';
},
isSelected(timezone) {
return this.value === timezone.formattedTimezone;
},
formatUtcOffset(offset) {
const parsed = parseInt(offset, 10);
if (Number.isNaN(parsed) || parsed === 0) {
return `0`;
}
const prefix = offset > 0 ? '+' : '-';
return `${prefix}${Math.abs(offset / 3600)}`;
},
formatTimezone(item) {
return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`;
},
},
};
</script>
<template>
<gl-new-dropdown :text="value" block lazy menu-class="gl-w-full!">
<template #button-content>
<span ref="buttonText" class="gl-flex-grow-1" :class="{ 'gl-text-gray-500': !value }">{{
selectTimezoneLabel
}}</span>
<gl-icon name="chevron-down" />
</template>
<gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus class="gl-m-3" />
<gl-dropdown-item
v-for="timezone in filteredResults"
:key="timezone.formattedTimezone"
@click="selectTimezone(timezone)"
>
<gl-icon
:class="{ invisible: !isSelected(timezone) }"
name="mobile-issue-close"
class="gl-vertical-align-middle"
/>
{{ timezone.formattedTimezone }}
</gl-dropdown-item>
<gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">
{{ $options.tranlations.noResultsText }}
</gl-dropdown-item>
</gl-new-dropdown>
</template>
export const MODAL_ID = 'deploy-freeze-modal';
export default {
MODAL_ID,
};
import Vue from 'vue';
import DeployFreezeSettings from './components/deploy_freeze_settings.vue';
import createStore from './store';
export default () => {
const el = document.getElementById('js-deploy-freeze-table');
const { projectId, timezoneData } = el.dataset;
const store = createStore({
projectId,
timezoneData: JSON.parse(timezoneData),
});
return new Vue({
el,
store,
render(createElement) {
return createElement(DeployFreezeSettings);
},
});
};
import * as types from './mutation_types';
import Api from '~/api';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export const requestAddFreezePeriod = ({ commit }) => {
commit(types.REQUEST_ADD_FREEZE_PERIOD);
};
export const receiveAddFreezePeriodSuccess = ({ commit }) => {
commit(types.RECEIVE_ADD_FREEZE_PERIOD_SUCCESS);
};
export const receiveAddFreezePeriodError = ({ commit }, error) => {
commit(types.RECEIVE_ADD_FREEZE_PERIOD_ERROR, error);
};
export const addFreezePeriod = ({ state, dispatch, commit }) => {
dispatch('requestAddFreezePeriod');
return Api.createFreezePeriod(state.projectId, {
freeze_start: state.freezeStartCron,
freeze_end: state.freezeEndCron,
cron_timezone: state.selectedTimezoneIdentifier,
})
.then(() => {
dispatch('receiveAddFreezePeriodSuccess');
commit(types.RESET_MODAL);
dispatch('fetchFreezePeriods');
})
.catch(error => {
createFlash(__('Error: Unable to create deploy freeze'));
dispatch('receiveAddFreezePeriodError', error);
});
};
export const requestFreezePeriods = ({ commit }) => {
commit(types.REQUEST_FREEZE_PERIODS);
};
export const receiveFreezePeriodsSuccess = ({ state, commit }, freezePeriods) => {
const addTimezoneIdentifier = freezePeriod =>
convertObjectPropsToCamelCase({
...freezePeriod,
cron_timezone: state.timezoneData.find(tz => tz.identifier === freezePeriod.cron_timezone)
?.name,
});
commit(types.RECEIVE_FREEZE_PERIODS_SUCCESS, freezePeriods.map(addTimezoneIdentifier));
};
export const fetchFreezePeriods = ({ dispatch, state }) => {
dispatch('requestFreezePeriods');
return Api.freezePeriods(state.projectId)
.then(({ data }) => {
dispatch('receiveFreezePeriodsSuccess', convertObjectPropsToCamelCase(data));
})
.catch(() => {
createFlash(__('There was an error fetching the deploy freezes.'));
});
};
export const setSelectedTimezone = ({ commit }, timezone) => {
commit(types.SET_SELECTED_TIMEZONE, timezone);
};
export const setFreezeStartCron = ({ commit }, { freezeStartCron }) => {
commit(types.SET_FREEZE_START_CRON, freezeStartCron);
};
export const setFreezeEndCron = ({ commit }, { freezeEndCron }) => {
commit(types.SET_FREEZE_END_CRON, freezeEndCron);
};
export const resetModal = ({ commit }) => {
commit(types.RESET_MODAL);
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
import createState from './state';
Vue.use(Vuex);
export default initialState =>
new Vuex.Store({
actions,
mutations,
state: createState(initialState),
});
export const REQUEST_FREEZE_PERIODS = 'REQUEST_FREEZE_PERIODS';
export const RECEIVE_FREEZE_PERIODS_SUCCESS = 'RECEIVE_FREEZE_PERIODS_SUCCESS';
export const REQUEST_ADD_FREEZE_PERIOD = 'REQUEST_ADD_FREEZE_PERIOD';
export const RECEIVE_ADD_FREEZE_PERIOD_SUCCESS = 'RECEIVE_ADD_FREEZE_PERIOD_SUCCESS';
export const RECEIVE_ADD_FREEZE_PERIOD_ERROR = 'RECEIVE_ADD_FREEZE_PERIOD_ERROR';
export const SET_SELECTED_TIMEZONE = 'SET_SELECTED_TIMEZONE';
export const SET_FREEZE_START_CRON = 'SET_FREEZE_START_CRON';
export const SET_FREEZE_END_CRON = 'SET_FREEZE_END_CRON';
export const RESET_MODAL = 'RESET_MODAL';
import * as types from './mutation_types';
export default {
[types.REQUEST_FREEZE_PERIODS](state) {
state.isLoading = true;
},
[types.RECEIVE_FREEZE_PERIODS_SUCCESS](state, freezePeriods) {
state.isLoading = false;
state.freezePeriods = freezePeriods;
},
[types.REQUEST_ADD_FREEZE_PERIOD](state) {
state.isLoading = true;
},
[types.RECEIVE_ADD_FREEZE_PERIOD_SUCCESS](state) {
state.isLoading = false;
},
[types.RECEIVE_ADD_FREEZE_PERIOD_ERROR](state, error) {
state.isLoading = false;
state.error = error;
},
[types.SET_SELECTED_TIMEZONE](state, timezone) {
state.selectedTimezone = timezone.formattedTimezone;
state.selectedTimezoneIdentifier = timezone.identifier;
},
[types.SET_FREEZE_START_CRON](state, freezeStartCron) {
state.freezeStartCron = freezeStartCron;
},
[types.SET_FREEZE_END_CRON](state, freezeEndCron) {
state.freezeEndCron = freezeEndCron;
},
[types.RESET_MODAL](state) {
state.freezeStartCron = '';
state.freezeEndCron = '';
state.selectedTimezone = '';
state.selectedTimezoneIdentifier = '';
},
};
export default ({
projectId,
freezePeriods = [],
timezoneData = [],
selectedTimezone = '',
selectedTimezoneIdentifier = '',
freezeStartCron = '',
freezeEndCron = '',
}) => ({
projectId,
freezePeriods,
timezoneData,
selectedTimezone,
selectedTimezoneIdentifier,
freezeStartCron,
freezeEndCron,
});
...@@ -3,6 +3,7 @@ import SecretValues from '~/behaviors/secret_values'; ...@@ -3,6 +3,7 @@ import SecretValues from '~/behaviors/secret_values';
import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
import registrySettingsApp from '~/registry/settings/registry_settings_bundle'; import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
import initVariableList from '~/ci_variable_list'; import initVariableList from '~/ci_variable_list';
import initDeployFreeze from '~/deploy_freeze';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels // Initialize expandable settings panels
...@@ -40,4 +41,5 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -40,4 +41,5 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
registrySettingsApp(); registrySettingsApp();
initDeployFreeze();
}); });
...@@ -366,7 +366,8 @@ ...@@ -366,7 +366,8 @@
margin-top: 1em; margin-top: 1em;
} }
.ci-variable-table { .ci-variable-table,
.deploy-freeze-table {
table { table {
thead { thead {
border-bottom: 1px solid $white-normal; border-bottom: 1px solid $white-normal;
......
#js-deploy-freeze-table{ data: { project_id: @project.id, timezone_data: timezone_data.to_json } }
...@@ -74,3 +74,22 @@ ...@@ -74,3 +74,22 @@
= link_to _('More information'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy', target: '_blank', rel: 'noopener noreferrer') = link_to _('More information'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy', target: '_blank', rel: 'noopener noreferrer')
.settings-content .settings-content
= render 'projects/registry/settings/index' = render 'projects/registry/settings/index'
- if can?(current_user, :create_freeze_period, @project)
%section.settings.no-animate#js-deploy-freeze-settings{ class: ('expanded' if expanded) }
.settings-header
%h4
= _("Deploy freezes")
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- freeze_period_docs = help_page_path('user/project/releases/index', anchor: 'prevent-unintentional-releases-by-setting-a-deploy-freeze')
- freeze_period_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: freeze_period_docs }
= s_('DeployFreeze|Specify times when deployments are not allowed for an environment. The <code>gitlab-ci.yml</code> file must be updated to make deployment jobs aware of the %{freeze_period_link_start}freeze period%{freeze_period_link_end}.').html_safe % { freeze_period_link_start: freeze_period_link_start, freeze_period_link_end: '</a>'.html_safe }
- cron_syntax_url = 'https://crontab.guru/'
- cron_syntax_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: cron_syntax_url }
= s_('DeployFreeze|You can specify deploy freezes using only %{cron_syntax_link_start}cron syntax%{cron_syntax_link_end}.').html_safe % { cron_syntax_link_start: cron_syntax_link_start, cron_syntax_link_end: "</a>".html_safe }
.settings-content
= render 'ci/deploy_freeze/index'
---
title: Resolve Set a deploy freeze in the UI
merge_request: 35163
author:
type: added
...@@ -791,6 +791,9 @@ msgstr "" ...@@ -791,6 +791,9 @@ msgstr ""
msgid "(revoked)" msgid "(revoked)"
msgstr "" msgstr ""
msgid "* * * * *"
msgstr ""
msgid "+ %{amount} more" msgid "+ %{amount} more"
msgstr "" msgstr ""
...@@ -1456,6 +1459,9 @@ msgstr "" ...@@ -1456,6 +1459,9 @@ msgstr ""
msgid "Add comment now" msgid "Add comment now"
msgstr "" msgstr ""
msgid "Add deploy freeze"
msgstr ""
msgid "Add domain" msgid "Add domain"
msgstr "" msgstr ""
...@@ -7079,6 +7085,9 @@ msgstr "" ...@@ -7079,6 +7085,9 @@ msgstr ""
msgid "Cron Timezone" msgid "Cron Timezone"
msgstr "" msgstr ""
msgid "Cron time zone"
msgstr ""
msgid "Crossplane" msgid "Crossplane"
msgstr "" msgstr ""
...@@ -7555,6 +7564,9 @@ msgstr "" ...@@ -7555,6 +7564,9 @@ msgstr ""
msgid "DefaultBranchLabel|default" msgid "DefaultBranchLabel|default"
msgstr "" msgstr ""
msgid "Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}"
msgstr ""
msgid "Define a custom pattern with cron syntax" msgid "Define a custom pattern with cron syntax"
msgstr "" msgstr ""
...@@ -7802,6 +7814,9 @@ msgstr[1] "" ...@@ -7802,6 +7814,9 @@ msgstr[1] ""
msgid "Deploy Keys" msgid "Deploy Keys"
msgstr "" msgstr ""
msgid "Deploy freezes"
msgstr ""
msgid "Deploy key was successfully updated." msgid "Deploy key was successfully updated."
msgstr "" msgstr ""
...@@ -7817,6 +7832,24 @@ msgstr "" ...@@ -7817,6 +7832,24 @@ msgstr ""
msgid "DeployBoard|Matching on the %{appLabel} label has been removed for deploy boards. To see all instances on your board, you must update your chart and redeploy." msgid "DeployBoard|Matching on the %{appLabel} label has been removed for deploy boards. To see all instances on your board, you must update your chart and redeploy."
msgstr "" msgstr ""
msgid "DeployFreeze|Freeze end"
msgstr ""
msgid "DeployFreeze|Freeze start"
msgstr ""
msgid "DeployFreeze|No deploy freezes exist for this project. To add one, click %{strongStart}Add deploy freeze%{strongEnd}"
msgstr ""
msgid "DeployFreeze|Specify times when deployments are not allowed for an environment. The <code>gitlab-ci.yml</code> file must be updated to make deployment jobs aware of the %{freeze_period_link_start}freeze period%{freeze_period_link_end}."
msgstr ""
msgid "DeployFreeze|Time zone"
msgstr ""
msgid "DeployFreeze|You can specify deploy freezes using only %{cron_syntax_link_start}cron syntax%{cron_syntax_link_end}."
msgstr ""
msgid "DeployKeys|+%{count} others" msgid "DeployKeys|+%{count} others"
msgstr "" msgstr ""
...@@ -9505,6 +9538,9 @@ msgstr "" ...@@ -9505,6 +9538,9 @@ msgstr ""
msgid "Error: %{error_message}" msgid "Error: %{error_message}"
msgstr "" msgstr ""
msgid "Error: Unable to create deploy freeze"
msgstr ""
msgid "ErrorTracking|Active" msgid "ErrorTracking|Active"
msgstr "" msgstr ""
...@@ -10564,6 +10600,12 @@ msgstr "" ...@@ -10564,6 +10600,12 @@ msgstr ""
msgid "Free Trial of GitLab.com Gold" msgid "Free Trial of GitLab.com Gold"
msgstr "" msgstr ""
msgid "Freeze end"
msgstr ""
msgid "Freeze start"
msgstr ""
msgid "Frequency" msgid "Frequency"
msgstr "" msgstr ""
...@@ -21165,6 +21207,9 @@ msgstr "" ...@@ -21165,6 +21207,9 @@ msgstr ""
msgid "Select timeframe" msgid "Select timeframe"
msgstr "" msgstr ""
msgid "Select timezone"
msgstr ""
msgid "Select user" msgid "Select user"
msgstr "" msgstr ""
...@@ -23813,6 +23858,9 @@ msgstr "" ...@@ -23813,6 +23858,9 @@ msgstr ""
msgid "There was an error fetching the Node's Groups" msgid "There was an error fetching the Node's Groups"
msgstr "" msgstr ""
msgid "There was an error fetching the deploy freezes."
msgstr ""
msgid "There was an error fetching the environments information." msgid "There was an error fetching the environments information."
msgstr "" msgstr ""
...@@ -23948,6 +23996,9 @@ msgstr "" ...@@ -23948,6 +23996,9 @@ msgstr ""
msgid "This %{viewer} could not be displayed because %{reason}. You can %{options} instead." msgid "This %{viewer} could not be displayed because %{reason}. You can %{options} instead."
msgstr "" msgstr ""
msgid "This Cron pattern is invalid"
msgstr ""
msgid "This GitLab instance does not provide any shared Runners yet. Instance administrators can register shared Runners in the admin area." msgid "This GitLab instance does not provide any shared Runners yet. Instance administrators can register shared Runners in the admin area."
msgstr "" msgstr ""
......
...@@ -842,4 +842,53 @@ describe('Api', () => { ...@@ -842,4 +842,53 @@ describe('Api', () => {
.catch(done.fail); .catch(done.fail);
}); });
}); });
describe('freezePeriods', () => {
it('fetches freezePeriods', () => {
const projectId = 8;
const freezePeriod = {
id: 3,
freeze_start: '5 4 * * *',
freeze_end: '5 9 * 8 *',
cron_timezone: 'America/New_York',
created_at: '2020-07-10T05:10:35.122Z',
updated_at: '2020-07-10T05:10:35.122Z',
};
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/freeze_periods`;
mock.onGet(expectedUrl).reply(200, [freezePeriod]);
return Api.freezePeriods(projectId).then(({ data }) => {
expect(data[0]).toStrictEqual(freezePeriod);
});
});
});
describe('createFreezePeriod', () => {
const projectId = 8;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/freeze_periods`;
const options = {
freeze_start: '* * * * *',
freeze_end: '* * * * *',
cron_timezone: 'America/Juneau',
};
const expectedResult = {
id: 10,
freeze_start: '* * * * *',
freeze_end: '* * * * *',
cron_timezone: 'America/Juneau',
created_at: '2020-07-11T07:04:50.153Z',
updated_at: '2020-07-11T07:04:50.153Z',
};
describe('when the freeze period is successfully created', () => {
it('resolves the Promise', () => {
mock.onPost(expectedUrl, options).replyOnce(201, expectedResult);
return Api.createFreezePeriod(projectId, options).then(({ data }) => {
expect(data).toStrictEqual(expectedResult);
});
});
});
});
}); });
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlDeprecatedButton, GlModal } from '@gitlab/ui';
import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vue';
import DeployFreezeTimezoneDropdown from '~/deploy_freeze/components/deploy_freeze_timezone_dropdown.vue';
import createStore from '~/deploy_freeze/store';
import { mockDeployFreezePayload, mockTimezoneData } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Deploy freeze modal', () => {
let wrapper;
let store;
beforeEach(() => {
store = createStore({
projectId: '8',
timezoneData: mockTimezoneData,
});
wrapper = shallowMount(DeployFreezeModal, {
attachToDocument: true,
stubs: {
GlModal,
},
localVue,
store,
});
});
const findModal = () => wrapper.find(GlModal);
const addDeployFreezeButton = () =>
findModal()
.findAll(GlDeprecatedButton)
.at(1);
const setInput = (freezeStartCron, freezeEndCron, selectedTimezone) => {
store.state.freezeStartCron = freezeStartCron;
store.state.freezeEndCron = freezeEndCron;
store.state.selectedTimezone = selectedTimezone;
wrapper.find('#deploy-freeze-start').trigger('input');
wrapper.find('#deploy-freeze-end').trigger('input');
wrapper.find(DeployFreezeTimezoneDropdown).trigger('input');
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('Basic interactions', () => {
it('button is disabled when freeze period is invalid', () => {
expect(addDeployFreezeButton().attributes('disabled')).toBeTruthy();
});
});
describe('Adding a new deploy freeze', () => {
beforeEach(() => {
const { freeze_start, freeze_end, cron_timezone } = mockDeployFreezePayload;
setInput(freeze_start, freeze_end, cron_timezone);
});
it('button is enabled when valid freeze period settings are present', () => {
expect(addDeployFreezeButton().attributes('disabled')).toBeUndefined();
});
});
describe('Validations', () => {
describe('when the cron state is invalid', () => {
beforeEach(() => {
setInput('invalid cron', 'invalid cron', 'invalid timezone');
});
it('disables the add deploy freeze button', () => {
expect(addDeployFreezeButton().attributes('disabled')).toBeTruthy();
});
});
describe('when the cron state is valid', () => {
beforeEach(() => {
const { freeze_start, freeze_end, cron_timezone } = mockDeployFreezePayload;
setInput(freeze_start, freeze_end, cron_timezone);
});
it('does not disable the submit button', () => {
expect(addDeployFreezeButton().attributes('disabled')).toBeFalsy();
});
});
});
});
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import DeployFreezeSettings from '~/deploy_freeze/components/deploy_freeze_settings.vue';
import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue';
import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vue';
import createStore from '~/deploy_freeze/store';
import { mockTimezoneData } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Deploy freeze settings', () => {
let wrapper;
let store;
beforeEach(() => {
store = createStore({
projectId: '8',
timezoneData: mockTimezoneData,
});
jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = shallowMount(DeployFreezeSettings, {
localVue,
store,
});
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('Deploy freeze table contains components', () => {
it('contains deploy freeze table', () => {
expect(wrapper.find(DeployFreezeTable).exists()).toBe(true);
});
it('contains deploy freeze modal', () => {
expect(wrapper.find(DeployFreezeModal).exists()).toBe(true);
});
});
});
import Vuex from 'vuex';
import { createLocalVue, mount } from '@vue/test-utils';
import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue';
import createStore from '~/deploy_freeze/store';
import { mockFreezePeriods, mockTimezoneData } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Deploy freeze table', () => {
let wrapper;
let store;
const createComponent = () => {
store = createStore({
projectId: '8',
timezoneData: mockTimezoneData,
});
jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = mount(DeployFreezeTable, {
attachToDocument: true,
localVue,
store,
});
};
const findEmptyFreezePeriods = () => wrapper.find('[data-testid="empty-freeze-periods"]');
const findAddDeployFreezeButton = () => wrapper.find('[data-testid="add-deploy-freeze"]');
const findDeployFreezeTable = () => wrapper.find('[data-testid="deploy-freeze-table"]');
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('dispatches fetchFreezePeriods when mounted', () => {
expect(store.dispatch).toHaveBeenCalledWith('fetchFreezePeriods');
});
describe('Renders correct data', () => {
it('displays empty', () => {
expect(findEmptyFreezePeriods().exists()).toBe(true);
expect(findEmptyFreezePeriods().text()).toBe(
'No deploy freezes exist for this project. To add one, click Add deploy freeze',
);
});
it('displays data', () => {
store.state.freezePeriods = mockFreezePeriods;
return wrapper.vm.$nextTick(() => {
const tableRows = findDeployFreezeTable().findAll('tbody tr');
expect(tableRows.length).toBe(mockFreezePeriods.length);
expect(findEmptyFreezePeriods().exists()).toBe(false);
});
});
});
describe('Table click actions', () => {
it('displays add deploy freeze button', () => {
expect(findAddDeployFreezeButton().exists()).toBe(true);
expect(findAddDeployFreezeButton().text()).toBe('Add deploy freeze');
});
});
});
import Vuex from 'vuex';
import DeployFreezeTimezoneDropdown from '~/deploy_freeze/components/deploy_freeze_timezone_dropdown.vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createStore from '~/deploy_freeze/store';
import { mockTimezoneData } from '../mock_data';
import { GlDropdownItem } from '@gitlab/ui';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Deploy freeze timezone dropdown', () => {
let wrapper;
let store;
const createComponent = term => {
store = createStore({
projectId: '8',
timezoneData: mockTimezoneData,
});
store.state.timezoneData = mockTimezoneData;
wrapper = shallowMount(DeployFreezeTimezoneDropdown, {
store,
localVue,
propsData: {
value: term,
timezoneData: mockTimezoneData,
},
});
};
const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findDropdownItemByIndex = index => wrapper.findAll(GlDropdownItem).at(index);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('No enviroments found', () => {
beforeEach(() => {
createComponent('UTC timezone');
});
it('renders empty results message', () => {
expect(findDropdownItemByIndex(0).text()).toBe('No matching results');
});
});
describe('Search term is empty', () => {
beforeEach(() => {
createComponent('');
});
it('renders all timezones when search term is empty', () => {
expect(findAllDropdownItems()).toHaveLength(mockTimezoneData.length);
});
});
describe('Time zones found', () => {
beforeEach(() => {
createComponent('Alaska');
});
it('renders only the time zone searched for', () => {
expect(findAllDropdownItems()).toHaveLength(1);
expect(findDropdownItemByIndex(0).text()).toBe('[UTC -8] Alaska');
});
it('should not display empty results message', () => {
expect(wrapper.find({ ref: 'noMatchingResults' }).exists()).toBe(false);
});
describe('Custom events', () => {
it('should emit selectTimezone if an environment is clicked', () => {
findDropdownItemByIndex(0).vm.$emit('click');
expect(wrapper.emitted('selectTimezone')).toEqual([
[
{
formattedTimezone: '[UTC -8] Alaska',
identifier: 'America/Juneau',
},
],
]);
});
});
});
});
This diff is collapsed.
import Api from '~/api';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import getInitialState from '~/deploy_freeze/store/state';
import * as actions from '~/deploy_freeze/store/actions';
import * as types from '~/deploy_freeze/store/mutation_types';
import { mockTimezoneData, mockFreezePeriods } from '../mock_data';
jest.mock('~/api.js');
jest.mock('~/flash.js');
describe('deploy freeze store actions', () => {
let mock;
let state;
beforeEach(() => {
mock = new MockAdapter(axios);
state = getInitialState({
projectId: '8',
timezoneData: mockTimezoneData,
});
Api.freezePeriods.mockResolvedValue({ data: mockFreezePeriods });
Api.createFreezePeriod.mockResolvedValue();
});
afterEach(() => {
mock.restore();
});
describe('setSelectedTimezone', () => {
it('commits SET_SELECTED_TIMEZONE mutation', () => {
testAction(actions.setSelectedTimezone, {}, {}, [
{
payload: {},
type: types.SET_SELECTED_TIMEZONE,
},
]);
});
});
describe('setFreezeStartCron', () => {
it('commits SET_FREEZE_START_CRON mutation', () => {
testAction(actions.setFreezeStartCron, {}, {}, [
{
type: types.SET_FREEZE_START_CRON,
},
]);
});
});
describe('setFreezeEndCron', () => {
it('commits SET_FREEZE_END_CRON mutation', () => {
testAction(actions.setFreezeEndCron, {}, {}, [
{
type: types.SET_FREEZE_END_CRON,
},
]);
});
});
describe('addFreezePeriod', () => {
it('dispatch correct actions on adding a freeze period', () => {
testAction(
actions.addFreezePeriod,
{},
state,
[{ type: 'RESET_MODAL' }],
[
{ type: 'requestAddFreezePeriod' },
{ type: 'receiveAddFreezePeriodSuccess' },
{ type: 'fetchFreezePeriods' },
],
);
});
it('should show flash error and set error in state on add failure', () => {
Api.createFreezePeriod.mockRejectedValue();
testAction(
actions.addFreezePeriod,
{},
state,
[],
[{ type: 'requestAddFreezePeriod' }, { type: 'receiveAddFreezePeriodError' }],
() => expect(createFlash).toHaveBeenCalled(),
);
});
});
describe('fetchFreezePeriods', () => {
it('dispatch correct actions on fetchFreezePeriods', () => {
testAction(
actions.fetchFreezePeriods,
{},
state,
[],
[
{ type: 'requestFreezePeriods' },
{ type: 'receiveFreezePeriodsSuccess', payload: mockFreezePeriods },
],
);
});
it('should show flash error and set error in state on fetch variables failure', () => {
Api.freezePeriods.mockRejectedValue();
testAction(
actions.fetchFreezePeriods,
{},
state,
[],
[{ type: 'requestFreezePeriods' }],
() =>
expect(createFlash).toHaveBeenCalledWith(
'There was an error fetching the deploy freezes.',
),
);
});
});
});
import state from '~/deploy_freeze/store/state';
import mutations from '~/deploy_freeze/store/mutations';
import * as types from '~/deploy_freeze/store/mutation_types';
import { mockFreezePeriods, mockTimezoneData } from '../mock_data';
describe('CI variable list mutations', () => {
let stateCopy;
beforeEach(() => {
stateCopy = state({
projectId: '8',
timezoneData: mockTimezoneData,
});
});
describe('RESET_MODAL', () => {
it('should reset modal state', () => {
mutations[types.RESET_MODAL](stateCopy);
expect(stateCopy.freezeStartCron).toBe('');
expect(stateCopy.freezeEndCron).toBe('');
expect(stateCopy.selectedTimezone).toBe('');
expect(stateCopy.selectedTimezoneIdentifier).toBe('');
});
});
describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => {
it('should set environments', () => {
mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, mockFreezePeriods);
expect(stateCopy.freezePeriods).toEqual(mockFreezePeriods);
});
});
describe('SET_SELECTED_TIMEZONE', () => {
it('should set the cron timezone', () => {
const timezone = {
formattedTimezone: '[UTC -7] Pacific Time (US & Canada)',
identifier: 'America/Los_Angeles',
};
mutations[types.SET_SELECTED_TIMEZONE](stateCopy, timezone);
expect(stateCopy.selectedTimezone).toEqual(timezone.formattedTimezone);
expect(stateCopy.selectedTimezoneIdentifier).toEqual(timezone.identifier);
});
});
describe('SET_FREEZE_START_CRON', () => {
it('should set freezeStartCron', () => {
mutations[types.SET_FREEZE_START_CRON](stateCopy, '5 0 * 8 *');
expect(stateCopy.freezeStartCron).toBe('5 0 * 8 *');
});
});
describe('SET_FREEZE_ENDT_CRON', () => {
it('should set freezeEndCron', () => {
mutations[types.SET_FREEZE_END_CRON](stateCopy, '5 0 * 8 *');
expect(stateCopy.freezeEndCron).toBe('5 0 * 8 *');
});
});
});
...@@ -3314,6 +3314,11 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: ...@@ -3314,6 +3314,11 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
sha.js "^2.4.8" sha.js "^2.4.8"
cron-validator@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/cron-validator/-/cron-validator-1.1.1.tgz#0a27bb75508c7bc03c8b840d2d9f170eeacb5615"
integrity sha512-vfZb05w/wezuwPZBDvdIBmJp2BvuJExHeyKRa5oBqD2ZDXR61hb3QgPc/3ZhBEQJlAy8Jlnn5XC/JCT3IDqxwg==
cropper@^2.3.0: cropper@^2.3.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/cropper/-/cropper-2.3.0.tgz#607461d4e7aa7a7fe15a26834b14b7f0c2801562" resolved "https://registry.yarnpkg.com/cropper/-/cropper-2.3.0.tgz#607461d4e7aa7a7fe15a26834b14b7f0c2801562"
......
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