Skip to content

Commit

Permalink
OCC-192: Catalog navigation and Search (#374)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
barthamark and sarahelsaig authored Oct 31, 2023
1 parent 9628996 commit 5a30434
Show file tree
Hide file tree
Showing 25 changed files with 625 additions and 51 deletions.
Original file line number Diff line number Diff line change
@@ -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<ProductListPart>
{
private readonly IProductListService _productListService;
private readonly IEnumerable<IAppliedProductListFilterParametersProvider> _productFilterProviders;

public ProductListPartDisplayDriver(
IProductListService productListService,
IEnumerable<IAppliedProductListFilterParametersProvider> productFilterProviders)
{
_productListService = productListService;
_productFilterProviders = productFilterProviders;
}

public override IDisplayResult Display(ProductListPart part, BuildPartDisplayContext context) =>
Combine(
Initialize<ProductListPartViewModel>(
GetDisplayShapeType(context),
async viewModel => await BuildViewModelAsync(viewModel, part, context))
.Location(Detail, "Content:25")
.Location(Summary, "Meta:10"),
Initialize<ProductListFiltersViewModel>(
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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
10 changes: 10 additions & 0 deletions src/Modules/OrchardCore.Commerce/Models/ProductList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using OrchardCore.ContentManagement;
using System.Collections.Generic;

namespace OrchardCore.Commerce.Models;

public class ProductList
{
public IEnumerable<ContentItem> Products { get; set; }
public int TotalItemCount { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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<ContentItem> Query { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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<string, string> FilterValues { get; } = new Dictionary<string, string>();
}
9 changes: 9 additions & 0 deletions src/Modules/OrchardCore.Commerce/Models/ProductListFilters.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Collections.Generic;

namespace OrchardCore.Commerce.Models;

public class ProductListFilters
{
public IList<string> OrderBy { get; } = new List<string>();
public IDictionary<string, string> FilterValues { get; } = new Dictionary<string, string>();
}
12 changes: 12 additions & 0 deletions src/Modules/OrchardCore.Commerce/Models/ProductListPart.cs
Original file line number Diff line number Diff line change
@@ -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
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
},
Expand Down Expand Up @@ -92,11 +82,7 @@
"Owner": null,
"Author": "admin",
"ProductList": {},
"ListPart": {
"DisableRssFeed": false,
"FeedProxyUrl": null,
"FeedItemsCount": 20
},
"ProductListPart": {},
"AutoroutePart": {
"Path": "my-shop",
"SetHomepage": true,
Expand Down Expand Up @@ -180,11 +166,6 @@
""
]
}
},
"ContainedPart": {
"ListContentItemId": "mainproductlist00000000000",
"ListContentType": "ProductList",
"Order": 0
}
},
{
Expand Down Expand Up @@ -253,12 +234,7 @@
},
"ProductSku": "TESTPRODUCT"
},
"Product": {},
"ContainedPart": {
"ListContentItemId": "mainproductlist00000000000",
"ListContentType": "ProductList",
"Order": 0
}
"Product": {}
},
{
"ContentItemId": "testfreeproduct000",
Expand Down Expand Up @@ -326,12 +302,7 @@
},
"ProductSku": "TESTFREEPRODUCT"
},
"Product": {},
"ContainedPart": {
"ListContentItemId": "mainproductlist00000000000",
"ListContentType": "ProductList",
"Order": 0
}
"Product": {}
},
{
"ContentItemId": "testdiscountedproduct000",
Expand Down Expand Up @@ -422,12 +393,7 @@
"Value": null
}
},
"Product": {},
"ContainedPart": {
"ListContentItemId": "mainproductlist00000000000",
"ListContentType": "ProductList",
"Order": 0
}
"Product": {}
},
{
"ContentItemId": "testproductlocalized000000",
Expand Down Expand Up @@ -468,11 +434,6 @@
"LocalizationPart": {
"LocalizationSet": "testproductlocalizationset",
"Culture": "en-US"
},
"ContainedPart": {
"ListContentItemId": "mainproductlist00000000000",
"ListContentType": "ProductList",
"Order": 0
}
},
{
Expand Down Expand Up @@ -507,11 +468,6 @@
"TitlePart": {
"Title": "Test Tiered Price Product"
},
"ContainedPart": {
"ListContentItemId": "mainproductlist00000000000",
"ListContentType": "ProductList",
"Order": 1
},
"ProductPart": {
"Sku": "TESTTIEREDPRICE",
"CanBeBought": {}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using OrchardCore.Commerce.Models;
using System.Threading.Tasks;

namespace OrchardCore.Commerce.Services;

/// <summary>
/// Provides a way to get the applied filter parameters for a product list.
/// </summary>
public interface IAppliedProductListFilterParametersProvider
{
/// <summary>
/// Gets the priority of this provider. The provider with the highest priority will be used.
/// </summary>
int Priority { get; }

/// <summary>
/// Returns the applied filter parameters for the given product list.
/// </summary>
Task<ProductListFilterParameters> GetFilterParametersAsync(ProductListPart productList);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides a way to filter a product list.
/// </summary>
public interface IProductListFilterProvider
{
/// <summary>
/// Gets the order in which the filter providers are applied.
/// </summary>
int Order { get; }

/// <summary>
/// Whether this provider can handle the given product list. If not, the next provider will be tried and this
/// provider will be skipped.
/// </summary>
Task<bool> IsApplicableAsync(ProductListPart productList);

/// <summary>
/// The IDs of the order by options that this provider can handle.
/// </summary>
Task<IEnumerable<string>> GetOrderByOptionIdsAsync(ProductListPart productList);

/// <summary>
/// The IDs of the filters that this provider can handle.
/// </summary>
Task<IEnumerable<string>> GetFilterIdsAsync(ProductListPart productListPart);

/// <summary>
/// Builds the query for the given product list and filter parameters.
/// </summary>
Task<IQuery<ContentItem>> BuildQueryAsync(ProductListFilterContext context);
}
26 changes: 26 additions & 0 deletions src/Modules/OrchardCore.Commerce/Services/IProductListService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using OrchardCore.Commerce.Models;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace OrchardCore.Commerce.Services;

/// <summary>
/// Provides a way to get the products for a product list.
/// </summary>
public interface IProductListService
{
/// <summary>
/// Gets the products for the given product list and filter parameters.
/// </summary>
Task<ProductList> GetProductsAsync(ProductListPart productList, ProductListFilterParameters filterParameters);

/// <summary>
/// Gets the order by options for the given product list.
/// </summary>
Task<IEnumerable<string>> GetOrderByOptionsAsync(ProductListPart productList);

/// <summary>
/// Gets the filter IDs for the given product list.
/// </summary>
Task<IEnumerable<string>> GetFilterIdsAsync(ProductListPart productList);
}
Loading

0 comments on commit 5a30434

Please sign in to comment.