diff --git a/.bowerrc b/.bowerrc new file mode 100644 index 00000000000..16f19592776 --- /dev/null +++ b/.bowerrc @@ -0,0 +1,3 @@ +{ + "directory": "static/bower" +} diff --git a/bower.json b/bower.json new file mode 100644 index 00000000000..a40ce2a0a4e --- /dev/null +++ b/bower.json @@ -0,0 +1,28 @@ +{ + "name": "Kitsune", + "description": "Platform for Firefox Help", + "homepage": "http://support.mozilla.org/", + "private": true, + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests" + ], + "dependencies": { + "ace": "ace-builds#1.1.8", + "d3": "d3#3.1.5", + "jquery": "jquery#1.10.1", + "jquery-ui": "jquery-ui#1.10.2", + "mailcheck": "mailcheck#1.1.0", + "modernizr": "modernizr#2.6.1", + "moment": "moment#2.8.3", + "normalize-css": "normalize-css#3.0.2", + "nunjucks": "nunjucks#1.0.5", + "nwmatcher": "nwmatcher#1.3.4", + "selectivizr": "selectivizr#1.0.2", + "underscore": "underscore#1.2.1", + "react": "~0.13.0" + } +} diff --git a/kitsune/bundles.py b/kitsune/bundles.py index baf1855fdf5..5d4cfa7324a 100644 --- a/kitsune/bundles.py +++ b/kitsune/bundles.py @@ -1,150 +1,269 @@ # Bundles for JS/CSS Minification -MINIFY_BUNDLES = { - 'css': { - 'common': ( - 'css/normalize.css', + +PIPELINE_CSS = { + 'common': { + 'source_filenames': ( + 'bower/normalize-css/normalize.css', 'less/main.less', 'less/search.less', ), - 'community': ( + 'output_filename': 'common-min.css' + }, + 'community': { + 'source_filenames': ( 'less/wiki-content.less', 'less/community.less', 'less/select.less', ), - 'mobile/common': ( - 'css/normalize.css', + 'output_filename': 'community-min.css' + }, + 'community-new': { + 'source_filenames': ( + 'css/font-awesome.css', + 'less/wiki-content.less', + 'less/community-new.less', + 'less/select.less', + ), + 'output_filename': 'community-new-min.css' + }, + 'mobile-common': { + 'source_filenames': ( + 'bower/normalize-css/normalize.css', 'less/mobile/main.less', ), - 'print': ( + 'output_filename': 'mobile-common-min.css' + }, + 'print': { + 'source_filenames': ( 'css/print.css', ), - # TODO: remove dependency on jquery ui CSS and use our own - 'jqueryui/jqueryui': ( + 'output_filename': 'print-min.css', + 'extra_context': { + 'media': 'print', + } + }, + # TODO: remove dependency on jquery ui CSS and use our own + 'jqueryui': { + 'source_filenames': ( 'css/jqueryui/jqueryui.css', ), - 'forums': ( + 'output_filename': 'jqueryui-min.css' + }, + 'forums': { + 'source_filenames': ( 'less/forums.less', 'less/reportabuse.less', ), - 'questions': ( + 'output_filename': 'forums-min.css' + }, + 'questions': { + 'source_filenames': ( 'less/questions.less', 'css/cannedresponses.css', 'less/reportabuse.less', ), - 'questions.metrics': ( + 'output_filename': 'questions-min.css' + }, + 'questions.metrics': { + 'source_filenames': ( 'less/questions.metrics.less', ), - 'mobile/questions': ( + 'output_filename': 'questions.metrics-min.css' + }, + 'mobile-questions': { + 'source_filenames': ( 'less/mobile/questions.less', ), - 'mobile/aaq': ( + 'output_filename': 'mobile-questions-min.css' + }, + 'mobile-aaq': { + 'source_filenames': ( 'less/mobile/aaq.less', ), - 'rickshaw': ( + 'output_filename': 'mobile-aaq-min.css' + }, + 'rickshaw': { + 'source_filenames': ( 'css/jqueryui/jqueryui.css', 'css/rickshaw.css', 'less/rickshaw.sumo.less', ), - 'mobile/search': ( + 'output_filename': 'rickshaw-min.css' + }, + 'mobile-search': { + 'source_filenames': ( 'less/mobile/search.less', ), - 'wiki': ( + 'output_filename': 'mobile-search-min.css' + }, + 'wiki': { + 'source_filenames': ( 'css/users.autocomplete.css', 'css/users.list.css', 'less/wiki.less', 'less/wiki-content.less', 'css/screencast.css', ), - 'mobile/wiki': ( + 'output_filename': 'wiki-min.css' + }, + 'mobile-wiki': { + 'source_filenames': ( 'less/mobile/wiki.less', 'less/wiki-content.less', ), - 'mobile/wiki-minimal': ( - 'css/normalize.css', + 'output_filename': 'mobile-wiki-min.css' + }, + 'mobile-wiki-minimal': { + 'source_filenames': ( + 'bower/normalize-css/normalize.css', 'less/mobile/main.less', 'less/mobile/wiki.less', 'less/wiki-content.less', ), - 'home': ( + 'output_filename': 'mobile-wiki-minimal-min.css' + }, + 'home': { + 'source_filenames': ( 'less/home.less', ), - 'gallery': ( + 'output_filename': 'home-min.css' + }, + 'gallery': { + 'source_filenames': ( 'less/gallery.less', ), - 'ie': ( + 'output_filename': 'gallery-min.css' + }, + 'ie': { + 'source_filenames': ( 'css/ie.css', 'css/ie8.css', ), - 'ie8': ( # IE 8 needs some specific help. + 'output_filename': 'ie-min.css' + }, + 'ie8': { + 'source_filenames': ( # IE 8 needs some specific help. 'css/ie8.css', ), - 'customercare': ( + 'output_filename': 'ie8-min.css' + }, + 'customercare': { + 'source_filenames': ( 'less/customercare.less', ), - 'users': ( + 'output_filename': 'customercare-min.css' + }, + 'users': { + 'source_filenames': ( 'less/users.less', 'less/reportabuse.less', ), - 'mobile/users': ( + 'output_filename': 'users-min.css' + }, + 'mobile-users': { + 'source_filenames': ( 'less/mobile/users.less', ), - 'monitor': ( + 'output_filename': 'mobile-users-min.css' + }, + 'monitor': { + 'source_filenames': ( 'css/monitor.css', ), - 'messages': ( + 'output_filename': 'monitor-min.css' + }, + 'messages': { + 'source_filenames': ( 'css/users.autocomplete.css', 'less/messages.less', ), - 'mobile/messages': ( + 'output_filename': 'messages-min.css' + }, + 'mobile-messages': { + 'source_filenames': ( 'less/mobile/messages.less', ), - 'products': ( + 'output_filename': 'mobile-messages-min.css' + }, + 'products': { + 'source_filenames': ( 'less/products.less', ), - 'mobile/products': ( + 'output_filename': 'products-min.css' + }, + 'mobile-products': { + 'source_filenames': ( 'less/mobile/products.less', ), - 'groups': ( + 'output_filename': 'mobile-products-min.css' + }, + 'groups': { + 'source_filenames': ( 'css/users.autocomplete.css', 'css/users.list.css', 'css/groups.css', 'css/wiki_syntax.css', ), - 'kpi.dashboard': ( + 'output_filename': 'groups-min.css' + }, + 'kpi.dashboard': { + 'source_filenames': ( 'less/kpi.dashboard.less', ), - 'locale-switcher': ( + 'output_filename': 'kpi.dashboard-min.css' + }, + 'locale-switcher': { + 'source_filenames': ( 'less/locale-switcher.less', ), - 'mobile/locale-switcher': ( + 'output_filename': 'locale-switcher-min.css' + }, + 'mobile-locale-switcher': { + 'source_filenames': ( 'less/mobile/locales.less', ), - 'kbdashboards': ( + 'output_filename': 'mobile-locale-switcher-min.css' + }, + 'kbdashboards': { + 'source_filenames': ( 'less/kbdashboards.less', ), - 'landings/get-involved': ( + 'output_filename': 'kbdashboards-min.css' + }, + 'landings-get-involved': { + 'source_filenames': ( 'less/landings/get-involved.less', ), - 'mobile/landings/get-involved': ( + 'output_filename': 'landings-get-involved-min.css' + }, + 'mobile-landings-get-involved': { + 'source_filenames': ( 'less/mobile/landings/get-involved.less', ), - 'badges': ( + 'output_filename': 'mobile-landings-get-involved-min.css' + }, + 'badges': { + 'source_filenames': ( 'less/badges.less', ), - }, - 'js': { - 'common': ( + 'output_filename': 'badges-min.css' + } +} + +PIPELINE_JS = { + 'common': { + 'source_filenames': ( 'js/i18n.js', - 'js/libs/underscore.js', - 'js/libs/moment-2.8.3.js', - 'js/libs/jquery-1.10.1.min.js', - 'js/libs/jquery.migrate.js', + 'bower/underscore/underscore.js', + 'bower/moment/moment.js', + 'bower/jquery/jquery.min.js', + 'bower/jquery/jquery-migrate.js', 'js/libs/jquery.cookie.js', 'js/libs/jquery.placeholder.js', 'js/templates/macros.js', 'js/templates/search-results-list.js', 'js/templates/search-results.js', - 'js/libs/nunjucks-slim.js', + 'bower/nunjucks/browser/nunjucks-slim.js', 'js/nunjucks.js', 'js/cached_xhr.js', 'js/search_utils.js', @@ -153,38 +272,75 @@ 'js/kbox.js', 'js/main.js', 'js/format.js', - 'js/libs/modernizr-2.6.1.js', + 'bower/modernizr/modernizr.js', 'js/geoip-locale.js', - 'js/libs/mailcheck.js', + 'bower/mailcheck/src/mailcheck.js', 'js/ui.js', 'js/analytics.js', 'js/surveygizmo.js', 'js/instant_search.js', ), - 'community': ( - 'js/libs/jquery-1.10.1.min.js', + 'output_filename': 'common-min.js' + }, + 'community': { + 'source_filenames': ( + 'bower/jquery/jquery.min.js', + 'bower/jquery/jquery-migrate.js', 'js/community.js', 'js/select.js', ), - 'mobile/common': ( + 'output_filename': 'community-min.js' + }, + 'community-new': { + 'source_filenames': ( + # This uses the minified version because it is optimized to leave + # out lots of debug stuff, so it is significantly smaller than + # just minifying react.js. + # TODO: Figure out how to include the full sized version in dev. + 'bower/react/react.min.js', + 'js/community-new.browserify.js', + ), + 'output_filename': 'community-new-min.js' + }, + 'mobile-common': { + 'source_filenames': ( 'js/i18n.js', - 'js/libs/underscore.js', - 'js/libs/jquery-1.10.1.min.js', - 'js/libs/jquery.migrate.js', - 'js/libs/modernizr-2.6.1.js', + 'bower/underscore/underscore.js', + 'bower/jquery/jquery.min.js', + 'bower/jquery/jquery-migrate.js', + 'bower/modernizr/modernizr.js', 'js/browserdetect.js', 'js/aaq.js', 'js/mobile/ui.js', 'js/analytics.js', ), - 'ie6-8': ( - 'js/libs/nwmatcher-1.2.5.js', - 'js/libs/selectivizr-1.0.2.js', - ), - 'libs/jqueryui': ( - 'js/libs/jqueryui.js', + 'output_filename': 'mobile-common-min.js' + }, + 'ie6-8': { + 'source_filenames': ( + 'bower/nwmatcher/src/nwmatcher.js', + 'bower/selectivizr/selectivizr.js', ), - 'questions': ( + 'output_filename': 'ie6-8-min.js' + }, + 'jqueryui': { + 'source_filenames': ( + 'bower/jquery/jquery.ui.core.js', + 'bower/jquery/jquery.ui.widget.js', + 'bower/jquery/jquery.ui.mouse.js', + 'bower/jquery/jquery.ui.position.js', + 'bower/jquery/jquery.ui.sortable.js', + 'bower/jquery/jquery.ui.accordion.js', + 'bower/jquery/jquery.ui.autocomplete.js', + 'bower/jquery/jquery.ui.datepicker.js', + 'bower/jquery/jquery.ui.menu.js', + 'bower/jquery/jquery.ui.slider.js', + 'bower/jquery/jquery.ui.tabs.js', + ), + 'output_filename': 'jqueryui-min.js' + }, + 'questions': { + 'source_filenames': ( 'js/markup.js', 'js/ajaxvote.js', 'js/ajaxpreview.js', @@ -199,34 +355,58 @@ 'js/libs/jquery.ajaxupload.js', 'js/upload.js', ), - 'questions.metrics': ( + 'output_filename': 'questions-min.js' + }, + 'questions.metrics': { + 'source_filenames': ( 'js/questions.metrics-dashboard.js', ), - 'mobile/questions': ( + 'output_filename': 'questions.metrics-min.js' + }, + 'mobile-questions': { + 'source_filenames': ( 'js/mobile/questions.js', 'js/questions.metrics.js', ), - 'mobile/aaq': ( + 'output_filename': 'mobile-questions-min.js' + }, + 'mobile-aaq': { + 'source_filenames': ( 'js/aaq.js', 'js/mobile/aaq.js', ), - 'products': ( + 'output_filename': 'mobile-aaq-min.js' + }, + 'products': { + 'source_filenames': ( 'js/products.js', ), - 'search': ( + 'output_filename': 'products-min.js' + }, + 'search': { + 'source_filenames': ( 'js/search.js', ), - 'forums': ( + 'output_filename': 'search-min.js' + }, + 'forums': { + 'source_filenames': ( 'js/markup.js', 'js/ajaxpreview.js', 'js/forums.js', 'js/reportabuse.js', ), - 'gallery': ( + 'output_filename': 'forums-min.js' + }, + 'gallery': { + 'source_filenames': ( 'js/libs/jquery.ajaxupload.js', 'js/gallery.js', ), - 'wiki': ( + 'output_filename': 'gallery-min.js' + }, + 'wiki': { + 'source_filenames': ( 'js/markup.js', 'js/libs/django/urlify.js', 'js/libs/django/prepopulate.js', @@ -244,15 +424,20 @@ 'js/editable.js', 'js/wiki.metrics.js', ), - 'rickshaw': ( - 'js/libs/jqueryui.js', - 'js/libs/d3.js', + 'output_filename': 'wiki-min.js' + }, + 'rickshaw': { + 'source_filenames': ( + 'bower/d3/d3.js', 'js/libs/d3.layout.min.js', 'js/libs/rickshaw.js', 'js/rickshaw_utils.js', ), - 'mobile/wiki': ( - 'js/libs/underscore.js', + 'output_filename': 'rickshaw-min.js' + }, + 'mobile-wiki': { + 'source_filenames': ( + 'bower/underscore/underscore.js', 'js/libs/jquery.cookie.js', 'js/libs/jquery.lazyload.js', 'js/browserdetect.js', @@ -261,12 +446,15 @@ 'js/mobile/wiki.js', 'js/wiki.metrics.js', ), - 'mobile/wiki-minimal': ( + 'output_filename': 'mobile-wiki-min.js' + }, + 'mobile-wiki-minimal': { + 'source_filenames': ( 'js/i18n.js', - 'js/libs/underscore.js', - 'js/libs/jquery-1.10.1.min.js', - 'js/libs/jquery.migrate.js', - 'js/libs/modernizr-2.6.1.js', + 'bower/underscore/underscore.js', + 'bower/jquery/jquery.min.js', + 'bower/jquery/jquery-migrate.js', + 'bower/modernizr/modernizr.js', 'js/browserdetect.js', 'js/mobile/ui.js', 'js/analytics.js', @@ -277,33 +465,54 @@ 'js/mobile/wiki.js', 'js/wiki.metrics.js', ), - 'wiki.history': ( + 'output_filename': 'mobile-wiki-minimal-min.js' + }, + 'wiki.history': { + 'source_filenames': ( 'js/historycharts.js', ), - 'wiki.diff': ( + 'output_filename': 'wiki.history-min.js' + }, + 'wiki.diff': { + 'source_filenames': ( 'js/libs/diff_match_patch_uncompressed.js', 'js/diff.js', ), - 'wiki.editor': ( - 'js/libs/ace/ace.js', - 'js/libs/ace/ext-language_tools.js', + 'output_filename': 'wiki.diff-min.js' + }, + 'wiki.editor': { + 'source_filenames': ( + 'bower/ace/src/ace.js', + 'bower/ace/src/ext-language_tools.js', 'js/ace.mode-sumo.js', ), - 'wiki.dashboard': ( + 'output_filename': 'wiki.editor-min.js' + }, + 'wiki.dashboard': { + 'source_filenames': ( 'js/wiki.dashboard.js', ), - 'customercare': ( + 'output_filename': 'wiki.dashboard-min.js' + }, + 'customercare': { + 'source_filenames': ( 'js/libs/jquery.cookie.js', 'js/libs/jquery.bullseye-1.0.min.js', 'js/libs/twitter-text.js', 'js/customercare.js', 'js/users.js', ), - 'users': ( + 'output_filename': 'customercare-min.js' + }, + 'users': { + 'source_filenames': ( 'js/users.js', 'js/reportabuse.js', ), - 'messages': ( + 'output_filename': 'users-min.js' + }, + 'messages': { + 'source_filenames': ( 'js/markup.js', 'js/libs/jquery.autoresize.js', 'js/libs/jquery.tokeninput.js', @@ -311,19 +520,29 @@ 'js/ajaxpreview.js', 'js/messages.js', ), - 'mobile/messages': ( + 'output_filename': 'messages-min.js' + }, + 'mobile-messages': { + 'source_filenames': ( 'js/libs/jquery.tokeninput.js', 'js/users.autocomplete.js', ), - 'groups': ( + 'output_filename': 'mobile-messages-min.js' + }, + 'groups': { + 'source_filenames': ( 'js/libs/jquery.tokeninput.js', 'js/users.autocomplete.js', 'js/markup.js', 'js/groups.js', 'js/editable.js', ), - 'kpi.dashboard': ( + 'output_filename': 'groups-min.js' + }, + 'kpi.dashboard': { + 'source_filenames': ( 'js/kpi.dashboard.js', ), - }, + 'output_filename': 'kpi.dashboard-min.js' + } } diff --git a/kitsune/community/api.py b/kitsune/community/api.py new file mode 100644 index 00000000000..c874714d3ba --- /dev/null +++ b/kitsune/community/api.py @@ -0,0 +1,180 @@ +from collections import defaultdict +from datetime import datetime, timedelta + +from elasticutils import F +from rest_framework import views, fields +from rest_framework.response import Response + +from kitsune.questions.models import AnswerMetricsMappingType +from kitsune.users.models import UserMappingType + + +# This should be the higher than the max number of contributors for a +# section. There isn't a way to tell ES to just return everything. +BIG_NUMBER = 10000 + + +class TopContributorsQuestions(views.APIView): + + def get(self, request): + return Response(self.get_data(request)) + + def get_filters(self): + f = F(by_asker=False) + + self.filter_values = { + 'startdate': (datetime.now() - timedelta(days=90)).strftime('%Y-%m-%d'), + 'enddate': datetime.now().strftime('%Y-%m-%d'), + } + # request.GET is a multidict, so simple `.update(request.GET)` causes + # everything to be a list. This converts it into a plain single dict. + self.filter_values.update(dict(self.request.GET.items())) + + for key, value in self.filter_values.items(): + filter_method = getattr(self, 'filter_' + key, lambda v: F()) + f &= filter_method(value) + + return f + + def filter_startdate(self, value): + date = fields.DateField().from_native(value) + dt = datetime.combine(date, datetime.min.time()) + return F(created__gte=dt) + + def filter_enddate(self, value): + date = fields.DateField().from_native(value) + dt = datetime.combine(date, datetime.max.time()) + return F(created__lte=dt) + + def filter_username(self, value): + username_lower = value.lower() + + username_filter = ( + F(iusername__prefix=username_lower) | + F(idisplay_name__prefix=username_lower) | + F(itwitter_usernames__prefix=username_lower)) + + users = UserMappingType.reshape( + UserMappingType + .search() + .filter(username_filter) + .values_dict('id') + [:BIG_NUMBER]) + + return F(creator_id__in=[u['id'] for u in users]) + + def filter_locale(self, value): + return F(locale=value) + + def get_data(self, request): + # So filters can use the request. + self.request = request + + # This is the base of all the metrics. Each metric branches off from + # this to get a particular metric type, since we can't do Aggregates. + query = AnswerMetricsMappingType.search() + base_filter = self.get_filters() + + # This branch is to get the total number of answers for each user. + answer_query = ( + query + .filter(base_filter) + .facet('creator_id', filtered=True, size=BIG_NUMBER)) + + # This branch gets the number of answers that are solutions for each user. + solutions_filter = base_filter & F(is_solution=True) + solutions_query = ( + query + .filter(solutions_filter) + .facet('creator_id', filtered=True, size=BIG_NUMBER)) + + # This branch gets the number of helpful votes across all answers for + # each user. It is a raw facet because elasticutils only supports the + # term facet type in non-raw facets. Because it is raw facet, we have + # to also put the filter in the facet ourselves. + helpful_query = ( + query + .facet_raw( + creator_id={ + 'terms_stats': { + 'key_field': 'creator_id', + 'value_field': 'helpful_count', + }, + 'facet_filter': query._process_filters(base_filter.filters), + })) + + # Collect three lists of objects that correlates users and the appropriate metric count + creator_answer_counts = answer_query.facet_counts()['creator_id']['terms'] + creator_solutions_counts = solutions_query.facet_counts()['creator_id']['terms'] + creator_helpful_counts = helpful_query.facet_counts()['creator_id']['terms'] + + # Combine all the metric types into one big list. + combined = defaultdict(lambda: { + 'answer_count': 0, + 'solution_count': 0, + 'helpful_vote_count': 0, + }) + + for d in creator_answer_counts: + combined[d['term']]['user_id'] = d['term'] + combined[d['term']]['answer_count'] = d['count'] + + for d in creator_solutions_counts: + combined[d['term']]['user_id'] = d['term'] + combined[d['term']]['solution_count'] = d['count'] + + for d in creator_helpful_counts: + combined[d['term']]['user_id'] = d['term'] + # Since this is a term_stats filter, not just a term filter, it is total, not count. + combined[d['term']]['helpful_vote_count'] = d['total'] + + # Sort by answer count, and get just the ids into a list. + top_contributors = combined.values() + top_contributors.sort(key=lambda d: d['answer_count'], reverse=True) + user_ids = [c['user_id'] for c in top_contributors] + full_count = len(user_ids) + + # Paginate those user ids. + try: + page = int(self.request.GET.get('page', 1)) + except ValueError: + page = 1 + count = 10 + page_start = (page - 1) * count + page_end = page_start + count + user_ids = user_ids[page_start:page_end] + + # Get full user objects for every id on this page. + users = UserMappingType.reshape( + UserMappingType + .search() + .filter(id__in=user_ids) + .values_dict('id', 'username', 'display_name', 'avatar', 'last_contribution_date') + [:count]) + + # For ever user object found, mix in the metrics counts for that user, + # and then reshape the data to make more sense to clients. + data = [] + for u in users: + d = combined[u['id']] + d['user'] = u + d['last_contribution_date'] = d['user'].get('last_contribution_date', None) + if 'user_id' in d: + del d['user_id'] + del d['user']['id'] + if 'last_contribution_date' in d['user']: + del d['user']['last_contribution_date'] + data.append(d) + + # One last sort, since ES didn't return the users in any particular order. + data.sort(key=lambda d: d['answer_count'], reverse=True) + + # Add ranks to the objects. + for i, contributor in enumerate(data, 1): + contributor['rank'] = page_start + i + + return { + 'results': data, + 'count': full_count, + 'filters': self.filter_values, + } diff --git a/kitsune/community/static/js/TopContributors.jsx b/kitsune/community/static/js/TopContributors.jsx new file mode 100644 index 00000000000..c91c34838a8 --- /dev/null +++ b/kitsune/community/static/js/TopContributors.jsx @@ -0,0 +1,328 @@ +/* jshint esnext: true */ +/* globals React:false */ +export class CommunityResults extends React.Component { + render() { + var filters = this.props.data.filters; + var results = this.props.data.results; + var fullCount = this.props.data.count; + + var setfilters = this.props.setFilters; + var pageCount = Math.ceil(fullCount / Math.max(results.length, 1)); + + return
+ + + + +
; + } +} + +var CommunityHeader = React.createClass({ + render: function() { + return
+

Top Contributors - Support Forum

+

Last 90 days

+
; + }, +}); + +class CommunityFilters extends React.Component { + handleChange(ev) { + // React does some goofy stuff with events, so using when + // something like _.throttle, the event has already been destroyed + // by the time the throttled handler runs. So here we do it by hand. + var value = ev.target.value; + var newFilters = {page: null}; + + if (value === '') { + newFilters[ev.target.name] = null; + } else { + newFilters[ev.target.name] = value; + } + + clearTimeout(this._timer); + this._timer = setTimeout(this.props.setFilters.bind(null, newFilters), 200); + } + + makeInput(name) { + return + } + + render() { + return
+ {this.makeInput('username')} + {this.makeInput('startdate')} + {this.makeInput('enddate')} +
; + } +} + +var ContributorsTable = React.createClass({ + getInitialState: function() { + return { + selections: this.props.contributors.map(function() { return false; }) + }; + }, + + handleSelection: function(index, value) { + var selections = this.state.selections; + selections[index] = value; + this.setState({selections: selections}); + }, + + handleSelectAll: function(value) { + var selections = this.state.selections; + selections = selections.map(function() { return value; }); + this.setState({selections: selections}); + }, + + render: function() { + if (this.props.contributors.length > 0) { + return
+ + + +
+
; + } else { + return

No contributors match filters.

; + } + } +}); + +var ContributorsTableHeader = React.createClass({ + handleChange: function(e) { + e.stopPropagation(); + this.props.onSelectAll(e.target.checked); + }, + + render: function() { + function and(a, b) { return a && b; } + var allSelected = this.props.selections.reduce(and, true); + + return ( + + + + + + Rank + Name + Answers + Solutions + Helpful Votes + Last Activity + + + + ); + } +}); + +var ContributorsTableBody = React.createClass({ + render: function() { + return ( + + {this.props.contributors.map(function(contributor, i) { + return ; + }.bind(this))} + + ); + } +}); + +var ContributorsTableRow = React.createClass({ + handleChange: function(e) { + e.stopPropagation(); + this.props.onSelection(e.target.checked); + }, + + render: function() { + return ( + + + + + + {this.props.rank} + + + + + + {this.props.answer_count} + + + {this.props.solution_count} + + + {this.props.helpful_vote_count} + + + + + + + + + ); + } +}); + +var UserChip = React.createClass({ + render: function() { + return ( + + + {this.props.display_name || this.props.username} + + ); + } +}); + +var RelativeTime = React.createClass({ + getDefaultProps: function() { + return { + future: true + }; + }, + + render: function() { + var timestamp = moment(this.props.timestamp); + if (!timestamp.isValid()) { + return Never; + } + // Limit to the present or the past, not the future. + if (!this.props.future && timestamp.isAfter(moment())) { + timestamp = moment(); + } + return ; + } +}); + +var Icon = React.createClass({ + render: function() { + return ; + } +}); + +var Paginator = React.createClass({ + changePage: function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + this.props.setFilters({page: ev.target.dataset.page}); + }, + + render: function() { + var currentPage = parseInt(this.props.filters.page); + if (isNaN(currentPage)) { + currentPage = 1; + } + var pageCount = this.props.pageCount; + var firstPage = Math.max(1, currentPage - 4); + var lastPage = Math.min(currentPage + 5, pageCount); + var pageSelectors = []; + var pageFilters; + var pageUrl; + + // Previous button + if (currentPage > 1) { + pageSelectors.push(); + } + + // First page button + if (firstPage >= 2) { + pageSelectors.push(); + } + if (firstPage >= 3) { + pageSelectors.push(); + } + + // Normal buttons + for (var i = firstPage; i <= lastPage; i++) { + pageSelectors.push(); + } + + // Next button + if (currentPage < pageCount) { + pageSelectors.push(); + } + + return
    {pageSelectors}
; + } +}); + +var PaginatorSelector = React.createClass({ + getDefaultProps: function() { + return { + page: 1, + text: null, + selected: false + } + }, + + render: function() { + var page = this.props.page; + var pageFilters = _.extend({}, this.props.filters, {page: page}); + var pageUrl = k.queryParamStringFromDict(pageFilters); + var liClasses = []; + var aClasses = []; + + if (this.props.selected) { + liClasses.push('selected'); + aClasses.push('btn-page'); + } + if (this.props.text) { + var textSlug = this.props.text.toLowerCase(); + liClasses.push(textSlug); + aClasses.push('btn-page'); + aClasses.push('btn-page-' + textSlug); + } + + return ( +
  • + + {this.props.text || this.props.page} + +
  • + ); + } +}); + + +var k = window.k || {}; +k.react = k.react || {}; +k.react.CommunityResults = CommunityResults; diff --git a/kitsune/community/static/js/community-new.browserify.js b/kitsune/community/static/js/community-new.browserify.js new file mode 100644 index 00000000000..d8382a48257 --- /dev/null +++ b/kitsune/community/static/js/community-new.browserify.js @@ -0,0 +1,51 @@ +/* jshint esnext: true */ +/* globals React:false */ +import {CommunityResults} from './TopContributors.jsx'; + +var mainContentEl = document.querySelector('#main-content'); +var filters = k.getQueryParamsAsDict() || {}; + +function firstLoad() { + var dataEl = document.querySelector('script[name="contributor-data"]'); + var data = JSON.parse(dataEl.innerHTML); + render(data); +} + +function setFilters(newFilters) { + var allSame = true; + _.map(newFilters, function(value, key) { + if (filters[key] !== value) { + allSame = false; + } + }); + + if (allSame) { + return; + } + + _.extend(filters, newFilters); + var qs = k.queryParamStringFromDict(filters); + history.replaceState(null, '', qs); + refresh(); +} + +window.setFilters = setFilters; + +function render(data) { + var el = ; + React.render(el, mainContentEl); +} + +function refresh() { + var qs = window.location.search; + var url = '/api/2/topcontributors/questions/' + qs; + $.getJSON(url) + .done(render) + .fail(function(err) { + mainContentEl.textContent = 'Something went wrong! ' + JSON.stringify(err); + }); +} + +firstLoad(); diff --git a/kitsune/community/static/less/community-new.less b/kitsune/community/static/less/community-new.less new file mode 100644 index 00000000000..3d103f19e1f --- /dev/null +++ b/kitsune/community/static/less/community-new.less @@ -0,0 +1,49 @@ +.community-results { + min-height: 550px; + + * { + box-sizing: border-box; + } + + h1, h2 { + text-align: center; + } +} + +table.top-contributors { + width: 100%; + + thead { + tr { + th { + padding-bottom: 5px; + } + } + } + + tbody { + tr { + height: 40px; + + td { + background: #fff; + border-bottom: 2px solid #EAEFF2; + min-width: 25px; + } + } + } + + [data-column="select"] { + text-align: center; + } +} + +.user-chip { + img { + height: 25px; + margin: -9px 5px 0 0; + position: relative; + top: 7px; + width: 25px; + } +} diff --git a/kitsune/community/templates/community/top_contributors_react.html b/kitsune/community/templates/community/top_contributors_react.html new file mode 100644 index 00000000000..963803fb383 --- /dev/null +++ b/kitsune/community/templates/community/top_contributors_react.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% set styles = ('common', 'community-new',) %} +{% set scripts = ('community-new',) %} + +{% if area == 'questions' %} + {% set title = _('Top Contributors - Support Forum') %} +{% elif area == 'kb' %} + {% set title = _('Top Contributors - Knowledge Base') %} +{% elif area == 'l10n' %} + {% set title = _('Top Contributors - Localization') %} +{% elif area == 'army-of-awesome' %} + {% set title = _('Top Contributors - Army of Awesome') %} +{% endif %} +{% set crumbs = [(url('community.home'), _('Community Hub')), + (None, title)] %} + +{% block content %} + +{% endblock %} diff --git a/kitsune/community/urls.py b/kitsune/community/urls.py index 2d075fed6b9..621efe96c69 100644 --- a/kitsune/community/urls.py +++ b/kitsune/community/urls.py @@ -7,4 +7,6 @@ url(r'^/search$', 'search', name='community.search'), url(r'^/top-contributors/(?P[\w-]+)$', 'top_contributors', name='community.top_contributors'), + url(r'^/top-contributors/(?P[\w-]+)/new$', 'top_contributors_new', + name='community.top_contributors_new'), ) diff --git a/kitsune/community/urls_api.py b/kitsune/community/urls_api.py new file mode 100644 index 00000000000..2b7512e7663 --- /dev/null +++ b/kitsune/community/urls_api.py @@ -0,0 +1,9 @@ +from django.conf.urls import patterns, url + +from kitsune.community import api + + +urlpatterns = patterns( + '', + url('^topcontributors/questions/$', api.TopContributorsQuestions.as_view()), +) diff --git a/kitsune/community/utils.py b/kitsune/community/utils.py index b24e21a2a37..ac9b1ad18d5 100644 --- a/kitsune/community/utils.py +++ b/kitsune/community/utils.py @@ -47,7 +47,7 @@ def top_contributors_l10n(start=None, end=None, locale=None, product=None, .facet('creator_id', filtered=True, size=BIG_NUMBER)) if locale is None: - # If there is no locale specified, exlude en-US only. The rest are + # If there is no locale specified, exclude en-US only. The rest are # l10n. query = query.filter(~F(locale=settings.WIKI_DEFAULT_LANGUAGE)) diff --git a/kitsune/community/views.py b/kitsune/community/views.py index e0744b3d011..55ce2cdd151 100644 --- a/kitsune/community/views.py +++ b/kitsune/community/views.py @@ -1,3 +1,4 @@ + import logging from datetime import datetime @@ -5,8 +6,10 @@ from django.http import Http404 from django.shortcuts import render, get_object_or_404 +from rest_framework.renderers import JSONRenderer from statsd import statsd +from kitsune.community import api from kitsune.community.utils import ( top_contributors_aoa, top_contributors_questions, top_contributors_kb, top_contributors_l10n) @@ -175,6 +178,14 @@ def top_contributors(request, area): }) +def top_contributors_new(request, area): + to_json = JSONRenderer().render + contributors = api.TopContributorsQuestions().get_data(request) + return render(request, 'community/top_contributors_react.html', { + 'contributors_json': to_json(contributors), + }) + + def _validate_locale(locale): """Make sure the locale is enabled on SUMO.""" if locale and locale not in settings.SUMO_LANGUAGES: diff --git a/kitsune/customercare/templates/customercare/base.html b/kitsune/customercare/templates/customercare/base.html index 52acc79c585..e910bebcb39 100644 --- a/kitsune/customercare/templates/customercare/base.html +++ b/kitsune/customercare/templates/customercare/base.html @@ -1,3 +1,3 @@ {% extends "base.html" %} -{% set styles = ('customercare', 'jqueryui/jqueryui') %} -{% set scripts = ('customercare', 'libs/jqueryui') %} +{% set styles = ('customercare', 'jqueryui') %} +{% set scripts = ('customercare', 'jqueryui') %} diff --git a/kitsune/dashboards/templates/dashboards/aggregated_metrics.html b/kitsune/dashboards/templates/dashboards/aggregated_metrics.html index 84f7af5b1dc..9d6760f4300 100644 --- a/kitsune/dashboards/templates/dashboards/aggregated_metrics.html +++ b/kitsune/dashboards/templates/dashboards/aggregated_metrics.html @@ -2,7 +2,7 @@ {% from "dashboards/includes/macros.html" import localization_sidebar_nav %} {% from "dashboards/includes/macros.html" import product_choice_list with context %} {% set title = _('Aggregated Localization Metrics') %} -{% set scripts = ('wiki', 'rickshaw', 'wiki.dashboard') %} +{% set scripts = ('wiki', 'jqueryui', 'rickshaw', 'wiki.dashboard') %} {% set styles = ('rickshaw', 'kbdashboards') %} {% set crumbs = [(None, title)] %} {% set classes = 'aggregated-metrics' %} diff --git a/kitsune/dashboards/templates/dashboards/locale_metrics.html b/kitsune/dashboards/templates/dashboards/locale_metrics.html index f286cb35081..fd1d3621832 100644 --- a/kitsune/dashboards/templates/dashboards/locale_metrics.html +++ b/kitsune/dashboards/templates/dashboards/locale_metrics.html @@ -2,7 +2,7 @@ {% from "dashboards/includes/macros.html" import localization_sidebar_nav %} {% from "dashboards/includes/macros.html" import product_choice_list with context %} {% set title = _('[{locale}] Locale Metrics')|f(locale=current_locale) %} -{% set scripts = ('wiki', 'rickshaw', 'wiki.dashboard') %} +{% set scripts = ('wiki', 'jqueryui', 'rickshaw', 'wiki.dashboard') %} {% set styles = ('rickshaw', 'kbdashboards') %} {% set crumbs = [(None, title)] %} {% set classes = 'locale-metrics' %} diff --git a/kitsune/forums/templates/forums/edit_post.html b/kitsune/forums/templates/forums/edit_post.html index 9a0afa8b2bd..a550bf6ec44 100644 --- a/kitsune/forums/templates/forums/edit_post.html +++ b/kitsune/forums/templates/forums/edit_post.html @@ -7,8 +7,8 @@ (url('forums.threads', forum.slug), forum.name), (url('forums.posts', forum.slug, thread.id), thread.title), (None, _('Edit a post'))] %} -{% set styles = ('forums', 'jqueryui/jqueryui') %} -{% set scripts = ('forums', 'libs/jqueryui') %} +{% set styles = ('forums', 'jqueryui') %} +{% set scripts = ('forums', 'jqueryui') %} {% block content %}
    diff --git a/kitsune/forums/templates/forums/new_thread.html b/kitsune/forums/templates/forums/new_thread.html index 82541504691..06027f70635 100644 --- a/kitsune/forums/templates/forums/new_thread.html +++ b/kitsune/forums/templates/forums/new_thread.html @@ -6,8 +6,8 @@ {% set crumbs = [(url('forums.forums'), _('Forums')), (url('forums.threads', forum.slug), forum.name), (None, _('Create a new thread'))] %} -{% set styles = ('forums', 'jqueryui/jqueryui') %} -{% set scripts = ('forums', 'libs/jqueryui') %} +{% set styles = ('forums', 'jqueryui') %} +{% set scripts = ('forums', 'jqueryui') %} {% block content %}
    diff --git a/kitsune/forums/templates/forums/posts.html b/kitsune/forums/templates/forums/posts.html index c1428e4f214..9b789a6ac8a 100644 --- a/kitsune/forums/templates/forums/posts.html +++ b/kitsune/forums/templates/forums/posts.html @@ -11,8 +11,8 @@ {% if posts.number > 1 %} {% set canonical_url = canonical_url|urlparams(page=posts.number) %} {% endif %} -{% set styles = ('forums', 'jqueryui/jqueryui') %} -{% set scripts = ('forums', 'libs/jqueryui') %} +{% set styles = ('forums', 'jqueryui') %} +{% set scripts = ('forums', 'jqueryui') %} {% block above_main %}

    {{ forum.name }}

    diff --git a/kitsune/kbforums/templates/kbforums/edit_post.html b/kitsune/kbforums/templates/kbforums/edit_post.html index 5dc0f54d95e..ebffdd2e6a4 100644 --- a/kitsune/kbforums/templates/kbforums/edit_post.html +++ b/kitsune/kbforums/templates/kbforums/edit_post.html @@ -8,8 +8,8 @@ (url('wiki.discuss.threads', document.slug), _('Discuss')), (url('wiki.discuss.posts', document.slug, thread.id), thread.title), (None, _('Edit a post'))] %} -{% set styles = ('forums', 'jqueryui/jqueryui') %} -{% set scripts = ('forums', 'libs/jqueryui') %} +{% set styles = ('forums', 'jqueryui') %} +{% set scripts = ('forums', 'jqueryui') %} {% block content %}
    diff --git a/kitsune/kbforums/templates/kbforums/new_thread.html b/kitsune/kbforums/templates/kbforums/new_thread.html index bd824a60598..2b207b52f78 100644 --- a/kitsune/kbforums/templates/kbforums/new_thread.html +++ b/kitsune/kbforums/templates/kbforums/new_thread.html @@ -7,8 +7,8 @@ {% set crumbs = [(document.get_absolute_url(), document.title), (url('wiki.discuss.threads', document.slug), _('Discuss')), (None, _('Create a new thread'))] %} -{% set styles = ('forums', 'jqueryui/jqueryui') %} -{% set scripts = ('forums', 'libs/jqueryui') %} +{% set styles = ('forums', 'jqueryui') %} +{% set scripts = ('forums', 'jqueryui') %} {% block content %}
    diff --git a/kitsune/kbforums/templates/kbforums/posts.html b/kitsune/kbforums/templates/kbforums/posts.html index e0db2833def..abc4dbf3f1d 100644 --- a/kitsune/kbforums/templates/kbforums/posts.html +++ b/kitsune/kbforums/templates/kbforums/posts.html @@ -7,8 +7,8 @@ {% set crumbs = [(document.get_absolute_url(), document.title), (url('wiki.discuss.threads', document.slug), _('Discuss')), (None, thread.title)] %} -{% set styles = ('forums', 'jqueryui/jqueryui') %} -{% set scripts = ('forums', 'libs/jqueryui') %} +{% set styles = ('forums', 'jqueryui') %} +{% set scripts = ('forums', 'jqueryui') %} {% block side_top_top %}