Implement "renamed" posts

Record when the discussion was renamed, from what, and by whom.
Information is stored in the `content` field as a serialised JSON
object because proper polymorphism will be too difficult with Ember
Data and especially when extensions try to add new post types.
This commit is contained in:
Toby Zerner 2015-02-13 10:23:38 +10:30
parent fa3523ac74
commit 011ae3603e
19 changed files with 225 additions and 90 deletions

View File

@ -0,0 +1,48 @@
import Ember from 'ember';
import FadeIn from 'flarum/mixins/fade-in';
import HasItemLists from 'flarum/mixins/has-item-lists';
var precompileTemplate = Ember.Handlebars.compile;
/**
Component for a `renamed`-typed post.
*/
export default Ember.Component.extend(FadeIn, HasItemLists, {
layoutName: 'components/discussion/post-renamed',
tagName: 'article',
classNames: ['post', 'post-renamed', 'post-activity'],
itemLists: ['controls'],
// The stream-content component instansiates this component and sets the
// `content` property to the content of the item in the post-stream object.
// This happens to be our post model!
post: Ember.computed.alias('content'),
decodedContent: Ember.computed('post.content', function() {
return JSON.parse(this.get('post.content'));
}),
oldTitle: Ember.computed.alias('decodedContent.0'),
newTitle: Ember.computed.alias('decodedContent.1'),
populateControls: function(items) {
this.addActionItem(items, 'delete', 'Delete', 'times', 'post.canDelete');
},
actions: {
// In the template, we render the "controls" dropdown with the contents of
// the `renderControls` property. This way, when a post is initially
// rendered, it doesn't have to go to the trouble of rendering the
// controls right away, which speeds things up. When the dropdown button
// is clicked, this will fill in the actual controls.
renderControls: function() {
this.set('renderControls', this.get('controls'));
},
delete: function() {
var post = this.get('post');
post.destroyRecord();
this.sendAction('postRemoved', post);
}
}
});

View File

