feat(web): add Mappings page with inline CRUD table
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
48
TaskTracker.Api/Pages/Mappings.cshtml
Normal file
48
TaskTracker.Api/Pages/Mappings.cshtml
Normal file
@@ -0,0 +1,48 @@
|
||||
@page
|
||||
@using TaskTracker.Api.Pages
|
||||
@model MappingsModel
|
||||
|
||||
<div class="mappings-page">
|
||||
<div class="analytics-header">
|
||||
<h1 class="page-title">App Mappings</h1>
|
||||
<button class="btn btn--primary btn--sm"
|
||||
hx-get="/mappings?handler=AddRow"
|
||||
hx-target="#mapping-tbody"
|
||||
hx-swap="afterbegin">
|
||||
+ Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (Model.Mappings.Count == 0)
|
||||
{
|
||||
<div class="surface empty-state">
|
||||
<p style="color: var(--color-text-secondary); font-size: 14px; margin-bottom: 12px;">No mappings configured</p>
|
||||
<button class="btn btn--ghost"
|
||||
hx-get="/mappings?handler=AddRow"
|
||||
hx-target="#mapping-tbody"
|
||||
hx-swap="afterbegin">
|
||||
+ Add your first mapping rule
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="surface" style="padding: 0; overflow: hidden;">
|
||||
<table class="mappings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pattern</th>
|
||||
<th>Match Type</th>
|
||||
<th>Category</th>
|
||||
<th>Friendly Name</th>
|
||||
<th style="width: 96px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mapping-tbody">
|
||||
@foreach (var m in Model.Mappings)
|
||||
{
|
||||
<partial name="Partials/_MappingRow" model="m" />
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
96
TaskTracker.Api/Pages/Mappings.cshtml.cs
Normal file
96
TaskTracker.Api/Pages/Mappings.cshtml.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using TaskTracker.Core.Entities;
|
||||
using TaskTracker.Core.Interfaces;
|
||||
|
||||
namespace TaskTracker.Api.Pages;
|
||||
|
||||
[IgnoreAntiforgeryToken]
|
||||
public class MappingsModel : PageModel
|
||||
{
|
||||
private readonly IAppMappingRepository _mappingRepo;
|
||||
|
||||
public MappingsModel(IAppMappingRepository mappingRepo) => _mappingRepo = mappingRepo;
|
||||
|
||||
public List<AppMapping> Mappings { get; set; } = [];
|
||||
|
||||
// Category colors (same as Board)
|
||||
public static readonly Dictionary<string, string> CategoryColors = new()
|
||||
{
|
||||
["Development"] = "#6366f1", ["Research"] = "#06b6d4", ["Communication"] = "#8b5cf6",
|
||||
["DevOps"] = "#f97316", ["Documentation"] = "#14b8a6", ["Design"] = "#ec4899",
|
||||
["Testing"] = "#3b82f6", ["General"] = "#64748b", ["Email"] = "#f59e0b",
|
||||
["Engineering"] = "#6366f1", ["LaserCutting"] = "#ef4444", ["Unknown"] = "#475569",
|
||||
};
|
||||
|
||||
public static readonly Dictionary<string, string> MatchTypeColors = new()
|
||||
{
|
||||
["ProcessName"] = "#6366f1",
|
||||
["TitleContains"] = "#06b6d4",
|
||||
["UrlContains"] = "#f97316",
|
||||
};
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
Mappings = await _mappingRepo.GetAllAsync();
|
||||
}
|
||||
|
||||
// Return an empty edit row for adding new
|
||||
public IActionResult OnGetAddRow()
|
||||
{
|
||||
return Partial("Partials/_MappingEditRow", new AppMapping());
|
||||
}
|
||||
|
||||
// Return the edit form for an existing mapping
|
||||
public async Task<IActionResult> OnGetEditRowAsync(int id)
|
||||
{
|
||||
var mapping = await _mappingRepo.GetByIdAsync(id);
|
||||
if (mapping is null) return NotFound();
|
||||
return Partial("Partials/_MappingEditRow", mapping);
|
||||
}
|
||||
|
||||
// Return the display row for an existing mapping (cancel edit)
|
||||
public async Task<IActionResult> OnGetRowAsync(int id)
|
||||
{
|
||||
var mapping = await _mappingRepo.GetByIdAsync(id);
|
||||
if (mapping is null) return NotFound();
|
||||
return Partial("Partials/_MappingRow", mapping);
|
||||
}
|
||||
|
||||
// Create or update a mapping
|
||||
public async Task<IActionResult> OnPostSaveAsync(int? id, string pattern, string matchType, string category, string? friendlyName)
|
||||
{
|
||||
if (id.HasValue && id.Value > 0)
|
||||
{
|
||||
// Update existing
|
||||
var mapping = await _mappingRepo.GetByIdAsync(id.Value);
|
||||
if (mapping is null) return NotFound();
|
||||
mapping.Pattern = pattern;
|
||||
mapping.MatchType = matchType;
|
||||
mapping.Category = category;
|
||||
mapping.FriendlyName = friendlyName;
|
||||
await _mappingRepo.UpdateAsync(mapping);
|
||||
return Partial("Partials/_MappingRow", mapping);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new
|
||||
var mapping = new AppMapping
|
||||
{
|
||||
Pattern = pattern,
|
||||
MatchType = matchType,
|
||||
Category = category,
|
||||
FriendlyName = friendlyName,
|
||||
};
|
||||
var created = await _mappingRepo.CreateAsync(mapping);
|
||||
return Partial("Partials/_MappingRow", created);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a mapping
|
||||
public async Task<IActionResult> OnDeleteAsync(int id)
|
||||
{
|
||||
await _mappingRepo.DeleteAsync(id);
|
||||
return Content(""); // htmx will remove the row
|
||||
}
|
||||
}
|
||||
67
TaskTracker.Api/Pages/Partials/_MappingEditRow.cshtml
Normal file
67
TaskTracker.Api/Pages/Partials/_MappingEditRow.cshtml
Normal file
@@ -0,0 +1,67 @@
|
||||
@using TaskTracker.Api.Pages
|
||||
@model TaskTracker.Core.Entities.AppMapping
|
||||
@{
|
||||
var isNew = Model.Id == 0;
|
||||
var formId = isNew ? "mapping-form-new" : $"mapping-form-{Model.Id}";
|
||||
var rowId = isNew ? "mapping-row-new" : $"mapping-row-{Model.Id}";
|
||||
}
|
||||
<tr id="@rowId" style="background: rgba(255,255,255,0.04);">
|
||||
<td>
|
||||
<input type="text" name="pattern" value="@Model.Pattern" placeholder="Pattern..."
|
||||
class="input" autofocus form="@formId" />
|
||||
</td>
|
||||
<td>
|
||||
<select name="matchType" class="select" form="@formId">
|
||||
@{
|
||||
var matchTypes = new[] { "ProcessName", "TitleContains", "UrlContains" };
|
||||
var currentMatch = string.IsNullOrEmpty(Model.MatchType) ? "ProcessName" : Model.MatchType;
|
||||
}
|
||||
@foreach (var mt in matchTypes)
|
||||
{
|
||||
if (mt == currentMatch)
|
||||
{
|
||||
<option value="@mt" selected="selected">@mt</option>
|
||||
}
|
||||
else
|
||||
{
|
||||
<option value="@mt">@mt</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="category" value="@Model.Category" placeholder="Category..."
|
||||
class="input" form="@formId" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="friendlyName" value="@Model.FriendlyName" placeholder="Friendly name (optional)"
|
||||
class="input" form="@formId" />
|
||||
</td>
|
||||
<td>
|
||||
<form id="@formId"
|
||||
hx-post="/mappings?handler=Save@(isNew ? "" : $"&id={Model.Id}")"
|
||||
hx-target="#@rowId"
|
||||
hx-swap="outerHTML"
|
||||
style="display: flex; gap: 4px;">
|
||||
<button type="submit" class="btn btn--ghost btn--sm" style="color: #22c55e;" title="Save">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</button>
|
||||
@if (isNew)
|
||||
{
|
||||
<button type="button" class="btn btn--ghost btn--sm" title="Cancel"
|
||||
onclick="this.closest('tr').remove()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn--ghost btn--sm" title="Cancel"
|
||||
hx-get="/mappings?handler=Row&id=@Model.Id"
|
||||
hx-target="#mapping-row-@Model.Id"
|
||||
hx-swap="outerHTML">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
41
TaskTracker.Api/Pages/Partials/_MappingRow.cshtml
Normal file
41
TaskTracker.Api/Pages/Partials/_MappingRow.cshtml
Normal file
@@ -0,0 +1,41 @@
|
||||
@using TaskTracker.Api.Pages
|
||||
@model TaskTracker.Core.Entities.AppMapping
|
||||
@{
|
||||
var matchColor = MappingsModel.MatchTypeColors.GetValueOrDefault(Model.MatchType, "#64748b");
|
||||
var catColor = MappingsModel.CategoryColors.GetValueOrDefault(Model.Category, "#64748b");
|
||||
}
|
||||
<tr id="mapping-row-@Model.Id">
|
||||
<td><span class="mapping-pattern">@Model.Pattern</span></td>
|
||||
<td>
|
||||
<span class="match-type-badge" style="background: @(matchColor)20; color: @matchColor;">
|
||||
@Model.MatchType
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--color-text-primary);">
|
||||
<span style="width: 8px; height: 8px; border-radius: 50%; background: @catColor; flex-shrink: 0;"></span>
|
||||
@Model.Category
|
||||
</span>
|
||||
</td>
|
||||
<td style="color: var(--color-text-secondary);">@(Model.FriendlyName ?? "\u2014")</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<button class="btn btn--ghost btn--sm"
|
||||
hx-get="/mappings?handler=EditRow&id=@Model.Id"
|
||||
hx-target="#mapping-row-@Model.Id"
|
||||
hx-swap="outerHTML"
|
||||
title="Edit">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||
</button>
|
||||
<button class="btn btn--ghost btn--sm"
|
||||
hx-delete="/mappings?id=@Model.Id"
|
||||
hx-target="#mapping-row-@Model.Id"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete this mapping rule?"
|
||||
title="Delete"
|
||||
style="color: var(--color-text-secondary);">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
Reference in New Issue
Block a user