diff --git a/app/assets/javascripts/admin/components/value-list.js.es6 b/app/assets/javascripts/admin/components/value-list.js.es6
index 60e4a3cda73..1f66a00bc74 100644
--- a/app/assets/javascripts/admin/components/value-list.js.es6
+++ b/app/assets/javascripts/admin/components/value-list.js.es6
@@ -1,113 +1,101 @@
+import { on } from "ember-addons/ember-computed-decorators";
+import computed from "ember-addons/ember-computed-decorators";
+
export default Ember.Component.extend({
classNameBindings: [":value-list"],
- _enableSorting: function() {
- const self = this;
- const placeholder = document.createElement("div");
- placeholder.className = "placeholder";
+ inputInvalid: Ember.computed.empty("newValue"),
- let dragging = null;
- let over = null;
- let nodePlacement;
+ inputDelimiter: null,
+ inputType: null,
+ newValue: "",
+ collection: null,
+ values: null,
- this.$().on("dragstart.discourse", ".values .value", function(e) {
- dragging = e.currentTarget;
- e.dataTransfer.effectAllowed = "move";
- e.dataTransfer.setData("text/html", e.currentTarget);
- });
+ @computed("addKey", "filteredChoices.length")
+ noneKey(addKey, filteredChoicesLength) {
+ return addKey || filteredChoicesLength === 0
+ ? "admin.site_settings.value_list.no_choices_none"
+ : "admin.site_settings.value_list.default_none";
+ },
- this.$().on("dragend.discourse", ".values .value", function() {
- Ember.run(function() {
- dragging.parentNode.removeChild(placeholder);
- dragging.style.display = "block";
-
- // Update data
- const from = Number(dragging.dataset.index);
- let to = Number(over.dataset.index);
- if (from < to) to--;
- if (nodePlacement === "after") to++;
-
- const collection = self.get("collection");
- const fromObj = collection.objectAt(from);
- collection.replace(from, 1);
- collection.replace(to, 0, [fromObj]);
- self._saveValues();
- });
- return false;
- });
-
- this.$().on("dragover.discourse", ".values", function(e) {
- e.preventDefault();
- dragging.style.display = "none";
- if (e.target.className === "placeholder") {
- return;
- }
- over = e.target;
-
- const relY = e.originalEvent.clientY - over.offsetTop;
- const height = over.offsetHeight / 2;
- const parent = e.target.parentNode;
-
- if (relY > height) {
- nodePlacement = "after";
- parent.insertBefore(placeholder, e.target.nextElementSibling);
- } else if (relY < height) {
- nodePlacement = "before";
- parent.insertBefore(placeholder, e.target);
- }
- });
- }.on("didInsertElement"),
-
- _removeSorting: function() {
- this.$()
- .off("dragover.discourse")
- .off("dragend.discourse")
- .off("dragstart.discourse");
- }.on("willDestroyElement"),
-
- _setupCollection: function() {
+ @on("didReceiveAttrs")
+ _setupCollection() {
const values = this.get("values");
if (this.get("inputType") === "array") {
this.set("collection", values || []);
- } else {
- this.set("collection", values && values.length ? values.split("\n") : []);
+ return;
}
- }
- .on("init")
- .observes("values"),
- _saveValues: function() {
- if (this.get("inputType") === "array") {
- this.set("values", this.get("collection"));
- } else {
- this.set("values", this.get("collection").join("\n"));
- }
+ this.set(
+ "collection",
+ this._splitValues(values, this.get("inputDelimiter") || "\n")
+ );
},
- inputInvalid: Ember.computed.empty("newValue"),
+ @computed("choices.[]", "collection.[]")
+ filteredChoices(choices, collection) {
+ return Ember.makeArray(choices).filter(i => collection.indexOf(i) < 0);
+ },
- keyDown(e) {
- if (e.keyCode === 13) {
- this.send("addValue");
- }
+ keyDown(event) {
+ if (event.keyCode === 13) this.send("addValue", this.get("newValue"));
},
actions: {
- addValue() {
- if (this.get("inputInvalid")) {
- return;
- }
+ changeValue(index, newValue) {
+ this._replaceValue(index, newValue);
+ },
+
+ addValue(newValue) {
+ if (this.get("inputInvalid")) return;
- this.get("collection").addObject(this.get("newValue"));
this.set("newValue", "");
-
- this._saveValues();
+ this._addValue(newValue);
},
removeValue(value) {
- const collection = this.get("collection");
- collection.removeObject(value);
- this._saveValues();
+ this._removeValue(value);
+ },
+
+ selectChoice(choice) {
+ this._addValue(choice);
+ }
+ },
+
+ _addValue(value) {
+ this.get("collection").addObject(value);
+ this._saveValues();
+ },
+
+ _removeValue(value) {
+ const collection = this.get("collection");
+ collection.removeObject(value);
+ this._saveValues();
+ },
+
+ _replaceValue(index, newValue) {
+ this.get("collection").replace(index, 1, [newValue]);
+ this._saveValues();
+ },
+
+ _saveValues() {
+ if (this.get("inputType") === "array") {
+ this.set("values", this.get("collection"));
+ return;
+ }
+
+ this.set(
+ "values",
+ this.get("collection").join(this.get("inputDelimiter") || "\n")
+ );
+ },
+
+ _splitValues(values, delimiter) {
+ if (values && values.length) {
+ return values.split(delimiter).filter(x => x);
+ } else {
+ return [];
}
}
});
diff --git a/app/assets/javascripts/admin/mixins/setting-component.js.es6 b/app/assets/javascripts/admin/mixins/setting-component.js.es6
index e00f14f4b3a..9636c47732d 100644
--- a/app/assets/javascripts/admin/mixins/setting-component.js.es6
+++ b/app/assets/javascripts/admin/mixins/setting-component.js.es6
@@ -10,7 +10,8 @@ const CUSTOM_TYPES = [
"category_list",
"value_list",
"category",
- "uploaded_image_list"
+ "uploaded_image_list",
+ "compact_list"
];
export default Ember.Mixin.create({
@@ -59,11 +60,20 @@ export default Ember.Mixin.create({
return setting.replace(/\_/g, " ");
},
- @computed("setting.type")
+ @computed("type")
componentType(type) {
return CUSTOM_TYPES.indexOf(type) !== -1 ? type : "string";
},
+ @computed("setting")
+ type(setting) {
+ if (setting.type === "list" && setting.list_type) {
+ return `${setting.list_type}_list`;
+ }
+
+ return setting.type;
+ },
+
@computed("typeClass")
componentName(typeClass) {
return "site-settings/" + typeClass;
diff --git a/app/assets/javascripts/admin/templates/components/site-settings/compact-list.hbs b/app/assets/javascripts/admin/templates/components/site-settings/compact-list.hbs
new file mode 100644
index 00000000000..e741bea5ed1
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/site-settings/compact-list.hbs
@@ -0,0 +1,3 @@
+{{list-setting settingValue=value choices=setting.choices settingName=setting.setting}}
+{{setting-validation-message message=validationMessage}}
+
{{#each collection as |value index|}}
-
+
{{d-button action="removeValue"
actionParam=value
icon="times"
- class="btn-small"}}
- {{value}}
+ class="remove-value-btn btn-small"}}
+
+ {{input value=value class="value-input" focus-out=(action "changeValue" index)}}
{{/each}}
{{/if}}
-
- {{text-field value=newValue placeholderKey=addKey}}
- {{d-button action="addValue" icon="plus" class="btn-primary btn-small" disabled=inputInvalid}}
-
+{{combo-box
+ allowAny=true
+ allowContentReplacement=true
+ none=noneKey
+ content=filteredChoices
+ onSelect=(action "selectChoice")}}
diff --git a/app/assets/javascripts/select-kit/components/select-kit.js.es6 b/app/assets/javascripts/select-kit/components/select-kit.js.es6
index 3155e0ce141..972143a40b0 100644
--- a/app/assets/javascripts/select-kit/components/select-kit.js.es6
+++ b/app/assets/javascripts/select-kit/components/select-kit.js.es6
@@ -151,9 +151,14 @@ export default Ember.Component.extend(
this
);
- const existingCreatedComputedContent = this.get(
- "computedContent"
- ).filterBy("created", true);
+ let existingCreatedComputedContent = [];
+ if (!this.get("allowContentReplacement")) {
+ existingCreatedComputedContent = this.get("computedContent").filterBy(
+ "created",
+ true
+ );
+ }
+
this.setProperties({
computedContent: content
.map(c => this.computeContentItem(c))
diff --git a/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 b/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6
index 8eae8b38ac1..73a3c2c6e2b 100644
--- a/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6
+++ b/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6
@@ -77,8 +77,8 @@ export default Ember.Mixin.create({
// use to collapse and remove focus
close(event) {
- this.collapse(event);
this.setProperties({ isFocused: false });
+ this.collapse(event);
},
focus() {
@@ -118,8 +118,11 @@ export default Ember.Mixin.create({
collapse() {
this.set("isExpanded", false);
- Ember.run.schedule("afterRender", () => this._removeFixedPosition());
- this._boundaryActionHandler("onCollapse", this);
+
+ Ember.run.next(() => {
+ Ember.run.schedule("afterRender", () => this._removeFixedPosition());
+ this._boundaryActionHandler("onCollapse", this);
+ });
},
// lose focus of the component in two steps
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index b424d4dac53..e8b33cb0822 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -873,25 +873,43 @@ table#user-badges {
.value-list {
.value {
- border-bottom: 1px solid #ddd;
- padding: 3px;
- margin-right: 10px;
+ padding: 0.125em 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- cursor: move;
+ display: flex;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .value-input {
+ box-sizing: border-box;
+ flex: 1;
+ border-color: $primary-low;
+ cursor: pointer;
+ margin: 0;
+
+ &:focus {
+ border-color: $tertiary;
+ box-shadow: none;
+ }
+ }
+
+ .remove-value-btn {
+ margin-right: 0.25em;
+ width: 29px;
+ border: 1px solid $primary-low;
+ outline: none;
+ padding: 0;
+
+ &:focus {
+ border-color: $tertiary;
+ }
+ }
}
.values {
- margin-bottom: 10px;
- }
- .placeholder {
- border-bottom: 1px solid #ddd;
- padding: 3px;
- margin-right: 10px;
- height: 30px;
- }
- input[type="text"] {
- width: 90%;
+ margin-bottom: 0.5em;
}
}
diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss
index b4fc2158aa4..bc08db8fcc3 100644
--- a/app/assets/stylesheets/common/admin/customize.scss
+++ b/app/assets/stylesheets/common/admin/customize.scss
@@ -444,9 +444,17 @@
padding: 0.25em 0;
&.input-area {
width: 75%;
+ .value-list,
+ .select-kit,
input[type="text"] {
width: 50%;
}
+
+ .value-list {
+ .select-kit {
+ width: 100%;
+ }
+ }
}
&.label-area {
width: 25%;
diff --git a/app/serializers/theme_settings_serializer.rb b/app/serializers/theme_settings_serializer.rb
index cb3aa20794b..d7fe44c1400 100644
--- a/app/serializers/theme_settings_serializer.rb
+++ b/app/serializers/theme_settings_serializer.rb
@@ -1,5 +1,6 @@
class ThemeSettingsSerializer < ApplicationSerializer
- attributes :setting, :type, :default, :value, :description, :valid_values
+ attributes :setting, :type, :default, :value, :description, :valid_values,
+ :list_type
def setting
object.name
@@ -32,4 +33,12 @@ class ThemeSettingsSerializer < ApplicationSerializer
def include_description?
object.description.present?
end
+
+ def list_type
+ object.list_type
+ end
+
+ def include_list_type?
+ object.type == ThemeSetting.types[:list]
+ end
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index ccc85631817..927fff07cac 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -3828,6 +3828,9 @@ en:
clear_filter: "Clear"
add_url: "add URL"
add_host: "add host"
+ value_list:
+ default_none: "Type to filter or create..."
+ no_choices_none: "Type to create..."
uploaded_image_list:
label: "Edit list"
empty: "There are no pictures yet. Please upload one."
diff --git a/config/site_settings.yml b/config/site_settings.yml
index b1266b5b9c4..f9aef37efd6 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -116,6 +116,7 @@ basic:
client: true
refresh: true
type: list
+ list_type: compact
default: "latest|new|unread|top|categories"
regex: "latest"
regex_error: "site_settings.errors.must_include_latest"
@@ -176,6 +177,7 @@ basic:
category_colors:
client: true
type: list
+ list_type: compact
default: 'BF1E2E|F1592A|F7941D|9EB83B|3AB54A|12A89D|25AAE2|0E76BD|652D90|92278F|ED207B|8C6238|231F20|808281|B3B5B4|283890'
category_style:
client: true
diff --git a/lib/site_settings/type_supervisor.rb b/lib/site_settings/type_supervisor.rb
index 25860d1da9a..32fd7733751 100644
--- a/lib/site_settings/type_supervisor.rb
+++ b/lib/site_settings/type_supervisor.rb
@@ -6,7 +6,7 @@ module SiteSettings; end
class SiteSettings::TypeSupervisor
include SiteSettings::Validations
- CONSUMED_OPTS = %i[enum choices type validator min max regex hidden regex_error allow_any].freeze
+ CONSUMED_OPTS = %i[enum choices type validator min max regex hidden regex_error allow_any list_type].freeze
VALIDATOR_OPTS = %i[min max regex hidden regex_error].freeze
# For plugins, so they can tell if a feature is supported
@@ -63,6 +63,7 @@ class SiteSettings::TypeSupervisor
@validators = {}
@types = {}
@allow_any = {}
+ @list_type = {}
end
def load_setting(name_arg, opts = {})
@@ -88,6 +89,7 @@ class SiteSettings::TypeSupervisor
if type.to_sym == :list
@allow_any[name] = opts[:allow_any] == false ? false : true
+ @list_type[name] = opts[:list_type] if opts[:list_type]
end
end
@types[name] = get_data_type(name, @defaults_provider[name])
@@ -144,6 +146,7 @@ class SiteSettings::TypeSupervisor
end
result[:choices] = @choices[name] if @choices.has_key? name
+ result[:list_type] = @list_type[name] if @list_type.has_key? name
result
end
diff --git a/lib/theme_settings_manager.rb b/lib/theme_settings_manager.rb
index 537b2c7de11..80802719440 100644
--- a/lib/theme_settings_manager.rb
+++ b/lib/theme_settings_manager.rb
@@ -93,8 +93,13 @@ class ThemeSettingsManager
(max.is_a?(::Integer) || max.is_a?(::Float)) && max != ::Float::INFINITY
end
- class List < self; end
- class String < self
+ class List < self
+ def list_type
+ @opts[:list_type]
+ end
+ end
+
+ class String < self
def is_valid_value?(new_value)
(@opts[:min]..@opts[:max]).include? new_value.to_s.length
end
diff --git a/lib/theme_settings_parser.rb b/lib/theme_settings_parser.rb
index 475d24a948e..064dd7ef143 100644
--- a/lib/theme_settings_parser.rb
+++ b/lib/theme_settings_parser.rb
@@ -33,6 +33,11 @@ class ThemeSettingsParser
opts[:max] = raw_opts[:max].is_a?(Numeric) ? raw_opts[:max] : Float::INFINITY
opts[:min] = raw_opts[:min].is_a?(Numeric) ? raw_opts[:min] : -Float::INFINITY
end
+
+ if raw_opts[:list_type]
+ opts[:list_type] = raw_opts[:list_type]
+ end
+
opts
end
diff --git a/spec/components/site_settings/type_supervisor_spec.rb b/spec/components/site_settings/type_supervisor_spec.rb
index fb27122c867..1683ebf9e89 100644
--- a/spec/components/site_settings/type_supervisor_spec.rb
+++ b/spec/components/site_settings/type_supervisor_spec.rb
@@ -306,7 +306,7 @@ describe SiteSettings::TypeSupervisor do
settings.setting(:type_url_list, 'string', type: 'url_list')
settings.setting(:type_enum_choices, '2', type: 'enum', choices: ['1', '2'])
settings.setting(:type_enum_class, 'a', enum: 'TestEnumClass2')
- settings.setting(:type_list, 'a', type: 'list', choices: ['a', 'b'])
+ settings.setting(:type_list, 'a', type: 'list', choices: ['a', 'b'], list_type: 'compact')
settings.refresh!
end
@@ -336,6 +336,10 @@ describe SiteSettings::TypeSupervisor do
expect(settings.type_supervisor.type_hash(:type_list)[:choices]).to eq ['a', 'b']
end
+ it 'returns list list_type' do
+ expect(settings.type_supervisor.type_hash(:type_list)[:list_type]).to eq 'compact'
+ end
+
it 'returns enum choices' do
hash = settings.type_supervisor.type_hash(:type_enum_choices)
expect(hash[:valid_values]).to eq [{ name: '1', value: '1' }, { name: '2', value: '2' }]
diff --git a/spec/components/theme_settings_manager_spec.rb b/spec/components/theme_settings_manager_spec.rb
index f5b90619205..eb29ed2024c 100644
--- a/spec/components/theme_settings_manager_spec.rb
+++ b/spec/components/theme_settings_manager_spec.rb
@@ -114,4 +114,11 @@ describe ThemeSettingsManager do
expect { string_setting.value = ("a" * 21) }.to raise_error(Discourse::InvalidParameters)
end
end
+
+ context "List" do
+ it "can have a list type" do
+ list_setting = find_by_name(:compact_list_setting)
+ expect(list_setting.list_type).to eq("compact")
+ end
+ end
end
diff --git a/spec/components/theme_settings_parser_spec.rb b/spec/components/theme_settings_parser_spec.rb
index cfdea790e63..4f8e9c9a42e 100644
--- a/spec/components/theme_settings_parser_spec.rb
+++ b/spec/components/theme_settings_parser_spec.rb
@@ -82,4 +82,11 @@ describe ThemeSettingsParser do
expect(choices.length).to eq(1)
end
end
+
+ context "list setting" do
+ it "supports list type" do
+ list_type = loader.find_by_name(:compact_list_setting)[:opts][:list_type]
+ expect(list_type).to eq("compact")
+ end
+ end
end
diff --git a/spec/fixtures/theme_settings/valid_settings.yaml b/spec/fixtures/theme_settings/valid_settings.yaml
index 33c9c4ed85e..8250f34f38a 100644
--- a/spec/fixtures/theme_settings/valid_settings.yaml
+++ b/spec/fixtures/theme_settings/valid_settings.yaml
@@ -37,6 +37,12 @@ list_setting:
description: "help text"
default: "name|age|last name"
+compact_list_setting:
+ type: list
+ list_type: compact
+ description: "help text"
+ default: "name|age|last name"
+
enum_setting:
default: "trust level 4"
type: enum
diff --git a/test/javascripts/components/value-list-test.js.es6 b/test/javascripts/components/value-list-test.js.es6
index 7af861a9b17..8a5d08ea3d5 100644
--- a/test/javascripts/components/value-list-test.js.es6
+++ b/test/javascripts/components/value-list-test.js.es6
@@ -1,63 +1,111 @@
import componentTest from "helpers/component-test";
moduleForComponent("value-list", { integration: true });
-componentTest("functionality", {
- template: '{{value-list values=values inputType="array"}}',
+componentTest("adding a value", {
+ template: "{{value-list values=values}}",
+
async test(assert) {
- assert.ok(this.$(".values .value").length === 0, "it has no values");
- assert.ok(this.$("input").length, "it renders the input");
+ this.set("values", "vinkas\nosama");
+
+ await selectKit().expand();
+ await selectKit().fillInFilter("eviltrout");
+ await selectKit().keyboard("enter");
+
assert.ok(
- this.$(".btn-primary[disabled]").length,
- "it is disabled with no value"
+ find(".values .value").length === 3,
+ "it adds the value to the list of values"
);
- await fillIn("input", "eviltrout");
+ assert.deepEqual(
+ this.get("values"),
+ "vinkas\nosama\neviltrout",
+ "it adds the value to the list of values"
+ );
+ }
+});
+
+componentTest("removing a value", {
+ template: "{{value-list values=values}}",
+
+ async test(assert) {
+ this.set("values", "vinkas\nosama");
+
+ await click(".values .value[data-index='0'] .remove-value-btn");
+
assert.ok(
- !this.$(".btn-primary[disabled]").length,
- "it isn't disabled anymore"
+ find(".values .value").length === 1,
+ "it removes the value from the list of values"
);
- await click(".btn-primary");
- 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");
-
- await click(".value .btn-small");
- assert.ok(this.$(".values .value").length === 0, "it removes the value");
+ assert.equal(this.get("values"), "osama", "it removes the expected value");
}
});
-componentTest("with string delimited values", {
- template: "{{value-list values=valueString}}",
- beforeEach() {
- this.set("valueString", "hello\nworld");
- },
+componentTest("selecting a value", {
+ template: "{{value-list values=values choices=choices}}",
async test(assert) {
- assert.equal(this.$(".values .value").length, 2);
+ this.set("values", "vinkas\nosama");
+ this.set("choices", ["maja", "michael"]);
- await fillIn("input", "eviltrout");
- await click(".btn-primary");
+ await selectKit().expand();
+ await selectKit().selectRowByValue("maja");
- assert.equal(this.$(".values .value").length, 3);
- assert.equal(this.get("valueString"), "hello\nworld\neviltrout");
+ assert.ok(
+ find(".values .value").length === 3,
+ "it adds the value to the list of values"
+ );
+
+ assert.deepEqual(
+ this.get("values"),
+ "vinkas\nosama\nmaja",
+ "it adds the value to the list of values"
+ );
}
});
-componentTest("with array values", {
- template: '{{value-list values=valueArray inputType="array"}}',
- beforeEach() {
- this.set("valueArray", ["abc", "def"]);
- },
+componentTest("array support", {
+ template: "{{value-list values=values inputType='array'}}",
async test(assert) {
- assert.equal(this.$(".values .value").length, 2);
+ this.set("values", ["vinkas", "osama"]);
- await fillIn("input", "eviltrout");
- await click(".btn-primary");
+ await selectKit().expand();
+ await selectKit().fillInFilter("eviltrout");
+ await selectKit().keyboard("enter");
- assert.equal(this.$(".values .value").length, 3);
- assert.deepEqual(this.get("valueArray"), ["abc", "def", "eviltrout"]);
+ assert.ok(
+ find(".values .value").length === 3,
+ "it adds the value to the list of values"
+ );
+
+ assert.deepEqual(
+ this.get("values"),
+ ["vinkas", "osama", "eviltrout"],
+ "it adds the value to the list of values"
+ );
+ }
+});
+
+componentTest("delimiter support", {
+ template: "{{value-list values=values inputDelimiter='|'}}",
+
+ async test(assert) {
+ this.set("values", "vinkas|osama");
+
+ await selectKit().expand();
+ await selectKit().fillInFilter("eviltrout");
+ await selectKit().keyboard("enter");
+
+ assert.ok(
+ find(".values .value").length === 3,
+ "it adds the value to the list of values"
+ );
+
+ assert.deepEqual(
+ this.get("values"),
+ "vinkas|osama|eviltrout",
+ "it adds the value to the list of values"
+ );
}
});