Template Editor: Apply item changes directly (#5849)

Closes #5847.
This commit is contained in:
d11n 2024-03-19 14:59:26 +01:00 committed by GitHub
parent 09dbe44bca
commit 966547db54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 85 additions and 72 deletions

View file

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

View file

@ -15,24 +15,26 @@
<div id="item-form" class="item" v-show="!!editingItem">
<div class="form-group">
<label for="EditorTitle" class="form-label" data-required>Title</label>
<input id="EditorTitle" required class="form-control mb-2" v-model="editingItem && editingItem.title" ref="inputTitle" />
<input id="EditorTitle" required class="form-control mb-2" v-model="editingItem && editingItem.title" v-on:change="onTitleChange" />
<div class="text-danger mb-3" v-if="errors.title">{{errors.title}}</div>
</div>
<div class="form-group">
<label for="EditorId" class="form-label" data-required>ID</label>
<input id="EditorId" required class="form-control mb-2" v-model="editingItem && editingItem.id" ref="inputtId" />
<div class="form-text">Leave blank to generate ID from title.</div>
<input id="EditorId" required class="form-control mb-2" v-model="editingItem && editingItem.id" v-on:change="onIdChange" />
<div class="text-danger mb-3" v-if="errors.id">{{errors.id}}</div>
<div class="form-text" v-else>Leave blank to generate ID from title.</div>
</div>
<div class="form-group row">
<div class="col-sm-6">
<label for="EditorPrice" class="form-label">Price</label>
<select id="EditorPrice" class="form-select" v-model="editingItem && editingItem.priceType">
<select id="EditorPrice" class="form-select" v-model="editingItem && editingItem.priceType" v-on:change="onPriceTypeChange">
<option v-for="option in customPriceOptions" :value="option.value">{{option.text}}</option>
</select>
</div>
<div class="col-sm-6" v-show="editingItem && editingItem.priceType !== 'Topup'">
<label for="EditorAmount" class="form-label">&nbsp;</label>
<div class="input-group mb-2">
<input class="form-control"
<input class="form-control hide-number-spin"
id="EditorAmount"
inputmode="decimal"
pattern="\d*"
@ -41,11 +43,12 @@
type="number"
required
v-model="editingItem && editingItem.price"
ref="inputPrice"
v-on:change="onPriceChange"
aria-describedby="currency-addon" />
<span class="input-group-text" id="currency-addon" v-pre>@Model.currency</span>
</div>
</div>
<div class="text-danger" v-if="errors.price">{{errors.price}}</div>
</div>
<div class="form-group">
<label for="EditorImageUrl" class="form-label">Image Url</label>
@ -63,7 +66,7 @@
</div>
<div class="form-group">
<label for="EditorInventory" class="form-label">Inventory</label>
<input id="EditorInventory" type="number" inputmode="numeric" min="0" step="1" class="form-control mb-2" v-model="editingItem && editingItem.inventory" ref="inputInventory" />
<input id="EditorInventory" type="number" inputmode="numeric" min="0" step="1" class="form-control mb-2" v-model="editingItem && editingItem.inventory" v-on:change="onInventoryChange" />
<div class="form-text">Leave blank to not use this feature.</div>
</div>
<div class="form-group">
@ -75,8 +78,6 @@
<label for="Disabled" class="form-check-label">Enable</label>
</div>
<vc:ui-extension-point location="app-template-editor-item-detail" model="Model"></vc:ui-extension-point>
<div class="text-danger mb-3" v-for="error of errors">{{error}}</div>
<button class="btn btn-primary d-none d-xl-inline-block" type="button" id="ApplyItemChanges" v-on:click="apply">Apply</button>
</div>
<div v-if="!editingItem">Select an item to edit</div>
</div>

View file

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