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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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({
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 [];
}
}
});

View File

@ -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;

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}}
<div class='desc'>{{{unbound setting.description}}}</div>

View File

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

View File

@ -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))

View File

@ -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

View File

@ -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;
}
}

View File

@ -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%;

View File

@ -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

View File

@ -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."

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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' }]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"
);
}
});