From 0fc053313424739000991833ad10c285c579d4ee Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 25 Sep 2014 11:32:08 -0400 Subject: [PATCH] FEATURE: Admin interface for adding custom fields for users --- .../controllers/admin-user-field-item.js.es6 | 43 +++++++++ .../controllers/admin-user-fields.js.es6 | 36 ++++++++ .../admin/models/user-field.js.es6 | 54 ++++++++++++ .../admin/routes/admin-user-fields.js.es6 | 14 +++ .../javascripts/admin/routes/admin_routes.js | 2 + .../admin/templates/customize.js.handlebars | 3 + .../admin/templates/user-fields.js.handlebars | 43 +++++++++ .../admin/views/admin-user-field-item.js.es6 | 13 +++ .../discourse/ember/resolver.js.es6 | 3 +- .../discourse/mixins/buffered-content.js.es6 | 16 ++++ app/assets/javascripts/main_include_admin.js | 1 + app/assets/javascripts/vendor.js | 1 + .../stylesheets/common/admin/admin_base.scss | 40 +++++++++ .../admin/user_fields_controller.rb | 30 +++++++ app/models/user_field.rb | 4 + app/models/user_field_serializer.rb | 3 + config/locales/client.en.yml | 17 ++++ config/routes.rb | 1 + .../20140925173220_create_user_fields.rb | 9 ++ .../tilt/es6_module_transpiler_template.rb | 3 +- .../admin/user_fields_controller_spec.rb | 57 ++++++++++++ spec/fabricators/user_field.rb | 4 + vendor/assets/javascripts/buffered-proxy.js | 87 +++++++++++++++++++ 23 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 app/assets/javascripts/admin/controllers/admin-user-field-item.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-user-fields.js.es6 create mode 100644 app/assets/javascripts/admin/models/user-field.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-user-fields.js.es6 create mode 100644 app/assets/javascripts/admin/templates/user-fields.js.handlebars create mode 100644 app/assets/javascripts/admin/views/admin-user-field-item.js.es6 create mode 100644 app/assets/javascripts/discourse/mixins/buffered-content.js.es6 create mode 100644 app/controllers/admin/user_fields_controller.rb create mode 100644 app/models/user_field.rb create mode 100644 app/models/user_field_serializer.rb create mode 100644 db/migrate/20140925173220_create_user_fields.rb create mode 100644 spec/controllers/admin/user_fields_controller_spec.rb create mode 100644 spec/fabricators/user_field.rb create mode 100644 vendor/assets/javascripts/buffered-proxy.js 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));