@ -107,7 +107,16 @@ export default Ember.Controller.extend(Ember.Evented, UseComposerMixin, {
rename: function(title) {
var discussion = this.get('model');
discussion.set('title', title);
discussion.save();
// When we save the title, we should get back an 'added post' in the
// response which documents the title change. We'll add this to the post
// stream.
var controller = this;
discussion.save().then(function(discussion) {
discussion.get('addedPosts').forEach(function(post) {
controller.get('stream').addPostToEnd(post);
});
});
},
delete: function() {

View File

@ -36,6 +36,7 @@ export default DS.Model.extend({
}),
loadedPosts: DS.hasMany('post'),
relevantPosts: DS.hasMany('post'),
addedPosts: DS.hasMany('post'),
readTime: DS.attr('date'),
readNumber: DS.attr('number'),

View File

@ -7,10 +7,19 @@ export default Ember.Route.extend({
start: {replace: true}
},
model: function(params) {
return this.store.findQueryOne('discussion', params.id, {
discussion: function(id, start) {
return this.store.findQueryOne('discussion', id, {
include: 'posts',
near: params.start
near: start
});
},
// When we fetch the discussion from the model hook (i.e. on a fresh page
// load), we'll wrap it in an object proxy and set a `loaded` flag to true
// so that it won't be reloaded later on.
model: function(params) {
return this.discussion(params.id, params.start).then(function(discussion) {
return Ember.ObjectProxy.create({content: discussion, loaded: true});
});
},
@ -36,18 +45,18 @@ export default Ember.Route.extend({
});
controller.set('stream', stream);
// Next, we need to make sure we have a list of the discussion's post
// IDs. If we don't already have this information, we'll need to
// reload the discussion model.
var promise = discussion.get('posts') ? Ember.RSVP.resolve(discussion) : this.model({
id: discussion.get('id'),
start: controller.get('start')
});
// We need to make sure we have an up-to-date list of the discussion's
// post IDs. If we didn't enter this route using the model hook (like if
// clicking on a discussion in the index), then we'll reload the model.
var promise = discussion.get('loaded') ?
Ember.RSVP.resolve(discussion.get('content')) :
this.discussion(discussion.get('id'), controller.get('start'));
// When we know we have the post IDs, we can set up the post stream with
// them. Then we will tell the view that we have finished loading so that
// it can scroll down to the appropriate post.
promise.then(function(discussion) {
controller.set('model', discussion);
var postIds = discussion.get('postIds');
stream.setup(postIds);
@ -70,11 +79,6 @@ export default Ember.Route.extend({
}));
}
// Clear the list of post IDs for this discussion (without
// dirtying the record), so that next time we load the discussion,
// the discussion details and post IDs will be refreshed.
controller.store.push('discussion', {id: discussion.get('id'), posts: ''});
// It's possible for this promise to have resolved but the user
// has clicked away to a different discussion. So only if we're
// still on the original one, we will tell the view that we're

View File

@ -152,10 +152,9 @@
}
.post-icon {
float: left;
margin-top: -2px;
margin-left: -90px;
width: 64px;
text-align: center;
text-align: right;
font-size: 22px;
}
.post.is-hidden {
@ -194,6 +193,25 @@
}
}
.post-activity {
&, & a {
color: @fl-body-muted-color;
}
& a {
font-weight: bold;
}
}
.post-activity-info {
font-size: 15px;
margin-bottom: 5px;
}
.post-renamed {
& .old-title, & .new-title {
font-weight: normal;
font-style: italic;
}
}
// ------------------------------------
// Scrubber

View File

@ -1,11 +1,4 @@
{{#if controls}}
{{ui/dropdown-button
items=renderControls
class="contextual-controls"
buttonClass="btn btn-default btn-icon btn-sm btn-naked"
buttonClick="renderControls"
menuClass="pull-right"}}
{{/if}}
{{partial "components/discussion/post-controls"}}
<header class="post-header">
{{ui/item-list items=header}}

View File

@ -0,0 +1,8 @@
{{#if controls}}
{{ui/dropdown-button
items=renderControls
class="contextual-controls"
buttonClass="btn btn-default btn-icon btn-sm btn-naked"
buttonClick="renderControls"
menuClass="pull-right"}}
{{/if}}

View File

@ -0,0 +1,7 @@
{{partial "components/discussion/post-controls"}}
{{fa-icon "pencil" class="post-icon"}}
<div class="post-activity-info">{{#link-to "user" post.user class="post-user"}}{{post.user.username}}{{/link-to}} changed the title from <strong class="old-title">{{oldTitle}}</strong> to <strong class="new-title">{{newTitle}}</strong>.</div>
<div class="post-activity-time">{{human-time post.time}}</div>

View File

@ -1,4 +0,0 @@
<div class="activity">
{{fa-icon "pencil" class="post-icon"}}
{{#link-to "user" post.user}}{{post.user.username}}{{/link-to}} changed the title from <strong class="old-title">{{post.oldTitle}}</strong> to <strong class="new-title">{{post.newTitle}}</strong>.
</div>

View File

@ -71,8 +71,9 @@ export default Ember.View.extend(HasItemLists, {
this.addActionItem(items, 'rename', 'Rename', 'pencil', 'discussion.canEdit', function() {
var discussion = view.get('controller.model');
var title = prompt('Enter a new title for this discussion:', discussion.get('title'));
if (title) {
var currentTitle = discussion.get('title');
var title = prompt('Enter a new title for this discussion:', currentTitle);
if (title && title !== currentTitle) {
view.get('controller').send('rename', title);
}
});

View File

@ -20,12 +20,13 @@ class Update extends Base
{
$discussionId = $this->param('id');
$readNumber = $this->input('discussions.readNumber');
$user = User::current();
// First, we will run the EditDiscussionCommand. This will update the
// discussion's direct properties; by default, this is just the title.
// As usual, however, we will fire an event to allow plugins to update
// additional properties.
$command = new EditDiscussionCommand($discussionId, User::current());
$command = new EditDiscussionCommand($discussionId, $user);
$this->fillCommandWithInput($command, 'discussions');
Event::fire('Flarum.Api.Actions.Discussions.Update.WillExecuteCommand', [$command]);
@ -36,14 +37,14 @@ class Update extends Base
// ReadDiscussionCommand. We won't bother firing an event for this one,
// because it's pretty specific. (This may need to change in the future.)
if ($readNumber) {
$command = new ReadDiscussionCommand($discussionId, User::current(), $readNumber);
$discussion = $this->commandBus->execute($command);
$command = new ReadDiscussionCommand($discussionId, $user, $readNumber);
$this->commandBus->execute($command);
}
// Presumably, the discussion was updated successfully. (One of the command
// handlers would have thrown an exception if not.) We set this
// discussion as our document's primary element.
$serializer = new DiscussionSerializer;
$serializer = new DiscussionSerializer(['addedPosts', 'addedPosts.user']);
$this->document->setPrimaryElement($serializer->resource($discussion));
return $this->respondWithDocument();

View File

@ -130,4 +130,17 @@ class DiscussionSerializer extends DiscussionBasicSerializer
{
return (new PostBasicSerializer($relations))->resource($discussion->lastPost);
}
/**
* Get a resource containing a discussion's list of posts that have been
* added during this request.
*
* @param Discussion $discussion
* @param array $relations
* @return Tobscure\JsonApi\Collection
*/
public function includeAddedPosts(Discussion $discussion, $relations)
{
return (new PostBasicSerializer($relations))->collection($discussion->getAddedPosts());
}
}

View File

@ -30,7 +30,7 @@ class PostBasicSerializer extends BaseSerializer
/**
* Serialize attributes of a Post model for JSON output.
*
*
* @param Post $post The Post model to serialize.
* @return array
*/
@ -40,17 +40,22 @@ class PostBasicSerializer extends BaseSerializer
'id' => (int) $post->id,
'number' => (int) $post->number,
'time' => $post->time->toRFC3339String(),
'type' => $post->type,
'content' => str_limit($post->content, 200)
'type' => $post->type
];
if ($post->type === 'comment') {
$attributes['content'] = str_limit($post->content, 200);
} else {
$attributes['content'] = json_encode($post->content);
}
return $this->attributesEvent($post, $attributes);
}
/**
* Get the URL templates where this resource and its related resources can
* be accessed.
*
*
* @return array
*/
public function href()
@ -62,7 +67,7 @@ class PostBasicSerializer extends BaseSerializer
/**
* Get a resource containing a post's user.
*
*
* @param Post $post
* @param array $relations
* @return Tobscure\JsonApi\Resource
@ -74,7 +79,7 @@ class PostBasicSerializer extends BaseSerializer
/**
* Get a resource containing a post's discussion ID.
*
*
* @param Post $post
* @return Tobscure\JsonApi\Resource
*/

View File

@ -38,13 +38,13 @@ class PostSerializer extends PostBasicSerializer
$canEdit = $post->can($user, 'edit');
if ($post->type != 'comment') {
$attributes['content'] = $post->content;
} else {
if ($post->type === 'comment') {
$attributes['contentHtml'] = $post->content_html;
if ($canEdit) {
$attributes['content'] = $post->content;
}
} else {
$attributes['content'] = json_encode($post->content);
}
if ($post->edit_time) {

View File

@ -9,17 +9,17 @@ use Flarum\Core\Formatter\FormatterManager;
class CoreServiceProvider extends ServiceProvider
{
/**
* Indicates if loading of the provider is deferred.
*
* @var bool
*/
* Indicates if loading of the provider is deferred.
*
* @var bool
*/
protected $defer = false;
/**
* Bootstrap the application events.
*
* @return void
*/
* Bootstrap the application events.
*
* @return void
*/
public function boot()
{
$this->package('flarum/core', 'flarum');
@ -30,14 +30,14 @@ class CoreServiceProvider extends ServiceProvider
Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\DiscussionMetadataUpdater');
Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\UserMetadataUpdater');
Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\TitleChangePostCreator');
Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\RenamedPostCreator');
}
/**
* Register the service provider.
*
* @return void
*/
* Register the service provider.
*
* @return void
*/
public function register()
{
// Start up the Laracasts Commander package. This is used as the basis
@ -69,36 +69,36 @@ class CoreServiceProvider extends ServiceProvider
$formatter->add('basic', 'Flarum\Core\Formatter\BasicFormatter');
return $formatter;
});
// $this->app->singleton(
// 'Flarum\Core\Repositories\Contracts\DiscussionRepository',
// function($app)
// {
// $discussion = new \Flarum\Core\Repositories\EloquentDiscussionRepository;
// return new DiscussionCacheDecorator($discussion);
// }
// 'Flarum\Core\Repositories\Contracts\DiscussionRepository',
// function($app)
// {
// $discussion = new \Flarum\Core\Repositories\EloquentDiscussionRepository;
// return new DiscussionCacheDecorator($discussion);
// }
// );
// $this->app->singleton(
// 'Flarum\Core\Repositories\Contracts\UserRepository',
// 'Flarum\Core\Repositories\EloquentUserRepository'
// 'Flarum\Core\Repositories\Contracts\UserRepository',
// 'Flarum\Core\Repositories\EloquentUserRepository'
// );
// $this->app->singleton(
// 'Flarum\Core\Repositories\Contracts\PostRepository',
// 'Flarum\Core\Repositories\EloquentPostRepository'
// 'Flarum\Core\Repositories\Contracts\PostRepository',
// 'Flarum\Core\Repositories\EloquentPostRepository'
// );
// $this->app->singleton(
// 'Flarum\Core\Repositories\Contracts\GroupRepository',
// 'Flarum\Core\Repositories\EloquentGroupRepository'
// 'Flarum\Core\Repositories\Contracts\GroupRepository',
// 'Flarum\Core\Repositories\EloquentGroupRepository'
// );
}
/**
* Get the services provided by the provider.
*
* @return array
*/
* Get the services provided by the provider.
*
* @return array
*/
public function provides()
{
return array();

View File

@ -8,6 +8,7 @@ use Flarum\Core\Forum;
use Flarum\Core\Permission;
use Flarum\Core\Support\Exceptions\PermissionDeniedException;
use Flarum\Core\Users\User;
use Flarum\Core\Posts\Post;
class Discussion extends Entity
{
@ -28,6 +29,8 @@ class Discussion extends Entity
'last_post_number' => 'integer'
];
protected $addedPosts = [];
public static function boot()
{
parent::boot();
@ -69,7 +72,7 @@ class Discussion extends Entity
return $discussion;
}
public function setLastPost($post)
public function setLastPost(Post $post)
{
$this->last_time = $post->time;
$this->last_user_id = $post->user_id;
@ -79,8 +82,19 @@ class Discussion extends Entity
public function refreshLastPost()
{
$lastPost = $this->comments()->orderBy('time', 'desc')->first();
$this->setLastPost($lastPost);
if ($lastPost = $this->comments()->orderBy('time', 'desc')->first()) {
$this->setLastPost($lastPost);
}
}
public function getAddedPosts()
{
return $this->addedPosts;
}
public function postWasAdded(Post $post)
{
$this->addedPosts[] = $post;
}
public function refreshCommentsCount()
@ -94,9 +108,10 @@ class Discussion extends Entity
return;
}
$oldTitle = $this->title;
$this->title = $title;
$this->raise(new Events\DiscussionWasRenamed($this, $user));
$this->raise(new Events\DiscussionWasRenamed($this, $user, $oldTitle));
}
public function getDates()

View File

@ -9,9 +9,12 @@ class DiscussionWasRenamed
public $user;
public function __construct(Discussion $discussion, User $user)
public $oldTitle;
public function __construct(Discussion $discussion, User $user, $oldTitle)
{
$this->discussion = $discussion;
$this->user = $user;
$this->oldTitle = $oldTitle;
}
}

View File

@ -3,10 +3,10 @@
use Laracasts\Commander\Events\EventListener;
use Flarum\Core\Posts\PostRepository;
use Flarum\Core\Posts\TitleChangePost;
use Flarum\Core\Posts\RenamedPost;
use Flarum\Core\Discussions\Events\DiscussionWasRenamed;
class TitleChangePostCreator extends EventListener
class RenamedPostCreator extends EventListener
{
protected $postRepo;
@ -17,12 +17,15 @@ class TitleChangePostCreator extends EventListener
public function whenDiscussionWasRenamed(DiscussionWasRenamed $event)
{
$post = TitleChangePost::reply(
$post = RenamedPost::reply(
$event->discussion->id,
$event->discussion->title,
$event->user->id
$event->user->id,
$event->oldTitle,
$event->discussion->title
);
$this->postRepo->save($post);
$event->discussion->postWasAdded($post);
}
}

View File

@ -8,18 +8,28 @@ use Flarum\Core\Permission;
use Flarum\Core\Support\Exceptions\PermissionDeniedException;
use Flarum\Core\Users\User;
class TitleChangePost extends Post
class RenamedPost extends Post
{
public static function reply($discussionId, $content, $userId)
public static function reply($discussionId, $userId, $oldTitle, $newTitle)
{
$post = new static;
$post->content = $content;
$post->content = [$oldTitle, $newTitle];
$post->time = time();
$post->discussion_id = $discussionId;
$post->user_id = $userId;
$post->type = 'titleChange';
$post->type = 'renamed';
return $post;
}
public function getContentAttribute($value)
{
return json_decode($value);
}
public function setContentAttribute($value)
{
$this->attributes['content'] = json_encode($value);
}
}