FIX: better edit conflict handling (#29789)
* FIX: better edit conflict handling Properly keep track of the original title and tags when dealing with edit conflicts. Internal ref - t/141218
This commit is contained in:
parent
89a6cac968
commit
806e37aaec
|
@ -8,7 +8,6 @@ import { isEmpty } from "@ember/utils";
|
||||||
import { observes, on } from "@ember-decorators/object";
|
import { observes, on } from "@ember-decorators/object";
|
||||||
import { Promise } from "rsvp";
|
import { Promise } from "rsvp";
|
||||||
import { extractError, throwAjaxError } from "discourse/lib/ajax-error";
|
import { extractError, throwAjaxError } from "discourse/lib/ajax-error";
|
||||||
import { propertyNotEqual } from "discourse/lib/computed";
|
|
||||||
import { QUOTE_REGEXP } from "discourse/lib/quote";
|
import { QUOTE_REGEXP } from "discourse/lib/quote";
|
||||||
import { prioritizeNameFallback } from "discourse/lib/settings";
|
import { prioritizeNameFallback } from "discourse/lib/settings";
|
||||||
import { emailValid, escapeExpression } from "discourse/lib/utilities";
|
import { emailValid, escapeExpression } from "discourse/lib/utilities";
|
||||||
|
@ -73,13 +72,15 @@ const CLOSED = "closed",
|
||||||
_update_serializer = {
|
_update_serializer = {
|
||||||
raw: "reply",
|
raw: "reply",
|
||||||
topic_id: "topic.id",
|
topic_id: "topic.id",
|
||||||
raw_old: "rawOld",
|
original_text: "originalText",
|
||||||
},
|
},
|
||||||
_edit_topic_serializer = {
|
_edit_topic_serializer = {
|
||||||
title: "topic.title",
|
title: "topic.title",
|
||||||
categoryId: "topic.category.id",
|
categoryId: "topic.category.id",
|
||||||
tags: "topic.tags",
|
tags: "topic.tags",
|
||||||
featuredLink: "topic.featured_link",
|
featuredLink: "topic.featured_link",
|
||||||
|
original_title: "originalTitle",
|
||||||
|
original_tags: "originalTags",
|
||||||
},
|
},
|
||||||
_draft_serializer = {
|
_draft_serializer = {
|
||||||
reply: "reply",
|
reply: "reply",
|
||||||
|
@ -94,6 +95,9 @@ const CLOSED = "closed",
|
||||||
typingTime: "typingTime",
|
typingTime: "typingTime",
|
||||||
postId: "post.id",
|
postId: "post.id",
|
||||||
recipients: "targetRecipients",
|
recipients: "targetRecipients",
|
||||||
|
original_text: "originalText",
|
||||||
|
original_title: "originalTitle",
|
||||||
|
original_tags: "originalTags",
|
||||||
},
|
},
|
||||||
_add_draft_fields = {},
|
_add_draft_fields = {},
|
||||||
FAST_REPLY_LENGTH_THRESHOLD = 10000;
|
FAST_REPLY_LENGTH_THRESHOLD = 10000;
|
||||||
|
@ -210,8 +214,6 @@ export default class Composer extends RestModel {
|
||||||
@equal("composeState", FULLSCREEN) viewFullscreen;
|
@equal("composeState", FULLSCREEN) viewFullscreen;
|
||||||
@or("viewOpen", "viewFullscreen") viewOpenOrFullscreen;
|
@or("viewOpen", "viewFullscreen") viewOpenOrFullscreen;
|
||||||
@and("editingPost", "post.firstPost") editingFirstPost;
|
@and("editingPost", "post.firstPost") editingFirstPost;
|
||||||
@propertyNotEqual("reply", "originalText") replyDirty;
|
|
||||||
@propertyNotEqual("title", "originalTitle") titleDirty;
|
|
||||||
|
|
||||||
@or(
|
@or(
|
||||||
"creatingTopic",
|
"creatingTopic",
|
||||||
|
@ -226,6 +228,16 @@ export default class Composer extends RestModel {
|
||||||
|
|
||||||
@tracked _categoryId = null;
|
@tracked _categoryId = null;
|
||||||
|
|
||||||
|
@discourseComputed("reply", "originalText")
|
||||||
|
replyDirty(reply, original) {
|
||||||
|
return (reply || "").trim() !== (original || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
@discourseComputed("title", "originalTitle")
|
||||||
|
titleDirty(title, original) {
|
||||||
|
return (title || "").trim() !== (original || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
@dependentKeyCompat
|
@dependentKeyCompat
|
||||||
get categoryId() {
|
get categoryId() {
|
||||||
return this._categoryId;
|
return this._categoryId;
|
||||||
|
@ -787,6 +799,7 @@ export default class Composer extends RestModel {
|
||||||
const category = Category.findById(categoryId);
|
const category = Category.findById(categoryId);
|
||||||
if (category) {
|
if (category) {
|
||||||
this.set("reply", category.topic_template || "");
|
this.set("reply", category.topic_template || "");
|
||||||
|
this.set("originalText", category.topic_template || "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -861,6 +874,9 @@ export default class Composer extends RestModel {
|
||||||
whisper: opts.whisper,
|
whisper: opts.whisper,
|
||||||
tags: opts.tags || [],
|
tags: opts.tags || [],
|
||||||
noBump: opts.noBump,
|
noBump: opts.noBump,
|
||||||
|
originalText: opts.originalText,
|
||||||
|
originalTitle: opts.originalTitle,
|
||||||
|
originalTags: opts.originalTags,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (opts.post) {
|
if (opts.post) {
|
||||||
|
@ -926,6 +942,13 @@ export default class Composer extends RestModel {
|
||||||
reply: post.raw,
|
reply: post.raw,
|
||||||
originalText: post.raw,
|
originalText: post.raw,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (post.post_number === 1 && this.canEditTitle) {
|
||||||
|
this.setProperties({
|
||||||
|
originalTitle: post.topic.title,
|
||||||
|
originalTags: post.topic.tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// edge case ... make a post then edit right away
|
// edge case ... make a post then edit right away
|
||||||
|
@ -945,24 +968,18 @@ export default class Composer extends RestModel {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else if (opts.action === REPLY && opts.quote) {
|
} else if (opts.action === REPLY && opts.quote) {
|
||||||
this.setProperties({
|
this.set("reply", opts.quote);
|
||||||
reply: opts.quote,
|
this.set("originalText", opts.quote);
|
||||||
originalText: opts.quote,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.title) {
|
if (opts.title) {
|
||||||
this.set("title", opts.title);
|
this.set("title", opts.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDraft = opts.draft || opts.skipDraftCheck;
|
|
||||||
this.set("originalText", isDraft ? "" : this.reply);
|
|
||||||
|
|
||||||
if (this.canEditTitle) {
|
if (this.canEditTitle) {
|
||||||
if (isEmpty(this.title) && this.title !== "") {
|
if (isEmpty(this.title) && this.title !== "") {
|
||||||
this.set("title", "");
|
this.set("title", "");
|
||||||
}
|
}
|
||||||
this.set("originalTitle", this.title);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isEdit(opts.action) || !opts.post) {
|
if (!isEdit(opts.action) || !opts.post) {
|
||||||
|
@ -1001,6 +1018,8 @@ export default class Composer extends RestModel {
|
||||||
clearState() {
|
clearState() {
|
||||||
this.setProperties({
|
this.setProperties({
|
||||||
originalText: null,
|
originalText: null,
|
||||||
|
originalTitle: null,
|
||||||
|
originalTags: null,
|
||||||
reply: null,
|
reply: null,
|
||||||
post: null,
|
post: null,
|
||||||
title: null,
|
title: null,
|
||||||
|
@ -1016,12 +1035,9 @@ export default class Composer extends RestModel {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@discourseComputed("editConflict", "originalText")
|
|
||||||
rawOld(editConflict, originalText) {
|
|
||||||
return editConflict ? null : originalText;
|
|
||||||
}
|
|
||||||
|
|
||||||
editPost(opts) {
|
editPost(opts) {
|
||||||
|
this.set("composeState", SAVING);
|
||||||
|
|
||||||
const post = this.post;
|
const post = this.post;
|
||||||
const oldCooked = post.cooked;
|
const oldCooked = post.cooked;
|
||||||
let promise = Promise.resolve();
|
let promise = Promise.resolve();
|
||||||
|
@ -1054,14 +1070,19 @@ export default class Composer extends RestModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = {
|
let props = {
|
||||||
edit_reason: opts.editReason,
|
edit_reason: opts.editReason,
|
||||||
image_sizes: opts.imageSizes,
|
image_sizes: opts.imageSizes,
|
||||||
cooked: this.getCookedHtml(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.serialize(_update_serializer, props);
|
this.serialize(_update_serializer, props);
|
||||||
this.set("composeState", SAVING);
|
|
||||||
|
// user clicked "overwrite edits" button
|
||||||
|
if (this.editConflict) {
|
||||||
|
delete props.original_text;
|
||||||
|
delete props.original_title;
|
||||||
|
delete props.original_tags;
|
||||||
|
}
|
||||||
|
|
||||||
const rollback = throwAjaxError((error) => {
|
const rollback = throwAjaxError((error) => {
|
||||||
post.setProperties("cooked", oldCooked);
|
post.setProperties("cooked", oldCooked);
|
||||||
|
@ -1071,7 +1092,8 @@ export default class Composer extends RestModel {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
post.setProperties({ cooked: props.cooked, staged: true });
|
const cooked = this.getCookedHtml();
|
||||||
|
post.setProperties({ cooked, staged: true });
|
||||||
this.appEvents.trigger("post-stream:refresh", { id: post.id });
|
this.appEvents.trigger("post-stream:refresh", { id: post.id });
|
||||||
|
|
||||||
return promise
|
return promise
|
||||||
|
@ -1292,16 +1314,9 @@ export default class Composer extends RestModel {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setProperties({
|
this.set("draftSaving", true);
|
||||||
draftSaving: true,
|
|
||||||
draftConflictUser: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
let data = this.serialize(_draft_serializer);
|
const data = this.serialize(_draft_serializer);
|
||||||
|
|
||||||
if (data.postId && !isEmpty(this.originalText)) {
|
|
||||||
data.originalText = this.originalText;
|
|
||||||
}
|
|
||||||
|
|
||||||
const draftSequence = this.draftSequence;
|
const draftSequence = this.draftSequence;
|
||||||
this.set("draftSequence", this.draftSequence + 1);
|
this.set("draftSequence", this.draftSequence + 1);
|
||||||
|
|
|
@ -13,17 +13,17 @@ acceptance("Composer - Edit conflict", function (needs) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Should not send originalText when posting a new reply", async function (assert) {
|
test("Should not send 'original_text' when posting a new reply", async function (assert) {
|
||||||
await visit("/t/internationalization-localization/280");
|
await visit("/t/internationalization-localization/280");
|
||||||
await click(".topic-post:nth-of-type(1) button.reply");
|
await click(".topic-post:nth-of-type(1) button.reply");
|
||||||
await fillIn(
|
await fillIn(
|
||||||
".d-editor-input",
|
".d-editor-input",
|
||||||
"hello world hello world hello world hello world hello world"
|
"hello world hello world hello world hello world hello world"
|
||||||
);
|
);
|
||||||
assert.false(lastBody.includes("originalText"));
|
assert.false(lastBody.includes("original_text"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Should send originalText when editing a reply", async function (assert) {
|
test("Should send 'original_text' when editing a reply", async function (assert) {
|
||||||
await visit("/t/internationalization-localization/280");
|
await visit("/t/internationalization-localization/280");
|
||||||
await click(".topic-post:nth-of-type(1) button.show-more-actions");
|
await click(".topic-post:nth-of-type(1) button.show-more-actions");
|
||||||
await click(".topic-post:nth-of-type(1) button.edit");
|
await click(".topic-post:nth-of-type(1) button.edit");
|
||||||
|
@ -31,6 +31,6 @@ acceptance("Composer - Edit conflict", function (needs) {
|
||||||
".d-editor-input",
|
".d-editor-input",
|
||||||
"hello world hello world hello world hello world hello world"
|
"hello world hello world hello world hello world hello world"
|
||||||
);
|
);
|
||||||
assert.true(lastBody.includes("originalText"));
|
assert.true(lastBody.includes("original_text"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -96,16 +96,26 @@ class DraftsController < ApplicationController
|
||||||
|
|
||||||
json = success_json.merge(draft_sequence: sequence)
|
json = success_json.merge(draft_sequence: sequence)
|
||||||
|
|
||||||
if data.present?
|
# check for conflicts when editing a post
|
||||||
# this is a bit of a kludge we need to remove (all the parsing) too many special cases here
|
if data.present? && data["postId"].present? && data["action"].to_s.start_with?("edit")
|
||||||
# we need to catch action edit and action editSharedDraft
|
original_text = data["original_text"] || data["originalText"]
|
||||||
if data["postId"].present? && data["originalText"].present? &&
|
original_title = data["original_title"]
|
||||||
data["action"].to_s.start_with?("edit")
|
original_tags = data["original_tags"]
|
||||||
post = Post.find_by(id: data["postId"])
|
|
||||||
if post && post.raw != data["originalText"]
|
if original_text.present?
|
||||||
conflict_user = BasicUserSerializer.new(post.last_editor, root: false)
|
if post = Post.find_by(id: data["postId"])
|
||||||
render json: json.merge(conflict_user: conflict_user)
|
conflict = original_text != post.raw
|
||||||
return
|
|
||||||
|
if post.post_number == 1
|
||||||
|
conflict ||= original_title.present? && original_title != post.topic.title
|
||||||
|
conflict ||=
|
||||||
|
original_tags.present? && original_tags.sort != post.topic.tags.pluck(:name).sort
|
||||||
|
end
|
||||||
|
|
||||||
|
if conflict
|
||||||
|
conflict_user = BasicUserSerializer.new(post.last_editor, root: false)
|
||||||
|
json.merge!(conflict_user:)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -240,8 +240,9 @@ class PostsController < ApplicationController
|
||||||
|
|
||||||
Post.plugin_permitted_update_params.keys.each { |param| changes[param] = params[:post][param] }
|
Post.plugin_permitted_update_params.keys.each { |param| changes[param] = params[:post][param] }
|
||||||
|
|
||||||
raw_old = params[:post][:raw_old]
|
# keep `raw_old` for backwards compatibility
|
||||||
if raw_old.present? && raw_old != post.raw
|
original_text = params[:post][:original_text] || params[:post][:raw_old]
|
||||||
|
if original_text.present? && original_text != post.raw
|
||||||
return render_json_error(I18n.t("edit_conflict"), status: 409)
|
return render_json_error(I18n.t("edit_conflict"), status: 409)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -359,8 +359,19 @@ class TopicsController < ApplicationController
|
||||||
|
|
||||||
def update
|
def update
|
||||||
topic = Topic.find_by(id: params[:topic_id])
|
topic = Topic.find_by(id: params[:topic_id])
|
||||||
|
|
||||||
guardian.ensure_can_edit!(topic)
|
guardian.ensure_can_edit!(topic)
|
||||||
|
|
||||||
|
original_title = params[:original_title]
|
||||||
|
if original_title.present? && original_title != topic.title
|
||||||
|
return render_json_error(I18n.t("edit_conflict"), status: 409)
|
||||||
|
end
|
||||||
|
|
||||||
|
original_tags = params[:original_tags]
|
||||||
|
if original_tags.present? && original_tags.sort != topic.tags.pluck(:name).sort
|
||||||
|
return render_json_error(I18n.t("edit_conflict"), status: 409)
|
||||||
|
end
|
||||||
|
|
||||||
if params[:category_id] && (params[:category_id].to_i != topic.category_id.to_i)
|
if params[:category_id] && (params[:category_id].to_i != topic.category_id.to_i)
|
||||||
if topic.shared_draft
|
if topic.shared_draft
|
||||||
topic.shared_draft.update(category_id: params[:category_id])
|
topic.shared_draft.update(category_id: params[:category_id])
|
||||||
|
|
|
@ -99,15 +99,15 @@ RSpec.describe DraftsController do
|
||||||
expect(response.status).to eq(404)
|
expect(response.status).to eq(404)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "checks for an conflict on update" do
|
it "checks for a raw conflict on update" do
|
||||||
sign_in(user)
|
sign_in(user)
|
||||||
post = Fabricate(:post, user: user)
|
post = Fabricate(:post, user:)
|
||||||
|
|
||||||
post "/drafts.json",
|
post "/drafts.json",
|
||||||
params: {
|
params: {
|
||||||
draft_key: "topic",
|
draft_key: "topic",
|
||||||
sequence: 0,
|
sequence: 0,
|
||||||
data: { postId: post.id, originalText: post.raw, action: "edit" }.to_json,
|
data: { postId: post.id, original_text: post.raw, action: "edit" }.to_json,
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
|
@ -117,7 +117,7 @@ RSpec.describe DraftsController do
|
||||||
params: {
|
params: {
|
||||||
draft_key: "topic",
|
draft_key: "topic",
|
||||||
sequence: 0,
|
sequence: 0,
|
||||||
data: { postId: post.id, originalText: "something else", action: "edit" }.to_json,
|
data: { postId: post.id, original_text: "something else", action: "edit" }.to_json,
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
|
@ -125,6 +125,46 @@ RSpec.describe DraftsController do
|
||||||
expect(response.parsed_body["conflict_user"]).to include("avatar_template")
|
expect(response.parsed_body["conflict_user"]).to include("avatar_template")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "checks for a title conflict on update" do
|
||||||
|
sign_in(user)
|
||||||
|
post = Fabricate(:post, user:)
|
||||||
|
|
||||||
|
post "/drafts.json",
|
||||||
|
params: {
|
||||||
|
draft_key: "topic",
|
||||||
|
sequence: 0,
|
||||||
|
data: {
|
||||||
|
postId: post.id,
|
||||||
|
original_text: post.raw,
|
||||||
|
original_title: "something else",
|
||||||
|
action: "edit",
|
||||||
|
}.to_json,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body["conflict_user"]["id"]).to eq(post.last_editor.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "checks for a tag conflict on update" do
|
||||||
|
sign_in(user)
|
||||||
|
post = Fabricate(:post, user:)
|
||||||
|
|
||||||
|
post "/drafts.json",
|
||||||
|
params: {
|
||||||
|
draft_key: "topic",
|
||||||
|
sequence: 0,
|
||||||
|
data: {
|
||||||
|
postId: post.id,
|
||||||
|
original_text: post.raw,
|
||||||
|
original_tags: %w[tag1 tag2],
|
||||||
|
action: "edit",
|
||||||
|
}.to_json,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body["conflict_user"]["id"]).to eq(post.last_editor.id)
|
||||||
|
end
|
||||||
|
|
||||||
it "cant trivially resolve conflicts without interaction" do
|
it "cant trivially resolve conflicts without interaction" do
|
||||||
sign_in(user)
|
sign_in(user)
|
||||||
|
|
||||||
|
|
|
@ -547,7 +547,7 @@ RSpec.describe PostsController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "checks for an edit conflict" do
|
it "checks for an edit conflict" do
|
||||||
update_params[:post][:raw_old] = "old body"
|
update_params[:post][:original_text] = "old body"
|
||||||
put "/posts/#{post.id}.json", params: update_params
|
put "/posts/#{post.id}.json", params: update_params
|
||||||
|
|
||||||
expect(response.status).to eq(409)
|
expect(response.status).to eq(409)
|
||||||
|
@ -726,7 +726,6 @@ RSpec.describe PostsController do
|
||||||
params: {
|
params: {
|
||||||
post: {
|
post: {
|
||||||
raw: "this is a random post",
|
raw: "this is a random post",
|
||||||
raw_old: post.raw,
|
|
||||||
random_number: 244,
|
random_number: 244,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -1683,6 +1683,28 @@ RSpec.describe TopicsController do
|
||||||
expect(response.parsed_body["basic_topic"]).to be_present
|
expect(response.parsed_body["basic_topic"]).to be_present
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "prevents conflicts when title was changed" do
|
||||||
|
put "/t/#{topic.slug}/#{topic.id}.json",
|
||||||
|
params: {
|
||||||
|
title: "brand new title",
|
||||||
|
original_title: "another title",
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(409)
|
||||||
|
expect(response.parsed_body["errors"].first).to eq(I18n.t("edit_conflict"))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "prevents conflicts when tags were changed" do
|
||||||
|
put "/t/#{topic.slug}/#{topic.id}.json",
|
||||||
|
params: {
|
||||||
|
tags: %w[tag1 tag2],
|
||||||
|
original_tags: %w[tag3 tag4],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(409)
|
||||||
|
expect(response.parsed_body["errors"].first).to eq(I18n.t("edit_conflict"))
|
||||||
|
end
|
||||||
|
|
||||||
it "throws an error if it could not be saved" do
|
it "throws an error if it could not be saved" do
|
||||||
PostRevisor.any_instance.stubs(:should_revise?).returns(false)
|
PostRevisor.any_instance.stubs(:should_revise?).returns(false)
|
||||||
put "/t/#{topic.slug}/#{topic.id}.json", params: { title: "brand new title" }
|
put "/t/#{topic.slug}/#{topic.id}.json", params: { title: "brand new title" }
|
||||||
|
|
Loading…
Reference in New Issue