Skip to content

Commit

Permalink
OCC-137: Add Shopping Cart Loaded Workflow Event (#330)
Browse files Browse the repository at this point in the history
* Create base event.

* CartLoadedEvent view and driver.

* Upgrade HL.

* Use new workflow manager extension methods.

* bug fix

* Update HL and add other events.

* Add WorkflowShoppingCartEvents scaffolding.

* Update HL again.

* Eliminate driver display boilerplate.

* Pass workflow output.

* Invoke LoadedAsync

* Amount can be parsed from string.

* Update HL version.

* Fix bug where LocalizedHtmlString won't serialize.

* Refactor ShoppingCartSerializer.PostProcessAttributes.

* Refactor for JSON compatibility.

* Fix bug where product display breaks if you have predefined attributes but no variants.

* Export JSON rather than base the raw object.

* Organize code.

* Fix deserialization problem with raw attributes after JS event.

* Update HL nuget for NRE bug fix.

* JSON and NRE fixes.

* fix unit test constructor

* Add "product" Liquid filter.

* Export as content item instead.

* Add samples.

* Fix bug where if no default value is provided and nothing is selected before clicking the add button you get an IndexOutOfRangeException.

* Move the activities into a separate Startup class.

* Add `Workflows` documentation.

* Add new doc link in Readme and mkdocs.

* Add workflow unit tests.

* Spelling.

* Member 'GetSkuFromJsonObject' does not access instance data and can be marked as static

* Update docs/features/workflows.md

Co-authored-by: Szabolcs Deme <[email protected]>

* Update docs/features/workflows.md

Co-authored-by: Szabolcs Deme <[email protected]>

* Add solution item.

* Documentation cleanup.

* don't use single letter variable name

* Simplify.

* Remove GetUniqueCartId from IShoppingCartPersistence.

* Add ShoppingCartPersistenceBase and scope caching.

* Serialization bug fix.

* Use AutoMocker to instantiate ShoppingCartController more safely.

* Instantiate ControllerContext too.

* spelling

* Pattern simplification and code cleanup.

* typo

* Fix mistaken use of Where instead of WhereNot.

---------

Co-authored-by: Szabolcs Deme <[email protected]>
  • Loading branch information
sarahelsaig and DemeSzabolcs authored Aug 27, 2023
1 parent defdc92 commit 9d3aa13
Show file tree
Hide file tree
Showing 69 changed files with 1,244 additions and 299 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ node_modules/
*.placeholder
/.editorconfig
.ps1-analyzer-stamp
*.orig
1 change: 1 addition & 0 deletions OrchardCore.Commerce.sln
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "features", "features", "{9B
docs\features\stripe-payment.md = docs\features\stripe-payment.md
docs\features\taxation.md = docs\features\taxation.md
docs\features\user-features.md = docs\features\user-features.md
docs\features\workflows.md = docs\features\workflows.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "releases", "releases", "{E4DD8D47-02FA-41F7-8133-CBC4419645F5}"
Expand Down
1 change: 1 addition & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ This project uses `Lombiq Node.js Extensions` to compile and lint client-side as
- [Stripe Payment](docs/features/stripe-payment.md)
- [Taxation](docs/features/taxation.md)
- [User Features](docs/features/user-features.md)
- [Workflows](docs/features/workflows.md)

## Demo video

Expand Down
53 changes: 53 additions & 0 deletions docs/features/workflows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Workflows

## Shopping Cart Event Workflow Events

These events get triggered by `WorkflowShoppingCartEvents` which implements the `IShoppingCartEvents` interface. For each you can access the input as a .NET object using the `Context` workflow input and the serialized version as the `JSON` workflow input.
All of these workflows expect to return one or more outputs which is passed back to the invoking code.

> ⚠ If you want to return an altered version of the input as the output, please always use the JSON which is already serialized in the expected format used by OrchardCore.Commerce's converters. For example, you can use the JS expression `JSON.parse(input('JSON'))`.
> ℹ When your output contains `LocalizedHtmlString`, it can be represented in JS either as `string` or `{ Name: string, Value: string }`. In case of just `string` the same text becomes `LocalizedHtmlString.Name` and `LocalizedHtmlString.Value` too.
### "Cart displaying" Event

Executes after the shopping cart data is prepared, but before the shapes are rendered.

- Input: `ShoppingCartDisplayingEventContext` object containing the current shopping cart's headers and lines.
- Outputs: either outputs are optional.
- Headers: `LocalizedHtmlString` array. The shopping cart header labels in order. If you have to support multiple locales, make sure to use the object format mentioned above, because `LocalizedHtmlString.Name` is used to generate the template name for the corresponding shopping cart column's cells.
- Lines: `ShoppingCartLineViewModel` array. This is only for display, in most cases, you shouldn't have to return this output.

### "Verifying cart item" Event

Executes before an item is added to the shopping cart to check whether it can be added based on inventory status.

- Input: `ShoppingCartItem` object.
- Outputs:
- Error: `LocalizedHtmlString` or `null`. The error message to be displayed if the input item can't be added to the cart. You can simply not output anything if the validation passes.

### "Cart loaded" Event

Executes after the shopping cart content is loaded from the store and before it's displayed or used for calculation.

- Input: `ShoppingCart` object.
- Outputs:
- ShoppingCart: `ShoppingCart` object. An altered version of the input. If no changes are necessary, the output can be skipped. Here it's the most important to only use `input('JSON')` as mentioned above, because `ShoppingCart` has custom JSON converters inside that will ony correctly serialize in .NET code.

## Other Workflow Events

These events are triggered without an expectation of an output. They can be used for other automation.

### "Product added to cart" Event

Executes when a product is added to the shopping cart.

- Inputs:
- LineItem: `ShoppingCartItem` object.

### "Order was created" Event

Executes when an order is created on the frontend.

- Inputs:
- ContentItem: `ContentItem` object of the `Order` content item that has just been created.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ nav:
- Stripe Payment: features/stripe-payment.md
- Taxation: features/taxation.md
- User Features: features/user-features.md
- Workflows: features/workflows.md
- Resources:
- Libraries: resources/libraries/README.md
- Releases:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ public static class Regions
catch { return null; }
})
.Where(region =>
region != null &&
region.TwoLetterISORegionName.Length == 2 && // Filter out world and other 3-digit regions.
region is { TwoLetterISORegionName.Length: 2 } && // Filter out world and other 3-digit regions.
!string.IsNullOrEmpty(region.EnglishName))
.Distinct()
.Select(region => new Region(region))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,9 @@ public override Amount Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS
}
}

