From 966547db54861d718ec467d4d983ac454349d8df Mon Sep 17 00:00:00 2001 From: d11n Date: Tue, 19 Mar 2024 14:59:26 +0100 Subject: [PATCH] Template Editor: Apply item changes directly (#5849) Closes #5847. --- BTCPayServer.Tests/SeleniumTests.cs | 15 ++- .../Views/Shared/TemplateEditor.cshtml | 19 +-- BTCPayServer/wwwroot/js/template-editor.js | 123 +++++++++--------- 3 files changed, 85 insertions(+), 72 deletions(-) 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 @@
- + +
{{errors.title}}
- -
Leave blank to generate ID from title.
+ +
{{errors.id}}
+
Leave blank to generate ID from title.
-
- @Model.currency
+
{{errors.price}}
@@ -63,7 +66,7 @@
- +
Leave blank to not use this feature.
@@ -75,8 +78,6 @@
-
{{error}}
-
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() {