From 98a1ebff4b4228fdb55bbdcbb4e638d034ddd2af Mon Sep 17 00:00:00 2001 From: Matt Lee Date: Wed, 18 Nov 2015 14:55:15 -0800 Subject: [PATCH] SubredditRules: Part 2: Markup changes and backbone views. --- r2/r2/controllers/front.py | 2 +- r2/r2/lib/js.py | 4 + .../public/static/js/edit-subreddit-rules.js | 367 ++++++++++++++++++ r2/r2/public/static/js/errors.js | 7 + .../public/static/js/models/subreddit-rule.js | 43 +- r2/r2/templates/rules.html | 201 +++++++--- 6 files changed, 560 insertions(+), 64 deletions(-) create mode 100644 r2/r2/public/static/js/edit-subreddit-rules.js diff --git a/r2/r2/controllers/front.py b/r2/r2/controllers/front.py index 25e4b6004..5f51114be 100644 --- a/r2/r2/controllers/front.py +++ b/r2/r2/controllers/front.py @@ -945,7 +945,7 @@ class FrontController(RedditController): """Get the rules for the current subreddit""" if not feature.is_enabled("subreddit_rules", subreddit=c.site.name): abort(404) - return Reddit(content=Rules()).render() + return ModToolsPage(content=Rules()).render() @require_oauth2_scope("read") @api_doc(api_section.subreddits, uses_site=True) diff --git a/r2/r2/lib/js.py b/r2/r2/lib/js.py index e0e6901d9..bf7e2c81c 100644 --- a/r2/r2/lib/js.py +++ b/r2/r2/lib/js.py @@ -551,6 +551,10 @@ module["reddit"] = LocalizedModule("reddit.js", ) module["modtools"] = Module("modtools.js", + "errors.js", + "models/validators.js", + "models/subreddit-rule.js", + "edit-subreddit-rules.js", wrap=catch_errors, ) diff --git a/r2/r2/public/static/js/edit-subreddit-rules.js b/r2/r2/public/static/js/edit-subreddit-rules.js new file mode 100644 index 000000000..4deaa7e63 --- /dev/null +++ b/r2/r2/public/static/js/edit-subreddit-rules.js @@ -0,0 +1,367 @@ +/* +requires Backbone +requires r.errors +requires r.models.SubredditRule +requires r.models.SubredditRuleCollection +requires r.ui.TextCounter + */ + +!function(r, Backbone, undefined) { + var SubredditRuleBaseView = Backbone.View.extend({ + countersInitialized: false, + state: '', + + DEFAULT_STATE: '', + EDITING_STATE: 'editing', + + events: { + 'click .subreddit-rule-delete-button': function onDelete(e) { + e.preventDefault(); + this.delete(); + }, + + 'click .subreddit-rule-edit-button': function onEdit(e) { + e.preventDefault(); + this.edit(); + }, + + 'click .subreddit-rule-cancel-button': function onCancel(e) { + e.preventDefault(); + this.cancel(); + }, + + 'submit form': function onSubmit(e) { + e.preventDefault(); + this.submit(); + }, + }, + + initialize: function(options) { + this.formTemplate = options.formTemplate; + }, + + delegateEvents: function() { + SubredditRuleBaseView.__super__.delegateEvents.apply(this, arguments); + + this.listenTo(this.model, 'request', this.disableForm); + this.listenTo(this.model, 'invalid', function(model, error) { + this.model.revert(); + this.showErrors([error]); + }); + this.listenTo(this.model, 'error', function(model, errors) { + this.model.revert(); + this.showErrors(errors); + }); + }, + + setState: function(state) { + if (this.state !== state) { + this.state = state; + this.render(); + } + }, + + initCounters: function() { + if (this.countersInitialized) { + return; + } + + var shortNameCounter = this.$el.find('.form-group-short_name')[0]; + var descriptionCounter = this.$el.find('.form-group-description')[0]; + + if (!shortNameCounter) { + return; + } + + this.shortNameCounter = new r.ui.TextCounter({ + el: shortNameCounter, + maxLength: this.model.SHORT_NAME_MAX_LENGTH, + initialText: this.model.get('short_name'), + }); + this.descriptionCounter = new r.ui.TextCounter({ + el: descriptionCounter, + maxLength: this.model.DESCRIPTION_MAX_LENGTH, + initialText: this.model.get('description'), + }); + this.countersInitialized = true; + }, + + removeCounters: function() { + if (!this.countersInitialized) { + return; + } + + this.shortNameCounter.remove(); + this.descriptionCounter.remove(); + this.shortNameCounter = null; + this.shortNameCounter = null; + this.countersInitialized = false; + }, + + delete: function() { + this.model.destroy(); + }, + + submit: function() { + var $form = this.$el.find('form'); + var formData = get_form_fields($form); + this.model.save(formData); + }, + + disableForm: function() { + r.errors.clearAPIErrors(this.$el); + this.$el.find('input, button') + .attr('disabled', true); + }, + + enableForm: function() { + this.$el.find('input, button') + .removeAttr('disabled'); + }, + + showErrors: function(errors) { + r.errors.clearAPIErrors(this.$el); + r.errors.showAPIErrors(this.$el, errors); + this.enableForm(); + }, + + render: function() { + this.removeCounters(); + this.renderTemplate(); + this.initCounters(); + }, + + focus: function() { + this.$el.find('input, textarea').get(0).focus(); + }, + }); + + + var SubredditRuleView = SubredditRuleBaseView.extend({ + DELETING_STATE: 'deleting', + + initialize: function(options) { + SubredditRuleView.__super__.initialize.apply(this, arguments); + this.ruleTemplate = options.ruleTemplate; + }, + + delegateEvents: function() { + SubredditRuleView.__super__.delegateEvents.apply(this, arguments); + this.listenTo(this.model, 'sync:update', this.cancel); + this.listenTo(this.model, 'sync:delete', this.remove); + }, + + delete: function() { + if (this.state === this.DELETING_STATE) { + this.model.destroy(); + } else if (this.state === this.DEFAULT_STATE) { + this.setState(this.DELETING_STATE); + } + }, + + edit: function() { + if (this.state === this.DEFAULT_STATE) { + this.setState(this.EDITING_STATE); + this.focus(); + } + }, + + cancel: function() { + if (this.state === this.EDITING_STATE || + this.state === this.DELETING_STATE) { + this.$el.removeClass('mod-action-deleting'); + this.setState(this.DEFAULT_STATE); + } + }, + + render: function() { + SubredditRuleView.__super__.render.call(this); + + if (this.state === this.DELETING_STATE) { + this.$el.addClass('mod-action-deleting'); + this.$el.find('.subreddit-rule-delete-confirmation').removeAttr('hidden'); + this.$el.find('.subreddit-rule-buttons button').attr('disabled', true); + } + }, + + renderTemplate: function() { + var modelData = this.model.toJSON(); + + if (this.state === this.EDITING_STATE) { + this.$el.html(this.formTemplate(modelData)); + } else { + this.$el.html(this.ruleTemplate(modelData)); + } + }, + }); + + + var AddSubredditRuleView = SubredditRuleBaseView.extend({ + DISABLED_STATE: 'disabled', + + initialize: function(options) { + AddSubredditRuleView.__super__.initialize.apply(this, arguments); + this.collection = options.collection; + this.initializeNewModel(); + this.$collapsedDisplay = this.$el.find('.subreddit-rule-add-form-buttons'); + this.$maxRulesNotice = this.$collapsedDisplay.find('.subreddit-rule-too-many-notice'); + + this.$el.removeAttr('hidden'); + + if (this.collection._disabled) { + this.setState(this.DISABLED_STATE); + } + }, + + delegateEvents: function() { + AddSubredditRuleView.__super__.delegateEvents.apply(this, arguments); + this.listenTo(this.collection, 'enabled', function() { + if (this.state === this.DISABLED_STATE) { + this.setState(this.DEFAULT_STATE); + } + }) + this.listenTo(this.collection, 'disabled', function() { + this.setState(this.DISABLED_STATE); + }); + this.listenTo(this.model, 'sync:create', this._handleRuleCreated); + }, + + initializeNewModel: function() { + var Model = this.collection.model; + this.model = new Model(undefined, { collection: this.collection }); + }, + + _handleRuleCreated: function(model) { + this.undelegateEvents(); + this.initializeNewModel(); + this.delegateEvents(); + this.cancel(); + this.trigger('success', model); + }, + + edit: function() { + if (this.state === this.DEFAULT_STATE) { + this.setState(this.EDITING_STATE); + } + }, + + cancel: function() { + if (this.state === this.EDITING_STATE) { + this.setState(this.DEFAULT_STATE); + } + }, + + render: function() { + this.$collapsedDisplay.detach(); + AddSubredditRuleView.__super__.render.call(this); + + if (this.state === this.DISABLED_STATE) { + this.$maxRulesNotice.removeAttr('hidden'); + this.$el.append(this.$collapsedDisplay); + this.disableForm(); + } else if (this.state === this.DEFAULT_STATE) { + this.$maxRulesNotice.attr('hidden', true); + this.$el.append(this.$collapsedDisplay); + this.enableForm(); + } else if (this.state === this.EDITING_STATE) { + this.focus(); + } + }, + + renderTemplate: function() { + if (this.state !== this.EDITING_STATE) { + this.$el.empty(); + } else { + var modelData = this.model.toJSON(); + this.$el.html(this.formTemplate(modelData)); + } + }, + }); + + + var SubredditRulesPage = Backbone.View.extend({ + initialize: function(options) { + this.ruleTemplate = options.ruleTemplate; + this.formTemplate = options.formTemplate; + this.collection = new r.models.SubredditRuleCollection(); + this.newRuleForm = new AddSubredditRuleView({ + el: options.addForm, + collection: this.collection, + formTemplate: this.formTemplate, + }); + + // initialize views for the rules prerendered on the page + var ruleItems = this.$el.find('.subreddit-rule-item').toArray(); + ruleItems.forEach(function(el) { + var model = this.createSubredditRuleModel(el); + this.createSubredditRuleView(el, model); + }, this); + }, + + delegateEvents: function() { + SubredditRulesPage.__super__.delegateEvents.apply(this, arguments); + + this.listenTo(this.newRuleForm, 'success', function(model) { + // need to defer this, otherwise event listeners added to the model + // by the new view will get called by the active event :( + setTimeout(function() { + this.addNewRule(model); + }.bind(this)); + }); + }, + + createSubredditRuleModel(el) { + var $el = $(el); + + return new r.models.SubredditRule({ + priority: parseInt($el.data('priority'), 10), + short_name: $el.find('.subreddit-rule-title').text(), + description: $el.data('description'), + description_html: $el.find('.subreddit-rule-description').html(), + }); + }, + + createSubredditRuleView: function(el, model) { + this.collection.add(model); + + return new SubredditRuleView({ + el: el, + model: model, + ruleTemplate: this.ruleTemplate, + formTemplate: this.formTemplate, + }); + }, + + addNewRule: function(model) { + var el = $.parseHTML('
')[0]; + var view = this.createSubredditRuleView(el, model); + view.render(); + this.$el.append(el); + }, + }); + + + $(function() { + var $page = $('.subreddit-rules-page'); + + if (!$page.hasClass('editable')) { + return; + } + + var ruleTemplate = document.getElementById('subreddit-rule-template'); + var formTemplate = document.getElementById('subreddit-rule-form-template'); + var addForm = document.getElementById('subreddit-rule-add-form'); + var ruleList = document.getElementById('subreddit-rule-list'); + + if (!ruleTemplate || !formTemplate) { + throw 'Subreddit rule templates not found!'; + } + + new SubredditRulesPage({ + el: ruleList, + addForm: addForm, + ruleTemplate: _.template(ruleTemplate.innerHTML), + formTemplate: _.template(formTemplate.innerHTML), + }); + }); +}(r, Backbone); diff --git a/r2/r2/public/static/js/errors.js b/r2/r2/public/static/js/errors.js index fb449a44d..93c05b3cd 100644 --- a/r2/r2/public/static/js/errors.js +++ b/r2/r2/public/static/js/errors.js @@ -1,5 +1,6 @@ !function(r) { var errors = { + 'UNKNOWN_ERROR': r._('unknown error %(message)s'), 'NO_TEXT': r._('we need something here'), 'TOO_LONG': r._('this is too long (max: %(max_length)s)'), 'TOO_SHORT': r._('this is too short (min: %(min_length)s)'), @@ -78,6 +79,12 @@ getAPIErrorsFromResponse: function(res) { if (res && res.json && res.json.errors && res.json.errors.length) { return res.json.errors.map(r.errors.formatAPIError); + } else if (!res || (res.error && typeof res.error === 'string')) { + var message = !res ? 'unknown' : res.error; + // return an array here for consistency + return [ + r.errors.createAPIError('', 'UNKNOWN_ERROR', { message: message }), + ]; } }, diff --git a/r2/r2/public/static/js/models/subreddit-rule.js b/r2/r2/public/static/js/models/subreddit-rule.js index 94854f74f..a2b0eaf47 100644 --- a/r2/r2/public/static/js/models/subreddit-rule.js +++ b/r2/r2/public/static/js/models/subreddit-rule.js @@ -11,6 +11,15 @@ var DESCRIPTION_MAX_LENGTH = 500; + function ValidRulesLength(attrName, maxLength) { + return function validate(collection) { + if (collection.length > maxLength) { + return r.errors.createAPIError(attrName, 'SR_RULE_TOO_MANY'); + } + } + } + + function ValidRule(attrName) { var vLength = r.models.validators.StringLength(attrName, 1, SHORT_NAME_MAX_LENGTH); @@ -18,9 +27,12 @@ var collection = model.collection; var isNew = model.isNew(); - if (collection) { - if (isNew && collection.length >= collection.maxLength) { - return r.errors.createAPIError(attrName, 'SR_RULE_TOO_MANY'); + if (collection && isNew) { + var vRulesLength = ValidRulesLength(attrName, collection.maxLength - 1); + var collectionError = vRulesLength(collection); + + if (collectionError) { + return collectionError; } } @@ -144,6 +156,9 @@ this.trigger('sync:' + method, this); this.trigger('sync', this, method); + }.bind(this), undefined, undefined, undefined, function(res) { + var errors = r.errors.getAPIErrorsFromResponse(res); + this.trigger('error', this, errors); }.bind(this)); }, @@ -153,9 +168,29 @@ }); + var RULES_COLLECTION_MAX_LENGTH = 10; + var SubredditRuleCollection = Backbone.Collection.extend({ model: SubredditRule, - maxLength: 10, + maxLength: RULES_COLLECTION_MAX_LENGTH, + + initialize: function() { + this._disabled = this.length >= this.maxLength; + + this.on('add', function() { + if (!this._disabled && this.length >= this.maxLength) { + this._disabled = true; + this.trigger('disabled'); + } + }.bind(this)); + + this.on('remove', function() { + if (this._disabled && this.length < this.maxLength) { + this._disabled = false; + this.trigger('enabled'); + } + }.bind(this)); + }, }); diff --git a/r2/r2/templates/rules.html b/r2/r2/templates/rules.html index d8dc421d0..c8e599971 100644 --- a/r2/r2/templates/rules.html +++ b/r2/r2/templates/rules.html @@ -20,70 +20,153 @@ ## reddit Inc. All Rights Reserved. ############################################################################### -<%namespace file="utils.html" import="error_field, md"/> +<%namespace file="utils.html" import="error_field"/> <%namespace name="utils" file="utils.html"/> -
- %if thing.rules: - - - - - - +<%! + from r2.lib.filters import keep_space, unsafe, safemarkdown +%> - %for rule in thing.rules: - - - - - - %endfor -
Priority${_('Short name')}${_('Description')}
${rule["priority"]}${rule["short_name"]}${md(rule["description"])}
- %endif +<%def name="mod_action_icon(name, title)"> + + - %if thing.can_edit: -
- - -
- - - -
+
+
+
+

Community Rules

+

+ Rules that visitors must follow to participate. May be used as + reasons to report or ban. +

+
+
-
-
- - -
- - -
- - - -
+
+
+ %if thing.rules: + %for rule in thing.rules: + <% description_html = unsafe(safemarkdown(rule['description'], wrap=False)) %> +
+ ${self.subreddit_rule( + short_name=rule['short_name'], + description=description_html, + editable=thing.can_edit, + )} +
+ %endfor + %endif +
+
-
-
- - -
- - - -
+ %if thing.can_edit: +
+ +
-
-
- - - -
- %endif + + + %endif
+ +<%def name="subreddit_rule(short_name='', description='', editable=False)"> +
+
+
+

+ ${short_name} +

+
+ ${description} +
+
+ %if editable: +
+ + +
+ %endif +
+ %if editable: + + %endif +
+ + +<%def name="subreddit_rule_form(short_name='', description='')"> +
+
+
+
+ +
+ + remaining +
+
+ +
+ ${error_field("TOO_SHORT", "short_name")} + ${error_field("NO_TEXT", "short_name")} + ${error_field("TOO_LONG", "short_name")} + ${error_field("SR_RULE_EXISTS", "short_name")} + ${error_field("SR_RULE_TOO_MANY", "short_name")} +
+
+
+ +
+ + remaining +
+ +
+ ${error_field("TOO_LONG", "description")} +
+
+
+
+ + +
+ ${error_field("UNKNOWN_ERROR", "unknown")} +
+