New tag selection modal when composing a discussion

Also numerous bug fixes. Still WIP
This commit is contained in:
Toby Zerner 2015-06-12 16:43:41 +09:30
parent c9a03d9d8a
commit e3b26b48a9
16 changed files with 536 additions and 111 deletions

View File

@ -8,6 +8,8 @@ import TagsPage from 'flarum-tags/components/tags-page';
import addTagList from 'flarum-tags/add-tag-list';
import addTagFilter from 'flarum-tags/add-tag-filter';
import addTagLabels from 'flarum-tags/add-tag-labels';
import addTagDiscussionControl from 'flarum-tags/add-tag-discussion-control';
import addTagComposer from 'flarum-tags/add-tag-composer';
app.initializers.add('flarum-tags', function() {
// Register routes.
@ -17,7 +19,7 @@ app.initializers.add('flarum-tags', function() {
// Register models.
app.store.models['tags'] = Tag;
Discussion.prototype.tags = Model.many('tags');
Discussion.prototype.canMove = Model.prop('canMove');
Discussion.prototype.canTag = Model.prop('canTag');
// Add a list of tags to the index navigation.
addTagList();
@ -28,7 +30,7 @@ app.initializers.add('flarum-tags', function() {
// Add tags to the discussion list and discussion hero.
addTagLabels();
// addMoveDiscussionControl();
addTagDiscussionControl();
// addDiscussionComposer();
addTagComposer();
});

View File

@ -0,0 +1,54 @@
import { extend, override } from 'flarum/extension-utils';
import IndexPage from 'flarum/components/index-page';
import DiscussionComposer from 'flarum/components/discussion-composer';
import icon from 'flarum/helpers/icon';
import TagDiscussionModal from 'flarum-tags/components/tag-discussion-modal';
import tagsLabel from 'flarum-tags/helpers/tags-label';
export default function() {
override(IndexPage.prototype, 'composeNewDiscussion', function(original, deferred) {
var tag = app.store.getBy('tags', 'slug', this.params().tags);
app.modal.show(
new TagDiscussionModal({
selectedTags: tag ? [tag] : [],
onsubmit: tags => {
original(deferred).then(component => component.tags(tags));
}
})
);
return deferred.promise;
});
// Add tag-selection abilities to the discussion composer.
DiscussionComposer.prototype.tags = m.prop([]);
DiscussionComposer.prototype.chooseTags = function() {
app.modal.show(
new TagDiscussionModal({
selectedTags: this.tags().slice(0),
onsubmit: tags => {
this.tags(tags);
this.$('textarea').focus();
}
})
);
};
// Add a tag-selection menu to the discussion composer's header, after the
// title.
extend(DiscussionComposer.prototype, 'headerItems', function(items) {
var tags = this.tags();
items.add('tags', m('a[href=javascript:;][tabindex=-1].control-change-tags', {onclick: this.chooseTags.bind(this)}, [
tagsLabel(tags)
]));
});
// Add the selected tags as data to submit to the server.
extend(DiscussionComposer.prototype, 'data', function(data) {
data.links = data.links || {};
data.links.tags = this.tags();
});
};

View File

@ -0,0 +1,18 @@
import { extend } from 'flarum/extension-utils';
import Discussion from 'flarum/models/discussion';
import ActionButton from 'flarum/components/action-button';
import TagDiscussionModal from 'flarum-tags/components/tag-discussion-modal';
export default function() {
// Add a control allowing the discussion to be moved to another category.
extend(Discussion.prototype, 'controls', function(items) {
if (this.canTag()) {
items.add('tags', ActionButton.component({
label: 'Edit Tags',
icon: 'tag',
onclick: () => app.modal.show(new TagDiscussionModal({ discussion: this }))
}), {after: 'rename'});
}
});
};

View File

@ -4,12 +4,13 @@ import DiscussionPage from 'flarum/components/discussion-page';
import DiscussionHero from 'flarum/components/discussion-hero';
import tagsLabel from 'flarum-tags/helpers/tags-label';
import sortTags from 'flarum-tags/utils/sort-tags';
export default function() {
// Add tag labels to each discussion in the discussion list.
extend(DiscussionList.prototype, 'infoItems', function(items, discussion) {
var tags = discussion.tags();
if (tags) {
if (tags && tags.length) {
items.add('tags', tagsLabel(tags.filter(tag => tag.slug() !== this.props.params.tags)), {first: true});
}
});
@ -21,8 +22,8 @@ export default function() {
// Restyle a discussion's hero to use its first tag's color.
extend(DiscussionHero.prototype, 'view', function(view) {
var tags = this.props.discussion.tags();
if (tags) {
var tags = sortTags(this.props.discussion.tags());
if (tags && tags.length) {
view.attrs.style = 'color: #fff; background-color: '+tags[0].color();
}
});
@ -31,7 +32,7 @@ export default function() {
// before the title. Put the title on its own line.
extend(DiscussionHero.prototype, 'items', function(items) {
var tags = this.props.discussion.tags();
if (tags) {
if (tags && tags.length) {
items.add('tags', tagsLabel(tags, {link: true}), {before: 'title'});
items.title.content.wrapperClass = 'block-item';

View File

@ -1,58 +0,0 @@
import Component from 'flarum/component';
import DiscussionPage from 'flarum/components/discussion-page';
import icon from 'flarum/helpers/icon';
import categoryLabel from 'flarum-categories/helpers/category-label';
export default class MoveDiscussionModal extends Component {
constructor(props) {
super(props);
this.categories = m.prop(app.store.all('categories'));
}
view() {
var discussion = this.props.discussion;
var discussionCategory = discussion && discussion.category();
return m('div.modal-dialog.modal-move-discussion', [
m('div.modal-content', [
m('button.btn.btn-icon.btn-link.close.back-control', {onclick: app.modal.close.bind(app.modal)}, icon('times')),
m('div.modal-header', m('h3.title-control', discussion
? ['Move ', m('em', discussion.title()), ' from ', categoryLabel(discussionCategory), ' to...']
: ['Start a Discussion In...'])),
m('div', [
m('ul.category-list', [
this.categories().map(category =>
(discussion && discussionCategory && category.id() === discussionCategory.id()) ? '' : m('li.category-tile', {style: 'background-color: '+category.color()}, [
m('a[href=javascript:;]', {onclick: this.save.bind(this, category)}, [
m('h3.title', category.title()),
m('p.description', category.description()),
m('span.count', category.discussionsCount()+' discussions'),
])
])
)
])
])
])
]);
}
save(category) {
var discussion = this.props.discussion;
if (discussion) {
discussion.save({links: {category}}).then(discussion => {
if (app.current instanceof DiscussionPage) {
app.current.stream.sync();
}
m.redraw();
});
}
this.props.onchange && this.props.onchange(category);
app.modal.close();
m.redraw.strategy('none');
}
}

View File

@ -0,0 +1,234 @@
import FormModal from 'flarum/components/form-modal';
import DiscussionPage from 'flarum/components/discussion-page';
import highlight from 'flarum/helpers/highlight';
import classList from 'flarum/utils/class-list';
import tagLabel from 'flarum-tags/helpers/tag-label';
import tagIcon from 'flarum-tags/helpers/tag-icon';
import sortTags from 'flarum-tags/utils/sort-tags';
export default class TagDiscussionModal extends FormModal {
constructor(props) {
super(props);
this.tags = sortTags(app.store.all('tags'));
this.selected = m.prop([]);
if (this.props.selectedTags) {
this.props.selectedTags.map(this.addTag.bind(this));
} else if (this.props.discussion) {
this.props.discussion.tags().map(this.addTag.bind(this));
}
this.filter = m.prop('');
this.index = m.prop(this.tags[0].id());
this.focused = m.prop(false);
}
addTag(tag) {
var selected = this.selected();
var parent = tag.parent();
if (parent) {
var index = selected.indexOf(parent);
if (index === -1) {
selected.push(parent);
}
}
selected.push(tag);
}
removeTag(tag) {
var selected = this.selected();
var index = selected.indexOf(tag);
selected.splice(index, 1);
selected.filter(selected => selected.parent() && selected.parent() === tag).forEach(child => {
var index = selected.indexOf(child);
selected.splice(index, 1);
});
}
view() {
var discussion = this.props.discussion;
var selected = this.selected();
var tags = this.tags;
var filter = this.filter().toLowerCase();
if (filter) {
tags = tags.filter(tag => tag.name().substr(0, filter.length).toLowerCase() === filter);
}
if (tags.indexOf(this.index()) === -1) {
this.index(tags[0]);
}
return super.view({
className: 'tag-discussion-modal',
title: discussion
? ['Edit Tags for ', m('em', discussion.title())]
: 'Start a Discussion About...',
body: [
m('div.tags-form', [
m('div.tags-input.form-control', {className: this.focused() ? 'focus' : ''}, [
m('span.tags-input-selected', selected.map(tag =>
m('span.remove-tag', {onclick: () => {
this.removeTag(tag);
this.ready();
}}, tagLabel(tag))
)),
m('input.form-control', {
placeholder: !selected.length ? 'Choose one or more topics' : '',
value: this.filter(),
oninput: m.withAttr('value', this.filter),
onkeydown: this.onkeydown.bind(this),
onfocus: () => this.focused(true),
onblur: () => this.focused(false)
})
]),
m('button[type=submit].btn.btn-primary', {disabled: !selected.length}, 'Confirm')
])
],
footer: [
m('ul.tags-select', tags.map(tag =>
filter || !tag.parent() || selected.indexOf(tag.parent()) !== -1
? m('li', {
'data-index': tag.id(),
className: classList({
category: tag.position() !== null,
selected: selected.indexOf(tag) !== -1,
active: this.index() == tag
}),
style: {
color: tag.color()
},
onmouseover: () => {
this.index(tag);
},
onclick: () => {
var selected = this.selected();
var index = selected.indexOf(tag);
if (index !== -1) {
this.removeTag(tag);
} else {
this.addTag(tag);
}
if (this.filter()) {
this.filter('');
this.index(this.tags[0]);
}
this.ready();
}
}, [
tagIcon(tag),
m('span.name', highlight(tag.name(), filter)),
tag.description() ? m('span.description', tag.description()) : ''
])
: ''
))
]
});
}
onkeydown(e) {
switch (e.which) {
case 40:
case 38: // Down/Up
e.preventDefault();
this.setIndex(this.getCurrentNumericIndex() + (e.which === 40 ? 1 : -1), true);
break;
case 13: // Return
e.preventDefault();
if (e.metaKey || e.ctrlKey || this.selected().indexOf(this.index()) !== -1) {
if (this.selected().length) {
this.$('form').submit();
}
} else {
this.getItem(this.index())[0].dispatchEvent(new Event('click'));
}
break;
case 8: // Backspace
if (e.target.selectionStart == 0 && e.target.selectionEnd == 0) {
e.preventDefault();
var selected = this.selected();
selected.splice(selected.length - 1, 1);
}
}
}
selectableItems() {
return this.$('.tags-select > li');
}
getCurrentNumericIndex() {
return this.selectableItems().index(
this.getItem(this.index())
);
}
getItem(index) {
var $items = this.selectableItems();
return $items.filter('[data-index='+index.id()+']');
}
setIndex(index, scrollToItem) {
var $items = this.selectableItems();
var $dropdown = $items.parent();
if (index < 0) {
index = $items.length - 1;
} else if (index >= $items.length) {
index = 0;
}
var $item = $items.eq(index);
this.index(app.store.getById('tags', $item.attr('data-index')));
m.redraw();
if (scrollToItem) {
var dropdownScroll = $dropdown.scrollTop();
var dropdownTop = $dropdown.offset().top;
var dropdownBottom = dropdownTop + $dropdown.outerHeight();
var itemTop = $item.offset().top;
var itemBottom = itemTop + $item.outerHeight();
var scrollTop;
if (itemTop < dropdownTop) {
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'));
} else if (itemBottom > dropdownBottom) {
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'));
}
if (typeof scrollTop !== 'undefined') {
$dropdown.stop(true).animate({scrollTop}, 100);
}
}
}
onsubmit(e) {
e.preventDefault();
var discussion = this.props.discussion;
var tags = this.selected();
if (discussion) {
discussion.save({links: {tags}}).then(discussion => {
if (app.current instanceof DiscussionPage) {
app.current.stream.sync();
}
m.redraw();
});
}
this.props.onsubmit && this.props.onsubmit(tags);
app.modal.close();
m.redraw.strategy('none');
}
}

View File

@ -1,4 +1,5 @@
import tagLabel from 'flarum-tags/helpers/tag-label';
import sortTags from 'flarum-tags/utils/sort-tags';
export default function tagsLabel(tags, attrs) {
attrs = attrs || {};
@ -8,7 +9,7 @@ export default function tagsLabel(tags, attrs) {
delete attrs.link;
if (tags) {
tags.forEach(tag => {
sortTags(tags).forEach(tag => {
children.push(tagLabel(tag, {link}));
});
} else {

View File

@ -0,0 +1,25 @@
export default function sortTags(tags) {
return tags.slice(0).sort((a, b) => {
var aPos = a.position();
var bPos = b.position();
var aParent = a.parent();
var bParent = b.parent();
if (aPos === null && bPos === null) {
return b.discussionsCount() - a.discussionsCount();
} else if (bPos === null) {
return -1;
} else if (aPos === null) {
return 1;
} else if (aParent === bParent) {
return aPos - bPos;
} else if (aParent) {
return aParent.position() - bPos;
} else if (bParent) {
return aPos - bParent.position();
}
return 0;
});
};

View File

@ -5,6 +5,7 @@
padding: 0.2em 0.55em;
border-radius: @border-radius-base;
background: @fl-body-secondary-color;
color: @fl-body-muted-color;
&.untagged {
background: transparent;
@ -91,3 +92,122 @@
margin-left: 10px;
}
}
.tag-discussion-modal {
& .modal-header {
background: @fl-body-secondary-color;
padding: 20px;
& h3 {
text-align: left;
color: @fl-body-muted-color;
font-size: 16px;
}
}
& .modal-body {
padding: 0 20px 20px;
}
& .modal-footer {
padding: 1px 0 0;
text-align: left;
}
}
.tags-form {
padding-right: 100px;
overflow: hidden;
& .tags-input {
float: left;
}
& .btn {
margin-right: -100px;
float: right;
width: 85px;
}
}
.tags-input {
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
white-space: nowrap;
& input {
display: inline;
outline: none;
margin-top: -2px;
border: 0 !important;
padding: 0;
width: 100%;
margin-right: -100%;
}
& .remove-tag {
cursor: not-allowed;
}
}
.tags-input-selected {
& .tag-label {
margin-right: 5px;
}
}
.tags-select {
padding: 0;
margin: 0;
list-style: none;
overflow: auto;
max-height: 50vh;
& > li {
padding: 7px 20px;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
&.category {
padding-top: 10px;
padding-bottom: 10px;
& .name {
font-size: 16px;
}
&.selected .tag-icon:before {
color: #fff;
}
}
&.active {
background: @fl-body-secondary-color;
}
& .name {
display: inline-block;
width: 150px;
margin-right: 10px;
margin-left: 10px;
}
& .description {
color: @fl-body-muted-color;
font-size: 12px;
}
&.selected {
& .tag-icon {
position: relative;
&:before {
.fa();
content: @fa-var-check;
color: @fl-body-muted-color;
position: absolute;
font-size: 14px;
width: 100%;
text-align: center;
padding-top: 1px;
}
}
}
& mark {
font-weight: bold;
background: none;
box-shadow: none;
color: inherit;
}
}
}

View File

@ -17,13 +17,20 @@ class CreateTagsTable extends Migration
$table->string('name', 100);
$table->string('slug', 100);
$table->text('description')->nullable();
$table->string('color', 50)->nullable();
$table->string('background_path', 100)->nullable();
$table->string('background_mode', 100)->nullable();
$table->string('icon_path', 100)->nullable();
$table->integer('discussions_count')->unsigned()->default(0);
$table->integer('position')->nullable();
$table->integer('parent_id')->unsigned()->nullable();
$table->string('default_sort', 50)->nullable();
$table->integer('discussions_count')->unsigned()->default(0);
$table->integer('last_time')->unsigned()->nullable();
$table->integer('last_discussion_id')->unsigned()->nullable();
});
}

View File

@ -1,9 +1,9 @@
<?php namespace Flarum\Categories\Events;
<?php namespace Flarum\Tags\Events;
use Flarum\Core\Models\Discussion;
use Flarum\Core\Models\User;
class DiscussionWasMoved
class DiscussionWasTagged
{
/**
* @var \Flarum\Core\Models\Discussion
@ -16,19 +16,19 @@ class DiscussionWasMoved
public $user;
/**
* @var integer
* @var array
*/
public $oldCategoryId;
public $oldTags;
/**
* @param \Flarum\Core\Models\Discussion $discussion
* @param \Flarum\Core\Models\User $user
* @param \Flarum\Categories\Category $oldCategory
*/
public function __construct(Discussion $discussion, User $user, $oldCategoryId)
public function __construct(Discussion $discussion, User $user, array $oldTags)
{
$this->discussion = $discussion;
$this->user = $user;
$this->oldCategoryId = $oldCategoryId;
$this->oldTags = $oldTags;
}
}

View File

@ -1,35 +0,0 @@
<?php namespace Flarum\Categories\Handlers;
use Flarum\Categories\Events\DiscussionWasMoved;
use Flarum\Core\Events\DiscussionWillBeSaved;
class CategorySaver
{
public function subscribe($events)
{
$events->listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@whenDiscussionWillBeSaved');
}
public function whenDiscussionWillBeSaved(DiscussionWillBeSaved $event)
{
if (isset($event->command->data['links']['category']['linkage'])) {
$linkage = $event->command->data['links']['category']['linkage'];
$categoryId = (int) $linkage['id'];
$discussion = $event->discussion;
$user = $event->command->user;
$oldCategoryId = (int) $discussion->category_id;
if ($oldCategoryId === $categoryId) {
return;
}
$discussion->category_id = $categoryId;
if ($discussion->exists) {
$discussion->raise(new DiscussionWasMoved($discussion, $user, $oldCategoryId));
}
}
}
}

View File

@ -0,0 +1,56 @@
<?php namespace Flarum\Tags\Handlers;
use Flarum\Tags\Events\DiscussionWasTagged;
use Flarum\Core\Events\DiscussionWillBeSaved;
use Flarum\Core\Events\DiscussionWasDeleted;
use Flarum\Core\Models\Discussion;
class TagSaver
{
public function subscribe($events)
{
$events->listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@whenDiscussionWillBeSaved');
$events->listen('Flarum\Core\Events\DiscussionWasDeleted', __CLASS__.'@whenDiscussionWasDeleted');
}
public function whenDiscussionWillBeSaved(DiscussionWillBeSaved $event)
{
if (isset($event->command->data['links']['tags']['linkage'])) {
$discussion = $event->discussion;
$user = $event->command->user;
$linkage = (array) $event->command->data['links']['tags']['linkage'];
$newTagIds = [];
foreach ($linkage as $link) {
$newTagIds[] = (int) $link['id'];
}
$oldTags = [];
if ($discussion->exists) {
$oldTags = $discussion->tags()->get();
$oldTagIds = $oldTags->lists('id');
if ($oldTagIds == $newTagIds) {
return;
}
}
// @todo is there a better (safer) way to do this?
// maybe store some info on the discussion model and then use the
// DiscussionWasTagged event to actually save the data?
Discussion::saved(function ($discussion) use ($newTagIds) {
$discussion->tags()->sync($newTagIds);
});
if ($discussion->exists) {
$discussion->raise(new DiscussionWasTagged($discussion, $user, $oldTags->all()));
}
}
}
public function whenDiscussionWasDeleted(DiscussionWasDeleted $event)
{
$event->discussion->tags()->sync([]);
}
}

View File

@ -25,9 +25,9 @@ class TagsServiceProvider extends ServiceProvider
]),
new EventSubscribers([
// 'Flarum\Categories\Handlers\DiscussionMovedNotifier',
// 'Flarum\Tags\Handlers\DiscussionTaggedNotifier',
'Flarum\Tags\Handlers\TagPreloader',
// 'Flarum\Categories\Handlers\CategorySaver'
'Flarum\Tags\Handlers\TagSaver'
]),
new Relationship('Flarum\Core\Models\Discussion', 'tags', function ($model) {
@ -38,7 +38,7 @@ class TagsServiceProvider extends ServiceProvider
new ApiInclude(['discussions.index', 'discussions.show'], 'tags', true),
(new Permission('discussion.editTags'))
(new Permission('discussion.tag'))
->serialize()
->grant(function ($grant, $user) {
$grant->where('start_user_id', $user->id);