diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 235e115267d..a149f31c093 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -42fab8fc526215f9426bc9f459f9e6da0951c574 +6b31501b13eae70aea5061edc8273c551ba4c349 diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index e7051cec956..5c4aae45ef2 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -342,20 +342,24 @@ export default { }); }, - updateFormState(state) { + setFormState(state) { this.store.setFormState(state); }, - updateAndShowForm(templates = {}) { + updateFormState(templates = {}) { + this.setFormState({ + title: this.state.titleText, + description: this.state.descriptionText, + lock_version: this.state.lock_version, + lockedWarningVisible: false, + updateLoading: false, + issuableTemplates: templates, + }); + }, + + updateAndShowForm(templates) { if (!this.showForm) { - this.store.setFormState({ - title: this.state.titleText, - description: this.state.descriptionText, - lock_version: this.state.lock_version, - lockedWarningVisible: false, - updateLoading: false, - issuableTemplates: templates, - }); + this.updateFormState(templates); this.showForm = true; } }, @@ -388,9 +392,7 @@ export default { }, updateIssuable() { - this.store.setFormState({ - updateLoading: true, - }); + this.setFormState({ updateLoading: true }); const { store: { formState }, @@ -428,10 +430,6 @@ export default { .catch((error = {}) => { const { message, response = {} } = error; - this.store.setFormState({ - updateLoading: false, - }); - let errMsg = this.defaultErrorMessage; if (response.data && response.data.errors) { @@ -443,6 +441,9 @@ export default { this.flashContainer = createFlash({ message: errMsg, }); + }) + .finally(() => { + this.setFormState({ updateLoading: false }); }); }, @@ -461,6 +462,12 @@ export default { } }, + handleListItemReorder(description) { + this.updateFormState(); + this.setFormState({ description }); + this.updateIssuable(); + }, + taskListUpdateStarted() { this.poll.stop(); }, @@ -498,7 +505,7 @@ export default { :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" :issuable-type="issuableType" - @updateForm="updateFormState" + @updateForm="setFormState" />
@@ -573,6 +580,8 @@ export default { :issuable-type="issuableType" :update-url="updateEndpoint" :lock-version="state.lock_version" + :is-updating="formState.updateLoading" + @listItemReorder="handleListItemReorder" @taskListUpdateStarted="taskListUpdateStarted" @taskListUpdateSucceeded="taskListUpdateSucceeded" @taskListUpdateFailed="taskListUpdateFailed" diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 831cef66836..4f97458dcd1 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -7,6 +7,7 @@ import { GlModalDirective, } from '@gitlab/ui'; import $ from 'jquery'; +import Sortable from 'sortablejs'; import Vue from 'vue'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; @@ -14,6 +15,7 @@ import createFlash from '~/flash'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; +import { getSortableDefaultOptions, isDragging } from '~/sortable/utils'; import TaskList from '~/task_list'; import Tracking from '~/tracking'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; @@ -22,9 +24,14 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import animateMixin from '../mixins/animate'; +import { convertDescriptionWithNewSort } from '../utils'; Vue.use(GlToast); +const workItemTypes = { + TASK: 'task', +}; + export default { directives: { SafeHtml, @@ -76,6 +83,11 @@ export default { required: false, default: null, }, + isUpdating: { + type: Boolean, + required: false, + default: false, + }, }, data() { const workItemId = getParameterByName('work_item_id'); @@ -146,6 +158,9 @@ export default { this.openWorkItemDetailModal(taskLink); } }, + beforeDestroy() { + this.removeAllPointerEventListeners(); + }, methods: { renderGFM() { $(this.$refs['gfm-content']).renderGFM(); @@ -161,9 +176,67 @@ export default { onSuccess: this.taskListUpdateSuccess.bind(this), onError: this.taskListUpdateError.bind(this), }); + + this.renderSortableLists(); } }, + renderSortableLists() { + this.removeAllPointerEventListeners(); + const lists = document.querySelectorAll('.description ul, .description ol'); + lists.forEach((list) => { + Array.from(list.children).forEach((listItem) => { + listItem.prepend(this.createDragIconElement()); + this.addPointerEventListeners(listItem); + }); + + Sortable.create( + list, + getSortableDefaultOptions({ + handle: '.drag-icon', + onUpdate: (event) => { + const description = convertDescriptionWithNewSort(this.descriptionText, event.to); + this.$emit('listItemReorder', description); + }, + }), + ); + }); + }, + createDragIconElement() { + const container = document.createElement('div'); + container.innerHTML = ``; + return container.firstChild; + }, + addPointerEventListeners(listItem) { + const pointeroverListener = (event) => { + if (isDragging() || this.isUpdating) { + return; + } + event.target.closest('li').querySelector('.drag-icon').style.visibility = 'visible'; // eslint-disable-line no-param-reassign + }; + const pointeroutListener = (event) => { + event.target.closest('li').querySelector('.drag-icon').style.visibility = 'hidden'; // eslint-disable-line no-param-reassign + }; + + // We use pointerover/pointerout instead of CSS so that when we hover over a + // list item with children, the drag icons of its children do not become visible. + listItem.addEventListener('pointerover', pointeroverListener); + listItem.addEventListener('pointerout', pointeroutListener); + + this.pointerEventListeners = this.pointerEventListeners || new Map(); + this.pointerEventListeners.set(listItem, [ + { type: 'pointerover', listener: pointeroverListener }, + { type: 'pointerout', listener: pointeroutListener }, + ]); + }, + removeAllPointerEventListeners() { + this.pointerEventListeners?.forEach((events, listItem) => { + events.forEach((event) => listItem.removeEventListener(event.type, event.listener)); + this.pointerEventListeners.delete(listItem); + }); + }, taskListUpdateStarted() { this.$emit('taskListUpdateStarted'); }, @@ -214,7 +287,10 @@ export default { taskListFields.forEach((item, index) => { const taskLink = item.querySelector('.gfm-issue'); if (taskLink) { - const { issue, referenceType } = taskLink.dataset; + const { issue, referenceType, issueType } = taskLink.dataset; + if (issueType !== workItemTypes.TASK) { + return; + } const workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue); this.addHoverListeners(taskLink, workItemId); taskLink.addEventListener('click', (e) => { @@ -237,10 +313,9 @@ export default { 'btn-md', 'gl-button', 'btn-default-tertiary', - 'gl-left-0', 'gl-p-0!', - 'gl-top-2', - 'gl-absolute', + 'gl-mt-n1', + 'gl-ml-3', 'js-add-task', ); button.id = `js-task-button-${index}`; @@ -252,7 +327,7 @@ export default { `; button.setAttribute('aria-label', s__('WorkItem|Convert to work item')); button.addEventListener('click', () => this.openCreateTaskModal(button)); - item.prepend(button); + item.append(button); }); }, addHoverListeners(taskLink, id) { diff --git a/app/assets/javascripts/issues/show/utils.js b/app/assets/javascripts/issues/show/utils.js new file mode 100644 index 00000000000..60e66f59f92 --- /dev/null +++ b/app/assets/javascripts/issues/show/utils.js @@ -0,0 +1,99 @@ +import { COLON, HYPHEN, NEWLINE } from '~/lib/utils/text_utility'; + +/** + * Get the index from sourcepos that represents the line of + * the description when the description is split by newline. + * + * @param {String} sourcepos Source position in format `23:3-23:14` + * @returns {Number} Index of description split by newline + */ +const getDescriptionIndex = (sourcepos) => { + const [startRange] = sourcepos.split(HYPHEN); + const [startRow] = startRange.split(COLON); + return startRow - 1; +}; + +/** + * Given a `ul` or `ol` element containing a new sort order, this function performs + * a depth-first search to get the new sort order in the form of sourcepos indices. + * + * @param {HTMLElement} list A `ul` or `ol` element containing a new sort order + * @returns {Array} An array representing the new order of the list + */ +const getNewSourcePositions = (list) => { + const newSourcePositions = []; + + function pushPositionOfChildListItems(el) { + if (!el) { + return; + } + if (el.tagName === 'LI') { + newSourcePositions.push(getDescriptionIndex(el.dataset.sourcepos)); + } + Array.from(el.children).forEach(pushPositionOfChildListItems); + } + + pushPositionOfChildListItems(list); + + return newSourcePositions; +}; + +/** + * Converts a description to one with a new list sort order. + * + * Given a description like: + * + *
+ * 1. I am text
+ * 2.
+ * 3. - Item 1
+ * 4. - Item 2
+ * 5.   - Item 3
+ * 6.   - Item 4
+ * 7. - Item 5
+ * 
+ * + * And a reordered list (due to dragging Item 2 into Item 1's position) like: + * + *
+ * 
    + *
  • + * Item 2 + *
      + *
    • Item 3
    • + *
    • Item 4
    • + *
    + *
  • + *
  • Item 1
  • + *
  • Item 5
  • + *
      + *
+ * + * This function returns: + * + *
+ * 1. I am text
+ * 2.
+ * 3. - Item 2
+ * 4.   - Item 3
+ * 5.   - Item 4
+ * 6. - Item 1
+ * 7. - Item 5
+ * 
+ * + * @param {String} description Description in markdown format + * @param {HTMLElement} list A `ul` or `ol` element containing a new sort order + * @returns {String} Markdown with a new list sort order + */ +export const convertDescriptionWithNewSort = (description, list) => { + const descriptionLines = description.split(NEWLINE); + const startIndexOfList = getDescriptionIndex(list.dataset.sourcepos); + + getNewSourcePositions(list) + .map((lineIndex) => descriptionLines[lineIndex]) + .forEach((line, index) => { + descriptionLines[startIndexOfList + index] = line; + }); + + return descriptionLines.join(NEWLINE); +}; diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 85fe5ed7e26..396b015ad83 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -94,7 +94,7 @@ export default { 'emptyStateIllustration', 'isScrollingDown', 'emptyStateAction', - 'hasRunnersForProject', + 'hasOfflineRunnersForProject', ]), shouldRenderContent() { @@ -220,7 +220,7 @@ export default { diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue index abd0c13702a..f9cde61e917 100644 --- a/app/assets/javascripts/jobs/components/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/stuck_block.vue @@ -11,7 +11,7 @@ export default { GlLink, }, props: { - hasNoRunnersForProject: { + hasOfflineRunnersForProject: { type: Boolean, required: true, }, @@ -37,7 +37,7 @@ export default { dataTestId: 'job-stuck-with-tags', showTags: true, }; - } else if (this.hasNoRunnersForProject) { + } else if (this.hasOfflineRunnersForProject) { return { text: s__(`Job|This job is stuck because the project doesn't have any runners online assigned to it.`), diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index 5849a31e9e6..a0f9db7409d 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -46,5 +46,5 @@ export const shouldRenderSharedRunnerLimitWarning = (state) => export const isScrollingDown = (state) => isScrolledToBottom() && !state.isJobLogComplete; -export const hasRunnersForProject = (state) => +export const hasOfflineRunnersForProject = (state) => state?.job?.runners?.available && !state?.job?.runners?.online; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 419afa0a0a9..dad9cbcb6f6 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -6,6 +6,10 @@ import { } from '~/lib/utils/constants'; import { allSingleQuotes } from '~/lib/utils/regexp'; +export const COLON = ':'; +export const HYPHEN = '-'; +export const NEWLINE = '\n'; + /** * Adds a , to a string composed by numbers, at every 3 chars. * diff --git a/app/assets/javascripts/sortable/constants.js b/app/assets/javascripts/sortable/constants.js index 7fddac00ab2..f5bb0a3b11f 100644 --- a/app/assets/javascripts/sortable/constants.js +++ b/app/assets/javascripts/sortable/constants.js @@ -1,3 +1,5 @@ +export const DRAG_CLASS = 'is-dragging'; + /** * Default config options for sortablejs. * @type {object} @@ -12,7 +14,7 @@ export const defaultSortableOptions = { animation: 200, forceFallback: true, - fallbackClass: 'is-dragging', + fallbackClass: DRAG_CLASS, fallbackOnBody: true, ghostClass: 'is-ghost', fallbackTolerance: 1, diff --git a/app/assets/javascripts/sortable/utils.js b/app/assets/javascripts/sortable/utils.js index c2c8fb03b58..88ac1295a39 100644 --- a/app/assets/javascripts/sortable/utils.js +++ b/app/assets/javascripts/sortable/utils.js @@ -1,13 +1,17 @@ /* global DocumentTouch */ -import { defaultSortableOptions } from './constants'; +import { defaultSortableOptions, DRAG_CLASS } from './constants'; export function sortableStart() { - document.body.classList.add('is-dragging'); + document.body.classList.add(DRAG_CLASS); } export function sortableEnd() { - document.body.classList.remove('is-dragging'); + document.body.classList.remove(DRAG_CLASS); +} + +export function isDragging() { + return document.body.classList.contains(DRAG_CLASS); } export function getSortableDefaultOptions(options) { diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 1d83a61f2ae..6c99a749edc 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -42,21 +42,30 @@ export default {