fix: preserve plugin details card order

This commit is contained in:
thgO.O 2026-05-26 16:56:16 -03:00
parent 6375f1f67e
commit eea9a3e410
No known key found for this signature in database
GPG Key ID: EDC540B3D14756CB
2 changed files with 186 additions and 112 deletions

View File

@ -14,6 +14,72 @@ public class PluginDetailsUITests(ITestOutputHelper output) : PageTest
{
private readonly XUnitLogger _log = new("PluginDetailsUITests", output);
[Fact]
public async Task PluginDetails_NonEmbed_KeepsReviewsStackedUnderDescription()
{
await using var tester = new PlaywrightTester(_log);
tester.Server.ReuseDatabase = false;
await tester.StartAsync();
var ownerId = await tester.Server.CreateFakeUserAsync("layout-owner@x.com", confirmEmail: true, githubVerified: true);
const string slug = "plugin-details-layout";
await tester.Server.CreateAndBuildPluginAsync(ownerId, slug);
await tester.Page!.SetViewportSizeAsync(1600, 1000);
await tester.GoToUrl($"/public/plugins/{slug}");
await tester.AssertNoError();
var descriptionCard = tester.Page.Locator(".card").Filter(new LocatorFilterOptions { HasText = "Description" });
var detailsCard = tester.Page.Locator("#download-btn").Locator("xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' card ')][1]");
var reviewsCard = tester.Page.Locator("#reviews");
await Expect(descriptionCard).ToHaveCountAsync(1);
await Expect(detailsCard).ToHaveCountAsync(1);
await Expect(reviewsCard).ToHaveCountAsync(1);
var descriptionBox = await descriptionCard.BoundingBoxAsync() ?? throw new InvalidOperationException("Description card was not visible.");
var detailsBox = await detailsCard.BoundingBoxAsync() ?? throw new InvalidOperationException("Details card was not visible.");
var reviewsBox = await reviewsCard.BoundingBoxAsync() ?? throw new InvalidOperationException("Reviews card was not visible.");
var reviewsGap = reviewsBox.Y - (descriptionBox.Y + descriptionBox.Height);
Assert.InRange(reviewsGap, 0, 80);
Assert.True(
reviewsBox.Y < detailsBox.Y + detailsBox.Height,
"Reviews should stack under the description column instead of waiting for the metadata card height.");
}
[Fact]
public async Task PluginDetails_EmbedNarrow_KeepsVersionBeforeReviews()
{
await using var tester = new PlaywrightTester(_log);
tester.Server.ReuseDatabase = false;
await tester.StartAsync();
var ownerId = await tester.Server.CreateFakeUserAsync("embed-layout-owner@x.com", confirmEmail: true, githubVerified: true);
const string slug = "plugin-details-embed-layout";
await tester.Server.CreateAndBuildPluginAsync(ownerId, slug);
await tester.Page!.SetViewportSizeAsync(700, 1000);
await tester.GoToUrl($"/public/plugins/{slug}?embed=1");
await tester.AssertNoError();
var descriptionCard = tester.Page.Locator(".plugin-details-description-card");
var detailsCard = tester.Page.Locator(".plugin-details-metadata-card");
var reviewsCard = tester.Page.Locator(".plugin-details-reviews-card");
await Expect(descriptionCard).ToHaveCountAsync(1);
await Expect(detailsCard).ToHaveCountAsync(1);
await Expect(reviewsCard).ToHaveCountAsync(1);
await Expect(tester.Page.Locator("#btcpay-install-plugin-btn")).ToHaveCountAsync(1);
var descriptionBox = await descriptionCard.BoundingBoxAsync() ?? throw new InvalidOperationException("Description card was not visible.");
var detailsBox = await detailsCard.BoundingBoxAsync() ?? throw new InvalidOperationException("Details card was not visible.");
var reviewsBox = await reviewsCard.BoundingBoxAsync() ?? throw new InvalidOperationException("Reviews card was not visible.");
Assert.True(descriptionBox.Y < detailsBox.Y, "Description should stay above metadata in embedded details.");
Assert.True(detailsBox.Y < reviewsBox.Y, "Metadata should stay above reviews in embedded details.");
}
[Fact]
public async Task PluginDetails_Reviews_Flow_Works()
{

View File

@ -60,6 +60,18 @@
</div>
}
@if (Model.EmbedMode)
{
<style>
@@media (max-width: 991.98px) {
/* Let cards from both columns share the same Bootstrap order in narrow embeds. */
body.embed-mode .plugin-details-main > .plugin-details-column {
display: contents;
}
}
</style>
}
<div class="@containerClass"
data-embed-page="details"
data-plugin-slug="@Model.Plugin.ProjectSlug"
@ -100,123 +112,18 @@
</div>
</div>
<div class="row">
<div class="col-12 col-lg-8">
<div class="card">
<div class="row plugin-details-main @(Model.EmbedMode ? "gx-0 gx-lg-4" : null)">
<div class="col-12 col-lg-8 plugin-details-column">
<div class="card plugin-details-description-card @(Model.EmbedMode ? "col-12 order-1" : null)">
<div class="card-body">
<h4>Description</h4>
<p>@Model.Plugin.Description</p>
</div>
</div>
</div>
<div class="col-12 col-lg-4 mt-4 mt-lg-0">
<div class="card">
<div class="card-body">
<div class="row mb-3">
<div class="col-6 d-flex align-items-center"><strong>Version</strong></div>
<div class="col-6 text-end">
<div class="dropdown d-inline-block">
<button id="version-dropdown-btn" class="btn btn-secondary btn-sm dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
@string.Join(".", Model.Plugin.Version.Split('.').Take(3))
</button>
<ul class="dropdown-menu dropdown-menu-end" style="max-height: 140px; overflow-y: auto;">
@foreach (var v in Model.PluginVersions)
{
var shortV = string.Join(".", v.Split('.').Take(3));
<li>
<a class="dropdown-item @(v == Model.Plugin.Version ? "active" : "")"
href="#" data-version="@v" data-display="@shortV">@shortV</a>
</li>
}
</ul>
</div>
<input type="hidden" id="version-selector" name="Plugin.Version" value="@Model.Plugin.Version" />
</div>
</div>
<div class="row mb-3">
<div class="col-6"><strong>Published</strong></div>
<div class="col-6 text-end">
<span id="plugin-published-date">@(buildDate != default ? buildDate.ToString("MMM dd, yyyy") : "")</span>
</div>
</div>
<div id="plugin-repository-row" class="row mb-3 @(string.IsNullOrEmpty(sourceUrl) ? "d-none" : "")">
<div class="col-6"><strong>Repository</strong></div>
<div class="col-6 text-end">
<a id="plugin-source-url" href="@sourceUrl" target="_blank" rel="noopener noreferrer">View Source</a>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.Plugin.Documentation))
{
<div class="row mb-3">
<div class="col-6"><strong>Documentation</strong></div>
<div class="col-6 text-end">
<a href="@Model.Plugin.Documentation" target="_blank" rel="noopener noreferrer">View Documentation</a>
</div>
</div>
}
<div id="plugin-min-btcpay-row" class="row mb-3 @(string.IsNullOrEmpty(Model.Plugin.BTCPayMinVersion) ? "d-none" : "")">
<div class="col-6"><strong>Min. BTCPay Version</strong></div>
<div class="col-6 text-end">
<span id="plugin-min-btcpay-version">@Model.Plugin.BTCPayMinVersion</span>
</div>
</div>
<div id="plugin-max-btcpay-row" class="row mb-3 @(string.IsNullOrEmpty(Model.Plugin.BTCPayMaxVersion) ? "d-none" : "")">
<div class="col-6"><strong>Max. BTCPay Version</strong></div>
<div class="col-6 text-end">
<span id="plugin-max-btcpay-version">@Model.Plugin.BTCPayMaxVersion</span>
</div>
</div>
@if (dependencies != null)
{
foreach (var dep in dependencies)
{
if (string.Equals(dep["Identifier"]?.ToString(), "BTCPayServer", StringComparison.OrdinalIgnoreCase)) continue;
<div class="row mb-3">
<div class="col-6"><strong>@dep["Identifier"]</strong></div>
<div class="col-6 text-end">@dep["Condition"]</div>
</div>
}
}
<div class="row mb-3">
<div class="col-6"><strong>Created</strong></div>
<div class="col-6 text-end">@Model.Plugin.CreatedDate.ToString("MMM dd, yyyy")</div>
</div>
<div class="row mb-3">
<div class="col-6"><strong>Rating</strong></div>
<div class="col-6 text-end">
<span class="d-inline-flex align-items-center gap-1 lh-1">
<span class="fw-semibold">@((Model.Plugin.RatingSummary?.Average ?? 0m).ToString("0.0"))</span>
<span class="text-warning"><vc:icon symbol="star-fill" /></span>
<span class="text-muted small">(@(Model.Plugin.RatingSummary?.TotalReviews ?? 0))</span>
</span>
</div>
</div>
@if (!Model.EmbedMode)
{
<a id="download-btn"
asp-action="Download" asp-controller="Api"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug"
asp-route-version="@Model.Plugin.Version"
class="btn btn-primary w-100" target="_blank" rel="noopener noreferrer">Download</a>
}
else
{
<button type="button"
id="btcpay-install-plugin-btn"
class="btn btn-primary w-100"
data-plugin-install
disabled="@(string.IsNullOrWhiteSpace(pluginIdentifier) ? "disabled" : null)">Install in BTCPay Server</button>
}
</div>
</div>
</div>
<div class="col-12 col-lg-8">
@if (mediaItems.Any())
{
<div class="card mt-4">
<div class="card mt-4 @(Model.EmbedMode ? "col-12 order-3" : null)">
<div class="card-body">
<h4 class="mb-3">Media</h4>
<div id="plugin-media-carousel" class="plugin-media-carousel" tabindex="0" aria-label="Plugin media carousel">
@ -280,7 +187,7 @@
</div>
}
<div class="card mt-4" id="reviews">
<div class="card mt-4 plugin-details-reviews-card @(Model.EmbedMode ? "col-12 order-4" : null)" id="reviews">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<h4 class="mb-0">Reviews</h4>
@ -488,10 +395,111 @@
</div>
</div>
<div class="col-12 col-lg-4">
<div class="col-12 col-lg-4 mt-4 mt-lg-0 plugin-details-column">
<div class="card plugin-details-metadata-card @(Model.EmbedMode ? "col-12 order-2 mt-4 mt-lg-0" : null)">
<div class="card-body">
<div class="row mb-3">
<div class="col-6 d-flex align-items-center"><strong>Version</strong></div>
<div class="col-6 text-end">
<div class="dropdown d-inline-block">
<button id="version-dropdown-btn" class="btn btn-secondary btn-sm dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
@string.Join(".", Model.Plugin.Version.Split('.').Take(3))
</button>
<ul class="dropdown-menu dropdown-menu-end" style="max-height: 140px; overflow-y: auto;">
@foreach (var v in Model.PluginVersions)
{
var shortV = string.Join(".", v.Split('.').Take(3));
<li>
<a class="dropdown-item @(v == Model.Plugin.Version ? "active" : "")"
href="#" data-version="@v" data-display="@shortV">@shortV</a>
</li>
}
</ul>
</div>
<input type="hidden" id="version-selector" name="Plugin.Version" value="@Model.Plugin.Version" />
</div>
</div>
<div class="row mb-3">
<div class="col-6"><strong>Published</strong></div>
<div class="col-6 text-end">
<span id="plugin-published-date">@(buildDate != default ? buildDate.ToString("MMM dd, yyyy") : "")</span>
</div>
</div>
<div id="plugin-repository-row" class="row mb-3 @(string.IsNullOrEmpty(sourceUrl) ? "d-none" : "")">
<div class="col-6"><strong>Repository</strong></div>
<div class="col-6 text-end">
<a id="plugin-source-url" href="@sourceUrl" target="_blank" rel="noopener noreferrer">View Source</a>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.Plugin.Documentation))
{
<div class="row mb-3">
<div class="col-6"><strong>Documentation</strong></div>
<div class="col-6 text-end">
<a href="@Model.Plugin.Documentation" target="_blank" rel="noopener noreferrer">View Documentation</a>
</div>
</div>
}
<div id="plugin-min-btcpay-row" class="row mb-3 @(string.IsNullOrEmpty(Model.Plugin.BTCPayMinVersion) ? "d-none" : "")">
<div class="col-6"><strong>Min. BTCPay Version</strong></div>
<div class="col-6 text-end">
<span id="plugin-min-btcpay-version">@Model.Plugin.BTCPayMinVersion</span>
</div>
</div>
<div id="plugin-max-btcpay-row" class="row mb-3 @(string.IsNullOrEmpty(Model.Plugin.BTCPayMaxVersion) ? "d-none" : "")">
<div class="col-6"><strong>Max. BTCPay Version</strong></div>
<div class="col-6 text-end">
<span id="plugin-max-btcpay-version">@Model.Plugin.BTCPayMaxVersion</span>
</div>
</div>
@if (dependencies != null)
{
foreach (var dep in dependencies)
{
if (string.Equals(dep["Identifier"]?.ToString(), "BTCPayServer", StringComparison.OrdinalIgnoreCase)) continue;
<div class="row mb-3">
<div class="col-6"><strong>@dep["Identifier"]</strong></div>
<div class="col-6 text-end">@dep["Condition"]</div>
</div>
}
}
<div class="row mb-3">
<div class="col-6"><strong>Created</strong></div>
<div class="col-6 text-end">@Model.Plugin.CreatedDate.ToString("MMM dd, yyyy")</div>
</div>
<div class="row mb-3">
<div class="col-6"><strong>Rating</strong></div>
<div class="col-6 text-end">
<span class="d-inline-flex align-items-center gap-1 lh-1">
<span class="fw-semibold">@((Model.Plugin.RatingSummary?.Average ?? 0m).ToString("0.0"))</span>
<span class="text-warning"><vc:icon symbol="star-fill" /></span>
<span class="text-muted small">(@(Model.Plugin.RatingSummary?.TotalReviews ?? 0))</span>
</span>
</div>
</div>
@if (!Model.EmbedMode)
{
<a id="download-btn"
asp-action="Download" asp-controller="Api"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug"
asp-route-version="@Model.Plugin.Version"
class="btn btn-primary w-100" target="_blank" rel="noopener noreferrer">Download</a>
}
else
{
<button type="button"
id="btcpay-install-plugin-btn"
class="btn btn-primary w-100"
data-plugin-install
disabled="@(string.IsNullOrWhiteSpace(pluginIdentifier) ? "disabled" : null)">Install in BTCPay Server</button>
}
</div>
</div>
@if (Model.Contributors?.Any() == true)
{
<div class="card mt-4">
<div class="card mt-4 @(Model.EmbedMode ? "col-12 order-5" : null)">
<div class="card-body">
<h4>Contributors</h4>
<div class="row">