Dashboard: Fix app stats tiles (#4775)

* Dashboard: Fix app stats tiles

They broke with #4747, because they contain script blocks that are loaded asynchronuosly and need to get run once the chart data is added to the page.

* Refactor PoS dashboard component

* Collocate the component JS files in separate files

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
d11n 2023-03-16 07:51:24 +01:00 committed by GitHub
parent e344622c9e
commit a671632fde
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 208 additions and 150 deletions

View file

@ -1,4 +1,5 @@
using System;
using System.Security.AccessControl;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels;
@ -24,15 +25,20 @@ public class AppSales : ViewComponent
_appService = appService;
}
public async Task<IViewComponentResult> InvokeAsync(AppSalesViewModel vm)
public async Task<IViewComponentResult> InvokeAsync(string appId, string appType)
{
if (vm.App == null)
throw new ArgumentNullException(nameof(vm.App));
var vm = new AppSalesViewModel()
{
Id = appId,
AppType = appType,
Url = Url.Action("AppSales", "UIApps", new { appId = appId }),
InitialRendering = HttpContext.GetAppData()?.Id != appId
};
if (vm.InitialRendering)
return View(vm);
var stats = await _appService.GetSalesStats(vm.App);
var app = HttpContext.GetAppData();
vm.AppType = app.AppType;
var stats = await _appService.GetSalesStats(HttpContext.GetAppData());
vm.SalesCount = stats.SalesCount;
vm.Series = stats.Series;

View file

@ -6,9 +6,12 @@ namespace BTCPayServer.Components.AppSales;
public class AppSalesViewModel
{
public AppData App { get; set; }
public AppSalesPeriod Period { get; set; } = AppSalesPeriod.Week;
public int SalesCount { get; set; }
public string Id { get; set; }
public string Name { get; set; }
public string AppType { get; set; }
public AppSalesPeriod Period { get; set; }
public string Url { get; set; }
public long SalesCount { get; set; }
public IEnumerable<SalesStatsItem> Series { get; set; }
public bool InitialRendering { get; set; }
}

View file

@ -1,17 +1,17 @@
@using BTCPayServer.Services.Apps
@using BTCPayServer.Components.AppSales
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model BTCPayServer.Components.AppSales.AppSalesViewModel
@{
var controller = $"UI{Model.App.AppType}";
var action = $"Update{Model.App.AppType}";
var label = Model.App.AppType == nameof(AppType.Crowdfund) ? "Contributions" : "Sales";
var controller = $"UI{Model.AppType}";
var action = $"Update{Model.AppType}";
var label = Model.AppType == "Crowdfund" ? "Contributions" : "Sales";
}
<div id="AppSales-@Model.App.Id" class="widget app-sales">
<div id="AppSales-@Model.Id" class="widget app-sales">
<header class="mb-3">
<h3>@Model.App.Name @label</h3>
<a asp-controller="@controller" asp-action="@action" asp-route-appId="@Model.App.Id">Manage</a>
<h3>@Model.Name @label</h3>
<a asp-controller="@controller" asp-action="@action" asp-route-appId="@Model.Id">Manage</a>
</header>
@if (Model.InitialRendering)
{
@ -20,15 +20,16 @@
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script src="~/Components/AppSales/Default.cshtml.js" asp-append-version="true"></script>
<script>
(async () => {
const url = @Safe.Json(Url.Action("AppSales", "UIApps", new { appId = Model.App.Id }));
const appId = @Safe.Json(Model.App.Id);
const url = @Safe.Json(Model.Url);
const appId = @Safe.Json(Model.Id);
const response = await fetch(url);
if (response.ok) {
document.getElementById(`AppSales-${appId}`).outerHTML = await response.text();
const initScript = document.querySelector(`#AppSales-${appId} script`);
if (initScript) eval(initScript.innerHTML);
const data = document.querySelector(`#AppSales-${appId} template`);
if (data) window.appSales.dataLoaded(JSON.parse(data.innerHTML));
}
})();
</script>
@ -40,54 +41,15 @@
<span class="sales-count">@Model.SalesCount</span> Total @label
</span>
<div class="btn-group only-for-js" role="group" aria-label="Filter">
<input type="radio" class="btn-check" name="AppSalesPeriod-@Model.App.Id" id="AppSalesPeriodWeek-@Model.App.Id" value="@AppSalesPeriod.Week" @(Model.Period == AppSalesPeriod.Week ? "checked" : "")>
<label class="btn btn-link" for="AppSalesPeriodWeek-@Model.App.Id">1W</label>
<input type="radio" class="btn-check" name="AppSalesPeriod-@Model.App.Id" id="AppSalesPeriodMonth-@Model.App.Id" value="@AppSalesPeriod.Month" @(Model.Period == AppSalesPeriod.Month ? "checked" : "")>
<label class="btn btn-link" for="AppSalesPeriodMonth-@Model.App.Id">1M</label>
<input type="radio" class="btn-check" name="AppSalesPeriod-@Model.Id" id="AppSalesPeriodWeek-@Model.Id" value="@AppSalesPeriod.Week" @(Model.Period == AppSalesPeriod.Week ? "checked" : "")>
<label class="btn btn-link" for="AppSalesPeriodWeek-@Model.Id">1W</label>
<input type="radio" class="btn-check" name="AppSalesPeriod-@Model.Id" id="AppSalesPeriodMonth-@Model.Id" value="@AppSalesPeriod.Month" @(Model.Period == AppSalesPeriod.Month ? "checked" : "")>
<label class="btn btn-link" for="AppSalesPeriodMonth-@Model.Id">1M</label>
</div>
</header>
<div class="ct-chart"></div>
<script>
(function () {
const id = @Safe.Json($"AppSales-{Model.App.Id}");
const appId = @Safe.Json(Model.App.Id);
const period = @Safe.Json(Model.Period.ToString());
const baseUrl = @Safe.Json(Url.Action("AppSales", "UIApps", new { appId = Model.App.Id }));
const data = { series: @Safe.Json(Model.Series), salesCount: @Safe.Json(Model.SalesCount) };
const render = (data, period) => {
const series = data.series.map(s => s.salesCount);
const labels = data.series.map((s, i) => period === @Safe.Json(Model.Period.ToString()) ? s.label : (i % 5 === 0 ? s.label : ''));
const min = Math.min(...series);
const max = Math.max(...series);
const low = min === max ? 0 : Math.max(min - ((max - min) / 5), 0);
document.querySelectorAll(`#${id} .sales-count`).innerText = data.salesCount;
new Chartist.Bar(`#${id} .ct-chart`, {
labels,
series: [series]
}, {
low,
});
};
render(data, period);
const update = async period => {
const url = `${baseUrl}/${period}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
render(data, period);
}
};
delegate('change', `#${id} [name="AppSalesPeriod-${appId}"]`, async e => {
const type = e.target.value;
await update(type);
});
})();
</script>
<template>
@Safe.Json(Model)
</template>
}
</div>

