diff --git a/EssentialCSharp.Web.Tests/SiteMappingTests.cs b/EssentialCSharp.Web.Tests/SiteMappingTests.cs index cb2c8f52..cf4c2c34 100644 --- a/EssentialCSharp.Web.Tests/SiteMappingTests.cs +++ b/EssentialCSharp.Web.Tests/SiteMappingTests.cs @@ -82,4 +82,71 @@ public void FindCSyntaxFundamentalsSanitizedWithAnchorReturnsCorrectSiteMap() Assert.NotNull(foundSiteMap); Assert.Equivalent(CSyntaxFundamentalsSiteMapping, foundSiteMap); } + + [Fact] + public void FindPercentComplete_KeyIsNull_ReturnsNull() + { + // Arrange + + // Act + string? percent = GetSiteMap().FindPercentComplete(null!); + + // Assert + Assert.Null(percent); + } + + [Theory] + [InlineData(" ")] + [InlineData("")] + public void FindPercentComplete_KeyIsWhiteSpace_ThrowsArgumentException(string? key) + { + // Arrange + + // Act + + // Assert + Assert.Throws(() => + { + GetSiteMap().FindPercentComplete(key); + }); + } + + [Theory] + [InlineData("hello-world", "50.00")] + [InlineData("c-syntax-fundamentals", "100.00")] + public void FindPercentComplete_ValidKey_Success(string? key, string result) + { + // Arrange + + // Act + string? percent = GetSiteMap().FindPercentComplete(key); + + // Assert + Assert.Equal(result, percent); + } + + [Fact] + public void FindPercentComplete_EmptySiteMappings_ReturnsZeroPercent() + { + // Arrange + IList siteMappings = new List(); + + // Act + string? percent = siteMappings.FindPercentComplete("test"); + + // Assert + Assert.Equal("0.00", percent); + } + + [Fact] + public void FindPercentComplete_KeyNotFound_ReturnsZeroPercent() + { + // Arrange + + // Act + string? percent = GetSiteMap().FindPercentComplete("non-existent-key"); + + // Assert + Assert.Equal("0.00", percent); + } } diff --git a/EssentialCSharp.Web/Controllers/HomeController.cs b/EssentialCSharp.Web/Controllers/HomeController.cs index 21b9d903..3495a7d6 100644 --- a/EssentialCSharp.Web/Controllers/HomeController.cs +++ b/EssentialCSharp.Web/Controllers/HomeController.cs @@ -30,6 +30,7 @@ public IActionResult Index() ViewBag.PageTitle = siteMapping.IndentLevel is 0 ? siteMapping.ChapterTitle + " " + siteMapping.RawHeading : siteMapping.RawHeading; ViewBag.NextPage = FlipPage(siteMapping!.ChapterNumber, siteMapping.PageNumber, true); + ViewBag.CurrentPageKey = siteMapping.PrimaryKey; ViewBag.PreviousPage = FlipPage(siteMapping.ChapterNumber, siteMapping.PageNumber, false); ViewBag.HeadContents = headHtml; ViewBag.Contents = html; diff --git a/EssentialCSharp.Web/Extensions/SiteMappingListExtensions.cs b/EssentialCSharp.Web/Extensions/SiteMappingListExtensions.cs index 28ae5643..617f5517 100644 --- a/EssentialCSharp.Web/Extensions/SiteMappingListExtensions.cs +++ b/EssentialCSharp.Web/Extensions/SiteMappingListExtensions.cs @@ -1,4 +1,6 @@ -namespace EssentialCSharp.Web.Extensions; +using System.Globalization; + +namespace EssentialCSharp.Web.Extensions; public static class SiteMappingListExtensions { @@ -23,4 +25,51 @@ public static class SiteMappingListExtensions } return null; } + /// + /// Finds percent complete based on a key. + /// + /// IList of SiteMappings + /// The key to search for. If null, returns null. + /// Returns a formatted double for use as the percent complete. + public static string? FindPercentComplete(this IList siteMappings, string? key) + { + if (key is null) + { + return null; + } + if (key.Trim().Length is 0) + { + throw new ArgumentException("Parameter 'key' cannot be null or whitespace.", nameof(key)); + } + int currentMappingCount = 0; + int overallMappingCount = 0; + bool currentPageFound = false; + IEnumerable> chapterGroupings = siteMappings.GroupBy(x => x.ChapterNumber).OrderBy(g => g.Key); + foreach (IGrouping chapterGrouping in chapterGroupings) + { + IEnumerable> pageGroupings = chapterGrouping.GroupBy(x => x.PageNumber).OrderBy(g => g.Key); + foreach (IGrouping pageGrouping in pageGroupings) + { + foreach (SiteMapping siteMapping in pageGrouping) + { + if (!currentPageFound) + { + currentMappingCount++; + } + overallMappingCount++; + if (siteMapping.PrimaryKey == key) + { + currentPageFound = true; + } + } + } + } + if (overallMappingCount is 0 || !currentPageFound) + { + return "0.00"; + } + + double result = (double)currentMappingCount / overallMappingCount * 100; + return string.Format(CultureInfo.InvariantCulture, "{0:0.00}", result); + } } diff --git a/EssentialCSharp.Web/Services/ISiteMappingService.cs b/EssentialCSharp.Web/Services/ISiteMappingService.cs index ac2a719e..f754eaa3 100644 --- a/EssentialCSharp.Web/Services/ISiteMappingService.cs +++ b/EssentialCSharp.Web/Services/ISiteMappingService.cs @@ -1,4 +1,4 @@ -namespace EssentialCSharp.Web.Services; +namespace EssentialCSharp.Web.Services; public interface ISiteMappingService { diff --git a/EssentialCSharp.Web/Services/SiteMappingService.cs b/EssentialCSharp.Web/Services/SiteMappingService.cs index 9eafb3a9..c435d14f 100644 --- a/EssentialCSharp.Web/Services/SiteMappingService.cs +++ b/EssentialCSharp.Web/Services/SiteMappingService.cs @@ -1,4 +1,4 @@ -using EssentialCSharp.Web.Models; +using System.Globalization; namespace EssentialCSharp.Web.Services; @@ -12,7 +12,6 @@ public SiteMappingService(IWebHostEnvironment webHostEnvironment) List? siteMappings = System.Text.Json.JsonSerializer.Deserialize>(File.OpenRead(path)) ?? throw new InvalidOperationException("No table of contents found"); SiteMappings = siteMappings; } - public IEnumerable GetTocData() { return SiteMappings.GroupBy(x => x.ChapterNumber).OrderBy(x => x.Key).Select(x => diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index e7e63787..4f7d1198 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -121,9 +121,12 @@ - + {{chapterParentPage.title}} +
@@ -268,8 +271,9 @@ - diff --git a/EssentialCSharp.Web/wwwroot/css/styles.css b/EssentialCSharp.Web/wwwroot/css/styles.css index 49fb64b7..f0b46c6c 100644 --- a/EssentialCSharp.Web/wwwroot/css/styles.css +++ b/EssentialCSharp.Web/wwwroot/css/styles.css @@ -224,13 +224,16 @@ a:hover { } } +.page-menu { + white-space: nowrap; + overflow: hidden; + text-decoration: none; +} + .menu-brand { font-style: normal; font-weight: 400; font-size: 1.5rem; - text-decoration: none; - white-space: nowrap; - overflow: hidden; margin-left: 5px; } @@ -238,11 +241,14 @@ a:hover { font-style: normal; font-weight: 300; font-size: 1.2rem; - text-decoration: none; text-overflow: ellipsis; - overflow: hidden; cursor: pointer; - white-space: nowrap; +} + +.menu-progress { + font-style: normal; + font-weight: 200; + font-size: 1rem; } .has-tooltip { diff --git a/EssentialCSharp.Web/wwwroot/js/site.js b/EssentialCSharp.Web/wwwroot/js/site.js index 917d0b3e..5d57eb98 100644 --- a/EssentialCSharp.Web/wwwroot/js/site.js +++ b/EssentialCSharp.Web/wwwroot/js/site.js @@ -210,6 +210,8 @@ const app = createApp({ const currentPage = findCurrentPage([], tocData) ?? []; + const percentComplete = ref(PERCENT_COMPLETE); + const chapterParentPage = currentPage.find((parent) => parent.level === 0); const sectionTitle = ref(currentPage?.[0]?.title || "Essential C#"); @@ -277,6 +279,11 @@ const app = createApp({ return tocData.filter(item => filterItem(item, query)); }); + const isContentPage = computed(() => { + let path = window.location.pathname; + return path !== '/home' && path !== '/guidelines' && path !== '/about' && path !== '/announcements'; + }); + function filterItem(item, query) { let matches = normalizeString(item.title).includes(query); if (item.items && item.items.length) { @@ -334,11 +341,13 @@ const app = createApp({ tocData, expandedTocs, currentPage, + percentComplete, chapterParentPage, searchQuery, filteredTocData, - enableTocFilter + enableTocFilter, + isContentPage }; }, });