diff --git a/DigitalLearningSolutions.Data.Tests/DataServices/CourseDataServiceTests.cs b/DigitalLearningSolutions.Data.Tests/DataServices/CourseDataServiceTests.cs index a3c3586bb9..c6234d0fa6 100644 --- a/DigitalLearningSolutions.Data.Tests/DataServices/CourseDataServiceTests.cs +++ b/DigitalLearningSolutions.Data.Tests/DataServices/CourseDataServiceTests.cs @@ -242,6 +242,7 @@ public void GetCourseStatisticsAtCentreForCategoryID_should_return_course_statis CompletedCount = 5, HideInLearnerPortal = false, CategoryName = "Office 2007", + CourseTopic = "Microsoft Office", LearningMinutes = "N/A" }; diff --git a/DigitalLearningSolutions.Data.Tests/DataServices/CourseTopicsDataServiceTests.cs b/DigitalLearningSolutions.Data.Tests/DataServices/CourseTopicsDataServiceTests.cs new file mode 100644 index 0000000000..b05d2515bc --- /dev/null +++ b/DigitalLearningSolutions.Data.Tests/DataServices/CourseTopicsDataServiceTests.cs @@ -0,0 +1,47 @@ +namespace DigitalLearningSolutions.Data.Tests.DataServices +{ + using System.Collections.Generic; + using System.Linq; + using DigitalLearningSolutions.Data.DataServices; + using DigitalLearningSolutions.Data.Models.Common; + using DigitalLearningSolutions.Data.Tests.TestHelpers; + using FluentAssertions; + using NUnit.Framework; + + public class CourseTopicsDataServiceTests + { + private CourseTopicsDataService courseTopicsDataService = null!; + + [SetUp] + public void Setup() + { + var connection = ServiceTestHelper.GetDatabaseConnection(); + courseTopicsDataService = new CourseTopicsDataService(connection); + } + + [Test] + public void GetTopicsAvailableAtCentre_should_return_expected_items() + { + // Given + var expectedTopics = new List + { + new Topic { CourseTopic = "Digital Skills", CourseTopicID = 2, Active = true}, + new Topic { CourseTopic = "Excel", CourseTopicID = 5, Active = true}, + new Topic { CourseTopic = "Microsoft Office", CourseTopicID = 3, Active = true}, + new Topic { CourseTopic = "OneNote", CourseTopicID = 10, Active = true}, + new Topic { CourseTopic = "Outlook", CourseTopicID = 7, Active = true}, + new Topic { CourseTopic = "PowerPoint", CourseTopicID = 6, Active = true}, + new Topic { CourseTopic = "SharePoint", CourseTopicID = 9, Active = true}, + new Topic { CourseTopic = "Social Media", CourseTopicID = 8, Active = true}, + new Topic { CourseTopic = "Undefined", CourseTopicID = 1, Active = true}, + new Topic { CourseTopic = "Word", CourseTopicID = 4, Active = true} + }; + + // When + var result = courseTopicsDataService.GetCourseTopicsAvailableAtCentre(101).ToList(); + + // Then + result.Should().BeEquivalentTo(expectedTopics); + } + } +} diff --git a/DigitalLearningSolutions.Data/DataServices/CourseDataService.cs b/DigitalLearningSolutions.Data/DataServices/CourseDataService.cs index 766f13584e..371ff1c94c 100644 --- a/DigitalLearningSolutions.Data/DataServices/CourseDataService.cs +++ b/DigitalLearningSolutions.Data/DataServices/CourseDataService.cs @@ -187,11 +187,13 @@ public IEnumerable GetCourseStatisticsAtCentreForCategoryId(in {AttemptsPassedQuery}, cu.HideInLearnerPortal, cc.CategoryName, + ct.CourseTopic, cu.LearningTimeMins AS LearningMinutes FROM dbo.Customisations AS cu INNER JOIN dbo.CentreApplications AS ca ON ca.ApplicationID = cu.ApplicationID INNER JOIN dbo.Applications AS ap ON ap.ApplicationID = ca.ApplicationID INNER JOIN dbo.CourseCategories AS cc ON cc.CourseCategoryID = ap.CourseCategoryID + INNER JOIN dbo.CourseTopics AS ct ON ct.CourseTopicID = ap.CourseTopicId WHERE (ap.CourseCategoryID = @categoryId OR @categoryId = 0) AND (cu.CentreID = @centreId OR (cu.AllCentres = 1 AND ca.Active = 1)) AND ca.CentreID = @centreId diff --git a/DigitalLearningSolutions.Data/DataServices/CourseTopicsDataService.cs b/DigitalLearningSolutions.Data/DataServices/CourseTopicsDataService.cs new file mode 100644 index 0000000000..0441d23114 --- /dev/null +++ b/DigitalLearningSolutions.Data/DataServices/CourseTopicsDataService.cs @@ -0,0 +1,33 @@ +namespace DigitalLearningSolutions.Data.DataServices +{ + using System.Collections.Generic; + using System.Data; + using Dapper; + using DigitalLearningSolutions.Data.Models.Common; + + public interface ICourseTopicsDataService + { + IEnumerable GetCourseTopicsAvailableAtCentre(int centreId); + } + + public class CourseTopicsDataService : ICourseTopicsDataService + { + private readonly IDbConnection connection; + + public CourseTopicsDataService(IDbConnection connection) + { + this.connection = connection; + } + + public IEnumerable GetCourseTopicsAvailableAtCentre(int centreId) + { + return connection.Query( + @"SELECT CourseTopicID, CourseTopic, Active + FROM CourseTopics + WHERE (CentreID = @CentreID OR CentreID = 0) AND (Active = 1) + ORDER BY CourseTopic", + new { centreId } + ); + } + } +} diff --git a/DigitalLearningSolutions.Data/Models/Courses/CourseStatistics.cs b/DigitalLearningSolutions.Data/Models/Courses/CourseStatistics.cs index 9b5831c9ef..12f2aabdf8 100644 --- a/DigitalLearningSolutions.Data/Models/Courses/CourseStatistics.cs +++ b/DigitalLearningSolutions.Data/Models/Courses/CourseStatistics.cs @@ -2,7 +2,7 @@ { using System; - public class CourseStatistics + public class CourseStatistics : BaseSearchableItem { public int CustomisationId { get; set; } public int CentreId { get; set; } @@ -17,11 +17,19 @@ public class CourseStatistics public int AttemptsPassed { get; set; } public bool HideInLearnerPortal { get; set; } public string CategoryName { get; set; } + public string CourseTopic { get; set; } public string LearningMinutes { get; set; } public string CourseName => string.IsNullOrWhiteSpace(CustomisationName) ? ApplicationName : ApplicationName + " - " + CustomisationName; + public double PassRate => AllAttempts == 0 ? 0 : Math.Round(100 * AttemptsPassed / (double)AllAttempts); + + public override string SearchableName + { + get => SearchableNameOverrideForFuzzySharp ?? CourseName; + set => SearchableNameOverrideForFuzzySharp = value; + } } } diff --git a/DigitalLearningSolutions.Web.Tests/Controllers/TrackingSystem/Centre/Administrator/AdministratorControllerTests.cs b/DigitalLearningSolutions.Web.Tests/Controllers/TrackingSystem/Centre/Administrator/AdministratorControllerTests.cs index 27183f0e96..637bd4b980 100644 --- a/DigitalLearningSolutions.Web.Tests/Controllers/TrackingSystem/Centre/Administrator/AdministratorControllerTests.cs +++ b/DigitalLearningSolutions.Web.Tests/Controllers/TrackingSystem/Centre/Administrator/AdministratorControllerTests.cs @@ -134,7 +134,7 @@ public void Index_with_null_filterBy_and_new_filter_query_parameter_add_new_cook public void Index_with_CLEAR_filterBy_and_new_filter_query_parameter_sets_new_cookie_value() { // Given - const string? filterBy = null; + const string? filterBy = "CLEAR"; const string? newFilterValue = "Role|IsCmsManager|true"; // When diff --git a/DigitalLearningSolutions.Web.Tests/Controllers/TrackingSystem/CourseSetup/CourseSetupControllerTests.cs b/DigitalLearningSolutions.Web.Tests/Controllers/TrackingSystem/CourseSetup/CourseSetupControllerTests.cs new file mode 100644 index 0000000000..55b753d8db --- /dev/null +++ b/DigitalLearningSolutions.Web.Tests/Controllers/TrackingSystem/CourseSetup/CourseSetupControllerTests.cs @@ -0,0 +1,172 @@ +namespace DigitalLearningSolutions.Web.Tests.Controllers.TrackingSystem.CourseSetup +{ + using System.Collections.Generic; + using DigitalLearningSolutions.Data.DataServices; + using DigitalLearningSolutions.Data.Models.Common; + using DigitalLearningSolutions.Data.Models.Courses; + using DigitalLearningSolutions.Data.Services; + using DigitalLearningSolutions.Web.Controllers.TrackingSystem.CourseSetup; + using DigitalLearningSolutions.Web.Tests.ControllerHelpers; + using DigitalLearningSolutions.Web.ViewModels.TrackingSystem.CourseSetup; + using FakeItEasy; + using FluentAssertions; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using NUnit.Framework; + + public class CourseSetupControllerTests + { + private readonly List categories = new List + { + new Category { CategoryName = "Category 1" }, + new Category { CategoryName = "Category 2" } + }; + + private readonly List courses = new List + { + new CourseStatistics + { + ApplicationName = "Course", + CustomisationName = "Customisation", + Active = true, + CourseTopic = "Topic 1", + CategoryName = "Category 1", + HideInLearnerPortal = true, + DelegateCount = 1, + CompletedCount = 1 + } + }; + + private readonly List topics = new List + { + new Topic { CourseTopic = "Topic 1" }, + new Topic { CourseTopic = "Topic 2" } + }; + + private CourseSetupController controller = null!; + private ICourseCategoriesDataService courseCategoryDataService = null!; + private ICourseService courseService = null!; + private ICourseTopicsDataService courseTopicsDataService = null!; + private HttpRequest httpRequest = null!; + private HttpResponse httpResponse = null!; + + [SetUp] + public void Setup() + { + courseCategoryDataService = A.Fake(); + courseTopicsDataService = A.Fake(); + courseService = A.Fake(); + + A.CallTo(() => courseService.GetCentreSpecificCourseStatistics(A._, A._)).Returns(courses); + A.CallTo(() => courseCategoryDataService.GetCategoriesForCentreAndCentrallyManagedCourses(A._)) + .Returns(categories); + A.CallTo(() => courseTopicsDataService.GetCourseTopicsAvailableAtCentre(A._)).Returns(topics); + + httpRequest = A.Fake(); + httpResponse = A.Fake(); + const string cookieName = "CourseFilter"; + const string cookieValue = "Status|Active|false"; + + controller = new CourseSetupController(courseService, courseCategoryDataService, courseTopicsDataService) + .WithMockHttpContextWithCookie(httpRequest, cookieName, cookieValue, httpResponse) + .WithMockUser(true) + .WithMockTempData(); + } + + [Test] + public void Index_with_no_query_parameters_uses_cookie_value_for_filterBy() + { + // When + var result = controller.Index(); + + // Then + result.As().Model.As().FilterBy.Should() + .Be("Status|Active|false"); + } + + [Test] + public void Index_with_query_parameters_uses_query_parameter_value_for_filterBy() + { + // Given + const string filterBy = "Status|HideInLearnerPortal|true"; + A.CallTo(() => httpRequest.Query.ContainsKey("filterBy")).Returns(true); + + // When + var result = controller.Index(filterBy: filterBy); + + // Then + result.As().Model.As().FilterBy.Should() + .Be(filterBy); + } + + [Test] + public void Index_with_CLEAR_filterBy_query_parameter_removes_cookie() + { + // Given + const string filterBy = "CLEAR"; + + // When + var result = controller.Index(filterBy: filterBy); + + // Then + A.CallTo(() => httpResponse.Cookies.Delete("CourseFilter")).MustHaveHappened(); + result.As().Model.As().FilterBy.Should() + .BeNull(); + } + + [Test] + public void Index_with_null_filterBy_and_new_filter_query_parameter_adds_new_cookie_value() + { + // Given + const string? filterBy = null; + const string newFilterValue = "Status|HideInLearnerPortal|true"; + A.CallTo(() => httpRequest.Query.ContainsKey("filterBy")).Returns(true); + + // When + var result = controller.Index(filterBy: filterBy, filterValue: newFilterValue); + + // Then + A.CallTo(() => httpResponse.Cookies.Append("CourseFilter", newFilterValue, A._)) + .MustHaveHappened(); + result.As().Model.As().FilterBy.Should() + .Be(newFilterValue); + } + + [Test] + public void Index_with_CLEAR_filterBy_and_new_filter_query_parameter_sets_new_cookie_value() + { + // Given + const string filterBy = "CLEAR"; + const string newFilterValue = "Status|HideInLearnerPortal|true"; + + // When + var result = controller.Index(filterBy: filterBy, filterValue: newFilterValue); + + // Then + A.CallTo(() => httpResponse.Cookies.Append("CourseFilter", newFilterValue, A._)) + .MustHaveHappened(); + result.As().Model.As().FilterBy.Should() + .Be(newFilterValue); + } + + [Test] + public void Index_with_no_filtering_should_default_to_Active_courses() + { + // Given + var controllerWithNoCookies = new CourseSetupController( + courseService, + courseCategoryDataService, + courseTopicsDataService + ) + .WithDefaultContext() + .WithMockUser(true); + + // When + var result = controllerWithNoCookies.Index(); + + // Then + result.As().Model.As().FilterBy.Should() + .Be("Status|Active|true"); + } + } +} diff --git a/DigitalLearningSolutions.Web.Tests/ViewModels/TrackingSystem/CourseSetup/CourseSetupViewModelTests.cs b/DigitalLearningSolutions.Web.Tests/ViewModels/TrackingSystem/CourseSetup/CourseSetupViewModelTests.cs new file mode 100644 index 0000000000..0f453b7f5a --- /dev/null +++ b/DigitalLearningSolutions.Web.Tests/ViewModels/TrackingSystem/CourseSetup/CourseSetupViewModelTests.cs @@ -0,0 +1,114 @@ +namespace DigitalLearningSolutions.Web.Tests.ViewModels.TrackingSystem.CourseSetup +{ + using System.Collections.Generic; + using System.Linq; + using DigitalLearningSolutions.Data.Models.Courses; + using DigitalLearningSolutions.Web.ViewModels.Common.SearchablePage; + using DigitalLearningSolutions.Web.ViewModels.TrackingSystem.CourseSetup; + using FluentAssertions; + using FluentAssertions.Execution; + using NUnit.Framework; + + public class CourseSetupViewModelTests + { + private readonly IEnumerable courses = new List + { + new CourseStatistics { ApplicationName = "A" }, + new CourseStatistics { ApplicationName = "B" }, + new CourseStatistics { ApplicationName = "C" }, + new CourseStatistics { ApplicationName = "D" }, + new CourseStatistics { ApplicationName = "E" }, + new CourseStatistics { ApplicationName = "F" }, + new CourseStatistics { ApplicationName = "G" }, + new CourseStatistics { ApplicationName = "H" }, + new CourseStatistics { ApplicationName = "I" }, + new CourseStatistics { ApplicationName = "J" }, + new CourseStatistics { ApplicationName = "K" }, + new CourseStatistics { ApplicationName = "L" }, + new CourseStatistics { ApplicationName = "M" }, + new CourseStatistics { ApplicationName = "N" }, + new CourseStatistics { ApplicationName = "O" } + }; + + [Test] + public void CourseSetupViewModel_should_default_to_returning_the_first_page_worth_of_delegates() + { + // When + var model = new CourseSetupViewModel( + courses, + new List(), + new List(), + null, + nameof(CourseStatistics.SearchableName), + BaseSearchablePageViewModel.Ascending, + null, + 1 + ); + + // Then + using (new AssertionScope()) + { + model.Courses.Count().Should().Be(BasePaginatedViewModel.DefaultItemsPerPage); + model.Courses.Any(c => c.CourseName == "K").Should() + .BeFalse(); + } + } + + [Test] + public void CourseSetupViewModel_should_correctly_return_the_second_page_of_delegates() + { + // When + var model = new CourseSetupViewModel( + courses, + new List(), + new List(), + null, + nameof(CourseStatistics.SearchableName), + BaseSearchablePageViewModel.Ascending, + null, + 2 + ); + + // Then + using (new AssertionScope()) + { + model.Courses.Count().Should().Be(5); + model.Courses.First().CourseName.Should().BeEquivalentTo("K"); + } + } + + [Test] + public void CourseSetupViewModel_filters_should_be_set() + { + // Given + var categories = new[] + { + "Category 1", + "Category 2" + }; + var topics = new[] + { + "Topic 1", + "Topic 2" + }; + + var expectedFilters = CourseStatisticsViewModelFilterOptions.GetFilterOptions(categories, topics); + + // When + var model = new CourseSetupViewModel( + courses, + categories, + topics, + null, + nameof(CourseStatistics.SearchableName), + BaseSearchablePageViewModel.Ascending, + null, + 2 + ); + + // Then + model.Filters.Should().BeEquivalentTo(expectedFilters); + model.Courses.First().CourseName.Should().BeEquivalentTo("K"); + } + } +} diff --git a/DigitalLearningSolutions.Web.Tests/ViewModels/TrackingSystem/CourseSetup/CourseStatisticsViewModelFilterOptionsTests.cs b/DigitalLearningSolutions.Web.Tests/ViewModels/TrackingSystem/CourseSetup/CourseStatisticsViewModelFilterOptionsTests.cs new file mode 100644 index 0000000000..e41b0841c2 --- /dev/null +++ b/DigitalLearningSolutions.Web.Tests/ViewModels/TrackingSystem/CourseSetup/CourseStatisticsViewModelFilterOptionsTests.cs @@ -0,0 +1,116 @@ +namespace DigitalLearningSolutions.Web.Tests.ViewModels.TrackingSystem.CourseSetup +{ + using System.Collections.Generic; + using DigitalLearningSolutions.Web.Helpers; + using DigitalLearningSolutions.Web.Models.Enums; + using DigitalLearningSolutions.Web.ViewModels.Common.SearchablePage; + using DigitalLearningSolutions.Web.ViewModels.TrackingSystem.CourseSetup; + using FluentAssertions; + using NUnit.Framework; + + public class CourseStatisticsViewModelFilterOptionsTests + { + private readonly FilterViewModel expectedCategoriesFilterViewModel = new FilterViewModel( + "CategoryName", + "Category", + new[] + { + new FilterOptionViewModel( + "Category 1", + "CategoryName" + FilteringHelper.Separator + "CategoryName" + FilteringHelper.Separator + + "Category 1", + FilterStatus.Default + ), + new FilterOptionViewModel( + "Category 2", + "CategoryName" + FilteringHelper.Separator + "CategoryName" + FilteringHelper.Separator + + "Category 2", + FilterStatus.Default + ) + } + ); + + private readonly FilterViewModel expectedStatusFilterViewModel = new FilterViewModel( + "Active", + "Status", + new[] + { + new FilterOptionViewModel( + "Inactive", + "Status" + FilteringHelper.Separator + "Active" + FilteringHelper.Separator + + "false", + FilterStatus.Warning + ), + new FilterOptionViewModel( + "Active", + "Status" + FilteringHelper.Separator + "Active" + FilteringHelper.Separator + + "true", + FilterStatus.Success + ) + } + ); + + private readonly FilterViewModel expectedTopicsFilterViewModel = new FilterViewModel( + "CourseTopic", + "Topic", + new[] + { + new FilterOptionViewModel( + "Topic 1", + "CourseTopic" + FilteringHelper.Separator + "CourseTopic" + FilteringHelper.Separator + + "Topic 1", + FilterStatus.Default + ), + new FilterOptionViewModel( + "Topic 2", + "CourseTopic" + FilteringHelper.Separator + "CourseTopic" + FilteringHelper.Separator + + "Topic 2", + FilterStatus.Default + ) + } + ); + + private readonly FilterViewModel expectedVisibilityFilterViewModel = new FilterViewModel( + "HideInLearnerPortal", + "Visibility", + new[] + { + new FilterOptionViewModel( + "Hidden in Learning Portal", + "Visibility" + FilteringHelper.Separator + "HideInLearnerPortal" + FilteringHelper.Separator + + "true", + FilterStatus.Warning + ), + new FilterOptionViewModel( + "Visible in Learning Portal", + "Visibility" + FilteringHelper.Separator + "HideInLearnerPortal" + FilteringHelper.Separator + + "false", + FilterStatus.Success + ) + } + ); + + private readonly List filterableCategories = new List { "Category 1", "Category 2" }; + + private readonly List filterableTopics = new List { "Topic 1", "Topic 2" }; + + [Test] + public void GetFilterOptions_correctly_sets_up_filters() + { + // When + var result = + CourseStatisticsViewModelFilterOptions.GetFilterOptions(filterableCategories, filterableTopics); + + // Then + result.Should().BeEquivalentTo( + new List + { + expectedCategoriesFilterViewModel, + expectedTopicsFilterViewModel, + expectedStatusFilterViewModel, + expectedVisibilityFilterViewModel + } + ); + } + } +} diff --git a/DigitalLearningSolutions.Web/Controllers/TrackingSystem/CourseSetup/CourseSetupController.cs b/DigitalLearningSolutions.Web/Controllers/TrackingSystem/CourseSetup/CourseSetupController.cs index 8fee1c696f..303a57c3e9 100644 --- a/DigitalLearningSolutions.Web/Controllers/TrackingSystem/CourseSetup/CourseSetupController.cs +++ b/DigitalLearningSolutions.Web/Controllers/TrackingSystem/CourseSetup/CourseSetupController.cs @@ -1,8 +1,11 @@ namespace DigitalLearningSolutions.Web.Controllers.TrackingSystem.CourseSetup { using System.Linq; + using DigitalLearningSolutions.Data.DataServices; using DigitalLearningSolutions.Data.Services; using DigitalLearningSolutions.Web.Helpers; + using DigitalLearningSolutions.Web.Helpers.FilterOptions; + using DigitalLearningSolutions.Web.ViewModels.Common.SearchablePage; using DigitalLearningSolutions.Web.ViewModels.TrackingSystem.CourseSetup; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,21 +16,78 @@ [Route("/TrackingSystem/CourseSetup")] public class CourseSetupController : Controller { + private const string CourseFilterCookieName = "CourseFilter"; + private readonly ICourseCategoriesDataService courseCategoriesDataService; private readonly ICourseService courseService; + private readonly ICourseTopicsDataService courseTopicsDataService; - public CourseSetupController(ICourseService courseService) + public CourseSetupController( + ICourseService courseService, + ICourseCategoriesDataService courseCategoriesDataService, + ICourseTopicsDataService courseTopicsDataService + ) { this.courseService = courseService; + this.courseCategoriesDataService = courseCategoriesDataService; + this.courseTopicsDataService = courseTopicsDataService; } - public IActionResult Index() + [Route("{page=1:int}")] + public IActionResult Index( + string? searchString = null, + string? sortBy = null, + string sortDirection = BaseSearchablePageViewModel.Ascending, + string? filterBy = null, + string? filterValue = null, + int page = 1 + ) { + if (filterBy == null && filterValue == null) + { + filterBy = Request.Cookies[CourseFilterCookieName] ?? CourseStatusFilterOptions.IsActive.FilterValue; + } + else if (filterBy?.ToUpper() == FilteringHelper.ClearString) + { + filterBy = null; + } + + sortBy ??= DefaultSortByOptions.Name.PropertyName; + filterBy = FilteringHelper.AddNewFilterToFilterBy(filterBy, filterValue); + var centreId = User.GetCentreId(); var categoryId = User.GetAdminCategoryId()!; + var centreCourses = courseService.GetCentreSpecificCourseStatistics(centreId, categoryId.Value); + var categories = courseCategoriesDataService.GetCategoriesForCentreAndCentrallyManagedCourses(centreId) + .Select(c => c.CategoryName); + var topics = courseTopicsDataService.GetCourseTopicsAvailableAtCentre(centreId).Select(c => c.CourseTopic); + var model = new CourseSetupViewModel( + centreCourses, + categories, + topics, + searchString, + sortBy, + sortDirection, + filterBy, + page + ); + + Response.UpdateOrDeleteFilterCookie(CourseFilterCookieName, filterBy); + + return View(model); + } + + [Route("AllCourseStatistics")] + public IActionResult AllCourseStatistics() + { + var centreId = User.GetCentreId(); + var categoryId = User.GetAdminCategoryId()!; var centreCourses = courseService.GetCentreSpecificCourseStatistics(centreId, categoryId.Value); - var model = new CourseSetupViewModel(centreCourses.Take(10)); + var categories = courseCategoriesDataService.GetCategoriesForCentreAndCentrallyManagedCourses(centreId) + .Select(c => c.CategoryName); + var topics = courseTopicsDataService.GetCourseTopicsAvailableAtCentre(centreId).Select(c => c.CourseTopic); + var model = new AllCourseStatisticsViewModel(centreCourses, categories, topics); return View(model); } } diff --git a/DigitalLearningSolutions.Web/Extensions/FilterModelExtensions.cs b/DigitalLearningSolutions.Web/Extensions/FilterModelExtensions.cs new file mode 100644 index 0000000000..f8359588d1 --- /dev/null +++ b/DigitalLearningSolutions.Web/Extensions/FilterModelExtensions.cs @@ -0,0 +1,20 @@ +namespace DigitalLearningSolutions.Web.Extensions +{ + using System.Collections.Generic; + using System.Linq; + using DigitalLearningSolutions.Web.ViewModels.Common.SearchablePage; + + public static class FilterModelExtensions + { + public static IEnumerable SelectAppliedFilterViewModels( + this IEnumerable filterViewModels + ) + { + return filterViewModels.Select( + f => f.FilterOptions.Select( + fo => new AppliedFilterViewModel(fo.DisplayText, f.FilterName, fo.FilterValue) + ) + ).SelectMany(af => af).Distinct(); + } + } +} diff --git a/DigitalLearningSolutions.Web/Helpers/FilterOptions/CourseFilterOptions.cs b/DigitalLearningSolutions.Web/Helpers/FilterOptions/CourseFilterOptions.cs index 1e0527b352..c7f0705052 100644 --- a/DigitalLearningSolutions.Web/Helpers/FilterOptions/CourseFilterOptions.cs +++ b/DigitalLearningSolutions.Web/Helpers/FilterOptions/CourseFilterOptions.cs @@ -19,6 +19,11 @@ public static class CourseStatusFilterOptions Group + FilteringHelper.Separator + nameof(CourseStatistics.Active) + FilteringHelper.Separator + "true", FilterStatus.Success ); + } + + public static class CourseVisibilityFilterOptions + { + private const string Group = "Visibility"; public static readonly FilterOptionViewModel IsHiddenInLearningPortal = new FilterOptionViewModel( "Hidden in Learning Portal", @@ -30,7 +35,7 @@ public static class CourseStatusFilterOptions public static readonly FilterOptionViewModel IsNotHiddenInLearningPortal = new FilterOptionViewModel( "Visible in Learning Portal", Group + FilteringHelper.Separator + nameof(CourseStatistics.HideInLearnerPortal) + - FilteringHelper.Separator + "true", + FilteringHelper.Separator + "false", FilterStatus.Success ); } diff --git a/DigitalLearningSolutions.Web/Helpers/FilterableTagHelper.cs b/DigitalLearningSolutions.Web/Helpers/FilterableTagHelper.cs index cc9460eb34..6a561840b9 100644 --- a/DigitalLearningSolutions.Web/Helpers/FilterableTagHelper.cs +++ b/DigitalLearningSolutions.Web/Helpers/FilterableTagHelper.cs @@ -71,11 +71,11 @@ CourseStatistics courseStatistics if (courseStatistics.HideInLearnerPortal) { - tags.Add(new SearchableTagViewModel(CourseStatusFilterOptions.IsHiddenInLearningPortal)); + tags.Add(new SearchableTagViewModel(CourseVisibilityFilterOptions.IsHiddenInLearningPortal)); } else { - tags.Add(new SearchableTagViewModel(CourseStatusFilterOptions.IsNotHiddenInLearningPortal)); + tags.Add(new SearchableTagViewModel(CourseVisibilityFilterOptions.IsNotHiddenInLearningPortal)); } return tags; diff --git a/DigitalLearningSolutions.Web/Helpers/GenericSortingHelper.cs b/DigitalLearningSolutions.Web/Helpers/GenericSortingHelper.cs index 89d0ec1252..b4aade431b 100644 --- a/DigitalLearningSolutions.Web/Helpers/GenericSortingHelper.cs +++ b/DigitalLearningSolutions.Web/Helpers/GenericSortingHelper.cs @@ -74,6 +74,9 @@ public static class CourseSortByOptions public static readonly (string DisplayText, string PropertyName) Brand = ("Brand", nameof(AvailableCourse.Brand)); public static readonly (string DisplayText, string PropertyName) Category = ("Category", nameof(AvailableCourse.Category)); public static readonly (string DisplayText, string PropertyName) Topic = ("Topic", nameof(AvailableCourse.Topic)); + public static readonly (string DisplayText, string PropertyName) CourseName = ("Course Name", nameof(CourseStatistics.CourseName)); + public static readonly (string DisplayText, string PropertyName) TotalDelegates = ("Total Delegates", nameof(CourseStatistics.DelegateCount)); + public static readonly (string DisplayText, string PropertyName) InProgress = ("In Progress", nameof(CourseStatistics.InProgressCount)); } public static class DefaultSortByOptions diff --git a/DigitalLearningSolutions.Web/Scripts/searchSortFilterAndPaginate/sort.ts b/DigitalLearningSolutions.Web/Scripts/searchSortFilterAndPaginate/sort.ts index 634de1e5f8..e4d902770a 100644 --- a/DigitalLearningSolutions.Web/Scripts/searchSortFilterAndPaginate/sort.ts +++ b/DigitalLearningSolutions.Web/Scripts/searchSortFilterAndPaginate/sort.ts @@ -55,6 +55,10 @@ export function getSortValue( return parseInt(getElementText(searchableElement, 'delegate-count'), 10); case 'CoursesCount': return parseInt(getElementText(searchableElement, 'courses-count'), 10); + case 'InProgressCount': + return parseInt(getElementText(searchableElement, 'in-progress-count'), 10); + case 'CourseName': + return getElementText(searchableElement, 'course-name').toLocaleLowerCase(); default: return ''; } diff --git a/DigitalLearningSolutions.Web/Scripts/spec/sort.spec.ts b/DigitalLearningSolutions.Web/Scripts/spec/sort.spec.ts index 675d40569e..0721cdf8fd 100644 --- a/DigitalLearningSolutions.Web/Scripts/spec/sort.spec.ts +++ b/DigitalLearningSolutions.Web/Scripts/spec/sort.spec.ts @@ -344,6 +344,62 @@ describe('sortSearchableElements delegates groups', () => { }); }); +describe('sortSearchableElements course setup', () => { + beforeEach(() => { + // Given + global.document = new JSDOM(` + + + + + +
+
+ A: Course +

5

+

7

+
+
+ B: Course +

1

+

2

+
+
+ C: Course +

7

+

5

+
+
+ + + `).window.document; + }); + + it.each` + sortBy | sortDirection | firstId | secondId | thirdId + ${'CourseName'} | ${'Ascending'} | ${'course-a'} | ${'course-b'} | ${'course-c'} + ${'CourseName'} | ${'Descending'} | ${'course-c'} | ${'course-b'} | ${'course-a'} + ${'DelegateCount'} | ${'Ascending'} | ${'course-b'} | ${'course-a'} | ${'course-c'} + ${'DelegateCount'} | ${'Descending'} | ${'course-c'} | ${'course-a'} | ${'course-b'} + ${'InProgressCount'} | ${'Ascending'} | ${'course-b'} | ${'course-c'} | ${'course-a'} + ${'InProgressCount'} | ${'Descending'} | ${'course-a'} | ${'course-c'} | ${'course-b'} + `('should correctly sort the cards $sortDirection by $sortBy', ({ + sortBy, sortDirection, firstId, secondId, thirdId, + }) => { + // When + setSortBy(sortBy); + setSortDirection(sortDirection); + const searchableElements = getSearchableElements(); + const newSearchableElements = sortSearchableElements(searchableElements); + + // Then + expect(newSearchableElements?.length).toEqual(3); + expect(newSearchableElements![0].element.id).toBe(firstId); + expect(newSearchableElements![1].element.id).toBe(secondId); + expect(newSearchableElements![2].element.id).toBe(thirdId); + }); +}); + function setSortBy(sortBy: string) { (document.getElementById('select-sort-by')).value = sortBy; } diff --git a/DigitalLearningSolutions.Web/Scripts/trackingSystem/centreCourseSetup.ts b/DigitalLearningSolutions.Web/Scripts/trackingSystem/centreCourseSetup.ts index fc42c3d2a6..7cd0f853dc 100644 --- a/DigitalLearningSolutions.Web/Scripts/trackingSystem/centreCourseSetup.ts +++ b/DigitalLearningSolutions.Web/Scripts/trackingSystem/centreCourseSetup.ts @@ -1,3 +1,8 @@ +import { SearchSortFilterAndPaginate } from '../searchSortFilterAndPaginate/searchSortFilterAndPaginate'; + +// eslint-disable-next-line no-new +new SearchSortFilterAndPaginate('TrackingSystem/CourseSetup/AllCourseStatistics', true, 'CourseFilter'); + const copyCourseLinkClass = 'copy-course-button'; const copyLinkIdPrefix = 'copy-course-'; const launchCourseButtonIdPrefix = 'launch-course-'; diff --git a/DigitalLearningSolutions.Web/Startup.cs b/DigitalLearningSolutions.Web/Startup.cs index b12553aeeb..705deadac5 100644 --- a/DigitalLearningSolutions.Web/Startup.cs +++ b/DigitalLearningSolutions.Web/Startup.cs @@ -196,6 +196,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); RegisterWebServiceFilters(services); } diff --git a/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/Centre/Administrator/AllAdminsViewModel.cs b/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/Centre/Administrator/AllAdminsViewModel.cs index f1fe8dea7a..b514bde7ae 100644 --- a/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/Centre/Administrator/AllAdminsViewModel.cs +++ b/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/Centre/Administrator/AllAdminsViewModel.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using DigitalLearningSolutions.Data.Models.User; + using DigitalLearningSolutions.Web.Extensions; using DigitalLearningSolutions.Web.ViewModels.Common.SearchablePage; public class AllAdminsViewModel : BaseJavaScriptFilterableViewModel @@ -26,11 +27,7 @@ public AllAdminsViewModel(IEnumerable adminUsers, IEnumerable "Account Status", AdministratorsViewModelFilterOptions.AccountStatusOptions ) - }.Select( - f => f.FilterOptions.Select( - fo => new AppliedFilterViewModel(fo.DisplayText, f.FilterName, fo.FilterValue) - ) - ).SelectMany(af => af).Distinct(); + }.SelectAppliedFilterViewModels(); } } } diff --git a/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/AllCourseStatisticsViewModel.cs b/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/AllCourseStatisticsViewModel.cs new file mode 100644 index 0000000000..5ea3a00b1c --- /dev/null +++ b/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/AllCourseStatisticsViewModel.cs @@ -0,0 +1,25 @@ +namespace DigitalLearningSolutions.Web.ViewModels.TrackingSystem.CourseSetup +{ + using System.Collections.Generic; + using System.Linq; + using DigitalLearningSolutions.Data.Models.Courses; + using DigitalLearningSolutions.Web.Extensions; + using DigitalLearningSolutions.Web.ViewModels.Common.SearchablePage; + + public class AllCourseStatisticsViewModel : BaseJavaScriptFilterableViewModel + { + public AllCourseStatisticsViewModel( + IEnumerable courses, + IEnumerable categories, + IEnumerable topics + ) + { + Courses = courses.Select(c => new SearchableCourseStatisticsViewModel(c)); + + Filters = CourseStatisticsViewModelFilterOptions.GetFilterOptions(categories, topics) + .SelectAppliedFilterViewModels(); + } + + public IEnumerable Courses { get; set; } + } +} diff --git a/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/CourseSetupViewModel.cs b/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/CourseSetupViewModel.cs index f4a1665fb9..61f2649008 100644 --- a/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/CourseSetupViewModel.cs +++ b/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/CourseSetupViewModel.cs @@ -3,14 +3,46 @@ using System.Collections.Generic; using System.Linq; using DigitalLearningSolutions.Data.Models.Courses; + using DigitalLearningSolutions.Web.Helpers; + using DigitalLearningSolutions.Web.ViewModels.Common.SearchablePage; - public class CourseSetupViewModel + public class CourseSetupViewModel : BaseSearchablePageViewModel { - public CourseSetupViewModel(IEnumerable courses) + public CourseSetupViewModel( + IEnumerable courses, + IEnumerable categories, + IEnumerable topics, + string? searchString, + string sortBy, + string sortDirection, + string? filterBy, + int page + ) : base(searchString, page, true, sortBy, sortDirection, filterBy) { - Courses = courses.Select(course => new SearchableCourseStatisticsViewModel(course)); + var sortedItems = GenericSortingHelper.SortAllItems( + courses.AsQueryable(), + sortBy, + sortDirection + ); + + var filteredItems = FilteringHelper.FilterItems(sortedItems.AsQueryable(), filterBy).ToList(); + var searchedItems = GenericSearchHelper.SearchItems(filteredItems, SearchString).ToList(); + MatchingSearchResults = searchedItems.Count; + SetTotalPages(); + var paginatedItems = GetItemsOnCurrentPage(searchedItems); + + Courses = paginatedItems.Select(c => new SearchableCourseStatisticsViewModel(c)); + + Filters = CourseStatisticsViewModelFilterOptions.GetFilterOptions(categories, topics); } public IEnumerable Courses { get; set; } + + public override IEnumerable<(string, string)> SortOptions { get; } = new[] + { + CourseSortByOptions.CourseName, + CourseSortByOptions.TotalDelegates, + CourseSortByOptions.InProgress + }; } } diff --git a/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/CourseStatisticsViewModelFilterOptions.cs b/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/CourseStatisticsViewModelFilterOptions.cs new file mode 100644 index 0000000000..38d3698edc --- /dev/null +++ b/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/CourseStatisticsViewModelFilterOptions.cs @@ -0,0 +1,73 @@ +namespace DigitalLearningSolutions.Web.ViewModels.TrackingSystem.CourseSetup +{ + using System.Collections.Generic; + using System.Linq; + using DigitalLearningSolutions.Data.Models.Courses; + using DigitalLearningSolutions.Web.Helpers; + using DigitalLearningSolutions.Web.Helpers.FilterOptions; + using DigitalLearningSolutions.Web.Models.Enums; + using DigitalLearningSolutions.Web.ViewModels.Common.SearchablePage; + + public static class CourseStatisticsViewModelFilterOptions + { + private static readonly IEnumerable CourseStatusOptions = new[] + { + CourseStatusFilterOptions.IsActive, + CourseStatusFilterOptions.IsInactive + }; + + private static readonly IEnumerable CourseVisibilityOptions = new[] + { + CourseVisibilityFilterOptions.IsHiddenInLearningPortal, + CourseVisibilityFilterOptions.IsNotHiddenInLearningPortal + }; + + public static IEnumerable GetFilterOptions( + IEnumerable categories, + IEnumerable topics + ) + { + return new[] + { + new FilterViewModel( + nameof(CourseStatistics.CategoryName), + "Category", + GetCategoryOptions(categories) + ), + new FilterViewModel( + nameof(CourseStatistics.CourseTopic), + "Topic", + GetTopicOptions(topics) + ), + new FilterViewModel(nameof(CourseStatistics.Active), "Status", CourseStatusOptions), + new FilterViewModel(nameof(CourseStatistics.HideInLearnerPortal), "Visibility", CourseVisibilityOptions) + }; + } + + private static IEnumerable GetCategoryOptions(IEnumerable categories) + { + return categories.Select( + category => new FilterOptionViewModel( + category, + nameof(CourseStatistics.CategoryName) + FilteringHelper.Separator + + nameof(CourseStatistics.CategoryName) + + FilteringHelper.Separator + category, + FilterStatus.Default + ) + ); + } + + private static IEnumerable GetTopicOptions(IEnumerable topics) + { + return topics.Select( + topic => new FilterOptionViewModel( + topic, + nameof(CourseStatistics.CourseTopic) + FilteringHelper.Separator + + nameof(CourseStatistics.CourseTopic) + + FilteringHelper.Separator + topic, + FilterStatus.Default + ) + ); + } + } +} diff --git a/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/SearchableCourseStatisticsViewModel.cs b/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/SearchableCourseStatisticsViewModel.cs index 29e097f770..fbc6529f0a 100644 --- a/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/SearchableCourseStatisticsViewModel.cs +++ b/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/CourseSetup/SearchableCourseStatisticsViewModel.cs @@ -14,6 +14,7 @@ public SearchableCourseStatisticsViewModel(CourseStatistics courseStatistics) InProgressCount = courseStatistics.InProgressCount; CourseName = courseStatistics.CourseName; CategoryName = courseStatistics.CategoryName; + CourseTopic = courseStatistics.CourseTopic; LearningMinutes = courseStatistics.LearningMinutes; Tags = FilterableTagHelper.GetCurrentTagsForCourseStatistics(courseStatistics); } @@ -23,8 +24,17 @@ public SearchableCourseStatisticsViewModel(CourseStatistics courseStatistics) public int InProgressCount { get; set; } public string CourseName { get; set; } public string CategoryName { get; set; } + public string CourseTopic { get; set; } public string LearningMinutes { get; set; } + public string CategoryFilter => nameof(CourseStatistics.CategoryName) + FilteringHelper.Separator + + nameof(CourseStatistics.CategoryName) + + FilteringHelper.Separator + CategoryName; + + public string TopicFilter => nameof(CourseStatistics.CourseTopic) + FilteringHelper.Separator + + nameof(CourseStatistics.CourseTopic) + + FilteringHelper.Separator + CourseTopic; + public string EmailHref => GenerateEmailHref(); private string GenerateEmailHref() diff --git a/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/Delegates/DelegateGroups/AllDelegateGroupsViewModel.cs b/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/Delegates/DelegateGroups/AllDelegateGroupsViewModel.cs index 5e0210e639..538a0fdc8f 100644 --- a/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/Delegates/DelegateGroups/AllDelegateGroupsViewModel.cs +++ b/DigitalLearningSolutions.Web/ViewModels/TrackingSystem/Delegates/DelegateGroups/AllDelegateGroupsViewModel.cs @@ -4,6 +4,7 @@ using System.Linq; using DigitalLearningSolutions.Data.Models.CustomPrompts; using DigitalLearningSolutions.Data.Models.DelegateGroups; + using DigitalLearningSolutions.Web.Extensions; using DigitalLearningSolutions.Web.ViewModels.Common.SearchablePage; public class AllDelegateGroupsViewModel : BaseJavaScriptFilterableViewModel @@ -28,11 +29,7 @@ public AllDelegateGroupsViewModel(List groups, IEnumerable "Linked field", DelegateGroupsViewModelFilterOptions.GetLinkedFieldOptions(registrationPrompts) ) - }.Select( - f => f.FilterOptions.Select( - fo => new AppliedFilterViewModel(fo.DisplayText, f.FilterName, fo.FilterValue) - ) - ).SelectMany(af => af).Distinct(); + }.SelectAppliedFilterViewModels(); } } } diff --git a/DigitalLearningSolutions.Web/Views/TrackingSystem/CourseSetup/AllCourseStatistics.cshtml b/DigitalLearningSolutions.Web/Views/TrackingSystem/CourseSetup/AllCourseStatistics.cshtml new file mode 100644 index 0000000000..a58b923b7d --- /dev/null +++ b/DigitalLearningSolutions.Web/Views/TrackingSystem/CourseSetup/AllCourseStatistics.cshtml @@ -0,0 +1,22 @@ +@using DigitalLearningSolutions.Web.ViewModels.TrackingSystem.CourseSetup +@model AllCourseStatisticsViewModel + +@{ + Layout = null; +} + + + + + + + + +@foreach (var course in Model.Courses) { + +} +@foreach (var filter in Model.Filters) { + +} + + diff --git a/DigitalLearningSolutions.Web/Views/TrackingSystem/CourseSetup/Index.cshtml b/DigitalLearningSolutions.Web/Views/TrackingSystem/CourseSetup/Index.cshtml index a5e4f51349..53979323f3 100644 --- a/DigitalLearningSolutions.Web/Views/TrackingSystem/CourseSetup/Index.cshtml +++ b/DigitalLearningSolutions.Web/Views/TrackingSystem/CourseSetup/Index.cshtml @@ -19,15 +19,42 @@

Centre course setup

+
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ @if (!Model.Courses.Any()) {

The centre has no courses set up yet.

} else { + +
@foreach (var course in Model.Courses) { }
} + @if (Model.TotalPages > 1) { + + }
diff --git a/DigitalLearningSolutions.Web/Views/TrackingSystem/CourseSetup/_CentreCourseCard.cshtml b/DigitalLearningSolutions.Web/Views/TrackingSystem/CourseSetup/_CentreCourseCard.cshtml index 5a9f696a2e..bc486a2bf8 100644 --- a/DigitalLearningSolutions.Web/Views/TrackingSystem/CourseSetup/_CentreCourseCard.cshtml +++ b/DigitalLearningSolutions.Web/Views/TrackingSystem/CourseSetup/_CentreCourseCard.cshtml @@ -4,7 +4,7 @@
- + @Model.CourseName @@ -17,11 +17,20 @@
Category
-
+
@Model.CategoryName
+
+
+ Topic +
+
+ @Model.CourseTopic +
+
+
Learning minutes @@ -35,7 +44,7 @@
Total delegates
-
+
@Model.DelegateCount
@@ -44,7 +53,7 @@
In progress
-
+
@Model.InProgressCount