FEATURE: Display pending posts on user’s page

Currently when a user creates posts that are moderated (for whatever
reason), a popup is displayed saying the post needs approval and the
total number of the user’s pending posts. But then this piece of
information is kind of lost and there is nowhere for the user to know
what are their pending posts or how many there are.

This patch solves this issue by adding a new “Pending” section to the
user’s activity page when there are some pending posts to display. When
there are none, then the “Pending” section isn’t displayed at all.
This commit is contained in:
Loïc Guitaut 2021-08-26 18:16:00 +02:00 committed by Loïc Guitaut
parent 6e603799eb
commit a5fbb90df4
49 changed files with 737 additions and 110 deletions

View File

@ -0,0 +1,8 @@
import Category from "discourse/models/category";
import { computed, get } from "@ember/object";
export default function categoryFromId(property) {
return computed(property, function () {
return Category.findById(get(this, property));
});
}

View File

@ -0,0 +1,9 @@
import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({
jsonMode: true,
pathFor(_store, _type, params) {
return `/posts/${params.username}/pending.json`;
},
});

View File

@ -0,0 +1,29 @@
import Component from "@ember/component";
import { afterRender } from "discourse-common/utils/decorators";
import { loadOneboxes } from "discourse/lib/load-oneboxes";
import { ajax } from "discourse/lib/ajax";
import { resolveAllShortUrls } from "pretty-text/upload-short-url";
export default Component.extend({
didRender() {
this._loadOneboxes();
this._resolveUrls();
},
@afterRender
_loadOneboxes() {
loadOneboxes(
this.element,
ajax,
this.post.topic_id,
this.post.category_id,
this.siteSettings.max_oneboxes_per_post,
true
);
},
@afterRender
_resolveUrls() {
resolveAllShortUrls(ajax, this.siteSettings, this.element, this.opts);
},
});

View File