View file

@ -0,0 +1,45 @@
if (!window.appSales) {
window.appSales =
{
dataLoaded: function (model) {
const id = "AppSales-" + model.id;
const appId = model.id;
const period = model.period;
const baseUrl = model.url;
const data = model;
const render = (data, period) => {
const series = data.series.map(s => s.salesCount);
const labels = data.series.map((s, i) => period === model.period ? s.label : (i % 5 === 0 ? s.label : ''));
const min = Math.min(...series);
const max = Math.max(...series);
const low = min === max ? 0 : Math.max(min - ((max - min) / 5), 0);
document.querySelectorAll(`#${id} .sales-count`).innerText = data.salesCount;
new Chartist.Bar(`#${id} .ct-chart`, {
labels,
series: [series]
}, {
low,
});
};
render(data, period);
const update = async period => {
const url = `${baseUrl}/${period}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
render(data, period);
}
};
delegate('change', `#${id} [name="AppSalesPeriod-${appId}"]`, async e => {
const type = e.target.value;
await update(type);
});
}
};
}

View file

@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Components.AppSales;
using BTCPayServer.Data;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores;
@ -18,17 +19,25 @@ public class AppTopItems : ViewComponent
_appService = appService;
}
public async Task<IViewComponentResult> InvokeAsync(AppTopItemsViewModel vm)
public async Task<IViewComponentResult> InvokeAsync(string appId, string appType = null)
{
if (vm.App == null)
throw new ArgumentNullException(nameof(vm.App));
var vm = new AppTopItemsViewModel()
{
Id = appId,
AppType = appType,
Url = Url.Action("AppTopItems", "UIApps", new { appId = appId }),
InitialRendering = HttpContext.GetAppData()?.Id != appId
};
if (vm.InitialRendering)
return View(vm);
var entries = Enum.Parse<AppType>(vm.App.AppType) == AppType.Crowdfund
? await _appService.GetPerkStats(vm.App)
: await _appService.GetItemStats(vm.App);
var app = HttpContext.GetAppData();
vm.AppType = app.AppType;
var entries = Enum.Parse<AppType>(vm.AppType) == AppType.Crowdfund
? await _appService.GetPerkStats(app)
: await _appService.GetItemStats(app);
vm.SalesCount = entries.Select(e => e.SalesCount).ToList();
vm.Entries = entries.ToList();
return View(vm);

View file

@ -6,7 +6,11 @@ namespace BTCPayServer.Components.AppTopItems;
public class AppTopItemsViewModel
{
public AppData App { get; set; }
public string Id { get; set; }
public string Name { get; set; }
public string AppType { get; set; }
public string Url { get; set; }
public List<ItemStats> Entries { get; set; }
public List<int> SalesCount { get; set; }
public bool InitialRendering { get; set; }
}

View file

@ -1,16 +1,16 @@
@using BTCPayServer.Services.Apps
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model BTCPayServer.Components.AppTopItems.AppTopItemsViewModel
@{
var controller = $"UI{Model.App.AppType}";
var action = $"Update{Model.App.AppType}";
var label = Model.App.AppType == nameof(AppType.Crowdfund) ? "contribution" : "sale";
var controller = $"UI{Model.AppType}";
var action = $"Update{Model.AppType}";
var label = Model.AppType == nameof(AppType.Crowdfund) ? "contribution" : "sale";
}
<div id="AppTopItems-@Model.App.Id" class="widget app-top-items">
<div id="AppTopItems-@Model.Id" class="widget app-top-items">
<header class="mb-3">
<h3>Top @(Model.App.AppType == nameof(AppType.Crowdfund) ? "Perks" : "Items")</h3>
<a asp-controller="@controller" asp-action="@action" asp-route-appId="@Model.App.Id">View All</a>
<h3>Top @(Model.AppType == nameof(AppType.Crowdfund) ? "Perks" : "Items")</h3>
<a asp-controller="@controller" asp-action="@action" asp-route-appId="@Model.Id">View All</a>
</header>
@if (Model.InitialRendering)
{
@ -19,15 +19,16 @@
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script src="~/Components/AppTopItems/Default.cshtml.js" asp-append-version="true"></script>
<script>
(async () => {
const url = @Safe.Json(Url.Action("AppTopItems", "UIApps", new { appId = Model.App.Id }));
const appId = @Safe.Json(Model.App.Id);
const url = @Safe.Json(Model.Url);
const appId = @Safe.Json(Model.Id);
const response = await fetch(url);
if (response.ok) {
document.getElementById(`AppTopItems-${appId}`).outerHTML = await response.text();
const initScript = document.querySelector(`#AppTopItems-${appId} script`);
if (initScript) eval(initScript.innerHTML);
const data = document.querySelector(`#AppSales-${appId} template`);
if (data) window.appSales.dataLoaded(JSON.parse(data.innerHTML));
}
})();
</script>
@ -35,21 +36,9 @@
else if (Model.Entries.Any())
{
<div class="ct-chart mb-3"></div>
<script>
(function () {
const id = @Safe.Json($"AppTopItems-{Model.App.Id}");
const series = @Safe.Json(Model.Entries.Select(i => i.SalesCount));
new Chartist.Bar(`#${id} .ct-chart`, { series }, {
distributeSeries: true,
horizontalBars: true,
showLabel: false,
stackBars: true,
axisY: {
offset: 0
}
});
})();
</script>
<template>
@Safe.Json(Model)
</template>
<div class="app-items">
@for (var i = 0; i < Model.Entries.Count; i++)
{

View file

@ -0,0 +1,18 @@
if (!window.appTopItems) {
window.appTopItems =
{
dataLoaded: function (model) {
const id = "AppTopItems-" + model.id;
const series = model.salesCount;
new Chartist.Bar(`#${id} .ct-chart`, { series }, {
distributeSeries: true,
horizontalBars: true,
showLabel: false,
stackBars: true,
axisY: {
offset: 0
}
});
}
};
}

View file

@ -21,8 +21,7 @@ namespace BTCPayServer.Controllers
app.StoreData = GetCurrentStore();
var vm = new AppTopItemsViewModel { App = app };
return ViewComponent("AppTopItems", new { vm });
return ViewComponent("AppTopItems", new { appId = app.Id });
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
@ -34,9 +33,7 @@ namespace BTCPayServer.Controllers
return NotFound();
app.StoreData = GetCurrentStore();
var vm = new AppSalesViewModel { App = app };
return ViewComponent("AppSales", new { vm });
return ViewComponent("AppSales", new { appId = app.Id });
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]

View file

@ -29,6 +29,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@ -274,6 +275,19 @@ namespace BTCPayServer.Hosting
}
});
// The framework during publish automatically publish the js files into
// wwwroot, so this shouldn't be needed.
// But somehow during debug the collocated js files, are error 404!
var componentsFolder = Path.Combine(env.ContentRootPath, "Components");
if (Directory.Exists(componentsFolder))
{
app.UseStaticFiles(new StaticFileOptions()
{
FileProvider = new PhysicalFileProvider(componentsFolder),
RequestPath = "/Components"
});
}
app.UseProviderStorage(dataDirectories);
app.UseAuthentication();
app.UseAuthorization();
@ -296,5 +310,16 @@ namespace BTCPayServer.Hosting
});
app.UsePlugins();
}
private static Action<Microsoft.AspNetCore.StaticFiles.StaticFileResponseContext> NewMethod()
{
return ctx =>
{
// Cache static assets for one year, set asp-append-version="true" on references to update on change.
// https://andrewlock.net/adding-cache-control-headers-to-static-files-in-asp-net-core/
const int durationInSeconds = 60 * 60 * 24 * 365;
ctx.Context.Response.Headers[HeaderNames.CacheControl] = "public,max-age=" + durationInSeconds;
};
}
}
}

View file

@ -118,8 +118,8 @@
<vc:store-recent-invoices vm="@(new StoreRecentInvoicesViewModel { Store = store, CryptoCode = Model.CryptoCode, InitialRendering = true })"/>
@foreach (var app in Model.Apps)
{
<vc:app-sales vm="@(new AppSalesViewModel { App = app, InitialRendering = true })"/>
<vc:app-top-items vm="@(new AppTopItemsViewModel { App = app, InitialRendering = true })"/>
<vc:app-sales app-id="@app.Id" app-type="@app.AppType" />
<vc:app-top-items app-id="@app.Id" app-type="@app.AppType" />
}
</div>
}