Commit a88e6f92 authored by Mike Lewis's avatar Mike Lewis

Merge branch 'master' into updating-slack-config-helptext

parents ad1d082a d6dc0201
<script> <script>
import { GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui'; import { GlTooltipDirective, GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default { export default {
name: 'ResolveWithIssueButton', name: 'ResolveWithIssueButton',
components: { components: {
Icon, GlButton,
GlDeprecatedButton,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -22,13 +20,12 @@ export default { ...@@ -22,13 +20,12 @@ export default {
<template> <template>
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<gl-deprecated-button <gl-button
v-gl-tooltip v-gl-tooltip
:href="url" :href="url"
:title="s__('MergeRequests|Resolve this thread in a new issue')" :title="s__('MergeRequests|Resolve this thread in a new issue')"
class="new-issue-for-discussion discussion-create-issue-btn" class="new-issue-for-discussion discussion-create-issue-btn"
> icon="issue-new"
<icon name="issue-new" /> />
</gl-deprecated-button>
</div> </div>
</template> </template>
...@@ -23,7 +23,6 @@ import { ...@@ -23,7 +23,6 @@ import {
commentLineOptions, commentLineOptions,
formatLineRange, formatLineRange,
} from './multiline_comment_utils'; } from './multiline_comment_utils';
import MultilineCommentForm from './multiline_comment_form.vue';
export default { export default {
name: 'NoteableNote', name: 'NoteableNote',
...@@ -34,7 +33,6 @@ export default { ...@@ -34,7 +33,6 @@ export default {
noteActions, noteActions,
NoteBody, NoteBody,
TimelineEntryItem, TimelineEntryItem,
MultilineCommentForm,
}, },
mixins: [noteable, resolvable, glFeatureFlagsMixin()], mixins: [noteable, resolvable, glFeatureFlagsMixin()],
props: { props: {
...@@ -147,14 +145,16 @@ export default { ...@@ -147,14 +145,16 @@ export default {
return getEndLineNumber(this.lineRange); return getEndLineNumber(this.lineRange);
}, },
showMultiLineComment() { showMultiLineComment() {
if (!this.glFeatures.multilineComments || !this.discussionRoot) return false; if (
if (this.isEditing) return true; !this.glFeatures.multilineComments ||
!this.discussionRoot ||
this.startLineNumber.length === 0 ||
this.endLineNumber.length === 0
)
return false;
return this.line && this.startLineNumber !== this.endLineNumber; return this.line && this.startLineNumber !== this.endLineNumber;
}, },
showMultilineCommentForm() {
return Boolean(this.isEditing && this.note.position && this.diffFile && this.line);
},
commentLineOptions() { commentLineOptions() {
const sideA = this.line.type === 'new' ? 'right' : 'left'; const sideA = this.line.type === 'new' ? 'right' : 'left';
const sideB = sideA === 'left' ? 'right' : 'left'; const sideB = sideA === 'left' ? 'right' : 'left';
...@@ -344,28 +344,19 @@ export default { ...@@ -344,28 +344,19 @@ export default {
:data-note-id="note.id" :data-note-id="note.id"
class="note note-wrapper qa-noteable-note-item" class="note note-wrapper qa-noteable-note-item"
> >
<div v-if="showMultiLineComment" data-testid="multiline-comment"> <div
<multiline-comment-form v-if="showMultiLineComment"
v-if="showMultilineCommentForm" data-testid="multiline-comment"
v-model="commentLineStart" class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3"
:line="line" >
:comment-line-options="commentLineOptions" <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
:line-range="note.position.line_range" <template #startLine>
class="gl-mb-3 gl-text-gray-700 gl-pb-3" <span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span>
/> </template>
<div <template #endLine>
v-else <span :class="getLineClasses(endLineNumber)">{{ endLineNumber }}</span>
class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" </template>
> </gl-sprintf>
<gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
<template #startLine>
<span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span>
</template>
<template #endLine>
<span :class="getLineClasses(endLineNumber)">{{ endLineNumber }}</span>
</template>
</gl-sprintf>
</div>
</div> </div>
<div v-once class="timeline-icon"> <div v-once class="timeline-icon">
<user-avatar-link <user-avatar-link
......
...@@ -3,7 +3,6 @@ import { mapState, mapActions, mapGetters } from 'vuex'; ...@@ -3,7 +3,6 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { BACK_URL_PARAM } from '~/releases/constants'; import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils'; import { getParameterByName } from '~/lib/utils/common_utils';
import AssetLinksForm from './asset_links_form.vue'; import AssetLinksForm from './asset_links_form.vue';
...@@ -22,9 +21,6 @@ export default { ...@@ -22,9 +21,6 @@ export default {
MilestoneCombobox, MilestoneCombobox,
TagField, TagField,
}, },
directives: {
autofocusonshow,
},
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
computed: { computed: {
...mapState('detail', [ ...mapState('detail', [
...@@ -40,9 +36,9 @@ export default { ...@@ -40,9 +36,9 @@ export default {
'manageMilestonesPath', 'manageMilestonesPath',
'projectId', 'projectId',
]), ]),
...mapGetters('detail', ['isValid']), ...mapGetters('detail', ['isValid', 'isExistingRelease']),
showForm() { showForm() {
return !this.isFetchingRelease && !this.fetchError; return Boolean(!this.isFetchingRelease && !this.fetchError && this.release);
}, },
subtitleText() { subtitleText() {
return sprintf( return sprintf(
...@@ -86,6 +82,9 @@ export default { ...@@ -86,6 +82,9 @@ export default {
showAssetLinksForm() { showAssetLinksForm() {
return this.glFeatures.releaseAssetLinkEditing; return this.glFeatures.releaseAssetLinkEditing;
}, },
saveButtonLabel() {
return this.isExistingRelease ? __('Save changes') : __('Create release');
},
isSaveChangesDisabled() { isSaveChangesDisabled() {
return this.isUpdatingRelease || !this.isValid; return this.isUpdatingRelease || !this.isValid;
}, },
...@@ -102,13 +101,17 @@ export default { ...@@ -102,13 +101,17 @@ export default {
]; ];
}, },
}, },
created() { mounted() {
this.fetchRelease(); // eslint-disable-next-line promise/catch-or-return
this.initializeRelease().then(() => {
// Focus the first non-disabled input element
this.$el.querySelector('input:enabled').focus();
});
}, },
methods: { methods: {
...mapActions('detail', [ ...mapActions('detail', [
'fetchRelease', 'initializeRelease',
'updateRelease', 'saveRelease',
'updateReleaseTitle', 'updateReleaseTitle',
'updateReleaseNotes', 'updateReleaseNotes',
'updateReleaseMilestones', 'updateReleaseMilestones',
...@@ -119,7 +122,7 @@ export default { ...@@ -119,7 +122,7 @@ export default {
<template> <template>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<p class="pt-3 js-subtitle-text" v-html="subtitleText"></p> <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
<form v-if="showForm" @submit.prevent="updateRelease()"> <form v-if="showForm" @submit.prevent="saveRelease()">
<tag-field /> <tag-field />
<gl-form-group> <gl-form-group>
<label for="release-title">{{ __('Release title') }}</label> <label for="release-title">{{ __('Release title') }}</label>
...@@ -127,8 +130,6 @@ export default { ...@@ -127,8 +130,6 @@ export default {
id="release-title" id="release-title"
ref="releaseTitleInput" ref="releaseTitleInput"
v-model="releaseTitle" v-model="releaseTitle"
v-autofocusonshow
autofocus
type="text" type="text"
class="form-control" class="form-control"
/> />
...@@ -162,8 +163,8 @@ export default { ...@@ -162,8 +163,8 @@ export default {
data-supports-quick-actions="false" data-supports-quick-actions="false"
:aria-label="__('Release notes')" :aria-label="__('Release notes')"
:placeholder="__('Write your release notes or drag your files here…')" :placeholder="__('Write your release notes or drag your files here…')"
@keydown.meta.enter="updateRelease()" @keydown.meta.enter="saveRelease()"
@keydown.ctrl.enter="updateRelease()" @keydown.ctrl.enter="saveRelease()"
></textarea> ></textarea>
</template> </template>
</markdown-field> </markdown-field>
...@@ -178,10 +179,11 @@ export default { ...@@ -178,10 +179,11 @@ export default {
category="primary" category="primary"
variant="success" variant="success"
type="submit" type="submit"
:aria-label="__('Save changes')"
:disabled="isSaveChangesDisabled" :disabled="isSaveChangesDisabled"
>{{ __('Save changes') }}</gl-button data-testid="submit-button"
> >
{{ saveButtonLabel }}
</gl-button>
<gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button> <gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button>
</div> </div>
</form> </form>
......
...@@ -3,76 +3,114 @@ import api from '~/api'; ...@@ -3,76 +3,114 @@ import api from '~/api';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import { import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase, export const initializeRelease = ({ commit, dispatch, getters }) => {
} from '~/lib/utils/common_utils'; if (getters.isExistingRelease) {
// When editing an existing release,
export const requestRelease = ({ commit }) => commit(types.REQUEST_RELEASE); // fetch the release object from the API
export const receiveReleaseSuccess = ({ commit }, data) => return dispatch('fetchRelease');
commit(types.RECEIVE_RELEASE_SUCCESS, data); }
export const receiveReleaseError = ({ commit }, error) => {
commit(types.RECEIVE_RELEASE_ERROR, error); // When creating a new release, initialize the
createFlash(s__('Release|Something went wrong while getting the release details')); // store with an empty release object
commit(types.INITIALIZE_EMPTY_RELEASE);
return Promise.resolve();
}; };
export const fetchRelease = ({ dispatch, state }) => { export const fetchRelease = ({ commit, state }) => {
dispatch('requestRelease'); commit(types.REQUEST_RELEASE);
return api return api
.release(state.projectId, state.tagName) .release(state.projectId, state.tagName)
.then(({ data }) => { .then(({ data }) => {
const release = { commit(types.RECEIVE_RELEASE_SUCCESS, apiJsonToRelease(data));
...data,
milestones: data.milestones || [],
};
dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true }));
}) })
.catch(error => { .catch(error => {
dispatch('receiveReleaseError', error); commit(types.RECEIVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while getting the release details'));
}); });
}; };
export const updateReleaseTagName = ({ commit }, tagName) => export const updateReleaseTagName = ({ commit }, tagName) =>
commit(types.UPDATE_RELEASE_TAG_NAME, tagName); commit(types.UPDATE_RELEASE_TAG_NAME, tagName);
export const updateCreateFrom = ({ commit }, createFrom) => export const updateCreateFrom = ({ commit }, createFrom) =>
commit(types.UPDATE_CREATE_FROM, createFrom); commit(types.UPDATE_CREATE_FROM, createFrom);
export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title); export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes); export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
export const updateReleaseMilestones = ({ commit }, milestones) => export const updateReleaseMilestones = ({ commit }, milestones) =>
commit(types.UPDATE_RELEASE_MILESTONES, milestones); commit(types.UPDATE_RELEASE_MILESTONES, milestones);
export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE); export const addEmptyAssetLink = ({ commit }) => {
export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => { commit(types.ADD_EMPTY_ASSET_LINK);
commit(types.RECEIVE_UPDATE_RELEASE_SUCCESS);
redirectTo(
rootState.featureFlags.releaseShowPage ? state.release._links.self : state.releasesPagePath,
);
}; };
export const receiveUpdateReleaseError = ({ commit }, error) => {
commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error); export const updateAssetLinkUrl = ({ commit }, { linkIdToUpdate, newUrl }) => {
createFlash(s__('Release|Something went wrong while saving the release details')); commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl });
}; };
export const updateRelease = ({ dispatch, state, getters }) => { export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => {
dispatch('requestUpdateRelease'); commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName });
};
const { release } = state; export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => {
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : []; commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType });
};
export const removeAssetLink = ({ commit }, linkIdToRemove) => {
commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
};
export const receiveSaveReleaseSuccess = ({ commit, state, rootState }, release) => {
commit(types.RECEIVE_SAVE_RELEASE_SUCCESS);
redirectTo(rootState.featureFlags.releaseShowPage ? release._links.self : state.releasesPagePath);
};
export const saveRelease = ({ commit, dispatch, getters }) => {
commit(types.REQUEST_SAVE_RELEASE);
const updatedRelease = convertObjectPropsToSnakeCase( dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease');
};
export const createRelease = ({ commit, dispatch, state, getters }) => {
const apiJson = releaseToApiJson(
{ {
name: release.name, ...state.release,
description: release.description, assets: {
milestones, links: getters.releaseLinksToCreate,
},
}, },
{ deep: true }, state.createFrom,
); );
return api
.createRelease(state.projectId, apiJson)
.then(({ data }) => {
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(data));
})
.catch(error => {
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while creating a new release'));
});
};
export const updateRelease = ({ commit, dispatch, state, getters }) => {
const apiJson = releaseToApiJson({
...state.release,
assets: {
links: getters.releaseLinksToCreate,
},
});
let updatedRelease = null;
return ( return (
api api
.updateRelease(state.projectId, state.tagName, updatedRelease) .updateRelease(state.projectId, state.tagName, apiJson)
/** /**
* Currently, we delete all existing links and then * Currently, we delete all existing links and then
...@@ -90,54 +128,31 @@ export const updateRelease = ({ dispatch, state, getters }) => { ...@@ -90,54 +128,31 @@ export const updateRelease = ({ dispatch, state, getters }) => {
* https://gitlab.com/gitlab-org/gitlab/-/issues/208702 * https://gitlab.com/gitlab-org/gitlab/-/issues/208702
* is closed. * is closed.
*/ */
.then(({ data }) => {
// Save this response since we need it later in the Promise chain
updatedRelease = data;
.then(() => {
// Delete all links currently associated with this Release // Delete all links currently associated with this Release
return Promise.all( return Promise.all(
getters.releaseLinksToDelete.map(l => getters.releaseLinksToDelete.map(l =>
api.deleteReleaseLink(state.projectId, release.tagName, l.id), api.deleteReleaseLink(state.projectId, state.release.tagName, l.id),
), ),
); );
}) })
.then(() => { .then(() => {
// Create a new link for each link in the form // Create a new link for each link in the form
return Promise.all( return Promise.all(
getters.releaseLinksToCreate.map(l => apiJson.assets.links.map(l =>
api.createReleaseLink( api.createReleaseLink(state.projectId, state.release.tagName, l),
state.projectId,
release.tagName,
convertObjectPropsToSnakeCase(l, { deep: true }),
),
), ),
); );
}) })
.then(() => dispatch('receiveUpdateReleaseSuccess')) .then(() => {
dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease));
})
.catch(error => { .catch(error => {
dispatch('receiveUpdateReleaseError', error); commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while saving the release details'));
}) })
); );
}; };
export const navigateToReleasesPage = ({ state }) => {
redirectTo(state.releasesPagePath);
};
export const addEmptyAssetLink = ({ commit }) => {
commit(types.ADD_EMPTY_ASSET_LINK);
};
export const updateAssetLinkUrl = ({ commit }, { linkIdToUpdate, newUrl }) => {
commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl });
};
export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => {
commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName });
};
export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => {
commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType });
};
export const removeAssetLink = ({ commit }, linkIdToRemove) => {
commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
};
...@@ -6,7 +6,7 @@ import { hasContent } from '~/lib/utils/text_utility'; ...@@ -6,7 +6,7 @@ import { hasContent } from '~/lib/utils/text_utility';
* `false` if the app is creating a new release. * `false` if the app is creating a new release.
*/ */
export const isExistingRelease = state => { export const isExistingRelease = state => {
return Boolean(state.originalRelease); return Boolean(state.tagName);
}; };
/** /**
......
export const INITIALIZE_EMPTY_RELEASE = 'INITIALIZE_EMPTY_RELEASE';
export const REQUEST_RELEASE = 'REQUEST_RELEASE'; export const REQUEST_RELEASE = 'REQUEST_RELEASE';
export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS'; export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS';
export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR'; export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
...@@ -8,9 +10,9 @@ export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE'; ...@@ -8,9 +10,9 @@ export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES'; export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES'; export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE'; export const REQUEST_SAVE_RELEASE = 'REQUEST_SAVE_RELEASE';
export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS'; export const RECEIVE_SAVE_RELEASE_SUCCESS = 'RECEIVE_SAVE_RELEASE_SUCCESS';
export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR'; export const RECEIVE_SAVE_RELEASE_ERROR = 'RECEIVE_SAVE_RELEASE_ERROR';
export const ADD_EMPTY_ASSET_LINK = 'ADD_EMPTY_ASSET_LINK'; export const ADD_EMPTY_ASSET_LINK = 'ADD_EMPTY_ASSET_LINK';
export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL'; export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL';
......
...@@ -7,6 +7,18 @@ const findReleaseLink = (release, id) => { ...@@ -7,6 +7,18 @@ const findReleaseLink = (release, id) => {
}; };
export default { export default {
[types.INITIALIZE_EMPTY_RELEASE](state) {
state.release = {
tagName: null,
name: '',
description: '',
milestones: [],
assets: {
links: [],
},
};
},
[types.REQUEST_RELEASE](state) { [types.REQUEST_RELEASE](state) {
state.isFetchingRelease = true; state.isFetchingRelease = true;
}, },
...@@ -39,14 +51,14 @@ export default { ...@@ -39,14 +51,14 @@ export default {
state.release.milestones = milestones; state.release.milestones = milestones;
}, },
[types.REQUEST_UPDATE_RELEASE](state) { [types.REQUEST_SAVE_RELEASE](state) {
state.isUpdatingRelease = true; state.isUpdatingRelease = true;
}, },
[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state) { [types.RECEIVE_SAVE_RELEASE_SUCCESS](state) {
state.updateError = undefined; state.updateError = undefined;
state.isUpdatingRelease = false; state.isUpdatingRelease = false;
}, },
[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error) { [types.RECEIVE_SAVE_RELEASE_ERROR](state, error) {
state.updateError = error; state.updateError = error;
state.isUpdatingRelease = false; state.isUpdatingRelease = false;
}, },
......
import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
/**
* Converts a release object into a JSON object that can sent to the public
* API to create or update a release.
* @param {Object} release The release object to convert
* @param {string} createFrom The ref to create a new tag from, if necessary
*/
export const releaseToApiJson = (release, createFrom = null) => {
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
return convertObjectPropsToSnakeCase(
{
tagName: release.tagName,
ref: createFrom,
name: release.name,
description: release.description,
milestones,
assets: release.assets,
},
{ deep: true },
);
};
/**
* Converts a JSON release object returned by the Release API
* into the structure this Vue application can work with.
* @param {Object} json The JSON object received from the release API
*/
export const apiJsonToRelease = json => {
const release = convertObjectPropsToCamelCase(json, { deep: true });
release.milestones = release.milestones || [];
return release;
};
...@@ -12,7 +12,8 @@ module TriggerableHooks ...@@ -12,7 +12,8 @@ module TriggerableHooks
merge_request_hooks: :merge_requests_events, merge_request_hooks: :merge_requests_events,
job_hooks: :job_events, job_hooks: :job_events,
pipeline_hooks: :pipeline_events, pipeline_hooks: :pipeline_events,
wiki_page_hooks: :wiki_page_events wiki_page_hooks: :wiki_page_events,
deployment_hooks: :deployment_events
}.freeze }.freeze
extend ActiveSupport::Concern extend ActiveSupport::Concern
......
...@@ -148,6 +148,7 @@ class Deployment < ApplicationRecord ...@@ -148,6 +148,7 @@ class Deployment < ApplicationRecord
def execute_hooks def execute_hooks
deployment_data = Gitlab::DataBuilder::Deployment.build(self) deployment_data = Gitlab::DataBuilder::Deployment.build(self)
project.execute_hooks(deployment_data, :deployment_hooks) if Feature.enabled?(:deployment_webhooks, project)
project.execute_services(deployment_data, :deployment_hooks) project.execute_services(deployment_data, :deployment_hooks)
end end
......
...@@ -17,7 +17,8 @@ class ProjectHook < WebHook ...@@ -17,7 +17,8 @@ class ProjectHook < WebHook
:merge_request_hooks, :merge_request_hooks,
:job_hooks, :job_hooks,
:pipeline_hooks, :pipeline_hooks,
:wiki_page_hooks :wiki_page_hooks,
:deployment_hooks
] ]
belongs_to :project belongs_to :project
......
...@@ -75,8 +75,6 @@ module Git ...@@ -75,8 +75,6 @@ module Git
end end
def merge_request_branches_for(changes) def merge_request_branches_for(changes)
return if Feature.disabled?(:refresh_only_existing_merge_requests_on_push, default_enabled: true)
@merge_requests_branches ||= MergeRequests::PushedBranchesService.new(project, current_user, changes: changes).execute @merge_requests_branches ||= MergeRequests::PushedBranchesService.new(project, current_user, changes: changes).execute
end end
end end
......
---
title: Fix multiline comment rendering
merge_request: 38721
author:
type: fixed
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: index, concepts, howto
---
# CI/CD development documentation # CI/CD development documentation
Development guides that are specific to CI/CD are listed here. Development guides that are specific to CI/CD are listed here.
......
---
stage: Release
group: Progressive Delivery
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: index, concepts, howto
---
# Development guide for GitLab CI/CD templates # Development guide for GitLab CI/CD templates
This document explains how to develop [GitLab CI/CD templates](../../ci/examples/README.md). This document explains how to develop [GitLab CI/CD templates](../../ci/examples/README.md).
......
--- ---
redirect_to: 'documentation/styleguide.md' redirect_to: 'documentation/styleguide.md'
--- ---
This document was moved to [another location](documentation/styleguide.md).
---
redirect_to: 'feature_flags/index.md'
---
This document was moved to [another location](feature_flags/index.md). This document was moved to [another location](feature_flags/index.md).
...@@ -91,7 +91,7 @@ export default { ...@@ -91,7 +91,7 @@ export default {
<template> <template>
<div class="audit-log-table" data-testid="audit-events-table" :data-qa-selector="qaSelector"> <div class="audit-log-table" data-testid="audit-events-table" :data-qa-selector="qaSelector">
<gl-table class="mt-3" :fields="$options.fields" :items="events" show-empty> <gl-table class="gl-mt-5" :fields="$options.fields" :items="events" show-empty stacked="md">
<template #cell(author)="{ value: { url, name } }"> <template #cell(author)="{ value: { url, name } }">
<url-table-cell :url="url" :name="name" /> <url-table-cell :url="url" :name="name" />
</template> </template>
......
...@@ -30,14 +30,14 @@ module Projects ...@@ -30,14 +30,14 @@ module Projects
def render_csv def render_csv
CsvBuilders::SingleBatch.new( CsvBuilders::SingleBatch.new(
::Gitlab::Ci::Parsers::Security::ScannedResources.new.scanned_resources_for_csv(@scanned_resources), @scanned_resources,
{ {
'Method': 'request_method', 'Method': 'request_method',
'Scheme': 'scheme', 'Scheme': 'url_scheme',
'Host': 'host', 'Host': 'url_host',
'Port': 'port', 'Port': 'url_port',
'Path': 'path', 'Path': 'url_path',
'Query String': 'query_string' 'Query String': 'url_query'
} }
).render ).render
end end
......
...@@ -9,22 +9,6 @@ module BillingPlansHelper ...@@ -9,22 +9,6 @@ module BillingPlansHelper
number_to_currency(value, unit: '$', strip_insignificant_zeros: true, format: "%u%n") number_to_currency(value, unit: '$', strip_insignificant_zeros: true, format: "%u%n")
end end
def current_plan?(plan)
plan.purchase_link&.action == 'current_plan'
end
def plan_purchase_link(href, link_text)
if href
link_to link_text, href, class: 'btn btn-success'
else
button_tag link_text, class: 'btn disabled'
end
end
def new_gitlab_com_trial_url
"#{EE::SUBSCRIPTIONS_URL}/trials/new?gl_com=true"
end
def subscription_plan_data_attributes(group, plan) def subscription_plan_data_attributes(group, plan)
return {} unless group return {} unless group
...@@ -36,26 +20,12 @@ module BillingPlansHelper ...@@ -36,26 +20,12 @@ module BillingPlansHelper
} }
end end
def plan_upgrade_url(group, plan)
return unless group && plan&.id
"#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/upgrade/#{plan.id}"
end
def use_new_purchase_flow?(namespace) def use_new_purchase_flow?(namespace)
namespace.group? && namespace.group? &&
namespace.actual_plan_name == Plan::FREE && namespace.actual_plan_name == Plan::FREE &&
Feature.enabled?(:free_group_new_purchase_flow, current_user) Feature.enabled?(:free_group_new_purchase_flow, current_user)
end end
def plan_purchase_url(group, plan)
if use_new_purchase_flow?(group)
new_subscriptions_path(plan_id: plan.id, namespace_id: group.id)
else
"#{plan.purchase_link.href}&gl_namespace_id=#{group.id}"
end
end
def show_contact_sales_button?(purchase_link_action) def show_contact_sales_button?(purchase_link_action)
experiment_enabled?(:contact_sales_btn_in_app) && experiment_enabled?(:contact_sales_btn_in_app) &&
purchase_link_action == 'upgrade' purchase_link_action == 'upgrade'
...@@ -89,4 +59,20 @@ module BillingPlansHelper ...@@ -89,4 +59,20 @@ module BillingPlansHelper
def namespace_for_user?(namespace) def namespace_for_user?(namespace)
namespace == current_user.namespace namespace == current_user.namespace
end end
private
def plan_purchase_url(group, plan)
if use_new_purchase_flow?(group)
new_subscriptions_path(plan_id: plan.id, namespace_id: group.id)
else
"#{plan.purchase_link.href}&gl_namespace_id=#{group.id}"
end
end
def plan_upgrade_url(group, plan)
return unless group && plan&.id
"#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/upgrade/#{plan.id}"
end
end end
...@@ -21,8 +21,8 @@ module Security ...@@ -21,8 +21,8 @@ module Security
scanned_resources = scanned_resources.first(@limit) if @limit scanned_resources = scanned_resources.first(@limit) if @limit
acc[type] = scanned_resources.map do |resource| acc[type] = scanned_resources.map do |resource|
{ {
'request_method' => resource['method'], 'request_method' => resource.request_method,
'url' => resource['url'] 'url' => resource.request_uri.to_s
} }
end end
end end
......
---
title: Audit events log table is now responsive to different display sizes
merge_request: 38803
author:
type: changed
...@@ -11,7 +11,8 @@ module Gitlab ...@@ -11,7 +11,8 @@ module Gitlab
report_data = parse_report(json_data) report_data = parse_report(json_data)
raise SecurityReportParserError, "Invalid report format" unless report_data.is_a?(Hash) raise SecurityReportParserError, "Invalid report format" unless report_data.is_a?(Hash)
report.scanned_resources = report_data.dig('scan', 'scanned_resources') || [] report.scanned_resources = create_scanned_resources(report_data.dig('scan', 'scanned_resources'))
create_scanner(report, report_data.dig('scan', 'scanner')) create_scanner(report, report_data.dig('scan', 'scanner'))
collate_remediations(report_data).each do |vulnerability| collate_remediations(report_data).each do |vulnerability|
...@@ -26,6 +27,17 @@ module Gitlab ...@@ -26,6 +27,17 @@ module Gitlab
protected protected
def create_scanned_resources(scanned_resources)
return [] unless scanned_resources
scanned_resources.map do |sr|
uri = URI.parse(sr['url'])
::Gitlab::Ci::Reports::Security::ScannedResource.new(uri, sr['method'])
rescue URI::InvalidURIError
nil
end.compact
end
def parse_report(json_data) def parse_report(json_data)
Gitlab::Json.parse!(json_data) Gitlab::Json.parse!(json_data)
end end
......
...@@ -13,21 +13,6 @@ module Gitlab ...@@ -13,21 +13,6 @@ module Gitlab
scanned_resources_sum scanned_resources_sum
end end
def scanned_resources_for_csv(scanned_resources)
scanned_resources.map do |sr|
uri = URI.parse(sr['url'] || '')
OpenStruct.new({
request_method: sr['method'],
scheme: uri.scheme,
host: uri.host,
port: uri.port,
path: uri.path,
query_string: uri.query,
raw_url: sr['url']
})
end
end
private private
def parse_report_json(blob) def parse_report_json(blob)
......
# frozen_string_literal: true
module Gitlab
module Ci
module Reports
module Security
class ScannedResource
include Gitlab::Utils::StrongMemoize
attr_reader :request_method
attr_reader :request_uri
delegate :scheme, :host, :port, :path, :query, to: :request_uri, prefix: :url
def initialize(uri, request_method)
raise ArgumentError unless uri.is_a?(URI)
@request_method = request_method
@request_uri = uri
end
end
end
end
end
end
...@@ -30,13 +30,6 @@ RSpec.describe Projects::Security::ScannedResourcesController do ...@@ -30,13 +30,6 @@ RSpec.describe Projects::Security::ScannedResourcesController do
end end
end end
it 'returns a CSV representation of the scanned resources' do
expect_next_instance_of(::Gitlab::Ci::Parsers::Security::ScannedResources) do |instance|
expect(instance).to receive(:scanned_resources_for_csv).and_return([])
end
expect(subject).to have_gitlab_http_status(:ok)
end
context 'when the pipeline id is missing' do context 'when the pipeline id is missing' do
let_it_be(:action_params) { { project_id: project, namespace_id: project.namespace } } let_it_be(:action_params) { { project_id: project, namespace_id: project.namespace } }
......
...@@ -3,20 +3,6 @@ ...@@ -3,20 +3,6 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe BillingPlansHelper do RSpec.describe BillingPlansHelper do
describe '#current_plan?' do
it 'returns true when current_plan' do
plan = Hashie::Mash.new(purchase_link: { action: 'current_plan' })
expect(helper.current_plan?(plan)).to be_truthy
end
it 'return false when not current_plan' do
plan = Hashie::Mash.new(purchase_link: { action: 'upgrade' })
expect(helper.current_plan?(plan)).to be_falsy
end
end
describe '#subscription_plan_data_attributes' do describe '#subscription_plan_data_attributes' do
let(:customer_portal_url) { "https://customers.gitlab.com/subscriptions" } let(:customer_portal_url) { "https://customers.gitlab.com/subscriptions" }
...@@ -60,8 +46,6 @@ RSpec.describe BillingPlansHelper do ...@@ -60,8 +46,6 @@ RSpec.describe BillingPlansHelper do
end end
describe '#use_new_purchase_flow?' do describe '#use_new_purchase_flow?' do
using RSpec::Parameterized::TableSyntax
where free_group_new_purchase: [true, false], where free_group_new_purchase: [true, false],
type: ['Group', nil], type: ['Group', nil],
plan: Plan.all_plans plan: Plan.all_plans
......
...@@ -24,67 +24,135 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do ...@@ -24,67 +24,135 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
expect(report.findings.map(&:confidence)).not_to include("undefined") expect(report.findings.map(&:confidence)).not_to include("undefined")
end end
context 'parsing scanned resources' do
describe 'when the URL is invalid' do
let(:raw_json) do
{
"vulnerabilities": [],
"remediations": [],
"dependency_files": [],
"scan": {
"scanned_resources": [
{
"method": "GET",
"type": "url",
"url": "not a URL"
}
]
}
}
end
it 'skips invalid URLs' do
parser.parse!(raw_json.to_json, report)
expect(report.scanned_resources).to be_empty
end
end
describe 'when the URLs are valid' do
let(:raw_json) do
{
"vulnerabilities": [],
"remediations": [],
"dependency_files": [],
"scan": {
"scanned_resources": [
{
"method": "GET",
"type": "url",
"url": "http://example.com/1"
},
{
"method": "POST",
"type": "url",
"url": "http://example.com/2"
},
{
"method": "GET",
"type": "url",
"url": "http://example.com/3"
}
]
}
}
end
it 'creates a scanned resource for each URL' do
parser.parse!(raw_json.to_json, report)
expect(report.scanned_resources.length).to eq(3)
end
it 'converts the JSON to Scanned Resource objects' do
parser.parse!(raw_json.to_json, report)
expect(report.scanned_resources.first).to be_a(::Gitlab::Ci::Reports::Security::ScannedResource)
end
end
end
context 'parsing remediations' do context 'parsing remediations' do
let(:raw_json) do let(:raw_json) do
{ {
"vulnerabilities": [ "vulnerabilities": [
{ {
"category": "dependency_scanning", "category": "dependency_scanning",
"name": "Vulnerabilities in libxml2", "name": "Vulnerabilities in libxml2",
"message": "Vulnerabilities in libxml2 in nokogiri", "message": "Vulnerabilities in libxml2 in nokogiri",
"description": "", "description": "",
"cve": "CVE-1020", "cve": "CVE-1020",
"severity": "High", "severity": "High",
"solution": "Upgrade to latest version.", "solution": "Upgrade to latest version.",
"scanner": { "id": "gemnasium", "name": "Gemnasium" }, "scanner": { "id": "gemnasium", "name": "Gemnasium" },
"location": {}, "location": {},
"identifiers": [], "identifiers": [],
"links": [{ "url": "" }] "links": [{ "url": "" }]
},
{
"id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3",
"category": "dependency_scanning",
"name": "Regular Expression Denial of Service",
"message": "Regular Expression Denial of Service in debug",
"description": "",
"cve": "CVE-1030",
"severity": "Unknown",
"solution": "Upgrade to latest versions.",
"scanner": {
"id": "gemnasium",
"name": "Gemnasium"
}, },
"location": {}, {
"identifiers": [], "id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3",
"links": [{ "url": "" }] "category": "dependency_scanning",
}, "name": "Regular Expression Denial of Service",
{ "message": "Regular Expression Denial of Service in debug",
"category": "dependency_scanning", "description": "",
"name": "Authentication bypass via incorrect DOM traversal and canonicalization", "cve": "CVE-1030",
"message": "Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js", "severity": "Unknown",
"description": "", "solution": "Upgrade to latest versions.",
"cve": "yarn/yarn.lock:saml2-js:gemnasium:9952e574-7b5b-46fa-a270-aeb694198a98", "scanner": {
"severity": "Unknown", "id": "gemnasium",
"solution": "Upgrade to fixed version.\r\n", "name": "Gemnasium"
},
"location": {},
"identifiers": [],
"links": [{ "url": "" }]
},
{
"category": "dependency_scanning",
"name": "Authentication bypass via incorrect DOM traversal and canonicalization",
"message": "Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js",
"description": "",
"cve": "yarn/yarn.lock:saml2-js:gemnasium:9952e574-7b5b-46fa-a270-aeb694198a98",
"severity": "Unknown",
"solution": "Upgrade to fixed version.\r\n",
"scanner": {
"id": "gemnasium",
"name": "Gemnasium"
},
"location": {},
"identifiers": [],
"links": [{ "url": "" }, { "url": "" }]
}
],
"remediations": [],
"dependency_files": [],
"scan": {
"scanner": { "scanner": {
"id": "gemnasium", "id": "gemnasium",
"name": "Gemnasium" "name": "Gemnasium",
}, "vendor": { "name": "GitLab" }
"location": {}, }
"identifiers": [],
"links": [{ "url": "" }, { "url": "" }]
}
],
"remediations": [],
"dependency_files": [],
"scan": {
"scanner": {
"id": "gemnasium",
"name": "Gemnasium",
"vendor": { "name": "GitLab" }
} }
} }
}
end end
it 'finds remediation with same cve' do it 'finds remediation with same cve' do
......
...@@ -32,61 +32,4 @@ RSpec.describe Gitlab::Ci::Parsers::Security::ScannedResources do ...@@ -32,61 +32,4 @@ RSpec.describe Gitlab::Ci::Parsers::Security::ScannedResources do
it { is_expected.to be(0) } it { is_expected.to be(0) }
end end
end end
describe '#scanned_resources_for_csv' do
subject { parser.scanned_resources_for_csv(scanned_resources) }
context 'when there are scanned resources' do
let(:scanned_resources) do
[
{ "method" => "GET", "type" => "url", "url" => "http://railsgoat:3001" },
{ "method" => "GET", "type" => "url", "url" => "http://railsgoat:3001/" },
{ "method" => "GET", "type" => "url", "url" => "http://railsgoat:3001/login?foo=bar" },
{ "method" => "POST", "type" => "url", "url" => "http://railsgoat:3001/users" }
]
end
it 'converts the hash to OpenStructs', :aggregate_failures do
expect(subject.length).to eq(4)
resource = subject[2]
expect(resource.request_method).to eq('GET')
expect(resource.scheme).to eq('http')
expect(resource.host).to eq('railsgoat')
expect(resource.port).to eq(3001)
expect(resource.path).to eq('/login')
expect(resource.query_string).to eq('foo=bar')
end
end
context 'when there is an invalid URL' do
let(:scanned_resources) do
[
{ "method" => "GET", "type" => "url", "url" => "http://railsgoat:3001" },
{ "method" => "GET", "type" => "url", "url" => "notaURL" },
{ "method" => "GET", "type" => "url", "url" => "" },
{ "method" => "GET", "type" => "url", "url" => nil },
{ "method" => "GET", "type" => "url", "url" => "http://railsgoat:3001/login?foo=bar" }
]
end
it 'returns a blank object with full URL string', :aggregate_failures do
expect(subject.length).to eq(5)
invalid_url = subject[1]
expect(invalid_url.request_method).to eq('GET')
expect(invalid_url.scheme).to be_nil
expect(invalid_url.raw_url).to eq('notaURL')
blank_url = subject[2]
expect(blank_url.request_method).to eq('GET')
expect(blank_url.scheme).to be_nil
expect(blank_url.raw_url).to eq('')
nil_url = subject[3]
expect(nil_url.request_method).to eq('GET')
expect(nil_url.scheme).to be_nil
expect(nil_url.raw_url).to be_nil
end
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Ci::Reports::Security::ScannedResource do
let(:url) { 'http://example.com:3001/1?foo=bar' }
let(:request_method) { 'GET' }
context 'when the URI is not a URI' do
subject { ::Gitlab::Ci::Reports::Security::ScannedResource.new(url, request_method) }
it 'raises an error' do
expect { subject }.to raise_error(ArgumentError)
end
end
context 'when the URL is valid' do
subject { ::Gitlab::Ci::Reports::Security::ScannedResource.new(URI.parse(url), request_method) }
it 'sets the URL attributes' do
expect(subject.request_method).to eq(request_method)
expect(subject.request_uri.to_s).to eq(url)
expect(subject.url_scheme).to eq('http')
expect(subject.url_host).to eq('example.com')
expect(subject.url_port).to eq(3001)
expect(subject.url_path).to eq('/1')
expect(subject.url_query).to eq('foo=bar')
end
end
end
...@@ -82,13 +82,29 @@ RSpec.describe Security::MergeReportsService, '#execute' do ...@@ -82,13 +82,29 @@ RSpec.describe Security::MergeReportsService, '#execute' do
let(:report_1_findings) { [finding_id_1, finding_id_2_loc_1, finding_cwe_2, finding_wasc_1] } let(:report_1_findings) { [finding_id_1, finding_id_2_loc_1, finding_cwe_2, finding_wasc_1] }
let(:scanned_resource) do
::Gitlab::Ci::Reports::Security::ScannedResource.new(URI.parse('example.com'), 'GET')
end
let(:scanned_resource_1) do
::Gitlab::Ci::Reports::Security::ScannedResource.new(URI.parse('example.com'), 'POST')
end
let(:scanned_resource_2) do
::Gitlab::Ci::Reports::Security::ScannedResource.new(URI.parse('example.com/2'), 'GET')
end
let(:scanned_resource_3) do
::Gitlab::Ci::Reports::Security::ScannedResource.new(URI.parse('example.com/3'), 'GET')
end
let(:report_1) do let(:report_1) do
build( build(
:ci_reports_security_report, :ci_reports_security_report,
scanners: [scanner_1, scanner_2], scanners: [scanner_1, scanner_2],
findings: report_1_findings, findings: report_1_findings,
identifiers: report_1_findings.flat_map(&:identifiers), identifiers: report_1_findings.flat_map(&:identifiers),
scanned_resources: ['example.com', 'example.com/1', 'example.com/2'] scanned_resources: [scanned_resource, scanned_resource_1, scanned_resource_2]
) )
end end
...@@ -100,7 +116,7 @@ RSpec.describe Security::MergeReportsService, '#execute' do ...@@ -100,7 +116,7 @@ RSpec.describe Security::MergeReportsService, '#execute' do
scanners: [scanner_2], scanners: [scanner_2],
findings: report_2_findings, findings: report_2_findings,
identifiers: finding_id_2_loc_2.identifiers, identifiers: finding_id_2_loc_2.identifiers,
scanned_resources: ['example.com', 'example.com/3'] scanned_resources: [scanned_resource, scanned_resource_1, scanned_resource_3]
) )
end end
...@@ -153,10 +169,10 @@ RSpec.describe Security::MergeReportsService, '#execute' do ...@@ -153,10 +169,10 @@ RSpec.describe Security::MergeReportsService, '#execute' do
it 'deduplicates scanned resources' do it 'deduplicates scanned resources' do
expect(subject.scanned_resources).to( expect(subject.scanned_resources).to(
eq([ eq([
'example.com', scanned_resource,
'example.com/1', scanned_resource_1,
'example.com/2', scanned_resource_2,
'example.com/3' scanned_resource_3
]) ])
) )
end end
......
...@@ -7093,6 +7093,9 @@ msgstr "" ...@@ -7093,6 +7093,9 @@ msgstr ""
msgid "Create project label" msgid "Create project label"
msgstr "" msgstr ""
msgid "Create release"
msgstr ""
msgid "Create requirement" msgid "Create requirement"
msgstr "" msgstr ""
...@@ -20099,6 +20102,9 @@ msgstr "" ...@@ -20099,6 +20102,9 @@ msgstr ""
msgid "Releases|New Release" msgid "Releases|New Release"
msgstr "" msgstr ""
msgid "Release|Something went wrong while creating a new release"
msgstr ""
msgid "Release|Something went wrong while getting the release details" msgid "Release|Something went wrong while getting the release details"
msgstr "" msgstr ""
......
import { GlDeprecatedButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue'; import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue';
...@@ -23,7 +23,7 @@ describe('ResolveWithIssueButton', () => { ...@@ -23,7 +23,7 @@ describe('ResolveWithIssueButton', () => {
}); });
it('it should have a link with the provided link property as href', () => { it('it should have a link with the provided link property as href', () => {
const button = wrapper.find(GlDeprecatedButton); const button = wrapper.find(GlButton);
expect(button.attributes().href).toBe(url); expect(button.attributes().href).toBe(url);
}); });
......
...@@ -83,18 +83,34 @@ describe('issue_note', () => { ...@@ -83,18 +83,34 @@ describe('issue_note', () => {
}); });
}); });
it('should render multiline comment if editing discussion root', () => { it('should only render if it has everything it needs', () => {
wrapper.setProps({ discussionRoot: true }); const position = {
wrapper.vm.isEditing = true; line_range: {
start: {
return wrapper.vm.$nextTick().then(() => { line_code: 'abc_1_1',
expect(findMultilineComment().exists()).toBe(true); type: null,
old_line: '',
new_line: '',
},
end: {
line_code: 'abc_2_2',
type: null,
old_line: '2',
new_line: '2',
},
},
};
const line = {
line_code: 'abc_1_1',
type: null,
old_line: '1',
new_line: '1',
};
wrapper.setProps({
note: { ...note, position },
discussionRoot: true,
line,
}); });
});
it('should only render multiline comment form if it has everything it needs', () => {
wrapper.setProps({ line: { line_code: '' } });
wrapper.vm.isEditing = true;
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(findMultilineComment().exists()).toBe(false); expect(findMultilineComment().exists()).toBe(false);
......
...@@ -27,8 +27,8 @@ describe('Release edit/new component', () => { ...@@ -27,8 +27,8 @@ describe('Release edit/new component', () => {
}; };
actions = { actions = {
fetchRelease: jest.fn(), initializeRelease: jest.fn(),
updateRelease: jest.fn(), saveRelease: jest.fn(),
addEmptyAssetLink: jest.fn(), addEmptyAssetLink: jest.fn(),
}; };
...@@ -64,6 +64,8 @@ describe('Release edit/new component', () => { ...@@ -64,6 +64,8 @@ describe('Release edit/new component', () => {
glFeatures: featureFlags, glFeatures: featureFlags,
}, },
}); });
wrapper.element.querySelectorAll('input').forEach(input => jest.spyOn(input, 'focus'));
}; };
beforeEach(() => { beforeEach(() => {
...@@ -87,8 +89,18 @@ describe('Release edit/new component', () => { ...@@ -87,8 +89,18 @@ describe('Release edit/new component', () => {
factory(); factory();
}); });
it('calls fetchRelease when the component is created', () => { it('calls initializeRelease when the component is created', () => {
expect(actions.fetchRelease).toHaveBeenCalledTimes(1); expect(actions.initializeRelease).toHaveBeenCalledTimes(1);
});
it('focuses the first non-disabled input element once the page is shown', () => {
const firstEnabledInput = wrapper.element.querySelector('input:enabled');
const allInputs = wrapper.element.querySelectorAll('input');
allInputs.forEach(input => {
const expectedFocusCalls = input === firstEnabledInput ? 1 : 0;
expect(input.focus).toHaveBeenCalledTimes(expectedFocusCalls);
});
}); });
it('renders the description text at the top of the page', () => { it('renders the description text at the top of the page', () => {
...@@ -109,9 +121,9 @@ describe('Release edit/new component', () => { ...@@ -109,9 +121,9 @@ describe('Release edit/new component', () => {
expect(findSubmitButton().attributes('type')).toBe('submit'); expect(findSubmitButton().attributes('type')).toBe('submit');
}); });
it('calls updateRelease when the form is submitted', () => { it('calls saveRelease when the form is submitted', () => {
wrapper.find('form').trigger('submit'); wrapper.find('form').trigger('submit');
expect(actions.updateRelease).toHaveBeenCalledTimes(1); expect(actions.saveRelease).toHaveBeenCalledTimes(1);
}); });
}); });
...@@ -143,6 +155,34 @@ describe('Release edit/new component', () => { ...@@ -143,6 +155,34 @@ describe('Release edit/new component', () => {
}); });
}); });
describe('when creating a new release', () => {
beforeEach(() => {
factory({
store: {
modules: {
detail: {
getters: {
isExistingRelease: () => false,
},
},
},
},
});
});
it('renders the submit button with the text "Create release"', () => {
expect(findSubmitButton().text()).toBe('Create release');
});
});
describe('when editing an existing release', () => {
beforeEach(factory);
it('renders the submit button with the text "Save changes"', () => {
expect(findSubmitButton().text()).toBe('Save changes');
});
});
describe('asset links form', () => { describe('asset links form', () => {
const findAssetLinksForm = () => wrapper.find(AssetLinksForm); const findAssetLinksForm = () => wrapper.find(AssetLinksForm);
......
...@@ -9,14 +9,14 @@ describe('releases/components/tag_field', () => { ...@@ -9,14 +9,14 @@ describe('releases/components/tag_field', () => {
let store; let store;
let wrapper; let wrapper;
const createComponent = ({ originalRelease }) => { const createComponent = ({ tagName }) => {
store = createStore({ store = createStore({
modules: { modules: {
detail: createDetailModule({}), detail: createDetailModule({}),
}, },
}); });
store.state.detail.originalRelease = originalRelease; store.state.detail.tagName = tagName;
wrapper = shallowMount(TagField, { store }); wrapper = shallowMount(TagField, { store });
}; };
...@@ -31,8 +31,7 @@ describe('releases/components/tag_field', () => { ...@@ -31,8 +31,7 @@ describe('releases/components/tag_field', () => {
describe('when an existing release is being edited', () => { describe('when an existing release is being edited', () => {
beforeEach(() => { beforeEach(() => {
const originalRelease = { name: 'Version 1.0' }; createComponent({ tagName: 'v1.0' });
createComponent({ originalRelease });
}); });
it('renders the TagFieldExisting component', () => { it('renders the TagFieldExisting component', () => {
...@@ -46,7 +45,7 @@ describe('releases/components/tag_field', () => { ...@@ -46,7 +45,7 @@ describe('releases/components/tag_field', () => {
describe('when a new release is being created', () => { describe('when a new release is being created', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ originalRelease: null }); createComponent({ tagName: null });
}); });
it('renders the TagFieldNew component', () => { it('renders the TagFieldNew component', () => {
......
...@@ -3,13 +3,13 @@ import * as getters from '~/releases/stores/modules/detail/getters'; ...@@ -3,13 +3,13 @@ import * as getters from '~/releases/stores/modules/detail/getters';
describe('Release detail getters', () => { describe('Release detail getters', () => {
describe('isExistingRelease', () => { describe('isExistingRelease', () => {
it('returns true if the release is an existing release that already exists in the database', () => { it('returns true if the release is an existing release that already exists in the database', () => {
const state = { originalRelease: { name: 'The first release' } }; const state = { tagName: 'test-tag-name' };
expect(getters.isExistingRelease(state)).toBe(true); expect(getters.isExistingRelease(state)).toBe(true);
}); });
it('returns false if the release is a new release that has not yet been saved to the database', () => { it('returns false if the release is a new release that has not yet been saved to the database', () => {
const state = { originalRelease: null }; const state = { tagName: null };
expect(getters.isExistingRelease(state)).toBe(false); expect(getters.isExistingRelease(state)).toBe(false);
}); });
......
...@@ -21,6 +21,22 @@ describe('Release detail mutations', () => { ...@@ -21,6 +21,22 @@ describe('Release detail mutations', () => {
release = convertObjectPropsToCamelCase(originalRelease); release = convertObjectPropsToCamelCase(originalRelease);
}); });
describe(`${types.INITIALIZE_EMPTY_RELEASE}`, () => {
it('set state.release to an empty release object', () => {
mutations[types.INITIALIZE_EMPTY_RELEASE](state);
expect(state.release).toEqual({
tagName: null,
name: '',
description: '',
milestones: [],
assets: {
links: [],
},
});
});
});
describe(`${types.REQUEST_RELEASE}`, () => { describe(`${types.REQUEST_RELEASE}`, () => {
it('set state.isFetchingRelease to true', () => { it('set state.isFetchingRelease to true', () => {
mutations[types.REQUEST_RELEASE](state); mutations[types.REQUEST_RELEASE](state);
...@@ -96,17 +112,17 @@ describe('Release detail mutations', () => { ...@@ -96,17 +112,17 @@ describe('Release detail mutations', () => {
}); });
}); });
describe(`${types.REQUEST_UPDATE_RELEASE}`, () => { describe(`${types.REQUEST_SAVE_RELEASE}`, () => {
it('set state.isUpdatingRelease to true', () => { it('set state.isUpdatingRelease to true', () => {
mutations[types.REQUEST_UPDATE_RELEASE](state); mutations[types.REQUEST_SAVE_RELEASE](state);
expect(state.isUpdatingRelease).toBe(true); expect(state.isUpdatingRelease).toBe(true);
}); });
}); });
describe(`${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => { describe(`${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => {
it('handles a successful response from the server', () => { it('handles a successful response from the server', () => {
mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, release); mutations[types.RECEIVE_SAVE_RELEASE_SUCCESS](state, release);
expect(state.updateError).toBeUndefined(); expect(state.updateError).toBeUndefined();
...@@ -114,10 +130,10 @@ describe('Release detail mutations', () => { ...@@ -114,10 +130,10 @@ describe('Release detail mutations', () => {
}); });
}); });
describe(`${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => { describe(`${types.RECEIVE_SAVE_RELEASE_ERROR}`, () => {
it('handles an unsuccessful response from the server', () => { it('handles an unsuccessful response from the server', () => {
const error = { message: 'An error occurred!' }; const error = { message: 'An error occurred!' };
mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error); mutations[types.RECEIVE_SAVE_RELEASE_ERROR](state, error);
expect(state.isUpdatingRelease).toBe(false); expect(state.isUpdatingRelease).toBe(false);
......
import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
describe('releases/util.js', () => {
describe('releaseToApiJson', () => {
it('converts a release JavaScript object into JSON that the Release API can accept', () => {
const release = {
tagName: 'tag-name',
name: 'Release name',
description: 'Release description',
milestones: [{ id: 1, title: '13.2' }, { id: 2, title: '13.3' }],
assets: {
links: [{ url: 'https://gitlab.example.com/link', linkType: 'other' }],
},
};
const expectedJson = {
tag_name: 'tag-name',
ref: null,
name: 'Release name',
description: 'Release description',
milestones: ['13.2', '13.3'],
assets: {
links: [{ url: 'https://gitlab.example.com/link', link_type: 'other' }],
},
};
expect(releaseToApiJson(release)).toEqual(expectedJson);
});
describe('when createFrom is provided', () => {
it('adds the provided createFrom ref to the JSON as a "ref" property', () => {
const createFrom = 'main';
const release = {};
const expectedJson = {
ref: createFrom,
};
expect(releaseToApiJson(release, createFrom)).toMatchObject(expectedJson);
});
});
describe('when release.milestones is falsy', () => {
it('includes a "milestone" property in the returned result as an empty array', () => {
const release = {};
const expectedJson = {
milestones: [],
};
expect(releaseToApiJson(release)).toMatchObject(expectedJson);
});
});
});
describe('apiJsonToRelease', () => {
it('converts JSON received from the Release API into an object usable by the Vue application', () => {
const json = {
tag_name: 'tag-name',
assets: {
links: [
{
link_type: 'other',
},
],
},
};
const expectedRelease = {
tagName: 'tag-name',
assets: {
links: [
{
linkType: 'other',
},
],
},
milestones: [],
};
expect(apiJsonToRelease(json)).toEqual(expectedRelease);
});
});
});
...@@ -190,18 +190,6 @@ RSpec.describe Git::ProcessRefChangesService do ...@@ -190,18 +190,6 @@ RSpec.describe Git::ProcessRefChangesService do
subject.execute subject.execute
end end
context 'refresh_only_existing_merge_requests_on_push disabled' do
before do
stub_feature_flags(refresh_only_existing_merge_requests_on_push: false)
end
it 'refreshes all merge requests' do
expect(UpdateMergeRequestsWorker).to receive(:perform_async).exactly(3).times
subject.execute
end
end
end end
end end
......
...@@ -49,5 +49,29 @@ RSpec.describe Deployments::FinishedWorker do ...@@ -49,5 +49,29 @@ RSpec.describe Deployments::FinishedWorker do
expect(ProjectServiceWorker).not_to have_received(:perform_async) expect(ProjectServiceWorker).not_to have_received(:perform_async)
end end
it 'execute webhooks' do
deployment = create(:deployment)
project = deployment.project
web_hook = create(:project_hook, deployment_events: true, project: project)
expect_next_instance_of(WebHookService, web_hook, an_instance_of(Hash), "deployment_hooks") do |service|
expect(service).to receive(:async_execute)
end
worker.perform(deployment.id)
end
it 'does not execute webhooks if feature flag is disabled' do
stub_feature_flags(deployment_webhooks: false)
deployment = create(:deployment)
project = deployment.project
create(:project_hook, deployment_events: true, project: project)
expect(WebHookService).not_to receive(:new)
worker.perform(deployment.id)
end
end end
end end
...@@ -843,15 +843,15 @@ ...@@ -843,15 +843,15 @@
eslint-plugin-vue "^6.2.1" eslint-plugin-vue "^6.2.1"
vue-eslint-parser "^7.0.0" vue-eslint-parser "^7.0.0"
"@gitlab/svgs@1.157.0": "@gitlab/svgs@1.158.0":
version "1.157.0" version "1.158.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.157.0.tgz#ada33c2b706836a2f5baa2c539f1348791d74859" resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.158.0.tgz#300d416184a2b0e05f15a96547f726e1825b08a1"
integrity sha512-H07Rn4Cy2QW+wnadvuFBSIWrtn8l4hGFLn62f1fT0iYZy58zb/q5/FsShxk9cSKnZYNkXp8I4Nnk/4R7y1MEOw== integrity sha512-5OJl+7TsXN9PJhY6/uwi+mTwmDZa9n/6119rf77orQ/joFYUypaYhBmy/1TcKVPsy5Zs6KCxE1kmGsfoXc1TYA==
"@gitlab/ui@18.1.0": "@gitlab/ui@18.3.0":
version "18.1.0" version "18.3.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-18.1.0.tgz#36c1e292cae47d1580d2a3918fe5dd16893e2219" resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-18.3.0.tgz#c582eca1a0a851823700dabc7f4456feef882d9a"
integrity sha512-oXKTJ07hMFYxXZiJOgbNzVCpz/ooz0rY7D3ISG9ocawGVFVjrwLj41wgNtOzYAnQntxUcgvxNeBt3X6SS/zeTg== integrity sha512-H0I3ExZJIqDd9rFDzyZwUerS3ZHDxRf2wHmAzMzK9smq/kr8aL5Pvb2E0KPcgDsVhGQCt7coCBN5NI0p+kf8oQ==
dependencies: dependencies:
"@babel/standalone" "^7.0.0" "@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0" "@gitlab/vue-toasted" "^1.3.0"
......
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