SubredditRules: Part 2: Markup changes and backbone views.

This commit is contained in:
Matt Lee
2015-11-18 14:55:15 -08:00
committed by MelissaCole
parent 7126b39749
commit 98a1ebff4b
6 changed files with 560 additions and 64 deletions

View File

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

View File

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

View File

@@ -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('<div class="subreddit-rule-item"></div>')[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);

View File

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

View File

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

View File

@@ -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"/>
<div>
%if thing.rules:
<table class="usertable" style="white-space: normal">
<tr>
<td class="usertable">Priority</td>
<td class="usertable">${_('Short name')}</td>
<td class="usertable">${_('Description')}</td>
</tr>
<%!
from r2.lib.filters import keep_space, unsafe, safemarkdown
%>
%for rule in thing.rules:
<tr>
<td>${rule["priority"]}</td>
<td>${rule["short_name"]}</td>
<td>${md(rule["description"])}</td>
</tr>
%endfor
</table>
%endif
<%def name="mod_action_icon(name, title)">
<span class="mod-action-icon mod-action-icon-${name}"
title="${title}"
alt="${title}"></span>
</%def>
%if thing.can_edit:
<form method="post" action="/api/add_subreddit_rule"
onsubmit="return post_form(this, 'add_subreddit_rule')">
<label for="short_name">short name</label>
<textarea name="short_name" value=""></textarea>
<br>
<label for="description">description</label>
<textarea name="description" rows=4></textarea>
<input type="submit" value="Add a new rule">
</form>
<div class="subreddit-rules-page ${'editable' if thing.can_edit else ''}">
<header class="md-container">
<div class="md">
<h2>Community Rules</h2>
<p>
Rules that visitors must follow to participate. May be used as
reasons to report or ban.
</p>
</div>
</header>
<br>
<form method="post" action="/api/update_subreddit_rule"
onsubmit="return post_form(this, 'update_subreddit_rule')">
<label for="old_short_name">old short name</label>
<textarea name="old_short_name" value=""></textarea>
<br>
<label for="short_name">short name</label>
<textarea name="short_name" value=""></textarea>
<br>
<label for="description">description</label>
<textarea name="description" rows=4></textarea>
<input type="submit" value="Update rule">
</form>
<div class="md-container-small">
<div id="subreddit-rule-list" class="md">
%if thing.rules:
%for rule in thing.rules:
<% description_html = unsafe(safemarkdown(rule['description'], wrap=False)) %>
<div class="subreddit-rule-item"
data-priority="${rule['priority']}"
data-description="${keep_space(rule['description'])}">
${self.subreddit_rule(
short_name=rule['short_name'],
description=description_html,
editable=thing.can_edit,
)}
</div>
%endfor
%endif
</div>
</div>
<br>
<form method="post" action="/api/reorder_subreddit_rule"
onsubmit="return post_form(this, 'reorder_subreddit_rule')">
<label for="short_name">short name</label>
<input type="text" name="short_name" size="2" value="0"></textarea>
<br>
<label for="priority">priority</label>
<input type="text" name="priority" size="2" value="0"></textarea>
<input type="submit" value="Reorder rule">
</form>
%if thing.can_edit:
<footer class="md-container-small">
<div id="subreddit-rule-add-form" class="md" hidden>
<div class="subreddit-rule-add-form-buttons">
<button class="subreddit-rule-edit-button">
${self.mod_action_icon('add', _('Add a new rule.'))}&#32;
Add a rule
</button>
<div class="subreddit-rule-too-many-notice" hidden>
You have reached the maximum number of rules.
</div>
</div>
</div>
</footer>
<br>
<form method="post" action="/api/remove_subreddit_rule"
onsubmit="return post_form(this, 'remove_subreddit_rule')" style="padding:20px">
<label for="short_name">short_name to delete</label>
<input type="text" name="short_name" size="50" value="0"></textarea>
<input type="submit" value="Delete this rule">
</form>
%endif
<script id="subreddit-rule-template" type="text/template">
${self.subreddit_rule(
short_name=unsafe("<%= short_name %>"),
description=unsafe("<%= description_html %>"),
editable=True,
)}
</script>
<script id="subreddit-rule-form-template" type="text/template">
${self.subreddit_rule_form(
short_name=unsafe("<%= short_name %>"),
description=unsafe("<%= description %>"),
)}
</script>
%endif
</div>
<%def name="subreddit_rule(short_name='', description='', editable=False)">
<div class="subreddit-rule ${'editable' if editable else ''}">
<div class="subreddit-rule-contents">
<div class="subreddit-rule-contents-display">
<h4 class="subreddit-rule-title">
${short_name}
</h4>
<div class="subreddit-rule-description">
${description}
</div>
</div>
%if editable:
<div class="subreddit-rule-buttons">
<button class="subreddit-rule-delete-button">
${self.mod_action_icon('delete', _('Delete this rule.'))}
</button>
<button class="subreddit-rule-edit-button">
${self.mod_action_icon('edit', _('Edit this rule.'))}
</button>
</div>
%endif
</div>
%if editable:
<div class="subreddit-rule-delete-confirmation" hidden>
Delete this rule?&#32;
<button class="subreddit-rule-delete-button">delete</button>
&#32;|&#32;
<button class="subreddit-rule-cancel-button">cancel</button>
</div>
%endif
</div>
</%def>
<%def name="subreddit_rule_form(short_name='', description='')">
<form method="post" class="subreddit-rule-form">
<div class="form-inputs">
<div class="c-form-group form-group-short_name">
<div>
<label for="short_name" class="required">Short name</label>
<div class="text-counter">
<span class="text-counter-display"></span>&#32;
remaining
</div>
</div>
<input type="text" class="c-form-control text-counter-input" name="short_name" value="${short_name}">
<div class="error-fields">
${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")}
</div>
</div>
<div class="c-form-group form-group-description">
<label for="description">Full description of this rule</label>
<div class="text-counter">
<span class="text-counter-display" rel="description"></span>&#32;
remaining
</div>
<textarea class="c-form-control text-counter-input" name="description" rows=4>${description}</textarea>
<div class="error-fields">
${error_field("TOO_LONG", "description")}
</div>
</div>
</div>
<div class="form-buttons">
<button type="reset" class="subreddit-rule-cancel-button">
${self.mod_action_icon('cancel', _('Cancel this action.'))}
</button>
<button type="submit" class="subreddit-rule-submit-button">
${self.mod_action_icon('confirm', _('Confirm this action.'))}
</button>
</div>
${error_field("UNKNOWN_ERROR", "unknown")}
</form>
</%def>