Commit 70b92fb3 authored by Sean McGivern's avatar Sean McGivern

Merge branch...

Merge branch '19745-forms-with-task-lists-can-be-overwritten-when-editing-simultaneously' into 'master'

Forms with task lists can be overwritten when editing simultaneously

See merge request gitlab-org/gitlab-ce!23938
parents 200ebab1 863ee930
...@@ -113,7 +113,7 @@ gem 'seed-fu', '~> 2.3.7' ...@@ -113,7 +113,7 @@ gem 'seed-fu', '~> 2.3.7'
# Markdown and HTML processing # Markdown and HTML processing
gem 'html-pipeline', '~> 2.8' gem 'html-pipeline', '~> 2.8'
gem 'deckar01-task_list', '2.0.1' gem 'deckar01-task_list', '2.2.0'
gem 'gitlab-markup', '~> 1.6.5' gem 'gitlab-markup', '~> 1.6.5'
gem 'github-markup', '~> 1.7.0', require: 'github/markup' gem 'github-markup', '~> 1.7.0', require: 'github/markup'
gem 'redcarpet', '~> 3.4' gem 'redcarpet', '~> 3.4'
......
...@@ -143,7 +143,7 @@ GEM ...@@ -143,7 +143,7 @@ GEM
database_cleaner (1.7.0) database_cleaner (1.7.0)
debug_inspector (0.0.3) debug_inspector (0.0.3)
debugger-ruby_core_source (1.3.8) debugger-ruby_core_source (1.3.8)
deckar01-task_list (2.0.1) deckar01-task_list (2.2.0)
html-pipeline html-pipeline
declarative (0.0.10) declarative (0.0.10)
declarative-option (0.1.0) declarative-option (0.1.0)
...@@ -982,7 +982,7 @@ DEPENDENCIES ...@@ -982,7 +982,7 @@ DEPENDENCIES
connection_pool (~> 2.0) connection_pool (~> 2.0)
creole (~> 0.5.0) creole (~> 0.5.0)
database_cleaner (~> 1.7.0) database_cleaner (~> 1.7.0)
deckar01-task_list (= 2.0.1) deckar01-task_list (= 2.2.0)
device_detector device_detector
devise (~> 4.4) devise (~> 4.4)
devise-two-factor (~> 3.0.0) devise-two-factor (~> 3.0.0)
......
<script> <script>
import Visibility from 'visibilityjs'; import Visibility from 'visibilityjs';
import { __, s__, sprintf } from '~/locale';
import createFlash from '~/flash';
import { visitUrl } from '../../lib/utils/url_utility'; import { visitUrl } from '../../lib/utils/url_utility';
import Poll from '../../lib/utils/poll'; import Poll from '../../lib/utils/poll';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
...@@ -10,7 +12,6 @@ import descriptionComponent from './description.vue'; ...@@ -10,7 +12,6 @@ import descriptionComponent from './description.vue';
import editedComponent from './edited.vue'; import editedComponent from './edited.vue';
import formComponent from './form.vue'; import formComponent from './form.vue';
import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
import { __ } from '~/locale';
export default { export default {
components: { components: {
...@@ -130,6 +131,11 @@ export default { ...@@ -130,6 +131,11 @@ export default {
required: false, required: false,
default: true, default: true,
}, },
lockVersion: {
type: Number,
required: false,
default: 0,
},
}, },
data() { data() {
const store = new Store({ const store = new Store({
...@@ -141,6 +147,7 @@ export default { ...@@ -141,6 +147,7 @@ export default {
updatedByName: this.updatedByName, updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath, updatedByPath: this.updatedByPath,
taskStatus: this.initialTaskStatus, taskStatus: this.initialTaskStatus,
lock_version: this.lockVersion,
}); });
return { return {
...@@ -161,6 +168,9 @@ export default { ...@@ -161,6 +168,9 @@ export default {
const titleChanged = this.initialTitleText !== this.store.formState.title; const titleChanged = this.initialTitleText !== this.store.formState.title;
return descriptionChanged || titleChanged; return descriptionChanged || titleChanged;
}, },
defaultErrorMessage() {
return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType });
},
}, },
created() { created() {
this.service = new Service(this.endpoint); this.service = new Service(this.endpoint);
...@@ -207,6 +217,17 @@ export default { ...@@ -207,6 +217,17 @@ export default {
} }
return undefined; return undefined;
}, },
updateStoreState() {
return this.service
.getData()
.then(res => res.data)
.then(data => {
this.store.updateState(data);
})
.catch(() => {
createFlash(this.defaultErrorMessage);
});
},
openForm() { openForm() {
if (!this.showForm) { if (!this.showForm) {
...@@ -214,6 +235,7 @@ export default { ...@@ -214,6 +235,7 @@ export default {
this.store.setFormState({ this.store.setFormState({
title: this.state.titleText, title: this.state.titleText,
description: this.state.descriptionText, description: this.state.descriptionText,
lock_version: this.state.lock_version,
lockedWarningVisible: false, lockedWarningVisible: false,
updateLoading: false, updateLoading: false,
}); });
...@@ -232,20 +254,24 @@ export default { ...@@ -232,20 +254,24 @@ export default {
if (window.location.pathname !== data.web_url) { if (window.location.pathname !== data.web_url) {
visitUrl(data.web_url); visitUrl(data.web_url);
} }
return this.service.getData();
}) })
.then(res => res.data) .then(this.updateStoreState)
.then(data => { .then(() => {
this.store.updateState(data);
eventHub.$emit('close.form'); eventHub.$emit('close.form');
}) })
.catch(error => { .catch((error = {}) => {
if (error && error.name === 'SpamError') { const { name, response = {} } = error;
if (name === 'SpamError') {
this.openRecaptcha(); this.openRecaptcha();
} else { } else {
eventHub.$emit('close.form'); let errMsg = this.defaultErrorMessage;
window.Flash(`Error updating ${this.issuableType}`);
if (response.data && response.data.errors) {
errMsg += `. ${response.data.errors.join(' ')}`;
}
createFlash(errMsg);
} }
}); });
}, },
...@@ -269,8 +295,9 @@ export default { ...@@ -269,8 +295,9 @@ export default {
visitUrl(data.web_url); visitUrl(data.web_url);
}) })
.catch(() => { .catch(() => {
eventHub.$emit('close.form'); createFlash(
window.Flash(`Error deleting ${this.issuableType}`); sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }),
);
}); });
}, },
}, },
...@@ -314,6 +341,8 @@ export default { ...@@ -314,6 +341,8 @@ export default {
:task-status="state.taskStatus" :task-status="state.taskStatus"
:issuable-type="issuableType" :issuable-type="issuableType"
:update-url="updateEndpoint" :update-url="updateEndpoint"
:lock-version="state.lock_version"
@taskListUpdateFailed="updateStoreState"
/> />
<edited-component <edited-component
v-if="hasUpdated" v-if="hasUpdated"
......
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import { __ } from '~/locale';
import animateMixin from '../mixins/animate'; import animateMixin from '../mixins/animate';
import TaskList from '../../task_list'; import TaskList from '../../task_list';
import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
...@@ -35,6 +36,11 @@ export default { ...@@ -35,6 +36,11 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
lockVersion: {
type: Number,
required: false,
default: 0,
},
}, },
data() { data() {
return { return {
...@@ -67,8 +73,10 @@ export default { ...@@ -67,8 +73,10 @@ export default {
new TaskList({ new TaskList({
dataType: this.issuableType, dataType: this.issuableType,
fieldName: 'description', fieldName: 'description',
lockVersion: this.lockVersion,
selector: '.detail-page-description', selector: '.detail-page-description',
onSuccess: this.taskListUpdateSuccess.bind(this), onSuccess: this.taskListUpdateSuccess.bind(this),
onError: this.taskListUpdateError.bind(this),
}); });
} }
}, },
...@@ -82,6 +90,16 @@ export default { ...@@ -82,6 +90,16 @@ export default {
} }
}, },
taskListUpdateError() {
window.Flash(
__(
'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.',
),
);
this.$emit('taskListUpdateFailed');
},
updateTaskStatusText() { updateTaskStatusText() {
const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/); const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
const $issuableHeader = $('.issuable-meta'); const $issuableHeader = $('.issuable-meta');
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default class Store { export default class Store {
constructor(initialState) { constructor(initialState) {
this.state = initialState; this.state = initialState;
...@@ -6,6 +8,7 @@ export default class Store { ...@@ -6,6 +8,7 @@ export default class Store {
description: '', description: '',
lockedWarningVisible: false, lockedWarningVisible: false,
updateLoading: false, updateLoading: false,
lock_version: 0,
}; };
} }
...@@ -14,14 +17,10 @@ export default class Store { ...@@ -14,14 +17,10 @@ export default class Store {
this.formState.lockedWarningVisible = true; this.formState.lockedWarningVisible = true;
} }
Object.assign(this.state, convertObjectPropsToCamelCase(data));
this.state.titleHtml = data.title; this.state.titleHtml = data.title;
this.state.titleText = data.title_text;
this.state.descriptionHtml = data.description; this.state.descriptionHtml = data.description;
this.state.descriptionText = data.description_text; this.state.lock_version = data.lock_version;
this.state.taskStatus = data.task_status;
this.state.updatedAt = data.updated_at;
this.state.updatedByName = data.updated_by_name;
this.state.updatedByPath = data.updated_by_path;
} }
stateShouldUpdate(data) { stateShouldUpdate(data) {
......
...@@ -35,6 +35,7 @@ function MergeRequest(opts) { ...@@ -35,6 +35,7 @@ function MergeRequest(opts) {
dataType: 'merge_request', dataType: 'merge_request',
fieldName: 'description', fieldName: 'description',
selector: '.detail-page-description', selector: '.detail-page-description',
lockVersion: this.$el.data('lockVersion'),
onSuccess: result => { onSuccess: result => {
document.querySelector('#task_status').innerText = result.task_status; document.querySelector('#task_status').innerText = result.task_status;
document.querySelector('#task_status_short').innerText = result.task_status_short; document.querySelector('#task_status_short').innerText = result.task_status_short;
......
import $ from 'jquery'; import $ from 'jquery';
import 'deckar01-task_list'; import 'deckar01-task_list';
import { __ } from '~/locale';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
import Flash from './flash'; import Flash from './flash';
...@@ -8,46 +9,79 @@ export default class TaskList { ...@@ -8,46 +9,79 @@ export default class TaskList {
this.selector = options.selector; this.selector = options.selector;
this.dataType = options.dataType; this.dataType = options.dataType;
this.fieldName = options.fieldName; this.fieldName = options.fieldName;
this.lockVersion = options.lockVersion;
this.taskListContainerSelector = `${this.selector} .js-task-list-container`;
this.updateHandler = this.update.bind(this);
this.onSuccess = options.onSuccess || (() => {}); this.onSuccess = options.onSuccess || (() => {});
this.onError = function showFlash(e) { this.onError =
options.onError ||
function showFlash(e) {
let errorMessages = ''; let errorMessages = '';
if (e.response.data && typeof e.response.data === 'object') { if (e.response.data && typeof e.response.data === 'object') {
errorMessages = e.response.data.errors.join(' '); errorMessages = e.response.data.errors.join(' ');
} }
return new Flash(errorMessages || 'Update failed', 'alert'); return new Flash(errorMessages || __('Update failed'), 'alert');
}; };
this.init(); this.init();
} }
init() { init() {
// Prevent duplicate event bindings this.disable(); // Prevent duplicate event bindings
this.disable();
$(`${this.selector} .js-task-list-container`).taskList('enable'); $(this.taskListContainerSelector).taskList('enable');
$(document).on( $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler);
'tasklist:changed', }
`${this.selector} .js-task-list-container`,
this.update.bind(this), getTaskListTarget(e) {
); return e && e.currentTarget ? $(e.currentTarget) : $(this.taskListContainerSelector);
}
disableTaskListItems(e) {
this.getTaskListTarget(e).taskList('disable');
}
enableTaskListItems(e) {
this.getTaskListTarget(e).taskList('enable');
} }
disable() { disable() {
$(`${this.selector} .js-task-list-container`).taskList('disable'); this.disableTaskListItems();
$(document).off('tasklist:changed', `${this.selector} .js-task-list-container`); $(document).off('tasklist:changed', this.taskListContainerSelector);
} }
update(e) { update(e) {
const $target = $(e.target); const $target = $(e.target);
const { index, checked, lineNumber, lineSource } = e.detail;
const patchData = {}; const patchData = {};
patchData[this.dataType] = { patchData[this.dataType] = {
[this.fieldName]: $target.val(), [this.fieldName]: $target.val(),
lock_version: this.lockVersion,
update_task: {
index,
checked,
line_number: lineNumber,
line_source: lineSource,
},
}; };
this.disableTaskListItems(e);
return axios return axios
.patch($target.data('updateUrl') || $('form.js-issuable-update').attr('action'), patchData) .patch($target.data('updateUrl') || $('form.js-issuable-update').attr('action'), patchData)
.then(({ data }) => this.onSuccess(data)) .then(({ data }) => {
.catch(err => this.onError(err)); this.lockVersion = data.lock_version;
this.enableTaskListItems(e);
return this.onSuccess(data);
})
.catch(({ response }) => {
this.enableTaskListItems(e);
return this.onError(response.data);
});
} }
} }
...@@ -58,7 +58,8 @@ module IssuableActions ...@@ -58,7 +58,8 @@ module IssuableActions
title_text: issuable.title, title_text: issuable.title,
description: view_context.markdown_field(issuable, :description), description: view_context.markdown_field(issuable, :description),
description_text: issuable.description, description_text: issuable.description,
task_status: issuable.task_status task_status: issuable.task_status,
lock_version: issuable.lock_version
} }
if issuable.edited? if issuable.edited?
......
...@@ -246,7 +246,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -246,7 +246,7 @@ class Projects::IssuesController < Projects::ApplicationController
task_num task_num
lock_version lock_version
discussion_locked discussion_locked
] + [{ label_ids: [], assignee_ids: [] }] ] + [{ label_ids: [], assignee_ids: [], update_task: [:index, :checked, :line_number, :line_source] }]
end end
def store_uri def store_uri
......
...@@ -269,6 +269,7 @@ module IssuablesHelper ...@@ -269,6 +269,7 @@ module IssuablesHelper
markdownPreviewPath: preview_markdown_path(parent), markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'), markdownDocsPath: help_page_path('user/markdown'),
markdownVersion: issuable.cached_markdown_version, markdownVersion: issuable.cached_markdown_version,
lockVersion: issuable.lock_version,
issuableTemplates: issuable_templates(issuable), issuableTemplates: issuable_templates(issuable),
initialTitleHtml: markdown_field(issuable, :title), initialTitleHtml: markdown_field(issuable, :title),
initialTitleText: issuable.title, initialTitleText: issuable.title,
......
...@@ -130,13 +130,17 @@ module CacheMarkdownField ...@@ -130,13 +130,17 @@ module CacheMarkdownField
def latest_cached_markdown_version def latest_cached_markdown_version
return CacheMarkdownField::CACHE_COMMONMARK_VERSION unless cached_markdown_version return CacheMarkdownField::CACHE_COMMONMARK_VERSION unless cached_markdown_version
if cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START if legacy_markdown?
CacheMarkdownField::CACHE_REDCARPET_VERSION CacheMarkdownField::CACHE_REDCARPET_VERSION
else else
CacheMarkdownField::CACHE_COMMONMARK_VERSION CacheMarkdownField::CACHE_COMMONMARK_VERSION
end end
end end
def legacy_markdown?
cached_markdown_version && cached_markdown_version.between?(1, CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - 1)
end
included do included do
cattr_reader :cached_markdown_fields do cattr_reader :cached_markdown_fields do
FieldData.new FieldData.new
...@@ -179,6 +183,8 @@ module CacheMarkdownField ...@@ -179,6 +183,8 @@ module CacheMarkdownField
define_method(invalidation_method) do define_method(invalidation_method) do
changed_fields = changed_attributes.keys changed_fields = changed_attributes.keys
invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY] invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html")
!invalidations.empty? || !cached_html_up_to_date?(markdown_field) !invalidations.empty? || !cached_html_up_to_date?(markdown_field)
end end
end end
......
...@@ -270,6 +270,8 @@ module Issuable ...@@ -270,6 +270,8 @@ module Issuable
def to_hook_data(user, old_associations: {}) def to_hook_data(user, old_associations: {})
changes = previous_changes changes = previous_changes
if old_associations
old_labels = old_associations.fetch(:labels, []) old_labels = old_associations.fetch(:labels, [])
old_assignees = old_associations.fetch(:assignees, []) old_assignees = old_associations.fetch(:assignees, [])
...@@ -292,6 +294,7 @@ module Issuable ...@@ -292,6 +294,7 @@ module Issuable
changes[:total_time_spent] = [old_total_time_spent, total_time_spent] changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
end end
end end
end
Gitlab::HookData::IssuableBuilder.new(self).build(user: user, changes: changes) Gitlab::HookData::IssuableBuilder.new(self).build(user: user, changes: changes)
end end
......
...@@ -11,6 +11,8 @@ require 'task_list/filter' ...@@ -11,6 +11,8 @@ require 'task_list/filter'
module Taskable module Taskable
COMPLETED = 'completed'.freeze COMPLETED = 'completed'.freeze
INCOMPLETE = 'incomplete'.freeze INCOMPLETE = 'incomplete'.freeze
COMPLETE_PATTERN = /(\[[xX]\])/.freeze
INCOMPLETE_PATTERN = /(\[[\s]\])/.freeze
ITEM_PATTERN = %r{ ITEM_PATTERN = %r{
^ ^
\s*(?:[-+*]|(?:\d+\.)) # list prefix required - task item has to be always in a list \s*(?:[-+*]|(?:\d+\.)) # list prefix required - task item has to be always in a list
......
...@@ -11,4 +11,5 @@ class MergeRequestBasicEntity < Grape::Entity ...@@ -11,4 +11,5 @@ class MergeRequestBasicEntity < Grape::Entity
expose :labels, using: LabelEntity expose :labels, using: LabelEntity
expose :assignee, using: API::Entities::UserBasic expose :assignee, using: API::Entities::UserBasic
expose :task_status, :task_status_short expose :task_status, :task_status_short
expose :lock_version, :lock_version
end end
...@@ -20,7 +20,7 @@ module Issuable ...@@ -20,7 +20,7 @@ module Issuable
create_due_date_note if issuable.previous_changes.include?('due_date') create_due_date_note if issuable.previous_changes.include?('due_date')
create_milestone_note if issuable.previous_changes.include?('milestone_id') create_milestone_note if issuable.previous_changes.include?('milestone_id')
create_labels_note(old_labels) if issuable.labels != old_labels create_labels_note(old_labels) if old_labels && issuable.labels != old_labels
end end
private private
......
...@@ -235,6 +235,63 @@ class IssuableBaseService < BaseService ...@@ -235,6 +235,63 @@ class IssuableBaseService < BaseService
issuable issuable
end end
def update_task(issuable)
filter_params(issuable)
if issuable.changed? || params.present?
issuable.assign_attributes(params.merge(updated_by: current_user,
last_edited_at: Time.now,
last_edited_by: current_user))
before_update(issuable)
if issuable.with_transaction_returning_status { issuable.save }
# We do not touch as it will affect a update on updated_at field
ActiveRecord::Base.no_touching do
Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: nil)
end
handle_task_changes(issuable)
invalidate_cache_counts(issuable, users: issuable.assignees.to_a)
after_update(issuable)
execute_hooks(issuable, 'update', old_associations: nil)
end
end
issuable
end
# Handle the `update_task` event sent from UI. Attempts to update a specific
# line in the markdown and cached html, bypassing any unnecessary updates or checks.
def update_task_event(issuable)
update_task_params = params.delete(:update_task)
return unless update_task_params
tasklist_toggler = TaskListToggleService.new(issuable.description, issuable.description_html,
line_source: update_task_params[:line_source],
line_number: update_task_params[:line_number].to_i,
toggle_as_checked: update_task_params[:checked],
index: update_task_params[:index].to_i,
sourcepos: !issuable.legacy_markdown?)
unless tasklist_toggler.execute
# if we make it here, the data is much newer than we thought it was - fail fast
raise ActiveRecord::StaleObjectError
end
# by updating the description_html field at the same time,
# the markdown cache won't be considered invalid
params[:description] = tasklist_toggler.updated_markdown
params[:description_html] = tasklist_toggler.updated_markdown_html
# since we're updating a very specific line, we don't care whether
# the `lock_version` sent from the FE is the same or not. Just
# make sure the data hasn't changed since we queried it
params[:lock_version] = issuable.lock_version
update_task(issuable)
end
def labels_changing?(old_label_ids, new_label_ids) def labels_changing?(old_label_ids, new_label_ids)
old_label_ids.sort != new_label_ids.sort old_label_ids.sort != new_label_ids.sort
end end
...@@ -317,6 +374,10 @@ class IssuableBaseService < BaseService ...@@ -317,6 +374,10 @@ class IssuableBaseService < BaseService
def handle_changes(issuable, options) def handle_changes(issuable, options)
end end
# override if needed
def handle_task_changes(issuable)
end
# override if needed # override if needed
def execute_hooks(issuable, action = 'open', params = {}) def execute_hooks(issuable, action = 'open', params = {})
end end
......
...@@ -8,7 +8,7 @@ module Issues ...@@ -8,7 +8,7 @@ module Issues
handle_move_between_ids(issue) handle_move_between_ids(issue)
filter_spam_check_params filter_spam_check_params
change_issue_duplicate(issue) change_issue_duplicate(issue)
move_issue_to_new_project(issue) || update(issue) move_issue_to_new_project(issue) || update_task_event(issue) || update(issue)
end end
def update(issue) def update(issue)
...@@ -63,6 +63,11 @@ module Issues ...@@ -63,6 +63,11 @@ module Issues
end end
end end
def handle_task_changes(issuable)
todo_service.mark_pending_todos_as_done(issuable, current_user)
todo_service.update_issue(issuable, current_user)
end
def handle_move_between_ids(issue) def handle_move_between_ids(issue)
return unless params[:move_between_ids] return unless params[:move_between_ids]
...@@ -78,6 +83,8 @@ module Issues ...@@ -78,6 +83,8 @@ module Issues
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def change_issue_duplicate(issue) def change_issue_duplicate(issue)
canonical_issue_id = params.delete(:canonical_issue_id) canonical_issue_id = params.delete(:canonical_issue_id)
return unless canonical_issue_id
canonical_issue = IssuesFinder.new(current_user).find_by(id: canonical_issue_id) canonical_issue = IssuesFinder.new(current_user).find_by(id: canonical_issue_id)
if canonical_issue if canonical_issue
......
# frozen_string_literal: true
# Finds the correct checkbox in the passed in markdown/html and toggles it's state,
# returning the updated markdown/html.
# We don't care if the text has changed above or below the specific checkbox, as long
# the checkbox still exists at exactly the same line number and the text is equal.
# If successful, new values are available in `updated_markdown` and `updated_markdown_html`
#
# Note: once we've removed RedCarpet support, we can remove the `index` and `sourcepos`
# parameters
class TaskListToggleService
attr_reader :updated_markdown, :updated_markdown_html
def initialize(markdown, markdown_html, line_source:, line_number:, toggle_as_checked:, index:, sourcepos: true)
@markdown, @markdown_html = markdown, markdown_html
@line_source, @line_number = line_source, line_number
@toggle_as_checked = toggle_as_checked
@index, @use_sourcepos = index, sourcepos
@updated_markdown, @updated_markdown_html = nil
end
def execute
return false unless markdown && markdown_html
toggle_markdown && toggle_markdown_html
end
private
attr_reader :markdown, :markdown_html, :index, :toggle_as_checked
attr_reader :line_source, :line_number, :use_sourcepos
def toggle_markdown
source_lines = markdown.split("\n")
source_line_index = line_number - 1
markdown_task = source_lines[source_line_index]
return unless markdown_task == line_source
return unless source_checkbox = Taskable::ITEM_PATTERN.match(markdown_task)
currently_checked = TaskList::Item.new(source_checkbox[1]).complete?
# Check `toggle_as_checked` to make sure we don't accidentally replace
# any `[ ]` or `[x]` in the middle of the text
if currently_checked
markdown_task.sub!(Taskable::COMPLETE_PATTERN, '[ ]') unless toggle_as_checked
else
markdown_task.sub!(Taskable::INCOMPLETE_PATTERN, '[x]') if toggle_as_checked
end
source_lines[source_line_index] = markdown_task
@updated_markdown = source_lines.join("\n")
end
def toggle_markdown_html
html = Nokogiri::HTML.fragment(markdown_html)
html_checkbox = get_html_checkbox(html)
return unless html_checkbox
if toggle_as_checked
html_checkbox[:checked] = 'checked'
else
html_checkbox.remove_attribute('checked')
end
@updated_markdown_html = html.to_html
end
# When using CommonMark, we should be able to use the embedded `sourcepos` attribute to
# target the exact line in the DOM. For RedCarpet, we need to use the index of the checkbox
# that was checked and match it with what we think is the same checkbox.
# The reason `sourcepos` is slightly more reliable is the case where a line of text is
# changed from a regular line into a checkbox (or vice versa). Then the checked index
# in the UI will be off from the list of checkboxes we've calculated locally.
# It's a rare circumstance, but since we can account for it, we do.
def get_html_checkbox(html)
if use_sourcepos
html.css(".task-list-item[data-sourcepos^='#{line_number}:'] > input.task-list-item-checkbox").first
else
html.css('.task-list-item-checkbox')[index - 1]
end
end
end
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
- page_card_attributes @merge_request.card_attributes - page_card_attributes @merge_request.card_attributes
- suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes') - suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes')
.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } } .merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } }
= render "projects/merge_requests/mr_title" = render "projects/merge_requests/mr_title"
.merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
......
---
title: Increase reliability and performance of toggling task items
merge_request: 23938
author:
type: fixed
...@@ -2992,6 +2992,9 @@ msgstr "" ...@@ -2992,6 +2992,9 @@ msgstr ""
msgid "Error Tracking" msgid "Error Tracking"
msgstr "" msgstr ""
msgid "Error deleting %{issuableType}"
msgstr ""
msgid "Error fetching contributors data." msgid "Error fetching contributors data."
msgstr "" msgstr ""
...@@ -3040,6 +3043,9 @@ msgstr "" ...@@ -3040,6 +3043,9 @@ msgstr ""
msgid "Error saving label update." msgid "Error saving label update."
msgstr "" msgstr ""
msgid "Error updating %{issuableType}"
msgstr ""
msgid "Error updating status for all todos." msgid "Error updating status for all todos."
msgstr "" msgstr ""
...@@ -6524,6 +6530,9 @@ msgstr "" ...@@ -6524,6 +6530,9 @@ msgstr ""
msgid "Snippets" msgid "Snippets"
msgstr "" msgstr ""
msgid "Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again."
msgstr ""
msgid "Something went wrong on our end" msgid "Something went wrong on our end"
msgstr "" msgstr ""
...@@ -7710,6 +7719,9 @@ msgstr "" ...@@ -7710,6 +7719,9 @@ msgstr ""
msgid "Update" msgid "Update"
msgstr "" msgstr ""
msgid "Update failed"
msgstr ""
msgid "Update now" msgid "Update now"
msgstr "" msgstr ""
......
...@@ -379,6 +379,23 @@ describe Projects::IssuesController do ...@@ -379,6 +379,23 @@ describe Projects::IssuesController do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
end end
end end
context 'when getting the changes' do
before do
project.add_developer(user)
sign_in(user)
end
it 'returns the necessary data' do
go(id: issue.iid)
data = JSON.parse(response.body)
expect(data).to include('title_text', 'description', 'description_text')
expect(data).to include('task_status', 'lock_version')
end
end
end end
describe 'Confidential Issues' do describe 'Confidential Issues' do
......
...@@ -170,8 +170,11 @@ describe 'Task Lists' do ...@@ -170,8 +170,11 @@ describe 'Task Lists' do
expect(page).to have_content("2 of 7 tasks completed") expect(page).to have_content("2 of 7 tasks completed")
page.find('li.task-list-item', text: 'Task b').find('input').click page.find('li.task-list-item', text: 'Task b').find('input').click
wait_for_requests
page.find('li.task-list-item ul li.task-list-item', text: 'Task a.2').find('input').click page.find('li.task-list-item ul li.task-list-item', text: 'Task a.2').find('input').click
wait_for_requests
page.find('li.task-list-item ol li.task-list-item', text: 'Task 1.1').find('input').click page.find('li.task-list-item ol li.task-list-item', text: 'Task 1.1').find('input').click
wait_for_requests
expect(page).to have_content("5 of 7 tasks completed") expect(page).to have_content("5 of 7 tasks completed")
...@@ -184,25 +187,24 @@ describe 'Task Lists' do ...@@ -184,25 +187,24 @@ describe 'Task Lists' do
end end
describe 'nested tasks', :js do describe 'nested tasks', :js do
context 'with Redcarpet' do let(:cache_version) { CacheMarkdownField::CACHE_COMMONMARK_VERSION }
let(:issue) { create(:issue, description: nested_tasks_markdown_redcarpet, author: user, project: project) } let!(:issue) do
create(:issue, description: nested_tasks_markdown, author: user, project: project,
cached_markdown_version: cache_version)
end
before do before do
allow_any_instance_of(Banzai::Filter::MarkdownFilter).to receive(:engine).and_return('Redcarpet')
visit_issue(project, issue) visit_issue(project, issue)
end end
context 'with Redcarpet' do
let(:cache_version) { CacheMarkdownField::CACHE_REDCARPET_VERSION }
let(:nested_tasks_markdown) { nested_tasks_markdown_redcarpet }
it_behaves_like 'shared nested tasks' it_behaves_like 'shared nested tasks'
end end
context 'with CommonMark' do context 'with CommonMark' do
let(:issue) { create(:issue, description: nested_tasks_markdown, author: user, project: project) }
before do
allow_any_instance_of(Banzai::Filter::MarkdownFilter).to receive(:engine).and_return('CommonMark')
visit_issue(project, issue)
end
it_behaves_like 'shared nested tasks' it_behaves_like 'shared nested tasks'
end end
end end
......
...@@ -22,7 +22,8 @@ ...@@ -22,7 +22,8 @@
"type": [ "array", "null" ] "type": [ "array", "null" ]
}, },
"task_status": { "type": "string" }, "task_status": { "type": "string" },
"task_status_short": { "type": "string" } "task_status_short": { "type": "string" },
"lock_version": { "type": ["string", "null"] }
}, },
"additionalProperties": false "additionalProperties": false
} }
...@@ -190,6 +190,7 @@ describe IssuablesHelper do ...@@ -190,6 +190,7 @@ describe IssuablesHelper do
markdownDocsPath: '/help/user/markdown', markdownDocsPath: '/help/user/markdown',
markdownVersion: CacheMarkdownField::CACHE_COMMONMARK_VERSION, markdownVersion: CacheMarkdownField::CACHE_COMMONMARK_VERSION,
issuableTemplates: [], issuableTemplates: [],
lockVersion: issue.lock_version,
projectPath: @project.path, projectPath: @project.path,
projectNamespace: @project.namespace.path, projectNamespace: @project.namespace.path,
initialTitleHtml: issue.title, initialTitleHtml: issue.title,
......
...@@ -18,9 +18,13 @@ describe('Issuable output', () => { ...@@ -18,9 +18,13 @@ describe('Issuable output', () => {
let realtimeRequestCount = 0; let realtimeRequestCount = 0;
let vm; let vm;
document.body.innerHTML = '<span id="task_status"></span>';
beforeEach(done => { beforeEach(done => {
setFixtures(`
<div>
<div class="flash-container"></div>
<span id="task_status"></span>
</div>
`);
spyOn(eventHub, '$emit'); spyOn(eventHub, '$emit');
const IssuableDescriptionComponent = Vue.extend(issuableApp); const IssuableDescriptionComponent = Vue.extend(issuableApp);
...@@ -43,6 +47,7 @@ describe('Issuable output', () => { ...@@ -43,6 +47,7 @@ describe('Issuable output', () => {
initialTitleText: '', initialTitleText: '',
initialDescriptionHtml: 'test', initialDescriptionHtml: 'test',
initialDescriptionText: 'test', initialDescriptionText: 'test',
lockVersion: 1,
markdownPreviewPath: '/', markdownPreviewPath: '/',
markdownDocsPath: '/', markdownDocsPath: '/',
projectNamespace: '/', projectNamespace: '/',
...@@ -78,6 +83,7 @@ describe('Issuable output', () => { ...@@ -78,6 +83,7 @@ describe('Issuable output', () => {
expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/); expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
expect(editedText.querySelector('.author-link').href).toMatch(/\/some_user$/); expect(editedText.querySelector('.author-link').href).toMatch(/\/some_user$/);
expect(editedText.querySelector('time')).toBeTruthy(); expect(editedText.querySelector('time')).toBeTruthy();
expect(vm.state.lock_version).toEqual(1);
}) })
.then(() => { .then(() => {
vm.poll.makeRequest(); vm.poll.makeRequest();
...@@ -95,6 +101,7 @@ describe('Issuable output', () => { ...@@ -95,6 +101,7 @@ describe('Issuable output', () => {
expect(editedText.querySelector('.author-link').href).toMatch(/\/other_user$/); expect(editedText.querySelector('.author-link').href).toMatch(/\/other_user$/);
expect(editedText.querySelector('time')).toBeTruthy(); expect(editedText.querySelector('time')).toBeTruthy();
expect(vm.state.lock_version).toEqual(2);
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
...@@ -137,21 +144,17 @@ describe('Issuable output', () => { ...@@ -137,21 +144,17 @@ describe('Issuable output', () => {
describe('updateIssuable', () => { describe('updateIssuable', () => {
it('fetches new data after update', done => { it('fetches new data after update', done => {
spyOn(vm, 'updateStoreState').and.callThrough();
spyOn(vm.service, 'getData').and.callThrough(); spyOn(vm.service, 'getData').and.callThrough();
spyOn(vm.service, 'updateIssuable').and.callFake( spyOn(vm.service, 'updateIssuable').and.returnValue(
() => Promise.resolve({
new Promise(resolve => { data: { web_url: window.location.pathname },
resolve({
data: {
confidential: false,
web_url: window.location.pathname,
},
});
}), }),
); );
vm.updateIssuable() vm.updateIssuable()
.then(() => { .then(() => {
expect(vm.updateStoreState).toHaveBeenCalled();
expect(vm.service.getData).toHaveBeenCalled(); expect(vm.service.getData).toHaveBeenCalled();
}) })
.then(done) .then(done)
...@@ -159,10 +162,9 @@ describe('Issuable output', () => { ...@@ -159,10 +162,9 @@ describe('Issuable output', () => {
}); });
it('correctly updates issuable data', done => { it('correctly updates issuable data', done => {
spyOn(vm.service, 'updateIssuable').and.callFake( spyOn(vm.service, 'updateIssuable').and.returnValue(
() => Promise.resolve({
new Promise(resolve => { data: { web_url: window.location.pathname },
resolve();
}), }),
); );
...@@ -177,15 +179,12 @@ describe('Issuable output', () => { ...@@ -177,15 +179,12 @@ describe('Issuable output', () => {
it('does not redirect if issue has not moved', done => { it('does not redirect if issue has not moved', done => {
const visitUrl = spyOnDependency(issuableApp, 'visitUrl'); const visitUrl = spyOnDependency(issuableApp, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake( spyOn(vm.service, 'updateIssuable').and.returnValue(
() => Promise.resolve({
new Promise(resolve => {
resolve({
data: { data: {
web_url: window.location.pathname, web_url: window.location.pathname,
confidential: vm.isConfidential, confidential: vm.isConfidential,
}, },
});
}), }),
); );
...@@ -199,15 +198,12 @@ describe('Issuable output', () => { ...@@ -199,15 +198,12 @@ describe('Issuable output', () => {
it('redirects if returned web_url has changed', done => { it('redirects if returned web_url has changed', done => {
const visitUrl = spyOnDependency(issuableApp, 'visitUrl'); const visitUrl = spyOnDependency(issuableApp, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake( spyOn(vm.service, 'updateIssuable').and.returnValue(
() => Promise.resolve({
new Promise(resolve => {
resolve({
data: { data: {
web_url: '/testing-issue-move', web_url: '/testing-issue-move',
confidential: vm.isConfidential, confidential: vm.isConfidential,
}, },
});
}), }),
); );
...@@ -227,6 +223,7 @@ describe('Issuable output', () => { ...@@ -227,6 +223,7 @@ describe('Issuable output', () => {
vm.handleBeforeUnloadEvent(e); vm.handleBeforeUnloadEvent(e);
Vue.nextTick(() => { Vue.nextTick(() => {
expect(e.returnValue).not.toBeNull(); expect(e.returnValue).not.toBeNull();
done(); done();
}); });
}); });
...@@ -238,6 +235,7 @@ describe('Issuable output', () => { ...@@ -238,6 +235,7 @@ describe('Issuable output', () => {
vm.handleBeforeUnloadEvent(e); vm.handleBeforeUnloadEvent(e);
Vue.nextTick(() => { Vue.nextTick(() => {
expect(e.returnValue).not.toBeNull(); expect(e.returnValue).not.toBeNull();
done(); done();
}); });
}); });
...@@ -247,49 +245,61 @@ describe('Issuable output', () => { ...@@ -247,49 +245,61 @@ describe('Issuable output', () => {
vm.handleBeforeUnloadEvent(e); vm.handleBeforeUnloadEvent(e);
Vue.nextTick(() => { Vue.nextTick(() => {
expect(e.returnValue).toBeNull(); expect(e.returnValue).toBeNull();
done(); done();
}); });
}); });
}); });
describe('error when updating', () => { describe('error when updating', () => {
beforeEach(() => {
spyOn(window, 'Flash').and.callThrough();
spyOn(vm.service, 'updateIssuable').and.callFake(
() =>
new Promise((resolve, reject) => {
reject();
}),
);
});
it('closes form on error', done => { it('closes form on error', done => {
spyOn(vm.service, 'updateIssuable').and.callFake(() => Promise.reject());
vm.updateIssuable(); vm.updateIssuable();
setTimeout(() => { setTimeout(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('close.form'); expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
expect(window.Flash).toHaveBeenCalledWith('Error updating issue'); `Error updating issue`,
);
done(); done();
}); });
}); });
it('returns the correct error message for issuableType', done => { it('returns the correct error message for issuableType', done => {
spyOn(vm.service, 'updateIssuable').and.callFake(() => Promise.reject());
vm.issuableType = 'merge request'; vm.issuableType = 'merge request';
Vue.nextTick(() => { Vue.nextTick(() => {
vm.updateIssuable(); vm.updateIssuable();
setTimeout(() => { setTimeout(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('close.form'); expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
expect(window.Flash).toHaveBeenCalledWith('Error updating merge request'); `Error updating merge request`,
);
done(); done();
}); });
}); });
}); });
it('shows error mesage from backend if exists', done => {
const msg = 'Custom error message from backend';
spyOn(vm.service, 'updateIssuable').and.callFake(
// eslint-disable-next-line prefer-promise-reject-errors
() => Promise.reject({ response: { data: { errors: [msg] } } }),
);
vm.updateIssuable();
setTimeout(() => {
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
`${vm.defaultErrorMessage}. ${msg}`,
);
done();
});
});
}); });
}); });
...@@ -342,14 +352,11 @@ describe('Issuable output', () => { ...@@ -342,14 +352,11 @@ describe('Issuable output', () => {
describe('deleteIssuable', () => { describe('deleteIssuable', () => {
it('changes URL when deleted', done => { it('changes URL when deleted', done => {
const visitUrl = spyOnDependency(issuableApp, 'visitUrl'); const visitUrl = spyOnDependency(issuableApp, 'visitUrl');
spyOn(vm.service, 'deleteIssuable').and.callFake( spyOn(vm.service, 'deleteIssuable').and.returnValue(
() => Promise.resolve({
new Promise(resolve => {
resolve({
data: { data: {
web_url: '/test', web_url: '/test',
}, },
});
}), }),
); );
...@@ -357,6 +364,7 @@ describe('Issuable output', () => { ...@@ -357,6 +364,7 @@ describe('Issuable output', () => {
setTimeout(() => { setTimeout(() => {
expect(visitUrl).toHaveBeenCalledWith('/test'); expect(visitUrl).toHaveBeenCalledWith('/test');
done(); done();
}); });
}); });
...@@ -364,14 +372,11 @@ describe('Issuable output', () => { ...@@ -364,14 +372,11 @@ describe('Issuable output', () => {
it('stops polling when deleting', done => { it('stops polling when deleting', done => {
spyOnDependency(issuableApp, 'visitUrl'); spyOnDependency(issuableApp, 'visitUrl');
spyOn(vm.poll, 'stop').and.callThrough(); spyOn(vm.poll, 'stop').and.callThrough();
spyOn(vm.service, 'deleteIssuable').and.callFake( spyOn(vm.service, 'deleteIssuable').and.returnValue(
() => Promise.resolve({
new Promise(resolve => {
resolve({
data: { data: {
web_url: '/test', web_url: '/test',
}, },
});
}), }),
); );
...@@ -379,25 +384,21 @@ describe('Issuable output', () => { ...@@ -379,25 +384,21 @@ describe('Issuable output', () => {
setTimeout(() => { setTimeout(() => {
expect(vm.poll.stop).toHaveBeenCalledWith(); expect(vm.poll.stop).toHaveBeenCalledWith();
done(); done();
}); });
}); });
it('closes form on error', done => { it('closes form on error', done => {
spyOn(window, 'Flash').and.callThrough(); spyOn(vm.service, 'deleteIssuable').and.returnValue(Promise.reject());
spyOn(vm.service, 'deleteIssuable').and.callFake(
() =>
new Promise((resolve, reject) => {
reject();
}),
);
vm.deleteIssuable(); vm.deleteIssuable();
setTimeout(() => { setTimeout(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('close.form'); expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
expect(window.Flash).toHaveBeenCalledWith('Error deleting issue'); 'Error deleting issue',
);
done(); done();
}); });
...@@ -420,6 +421,7 @@ describe('Issuable output', () => { ...@@ -420,6 +421,7 @@ describe('Issuable output', () => {
.then(vm.$nextTick) .then(vm.$nextTick)
.then(() => { .then(() => {
expect(vm.formState.lockedWarningVisible).toEqual(true); expect(vm.formState.lockedWarningVisible).toEqual(true);
expect(vm.formState.lock_version).toEqual(1);
expect(vm.$el.querySelector('.alert')).not.toBeNull(); expect(vm.$el.querySelector('.alert')).not.toBeNull();
}) })
.then(done) .then(done)
...@@ -438,4 +440,34 @@ describe('Issuable output', () => { ...@@ -438,4 +440,34 @@ describe('Issuable output', () => {
expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined(); expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined();
}); });
}); });
describe('updateStoreState', () => {
it('should make a request and update the state of the store', done => {
const data = { foo: 1 };
spyOn(vm.store, 'updateState');
spyOn(vm.service, 'getData').and.returnValue(Promise.resolve({ data }));
vm.updateStoreState()
.then(() => {
expect(vm.service.getData).toHaveBeenCalled();
expect(vm.store.updateState).toHaveBeenCalledWith(data);
})
.then(done)
.catch(done.fail);
});
it('should show error message if store update fails', done => {
spyOn(vm.service, 'getData').and.returnValue(Promise.reject());
vm.issuableType = 'merge request';
vm.updateStoreState()
.then(() => {
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
`Error updating ${vm.issuableType}`,
);
})
.then(done)
.catch(done.fail);
});
});
}); });
...@@ -123,7 +123,10 @@ describe('Description component', () => { ...@@ -123,7 +123,10 @@ describe('Description component', () => {
fieldName: 'description', fieldName: 'description',
selector: '.detail-page-description', selector: '.detail-page-description',
onSuccess: jasmine.any(Function), onSuccess: jasmine.any(Function),
onError: jasmine.any(Function),
lockVersion: 0,
}); });
done(); done();
}); });
}); });
...@@ -184,4 +187,18 @@ describe('Description component', () => { ...@@ -184,4 +187,18 @@ describe('Description component', () => {
it('sets data-update-url', () => { it('sets data-update-url', () => {
expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(gl.TEST_HOST); expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(gl.TEST_HOST);
}); });
describe('taskListUpdateError', () => {
it('should create flash notification and emit an event to parent', () => {
const msg =
'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.';
spyOn(window, 'Flash');
spyOn(vm, '$emit');
vm.taskListUpdateError();
expect(window.Flash).toHaveBeenCalledWith(msg);
expect(vm.$emit).toHaveBeenCalledWith('taskListUpdateFailed');
});
});
}); });
...@@ -8,6 +8,7 @@ export default { ...@@ -8,6 +8,7 @@ export default {
updated_at: '2015-05-15T12:31:04.428Z', updated_at: '2015-05-15T12:31:04.428Z',
updated_by_name: 'Some User', updated_by_name: 'Some User',
updated_by_path: '/some_user', updated_by_path: '/some_user',
lock_version: 1,
}, },
secondRequest: { secondRequest: {
title: '<p>2</p>', title: '<p>2</p>',
...@@ -18,5 +19,6 @@ export default { ...@@ -18,5 +19,6 @@ export default {
updated_at: '2016-05-15T12:31:04.428Z', updated_at: '2016-05-15T12:31:04.428Z',
updated_by_name: 'Other User', updated_by_name: 'Other User',
updated_by_path: '/other_user', updated_by_path: '/other_user',
lock_version: 2,
}, },
}; };
...@@ -41,15 +41,28 @@ describe('MergeRequest', function() { ...@@ -41,15 +41,28 @@ describe('MergeRequest', function() {
}); });
it('submits an ajax request on tasklist:changed', done => { it('submits an ajax request on tasklist:changed', done => {
$('.js-task-list-field').trigger('tasklist:changed'); const lineNumber = 8;
const lineSource = '- [ ] item 8';
const index = 3;
const checked = true;
$('.js-task-list-field').trigger({
type: 'tasklist:changed',
detail: { lineNumber, lineSource, index, checked },
});
setTimeout(() => { setTimeout(() => {
expect(axios.patch).toHaveBeenCalledWith( expect(axios.patch).toHaveBeenCalledWith(
`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`, `${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`,
{ {
merge_request: { description: '- [ ] Task List Item' }, merge_request: {
description: '- [ ] Task List Item',
lock_version: undefined,
update_task: { line_number: lineNumber, line_source: lineSource, index, checked },
},
}, },
); );
done(); done();
}); });
}); });
......
...@@ -89,10 +89,25 @@ describe('Notes', function() { ...@@ -89,10 +89,25 @@ describe('Notes', function() {
}); });
it('submits an ajax request on tasklist:changed', function(done) { it('submits an ajax request on tasklist:changed', function(done) {
$('.js-task-list-container').trigger('tasklist:changed'); const lineNumber = 8;
const lineSource = '- [ ] item 8';
const index = 3;
const checked = true;
$('.js-task-list-container').trigger({
type: 'tasklist:changed',
detail: { lineNumber, lineSource, index, checked },
});
setTimeout(() => { setTimeout(() => {
expect(axios.patch).toHaveBeenCalled(); expect(axios.patch).toHaveBeenCalledWith(undefined, {
note: {
note: '',
lock_version: undefined,
update_task: { index, checked, line_number: lineNumber, line_source: lineSource },
},
});
done(); done();
}); });
}); });
......
import $ from 'jquery';
import TaskList from '~/task_list';
import axios from '~/lib/utils/axios_utils';
describe('TaskList', () => {
let taskList;
let currentTarget;
const taskListOptions = {
selector: '.task-list',
dataType: 'issue',
fieldName: 'description',
lockVersion: 2,
};
const createTaskList = () => new TaskList(taskListOptions);
beforeEach(() => {
setFixtures(`
<div class="task-list">
<div class="js-task-list-container"></div>
</div>
`);
currentTarget = $('<div></div>');
taskList = createTaskList();
});
it('should call init when the class constructed', () => {
spyOn(TaskList.prototype, 'init').and.callThrough();
spyOn(TaskList.prototype, 'disable');
spyOn($.prototype, 'taskList');
spyOn($.prototype, 'on');
taskList = createTaskList();
const $taskListEl = $(taskList.taskListContainerSelector);
expect(taskList.init).toHaveBeenCalled();
expect(taskList.disable).toHaveBeenCalled();
expect($taskListEl.taskList).toHaveBeenCalledWith('enable');
expect($(document).on).toHaveBeenCalledWith(
'tasklist:changed',
taskList.taskListContainerSelector,
taskList.updateHandler,
);
});
describe('getTaskListTarget', () => {
it('should return currentTarget from event object if exists', () => {
const $target = taskList.getTaskListTarget({ currentTarget });
expect($target).toEqual(currentTarget);
});
it('should return element of the taskListContainerSelector', () => {
const $target = taskList.getTaskListTarget();
expect($target).toEqual($(taskList.taskListContainerSelector));
});
});
describe('disableTaskListItems', () => {
it('should call taskList method with disable param', () => {
spyOn($.prototype, 'taskList');
taskList.disableTaskListItems({ currentTarget });
expect(currentTarget.taskList).toHaveBeenCalledWith('disable');
});
});
describe('enableTaskListItems', () => {
it('should call taskList method with enable param', () => {
spyOn($.prototype, 'taskList');
taskList.enableTaskListItems({ currentTarget });
expect(currentTarget.taskList).toHaveBeenCalledWith('enable');
});
});
describe('disable', () => {
it('should disable task list items and off document event', () => {
spyOn(taskList, 'disableTaskListItems');
spyOn($.prototype, 'off');
taskList.disable();
expect(taskList.disableTaskListItems).toHaveBeenCalled();
expect($(document).off).toHaveBeenCalledWith(
'tasklist:changed',
taskList.taskListContainerSelector,
);
});
});
describe('update', () => {
it('should disable task list items and make a patch request then enable them again', done => {
const response = { data: { lock_version: 3 } };
spyOn(taskList, 'enableTaskListItems');
spyOn(taskList, 'disableTaskListItems');
spyOn(taskList, 'onSuccess');
spyOn(axios, 'patch').and.returnValue(Promise.resolve(response));
const value = 'hello world';
const endpoint = '/foo';
const target = $(`<input data-update-url="${endpoint}" value="${value}" />`);
const detail = {
index: 2,
checked: true,
lineNumber: 8,
lineSource: '- [ ] check item',
};
const event = { target, detail };
const patchData = {
[taskListOptions.dataType]: {
[taskListOptions.fieldName]: value,
lock_version: taskListOptions.lockVersion,
update_task: {
index: detail.index,
checked: detail.checked,
line_number: detail.lineNumber,
line_source: detail.lineSource,
},
},
};
taskList
.update(event)
.then(() => {
expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event);
expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData);
expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event);
expect(taskList.onSuccess).toHaveBeenCalledWith(response.data);
expect(taskList.lockVersion).toEqual(response.data.lock_version);
})
.then(done)
.catch(done.fail);
});
});
it('should handle request error and enable task list items', done => {
const response = { data: { error: 1 } };
spyOn(taskList, 'enableTaskListItems');
spyOn(taskList, 'onError');
spyOn(axios, 'patch').and.returnValue(Promise.reject({ response })); // eslint-disable-line prefer-promise-reject-errors
const event = { detail: {} };
taskList
.update(event)
.then(() => {
expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event);
expect(taskList.onError).toHaveBeenCalledWith(response.data);
})
.then(done)
.catch(done.fail);
});
});
...@@ -133,6 +133,15 @@ describe CacheMarkdownField do ...@@ -133,6 +133,15 @@ describe CacheMarkdownField do
end end
end end
context 'when a markdown field and html field are both changed' do
it do
expect(thing).not_to receive(:refresh_markdown_cache)
thing.foo = '_look over there!_'
thing.foo_html = '<em>look over there!</em>'
thing.save
end
end
context 'a non-markdown field changed' do context 'a non-markdown field changed' do
shared_examples 'with cache version' do |cache_version| shared_examples 'with cache version' do |cache_version|
let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) } let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) }
...@@ -242,6 +251,30 @@ describe CacheMarkdownField do ...@@ -242,6 +251,30 @@ describe CacheMarkdownField do
end end
end end
describe '#legacy_markdown?' do
subject { thing.legacy_markdown? }
it 'returns true for redcarpet versions' do
thing.cached_markdown_version = CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - 1
is_expected.to be_truthy
end
it 'returns false for commonmark versions' do
thing.cached_markdown_version = CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
is_expected.to be_falsey
end
it 'returns false if nil' do
thing.cached_markdown_version = nil
is_expected.to be_falsey
end
it 'returns false if 0' do
thing.cached_markdown_version = 0
is_expected.to be_falsey
end
end
describe '#refresh_markdown_cache' do describe '#refresh_markdown_cache' do
before do before do
thing.foo = updated_markdown thing.foo = updated_markdown
......
...@@ -543,6 +543,76 @@ describe Issues::UpdateService, :mailer do ...@@ -543,6 +543,76 @@ describe Issues::UpdateService, :mailer do
end end
end end
context 'when updating a single task' do
before do
update_issue(description: "- [ ] Task 1\n- [ ] Task 2")
end
it { expect(issue.tasks?).to eq(true) }
context 'when a task is marked as completed' do
before do
update_issue(update_task: { index: 1, checked: true, line_source: '- [ ] Task 1', line_number: 1 })
end
it 'creates system note about task status change' do
note1 = find_note('marked the task **Task 1** as completed')
expect(note1).not_to be_nil
description_notes = find_notes('description')
expect(description_notes.length).to eq(1)
end
end
context 'when a task is marked as incomplete' do
before do
update_issue(description: "- [x] Task 1\n- [X] Task 2")
update_issue(update_task: { index: 2, checked: false, line_source: '- [X] Task 2', line_number: 2 })
end
it 'creates system note about task status change' do
note1 = find_note('marked the task **Task 2** as incomplete')
expect(note1).not_to be_nil
description_notes = find_notes('description')
expect(description_notes.length).to eq(1)
end
end
context 'when the task position has been modified' do
before do
update_issue(description: "- [ ] Task 1\n- [ ] Task 3\n- [ ] Task 2")
end
it 'raises an exception' do
expect(Note.count).to eq(2)
expect do
update_issue(update_task: { index: 2, checked: true, line_source: '- [ ] Task 2', line_number: 2 })
end.to raise_error(ActiveRecord::StaleObjectError)
expect(Note.count).to eq(2)
end
end
context 'when the content changes but not task line number' do
before do
update_issue(description: "Paragraph\n\n- [ ] Task 1\n- [x] Task 2")
update_issue(description: "Paragraph with more words\n\n- [ ] Task 1\n- [x] Task 2")
update_issue(update_task: { index: 2, checked: false, line_source: '- [x] Task 2', line_number: 4 })
end
it 'creates system note about task status change' do
note1 = find_note('marked the task **Task 2** as incomplete')
expect(note1).not_to be_nil
description_notes = find_notes('description')
expect(description_notes.length).to eq(2)
end
end
end
context 'updating labels' do context 'updating labels' do
let(:label3) { create(:label, project: project) } let(:label3) { create(:label, project: project) }
let(:result) { described_class.new(project, user, params).execute(issue).reload } let(:result) { described_class.new(project, user, params).execute(issue).reload }
......
# frozen_string_literal: true
require 'spec_helper'
describe TaskListToggleService do
let(:sourcepos) { true }
let(:markdown) do
<<-EOT.strip_heredoc
* [ ] Task 1
* [x] Task 2
A paragraph
1. [X] Item 1
- [ ] Sub-item 1
EOT
end
let(:markdown_html) do
<<-EOT.strip_heredoc
<ul data-sourcepos="1:1-3:0" class="task-list" dir="auto">
<li data-sourcepos="1:1-1:12" class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled> Task 1
</li>
<li data-sourcepos="2:1-3:0" class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled checked> Task 2
</li>
</ul>
<p data-sourcepos="4:1-4:11" dir="auto">A paragraph</p>
<ol data-sourcepos="6:1-7:19" class="task-list" dir="auto">
<li data-sourcepos="6:1-7:19" class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled checked> Item 1
<ul data-sourcepos="7:4-7:19" class="task-list">
<li data-sourcepos="7:4-7:19" class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled> Sub-item 1
</li>
</ul>
</li>
</ol>
EOT
end
shared_examples 'task lists' do
it 'checks Task 1' do
toggler = described_class.new(markdown, markdown_html,
index: 1, toggle_as_checked: true,
line_source: '* [ ] Task 1', line_number: 1,
sourcepos: sourcepos)
expect(toggler.execute).to be_truthy
expect(toggler.updated_markdown.lines[0]).to eq "* [x] Task 1\n"
expect(toggler.updated_markdown_html).to include('disabled checked> Task 1')
end
it 'unchecks Item 1' do
toggler = described_class.new(markdown, markdown_html,
index: 3, toggle_as_checked: false,
line_source: '1. [X] Item 1', line_number: 6,
sourcepos: sourcepos)
expect(toggler.execute).to be_truthy
expect(toggler.updated_markdown.lines[5]).to eq "1. [ ] Item 1\n"
expect(toggler.updated_markdown_html).to include('disabled> Item 1')
end
it 'returns false if line_source does not match the text' do
toggler = described_class.new(markdown, markdown_html,
index: 2, toggle_as_checked: false,
line_source: '* [x] Task Added', line_number: 2,
sourcepos: sourcepos)
expect(toggler.execute).to be_falsey
end
it 'returns false if markdown is nil' do
toggler = described_class.new(nil, markdown_html,
index: 2, toggle_as_checked: false,
line_source: '* [x] Task Added', line_number: 2,
sourcepos: sourcepos)
expect(toggler.execute).to be_falsey
end
it 'returns false if markdown_html is nil' do
toggler = described_class.new(markdown, nil,
index: 2, toggle_as_checked: false,
line_source: '* [x] Task Added', line_number: 2,
sourcepos: sourcepos)
expect(toggler.execute).to be_falsey
end
end
context 'when using sourcepos' do
it_behaves_like 'task lists'
end
context 'when using checkbox indexing' do
let(:sourcepos) { false }
let(:markdown_html) do
<<-EOT.strip_heredoc
<ul class="task-list" dir="auto">
<li class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled> Task 1
</li>
<li class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled checked> Task 2
</li>
</ul>
<p dir="auto">A paragraph</p>
<ol class="task-list" dir="auto">
<li class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled checked> Item 1
<ul class="task-list">
<li class="task-list-item">
<input type="checkbox" class="task-list-item-checkbox" disabled> Sub-item 1
</li>
</ul>
</li>
</ol>
EOT
end
it_behaves_like 'task lists'
end
end
...@@ -3032,10 +3032,10 @@ decamelize@^2.0.0: ...@@ -3032,10 +3032,10 @@ decamelize@^2.0.0:
dependencies: dependencies:
xregexp "4.0.0" xregexp "4.0.0"
deckar01-task_list@^2.0.1: deckar01-task_list@^2.2.0:
version "2.0.1" version "2.2.0"
resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.0.1.tgz#fdcfb6ab5717055a82f29e863a49990a043a06a9" resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.2.0.tgz#5cc3ea06f01d3d786b1a667064a462eb5d069bd3"
integrity sha512-i5fT8QxJ9iV6dfgy5U0NHW91O5cKsvDc4u8JNMnZ6efQc356bA9vKuXO3732agSry+bO6TolzTmuqSRi4tkkeA== integrity sha512-NUfu5ARoD9SC2k+fBT5cBer59iKfEdawPrmfqp5+zAahTECb8z9dsuS1Xnx7jzFAmCCLnEs3z/aYucYXzNrKkQ==
decode-uri-component@^0.2.0: decode-uri-component@^0.2.0:
version "0.2.0" version "0.2.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