diff --git a/EssentialCSharp.Web.Tests/SiteMappingTests.cs b/EssentialCSharp.Web.Tests/SiteMappingTests.cs index 140e982c..a04646bd 100644 --- a/EssentialCSharp.Web.Tests/SiteMappingTests.cs +++ b/EssentialCSharp.Web.Tests/SiteMappingTests.cs @@ -78,4 +78,59 @@ public void FindCSyntaxFundamentalsSanitizedWithAnchorReturnsCorrectSiteMap() Assert.NotNull(foundSiteMap); Assert.Equal(CSyntaxFundamentalsSiteMapping, foundSiteMap); } + + [Fact] + public void FindPercentComplete_KeyIsNull_ThrowsArgumentNullException() + { + // 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); + } } diff --git a/EssentialCSharp.Web/Controllers/HomeController.cs b/EssentialCSharp.Web/Controllers/HomeController.cs index f20f0ee0..690d063a 100644 --- a/EssentialCSharp.Web/Controllers/HomeController.cs +++ b/EssentialCSharp.Web/Controllers/HomeController.cs @@ -41,6 +41,7 @@ public IActionResult Index(string key) ViewBag.PageTitle = siteMapping.IndentLevel is 0 ? siteMapping.ChapterTitle + " " + siteMapping.RawHeading : siteMapping.RawHeading; ViewBag.NextPage = FlipPage(siteMapping!.ChapterNumber, siteMapping.PageNumber, true); + ViewBag.CurrentPageKey = siteMapping.Key; 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 058e7ff7..c95b4539 100644 --- a/EssentialCSharp.Web/Extensions/SiteMappingListExtensions.cs +++ b/EssentialCSharp.Web/Extensions/SiteMappingListExtensions.cs @@ -1,4 +1,7 @@ -namespace EssentialCSharp.Web.Extensions; +using EssentialCSharp.Common; +using System.Globalization; + +namespace EssentialCSharp.Web.Extensions; public static class SiteMappingListExtensions { @@ -23,4 +26,50 @@ public static class SiteMappingListExtensions } return null; } + /// + /// Finds percent complete based on a key. + /// + /// IList of SiteMappings + /// If null, uses the first key in the list + /// 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 is whitespace or empty: ", 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.Key == key) + { + currentPageFound = true; + } + } + } + } + if (overallMappingCount is 0) + { + return "0.00"; + } + double result = (double)currentMappingCount / overallMappingCount * 100; + return string.Format(CultureInfo.InvariantCulture, "{0:0.00}", result); + } } diff --git a/EssentialCSharp.Web/Services/SiteMappingService.cs b/EssentialCSharp.Web/Services/SiteMappingService.cs index c086596f..f95553ba 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; diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 04a9367b..0433a4ca 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -120,9 +120,12 @@ - + {{chapterParentPage.title}} +
@@ -292,8 +295,10 @@ Title = $"Chapter {x.Key}: {x.First().ChapterTitle}", Items = GetItems(x, 1) }); - } + var percentComplete = _SiteMappings.SiteMappings.FindPercentComplete((string) ViewBag.CurrentPageKey); + } + PERCENT_COMPLETE = @Json.Serialize(percentComplete); PREVIOUS_PAGE = @Json.Serialize(ViewBag.PreviousPage) NEXT_PAGE = @Json.Serialize(ViewBag.NextPage) TOC_DATA = @Json.Serialize(tocData) @@ -343,6 +348,5 @@ - diff --git a/EssentialCSharp.Web/wwwroot/css/styles.css b/EssentialCSharp.Web/wwwroot/css/styles.css index d39016ad..c476d3da 100644 --- a/EssentialCSharp.Web/wwwroot/css/styles.css +++ b/EssentialCSharp.Web/wwwroot/css/styles.css @@ -207,13 +207,16 @@ a:hover { } } +.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; } @@ -221,11 +224,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 a3e95e07..47477a5c 100644 --- a/EssentialCSharp.Web/wwwroot/js/site.js +++ b/EssentialCSharp.Web/wwwroot/js/site.js @@ -194,6 +194,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#"); @@ -255,6 +257,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) { @@ -311,11 +318,13 @@ const app = createApp({ tocData, expandedTocs, currentPage, + percentComplete, chapterParentPage, searchQuery, filteredTocData, - enableTocFilter + enableTocFilter, + isContentPage }; }, });