@ -35,6 +35,13 @@ export default Controller.extend({
: I18n.t("drafts.label");
},
@discourseComputed("model.pending_posts_count")
pendingLabel(count) {
return count > 0
? I18n.t("pending_posts.label_with_count", { count })
: I18n.t("pending_posts.label");
},
actions: {
exportUserArchive() {
bootbox.confirm(

View File

@ -1,4 +1,4 @@
import Category from "discourse/models/category";
import categoryFromId from "discourse-common/utils/category-macro";
import I18n from "I18n";
import { Promise } from "rsvp";
import RestModel from "discourse/models/rest";
@ -119,10 +119,7 @@ const Bookmark = RestModel.extend({
return newTags;
},
@discourseComputed("category_id")
category(categoryId) {
return Category.findById(categoryId);
},
category: categoryFromId("category_id"),
@discourseComputed("reminder_at", "currentUser")
formattedReminder(bookmarkReminderAt, currentUser) {

View File

@ -0,0 +1,28 @@
import discourseComputed from "discourse-common/utils/decorators";
import RestModel from "discourse/models/rest";
import categoryFromId from "discourse-common/utils/category-macro";
import { userPath } from "discourse/lib/url";
import { reads } from "@ember/object/computed";
import { cookAsync } from "discourse/lib/text";
const PendingPost = RestModel.extend({
expandedExcerpt: null,
postUrl: reads("topic_url"),
truncated: false,
init() {
this._super(...arguments);
cookAsync(this.raw_text).then((cooked) => {
this.set("expandedExcerpt", cooked);
});
},
@discourseComputed("username")
userUrl(username) {
return userPath(username.toLowerCase());
},
category: categoryFromId("category_id"),
});
export default PendingPost;

View File

@ -1,4 +1,4 @@
import Category from "discourse/models/category";
import categoryFromId from "discourse-common/utils/category-macro";
import I18n from "I18n";
import { Promise } from "rsvp";
import RestModel from "discourse/models/rest";
@ -24,10 +24,7 @@ const Reviewable = RestModel.extend({
});
},
@discourseComputed("category_id")
category(categoryId) {
return Category.findById(categoryId);
},
category: categoryFromId("category_id"),
update(updates) {
// If no changes, do nothing

View File

@ -1,7 +1,7 @@
import { alias, and, equal, notEmpty, or } from "@ember/object/computed";
import { fmt, propertyEqual } from "discourse/lib/computed";
import ActionSummary from "discourse/models/action-summary";
import Category from "discourse/models/category";
import categoryFromId from "discourse-common/utils/category-macro";
import Bookmark from "discourse/models/bookmark";
import EmberObject from "@ember/object";
import I18n from "I18n";
@ -209,10 +209,7 @@ const Topic = RestModel.extend({
return { type: "topic", id };
},
@discourseComputed("category_id")
category(categoryId) {
return Category.findById(categoryId);
},
category: categoryFromId("category_id"),
@discourseComputed("url")
shareUrl(url) {

View File

@ -1,6 +1,6 @@
import { and, equal, or } from "@ember/object/computed";
import discourseComputed, { on } from "discourse-common/utils/decorators";
import Category from "discourse/models/category";
import discourseComputed from "discourse-common/utils/decorators";
import categoryFromId from "discourse-common/utils/category-macro";
import RestModel from "discourse/models/rest";
import User from "discourse/models/user";
import UserActionGroup from "discourse/models/user-action-group";
@ -19,7 +19,6 @@ const UserActionTypes = {
edits: 11,
messages_sent: 12,
messages_received: 13,
pending: 14,
};
const InvertedActionTypes = {};
@ -28,13 +27,7 @@ Object.keys(UserActionTypes).forEach(
);
const UserAction = RestModel.extend({
@on("init")
_attachCategory() {
const categoryId = this.category_id;
if (categoryId) {
this.set("category", Category.findById(categoryId));
}
},
category: categoryFromId("category_id"),
@discourseComputed("action_type")
descriptionKey(action) {

View File

@ -1,6 +1,36 @@
import UserAction from "discourse/models/user-action";
import UserActivityStreamRoute from "discourse/routes/user-activity-stream";
import DiscourseRoute from "discourse/routes/discourse";
export default UserActivityStreamRoute.extend({
userActionType: UserAction.TYPES.pending,
export default DiscourseRoute.extend({
beforeModel() {
this.username = this.modelFor("user").username_lower;
},
model() {
return this.store.findAll("pending-post", {
username: this.username,
});
},
activate() {
this.appEvents.on(
`count-updated:${this.username}:pending_posts_count`,
this,
"_handleCountChange"
);
},
deactivate() {
this.appEvents.off(
`count-updated:${this.username}:pending_posts_count`,
this,
"_handleCountChange"
);
},
_handleCountChange(count) {
this.refresh();
if (count <= 0) {
this.transitionTo("userActivity");
}
},
});

View File

@ -69,6 +69,15 @@ export default DiscourseRoute.extend({
this.messageBus.subscribe(`/u/${user.username_lower}`, (data) =>
user.loadUserAction(data)
);
this.messageBus.subscribe(`/u/${user.username_lower}/counters`, (data) => {
user.setProperties(data);
Object.entries(data).forEach(([key, value]) =>
this.appEvents.trigger(
`count-updated:${user.username_lower}:${key}`,
value
)
);
});
},
deactivate() {
@ -76,6 +85,7 @@ export default DiscourseRoute.extend({
const user = this.modelFor("user");
this.messageBus.unsubscribe(`/u/${user.username_lower}`);
this.messageBus.unsubscribe(`/u/${user.username_lower}/counters`);
// Remove the search context
this.searchService.set("searchContext", null);

View File

@ -0,0 +1,6 @@
<div class="empty-state">
<span data-test-title class="empty-state-title">{{@title}}</span>
<div class="empty-state-body">
<p data-test-body>{{@body}}</p>
</div>
</div>

View File

@ -0,0 +1 @@
<UserStreamItem @item={{@post}} />

View File

@ -0,0 +1,5 @@
<ul class="user-stream">
{{#each @model as |pending_post|}}
<PendingPost @post={{pending_post}} />
{{/each}}
</ul>

View File

@ -1,10 +1,8 @@
{{#if noContent}}
<div class="empty-state">
<span class="empty-state-title">{{model.emptyState.title}}</span>
<div class="empty-state-body">
<p>{{model.emptyState.body}}</p>
</div>
</div>
<EmptyState
@title={{model.emptyState.title}}
@body={{model.emptyState.body}}
/>
{{else}}
{{#load-more class="paginated-topics-list" selector=".paginated-topics-list .topic-list .topic-list-item" action=(action "loadMore")}}
{{topic-dismiss-buttons

View File

@ -17,6 +17,12 @@
{{/d-navigation-item}}
{{/if}}
{{#if (gt model.pending_posts_count 0)}}
{{#d-navigation-item route="userActivity.pending"}}
{{pendingLabel}}
{{/d-navigation-item}}
{{/if}}
{{#d-navigation-item route="userActivity.likesGiven"}}{{i18n "user_action_groups.1"}}{{/d-navigation-item}}
{{#if user.showBookmarks}}

View File

@ -2,12 +2,10 @@
{{#if permissionDenied}}
<div class="alert alert-info">{{i18n "bookmarks.list_permission_denied"}}</div>
{{else if userDoesNotHaveBookmarks}}
<div class="empty-state">
<span class="empty-state-title">{{i18n "user.no_bookmarks_title"}}</span>
<div class="empty-state-body">
<p>{{emptyStateBody}}</p>
</div>
</div>
<EmptyState
@title={{i18n "user.no_bookmarks_title"}}
@body={{emptyStateBody}}
/>
{{else}}
<div class="inline-form full-width bookmark-search-form">
{{input type="text"

View File

@ -7,12 +7,10 @@
{{/if}}
</div>
{{else if doesNotHaveNotifications}}
<div class="empty-state">
<span class="empty-state-title">{{i18n "user.no_notifications_page_title"}}</span>
<div class="empty-state-body">
<p>{{emptyStateBody}}</p>
</div>
</div>
<EmptyState
@title={{i18n "user.no_notifications_page_title"}}
@body={{emptyStateBody}}
/>
{{else}}
<div class="user-notifications-filter">
{{notifications-filter value=filter onChange=(action (mut filter))}}

View File

@ -2,12 +2,10 @@
{{#if model.isAnotherUsersPage}}
<div class="alert alert-info">{{model.emptyStateOthers}}</div>
{{else}}
<div class="empty-state">
<span class="empty-state-title">{{model.emptyState.title}}</span>
<div class="empty-state-body">
<p>{{model.emptyState.body}}</p>
</div>
</div>
<EmptyState
@title={{model.emptyState.title}}
@body={{model.emptyState.body}}
/>
{{/if}}
{{/if}}
{{user-stream stream=model.stream}}

View File

@ -80,6 +80,7 @@
]
},
"devDependencies": {
"ember-exam": "6.1.0"
"ember-exam": "6.1.0",
"ember-test-selectors": "^6.0.0"
}
}

View File

@ -0,0 +1,31 @@
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { click, visit } from "@ember/test-helpers";
import { setupApplicationTest as EMBER_CLI_ENV } from "ember-qunit";
acceptance("Pending posts - no existing pending posts", function (needs) {
if (!EMBER_CLI_ENV) {
return; // dom helpers not available in legacy env
}
needs.user();
test("No link to pending posts", async function (assert) {
await visit("/u/eviltrout");
assert.dom(".action-list").doesNotIncludeText("Pending");
});
});
acceptance("Pending posts - existing pending posts", function (needs) {
if (!EMBER_CLI_ENV) {
return; // dom helpers not available in legacy env
}
needs.user({ pending_posts_count: 2 });
test("Navigate to pending posts", async function (assert) {
await visit("/u/eviltrout");
await click("[href='/u/eviltrout/activity/pending']");
assert.dom(".user-stream-item").exists({ count: 2 });
});
});

View File

@ -0,0 +1,32 @@
export default {
"/posts/eviltrout/pending.json": {
pending_posts: [
{
id: 2,
avatar_template: "/user_avatar/localhost/eviltrout/{size}/5275.png",
category_id: 2,
created_at: "2021-10-19T10:18:13.238Z",
created_by_id: 19,
name: "Robin Ward",
raw_text: "**bold text**",
title: "Lorem ipsum dolor sit amet",
topic_id: 130,
topic_url: "/t/lorem-ipsum-dolor-sit-amet/130",
username: "eviltrout"
},
{
id: 1,
avatar_template: "/user_avatar/localhost/eviltrout/{size}/5275.png",
category_id: 2,
created_at: "2021-10-19T09:38:35.110Z",
created_by_id: 19,
name: "Robin Ward",
raw_text: "This will be moderated in theory :thinking:",
title: "Lorem ipsum dolor sit amet",
topic_id: 130,
topic_url: "/t/lorem-ipsum-dolor-sit-amet/130",
username: "eviltrout"
},
]
}
};

View File

@ -0,0 +1,23 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "ember-qunit";
import { render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
const LEGACY_ENV = !setupRenderingTest;
module("Integration | Component | empty-state", function (hooks) {
if (LEGACY_ENV) {
return;
}
setupRenderingTest(hooks);
test("it renders", async function (assert) {
await render(hbs`
<EmptyState @title="title" @body="body" />
`);
assert.dom("[data-test-title]").hasText("title");
assert.dom("[data-test-body]").hasText("body");
});
});

View File

@ -0,0 +1,37 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "ember-qunit";
import { render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import PendingPost from "discourse/models/pending-post";
import createStore from "discourse/tests/helpers/create-store";
const LEGACY_ENV = !setupRenderingTest;
module("Integration | Component | pending-post", function (hooks) {
if (LEGACY_ENV) {
return;
}
setupRenderingTest(hooks);
test("it renders", async function (assert) {
const store = createStore();
store.createRecord("category", { id: 2 });
const post = PendingPost.create({
id: 1,
topic_url: "topic-url",
username: "USERNAME",
category_id: 2,
raw_text: "**bold text**",
});
this.set("post", post);
await render(hbs`<PendingPost @post={{this.post}}/>`);
assert.equal(
this.element.querySelector("p.excerpt").textContent.trim(),
"bold text",
"renders the cooked text"
);
});
});

View File

@ -2,6 +2,8 @@ import config from "../config/environment";
import { setEnvironment } from "discourse-common/config/environment";
import { start } from "ember-qunit";
import loadEmberExam from "ember-exam/test-support/load";
import * as QUnit from "qunit";
import { setup } from "qunit-dom";
setEnvironment("testing");
@ -20,6 +22,7 @@ document.addEventListener("discourse-booted", () => {
`
);
setup(QUnit.assert);
setupTests(config.APP);
let loader = loadEmberExam();
loader.loadModules();

View File

@ -0,0 +1,36 @@
import { module, test } from "qunit";
import PendingPost from "discourse/models/pending-post";
import createStore from "discourse/tests/helpers/create-store";
import { run } from "@ember/runloop";
module("Unit | Model | pending-post", function () {
test("Properties", function (assert) {
const store = createStore();
const category = store.createRecord("category", { id: 2 });
const post = PendingPost.create({
id: 1,
topic_url: "topic-url",
username: "USERNAME",
category_id: 2,
});
assert.equal(post.postUrl, "topic-url", "topic_url is aliased to postUrl");
assert.equal(post.truncated, false, "truncated is always false");
assert.equal(
post.userUrl,
"/u/username",
"it returns user URL from the username"
);
assert.strictEqual(
post.category,
category,
"it returns the proper category object based on category_id"
);
});
test("it cooks raw_text", function (assert) {
const post = run(() => PendingPost.create({ raw_text: "**bold text**" }));
assert.equal(
post.expandedExcerpt.string,
"<p><strong>bold text</strong></p>"
);
});
});

View File

@ -4862,7 +4862,7 @@ ember-cli-babel@^7.0.0, ember-cli-babel@^7.11.0, ember-cli-babel@^7.11.1, ember-
rimraf "^3.0.1"
semver "^5.5.0"
ember-cli-babel@^7.21.0:
ember-cli-babel@^7.21.0, ember-cli-babel@^7.26.4:
version "7.26.6"
resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.26.6.tgz#322fbbd3baad9dd99e3276ff05bc6faef5e54b39"
integrity sha512-040svtfj2RC35j/WMwdWJFusZaXmNoytLAMyBDGLMSlRvznudTxZjGlPV6UupmtTBApy58cEF8Fq4a+COWoEmQ==
@ -5414,6 +5414,15 @@ ember-template-lint@^1.2.0:
resolve "^1.15.1"
strip-bom "^3.0.0"
ember-test-selectors@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/ember-test-selectors/-/ember-test-selectors-6.0.0.tgz#ba9bb19550d9dec6e4037d86d2b13c2cfd329341"
integrity sha512-PgYcI9PeNvtKaF0QncxfbS68olMYM1idwuI8v/WxsjOGqUx5bmsu6V17vy/d9hX4mwmjgsBhEghrVasGSuaIgw==
dependencies:
calculate-cache-key-for-tree "^2.0.0"
ember-cli-babel "^7.26.4"
ember-cli-version-checker "^5.1.2"
ember-try-config@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/ember-try-config/-/ember-try-config-3.0.0.tgz#012d8c90cae9eb624e2b62040bf7e76a1aa58edc"

View File

@ -598,6 +598,14 @@ class PostsController < ApplicationController
render_serialized(posts, AdminUserActionSerializer)
end
def pending
params.require(:username)
user = fetch_user_from_params
raise Discourse::NotFound unless guardian.can_edit_user?(user)
render_serialized(user.pending_posts.order(created_at: :desc), PendingPostSerializer, root: :pending_posts)
end
protected
# We can't break the API for making posts. The new, queue supporting API

View File

@ -7,6 +7,8 @@ class ReviewableQueuedPost < Reviewable
DiscourseEvent.trigger(:queued_post_created, self)
end
after_commit :compute_user_stats, only: %i[create update]
def build_actions(actions, guardian, args)
unless approved?
@ -157,6 +159,16 @@ class ReviewableQueuedPost < Reviewable
delete_as_spammer: true
}
end
def compute_user_stats
return unless status_changed_from_or_to_pending?
created_by.user_stat.update_pending_posts
end
def status_changed_from_or_to_pending?
saved_change_to_id?(from: nil) && pending? ||
saved_change_to_status?(from: self.class.statuses[:pending])
end
end
# == Schema Information

View File

@ -38,6 +38,7 @@ class User < ActiveRecord::Base
has_many :reviewable_scores, dependent: :destroy
has_many :invites, foreign_key: :invited_by_id, dependent: :destroy
has_many :user_custom_fields, dependent: :destroy
has_many :pending_posts, -> { merge(Reviewable.pending) }, class_name: 'ReviewableQueuedPost', foreign_key: :created_by_id
has_one :user_option, dependent: :destroy
has_one :user_avatar, dependent: :destroy

View File

@ -291,6 +291,16 @@ class UserStat < ActiveRecord::Base
Discourse.redis.setex(last_seen_key(id), MAX_TIME_READ_DIFF, val)
end
def update_pending_posts
update(pending_posts_count: user.pending_posts.count)
MessageBus.publish(
"/u/#{user.username_lower}/counters",
{ pending_posts_count: pending_posts_count },
user_ids: [user.id],
group_ids: [Group::AUTO_GROUPS[:staff]]
)
end
protected
def trigger_badges
@ -323,5 +333,7 @@ end
# distinct_badge_count :integer default(0), not null
# first_unread_pm_at :datetime not null
# digest_attempted_at :datetime
# draft_count :integer default(0), not null
# post_edits_count :integer
# draft_count :integer default(0), not null
# pending_posts_count :integer default(0), not null
#

View File

@ -41,7 +41,7 @@ class CurrentUserSerializer < BasicUserSerializer
:dismissed_banner_key,
:is_anonymous,
:reviewable_count,
:read_faq,
:read_faq?,
:automatically_unpin_topics,
:mailing_list_mode,
:treat_as_new_topic_start_date,
@ -67,6 +67,10 @@ class CurrentUserSerializer < BasicUserSerializer
:can_review,
:draft_count,
:default_calendar,
:pending_posts_count
delegate :user_stat, to: :object, private: true
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat
def groups
owned_group_ids = GroupUser.where(user_id: id, owner: true).pluck(:group_id).to_set
@ -93,14 +97,6 @@ class CurrentUserSerializer < BasicUserSerializer
scope.can_create_group?
end
def read_faq
object.user_stat.read_faq?
end
def any_posts
object.user_stat.any_posts
end
def hide_profile_and_presence
object.user_option.hide_profile_and_presence
end
@ -321,8 +317,4 @@ class CurrentUserSerializer < BasicUserSerializer
def include_has_topic_draft?
Draft.has_topic_draft(object)
end
def draft_count
object.user_stat.draft_count
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class PendingPostSerializer < ApplicationSerializer
attributes :id,
:avatar_template,
:category_id,
:created_at,
:created_by_id,
:name,
:raw_text,
:title,
:topic_id,
:topic_url,
:username
delegate :created_by, :payload, :topic, to: :object, private: true
delegate :url, to: :topic, prefix: true, allow_nil: true
delegate :avatar_template, :name, :username, to: :created_by, allow_nil: true
def raw_text
payload["raw"]
end
def title
payload["title"] || topic.title
end
end

View File

@ -65,7 +65,8 @@ class UserCardSerializer < BasicUserSerializer
:flair_bg_color,
:flair_color,
:featured_topic,
:timezone
:timezone,
:pending_posts_count
untrusted_attributes :bio_excerpt,
:website,
@ -77,6 +78,13 @@ class UserCardSerializer < BasicUserSerializer
has_many :featured_user_badges, embed: :ids, serializer: UserBadgeSerializer, root: :user_badges
delegate :user_stat, to: :object, private: true
delegate :pending_posts_count, to: :user_stat
def include_pending_posts_count?
scope.is_me?(object) || scope.is_staff?
end
def include_email?
(object.id && object.id == scope.user.try(:id)) ||
(scope.is_staff? && object.staged?)

View File

@ -36,6 +36,7 @@ class WebHookUserSerializer < UserSerializer
user_auth_tokens
user_auth_token_logs
use_logo_small_as_avatar
pending_posts_count
}.each do |attr|
define_method("include_#{attr}?") do
false

View File

@ -3514,6 +3514,9 @@ en:
title: "This topic is a personal message"
help: "This topic is a personal message"
posts: "Posts"
pending_posts:
label: "Pending"
label_with_count: "Pending (%{count})"
# keys ending with _MF use message format, see https://meta.discourse.org/t/message-format-support-for-localization/7035 for details
posts_likes_MF: |

View File

@ -579,6 +579,7 @@ Discourse::Application.routes.draw do
get "posts/:id/reply-ids/all" => "posts#all_reply_ids"
get "posts/:username/deleted" => "posts#deleted_posts", constraints: { username: RouteFormat.username }
get "posts/:username/flagged" => "posts#flagged_posts", constraints: { username: RouteFormat.username }
get "posts/:username/pending" => "posts#pending", constraints: { username: RouteFormat.username }
%w{groups g}.each do |root_path|
resources :groups, id: RouteFormat.username, path: root_path do

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddPendingPostsCountToUserStats < ActiveRecord::Migration[6.1]
def change
add_column :user_stats, :pending_posts_count, :integer, null: false, default: 0
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class PopulatePendingPostsCountColumn < ActiveRecord::Migration[6.1]
def up
execute <<~SQL
WITH to_update AS (
SELECT COUNT(id) AS posts, created_by_id
FROM reviewables
WHERE type = 'ReviewableQueuedPost'
AND status = #{ReviewableQueuedPost.statuses[:pending]}
GROUP BY created_by_id
)
UPDATE user_stats
SET pending_posts_count = to_update.posts
FROM to_update
WHERE to_update.created_by_id = user_stats.user_id
SQL
end
def down
end
end

View File

@ -550,6 +550,10 @@ class Guardian
@user.has_trust_level_or_staff?(SiteSetting.min_trust_level_for_here_mention)
end
def is_me?(other)
other && authenticated? && other.is_a?(User) && @user == other
end
private
def is_my_own?(obj)
@ -562,10 +566,6 @@ class Guardian
false
end
def is_me?(other)
other && authenticated? && other.is_a?(User) && @user == other
end
def is_not_me?(other)
@user.blank? || !is_me?(other)
end

View File

@ -55,6 +55,7 @@
"pretender": "^3.4.7",
"puppeteer": "1.20",
"qunit": "2.8.0",
"qunit-dom": "^2.0.0",
"route-recognizer": "^0.3.3",
"sinon": "^9.0.2",
"squoosh": "discourse/squoosh#dc9649d"

View File

@ -196,4 +196,36 @@ RSpec.describe ReviewableQueuedPost, type: :model do
expect(Post.count).to eq(post_count)
end
end
describe "Callbacks" do
context "when creating a new pending reviewable" do
let(:reviewable) { Fabricate.build(:reviewable_queued_post_topic, category: category, created_by: user) }
let(:user) { Fabricate(:user) }
let(:user_stats) { user.user_stat }
it "updates user stats" do
user_stats.expects(:update_pending_posts)
reviewable.save!
end
end
context "when updating an existing reviewable" do
let!(:reviewable) { Fabricate(:reviewable_queued_post_topic, category: category) }
let(:user_stats) { reviewable.created_by.user_stat }
context "when status changes from 'pending' to something else" do
it "updates user stats" do
user_stats.expects(:update_pending_posts)
reviewable.update!(status: described_class.statuses[:approved])
end
end
context "when status doesnt change" do
it "doesnt update user stats" do
user_stats.expects(:update_pending_posts).never
reviewable.update!(score: 10)
end
end
end
end
end

View File

@ -9,6 +9,8 @@ describe User do
I18n.t(:"activerecord.errors.models.user.attributes.#{keys.join('.')}")
end
it { is_expected.to have_many(:pending_posts).class_name('ReviewableQueuedPost').with_foreign_key(:created_by_id) }
context 'validations' do
describe '#username' do
it { is_expected.to validate_presence_of :username }

View File

@ -261,4 +261,27 @@ describe UserStat do
expect(user.user_stat.draft_count).to eq(3)
end
end
describe "#update_pending_posts" do
subject(:update_pending_posts) { stat.update_pending_posts }
let!(:reviewable) { Fabricate(:reviewable_queued_post) }
let(:user) { reviewable.created_by }
let(:stat) { user.user_stat }
before do
stat.update!(pending_posts_count: 0) # the reviewable callback will have set this to 1 already.
end
it "sets 'pending_posts_count'" do
expect { update_pending_posts }.to change { stat.pending_posts_count }.to 1
end
it "publishes a message to clients" do
MessageBus.expects(:publish).with("/u/#{user.username_lower}/counters",
{ pending_posts_count: 1 },
user_ids: [user.id], group_ids: [Group::AUTO_GROUPS[:staff]])
update_pending_posts
end
end
end

View File

@ -206,6 +206,9 @@
"pending_count": {
"type": "integer"
},
"pending_posts_count": {
"type": "integer"
},
"profile_view_count": {
"type": "integer"
},

View File

@ -2156,6 +2156,101 @@ describe PostsController do
end
end
describe "#pending" do
subject(:request) { get "/posts/#{user.username}/pending.json" }
context "when user is not logged in" do
it_behaves_like "action requires login", :get, "/posts/system/pending.json"
end
context "when user is logged in" do
let(:pending_posts) { response.parsed_body["pending_posts"] }
before { sign_in(current_user) }
context "when current user is the same as user" do
let(:current_user) { user }
context "when there are existing pending posts" do
let!(:owner_pending_posts) { Fabricate.times(2, :reviewable_queued_post, created_by: user) }
let!(:other_pending_post) { Fabricate(:reviewable_queued_post) }
let(:expected_keys) do
%w[
avatar_template
category_id
created_at
created_by_id
name
raw_text
title
topic_id
topic_url
username
]
end
it "returns user's pending posts" do
request
expect(pending_posts).to all include "id" => be_in(owner_pending_posts.map(&:id))
expect(pending_posts).to all include(*expected_keys)
end
end
context "when there aren't any pending posts" do
it "returns an empty array" do
request
expect(pending_posts).to be_empty
end
end
end
context "when current user is a staff member" do
let(:current_user) { moderator }
context "when there are existing pending posts" do
let!(:owner_pending_posts) { Fabricate.times(2, :reviewable_queued_post, created_by: user) }
let!(:other_pending_post) { Fabricate(:reviewable_queued_post) }
let(:expected_keys) do
%w[
avatar_template
category_id
created_at
created_by_id
name
raw_text
title
topic_id
topic_url
username
]
end
it "returns user's pending posts" do
request
expect(pending_posts).to all include "id" => be_in(owner_pending_posts.map(&:id))
expect(pending_posts).to all include(*expected_keys)
end
end
context "when there aren't any pending posts" do
it "returns an empty array" do
request
expect(pending_posts).to be_empty
end
end
end
context "when current user is another user" do
let(:current_user) { Fabricate(:user) }
it "does not allow access" do
request
expect(response).to have_http_status :not_found
end
end
end
end
describe Plugin::Instance do
describe '#add_permitted_post_create_param' do
fab!(:user) { Fabricate(:user) }

View File

@ -3,11 +3,12 @@
require 'rails_helper'
RSpec.describe CurrentUserSerializer do
subject(:serializer) { described_class.new(user, scope: guardian, root: false) }
let(:guardian) { Guardian.new }
context "when SSO is not enabled" do
fab!(:user) { Fabricate(:user) }
let :serializer do
CurrentUserSerializer.new(user, scope: Guardian.new, root: false)
end
it "should not include the external_id field" do
payload = serializer.as_json
@ -22,10 +23,6 @@ RSpec.describe CurrentUserSerializer do
user
end
let :serializer do
CurrentUserSerializer.new(user, scope: Guardian.new, root: false)
end
it "should include the external_id" do
SiteSetting.discourse_connect_url = "http://example.com/discourse_sso"
SiteSetting.discourse_connect_secret = "12345678910"
@ -40,9 +37,6 @@ RSpec.describe CurrentUserSerializer do
fab!(:category1) { Fabricate(:category) }
fab!(:category2) { Fabricate(:category) }
fab!(:category3) { Fabricate(:category) }
let :serializer do
CurrentUserSerializer.new(user, scope: Guardian.new, root: false)
end
it "should include empty top_category_ids array" do
payload = serializer.as_json
@ -77,9 +71,6 @@ RSpec.describe CurrentUserSerializer do
tag_id: tag.id
)
end
let :serializer do
CurrentUserSerializer.new(user, scope: Guardian.new, root: false)
end
it 'include muted tag ids' do
payload = serializer.as_json
@ -89,9 +80,7 @@ RSpec.describe CurrentUserSerializer do
context "#second_factor_enabled" do
fab!(:user) { Fabricate(:user) }
let :serializer do
CurrentUserSerializer.new(user, scope: Guardian.new(user), root: false)
end
let(:guardian) { Guardian.new(user) }
let(:json) { serializer.as_json }
it "is false by default" do
@ -120,18 +109,15 @@ RSpec.describe CurrentUserSerializer do
end
context "#groups" do
fab!(:member) { Fabricate(:user) }
let :serializer do
CurrentUserSerializer.new(member, scope: Guardian.new, root: false)
end
fab!(:user) { Fabricate(:user) }
it "should only show visible groups" do
Fabricate.build(:group, visibility_level: Group.visibility_levels[:public])
hidden_group = Fabricate.build(:group, visibility_level: Group.visibility_levels[:owners])
public_group = Fabricate.build(:group, visibility_level: Group.visibility_levels[:public], name: "UppercaseGroupName")
hidden_group.add(member)
hidden_group.add(user)
hidden_group.save!
public_group.add(member)
public_group.add(user)
public_group.save!
payload = serializer.as_json
@ -143,9 +129,6 @@ RSpec.describe CurrentUserSerializer do
context "#has_topic_draft" do
fab!(:user) { Fabricate(:user) }
let :serializer do
CurrentUserSerializer.new(user, scope: Guardian.new, root: false)
end
it "is not included by default" do
payload = serializer.as_json
@ -169,23 +152,36 @@ RSpec.describe CurrentUserSerializer do
end
context '#can_review' do
it 'return false for regular users' do
serializer = serializer(Fabricate(:user))
payload = serializer.as_json
context "#can_review" do
let(:guardian) { Guardian.new(user) }
let(:payload) { serializer.as_json }
context "when user is a regular one" do
let(:user) { Fabricate(:user) }
it "return false for regular users" do
expect(payload[:can_review]).to eq(false)
end
end
it 'returns trus for staff' do
serializer = serializer(Fabricate(:admin))
payload = serializer.as_json
context "when user is a staff member" do
let(:user) { Fabricate(:admin) }
it "returns true" do
expect(payload[:can_review]).to eq(true)
end
end
end
def serializer(user)
CurrentUserSerializer.new(user, scope: Guardian.new(user), root: false)
describe "#pending_posts_count" do
subject(:pending_posts_count) { serializer.pending_posts_count }
let(:user) { Fabricate(:user) }
before { user.user_stat.pending_posts_count = 3 }
it "serializes 'pending_posts_count'" do
expect(pending_posts_count).to eq 3
end
end
end

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe PendingPostSerializer do
subject(:serializer) { described_class.new(post, scope: guardian, root: false) }
let(:guardian) { Guardian.new(author) }
let(:author) { post.created_by }
before { freeze_time }
context "when creating a new topic" do
let(:post) { Fabricate(:reviewable_queued_post_topic) }
let(:expected_attributes) do
{
id: post.id,
avatar_template: author.avatar_template,
category_id: post.category_id,
created_at: Time.current,
created_by_id: author.id,
name: author.name,
raw_text: post.payload["raw"],
title: post.payload["title"],
topic_id: nil,
topic_url: nil,
username: author.username
}
end
it "serializes a pending post properly" do
expect(serializer.as_json).to match expected_attributes
end
end
context "when not creating a new topic" do
let(:post) { Fabricate(:reviewable_queued_post) }
let(:topic) { post.topic }
let(:expected_attributes) do
{
id: post.id,
avatar_template: author.avatar_template,
category_id: post.category_id,
created_at: Time.current,
created_by_id: author.id,
name: author.name,
raw_text: post.payload["raw"],
title: topic.title,
topic_id: topic.id,
topic_url: topic.url,
username: author.username
}
end
it "serializes a pending post properly" do
expect(serializer.as_json).to match expected_attributes
end
end
end

View File

@ -37,4 +37,39 @@ describe UserCardSerializer do
expect(json[:unconfirmed_emails]).to be_nil
end
end
describe "#pending_posts_count" do
let(:user) { Fabricate(:user) }
let(:serializer) { described_class.new(user, scope: guardian, root: false) }
let(:json) { serializer.as_json }
context "when guardian is another user" do
let(:guardian) { Guardian.new(other_user) }
context "when other user is not a staff member" do
let(:other_user) { Fabricate(:user) }
it "does not serialize pending_posts_count" do
expect(json.keys).not_to include :pending_posts_count
end
end
context "when other user is a staff member" do
let(:other_user) { Fabricate(:user, moderator: true) }
it "serializes pending_posts_count" do
expect(json[:pending_posts_count]).to eq 0
end
end
end
context "when guardian is the current user" do
let(:guardian) { Guardian.new(user) }
it "serializes pending_posts_count" do
expect(json[:pending_posts_count]).to eq 0
end
end
end
end