FEATURE: User Directory, with sorting and time period filter

This commit is contained in:
Robin Ward 2015-03-16 15:14:33 -04:00
parent 6b85d5582c
commit 3d2d224312
47 changed files with 684 additions and 146 deletions

View file

@ -1,31 +1,45 @@
const ADMIN_MODELS = ['plugin']; const ADMIN_MODELS = ['plugin'];
export default Ember.Object.extend({ export default Ember.Object.extend({
pathFor(type, id) { pathFor(store, type, findArgs) {
let path = "/" + Ember.String.underscore(type + 's'); let path = "/" + Ember.String.underscore(store.pluralize(type));
if (ADMIN_MODELS.indexOf(type) !== -1) { path = "/admin/" + path; } 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; return path;
}, },
findAll(store, type) { findAll(store, type) {
return Discourse.ajax(this.pathFor(type)); return Discourse.ajax(this.pathFor(store, type));
}, },
find(store, type, id) { find(store, type, findArgs) {
return Discourse.ajax(this.pathFor(type, id)); return Discourse.ajax(this.pathFor(store, type, findArgs));
}, },
update(store, type, id, attrs) { update(store, type, id, attrs) {
const data = {}; const data = {};
data[Ember.String.underscore(type)] = attrs; 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) { 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' });
} }
}); });

View file

@ -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 });
}
}
});

View file

@ -10,9 +10,9 @@ export default Ember.Component.extend(CleansUp, {
}, },
_clickToClose: function() { _clickToClose: function() {
var self = this; const self = this;
$('html').off('mousedown.top-period').on('mousedown.top-period', function(e) { $('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)) { if (($target.prop('id') === 'topic-entrance') || (self.$().has($target).length !== 0)) {
return; 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')) { 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.$('#period-popup').css($chevron.position());
this.set('showPeriods', true); this.set('showPeriods', true);
this._clickToClose(); this._clickToClose();
} }
},
actions: {
changePeriod(p) {
this.cleanUp();
this.set('period', p);
this.sendAction('action', p);
}
} }
}); });

View file

@ -1,3 +1,14 @@
export default Ember.Component.extend({ 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);
}
}
}); });

View file

@ -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);
}
}
});

View file

@ -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'));
}
});

View file

@ -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();
}
}
});

View file

@ -1,5 +1,4 @@
import ObjectController from 'discourse/controllers/object'; import ObjectController from 'discourse/controllers/object';
import TopPeriod from 'discourse/models/top-period';
export default ObjectController.extend({ export default ObjectController.extend({
needs: ['navigation/category', 'discovery/topics', 'application'], needs: ['navigation/category', 'discovery/topics', 'application'],
@ -15,7 +14,7 @@ export default ObjectController.extend({
}.observes("loadedAllItems"), }.observes("loadedAllItems"),
showMoreUrl(period) { showMoreUrl(period) {
var url = '', category = this.get('category'); let url = '', category = this.get('category');
if (category) { if (category) {
url = '/c/' + Discourse.Category.slugFor(category) + (this.get('noSubcategories') ? '/none' : '') + '/l'; url = '/c/' + Discourse.Category.slugFor(category) + (this.get('noSubcategories') ? '/none' : '') + '/l';
} }
@ -23,15 +22,10 @@ export default ObjectController.extend({
return url; return url;
}, },
periods: function() { actions: {
const self = this, changePeriod(p) {
periods = []; Discourse.URL.routeTo(this.showMoreUrl(p));
this.site.get('periods').forEach(function(p) { }
periods.pushObject(TopPeriod.create({ id: p, }
showMoreUrl: self.showMoreUrl(p),
periods }));
});
return periods;
}.property('category', 'noSubcategories'),
}); });

View 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);
});

View file

@ -1,33 +1,30 @@
export default Ember.Mixin.create({ export default Ember.Mixin.create({
_watchProps: function() { _watchProps: function() {
var args = this.get('rerenderTriggers'); const args = this.get('rerenderTriggers');
if (!Ember.isNone(args)) { if (!Ember.isNone(args)) {
var self = this; args.forEach(k => this.addObserver(k, this.rerenderString));
args.forEach(function(k) {
self.addObserver(k, self.rerenderString);
});
} }
}.on('init'), }.on('init'),
render: function(buffer) { render(buffer) {
this.renderString(buffer); this.renderString(buffer);
}, },
renderString: function(buffer){ renderString(buffer){
var template = Discourse.__container__.lookup('template:' + this.rawTemplate); const template = Discourse.__container__.lookup('template:' + this.rawTemplate);
if (template) { if (template) {
buffer.push(template(this)); buffer.push(template(this));
} }
}, },
_rerenderString: function() { _rerenderString() {
var buffer = []; const buffer = [];
this.renderString(buffer); this.renderString(buffer);
this.$().html(buffer.join('')); this.$().html(buffer.join(''));
}, },
rerenderString: function() { rerenderString() {
Ember.run.once(this, '_rerenderString'); Ember.run.once(this, '_rerenderString');
} }

View 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);
}
});

View 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();
}
});

View file

