FEATURE: Admin interface for adding custom fields for users

This commit is contained in:
Robin Ward 2014-09-25 11:32:08 -04:00
parent a3e2e1fa6e
commit 0fc0533134
23 changed files with 482 additions and 2 deletions

View File

@ -0,0 +1,43 @@
import UserField from 'admin/models/user-field';
import BufferedContent from 'discourse/mixins/buffered-content';
export default Ember.ObjectController.extend(BufferedContent, {
needs: ['admin-user-fields'],
editing: Ember.computed.empty('id'),
fieldName: function() {
return UserField.fieldTypeById(this.get('field_type')).get('name');
}.property('field_type'),
actions: {
save: function() {
var self = this;
this.commitBuffer();
this.get('model').save().then(function(res) {
self.set('model.id', res.user_field.id);
self.set('editing', false);
}).catch(function() {
bootbox.alert(I18n.t('generic_error'));
});
},
edit: function() {
this.set('editing', true);
},
destroy: function() {
this.get('controllers.admin-user-fields').send('destroy', this.get('model'));
},
cancel: function() {
var id = this.get('id');
if (Ember.empty(id)) {
this.get('controllers.admin-user-fields').send('destroy', this.get('model'));
} else {
this.rollbackBuffer();
this.set('editing', false);
}
}
}
});

View File

@ -0,0 +1,36 @@
import UserField from 'admin/models/user-field';
export default Ember.ArrayController.extend({
fieldTypes: null,
createDisabled: Em.computed.gte('model.length', 3),
_performDestroy: function(f, model) {
return f.destroy().then(function() {
model.removeObject(f);
});
},
actions: {
createField: function() {
this.pushObject(UserField.create({
field_type: 'text',
name: I18n.t('admin.user_fields.untitled')
}));
},
destroy: function(f) {
var model = this.get('model'),
self = this;
// Only confirm if we already been saved
if (f.get('id')) {
bootbox.confirm(I18n.t("admin.user_fields.delete_confirm"), function(result) {
if (result) { self._performDestroy(f, model); }
});
} else {
self._performDestroy(f, model);
}
}
}
});

View File

@ -0,0 +1,54 @@
var _fieldTypes = [
Ember.Object.create({id: 'text', name: I18n.t('admin.user_fields.field_types.text') }),
Ember.Object.create({id: 'confirm', name: I18n.t('admin.user_fields.field_types.confirm') })
];
var UserField = Ember.Object.extend({
destroy: function() {
var self = this;
return new Ember.RSVP.Promise(function(resolve) {
var id = self.get('id');
if (id) {
return Discourse.ajax("/admin/customize/user_fields/" + id, { type: 'DELETE' }).then(function() {
resolve();
});
}
resolve();
});
},
save: function() {
var id = this.get('id');
if (!id) {
return Discourse.ajax("/admin/customize/user_fields", {
type: "POST",
data: { user_field: this.getProperties('name', 'field_type') }
});
} else {
return Discourse.ajax("/admin/customize/user_fields/" + id, {
type: "PUT",
data: { user_field: this.getProperties('name', 'field_type') }
});
}
}
});
UserField.reopenClass({
findAll: function() {
return Discourse.ajax("/admin/customize/user_fields").then(function(result) {
return result.user_fields.map(function(uf) {
return UserField.create(uf);
});
});
},
fieldTypes: function() {
return _fieldTypes;
},
fieldTypeById: function(id) {
return _fieldTypes.findBy('id', id);
}
});
export default UserField;

View File

@ -0,0 +1,14 @@
import UserField from 'admin/models/user-field';
export default Ember.Route.extend({
model: function() {
return UserField.findAll();
},
setupController: function(controller, model) {
controller.setProperties({
model: model,
fieldTypes: UserField.fieldTypes()
});
}
});

View File

