diff --git a/app/assets/javascripts/admin/components/admin-user-field-item.js.es6 b/app/assets/javascripts/admin/components/admin-user-field-item.js.es6 index f4d93d9c7..337b10557 100644 --- a/app/assets/javascripts/admin/components/admin-user-field-item.js.es6 +++ b/app/assets/javascripts/admin/components/admin-user-field-item.js.es6 @@ -10,6 +10,10 @@ export default Ember.Component.extend(bufferedProperty('userField'), { return I18n.t('admin.user_fields.description'); }.property(), + bufferedFieldType: function() { + return UserField.fieldTypeById(this.get('buffered.field_type')); + }.property('buffered.field_type'), + _focusOnEdit: function() { if (this.get('editing')) { Ember.run.scheduleOnce('afterRender', this, '_focusName'); @@ -42,7 +46,14 @@ export default Ember.Component.extend(bufferedProperty('userField'), { actions: { save() { const self = this; - const attrs = this.get('buffered').getProperties('name', 'description', 'field_type', 'editable', 'required', 'show_on_profile'); + const buffered = this.get('buffered'); + const attrs = buffered.getProperties('name', + 'description', + 'field_type', + 'editable', + 'required', + 'show_on_profile', + 'options'); this.get('userField').save(attrs).then(function(res) { self.set('userField.id', res.user_field.id); diff --git a/app/assets/javascripts/admin/components/value-list.js.es6 b/app/assets/javascripts/admin/components/value-list.js.es6 index c995097a9..a7704f254 100644 --- a/app/assets/javascripts/admin/components/value-list.js.es6 +++ b/app/assets/javascripts/admin/components/value-list.js.es6 @@ -3,11 +3,19 @@ export default Ember.Component.extend({ _setupCollection: function() { const values = this.get('values'); - this.set('collection', (values && values.length) ? values.split("\n") : []); + if (this.get('inputType') === "array") { + this.set('collection', values || []); + } else { + this.set('collection', (values && values.length) ? values.split("\n") : []); + } }.on('init').observes('values'), _collectionChanged: function() { - this.set('values', this.get('collection').join("\n")); + if (this.get('inputType') === "array") { + this.set('values', this.get('collection')); + } else { + this.set('values', this.get('collection').join("\n")); + } }.observes('collection.@each'), inputInvalid: Ember.computed.empty('newValue'), diff --git a/app/assets/javascripts/admin/models/user-field.js.es6 b/app/assets/javascripts/admin/models/user-field.js.es6 index e5c3348ef..35ee8e0bd 100644 --- a/app/assets/javascripts/admin/models/user-field.js.es6 +++ b/app/assets/javascripts/admin/models/user-field.js.es6 @@ -1,8 +1,9 @@ -var UserField = Ember.Object.extend({ - destroy: function() { - var self = this; +const UserField = Ember.Object.extend({ + + destroy() { + const self = this; return new Ember.RSVP.Promise(function(resolve) { - var id = self.get('id'); + const id = self.get('id'); if (id) { return Discourse.ajax("/admin/customize/user_fields/" + id, { type: 'DELETE' }).then(function() { resolve(); @@ -12,8 +13,8 @@ var UserField = Ember.Object.extend({ }); }, - save: function(attrs) { - var id = this.get('id'); + save(attrs) { + const id = this.get('id'); if (!id) { return Discourse.ajax("/admin/customize/user_fields", { type: "POST", @@ -28,8 +29,12 @@ var UserField = Ember.Object.extend({ } }); +const UserFieldType = Ember.Object.extend({ + name: Discourse.computed.i18n('id', 'admin.user_fields.field_types.%@') +}); + UserField.reopenClass({ - findAll: function() { + findAll() { return Discourse.ajax("/admin/customize/user_fields").then(function(result) { return result.user_fields.map(function(uf) { return UserField.create(uf); @@ -37,18 +42,19 @@ UserField.reopenClass({ }); }, - fieldTypes: function() { + fieldTypes() { if (!this._fieldTypes) { this._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') }) + UserFieldType.create({ id: 'text' }), + UserFieldType.create({ id: 'confirm' }), + UserFieldType.create({ id: 'dropdown', hasOptions: true }) ]; } return this._fieldTypes; }, - fieldTypeById: function(id) { + fieldTypeById(id) { return this.fieldTypes().findBy('id', id); } }); diff --git a/app/assets/javascripts/admin/templates/components/admin-form-row.hbs b/app/assets/javascripts/admin/templates/components/admin-form-row.hbs index d9ea5d585..aee95b670 100644 --- a/app/assets/javascripts/admin/templates/components/admin-form-row.hbs +++ b/app/assets/javascripts/admin/templates/components/admin-form-row.hbs @@ -1,11 +1,11 @@ -
+
{{#if label}} {{else}}   {{/if}}
-
+
{{#if wrapLabel}} {{else}} diff --git a/app/assets/javascripts/admin/templates/components/admin-user-field-item.hbs b/app/assets/javascripts/admin/templates/components/admin-user-field-item.hbs index a5754befa..4bb52c9e6 100644 --- a/app/assets/javascripts/admin/templates/components/admin-user-field-item.hbs +++ b/app/assets/javascripts/admin/templates/components/admin-user-field-item.hbs @@ -11,6 +11,12 @@ {{input value=buffered.description class="user-field-desc"}} {{/admin-form-row}} + {{#if bufferedFieldType.hasOptions}} + {{#admin-form-row label="admin.user_fields.options"}} + {{value-list values=buffered.options inputType="array"}} + {{/admin-form-row}} + {{/if}} + {{#admin-form-row wrapLabel="true"}} {{input type="checkbox" checked=buffered.editable}} {{i18n 'admin.user_fields.editable.title'}} {{/admin-form-row}} diff --git a/app/assets/javascripts/discourse/components/combo-box.js.es6 b/app/assets/javascripts/discourse/components/combo-box.js.es6 index 5fdd914e8..d74410e89 100644 --- a/app/assets/javascripts/discourse/components/combo-box.js.es6 +++ b/app/assets/javascripts/discourse/components/combo-box.js.es6 @@ -3,8 +3,9 @@ export default Ember.Component.extend({ attributeBindings: ['tabindex'], classNames: ['combobox'], valueAttribute: 'id', + nameProperty: 'name', - buildData(o) { + _buildData(o) { let result = ""; if (this.resultAttributes) { this.resultAttributes.forEach(function(a) { @@ -14,19 +15,15 @@ export default Ember.Component.extend({ return result; }, - realNameProperty: function() { - return this.get('nameProperty') || 'name'; - }.property('nameProperty'), - render(buffer) { - const nameProperty = this.get('realNameProperty'), - none = this.get('none'); + const nameProperty = this.get('nameProperty'); + const none = this.get('none'); // Add none option if required if (typeof none === "string") { buffer.push('"); } else if (typeof none === "object") { - buffer.push(""); + buffer.push(""); } let selected = this.get('value'); @@ -35,18 +32,20 @@ export default Ember.Component.extend({ if (this.get('content')) { const self = this; this.get('content').forEach(function(o) { - let val = o[self.get('valueAttribute')]; + let val = o[self.get('valueAttribute')] || o; if (!Em.isNone(val)) { val = val.toString(); } const selectedText = (val === selected) ? "selected" : ""; - buffer.push(""); + const name = Ember.get(o, nameProperty) || o; + buffer.push(""); }); } }, valueChanged: function() { const $combo = this.$(), - val = this.get('value'); + val = this.get('value'); + if (val !== undefined && val !== null) { $combo.select2('val', val.toString()); } else { @@ -54,13 +53,11 @@ export default Ember.Component.extend({ } }.observes('value'), - contentChanged: function() { + _rerenderOnChange: function() { this.rerender(); }.observes('content.@each'), _initializeCombo: function() { - const $elem = this.$(), - self = this; // Workaround for https://github.com/emberjs/ember.js/issues/9813 // Can be removed when fixed. Without it, the wrong option is selected @@ -70,12 +67,14 @@ export default Ember.Component.extend({ // observer for item names changing (optional) if (this.get('nameChanges')) { - this.addObserver('content.@each.' + this.get('realNameProperty'), this.rerender); + this.addObserver('content.@each.' + this.get('nameProperty'), this.rerender); } + const $elem = this.$(); $elem.select2({formatResult: this.comboTemplate, minimumResultsForSearch: 5, width: 'resolve'}); const castInteger = this.get('castInteger'); + const self = this; $elem.on("change", function (e) { let val = $(e.target).val(); if (val && val.length && castInteger) { diff --git a/app/assets/javascripts/discourse/components/user-field.js.es6 b/app/assets/javascripts/discourse/components/user-field.js.es6 index a1c6f56b4..625067622 100644 --- a/app/assets/javascripts/discourse/components/user-field.js.es6 +++ b/app/assets/javascripts/discourse/components/user-field.js.es6 @@ -1,6 +1,10 @@ export default Ember.Component.extend({ classNameBindings: [':user-field', 'field.field_type'], - layoutName: function() { - return "components/user-fields/" + this.get('field.field_type'); - }.property('field.field_type') + layoutName: Discourse.computed.fmt('field.field_type', 'components/user-fields/%@'), + + noneLabel: function() { + if (!this.get('field.required')) { + return 'user_fields.none'; + } + }.property('field.required') }); diff --git a/app/assets/javascripts/discourse/templates/components/user-fields/dropdown.hbs b/app/assets/javascripts/discourse/templates/components/user-fields/dropdown.hbs new file mode 100644 index 000000000..9c5d37ec0 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/user-fields/dropdown.hbs @@ -0,0 +1,6 @@ + +
+ {{combo-box content=field.options value=value none=noneLabel}} + {{#if field.required}}*{{/if}} +

{{{field.description}}}

+
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index bcd9a529e..0c572b531 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -1437,15 +1437,24 @@ tr.not-activated { .form-element, .form-element-desc { float: left; - width: 25%; - height: 30px; + min-height: 30px; padding: 0.25em 0; - } - .form-element-desc label { - margin: 0.5em 1em 0 0; - text-align: right; - font-weight: bold; + &.input-area { + width: 75%; + input[type=text] { + width: 50%; + } + } + + &.label-area { + width: 25%; + label { + margin: 0.5em 1em 0 0; + text-align: right; + font-weight: bold; + } + } } .controls { diff --git a/app/controllers/admin/user_fields_controller.rb b/app/controllers/admin/user_fields_controller.rb index e5e8c9c3a..6770d67bd 100644 --- a/app/controllers/admin/user_fields_controller.rb +++ b/app/controllers/admin/user_fields_controller.rb @@ -7,13 +7,16 @@ class Admin::UserFieldsController < Admin::AdminController def create field = UserField.new(params.require(:user_field).permit(*Admin::UserFieldsController.columns)) field.required = params[:required] == "true" + fetch_options(field) + json_result(field, serializer: UserFieldSerializer) do field.save end end def index - render_serialized(UserField.all, UserFieldSerializer, root: 'user_fields') + user_fields = UserField.all.includes(:user_field_options) + render_serialized(user_fields, UserFieldSerializer, root: 'user_fields') end def update @@ -24,6 +27,8 @@ class Admin::UserFieldsController < Admin::AdminController Admin::UserFieldsController.columns.each do |col| field.send("#{col}=", field_params[col] || false) end + UserFieldOption.where(user_field_id: field.id).delete_all + fetch_options(field) json_result(field, serializer: UserFieldSerializer) do field.save @@ -36,5 +41,14 @@ class Admin::UserFieldsController < Admin::AdminController render nothing: true end + + protected + + def fetch_options(field) + options = params[:user_field][:options] + if options.present? + field.user_field_options_attributes = options.map {|o| {value: o} }.uniq + end + end end diff --git a/app/models/user_field.rb b/app/models/user_field.rb index 648163326..e06079509 100644 --- a/app/models/user_field.rb +++ b/app/models/user_field.rb @@ -1,5 +1,7 @@ class UserField < ActiveRecord::Base validates_presence_of :name, :description, :field_type + has_many :user_field_options, dependent: :destroy + accepts_nested_attributes_for :user_field_options def self.max_length 2048 diff --git a/app/models/user_field_option.rb b/app/models/user_field_option.rb new file mode 100644 index 000000000..b603fcaea --- /dev/null +++ b/app/models/user_field_option.rb @@ -0,0 +1,2 @@ +class UserFieldOption < ActiveRecord::Base +end diff --git a/app/serializers/user_field_serializer.rb b/app/serializers/user_field_serializer.rb index 8638b2c04..0c8f6f721 100644 --- a/app/serializers/user_field_serializer.rb +++ b/app/serializers/user_field_serializer.rb @@ -5,5 +5,14 @@ class UserFieldSerializer < ApplicationSerializer :field_type, :editable, :required, - :show_on_profile + :show_on_profile, + :options + + def options + object.user_field_options.pluck(:value) + end + + def include_options? + options.present? + end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 21f622367..d8da64bb3 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -388,6 +388,9 @@ en: post_count: "# posts" confirm_delete_other_accounts: "Are you sure you want to delete these accounts?" + user_fields: + none: "(select an option)" + user: said: "{{username}}:" profile: "Profile" @@ -2331,6 +2334,7 @@ en: delete: "Delete" cancel: "Cancel" delete_confirm: "Are you sure you want to delete that user field?" + options: "Options" required: title: "Required at signup?" enabled: "required" @@ -2347,6 +2351,7 @@ en: field_types: text: 'Text Field' confirm: 'Confirmation' + dropdown: "Dropdown" site_text: none: "Choose a type of content to begin editing." diff --git a/db/migrate/20150727193414_create_user_field_options.rb b/db/migrate/20150727193414_create_user_field_options.rb new file mode 100644 index 000000000..189025a96 --- /dev/null +++ b/db/migrate/20150727193414_create_user_field_options.rb @@ -0,0 +1,9 @@ +class CreateUserFieldOptions < ActiveRecord::Migration + def change + create_table :user_field_options do |t| + t.references :user_field, null: false + t.string :value, null: false + t.timestamps + end + end +end diff --git a/spec/controllers/admin/user_fields_controller_spec.rb b/spec/controllers/admin/user_fields_controller_spec.rb index 6b173cb91..bd6bd517d 100644 --- a/spec/controllers/admin/user_fields_controller_spec.rb +++ b/spec/controllers/admin/user_fields_controller_spec.rb @@ -16,6 +16,18 @@ describe Admin::UserFieldsController do expect(response).to be_success }.to change(UserField, :count).by(1) end + + it "creates a user field with options" do + expect { + xhr :post, :create, {user_field: {name: 'hello', + description: 'hello desc', + field_type: 'dropdown', + options: ['a', 'b', 'c']} } + expect(response).to be_success + }.to change(UserField, :count).by(1) + + expect(UserFieldOption.count).to eq(3) + end end context '.index' do @@ -50,6 +62,18 @@ describe Admin::UserFieldsController do expect(user_field.name).to eq('fraggle') expect(user_field.field_type).to eq('confirm') end + + it "updates the user field options" do + xhr :put, :update, id: user_field.id, user_field: {name: 'fraggle', + field_type: 'dropdown', + description: 'muppet', + options: ['hello', 'hello', 'world']} + expect(response).to be_success + user_field.reload + expect(user_field.name).to eq('fraggle') + expect(user_field.field_type).to eq('dropdown') + expect(user_field.user_field_options.size).to eq(2) + end end end diff --git a/test/javascripts/components/combo-box-test.js.es6 b/test/javascripts/components/combo-box-test.js.es6 new file mode 100644 index 000000000..4badfcd1a --- /dev/null +++ b/test/javascripts/components/combo-box-test.js.es6 @@ -0,0 +1,45 @@ +import componentTest from 'helpers/component-test'; +moduleForComponent('combo-box', {integration: true}); + +componentTest('with objects', { + template: '{{combo-box content=items value=value}}', + setup() { + this.set('items', [{id: 1, name: 'hello'}, {id: 2, name: 'world'}]); + }, + + test(assert) { + assert.equal(this.get('value'), 1); + assert.ok(this.$('.combobox').length); + assert.equal(this.$("select option[value='1']").text(), 'hello'); + assert.equal(this.$("select option[value='2']").text(), 'world'); + } +}); + +componentTest('with an array', { + template: '{{combo-box content=items value=value}}', + setup() { + this.set('items', ['evil', 'trout', 'hat']); + }, + + test(assert) { + assert.equal(this.get('value'), 'evil'); + assert.ok(this.$('.combobox').length); + assert.equal(this.$("select option[value='evil']").text(), 'evil'); + assert.equal(this.$("select option[value='trout']").text(), 'trout'); + } +}); + +componentTest('with none', { + template: '{{combo-box content=items none="test.none" value=value}}', + setup() { + I18n.translations[I18n.locale].js.test = {none: 'none'}; + this.set('items', ['evil', 'trout', 'hat']); + }, + + test(assert) { + assert.equal(this.$("select option:eq(0)").text(), 'none'); + assert.equal(this.$("select option:eq(0)").val(), ''); + assert.equal(this.$("select option:eq(1)").text(), 'evil'); + assert.equal(this.$("select option:eq(2)").text(), 'trout'); + } +}); diff --git a/test/javascripts/components/value-list-test.js.es6 b/test/javascripts/components/value-list-test.js.es6 index df838f954..7bb9599ee 100644 --- a/test/javascripts/components/value-list-test.js.es6 +++ b/test/javascripts/components/value-list-test.js.es6 @@ -2,13 +2,11 @@ import componentTest from 'helpers/component-test'; moduleForComponent('value-list', {integration: true}); componentTest('functionality', { - template: '{{value-list value=values}}', - test: function(assert) { - andThen(() => { - assert.ok(this.$('.values .value').length === 0, 'it has no values'); - assert.ok(this.$('input').length, 'it renders the input'); - assert.ok(this.$('.btn-primary[disabled]').length, 'it is disabled with no value'); - }); + template: '{{value-list values=values inputType="array"}}', + test(assert) { + assert.ok(this.$('.values .value').length === 0, 'it has no values'); + assert.ok(this.$('input').length, 'it renders the input'); + assert.ok(this.$('.btn-primary[disabled]').length, 'it is disabled with no value'); fillIn('input', 'eviltrout'); andThen(() => { @@ -17,9 +15,10 @@ componentTest('functionality', { click('.btn-primary'); andThen(() => { - assert.ok(this.$('.values .value').length === 1, 'it adds the value'); - assert.ok(this.$('input').val() === '', 'it clears the input'); + assert.equal(this.$('.values .value').length, 1, 'it adds the value'); + assert.equal(this.$('input').val(), '', 'it clears the input'); assert.ok(this.$('.btn-primary[disabled]').length, "it is disabled again"); + assert.equal(this.get('values'), 'eviltrout', 'it appends the value'); }); click('.value .btn-small'); @@ -28,3 +27,41 @@ componentTest('functionality', { }); } }); + +componentTest('with string delimited values', { + template: '{{value-list values=valueString}}', + setup() { + this.set('valueString', "hello\nworld"); + }, + + test(assert) { + assert.equal(this.$('.values .value').length, 2); + + fillIn('input', 'eviltrout'); + click('.btn-primary'); + + andThen(() => { + assert.equal(this.$('.values .value').length, 3); + assert.equal(this.get('valueString'), "hello\nworld\neviltrout"); + }); + } +}); + +componentTest('with array values', { + template: '{{value-list values=valueArray inputType="array"}}', + setup() { + this.set('valueArray', ['abc', 'def']); + }, + + test(assert) { + assert.equal(this.$('.values .value').length, 2); + + fillIn('input', 'eviltrout'); + click('.btn-primary'); + + andThen(() => { + assert.equal(this.$('.values .value').length, 3); + assert.deepEqual(this.get('valueArray'), ['abc', 'def', 'eviltrout']); + }); + } +});