DEV: Convert history modal to component-based API (#22522)

This PR converts the `history` modal to make use of the new component-based API
This commit is contained in:
Isaac Janzen 2023-07-17 13:16:40 -05:00 committed by GitHub
parent e214fc38ed
commit 80a1709965
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 772 additions and 796 deletions

View File

@ -0,0 +1,63 @@
<DModal
@title={{i18n this.modalTitleKey}}
@closeModal={{@closeModal}}
class="history-modal"
>
<:body>
<Modal::History::Revision
@model={{this.postRevision}}
@mobileView={{this.site.mobileView}}
@wikiDisabled={{this.wikiDisabled}}
@previousCategory={{this.previousCategory}}
@currentCategory={{this.currentCategory}}
@displayInline={{this.displayInline}}
@displaySideBySide={{this.displaySideBySide}}
@displaySideBySideMarkdown={{this.displaySideBySideMarkdown}}
@viewMode={{this.viewMode}}
/>
<Modal::History::Revisions
@model={{this.postRevision}}
@hiddenClasses={{this.hiddenClasses}}
@mobileView={{this.site.mobileView}}
@userChanges={{this.user_changes}}
@wikiDisabled={{this.wikiDisabled}}
@postTypeDisabled={{this.postTypeDisabled}}
@previousCategory={{this.previousCategory}}
@currentCategory={{this.currentCategory}}
@previousTagChanges={{this.previousTagChanges}}
@currentTagChanges={{this.currentTagChanges}}
@bodyDiffHTML={{this.bodyDiffHTML}}
@bodyDiff={{this.bodyDiff}}
@calculateBodyDiff={{this.calculateBodyDiff}}
@titleDiff={{this.titleDiff}}
/>
</:body>
<:footer>
{{#if @model.editPost}}
<Modal::History::TopicFooter
@model={{this.postRevision}}
@loadFirstVersion={{this.loadFirstVersion}}
@loadPreviousVersion={{this.loadPreviousVersion}}
@loadNextVersion={{this.loadNextVersion}}
@loadLastVersion={{this.loadLastVersion}}
@displayEdit={{this.displayEdit}}
@editPost={{this.editPost}}
@editButtonLabel={{this.editButtonLabel}}
@revertToVersion={{this.revertToVersion}}
@hideVersion={{this.hideVersion}}
@showVersion={{this.showVersion}}
@permanentlyDeleteVersions={{this.permanentlyDeleteVersions}}
@loading={{this.loading}}
@canPermanentlyDelete={{this.siteSettings.can_permanently_delete}}
@loadFirstDisabled={{this.loadFirstDisabled}}
@loadPreviousDisabled={{this.loadPreviousDisabled}}
@displayRevisions={{this.displayRevisions}}
@revisionsText={{this.revisionsText}}
@loadNextDisabled={{this.loadNextDisabled}}
@loadLastDisabled={{this.loadLastDisabled}}
@revertToRevisionText={{this.revertToRevisionText}}
@isStaff={{this.currentUser.staff}}
/>
{{/if}}
</:footer>
</DModal>

View File

@ -0,0 +1,339 @@
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
import Category from "discourse/models/category";
import Component from "@glimmer/component";
import I18n from "I18n";
import Post from "discourse/models/post";
import { categoryBadgeHTML } from "discourse/helpers/category-link";
import { iconHTML } from "discourse-common/lib/icon-library";
import { sanitizeAsync } from "discourse/lib/text";
import { inject as service } from "@ember/service";
function customTagArray(val) {
if (!val) {
return [];
}
if (!Array.isArray(val)) {
val = [val];
}
return val;
}
export default class History extends Component {
@service dialog;
@service site;
@service currentUser;
@service siteSettings;
@tracked loading;
@tracked postRevision;
@tracked viewMode = this.site?.mobileView ? "inline" : "side_by_side";
@tracked bodyDiff;
constructor() {
super(...arguments);
this.refresh(this.args.model.postId, this.args.model.postVersion);
}
get loadFirstDisabled() {
return (
this.loading ||
this.postRevision?.current_revision <= this.postRevision?.first_revision
);
}
get loadPreviousDisabled() {
return (
this.loading ||
(!this.postRevision.previous_revision &&
this.postRevision.current_revision <=
this.postRevision.previous_revision)
);
}
get loadNextDisabled() {
return (
this.loading ||
this.postRevision?.current_revision >= this.postRevision?.next_revision
);
}
get loadLastDisabled() {
return (
this.loading ||
this.postRevision?.current_revision >= this.postRevision?.next_revision
);
}
get displayRevisions() {
return this.postRevision?.version_count > 2;
}
get modalTitleKey() {
return this.args.model.post.version > 100
? "history_capped_revisions"
: "history";
}
get previousVersion() {
return this.postRevision?.current_version
? this.postRevision.current_version - 1
: null;
}
get revisionsText() {
return I18n.t(
"post.revisions.controls.comparing_previous_to_current_out_of_total",
{
previous: this.previousVersion,
icon: iconHTML("arrows-alt-h"),
current: this.postRevision?.current_version,
total: this.postRevision?.version_count,
}
);
}
get titleDiff() {
let mode = this.viewMode;
if (mode === "side_by_side_markdown") {
mode = "side_by_side";
}
return this.postRevision?.title_changes?.[mode];
}
get bodyDiffHTML() {
return this.postRevision?.body_changes?.[this.viewMode];
}
@action
async calculateBodyDiff(_, bodyDiff) {
let html = bodyDiff;
if (this.viewMode !== "side_by_side_markdown") {
const opts = {
features: { editHistory: true, historyOneboxes: true },
allowListed: {
editHistory: { custom: (tag, attr) => attr === "class" },
historyOneboxes: ["header", "article", "div[style]"],
},
};
html = await sanitizeAsync(html, opts);
}
this.bodyDiff = html;
}
get previousTagChanges() {
const previousArray = customTagArray(
this.postRevision.tags_changes.previous
);
const currentSet = new Set(
customTagArray(this.postRevision.tags_changes.current)
);
return previousArray.map((name) => ({
name,
deleted: !currentSet.has(name),
}));
}
get currentTagChanges() {
const previousSet = new Set(
customTagArray(this.postRevision.tags_changes.previous)
);
const currentArray = customTagArray(this.postRevision.tags_changes.current);
return currentArray.map((name) => ({
name,
inserted: !previousSet.has(name),
}));
}
get createdAtDate() {
return moment(this.postRevision.created_at).format("LLLL");
}
get displayEdit() {
return !!(
this.postRevision?.can_edit &&
this.args.model.editPost &&
this.postRevision?.last_revision === this.postRevision?.current_revision
);
}
get revertToRevisionText() {
return I18n.t("post.revisions.controls.revert", {
revision: this.previousVersion,
});
}
refresh(postId, postVersion) {
this.loading = true;
Post.loadRevision(postId, postVersion).then((result) => {
this.loading = false;
this.postRevision = result;
});
}
hide(postId, postVersion) {
Post.hideRevision(postId, postVersion).then(() =>
this.refresh(postId, postVersion)
);
}
show(postId, postVersion) {
Post.showRevision(postId, postVersion).then(() =>
this.refresh(postId, postVersion)
);
}
revert(post, postVersion) {
post
.revertToRevision(postVersion)
.then((result) => {
this.refresh(post.id, postVersion);
if (result.topic) {
post.set("topic.slug", result.topic.slug);
post.set("topic.title", result.topic.title);
post.set("topic.fancy_title", result.topic.fancy_title);
}
if (result.category_id) {
post.set("topic.category", Category.findById(result.category_id));
}
this.args.closeModal();
})
.catch(function (e) {
if (e.jqXHR.responseJSON?.errors?.[0]) {
this.dialog.alert(e.jqXHR.responseJSON.errors[0]);
}
});
}
get editButtonLabel() {
return `post.revisions.controls.${
this.postRevision.wiki ? "edit_wiki" : "edit_post"
}`;
}
get hiddenClasses() {
if (this.viewMode === "inline") {
return this.postRevision?.previous_hidden ||
this.postRevision?.current_hidden
? "hidden-revision-either"
: null;
} else {
let result = [];
if (this.postRevision?.previous_hidden) {
result.push("hidden-revision-previous");
}
if (this.postRevision?.current_hidden) {
result.push("hidden-revision-current");
}
return result.join(" ");
}
}
get previousCategory() {
if (this.postRevision?.category_id_changes) {
let category = Category.findById(
this.postRevision.category_id_changes.previous
);
return categoryBadgeHTML(category, { allowUncategorized: true });
}
}
get currentCategory() {
if (this.postRevision?.category_id_changes) {
let category = Category.findById(
this.postRevision.category_id_changes.current
);
return categoryBadgeHTML(category, { allowUncategorized: true });
}
}
get wikiDisabled() {
return !this.postRevision.wiki_changes?.current;
}
get postTypeDisabled() {
return (
this.postRevision?.post_type_changes?.current !==
this.site.post_types.moderator_action
);
}
@action
displayInline(event) {
event?.preventDefault();
this.viewMode = "inline";
}
@action
displaySideBySide(event) {
event?.preventDefault();
this.viewMode = "side_by_side";
}
@action
displaySideBySideMarkdown(event) {
event?.preventDefault();
this.viewMode = "side_by_side_markdown";
}
@action
loadFirstVersion() {
this.refresh(this.postRevision.post_id, this.postRevision.first_revision);
}
@action
loadPreviousVersion() {
this.refresh(
this.postRevision.post_id,
this.postRevision.previous_revision
);
}
@action
loadNextVersion() {
this.refresh(this.postRevision.post_id, this.postRevision.next_revision);
}
@action
loadLastVersion() {
return this.refresh(
this.postRevision.post_id,
this.postRevision.last_revision
);
}
@action
hideVersion() {
this.hide(this.postRevision.post_id, this.postRevision.current_revision);
}
@action
permanentlyDeleteVersions() {
this.dialog.yesNoConfirm({
message: I18n.t("post.revisions.controls.destroy_confirm"),
didConfirm: () => {
Post.permanentlyDeleteRevisions(this.postRevision.post_id).then(() => {
this.args.closeModal();
});
},
});
}
@action
showVersion() {
this.show(this.postRevision.post_id, this.postRevision.current_revision);
}
@action
editPost() {
this.args.model.editPost(this.args.model.post);
this.args.closeModal();
}
@action
revertToVersion() {
this.revert(this.args.model.post, this.postRevision.current_revision);
}
}

View File

@ -0,0 +1,101 @@
<div id="revision">
<div id="revision-details">
{{d-icon "pencil-alt"}}
<LinkTo @route="user" @model={{@model.username}}>
{{bound-avatar-template @model.avatar_template "small"}}
{{@model.username}}
</LinkTo>
<PluginOutlet
@name="revision-user-details-after"
@outletArgs={{hash model=@model}}
/>
<span class="date">{{bound-date @model.created_at}}</span>
{{#if @model.edit_reason}}
&mdash;
<span class="edit-reason">{{@model.edit_reason}}</span>
{{/if}}
{{#unless @mobileView}}
{{#if @model.user_changes}}
&mdash;
{{bound-avatar-template
@model.user_changes.previous.avatar_template
"small"
}}
{{@model.user_changes.previous.username}}
&rarr;
{{bound-avatar-template
@model.user_changes.current.avatar_template
"small"
}}
{{@model.user_changes.current.username}}
{{/if}}
{{#if @model.wiki_changes}}
&mdash;
<DisabledIcon @icon="far-edit" @disabled={{@wikiDisabled}} />
{{/if}}
{{#if @model.post_type_changes}}
&mdash;
<DisabledIcon @icon="shield-alt" @disabled={{@postTypeDisabled}} />
{{/if}}
{{#if @model.category_id_changes}}
&mdash;
{{html-safe @previousCategory}}
&rarr;
{{html-safe @currentCategory}}
{{/if}}
{{/unless}}
</div>
{{#unless @mobileView}}
<div id="display-modes">
<ul class="nav nav-pills">
<li>
<a
href
class={{concat-class
"inline-mode"
(if (eq @viewMode "inline") "active")
}}
{{on "click" @displayInline}}
title={{i18n "post.revisions.displays.inline.title"}}
aria-label={{i18n "post.revisions.displays.inline.title"}}
>
{{d-icon "far-square"}}
{{i18n "post.revisions.displays.inline.button"}}
</a>
</li>
<li>
<a
href
class={{concat-class
"side-by-side-mode"
(if (eq @viewMode "side_by_side") "active")
}}
{{on "click" @displaySideBySide}}
title={{i18n "post.revisions.displays.side_by_side.title"}}
aria-label={{i18n "post.revisions.displays.side_by_side.title"}}
>
{{d-icon "columns"}}
{{i18n "post.revisions.displays.side_by_side.button"}}
</a>
</li>
<li>
<a
href
class={{concat-class
"side-by-side-markdown-mode"
(if (eq @viewMode "side_by_side_markdown") "active")
}}
{{on "click" @displaySideBySideMarkdown}}
title={{i18n "post.revisions.displays.side_by_side_markdown.title"}}
aria-label={{i18n
"post.revisions.displays.side_by_side_markdown.title"
}}
>
{{d-icon "columns"}}
{{i18n "post.revisions.displays.side_by_side_markdown.button"}}
</a>
</li>
</ul>
</div>
{{/unless}}
</div>

View File

@ -0,0 +1,75 @@
<div id="revisions" data-post-id={{@model.post_id}} class={{@hiddenClasses}}>
{{#if @model.title_changes}}
<div class="row">
<h2>{{html-safe @titleDiff}}</h2>
</div>
{{/if}}
{{#if @mobileView}}
{{#if @userChanges}}
<div class="row">
{{bound-avatar-template
@model.user_changes.previous.avatar_template
"small"
}}
{{@model.user_changes.previous.username}}
&rarr;
{{bound-avatar-template
@model.user_changes.current.avatar_template
"small"
}}
{{@model.user_changes.current.username}}
</div>
{{/if}}
{{#if @model.wiki_changes}}
<div class="row">
<DisabledIcon @icon="far-edit" @disabled={{@wikiDisabled}} />
</div>
{{/if}}
{{#if @model.post_type_changes}}
<div class="row">
<DisabledIcon @icon="shield-alt" @disabled={{@postTypeDisabled}} />
</div>
{{/if}}
{{#if @model.category_id_changes}}
<div class="row">
{{html-safe @previousCategory}}
&rarr;
{{html-safe @currentCategory}}
</div>
{{/if}}
{{/if}}
{{#if @model.tags_changes}}
<div class="row">
{{i18n "tagging.changed"}}
{{#each @previousTagChanges as |t|}}
{{discourse-tag t.name style=(if t.deleted "diff-del")}}
{{/each}}
&rarr;&nbsp;
{{#each @currentTagChanges as |t|}}
{{discourse-tag t.name style=(if t.inserted "diff-ins")}}
{{/each}}
</div>
{{/if}}
{{#if @model.featured_link_changes}}
<div class="row">
{{@model.featured_link_changes.previous}}
&rarr;
{{@model.featured_link_changes.current}}
</div>
{{/if}}
<span>
<PluginOutlet
@name="post-revisions"
@connectorTagName="div"
@outletArgs={{hash model=@model}}
/>
</span>
<LinksRedirect
@class="row body-diff"
{{did-update @calculateBodyDiff @bodyDiffHTML}}
>
{{html-safe @bodyDiff}}
</LinksRedirect>
</div>

View File

@ -0,0 +1,84 @@
<div id="revision-controls">
<DButton
class="btn-default first-revision"
@action={{@loadFirstVersion}}
@icon="fast-backward"
@title="post.revisions.controls.first"
@disabled={{@loadFirstDisabled}}
/>
<DButton
class="btn-default previous-revision"
@action={{@loadPreviousVersion}}
@icon="backward"
@title="post.revisions.controls.previous"
@disabled={{@loadPreviousDisabled}}
/>
<div id="revision-numbers" class={{unless @displayRevisions "invisible"}}>
<ConditionalLoadingSpinner @condition={{@loading}} @size="small">
{{html-safe @revisionsText}}
</ConditionalLoadingSpinner>
</div>
<DButton
class="btn-default next-revision"
@action={{@loadNextVersion}}
@icon="forward"
@title="post.revisions.controls.next"
@disabled={{@loadNextDisabled}}
/>
<DButton
class="btn-default last-revision"
@action={{@loadLastVersion}}
@icon="fast-forward"
@title="post.revisions.controls.last"
@disabled={{@loadLastDisabled}}
/>
</div>
<div id="revision-footer-buttons">
{{#if @displayEdit}}
<DButton
@action={{@editPost}}
@icon="pencil-alt"
class="btn-default edit-post"
@label={{@editButtonLabel}}
/>
{{/if}}
{{#if @isStaff}}
<DButton
@action={{@revertToVersion}}
@icon="undo"
@translatedLabel={{@revertToRevisionText}}
class="btn-danger revert-to-version"
@disabled={{@loading}}
/>
{{#if (not @model.previous_hidden)}}
<DButton
@action={{@hideVersion}}
@icon="far-eye-slash"
@label="post.revisions.controls.hide"
class="btn-danger hide-revision"
@disabled={{@loading}}
/>
{{else}}
<DButton
@action={{@showVersion}}
@icon="far-eye"
@label="post.revisions.controls.show"
class="btn-default show-revision"
@disabled={{@loading}}
/>
{{/if}}
{{#if (and @canPermanentlyDelete @model.previous_hidden)}}
<DButton
@action={{@permanentlyDeleteVersions}}
@icon="far-trash-alt"
@label="post.revisions.controls.destroy"
class="btn-danger destroy-revision"
@disabled={{@loading}}
/>
{{/if}}
{{/if}}
</div>

View File

@ -4,9 +4,12 @@ import { action } from "@ember/object";
import { gt } from "@ember/object/computed";
import { historyHeat } from "discourse/widgets/post-edits-indicator";
import { longDate } from "discourse/lib/formatter";
import showModal from "discourse/lib/show-modal";
import { inject as service } from "@ember/service";
import HistoryModal from "discourse/components/modal/history";
export default Component.extend({
modal: service(),
hasEdits: gt("reviewable.post_version", 1),
@discourseComputed("reviewable.post_updated_at")
@ -24,13 +27,14 @@ export default Component.extend({
event?.preventDefault();
let postId = this.get("reviewable.post_id");
this.store.find("post", postId).then((post) => {
let historyController = showModal("history", {
model: post,
modalClass: "history-modal",
this.modal.show(HistoryModal, {
model: {
post,
postId,
postVersion: "latest",
topicController: null,
},
});
historyController.refresh(postId, "latest");
historyController.set("post", post);
historyController.set("topicController", null);
});
},
});

View File

@ -1,392 +0,0 @@
import { action } from "@ember/object";
import { alias, equal, gt, not, or } from "@ember/object/computed";
import discourseComputed, {
observes,
on,
} from "discourse-common/utils/decorators";
import { propertyGreaterThan, propertyLessThan } from "discourse/lib/computed";
import Category from "discourse/models/category";
import Controller from "@ember/controller";
import I18n from "I18n";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import Post from "discourse/models/post";
import { categoryBadgeHTML } from "discourse/helpers/category-link";
import { iconHTML } from "discourse-common/lib/icon-library";
import { sanitizeAsync } from "discourse/lib/text";
import { inject as service } from "@ember/service";
function customTagArray(val) {
if (!val) {
return [];
}
if (!Array.isArray(val)) {
val = [val];
}
return val;
}
// This controller handles displaying of history
export default Controller.extend(ModalFunctionality, {
dialog: service(),
loading: true,
viewMode: "side_by_side",
@on("init")
_changeViewModeOnMobile() {
if (this.site && this.site.mobileView) {
this.set("viewMode", "inline");
}
},
previousFeaturedLink: alias("model.featured_link_changes.previous"),
currentFeaturedLink: alias("model.featured_link_changes.current"),
@discourseComputed(
"model.tags_changes.previous",
"model.tags_changes.current"
)
previousTagChanges(previous, current) {
const previousArray = customTagArray(previous);
const currentSet = new Set(customTagArray(current));
return previousArray.map((name) => ({
name,
deleted: !currentSet.has(name),
}));
},
@discourseComputed(
"model.tags_changes.previous",
"model.tags_changes.current"
)
currentTagChanges(previous, current) {
const previousSet = new Set(customTagArray(previous));
const currentArray = customTagArray(current);
return currentArray.map((name) => ({
name,
inserted: !previousSet.has(name),
}));
},
@discourseComputed("post.version")
modalTitleKey(version) {
if (version > 100) {
return "history_capped_revisions";
} else {
return "history";
}
},
@discourseComputed(
"previousVersion",
"model.current_version",
"model.version_count"
)
revisionsText(previous, current, total) {
return I18n.t(
"post.revisions.controls.comparing_previous_to_current_out_of_total",
{
previous,
icon: iconHTML("arrows-alt-h"),
current,
total,
}
);
},
@discourseComputed("previousVersion")
revertToRevisionText(revision) {
return I18n.t("post.revisions.controls.revert", { revision });
},
refresh(postId, postVersion) {
this.set("loading", true);
Post.loadRevision(postId, postVersion).then((result) => {
this.setProperties({ loading: false, model: result });
});
},
hide(postId, postVersion) {
Post.hideRevision(postId, postVersion).then(() =>
this.refresh(postId, postVersion)
);
},
permanentlyDeleteRevisions(postId) {
this.dialog.yesNoConfirm({
message: I18n.t("post.revisions.controls.destroy_confirm"),
didConfirm: () => {
Post.permanentlyDeleteRevisions(postId).then(() => {
this.send("closeModal");
});
},
});
},
show(postId, postVersion) {
Post.showRevision(postId, postVersion).then(() =>
this.refresh(postId, postVersion)
);
},
revert(post, postVersion) {
post
.revertToRevision(postVersion)
.then((result) => {
this.refresh(post.get("id"), postVersion);
if (result.topic) {
post.set("topic.slug", result.topic.slug);
post.set("topic.title", result.topic.title);
post.set("topic.fancy_title", result.topic.fancy_title);
}
if (result.category_id) {
post.set("topic.category", Category.findById(result.category_id));
}
this.send("closeModal");
})
.catch(function (e) {
if (
e.jqXHR.responseJSON &&
e.jqXHR.responseJSON.errors &&
e.jqXHR.responseJSON.errors[0]
) {
this.dialog.alert(e.jqXHR.responseJSON.errors[0]);
}
});
},
@discourseComputed("model.created_at")
createdAtDate(createdAt) {
return moment(createdAt).format("LLLL");
},
@discourseComputed("model.current_version")
previousVersion(current) {
return current - 1;
},
@discourseComputed("model.current_revision", "model.previous_revision")
displayGoToPrevious(current, prev) {
return prev && current > prev;
},
displayRevisions: gt("model.version_count", 2),
displayGoToFirst: propertyGreaterThan(
"model.current_revision",
"model.first_revision"
),
displayGoToNext: propertyLessThan(
"model.current_revision",
"model.next_revision"
),
displayGoToLast: propertyLessThan(
"model.current_revision",
"model.next_revision"
),
hideGoToFirst: not("displayGoToFirst"),
hideGoToPrevious: not("displayGoToPrevious"),
hideGoToNext: not("displayGoToNext"),
hideGoToLast: not("displayGoToLast"),
loadFirstDisabled: or("loading", "hideGoToFirst"),
loadPreviousDisabled: or("loading", "hideGoToPrevious"),
loadNextDisabled: or("loading", "hideGoToNext"),
loadLastDisabled: or("loading", "hideGoToLast"),
@discourseComputed("model.previous_hidden")
displayShow(prevHidden) {
return prevHidden && this.currentUser && this.currentUser.get("staff");
},
@discourseComputed("model.previous_hidden")
displayHide(prevHidden) {
return !prevHidden && this.currentUser && this.currentUser.get("staff");
},
@discourseComputed(
"model.last_revision",
"model.current_revision",
"model.can_edit",
"topicController"
)
displayEdit(lastRevision, currentRevision, canEdit, topicController) {
return !!(canEdit && topicController && lastRevision === currentRevision);
},
@discourseComputed("model.wiki")
editButtonLabel(wiki) {
return `post.revisions.controls.${wiki ? "edit_wiki" : "edit_post"}`;
},
@discourseComputed()
displayRevert() {
return this.currentUser && this.currentUser.get("staff");
},
@discourseComputed("model.previous_hidden")
displayPermanentlyDeleteButton(previousHidden) {
return (
this.siteSettings.can_permanently_delete &&
this.currentUser?.staff &&
previousHidden
);
},
isEitherRevisionHidden: or("model.previous_hidden", "model.current_hidden"),
@discourseComputed(
"model.previous_hidden",
"model.current_hidden",
"displayingInline"
)
hiddenClasses(prevHidden, currentHidden, displayingInline) {
if (displayingInline) {
return this.isEitherRevisionHidden ? "hidden-revision-either" : null;
} else {
let result = [];
if (prevHidden) {
result.push("hidden-revision-previous");
}
if (currentHidden) {
result.push("hidden-revision-current");
}
return result.join(" ");
}
},
displayingInline: equal("viewMode", "inline"),
displayingSideBySide: equal("viewMode", "side_by_side"),
displayingSideBySideMarkdown: equal("viewMode", "side_by_side_markdown"),
@discourseComputed("displayingInline")
inlineClass(displayingInline) {
return displayingInline ? "active" : "";
},
@discourseComputed("displayingSideBySide")
sideBySideClass(displayingSideBySide) {
return displayingSideBySide ? "active" : "";
},
@discourseComputed("displayingSideBySideMarkdown")
sideBySideMarkdownClass(displayingSideBySideMarkdown) {
return displayingSideBySideMarkdown ? "active" : "";
},
@discourseComputed("model.category_id_changes")
previousCategory(changes) {
if (changes) {
let category = Category.findById(changes["previous"]);
return categoryBadgeHTML(category, { allowUncategorized: true });
}
},
@discourseComputed("model.category_id_changes")
currentCategory(changes) {
if (changes) {
let category = Category.findById(changes["current"]);
return categoryBadgeHTML(category, { allowUncategorized: true });
}
},
@discourseComputed("model.wiki_changes")
wikiDisabled(changes) {
return changes && !changes["current"];
},
@discourseComputed("model.post_type_changes")
postTypeDisabled(changes) {
return (
changes &&
changes["current"] !== this.site.get("post_types.moderator_action")
);
},
@discourseComputed("viewMode", "model.title_changes")
titleDiff(viewMode) {
if (viewMode === "side_by_side_markdown") {
viewMode = "side_by_side";
}
return this.get("model.title_changes." + viewMode);
},
@observes("viewMode", "model.body_changes")
bodyDiffChanged() {
const viewMode = this.viewMode;
const html = this.get(`model.body_changes.${viewMode}`);
if (viewMode === "side_by_side_markdown") {
this.set("bodyDiff", html);
} else {
const opts = {
features: { editHistory: true, historyOneboxes: true },
allowListed: {
editHistory: { custom: (tag, attr) => attr === "class" },
historyOneboxes: ["header", "article", "div[style]"],
},
};
return sanitizeAsync(html, opts).then((result) =>
this.set("bodyDiff", result)
);
}
},
@action
displayInline(event) {
event?.preventDefault();
this.set("viewMode", "inline");
},
@action
displaySideBySide(event) {
event?.preventDefault();
this.set("viewMode", "side_by_side");
},
@action
displaySideBySideMarkdown(event) {
event?.preventDefault();
this.set("viewMode", "side_by_side_markdown");
},
actions: {
loadFirstVersion() {
this.refresh(this.get("model.post_id"), this.get("model.first_revision"));
},
loadPreviousVersion() {
this.refresh(
this.get("model.post_id"),
this.get("model.previous_revision")
);
},
loadNextVersion() {
this.refresh(this.get("model.post_id"), this.get("model.next_revision"));
},
loadLastVersion() {
this.refresh(this.get("model.post_id"), this.get("model.last_revision"));
},
hideVersion() {
this.hide(this.get("model.post_id"), this.get("model.current_revision"));
},
permanentlyDeleteVersions() {
this.permanentlyDeleteRevisions(this.get("model.post_id"));
},
showVersion() {
this.show(this.get("model.post_id"), this.get("model.current_revision"));
},
editPost() {
this.topicController.send("editPost", this.post);
this.send("closeModal");
},
revertToVersion() {
this.revert(this.post, this.get("model.current_revision"));
},
},
});

View File

@ -10,12 +10,14 @@ import { setTopicId } from "discourse/lib/topic-list-tracker";
import showModal from "discourse/lib/show-modal";
import TopicFlag from "discourse/lib/flag-targets/topic-flag";
import PostFlag from "discourse/lib/flag-targets/post-flag";
import HistoryModal from "discourse/components/modal/history";
const SCROLL_DELAY = 500;
const TopicRoute = DiscourseRoute.extend({
composer: service(),
screenTrack: service(),
modal: service(),
scheduledReplace: null,
lastScrollPos: null,
@ -153,13 +155,14 @@ const TopicRoute = DiscourseRoute.extend({
@action
showHistory(model, revision) {
let historyController = showModal("history", {
model,
modalClass: "history-modal",
this.modal.show(HistoryModal, {
model: {
postId: model.id,
postVersion: revision || "latest",
post: model,
editPost: (post) => this.controllerFor("topic").send("editPost", post),
},
});
historyController.refresh(model.get("id"), revision || "latest");
historyController.set("post", model);
historyController.set("topicController", this.controllerFor("topic"));
},
@action

View File

@ -1,271 +0,0 @@
<DModalBody @title={{this.modalTitleKey}}>
<div id="revision">
<div id="revision-details">
{{d-icon "pencil-alt"}}
<LinkTo @route="user" @model={{this.model.username}}>
{{bound-avatar-template this.model.avatar_template "small"}}
{{this.model.username}}
</LinkTo>
<PluginOutlet
@name="revision-user-details-after"
@outletArgs={{hash model=this.model}}
/>
<span class="date">{{bound-date this.model.created_at}}</span>
{{#if this.model.edit_reason}}
&mdash;
<span class="edit-reason">{{this.model.edit_reason}}</span>
{{/if}}
{{#unless this.site.mobileView}}
{{#if this.model.user_changes}}
&mdash;
{{bound-avatar-template
this.model.user_changes.previous.avatar_template
"small"
}}
{{this.model.user_changes.previous.username}}
&rarr;
{{bound-avatar-template
this.model.user_changes.current.avatar_template
"small"
}}
{{this.model.user_changes.current.username}}
{{/if}}
{{#if this.model.wiki_changes}}
&mdash;
<DisabledIcon @icon="far-edit" @disabled={{this.wikiDisabled}} />
{{/if}}
{{#if this.model.post_type_changes}}
&mdash;
<DisabledIcon
@icon="shield-alt"
@disabled={{this.postTypeDisabled}}
/>
{{/if}}
{{#if this.model.category_id_changes}}
&mdash;
{{html-safe this.previousCategory}}
&rarr;
{{html-safe this.currentCategory}}
{{/if}}
{{/unless}}
</div>
{{#unless this.site.mobileView}}
<div id="display-modes">
<ul class="nav nav-pills">
<li>
<a
href
class={{this.inlineClass}}
{{on "click" this.displayInline}}
title={{i18n "post.revisions.displays.inline.title"}}
aria-label={{i18n "post.revisions.displays.inline.title"}}
>
{{d-icon "far-square"}}
{{i18n "post.revisions.displays.inline.button"}}
</a>
</li>
<li>
<a
href
class={{this.sideBySideClass}}
{{on "click" this.displaySideBySide}}
title={{i18n "post.revisions.displays.side_by_side.title"}}
aria-label={{i18n "post.revisions.displays.side_by_side.title"}}
>
{{d-icon "columns"}}
{{i18n "post.revisions.displays.side_by_side.button"}}
</a>
</li>
<li>
<a
href
class={{this.sideBySideMarkdownClass}}
{{on "click" this.displaySideBySideMarkdown}}
title={{i18n
"post.revisions.displays.side_by_side_markdown.title"
}}
aria-label={{i18n
"post.revisions.displays.side_by_side_markdown.title"
}}
>
{{d-icon "columns"}}
{{i18n "post.revisions.displays.side_by_side_markdown.button"}}
</a>
</li>
</ul>
</div>
{{/unless}}
</div>
<div
id="revisions"
data-post-id={{this.model.post_id}}
class={{this.hiddenClasses}}
>
{{#if this.model.title_changes}}
<div class="row">
<h2>{{html-safe this.titleDiff}}</h2>
</div>
{{/if}}
{{#if this.site.mobileView}}
{{#if this.user_changes}}
<div class="row">
{{bound-avatar-template
this.model.user_changes.previous.avatar_template
"small"
}}
{{this.model.user_changes.previous.username}}
&rarr;
{{bound-avatar-template
this.model.user_changes.current.avatar_template
"small"
}}
{{this.model.user_changes.current.username}}
</div>
{{/if}}
{{#if this.model.wiki_changes}}
<div class="row">
<DisabledIcon @icon="far-edit" @disabled={{this.wikiDisabled}} />
</div>
{{/if}}
{{#if this.model.post_type_changes}}
<div class="row">
<DisabledIcon
@icon="shield-alt"
@disabled={{this.postTypeDisabled}}
/>
</div>
{{/if}}
{{#if this.model.category_id_changes}}
<div class="row">
{{html-safe this.previousCategory}}
&rarr;
{{html-safe this.currentCategory}}
</div>
{{/if}}
{{/if}}
{{#if this.model.tags_changes}}
<div class="row">
{{i18n "tagging.changed"}}
{{#each this.previousTagChanges as |t|}}
{{discourse-tag t.name style=(if t.deleted "diff-del")}}
{{/each}}
&rarr;&nbsp;
{{#each this.currentTagChanges as |t|}}
{{discourse-tag t.name style=(if t.inserted "diff-ins")}}
{{/each}}
</div>
{{/if}}
{{#if this.model.featured_link_changes}}
<div class="row">
{{this.model.featured_link_changes.previous}}
&rarr;
{{this.model.featured_link_changes.current}}
</div>
{{/if}}
<span>
<PluginOutlet
@name="post-revisions"
@connectorTagName="div"
@outletArgs={{hash model=this.model}}
/>
</span>
<LinksRedirect @class="row">
{{html-safe this.bodyDiff}}
</LinksRedirect>
</div>
</DModalBody>
{{#if this.topicController}}
<div class="modal-footer">
<div id="revision-controls">
<DButton
@class="btn-default"
@action={{action "loadFirstVersion"}}
@icon="fast-backward"
@title="post.revisions.controls.first"
@disabled={{this.loadFirstDisabled}}
/>
<DButton
@class="btn-default"
@action={{action "loadPreviousVersion"}}
@icon="backward"
@title="post.revisions.controls.previous"
@disabled={{this.loadPreviousDisabled}}
/>
<div
id="revision-numbers"
class={{unless this.displayRevisions "invisible"}}
>
<ConditionalLoadingSpinner @condition={{this.loading}} @size="small">
{{html-safe this.revisionsText}}
</ConditionalLoadingSpinner>
</div>
<DButton
@class="btn-default"
@action={{action "loadNextVersion"}}
@icon="forward"
@title="post.revisions.controls.next"
@disabled={{this.loadNextDisabled}}
/>
<DButton
@class="btn-default"
@action={{action "loadLastVersion"}}
@icon="fast-forward"
@title="post.revisions.controls.last"
@disabled={{this.loadLastDisabled}}
/>
</div>
<div id="revision-footer-buttons">
{{#if this.displayEdit}}
<DButton
@action={{action "editPost"}}
@icon="pencil-alt"
@class="btn-default"
@label={{this.editButtonLabel}}
/>
{{/if}}
{{#if this.displayRevert}}
<DButton
@action={{action "revertToVersion"}}
@icon="undo"
@translatedLabel={{this.revertToRevisionText}}
@class="btn-danger"
@disabled={{this.loading}}
/>
{{/if}}
{{#if this.displayHide}}
<DButton
@action={{action "hideVersion"}}
@icon="far-eye-slash"
@label="post.revisions.controls.hide"
@class="btn-danger"
@disabled={{this.loading}}
/>
{{/if}}
{{#if this.displayShow}}
<DButton
@action={{action "showVersion"}}
@icon="far-eye"
@label="post.revisions.controls.show"
@class="btn-default"
@disabled={{this.loading}}
/>
{{/if}}
{{#if this.displayPermanentlyDeleteButton}}
<DButton
@action={{action "permanentlyDeleteVersions"}}
@icon="far-trash-alt"
@label="post.revisions.controls.destroy"
@class="btn-danger"
@disabled={{this.loading}}
/>
{{/if}}
</div>
</div>
{{/if}}

View File

@ -0,0 +1,90 @@
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { click, visit } from "@ember/test-helpers";
const revisionResponse = {
created_at: "2021-11-24T10:59:36.163Z",
post_id: 419,
previous_hidden: false,
current_hidden: false,
first_revision: 1,
previous_revision: 1,
current_revision: 2,
next_revision: null,
last_revision: 2,
current_version: 2,
version_count: 2,
username: "bianca",
display_username: "bianca",
avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png",
edit_reason: null,
body_changes: {
inline: '<div class="inline-diff"><p>Welcome to Discourse!</p</div>',
side_by_side:
'<div class="revision-content"><p>Welcome to Discourse!</p</div><div class="revision-content"><p>Welcome to Discourse!</p</div>',
side_by_side_markdown:
'<table class="markdown"><tr><td>Welcome to Discourse!</td><td>Welcome to Discourse!</td></tr></table>',
},
title_changes: {
inline: '<div class="inline-diff"><div>Welcome to Discourse!</div></div>',
side_by_side:
'<div class="revision-content"><div>Welcome to Discourse!</div></div><div class="revision-content"><div>Welcome to Discourse!</div></div>',
},
user_changes: null,
tags_changes: {
previous: ["tag1", "tag2"],
current: ["tag2", "tag3"],
},
wiki: false,
can_edit: true,
};
acceptance("History Modal - authorized", function (needs) {
needs.user();
needs.pretender((server, helper) => {
server.get("/posts/419/revisions/latest.json", () => {
return helper.response(revisionResponse);
});
server.get("/posts/419/revisions/1.json", () => {
return helper.response({ ...revisionResponse, current_revision: 1 });
});
});
test("edit post button", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("article[data-post-id='419'] .edits button");
assert
.dom(".history-modal #revision-footer-buttons .edit-post")
.exists("displays the edit post button on the latest revision");
});
test("edit post button - not last revision", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("article[data-post-id='419'] .edits button");
await click(".history-modal .previous-revision");
assert
.dom(".history-modal #revision-footer-buttons .edit-post")
.doesNotExist(
"hides the edit post button when not on the latest revision"
);
});
});
acceptance("History Modal - anonymous", function (needs) {
needs.pretender((server, helper) => {
server.get("/posts/419/revisions/latest.json", () => {
return helper.response({ ...revisionResponse, can_edit: false });
});
});
test("edit post button", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("article[data-post-id='419'] .edits button");
assert
.dom(".history-modal #revision-footer-buttons .edit-post")
.doesNotExist(
"it should not display edit button when user cannot edit the post"
);
});
});

View File

@ -1,120 +0,0 @@
import { module, test } from "qunit";
import { setupTest } from "ember-qunit";
module("Unit | Controller | history", function (hooks) {
setupTest(hooks);
test("displayEdit", async function (assert) {
const controller = this.owner.lookup("controller:history");
controller.setProperties({
model: { last_revision: 3, current_revision: 3, can_edit: false },
topicController: {},
});
assert.strictEqual(
controller.displayEdit,
false,
"it should not display edit button when user cannot edit the post"
);
controller.set("model.can_edit", true);
assert.strictEqual(
controller.displayEdit,
true,
"it should display edit button when user can edit the post"
);
controller.set("topicController", null);
assert.strictEqual(
controller.displayEdit,
false,
"it should not display edit button when there is not topic controller"
);
controller.set("topicController", {});
controller.set("model.current_revision", 2);
assert.strictEqual(
controller.displayEdit,
false,
"it should only display the edit button on the latest revision"
);
const html = `<div class="revision-content">
<p><img src="/uploads/default/original/1X/6b963ffc13cb0c053bbb90c92e99d4fe71b286ef.jpg" alt="" class="diff-del"><img/src=x onerror=alert(document.domain)>" width="276" height="183"></p>
</div>
<aside class="onebox allowlistedgeneric">
<header class="source">
<img src="/uploads/default/original/1X/1b0984d7ee08bce90572f46a1950e1ced436d028.png" class="site-icon" width="32" height="32">
<a href="https://meta.discourse.org/t/discourse-version-2-5/125302">Discourse Meta 9 Aug 19</a>
</header>
<article class="onebox-body">
<img src="/uploads/default/optimized/1X/ecc92a52ee7353e03d5c0d1ea6521ce4541d9c25_2_500x500.png" class="thumbnail onebox-avatar d-lazyload" width="500" height="500">
<h3><a href="https://meta.discourse.org/t/discourse-version-2-5/125302" target="_blank">Discourse Version 2.5</a></h3>
<div style="clear: both"></div>
</article>
</aside>
<table background="javascript:alert(\"HACKEDXSS\")">
<thead>
<tr>
<th>Column</th>
<th style="text-align:left">Test</th>
</tr>
</thead>
<tbody>
<tr>
<td background="javascript:alert('HACKEDXSS')">Osama</td>
<td style="text-align:right">Testing</td>
</tr>
</tbody>
</table>`;
const expectedOutput = `<div class="revision-content">
<p><img src="/uploads/default/original/1X/6b963ffc13cb0c053bbb90c92e99d4fe71b286ef.jpg" alt class="diff-del">" width="276" height="183"&gt;</p>
</div>
<aside class="onebox allowlistedgeneric">
<header class="source">
<img src="/uploads/default/original/1X/1b0984d7ee08bce90572f46a1950e1ced436d028.png" class="site-icon" width="32" height="32">
<a href="https://meta.discourse.org/t/discourse-version-2-5/125302">Discourse Meta 9 Aug 19</a>
</header>
<article class="onebox-body">
<img src="/uploads/default/optimized/1X/ecc92a52ee7353e03d5c0d1ea6521ce4541d9c25_2_500x500.png" class="thumbnail onebox-avatar d-lazyload" width="500" height="500">
<h3><a href="https://meta.discourse.org/t/discourse-version-2-5/125302" target="_blank">Discourse Version 2.5</a></h3>
<div style="clear: both"></div>
</article>
</aside>
<table>
<thead>
<tr>
<th>Column</th>
<th style="text-align:left">Test</th>
</tr>
</thead>
<tbody>
<tr>
<td>Osama</td>
<td style="text-align:right">Testing</td>
</tr>
</tbody>
</table>`;
controller.setProperties({
viewMode: "side_by_side",
model: {
body_changes: {
side_by_side: html,
},
},
});
await controller.bodyDiffChanged();
const output = controller.bodyDiff;
assert.strictEqual(
output,
expectedOutput,
"it keeps HTML safe and doesn't strip onebox tags"
);
});
});