Phase 15C - Feature Requests: Layout Polish, Notifications and Public Roadmap Foundation
This commit is contained in:
parent
ac14776e45
commit
6896629ed0
@ -10,7 +10,8 @@ namespace PlotLine.Controllers;
|
||||
[Route("admin")]
|
||||
public sealed class AdminController(
|
||||
ICurrentUserService currentUser,
|
||||
IFeatureRequestService featureRequests) : Controller
|
||||
IFeatureRequestService featureRequests,
|
||||
IEmailService emails) : Controller
|
||||
{
|
||||
[HttpGet("")]
|
||||
[HttpGet("index")]
|
||||
@ -78,12 +79,27 @@ public sealed class AdminController(
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
var before = await featureRequests.GetForAdminAsync(model.FeatureRequestID);
|
||||
if (before is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var updated = await featureRequests.UpdateAdminFieldsAsync(model);
|
||||
if (!updated)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var after = await featureRequests.GetForAdminAsync(model.FeatureRequestID);
|
||||
if (after is not null && !string.Equals(before.Status, after.Status, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await emails.SendFeatureRequestStatusChangedAsync(
|
||||
after,
|
||||
BuildAbsoluteUrl("Details", "FeatureRequests", new { id = after.FeatureRequestID }),
|
||||
currentUser.Email ?? string.Empty);
|
||||
}
|
||||
|
||||
TempData["AdminMessage"] = "Feature request updated.";
|
||||
return RedirectToAction(nameof(FeatureRequestDetails), new { id = model.FeatureRequestID });
|
||||
}
|
||||
@ -115,6 +131,23 @@ public sealed class AdminController(
|
||||
}
|
||||
|
||||
await featureRequests.AddAdminReplyAsync(model.FeatureRequestID, userId, model.MessageBody);
|
||||
await emails.SendFeatureRequestAdminReplyAsync(
|
||||
request,
|
||||
Preview(model.MessageBody),
|
||||
BuildAbsoluteUrl("Details", "FeatureRequests", new { id = request.FeatureRequestID }),
|
||||
currentUser.Email ?? string.Empty);
|
||||
return RedirectToAction(nameof(FeatureRequestDetails), new { id = model.FeatureRequestID });
|
||||
}
|
||||
|
||||
private string BuildAbsoluteUrl(string action, string controller, object routeValues)
|
||||
{
|
||||
return Url.Action(action, controller, routeValues, Request.Scheme)
|
||||
?? $"{Request.Scheme}://{Request.Host}";
|
||||
}
|
||||
|
||||
private static string Preview(string value)
|
||||
{
|
||||
var cleaned = value.ReplaceLineEndings(" ").Trim();
|
||||
return cleaned.Length <= 240 ? cleaned : $"{cleaned[..240]}...";
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,8 @@ namespace PlotLine.Controllers;
|
||||
[Authorize]
|
||||
public sealed class FeatureRequestsController(
|
||||
ICurrentUserService currentUser,
|
||||
IFeatureRequestService featureRequests) : Controller
|
||||
IFeatureRequestService featureRequests,
|
||||
IEmailService emails) : Controller
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
@ -24,6 +25,15 @@ public sealed class FeatureRequestsController(
|
||||
return View(new FeatureRequestCreateViewModel());
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Roadmap()
|
||||
{
|
||||
return View(new FeatureRequestRoadmapViewModel
|
||||
{
|
||||
Sections = await featureRequests.ListRoadmapAsync()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(FeatureRequestCreateViewModel model)
|
||||
@ -47,6 +57,10 @@ public sealed class FeatureRequestsController(
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await featureRequests.MarkViewedForUserAsync(request.FeatureRequestID, request.UserID);
|
||||
request.UserNeedsAttention = false;
|
||||
request.UserAttentionUtc = null;
|
||||
|
||||
return View(new FeatureRequestDetailsViewModel
|
||||
{
|
||||
Request = request,
|
||||
@ -77,6 +91,11 @@ public sealed class FeatureRequestsController(
|
||||
}
|
||||
|
||||
await featureRequests.AddUserReplyAsync(model.FeatureRequestID, userId, model.MessageBody);
|
||||
await emails.SendFeatureRequestUserReplyAsync(
|
||||
request,
|
||||
Preview(model.MessageBody),
|
||||
BuildAbsoluteUrl("FeatureRequestDetails", "Admin", new { id = request.FeatureRequestID }),
|
||||
currentUser.Email ?? string.Empty);
|
||||
return RedirectToAction(nameof(Details), new { id = model.FeatureRequestID });
|
||||
}
|
||||
|
||||
@ -91,4 +110,16 @@ public sealed class FeatureRequestsController(
|
||||
model.ImportanceOptions = PlotLine.Models.FeatureRequestOptions.ImportanceOptions;
|
||||
return model;
|
||||
}
|
||||
|
||||
private string BuildAbsoluteUrl(string action, string controller, object routeValues)
|
||||
{
|
||||
return Url.Action(action, controller, routeValues, Request.Scheme)
|
||||
?? $"{Request.Scheme}://{Request.Host}";
|
||||
}
|
||||
|
||||
private static string Preview(string value)
|
||||
{
|
||||
var cleaned = value.ReplaceLineEndings(" ").Trim();
|
||||
return cleaned.Length <= 240 ? cleaned : $"{cleaned[..240]}...";
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,10 +11,15 @@ public interface IFeatureRequestRepository
|
||||
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
|
||||
Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId);
|
||||
Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status, string? appArea, string? importance, string? search, string sort);
|
||||
Task<IReadOnlyList<FeatureRequest>> ListRoadmapAsync();
|
||||
Task<FeatureRequest?> UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate);
|
||||
Task<FeatureRequest?> UpdateRoadmapVisibilityAsync(int featureRequestId, bool isPublicCandidate);
|
||||
Task<FeatureRequest?> UpdateAdminNotesAsync(int featureRequestId, string? adminNotes);
|
||||
Task<FeatureRequestMessage?> AddMessageAsync(int featureRequestId, int? userId, bool isAdmin, string messageBody);
|
||||
Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId);
|
||||
Task<bool> MarkViewedForUserAsync(int featureRequestId, int userId);
|
||||
Task<int> CountAttentionForUserAsync(int userId);
|
||||
Task<int> CountWaitingForAdminAsync();
|
||||
}
|
||||
|
||||
public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFactory) : IFeatureRequestRepository
|
||||
@ -66,6 +71,15 @@ public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFac
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FeatureRequest>> ListRoadmapAsync()
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<FeatureRequest>(
|
||||
"dbo.FeatureRequest_ListRoadmap",
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<FeatureRequest?> UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
@ -75,6 +89,15 @@ public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFac
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<FeatureRequest?> UpdateRoadmapVisibilityAsync(int featureRequestId, bool isPublicCandidate)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<FeatureRequest>(
|
||||
"dbo.FeatureRequest_UpdateRoadmapVisibility",
|
||||
new { FeatureRequestID = featureRequestId, IsPublicCandidate = isPublicCandidate },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<FeatureRequest?> UpdateAdminNotesAsync(int featureRequestId, string? adminNotes)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
@ -102,4 +125,30 @@ public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFac
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> MarkViewedForUserAsync(int featureRequestId, int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<bool>(
|
||||
"dbo.FeatureRequest_MarkViewedForUser",
|
||||
new { FeatureRequestID = featureRequestId, UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<int> CountAttentionForUserAsync(int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.FeatureRequest_CountAttentionForUser",
|
||||
new { UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<int> CountWaitingForAdminAsync()
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.FeatureRequest_CountWaitingForAdmin",
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,6 +104,9 @@ public sealed class FeatureRequest
|
||||
public bool? LatestMessageIsAdmin { get; set; }
|
||||
public string? LatestMessagePreview { get; set; }
|
||||
public int MessageCount { get; set; }
|
||||
public DateTime? UserLastViewedUtc { get; set; }
|
||||
public DateTime? UserAttentionUtc { get; set; }
|
||||
public bool UserNeedsAttention { get; set; }
|
||||
|
||||
public bool IsOpen => FeatureRequestStatuses.IsOpen(Status);
|
||||
public string StatusExplanation => FeatureRequestStatuses.Explanation(Status);
|
||||
@ -126,6 +129,13 @@ public sealed class FeatureRequest
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FeatureRequestRoadmapSection
|
||||
{
|
||||
public string Heading { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public IReadOnlyList<FeatureRequest> Requests { get; set; } = [];
|
||||
}
|
||||
|
||||
public static class FeatureRequestConversationStates
|
||||
{
|
||||
public const string WaitingForAdminReply = "Waiting for Admin Reply";
|
||||
|
||||
@ -47,6 +47,9 @@ public interface IEmailService
|
||||
{
|
||||
Task SendVerificationEmailAsync(string email, string displayName, Guid token);
|
||||
Task SendPasswordResetEmailAsync(string email, string displayName, Guid token);
|
||||
Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail);
|
||||
Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail);
|
||||
Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail);
|
||||
}
|
||||
|
||||
public interface ICurrentUserService
|
||||
|
||||
@ -51,6 +51,7 @@ public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor)
|
||||
|
||||
public sealed class EmailService(
|
||||
IOptions<EmailSettings> emailOptions,
|
||||
IConfiguration configuration,
|
||||
IEmailQueueRepository emailQueue,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<EmailService> logger) : IEmailService
|
||||
@ -85,8 +86,107 @@ public sealed class EmailService(
|
||||
return QueueAsync(email, displayName, "Reset your PlotDirector password", plainText, html);
|
||||
}
|
||||
|
||||
private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string html)
|
||||
public Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail)
|
||||
{
|
||||
if (string.Equals(request.UserEmail, actorEmail, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var body = $"""
|
||||
Hi {DisplayName(request.UserDisplayName)},
|
||||
|
||||
There is a new reply on your PlotDirector feature request:
|
||||
|
||||
"{request.Title}"
|
||||
|
||||
{messagePreview}
|
||||
|
||||
You can view and reply here:
|
||||
{link}
|
||||
|
||||
Thanks,
|
||||
The PlotDirector team
|
||||
""";
|
||||
|
||||
return QueueAsync(
|
||||
request.UserEmail ?? string.Empty,
|
||||
DisplayName(request.UserDisplayName),
|
||||
$"PlotDirector feature request update: {request.Title}",
|
||||
body,
|
||||
html: null);
|
||||
}
|
||||
|
||||
public Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail)
|
||||
{
|
||||
if (string.Equals(request.UserEmail, actorEmail, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var body = $"""
|
||||
Hi {DisplayName(request.UserDisplayName)},
|
||||
|
||||
The status of your feature request has changed:
|
||||
|
||||
"{request.Title}"
|
||||
|
||||
New status: {request.Status}
|
||||
|
||||
{request.StatusExplanation}
|
||||
|
||||
You can view the request here:
|
||||
{link}
|
||||
|
||||
Thanks,
|
||||
The PlotDirector team
|
||||
""";
|
||||
|
||||
return QueueAsync(
|
||||
request.UserEmail ?? string.Empty,
|
||||
DisplayName(request.UserDisplayName),
|
||||
$"PlotDirector feature request status changed: {request.Title}",
|
||||
body,
|
||||
html: null);
|
||||
}
|
||||
|
||||
public async Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail)
|
||||
{
|
||||
var adminEmails = configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
|
||||
foreach (var adminEmail in adminEmails
|
||||
.Where(email => !string.IsNullOrWhiteSpace(email))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Where(email => !string.Equals(email, actorEmail, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var body = $"""
|
||||
A user replied to a PlotDirector feature request:
|
||||
|
||||
"{request.Title}"
|
||||
|
||||
User: {request.UserDisplayName} ({request.UserEmail})
|
||||
|
||||
{messagePreview}
|
||||
|
||||
View the request here:
|
||||
{link}
|
||||
""";
|
||||
|
||||
await QueueAsync(
|
||||
adminEmail,
|
||||
"PlotDirector admin",
|
||||
$"PlotDirector feature request reply: {request.Title}",
|
||||
body,
|
||||
html: null);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string? html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(toEmail))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var emailQueueId = await emailQueue.CreateAsync(new EmailQueueItem
|
||||
{
|
||||
ToEmail = toEmail,
|
||||
@ -163,6 +263,11 @@ public sealed class EmailService(
|
||||
return string.IsNullOrWhiteSpace(value) ? "PlotDirector" : value.Trim();
|
||||
}
|
||||
|
||||
private static string DisplayName(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? "there" : value.Trim();
|
||||
}
|
||||
|
||||
private string GetFromAddress()
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(_settings.FromAddress)
|
||||
|
||||
@ -11,11 +11,15 @@ public interface IFeatureRequestService
|
||||
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
|
||||
Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId);
|
||||
Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(AdminFeatureRequestFilter filter);
|
||||
Task<IReadOnlyList<FeatureRequestRoadmapSection>> ListRoadmapAsync();
|
||||
Task<FeatureRequestAdminSummary> GetAdminSummaryAsync();
|
||||
Task<bool> UpdateAdminFieldsAsync(AdminFeatureRequestUpdateViewModel model);
|
||||
Task<bool> AddUserReplyAsync(int featureRequestId, int userId, string messageBody);
|
||||
Task<bool> AddAdminReplyAsync(int featureRequestId, int adminUserId, string messageBody);
|
||||
Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId);
|
||||
Task<bool> MarkViewedForUserAsync(int featureRequestId, int userId);
|
||||
Task<int> CountAttentionForUserAsync(int userId);
|
||||
Task<int> CountWaitingForAdminAsync();
|
||||
}
|
||||
|
||||
public sealed class FeatureRequestService(IFeatureRequestRepository featureRequests) : IFeatureRequestService
|
||||
@ -50,6 +54,20 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
|
||||
sanitized.Sort);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FeatureRequestRoadmapSection>> ListRoadmapAsync()
|
||||
{
|
||||
var requests = await featureRequests.ListRoadmapAsync();
|
||||
return
|
||||
[
|
||||
BuildRoadmapSection("Under Consideration", FeatureRequestStatuses.Candidate, requests),
|
||||
BuildRoadmapSection("Planned", FeatureRequestStatuses.Planned, requests),
|
||||
BuildRoadmapSection("In Progress", FeatureRequestStatuses.InProgress, requests),
|
||||
BuildRoadmapSection("Recently Shipped", FeatureRequestStatuses.Shipped, requests
|
||||
.OrderByDescending(request => request.ShippedUtc ?? request.UpdatedUtc)
|
||||
.ToList())
|
||||
];
|
||||
}
|
||||
|
||||
public async Task<FeatureRequestAdminSummary> GetAdminSummaryAsync()
|
||||
{
|
||||
var requests = await featureRequests.ListForAdminAsync(
|
||||
@ -81,12 +99,24 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
|
||||
return false;
|
||||
}
|
||||
|
||||
var updated = await featureRequests.UpdateStatusAsync(model.FeatureRequestID, model.Status, model.IsPublicCandidate);
|
||||
if (updated is null)
|
||||
var current = await featureRequests.GetForAdminAsync(model.FeatureRequestID);
|
||||
if (current is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var statusChanged = !string.Equals(current.Status, model.Status, StringComparison.OrdinalIgnoreCase);
|
||||
var roadmapVisibilityChanged = current.IsPublicCandidate != model.IsPublicCandidate;
|
||||
|
||||
if (statusChanged)
|
||||
{
|
||||
await featureRequests.UpdateStatusAsync(model.FeatureRequestID, model.Status, model.IsPublicCandidate);
|
||||
}
|
||||
else if (roadmapVisibilityChanged)
|
||||
{
|
||||
await featureRequests.UpdateRoadmapVisibilityAsync(model.FeatureRequestID, model.IsPublicCandidate);
|
||||
}
|
||||
|
||||
await featureRequests.UpdateAdminNotesAsync(model.FeatureRequestID, CleanNullable(model.AdminNotes));
|
||||
return true;
|
||||
}
|
||||
@ -106,6 +136,15 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
|
||||
public Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId) =>
|
||||
featureRequests.ListMessagesAsync(featureRequestId);
|
||||
|
||||
public Task<bool> MarkViewedForUserAsync(int featureRequestId, int userId) =>
|
||||
featureRequests.MarkViewedForUserAsync(featureRequestId, userId);
|
||||
|
||||
public Task<int> CountAttentionForUserAsync(int userId) =>
|
||||
featureRequests.CountAttentionForUserAsync(userId);
|
||||
|
||||
public Task<int> CountWaitingForAdminAsync() =>
|
||||
featureRequests.CountWaitingForAdminAsync();
|
||||
|
||||
private static string Clean(string value) => value.Trim();
|
||||
|
||||
private static string? CleanNullable(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
@ -121,4 +160,16 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
|
||||
Sort = FeatureRequestSortOptions.IsValid(filter.Sort) ? filter.Sort : FeatureRequestSortOptions.LatestActivity
|
||||
};
|
||||
}
|
||||
|
||||
private static FeatureRequestRoadmapSection BuildRoadmapSection(string heading, string status, IEnumerable<FeatureRequest> requests)
|
||||
{
|
||||
return new FeatureRequestRoadmapSection
|
||||
{
|
||||
Heading = heading,
|
||||
Status = status,
|
||||
Requests = requests
|
||||
.Where(request => string.Equals(request.Status, status, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
331
PlotLine/Sql/092_Phase15C_FeatureRequestNotificationsRoadmap.sql
Normal file
331
PlotLine/Sql/092_Phase15C_FeatureRequestNotificationsRoadmap.sql
Normal file
@ -0,0 +1,331 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.FeatureRequests', N'UserLastViewedUtc') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FeatureRequests ADD UserLastViewedUtc datetime2 NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.FeatureRequests', N'UserAttentionUtc') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.FeatureRequests ADD UserAttentionUtc datetime2 NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FeatureRequests_UserAttention' AND object_id = OBJECT_ID(N'dbo.FeatureRequests'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_FeatureRequests_UserAttention
|
||||
ON dbo.FeatureRequests(UserID, UserAttentionUtc)
|
||||
WHERE UserAttentionUtc IS NOT NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_GetForUser
|
||||
@FeatureRequestID int,
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
|
||||
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
|
||||
fr.AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
|
||||
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
|
||||
latest.IsAdmin AS LatestMessageIsAdmin,
|
||||
latest.MessageBody AS LatestMessagePreview,
|
||||
messageCounts.MessageCount,
|
||||
fr.UserLastViewedUtc,
|
||||
fr.UserAttentionUtc,
|
||||
CAST(CASE WHEN fr.UserAttentionUtc IS NOT NULL THEN 1 ELSE 0 END AS bit) AS UserNeedsAttention
|
||||
FROM dbo.FeatureRequests fr
|
||||
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
|
||||
OUTER APPLY
|
||||
(
|
||||
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
|
||||
FROM dbo.FeatureRequestMessages frm
|
||||
WHERE frm.FeatureRequestID = fr.FeatureRequestID
|
||||
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
|
||||
) latest
|
||||
OUTER APPLY
|
||||
(
|
||||
SELECT COUNT(1) AS MessageCount
|
||||
FROM dbo.FeatureRequestMessages frm
|
||||
WHERE frm.FeatureRequestID = fr.FeatureRequestID
|
||||
) messageCounts
|
||||
WHERE fr.FeatureRequestID = @FeatureRequestID
|
||||
AND fr.UserID = @UserID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_GetForAdmin
|
||||
@FeatureRequestID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
|
||||
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
|
||||
fr.AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
|
||||
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
|
||||
latest.IsAdmin AS LatestMessageIsAdmin,
|
||||
latest.MessageBody AS LatestMessagePreview,
|
||||
messageCounts.MessageCount,
|
||||
fr.UserLastViewedUtc,
|
||||
fr.UserAttentionUtc,
|
||||
CAST(CASE WHEN fr.UserAttentionUtc IS NOT NULL THEN 1 ELSE 0 END AS bit) AS UserNeedsAttention
|
||||
FROM dbo.FeatureRequests fr
|
||||
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
|
||||
OUTER APPLY
|
||||
(
|
||||
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
|
||||
FROM dbo.FeatureRequestMessages frm
|
||||
WHERE frm.FeatureRequestID = fr.FeatureRequestID
|
||||
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
|
||||
) latest
|
||||
OUTER APPLY
|
||||
(
|
||||
SELECT COUNT(1) AS MessageCount
|
||||
FROM dbo.FeatureRequestMessages frm
|
||||
WHERE frm.FeatureRequestID = fr.FeatureRequestID
|
||||
) messageCounts
|
||||
WHERE fr.FeatureRequestID = @FeatureRequestID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_ListForUser
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
|
||||
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
|
||||
CAST(NULL AS nvarchar(max)) AS AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
|
||||
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
|
||||
latest.IsAdmin AS LatestMessageIsAdmin,
|
||||
latest.MessageBody AS LatestMessagePreview,
|
||||
messageCounts.MessageCount,
|
||||
fr.UserLastViewedUtc,
|
||||
fr.UserAttentionUtc,
|
||||
CAST(CASE WHEN fr.UserAttentionUtc IS NOT NULL THEN 1 ELSE 0 END AS bit) AS UserNeedsAttention
|
||||
FROM dbo.FeatureRequests fr
|
||||
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
|
||||
OUTER APPLY
|
||||
(
|
||||
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
|
||||
FROM dbo.FeatureRequestMessages frm
|
||||
WHERE frm.FeatureRequestID = fr.FeatureRequestID
|
||||
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
|
||||
) latest
|
||||
OUTER APPLY
|
||||
(
|
||||
SELECT COUNT(1) AS MessageCount
|
||||
FROM dbo.FeatureRequestMessages frm
|
||||
WHERE frm.FeatureRequestID = fr.FeatureRequestID
|
||||
) messageCounts
|
||||
WHERE fr.UserID = @UserID
|
||||
ORDER BY COALESCE(fr.UserAttentionUtc, latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) DESC, fr.FeatureRequestID DESC;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_ListForAdmin
|
||||
@Status nvarchar(50) = NULL,
|
||||
@AppArea nvarchar(100) = NULL,
|
||||
@Importance nvarchar(50) = NULL,
|
||||
@Search nvarchar(200) = NULL,
|
||||
@Sort nvarchar(50) = N'LatestActivity'
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @CleanSearch nvarchar(200) = NULLIF(LTRIM(RTRIM(@Search)), N'');
|
||||
|
||||
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
|
||||
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
|
||||
fr.AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
|
||||
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
|
||||
latest.IsAdmin AS LatestMessageIsAdmin,
|
||||
latest.MessageBody AS LatestMessagePreview,
|
||||
messageCounts.MessageCount,
|
||||
fr.UserLastViewedUtc,
|
||||
fr.UserAttentionUtc,
|
||||
CAST(CASE WHEN fr.UserAttentionUtc IS NOT NULL THEN 1 ELSE 0 END AS bit) AS UserNeedsAttention
|
||||
FROM dbo.FeatureRequests fr
|
||||
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
|
||||
OUTER APPLY
|
||||
(
|
||||
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
|
||||
FROM dbo.FeatureRequestMessages frm
|
||||
WHERE frm.FeatureRequestID = fr.FeatureRequestID
|
||||
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
|
||||
) latest
|
||||
OUTER APPLY
|
||||
(
|
||||
SELECT COUNT(1) AS MessageCount
|
||||
FROM dbo.FeatureRequestMessages frm
|
||||
WHERE frm.FeatureRequestID = fr.FeatureRequestID
|
||||
) messageCounts
|
||||
WHERE (@Status IS NULL OR fr.Status = @Status)
|
||||
AND (@AppArea IS NULL OR fr.AppArea = @AppArea)
|
||||
AND (@Importance IS NULL OR fr.Importance = @Importance)
|
||||
AND
|
||||
(
|
||||
@CleanSearch IS NULL
|
||||
OR fr.Title LIKE N'%' + @CleanSearch + N'%'
|
||||
OR fr.Description LIKE N'%' + @CleanSearch + N'%'
|
||||
OR au.DisplayName LIKE N'%' + @CleanSearch + N'%'
|
||||
OR au.Email LIKE N'%' + @CleanSearch + N'%'
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN @Sort = N'OldestFirst' THEN fr.CreatedUtc END ASC,
|
||||
CASE WHEN @Sort = N'NewestFirst' THEN fr.CreatedUtc END DESC,
|
||||
CASE WHEN @Sort IS NULL OR @Sort = N'LatestActivity' THEN COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) END DESC,
|
||||
fr.FeatureRequestID DESC;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_UpdateStatus
|
||||
@FeatureRequestID int,
|
||||
@Status nvarchar(50),
|
||||
@IsPublicCandidate bit = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.FeatureRequests
|
||||
SET Status = @Status,
|
||||
IsPublicCandidate = COALESCE(@IsPublicCandidate, IsPublicCandidate),
|
||||
UpdatedUtc = sysutcdatetime(),
|
||||
UserAttentionUtc = sysutcdatetime(),
|
||||
ClosedUtc = CASE WHEN @Status IN (N'Shipped', N'Not Planned') THEN COALESCE(ClosedUtc, sysutcdatetime()) ELSE NULL END,
|
||||
ShippedUtc = CASE WHEN @Status = N'Shipped' THEN COALESCE(ShippedUtc, sysutcdatetime()) ELSE NULL END
|
||||
WHERE FeatureRequestID = @FeatureRequestID;
|
||||
|
||||
EXEC dbo.FeatureRequest_GetForAdmin @FeatureRequestID = @FeatureRequestID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_UpdateRoadmapVisibility
|
||||
@FeatureRequestID int,
|
||||
@IsPublicCandidate bit
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.FeatureRequests
|
||||
SET IsPublicCandidate = @IsPublicCandidate,
|
||||
UpdatedUtc = sysutcdatetime()
|
||||
WHERE FeatureRequestID = @FeatureRequestID;
|
||||
|
||||
EXEC dbo.FeatureRequest_GetForAdmin @FeatureRequestID = @FeatureRequestID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FeatureRequestMessage_Add
|
||||
@FeatureRequestID int,
|
||||
@UserID int = NULL,
|
||||
@IsAdmin bit = 0,
|
||||
@MessageBody nvarchar(max)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.FeatureRequests WHERE FeatureRequestID = @FeatureRequestID)
|
||||
RETURN;
|
||||
|
||||
INSERT INTO dbo.FeatureRequestMessages (FeatureRequestID, UserID, IsAdmin, MessageBody)
|
||||
VALUES (@FeatureRequestID, @UserID, @IsAdmin, @MessageBody);
|
||||
|
||||
UPDATE dbo.FeatureRequests
|
||||
SET UpdatedUtc = sysutcdatetime(),
|
||||
UserAttentionUtc = CASE WHEN @IsAdmin = 1 THEN sysutcdatetime() ELSE UserAttentionUtc END
|
||||
WHERE FeatureRequestID = @FeatureRequestID;
|
||||
|
||||
SELECT frm.FeatureRequestMessageID, frm.FeatureRequestID, frm.UserID, au.Email AS UserEmail,
|
||||
au.DisplayName AS UserDisplayName, frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
|
||||
FROM dbo.FeatureRequestMessages frm
|
||||
LEFT JOIN dbo.AppUser au ON au.UserID = frm.UserID
|
||||
WHERE frm.FeatureRequestMessageID = SCOPE_IDENTITY();
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_MarkViewedForUser
|
||||
@FeatureRequestID int,
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.FeatureRequests
|
||||
SET UserLastViewedUtc = sysutcdatetime(),
|
||||
UserAttentionUtc = NULL
|
||||
WHERE FeatureRequestID = @FeatureRequestID
|
||||
AND UserID = @UserID;
|
||||
|
||||
SELECT CAST(@@ROWCOUNT AS bit) AS Succeeded;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_CountAttentionForUser
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT COUNT(1)
|
||||
FROM dbo.FeatureRequests
|
||||
WHERE UserID = @UserID
|
||||
AND UserAttentionUtc IS NOT NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_CountWaitingForAdmin
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT COUNT(1)
|
||||
FROM dbo.FeatureRequests fr
|
||||
OUTER APPLY
|
||||
(
|
||||
SELECT TOP (1) frm.IsAdmin
|
||||
FROM dbo.FeatureRequestMessages frm
|
||||
WHERE frm.FeatureRequestID = fr.FeatureRequestID
|
||||
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
|
||||
) latest
|
||||
WHERE fr.Status NOT IN (N'Shipped', N'Not Planned')
|
||||
AND (latest.IsAdmin IS NULL OR latest.IsAdmin = 0);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_ListRoadmap
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT FeatureRequestID, Title, CAST(NULL AS int) AS UserID, CAST(NULL AS nvarchar(256)) AS UserEmail,
|
||||
CAST(NULL AS nvarchar(200)) AS UserDisplayName, Description, AppArea, Importance, Status,
|
||||
IsPublicCandidate, CAST(NULL AS nvarchar(max)) AS AdminNotes, CreatedUtc, UpdatedUtc,
|
||||
ClosedUtc, ShippedUtc, UpdatedUtc AS LatestActivityUtc,
|
||||
CAST(NULL AS bit) AS LatestMessageIsAdmin, CAST(NULL AS nvarchar(max)) AS LatestMessagePreview,
|
||||
0 AS MessageCount, CAST(NULL AS datetime2) AS UserLastViewedUtc, CAST(NULL AS datetime2) AS UserAttentionUtc,
|
||||
CAST(0 AS bit) AS UserNeedsAttention
|
||||
FROM dbo.FeatureRequests
|
||||
WHERE IsPublicCandidate = 1
|
||||
AND Status IN (N'Candidate', N'Planned', N'In Progress', N'Shipped')
|
||||
ORDER BY
|
||||
CASE Status
|
||||
WHEN N'Candidate' THEN 1
|
||||
WHEN N'Planned' THEN 2
|
||||
WHEN N'In Progress' THEN 3
|
||||
WHEN N'Shipped' THEN 4
|
||||
ELSE 5
|
||||
END,
|
||||
CASE WHEN Status = N'Shipped' THEN ShippedUtc END DESC,
|
||||
UpdatedUtc DESC,
|
||||
FeatureRequestID DESC;
|
||||
END;
|
||||
GO
|
||||
@ -8,6 +8,11 @@ public sealed class FeatureRequestIndexViewModel
|
||||
public IReadOnlyList<FeatureRequest> Requests { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class FeatureRequestRoadmapViewModel
|
||||
{
|
||||
public IReadOnlyList<FeatureRequestRoadmapSection> Sections { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class FeatureRequestCreateViewModel
|
||||
{
|
||||
[Required(ErrorMessage = "Enter a title.")]
|
||||
|
||||
@ -94,7 +94,8 @@
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="IsPublicCandidate" name="IsPublicCandidate" value="true" checked="@request.IsPublicCandidate" />
|
||||
<input type="hidden" name="IsPublicCandidate" value="false" />
|
||||
<label class="form-check-label" for="IsPublicCandidate">Public candidate</label>
|
||||
<label class="form-check-label" for="IsPublicCandidate">Show on curated roadmap</label>
|
||||
<div class="form-text">Only Candidate, Planned, In Progress and Shipped requests appear on the roadmap.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="AdminNotes">Admin notes</label>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
@using System.Text.Encodings.Web
|
||||
@{
|
||||
ViewData["Title"] = "Feature Requests";
|
||||
ViewData["ShellClass"] = "plotline-shell-wide feature-request-admin-page";
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
@ -88,7 +89,7 @@ else
|
||||
{
|
||||
<section class="edit-panel">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<table class="table table-sm align-middle feature-request-admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
@ -100,9 +101,8 @@ else
|
||||
<th>Latest Activity</th>
|
||||
<th>Conversation</th>
|
||||
<th>Messages</th>
|
||||
<th>Latest message</th>
|
||||
<th>Updated</th>
|
||||
<th></th>
|
||||
<th class="feature-request-admin-table__preview">Latest message</th>
|
||||
<th class="feature-request-admin-table__action"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -118,8 +118,7 @@ else
|
||||
<td>@FormatNullableDate(request.LatestActivityUtc)</td>
|
||||
<td>@ConversationBadge(request.ConversationState)</td>
|
||||
<td>@request.MessageCount.ToString("N0")</td>
|
||||
<td class="text-truncate" style="max-width: 260px;">@Preview(request.LatestMessagePreview)</td>
|
||||
<td>@FormatDate(request.UpdatedUtc)</td>
|
||||
<td class="feature-request-admin-table__preview">@Preview(request.LatestMessagePreview)</td>
|
||||
<td class="text-end">
|
||||
<a class="btn btn-outline-primary btn-sm" asp-action="FeatureRequestDetails" asp-route-id="@request.FeatureRequestID">Open</a>
|
||||
</td>
|
||||
@ -144,7 +143,7 @@ else
|
||||
}
|
||||
|
||||
var cleaned = value.ReplaceLineEndings(" ").Trim();
|
||||
return cleaned.Length <= 120 ? cleaned : $"{cleaned[..120]}...";
|
||||
return cleaned.Length <= 90 ? cleaned : $"{cleaned[..90]}...";
|
||||
}
|
||||
|
||||
private static IHtmlContent StatusBadge(string status)
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
<p class="lead-text">Send suggestions and keep track of your conversations with the PlotDirector team.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-primary" asp-action="Roadmap">View Roadmap</a>
|
||||
<a class="btn btn-primary" asp-action="Create">Submit Feature Request</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -54,7 +55,13 @@ else
|
||||
@foreach (var request in Model.Requests)
|
||||
{
|
||||
<tr>
|
||||
<td>@request.Title</td>
|
||||
<td>
|
||||
@if (request.UserNeedsAttention)
|
||||
{
|
||||
<span class="badge text-bg-warning me-1">New</span>
|
||||
}
|
||||
@request.Title
|
||||
</td>
|
||||
<td>@StatusBadge(request.Status)</td>
|
||||
<td>@FormatNullableDate(request.LatestActivityUtc)</td>
|
||||
<td>@ConversationBadge(request.ConversationState)</td>
|
||||
|
||||
93
PlotLine/Views/FeatureRequests/Roadmap.cshtml
Normal file
93
PlotLine/Views/FeatureRequests/Roadmap.cshtml
Normal file
@ -0,0 +1,93 @@
|
||||
@model FeatureRequestRoadmapViewModel
|
||||
@using Microsoft.AspNetCore.Html
|
||||
@using System.Text.Encodings.Web
|
||||
@{
|
||||
ViewData["Title"] = "Feature Roadmap";
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Feedback</p>
|
||||
<h1>Feature Roadmap</h1>
|
||||
<p class="lead-text">Here are some improvements currently being considered, planned, in progress, or recently shipped. This is not a complete list of every suggestion, but it shows where PlotDirector is actively evolving.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-secondary" asp-action="Index">My Requests</a>
|
||||
<a class="btn btn-primary" asp-action="Create">Submit Feature Request</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="mb-3" aria-label="Breadcrumb">
|
||||
<a asp-action="Index">Feature Requests</a>
|
||||
<span class="muted"> / Roadmap</span>
|
||||
</nav>
|
||||
|
||||
@foreach (var roadmapSection in Model.Sections)
|
||||
{
|
||||
<section class="edit-panel">
|
||||
<h2>@roadmapSection.Heading</h2>
|
||||
@if (!roadmapSection.Requests.Any())
|
||||
{
|
||||
<p class="muted mb-0">@EmptyMessage(roadmapSection.Status)</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex flex-column gap-3">
|
||||
@foreach (var request in roadmapSection.Requests)
|
||||
{
|
||||
<article class="border rounded p-3">
|
||||
<div class="d-flex justify-content-between gap-3 flex-wrap mb-2">
|
||||
<div>
|
||||
<h3 class="fs-5 mb-1">@request.Title</h3>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@StatusBadge(request.Status)
|
||||
@if (!string.IsNullOrWhiteSpace(request.AppArea))
|
||||
{
|
||||
<span class="badge text-bg-secondary">@request.AppArea</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (string.Equals(request.Status, FeatureRequestStatuses.Shipped, StringComparison.OrdinalIgnoreCase) && request.ShippedUtc.HasValue)
|
||||
{
|
||||
<span class="muted">Shipped @FormatDate(request.ShippedUtc.Value)</span>
|
||||
}
|
||||
</div>
|
||||
<p class="mb-0">@Excerpt(request.Description)</p>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@functions {
|
||||
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd");
|
||||
|
||||
private static string Excerpt(string value)
|
||||
{
|
||||
var cleaned = value.ReplaceLineEndings(" ").Trim();
|
||||
return cleaned.Length <= 220 ? cleaned : $"{cleaned[..220]}...";
|
||||
}
|
||||
|
||||
private static string EmptyMessage(string status) => status switch
|
||||
{
|
||||
FeatureRequestStatuses.Shipped => "No shipped roadmap items yet.",
|
||||
FeatureRequestStatuses.InProgress => "No roadmap items are currently in progress.",
|
||||
FeatureRequestStatuses.Planned => "No planned roadmap items yet.",
|
||||
_ => "No roadmap items in this section yet."
|
||||
};
|
||||
|
||||
private static IHtmlContent StatusBadge(string status)
|
||||
{
|
||||
var css = status switch
|
||||
{
|
||||
FeatureRequestStatuses.Candidate => "text-bg-secondary",
|
||||
FeatureRequestStatuses.Planned => "text-bg-success",
|
||||
FeatureRequestStatuses.InProgress => "text-bg-success",
|
||||
FeatureRequestStatuses.Shipped => "text-bg-dark",
|
||||
_ => "text-bg-secondary"
|
||||
};
|
||||
|
||||
return new HtmlString($"<span class=\"badge {css}\">{HtmlEncoder.Default.Encode(status)}</span>");
|
||||
}
|
||||
}
|
||||
@ -2,13 +2,18 @@
|
||||
<html lang="en" data-theme="light" data-bs-theme="light">
|
||||
@using System.Security.Claims
|
||||
@inject IConfiguration Configuration
|
||||
@inject IFeatureRequestService FeatureRequestService
|
||||
@{
|
||||
var shellClass = ViewData["ShellClass"] as string ?? string.Empty;
|
||||
var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase);
|
||||
var userEmail = User.FindFirstValue(ClaimTypes.Email);
|
||||
var userIdValue = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var userId = int.TryParse(userIdValue, out var parsedUserId) ? parsedUserId : (int?)null;
|
||||
var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
|
||||
var isAdmin = !string.IsNullOrWhiteSpace(userEmail)
|
||||
&& adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase));
|
||||
var featureRequestAttentionCount = userId.HasValue ? await FeatureRequestService.CountAttentionForUserAsync(userId.Value) : 0;
|
||||
var adminFeatureRequestAttentionCount = isAdmin ? await FeatureRequestService.CountWaitingForAdminAsync() : 0;
|
||||
var defaultTitle = $"{ViewData["Title"]} - PlotDirector";
|
||||
var seoTitle = isMarketingPage && ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle;
|
||||
var seoDescription = isMarketingPage && ViewData["SeoDescription"] is string seoDescriptionValue ? seoDescriptionValue : null;
|
||||
@ -128,12 +133,24 @@
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Help" asp-action="Index">Help</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="FeatureRequests" asp-action="Index">Feature Requests</a>
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="FeatureRequests" asp-action="Index">
|
||||
Feature Requests
|
||||
@if (featureRequestAttentionCount > 0)
|
||||
{
|
||||
<span class="badge text-bg-warning ms-1">@featureRequestAttentionCount</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
@if (isAdmin)
|
||||
{
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Index">Admin</a>
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Index">
|
||||
Admin
|
||||
@if (adminFeatureRequestAttentionCount > 0)
|
||||
{
|
||||
<span class="badge text-bg-danger ms-1">@adminFeatureRequestAttentionCount</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
@ -169,6 +169,41 @@ td.text-end {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.feature-request-admin-page .page-heading.compact,
|
||||
.feature-request-admin-page .edit-panel {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.feature-request-admin-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.feature-request-admin-table th,
|
||||
.feature-request-admin-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.feature-request-admin-table__preview {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-request-admin-table__action {
|
||||
width: 78px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.feature-request-admin-table {
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.feature-request-admin-table__preview {
|
||||
max-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.trial-status-panel,
|
||||
.trial-dashboard-banner {
|
||||
border: 1px solid var(--plotline-line);
|
||||
|
||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user