Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,137 @@
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace PowderCoating.Web.TagHelpers;
/// <summary>
/// Razor tag helper that transforms any <c>&lt;th sortable="ColumnName"&gt;</c>
/// element into a clickable, sortable column header with a sort-direction chevron.
/// <para>
/// Usage in a Razor view:
/// <code>
/// &lt;th sortable="CustomerName"
/// current-sort="@Model.SortColumn"
/// current-direction="@Model.SortDirection"&gt;
/// Customer
/// &lt;/th&gt;
/// </code>
/// </para>
/// <para>
/// Design decisions:
/// <list type="bullet">
/// <item>
/// All existing query-string parameters (search term, page number, filters)
/// are preserved when building the sort URL. This means clicking a column
/// header does not reset an active search — a common UX frustration in
/// simpler implementations.
/// </item>
/// <item>
/// The page number is explicitly reset to 1 when the sort column changes
/// because sorted data shifts row positions, making a previously-active
/// page number meaningless.
/// </item>
/// <item>
/// The tag helper targets <c>&lt;th&gt;</c> elements (rather than a custom
/// element) so that existing table markup does not need structural changes —
/// adding the <c>sortable</c> attribute is sufficient.
/// </item>
/// <item>
/// The <c>sortable</c> css class is merged with any existing class on the
/// <c>&lt;th&gt;</c> (e.g. <c>text-end</c>, <c>ps-4</c>) so column-specific
/// alignment and spacing are preserved.
/// </item>
/// </list>
/// </para>
/// </summary>
[HtmlTargetElement("th", Attributes = "sortable")]
public class SortableColumnTagHelper : TagHelper
{
/// <summary>
/// The column identifier that will be sent as the <c>sortColumn</c> query
/// parameter when the header link is clicked. This should match the string
/// the controller uses to switch on sort behaviour.
/// </summary>
[HtmlAttributeName("sortable")]
public string ColumnName { get; set; } = string.Empty;
/// <summary>
/// The column currently being sorted, passed from the view model.
/// When this equals <see cref="ColumnName"/> the header shows a directional
/// chevron; otherwise a neutral double-arrow icon is shown.
/// </summary>
[HtmlAttributeName("current-sort")]
public string? CurrentSort { get; set; }
/// <summary>
/// The current sort direction (<c>"asc"</c> or <c>"desc"</c>), passed from
/// the view model. Clicking the header toggles this to the opposite value.
/// </summary>
[HtmlAttributeName("current-direction")]
public string? CurrentDirection { get; set; }
/// <summary>
/// The current view context, injected automatically by the Razor tag helper
/// infrastructure. <see cref="HtmlAttributeNotBound"/> prevents Razor from
/// treating <c>view-context</c> as an HTML attribute on the element.
/// Required to access <c>HttpContext.Request</c> for reading existing
/// query string parameters.
/// </summary>
[ViewContext]
[HtmlAttributeNotBound]
public ViewContext ViewContext { get; set; } = null!;
/// <summary>
/// Rewrites the <c>&lt;th&gt;</c> element's content to contain a sort link.
/// The method:
/// <list type="number">
/// <item>Copies all existing query parameters from the current URL.</item>
/// <item>Overrides <c>sortColumn</c>, <c>sortDirection</c>, and <c>pageNumber</c>.</item>
/// <item>Selects the appropriate Bootstrap Icon chevron for the current sort state.</item>
/// <item>Wraps the cell's original text in an anchor tag pointing to the new URL.</item>
/// </list>
/// </summary>
/// <param name="context">Tag helper execution context (provides access to all attributes).</param>
/// <param name="output">The output object that controls the rendered HTML.</param>
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var request = ViewContext.HttpContext.Request;
var queryParams = request.Query.ToDictionary(k => k.Key, v => v.Value.ToString());
// Determine new sort direction
var newDirection = "asc";
if (CurrentSort == ColumnName && CurrentDirection == "asc")
{
newDirection = "desc";
}
// Update query params
queryParams["sortColumn"] = ColumnName;
queryParams["sortDirection"] = newDirection;
queryParams["pageNumber"] = "1"; // Reset to page 1 when sorting changes
// Build query string
var queryString = string.Join("&", queryParams.Select(kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}"));
var url = $"{request.Path}?{queryString}";
// Determine icon
var icon = "bi-arrow-down-up";
if (CurrentSort == ColumnName)
{
icon = CurrentDirection == "asc" ? "bi-arrow-up" : "bi-arrow-down";
}
// Build output — preserve any existing classes (e.g. text-end, ps-4)
var existingClass = output.Attributes["class"]?.Value?.ToString() ?? "";
var combinedClass = string.IsNullOrEmpty(existingClass) ? "sortable" : $"sortable {existingClass}";
output.Attributes.SetAttribute("class", combinedClass);
// Use the tag's inner content as link text so display text matches the markup
var innerContent = (await output.GetChildContentAsync()).GetContent();
output.Content.Clear();
output.Content.AppendHtml($"<a href=\"{url}\" class=\"text-decoration-none\" style=\"color:inherit\">");
output.Content.AppendHtml(string.IsNullOrWhiteSpace(innerContent) ? context.AllAttributes["sortable"].Value.ToString() : innerContent);
output.Content.AppendHtml($" <i class=\"bi {icon}\"></i>");
output.Content.AppendHtml("</a>");
}
}