FEATURE: introduces list/compact_list components

This commit is contained in:
Joffrey JAFFEUX
2018-08-03 16:41:37 -04:00
committed by GitHub
parent 072f5ce825
commit 066010db7d
20 changed files with 297 additions and 160 deletions

View File

@ -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({ export default Ember.Component.extend({
classNameBindings: [":value-list"], classNameBindings: [":value-list"],
_enableSorting: function() { inputInvalid: Ember.computed.empty("newValue"),
const self = this;
const placeholder = document.createElement("div");
placeholder.className = "placeholder";
let dragging = null; inputDelimiter: null,
let over = null; inputType: null,
let nodePlacement; newValue: "",
collection: null,
values: null,
this.$().on("dragstart.discourse", ".values .value", function(e) { @computed("addKey", "filteredChoices.length")
dragging = e.currentTarget; noneKey(addKey, filteredChoicesLength) {
e.dataTransfer.effectAllowed = "move"; return addKey || filteredChoicesLength === 0
e.dataTransfer.setData("text/html", e.currentTarget); ? "admin.site_settings.value_list.no_choices_none"
}); : "admin.site_settings.value_list.default_none";
},
this.$().on("dragend.discourse", ".values .value", function() { @on("didReceiveAttrs")
Ember.run(function() { _setupCollection() {
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() {
const values = this.get("values"); const values = this.get("values");
if (this.get("inputType") === "array") { if (this.get("inputType") === "array") {
this.set("collection", values || []); this.set("collection", values || []);
} else { return;
this.set("collection", values && values.length ? values.split("\n") : []);
} }
}
.on("init")
.observes("values"),
_saveValues: function() { this.set(
if (this.get("inputType") === "array") { "collection",
this.set("values", this.get("collection")); this._splitValues(values, this.get("inputDelimiter") || "\n")
} else { );
this.set("values", this.get("collection").join("\n"));
}
}, },
inputInvalid: Ember.computed.empty("newValue"), @computed("choices.[]", "collection.[]")
filteredChoices(choices, collection) {
return Ember.makeArray(choices).filter(i => collection.indexOf(i) < 0);
},
keyDown(e) { keyDown(event) {
if (e.keyCode === 13) { if (event.keyCode === 13) this.send("addValue", this.get("newValue"));
this.send("addValue");
}
}, },
actions: { actions: {
addValue() { changeValue(index, newValue) {
if (this.get("inputInvalid")) { this._replaceValue(index, newValue);
return; },
}
addValue(newValue) {
if (this.get("inputInvalid")) return;
this.get("collection").addObject(this.get("newValue"));
this.set("newValue", ""); this.set("newValue", "");
this._addValue(newValue);
this._saveValues();
}, },
removeValue(value) { removeValue(value) {
const collection = this.get("collection"); this._removeValue(value);
collection.removeObject(value); },
this._saveValues();
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 [];
} }
} }
}); });

View File

@ -10,7 +10,8 @@ const CUSTOM_TYPES = [
"category_list", "category_list",
"value_list", "value_list",
"category", "category",
"uploaded_image_list" "uploaded_image_list",
"compact_list"
]; ];
export default Ember.Mixin.create({ export default Ember.Mixin.create({
@ -59,11 +60,20 @@ export default Ember.Mixin.create({
return setting.replace(/\_/g, " "); return setting.replace(/\_/g, " ");
}, },
@computed("setting.type") @computed("type")
componentType(type) { componentType(type) {
return CUSTOM_TYPES.indexOf(type) !== -1 ? type : "string"; 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") @computed("typeClass")
componentName(typeClass) { componentName(typeClass) {
return "site-settings/" + typeClass; return "site-settings/" + typeClass;

View File

@ -0,0 +1,3 @@
{{list-setting settingValue=value choices=setting.choices settingName=setting.setting}}
{{setting-validation-message message=validationMessage}}
<div class='desc'>{{{unbound setting.description}}}</div>

View File

@ -1,3 +1,3 @@
{{list-setting settingValue=value choices=setting.choices settingName=setting.setting}} {{value-list values=value inputDelimiter="|" choices=setting.choices}}
{{setting-validation-message message=validationMessage}} {{setting-validation-message message=validationMessage}}
<div class='desc'>{{{unbound setting.description}}}</div> <div class='desc'>{{{unbound setting.description}}}</div>

View File

@ -1,18 +1,21 @@
{{#if collection}} {{#if collection}}
<div class='values'> <div class='values'>
{{#each collection as |value index|}} {{#each collection as |value index|}}
<div class='value' draggable='true' data-index={{index}}> <div class='value' data-index={{index}}>
{{d-button action="removeValue" {{d-button action="removeValue"
actionParam=value actionParam=value
icon="times" icon="times"
class="btn-small"}} class="remove-value-btn btn-small"}}
{{value}}
{{input value=value class="value-input" focus-out=(action "changeValue" index)}}
</div> </div>
{{/each}} {{/each}}
</div> </div>
{{/if}} {{/if}}
<div class='input'> {{combo-box
{{text-field value=newValue placeholderKey=addKey}} allowAny=true
{{d-button action="addValue" icon="plus" class="btn-primary btn-small" disabled=inputInvalid}} allowContentReplacement=true
</div> none=noneKey
content=filteredChoices
onSelect=(action "selectChoice")}}

View File

@ -151,9 +151,14 @@ export default Ember.Component.extend(
this this
); );
const existingCreatedComputedContent = this.get( let existingCreatedComputedContent = [];
"computedContent" if (!this.get("allowContentReplacement")) {
).filterBy("created", true); existingCreatedComputedContent = this.get("computedContent").filterBy(
"created",
true
);
}
this.setProperties({ this.setProperties({
computedContent: content computedContent: content
.map(c => this.computeContentItem(c)) .map(c => this.computeContentItem(c))

View File

@ -77,8 +77,8 @@ export default Ember.Mixin.create({
// use to collapse and remove focus // use to collapse and remove focus
close(event) { close(event) {
this.collapse(event);
this.setProperties({ isFocused: false }); this.setProperties({ isFocused: false });
this.collapse(event);
}, },
focus() { focus() {
@ -118,8 +118,11 @@ export default Ember.Mixin.create({
collapse() { collapse() {
this.set("isExpanded", false); 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 // lose focus of the component in two steps

View File

@ -873,25 +873,43 @@ table#user-badges {
.value-list { .value-list {
.value { .value {
border-bottom: 1px solid #ddd; padding: 0.125em 0;
padding: 3px;
margin-right: 10px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; 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 { .values {
margin-bottom: 10px; margin-bottom: 0.5em;
}
.placeholder {
border-bottom: 1px solid #ddd;
padding: 3px;
margin-right: 10px;
height: 30px;
}
input[type="text"] {
width: 90%;
} }
} }

View File

@ -444,9 +444,17 @@
padding: 0.25em 0; padding: 0.25em 0;
&.input-area { &.input-area {
width: 75%; width: 75%;
.value-list,
.select-kit,
input[type="text"] { input[type="text"] {
width: 50%; width: 50%;
} }
.value-list {
.select-kit {
width: 100%;
}
}
} }
&.label-area { &.label-area {
width: 25%; width: 25%;

View File

@ -1,5 +1,6 @@
class ThemeSettingsSerializer < ApplicationSerializer class ThemeSettingsSerializer < ApplicationSerializer
attributes :setting, :type, :default, :value, :description, :valid_values attributes :setting, :type, :default, :value, :description, :valid_values,
:list_type
def setting def setting
object.name object.name
@ -32,4 +33,12 @@ class ThemeSettingsSerializer < ApplicationSerializer
def include_description? def include_description?
object.description.present? object.description.present?
end end
def list_type
object.list_type
end
def include_list_type?
object.type == ThemeSetting.types[:list]
end
end end

View File

@ -3828,6 +3828,9 @@ en:
clear_filter: "Clear" clear_filter: "Clear"
add_url: "add URL" add_url: "add URL"
add_host: "add host" add_host: "add host"
value_list:
default_none: "Type to filter or create..."
no_choices_none: "Type to create..."
uploaded_image_list: uploaded_image_list:
label: "Edit list" label: "Edit list"
empty: "There are no pictures yet. Please upload one." empty: "There are no pictures yet. Please upload one."

View File

@ -116,6 +116,7 @@ basic:
client: true client: true
refresh: true refresh: true
type: list type: list
list_type: compact
default: "latest|new|unread|top|categories" default: "latest|new|unread|top|categories"
regex: "latest" regex: "latest"
regex_error: "site_settings.errors.must_include_latest" regex_error: "site_settings.errors.must_include_latest"
@ -176,6 +177,7 @@ basic:
category_colors: category_colors:
client: true client: true
type: list type: list
list_type: compact
default: 'BF1E2E|F1592A|F7941D|9EB83B|3AB54A|12A89D|25AAE2|0E76BD|652D90|92278F|ED207B|8C6238|231F20|808281|B3B5B4|283890' default: 'BF1E2E|F1592A|F7941D|9EB83B|3AB54A|12A89D|25AAE2|0E76BD|652D90|92278F|ED207B|8C6238|231F20|808281|B3B5B4|283890'
category_style: category_style:
client: true client: true

View File

@ -6,7 +6,7 @@ module SiteSettings; end
class SiteSettings::TypeSupervisor class SiteSettings::TypeSupervisor
include SiteSettings::Validations 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 VALIDATOR_OPTS = %i[min max regex hidden regex_error].freeze
# For plugins, so they can tell if a feature is supported # For plugins, so they can tell if a feature is supported
@ -63,6 +63,7 @@ class SiteSettings::TypeSupervisor
@validators = {} @validators = {}
@types = {} @types = {}
@allow_any = {} @allow_any = {}
@list_type = {}
end end
def load_setting(name_arg, opts = {}) def load_setting(name_arg, opts = {})
@ -88,6 +89,7 @@ class SiteSettings::TypeSupervisor
if type.to_sym == :list if type.to_sym == :list
@allow_any[name] = opts[:allow_any] == false ? false : true @allow_any[name] = opts[:allow_any] == false ? false : true
@list_type[name] = opts[:list_type] if opts[:list_type]
end end
end end
@types[name] = get_data_type(name, @defaults_provider[name]) @types[name] = get_data_type(name, @defaults_provider[name])
@ -144,6 +146,7 @@ class SiteSettings::TypeSupervisor
end end
result[:choices] = @choices[name] if @choices.has_key? name result[:choices] = @choices[name] if @choices.has_key? name
result[:list_type] = @list_type[name] if @list_type.has_key? name
result result
end end

View File

@ -93,8 +93,13 @@ class ThemeSettingsManager
(max.is_a?(::Integer) || max.is_a?(::Float)) && max != ::Float::INFINITY (max.is_a?(::Integer) || max.is_a?(::Float)) && max != ::Float::INFINITY
end end
class List < self; end class List < self
class String < self def list_type
@opts[:list_type]
end
end
class String < self
def is_valid_value?(new_value) def is_valid_value?(new_value)
(@opts[:min]..@opts[:max]).include? new_value.to_s.length (@opts[:min]..@opts[:max]).include? new_value.to_s.length
end end

View File

@ -33,6 +33,11 @@ class ThemeSettingsParser
opts[:max] = raw_opts[:max].is_a?(Numeric) ? raw_opts[:max] : Float::INFINITY 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 opts[:min] = raw_opts[:min].is_a?(Numeric) ? raw_opts[:min] : -Float::INFINITY
end end
if raw_opts[:list_type]
opts[:list_type] = raw_opts[:list_type]
end
opts opts
end end

View File

@ -306,7 +306,7 @@ describe SiteSettings::TypeSupervisor do
settings.setting(:type_url_list, 'string', type: 'url_list') settings.setting(:type_url_list, 'string', type: 'url_list')
settings.setting(:type_enum_choices, '2', type: 'enum', choices: ['1', '2']) settings.setting(:type_enum_choices, '2', type: 'enum', choices: ['1', '2'])
settings.setting(:type_enum_class, 'a', enum: 'TestEnumClass2') 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! settings.refresh!
end end
@ -336,6 +336,10 @@ describe SiteSettings::TypeSupervisor do
expect(settings.type_supervisor.type_hash(:type_list)[:choices]).to eq ['a', 'b'] expect(settings.type_supervisor.type_hash(:type_list)[:choices]).to eq ['a', 'b']
end 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 it 'returns enum choices' do
hash = settings.type_supervisor.type_hash(:type_enum_choices) hash = settings.type_supervisor.type_hash(:type_enum_choices)
expect(hash[:valid_values]).to eq [{ name: '1', value: '1' }, { name: '2', value: '2' }] expect(hash[:valid_values]).to eq [{ name: '1', value: '1' }, { name: '2', value: '2' }]

View File

@ -114,4 +114,11 @@ describe ThemeSettingsManager do
expect { string_setting.value = ("a" * 21) }.to raise_error(Discourse::InvalidParameters) expect { string_setting.value = ("a" * 21) }.to raise_error(Discourse::InvalidParameters)
end end
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 end

View File

@ -82,4 +82,11 @@ describe ThemeSettingsParser do
expect(choices.length).to eq(1) expect(choices.length).to eq(1)
end end
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 end

View File

@ -37,6 +37,12 @@ list_setting:
description: "help text" description: "help text"
default: "name|age|last name" default: "name|age|last name"
compact_list_setting:
type: list
list_type: compact
description: "help text"
default: "name|age|last name"
enum_setting: enum_setting:
default: "trust level 4" default: "trust level 4"
type: enum type: enum

View File

@ -1,63 +1,111 @@
import componentTest from "helpers/component-test"; import componentTest from "helpers/component-test";
moduleForComponent("value-list", { integration: true }); moduleForComponent("value-list", { integration: true });
componentTest("functionality", { componentTest("adding a value", {
template: '{{value-list values=values inputType="array"}}', template: "{{value-list values=values}}",
async test(assert) { async test(assert) {
assert.ok(this.$(".values .value").length === 0, "it has no values"); this.set("values", "vinkas\nosama");
assert.ok(this.$("input").length, "it renders the input");
await selectKit().expand();
await selectKit().fillInFilter("eviltrout");
await selectKit().keyboard("enter");
assert.ok( assert.ok(
this.$(".btn-primary[disabled]").length, find(".values .value").length === 3,
"it is disabled with no value" "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( assert.ok(
!this.$(".btn-primary[disabled]").length, find(".values .value").length === 1,
"it isn't disabled anymore" "it removes the value from the list of values"
); );
await click(".btn-primary"); assert.equal(this.get("values"), "osama", "it removes the expected value");
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");
} }
}); });
componentTest("with string delimited values", { componentTest("selecting a value", {
template: "{{value-list values=valueString}}", template: "{{value-list values=values choices=choices}}",
beforeEach() {
this.set("valueString", "hello\nworld");
},
async test(assert) { 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 selectKit().expand();
await click(".btn-primary"); await selectKit().selectRowByValue("maja");
assert.equal(this.$(".values .value").length, 3); assert.ok(
assert.equal(this.get("valueString"), "hello\nworld\neviltrout"); 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", { componentTest("array support", {
template: '{{value-list values=valueArray inputType="array"}}', template: "{{value-list values=values inputType='array'}}",
beforeEach() {
this.set("valueArray", ["abc", "def"]);
},
async test(assert) { async test(assert) {
assert.equal(this.$(".values .value").length, 2); this.set("values", ["vinkas", "osama"]);
await fillIn("input", "eviltrout"); await selectKit().expand();
await click(".btn-primary"); await selectKit().fillInFilter("eviltrout");
await selectKit().keyboard("enter");
assert.equal(this.$(".values .value").length, 3); assert.ok(
assert.deepEqual(this.get("valueArray"), ["abc", "def", "eviltrout"]); 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"
);
} }
}); });