if (!IsKnownCurrency(currency?.CurrencyIsoCode ?? string.Empty))
{
currency = new Currency(
nativeName,
englishName,
symbol,
iso,
decimalDigits.GetValueOrDefault(DefaultDecimalDigits));
}
if (reader.TokenType == JsonTokenType.String) return LegacyAmountConverter.ReadString(reader.GetString());

if (currency is null) throw new InvalidOperationException("Invalid amount format. Must include a currency.");
currency = HandleUnknownCurrency(currency, nativeName, englishName, symbol, iso, decimalDigits);

return new Amount(value, currency);
}
Expand Down Expand Up @@ -105,4 +97,27 @@ public override void Write(Utf8JsonWriter writer, Amount amount, JsonSerializerO

writer.WriteEndObject();
}

private static ICurrency HandleUnknownCurrency(
ICurrency currency,
string nativeName,
string englishName,
string symbol,
string iso,
int? decimalDigits)
{
if (!IsKnownCurrency(currency?.CurrencyIsoCode ?? string.Empty))
{
return new Currency(
nativeName,
englishName,
symbol,
iso,
decimalDigits.GetValueOrDefault(DefaultDecimalDigits));
}

if (currency != null) return currency;

throw new InvalidOperationException($"Invalid amount format. Must include a {nameof(currency)}.");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Globalization;
using System.Linq;
using static OrchardCore.Commerce.MoneyDataType.Currency;
using static OrchardCore.Commerce.MoneyDataType.Serialization.AmountConverter;

Expand All @@ -15,7 +17,10 @@ public override Amount ReadJson(
bool hasExistingValue,
JsonSerializer serializer)
{
var attribute = (JObject)JToken.Load(reader);
var token = JToken.Load(reader);
if (token.Type == JTokenType.String) return ReadString(token.Value<string>());

var attribute = (JObject)token;

var value = attribute.Get<decimal>(ValueName);
var currencyCode = attribute.Get<string>(CurrencyName);
Expand Down Expand Up @@ -78,4 +83,15 @@ public override void WriteJson(JsonWriter writer, Amount amount, JsonSerializer

writer.WriteEndObject();
}

public static Amount ReadString(string text)
{
var parts = text.Split();
if (parts.Length < 2) throw new InvalidOperationException($"Unable to parse string amount \"{text}\".");

var currency = FromIsoCode(parts[0]);
var value = decimal.Parse(string.Join(string.Empty, parts.Skip(1)), CultureInfo.InvariantCulture);

return new Amount(value, currency);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
<ProjectReference Include="$(LombiqHelpfulLibrariesPath)" />
</ItemGroup>
<ItemGroup Condition="!Exists($(LombiqHelpfulLibrariesPath))">
<PackageReference Include="Lombiq.HelpfulLibraries.OrchardCore" Version="7.0.0" />
<PackageReference Include="Lombiq.HelpfulLibraries.OrchardCore" Version="7.0.1-alpha.7.occ-137" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<ProjectReference Include="$(LombiqHelpfulLibrariesPath)" />
</ItemGroup>
<ItemGroup Condition="!Exists($(LombiqHelpfulLibrariesPath))">
<PackageReference Include="Lombiq.HelpfulLibraries.OrchardCore" Version="7.0.0" />
<PackageReference Include="Lombiq.HelpfulLibraries.OrchardCore" Version="7.0.1-alpha.7.occ-137" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using OrchardCore.ContentManagement.Metadata.Models;
using System;
using System.Text.Json;

namespace OrchardCore.Commerce.Abstractions;
Expand All @@ -16,13 +17,29 @@ IProductAttributeValue Parse(
ContentPartFieldDefinition attributeFieldDefinition,
string[] value);

/// <summary>
/// Parses the provided attribute value.
/// </summary>
IProductAttributeValue CreateFromValue(
ContentTypePartDefinition partDefinition,
ContentPartFieldDefinition attributeFieldDefinition,
object value) =>
#pragma warning disable CS0618 // Type or member is obsolete. Backwards compatibility.
CreateFromJsonElement(
partDefinition,
attributeFieldDefinition,
value is JsonElement element ? element : default);
#pragma warning restore CS0618 // Type or member is obsolete. Backwards compatibility.

/// <summary>
/// Parses the provided JSON-serialized data.
/// </summary>
[Obsolete($"Use {nameof(CreateFromValue)} instead.")]
IProductAttributeValue CreateFromJsonElement(
ContentTypePartDefinition partDefinition,
ContentPartFieldDefinition attributeFieldDefinition,
JsonElement value);
JsonElement value) =>
throw new NotSupportedException($"This attribute provider doesn't support {nameof(JsonElement)}.");
}

public static class ProductAttributeProviderExtensions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,9 @@ public interface IShoppingCartEvents
/// Invoked before an item is added to the shopping cart to check whether it can be added based on inventory status.
/// </summary>
Task<LocalizedHtmlString> VerifyingItemAsync(ShoppingCartItem item);

/// <summary>
/// Invoked after the shopping cart content is loaded from the <see cref="IShoppingCartPersistence"/>.
/// </summary>
Task<ShoppingCart> LoadedAsync(ShoppingCart shoppingCart);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
using OrchardCore.Commerce.Models;
using OrchardCore.Commerce.Services;
using System.Threading.Tasks;

namespace OrchardCore.Commerce.Abstractions;

/// <summary>
/// Service that provides a way to retain shopping cart information.
/// </summary>
/// <remarks><para>
/// When deriving a custom implementation, please inherit from <see cref="ShoppingCartPersistenceBase"/> to retain event
/// handling and dependency injection scope level caching.
/// </para></remarks>
public interface IShoppingCartPersistence
{
/// <summary>
Expand All @@ -17,10 +22,4 @@ public interface IShoppingCartPersistence
/// Saves a shopping cart by a given ID.
/// </summary>
Task StoreAsync(ShoppingCart items, string shoppingCartId = null);

/// <summary>
/// Generates a shopping cart ID from <paramref name="shoppingCartId"/> that's unique to this persistence
/// implementation.
/// </summary>
string GetUniqueCartId(string shoppingCartId);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using OrchardCore.Commerce.Models;
using OrchardCore.Commerce.ProductAttributeValues;
using OrchardCore.Commerce.ViewModels;
using OrchardCore.ContentManagement.Metadata.Models;
using System.Collections.Generic;
Expand Down Expand Up @@ -35,4 +36,10 @@ public interface IShoppingCartSerializer
/// Returns a deserialized object from JSON string <paramref name="serializedCart"/>.
/// </summary>
Task<ShoppingCart> DeserializeAsync(string serializedCart);

/// <summary>
/// Process the products attributes for a cart line item, substitute temporary storage attributes such as <see
/// cref="RawProductAttributeValue"/>.
/// </summary>
ISet<IProductAttributeValue> PostProcessAttributes(IEnumerable<IProductAttributeValue> attributes, ProductPart productPart);
}
13 changes: 13 additions & 0 deletions src/Modules/OrchardCore.Commerce/Activities/CartDisplayingEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.Extensions.Localization;

namespace OrchardCore.Commerce.Activities;

public class CartDisplayingEvent : CommerceEventActivityBase
{
public override LocalizedString DisplayText => T["Cart displaying"];

public CartDisplayingEvent(IStringLocalizer<CartLoadedEvent> localizer)
: base(localizer)
{
}
}
13 changes: 13 additions & 0 deletions src/Modules/OrchardCore.Commerce/Activities/CartLoadedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.Extensions.Localization;

namespace OrchardCore.Commerce.Activities;

public class CartLoadedEvent : CommerceEventActivityBase
{
public override LocalizedString DisplayText => T["Cart loaded"];

public CartLoadedEvent(IStringLocalizer<CartLoadedEvent> localizer)
: base(localizer)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.Extensions.Localization;

namespace OrchardCore.Commerce.Activities;

public class CartVerifyingItemEvent : CommerceEventActivityBase
{
public override LocalizedString DisplayText => T["Verifying cart item"];

public CartVerifyingItemEvent(IStringLocalizer<CartLoadedEvent> localizer)
: base(localizer)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Lombiq.HelpfulLibraries.OrchardCore.Workflow;
using Microsoft.Extensions.Localization;

namespace OrchardCore.Commerce.Activities;

public abstract class CommerceEventActivityBase : SimpleEventActivityBase
{
public override LocalizedString Category => T["Commerce"];

protected CommerceEventActivityBase(IStringLocalizer stringLocalizer)
: base(stringLocalizer)
{
}
}
28 changes: 5 additions & 23 deletions src/Modules/OrchardCore.Commerce/Activities/OrderCreatedEvent.cs
Original file line number Diff line number Diff line change
@@ -1,31 +1,13 @@
using Microsoft.Extensions.Localization;
using OrchardCore.Workflows.Abstractions.Models;
using OrchardCore.Workflows.Activities;
using OrchardCore.Workflows.Models;
using System.Collections.Generic;

namespace OrchardCore.Commerce.Activities;

public class OrderCreatedEvent : EventActivity
public class OrderCreatedEvent : CommerceEventActivityBase
{
private readonly IStringLocalizer<OrderCreatedEvent> T;

public OrderCreatedEvent(IStringLocalizer<OrderCreatedEvent> localizer) =>
T = localizer;

public override string Name => nameof(OrderCreatedEvent);
public OrderCreatedEvent(IStringLocalizer<OrderCreatedEvent> localizer)
: base(localizer)
{
}

public override LocalizedString DisplayText => T["Order was created"];

public override LocalizedString Category => T["Commerce"];

public override IEnumerable<Outcome> GetPossibleOutcomes(
WorkflowExecutionContext workflowContext,
ActivityContext activityContext) =>
new[] { new Outcome(T["Done"]) };

public override ActivityExecutionResult Resume(
WorkflowExecutionContext workflowContext,
ActivityContext activityContext) =>
Outcomes("Done");
}
Loading

0 comments on commit 9d3aa13

Please sign in to comment.