Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First draft of support for task list - NOT READY FOR MERGE #254

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#nullable enable

using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;

namespace GovUk.Frontend.AspNetCore.HtmlGeneration
{
public partial class ComponentGenerator
{
internal const string TaskListElement = "ul";
internal const string TaskListTaskElement = "li";
internal const string TaskListTaskNameElement = "span";
internal const string TaskListHintElement = "span";
internal const string TaskListStatusElement = "span";

public TagBuilder GenerateTaskList(
AttributeDictionary? attributes,
IEnumerable<TaskListTask> tasks)
{
Guard.ArgumentNotNull(nameof(tasks), tasks);
Guard.ArgumentValid(nameof(tasks), "A task list must contain at least one task", tasks.Any());

var tagBuilder = new TagBuilder(TaskListElement);
if (attributes != null) { tagBuilder.MergeAttributes(attributes); }
tagBuilder.MergeCssClass("govuk-task-list");

var taskNumber = 1;
foreach (var task in tasks)
{
Guard.ArgumentValid(nameof(tasks), "Task name cannot be null or empty", task.Name.Content != null);

string taskId = GenerateTaskId(attributes, taskNumber, task);
var itemTagBuilder = new TagBuilder(TaskListTaskElement);
if (task.Attributes != null) { itemTagBuilder.MergeAttributes(task.Attributes); }
if (!itemTagBuilder.Attributes.ContainsKey("id")) { itemTagBuilder.MergeAttribute("id", taskId); }
itemTagBuilder.MergeCssClass("govuk-task-list__item");
tagBuilder.InnerHtml.AppendHtml(itemTagBuilder);

var taskNameTagBuilder = new TagBuilder(TaskListTaskNameElement);
taskNameTagBuilder.MergeAttributes(task.Name.Attributes);
taskNameTagBuilder.MergeCssClass("govuk-task-list__task-name-and-hint");

var statusText = TaskListTaskStatusText(task.Status.Status, task.Status.Content);

if (!string.IsNullOrEmpty(task.Href) && task.Status.Status != TaskListTaskStatus.NotApplicable && task.Status.Status != TaskListTaskStatus.CannotStartYet)
{
var taskLinkTagBuilder = new TagBuilder("a");
taskLinkTagBuilder.MergeAttribute("href", task.Href);
taskLinkTagBuilder.MergeCssClass("govuk-link");
taskLinkTagBuilder.MergeCssClass("govuk-task-list__link");
taskLinkTagBuilder.InnerHtml.AppendHtml(task.Name.Content!);
taskNameTagBuilder.InnerHtml.AppendHtml(taskLinkTagBuilder);
itemTagBuilder.MergeCssClass("govuk-task-list__item--with-link");

if (!string.IsNullOrEmpty(statusText))
{
var statusId = task.Status.Attributes.ContainsKey("id") ? task.Status.Attributes["id"] : taskId + "-status";
taskLinkTagBuilder.MergeAttribute("aria-describedby", statusId);
}
}
else
{
var unlinkedTaskTagBuilder = new TagBuilder("span");
unlinkedTaskTagBuilder.MergeCssClass("govuk-task-list__task-no-link");
unlinkedTaskTagBuilder.InnerHtml.AppendHtml(task.Name.Content!);
taskNameTagBuilder.InnerHtml.AppendHtml(unlinkedTaskTagBuilder);
}

if (task.Hint.Content != null)
{
var hintTagBuilder = new TagBuilder(TaskListHintElement);
hintTagBuilder.MergeAttributes(task.Hint.Attributes);
hintTagBuilder.MergeCssClass("govuk-task-list__task_hint");
hintTagBuilder.InnerHtml.AppendHtml(task.Hint.Content);
taskNameTagBuilder.InnerHtml.AppendHtml(hintTagBuilder);
}

itemTagBuilder.InnerHtml.AppendHtml(taskNameTagBuilder);

if (!string.IsNullOrEmpty(statusText))
{
var statusOuterTagBuilder = new TagBuilder(TaskListStatusElement);
statusOuterTagBuilder.MergeCssClass("govuk-task-list__status-container");

var statusInnerTagBuilder = new TagBuilder("span");
statusInnerTagBuilder.MergeAttributes(task.Status.Attributes);
statusInnerTagBuilder.MergeCssClass("govuk-task-list__status");
if (task.Status.Status.HasValue)
{
statusInnerTagBuilder.MergeCssClass(TaskStatusCssClass(task.Status.Status.Value));
}
if (!statusInnerTagBuilder.Attributes.ContainsKey("id")) { statusInnerTagBuilder.MergeAttribute("id", taskId + "-status"); }

statusInnerTagBuilder.InnerHtml.AppendHtml(statusText);
statusOuterTagBuilder.InnerHtml.AppendHtml(statusInnerTagBuilder);
itemTagBuilder.InnerHtml.AppendHtml(statusOuterTagBuilder);
}

taskNumber++;
}

return tagBuilder;
}

private string TaskStatusCssClass(TaskListTaskStatus status)
{
return "govuk-task-list__status-" + Regex.Replace(status.ToString(), "([A-Z])", "-$1").ToLowerInvariant();
}

public static string? TaskListTaskStatusText(TaskListTaskStatus? status, string? customStatus = null)
{
if (!string.IsNullOrEmpty(customStatus))
{
return customStatus;
}
else if (status.HasValue)
{
var statusText = Regex.Replace(status.ToString()!, "([A-Z])", " $1").ToLowerInvariant().Trim();
return statusText.Substring(0, 1).ToUpperInvariant() + statusText.Substring(1);
}
else return null;
}

private static string GenerateTaskId(AttributeDictionary? attributes, int taskNumber, TaskListTask task)
{
string taskId = string.Empty;
if (task.Attributes != null && task.Attributes.ContainsKey("id"))
{
taskId = task.Attributes["id"]!;
}
else
{
taskId = Regex.Replace(Regex.Replace(task.Name.Content!.Value!, "<.*?>", string.Empty, RegexOptions.IgnoreCase), "[^A-Z0-9- ]", string.Empty, RegexOptions.IgnoreCase).Replace(" ", "-").ToLowerInvariant();
}
if (string.IsNullOrEmpty(taskId)) { taskId = "task-" + taskNumber; }
if (attributes != null && attributes.ContainsKey("id")) { taskId = attributes["id"] + "-" + taskId; }

return taskId;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#nullable enable

using System;
using System.Web;
using Microsoft.AspNetCore.Mvc.Rendering;

namespace GovUk.Frontend.AspNetCore.HtmlGeneration
{
public partial class ComponentGenerator
{
internal const string TaskListSummaryElement = "div";
internal const int TaskListSummaryDefaultHeadingLevel = 2;
internal const int TaskListSummaryMinHeadingLevel = 1;
internal const int TaskListSummaryMaxHeadingLevel = 6;
public const string TaskListSummaryDefaultIncompleteStatus = "Tasks incomplete";
public const string TaskListSummaryDefaultCompletedStatus = "Tasks completed";
public const string TaskListSummaryDefaultTracker = "You've completed {0} of {1} tasks.";

public TagBuilder GenerateTaskListSummary(TaskListSummary taskListSummary)
{
if (taskListSummary.HeadingLevel < TaskListSummaryMinHeadingLevel || taskListSummary.HeadingLevel > TaskListSummaryMaxHeadingLevel)
{
throw new ArgumentOutOfRangeException(
$"{nameof(taskListSummary.HeadingLevel)} must be between {TaskListSummaryMinHeadingLevel} and {TaskListSummaryMaxHeadingLevel}.",
nameof(taskListSummary.HeadingLevel));
}

Guard.ArgumentNotNullOrEmpty(nameof(taskListSummary.IncompleteStatus), taskListSummary.IncompleteStatus);
Guard.ArgumentNotNullOrEmpty(nameof(taskListSummary.CompletedStatus), taskListSummary.CompletedStatus);
Guard.ArgumentNotNullOrEmpty(nameof(taskListSummary.Tracker), taskListSummary.Tracker);

var tagBuilder = new TagBuilder(TaskListSummaryElement);
if (taskListSummary.Attributes != null) { tagBuilder.MergeAttributes(taskListSummary.Attributes); }
tagBuilder.MergeCssClass("govuk-task-list-summary");

var statusTagBuilder = new TagBuilder($"h{taskListSummary.HeadingLevel}");
statusTagBuilder.MergeCssClass("govuk-heading-s");
statusTagBuilder.MergeCssClass("govuk-task-list-summary__heading");
statusTagBuilder.InnerHtml.Append(taskListSummary.CompletedTasks == taskListSummary.TotalTasks ? taskListSummary.CompletedStatus : taskListSummary.IncompleteStatus);
tagBuilder.InnerHtml.AppendHtml(statusTagBuilder);

var trackerTagBuilder = new TagBuilder("p");
trackerTagBuilder.MergeCssClass("govuk-body");
trackerTagBuilder.MergeCssClass("govuk-task-list-summary__tracker");
trackerTagBuilder.InnerHtml.AppendHtml(string.Format(HttpUtility.HtmlEncode(taskListSummary.Tracker),
"<span class=\"govuk-task-list-summary__completed-tasks\">" + taskListSummary.CompletedTasks + "</span>",
"<span class=\"govuk-task-list-summary__total-tasks\">" + taskListSummary.TotalTasks + "</span>"));
tagBuilder.InnerHtml.AppendHtml(trackerTagBuilder);

return tagBuilder;
}
}
}
15 changes: 15 additions & 0 deletions src/GovUk.Frontend.AspNetCore/HtmlGeneration/TaskListSummary.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Mvc.ViewFeatures;

namespace GovUk.Frontend.AspNetCore.HtmlGeneration
{
public class TaskListSummary
{
public AttributeDictionary Attributes { get; set; }
public string IncompleteStatus { get; set; }
public string CompletedStatus { get; set; }
public string Tracker { get; set; }
public int TotalTasks { get; set; }
public int CompletedTasks { get; set; }
public int HeadingLevel { get; set; }
}
}
14 changes: 14 additions & 0 deletions src/GovUk.Frontend.AspNetCore/HtmlGeneration/TaskListTask.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.ViewFeatures;

namespace GovUk.Frontend.AspNetCore.HtmlGeneration
{
public class TaskListTask
{
public AttributeDictionary Attributes { get; set; }
public (AttributeDictionary Attributes, HtmlString Content) Name { get; init; }
public string Href { get; set; }
public (AttributeDictionary Attributes, TaskListTaskStatus? Status, string Content) Status { get; internal set; }
public (AttributeDictionary Attributes, HtmlString Content) Hint { get; internal set; }
}
}
4 changes: 4 additions & 0 deletions src/GovUk.Frontend.AspNetCore/IGovUkHtmlGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ TagBuilder GenerateTabs(

TagBuilder GenerateTag(IHtmlContent content, AttributeDictionary attributes);

TagBuilder GenerateTaskList(AttributeDictionary? attributes, IEnumerable<TaskListTask> tasks);

TagBuilder GenerateTaskListSummary(TaskListSummary taskListSummary);

TagBuilder GenerateTextArea(
bool haveError,
string id,
Expand Down
32 changes: 32 additions & 0 deletions src/GovUk.Frontend.AspNetCore/TagHelpers/TaskListContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Collections.Generic;
using GovUk.Frontend.AspNetCore.HtmlGeneration;

namespace GovUk.Frontend.AspNetCore.TagHelpers
{
internal class TaskListContext
{
private readonly List<TaskListTask> _tasks;

public TaskListContext()
{
_tasks = new List<TaskListTask>();
}

public IReadOnlyList<TaskListTask> Tasks => _tasks;

public void AddTask(TaskListTask task)
{
Guard.ArgumentNotNull(nameof(task), task);

_tasks.Add(task);
}

public void ThrowIfIncomplete()
{
if (Tasks.Count < 1)
{
throw ExceptionHelper.AChildElementMustBeProvided(TaskListTaskTagHelper.TagName);
}
}
}
}
113 changes: 113 additions & 0 deletions src/GovUk.Frontend.AspNetCore/TagHelpers/TaskListSummaryTagHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System;
using System.Threading.Tasks;
using GovUk.Frontend.AspNetCore.HtmlGeneration;
using Microsoft.AspNetCore.Mvc.TagHelpers;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace GovUk.Frontend.AspNetCore.TagHelpers
{
/// <summary>
/// Generates a GOV.UK task list summary component.
/// </summary>
[HtmlTargetElement(TagName)]
[OutputElementHint(ComponentGenerator.TaskListSummaryElement)]
public class TaskListSummaryTagHelper : TagHelper
{
internal const string TagName = "govuk-task-list-summary";

private readonly IGovUkHtmlGenerator _htmlGenerator;
private int _headingLevel = ComponentGenerator.TaskListSummaryDefaultHeadingLevel;

/// <summary>
/// The heading to display when the task list is incomplete.
/// </summary>
[HtmlAttributeName("incomplete-status")]
public string IncompleteStatus { get; set; } = ComponentGenerator.TaskListSummaryDefaultIncompleteStatus;

/// <summary>
/// The heading to display when the task list is completed.
/// </summary>
[HtmlAttributeName("completed-status")]
public string CompletedStatus { get; set; } = ComponentGenerator.TaskListSummaryDefaultCompletedStatus;

/// <summary>
/// Text that displays how many tasks are completed. {0} is replaced with the number of completed tasks, and {1} is replaced with the total number of tasks.
/// </summary>
[HtmlAttributeName("tracker")]
public string Tracker { get; set; } = ComponentGenerator.TaskListSummaryDefaultTracker;

/// <summary>
/// The total number of tasks in the task list that must be completed.
/// </summary>
[HtmlAttributeName("total-tasks")]
public int TotalTasks { get; set; } = 0;

/// <summary>
/// The number of tasks in the task list that have been completed so far.
/// </summary>
[HtmlAttributeName("completed-tasks")]
public int CompletedTasks { get; set; } = 0;


/// <summary>
/// The heading level.
/// </summary>
/// <remarks>
/// Must be between <c>1</c> and <c>6</c> (inclusive). The default is <c>2</c>.
/// </remarks>
[HtmlAttributeName("heading-level")]
public int HeadingLevel
{
get => _headingLevel;
set
{
if (value < ComponentGenerator.TaskListSummaryMinHeadingLevel ||
value > ComponentGenerator.TaskListSummaryMaxHeadingLevel)
{
throw new ArgumentOutOfRangeException(
nameof(value),
$"{nameof(HeadingLevel)} must be between {ComponentGenerator.TaskListSummaryMinHeadingLevel} and {ComponentGenerator.TaskListSummaryMaxHeadingLevel}.");
}

_headingLevel = value;
}
}

/// <summary>
/// Creates a new <see cref="TaskListSummaryTagHelper"/>.
/// </summary>
public TaskListSummaryTagHelper()
: this(htmlGenerator: null)
{
}

internal TaskListSummaryTagHelper(IGovUkHtmlGenerator htmlGenerator)
{
_htmlGenerator = htmlGenerator ?? new ComponentGenerator();
}

/// <inheritdoc/>
public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var tagBuilder = _htmlGenerator.GenerateTaskListSummary(new TaskListSummary
{
Attributes = output.Attributes.ToAttributeDictionary(),
HeadingLevel = HeadingLevel,
IncompleteStatus = IncompleteStatus,
CompletedStatus = CompletedStatus,
Tracker = Tracker,
TotalTasks = TotalTasks,
CompletedTasks = CompletedTasks
});

output.TagName = tagBuilder.TagName;
output.TagMode = TagMode.StartTagAndEndTag;

output.Attributes.Clear();
output.MergeAttributes(tagBuilder);
output.Content.SetHtmlContent(tagBuilder.InnerHtml);

return Task.CompletedTask;
}
}
}
Loading