@ -1,40 +1,49 @@
import RestModel from 'discourse/models/rest';
import ResultSet from 'discourse/models/result-set';
const _identityMap = {}; const _identityMap = {};
const RestModel = Ember.Object.extend({ export default Ember.Object.extend({
update(attrs) { pluralize(thing) {
const self = this, return thing + "s";
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);
}
});
export default Ember.Object.extend({
findAll(type) { findAll(type) {
const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest'); const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest');
const self = this; const self = this;
return adapter.findAll(this, type).then(function(result) { 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 adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest');
const self = this; const self = this;
return adapter.find(this, type, id).then(function(result) { return adapter.find(this, type, findArgs).then(function(result) {
return self._hydrate(type, result[Ember.String.underscore(type)]); 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) { _hydrate(type, obj) {
if (!obj) { throw "Can't hydrate " + type + " of `null`"; } if (!obj) { throw "Can't hydrate " + type + " of `null`"; }
if (!obj.id) { throw "Can't hydrate " + type + " without an `id`"; } if (!obj.id) { throw "Can't hydrate " + type + " without an `id`"; }

View file

@ -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')
});

View file

@ -11,6 +11,10 @@ export default function() {
}); });
this.resource('topicBySlug', { path: '/t/:slug' }); this.resource('topicBySlug', { path: '/t/:slug' });
this.resource('directory', function() {
this.route('show', {path: '/:period'});
});
this.resource('discovery', { path: '/' }, function() { this.resource('discovery', { path: '/' }, function() {
// top // top
this.route('top'); this.route('top');

View file

@ -67,14 +67,13 @@ export default function(filter, params) {
setupController: function(controller, model) { setupController: function(controller, model) {
var topics = this.get('topics'), var topics = this.get('topics'),
periods = this.controllerFor('discovery').get('periods'),
periodId = topics.get('for_period') || (filter.indexOf('/') > 0 ? filter.split('/')[1] : ''); periodId = topics.get('for_period') || (filter.indexOf('/') > 0 ? filter.split('/')[1] : '');
this.controllerFor('navigation/category').set('canCreateTopic', topics.get('can_create_topic')); this.controllerFor('navigation/category').set('canCreateTopic', topics.get('can_create_topic'));
this.controllerFor('discovery/topics').setProperties({ this.controllerFor('discovery/topics').setProperties({
model: topics, model: topics,
category: model, category: model,
period: periods.findBy('id', periodId), period: periodId,
selected: [], selected: [],
noSubcategories: params && !!params.no_subcategories, noSubcategories: params && !!params.no_subcategories,
order: topics.get('params.order'), order: topics.get('params.order'),

View file

@ -45,13 +45,11 @@ export default function(filter, extras) {
}))); })));
} }
const periods = this.controllerFor('discovery').get('periods'), const period = model.get('for_period') || (filter.indexOf('/') > 0 ? filter.split('/')[1] : '');
periodId = model.get('for_period') || (filter.indexOf('/') > 0 ? filter.split('/')[1] : '');
const topicOpts = { const topicOpts = {
model, model,
category: null, category: null,
period: periods.findBy('id', periodId), period,
selected: [], selected: [],
expandGloballyPinned: true expandGloballyPinned: true
}; };

View file

@ -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');
}
});

View file

@ -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);
}
}
});

View file

@ -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>

View file

@ -1,3 +1,5 @@
{{#each p in period.availablePeriods}} {{#each p in periods}}
{{top-title-button period=p}} {{#d-button action="changePeriod" actionParam=p}}
{{period-title p}}
{{/d-button}}
{{/each}} {{/each}}

View file

@ -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>

View file

@ -0,0 +1,5 @@
<div class="container">
<div class='directory'>
{{outlet}}
</div>
</div>

View file

@ -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>&nbsp;</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}}

View file

@ -19,7 +19,7 @@
<div class='contents'> <div class='contents'>
{{#if top}} {{#if top}}
<div class='top-lists'> <div class='top-lists'>
{{top-period-chooser period=period}} {{period-chooser period=period action="changePeriod"}}
</div> </div>
{{/if}} {{/if}}
{{#if topicTrackingState.hasIncoming}} {{#if topicTrackingState.hasIncoming}}
@ -73,7 +73,7 @@
{{#if top}} {{#if top}}
<h3> <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'}} {{#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> </h3>
{{else}} {{else}}
<div class="education"> <div class="education">

View file

@ -1,7 +1,7 @@
<div class='contents'> <div class='contents'>
{{#if top}} {{#if top}}
<div class='top-lists'> <div class='top-lists'>
{{top-period-chooser period=period}} {{period-chooser period=period action="changePeriod"}}
</div> </div>
{{/if}} {{/if}}
@ -45,7 +45,7 @@
<h3> <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'}} {{#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/> <br/>
{{top-period-buttons period=period}} {{top-period-buttons period=period action="changePeriod"}}
</h3> </h3>
{{else}} {{else}}
<div class="education"> <div class="education">

View file

@ -22,6 +22,8 @@
</li> </li>
{{/if}} {{/if}}
<li>{{#link-to 'directory'}}{{i18n "directory.title"}}{{/link-to}}</li>
{{plugin-outlet "site-map-links"}} {{plugin-outlet "site-map-links"}}
{{#if showKeyboardShortcuts}} {{#if showKeyboardShortcuts}}

View file

@ -0,0 +1,5 @@
import LoadMore from 'discourse/mixins/load-more';
export default Discourse.View.extend(LoadMore, {
eyelineSelector: '.directory tbody tr'
});

View file

@ -29,7 +29,6 @@
//= require ./discourse/models/post-stream //= require ./discourse/models/post-stream
//= require ./discourse/models/topic-details //= require ./discourse/models/topic-details
//= require ./discourse/models/topic //= require ./discourse/models/topic
//= require ./discourse/models/top-period
//= require ./discourse/controllers/controller //= require ./discourse/controllers/controller
//= require ./discourse/controllers/discovery-sortable //= require ./discourse/controllers/discovery-sortable
//= require ./discourse/controllers/object //= require ./discourse/controllers/object
@ -51,7 +50,6 @@
//= require ./discourse/routes/user-topic-list //= require ./discourse/routes/user-topic-list
//= require ./discourse/routes/user-activity-stream //= require ./discourse/routes/user-activity-stream
//= require ./discourse/routes/topic-from-params //= require ./discourse/routes/topic-from-params
//= require ./discourse/components/top-title
//= require ./discourse/components/text-field //= require ./discourse/components/text-field
//= require ./discourse/components/visible //= require ./discourse/components/visible
//= require ./discourse/components/conditional-loading-spinner //= require ./discourse/components/conditional-loading-spinner

View file

@ -226,11 +226,12 @@ ol.category-breadcrumb {
} }
.period-chooser { .period-chooser {
display: inline-block;
@include unselectable; @include unselectable;
h2 { h2 {
float: left; float: left;
margin: 5px 0 10px;
} }
button { button {
@ -274,6 +275,10 @@ ol.category-breadcrumb {
.top-title-buttons { .top-title-buttons {
display: inline; display: inline;
button {
margin-right: 0.5em;
}
} }
div.education { div.education {

View 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;
}
}
}
}

View 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

View 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

View file

@ -0,0 +1,9 @@
module Jobs
class DirectoryRefresh < Jobs::Scheduled
every 1.hour
def execute(args)
DirectoryItem.refresh!
end
end
end

View 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

View 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

View file

@ -0,0 +1,9 @@
class DirectorySerializer < ApplicationSerializer
attributes :id
has_many :directory_items, serializer: DirectoryItemSerializer, embed: :objects
def id
object.filter
end
end

View file

@ -237,6 +237,19 @@ en:
sent_by_user: "Sent by <a href='{{userUrl}}'>{{user}}</a>" sent_by_user: "Sent by <a href='{{userUrl}}'>{{user}}</a>"
sent_by_you: "Sent by <a href='{{userUrl}}'>you</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: groups:
visible: "Group is visible to all users" visible: "Group is visible to all users"
title: title:

View file

@ -25,6 +25,9 @@ Discourse::Application.routes.draw do
resources :about resources :about
resources :directory
resources :directory_items
get "site" => "site#site" get "site" => "site#site"
namespace :site do namespace :site do
get "settings" get "settings"

View 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

View 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

View 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

File diff suppressed because one or more lines are too long

View file

@ -7,6 +7,10 @@ function parsePostData(query) {
return result; return result;
} }
function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
function response(code, obj) { function response(code, obj) {
if (typeof code === "object") { if (typeof code === "object") {
obj = code; obj = code;
@ -24,6 +28,11 @@ const _widgets = [
{id: 124, name: 'Evil Repellant'} {id: 124, name: 'Evil Repellant'}
]; ];
const _moreWidgets = [
{id: 223, name: 'Bass Lure'},
{id: 224, name: 'Good Repellant'}
];
export default function() { export default function() {
const server = new Pretender(function() { const server = new Pretender(function() {
@ -101,12 +110,23 @@ export default function() {
this.put('/widgets/:widget_id', function(request) { this.put('/widgets/:widget_id', function(request) {
const w = _widgets.findBy('id', parseInt(request.params.widget_id)); const w = _widgets.findBy('id', parseInt(request.params.widget_id));
const cloned = JSON.parse(JSON.stringify(w)); return response({ widget: clone(w) });
return response({ widget: cloned });
}); });
this.get('/widgets', function() { this.get('/widgets', function(request) {
return response({ widgets: _widgets }); 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); this.delete('/widgets/:widget_id', success);

View 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");
});
});

View 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'));
});
});
});

View file

@ -19,7 +19,20 @@ test('find', function() {
store.find('widget', 123).then(function(w2) { store.find('widget', 123).then(function(w2) {
equal(w, 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() { test('findAll', function() {
const store = createStore(); const store = createStore();
store.findAll('widget').then(function(result) { store.findAll('widget').then(function(result) {
equal(result.length, 2); equal(result.get('length'), 2);
const w = result.findBy('id', 124); const w = result.findBy('id', 124);
equal(w.get('name'), 'Evil Repellant'); equal(w.get('name'), 'Evil Repellant');
}); });