diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-08-10 18:09:49 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-08-10 18:09:49 +0300 |
commit | 70732753863e569f95ed954ca3c41421292f912b (patch) | |
tree | f642d44c83ab951fd4581e3c46491784376b9c18 /app | |
parent | bf593ae68b7135bf633484aa3442b7592126b1d2 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
15 files changed, 356 insertions, 178 deletions
diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue index e5f78b1c7de..cace382ccd6 100644 --- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue +++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue @@ -1,12 +1,10 @@ <script> -import { GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; export default { name: 'ResolveWithIssueButton', components: { - Icon, - GlDeprecatedButton, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -22,13 +20,12 @@ export default { <template> <div class="btn-group" role="group"> - <gl-deprecated-button + <gl-button v-gl-tooltip :href="url" :title="s__('MergeRequests|Resolve this thread in a new issue')" class="new-issue-for-discussion discussion-create-issue-btn" - > - <icon name="issue-new" /> - </gl-deprecated-button> + icon="issue-new" + /> </div> </template> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index adde1e85520..3c84746595d 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -23,7 +23,6 @@ import { commentLineOptions, formatLineRange, } from './multiline_comment_utils'; -import MultilineCommentForm from './multiline_comment_form.vue'; export default { name: 'NoteableNote', @@ -34,7 +33,6 @@ export default { noteActions, NoteBody, TimelineEntryItem, - MultilineCommentForm, }, mixins: [noteable, resolvable, glFeatureFlagsMixin()], props: { @@ -147,14 +145,16 @@ export default { return getEndLineNumber(this.lineRange); }, showMultiLineComment() { - if (!this.glFeatures.multilineComments || !this.discussionRoot) return false; - if (this.isEditing) return true; + if ( + !this.glFeatures.multilineComments || + !this.discussionRoot || + this.startLineNumber.length === 0 || + this.endLineNumber.length === 0 + ) + return false; return this.line && this.startLineNumber !== this.endLineNumber; }, - showMultilineCommentForm() { - return Boolean(this.isEditing && this.note.position && this.diffFile && this.line); - }, commentLineOptions() { const sideA = this.line.type === 'new' ? 'right' : 'left'; const sideB = sideA === 'left' ? 'right' : 'left'; @@ -344,28 +344,19 @@ export default { :data-note-id="note.id" class="note note-wrapper qa-noteable-note-item" > - <div v-if="showMultiLineComment" data-testid="multiline-comment"> - <multiline-comment-form - v-if="showMultilineCommentForm" - v-model="commentLineStart" - :line="line" - :comment-line-options="commentLineOptions" - :line-range="note.position.line_range" - class="gl-mb-3 gl-text-gray-700 gl-pb-3" - /> - <div - v-else - class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" - > - <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 + v-if="showMultiLineComment" + data-testid="multiline-comment" + class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" + > + <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 v-once class="timeline-icon"> <user-avatar-link diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 09fdd9e4438..1710abe72ef 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -3,7 +3,6 @@ import { mapState, mapActions, mapGetters } from 'vuex'; import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import { BACK_URL_PARAM } from '~/releases/constants'; import { getParameterByName } from '~/lib/utils/common_utils'; import AssetLinksForm from './asset_links_form.vue'; @@ -22,9 +21,6 @@ export default { MilestoneCombobox, TagField, }, - directives: { - autofocusonshow, - }, mixins: [glFeatureFlagsMixin()], computed: { ...mapState('detail', [ @@ -40,9 +36,9 @@ export default { 'manageMilestonesPath', 'projectId', ]), - ...mapGetters('detail', ['isValid']), + ...mapGetters('detail', ['isValid', 'isExistingRelease']), showForm() { - return !this.isFetchingRelease && !this.fetchError; + return Boolean(!this.isFetchingRelease && !this.fetchError && this.release); }, subtitleText() { return sprintf( @@ -86,6 +82,9 @@ export default { showAssetLinksForm() { return this.glFeatures.releaseAssetLinkEditing; }, + saveButtonLabel() { + return this.isExistingRelease ? __('Save changes') : __('Create release'); + }, isSaveChangesDisabled() { return this.isUpdatingRelease || !this.isValid; }, @@ -102,13 +101,17 @@ export default { ]; }, }, - created() { - this.fetchRelease(); + mounted() { + // 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: { ...mapActions('detail', [ - 'fetchRelease', - 'updateRelease', + 'initializeRelease', + 'saveRelease', 'updateReleaseTitle', 'updateReleaseNotes', 'updateReleaseMilestones', @@ -119,7 +122,7 @@ export default { <template> <div class="d-flex flex-column"> <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 /> <gl-form-group> <label for="release-title">{{ __('Release title') }}</label> @@ -127,8 +130,6 @@ export default { id="release-title" ref="releaseTitleInput" v-model="releaseTitle" - v-autofocusonshow - autofocus type="text" class="form-control" /> @@ -162,8 +163,8 @@ export default { data-supports-quick-actions="false" :aria-label="__('Release notes')" :placeholder="__('Write your release notes or drag your files hereā¦')" - @keydown.meta.enter="updateRelease()" - @keydown.ctrl.enter="updateRelease()" + @keydown.meta.enter="saveRelease()" + @keydown.ctrl.enter="saveRelease()" ></textarea> </template> </markdown-field> @@ -178,10 +179,11 @@ export default { category="primary" variant="success" type="submit" - :aria-label="__('Save changes')" :disabled="isSaveChangesDisabled" - >{{ __('Save changes') }}</gl-button + data-testid="submit-button" > + {{ saveButtonLabel }} + </gl-button> <gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button> </div> </form> diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js index 67d31d37384..2f7d1cb4711 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/actions.js +++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js @@ -3,76 +3,114 @@ import api from '~/api'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; -import { - convertObjectPropsToCamelCase, - convertObjectPropsToSnakeCase, -} from '~/lib/utils/common_utils'; - -export const requestRelease = ({ commit }) => commit(types.REQUEST_RELEASE); -export const receiveReleaseSuccess = ({ commit }, data) => - commit(types.RECEIVE_RELEASE_SUCCESS, data); -export const receiveReleaseError = ({ commit }, error) => { - commit(types.RECEIVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while getting the release details')); +import { releaseToApiJson, apiJsonToRelease } from '~/releases/util'; + +export const initializeRelease = ({ commit, dispatch, getters }) => { + if (getters.isExistingRelease) { + // When editing an existing release, + // fetch the release object from the API + return dispatch('fetchRelease'); + } + + // When creating a new release, initialize the + // store with an empty release object + commit(types.INITIALIZE_EMPTY_RELEASE); + return Promise.resolve(); }; -export const fetchRelease = ({ dispatch, state }) => { - dispatch('requestRelease'); +export const fetchRelease = ({ commit, state }) => { + commit(types.REQUEST_RELEASE); return api .release(state.projectId, state.tagName) .then(({ data }) => { - const release = { - ...data, - milestones: data.milestones || [], - }; - - dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true })); + commit(types.RECEIVE_RELEASE_SUCCESS, apiJsonToRelease(data)); }) .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) => commit(types.UPDATE_RELEASE_TAG_NAME, tagName); + export const updateCreateFrom = ({ commit }, createFrom) => commit(types.UPDATE_CREATE_FROM, createFrom); + export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title); + export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes); + export const updateReleaseMilestones = ({ commit }, milestones) => commit(types.UPDATE_RELEASE_MILESTONES, milestones); -export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE); -export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => { - commit(types.RECEIVE_UPDATE_RELEASE_SUCCESS); - redirectTo( - rootState.featureFlags.releaseShowPage ? state.release._links.self : state.releasesPagePath, - ); +export const addEmptyAssetLink = ({ commit }) => { + commit(types.ADD_EMPTY_ASSET_LINK); }; -export const receiveUpdateReleaseError = ({ commit }, error) => { - commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while saving the release details')); + +export const updateAssetLinkUrl = ({ commit }, { linkIdToUpdate, newUrl }) => { + commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl }); }; -export const updateRelease = ({ dispatch, state, getters }) => { - dispatch('requestUpdateRelease'); +export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => { + commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName }); +}; - const { release } = state; - const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : []; +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); +}; + +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, - description: release.description, - milestones, + ...state.release, + assets: { + 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 ( api - .updateRelease(state.projectId, state.tagName, updatedRelease) + .updateRelease(state.projectId, state.tagName, apiJson) /** * Currently, we delete all existing links and then @@ -90,54 +128,31 @@ export const updateRelease = ({ dispatch, state, getters }) => { * https://gitlab.com/gitlab-org/gitlab/-/issues/208702 * 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 return Promise.all( getters.releaseLinksToDelete.map(l => - api.deleteReleaseLink(state.projectId, release.tagName, l.id), + api.deleteReleaseLink(state.projectId, state.release.tagName, l.id), ), ); }) .then(() => { // Create a new link for each link in the form return Promise.all( - getters.releaseLinksToCreate.map(l => - api.createReleaseLink( - state.projectId, - release.tagName, - convertObjectPropsToSnakeCase(l, { deep: true }), - ), + apiJson.assets.links.map(l => + api.createReleaseLink(state.projectId, state.release.tagName, l), ), ); }) - .then(() => dispatch('receiveUpdateReleaseSuccess')) + .then(() => { + dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease)); + }) .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); -}; diff --git a/app/assets/javascripts/releases/stores/modules/detail/getters.js b/app/assets/javascripts/releases/stores/modules/detail/getters.js index ffbbc756f39..0d2375566c2 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/getters.js +++ b/app/assets/javascripts/releases/stores/modules/detail/getters.js @@ -6,7 +6,7 @@ import { hasContent } from '~/lib/utils/text_utility'; * `false` if the app is creating a new release. */ export const isExistingRelease = state => { - return Boolean(state.originalRelease); + return Boolean(state.tagName); }; /** diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js index a50086693bf..7784e0cc741 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js @@ -1,3 +1,5 @@ +export const INITIALIZE_EMPTY_RELEASE = 'INITIALIZE_EMPTY_RELEASE'; + export const REQUEST_RELEASE = 'REQUEST_RELEASE'; export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS'; export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR'; @@ -8,9 +10,9 @@ export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE'; export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES'; export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES'; -export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE'; -export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS'; -export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR'; +export const REQUEST_SAVE_RELEASE = 'REQUEST_SAVE_RELEASE'; +export const RECEIVE_SAVE_RELEASE_SUCCESS = 'RECEIVE_SAVE_RELEASE_SUCCESS'; +export const RECEIVE_SAVE_RELEASE_ERROR = 'RECEIVE_SAVE_RELEASE_ERROR'; export const ADD_EMPTY_ASSET_LINK = 'ADD_EMPTY_ASSET_LINK'; export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL'; diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js index 2a8e8a6eb93..155e67b2e55 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js @@ -7,6 +7,18 @@ const findReleaseLink = (release, id) => { }; export default { + [types.INITIALIZE_EMPTY_RELEASE](state) { + state.release = { + tagName: null, + name: '', + description: '', + milestones: [], + assets: { + links: [], + }, + }; + }, + [types.REQUEST_RELEASE](state) { state.isFetchingRelease = true; }, @@ -39,14 +51,14 @@ export default { state.release.milestones = milestones; }, - [types.REQUEST_UPDATE_RELEASE](state) { + [types.REQUEST_SAVE_RELEASE](state) { state.isUpdatingRelease = true; }, - [types.RECEIVE_UPDATE_RELEASE_SUCCESS](state) { + [types.RECEIVE_SAVE_RELEASE_SUCCESS](state) { state.updateError = undefined; state.isUpdatingRelease = false; }, - [types.RECEIVE_UPDATE_RELEASE_ERROR](state, error) { + [types.RECEIVE_SAVE_RELEASE_ERROR](state, error) { state.updateError = error; state.isUpdatingRelease = false; }, diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js new file mode 100644 index 00000000000..efb50dac9cf --- /dev/null +++ b/app/assets/javascripts/releases/util.js @@ -0,0 +1,39 @@ +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; +}; diff --git a/app/assets/javascripts/static_site_editor/services/templater.js b/app/assets/javascripts/static_site_editor/services/templater.js index 081db60b601..a1c1bb6b8d6 100644 --- a/app/assets/javascripts/static_site_editor/services/templater.js +++ b/app/assets/javascripts/static_site_editor/services/templater.js @@ -1,62 +1,89 @@ +/** + * The purpose of this file is to modify Markdown source such that templated code (embedded ruby currently) can be temporarily wrapped and unwrapped in codeblocks: + * 1. `wrap()`: temporarily wrap in codeblocks (useful for a WYSIWYG editing experience) + * 2. `unwrap()`: undo the temporarily wrapped codeblocks (useful for Markdown editing experience and saving edits) + * + * Without this `templater`, the templated code is otherwise interpreted as Markdown content resulting in loss of spacing, indentation, escape characters, etc. + * + */ + const ticks = '```'; const marker = 'sse'; -const prefix = `${ticks} ${marker}\n`; // Space intentional due to https://github.com/nhn/tui.editor/blob/6bcec75c69028570d93d973aa7533090257eaae0/libs/to-mark/src/renderer.gfm.js#L26 -const postfix = `\n${ticks}`; -const flagPrefix = `${marker}-${Date.now()}`; -const template = `.| |\\t|\\n(?!(\\n|${flagPrefix}))`; -const templatedRegex = new RegExp(`(^${prefix}(${template})+?${postfix}$)`, 'gm'); - -const nonErbMarkupRegex = new RegExp(`^((<(?!%).+>){1}(${template})+(</.+>){1})$`, 'gm'); -const embeddedRubyBlockRegex = new RegExp(`(^<%(${template})+%>$)`, 'gm'); -const embeddedRubyInlineRegex = new RegExp(`(^.*[<|<]%(${template})+$)`, 'gm'); - -// Order is intentional (general to specific) where HTML markup is flagged first, then ERB blocks, then inline ERB -// Order in combo with the `flag()` algorithm is used to mitigate potential duplicate pattern matches (ERB nested in HTML for example) -const orderedPatterns = [nonErbMarkupRegex, embeddedRubyBlockRegex, embeddedRubyInlineRegex]; - -const unwrap = source => { - let text = source; - const matches = text.match(templatedRegex); +const wrapPrefix = `${ticks} ${marker}\n`; // Space intentional due to https://github.com/nhn/tui.editor/blob/6bcec75c69028570d93d973aa7533090257eaae0/libs/to-mark/src/renderer.gfm.js#L26 +const wrapPostfix = `\n${ticks}`; +const markPrefix = `${marker}-${Date.now()}`; - if (matches) { - matches.forEach(match => { - const initial = match.replace(`${prefix}`, '').replace(`${postfix}`, ''); - text = text.replace(match, initial); - }); - } +const reHelpers = { + template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`, + openTag: '<[a-zA-Z]+.*?>', + closeTag: '</.+>', +}; +const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm'); +const rePreexistingCodeBlocks = new RegExp(`(^${ticks}.*\\n(.|\\s)+?${ticks}$)`, 'gm'); +const reHtmlMarkup = new RegExp( + `^((${reHelpers.openTag}){1}(${reHelpers.template})*(${reHelpers.closeTag}){1})$`, + 'gm', +); +const reEmbeddedRubyBlock = new RegExp(`(^<%(${reHelpers.template})+%>$)`, 'gm'); +const reEmbeddedRubyInline = new RegExp(`(^.*[<|<]%(${reHelpers.template})+$)`, 'gm'); - return text; +const patternGroups = { + ignore: [rePreexistingCodeBlocks], + // Order is intentional (general to specific) where HTML markup is marked first, then ERB blocks, then inline ERB + // Order in combo with the `mark()` algorithm is used to mitigate potential duplicate pattern matches (ERB nested in HTML for example) + allow: [reHtmlMarkup, reEmbeddedRubyBlock, reEmbeddedRubyInline], }; -const flag = (source, patterns) => { +const mark = (source, groups) => { let text = source; let id = 0; const hash = {}; - patterns.forEach(pattern => { - const matches = text.match(pattern); - if (matches) { - matches.forEach(match => { - const key = `${flagPrefix}${id}`; - text = text.replace(match, key); - hash[key] = match; - id += 1; - }); - } + Object.entries(groups).forEach(([groupKey, group]) => { + group.forEach(pattern => { + const matches = text.match(pattern); + if (matches) { + matches.forEach(match => { + const key = `${markPrefix}-${groupKey}-${id}`; + text = text.replace(match, key); + hash[key] = match; + id += 1; + }); + } + }); }); return { text, hash }; }; -const wrap = source => { - const { text, hash } = flag(unwrap(source), orderedPatterns); +const unmark = (text, hash) => { + let source = text; - let wrappedSource = text; Object.entries(hash).forEach(([key, value]) => { - wrappedSource = wrappedSource.replace(key, `${prefix}${value}${postfix}`); + const newVal = key.includes('ignore') ? value : `${wrapPrefix}${value}${wrapPostfix}`; + source = source.replace(key, newVal); }); - return wrappedSource; + return source; +}; + +const unwrap = source => { + let text = source; + const matches = text.match(reTemplated); + + if (matches) { + matches.forEach(match => { + const initial = match.replace(`${wrapPrefix}`, '').replace(`${wrapPostfix}`, ''); + text = text.replace(match, initial); + }); + } + + return text; +}; + +const wrap = source => { + const { text, hash } = mark(unwrap(source), patternGroups); + return unmark(text, hash); }; export default { wrap, unwrap }; diff --git a/app/graphql/mutations/boards/issues/issue_move_list.rb b/app/graphql/mutations/boards/issues/issue_move_list.rb new file mode 100644 index 00000000000..d4bf47af4cf --- /dev/null +++ b/app/graphql/mutations/boards/issues/issue_move_list.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Mutations + module Boards + module Issues + class IssueMoveList < Mutations::Issues::Base + graphql_name 'IssueMoveList' + + argument :board_id, GraphQL::ID_TYPE, + required: true, + loads: Types::BoardType, + description: 'Global ID of the board that the issue is in' + + argument :project_path, GraphQL::ID_TYPE, + required: true, + description: 'Project the issue to mutate is in' + + argument :iid, GraphQL::STRING_TYPE, + required: true, + description: 'IID of the issue to mutate' + + argument :from_list_id, GraphQL::ID_TYPE, + required: false, + description: 'ID of the board list that the issue will be moved from' + + argument :to_list_id, GraphQL::ID_TYPE, + required: false, + description: 'ID of the board list that the issue will be moved to' + + argument :move_before_id, GraphQL::ID_TYPE, + required: false, + description: 'ID of issue before which the current issue will be positioned at' + + argument :move_after_id, GraphQL::ID_TYPE, + required: false, + description: 'ID of issue after which the current issue will be positioned at' + + def ready?(**args) + if move_arguments(args).blank? + raise Gitlab::Graphql::Errors::ArgumentError, + 'At least one of the arguments fromListId, toListId, afterId or beforeId is required' + end + + if move_list_arguments(args).one? + raise Gitlab::Graphql::Errors::ArgumentError, + 'Both fromListId and toListId must be present' + end + + super + end + + def resolve(board:, **args) + raise_resource_not_available_error! unless board + authorize_board!(board) + + issue = authorized_find!(project_path: args[:project_path], iid: args[:iid]) + move_params = { id: issue.id, board_id: board.id }.merge(move_arguments(args)) + + move_issue(board, issue, move_params) + + { + issue: issue.reset, + errors: issue.errors.full_messages + } + end + + private + + def move_issue(board, issue, move_params) + service = ::Boards::Issues::MoveService.new(board.resource_parent, current_user, move_params) + + service.execute(issue) + end + + def move_list_arguments(args) + args.slice(:from_list_id, :to_list_id) + end + + def move_arguments(args) + args.slice(:from_list_id, :to_list_id, :move_after_id, :move_before_id) + end + + def authorize_board!(board) + return if Ability.allowed?(current_user, :read_board, board.resource_parent) + + raise_resource_not_available_error! + end + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index a2b2ab4a6b2..e3025587ba6 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -14,6 +14,7 @@ module Types mount_mutation Mutations::AwardEmojis::Add mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Toggle + mount_mutation Mutations::Boards::Issues::IssueMoveList mount_mutation Mutations::Branches::Create, calls_gitaly: true mount_mutation Mutations::Commits::Create, calls_gitaly: true mount_mutation Mutations::Discussions::ToggleResolve diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index c52baa0524c..b64a9e4f70b 100644 --- a/app/models/concerns/triggerable_hooks.rb +++ b/app/models/concerns/triggerable_hooks.rb @@ -12,7 +12,8 @@ module TriggerableHooks merge_request_hooks: :merge_requests_events, job_hooks: :job_events, pipeline_hooks: :pipeline_events, - wiki_page_hooks: :wiki_page_events + wiki_page_hooks: :wiki_page_events, + deployment_hooks: :deployment_events }.freeze extend ActiveSupport::Concern diff --git a/app/models/deployment.rb b/app/models/deployment.rb index aa3e3a8f66d..87587bb5afa 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -148,6 +148,7 @@ class Deployment < ApplicationRecord def execute_hooks 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) end diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index 71494b6de4d..2d1bdecc770 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -17,7 +17,8 @@ class ProjectHook < WebHook :merge_request_hooks, :job_hooks, :pipeline_hooks, - :wiki_page_hooks + :wiki_page_hooks, + :deployment_hooks ] belongs_to :project diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb index 6d1ff97016b..c012c61a337 100644 --- a/app/services/git/process_ref_changes_service.rb +++ b/app/services/git/process_ref_changes_service.rb @@ -75,8 +75,6 @@ module Git end 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 end end |