From 5a3043471307b91541ef171835722514512b61a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Bartha?= Date: Tue, 31 Oct 2023 18:33:18 +0100 Subject: [PATCH] OCC-192: Catalog navigation and Search (#374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adding Product List base architecture * Adding ordering options * Adding title filter * Resolving applied filters from query string * Adding UI tests * Renaming interface and fixing analyzer violations * Fixing analyzer violation * Ignoring pager HTML validation error * Removing unnecessary ContainedPart from products * Minor refactorings * Accepting only one order by value * Fixing failing UI test * Update src/Modules/OrchardCore.Commerce/Services/ProductListService.cs --------- Co-authored-by: Sára El-Saig --- .../Drivers/ProductListPartDisplayDriver.cs | 59 +++++++++++ .../Migrations/ProductListMigrations.cs | 24 +++++ .../Models/ProductList.cs | 10 ++ .../Models/ProductListFilterContext.cs | 11 +++ .../Models/ProductListFilterParameters.cs | 11 +++ .../Models/ProductListFilters.cs | 9 ++ .../Models/ProductListPart.cs | 12 +++ ...mmerce.Development.Tests.Setup.recipe.json | 1 + ...dCore.Commerce.Samples.Product.recipe.json | 56 ++--------- ...liedProductListFilterParametersProvider.cs | 20 ++++ .../Services/IProductListFilterProvider.cs | 39 ++++++++ .../Services/IProductListService.cs | 26 +++++ .../Services/ProductListService.cs | 97 +++++++++++++++++++ .../ProductListTitleFilterProvider.cs | 46 +++++++++ ...liedProductListFilterParametersProvider.cs | 58 +++++++++++ src/Modules/OrchardCore.Commerce/Startup.cs | 8 ++ .../ViewModels/ProductListFiltersViewModel.cs | 11 +++ .../ViewModels/ProductListPartViewModel.cs | 15 +++ .../Views/ProductList-Filter-Title.cshtml | 6 ++ .../Views/ProductList-OrderBy-TitleAsc.cshtml | 3 + .../ProductList-OrderBy-TitleDesc.cshtml | 3 + .../Views/ProductListPart.Filters.cshtml | 45 +++++++++ .../Views/ProductListPart.cshtml | 26 +++++ .../BehaviorProductListTests.cs | 78 +++++++++++++++ .../ProductTests/RetrievalProductTests.cs | 2 +- 25 files changed, 625 insertions(+), 51 deletions(-) create mode 100644 src/Modules/OrchardCore.Commerce/Drivers/ProductListPartDisplayDriver.cs create mode 100644 src/Modules/OrchardCore.Commerce/Migrations/ProductListMigrations.cs create mode 100644 src/Modules/OrchardCore.Commerce/Models/ProductList.cs create mode 100644 src/Modules/OrchardCore.Commerce/Models/ProductListFilterContext.cs create mode 100644 src/Modules/OrchardCore.Commerce/Models/ProductListFilterParameters.cs create mode 100644 src/Modules/OrchardCore.Commerce/Models/ProductListFilters.cs create mode 100644 src/Modules/OrchardCore.Commerce/Models/ProductListPart.cs create mode 100644 src/Modules/OrchardCore.Commerce/Services/IAppliedProductListFilterParametersProvider.cs create mode 100644 src/Modules/OrchardCore.Commerce/Services/IProductListFilterProvider.cs create mode 100644 src/Modules/OrchardCore.Commerce/Services/IProductListService.cs create mode 100644 src/Modules/OrchardCore.Commerce/Services/ProductListService.cs create mode 100644 src/Modules/OrchardCore.Commerce/Services/ProductListTitleFilterProvider.cs create mode 100644 src/Modules/OrchardCore.Commerce/Services/QueryStringAppliedProductListFilterParametersProvider.cs create mode 100644 src/Modules/OrchardCore.Commerce/ViewModels/ProductListFiltersViewModel.cs create mode 100644 src/Modules/OrchardCore.Commerce/ViewModels/ProductListPartViewModel.cs create mode 100644 src/Modules/OrchardCore.Commerce/Views/ProductList-Filter-Title.cshtml create mode 100644 src/Modules/OrchardCore.Commerce/Views/ProductList-OrderBy-TitleAsc.cshtml create mode 100644 src/Modules/OrchardCore.Commerce/Views/ProductList-OrderBy-TitleDesc.cshtml create mode 100644 src/Modules/OrchardCore.Commerce/Views/ProductListPart.Filters.cshtml create mode 100644 src/Modules/OrchardCore.Commerce/Views/ProductListPart.cshtml create mode 100644 test/OrchardCore.Commerce.Tests.UI/Tests/ProductListTests/BehaviorProductListTests.cs diff --git a/src/Modules/OrchardCore.Commerce/Drivers/ProductListPartDisplayDriver.cs b/src/Modules/OrchardCore.Commerce/Drivers/ProductListPartDisplayDriver.cs new file mode 100644 index 000000000..89ece4db1 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Drivers/ProductListPartDisplayDriver.cs @@ -0,0 +1,59 @@ +using OrchardCore.Commerce.Models; +using OrchardCore.Commerce.Services; +using OrchardCore.Commerce.ViewModels; +using OrchardCore.ContentManagement.Display.ContentDisplay; +using OrchardCore.ContentManagement.Display.Models; +using OrchardCore.DisplayManagement.Views; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using static Lombiq.HelpfulLibraries.OrchardCore.Contents.CommonContentDisplayTypes; + +namespace OrchardCore.Commerce.Drivers; + +public class ProductListPartDisplayDriver : ContentPartDisplayDriver +{ + private readonly IProductListService _productListService; + private readonly IEnumerable _productFilterProviders; + + public ProductListPartDisplayDriver( + IProductListService productListService, + IEnumerable productFilterProviders) + { + _productListService = productListService; + _productFilterProviders = productFilterProviders; + } + + public override IDisplayResult Display(ProductListPart part, BuildPartDisplayContext context) => + Combine( + Initialize( + GetDisplayShapeType(context), + async viewModel => await BuildViewModelAsync(viewModel, part, context)) + .Location(Detail, "Content:25") + .Location(Summary, "Meta:10"), + Initialize( + GetDisplayShapeType(context) + "_Filters", + async viewModel => await BuildFiltersViewModelAsync(viewModel, part)) + .Location(Detail, "Content:20")); + + private async Task BuildViewModelAsync(ProductListPartViewModel viewModel, ProductListPart part, BuildPartDisplayContext context) + { + viewModel.ProductListPart = part; + + var filterParameters = await _productFilterProviders + .MaxBy(provider => provider.Priority).GetFilterParametersAsync(part) ?? new ProductListFilterParameters(); + + var productList = await _productListService.GetProductsAsync(part, filterParameters); + viewModel.Products = productList.Products; + viewModel.Pager = (await context.New.Pager(filterParameters.Pager)).TotalItemCount(productList.TotalItemCount); + viewModel.Context = context; + } + + private async Task BuildFiltersViewModelAsync(ProductListFiltersViewModel viewModel, ProductListPart part) + { + viewModel.ProductListPart = part; + + viewModel.FilterIds = await _productListService.GetFilterIdsAsync(part); + viewModel.OrderByOptions = await _productListService.GetOrderByOptionsAsync(part); + } +} diff --git a/src/Modules/OrchardCore.Commerce/Migrations/ProductListMigrations.cs b/src/Modules/OrchardCore.Commerce/Migrations/ProductListMigrations.cs new file mode 100644 index 000000000..961838ce6 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Migrations/ProductListMigrations.cs @@ -0,0 +1,24 @@ +using OrchardCore.Commerce.Models; +using OrchardCore.ContentManagement.Metadata; +using OrchardCore.ContentManagement.Metadata.Settings; +using OrchardCore.Data.Migration; + +namespace OrchardCore.Commerce.Migrations; + +public class ProductListMigrations : DataMigration +{ + private readonly IContentDefinitionManager _contentDefinitionManager; + + public ProductListMigrations(IContentDefinitionManager contentDefinitionManager) => + _contentDefinitionManager = contentDefinitionManager; + + public int Create() + { + _contentDefinitionManager + .AlterPartDefinition(nameof(ProductListPart), builder => builder + .Attachable() + .WithDescription("Displays a list of products with optional filtering and sorting controlled by widgets.")); + + return 1; + } +} diff --git a/src/Modules/OrchardCore.Commerce/Models/ProductList.cs b/src/Modules/OrchardCore.Commerce/Models/ProductList.cs new file mode 100644 index 000000000..24478d3b4 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Models/ProductList.cs @@ -0,0 +1,10 @@ +using OrchardCore.ContentManagement; +using System.Collections.Generic; + +namespace OrchardCore.Commerce.Models; + +public class ProductList +{ + public IEnumerable Products { get; set; } + public int TotalItemCount { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce/Models/ProductListFilterContext.cs b/src/Modules/OrchardCore.Commerce/Models/ProductListFilterContext.cs new file mode 100644 index 000000000..98978a25c --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Models/ProductListFilterContext.cs @@ -0,0 +1,11 @@ +using OrchardCore.ContentManagement; +using YesSql; + +namespace OrchardCore.Commerce.Models; + +public class ProductListFilterContext +{ + public ProductListPart ProductList { get; set; } + public ProductListFilterParameters FilterParameters { get; set; } + public IQuery Query { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce/Models/ProductListFilterParameters.cs b/src/Modules/OrchardCore.Commerce/Models/ProductListFilterParameters.cs new file mode 100644 index 000000000..440f4e884 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Models/ProductListFilterParameters.cs @@ -0,0 +1,11 @@ +using OrchardCore.Navigation; +using System.Collections.Generic; + +namespace OrchardCore.Commerce.Models; + +public class ProductListFilterParameters +{ + public Pager Pager { get; set; } + public string OrderBy { get; set; } + public IDictionary FilterValues { get; } = new Dictionary(); +} diff --git a/src/Modules/OrchardCore.Commerce/Models/ProductListFilters.cs b/src/Modules/OrchardCore.Commerce/Models/ProductListFilters.cs new file mode 100644 index 000000000..21118b01a --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Models/ProductListFilters.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace OrchardCore.Commerce.Models; + +public class ProductListFilters +{ + public IList OrderBy { get; } = new List(); + public IDictionary FilterValues { get; } = new Dictionary(); +} diff --git a/src/Modules/OrchardCore.Commerce/Models/ProductListPart.cs b/src/Modules/OrchardCore.Commerce/Models/ProductListPart.cs new file mode 100644 index 000000000..b33ca32ec --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Models/ProductListPart.cs @@ -0,0 +1,12 @@ +using OrchardCore.ContentManagement; +using System.Diagnostics.CodeAnalysis; + +namespace OrchardCore.Commerce.Models; + +[SuppressMessage( + "Minor Code Smell", + "S2094:Classes should not be empty", + Justification = "This part doesn't store data.")] +public class ProductListPart : ContentPart +{ +} diff --git a/src/Modules/OrchardCore.Commerce/Recipes/OrchardCore.Commerce.Development.Tests.Setup.recipe.json b/src/Modules/OrchardCore.Commerce/Recipes/OrchardCore.Commerce.Development.Tests.Setup.recipe.json index 1de7b8466..2d29337e3 100644 --- a/src/Modules/OrchardCore.Commerce/Recipes/OrchardCore.Commerce.Development.Tests.Setup.recipe.json +++ b/src/Modules/OrchardCore.Commerce/Recipes/OrchardCore.Commerce.Development.Tests.Setup.recipe.json @@ -16,6 +16,7 @@ { "name": "settings", "UseCdn": false, + "PageSize": 5, // To make sure that e.g. numbers and dates are formatted the same way on all machines we have to specify the // culture too. "LocalizationSettings": { diff --git a/src/Modules/OrchardCore.Commerce/Recipes/OrchardCore.Commerce.Samples.Product.recipe.json b/src/Modules/OrchardCore.Commerce/Recipes/OrchardCore.Commerce.Samples.Product.recipe.json index 1a38c0b84..34a34ff01 100644 --- a/src/Modules/OrchardCore.Commerce/Recipes/OrchardCore.Commerce.Samples.Product.recipe.json +++ b/src/Modules/OrchardCore.Commerce/Recipes/OrchardCore.Commerce.Samples.Product.recipe.json @@ -41,21 +41,11 @@ } }, { - "PartName": "ListPart", - "Name": "ListPart", + "PartName": "ProductListPart", + "Name": "ProductListPart", "Settings": { "ContentTypePartSettings": { "Position": "2" - }, - "ListPartSettings": { - "PageSize": 10, - "ContainedContentTypes": [ - "LocalizedProduct", - "PriceVariantsProduct", - "Product", - "TieredPriceProduct" - ], - "EnableOrdering": true } } }, @@ -92,11 +82,7 @@ "Owner": null, "Author": "admin", "ProductList": {}, - "ListPart": { - "DisableRssFeed": false, - "FeedProxyUrl": null, - "FeedItemsCount": 20 - }, + "ProductListPart": {}, "AutoroutePart": { "Path": "my-shop", "SetHomepage": true, @@ -180,11 +166,6 @@ "" ] } - }, - "ContainedPart": { - "ListContentItemId": "mainproductlist00000000000", - "ListContentType": "ProductList", - "Order": 0 } }, { @@ -253,12 +234,7 @@ }, "ProductSku": "TESTPRODUCT" }, - "Product": {}, - "ContainedPart": { - "ListContentItemId": "mainproductlist00000000000", - "ListContentType": "ProductList", - "Order": 0 - } + "Product": {} }, { "ContentItemId": "testfreeproduct000", @@ -326,12 +302,7 @@ }, "ProductSku": "TESTFREEPRODUCT" }, - "Product": {}, - "ContainedPart": { - "ListContentItemId": "mainproductlist00000000000", - "ListContentType": "ProductList", - "Order": 0 - } + "Product": {} }, { "ContentItemId": "testdiscountedproduct000", @@ -422,12 +393,7 @@ "Value": null } }, - "Product": {}, - "ContainedPart": { - "ListContentItemId": "mainproductlist00000000000", - "ListContentType": "ProductList", - "Order": 0 - } + "Product": {} }, { "ContentItemId": "testproductlocalized000000", @@ -468,11 +434,6 @@ "LocalizationPart": { "LocalizationSet": "testproductlocalizationset", "Culture": "en-US" - }, - "ContainedPart": { - "ListContentItemId": "mainproductlist00000000000", - "ListContentType": "ProductList", - "Order": 0 } }, { @@ -507,11 +468,6 @@ "TitlePart": { "Title": "Test Tiered Price Product" }, - "ContainedPart": { - "ListContentItemId": "mainproductlist00000000000", - "ListContentType": "ProductList", - "Order": 1 - }, "ProductPart": { "Sku": "TESTTIEREDPRICE", "CanBeBought": {} diff --git a/src/Modules/OrchardCore.Commerce/Services/IAppliedProductListFilterParametersProvider.cs b/src/Modules/OrchardCore.Commerce/Services/IAppliedProductListFilterParametersProvider.cs new file mode 100644 index 000000000..80dbc9b3e --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Services/IAppliedProductListFilterParametersProvider.cs @@ -0,0 +1,20 @@ +using OrchardCore.Commerce.Models; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Services; + +/// +/// Provides a way to get the applied filter parameters for a product list. +/// +public interface IAppliedProductListFilterParametersProvider +{ + /// + /// Gets the priority of this provider. The provider with the highest priority will be used. + /// + int Priority { get; } + + /// + /// Returns the applied filter parameters for the given product list. + /// + Task GetFilterParametersAsync(ProductListPart productList); +} diff --git a/src/Modules/OrchardCore.Commerce/Services/IProductListFilterProvider.cs b/src/Modules/OrchardCore.Commerce/Services/IProductListFilterProvider.cs new file mode 100644 index 000000000..53a590b81 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Services/IProductListFilterProvider.cs @@ -0,0 +1,39 @@ +using OrchardCore.Commerce.Models; +using OrchardCore.ContentManagement; +using System.Collections.Generic; +using System.Threading.Tasks; +using YesSql; + +namespace OrchardCore.Commerce.Services; + +/// +/// Provides a way to filter a product list. +/// +public interface IProductListFilterProvider +{ + /// + /// Gets the order in which the filter providers are applied. + /// + int Order { get; } + + /// + /// Whether this provider can handle the given product list. If not, the next provider will be tried and this + /// provider will be skipped. + /// + Task IsApplicableAsync(ProductListPart productList); + + /// + /// The IDs of the order by options that this provider can handle. + /// + Task> GetOrderByOptionIdsAsync(ProductListPart productList); + + /// + /// The IDs of the filters that this provider can handle. + /// + Task> GetFilterIdsAsync(ProductListPart productListPart); + + /// + /// Builds the query for the given product list and filter parameters. + /// + Task> BuildQueryAsync(ProductListFilterContext context); +} diff --git a/src/Modules/OrchardCore.Commerce/Services/IProductListService.cs b/src/Modules/OrchardCore.Commerce/Services/IProductListService.cs new file mode 100644 index 000000000..90ba441e0 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Services/IProductListService.cs @@ -0,0 +1,26 @@ +using OrchardCore.Commerce.Models; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Services; + +/// +/// Provides a way to get the products for a product list. +/// +public interface IProductListService +{ + /// + /// Gets the products for the given product list and filter parameters. + /// + Task GetProductsAsync(ProductListPart productList, ProductListFilterParameters filterParameters); + + /// + /// Gets the order by options for the given product list. + /// + Task> GetOrderByOptionsAsync(ProductListPart productList); + + /// + /// Gets the filter IDs for the given product list. + /// + Task> GetFilterIdsAsync(ProductListPart productList); +} diff --git a/src/Modules/OrchardCore.Commerce/Services/ProductListService.cs b/src/Modules/OrchardCore.Commerce/Services/ProductListService.cs new file mode 100644 index 000000000..4a9a58c36 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Services/ProductListService.cs @@ -0,0 +1,97 @@ +using OrchardCore.Commerce.Models; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Metadata; +using OrchardCore.ContentManagement.Records; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using YesSql; +using YesSql.Services; + +namespace OrchardCore.Commerce.Services; + +public class ProductListService : IProductListService +{ + private readonly ISession _session; + private readonly IEnumerable _productListQueryProviders; + private readonly IContentDefinitionManager _contentDefinitionManager; + + public ProductListService( + ISession session, + IEnumerable productListQueryProviders, + IContentDefinitionManager contentDefinitionManager) + { + _session = session; + _productListQueryProviders = productListQueryProviders; + _contentDefinitionManager = contentDefinitionManager; + } + + public async Task GetProductsAsync(ProductListPart productList, ProductListFilterParameters filterParameters) + { + ArgumentNullException.ThrowIfNull(productList); + ArgumentNullException.ThrowIfNull(filterParameters); + + var productTypes = _contentDefinitionManager.ListTypeDefinitions() + .Where(type => type.Parts.Any(part => part.PartDefinition.Name == nameof(ProductPart))) + .Select(type => type.Name) + .ToArray(); + + var applicableProviders = await GetOrderedApplicableProvidersAsync(productList); + + var query = _session.Query(); + query = query.With(index => index.ContentType.IsIn(productTypes) && index.Published); + + var context = new ProductListFilterContext + { + ProductList = productList, + FilterParameters = filterParameters, + Query = query, + }; + + if (string.IsNullOrEmpty(filterParameters.OrderBy)) + { + filterParameters.OrderBy = ProductListTitleFilterProvider.TitleAscOrderById; + } + + foreach (var provider in applicableProviders) + { + context.Query = await provider.BuildQueryAsync(context) ?? context.Query; + } + + var totalItemCount = await query.CountAsync(); + var contentItems = await query.PaginateAsync(filterParameters.Pager.Page - 1, filterParameters.Pager.PageSize); + + return new ProductList + { + Products = contentItems, + TotalItemCount = totalItemCount, + }; + } + + public async Task> GetOrderByOptionsAsync(ProductListPart productList) + { + ArgumentNullException.ThrowIfNull(productList); + + var applicableProviders = await GetOrderedApplicableProvidersAsync(productList); + + return (await applicableProviders.AwaitEachAsync(provider => provider.GetOrderByOptionIdsAsync(productList))) + .SelectMany(options => options); + } + + public async Task> GetFilterIdsAsync(ProductListPart productList) + { + ArgumentNullException.ThrowIfNull(productList); + + var applicableProviders = await GetOrderedApplicableProvidersAsync(productList); + + return (await applicableProviders.AwaitEachAsync(provider => provider.GetFilterIdsAsync(productList))) + .SelectMany(options => options); + } + + private async Task> GetOrderedApplicableProvidersAsync(ProductListPart productList) => + (await _productListQueryProviders + .WhereAsync(async provider => await provider.IsApplicableAsync(productList))) + .OrderBy(provider => provider.Order) + .ToList(); +} diff --git a/src/Modules/OrchardCore.Commerce/Services/ProductListTitleFilterProvider.cs b/src/Modules/OrchardCore.Commerce/Services/ProductListTitleFilterProvider.cs new file mode 100644 index 000000000..f61eadafe --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Services/ProductListTitleFilterProvider.cs @@ -0,0 +1,46 @@ +using OrchardCore.Commerce.Models; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Records; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using YesSql; + +namespace OrchardCore.Commerce.Services; + +public class ProductListTitleFilterProvider : IProductListFilterProvider +{ + public const string TitleFilterId = "title"; + public const string TitleAscOrderById = "titleAsc"; + public const string TitleDescOrderById = "titleDesc"; + + public int Order => 10; + + public Task IsApplicableAsync(ProductListPart productList) => Task.FromResult(true); + + public Task> GetOrderByOptionIdsAsync(ProductListPart productList) => + Task.FromResult>(new[] { TitleAscOrderById, TitleDescOrderById }); + + public Task> GetFilterIdsAsync(ProductListPart productListPart) => + Task.FromResult>(new[] { TitleFilterId }); + + public Task> BuildQueryAsync(ProductListFilterContext context) + { + var query = context.Query; + if (context.FilterParameters.FilterValues.TryGetValue(TitleFilterId, out var title)) + { + query = query.With(index => index.DisplayText.Contains(title)); + } + + if (context.FilterParameters.OrderBy.EqualsOrdinalIgnoreCase(TitleAscOrderById)) + { + query = query.With().OrderBy(index => index.DisplayText); + } + else if (context.FilterParameters.OrderBy.EqualsOrdinalIgnoreCase(TitleDescOrderById)) + { + query = query.With().OrderByDescending(index => index.DisplayText); + } + + return Task.FromResult(query); + } +} diff --git a/src/Modules/OrchardCore.Commerce/Services/QueryStringAppliedProductListFilterParametersProvider.cs b/src/Modules/OrchardCore.Commerce/Services/QueryStringAppliedProductListFilterParametersProvider.cs new file mode 100644 index 000000000..165bfc4ef --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Services/QueryStringAppliedProductListFilterParametersProvider.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Http; +using OrchardCore.Commerce.Models; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.Navigation; +using OrchardCore.Settings; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Services; + +public class QueryStringAppliedProductListFilterParametersProvider : IAppliedProductListFilterParametersProvider +{ + public const string QueryStringPrefix = "products."; + + private readonly IHttpContextAccessor _hca; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly ISiteService _siteService; + + public int Priority => 10; + + public QueryStringAppliedProductListFilterParametersProvider( + IHttpContextAccessor hca, + IUpdateModelAccessor updateModelAccessor, + ISiteService siteService) + { + _hca = hca; + _updateModelAccessor = updateModelAccessor; + _siteService = siteService; + } + + public async Task GetFilterParametersAsync(ProductListPart productList) + { + var queryStrings = _hca.HttpContext.Request.Query; + var orderByValue = queryStrings + .Where(queryString => queryString.Key.StartsWith(QueryStringPrefix + "orderBy", StringComparison.InvariantCulture)) + .SelectMany(queryString => queryString.Value) + .FirstOrDefault(); + var filterValues = queryStrings + .Where(queryString => queryString.Key.StartsWith(QueryStringPrefix, StringComparison.InvariantCulture)) + .ToDictionary( + queryString => queryString.Key[QueryStringPrefix.Length..], + queryString => queryString.Value.FirstOrDefault()); + + var pagerParameters = new PagerParameters(); + await _updateModelAccessor.ModelUpdater.TryUpdateModelAsync(pagerParameters); + + var filterParameters = new ProductListFilterParameters + { + Pager = new Pager(pagerParameters, (await _siteService.GetSiteSettingsAsync()).PageSize), + OrderBy = orderByValue, + }; + filterParameters.FilterValues.AddRange(filterValues); + + return filterParameters; + } +} diff --git a/src/Modules/OrchardCore.Commerce/Startup.cs b/src/Modules/OrchardCore.Commerce/Startup.cs index a1a3475bc..ee4ef9c4f 100644 --- a/src/Modules/OrchardCore.Commerce/Startup.cs +++ b/src/Modules/OrchardCore.Commerce/Startup.cs @@ -213,6 +213,14 @@ public override void ConfigureServices(IServiceCollection services) .AddLiquidFilter("address_field_editor_view_model") // Liquid filter to create OrderLineItemViewModels. .AddLiquidFilter("order_line_item_view_models_and_tax_rates"); + + // Product List + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddContentPart() + .UseDisplayDriver(); } } diff --git a/src/Modules/OrchardCore.Commerce/ViewModels/ProductListFiltersViewModel.cs b/src/Modules/OrchardCore.Commerce/ViewModels/ProductListFiltersViewModel.cs new file mode 100644 index 000000000..9044a76c4 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/ViewModels/ProductListFiltersViewModel.cs @@ -0,0 +1,11 @@ +using OrchardCore.Commerce.Models; +using System.Collections.Generic; + +namespace OrchardCore.Commerce.ViewModels; + +public class ProductListFiltersViewModel +{ + public ProductListPart ProductListPart { get; set; } + public IEnumerable OrderByOptions { get; set; } + public IEnumerable FilterIds { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce/ViewModels/ProductListPartViewModel.cs b/src/Modules/OrchardCore.Commerce/ViewModels/ProductListPartViewModel.cs new file mode 100644 index 000000000..3cf3ac153 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/ViewModels/ProductListPartViewModel.cs @@ -0,0 +1,15 @@ +using OrchardCore.Commerce.Models; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Display.Models; +using OrchardCore.DisplayManagement; +using System.Collections.Generic; + +namespace OrchardCore.Commerce.ViewModels; + +public class ProductListPartViewModel +{ + public ProductListPart ProductListPart { get; set; } + public IShape Pager { get; set; } + public IEnumerable Products { get; set; } + public BuildPartDisplayContext Context { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce/Views/ProductList-Filter-Title.cshtml b/src/Modules/OrchardCore.Commerce/Views/ProductList-Filter-Title.cshtml new file mode 100644 index 000000000..3df3d63c3 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Views/ProductList-Filter-Title.cshtml @@ -0,0 +1,6 @@ +
+
+ + +
+
diff --git a/src/Modules/OrchardCore.Commerce/Views/ProductList-OrderBy-TitleAsc.cshtml b/src/Modules/OrchardCore.Commerce/Views/ProductList-OrderBy-TitleAsc.cshtml new file mode 100644 index 000000000..27fca9604 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Views/ProductList-OrderBy-TitleAsc.cshtml @@ -0,0 +1,3 @@ +@using OrchardCore.Commerce.Services +@using Microsoft.AspNetCore.Mvc.TagHelpers + diff --git a/src/Modules/OrchardCore.Commerce/Views/ProductList-OrderBy-TitleDesc.cshtml b/src/Modules/OrchardCore.Commerce/Views/ProductList-OrderBy-TitleDesc.cshtml new file mode 100644 index 000000000..5b0ed0c62 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Views/ProductList-OrderBy-TitleDesc.cshtml @@ -0,0 +1,3 @@ +@using OrchardCore.Commerce.Services +@using Microsoft.AspNetCore.Mvc.TagHelpers + diff --git a/src/Modules/OrchardCore.Commerce/Views/ProductListPart.Filters.cshtml b/src/Modules/OrchardCore.Commerce/Views/ProductListPart.Filters.cshtml new file mode 100644 index 000000000..a51226387 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Views/ProductListPart.Filters.cshtml @@ -0,0 +1,45 @@ +@using OrchardCore.Commerce.Services +@model ProductListFiltersViewModel + +@{ + var filterIdQueryStringKeys = Model.FilterIds.Select(id => "products." + id).ToArray(); + var activeOrderByOption = Context.Request.Query["products.orderBy"].ToString(); + if (string.IsNullOrEmpty(activeOrderByOption)) + { + activeOrderByOption = ProductListTitleFilterProvider.TitleAscOrderById; + } +} + +

@T["Filter Products"]

+ +
+ @foreach (var filterId in Model.FilterIds) + { + var shapeType = "ProductList__Filter__" + filterId; + + } + +
+
+ + +
+
+ + @foreach (var key in Context.Request.Query.Keys) + { + if (!key.EqualsOrdinalIgnoreCase("products.orderBy") && !filterIdQueryStringKeys.Contains(key, StringComparer.OrdinalIgnoreCase)) + { + + } + } + + + diff --git a/src/Modules/OrchardCore.Commerce/Views/ProductListPart.cshtml b/src/Modules/OrchardCore.Commerce/Views/ProductListPart.cshtml new file mode 100644 index 000000000..19cd67280 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Views/ProductListPart.cshtml @@ -0,0 +1,26 @@ +@using Lombiq.HelpfulLibraries.OrchardCore.Contents +@model ProductListPartViewModel + +@inject OrchardCore.ContentManagement.Display.IContentItemDisplayManager ContentItemDisplayManager + +@if (Model.Products.Any()) +{ +
    + @foreach (var contentItem in Model.Products) + { + var contentItemSummary = await ContentItemDisplayManager.BuildDisplayAsync(contentItem, Model.Context.Updater, CommonContentDisplayTypes.Summary, Model.Context.GroupId); + +
  • + @await DisplayAsync(contentItemSummary) +
  • + } +
+} +else +{ +

@T["There are no products to display."]

+} + + +@await DisplayAsync(Model.Pager) + diff --git a/test/OrchardCore.Commerce.Tests.UI/Tests/ProductListTests/BehaviorProductListTests.cs b/test/OrchardCore.Commerce.Tests.UI/Tests/ProductListTests/BehaviorProductListTests.cs new file mode 100644 index 000000000..f7b0c0d6c --- /dev/null +++ b/test/OrchardCore.Commerce.Tests.UI/Tests/ProductListTests/BehaviorProductListTests.cs @@ -0,0 +1,78 @@ +using Lombiq.Tests.UI.Attributes; +using Lombiq.Tests.UI.Extensions; +using Lombiq.Tests.UI.Services; +using OpenQA.Selenium; +using Shouldly; +using Xunit; +using Xunit.Abstractions; + +namespace OrchardCore.Commerce.Tests.UI.Tests.ProductListTests; + +public class BehaviorProductListTests : UITestBase +{ + public BehaviorProductListTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + [Theory, Chrome] + public Task SortingByTitleShouldWork(Browser browser) => + ExecuteTestAfterSetupAsync( + async context => + { + await context.SetDropdownByTextAsync("products-order-by", "Title A-Z"); + await context.ClickReliablyOnSubmitAsync(); + + var titles = GetProductTitles(context); + + titles[0].ShouldBe("Test Discounted Product"); + titles[1].ShouldBe("Test Free Product"); + + await context.SetDropdownByTextAsync("products-order-by", "Title Z-A"); + await context.ClickReliablyOnSubmitAsync(); + + titles = GetProductTitles(context); + + titles[0].ShouldBe("Test Tiered Price Product"); + titles[1].ShouldBe("Test Product"); + }, + browser); + + [Theory, Chrome] + public Task FilteringByTitleShouldWork(Browser browser) => + ExecuteTestAfterSetupAsync( + async context => + { + await context.FillInWithRetriesAsync(By.Id("products-filter-title"), "tiered"); + await context.ClickReliablyOnSubmitAsync(); + + var titles = GetProductTitles(context); + + titles.Count.ShouldBe(1); + titles[0].ShouldBe("Test Tiered Price Product"); + }, + browser); + + [Theory, Chrome] + public Task SortingAndPaginationShouldWorkTogether(Browser browser) => + ExecuteTestAfterSetupAsync( + async context => + { + await context.SetDropdownByTextAsync("products-order-by", "Title Z-A"); + await context.ClickReliablyOnSubmitAsync(); + + await context.ClickReliablyOnAsync(By.CssSelector(".pager li.last a")); + + var titles = GetProductTitles(context); + + titles.Count.ShouldBe(1); + titles[0].ShouldBe("Test Discounted Product"); + }, + browser); + + private static IList GetProductTitles(UITestContext context) => + context + .GetAll(By.CssSelector(".content header h2 a")) + .Select(element => element.Text) + .ToList(); +} diff --git a/test/OrchardCore.Commerce.Tests.UI/Tests/ProductTests/RetrievalProductTests.cs b/test/OrchardCore.Commerce.Tests.UI/Tests/ProductTests/RetrievalProductTests.cs index ad4ae5d9f..f1658157f 100644 --- a/test/OrchardCore.Commerce.Tests.UI/Tests/ProductTests/RetrievalProductTests.cs +++ b/test/OrchardCore.Commerce.Tests.UI/Tests/ProductTests/RetrievalProductTests.cs @@ -32,7 +32,7 @@ void TextShouldBe(string css, string expectedText) => ".content-price-variants-product > .field-name-product-" + "part-product-image > .name", "Product Image"); - TextShouldBe(".content-product header h2 a", "Test Product"); + TextShouldBe(".content-product header h2 a", "Test Discounted Product"); TextShouldBe( ".content-product > .field-name-product-" + "part-product-image > .name",