diff --git a/app/assets/javascripts/admin/controllers/admin-user-field-item.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-field-item.js.es6
new file mode 100644
index 00000000000..d958f7c1c6f
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin-user-field-item.js.es6
@@ -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);
+ }
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/controllers/admin-user-fields.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-fields.js.es6
new file mode 100644
index 00000000000..77c159750c0
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin-user-fields.js.es6
@@ -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);
+ }
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/models/user-field.js.es6 b/app/assets/javascripts/admin/models/user-field.js.es6
new file mode 100644
index 00000000000..98f3da5c00e
--- /dev/null
+++ b/app/assets/javascripts/admin/models/user-field.js.es6
@@ -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;
diff --git a/app/assets/javascripts/admin/routes/admin-user-fields.js.es6 b/app/assets/javascripts/admin/routes/admin-user-fields.js.es6
new file mode 100644
index 00000000000..10704c845bf
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin-user-fields.js.es6
@@ -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()
+ });
+ }
+});
diff --git a/app/assets/javascripts/admin/routes/admin_routes.js b/app/assets/javascripts/admin/routes/admin_routes.js
index 535d82da79b..cee2200f8dc 100644
--- a/app/assets/javascripts/admin/routes/admin_routes.js
+++ b/app/assets/javascripts/admin/routes/admin_routes.js
@@ -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');
diff --git a/app/assets/javascripts/admin/templates/customize.js.handlebars b/app/assets/javascripts/admin/templates/customize.js.handlebars
index 87411a46c56..27c5b3ad7d3 100644
--- a/app/assets/javascripts/admin/templates/customize.js.handlebars
+++ b/app/assets/javascripts/admin/templates/customize.js.handlebars
@@ -4,6 +4,9 @@
{{#link-to 'adminCustomize.colors'}}{{i18n admin.customize.colors.title}}{{/link-to}}
{{#link-to 'adminCustomize.css_html'}}{{i18n admin.customize.css_html.title}}{{/link-to}}
{{#link-to 'adminSiteText'}}{{i18n admin.site_text.title}}{{/link-to}}
+ {{#if USERFIELD_FEATURE_COMPLETE}}
+ {{#link-to 'adminUserFields'}}{{i18n admin.user_fields.title}}{{/link-to}}
+ {{/if}}
diff --git a/app/assets/javascripts/admin/templates/user-fields.js.handlebars b/app/assets/javascripts/admin/templates/user-fields.js.handlebars
new file mode 100644
index 00000000000..540c67b0b67
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/user-fields.js.handlebars
@@ -0,0 +1,43 @@
+
+
{{i18n admin.user_fields.title}}
+
+
{{i18n admin.user_fields.description}}
+
+ {{#if model}}
+ {{#each f in model itemController="admin-user-field-item" itemView="admin-user-field-item"}}
+ {{#if f.editing}}
+
+
+
+
+
+
+
+
+
+
+ {{else}}
+
+ {{f.name}}
+
+
+ {{f.fieldName}}
+
+
+
+
+
+ {{/if}}
+
+ {{/each}}
+ {{/if}}
+
+
+
diff --git a/app/assets/javascripts/admin/views/admin-user-field-item.js.es6 b/app/assets/javascripts/admin/views/admin-user-field-item.js.es6
new file mode 100644
index 00000000000..4e96b9b0ddb
--- /dev/null
+++ b/app/assets/javascripts/admin/views/admin-user-field-item.js.es6
@@ -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();
+ }
+});
diff --git a/app/assets/javascripts/discourse/ember/resolver.js.es6 b/app/assets/javascripts/discourse/ember/resolver.js.es6
index 7f51291c423..f4c6e0b11ee 100644
--- a/app/assets/javascripts/discourse/ember/resolver.js.es6
+++ b/app/assets/javascripts/discourse/ember/resolver.js.es6
@@ -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];
}
}
diff --git a/app/assets/javascripts/discourse/mixins/buffered-content.js.es6 b/app/assets/javascripts/discourse/mixins/buffered-content.js.es6
new file mode 100644
index 00000000000..1f01423b2e5
--- /dev/null
+++ b/app/assets/javascripts/discourse/mixins/buffered-content.js.es6
@@ -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();
+ }
+});
diff --git a/app/assets/javascripts/main_include_admin.js b/app/assets/javascripts/main_include_admin.js
index 3c9d429d3e8..d83be37c142 100644
--- a/app/assets/javascripts/main_include_admin.js
+++ b/app/assets/javascripts/main_include_admin.js
@@ -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
diff --git a/app/assets/javascripts/vendor.js b/app/assets/javascripts/vendor.js
index 0851f91ceb3..4e769ed9ba0 100644
--- a/app/assets/javascripts/vendor.js
+++ b/app/assets/javascripts/vendor.js
@@ -39,4 +39,5 @@
//= require lock-on.js
//= require ember-cloaking
//= require break_string
+//= require buffered-proxy
//= require_tree ./discourse/ember
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index c592a534456..45c3c7ff716 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -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;
+ }
+ }
+}
diff --git a/app/controllers/admin/user_fields_controller.rb b/app/controllers/admin/user_fields_controller.rb
new file mode 100644
index 00000000000..a10147d0be7
--- /dev/null
+++ b/app/controllers/admin/user_fields_controller.rb
@@ -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
+
diff --git a/app/models/user_field.rb b/app/models/user_field.rb
new file mode 100644
index 00000000000..f931af22504
--- /dev/null
+++ b/app/models/user_field.rb
@@ -0,0 +1,4 @@
+class UserField < ActiveRecord::Base
+ validates_presence_of :name, :field_type
+end
+
diff --git a/app/models/user_field_serializer.rb b/app/models/user_field_serializer.rb
new file mode 100644
index 00000000000..316f3511765
--- /dev/null
+++ b/app/models/user_field_serializer.rb
@@ -0,0 +1,3 @@
+class UserFieldSerializer < ApplicationSerializer
+ attributes :id, :name, :field_type
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index d19ddf8a5ae..afa1dd88fd1 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -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'
diff --git a/config/routes.rb b/config/routes.rb
index e2bc53d64f3..9d734b34905 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/db/migrate/20140925173220_create_user_fields.rb b/db/migrate/20140925173220_create_user_fields.rb
new file mode 100644
index 00000000000..68c72452b60
--- /dev/null
+++ b/db/migrate/20140925173220_create_user_fields.rb
@@ -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
diff --git a/lib/es6_module_transpiler/tilt/es6_module_transpiler_template.rb b/lib/es6_module_transpiler/tilt/es6_module_transpiler_template.rb
index 8de52eada5f..4081325d50b 100644
--- a/lib/es6_module_transpiler/tilt/es6_module_transpiler_template.rb
+++ b/lib/es6_module_transpiler/tilt/es6_module_transpiler_template.rb
@@ -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
diff --git a/spec/controllers/admin/user_fields_controller_spec.rb b/spec/controllers/admin/user_fields_controller_spec.rb
new file mode 100644
index 00000000000..d0e4550bb6e
--- /dev/null
+++ b/spec/controllers/admin/user_fields_controller_spec.rb
@@ -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
+
diff --git a/spec/fabricators/user_field.rb b/spec/fabricators/user_field.rb
new file mode 100644
index 00000000000..3257d636f8e
--- /dev/null
+++ b/spec/fabricators/user_field.rb
@@ -0,0 +1,4 @@
+Fabricator(:user_field) do
+ name { sequence(:name) {|i| "field_#{i}" } }
+ field_type 'text'
+end
diff --git a/vendor/assets/javascripts/buffered-proxy.js b/vendor/assets/javascripts/buffered-proxy.js
new file mode 100644
index 00000000000..289dcc07b41
--- /dev/null
+++ b/vendor/assets/javascripts/buffered-proxy.js
@@ -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));