@ -18,6 +18,8 @@ Discourse.Route.buildRoutes(function() {
this.resource('adminSiteText', { path: '/site_text' }, function() {
this.route('edit', {path: '/:text_type'});
});
this.resource('adminUserFields', { path: '/user_fields' }, function() {
});
});
this.route('api');

View File

@ -4,6 +4,9 @@
<li>{{#link-to 'adminCustomize.colors'}}{{i18n admin.customize.colors.title}}{{/link-to}}</li>
<li>{{#link-to 'adminCustomize.css_html'}}{{i18n admin.customize.css_html.title}}{{/link-to}}</li>
<li>{{#link-to 'adminSiteText'}}{{i18n admin.site_text.title}}{{/link-to}}</li>
{{#if USERFIELD_FEATURE_COMPLETE}}
<li>{{#link-to 'adminUserFields'}}{{i18n admin.user_fields.title}}{{/link-to}}</li>
{{/if}}
</ul>
</div>
</div>

View File

@ -0,0 +1,43 @@
<div class='user-fields'>
<h2>{{i18n admin.user_fields.title}}</h2>
<p class="desc">{{i18n admin.user_fields.description}}</p>
{{#if model}}
{{#each f in model itemController="admin-user-field-item" itemView="admin-user-field-item"}}
{{#if f.editing}}
<div class='form-element'>
<label>{{i18n admin.user_fields.name}}
{{input value=f.buffered.name class="user-field-name"}}
</label>
</div>
<div class='form-element'>
<label>{{i18n admin.user_fields.type}}
{{combo-box content=fieldTypes valueAttribute="id" value=f.buffered.field_type}}
</label>
</div>
<div class='form-element controls'>
<button {{action "save"}}class='btn btn-primary'>{{fa-icon 'check'}} {{i18n admin.user_fields.save}}</button>
<button {{action "cancel"}} class='btn btn-danger'>{{fa-icon 'times'}} {{i18n admin.user_fields.cancel}}</button>
</div>
{{else}}
<div class='form-display'>
{{f.name}}
</div>
<div class='form-display'>
{{f.fieldName}}
</div>
<div class='form-element controls'>
<button {{action "edit"}}class='btn btn-default'>{{fa-icon 'pencil'}} {{i18n admin.user_fields.edit}}</button>
<button {{action "destroy"}}class='btn btn-danger'>{{fa-icon 'trash-o'}} {{i18n admin.user_fields.delete}}</button>
</div>
{{/if}}
<div class='clearfix'></div>
{{/each}}
{{/if}}
<button {{bind-attr disabled="createDisabled"}} class='btn btn-primary' {{action createField}}>
{{fa-icon "plus"}}
{{i18n admin.user_fields.create}}
</button>
</div>

View File

@ -0,0 +1,13 @@
export default Ember.View.extend({
classNameBindings: [':user-field'],
_focusOnEdit: function() {
if (this.get('controller.editing')) {
Ember.run.scheduleOnce('afterRender', this, '_focusName');
}
}.observes('controller.editing').on('didInsertElement'),
_focusName: function() {
$('.user-field-name').select();
}
});

View File

@ -136,7 +136,8 @@ export default Ember.DefaultResolver.extend({
decamelized = decamelized.replace(/^admin\_/, 'admin/templates/');
decamelized = decamelized.replace(/^admin\./, 'admin/templates/');
decamelized = decamelized.replace(/\./, '_');
return Ember.TEMPLATES[decamelized];
var dashed = decamelized.replace(/_/g, '-');
return Ember.TEMPLATES[decamelized] || Ember.TEMPLATES[dashed];
}
}

View File

@ -0,0 +1,16 @@
/* global BufferedProxy: true */
export default Ember.Mixin.create({
buffered: function() {
return Em.ObjectProxy.extend(BufferedProxy).create({
content: this.get('content')
});
}.property('content'),
rollbackBuffer: function() {
this.get('buffered').discardBufferedChanges();
},
commitBuffer: function() {
this.get('buffered').applyBufferedChanges();
}
});

View File

@ -1,3 +1,4 @@
//= require admin/models/user-field
//= require admin/controllers/admin-email-skipped
//= require admin/controllers/change-site-customization-details
//= require_tree ./admin

View File

@ -39,4 +39,5 @@
//= require lock-on.js
//= require ember-cloaking
//= require break_string
//= require buffered-proxy
//= require_tree ./discourse/ember

View File

@ -1319,3 +1319,43 @@ tr.not-activated {
color: #bbb;
}
}
.user-fields {
h2 {
margin-bottom: 10px;
}
.user-field {
padding: 10px;
margin-bottom: 10px;
border-bottom: 1px solid scale-color-diff();
.form-display {
width: 35%;
display: inline-block;
float: left;
}
.form-element {
float: left;
width: 35%;
margin-right: 10px;
label {
margin-right: 10px;
}
input, div.combobox {
margin-left: 10px;
}
}
.controls {
float: right;
text-align: right;
width: 20%;
}
.clearfix {
clear: both;
}
}
}

View File

@ -0,0 +1,30 @@
class Admin::UserFieldsController < Admin::AdminController
def create
field = UserField.create!(params.require(:user_field).permit(:name, :field_type))
render_serialized(field, UserFieldSerializer)
end
def index
render_serialized(UserField.all, UserFieldSerializer, root: 'user_fields')
end
def update
field_params = params.require(:user_field)
field = UserField.where(id: params.require(:id)).first
field.name = field_params[:name]
field.field_type = field_params[:field_type]
field.save
render_serialized(field, UserFieldSerializer)
end
def destroy
field = UserField.where(id: params.require(:id)).first
field.destroy if field.present?
render nothing: true
end
end

4
app/models/user_field.rb Normal file
View File

@ -0,0 +1,4 @@
class UserField < ActiveRecord::Base
validates_presence_of :name, :field_type
end

View File

@ -0,0 +1,3 @@
class UserFieldSerializer < ApplicationSerializer
attributes :id, :name, :field_type
end

View File

@ -1983,6 +1983,23 @@ en:
external_email: "Email"
external_avatar_url: "Avatar URL"
user_fields:
title: "User Fields"
description: "Any fields added here will be required from users when they sign up."
create: "Create User Field"
untitled: "Untitled"
name: "Field Name"
type: "Field Type"
save: "Save"
edit: "Edit"
delete: "Delete"
cancel: "Cancel"
delete_confirm: "Are you sure you want to delete that user field?"
field_types:
text: 'Text Field'
confirm: 'Confirmation'
site_text:
none: "Choose a type of content to begin editing."
title: 'Text Content'

View File

@ -112,6 +112,7 @@ Discourse::Application.routes.draw do
scope "/customize" do
resources :site_text, constraints: AdminConstraint.new
resources :site_text_types, constraints: AdminConstraint.new
resources :user_fields, constraints: AdminConstraint.new
end
resources :color_schemes, constraints: AdminConstraint.new

View File

@ -0,0 +1,9 @@
class CreateUserFields < ActiveRecord::Migration
def change
create_table :user_fields do |t|
t.string :name, null: false
t.string :field_type, null: false
t.timestamps
end
end
end

View File

@ -70,7 +70,7 @@ module Tilt
# For backwards compatibility with plugins, for now export the Global format too.
# We should eventually have an upgrade system for plugins to use ES6 or some other
# resolve based API.
if ENV['DISCOURSE_NO_CONSTANTS'].nil? && scope.logical_path =~ /(discourse|admin)\/(controllers|components|views|routes|mixins)\/(.*)/
if ENV['DISCOURSE_NO_CONSTANTS'].nil? && scope.logical_path =~ /(discourse|admin)\/(controllers|components|views|routes|mixins|models)\/(.*)/
type = Regexp.last_match[2]
file_name = Regexp.last_match[3].gsub(/[\-\/]/, '_')
class_name = file_name.classify
@ -87,6 +87,7 @@ module Tilt
# HAX
result = "Controller" if result == "ControllerController"
result.gsub!(/Mixin$/, '')
result.gsub!(/Model$/, '')
@output << "\n\nDiscourse.#{result} = require('#{require_name}').default;\n"
end

View File

@ -0,0 +1,57 @@
require 'spec_helper'
describe Admin::UserFieldsController do
it "is a subclass of AdminController" do
(Admin::ApiController < Admin::AdminController).should == true
end
context "when logged in" do
let!(:user) { log_in(:admin) }
context '.create' do
it "creates a user field" do
-> {
xhr :post, :create, {user_field: {name: 'hello', field_type: 'text'} }
response.should be_success
}.should change(UserField, :count).by(1)
end
end
context '.index' do
let!(:user_field) { Fabricate(:user_field) }
it "returns a list of user fields" do
xhr :get, :index
response.should be_success
json = ::JSON.parse(response.body)
json['user_fields'].should be_present
end
end
context '.destroy' do
let!(:user_field) { Fabricate(:user_field) }
it "returns a list of user fields" do
-> {
xhr :delete, :destroy, id: user_field.id
response.should be_success
}.should change(UserField, :count).by(-1)
end
end
context '.update' do
let!(:user_field) { Fabricate(:user_field) }
it "returns a list of user fields" do
xhr :put, :update, id: user_field.id, user_field: {name: 'fraggle', field_type: 'confirm'}
response.should be_success
user_field.reload
user_field.name.should == 'fraggle'
user_field.field_type.should == 'confirm'
end
end
end
end

View File

@ -0,0 +1,4 @@
Fabricator(:user_field) do
name { sequence(:name) {|i| "field_#{i}" } }
field_type 'text'
end

View File

@ -0,0 +1,87 @@
(function (global) {
"use strict";
function empty(obj) {
var key;
for (key in obj) if (obj.hasOwnProperty(key)) return false;
return true;
}
var Ember = global.Ember,
get = Ember.get, set = Ember.set;
var BufferedProxy = Ember.Mixin.create({
buffer: null,
hasBufferedChanges: false,
unknownProperty: function (key) {
var buffer = this.buffer;
return buffer && buffer.hasOwnProperty(key) ? buffer[key] : this._super(key);
},
setUnknownProperty: function (key, value) {
if (!this.buffer) this.buffer = {};
var buffer = this.buffer,
content = this.get('content'),
current = content && get(content, key),
previous = buffer.hasOwnProperty(key) ? buffer[key] : current;
if (previous === value) return;
this.propertyWillChange(key);
if (current === value) {
delete buffer[key];
if (empty(buffer)) {
this.set('hasBufferedChanges', false);
}
} else {
buffer[key] = value;
this.set('hasBufferedChanges', true);
}
this.propertyDidChange(key);
return value;
},
applyBufferedChanges: function() {
var buffer = this.buffer,
content = this.get('content'),
key;
for (key in buffer) {
if (!buffer.hasOwnProperty(key)) continue;
set(content, key, buffer[key]);
}
this.buffer = {};
this.set('hasBufferedChanges', false);
},
discardBufferedChanges: function() {
var buffer = this.buffer,
content = this.get('content'),
key;
for (key in buffer) {
if (!buffer.hasOwnProperty(key)) continue;
this.propertyWillChange(key);
delete buffer[key];
this.propertyDidChange(key);
}
this.set('hasBufferedChanges', false);
}
});
// CommonJS module
if (typeof module !== 'undefined' && module.exports) {
module.exports = BufferedProxy;
} else if (typeof define === "function" && define.amd) {
define("buffered-proxy", function (require, exports, module) {
return BufferedProxy;
});
} else {
global.BufferedProxy = BufferedProxy;
}
}(this));