diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs
index 19ce4171d..ac8fbf0f9 100644
--- a/BTCPayServer.Tests/SeleniumTests.cs
+++ b/BTCPayServer.Tests/SeleniumTests.cs
@@ -1155,8 +1155,8 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.CssSelector(".template-item:nth-of-type(1)")).Click();
s.Driver.FindElement(By.Id("BuyButtonText")).SendKeys("Take my money");
s.Driver.FindElement(By.Id("EditorCategories-ts-control")).SendKeys("Drinks");
- s.Driver.FindElement(By.Id("ApplyItemChanges")).Click();
-
+ s.Driver.ScrollTo(By.Id("CodeTabButton"));
+ s.Driver.FindElement(By.Id("CodeTabButton")).Click();
var template = s.Driver.FindElement(By.Id("TemplateConfig")).GetAttribute("value");
Assert.Contains("\"buyButtonText\": \"Take my money\"", template);
Assert.Matches("\"categories\": \\[\n\\s+\"Drinks\"\n\\s+\\]", template);
@@ -1357,9 +1357,16 @@ namespace BTCPayServer.Tests
s.Driver.ScrollTo(By.Id("btAddItem"));
s.Driver.FindElement(By.Id("btAddItem")).Click();
s.Driver.FindElement(By.Id("EditorTitle")).SendKeys("Perk 1");
- s.Driver.FindElement(By.Id("EditorId")).SendKeys("Perk-1");
s.Driver.FindElement(By.Id("EditorAmount")).SendKeys("20");
- s.Driver.FindElement(By.Id("ApplyItemChanges")).Click();
+ // Test autogenerated ID
+ Assert.Equal("perk-1", s.Driver.FindElement(By.Id("EditorId")).GetAttribute("value"));
+ s.Driver.FindElement(By.Id("EditorId")).Clear();
+ s.Driver.FindElement(By.Id("EditorId")).SendKeys("Perk-1");
+ s.Driver.ScrollTo(By.Id("CodeTabButton"));
+ s.Driver.FindElement(By.Id("CodeTabButton")).Click();
+ var template = s.Driver.FindElement(By.Id("TemplateConfig")).GetAttribute("value");
+ Assert.Contains("\"title\": \"Perk 1\"", template);
+ Assert.Contains("\"id\": \"Perk-1\"", template);
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
diff --git a/BTCPayServer/Views/Shared/TemplateEditor.cshtml b/BTCPayServer/Views/Shared/TemplateEditor.cshtml
index c79ed670f..d5a891cc8 100644
--- a/BTCPayServer/Views/Shared/TemplateEditor.cshtml
+++ b/BTCPayServer/Views/Shared/TemplateEditor.cshtml
@@ -15,24 +15,26 @@
Select an item to edit
diff --git a/BTCPayServer/wwwroot/js/template-editor.js b/BTCPayServer/wwwroot/js/template-editor.js
index b7273320d..9eb1cbf4e 100644
--- a/BTCPayServer/wwwroot/js/template-editor.js
+++ b/BTCPayServer/wwwroot/js/template-editor.js
@@ -90,7 +90,7 @@ document.addEventListener('DOMContentLoaded', () => {
},
data () {
return {
- errors: [],
+ errors: {},
editingItem: null,
categoriesSelect: null,
customPriceOptions: [
@@ -106,74 +106,79 @@ document.addEventListener('DOMContentLoaded', () => {
}
},
methods: {
- validate () {
- this.errors = [];
-
+ toId(value) {
+ return value.toLowerCase().trim().replace(/\W+/gi, '-')
+ },
+ onTitleChange(e) {
+ const $input = e.target;
+ $input.classList.toggle('is-invalid', !$input.checkValidity())
+ if (!$input.checkValidity()) {
+ Vue.set(this.errors, 'title', 'Title is required')
+ } else if (this.editingItem.title.startsWith('-')){
+ Vue.set(this.errors, 'title', 'Title cannot start with "-"')
+ } else if (!this.editingItem.title.trim()){
+ Vue.set(this.errors, 'title', 'Title is required')
+ } else {
+ Vue.delete(this.errors, 'title')
+ }
+ // set id from title if not set
+ if (!this.editingItem.id) {
+ this.editingItem.id = this.toId(this.editingItem.title)
+ Vue.delete(this.errors, 'id')
+ }
+ },
+ onIdChange(e) {
+ // set id from title if not set
+ if (!this.editingItem.id) this.editingItem.id = this.toId(this.editingItem.title)
+ // validate
+ const $input = e.target;
+ $input.classList.toggle('is-invalid', !$input.checkValidity())
if (this.editingItem.id) {
const existingItem = this.$parent.items.find(i => i.id === this.editingItem.id);
if (existingItem && existingItem.id !== this.item.id)
- this.errors.push(`An item with the ID "${this.editingItem.id}" already exists`);
+ Vue.set(this.errors, 'id', `An item with the ID "${this.editingItem.id}" already exists`)
if (this.editingItem.id.startsWith('-'))
- this.errors.push('ID cannot start with "-"');
+ Vue.set(this.errors, 'id', 'ID cannot start with "-"')
else if (this.editingItem.id.trim() === '')
- this.errors.push('ID is required');
+ Vue.set(this.errors, 'id', 'ID is required')
+ else
+ Vue.delete(this.errors, 'id')
} else {
- this.errors.push('ID is required');
+ Vue.set(this.errors, 'id', 'ID is required')
}
-
- const { inputTitle, inputPrice, inputInventory } = this.$refs
- Object.keys(this.$refs).forEach(ref => {
- if (ref.startsWith('input')) {
- const $ref = this.$refs[ref];
- $ref.classList.toggle('is-invalid', !$ref.checkValidity())
- }
- })
-
- if (this.editingItem.priceType !== 'Topup' && !inputPrice.checkValidity()) {
- this.errors.push('Price must be a valid number');
- }
-
- if (!inputTitle.checkValidity()) {
- this.errors.push('Title is required');
- } else if (this.editingItem.title.startsWith('-')){
- this.errors.push('Title cannot start with "-"');
- } else if (!this.editingItem.title.trim()){
- this.errors.push('Title is required');
- }
-
- if (!inputInventory.checkValidity()) {
- this.errors.push('Inventory must not be set or be a valid number (>=0)');
- }
-
- return this.errors.length === 0;
},
- apply() {
- // set id from title if not set
- if (!this.editingItem.id) this.editingItem.id = this.editingItem.title.toLowerCase().trim();
- // validate
- if (!this.validate()) return;
- // set item props
- Object.keys(this.editingItem).forEach(prop => {
- const value = this.editingItem[prop];
- Vue.set(this.$parent.selectedItem, prop, value);
- })
- // remove empty/non-existing props on item
- Object.keys(this.item).forEach(prop => {
- const value = this.editingItem[prop];
- if (typeof value === 'undefined' || value === null) {
- Vue.delete(this.$parent.selectedItem, prop);
- }
- })
- // update categories
- this.categoriesSelect.clearOptions();
- this.categoriesSelect.addOptions(this.allCategories.map(value => ({ value, text: value })));
+ onInventoryChange(e) {
+ const $input = e.target;
+ $input.classList.toggle('is-invalid', !$input.checkValidity())
+ if (!$input.checkValidity()) {
+ Vue.set(this.errors, 'inventory', 'Inventory must not be set or be a valid number (>=0)')
+ }
+ },
+ onPriceChange(e) {
+ const $input = e.target;
+ $input.classList.toggle('is-invalid', !$input.checkValidity())
+ if (this.editingItem.priceType !== 'Topup' && !$input.checkValidity()) {
+ Vue.set(this.errors, 'price', 'Price must be a valid number')
+ } else {
+ Vue.delete(this.errors, 'price')
+ }
+ },
+ onPriceTypeChange(e) {
+ const $input = e.target;
+ $input.classList.toggle('is-invalid', !$input.checkValidity())
+ if ($input.value === 'Topup') {
+ Vue.set(this.editingItem, 'price', null)
+ }
}
},
watch: {
- item: function (newItem) {
- this.errors = [];
- this.editingItem = newItem ? { ...newItem } : null;
+ item(newItem) {
+ this.errors = {};
+ this.editingItem = newItem;
if (this.editingItem != null) {
+ // update categories
+ this.categoriesSelect.clearOptions();
+ this.categoriesSelect.addOptions(this.allCategories.map(value => ({ value, text: value })));
this.categoriesSelect.setValue(this.editingItem.categories);
}
}
@@ -187,11 +192,11 @@ document.addEventListener('DOMContentLoaded', () => {
});
this.categoriesSelect.on('change', () => {
const value = this.categoriesSelect.getValue();
- this.editingItem.categories = Array.from(value.split(',').reduce((res, item) => {
+ Vue.set(this.editingItem, 'categories', Array.from(value.split(',').reduce((res, item) => {
const category = item.trim();
if (category) res.add(category);
return res;
- }, new Set()));
+ }, new Set())))
});
},
beforeDestroy() {