mirror of
https://github.com/discourse/discourse.git
synced 2024-11-23 02:50:00 +08:00
FEATURE: User Directory, with sorting and time period filter
This commit is contained in:
parent
6b85d5582c
commit
3d2d224312
|
@ -1,31 +1,45 @@
|
|||
const ADMIN_MODELS = ['plugin'];
|
||||
|
||||
export default Ember.Object.extend({
|
||||
pathFor(type, id) {
|
||||
let path = "/" + Ember.String.underscore(type + 's');
|
||||
pathFor(store, type, findArgs) {
|
||||
let path = "/" + Ember.String.underscore(store.pluralize(type));
|
||||
|
||||
if (ADMIN_MODELS.indexOf(type) !== -1) { path = "/admin/" + path; }
|
||||
if (id) { path += "/" + id; }
|
||||
|
||||
if (findArgs) {
|
||||
if (typeof findArgs === "object") {
|
||||
const queryString = Object.keys(findArgs)
|
||||
.reject(k => !findArgs[k])
|
||||
.map(k => k + "=" + encodeURIComponent(findArgs[k]));
|
||||
|
||||
if (queryString.length) {
|
||||
path += "?" + queryString.join('&');
|
||||
}
|
||||
} else {
|
||||
// It's serializable as a string if not an object
|
||||
path += "/" + findArgs;
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
},
|
||||
|
||||
findAll(store, type) {
|
||||
return Discourse.ajax(this.pathFor(type));
|
||||
return Discourse.ajax(this.pathFor(store, type));
|
||||
},
|
||||
|
||||
find(store, type, id) {
|
||||
return Discourse.ajax(this.pathFor(type, id));
|
||||
find(store, type, findArgs) {
|
||||
return Discourse.ajax(this.pathFor(store, type, findArgs));
|
||||
},
|
||||
|
||||
update(store, type, id, attrs) {
|
||||
const data = {};
|
||||
data[Ember.String.underscore(type)] = attrs;
|
||||
return Discourse.ajax(this.pathFor(type, id), { method: 'PUT', data });
|
||||
return Discourse.ajax(this.pathFor(store, type, id), { method: 'PUT', data });
|
||||
},
|
||||
|
||||
destroyRecord(store, type, record) {
|
||||
return Discourse.ajax(this.pathFor(type, record.get('id')), { method: 'DELETE' });
|
||||
return Discourse.ajax(this.pathFor(store, type, record.get('id')), { method: 'DELETE' });
|
||||
}
|
||||
|
||||
});
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import StringBuffer from 'discourse/mixins/string-buffer';
|
||||
import { iconHTML } from 'discourse/helpers/fa-icon';
|
||||
|
||||
export default Ember.Component.extend(StringBuffer, {
|
||||
tagName: 'th',
|
||||
classNames: ['sortable'],
|
||||
rerenderTriggers: ['order', 'asc'],
|
||||
|
||||
renderString(buffer) {
|
||||
const field = this.get('field');
|
||||
buffer.push(I18n.t('directory.' + field));
|
||||
|
||||
if (field === this.get('order')) {
|
||||
buffer.push(iconHTML(this.get('asc') ? 'chevron-up' : 'chevron-down'));
|
||||
}
|
||||
},
|
||||
|
||||
click() {
|
||||
const currentOrder = this.get('order'),
|
||||
field = this.get('field');
|
||||
|
||||
if (currentOrder === field) {
|
||||
this.set('asc', this.get('asc') ? null : true);
|
||||
} else {
|
||||
this.setProperties({ order: field, asc: null });
|
||||
}
|
||||
}
|
||||
});
|
|
@ -10,9 +10,9 @@ export default Ember.Component.extend(CleansUp, {
|
|||
},
|
||||
|
||||
_clickToClose: function() {
|
||||
var self = this;
|
||||
const self = this;
|
||||
$('html').off('mousedown.top-period').on('mousedown.top-period', function(e) {
|
||||
var $target = $(e.target);
|
||||
const $target = $(e.target);
|
||||
if (($target.prop('id') === 'topic-entrance') || (self.$().has($target).length !== 0)) {
|
||||
return;
|
||||
}
|
||||
|
@ -20,12 +20,23 @@ export default Ember.Component.extend(CleansUp, {
|
|||
});
|
||||
},
|
||||
|
||||
click: function() {
|
||||
click(e) {
|
||||
if ($(e.target).closest('.period-popup').length) { return; }
|
||||
|
||||
if (!this.get('showPeriods')) {
|
||||
var $chevron = this.$('i.fa-caret-down');
|
||||
const $chevron = this.$('i.fa-caret-down');
|
||||
this.$('#period-popup').css($chevron.position());
|
||||
this.set('showPeriods', true);
|
||||
this._clickToClose();
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
changePeriod(p) {
|
||||
this.cleanUp();
|
||||
this.set('period', p);
|
||||
this.sendAction('action', p);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
|
@ -1,3 +1,14 @@
|
|||
export default Ember.Component.extend({
|
||||
classNames: ['top-title-buttons']
|
||||
classNames: ['top-title-buttons'],
|
||||
|
||||
periods: function() {
|
||||
const period = this.get('period');
|
||||
return this.site.get('periods').filter(p => p !== period);
|
||||
}.property('period'),
|
||||
|
||||
actions: {
|
||||
changePeriod(p) {
|
||||
this.sendAction('action', p);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
import TopTitle from 'discourse/components/top-title';
|
||||
|
||||
export default TopTitle.extend({
|
||||
tagName: 'button',
|
||||
classNameBindings: [':btn', ':btn-default', 'unless:hidden'],
|
||||
|
||||
click: function() {
|
||||
var url = this.get('period.showMoreUrl');
|
||||
if (url) {
|
||||
Discourse.URL.routeTo(url);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -1,10 +0,0 @@
|
|||
import StringBuffer from 'discourse/mixins/string-buffer';
|
||||
|
||||
export default Ember.Component.extend(StringBuffer, {
|
||||
tagName: 'h2',
|
||||
rerenderTriggers: ['period.title'],
|
||||
|
||||
renderString: function(buffer) {
|
||||
buffer.push("<i class='fa fa-calendar-o'></i> " + this.get('period.title'));
|
||||
}
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
export default Ember.Controller.extend({
|
||||
queryParams: ['order', 'asc'],
|
||||
order: 'likes_received',
|
||||
asc: null,
|
||||
|
||||
showTimeRead: Ember.computed.equal('period', 'all'),
|
||||
|
||||
actions: {
|
||||
loadMore() {
|
||||
this.get('model').loadMore();
|
||||
}
|
||||
}
|
||||
});
|
|
@ -1,5 +1,4 @@
|
|||
import ObjectController from 'discourse/controllers/object';
|
||||
import TopPeriod from 'discourse/models/top-period';
|
||||
|
||||
export default ObjectController.extend({
|
||||
needs: ['navigation/category', 'discovery/topics', 'application'],
|
||||
|
@ -15,7 +14,7 @@ export default ObjectController.extend({
|
|||
}.observes("loadedAllItems"),
|
||||
|
||||
showMoreUrl(period) {
|
||||
var url = '', category = this.get('category');
|
||||
let url = '', category = this.get('category');
|
||||
if (category) {
|
||||
url = '/c/' + Discourse.Category.slugFor(category) + (this.get('noSubcategories') ? '/none' : '') + '/l';
|
||||
}
|
||||
|
@ -23,15 +22,10 @@ export default ObjectController.extend({
|
|||
return url;
|
||||
},
|
||||
|
||||
periods: function() {
|
||||
const self = this,
|
||||
periods = [];
|
||||
this.site.get('periods').forEach(function(p) {
|
||||
periods.pushObject(TopPeriod.create({ id: p,
|
||||
showMoreUrl: self.showMoreUrl(p),
|
||||
periods }));
|
||||
});
|
||||
return periods;
|
||||
}.property('category', 'noSubcategories'),
|
||||
actions: {
|
||||
changePeriod(p) {
|
||||
Discourse.URL.routeTo(this.showMoreUrl(p));
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
|
11
app/assets/javascripts/discourse/helpers/period-title.js.es6
Normal file
11
app/assets/javascripts/discourse/helpers/period-title.js.es6
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { iconHTML } from 'discourse/helpers/fa-icon';
|
||||
|
||||
const TITLE_SUBS = { yearly: 'this_year',
|
||||
monthly: 'this_month',
|
||||
daily: 'today',
|
||||
all: 'all' };
|
||||
|
||||
export default Ember.Handlebars.makeBoundHelper(function (period) {
|
||||
const title = I18n.t('filters.top.' + (TITLE_SUBS[period] || 'this_week'));
|
||||
return new Handlebars.SafeString(iconHTML('calendar-o') + " " + title);
|
||||
});
|
|
@ -1,33 +1,30 @@
|
|||
export default Ember.Mixin.create({
|
||||
|
||||
_watchProps: function() {
|
||||
var args = this.get('rerenderTriggers');
|
||||
const args = this.get('rerenderTriggers');
|
||||
if (!Ember.isNone(args)) {
|
||||
var self = this;
|
||||
args.forEach(function(k) {
|
||||
self.addObserver(k, self.rerenderString);
|
||||
});
|
||||
args.forEach(k => this.addObserver(k, this.rerenderString));
|
||||
}
|
||||
}.on('init'),
|
||||
|
||||
render: function(buffer) {
|
||||
render(buffer) {
|
||||
this.renderString(buffer);
|
||||
},
|
||||
|
||||
renderString: function(buffer){
|
||||
var template = Discourse.__container__.lookup('template:' + this.rawTemplate);
|
||||
renderString(buffer){
|
||||
const template = Discourse.__container__.lookup('template:' + this.rawTemplate);
|
||||
if (template) {
|
||||
buffer.push(template(this));
|
||||
}
|
||||
},
|
||||
|
||||
_rerenderString: function() {
|
||||
var buffer = [];
|
||||
_rerenderString() {
|
||||
const buffer = [];
|
||||
this.renderString(buffer);
|
||||
this.$().html(buffer.join(''));
|
||||
},
|
||||
|
||||
rerenderString: function() {
|
||||
rerenderString() {
|
||||
Ember.run.once(this, '_rerenderString');
|
||||
}
|
||||
|
||||
|
|
20
app/assets/javascripts/discourse/models/rest.js.es6
Normal file
20
app/assets/javascripts/discourse/models/rest.js.es6
Normal file
|
@ -0,0 +1,20 @@
|
|||
export default Ember.Object.extend({
|
||||
update(attrs) {
|
||||
const self = this,
|
||||
type = this.get('__type');
|
||||
return this.store.update(type, this.get('id'), attrs).then(function(result) {
|
||||
if (result && result[type]) {
|
||||
Object.keys(result).forEach(function(k) {
|
||||
attrs[k] = result[k];
|
||||
});
|
||||
}
|
||||
self.setProperties(attrs);
|
||||
return result;
|
||||
});
|
||||
},
|
||||
|
||||
destroyRecord() {
|
||||
const type = this.get('__type');
|
||||
return this.store.destroyRecord(type, this);
|
||||
}
|
||||
});
|
22
app/assets/javascripts/discourse/models/result-set.js.es6
Normal file
22
app/assets/javascripts/discourse/models/result-set.js.es6
Normal file
|
@ -0,0 +1,22 @@
|
|||
export default Ember.ArrayProxy.extend({
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
totalRows: 0,
|
||||
|
||||
loadMore() {
|
||||
const loadMoreUrl = this.get('loadMoreUrl');
|
||||
if (!loadMoreUrl) { return; }
|
||||
|
||||
const totalRows = this.get('totalRows');
|
||||
if (this.get('length') < totalRows && !this.get('loadingMore')) {
|
||||
this.set('loadingMore', true);
|
||||
|
||||
const self = this;
|
||||
return this.store.appendResults(this, this.get('__type'), loadMoreUrl).then(function() {
|
||||
self.set('loadingMore', false);
|
||||
});
|
||||
}
|
||||
|
||||
return Ember.RSVP.resolve();
|
||||
}
|
||||
});
|
|
@ -1,40 +1,49 @@
|
|||
import RestModel from 'discourse/models/rest';
|
||||
import ResultSet from 'discourse/models/result-set';
|
||||
|
||||
const _identityMap = {};
|
||||
|
||||
const RestModel = Ember.Object.extend({
|
||||
update(attrs) {
|
||||
const self = this,
|
||||
type = this.get('__type');
|
||||
return this.store.update(type, this.get('id'), attrs).then(function(result) {
|
||||
if (result && result[type]) {
|
||||
Object.keys(result).forEach(function(k) {
|
||||
attrs[k] = result[k];
|
||||
});
|
||||
}
|
||||
self.setProperties(attrs);
|
||||
return result;
|
||||
});
|
||||
export default Ember.Object.extend({
|
||||
pluralize(thing) {
|
||||
return thing + "s";
|
||||
},
|
||||
|
||||
destroyRecord() {
|
||||
const type = this.get('__type');
|
||||
return this.store.destroyRecord(type, this);
|
||||
}
|
||||
});
|
||||
|
||||
export default Ember.Object.extend({
|
||||
findAll(type) {
|
||||
const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest');
|
||||
const self = this;
|
||||
return adapter.findAll(this, type).then(function(result) {
|
||||
return result[Ember.String.underscore(type + 's')].map(obj => self._hydrate(type, obj));
|
||||
return self._resultSet(type, result);
|
||||
});
|
||||
},
|
||||
|
||||
find(type, id) {
|
||||
find(type, findArgs) {
|
||||
const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest');
|
||||
const self = this;
|
||||
return adapter.find(this, type, id).then(function(result) {
|
||||
return self._hydrate(type, result[Ember.String.underscore(type)]);
|
||||
return adapter.find(this, type, findArgs).then(function(result) {
|
||||
if (typeof findArgs === "object") {
|
||||
return self._resultSet(type, result);
|
||||
} else {
|
||||
return self._hydrate(type, result[Ember.String.underscore(type)]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
appendResults(resultSet, type, url) {
|
||||
const self = this;
|
||||
|
||||
return Discourse.ajax(url).then(function(result) {
|
||||
const typeName = Ember.String.underscore(self.pluralize(type)),
|
||||
totalRows = result["total_rows_" + typeName] || result.get('totalRows'),
|
||||
loadMoreUrl = result["load_more_" + typeName],
|
||||
content = result[typeName].map(obj => self._hydrate(type, obj));
|
||||
|
||||
resultSet.setProperties({ totalRows, loadMoreUrl });
|
||||
resultSet.get('content').pushObjects(content);
|
||||
|
||||
// If we've loaded them all, clear the load more URL
|
||||
if (resultSet.get('length') >= totalRows) {
|
||||
resultSet.set('loadMoreUrl', null);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -63,6 +72,15 @@ export default Ember.Object.extend({
|
|||
});
|
||||
},
|
||||
|
||||
_resultSet(type, result) {
|
||||
const typeName = Ember.String.underscore(this.pluralize(type)),
|
||||
content = result[typeName].map(obj => this._hydrate(type, obj)),
|
||||
totalRows = result["total_rows_" + typeName] || content.length,
|
||||
loadMoreUrl = result["load_more_" + typeName];
|
||||
|
||||
return ResultSet.create({ content, totalRows, loadMoreUrl, store: this, __type: type });
|
||||
},
|
||||
|
||||
_hydrate(type, obj) {
|
||||
if (!obj) { throw "Can't hydrate " + type + " of `null`"; }
|
||||
if (!obj.id) { throw "Can't hydrate " + type + " without an `id`"; }
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
export default Ember.Object.extend({
|
||||
title: null,
|
||||
|
||||
availablePeriods: function() {
|
||||
var periods = this.get('periods');
|
||||
if (!periods) { return; }
|
||||
|
||||
var self = this;
|
||||
return periods.filter(function(p) {
|
||||
return p !== self;
|
||||
});
|
||||
}.property('showMoreUrl'),
|
||||
|
||||
_createTitle: function() {
|
||||
var id = this.get('id');
|
||||
if (id) {
|
||||
var title = "this_week";
|
||||
if (id === "yearly") {
|
||||
title = "this_year";
|
||||
} else if (id === "monthly") {
|
||||
title = "this_month";
|
||||
} else if (id === "daily") {
|
||||
title = "today";
|
||||
} else if (id === "all") {
|
||||
title = "all";
|
||||
}
|
||||
|
||||
this.set('title', I18n.t("filters.top." + title));
|
||||
}
|
||||
}.on('init')
|
||||
|
||||
});
|
|
@ -11,6 +11,10 @@ export default function() {
|
|||
});
|
||||
this.resource('topicBySlug', { path: '/t/:slug' });
|
||||
|
||||
this.resource('directory', function() {
|
||||
this.route('show', {path: '/:period'});
|
||||
});
|
||||
|
||||
this.resource('discovery', { path: '/' }, function() {
|
||||
// top
|
||||
this.route('top');
|
||||
|
|
|
@ -67,14 +67,13 @@ export default function(filter, params) {
|
|||
|
||||
setupController: function(controller, model) {
|
||||
var topics = this.get('topics'),
|
||||
periods = this.controllerFor('discovery').get('periods'),
|
||||
periodId = topics.get('for_period') || (filter.indexOf('/') > 0 ? filter.split('/')[1] : '');
|
||||
|
||||
this.controllerFor('navigation/category').set('canCreateTopic', topics.get('can_create_topic'));
|
||||
this.controllerFor('discovery/topics').setProperties({
|
||||
model: topics,
|
||||
category: model,
|
||||
period: periods.findBy('id', periodId),
|
||||
period: periodId,
|
||||
selected: [],
|
||||
noSubcategories: params && !!params.no_subcategories,
|
||||
order: topics.get('params.order'),
|
||||
|
|
|
@ -45,13 +45,11 @@ export default function(filter, extras) {
|
|||
})));
|
||||
}
|
||||
|
||||
const periods = this.controllerFor('discovery').get('periods'),
|
||||
periodId = model.get('for_period') || (filter.indexOf('/') > 0 ? filter.split('/')[1] : '');
|
||||
|
||||
const period = model.get('for_period') || (filter.indexOf('/') > 0 ? filter.split('/')[1] : '');
|
||||
const topicOpts = {
|
||||
model,
|
||||
category: null,
|
||||
period: periods.findBy('id', periodId),
|
||||
period,
|
||||
selected: [],
|
||||
expandGloballyPinned: true
|
||||
};
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
export default Discourse.Route.extend({
|
||||
beforeModel: function() {
|
||||
this.controllerFor('directory-show').setProperties({ sort: null, asc: null });
|
||||
this.replaceWith('directory.show', 'all');
|
||||
}
|
||||
});
|
|
@ -0,0 +1,31 @@
|
|||
export default Discourse.Route.extend({
|
||||
queryParams: {
|
||||
order: { refreshModel: true },
|
||||
asc: { refreshModel: true },
|
||||
},
|
||||
|
||||
model(params) {
|
||||
// If we refresh via `refreshModel` set the old model to loading
|
||||
const existing = this.modelFor('directory-show');
|
||||
if (existing) {
|
||||
existing.set('loading', true);
|
||||
}
|
||||
|
||||
this._period = params.period;
|
||||
return this.store.find('directoryItem', {
|
||||
id: params.period,
|
||||
asc: params.asc,
|
||||
order: params.order
|
||||
});
|
||||
},
|
||||
|
||||
setupController(controller, model) {
|
||||
controller.setProperties({ model, period: this._period });
|
||||
},
|
||||
|
||||
actions: {
|
||||
changePeriod(period) {
|
||||
this.transitionTo('directory.show', period);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -0,0 +1,11 @@
|
|||
<h2>{{period-title period}}</h2>
|
||||
<button>{{fa-icon "caret-down"}}</button>
|
||||
|
||||
<div id='period-popup' {{bind-attr class="showPeriods::hidden :period-popup"}}>
|
||||
<ul>
|
||||
{{#each p in site.periods}}
|
||||
<li><a href {{action "changePeriod" p}}>{{period-title p}}</a></li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
<div class='clearfix'></div>
|
|
@ -1,3 +1,5 @@
|
|||
{{#each p in period.availablePeriods}}
|
||||
{{top-title-button period=p}}
|
||||
{{#each p in periods}}
|
||||
{{#d-button action="changePeriod" actionParam=p}}
|
||||
{{period-title p}}
|
||||
{{/d-button}}
|
||||
{{/each}}
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
{{top-title period=period}}
|
||||
<button><i class='fa fa-caret-down'></i></button>
|
||||
|
||||
<div id='period-popup' {{bind-attr class="showPeriods::hidden"}}>
|
||||
<ul>
|
||||
{{#each p in period.availablePeriods}}
|
||||
<li><a {{bind-attr href="p.showMoreUrl"}}>{{top-title tagName="span" period=p}}</a></li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
<div class='clearfix'></div>
|
5
app/assets/javascripts/discourse/templates/directory.hbs
Normal file
5
app/assets/javascripts/discourse/templates/directory.hbs
Normal file
|
@ -0,0 +1,5 @@
|
|||
<div class="container">
|
||||
<div class='directory'>
|
||||
{{outlet}}
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,50 @@
|
|||
{{period-chooser period=period action="changePeriod"}}
|
||||
|
||||
{{#loading-spinner condition=model.loading}}
|
||||
{{#if model.length}}
|
||||
<span class='total-rows'>{{i18n "directory.total_rows" count=model.totalRows}}</span>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<th> </th>
|
||||
{{directory-toggle field="likes_received" order=order asc=asc}}
|
||||
{{directory-toggle field="likes_given" order=order asc=asc}}
|
||||
{{directory-toggle field="topic_count" order=order asc=asc}}
|
||||
{{directory-toggle field="post_count" order=order asc=asc}}
|
||||
{{directory-toggle field="topics_entered" order=order asc=asc}}
|
||||
{{#if showTimeRead}}
|
||||
<th>{{i18n "directory.time_read"}}</th>
|
||||
{{/if}}
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each item in model}}
|
||||
<tr>
|
||||
<td>
|
||||
{{avatar item imageSize="tiny"}}
|
||||
{{#link-to 'user' item.username}}{{unbound item.username}}{{/link-to}}
|
||||
</td>
|
||||
<td class="likes">
|
||||
{{fa-icon "heart"}}
|
||||
{{number item.likes_received}}
|
||||
</td>
|
||||
<td class="likes">
|
||||
{{fa-icon "heart"}}
|
||||
{{number item.likes_given}}
|
||||
</td>
|
||||
<td>{{number item.topic_count}}</td>
|
||||
<td>{{number item.post_count}}</td>
|
||||
<td>{{number item.topics_entered}}</td>
|
||||
{{#if showTimeRead}}
|
||||
<td>{{unbound item.time_read}}</td>
|
||||
{{/if}}
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{loading-spinner condition=model.loadingMore}}
|
||||
{{else}}
|
||||
<div class='clearfix'></div>
|
||||
<p>{{i18n "directory.no_results"}}</p>
|
||||
{{/if}}
|
||||
{{/loading-spinner}}
|
|
@ -19,7 +19,7 @@
|
|||
<div class='contents'>
|
||||
{{#if top}}
|
||||
<div class='top-lists'>
|
||||
{{top-period-chooser period=period}}
|
||||
{{period-chooser period=period action="changePeriod"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if topicTrackingState.hasIncoming}}
|
||||
|
@ -73,7 +73,7 @@
|
|||
{{#if top}}
|
||||
<h3>
|
||||
{{#link-to "discovery.categories"}}{{i18n 'topic.browse_all_categories'}}{{/link-to}}, {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}} {{i18n 'or'}} {{i18n 'filters.top.other_periods'}}
|
||||
{{top-period-buttons period=period}}
|
||||
{{top-period-buttons period=period action="changePeriod"}}
|
||||
</h3>
|
||||
{{else}}
|
||||
<div class="education">
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<div class='contents'>
|
||||
{{#if top}}
|
||||
<div class='top-lists'>
|
||||
{{top-period-chooser period=period}}
|
||||
{{period-chooser period=period action="changePeriod"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
|
@ -45,7 +45,7 @@
|
|||
<h3>
|
||||
{{#link-to "discovery.categories"}}{{i18n 'topic.browse_all_categories'}}{{/link-to}}, {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}} {{i18n 'or'}} {{i18n 'filters.top.other_periods'}}
|
||||
<br/>
|
||||
{{top-period-buttons period=period}}
|
||||
{{top-period-buttons period=period action="changePeriod"}}
|
||||
</h3>
|
||||
{{else}}
|
||||
<div class="education">
|
||||
|
|
|
@ -22,6 +22,8 @@
|
|||
</li>
|
||||
{{/if}}
|
||||
|
||||
<li>{{#link-to 'directory'}}{{i18n "directory.title"}}{{/link-to}}</li>
|
||||
|
||||
{{plugin-outlet "site-map-links"}}
|
||||
|
||||
{{#if showKeyboardShortcuts}}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import LoadMore from 'discourse/mixins/load-more';
|
||||
|
||||
export default Discourse.View.extend(LoadMore, {
|
||||
eyelineSelector: '.directory tbody tr'
|
||||
});
|
|
@ -29,7 +29,6 @@
|
|||
//= require ./discourse/models/post-stream
|
||||
//= require ./discourse/models/topic-details
|
||||
//= require ./discourse/models/topic
|
||||
//= require ./discourse/models/top-period
|
||||
//= require ./discourse/controllers/controller
|
||||
//= require ./discourse/controllers/discovery-sortable
|
||||
//= require ./discourse/controllers/object
|
||||
|
@ -51,7 +50,6 @@
|
|||
//= require ./discourse/routes/user-topic-list
|
||||
//= require ./discourse/routes/user-activity-stream
|
||||
//= require ./discourse/routes/topic-from-params
|
||||
//= require ./discourse/components/top-title
|
||||
//= require ./discourse/components/text-field
|
||||
//= require ./discourse/components/visible
|
||||
//= require ./discourse/components/conditional-loading-spinner
|
||||
|
|
|
@ -226,11 +226,12 @@ ol.category-breadcrumb {
|
|||
}
|
||||
|
||||
.period-chooser {
|
||||
|
||||
display: inline-block;
|
||||
@include unselectable;
|
||||
|
||||
h2 {
|
||||
float: left;
|
||||
margin: 5px 0 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
|
@ -274,6 +275,10 @@ ol.category-breadcrumb {
|
|||
|
||||
.top-title-buttons {
|
||||
display: inline;
|
||||
|
||||
button {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
div.education {
|
||||
|
|
46
app/assets/stylesheets/common/base/directory.scss
Normal file
46
app/assets/stylesheets/common/base/directory.scss
Normal file
|
@ -0,0 +1,46 @@
|
|||
.directory {
|
||||
margin-bottom: 100px;
|
||||
|
||||
.period-chooser {
|
||||
float: left;
|
||||
}
|
||||
.total-rows {
|
||||
margin-top: 0.5em;
|
||||
color: darken(scale-color-diff(), 20%);
|
||||
float: right;
|
||||
}
|
||||
.spinner {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
|
||||
td, th {
|
||||
padding: 0.5em;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid scale-color-diff();
|
||||
}
|
||||
|
||||
th.sortable {
|
||||
cursor: pointer;
|
||||
|
||||
width: 13%;
|
||||
i.fa {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: scale-color-diff();
|
||||
}
|
||||
}
|
||||
|
||||
td.likes {
|
||||
i {
|
||||
color: $love;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
8
app/controllers/directory_controller.rb
Normal file
8
app/controllers/directory_controller.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
class DirectoryController < ApplicationController
|
||||
# This controller just exists to avoid 404s and to have the ember app load up
|
||||
def index
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
end
|
35
app/controllers/directory_items_controller.rb
Normal file
35
app/controllers/directory_items_controller.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
class DirectoryItemsController < ApplicationController
|
||||
PAGE_SIZE = 50
|
||||
|
||||
def index
|
||||
id = params.require(:id)
|
||||
period_type = DirectoryItem.period_types[id.to_sym]
|
||||
raise Discourse::InvalidAccess.new(:period_type) unless period_type
|
||||
|
||||
result = DirectoryItem.where(period_type: period_type).includes(:user)
|
||||
|
||||
order = params[:order] || DirectoryItem.headings.first
|
||||
if DirectoryItem.headings.include?(order.to_sym)
|
||||
dir = params[:asc] ? 'ASC' : 'DESC'
|
||||
result = result.order("directory_items.#{order} #{dir}")
|
||||
end
|
||||
|
||||
if period_type == DirectoryItem.period_types[:all]
|
||||
result = result.includes(:user_stat)
|
||||
end
|
||||
page = params[:page].to_i
|
||||
result = result.order('users.username')
|
||||
result_count = result.dup.count
|
||||
result = result.limit(PAGE_SIZE).offset(PAGE_SIZE * page)
|
||||
|
||||
serialized = serialize_data(result, DirectoryItemSerializer)
|
||||
|
||||
more_params = params.slice(:id, :order, :asc)
|
||||
more_params[:page] = page + 1
|
||||
|
||||
render_json_dump directory_items: serialized,
|
||||
total_rows_directory_items: result_count,
|
||||
load_more_directory_items: directory_items_path(more_params)
|
||||
|
||||
end
|
||||
end
|
9
app/jobs/scheduled/directory_refresh.rb
Normal file
9
app/jobs/scheduled/directory_refresh.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
module Jobs
|
||||
class DirectoryRefresh < Jobs::Scheduled
|
||||
every 1.hour
|
||||
|
||||
def execute(args)
|
||||
DirectoryItem.refresh!
|
||||
end
|
||||
end
|
||||
end
|
57
app/models/directory_item.rb
Normal file
57
app/models/directory_item.rb
Normal file
|
@ -0,0 +1,57 @@
|
|||
class DirectoryItem < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
has_one :user_stat, foreign_key: :user_id, primary_key: :user_id
|
||||
|
||||
def self.headings
|
||||
@headings ||= [:likes_received,
|
||||
:likes_given,
|
||||
:topics_entered,
|
||||
:topic_count,
|
||||
:post_count]
|
||||
end
|
||||
|
||||
def self.period_types
|
||||
@types ||= Enum.new(:all, :yearly, :monthly, :weekly, :daily)
|
||||
end
|
||||
|
||||
def self.refresh!
|
||||
ActiveRecord::Base.transaction do
|
||||
exec_sql "TRUNCATE TABLE directory_items"
|
||||
period_types.keys.each {|p| refresh_period!(p)}
|
||||
end
|
||||
end
|
||||
|
||||
def self.refresh_period!(period_type)
|
||||
since = case period_type
|
||||
when :daily then 1.day.ago
|
||||
when :weekly then 1.week.ago
|
||||
when :monthly then 1.month.ago
|
||||
when :yearly then 1.year.ago
|
||||
else 1000.years.ago
|
||||
end
|
||||
|
||||
exec_sql "INSERT INTO directory_items
|
||||
(period_type, user_id, likes_received, likes_given, topics_entered, topic_count, post_count)
|
||||
SELECT
|
||||
:period_type,
|
||||
u.id,
|
||||
SUM(CASE WHEN ua.action_type = :was_liked_type THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN ua.action_type = :like_type THEN 1 ELSE 0 END),
|
||||
(SELECT COUNT(topic_id) FROM topic_views AS v WHERE v.user_id = u.id AND v.viewed_at > :since),
|
||||
SUM(CASE WHEN ua.action_type = :new_topic_type THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN ua.action_type = :reply_type THEN 1 ELSE 0 END)
|
||||
FROM users AS u
|
||||
LEFT OUTER JOIN user_actions AS ua ON ua.user_id = u.id
|
||||
WHERE u.active
|
||||
AND NOT u.blocked
|
||||
AND COALESCE(ua.created_at, :since) >= :since
|
||||
AND u.id > 0
|
||||
GROUP BY u.id",
|
||||
period_type: period_types[period_type],
|
||||
since: since,
|
||||
like_type: UserAction::LIKE,
|
||||
was_liked_type: UserAction::WAS_LIKED,
|
||||
new_topic_type: UserAction::NEW_TOPIC,
|
||||
reply_type: UserAction::REPLY
|
||||
end
|
||||
end
|
35
app/serializers/directory_item_serializer.rb
Normal file
35
app/serializers/directory_item_serializer.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
class DirectoryItemSerializer < ApplicationSerializer
|
||||
|
||||
attributes :id,
|
||||
:username,
|
||||
:uploaded_avatar_id,
|
||||
:avatar_template,
|
||||
:time_read
|
||||
|
||||
attributes *DirectoryItem.headings
|
||||
|
||||
def id
|
||||
object.user_id
|
||||
end
|
||||
|
||||
def username
|
||||
object.user.username
|
||||
end
|
||||
|
||||
def uploaded_avatar_id
|
||||
object.user.uploaded_avatar_id
|
||||
end
|
||||
|
||||
def avatar_template
|
||||
object.user.avatar_template
|
||||
end
|
||||
|
||||
def time_read
|
||||
AgeWords.age_words(object.user_stat.time_read)
|
||||
end
|
||||
|
||||
def include_time_read?
|
||||
object.period_type == DirectoryItem.period_types[:all]
|
||||
end
|
||||
|
||||
end
|
9
app/serializers/directory_serializer.rb
Normal file
9
app/serializers/directory_serializer.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
class DirectorySerializer < ApplicationSerializer
|
||||
attributes :id
|
||||
has_many :directory_items, serializer: DirectoryItemSerializer, embed: :objects
|
||||
|
||||
def id
|
||||
object.filter
|
||||
end
|
||||
|
||||
end
|
|
@ -237,6 +237,19 @@ en:
|
|||
sent_by_user: "Sent by <a href='{{userUrl}}'>{{user}}</a>"
|
||||
sent_by_you: "Sent by <a href='{{userUrl}}'>you</a>"
|
||||
|
||||
directory:
|
||||
title: "User Directory"
|
||||
likes_given: "Likes Given"
|
||||
likes_received: "Likes Received"
|
||||
topics_entered: "Topics Entered"
|
||||
time_read: "Time Read"
|
||||
topic_count: "Topics"
|
||||
post_count: "Replies"
|
||||
no_results: "No results were found for this time period."
|
||||
total_rows:
|
||||
one: "1 user found"
|
||||
other: "%{count} users found"
|
||||
|
||||
groups:
|
||||
visible: "Group is visible to all users"
|
||||
title:
|
||||
|
|
|
@ -25,6 +25,9 @@ Discourse::Application.routes.draw do
|
|||
|
||||
resources :about
|
||||
|
||||
resources :directory
|
||||
resources :directory_items
|
||||
|
||||
get "site" => "site#site"
|
||||
namespace :site do
|
||||
get "settings"
|
||||
|
|
16
db/migrate/20150318143915_create_directory_items.rb
Normal file
16
db/migrate/20150318143915_create_directory_items.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
class CreateDirectoryItems < ActiveRecord::Migration
|
||||
def change
|
||||
create_table :directory_items do |t|
|
||||
t.integer :period_type, null: false
|
||||
t.references :user, null: false
|
||||
t.integer :likes_received, null: false
|
||||
t.integer :likes_given, null: false
|
||||
t.integer :topics_entered, null: false
|
||||
t.integer :topic_count, null: false
|
||||
t.integer :post_count, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :directory_items, :period_type
|
||||
end
|
||||
end
|
31
spec/controllers/directory_items_controller_spec.rb
Normal file
31
spec/controllers/directory_items_controller_spec.rb
Normal file
|
@ -0,0 +1,31 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe DirectoryItemsController do
|
||||
|
||||
it "requires an `id` param" do
|
||||
->{ xhr :get, :index }.should raise_error
|
||||
end
|
||||
|
||||
it "requires a proper `id` param" do
|
||||
xhr :get, :index, id: 'eviltrout'
|
||||
response.should_not be_success
|
||||
end
|
||||
|
||||
context "with data" do
|
||||
before do
|
||||
Fabricate(:user)
|
||||
DirectoryItem.refresh!
|
||||
end
|
||||
|
||||
it "succeeds with a valid value" do
|
||||
xhr :get, :index, id: 'all'
|
||||
response.should be_success
|
||||
json = ::JSON.parse(response.body)
|
||||
|
||||
json.should be_present
|
||||
json['directory_items'].should be_present
|
||||
json['total_rows_directory_items'].should be_present
|
||||
json['load_more_directory_items'].should be_present
|
||||
end
|
||||
end
|
||||
end
|
15
spec/models/directory_item_spec.rb
Normal file
15
spec/models/directory_item_spec.rb
Normal file
|
@ -0,0 +1,15 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe DirectoryItem do
|
||||
context 'refresh' do
|
||||
let!(:user) { Fabricate(:user) }
|
||||
|
||||
it "creates the record for the user" do
|
||||
DirectoryItem.refresh!
|
||||
expect(DirectoryItem.where(period_type: DirectoryItem.period_types[:all])
|
||||
.where(user_id: user.id)
|
||||
.exists?).to be_true
|
||||
end
|
||||
|
||||
end
|
||||
end
|
3
test/javascripts/fixtures/directory-fixtures.js.es6
Normal file
3
test/javascripts/fixtures/directory-fixtures.js.es6
Normal file
File diff suppressed because one or more lines are too long
|
@ -7,6 +7,10 @@ function parsePostData(query) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function clone(obj) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function response(code, obj) {
|
||||
if (typeof code === "object") {
|
||||
obj = code;
|
||||
|
@ -24,6 +28,11 @@ const _widgets = [
|
|||
{id: 124, name: 'Evil Repellant'}
|
||||
];
|
||||
|
||||
const _moreWidgets = [
|
||||
{id: 223, name: 'Bass Lure'},
|
||||
{id: 224, name: 'Good Repellant'}
|
||||
];
|
||||
|
||||
export default function() {
|
||||
const server = new Pretender(function() {
|
||||
|
||||
|
@ -101,12 +110,23 @@ export default function() {
|
|||
|
||||
this.put('/widgets/:widget_id', function(request) {
|
||||
const w = _widgets.findBy('id', parseInt(request.params.widget_id));
|
||||
const cloned = JSON.parse(JSON.stringify(w));
|
||||
return response({ widget: cloned });
|
||||
return response({ widget: clone(w) });
|
||||
});
|
||||
|
||||
this.get('/widgets', function() {
|
||||
return response({ widgets: _widgets });
|
||||
this.get('/widgets', function(request) {
|
||||
let result = _widgets;
|
||||
|
||||
const qp = request.queryParams;
|
||||
if (qp) {
|
||||
if (qp.name) { result = result.filterBy('name', qp.name); }
|
||||
if (qp.id) { result = result.filterBy('id', parseInt(qp.id)); }
|
||||
}
|
||||
|
||||
return response({ widgets: result, total_rows_widgets: 4, load_more_widgets: '/load-more-widgets' });
|
||||
});
|
||||
|
||||
this.get('/load-more-widgets', function() {
|
||||
return response({ widgets: _moreWidgets, total_rows_widgets: 4, load_more_widgets: '/load-more-widgets' });
|
||||
});
|
||||
|
||||
this.delete('/widgets/:widget_id', success);
|
||||
|
|
8
test/javascripts/integration/directory-test.js.es6
Normal file
8
test/javascripts/integration/directory-test.js.es6
Normal file
|
@ -0,0 +1,8 @@
|
|||
integration("User Directory");
|
||||
|
||||
test("Visit Page", function() {
|
||||
visit("/directory/all");
|
||||
andThen(function() {
|
||||
ok(exists('.directory table tr'), "has a list of users");
|
||||
});
|
||||
});
|
28
test/javascripts/models/result-set-test.js.es6
Normal file
28
test/javascripts/models/result-set-test.js.es6
Normal file
|
@ -0,0 +1,28 @@
|
|||
module('result-set');
|
||||
|
||||
import ResultSet from 'discourse/models/result-set';
|
||||
import createStore from 'helpers/create-store';
|
||||
|
||||
test('defaults', function() {
|
||||
const rs = ResultSet.create({ content: [] });
|
||||
equal(rs.get('length'), 0);
|
||||
equal(rs.get('totalRows'), 0);
|
||||
ok(!rs.get('loadMoreUrl'));
|
||||
ok(!rs.get('loading'));
|
||||
ok(!rs.get('loadingMore'));
|
||||
});
|
||||
|
||||
test('pagination support', function() {
|
||||
const store = createStore();
|
||||
store.findAll('widget').then(function(rs) {
|
||||
equal(rs.get('length'), 2);
|
||||
equal(rs.get('totalRows'), 4);
|
||||
ok(rs.get('loadMoreUrl'), 'has a url to load more');
|
||||
|
||||
rs.loadMore().then(function() {
|
||||
equal(rs.get('length'), 4);
|
||||
ok(!rs.get('loadMoreUrl'));
|
||||
});
|
||||
});
|
||||
|
||||
});
|
|
@ -19,7 +19,20 @@ test('find', function() {
|
|||
store.find('widget', 123).then(function(w2) {
|
||||
equal(w, w2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('find with object id', function() {
|
||||
const store = createStore();
|
||||
store.find('widget', {id: 123}).then(function(w) {
|
||||
equal(w.get('firstObject.name'), 'Trout Lure');
|
||||
});
|
||||
});
|
||||
|
||||
test('find with query param', function() {
|
||||
const store = createStore();
|
||||
store.find('widget', {name: 'Trout Lure'}).then(function(w) {
|
||||
equal(w.get('firstObject.id'), 123);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -33,7 +46,7 @@ test('update', function() {
|
|||
test('findAll', function() {
|
||||
const store = createStore();
|
||||
store.findAll('widget').then(function(result) {
|
||||
equal(result.length, 2);
|
||||
equal(result.get('length'), 2);
|
||||
const w = result.findBy('id', 124);
|
||||
equal(w.get('name'), 'Evil Repellant');
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue
Block a user