DEV: Add reviewables tab to the new user menu (#17630)

This commit is a subset of the changes proposed in https://github.com/discourse/discourse/pull/17379.
This commit is contained in:
Osama Sayegh 2022-07-28 11:16:33 +03:00 committed by GitHub
parent f4b45df83f
commit 988a175e94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 935 additions and 26 deletions

View File

@ -0,0 +1,9 @@
<li class={{unless this.reviewable.pending "reviewed"}}>
<LinkTo @route="review.show" @model={{this.reviewable.id}}>
{{d-icon this.icon}}
<div>
<span class="reviewable-label">{{this.actor}}</span>
<span class="reviewable-description">{{this.description}}</span>
</div>
</LinkTo>
</li>

View File

@ -0,0 +1,28 @@
import GlimmerComponent from "discourse/components/glimmer";
import I18n from "I18n";
export default class UserMenuReviewableItem extends GlimmerComponent {
constructor() {
super(...arguments);
this.reviewable = this.args.item;
}
get actor() {
const flagger = this.reviewable.flagger_username;
if (flagger) {
return flagger;
} else {
return I18n.t("user_menu.reviewable.deleted_user");
}
}
get description() {
return I18n.t("user_menu.reviewable.default_item", {
reviewable_id: this.reviewable.id,
});
}
get icon() {
return "flag";
}
}

View File

@ -9,7 +9,7 @@
{{/each}}
</ul>
<div class="panel-body-bottom">
{{#if this.showAll}}
{{#if this.showAllHref}}
<a class="btn btn-default btn-icon no-text show-all" href={{this.showAllHref}} title={{this.showAllTitle}}>
{{d-icon "chevron-down" aria-label=this.showAllTitle}}
</a>

View File

@ -14,15 +14,7 @@ export default class UserMenuItemsList extends GlimmerComponent {
get itemsCacheKey() {}
get showAll() {
return false;
}
get showAllHref() {
throw new Error(
`the showAllHref getter must be implemented in ${this.constructor.name}`
);
}
get showAllHref() {}
get showAllTitle() {}

View File

@ -6,6 +6,8 @@ import UserMenuTab from "discourse/lib/user-menu/tab";
const DEFAULT_TAB_ID = "all-notifications";
const DEFAULT_PANEL_COMPONENT = "user-menu/notifications-list";
const REVIEW_QUEUE_TAB_ID = "review-queue";
const CORE_TOP_TABS = [
class extends UserMenuTab {
get id() {
@ -66,6 +68,28 @@ const CORE_TOP_TABS = [
return !this.currentUser.likes_notifications_disabled;
}
},
class extends UserMenuTab {
get id() {
return REVIEW_QUEUE_TAB_ID;
}
get icon() {
return "flag";
}
get panelComponent() {
return "user-menu/reviewables-list";
}
get shouldDisplay() {
return this.currentUser.can_review;
}
get count() {
return this.currentUser.get("reviewable_count");
}
},
];
export default class UserMenu extends GlimmerComponent {

View File

@ -7,10 +7,6 @@ export default class UserMenuNotificationsList extends UserMenuItemsList {
return null;
}
get showAll() {
return true;
}
get showAllHref() {
return `${this.currentUser.path}/notifications`;
}

View File

@ -0,0 +1,20 @@
import UserMenuDefaultReviewableItem from "discourse/components/user-menu/default-reviewable-item";
import I18n from "I18n";
import { htmlSafe } from "@ember/template";
export default class UserMenuReviewableFlaggedPostItem extends UserMenuDefaultReviewableItem {
get description() {
const title = this.reviewable.topic_fancy_title;
const postNumber = this.reviewable.post_number;
if (title && postNumber) {
return htmlSafe(
I18n.t("user_menu.reviewable.post_number_with_topic_title", {
post_number: postNumber,
title,
})
);
} else {
return I18n.t("user_menu.reviewable.delete_post");
}
}
}

View File

@ -0,0 +1,32 @@
import UserMenuDefaultReviewableItem from "discourse/components/user-menu/default-reviewable-item";
import I18n from "I18n";
import { htmlSafe } from "@ember/template";
import { escapeExpression } from "discourse/lib/utilities";
import { emojiUnescape } from "discourse/lib/text";
export default class UserMenuReviewableQueuedPostItem extends UserMenuDefaultReviewableItem {
get actor() {
return I18n.t("user_menu.reviewable.queue");
}
get description() {
let title = this.reviewable.topic_fancy_title;
if (!title) {
title = escapeExpression(this.reviewable.payload_title);
}
title = emojiUnescape(title);
if (this.reviewable.is_new_topic) {
return htmlSafe(title);
} else {
return htmlSafe(
I18n.t("user_menu.reviewable.new_post_in_topic", {
title,
})
);
}
}
get icon() {
return "layer-group";
}
}

View File

@ -0,0 +1,14 @@
import UserMenuDefaultReviewableItem from "discourse/components/user-menu/default-reviewable-item";
import I18n from "I18n";
export default class UserMenuReviewableUserItem extends UserMenuDefaultReviewableItem {
get description() {
return I18n.t("user_menu.reviewable.suspicious_user", {
username: this.reviewable.username,
});
}
get icon() {
return "user";
}
}

View File

@ -0,0 +1,27 @@
import UserMenuItemsList from "discourse/components/user-menu/items-list";
import { ajax } from "discourse/lib/ajax";
import UserMenuReviewable from "discourse/models/user-menu-reviewable";
import I18n from "I18n";
import getUrl from "discourse-common/lib/get-url";
export default class UserMenuReviewablesList extends UserMenuItemsList {
get showAllHref() {
return getUrl("/review");
}
get showAllTitle() {
return I18n.t("user_menu.reviewable.view_all");
}
get itemsCacheKey() {
return "pending-reviewables";
}
fetchItems() {
return ajax("/review/user-menu-list").then((data) => {
return data.reviewables.map((item) => {
return UserMenuReviewable.create(item);
});
});
}
}

View File

@ -0,0 +1,18 @@
import RestModel from "discourse/models/rest";
import { tracked } from "@glimmer/tracking";
const DEFAULT_COMPONENT = "user-menu/default-reviewable-item";
const DEFAULT_ITEM_COMPONENTS = {
ReviewableFlaggedPost: "user-menu/reviewable-flagged-post-item",
ReviewableQueuedPost: "user-menu/reviewable-queued-post-item",
ReviewableUser: "user-menu/reviewable-user-item",
};
export default class UserMenuReviewable extends RestModel {
@tracked pending;
get userMenuComponent() {
return DEFAULT_ITEM_COMPONENTS[this.type] || DEFAULT_COMPONENT;
}
}

View File

@ -142,6 +142,82 @@ export default function (helpers) {
this.put("/review/settings", () => response(200, {}));
this.get("/review/user-menu-list", () => {
return response({
reviewables: [
{
flagger_username: "osama",
id: 17,
type: "ReviewableFlaggedPost",
pending: true,
post_number: 3,
topic_fancy_title:
"Emotion clustering crisis struggling sallyport eagled ask",
},
{
flagger_username: "osama",
id: 15,
type: "ReviewableFlaggedPost",
pending: true,
post_number: 5,
topic_fancy_title:
"Emotion clustering crisis struggling sallyport eagled ask",
},
{
flagger_username: "system",
id: 4,
type: "ReviewableUser",
pending: false,
username: "trustlevel003",
},
{
flagger_username: "osama",
id: 18,
type: "ReviewableFlaggedPost",
pending: false,
post_number: 2,
topic_fancy_title:
"Emotion clustering crisis struggling sallyport eagled ask",
},
{
flagger_username: "osama",
id: 16,
type: "ReviewableFlaggedPost",
pending: false,
post_number: 4,
topic_fancy_title:
"Emotion clustering crisis struggling sallyport eagled ask",
},
{
flagger_username: "osama",
id: 12,
type: "ReviewableFlaggedPost",
pending: false,
post_number: 9,
topic_fancy_title:
"Emotion clustering crisis struggling sallyport eagled ask",
},
{
flagger_username: "tony",
id: 1,
type: "ReviewableQueuedPost",
pending: false,
payload_title: "Hello this is a test topic",
is_new_topic: true,
},
{
flagger_username: "tony",
id: 100,
type: "ReviewableQueuedPost",
pending: false,
topic_fancy_title: "Hello this is a test topic",
is_new_topic: false,
},
],
__rest_serializer: "1",
});
});
this.get("/review/:id", () => {
return response(200, {
reviewable: flag,

View File

@ -0,0 +1,75 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
import UserMenuReviewable from "discourse/models/user-menu-reviewable";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import I18n from "I18n";
function getReviewable(overrides = {}) {
return UserMenuReviewable.create(
Object.assign(
{
flagger_username: "sayo2",
id: 17,
pending: false,
post_number: 3,
topic_fancy_title: "anything hello world",
type: "ReviewableFlaggedPost",
},
overrides
)
);
}
module(
"Integration | Component | user-menu | default-reviewable-item",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`<UserMenu::DefaultReviewableItem @item={{this.item}}/>`;
test("doesn't push `reviewed` to the classList if the reviewable is pending", async function (assert) {
this.set("item", getReviewable({ pending: true }));
await render(template);
assert.ok(!exists("li.reviewed"));
assert.ok(exists("li"));
});
test("pushes `reviewed` to the classList if the reviewable isn't pending", async function (assert) {
this.set("item", getReviewable({ pending: false }));
await render(template);
assert.ok(exists("li.reviewed"));
});
test("has elements for label and description", async function (assert) {
this.set("item", getReviewable());
await render(template);
const label = query("li .reviewable-label");
const description = query("li .reviewable-description");
assert.strictEqual(
label.textContent.trim(),
"sayo2",
"the label is the flagger_username"
);
assert.strictEqual(
description.textContent.trim(),
I18n.t("user_menu.reviewable.default_item", {
reviewable_id: this.item.id,
}),
"displays the description for the reviewable"
);
});
test("the item's label is a placeholder that indicates deleted user if flagger_username is absent", async function (assert) {
this.set("item", getReviewable({ flagger_username: null }));
await render(template);
const label = query("li .reviewable-label");
assert.strictEqual(
label.textContent.trim(),
I18n.t("user_menu.reviewable.deleted_user")
);
});
}
);

View File

@ -1,7 +1,7 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { exists, query, queryAll } from "discourse/tests/helpers/qunit-helpers";
import { click, render } from "@ember/test-helpers";
import { click, render, settled } from "@ember/test-helpers";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import { hbs } from "ember-cli-htmlbars";
import pretender from "discourse/tests/helpers/create-pretender";
@ -81,12 +81,44 @@ module("Integration | Component | user-menu", function (hooks) {
assert.deepEqual(
tabs.map((t) => t.dataset.tabNumber),
[...Array(4).keys()].map((n) => n.toString()),
["0", "1", "2", "3"],
"data-tab-number of the tabs has no gaps when the likes tab is hidden"
);
});
test("reviewables tab is shown if current user can review", async function (assert) {
this.currentUser.set("can_review", true);
await render(template);
const tab = query("#user-menu-button-review-queue");
assert.strictEqual(tab.dataset.tabNumber, "4");
const tabs = Array.from(queryAll(".tabs-list .btn")); // top and bottom tabs
assert.strictEqual(tabs.length, 6);
assert.deepEqual(
tabs.map((t) => t.dataset.tabNumber),
["0", "1", "2", "3", "4", "5"],
"data-tab-number of the tabs has no gaps when the reviewables tab is show"
);
});
test("reviewables count is shown on the reviewables tab", async function (assert) {
this.currentUser.set("can_review", true);
this.currentUser.set("reviewable_count", 4);
await render(template);
const countBadge = query(
"#user-menu-button-review-queue .badge-notification"
);
assert.strictEqual(countBadge.textContent, "4");
this.currentUser.set("reviewable_count", 0);
await settled();
assert.ok(!exists("#user-menu-button-review-queue .badge-notification"));
});
test("changing tabs", async function (assert) {
this.currentUser.set("can_review", true);
await render(template);
let queryParams;
pretender.get("/notifications", (request) => {
@ -224,5 +256,16 @@ module("Integration | Component | user-menu", function (hooks) {
"active tab is now the likes tab"
);
assert.strictEqual(queryAll("#quick-access-likes ul li").length, 3);
await click("#user-menu-button-review-queue");
assert.ok(exists("#quick-access-review-queue.quick-access-panel"));
activeTabs = queryAll(".top-tabs .btn.active");
assert.strictEqual(activeTabs.length, 1);
assert.strictEqual(
activeTabs[0].id,
"user-menu-button-review-queue",
"active tab is now the reviewables tab"
);
assert.strictEqual(queryAll("#quick-access-review-queue ul li").length, 8);
});
});

View File

@ -0,0 +1,77 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
import UserMenuReviewable from "discourse/models/user-menu-reviewable";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import I18n from "I18n";
function getReviewable(overrides = {}) {
return UserMenuReviewable.create(
Object.assign(
{
flagger_username: "sayo2",
id: 17,
pending: false,
topic_fancy_title: "anything hello world",
type: "ReviewableQueuedPost",
},
overrides
)
);
}
module(
"Integration | Component | user-menu | reviewable-queued-post-item",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`<UserMenu::ReviewableQueuedPostItem @item={{this.item}}/>`;
test("doesn't escape topic_fancy_title because it's safe", async function (assert) {
this.set(
"item",
getReviewable({
topic_fancy_title: "This is safe title &lt;a&gt; :heart:",
})
);
await render(template);
const description = query(".reviewable-description");
assert.strictEqual(
description.textContent.trim(),
I18n.t("user_menu.reviewable.new_post_in_topic", {
title: "This is safe title <a>",
})
);
assert.strictEqual(
description.querySelectorAll("img.emoji").length,
1,
"emojis are rendered"
);
});
test("escapes payload_title because it's not safe", async function (assert) {
this.set(
"item",
getReviewable({
topic_fancy_title: null,
payload_title: "This is unsafe title <a> :heart:",
})
);
await render(template);
const description = query(".reviewable-description");
assert.strictEqual(
description.textContent.trim(),
I18n.t("user_menu.reviewable.new_post_in_topic", {
title: "This is unsafe title <a>",
})
);
assert.strictEqual(
description.querySelectorAll("img.emoji").length,
1,
"emojis are rendered"
);
assert.ok(!exists(".reviewable-description a"));
});
}
);

View File

@ -0,0 +1,32 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query, queryAll } from "discourse/tests/helpers/qunit-helpers";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import I18n from "I18n";
module(
"Integration | Component | user-menu | reviewables-list",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`<UserMenu::ReviewablesList/>`;
test("has a 'show all' link", async function (assert) {
await render(template);
const showAll = query(".panel-body-bottom a.show-all");
assert.ok(showAll.href.endsWith("/review"), "links to the /review page");
assert.strictEqual(
showAll.title,
I18n.t("user_menu.reviewable.view_all"),
"the 'show all' link has a title"
);
});
test("renders a list of reviewables", async function (assert) {
await render(template);
const reviewables = queryAll("ul li");
assert.strictEqual(reviewables.length, 8);
});
}
);

View File

@ -58,7 +58,7 @@ class ReviewablesController < ApplicationController
end,
meta: filters.merge(
total_rows_reviewables: total_rows, types: meta_types, reviewable_types: Reviewable.types,
reviewable_count: Reviewable.list_for(current_user).count
reviewable_count: current_user.reviewable_count
)
}
if (offset + PER_PAGE) < total_rows
@ -69,6 +69,14 @@ class ReviewablesController < ApplicationController
render_json_dump(json, rest_serializer: true)
end
def user_menu_list
reviewables = Reviewable.recent_list_with_pending_first(current_user).to_a
json = {
reviewables: reviewables.map! { |r| r.basic_serializer.new(r, scope: guardian, root: nil).as_json }
}
render_json_dump(json, rest_serializer: true)
end
def count
render_json_dump(count: Reviewable.pending_count(current_user))
end

View File

@ -1,6 +1,12 @@
# frozen_string_literal: true
class Reviewable < ActiveRecord::Base
TYPE_TO_BASIC_SERIALIZER = {
ReviewableFlaggedPost: BasicReviewableFlaggedPostSerializer,
ReviewableQueuedPost: BasicReviewableQueuedPostSerializer,
ReviewableUser: BasicReviewableUserSerializer
}
class UpdateConflict < StandardError; end
class InvalidAction < StandardError
@ -458,7 +464,8 @@ class Reviewable < ActiveRecord::Base
sort_order: nil,
from_date: nil,
to_date: nil,
additional_filters: {}
additional_filters: {},
preload: true
)
order = case sort_order
when 'score_asc'
@ -473,11 +480,11 @@ class Reviewable < ActiveRecord::Base
if username.present?
user_id = User.find_by_username(username)&.id
return [] if user_id.blank?
return none if user_id.blank?
end
return [] if user.blank?
result = viewable_by(user, order: order)
return none if user.blank?
result = viewable_by(user, order: order, preload: preload)
result = by_status(result, status)
result = result.where(id: ids) if ids
@ -489,7 +496,7 @@ class Reviewable < ActiveRecord::Base
if reviewed_by
reviewed_by_id = User.find_by_username(reviewed_by)&.id
return [] if reviewed_by_id.nil?
return none if reviewed_by_id.nil?
result = result.joins(<<~SQL
INNER JOIN(
@ -534,10 +541,36 @@ class Reviewable < ActiveRecord::Base
result
end
def self.recent_list_with_pending_first(user, limit: 30)
min_score = Reviewable.min_score_for_priority
query = Reviewable
.includes(:created_by, :topic, :target)
.viewable_by(user, preload: false)
.except(:order)
.order(score: :desc, created_at: :desc)
.limit(limit)
if min_score > 0
query = query.where(<<~SQL, min_score: min_score)
reviewables.score >= :min_score OR reviewables.force_review
SQL
end
records = query.where(status: Reviewable.statuses[:pending]).to_a
if records.size < limit
records += query.where.not(status: Reviewable.statuses[:pending]).to_a
end
records
end
def serializer
self.class.serializer_for(self)
end
def basic_serializer
TYPE_TO_BASIC_SERIALIZER[self.type.to_sym] || BasicReviewableSerializer
end
def self.lookup_serializer_for(type)
"#{type}Serializer".constantize
rescue NameError
@ -753,6 +786,7 @@ end
#
# Indexes
#
# idx_reviewables_score_desc_created_at_desc (score,created_at)
# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id)
# index_reviewables_on_status_and_created_at (status,created_at)
# index_reviewables_on_status_and_score (status,score)

View File

@ -357,6 +357,7 @@ end
#
# Indexes
#
# idx_reviewables_score_desc_created_at_desc (score,created_at)
# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id)
# index_reviewables_on_status_and_created_at (status,created_at)
# index_reviewables_on_status_and_score (status,score)

View File

@ -137,6 +137,7 @@ end
#
# Indexes
#
# idx_reviewables_score_desc_created_at_desc (score,created_at)
# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id)
# index_reviewables_on_status_and_created_at (status,created_at)
# index_reviewables_on_status_and_score (status,score)

View File

@ -205,6 +205,7 @@ end
#
# Indexes
#
# idx_reviewables_score_desc_created_at_desc (score,created_at)
# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id)
# index_reviewables_on_status_and_created_at (status,created_at)
# index_reviewables_on_status_and_score (status,score)

View File

@ -126,6 +126,7 @@ end
#
# Indexes
#
# idx_reviewables_score_desc_created_at_desc (score,created_at)
# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id)
# index_reviewables_on_status_and_created_at (status,created_at)
# index_reviewables_on_status_and_score (status,score)

View File

@ -591,6 +591,10 @@ class User < ActiveRecord::Base
@unread_total_notifications ||= notifications.where("read = false").count
end
def reviewable_count
Reviewable.list_for(self).count
end
def saw_notification_id(notification_id)
if seen_notification_id.to_i < notification_id.to_i
update_columns(seen_notification_id: notification_id.to_i)

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class BasicReviewableFlaggedPostSerializer < BasicReviewableSerializer
attributes :post_number, :topic_fancy_title
def post_number
object.post.post_number
end
def topic_fancy_title
object.topic.fancy_title
end
def include_post_number?
object.post.present?
end
def include_topic_fancy_title?
object.topic.present?
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
class BasicReviewableQueuedPostSerializer < BasicReviewableSerializer
attributes :topic_fancy_title, :payload_title, :is_new_topic
def topic_fancy_title
object.topic.fancy_title
end
def payload_title
object.payload["title"]
end
def is_new_topic
object.payload["title"].present?
end
def include_topic_fancy_title?
object.topic.present?
end
def include_payload_title?
is_new_topic
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class BasicReviewableSerializer < ApplicationSerializer
attributes :flagger_username, :id, :type, :pending
def flagger_username
object.created_by&.username
end
def pending
object.pending?
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class BasicReviewableUserSerializer < BasicReviewableSerializer
attributes :username
def username
object.payload["username"]
end
end

View File

@ -260,10 +260,6 @@ class CurrentUserSerializer < BasicUserSerializer
object.anonymous?
end
def reviewable_count
Reviewable.list_for(object).count
end
def can_review
scope.can_see_review_queue?
end

View File

@ -2555,6 +2555,14 @@ en:
generic_no_items: "There are no items in this list."
sr_menu_tabs: "Menu tabs"
view_all_notifications: "view all notifications"
reviewable:
view_all: "view all review items"
queue: "Queue"
deleted_user: "(deleted user)"
post_number_with_topic_title: "post #%{post_number} - %{title}"
new_post_in_topic: "new post in %{title}"
suspicious_user: "suspicious user %{username}"
default_item: "reviewable item #%{reviewable_id}"
topics:
new_messages_marker: "last visit"

View File

@ -358,6 +358,7 @@ Discourse::Application.routes.draw do
get "review/count" => "reviewables#count"
get "review/topics" => "reviewables#topics"
get "review/settings" => "reviewables#settings"
get "review/user-menu-list" => "reviewables#user_menu_list", format: :json
put "review/settings" => "reviewables#settings"
put "review/:reviewable_id/perform/:action_id" => "reviewables#perform", constraints: {
reviewable_id: /\d+/,

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
class CreateIndexOnReviewablesScoreDescCreatedAtDesc < ActiveRecord::Migration[7.0]
def change
add_index(
:reviewables,
[:score, :created_at],
order: { score: :desc, created_at: :desc },
name: 'idx_reviewables_score_desc_created_at_desc'
)
end
end

View File

@ -244,6 +244,87 @@ RSpec.describe Reviewable, type: :model do
end
end
describe ".recent_list_with_pending_first" do
fab!(:pending_reviewable1) do
Fabricate(
:reviewable,
score: 150,
created_at: 7.minutes.ago,
status: Reviewable.statuses[:pending]
)
end
fab!(:pending_reviewable2) do
Fabricate(
:reviewable,
score: 100,
status: Reviewable.statuses[:pending]
)
end
fab!(:approved_reviewable1) do
Fabricate(
:reviewable,
created_at: 1.minutes.ago,
score: 300,
status: Reviewable.statuses[:approved]
)
end
fab!(:approved_reviewable2) do
Fabricate(
:reviewable,
created_at: 5.minutes.ago,
score: 200,
status: Reviewable.statuses[:approved]
)
end
fab!(:admin) { Fabricate(:admin) }
it "returns a list of reviewables with pending items first" do
list = Reviewable.recent_list_with_pending_first(admin)
expect(list.map(&:id)).to eq([
pending_reviewable1.id,
pending_reviewable2.id,
approved_reviewable1.id,
approved_reviewable2.id
])
pending_reviewable1.update!(status: Reviewable.statuses[:rejected])
rejected_reviewable = pending_reviewable1
list = Reviewable.recent_list_with_pending_first(admin)
expect(list.map(&:id)).to eq([
pending_reviewable2.id,
approved_reviewable1.id,
approved_reviewable2.id,
rejected_reviewable.id,
])
end
it "only includes reviewables whose score is above the minimum or are forced for review" do
SiteSetting.reviewable_default_visibility = 'high'
Reviewable.set_priorities({ high: 200 })
list = Reviewable.recent_list_with_pending_first(admin)
expect(list.map(&:id)).to eq([
approved_reviewable1.id,
approved_reviewable2.id,
])
pending_reviewable1.update!(force_review: true)
list = Reviewable.recent_list_with_pending_first(admin)
expect(list.map(&:id)).to eq([
pending_reviewable1.id,
approved_reviewable1.id,
approved_reviewable2.id,
])
end
it "accepts a limit argument to limit the number of returned records" do
expect(Reviewable.recent_list_with_pending_first(admin, limit: 2).size).to eq(2)
end
end
it "valid_types returns the appropriate types" do
expect(Reviewable.valid_type?('ReviewableUser')).to eq(true)
expect(Reviewable.valid_type?('ReviewableQueuedPost')).to eq(true)

View File

@ -254,6 +254,73 @@ RSpec.describe ReviewablesController do
end
end
describe "#user_menu_list" do
it "renders each reviewable with its basic serializers" do
reviewable_user = Fabricate(:reviewable_user, payload: { username: "someb0dy" })
reviewable_flagged_post = Fabricate(:reviewable_flagged_post)
reviewable_queued_post = Fabricate(:reviewable_queued_post)
get "/review/user-menu-list.json"
expect(response.status).to eq(200)
reviewables = response.parsed_body["reviewables"]
reviewable_queued_post_json = reviewables.find { |r| r["id"] == reviewable_queued_post.id }
expect(reviewable_queued_post_json["is_new_topic"]).to eq(false)
expect(reviewable_queued_post_json["topic_fancy_title"]).to eq(
reviewable_queued_post.topic.fancy_title
)
reviewable_flagged_post_json = reviewables.find { |r| r["id"] == reviewable_flagged_post.id }
expect(reviewable_flagged_post_json["post_number"]).to eq(
reviewable_flagged_post.post.post_number
)
expect(reviewable_flagged_post_json["topic_fancy_title"]).to eq(
reviewable_flagged_post.topic.fancy_title
)
reviewable_user_json = reviewables.find { |r| r["id"] == reviewable_user.id }
expect(reviewable_user_json["username"]).to eq("someb0dy")
end
it "returns JSON containing basic information of reviewables" do
reviewable1 = Fabricate(:reviewable)
reviewable2 = Fabricate(:reviewable, status: Reviewable.statuses[:approved])
get "/review/user-menu-list.json"
expect(response.status).to eq(200)
reviewables = response.parsed_body["reviewables"]
expect(reviewables.size).to eq(2)
expect(reviewables[0]["flagger_username"]).to eq(reviewable1.created_by.username)
expect(reviewables[0]["id"]).to eq(reviewable1.id)
expect(reviewables[0]["type"]).to eq(reviewable1.type)
expect(reviewables[0]["pending"]).to eq(true)
expect(reviewables[1]["flagger_username"]).to eq(reviewable2.created_by.username)
expect(reviewables[1]["id"]).to eq(reviewable2.id)
expect(reviewables[1]["type"]).to eq(reviewable2.type)
expect(reviewables[1]["pending"]).to eq(false)
end
it "puts pending reviewables on top" do
approved1 = Fabricate(
:reviewable,
status: Reviewable.statuses[:approved]
)
pending = Fabricate(
:reviewable,
status: Reviewable.statuses[:pending]
)
approved2 = Fabricate(
:reviewable,
status: Reviewable.statuses[:approved]
)
get "/review/user-menu-list.json"
expect(response.status).to eq(200)
reviewables = response.parsed_body["reviewables"]
expect(reviewables.map { |r| r["id"] }).to eq([pending.id, approved2.id, approved1.id])
end
end
describe "#show" do
context "basics" do
fab!(:reviewable) { Fabricate(:reviewable) }

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
describe BasicReviewableFlaggedPostSerializer do
fab!(:topic) { Fabricate(:topic, title: "safe title <a> hello world") }
fab!(:post) { Fabricate(:post, topic: topic) }
fab!(:reviewable) do
ReviewableFlaggedPost.needs_review!(target: post, topic: topic, created_by: Discourse.system_user)
end
subject { described_class.new(reviewable, root: false).as_json }
include_examples "basic reviewable attributes"
describe "#post_number" do
it "equals the post_number of the post" do
expect(subject[:post_number]).to eq(post.post_number)
end
it "is not included if the reviewable is associated with no post" do
reviewable.update!(target: nil)
expect(subject.key?(:post_number)).to eq(false)
end
end
describe "#topic_fancy_title" do
it "equals the fancy_title of the topic" do
expect(subject[:topic_fancy_title]).to eq("Safe title &lt;a&gt; hello world")
end
it "is not included if the reviewable is associated with no topic" do
reviewable.update!(topic: nil)
expect(subject.key?(:topic_fancy_title)).to eq(false)
end
end
end

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
describe BasicReviewableQueuedPostSerializer do
fab!(:topic) { Fabricate(:topic, title: "safe title <a> existing topic") }
fab!(:reviewable) do
ReviewableQueuedPost.create!(
created_by: Discourse.system_user,
topic_id: topic.id,
payload: { raw: "new post 123", title: "unsafe title <a>" }
)
end
subject { described_class.new(reviewable, root: false).as_json }
include_examples "basic reviewable attributes"
describe "#topic_fancy_title" do
it "equals the topic's fancy_title" do
expect(subject[:topic_fancy_title]).to eq("Safe title &lt;a&gt; existing topic")
end
it "is not included if the reviewable is associated with no topic" do
reviewable.update!(topic: nil)
expect(subject.key?(:topic_fancy_title)).to eq(false)
end
end
describe "#is_new_topic" do
it "is true if the reviewable's payload has a title attribute" do
expect(subject[:is_new_topic]).to eq(true)
end
it "is false if the reviewable's payload doesn't have a title attribute" do
reviewable.update!(payload: { raw: "new post 123" })
expect(subject[:is_new_topic]).to eq(false)
end
end
describe "#payload_title" do
it "equals the title in the reviewable's payload" do
expect(subject[:payload_title]).to eq("unsafe title <a>")
end
it "is not included if the reviewable's payload doesn't have a title attribute" do
reviewable.update!(payload: { raw: "new post 123" })
expect(subject.key?(:payload_title)).to eq(false)
end
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
describe BasicReviewableSerializer do
fab!(:reviewable) { Fabricate(:reviewable) }
subject { described_class.new(reviewable, root: false).as_json }
include_examples "basic reviewable attributes"
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
describe BasicReviewableUserSerializer do
fab!(:user) { Fabricate(:user) }
fab!(:reviewable) do
ReviewableUser.needs_review!(
target: user,
created_by: Discourse.system_user,
payload: {
username: user.username,
name: user.name,
email: user.email,
bio: "blah whatever",
website: "ff.website.com"
}
)
end
subject { described_class.new(reviewable, root: false).as_json }
include_examples "basic reviewable attributes"
describe "#username" do
it "equals the username in the reviewable's payload" do
expect(subject[:username]).to eq(user.username)
end
end
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
shared_examples "basic reviewable attributes" do
describe "#id" do
it "equals the reviewable's id" do
expect(subject[:id]).to eq(reviewable.id)
end
end
describe "#type" do
it "is the reviewable's type" do
expect(subject[:type]).to eq(reviewable.type)
end
end
describe "#pending" do
it "is false if the reviewable is approved" do
reviewable.update!(status: Reviewable.statuses[:approved])
expect(subject[:pending]).to eq(false)
end
it "is false if the reviewable is rejected" do
reviewable.update!(status: Reviewable.statuses[:rejected])
expect(subject[:pending]).to eq(false)
end
it "is true if the reviewable is pending" do
reviewable.update!(status: Reviewable.statuses[:pending])
expect(subject[:pending]).to eq(true)
end
end
describe "#flagger_username" do
it "equals to the username of the user who created the reviewable" do
reviewable.update!(
created_by: Fabricate(:user, username: "gg.osama")
)
expect(subject[:flagger_username]).to eq("gg.osama")
end
end
end