From b7f76c022dc0fa1a4fa8ebfae5e274e519a22c48 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 6 Aug 2024 17:50:43 +0800 Subject: [PATCH 01/13] Add chapter 'from polymorphic to normal variant' --- docs/.vitepress/config.js | 1 + docs/poly-to-normal-variant/Discount.re | 10 + docs/poly-to-normal-variant/Item.re | 26 ++ docs/poly-to-normal-variant/Promo.re | 67 +++++ docs/poly-to-normal-variant/RR.re | 3 + docs/poly-to-normal-variant/Types.re | 102 +++++++ docs/poly-to-normal-variant/dune | 6 + docs/poly-to-normal-variant/index.md | 298 ++++++++++++++++++++ docs/poly-to-normal-variant/type-error.txt | 7 + docs/promo-component/index.md | 7 +- index.html | 3 + src/poly-to-normal-variant/Array.re | 7 + src/poly-to-normal-variant/BurgerTests.re | 85 ++++++ src/poly-to-normal-variant/Discount.re | 118 ++++++++ src/poly-to-normal-variant/DiscountTests.re | 248 ++++++++++++++++ src/poly-to-normal-variant/Index.re | 35 +++ src/poly-to-normal-variant/Item.re | 99 +++++++ src/poly-to-normal-variant/ListSafe.re | 2 + src/poly-to-normal-variant/Order.re | 61 ++++ src/poly-to-normal-variant/Promo.re | 83 ++++++ src/poly-to-normal-variant/RR.re | 15 + src/poly-to-normal-variant/SandwichTests.re | 43 +++ src/poly-to-normal-variant/dune | 11 + src/poly-to-normal-variant/index.html | 12 + src/poly-to-normal-variant/tests.t | 123 ++++++++ vite.config.mjs | 1 + 26 files changed, 1470 insertions(+), 3 deletions(-) create mode 100644 docs/poly-to-normal-variant/Discount.re create mode 100644 docs/poly-to-normal-variant/Item.re create mode 100644 docs/poly-to-normal-variant/Promo.re create mode 100644 docs/poly-to-normal-variant/RR.re create mode 100644 docs/poly-to-normal-variant/Types.re create mode 100644 docs/poly-to-normal-variant/dune create mode 100644 docs/poly-to-normal-variant/index.md create mode 100644 docs/poly-to-normal-variant/type-error.txt create mode 100644 src/poly-to-normal-variant/Array.re create mode 100644 src/poly-to-normal-variant/BurgerTests.re create mode 100644 src/poly-to-normal-variant/Discount.re create mode 100644 src/poly-to-normal-variant/DiscountTests.re create mode 100644 src/poly-to-normal-variant/Index.re create mode 100644 src/poly-to-normal-variant/Item.re create mode 100644 src/poly-to-normal-variant/ListSafe.re create mode 100644 src/poly-to-normal-variant/Order.re create mode 100644 src/poly-to-normal-variant/Promo.re create mode 100644 src/poly-to-normal-variant/RR.re create mode 100644 src/poly-to-normal-variant/SandwichTests.re create mode 100644 src/poly-to-normal-variant/dune create mode 100644 src/poly-to-normal-variant/index.html create mode 100644 src/poly-to-normal-variant/tests.t diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 44b7896d..047aa6c4 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -41,6 +41,7 @@ export default defineConfig({ { text: 'Discounts Using Lists', link: '/discounts-lists/' }, { text: 'Promo Codes', link: '/promo-codes/' }, { text: 'Promo Component', link: '/promo-component/' }, + { text: 'From Polymorphic to Normal Variant', link: '/poly-to-normal-variant/' }, { text: 'Order with Promo', link: '/order-with-promo/' }, ] } diff --git a/docs/poly-to-normal-variant/Discount.re b/docs/poly-to-normal-variant/Discount.re new file mode 100644 index 00000000..3a7efbac --- /dev/null +++ b/docs/poly-to-normal-variant/Discount.re @@ -0,0 +1,10 @@ +type error = + | InvalidCode + | ExpiredCode; + +let getDiscountFunction = (code, _date) => { + switch (code) { + | "FREE" => Ok(_items => Ok(0.0)) + | _ => Error(InvalidCode) + }; +}; diff --git a/docs/poly-to-normal-variant/Item.re b/docs/poly-to-normal-variant/Item.re new file mode 100644 index 00000000..57d188b9 --- /dev/null +++ b/docs/poly-to-normal-variant/Item.re @@ -0,0 +1,26 @@ +module Burger = { + type t = { + lettuce: bool, + onions: int, + cheese: int, + tomatoes: bool, + bacon: int, + }; +}; + +module Sandwich = { + type t = + | Portabello + | Ham + | Unicorn + | Turducken; +}; + +type t = + | Sandwich(Sandwich.t) + | Burger(Burger.t) + | Hotdog; + +let toPrice = (~date as _: Js.Date.t, _t: t) => 0.; + +let toEmoji = (_t: t) => ""; diff --git a/docs/poly-to-normal-variant/Promo.re b/docs/poly-to-normal-variant/Promo.re new file mode 100644 index 00000000..e67db7b3 --- /dev/null +++ b/docs/poly-to-normal-variant/Promo.re @@ -0,0 +1,67 @@ +[@react.component] +let make = (~items as _: list(Item.t), ~date as _: Js.Date.t, ~onApply as _) => +
; + +type discount('a) = + | CodeError(Discount.error) + | Discount(float) + | DiscountError('a) + | NoSubmittedCode; + +let _ = + (submittedCode, date, items) => { + // #region discount-variant + let discount = + switch (submittedCode) { + | None => NoSubmittedCode + | Some(code) => + switch (Discount.getDiscountFunction(code, date)) { + | Error(error) => CodeError(error) + | Ok(discountFunction) => + switch (discountFunction(items)) { + | Error(error) => DiscountError(error) + | Ok(value) => Discount(value) + } + } + }; + // #endregion discount-variant + + ignore(discount); + }; + +module Style = { + let codeError = ""; + let discountError = ""; +}; + +let _ = + discount => { + <> + // #region discount-render + {switch (discount) { + | NoSubmittedCode => React.null + | Discount(discount) => discount |> Float.neg |> RR.currency + | CodeError(error) => +
+ {let errorType = + switch (error) { + | Discount.InvalidCode => "Invalid" + | ExpiredCode => "Expired" + }; + {j|$errorType promo code|j} |> RR.s} +
+ | DiscountError(code) => + let buyWhat = + switch (code) { + | `NeedOneBurger => "at least 1 more burger" + | `NeedTwoBurgers => "at least 2 burgers" + | `NeedMegaBurger => "a burger with every topping" + | `MissingSandwichTypes => "every sandwich" + }; +
+ {RR.s({j|Buy $buyWhat to enjoy this promotion|j})} +
; + }} + // #endregion discount-render + ; + }; diff --git a/docs/poly-to-normal-variant/RR.re b/docs/poly-to-normal-variant/RR.re new file mode 100644 index 00000000..537f938c --- /dev/null +++ b/docs/poly-to-normal-variant/RR.re @@ -0,0 +1,3 @@ +let s = React.string; + +let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; diff --git a/docs/poly-to-normal-variant/Types.re b/docs/poly-to-normal-variant/Types.re new file mode 100644 index 00000000..682b3c76 --- /dev/null +++ b/docs/poly-to-normal-variant/Types.re @@ -0,0 +1,102 @@ +/** + +// #region inferred-type +[> `CodeError(Discount.error) + | `Discount(float) + | `DiscountError([> `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers ]) + | `NoSubmittedCode ] +// #endregion inferred-type + + */ + +/** + +// #region bad-discount-type +type discount = + | CodeError(Discount.error) + | Discount(float) + | DiscountError( + [> + | `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers + ], + ) + | NoSubmittedCode; +// #endregion bad-discount-type + +*/ + +// #region delete-refinement +type discount = + | CodeError(Discount.error) + | Discount(float) + | DiscountError( + [ + | `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers + ], + ) + | NoSubmittedCode; +// #endregion delete-refinement + +module TypeVar = { + // #region type-variable + type discount('a) = + | CodeError(Discount.error) + | Discount(float) + | DiscountError('a) + | NoSubmittedCode; + // #endregion type-variable +}; + +/** + +// #region explicit-type-var +type discount = + | CodeError(Discount.error) + | Discount(float) + | DiscountError( + [> + | `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers + ] as 'a, + ) + | NoSubmittedCode; +// #endregion explicit-type-var + +*/ +module AddTypeArg = { + // #region add-type-arg + type discount('a) = + | CodeError(Discount.error) + | Discount(float) + | DiscountError( + [> + | `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers + ] as 'a, + ) + | NoSubmittedCode; + // #endregion add-type-arg +}; + +module MustBePoly = { + // #region must-be-poly + type discount('a) = + | CodeError(Discount.error) + | Discount(float) + | DiscountError([> ] as 'a) + | NoSubmittedCode; + // #endregion must-be-poly +}; diff --git a/docs/poly-to-normal-variant/dune b/docs/poly-to-normal-variant/dune new file mode 100644 index 00000000..e411ebad --- /dev/null +++ b/docs/poly-to-normal-variant/dune @@ -0,0 +1,6 @@ +(melange.emit + (target output) + (libraries reason-react melange-fest styled-ppx.emotion) + (preprocess + (pps melange.ppx reason-react-ppx styled-ppx)) + (module_systems es6)) diff --git a/docs/poly-to-normal-variant/index.md b/docs/poly-to-normal-variant/index.md new file mode 100644 index 00000000..ca4fe29f --- /dev/null +++ b/docs/poly-to-normal-variant/index.md @@ -0,0 +1,298 @@ +# From Polymorphic to Normal Variant + +The `Promo` component works fine now, but let's take a moment to explore how to +implement it with a normal variant instead of a polymorphic variant. This small +refactor should give us more insight into OCaml's type system. Additionally, +normal variants are better than polymorphic variants at "documenting" the types +that will be used in your program, since they must always be explicitly defined +before you can use them. + +## Add `discount` type + +When we hover over the `discount` variable in `Promo.make`, we see this type +expression: + +<<< Types.re#inferred-type + +The easiest thing to do is to create a new `discount` type and assign it to the +above type expression, then delete the `` ` `` from the top-level variant tags +to turn them into variant constructors[^1]: + +<<< Types.re#bad-discount-type + +However, this results in a compilation error: + +<<< type-error.txt + +We'll come back to this error message later. For now, observe that the error +disappears if we simply delete `>`: + +<<< Types.re#delete-refinement + +This fixes the syntax error so that we now have a correctly-defined variant +type. + +## Refactor `discount` to use normal variant + +Refactor the `discount` derived variable inside `Promo.make` to use our new +variant type by deleting all occurrences of `` ` ``: + +<<< Promo.re#discount-variant{3,6,9-10} + +You can likewise refactor the switch expression inside the render logic: + +<<< Promo.re#discount-render{2-4,13} + +## Type constructor and type variable + +Change the `discount` type to this: + +<<< Types.re#type-variable + +Now `discount` is a *type constructor* that takes a *type variable* named `'a`. +A type constructor is not a fixed type---you can think of it as a function that +takes a type and outputs a new type. + +The advantage of doing this is that the variant tags inside `DiscountError` are +no longer constrained by our `discount` type. This makes sense because they are +used primarily in the `Discount` module, and if any variant tags are renamed, +added, or deleted, those changes will and should happen in `Discount`. + +Using a type variable does not sacrifice type safety, if you hover over the +`discount` variable, you see that its type is: + +```reason +discount([> `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers ]) +``` + +Based on its usage, OCaml can figure out the exact type of the `discount` +variable and automatically fill in the value of the type variable. + +## `>` = "allow more than" + +In the type expression above, we once again see `>`, so let's see what it means. +In polymorphic variant type expressions, it means "allow more than". In this +case, it means that tags other than the four that are listed are allowed. For +example, this type would be allowed: + +```reason{5-6} +discount([| `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers + | `HoneyButter + | `KewpieMayo ]) +``` + +When defining your own types, you will most often used *fixed* polymormorphic +variants, i.e. those that don't have `>` in their type expressions. But it is +still useful to know what `>` does, since it appears when the compiler +infers the type of a variable or function that uses polymorphic variants. + +::: tip + +Fixed polymorphic variants and normal variants are roughly equivalent and can be +used interchangeably. + +::: + +## Implicit type variable + +Let's come back to the question of why the original attempt at a variant type +definition was syntactically invalid: + +<<< Types.re#bad-discount-type + +The reason is that there's an implicit type variable around the `>`. The +above type expression is equivalent to: + +<<< Types.re#explicit-type-var{14} + +Now the error message makes a bit more sense: + +<<< type-error.txt{7} + +The type variable exists, but it's pointless unless it appears as an argument of +the `discount` type constructor. Once it's added, it compiles: + +<<< Types.re#add-type-arg{1} + +This is somewhat like accidentally using a variable in a function but forgetting +to add that variable to the function's argument list. + +## Force `DiscountError` argument to be polymorphic variant + +Right now the argument of the `DiscountError` constructor can be any type at +all, but to be explicit, we can force it to be a polymorphic variant: + +<<< Types.re#must-be-poly{4} + +The `[> ]` type expression means a polymorphic variant that has no tags, but +allows more tags, which basically means any polymorphic variant. Note that +adding this small restriction to the type doesn't make a real difference in this +program---it's just a way to make it clear that `DiscountError`'s argument +should be a polymorphic variant. It's an optional embellishment that you can +feel free to leave out. + +--- + +Phew! You refactored the `discount` reactive variable to use a normal variant +instead of a polymorphic variant. The code changes were fairly minimal, but to +understand what was happening, it was necessary to learn the basics of type +constructors and type variables. In the next sections, we'll set types and other +theoretical considerations aside and get into the nitty-gritty of the UI changes +you must make to add promo support to the `Order` component. + +## Overview + +- A type constructor takes a type and outputs another type +- A type variable is a variable that stands in a for a type and often appears in + type constructors or type signatures +- In polymorphic variant type expressions, `>` means that the polymorphic + variant can accept more than the variant tags that are listed + - You rarely need to use `>` in your own type definitions, but it often + appears in inferred type definitions (that appear when you hover over + variables and functions) + - Inferred type definitions that contain `>` also have an implicit type + variable + +## Exercises + +1. The following code +([playground](https://melange.re/v4.0.0/playground/?language=Reason&code=bGV0IGdldE5hbWUgPSAoYW5pbWFsOiBbfCBgQ2F0IHwgYERvZyhpbnQpIHwgYFVuaWNvcm4oc3RyaW5nKV0pID0%2BCiAgc3dpdGNoIChhbmltYWwpIHsKICB8IGBDYXQgPT4gIk1yIFdoaXNrZXJzIgogIHwgYERvZyhuKSA9PiAiQmFuZGl0ICIgKysgc3RyaW5nX29mX2ludChuKQogIHwgYFVuaWNvcm4obmFtZSkgPT4gIlNpciAiICsrIG5hbWUKICB9Ow%3D%3D&live=off)) +doesn’t compile. Fix it by adding a single character. + +```reason +let getName = (animal: [| `Cat | `Dog(int) | `Unicorn(string)]) => + switch (animal) { + | `Cat => "Mr Whiskers" + | `Dog(n) => "Bandit " ++ string_of_int(n) + | `Unicorn(name) => "Sir " ++ name + }; +``` + +::: details Hint + +Find a place where you can insert a space. + +::: + +::: details Solution + +```reason{1} +let getName = (animal: [ | `Cat | `Dog(int) | `Unicorn(string)]) => + switch (animal) { + | `Cat => "Mr Whiskers" + | `Dog(n) => "Bandit " ++ string_of_int(n) + | `Unicorn(name) => "Sir " ++ name + }; +``` + +A common mistake when writing polymorphic variant type definitions is forgetting +to put a space between the `[` and the `|` characters. Note that you don't need +to add the implicit type variable in type annotations. + +::: warning + +In the next version of Melange, polymorphic variant definitions no longer +require a space between `[` and `|`. + +::: + +2. The following code +([playground](https://melange.re/v4.0.0/playground/?language=Reason&code=bGV0IGdldE5hbWUgPSAoYW5pbWFsOiBbIHwgYENhdCB8IGBEb2coaW50KSB8IGBVbmljb3JuKHN0cmluZyldKSA9PgogIHN3aXRjaCAoYW5pbWFsKSB7CiAgfCBgQ2F0ID0%2BICJNciBXaGlza2VycyIKICB8IGBEb2cobikgPT4gIkJhbmRpdCAiICsrIHN0cmluZ19vZl9pbnQobikKICB8IGBVbmljb3JuKG5hbWUpID0%2BICJTaXIgIiArKyBuYW1lCiAgfCBgRHJhZ29uID0%2BICJQdWZmIHRoZSBNYWdpYyIKICB9Ow%3D%3D&live=off)) +doesn’t compile. Fix it by adding a single character. + +```reason +let getName = (animal: [| `Cat | `Dog(int) | `Unicorn(string)]) => + switch (animal) { + | `Cat => "Mr Whiskers" + | `Dog(n) => "Bandit " ++ string_of_int(n) + | `Unicorn(name) => "Sir " ++ name + | `Dragon => "Puff the Magic" + }; +``` + +::: details Solution + +```reason{1} +let getName = (animal: [> | `Cat | `Dog(int) | `Unicorn(string)]) => + switch (animal) { + | `Cat => "Mr Whiskers" + | `Dog(n) => "Bandit " ++ string_of_int(n) + | `Unicorn(name) => "Sir " ++ name + | `Dragon => "Puff the Magic" + }; +``` + +Adding a `>` to the polymorphic variant type definition allows it to accept more +than the listed variant tags. + +::: + +3. Fix the following code ([playground](https://melange.re/v4.0.0/playground/?language=Reason&code=LyoqIE9ubHkgaW52b2tlIFtmXSB3aGVuIFtvMV0gYW5kIFtvMl0gYXJlIFtTb21lXSAqLwpsZXQgbWFwMjogKG9wdGlvbignYSksIG9wdGlvbignYSksICgnYSwgJ2EpID0%2BICdhKSA9PiBvcHRpb24oJ2EpID0KICAobzEsIG8yLCBmKSA9PgogICAgc3dpdGNoIChvMSwgbzIpIHsKICAgIHwgKE5vbmUsIE5vbmUpCiAgICB8IChOb25lLCBTb21lKF8pKQogICAgfCAoU29tZShfKSwgTm9uZSkgPT4gTm9uZQogICAgfCAoU29tZSh2MSksIFNvbWUodjIpKSA9PiBTb21lKGYodjEsIHYyKSkKICAgIH07CgpKcy5sb2cobWFwMihTb21lKDExKSwgU29tZSgzMyksICgrKSkpOwpKcy5sb2cobWFwMihTb21lKCJBQkMiKSwgU29tZSgxMjMpLCAoYSwgYikgPT4gKGEsIGIpKSk7&live=off)) which fails to compile: + +```reason +/** Only invoke [f] when [o1] and [o2] are [Some] */ +let map2: (option('a), option('a), ('a, 'a) => 'a) => option('a) = + (o1, o2, f) => + switch (o1, o2) { + | (None, None) + | (None, Some(_)) + | (Some(_), None) => None + | (Some(v1), Some(v2)) => Some(f(v1, v2)) + }; + +Js.log(map2(Some(11), Some(33), (+))); +Js.log(map2(Some("ABC"), Some(123), (a, b) => (a, b))); +``` + +::: details Hint 1 + +Fix the type annotation. + +::: + +::: details Hint 2 + +Delete the type annotation. + +::: + +::: details Solution + +```reason +/** Only invoke [f] when [o1] and [o2] are [Some] */ +let map2: (option('a), option('b), ('a, 'b) => 'c) => option('c) = + (o1, o2, f) => + switch (o1, o2) { + | (None, None) + | (None, Some(_)) + | (Some(_), None) => None + | (Some(v1), Some(v2)) => Some(f(v1, v2)) + }; + +Js.log(map2(Some(11), Some(33), (+))); +Js.log(map2(Some("ABC"), Some(123), (a, b) => (a, b))); +``` + +We have to use different type variables if we expect that the types might be +different. Note that we could have deleted the type annotation and then OCaml's +inferred type would be the same as the type annotation above. + +::: + +----- + +View [source +code](https://github.com/melange-re/melange-for-react-devs/blob/main/src/poly-to-normal-variant/) +and [demo](https://react-book.melange.re/demo/src/poly-to-normal-variant/) for this chapter. + +----- + +[^1]: In OCaml terminology, variant tags start with `` ` `` and correspond to + polymorphic variant types, while variant constructors correspond to normal + variant types. diff --git a/docs/poly-to-normal-variant/type-error.txt b/docs/poly-to-normal-variant/type-error.txt new file mode 100644 index 00000000..317e4734 --- /dev/null +++ b/docs/poly-to-normal-variant/type-error.txt @@ -0,0 +1,7 @@ +Error: A type variable is unbound in this type declaration. + In case + DiscountError of ([> `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers ] + as 'a) the variable 'a is unbound diff --git a/docs/promo-component/index.md b/docs/promo-component/index.md index a5bb571f..4642c671 100644 --- a/docs/promo-component/index.md +++ b/docs/promo-component/index.md @@ -410,8 +410,10 @@ Here is an example of a case that is not matched: Muy bueno! You've created a `Promo` component that can be used to submit promo codes and see the discounts they produce, along with any errors that might -occur. In the next chapter, we'll integrate this `Promo` component into your -`Order` component. +occur. You also saw how to use a polymorphic variant to streamline the render +logic in `Promo`. In the next chapter, we'll explore how to implement `Promo` +using a normal variant, and in the process, learn more about OCaml's type +system. ## Overview @@ -483,7 +485,6 @@ You should do some cleanup: ::: - 3. Right now, `Item.toPrice` is a nondeterministic function---it returns a different price for a turducken sandwich depending on the day of the week[^4]. This makes writing some discount tests more complicated than necessary, and diff --git a/index.html b/index.html index 6565f5e4..77ffd82b 100644 --- a/index.html +++ b/index.html @@ -50,6 +50,9 @@

Melange for React Developers

  • Promo Component
  • +
  • + From Polymorphic to Normal Variant +
  • Order with Promo
  • diff --git a/src/poly-to-normal-variant/Array.re b/src/poly-to-normal-variant/Array.re new file mode 100644 index 00000000..9582e9a9 --- /dev/null +++ b/src/poly-to-normal-variant/Array.re @@ -0,0 +1,7 @@ +// Safe array access function +let get: (array('a), int) => option('a) = + (array, index) => + switch (index) { + | index when index < 0 || index >= Js.Array.length(array) => None + | index => Some(Stdlib.Array.get(array, index)) + }; diff --git a/src/poly-to-normal-variant/BurgerTests.re b/src/poly-to-normal-variant/BurgerTests.re new file mode 100644 index 00000000..e720b427 --- /dev/null +++ b/src/poly-to-normal-variant/BurgerTests.re @@ -0,0 +1,85 @@ +open Fest; + +test("A fully-loaded burger", () => + expect + |> equal( + Item.Burger.toEmoji({ + lettuce: true, + onions: 2, + cheese: 3, + tomatoes: true, + bacon: 4, + }), + {js|πŸ”{πŸ₯¬,πŸ…,πŸ§…Γ—2,πŸ§€Γ—3,πŸ₯“Γ—4}|js}, + ) +); + +test("Burger with 0 of onions, cheese, or bacon doesn't show those emoji", () => + expect + |> equal( + Item.Burger.toEmoji({ + lettuce: true, + tomatoes: true, + onions: 0, + cheese: 0, + bacon: 0, + }), + {js|πŸ”{πŸ₯¬,πŸ…}|js}, + ) +); + +test( + "Burger with 1 of onions, cheese, or bacon should show just the emoji without Γ—", + () => + expect + |> equal( + Item.Burger.toEmoji({ + lettuce: true, + tomatoes: true, + onions: 1, + cheese: 1, + bacon: 1, + }), + {js|πŸ”{πŸ₯¬,πŸ…,πŸ§…,πŸ§€,πŸ₯“}|js}, + ) +); + +test("Burger with 2 or more of onions, cheese, or bacon should show Γ—", () => + expect + |> equal( + Item.Burger.toEmoji({ + lettuce: true, + tomatoes: true, + onions: 2, + cheese: 2, + bacon: 2, + }), + {js|πŸ”{πŸ₯¬,πŸ…,πŸ§…Γ—2,πŸ§€Γ—2,πŸ₯“Γ—2}|js}, + ) +); + +test("Burger with more than 12 toppings should also show bowl emoji", () => { + expect + |> equal( + Item.Burger.toEmoji({ + lettuce: true, + tomatoes: true, + onions: 4, + cheese: 2, + bacon: 5, + }), + {js|πŸ”πŸ₯£{πŸ₯¬,πŸ…,πŸ§…Γ—4,πŸ§€Γ—2,πŸ₯“Γ—5}|js}, + ); + + expect + |> equal( + Item.Burger.toEmoji({ + lettuce: true, + tomatoes: true, + onions: 4, + cheese: 2, + bacon: 4, + }), + {js|πŸ”{πŸ₯¬,πŸ…,πŸ§…Γ—4,πŸ§€Γ—2,πŸ₯“Γ—4}|js}, + ); +}); diff --git a/src/poly-to-normal-variant/Discount.re b/src/poly-to-normal-variant/Discount.re new file mode 100644 index 00000000..fcbac1e4 --- /dev/null +++ b/src/poly-to-normal-variant/Discount.re @@ -0,0 +1,118 @@ +type error = + | InvalidCode + | ExpiredCode; + +/** Buy n burgers, get n/2 burgers free */ +let getFreeBurgers = (items: list(Item.t)) => { + let prices = + items + |> List.filter_map(item => + switch (item) { + | Item.Burger(burger) => Some(Item.Burger.toPrice(burger)) + | Sandwich(_) + | Hotdog => None + } + ); + + switch (prices) { + | [] => Error(`NeedTwoBurgers) + | [_] => Error(`NeedOneBurger) + | prices => + let result = + prices + |> List.sort((x, y) => - Float.compare(x, y)) + |> List.filteri((index, _) => index mod 2 == 1) + |> List.fold_left((+.), 0.0); + Ok(result); + }; +}; + +/** Buy 1+ burger with 1+ of every topping, get half off */ +let getHalfOff = (items: list(Item.t), ~date: Js.Date.t) => { + let meetsCondition = + items + |> List.exists( + fun + | Item.Burger({lettuce: true, tomatoes: true, onions, cheese, bacon}) + when onions > 0 && cheese > 0 && bacon > 0 => + true + | Burger(_) + | Sandwich(_) + | Hotdog => false, + ); + + switch (meetsCondition) { + | false => Error(`NeedMegaBurger) + | true => + let total = + items + |> ListLabels.fold_left(~init=0.0, ~f=(total, item) => + total +. Item.toPrice(item, ~date) + ); + Ok(total /. 2.0); + }; +}; + +type sandwichTracker = { + portabello: bool, + ham: bool, + unicorn: bool, + turducken: bool, +}; + +/** Buy 1+ of every type of sandwich, get half off */ +let getSandwichHalfOff = (items: list(Item.t), ~date: Js.Date.t) => { + let tracker = + items + |> List.filter_map( + fun + | Item.Sandwich(sandwich) => Some(sandwich) + | Burger(_) + | Hotdog => None, + ) + |> ListLabels.fold_left( + ~init={ + portabello: false, + ham: false, + unicorn: false, + turducken: false, + }, + ~f=(tracker, sandwich: Item.Sandwich.t) => + switch (sandwich) { + | Portabello => {...tracker, portabello: true} + | Ham => {...tracker, ham: true} + | Unicorn => {...tracker, unicorn: true} + | Turducken => {...tracker, turducken: true} + } + ); + + switch (tracker) { + | {portabello: true, ham: true, unicorn: true, turducken: true} => + let total = + items + |> ListLabels.fold_left(~init=0.0, ~f=(total, item) => + total +. Item.toPrice(item, ~date) + ); + Ok(total /. 2.0); + | _ => Error(`MissingSandwichTypes) + }; +}; + +let getDiscountPair = (code, date) => { + let month = date |> Js.Date.getMonth; + let dayOfMonth = date |> Js.Date.getDate; + + switch (code |> Js.String.toUpperCase) { + | "FREE" when month == 4.0 => Ok((`FreeBurgers, getFreeBurgers)) + | "HALF" when month == 4.0 && dayOfMonth == 28.0 => + Ok((`HalfOff, getHalfOff(~date))) + | "HALF" when month == 10.0 && dayOfMonth == 3.0 => + Ok((`SandwichHalfOff, getSandwichHalfOff(~date))) + | "FREE" + | "HALF" => Error(ExpiredCode) + | _ => Error(InvalidCode) + }; +}; + +let getDiscountFunction = (code, date) => + getDiscountPair(code, date) |> Result.map(snd); diff --git a/src/poly-to-normal-variant/DiscountTests.re b/src/poly-to-normal-variant/DiscountTests.re new file mode 100644 index 00000000..05121a8a --- /dev/null +++ b/src/poly-to-normal-variant/DiscountTests.re @@ -0,0 +1,248 @@ +open Fest; + +// 2024 June 3 is a Monday +let june3 = Js.Date.fromString("2024-06-03T00:00"); + +module FreeBurger = { + let getFreeBurgers = Discount.getFreeBurgers; + + let burger: Item.Burger.t = { + lettuce: false, + onions: 0, + cheese: 0, + tomatoes: false, + bacon: 0, + }; + + test("0 burgers, no discount", () => + expect + |> deepEqual( + getFreeBurgers([Hotdog, Sandwich(Ham), Sandwich(Turducken)]), + Error(`NeedTwoBurgers), + ) + ); + + test("1 burger, no discount", () => + expect + |> deepEqual( + getFreeBurgers([Hotdog, Sandwich(Ham), Burger(burger)]), + Error(`NeedOneBurger), + ) + ); + + test("2 burgers of same price, discount", () => + expect + |> deepEqual( + getFreeBurgers([ + Hotdog, + Burger(burger), + Sandwich(Ham), + Burger(burger), + ]), + Ok(15.), + ) + ); + + test("2 burgers of different price, discount of cheaper one", () => + expect + |> deepEqual( + getFreeBurgers([ + Hotdog, + Burger({...burger, tomatoes: true}), // 15.05 + Sandwich(Ham), + Burger({...burger, bacon: 2}) // 16.00 + ]), + Ok(15.05), + ) + ); + + test("3 burgers of different price, return Ok(15.15)", () => + expect + |> deepEqual( + getFreeBurgers([ + Burger(burger), // 15 + Hotdog, + Burger({...burger, tomatoes: true, cheese: 1}), // 15.15 + Sandwich(Ham), + Burger({...burger, bacon: 2}) // 16.00 + ]), + Ok(15.15), + ) + ); + + test("7 burgers, return Ok(46.75)", () => + expect + |> deepEqual( + getFreeBurgers([ + Burger(burger), // 15 + Hotdog, + Burger({...burger, cheese: 5}), // 15.50 + Sandwich(Unicorn), + Burger({...burger, bacon: 4}), // 17.00 + Burger({...burger, tomatoes: true, cheese: 1}), // 15.15 + Sandwich(Ham), + Burger({...burger, bacon: 2}), // 16.00 + Burger({...burger, onions: 6}), // 16.20 + Sandwich(Portabello), + Burger({...burger, tomatoes: true}) // 15.05 + ]), + Ok(46.75), + ) + ); +}; + +module HalfOff = { + test("No burger has 1+ of every topping, return Error(`NeedMegaBurger)", () => + expect + |> deepEqual( + Discount.getHalfOff( + ~date=june3, + [ + Hotdog, + Sandwich(Portabello), + Burger({ + lettuce: true, + tomatoes: true, + cheese: 1, + onions: 1, + bacon: 0, + }), + ], + ), + Error(`NeedMegaBurger), + ) + ); + + test("One burger has 1+ of every topping, return Ok(15.675)", () => + expect + |> deepEqual( + Discount.getHalfOff( + ~date=june3, + [ + Hotdog, + Sandwich(Portabello), + Burger({ + lettuce: true, + tomatoes: true, + cheese: 1, + onions: 1, + bacon: 2, + }), + ], + ), + Ok(15.675), + ) + ); +}; + +module SandwichHalfOff = { + test("Not all sandwiches, return Error", () => + expect + |> deepEqual( + Discount.getSandwichHalfOff( + ~date=june3, + [ + Sandwich(Unicorn), + Hotdog, + Sandwich(Portabello), + Sandwich(Ham), + ], + ), + Error(`MissingSandwichTypes), + ) + ); + + test("All sandwiches, return Ok", () => + expect + |> deepEqual( + Discount.getSandwichHalfOff( + ~date=june3, + [ + Sandwich(Turducken), + Hotdog, + Sandwich(Portabello), + Burger({ + lettuce: true, + tomatoes: true, + cheese: 1, + onions: 1, + bacon: 2, + }), + Sandwich(Unicorn), + Sandwich(Ham), + ], + ), + Ok(70.675), + ) + ); +}; + +module GetDiscount = { + let getDiscountFunction = (code, date) => + Discount.getDiscountPair(code, date) |> Result.map(fst); + + test("Invalid promo code return Error", () => { + let date = Js.Date.make(); + ["", "FREEDOM", "UNICORN", "POO"] + |> List.iter(code => + expect + |> deepEqual(getDiscountFunction(code, date), Error(InvalidCode)) + ); + }); + + test("FREE promo code works in May but not other months", () => { + List.init(12, i => i) + |> List.iter(month => { + let date = + Js.Date.makeWithYMD( + ~year=2024., + ~month=float_of_int(month), + ~date=10., + ); + + expect + |> deepEqual( + getDiscountFunction("FREE", date), + month == 4 ? Ok(`FreeBurgers) : Error(ExpiredCode), + ); + }) + }); + + test( + "HALF promo code returns getHalfOff on May 28 but not other days of May", + () => { + for (dayOfMonth in 1 to 31) { + let date = + Js.Date.makeWithYMD( + ~year=2024., + ~month=4.0, + ~date=float_of_int(dayOfMonth), + ); + + expect + |> deepEqual( + getDiscountFunction("HALF", date), + dayOfMonth == 28 ? Ok(`HalfOff) : Error(ExpiredCode), + ); + } + }); + + test( + "HALF promo code returns getSandwichHalfOff on Nov 3 but not other days of Nov", + () => { + for (dayOfMonth in 1 to 30) { + let date = + Js.Date.makeWithYMD( + ~year=2024., + ~month=10.0, + ~date=float_of_int(dayOfMonth), + ); + + expect + |> deepEqual( + getDiscountFunction("HALF", date), + dayOfMonth == 3 ? Ok(`SandwichHalfOff) : Error(ExpiredCode), + ); + } + }); +}; diff --git a/src/poly-to-normal-variant/Index.re b/src/poly-to-normal-variant/Index.re new file mode 100644 index 00000000..996226b1 --- /dev/null +++ b/src/poly-to-normal-variant/Index.re @@ -0,0 +1,35 @@ +module App = { + let items: Order.t = [ + Sandwich(Portabello), + Sandwich(Unicorn), + Sandwich(Ham), + Sandwich(Turducken), + Hotdog, + Burger({lettuce: true, tomatoes: true, onions: 3, cheese: 2, bacon: 6}), + Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 0, bacon: 0}), + Burger({lettuce: true, tomatoes: false, onions: 1, cheese: 1, bacon: 1}), + Burger({lettuce: false, tomatoes: false, onions: 1, cheese: 0, bacon: 0}), + Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 1, bacon: 0}), + ]; + + [@react.component] + let make = () => { + let date = Js.Date.fromString("2024-05-10T00:00"); + +
    +

    {RR.s("Promo")}

    + +

    {RR.s("Order confirmation")}

    + +
    ; + }; +}; + +let node = ReactDOM.querySelector("#root"); +switch (node) { +| None => + Js.Console.error("Failed to start React: couldn't find the #root element") +| Some(root) => + let root = ReactDOM.Client.createRoot(root); + ReactDOM.Client.render(root, ); +}; diff --git a/src/poly-to-normal-variant/Item.re b/src/poly-to-normal-variant/Item.re new file mode 100644 index 00000000..9a09d9e0 --- /dev/null +++ b/src/poly-to-normal-variant/Item.re @@ -0,0 +1,99 @@ +module Burger = { + type t = { + lettuce: bool, + onions: int, + cheese: int, + tomatoes: bool, + bacon: int, + }; + + let toEmoji = t => { + let multiple = (emoji, count) => + switch (count) { + | 0 => "" + | 1 => emoji + | count => Printf.sprintf({js|%sΓ—%d|js}, emoji, count) + }; + + switch (t) { + | {lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0} => {js|πŸ”|js} + | {lettuce, onions, cheese, tomatoes, bacon} => + let toppingsCount = + (lettuce ? 1 : 0) + (tomatoes ? 1 : 0) + onions + cheese + bacon; + + Printf.sprintf( + {js|πŸ”%s{%s}|js}, + toppingsCount > 12 ? {js|πŸ₯£|js} : "", + [| + lettuce ? {js|πŸ₯¬|js} : "", + tomatoes ? {js|πŸ…|js} : "", + multiple({js|πŸ§…|js}, onions), + multiple({js|πŸ§€|js}, cheese), + multiple({js|πŸ₯“|js}, bacon), + |] + |> Js.Array.filter(~f=str => str != "") + |> Js.Array.join(~sep=","), + ); + }; + }; + + let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { + let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; + + 15. // base cost + +. toppingCost(onions, 0.2) + +. toppingCost(cheese, 0.1) + +. (tomatoes ? 0.05 : 0.0) + +. toppingCost(bacon, 0.5); + }; +}; + +module Sandwich = { + type t = + | Portabello + | Ham + | Unicorn + | Turducken; + + let toPrice = (~date: Js.Date.t, t) => { + let day = date |> Js.Date.getDay |> int_of_float; + + switch (t) { + | Portabello + | Ham => 10. + | Unicorn => 80. + | Turducken when day == 2 => 10. + | Turducken => 20. + }; + }; + + let toEmoji = t => + Printf.sprintf( + {js|πŸ₯ͺ(%s)|js}, + switch (t) { + | Portabello => {js|πŸ„|js} + | Ham => {js|🐷|js} + | Unicorn => {js|πŸ¦„|js} + | Turducken => {js|πŸ¦ƒπŸ¦†πŸ“|js} + }, + ); +}; + +type t = + | Sandwich(Sandwich.t) + | Burger(Burger.t) + | Hotdog; + +let toPrice = (~date: Js.Date.t, t) => { + switch (t) { + | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date) + | Burger(burger) => Burger.toPrice(burger) + | Hotdog => 5. + }; +}; + +let toEmoji = + fun + | Hotdog => {js|🌭|js} + | Burger(burger) => Burger.toEmoji(burger) + | Sandwich(sandwich) => Sandwich.toEmoji(sandwich); diff --git a/src/poly-to-normal-variant/ListSafe.re b/src/poly-to-normal-variant/ListSafe.re new file mode 100644 index 00000000..6eba66a7 --- /dev/null +++ b/src/poly-to-normal-variant/ListSafe.re @@ -0,0 +1,2 @@ +/** Return the nth element encased in Some; if it doesn't exist, return None */ +let nth = (n, list) => n < 0 ? None : List.nth_opt(list, n); diff --git a/src/poly-to-normal-variant/Order.re b/src/poly-to-normal-variant/Order.re new file mode 100644 index 00000000..8f946f3d --- /dev/null +++ b/src/poly-to-normal-variant/Order.re @@ -0,0 +1,61 @@ +type t = list(Item.t); + +module OrderItem = { + module Style = { + let item = [%cx {|border-top: 1px solid lightgray;|}]; + let emoji = [%cx {|font-size: 2em;|}]; + let price = [%cx {|text-align: right;|}]; + }; + + [@react.component] + let make = (~item: Item.t, ~date: Js.Date.t) => + + {item |> Item.toEmoji |> RR.s} + + {item |> Item.toPrice(~date) |> RR.currency} + + ; +}; + +module Style = { + let order = [%cx + {| + border-collapse: collapse; + + td { + padding: 0.5em; + } + |} + ]; + + let total = [%cx + {| + border-top: 1px solid gray; + font-weight: bold; + text-align: right; + |} + ]; +}; + +[@react.component] +let make = (~items: t, ~date: Js.Date.t) => { + let total = + items + |> ListLabels.fold_left(~init=0., ~f=(acc, order) => + acc +. Item.toPrice(order, ~date) + ); + + + + {items + |> List.mapi((index, item) => + + ) + |> RR.list} + + + + + +
    {RR.s("Total")} {total |> RR.currency}
    ; +}; diff --git a/src/poly-to-normal-variant/Promo.re b/src/poly-to-normal-variant/Promo.re new file mode 100644 index 00000000..449ca5c8 --- /dev/null +++ b/src/poly-to-normal-variant/Promo.re @@ -0,0 +1,83 @@ +module Style = { + let form = [%cx {| + display: flex; + flex-direction: column; + |}]; + + let input = [%cx + {| + font-family: monospace; + text-transform: uppercase; + |} + ]; + + let codeError = [%cx {|color: red|}]; + + let discountError = [%cx {|color: purple|}]; +}; + +type discount('a) = + | CodeError(Discount.error) + | Discount(float) + | DiscountError([> ] as 'a) + | NoSubmittedCode; + +[@react.component] +let make = (~items: list(Item.t), ~date: Js.Date.t) => { + let (code, setCode) = RR.useStateValue(""); + let (submittedCode, setSubmittedCode) = RR.useStateValue(None); + + let discount = + switch (submittedCode) { + | None => NoSubmittedCode + | Some(code) => + switch (Discount.getDiscountFunction(code, date)) { + | Error(error) => CodeError(error) + | Ok(discountFunction) => + switch (discountFunction(items)) { + | Error(error) => DiscountError(error) + | Ok(value) => Discount(value) + } + } + }; + +
    { + evt |> React.Event.Form.preventDefault; + setSubmittedCode(Some(code)); + }}> + { + evt |> RR.getValueFromEvent |> setCode; + setSubmittedCode(None); + }} + /> + {switch (discount) { + | NoSubmittedCode => React.null + | Discount(discount) => discount |> Float.neg |> RR.currency + | CodeError(error) => +
    + {let errorType = + switch (error) { + | Discount.InvalidCode => "Invalid" + | ExpiredCode => "Expired" + }; + {j|$errorType promo code|j} |> RR.s} +
    + | DiscountError(code) => + let buyWhat = + switch (code) { + | `NeedOneBurger => "at least 1 more burger" + | `NeedTwoBurgers => "at least 2 burgers" + | `NeedMegaBurger => "a burger with every topping" + | `MissingSandwichTypes => "every sandwich" + }; +
    + {RR.s({j|Buy $buyWhat to enjoy this promotion|j})} +
    ; + }} +
    ; +}; diff --git a/src/poly-to-normal-variant/RR.re b/src/poly-to-normal-variant/RR.re new file mode 100644 index 00000000..5dce9616 --- /dev/null +++ b/src/poly-to-normal-variant/RR.re @@ -0,0 +1,15 @@ +/** Get string value from the given event's target */ +let getValueFromEvent = (evt): string => React.Event.Form.target(evt)##value; + +/** Alias for [React.string] */ +let s = React.string; + +/** Render a list of [React.element]s */ +let list = list => list |> Stdlib.Array.of_list |> React.array; + +/** Render a float as currency */ +let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; + +/** Like [React.useState] but doesn't use callback functions */ +let useStateValue = initial => + React.useReducer((_state, newState) => newState, initial); diff --git a/src/poly-to-normal-variant/SandwichTests.re b/src/poly-to-normal-variant/SandwichTests.re new file mode 100644 index 00000000..abaac03c --- /dev/null +++ b/src/poly-to-normal-variant/SandwichTests.re @@ -0,0 +1,43 @@ +open Fest; + +test("Item.Sandwich.toEmoji", () => { + expect + |> deepEqual( + [|Portabello, Ham, Unicorn, Turducken|] + |> Js.Array.map(~f=Item.Sandwich.toEmoji), + [| + {js|πŸ₯ͺ(πŸ„)|js}, + {js|πŸ₯ͺ(🐷)|js}, + {js|πŸ₯ͺ(πŸ¦„)|js}, + {js|πŸ₯ͺ(πŸ¦ƒπŸ¦†πŸ“)|js}, + |], + ) +}); + +test("Item.Sandwich.toPrice", () => { + // 14 Feb 2024 is a Wednesday + let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.); + + expect + |> deepEqual( + [|Portabello, Ham, Unicorn, Turducken|] + |> Js.Array.map(~f=Item.Sandwich.toPrice(~date)), + [|10., 10., 80., 20.|], + ); +}); + +test("Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays", () => { + // Make an array of all dates in a single week; 1 Jan 2024 is a Monday + let dates = + [|1., 2., 3., 4., 5., 6., 7.|] + |> Js.Array.map(~f=date => + Js.Date.makeWithYMD(~year=2024., ~month=0., ~date) + ); + + expect + |> deepEqual( + dates + |> Js.Array.map(~f=date => Item.Sandwich.toPrice(Turducken, ~date)), + [|20., 10., 20., 20., 20., 20., 20.|], + ); +}); diff --git a/src/poly-to-normal-variant/dune b/src/poly-to-normal-variant/dune new file mode 100644 index 00000000..095fe4de --- /dev/null +++ b/src/poly-to-normal-variant/dune @@ -0,0 +1,11 @@ +(melange.emit + (target output) + (libraries reason-react melange-fest styled-ppx.emotion) + (preprocess + (pps melange.ppx reason-react-ppx styled-ppx)) + (module_systems + (es6 mjs))) + +(cram + (deps + (alias melange))) diff --git a/src/poly-to-normal-variant/index.html b/src/poly-to-normal-variant/index.html new file mode 100644 index 00000000..76a9f379 --- /dev/null +++ b/src/poly-to-normal-variant/index.html @@ -0,0 +1,12 @@ + + + + + + Melange for React Devs + + + +
    + + diff --git a/src/poly-to-normal-variant/tests.t b/src/poly-to-normal-variant/tests.t new file mode 100644 index 00000000..2d9af497 --- /dev/null +++ b/src/poly-to-normal-variant/tests.t @@ -0,0 +1,123 @@ +Sandwich tests + $ node ./output/src/poly-to-normal-variant/SandwichTests.mjs | sed '/duration_ms/d' + TAP version 13 + # Subtest: Item.Sandwich.toEmoji + ok 1 - Item.Sandwich.toEmoji + --- + ... + # Subtest: Item.Sandwich.toPrice + ok 2 - Item.Sandwich.toPrice + --- + ... + # Subtest: Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays + ok 3 - Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays + --- + ... + 1..3 + # tests 3 + # suites 0 + # pass 3 + # fail 0 + # cancelled 0 + # skipped 0 + # todo 0 + +Burger tests + $ node ./output/src/poly-to-normal-variant/BurgerTests.mjs | sed '/duration_ms/d' + TAP version 13 + # Subtest: A fully-loaded burger + ok 1 - A fully-loaded burger + --- + ... + # Subtest: Burger with 0 of onions, cheese, or bacon doesn't show those emoji + ok 2 - Burger with 0 of onions, cheese, or bacon doesn't show those emoji + --- + ... + # Subtest: Burger with 1 of onions, cheese, or bacon should show just the emoji without × + ok 3 - Burger with 1 of onions, cheese, or bacon should show just the emoji without × + --- + ... + # Subtest: Burger with 2 or more of onions, cheese, or bacon should show × + ok 4 - Burger with 2 or more of onions, cheese, or bacon should show × + --- + ... + # Subtest: Burger with more than 12 toppings should also show bowl emoji + ok 5 - Burger with more than 12 toppings should also show bowl emoji + --- + ... + 1..5 + # tests 5 + # suites 0 + # pass 5 + # fail 0 + # cancelled 0 + # skipped 0 + # todo 0 + +Discount tests + $ node ./output/src/poly-to-normal-variant/DiscountTests.mjs | sed '/duration_ms/d' + TAP version 13 + # Subtest: 0 burgers, no discount + ok 1 - 0 burgers, no discount + --- + ... + # Subtest: 1 burger, no discount + ok 2 - 1 burger, no discount + --- + ... + # Subtest: 2 burgers of same price, discount + ok 3 - 2 burgers of same price, discount + --- + ... + # Subtest: 2 burgers of different price, discount of cheaper one + ok 4 - 2 burgers of different price, discount of cheaper one + --- + ... + # Subtest: 3 burgers of different price, return Ok(15.15) + ok 5 - 3 burgers of different price, return Ok(15.15) + --- + ... + # Subtest: 7 burgers, return Ok(46.75) + ok 6 - 7 burgers, return Ok(46.75) + --- + ... + # Subtest: No burger has 1+ of every topping, return Error(`NeedMegaBurger) + ok 7 - No burger has 1+ of every topping, return Error(`NeedMegaBurger) + --- + ... + # Subtest: One burger has 1+ of every topping, return Ok(15.675) + ok 8 - One burger has 1+ of every topping, return Ok(15.675) + --- + ... + # Subtest: Not all sandwiches, return Error + ok 9 - Not all sandwiches, return Error + --- + ... + # Subtest: All sandwiches, return Ok + ok 10 - All sandwiches, return Ok + --- + ... + # Subtest: Invalid promo code return Error + ok 11 - Invalid promo code return Error + --- + ... + # Subtest: FREE promo code works in May but not other months + ok 12 - FREE promo code works in May but not other months + --- + ... + # Subtest: HALF promo code returns getHalfOff on May 28 but not other days of May + ok 13 - HALF promo code returns getHalfOff on May 28 but not other days of May + --- + ... + # Subtest: HALF promo code returns getSandwichHalfOff on Nov 3 but not other days of Nov + ok 14 - HALF promo code returns getSandwichHalfOff on Nov 3 but not other days of Nov + --- + ... + 1..14 + # tests 14 + # suites 0 + # pass 14 + # fail 0 + # cancelled 0 + # skipped 0 + # todo 0 diff --git a/vite.config.mjs b/vite.config.mjs index 81dacdc3..44c60b5a 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -31,6 +31,7 @@ export default defineConfig({ 'discounts-lists': resolve(__dirname, 'src/discounts-lists/index.html'), 'promo-codes': resolve(__dirname, 'src/promo-codes/index.html'), 'promo-component': resolve(__dirname, 'src/promo-component/index.html'), + 'poly-to-normal-variant': resolve(__dirname, 'src/poly-to-normal-variant/index.html'), 'order-with-promo': resolve(__dirname, 'src/order-with-promo/index.html'), }, }, From 110270efa1be9a7cab5cce62c96f01d74aa68a0a Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 13 Aug 2024 23:26:36 -0500 Subject: [PATCH 02/13] Clarify difference between variant constructor and variant tag --- docs/poly-to-normal-variant/index.md | 52 ++++++++++++++++++--------- src/poly-to-normal-variant/index.html | 2 +- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/docs/poly-to-normal-variant/index.md b/docs/poly-to-normal-variant/index.md index ca4fe29f..8bc54ef6 100644 --- a/docs/poly-to-normal-variant/index.md +++ b/docs/poly-to-normal-variant/index.md @@ -16,10 +16,18 @@ expression: The easiest thing to do is to create a new `discount` type and assign it to the above type expression, then delete the `` ` `` from the top-level variant tags -to turn them into variant constructors[^1]: +to turn them into variant constructors: <<< Types.re#bad-discount-type +::: tip + +The alternative forms of a normal variant are called *variant constructors*, +while the forms of a polymorphic variant are called *variant tags* (and always +start with `` ` ``). + +::: + However, this results in a compilation error: <<< type-error.txt @@ -32,19 +40,29 @@ disappears if we simply delete `>`: This fixes the syntax error so that we now have a correctly-defined variant type. -## Refactor `discount` to use normal variant +## Refactor `discount` variable to use normal variant Refactor the `discount` derived variable inside `Promo.make` to use our new -variant type by deleting all occurrences of `` ` ``: +variant type by deleting all occurrences of `` ` `` in the switch expression: <<< Promo.re#discount-variant{3,6,9-10} -You can likewise refactor the switch expression inside the render logic: +Likewise, the render logic can be fixed by removing all the `` ` `` occurrences: <<< Promo.re#discount-render{2-4,13} ## Type constructor and type variable +The current definition of the `discount` type works fine, but it is more verbose +than necessary. In particular, the `DiscountError` constructor's polymorphic +tags don't need to be fully specified: + +<<< Types.re#delete-refinement{4-11} + +The variant tags inside `DiscountError` originate from our `Discount` module, so +they shouldn't be needlessly constrained by the `discount` type defined inside +the `Promo` module. + Change the `discount` type to this: <<< Types.re#type-variable @@ -53,13 +71,8 @@ Now `discount` is a *type constructor* that takes a *type variable* named `'a`. A type constructor is not a fixed type---you can think of it as a function that takes a type and outputs a new type. -The advantage of doing this is that the variant tags inside `DiscountError` are -no longer constrained by our `discount` type. This makes sense because they are -used primarily in the `Discount` module, and if any variant tags are renamed, -added, or deleted, those changes will and should happen in `Discount`. - Using a type variable does not sacrifice type safety, if you hover over the -`discount` variable, you see that its type is: +`discount` variable, you see that its type is now: ```reason discount([> `MissingSandwichTypes @@ -68,8 +81,14 @@ discount([> `MissingSandwichTypes | `NeedTwoBurgers ]) ``` -Based on its usage, OCaml can figure out the exact type of the `discount` -variable and automatically fill in the value of the type variable. +This is essentially the same type as before (other than the `>` symbol, +explained in the next section), just written differently. By looking at the +usage of the `discount` variable, OCaml can infer how to fill in the type +variable and produce the fixed type shown above. + +The advantage of using a type variable for the definition of the +`Promo.discount` type is that when you add, rename, or delete variant tags in +`Discount`, you won't have to make corresponding edits to `Promo.discount`[^1]. ## `>` = "allow more than" @@ -289,10 +308,11 @@ inferred type would be the same as the type annotation above. View [source code](https://github.com/melange-re/melange-for-react-devs/blob/main/src/poly-to-normal-variant/) -and [demo](https://react-book.melange.re/demo/src/poly-to-normal-variant/) for this chapter. +and [demo](https://react-book.melange.re/demo/src/poly-to-normal-variant/) for +this chapter. ----- -[^1]: In OCaml terminology, variant tags start with `` ` `` and correspond to - polymorphic variant types, while variant constructors correspond to normal - variant types. +[^1]: Admittedly, this is a small advantage, because you still must handle any + changes to `Discount`'s variant tags inside the `Promo.make` function's + switch expression. diff --git a/src/poly-to-normal-variant/index.html b/src/poly-to-normal-variant/index.html index 76a9f379..059ecfaa 100644 --- a/src/poly-to-normal-variant/index.html +++ b/src/poly-to-normal-variant/index.html @@ -4,7 +4,7 @@ Melange for React Devs - +
    From b74482162fcd29f124d2d3bb428a5256489d14b6 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Thu, 15 Aug 2024 10:29:39 -0500 Subject: [PATCH 03/13] Delete type material from 'order with promo' chapter --- docs/order-with-promo/Types.re | 102 -------------- docs/order-with-promo/index.md | 193 ++++----------------------- docs/order-with-promo/type-error.txt | 7 - 3 files changed, 26 insertions(+), 276 deletions(-) delete mode 100644 docs/order-with-promo/Types.re delete mode 100644 docs/order-with-promo/type-error.txt diff --git a/docs/order-with-promo/Types.re b/docs/order-with-promo/Types.re deleted file mode 100644 index 682b3c76..00000000 --- a/docs/order-with-promo/Types.re +++ /dev/null @@ -1,102 +0,0 @@ -/** - -// #region inferred-type -[> `CodeError(Discount.error) - | `Discount(float) - | `DiscountError([> `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers ]) - | `NoSubmittedCode ] -// #endregion inferred-type - - */ - -/** - -// #region bad-discount-type -type discount = - | CodeError(Discount.error) - | Discount(float) - | DiscountError( - [> - | `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers - ], - ) - | NoSubmittedCode; -// #endregion bad-discount-type - -*/ - -// #region delete-refinement -type discount = - | CodeError(Discount.error) - | Discount(float) - | DiscountError( - [ - | `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers - ], - ) - | NoSubmittedCode; -// #endregion delete-refinement - -module TypeVar = { - // #region type-variable - type discount('a) = - | CodeError(Discount.error) - | Discount(float) - | DiscountError('a) - | NoSubmittedCode; - // #endregion type-variable -}; - -/** - -// #region explicit-type-var -type discount = - | CodeError(Discount.error) - | Discount(float) - | DiscountError( - [> - | `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers - ] as 'a, - ) - | NoSubmittedCode; -// #endregion explicit-type-var - -*/ -module AddTypeArg = { - // #region add-type-arg - type discount('a) = - | CodeError(Discount.error) - | Discount(float) - | DiscountError( - [> - | `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers - ] as 'a, - ) - | NoSubmittedCode; - // #endregion add-type-arg -}; - -module MustBePoly = { - // #region must-be-poly - type discount('a) = - | CodeError(Discount.error) - | Discount(float) - | DiscountError([> ] as 'a) - | NoSubmittedCode; - // #endregion must-be-poly -}; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 09b7faed..8633269f 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -4,170 +4,36 @@ Now that you have a `Promo` component, you can add it to your `Order` component. With that in place, customers can finally enter promo codes and enjoy discounts on their orders. -## Add `discount` type - -But first, let's see how to create a normal variant type for the `discount` -derived variable inside `Promo`. We do not have to do this, because it works fine -the way it is now, but the process of creating this new type should give us more -insight into OCaml's type system. Additionally, normal variants are better than -polymorphic variants at "documenting" the types that will be used in your -program, since they must always be explicitly defined before you can use them. - -When we hover over the `discount` variable, we see this type expression: - -<<< Types.re#inferred-type - -The easiest thing to do is to create a new `discount` type and assign it to the -above type expression, then delete the `` ` `` from the top-level variant tags -to turn them into variant constructors[^1]: - -<<< Types.re#bad-discount-type - -However, this results in a compilation error: - -<<< type-error.txt - -We'll come back to this error message later. For now, observe that the error -disappears if we simply delete `>`: - -<<< Types.re#delete-refinement - -This fixes the syntax error so that we now have a correctly-defined variant -type. - -## Refactor `discount` - -Refactor the `discount` derived variable inside `Promo.make` to use our new -variant type by deleting all occurrences of `` ` ``: - -<<< Promo.re#discount-variant - -You can likewise refactor the switch expression inside the render logic: - -<<< Promo.re#discount-render - -## Type constructor and type variable - -Change the `discount` type to this: - -<<< Types.re#type-variable - -Now `discount` is a *type constructor* that takes a *type variable* named `'a`. -A type constructor is not a fixed type---you can think of it as a function that -takes a type and outputs a new type. - -The advantage of doing this is that the variant tags inside `DiscountError` are -no longer constrained by our `discount` type. This makes sense because they are -used primarily in the `Discount` module, and if any variant tags are renamed, -added, or deleted, those changes will and should happen in `Discount`. - -Using a type variable does not sacrifice type safety, if you hover over the -`discount` variable, you see that its type is: - -```reason -discount([> `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers ]) -``` - -Based on its usage, OCaml can figure out the exact type of the `discount` -variable and automatically fill in the value of the type variable. - -## `>` = "allow more than" - -In the type expression above, we once again see `>`, so let's see what it means. -In polymorphic variant type expressions, it means "allow more than". In this -case, it means that tags other than the four that are listed are allowed. For -example, this type would be allowed: - -```reason{5-6} -discount([| `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers - | `HoneyButter - | `KewpieMayo ]) -``` - -When defining your own types, you will most often used *fixed* polymormorphic -variants, i.e. those that don't have `>` in their type expressions. But it is -still useful to know what `>` does, since it appears when the compiler -infers the type of a variable or function that uses polymorphic variants. - -::: tip - -Fixed polymorphic variants and normal variants are roughly equivalent and can be -used interchangeably. - -::: - -## Implicit type variable - -Let's come back to the question of why the original attempt at a variant type -definition was syntactically invalid: - -<<< Types.re#bad-discount-type - -The reason is that there's an implicit type variable around the `>`. The -above type expression is equivalent to: - -<<< Types.re#explicit-type-var{14} - -Now the error message makes a bit more sense: - -<<< type-error.txt{7} - -The type variable exists, but it's pointless unless it appears as an argument of -the `discount` type constructor. Once it's added, it compiles: - -<<< Types.re#add-type-arg{1} - -This is somewhat like accidentally using a variable in a function but forgetting -to add that variable to the function's argument list. - -## Force `DiscountError` argument to be polymorphic variant - -Right now the argument of the `DiscountError` constructor can be any type at -all, but to be explicit, we can force it to be a polymorphic variant: - -<<< Types.re#must-be-poly{4} - -The `[> ]` type expression means a polymorphic variant that has no tags, but -allows more tags, which basically means any polymorphic variant. Note that -adding this small restriction to the type doesn't make a real difference in this -program---it's just a way to make it clear that `DiscountError`'s argument -should be a polymorphic variant. It's an optional embellishment that you can -feel free to leave out. - -## Quick summary - -You refactored the `discount` reactive variable to use a normal variant instead -of a polymorphic variant. The code changes were fairly minimal, but to -understand what was happening, it was necessary to learn the basics of type -constructors and type variables. In the next sections, we'll set types and other -theoretical considerations aside and get into the nitty-gritty of the UI changes -you must make to add promo support to the `Order` component. - ## Add `DateInput` component -To see different promotions in action, we want to be able to easily change the -date in our demo, so add a new file `DateInput.re`: +We need a widget to interactively change the date in `Demo` because different +promos are active during different periods of time. Add a new file +`DateInput.re`: <<< DateInput.re{7,16} A few notes: - We use `Printf.sprintf` to give us more control over how the `float` - components of a Date[^2] are converted to strings: + components of a Date[^1] are converted to strings: - The [float conversion specification](https://melange.re/v4.0.0/api/re/melange/Stdlib/Printf/index.html#val-fprintf) - `%4.0f` sets a minimum width of 4 and 0 numbers after the decimal + `%4.0f` sets a minimum width of 4, with 0 numbers after the decimal - The float conversion specification `%02.0f` sets a minimum width of 2 - (left padded with 0) and 0 numbers after the decimal + (left padded with 0), with 0 numbers after the decimal - The `type` prop of `input` has been renamed to `type_`, because in OCaml, - `type` is a reserved keyword and can't be used as an argument name. But don't - worry, it will still say `type` in the generated JS output. + `type` is a reserved keyword and can't be used as an identifier. + +::: tip + +Even though the prop `type` has been renamed to `type_`, the [generated JS +output will say `type`](https://melange.re/v4.0.0/playground/?language=Reason&code=W0ByZWFjdC5jb21wb25lbnRdCmxldCBtYWtlID0gKCkgPT4gPGlucHV0IHR5cGVfPSJkYXRlIiAvPjs%3D&live=off). + +Some other prop names also clash with OCaml reserved keywords and must be +suffixed with an underscore, such as: `as_`, `open_`, `begin_`, `end_`, `in_`, +and `to_`. + +::: ## Add `Demo` component @@ -224,7 +90,7 @@ which essentially means "do nothing". hook](https://react.dev/reference/react/useEffect). The number `1` at the end of the function indicates how many dependencies this function is supposed to take. Accordingly, we also have `React.useEffect0`, `React.useEffect2`, etc, all the -way up to `React.useEffect7`[^3]. +way up to `React.useEffect7`[^2]. All `React.useEffect*` functions accept a [setup callback](https://react.dev/reference/react/useEffect#reference) as their first @@ -349,7 +215,7 @@ The easiest fix is to simply change the dependency to `submittedCode` instead of This does the trick---the Effect only runs once every time you submit a new promo code. But wait! Why does it behave differently when `submittedCode` is an -`option`, and `option` is just another variant type?[^4] +`option`, and `option` is just another variant type?[^3] Although `option` is a variant type, its [runtime representation is a special case](../burger-discounts/#runtime-representation-of-option): @@ -430,9 +296,9 @@ the next chapter, we'll further polish the sandwich promotion logic. variables and functions) - Inferred type definitions that contain `>` also have an implicit type variable -- Some component props have names that aren't legal as function arguments in - OCaml, and we must add an underscore after them. A common example is `type`, - which must be rewritten as `type_`[^5]. +- Some component props have names that aren't legal as identifiers in OCaml, and + we must add an underscore after them, e.g. `type`, which must be rewritten as + `type_`. - ReasonReact has several binding functions for React's `useEffect` hook, e.g. `React.useEffect0`, `React.useEffect1`, ...., `React.useEffect7` - The number at the end indicates how many dependencies the function takes @@ -668,24 +534,17 @@ and [demo](https://react-book.melange.re/demo/src/order-with-promo/) for this ch ----- -[^1]: In OCaml terminology, variant tags start with `` ` `` and correspond to - polymorphic variant types, while variant constructors correspond to normal - variant types. - -[^2]: It might be a little confusing that `Js.Date.get*` functions all return +[^1]: It might be a little confusing that `Js.Date.get*` functions all return `float` instead of `int`. The reason is that these functions [must return `NaN` if the input Date is invalid](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/getFullYear#return_value), and in OCaml, only `float` is capable of representing [`NaN`](https://melange.re/v4.0.0/api/re/melange/Js/Float/#val-_NaN). -[^3]: If you happen to need more than 7 dependencies, you can define your own +[^2]: If you happen to need more than 7 dependencies, you can define your own binding function based on the [current binding functions](https://github.com/reasonml/reason-react/blob/713ab6cdb1644fb44e2c0c8fdcbef31007b37b8d/src/React.rei#L248-L255). We'll cover bindings in more detail [later](/todo). -[^4]: Recall that variant constructors with arguments also get turned into +[^3]: Recall that variant constructors with arguments also get turned into objects in the JS runtime. - -[^5]: Some other prop names which cannot be used in their original form are: - `as_`, `open_`, `begin_`, `end_`, `in_`, and `to_`. diff --git a/docs/order-with-promo/type-error.txt b/docs/order-with-promo/type-error.txt deleted file mode 100644 index 317e4734..00000000 --- a/docs/order-with-promo/type-error.txt +++ /dev/null @@ -1,7 +0,0 @@ -Error: A type variable is unbound in this type declaration. - In case - DiscountError of ([> `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers ] - as 'a) the variable 'a is unbound From de44437d0910fe5d5dd4e2ac1173b8667982ce3a Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Thu, 15 Aug 2024 12:06:30 -0500 Subject: [PATCH 04/13] Add exercise 4 in 'from polymorphic to normal variant' chapter --- docs/poly-to-normal-variant/Promo.re | 34 +++++++++++++++++++ docs/poly-to-normal-variant/index.md | 31 ++++++++++++++++-- src/order-with-promo/Promo.re | 49 +++++++++++++--------------- src/poly-to-normal-variant/Promo.re | 2 +- 4 files changed, 86 insertions(+), 30 deletions(-) diff --git a/docs/poly-to-normal-variant/Promo.re b/docs/poly-to-normal-variant/Promo.re index e67db7b3..5a2b5b29 100644 --- a/docs/poly-to-normal-variant/Promo.re +++ b/docs/poly-to-normal-variant/Promo.re @@ -65,3 +65,37 @@ let _ = // #endregion discount-render ; }; + +let _ = + (submittedCode, date, items) => { + // #region when-solution + let discount = + switch (submittedCode) { + | None => NoSubmittedCode + | Some(code) when Js.String.trim(code) == "" => NoSubmittedCode + | Some(code) => + switch (Discount.getDiscountFunction(code, date)) { + | Error(error) => CodeError(error) + | Ok(discountFunction) => + switch (discountFunction(items)) { + | Error(error) => DiscountError(error) + | Ok(value) => Discount(value) + } + } + }; + // #endregion when-solution + + ignore(discount); + }; + +let _ = + (code, setSubmittedCode) => { + // #region onsubmit-solution +
    { + evt |> React.Event.Form.preventDefault; + setSubmittedCode(Js.String.trim(code) == "" ? None : Some(code)); + }}> + // #endregion onsubmit-solution + {RR.s("")}
    ; + }; diff --git a/docs/poly-to-normal-variant/index.md b/docs/poly-to-normal-variant/index.md index 8bc54ef6..85a33ca5 100644 --- a/docs/poly-to-normal-variant/index.md +++ b/docs/poly-to-normal-variant/index.md @@ -226,7 +226,7 @@ require a space between `[` and `|`. doesn’t compile. Fix it by adding a single character. ```reason -let getName = (animal: [| `Cat | `Dog(int) | `Unicorn(string)]) => +let getName = (animal: [ | `Cat | `Dog(int) | `Unicorn(string)]) => switch (animal) { | `Cat => "Mr Whiskers" | `Dog(n) => "Bandit " ++ string_of_int(n) @@ -283,7 +283,7 @@ Delete the type annotation. ::: details Solution -```reason +```reason{2} /** Only invoke [f] when [o1] and [o2] are [Some] */ let map2: (option('a), option('b), ('a, 'b) => 'c) => option('c) = (o1, o2, f) => @@ -304,6 +304,33 @@ inferred type would be the same as the type annotation above. ::: +4. When you enter a promo code that is blank (empty or consists of only +spaces) and hit Enter, it will still show the β€œInvalid promo code” error. Fix it +so that it shows no error in this case. + +::: details Hint + +Use +[Js.String.trim](https://melange.re/v4.0.0/api/re/melange/Js/String/#val-trim) +to help you check whether a string is blank. + +::: + +::: details Solution + +One way to do it is to add a new branch with a `when` guard in `Promo.make`'s +first switch expression: + +<<< Promo.re#when-solution{4} + +A better solution is to modify the `form` element's `onSubmit` callback: + +<<< Promo.re#onsubmit-solution{4} + +The second solution is simpler and more direct, but both work. + +::: + ----- View [source diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index 30fd3be3..b3f55007 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -23,45 +23,42 @@ type discount('a) = | NoSubmittedCode; [@react.component] -let make = (~items: list(Item.t), ~date: Js.Date.t, ~onApply: float => unit) => { +let make = (~items: list(Item.t), ~date: Js.Date.t) => { let (code, setCode) = RR.useStateValue(""); let (submittedCode, setSubmittedCode) = RR.useStateValue(None); - let getDiscount = - fun - | None => `NoSubmittedCode + let discount = + switch (submittedCode) { + | None => NoSubmittedCode | Some(code) => switch (Discount.getDiscountFunction(code, date)) { - | Error(error) => `CodeError(error) - | Ok(discountFunc) => - switch (discountFunc(items)) { - | Error(error) => `DiscountError(error) - | Ok(value) => `Discount(value) + | Error(error) => CodeError(error) + | Ok(discountFunction) => + switch (discountFunction(items)) { + | Error(error) => DiscountError(error) + | Ok(value) => Discount(value) } - }; + } + };
    { evt |> React.Event.Form.preventDefault; - let newSubmittedCode = Some(code); - setSubmittedCode(newSubmittedCode); - switch (getDiscount(newSubmittedCode)) { - | `NoSubmittedCode - | `CodeError(_) - | `DiscountError(_) => () - | `Discount(value) => onApply(value) - }; + setSubmittedCode(Js.String.trim(code) == "" ? None : Some(code)); }}> {evt |> RR.getValueFromEvent |> setCode}} + onChange={evt => { + evt |> RR.getValueFromEvent |> setCode; + setSubmittedCode(None); + }} /> - {switch (getDiscount(submittedCode)) { - | `NoSubmittedCode => React.null - | `Discount(discount) => discount |> Float.neg |> RR.currency - | `CodeError(error) => + {switch (discount) { + | NoSubmittedCode => React.null + | Discount(discount) => discount |> Float.neg |> RR.currency + | CodeError(error) =>
    {let errorType = switch (error) { @@ -70,15 +67,13 @@ let make = (~items: list(Item.t), ~date: Js.Date.t, ~onApply: float => unit) => }; {j|$errorType promo code|j} |> RR.s}
    - | `DiscountError(code) => + | DiscountError(code) => let buyWhat = switch (code) { | `NeedOneBurger => "at least 1 more burger" | `NeedTwoBurgers => "at least 2 burgers" | `NeedMegaBurger => "a burger with every topping" - | `MissingSandwichTypes(missing) => - (missing |> Stdlib.Array.of_list |> Js.Array.join(~sep=", ")) - ++ " sandwiches" + | `MissingSandwichTypes => "every sandwich" };
    {RR.s({j|Buy $buyWhat to enjoy this promotion|j})} diff --git a/src/poly-to-normal-variant/Promo.re b/src/poly-to-normal-variant/Promo.re index 449ca5c8..b3f55007 100644 --- a/src/poly-to-normal-variant/Promo.re +++ b/src/poly-to-normal-variant/Promo.re @@ -45,7 +45,7 @@ let make = (~items: list(Item.t), ~date: Js.Date.t) => { className=Style.form onSubmit={evt => { evt |> React.Event.Form.preventDefault; - setSubmittedCode(Some(code)); + setSubmittedCode(Js.String.trim(code) == "" ? None : Some(code)); }}> Date: Wed, 4 Sep 2024 10:55:31 -0500 Subject: [PATCH 05/13] Remove effects material --- docs/order-with-promo/{Demo.re => App.re} | 50 +-- docs/order-with-promo/Index.re | 8 - docs/order-with-promo/Promo.re | 181 +++------ docs/order-with-promo/RR.re | 5 - docs/order-with-promo/index.md | 440 +++++----------------- src/order-with-promo/DateInput.re | 21 -- src/order-with-promo/Demo.re | 85 ----- src/order-with-promo/Discount.re | 11 +- src/order-with-promo/DiscountTests.re | 2 +- src/order-with-promo/Index.re | 29 +- src/order-with-promo/Order.re | 10 +- src/order-with-promo/Promo.re | 18 +- src/order-with-promo/RR.re | 3 - 13 files changed, 209 insertions(+), 654 deletions(-) rename docs/order-with-promo/{Demo.re => App.re} (78%) delete mode 100644 docs/order-with-promo/Index.re delete mode 100644 src/order-with-promo/DateInput.re delete mode 100644 src/order-with-promo/Demo.re diff --git a/docs/order-with-promo/Demo.re b/docs/order-with-promo/App.re similarity index 78% rename from docs/order-with-promo/Demo.re rename to docs/order-with-promo/App.re index c00259c8..7a9360ab 100644 --- a/docs/order-with-promo/Demo.re +++ b/docs/order-with-promo/App.re @@ -1,31 +1,3 @@ -// #region initial -let items: Order.t = [ - Sandwich(Portabello), - Sandwich(Unicorn), - Sandwich(Ham), - Sandwich(Turducken), - Hotdog, - Burger({lettuce: true, tomatoes: true, onions: 3, cheese: 2, bacon: 6}), - Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 0, bacon: 0}), - Burger({lettuce: true, tomatoes: false, onions: 1, cheese: 1, bacon: 1}), - Burger({lettuce: false, tomatoes: false, onions: 1, cheese: 0, bacon: 0}), - Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 1, bacon: 0}), -]; - -[@react.component] -let make = () => { - let (date, setDate) = - RR.useStateValue(Js.Date.fromString("2024-05-28T00:00")); - -
    -

    {RR.s("Order confirmation")}

    - -

    {RR.s("Order")}

    - -
    ; -}; -// #endregion initial - // #region datasets let burger = Item.Burger.{ @@ -117,10 +89,27 @@ let datasets': list((string, list(Item.t))) = [ // #endregion burger-expression ]; */ -ignore(make); // #region refactor [@react.component] +let make = () => { + let date = Js.Date.fromString("2024-05-28T00:00"); + +
    +

    {RR.s("Order Confirmation")}

    + {datasets + |> List.map(((label, items)) => { +

    {RR.s(label)}

    + }) + |> RR.list} +
    ; +}; +// #endregion refactor + +ignore(make); + +// #region add-date-input +[@react.component] let make = () => { let (date, setDate) = RR.useStateValue(Js.Date.fromString("2024-05-28T00:00")); @@ -128,7 +117,6 @@ let make = () => {

    {RR.s("Order Confirmation")}

    -

    {RR.s("Order")}

    {datasets |> List.map(((label, items)) => {

    {RR.s(label)}

    @@ -136,7 +124,7 @@ let make = () => { |> RR.list}
    ; }; -// #endregion refactor +// #endregion add-date-input ignore(make); diff --git a/docs/order-with-promo/Index.re b/docs/order-with-promo/Index.re deleted file mode 100644 index 71fe02d4..00000000 --- a/docs/order-with-promo/Index.re +++ /dev/null @@ -1,8 +0,0 @@ -let node = ReactDOM.querySelector("#root"); -switch (node) { -| None => - Js.Console.error("Failed to start React: couldn't find the #root element") -| Some(root) => - let root = ReactDOM.Client.createRoot(root); - ReactDOM.Client.render(root, ); -}; diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re index 74a0f952..315ecc35 100644 --- a/docs/order-with-promo/Promo.re +++ b/docs/order-with-promo/Promo.re @@ -8,64 +8,12 @@ type discount('a) = | DiscountError('a) | NoSubmittedCode; -let _ = - (submittedCode, date, items) => { - // #region discount-variant - let discount = - switch (submittedCode) { - | None => NoSubmittedCode - | Some(code) => - switch (Discount.getDiscountFunction(code, date)) { - | Error(error) => CodeError(error) - | Ok(discountFunction) => - switch (discountFunction(items)) { - | Error(error) => DiscountError(error) - | Ok(value) => Discount(value) - } - } - }; - // #endregion discount-variant - - ignore(discount); - }; - module Style = { let codeError = ""; let discountError = ""; + let form = ""; }; -let _ = - discount => { - <> - // #region discount-render - {switch (discount) { - | NoSubmittedCode => React.null - | Discount(discount) => discount |> Float.neg |> RR.currency - | CodeError(error) => -
    - {let errorType = - switch (error) { - | Discount.InvalidCode => "Invalid" - | ExpiredCode => "Expired" - }; - {j|$errorType promo code|j} |> RR.s} -
    - | DiscountError(code) => - let buyWhat = - switch (code) { - | `NeedOneBurger => "at least 1 more burger" - | `NeedTwoBurgers => "at least 2 burgers" - | `NeedMegaBurger => "a burger with every topping" - | `MissingSandwichTypes => "every sandwich" - }; -
    - {RR.s({j|Buy $buyWhat to enjoy this promotion|j})} -
    ; - }} - // #endregion discount-render - ; - }; - [@warning "-27"] module AddOnApply = { // #region add-on-apply @@ -77,83 +25,51 @@ module AddOnApply = { }; let _ = - (discount, onApply) => { - // #region use-effect - React.useEffect1( - () => { - switch (discount) { - | NoSubmittedCode - | CodeError(_) - | DiscountError(_) => () - | Discount(value) => onApply(value) - }; - None; - }, - [|discount|], - ); - // #endregion use-effect - (); - }; - -let _ = - (discount, onApply) => { - // #region use-effect-helper - RR.useEffect1( - () => { - switch (discount) { - | NoSubmittedCode - | CodeError(_) - | DiscountError(_) => () - | Discount(value) => onApply(value) - }; - None; - }, - discount, - ); - // #endregion use-effect-helper - (); - }; + (setSubmittedCode, onApply, code, date, items) => { + // #region switch-in-on-submit + { + evt |> React.Event.Form.preventDefault; + let newSubmittedCode = + Js.String.trim(code) == "" ? None : Some(code); + setSubmittedCode(newSubmittedCode); -let _ = - (discount, onApply) => { - // #region log - RR.useEffect1( - () => { - switch (discount) { - | NoSubmittedCode - | CodeError(_) - | DiscountError(_) => () - | Discount(value) => - Js.log2("useEffect1 depending on discount", value); - onApply(value); + switch (newSubmittedCode) { + | None => () + | Some(code) => + switch (Discount.getDiscountFunction(code, date)) { + | Error(_) => () + | Ok(discountFunction) => + switch (discountFunction(items)) { + | Error(_) => () + | Ok(value) => onApply(value) + } + } }; - None; - }, - discount, - ); - // #endregion log - (); + }}> + // #endregion switch-in-on-submit + React.null ; }; let _ = - (discount, submittedCode, onApply) => { - // #region submitted-code-dep - RR.useEffect1( - () => { - switch (discount) { - | NoSubmittedCode - | CodeError(_) - | DiscountError(_) => () - | Discount(value) => - Js.log2("useEffect1 depending on discount", value); - onApply(value); - }; - None; - }, - submittedCode, - ); - // #endregion submitted-code-dep - (); + (submittedCode, date, items) => { + // #region discount-derived-variable + let discount = + switch (submittedCode) { + | None => NoSubmittedCode + | Some(code) => + switch (Discount.getDiscountFunction(code, date)) { + | Error(error) => CodeError(error) + | Ok(discountFunction) => + switch (discountFunction(items)) { + | Error(error) => DiscountError(error) + | Ok(value) => Discount(value) + } + } + }; + // #endregion discount-derived-variable + ignore(discount); }; let _ = @@ -179,7 +95,7 @@ let _ = let _ = (getDiscount, onApply, code, setSubmittedCode) => {
    { evt |> React.Event.Form.preventDefault; let newSubmittedCode = Some(code); @@ -191,10 +107,21 @@ let _ = | Discount(value) => onApply(value) }; }} - // #endregion on-submit + // #endregion on-submit-using-get-discount />; }; +let _ = + (getDiscount, submittedCode) => { + // #region get-discount-in-render + switch (getDiscount(submittedCode)) { + | NoSubmittedCode => React.null + // ... + // #endregion get-discount-in-render + | _ => React.null + }; + }; + let _ = (~thing) => { switch (thing) { diff --git a/docs/order-with-promo/RR.re b/docs/order-with-promo/RR.re index b0cb672f..4e30f558 100644 --- a/docs/order-with-promo/RR.re +++ b/docs/order-with-promo/RR.re @@ -8,8 +8,3 @@ let useStateValue = initial => let list = list => list |> Stdlib.Array.of_list |> React.array; let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; - -// #region use-effect-1 -/** Helper for [React.useEffect1] */ -let useEffect1 = (func, dep) => React.useEffect1(func, [|dep|]); -// #endregion use-effect-1 diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 8633269f..a82f1f64 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -4,58 +4,19 @@ Now that you have a `Promo` component, you can add it to your `Order` component. With that in place, customers can finally enter promo codes and enjoy discounts on their orders. -## Add `DateInput` component - -We need a widget to interactively change the date in `Demo` because different -promos are active during different periods of time. Add a new file -`DateInput.re`: - -<<< DateInput.re{7,16} - -A few notes: - -- We use `Printf.sprintf` to give us more control over how the `float` - components of a Date[^1] are converted to strings: - - The [float conversion - specification](https://melange.re/v4.0.0/api/re/melange/Stdlib/Printf/index.html#val-fprintf) - `%4.0f` sets a minimum width of 4, with 0 numbers after the decimal - - The float conversion specification `%02.0f` sets a minimum width of 2 - (left padded with 0), with 0 numbers after the decimal -- The `type` prop of `input` has been renamed to `type_`, because in OCaml, - `type` is a reserved keyword and can't be used as an identifier. - -::: tip - -Even though the prop `type` has been renamed to `type_`, the [generated JS -output will say `type`](https://melange.re/v4.0.0/playground/?language=Reason&code=W0ByZWFjdC5jb21wb25lbnRdCmxldCBtYWtlID0gKCkgPT4gPGlucHV0IHR5cGVfPSJkYXRlIiAvPjs%3D&live=off). - -Some other prop names also clash with OCaml reserved keywords and must be -suffixed with an underscore, such as: `as_`, `open_`, `begin_`, `end_`, `in_`, -and `to_`. - -::: - -## Add `Demo` component - -Move the contents of `Index.App` into a new file called `Demo.re`. In the -process, add our newly-created `DateInput` component: - -<<< Demo.re#initial{16-17,21} - -Change `Index` to use the new `Demo` component: - -<<< Index.re{7} - ## Add `Promo` to `Order` Add the `Promo` component to the `Order` component: -<<< Order.re#make{3,22-25,28} +<<< Order.re#make{3,5,18-25,28} A breakdown: - Create a new state variable called `discount` (along with its attendant `setDiscount` function) +- Rename the `total` variable to `subtotal` +- Add a new table row labeld "Subtotal" to display the value of `subtotal` +- Add a new table row labeled "Promo" to render the `Promo` component - Set the value of `discount` through `Promo`'s `onApply` callback prop (we'll add this prop in the next step) - Subtract `discount` from `subtotal` when rendering the total price of the @@ -70,106 +31,105 @@ successfully submitted and results in a discount: ::: tip -You don't have to type annotate your component props, but it's a good idea to at -least type annotate your component's callback props as a form of documentation. +You don't have to type annotate all your component props, but it's a good idea +to at least type annotate your component's callback props as a form of +documentation. ::: -To invoke `onApply`, we can add a `useEffect` hook that invokes `onApply` when -`discount` has a value of the form `` `Discount(value) ``: +Add a switch expression to the `form` element's `onSubmit` callback which +invokes `onApply`: -<<< Promo.re#use-effect +<<< Promo.re#switch-in-on-submit{9-20} -Note that when `discount` has an error value, we return -[`()`](https://ocaml.org/docs/basic-data-types#unit) from the switch expression, -which essentially means "do nothing". +The structure of this switch expression closely mirrors the structure of the +switch expression used to define the `discount` derived variable: -## `React.useEffect*` functions +<<< Promo.re#discount-derived-variable -`React.useEffect1` is one of the binding functions for React's [useEffect -hook](https://react.dev/reference/react/useEffect). The number `1` at the end of -the function indicates how many dependencies this function is supposed to take. -Accordingly, we also have `React.useEffect0`, `React.useEffect2`, etc, all the -way up to `React.useEffect7`[^2]. +The branches in the two switch expressions are exactly the same and only the +return values are different. You wonder if there's some way to reduce the +redundancy. There is! You can replace the definition of `discount` with a +`getDiscount` function: -All `React.useEffect*` functions accept a [setup -callback](https://react.dev/reference/react/useEffect#reference) as their first -argument, the type of which is: +<<< Promo.re#get-discount -```reason -unit => option(unit => unit) -``` +Now use `getDiscount` inside the `onSubmit` callback: -The setup callback's return type is `option(unit => unit)`, which allows you to -return a cleanup function encased in `Some`. When the effect doesn't need a -cleanup function, just return `None`. +<<< Promo.re#on-submit-using-get-discount{5-10} -The second argument for all `React.useEffect*` functions (except -`React.useEffect0`) is for the dependencies. For example, the type of -`React.useEffect2` is: +And also use it in the render logic's switch expression: -```reason -(unit => option(unit => unit), ('a, 'b)) => unit -``` +<<< Promo.re#get-discount-in-render{1} -And the type of `React.useEffect3` is: +## Move `App` into its own file -```reason -(unit => option(unit => unit), ('a, 'b, 'c)) => unit -``` +It was OK to keep our `App` component inside of `Index.re` because it was always +very small. But now we'd like to add much more to it, so it makes sense to move +it into its own file. Create a new file called `App.re` and move the contents of +`Index.App` into it. + +To make it easier to see the different promo-related error messages, you can +create different collections of items. Add a `datasets` variable to `App`: + +<<< App.re#datasets + +Since the `burgers` value is only used the "5 burgers" expression, we can move +it inside that expression: + +<<< App.re#burger-expression{2-9} ::: tip -Every time you add or remove a dependency from your `useEffect` hook, you'll -need to use a different `React.useEffect*` function (the one that corresponds to -how many dependencies you now have). +OCaml makes it easy to move variable definitions closer to where they are +actually used. Unlike in JavaScript, you can use `let` anywhere, even inside an +expression. ::: -## Why does `React.useEffect2` accept a tuple? +Now we can refactor `App` to render a different `Order` for each collection of +items: -`React.useEffect2` takes its dependencies as a tuple instead of an array. To -understand why, we need to understand the type properties of tuples and arrays: +<<< App.re#refactor -- The elements of tuples can have different types, e.g. `(1, "a", 23.5)` -- The elements of arrays must all be of the same type, e.g. `[|1, 2, 3|]`, - `[|"a", "b", "c"|]` +You can delete the now-unused `App.items` variable. -Therefore, we must use tuples to express the dependencies of `useEffect` hooks, -otherwise our dependencies would all have to be of the same type. This applies -to all `React.useEffect*` functions which take 2 or more dependencies. -Even though we use tuples for dependencies in our OCaml code, they are turned -into JS arrays in the runtime. So the generated code will run the same as in any -ReactJS app. +## Add `DateInput` component -However, you might have noticed that `React.useEffect1` is the odd man out, -because it accepts an array for its single dependency. The reason is that -one-element OCaml tuples don't become arrays in the JS runtime, they instead -take on the value of their single element. So in this case, `React.useEffect1` -[must take an -array](https://reasonml.github.io/reason-react/docs/en/components#hooks) so that -it respects the API of the underlying `useEffect` function. +Currently, the date in `App` is static, but it would be very convenient to be +able to change it interactively in order to see the behavior of `Promo` on +different dates. Let's add a new file `DateInput.re`: -## `RR.useEffect1` helper function +<<< DateInput.re{7,16} -`React.useEffect1` taking an array means that you could accidentally pass in an -empty array as the dependency for `React.useEffect1`. You can sidestep this -possibility by adding a helper function to your `RR` module: +A few notes: -<<< RR.re#use-effect-1 +- We use `Printf.sprintf` to give us more control over how the `float` + components of a Date[^1] are converted to strings: + - The [float conversion + specification](https://melange.re/v4.0.0/api/re/melange/Stdlib/Printf/index.html#val-fprintf) + `%4.0f` sets a minimum width of 4, with 0 numbers after the decimal + - The float conversion specification `%02.0f` sets a minimum width of 2 + (left padded with 0), with 0 numbers after the decimal +- The `type` prop of `input` has been renamed to `type_`, because in OCaml, + `type` is a reserved keyword and can't be used as an identifier. -Refactor `Promo` to use your new helper function: +::: tip -<<< Promo.re#use-effect-helper{1,11} +Even though the prop `type` has been renamed to `type_` in OCaml, the [generated +JS output will say +`type`](https://melange.re/v4.0.0/playground/?language=Reason&code=W0ByZWFjdC5jb21wb25lbnRdCmxldCBtYWtlID0gKCkgPT4gPGlucHV0IHR5cGVfPSJkYXRlIiAvPjs%3D&live=off). -You may be wondering why ReasonReact doesn't provide this helper function for -you. The reason is that its bindings to React functions are supposed to be -zero-cost, without any additional abstractions on top. This is the same reason -that something like our `RR.useStateValue` helper function is also not included -with ReasonReact. +Some other prop names also clash with OCaml reserved keywords and must be +suffixed with an underscore, such as: `as_`, `open_`, `begin_`, `end_`, `in_`, +and `to_`. -## Add styling for promo code row +::: + +Add `DateInput` to `App.make`: + +<<< App.re#add-date-input{3-4,8} Execute `npm run serve` to see your app in action. Verify that it behaves as expected: @@ -181,8 +141,9 @@ expected: - Change the date to something other than May 28. It should show an error saying "Expired promo code" -However, the styling is a little bit off. Add the following value to -`Order.Style`: +## Add styling for promo code row + +The styling is a little bit off. Add the following value to `Order.Style`: <<< Order.re#promo-class @@ -190,282 +151,67 @@ Then set the `className` of the promo code row to `Style.promo`: <<< Order.re#set-promo-class{1} -## How often does the Effect run? - -Everything seems to be working correctly, but let's see how often our -`useEffect` hook fires by adding a little logging: - -<<< Promo.re#log{8} - -You see that every time a promo code is successfully applied, it logs twice to -the console. That doesn't seem right, because the value of `discount` only -changes once when you submit a new promo code. - -The reason lies in the runtime representation of `discount`---recall that -variant constructors with arguments are turned into objects in the JS runtime. -Because `discount` is a derived variable, it gets recreated on every render, and -even if its contents didn't change, the [hook will always treat it as having -changed because the object is no longer the same one as -before](https://react.dev/reference/react/useEffect#removing-unnecessary-object-dependencies). - -The easiest fix is to simply change the dependency to `submittedCode` instead of -`discount`: - -<<< Promo.re#submitted-code-dep{13} - -This does the trick---the Effect only runs once every time you submit a new -promo code. But wait! Why does it behave differently when `submittedCode` is an -`option`, and `option` is just another variant type?[^3] - -Although `option` is a variant type, its [runtime representation is a special -case](../burger-discounts/#runtime-representation-of-option): - -- `None` becomes `undefined` -- `Some(value)` becomes `value` - -Therefore, an `option` value that wraps a primitive value doesn't ever turn into -an object in the JS runtime, and therefore can be used as a dependency for React -hooks. - -## You don't need an Effect - -The above discussion about Effects was somewhat academic, because we [don't -actually need Effects to handle user -events](https://react.dev/learn/you-might-not-need-an-effect#how-to-remove-unnecessary-effects). -Let's delete the call to `RR.useEffect1` and start over. - -A better place to call `onApply` is from within the `form`'s `onSubmit` -callback. Replace the `discount` derived variable with a `getDiscount` function: - -<<< Promo.re#get-discount - -Call `getDiscount` within the `onSubmit` callback function: - -<<< Promo.re#on-submit - -Inside the render logic, change the input of the switch expression from -`discount` to `getDiscount(submittedCode)`: - -```reason -{switch (discount) { // [!code --] -{switch (getDiscount(submittedCode)) { // [!code ++] -``` - -## Add `datasets` to `Demo` - -To make it easier to see the different promo-related error messages, you can -create different collections of items. Add a `datasets` variable to `Demo`: - -<<< Demo.re#datasets - -Since the `burgers` value is only used in a single expression, we can move it -inside that expression: - -<<< Demo.re#burger-expression{2-9} - -::: tip - -OCaml makes it easy to move variable definitions closer to where they are -actually used. Unlike in JavaScript, you can use `let` anywhere, even inside an -expression. - -::: - -Now we can refactor `Demo` to render a different `Order` for each collection of -items: - -<<< Demo.re#refactor - -Remember to delete the now-unused `Demo.items` variable. - --- Hot diggity! You've added the promo codes to your order confirmation widget, just in time for Madame Jellobutter's International Burger Day promotions. In -the next chapter, we'll further polish the sandwich promotion logic. +the next chapter, we'll start writing a completely new app. ## Overview -- A type constructor takes a type and outputs another type -- A type variable is a variable that stands in a for a type and often appears in - type constructors or type signatures -- In polymorphic variant type expressions, `>` means that the polymorphic - variant can accept more than the variant tags that are listed - - You rarely need to use `>` in your own type definitions, but it often - appears in inferred type definitions (that appear when you hover over - variables and functions) - - Inferred type definitions that contain `>` also have an implicit type - variable - Some component props have names that aren't legal as identifiers in OCaml, and we must add an underscore after them, e.g. `type`, which must be rewritten as `type_`. -- ReasonReact has several binding functions for React's `useEffect` hook, e.g. - `React.useEffect0`, `React.useEffect1`, ...., `React.useEffect7` - - The number at the end indicates how many dependencies the function takes - - `React.useEffect1` takes an array for its one dependency - - `React.useEffect2` and above take tuples for their dependencies -- The elements of a tuple can be different types -- Tuples become arrays in the JavaScript runtime -- The elements of an array must all be the same type -- Be careful about using variants as hook dependencies, because they often get - turned into objects in the runtime and cause Effects to run more often than - you want -- It's often safe to use `option` as a hook dependency, because even though it's - a variant, it's a special case and does not become an object in the JavaScript - runtime - You can use `let` inside expressions, which allows you to define variables closer to where they're used ## Exercises -1. The following code -([playground](https://melange.re/v4.0.0/playground/?language=Reason&code=bGV0IGdldE5hbWUgPSAoYW5pbWFsOiBbfCBgQ2F0IHwgYERvZyhpbnQpIHwgYFVuaWNvcm4oc3RyaW5nKV0pID0%2BCiAgc3dpdGNoIChhbmltYWwpIHsKICB8IGBDYXQgPT4gIk1yIFdoaXNrZXJzIgogIHwgYERvZyhuKSA9PiAiQmFuZGl0ICIgKysgc3RyaW5nX29mX2ludChuKQogIHwgYFVuaWNvcm4obmFtZSkgPT4gIlNpciAiICsrIG5hbWUKICB9Ow%3D%3D&live=off)) -doesn’t compile. Fix it by adding a single character. - -```reason -let getName = (animal: [| `Cat | `Dog(int) | `Unicorn(string)]) => - switch (animal) { - | `Cat => "Mr Whiskers" - | `Dog(n) => "Bandit " ++ string_of_int(n) - | `Unicorn(name) => "Sir " ++ name - }; -``` +1. Change the render logic so that a `DateInput` is rendered above each +`Order`. Changing the date on a `DateInput` changes the date for the `Order` +below it, but doesn't affect the dates of the other `Order`s. ::: details Hint -Find a place where you can insert a space. +Define a `DateAndOrder` helper component. ::: ::: details Solution -```reason{1} -let getName = (animal: [ | `Cat | `Dog(int) | `Unicorn(string)]) => - switch (animal) { - | `Cat => "Mr Whiskers" - | `Dog(n) => "Bandit " ++ string_of_int(n) - | `Unicorn(name) => "Sir " ++ name - }; -``` +Add `App.DateAndOrder` subcomponent: -A common mistake when writing polymorphic variant type definitions is forgetting -to put a space between the `[` and the `|` characters. Note that you don't need -to add the implicit type variable in type annotations. +<<< App.re#date-and-order -::: warning +Then refactor `App.make` to use the new component: -In the next version of Melange, polymorphic variant definitions no longer -require a space between `[` and `|`. +<<< App.re#make ::: -2. The following code -([playground](https://melange.re/v4.0.0/playground/?language=Reason&code=bGV0IGdldE5hbWUgPSAoYW5pbWFsOiBbIHwgYENhdCB8IGBEb2coaW50KSB8IGBVbmljb3JuKHN0cmluZyldKSA9PgogIHN3aXRjaCAoYW5pbWFsKSB7CiAgfCBgQ2F0ID0%2BICJNciBXaGlza2VycyIKICB8IGBEb2cobikgPT4gIkJhbmRpdCAiICsrIHN0cmluZ19vZl9pbnQobikKICB8IGBVbmljb3JuKG5hbWUpID0%2BICJTaXIgIiArKyBuYW1lCiAgfCBgRHJhZ29uID0%2BICJQdWZmIHRoZSBNYWdpYyIKICB9Ow%3D%3D&live=off)) -doesn’t compile. Fix it by adding a single character. - -```reason -let getName = (animal: [| `Cat | `Dog(int) | `Unicorn(string)]) => - switch (animal) { - | `Cat => "Mr Whiskers" - | `Dog(n) => "Bandit " ++ string_of_int(n) - | `Unicorn(name) => "Sir " ++ name - | `Dragon => "Puff the Magic" - }; -``` - -::: details Solution - -```reason{1} -let getName = (animal: [> | `Cat | `Dog(int) | `Unicorn(string)]) => - switch (animal) { - | `Cat => "Mr Whiskers" - | `Dog(n) => "Bandit " ++ string_of_int(n) - | `Unicorn(name) => "Sir " ++ name - | `Dragon => "Puff the Magic" - }; -``` - -Adding a `>` to the polymorphic variant type definition allows it to accept more -than the listed variant tags. - -::: - -3. Fix the following code ([playground](https://melange.re/v4.0.0/playground/?language=Reason&code=LyoqIE9ubHkgaW52b2tlIFtmXSB3aGVuIFtvMV0gYW5kIFtvMl0gYXJlIFtTb21lXSAqLwpsZXQgbWFwMjogKG9wdGlvbignYSksIG9wdGlvbignYSksICgnYSwgJ2EpID0%2BICdhKSA9PiBvcHRpb24oJ2EpID0KICAobzEsIG8yLCBmKSA9PgogICAgc3dpdGNoIChvMSwgbzIpIHsKICAgIHwgKE5vbmUsIE5vbmUpCiAgICB8IChOb25lLCBTb21lKF8pKQogICAgfCAoU29tZShfKSwgTm9uZSkgPT4gTm9uZQogICAgfCAoU29tZSh2MSksIFNvbWUodjIpKSA9PiBTb21lKGYodjEsIHYyKSkKICAgIH07CgpKcy5sb2cobWFwMihTb21lKDExKSwgU29tZSgzMyksICgrKSkpOwpKcy5sb2cobWFwMihTb21lKCJBQkMiKSwgU29tZSgxMjMpLCAoYSwgYikgPT4gKGEsIGIpKSk7&live=off)) which fails to compile: - -```reason -/** Only invoke [f] when [o1] and [o2] are [Some] */ -let map2: (option('a), option('a), ('a, 'a) => 'a) => option('a) = - (o1, o2, f) => - switch (o1, o2) { - | (None, None) - | (None, Some(_)) - | (Some(_), None) => None - | (Some(v1), Some(v2)) => Some(f(v1, v2)) - }; - -Js.log(map2(Some(11), Some(33), (+))); -Js.log(map2(Some("ABC"), Some(123), (a, b) => (a, b))); -``` +2. The `DateInput` component has a pretty serious bug. Try to manually enter the day of the month and it will likely crash the entire program. Fix the bug inside `DateInput` without using exceptions. ::: details Hint 1 -Fix the type annotation. +Use `option` ::: ::: details Hint 2 -Delete the type annotation. +tbd ::: ::: details Solution -```reason -/** Only invoke [f] when [o1] and [o2] are [Some] */ -let map2: (option('a), option('b), ('a, 'b) => 'c) => option('c) = - (o1, o2, f) => - switch (o1, o2) { - | (None, None) - | (None, Some(_)) - | (Some(_), None) => None - | (Some(v1), Some(v2)) => Some(f(v1, v2)) - }; - -Js.log(map2(Some(11), Some(33), (+))); -Js.log(map2(Some("ABC"), Some(123), (a, b) => (a, b))); -``` - -We have to use different type variables if we expect that the types might be -different. Note that we could have deleted the type annotation and then OCaml's -inferred type would be the same as the type annotation above. +tbd ::: +3. (split this into two exercises) -4. Change the render logic so that a `DateInput` is rendered above each -`Order`. Changing the date on a `DateInput` changes the date for the `Order` -below it. - -::: details Hint - -Define a `DateAndOrder` helper component. - -::: - -::: details Solution - -Add `Demo.DateAndOrder` subcomponent: - -<<< Demo.re#date-and-order - -Then refactor `Demo.make` to use the new component: - -<<< Demo.re#make - -::: - -5. Make the message for `Discount.getSandwichHalfOff`'s `` +Make the message for `Discount.getSandwichHalfOff`'s `` `MissingSandwichTypes`` error more friendly by listing the sandwiches you still need to buy to fulfill the conditions of the promotion. As a start, change the "Not all sandwiches, return Error" test in `DiscountTests.SandwichHalfOff`: @@ -540,11 +286,3 @@ and [demo](https://react-book.melange.re/demo/src/order-with-promo/) for this ch invalid](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/getFullYear#return_value), and in OCaml, only `float` is capable of representing [`NaN`](https://melange.re/v4.0.0/api/re/melange/Js/Float/#val-_NaN). - -[^2]: If you happen to need more than 7 dependencies, you can define your own - binding function based on the [current binding - functions](https://github.com/reasonml/reason-react/blob/713ab6cdb1644fb44e2c0c8fdcbef31007b37b8d/src/React.rei#L248-L255). - We'll cover bindings in more detail [later](/todo). - -[^3]: Recall that variant constructors with arguments also get turned into - objects in the JS runtime. diff --git a/src/order-with-promo/DateInput.re b/src/order-with-promo/DateInput.re deleted file mode 100644 index 1a932b86..00000000 --- a/src/order-with-promo/DateInput.re +++ /dev/null @@ -1,21 +0,0 @@ -let stringToDate = s => - // add "T00:00" to make sure the date is in local time - s ++ "T00:00" |> Js.Date.fromString; - -let dateToString = d => - Printf.sprintf( - "%4.0f-%02.0f-%02.0f", - Js.Date.getFullYear(d), - Js.Date.getMonth(d) +. 1., - Js.Date.getDate(d), - ); - -[@react.component] -let make = (~date: Js.Date.t, ~onChange: Js.Date.t => unit) => { - evt |> RR.getValueFromEvent |> stringToDate |> onChange} - />; -}; diff --git a/src/order-with-promo/Demo.re b/src/order-with-promo/Demo.re deleted file mode 100644 index 7865c2f0..00000000 --- a/src/order-with-promo/Demo.re +++ /dev/null @@ -1,85 +0,0 @@ -let datasets = { - [ - ( - "No burgers", - Item.[ - Sandwich(Unicorn), - Hotdog, - Sandwich(Ham), - Sandwich(Turducken), - Hotdog, - ], - ), - { - let burger = - Item.Burger.{ - lettuce: false, - tomatoes: false, - onions: 0, - cheese: 0, - bacon: 0, - }; - ( - "5 burgers", - { - [ - Burger({...burger, tomatoes: true}), - Burger({...burger, lettuce: true}), - Burger({...burger, bacon: 2}), - Burger({...burger, cheese: 3, onions: 9, tomatoes: true}), - Burger({...burger, onions: 2}), - ]; - }, - ); - }, - ( - "1 burger with at least one of every topping", - [ - Hotdog, - Burger({ - lettuce: true, - tomatoes: true, - onions: 1, - cheese: 2, - bacon: 3, - }), - Sandwich(Turducken), - ], - ), - ( - "All sandwiches", - [ - Sandwich(Ham), - Hotdog, - Sandwich(Portabello), - Sandwich(Unicorn), - Hotdog, - Sandwich(Turducken), - ], - ), - ]; -}; - -module DateAndOrder = { - [@react.component] - let make = (~label: string, ~items: list(Item.t)) => { - let (date, setDate) = - RR.useStateValue(Js.Date.fromString("2024-05-28T00:00")); - -
    -

    {RR.s(label)}

    - - -
    ; - }; -}; - -[@react.component] -let make = () => { -
    -

    {RR.s("Order Confirmation")}

    - {datasets - |> List.map(((label, items)) => ) - |> RR.list} -
    ; -}; diff --git a/src/order-with-promo/Discount.re b/src/order-with-promo/Discount.re index e8b5724d..fcbac1e4 100644 --- a/src/order-with-promo/Discount.re +++ b/src/order-with-promo/Discount.re @@ -94,16 +94,7 @@ let getSandwichHalfOff = (items: list(Item.t), ~date: Js.Date.t) => { total +. Item.toPrice(item, ~date) ); Ok(total /. 2.0); - | tracker => - let missing = - [ - tracker.portabello ? "" : "portabello", - tracker.ham ? "" : "ham", - tracker.unicorn ? "" : "unicorn", - tracker.turducken ? "" : "turducken", - ] - |> List.filter((!=)("")); - Error(`MissingSandwichTypes(missing)); + | _ => Error(`MissingSandwichTypes) }; }; diff --git a/src/order-with-promo/DiscountTests.re b/src/order-with-promo/DiscountTests.re index 2b2fe4bc..05121a8a 100644 --- a/src/order-with-promo/DiscountTests.re +++ b/src/order-with-promo/DiscountTests.re @@ -148,7 +148,7 @@ module SandwichHalfOff = { Sandwich(Ham), ], ), - Error(`MissingSandwichTypes(["turducken"])), + Error(`MissingSandwichTypes), ) ); diff --git a/src/order-with-promo/Index.re b/src/order-with-promo/Index.re index 71fe02d4..9133a191 100644 --- a/src/order-with-promo/Index.re +++ b/src/order-with-promo/Index.re @@ -1,8 +1,35 @@ +module App = { + let items: Order.t = [ + Sandwich(Portabello), + Sandwich(Unicorn), + Sandwich(Ham), + Sandwich(Turducken), + Hotdog, + Burger({lettuce: true, tomatoes: true, onions: 3, cheese: 2, bacon: 6}), + Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 0, bacon: 0}), + Burger({lettuce: true, tomatoes: false, onions: 1, cheese: 1, bacon: 1}), + Burger({lettuce: false, tomatoes: false, onions: 1, cheese: 0, bacon: 0}), + Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 1, bacon: 0}), + ]; + + [@react.component] + let make = () => { + let date = Js.Date.fromString("2024-05-10T00:00"); + +
    +

    {RR.s("Promo")}

    + ()} /> +

    {RR.s("Order confirmation")}

    + +
    ; + }; +}; + let node = ReactDOM.querySelector("#root"); switch (node) { | None => Js.Console.error("Failed to start React: couldn't find the #root element") | Some(root) => let root = ReactDOM.Client.createRoot(root); - ReactDOM.Client.render(root, ); + ReactDOM.Client.render(root, ); }; diff --git a/src/order-with-promo/Order.re b/src/order-with-promo/Order.re index 75742433..15155595 100644 --- a/src/order-with-promo/Order.re +++ b/src/order-with-promo/Order.re @@ -35,14 +35,6 @@ module Style = { text-align: right; |} ]; - - let promo = [%cx - {| - border-top: 1px solid gray; - text-align: right; - vertical-align: top; - |} - ]; }; [@react.component] @@ -66,7 +58,7 @@ let make = (~items: t, ~date: Js.Date.t) => { {RR.s("Subtotal")} {subtotal |> RR.currency} - + {RR.s("Promo code")} diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index b3f55007..8e8e838b 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -23,7 +23,7 @@ type discount('a) = | NoSubmittedCode; [@react.component] -let make = (~items: list(Item.t), ~date: Js.Date.t) => { +let make = (~items: list(Item.t), ~date: Js.Date.t, ~onApply: float => unit) => { let (code, setCode) = RR.useStateValue(""); let (submittedCode, setSubmittedCode) = RR.useStateValue(None); @@ -45,7 +45,21 @@ let make = (~items: list(Item.t), ~date: Js.Date.t) => { className=Style.form onSubmit={evt => { evt |> React.Event.Form.preventDefault; - setSubmittedCode(Js.String.trim(code) == "" ? None : Some(code)); + let newSubmittedCode = Js.String.trim(code) == "" ? None : Some(code); + setSubmittedCode(newSubmittedCode); + + switch (newSubmittedCode) { + | None => () + | Some(code) => + switch (Discount.getDiscountFunction(code, date)) { + | Error(_) => () + | Ok(discountFunction) => + switch (discountFunction(items)) { + | Error(_) => () + | Ok(value) => onApply(value) + } + } + }; }}> value |> Js.Float.toFixed(~digits=2) |> React.string; /** Like [React.useState] but doesn't use callback functions */ let useStateValue = initial => React.useReducer((_state, newState) => newState, initial); - -/** Helper for [React.useEffect1] */ -let useEffect1 = (func, dep) => React.useEffect1(func, [|dep|]); From b149b82b774868695a9d14eceb296c314c6f61ef Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Thu, 5 Sep 2024 11:31:04 -0500 Subject: [PATCH 06/13] Update the code in src/order-with-promo --- docs/order-with-promo/DateInput.re | 2 + docs/order-with-promo/index.md | 75 +++++++++++++++++------------ src/order-with-promo/App.re | 77 ++++++++++++++++++++++++++++++ src/order-with-promo/DateInput.re | 23 +++++++++ src/order-with-promo/Index.re | 27 ----------- src/order-with-promo/Order.re | 10 +++- src/order-with-promo/Promo.re | 29 +++++------ 7 files changed, 166 insertions(+), 77 deletions(-) create mode 100644 src/order-with-promo/App.re create mode 100644 src/order-with-promo/DateInput.re diff --git a/docs/order-with-promo/DateInput.re b/docs/order-with-promo/DateInput.re index 1a932b86..9a676a32 100644 --- a/docs/order-with-promo/DateInput.re +++ b/docs/order-with-promo/DateInput.re @@ -2,6 +2,8 @@ let stringToDate = s => // add "T00:00" to make sure the date is in local time s ++ "T00:00" |> Js.Date.fromString; +/* Convert a Date to the yyyy-mm-dd format that the input element accepts for + its value attribute */ let dateToString = d => Printf.sprintf( "%4.0f-%02.0f-%02.0f", diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index a82f1f64..aa850d54 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -15,8 +15,8 @@ A breakdown: - Create a new state variable called `discount` (along with its attendant `setDiscount` function) - Rename the `total` variable to `subtotal` -- Add a new table row labeld "Subtotal" to display the value of `subtotal` -- Add a new table row labeled "Promo" to render the `Promo` component +- Add a new table row with label "Subtotal" to display the value of `subtotal` +- Add a new table row with label "Promo" to render the `Promo` component - Set the value of `discount` through `Promo`'s `onApply` callback prop (we'll add this prop in the next step) - Subtract `discount` from `subtotal` when rendering the total price of the @@ -47,8 +47,8 @@ switch expression used to define the `discount` derived variable: <<< Promo.re#discount-derived-variable -The branches in the two switch expressions are exactly the same and only the -return values are different. You wonder if there's some way to reduce the +The branches in the two switch expressions are exactly the same with only the +return values being different. You wonder if there's some way to reduce the redundancy. There is! You can replace the definition of `discount` with a `getDiscount` function: @@ -62,23 +62,34 @@ And also use it in the render logic's switch expression: <<< Promo.re#get-discount-in-render{1} -## Move `App` into its own file +Take a little time to verify that `Order` is able to accept promo codes. + +- Type `FREE` into the input and press Enter. It should deduct the price of + every other burger (ordered by price descending). +- Type `HALF` into the input and press Enter. It should show an error saying + "Expired promo code". + +## Add datasets to `App` It was OK to keep our `App` component inside of `Index.re` because it was always -very small. But now we'd like to add much more to it, so it makes sense to move -it into its own file. Create a new file called `App.re` and move the contents of -`Index.App` into it. +very small. But if we want to add more to it, it makes sense for `App` to be +inside its own file. Create a new file called `App.re` and move the contents of +`Index.App` into it (deleting `Index.App` in the process). -To make it easier to see the different promo-related error messages, you can -create different collections of items. Add a `datasets` variable to `App`: +Promo behavior tightly depends on what items are in an order, so it would be +nice to render multiple `Order` components containing different sets of items. +Add a `datasets` variable to `App`: <<< App.re#datasets -Since the `burgers` value is only used the "5 burgers" expression, we can move -it inside that expression: +Since the `burgers` value is only used in the "5 burgers" expression, we can +move it inside that expression: <<< App.re#burger-expression{2-9} +Note that we need to add curly braces (`{}`) around the expression because +multi-line expressions must be surrounded by curly braces. + ::: tip OCaml makes it easy to move variable definitions closer to where they are @@ -94,6 +105,12 @@ items: You can delete the now-unused `App.items` variable. +Enter the promo code "HALF" into different `Order`s to see different messages: + +- In the "No burgers" order, you'll get the error message "Buy a burger with + every topping to enjoy this promotion" +- In the "1 burger with at least one of every topping" order, you'll get a + discount of 15.97. ## Add `DateInput` component @@ -101,15 +118,15 @@ Currently, the date in `App` is static, but it would be very convenient to be able to change it interactively in order to see the behavior of `Promo` on different dates. Let's add a new file `DateInput.re`: -<<< DateInput.re{7,16} +<<< DateInput.re{9,18} A few notes: -- We use `Printf.sprintf` to give us more control over how the `float` - components of a Date[^1] are converted to strings: - - The [float conversion - specification](https://melange.re/v4.0.0/api/re/melange/Stdlib/Printf/index.html#val-fprintf) - `%4.0f` sets a minimum width of 4, with 0 numbers after the decimal +- `Printf.sprintf` accepts a string containing [float conversion + specifications](https://melange.re/v4.0.0/api/re/melange/Stdlib/Printf/index.html#val-fprintf) + which can convert the float components of a Date[^1] into strings: + - The float conversion specification `%4.0f` sets a minimum width of 4, with 0 + numbers after the decimal - The float conversion specification `%02.0f` sets a minimum width of 2 (left padded with 0), with 0 numbers after the decimal - The `type` prop of `input` has been renamed to `type_`, because in OCaml, @@ -121,9 +138,8 @@ Even though the prop `type` has been renamed to `type_` in OCaml, the [generated JS output will say `type`](https://melange.re/v4.0.0/playground/?language=Reason&code=W0ByZWFjdC5jb21wb25lbnRdCmxldCBtYWtlID0gKCkgPT4gPGlucHV0IHR5cGVfPSJkYXRlIiAvPjs%3D&live=off). -Some other prop names also clash with OCaml reserved keywords and must be -suffixed with an underscore, such as: `as_`, `open_`, `begin_`, `end_`, `in_`, -and `to_`. +Some other prop names that clash with OCaml reserved keywords and must also be +suffixed with an underscore: `as_`, `open_`, `begin_`, `end_`, `in_`, `to_`. ::: @@ -131,15 +147,8 @@ Add `DateInput` to `App.make`: <<< App.re#add-date-input{3-4,8} -Execute `npm run serve` to see your app in action. Verify that it behaves as -expected: - -- Type `FREE` into the input and press Enter. It should deduct the price of - every other burger (ordered by price descending). -- Type `HALF` into the input and press Enter. It should deduct half off the - entire order. -- Change the date to something other than May 28. It should show an error saying - "Expired promo code" +Try changing the date to November 3 and enter the promo code "HALF" into the +"All sandwiches" order. You should see a discount of 65.00. ## Add styling for promo code row @@ -155,16 +164,20 @@ Then set the `className` of the promo code row to `Style.promo`: Hot diggity! You've added the promo codes to your order confirmation widget, just in time for Madame Jellobutter's International Burger Day promotions. In -the next chapter, we'll start writing a completely new app. +the next chapter, we'll start writing a completely new app. Before that, cap off +what you've learned by doing some exercises. ## Overview +- When a nested module starts to get big, it's a good idea to put it into its + own file - Some component props have names that aren't legal as identifiers in OCaml, and we must add an underscore after them, e.g. `type`, which must be rewritten as `type_`. - You can use `let` inside expressions, which allows you to define variables closer to where they're used + ## Exercises 1. Change the render logic so that a `DateInput` is rendered above each diff --git a/src/order-with-promo/App.re b/src/order-with-promo/App.re new file mode 100644 index 00000000..8dbf48ce --- /dev/null +++ b/src/order-with-promo/App.re @@ -0,0 +1,77 @@ +let datasets = { + [ + ( + "No burgers", + Item.[ + Sandwich(Unicorn), + Hotdog, + Sandwich(Ham), + Sandwich(Turducken), + Hotdog, + ], + ), + { + let burger = + Item.Burger.{ + lettuce: false, + tomatoes: false, + onions: 0, + cheese: 0, + bacon: 0, + }; + ( + "5 burgers", + { + [ + Burger({...burger, tomatoes: true}), + Burger({...burger, lettuce: true}), + Burger({...burger, bacon: 2}), + Burger({...burger, cheese: 3, onions: 9, tomatoes: true}), + Burger({...burger, onions: 2}), + ]; + }, + ); + }, + ( + "1 burger with at least one of every topping", + [ + Hotdog, + Burger({ + lettuce: true, + tomatoes: true, + onions: 1, + cheese: 2, + bacon: 3, + }), + Sandwich(Turducken), + ], + ), + ( + "All sandwiches", + [ + Sandwich(Ham), + Hotdog, + Sandwich(Portabello), + Sandwich(Unicorn), + Hotdog, + Sandwich(Turducken), + ], + ), + ]; +}; + +[@react.component] +let make = () => { + let (date, setDate) = + RR.useStateValue(Js.Date.fromString("2024-05-28T00:00")); + +
    +

    {RR.s("Order Confirmation")}

    + + {datasets + |> List.map(((label, items)) => { +

    {RR.s(label)}

    + }) + |> RR.list} +
    ; +}; diff --git a/src/order-with-promo/DateInput.re b/src/order-with-promo/DateInput.re new file mode 100644 index 00000000..9a676a32 --- /dev/null +++ b/src/order-with-promo/DateInput.re @@ -0,0 +1,23 @@ +let stringToDate = s => + // add "T00:00" to make sure the date is in local time + s ++ "T00:00" |> Js.Date.fromString; + +/* Convert a Date to the yyyy-mm-dd format that the input element accepts for + its value attribute */ +let dateToString = d => + Printf.sprintf( + "%4.0f-%02.0f-%02.0f", + Js.Date.getFullYear(d), + Js.Date.getMonth(d) +. 1., + Js.Date.getDate(d), + ); + +[@react.component] +let make = (~date: Js.Date.t, ~onChange: Js.Date.t => unit) => { + evt |> RR.getValueFromEvent |> stringToDate |> onChange} + />; +}; diff --git a/src/order-with-promo/Index.re b/src/order-with-promo/Index.re index 9133a191..ec7d0ebd 100644 --- a/src/order-with-promo/Index.re +++ b/src/order-with-promo/Index.re @@ -1,30 +1,3 @@ -module App = { - let items: Order.t = [ - Sandwich(Portabello), - Sandwich(Unicorn), - Sandwich(Ham), - Sandwich(Turducken), - Hotdog, - Burger({lettuce: true, tomatoes: true, onions: 3, cheese: 2, bacon: 6}), - Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 0, bacon: 0}), - Burger({lettuce: true, tomatoes: false, onions: 1, cheese: 1, bacon: 1}), - Burger({lettuce: false, tomatoes: false, onions: 1, cheese: 0, bacon: 0}), - Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 1, bacon: 0}), - ]; - - [@react.component] - let make = () => { - let date = Js.Date.fromString("2024-05-10T00:00"); - -
    -

    {RR.s("Promo")}

    - ()} /> -

    {RR.s("Order confirmation")}

    - -
    ; - }; -}; - let node = ReactDOM.querySelector("#root"); switch (node) { | None => diff --git a/src/order-with-promo/Order.re b/src/order-with-promo/Order.re index 15155595..8d89c155 100644 --- a/src/order-with-promo/Order.re +++ b/src/order-with-promo/Order.re @@ -35,6 +35,14 @@ module Style = { text-align: right; |} ]; + + let promo = [%cx + {| + border-top: 1px solid gray; + text-align: right; + vertical-align: top; + |} + ]; }; [@react.component] @@ -58,7 +66,7 @@ let make = (~items: t, ~date: Js.Date.t) => { {RR.s("Subtotal")} {subtotal |> RR.currency} - + {RR.s("Promo code")} diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index 8e8e838b..cda72e15 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -27,19 +27,18 @@ let make = (~items: list(Item.t), ~date: Js.Date.t, ~onApply: float => unit) => let (code, setCode) = RR.useStateValue(""); let (submittedCode, setSubmittedCode) = RR.useStateValue(None); - let discount = - switch (submittedCode) { + let getDiscount = + fun | None => NoSubmittedCode | Some(code) => switch (Discount.getDiscountFunction(code, date)) { | Error(error) => CodeError(error) - | Ok(discountFunction) => - switch (discountFunction(items)) { + | Ok(discountFunc) => + switch (discountFunc(items)) { | Error(error) => DiscountError(error) | Ok(value) => Discount(value) } - } - }; + }; unit) => let newSubmittedCode = Js.String.trim(code) == "" ? None : Some(code); setSubmittedCode(newSubmittedCode); - switch (newSubmittedCode) { - | None => () - | Some(code) => - switch (Discount.getDiscountFunction(code, date)) { - | Error(_) => () - | Ok(discountFunction) => - switch (discountFunction(items)) { - | Error(_) => () - | Ok(value) => onApply(value) - } - } + switch (getDiscount(newSubmittedCode)) { + | NoSubmittedCode + | CodeError(_) + | DiscountError(_) => () + | Discount(value) => onApply(value) }; }}> unit) => setSubmittedCode(None); }} /> - {switch (discount) { + {switch (getDiscount(submittedCode)) { | NoSubmittedCode => React.null | Discount(discount) => discount |> Float.neg |> RR.currency | CodeError(error) => From 1d10bc72f5cb0b61e2539a3be64525db51f6cb9c Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Thu, 5 Sep 2024 22:04:27 -0500 Subject: [PATCH 07/13] Complete exercise 2 --- docs/order-with-promo/DateInput.re | 37 +++++++++++++++++++++++- docs/order-with-promo/index.md | 46 ++++++++++++++++++++++-------- src/order-with-promo/App.re | 22 +++++++++----- src/order-with-promo/DateInput.re | 12 +++++--- 4 files changed, 93 insertions(+), 24 deletions(-) diff --git a/docs/order-with-promo/DateInput.re b/docs/order-with-promo/DateInput.re index 9a676a32..df5c66a3 100644 --- a/docs/order-with-promo/DateInput.re +++ b/docs/order-with-promo/DateInput.re @@ -1,3 +1,4 @@ +// #region initial let stringToDate = s => // add "T00:00" to make sure the date is in local time s ++ "T00:00" |> Js.Date.fromString; @@ -6,7 +7,7 @@ let stringToDate = s => its value attribute */ let dateToString = d => Printf.sprintf( - "%4.0f-%02.0f-%02.0f", + "%04.0f-%02.0f-%02.0f", Js.Date.getFullYear(d), Js.Date.getMonth(d) +. 1., Js.Date.getDate(d), @@ -21,3 +22,37 @@ let make = (~date: Js.Date.t, ~onChange: Js.Date.t => unit) => { onChange={evt => evt |> RR.getValueFromEvent |> stringToDate |> onChange} />; }; +// #endregion initial + +// #region string-to-date-option +let stringToDate = s => { + // add "T00:00" to make sure the date is in local time + let date = s ++ "T00:00" |> Js.Date.fromString; + date |> Js.Date.valueOf |> Js.Float.isNaN ? None : Some(date); +}; +// #endregion string-to-date-option + +let _ = + onChange => { + + switch (evt |> RR.getValueFromEvent |> stringToDate) { + | None => () + | Some(date) => onChange(date) + } + } + // #endregion on-change-switch + />; + }; + +let _ = + onChange => { + + evt |> RR.getValueFromEvent |> stringToDate |> Option.iter(onChange) + } + // #endregion on-change-option-iter + />; + }; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index aa850d54..61283b27 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -118,17 +118,16 @@ Currently, the date in `App` is static, but it would be very convenient to be able to change it interactively in order to see the behavior of `Promo` on different dates. Let's add a new file `DateInput.re`: -<<< DateInput.re{9,18} +<<< DateInput.re#initial{9,18} A few notes: -- `Printf.sprintf` accepts a string containing [float conversion +- This invocation of `Printf.sprintf` contains [float conversion specifications](https://melange.re/v4.0.0/api/re/melange/Stdlib/Printf/index.html#val-fprintf) which can convert the float components of a Date[^1] into strings: - - The float conversion specification `%4.0f` sets a minimum width of 4, with 0 - numbers after the decimal - - The float conversion specification `%02.0f` sets a minimum width of 2 - (left padded with 0), with 0 numbers after the decimal + - The float conversion specification `%04.0f` sets a minimum width of 4, + left-padded with 0, with 0 numbers after the decimal. It converts the year + part of the given date. - The `type` prop of `input` has been renamed to `type_`, because in OCaml, `type` is a reserved keyword and can't be used as an identifier. @@ -176,6 +175,8 @@ what you've learned by doing some exercises. `type_`. - You can use `let` inside expressions, which allows you to define variables closer to where they're used +- Float conversion specifications, in conjunction with `Printf.sprintf`, gives + you fine-grained control over how to convert a `float` value to a string. ## Exercises @@ -186,7 +187,7 @@ below it, but doesn't affect the dates of the other `Order`s. ::: details Hint -Define a `DateAndOrder` helper component. +Define a `DateAndOrder` helper component inside `App`. ::: @@ -198,27 +199,48 @@ Add `App.DateAndOrder` subcomponent: Then refactor `App.make` to use the new component: -<<< App.re#make +<<< App.re#make{6} ::: -2. The `DateInput` component has a pretty serious bug. Try to manually enter the day of the month and it will likely crash the entire program. Fix the bug inside `DateInput` without using exceptions. +2. The `DateInput` component has a pretty annoying bug. Focus on +`DateInput`'s `input` element and press the `0` key. The `input` element will go +into an "invalid date" state. Refactor `DateInput` so that the user can no +longer enter invalid dates. ::: details Hint 1 -Use `option` +Use +[Js.Date.valueOf](https://melange.re/v4.0.0/api/re/melange/Js/Date/index.html#val-valueOf) +and +[Js.Float.isNaN](https://melange.re/v4.0.0/api/re/melange/Js/Float/#val-isNaN) +to check the validity of a date. ::: ::: details Hint 2 -tbd +Use [Option.iter](https://melange.re/v4.0.0/api/re/melange/Stdlib/Option/#val-iter). ::: ::: details Solution -tbd +First, modify `DateInput.stringToDate` so that it returns `Some(date)` when the date +is valid and `None` otherwise: + +<<< DateInput.re#string-to-date-option{4} + +Then modify the `input` element's `onChange` callback so that `onChange` is only +invoked when the return value of `stringToDate` is `Some(date)`: + +<<< DateInput.re#on-change-switch + +A shorter but equivalent way to write the `onChange` callback is to use the +[Option.iter](https://melange.re/v4.0.0/api/re/melange/Stdlib/Option/#val-iter) +function: + +<<< DateInput.re#on-change-option-iter ::: diff --git a/src/order-with-promo/App.re b/src/order-with-promo/App.re index 8dbf48ce..7865c2f0 100644 --- a/src/order-with-promo/App.re +++ b/src/order-with-promo/App.re @@ -60,18 +60,26 @@ let datasets = { ]; }; +module DateAndOrder = { + [@react.component] + let make = (~label: string, ~items: list(Item.t)) => { + let (date, setDate) = + RR.useStateValue(Js.Date.fromString("2024-05-28T00:00")); + +
    +

    {RR.s(label)}

    + + +
    ; + }; +}; + [@react.component] let make = () => { - let (date, setDate) = - RR.useStateValue(Js.Date.fromString("2024-05-28T00:00")); -

    {RR.s("Order Confirmation")}

    - {datasets - |> List.map(((label, items)) => { -

    {RR.s(label)}

    - }) + |> List.map(((label, items)) => ) |> RR.list}
    ; }; diff --git a/src/order-with-promo/DateInput.re b/src/order-with-promo/DateInput.re index 9a676a32..194ccfb2 100644 --- a/src/order-with-promo/DateInput.re +++ b/src/order-with-promo/DateInput.re @@ -1,12 +1,14 @@ -let stringToDate = s => +let stringToDate = s => { // add "T00:00" to make sure the date is in local time - s ++ "T00:00" |> Js.Date.fromString; + let date = s ++ "T00:00" |> Js.Date.fromString; + date |> Js.Date.valueOf |> Js.Float.isNaN ? None : Some(date); +}; /* Convert a Date to the yyyy-mm-dd format that the input element accepts for its value attribute */ let dateToString = d => Printf.sprintf( - "%4.0f-%02.0f-%02.0f", + "%04.0f-%02.0f-%02.0f", Js.Date.getFullYear(d), Js.Date.getMonth(d) +. 1., Js.Date.getDate(d), @@ -18,6 +20,8 @@ let make = (~date: Js.Date.t, ~onChange: Js.Date.t => unit) => { type_="date" required=true value={dateToString(date)} - onChange={evt => evt |> RR.getValueFromEvent |> stringToDate |> onChange} + onChange={evt => + evt |> RR.getValueFromEvent |> stringToDate |> Option.iter(onChange) + } />; }; From f5f2074458ebbed313b80c45d174b874c09c43f7 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Mon, 9 Sep 2024 22:06:24 -0500 Subject: [PATCH 08/13] Add exercise 4 --- docs/order-with-promo/DiscountTests.re | 9 +-- docs/order-with-promo/ListSafe.re | 25 ++++++ docs/order-with-promo/Promo.re | 41 ++++++---- docs/order-with-promo/index.md | 108 ++++++++++++++++++++----- src/order-with-promo/Discount.re | 12 ++- src/order-with-promo/DiscountTests.re | 9 +-- src/order-with-promo/ListSafe.re | 13 +++ src/order-with-promo/ListSafeTests.re | 22 +++++ src/order-with-promo/Promo.re | 2 +- src/order-with-promo/tests.t | 20 +++++ 10 files changed, 211 insertions(+), 50 deletions(-) create mode 100644 docs/order-with-promo/ListSafe.re create mode 100644 src/order-with-promo/ListSafeTests.re diff --git a/docs/order-with-promo/DiscountTests.re b/docs/order-with-promo/DiscountTests.re index e24ec5e3..895c9cae 100644 --- a/docs/order-with-promo/DiscountTests.re +++ b/docs/order-with-promo/DiscountTests.re @@ -9,14 +9,9 @@ module SandwichHalfOff = { |> deepEqual( Discount.getSandwichHalfOff( ~date=june3, - [ - Sandwich(Unicorn), - Hotdog, - Sandwich(Portabello), - Sandwich(Ham), - ], + [Sandwich(Portabello), Hotdog, Sandwich(Ham)], ), - Error(`MissingSandwichTypes(["turducken"])), + Error(`MissingSandwichTypes(["unicorn", "turducken"])), ) ); // #endregion not-all-sandwiches diff --git a/docs/order-with-promo/ListSafe.re b/docs/order-with-promo/ListSafe.re new file mode 100644 index 00000000..04e9b2fa --- /dev/null +++ b/docs/order-with-promo/ListSafe.re @@ -0,0 +1,25 @@ +// #region humanize +/** Take a list of strings and return a human-readable string */ +let humanize = + fun + | [] => "" + | [x] => x + | [x, y] => x ++ " and " ++ y + | [first, ...rest] => + rest + |> List.rev + |> List.mapi((i, s) => i == 0 ? "and " ++ s : s) + |> List.rev + |> List.fold_left((acc, s) => acc ++ ", " ++ s, first); +// #endregion humanize + +let _ = + (rest, first) => { + // #region alternate + rest + |> List.rev + |> List.mapi((i, s) => ", " ++ (i == 0 ? "and " ++ s : s)) + |> List.rev + |> List.fold_left((++), first); + // #endregion alternate + }; diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re index 315ecc35..db58bc5c 100644 --- a/docs/order-with-promo/Promo.re +++ b/docs/order-with-promo/Promo.re @@ -123,22 +123,31 @@ let _ = }; let _ = - (~thing) => { - switch (thing) { + code => { + // #region missing-sandwich-types-wildcard + let buyWhat = + switch (code) { + | `NeedOneBurger => "at least 1 more burger" + | `NeedTwoBurgers => "at least 2 burgers" + | `NeedMegaBurger => "a burger with every topping" + | `MissingSandwichTypes(_missing) => "every sandwich" + }; + // #endregion missing-sandwich-types-wildcard + ignore(buyWhat); + }; + +let _ = + code => { // #region show-missing-sandwich-types - | `DiscountError(code) => - let buyWhat = - switch (code) { - | `NeedOneBurger => "at least 1 more burger" - | `NeedTwoBurgers => "at least 2 burgers" - | `NeedMegaBurger => "a burger with every topping" - | `MissingSandwichTypes(missing) => - (missing |> Stdlib.Array.of_list |> Js.Array.join(~sep=", ")) - ++ " sandwiches" - }; -
    - {RR.s({j|Buy $buyWhat to enjoy this promotion|j})} -
    ; + let buyWhat = + switch (code) { + | `NeedOneBurger => "at least 1 more burger" + | `NeedTwoBurgers => "at least 2 burgers" + | `NeedMegaBurger => "a burger with every topping" + | `MissingSandwichTypes(missing) => + (missing |> Stdlib.Array.of_list |> Js.Array.join(~sep=", ")) + ++ " sandwiches" + }; // #endregion show-missing-sandwich-types - }; + ignore(buyWhat); }; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 61283b27..ab1f5162 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -244,33 +244,35 @@ function: ::: -3. (split this into two exercises) +3. Improve `Discount.getSandwichHalfOff` so that it returns a list of +missing sandwich types when the discount can't be applied. Start by changing the +"Not all sandwiches, return Error" test in `DiscountTests` to this: -Make the message for `Discount.getSandwichHalfOff`'s `` -`MissingSandwichTypes`` error more friendly by listing the sandwiches you still -need to buy to fulfill the conditions of the promotion. As a start, change the -"Not all sandwiches, return Error" test in `DiscountTests.SandwichHalfOff`: +<<< DiscountTests.re#not-all-sandwiches{6,8} -<<< DiscountTests.re#not-all-sandwiches{13} +Note that the `` `MissingSandwichTypes`` variant tag now contains an argument of +`list(string)`. Make this test pass, and fix any incidental compilation errors. +(Don't worry about changing the render logic, that's the next exercise.) -Note that the `` `MissingSandwichTypes`` variant tag now has an argument which -is a list of strings. ::: details Hint 1 -Inside `Discount.getSandwichHalfOff`, use -[List.filter](https://melange.re/v4.0.0/api/re/melange/Stdlib/List/#val-filter) -to filter out sandwich types that don't appear in `items`. +To create a list of strings from the `tracker` record, try this: + +```reason +[ + tracker.portabello ? "portabello" : "", + //... +] +``` ::: ::: details Hint 2 -In `Promo.make`, use -[Stdlib.Array.of_list](https://melange.re/v4.0.0/api/re/melange/Stdlib/Array/#val-of_list) -and -[Js.Array.join](https://melange.re/v4.0.0/api/re/melange/Js/Array/index.html#val-join) -to create a comma-delimited string. +Use +[List.filter](https://melange.re/v4.0.0/api/re/melange/Stdlib/List/#val-filter) +to filter out empty strings. ::: @@ -294,7 +296,78 @@ We could have instead written |> List.filter(s => s != "") ``` -which is a little more verbose and arguably easier to understand. +which is a little more verbose but arguably easier to understand. + +In order to fix the compilation error in `Promo`, add a wildcard variable to the +`` `MissingSandwichTypes`` branch: + +<<< Promo.re#missing-sandwich-types-wildcard{6} + +::: + +4. Add a new helper function `ListSafe.humanize`, which takes a list of +strings and returns a human-readable string. The logic should look something +like this: + +```reason +/** Take a list of strings and return a human-readable string */ +let humanize = + fun + | [] => "" + | [x] => x + | [x, y] => x ++ " and " ++ y + | [first, ...rest] => { + // fill in this part +``` + +To test this function, add a new file `ListSafeTests.re`: + +<<< @/../src/order-with-promo/ListSafeTests.re + +Also add a test command to `tests.t`: + +```text +ListSafe tests + $ node ./output/src/order-confirmation/ListSafeTests.mjs | sed '/duration_ms/d' +``` + +Recall that you can run all your tests by running `npm run test`. + +::: details Hint 1 + +You may want to use +[List.fold_left](https://melange.re/v4.0.0/api/re/melange/Stdlib/List/#val-fold_left), +[List.mapi](https://melange.re/v4.0.0/api/re/melange/Stdlib/List/#val-mapi), and +[List.rev](https://melange.re/v4.0.0/api/re/melange/Stdlib/List/#val-mapi). + +::: + +::: details Hint 2 + +Use `List.rev` in conjunction with `List.mapi` to prefix the last element with +"and ". + +::: + +::: details Hint 3 + +Use `List.fold_left` to add ", " in front of each element of `rest`. + +::: + +::: details Solution + +<<< ListSafe.re#humanize{8-12} + +An alternate solution would have been to add the commas in the callback to +`List.mapi`, which simplifies the call to `List.fold_left`: + +<<< ListSafe.re#alternate{3,5} + +::: + +5. Use `ListSafe.humanize` in `Promo.make` to make the `` +`MissingSandwichTypes`` error message more human-readable. Then change the render logic inside `Promo.make`'s `` `MissingSandwichTypes`` branch to convert the list of missing sandwich types to a comma-delimited @@ -305,7 +378,6 @@ string: Recall that we have to use `Stdlib.Array.of_list` instead of `Array.of_list` because our custom `Array` module takes precedence. -::: ----- diff --git a/src/order-with-promo/Discount.re b/src/order-with-promo/Discount.re index fcbac1e4..b2d60585 100644 --- a/src/order-with-promo/Discount.re +++ b/src/order-with-promo/Discount.re @@ -94,7 +94,17 @@ let getSandwichHalfOff = (items: list(Item.t), ~date: Js.Date.t) => { total +. Item.toPrice(item, ~date) ); Ok(total /. 2.0); - | _ => Error(`MissingSandwichTypes) + | tracker => + let missing = + [ + tracker.portabello ? "" : "portabello", + tracker.ham ? "" : "ham", + tracker.unicorn ? "" : "unicorn", + tracker.turducken ? "" : "turducken", + ] + |> List.filter((!=)("")); + + Error(`MissingSandwichTypes(missing)); }; }; diff --git a/src/order-with-promo/DiscountTests.re b/src/order-with-promo/DiscountTests.re index 05121a8a..151eb38b 100644 --- a/src/order-with-promo/DiscountTests.re +++ b/src/order-with-promo/DiscountTests.re @@ -141,14 +141,9 @@ module SandwichHalfOff = { |> deepEqual( Discount.getSandwichHalfOff( ~date=june3, - [ - Sandwich(Unicorn), - Hotdog, - Sandwich(Portabello), - Sandwich(Ham), - ], + [Sandwich(Portabello), Hotdog, Sandwich(Ham)], ), - Error(`MissingSandwichTypes), + Error(`MissingSandwichTypes(["unicorn", "turducken"])), ) ); diff --git a/src/order-with-promo/ListSafe.re b/src/order-with-promo/ListSafe.re index 6eba66a7..a2951599 100644 --- a/src/order-with-promo/ListSafe.re +++ b/src/order-with-promo/ListSafe.re @@ -1,2 +1,15 @@ /** Return the nth element encased in Some; if it doesn't exist, return None */ let nth = (n, list) => n < 0 ? None : List.nth_opt(list, n); + +/** Take a list of strings and return a human-readable string */ +let humanize = + fun + | [] => "" + | [x] => x + | [x, y] => x ++ " and " ++ y + | [first, ...rest] => + rest + |> List.rev + |> List.mapi((i, s) => i == 0 ? "and " ++ s : s) + |> List.rev + |> List.fold_left((acc, s) => acc ++ ", " ++ s, first); diff --git a/src/order-with-promo/ListSafeTests.re b/src/order-with-promo/ListSafeTests.re new file mode 100644 index 00000000..14090711 --- /dev/null +++ b/src/order-with-promo/ListSafeTests.re @@ -0,0 +1,22 @@ +open Fest; + +test("Zero, one, and two elements", () => { + expect |> deepEqual(ListSafe.humanize([]), ""); + + expect |> deepEqual(ListSafe.humanize(["foo"]), "foo"); + + expect |> deepEqual(ListSafe.humanize(["foo", "bar"]), "foo and bar"); +}); + +test("Three, four, and five elements", () => { + expect |> deepEqual(ListSafe.humanize(["A", "B", "C"]), "A, B, and C"); + + expect + |> deepEqual(ListSafe.humanize(["A", "B", "C", "D"]), "A, B, C, and D"); + + expect + |> deepEqual( + ListSafe.humanize(["A", "B", "C", "D", "E"]), + "A, B, C, D, and E", + ); +}); diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index cda72e15..30a9ba3a 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -80,7 +80,7 @@ let make = (~items: list(Item.t), ~date: Js.Date.t, ~onApply: float => unit) => | `NeedOneBurger => "at least 1 more burger" | `NeedTwoBurgers => "at least 2 burgers" | `NeedMegaBurger => "a burger with every topping" - | `MissingSandwichTypes => "every sandwich" + | `MissingSandwichTypes(_missing) => "every sandwich" };
    {RR.s({j|Buy $buyWhat to enjoy this promotion|j})} diff --git a/src/order-with-promo/tests.t b/src/order-with-promo/tests.t index f5aef5df..bc0394e6 100644 --- a/src/order-with-promo/tests.t +++ b/src/order-with-promo/tests.t @@ -121,3 +121,23 @@ Discount tests # cancelled 0 # skipped 0 # todo 0 + +ListSafe tests + $ node ./output/src/order-with-promo/ListSafeTests.mjs | sed '/duration_ms/d' + TAP version 13 + # Subtest: Zero, one, and two elements + ok 1 - Zero, one, and two elements + --- + ... + # Subtest: Three, four, and five elements + ok 2 - Three, four, and five elements + --- + ... + 1..2 + # tests 2 + # suites 0 + # pass 2 + # fail 0 + # cancelled 0 + # skipped 0 + # todo 0 From d00e9ab7ee4c6e8a8766f3d7a363225d640b23e9 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 10 Sep 2024 22:11:10 -0500 Subject: [PATCH 09/13] Delete exercise 5 Save it for next chapter --- docs/order-with-promo/Promo.re | 16 ---------------- docs/order-with-promo/index.md | 21 ++++----------------- docs/poly-to-normal-variant/index.md | 2 +- 3 files changed, 5 insertions(+), 34 deletions(-) diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re index db58bc5c..23272902 100644 --- a/docs/order-with-promo/Promo.re +++ b/docs/order-with-promo/Promo.re @@ -135,19 +135,3 @@ let _ = // #endregion missing-sandwich-types-wildcard ignore(buyWhat); }; - -let _ = - code => { - // #region show-missing-sandwich-types - let buyWhat = - switch (code) { - | `NeedOneBurger => "at least 1 more burger" - | `NeedTwoBurgers => "at least 2 burgers" - | `NeedMegaBurger => "a burger with every topping" - | `MissingSandwichTypes(missing) => - (missing |> Stdlib.Array.of_list |> Js.Array.join(~sep=", ")) - ++ " sandwiches" - }; - // #endregion show-missing-sandwich-types - ignore(buyWhat); - }; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index ab1f5162..5ae8e4dc 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -163,8 +163,7 @@ Then set the `className` of the promo code row to `Style.promo`: Hot diggity! You've added the promo codes to your order confirmation widget, just in time for Madame Jellobutter's International Burger Day promotions. In -the next chapter, we'll start writing a completely new app. Before that, cap off -what you've learned by doing some exercises. +the next chapter, we'll learn how to test our components. ## Overview @@ -252,7 +251,8 @@ missing sandwich types when the discount can't be applied. Start by changing the Note that the `` `MissingSandwichTypes`` variant tag now contains an argument of `list(string)`. Make this test pass, and fix any incidental compilation errors. -(Don't worry about changing the render logic, that's the next exercise.) +(Don't worry about changing the render logic, that's a subject for the next +chapter.) ::: details Hint 1 @@ -316,7 +316,7 @@ let humanize = | [] => "" | [x] => x | [x, y] => x ++ " and " ++ y - | [first, ...rest] => { + | [first, ...rest] => // fill in this part ``` @@ -366,19 +366,6 @@ An alternate solution would have been to add the commas in the callback to ::: -5. Use `ListSafe.humanize` in `Promo.make` to make the `` -`MissingSandwichTypes`` error message more human-readable. - -Then change the render logic inside `Promo.make`'s `` `MissingSandwichTypes`` -branch to convert the list of missing sandwich types to a comma-delimited -string: - -<<< Promo.re#show-missing-sandwich-types{7-9} - -Recall that we have to use `Stdlib.Array.of_list` instead of `Array.of_list` -because our custom `Array` module takes precedence. - - ----- View [source diff --git a/docs/poly-to-normal-variant/index.md b/docs/poly-to-normal-variant/index.md index 85a33ca5..31761805 100644 --- a/docs/poly-to-normal-variant/index.md +++ b/docs/poly-to-normal-variant/index.md @@ -158,7 +158,7 @@ feel free to leave out. --- -Phew! You refactored the `discount` reactive variable to use a normal variant +Phew! You refactored the `discount` derived variable to use a normal variant instead of a polymorphic variant. The code changes were fairly minimal, but to understand what was happening, it was necessary to learn the basics of type constructors and type variables. In the next sections, we'll set types and other From 42b2f936e72e326a4b5f9c76b76adca1e9c68d7f Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Wed, 11 Sep 2024 20:52:27 -0500 Subject: [PATCH 10/13] Fix typos --- docs/poly-to-normal-variant/index.md | 30 ++++++++++------------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/docs/poly-to-normal-variant/index.md b/docs/poly-to-normal-variant/index.md index 31761805..121a217b 100644 --- a/docs/poly-to-normal-variant/index.md +++ b/docs/poly-to-normal-variant/index.md @@ -2,7 +2,7 @@ The `Promo` component works fine now, but let's take a moment to explore how to implement it with a normal variant instead of a polymorphic variant. This small -refactor should give us more insight into OCaml's type system. Additionally, +refactor will give us more insight into OCaml's type system. Additionally, normal variants are better than polymorphic variants at "documenting" the types that will be used in your program, since they must always be explicitly defined before you can use them. @@ -15,8 +15,8 @@ expression: <<< Types.re#inferred-type The easiest thing to do is to create a new `discount` type and assign it to the -above type expression, then delete the `` ` `` from the top-level variant tags -to turn them into variant constructors: +above type expression, then delete the top-level backticks (`` ` ``) and +brackets (`[]`) to turn them into variant constructors: <<< Types.re#bad-discount-type @@ -24,7 +24,7 @@ to turn them into variant constructors: The alternative forms of a normal variant are called *variant constructors*, while the forms of a polymorphic variant are called *variant tags* (and always -start with `` ` ``). +start with a backtick). ::: @@ -37,8 +37,8 @@ disappears if we simply delete `>`: <<< Types.re#delete-refinement -This fixes the syntax error so that we now have a correctly-defined variant -type. +This fixes the syntax error so that we now have a correctly-defined normal +variant type. ## Refactor `discount` variable to use normal variant @@ -106,22 +106,15 @@ discount([| `MissingSandwichTypes | `KewpieMayo ]) ``` -When defining your own types, you will most often used *fixed* polymormorphic +When defining your own types, you will most often used *exact* polymorphic variants, i.e. those that don't have `>` in their type expressions. But it is -still useful to know what `>` does, since it appears when the compiler -infers the type of a variable or function that uses polymorphic variants. - -::: tip - -Fixed polymorphic variants and normal variants are roughly equivalent and can be -used interchangeably. - -::: +still useful to know what `>` does, since it appears when the compiler infers +the type of a variable or function that uses polymorphic variants. ## Implicit type variable Let's come back to the question of why the original attempt at a variant type -definition was syntactically invalid: +definition was semantically invalid: <<< Types.re#bad-discount-type @@ -153,8 +146,7 @@ The `[> ]` type expression means a polymorphic variant that has no tags, but allows more tags, which basically means any polymorphic variant. Note that adding this small restriction to the type doesn't make a real difference in this program---it's just a way to make it clear that `DiscountError`'s argument -should be a polymorphic variant. It's an optional embellishment that you can -feel free to leave out. +should be a polymorphic variant. --- From 63540662151e04713e8dce303f483206dba4ab9f Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Wed, 11 Sep 2024 21:59:51 -0500 Subject: [PATCH 11/13] Reword `>` section for clarity --- docs/poly-to-normal-variant/index.md | 49 +++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/docs/poly-to-normal-variant/index.md b/docs/poly-to-normal-variant/index.md index 121a217b..c9369183 100644 --- a/docs/poly-to-normal-variant/index.md +++ b/docs/poly-to-normal-variant/index.md @@ -90,12 +90,13 @@ The advantage of using a type variable for the definition of the `Promo.discount` type is that when you add, rename, or delete variant tags in `Discount`, you won't have to make corresponding edits to `Promo.discount`[^1]. -## `>` = "allow more than" +## `>` means open variant type -In the type expression above, we once again see `>`, so let's see what it means. -In polymorphic variant type expressions, it means "allow more than". In this -case, it means that tags other than the four that are listed are allowed. For -example, this type would be allowed: +In the type expression above, we once again see `>`, which means that it's an +*open variant type*. In polymorphic variant type expressions, it means "allow +more tags than the ones listed here". In this case, it means that tags other +than the four that are listed are allowed. To illustrate, this type (which has +two extra tags) is compatible: ```reason{5-6} discount([| `MissingSandwichTypes @@ -106,10 +107,40 @@ discount([| `MissingSandwichTypes | `KewpieMayo ]) ``` -When defining your own types, you will most often used *exact* polymorphic -variants, i.e. those that don't have `>` in their type expressions. But it is -still useful to know what `>` does, since it appears when the compiler infers -the type of a variable or function that uses polymorphic variants. +This type (which is missing one of the tags) is not compatible: + +```reason{5-6} +discount([| `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger ]) +``` + +For reference, there are also *closed variant types*, denoted by `<`, which +define all possible tags, but compatible types don't need to have all of those +tags. An example: + +```reason{1,9} +let getNoise = (animal: [< | `cat | `dog | `fox | `snake]) => + switch (animal) { + | `cat => "meow" + | `dog => "woof" + | `snake => "sss" + | `fox => "wowza" + }; + +let fox: [ | `fox] = `fox; +Js.log(getNoise(fox)); +``` + +The `getNoise` function accepts a type with as many as four tags, but it will +happily accept types that only have one tag (as long as that one tag is one of the +four tags). + +When defining your own polymorphic variant types, you will most often use *exact +variant types*, which define all their tags, with no flexibility to add or +remove tags. Exact variant types don't have `>` or `<` in their type +expressions. + ## Implicit type variable From 5fe39cf66d15a9c44fe22e907aff9b91a97e1395 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Thu, 12 Sep 2024 19:07:55 -0500 Subject: [PATCH 12/13] Ascribe change in polymorphic variant syntax to Reason, not Melange --- docs/poly-to-normal-variant/index.md | 25 ++++++++++++++----------- melange-for-react-devs.opam | 2 +- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/poly-to-normal-variant/index.md b/docs/poly-to-normal-variant/index.md index c9369183..e6fd43d9 100644 --- a/docs/poly-to-normal-variant/index.md +++ b/docs/poly-to-normal-variant/index.md @@ -23,8 +23,8 @@ brackets (`[]`) to turn them into variant constructors: ::: tip The alternative forms of a normal variant are called *variant constructors*, -while the forms of a polymorphic variant are called *variant tags* (and always -start with a backtick). +while the forms of a polymorphic variant are called *variant tags*. They are +easy to tell apart because variant tags always start with a backtick (`` ` ``). ::: @@ -193,13 +193,16 @@ you must make to add promo support to the `Order` component. - A type constructor takes a type and outputs another type - A type variable is a variable that stands in a for a type and often appears in type constructors or type signatures -- In polymorphic variant type expressions, `>` means that the polymorphic - variant can accept more than the variant tags that are listed - - You rarely need to use `>` in your own type definitions, but it often - appears in inferred type definitions (that appear when you hover over - variables and functions) - - Inferred type definitions that contain `>` also have an implicit type - variable +- In polymorphic variant type expressions, + - `>` means it's an open variant type that can accept more than the variant + tags that are listed + - `<` means it's a closed variant type that can accept fewer than the variant + tags that are listed +- You rarely need to use `>` or `<` in your own type definitions, but they + appear in inferred type definitions which you'll see in compiler error + messages or when you hover over variables/functions +- Inferred type definitions that contain `>` or `<` also have an implicit type + variable ## Exercises @@ -239,8 +242,8 @@ to add the implicit type variable in type annotations. ::: warning -In the next version of Melange, polymorphic variant definitions no longer -require a space between `[` and `|`. +In the next version of [Reason](https://reasonml.github.io/), polymorphic +variant definitions no longer require a space between `[` and `|`. ::: diff --git a/melange-for-react-devs.opam b/melange-for-react-devs.opam index f162590d..9719a760 100644 --- a/melange-for-react-devs.opam +++ b/melange-for-react-devs.opam @@ -8,7 +8,7 @@ homepage: "https://github.com/melange-re/melange-for-react-devs" bug-reports: "https://github.com/melange-re/melange-for-react-devs" depends: [ "ocaml" {>= "5.1.1"} - "reason" {>= "3.10.0"} + "reason" {>= "3.12.0"} "dune" {>= "3.8"} "melange" {>= "4.0.0-51"} "reason-react" {>= "0.14.0"} From 74fa01cd5ab8646f70c45d739bf3bcdce060dcc9 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Mon, 16 Sep 2024 23:33:57 -0500 Subject: [PATCH 13/13] Add sections about type-related error messages and putting `discount` type inside a submodule --- docs/poly-to-normal-variant/Promo2.re | 72 ++++++++++ docs/poly-to-normal-variant/index.md | 195 ++++++++++++++++++++++---- 2 files changed, 243 insertions(+), 24 deletions(-) create mode 100644 docs/poly-to-normal-variant/Promo2.re diff --git a/docs/poly-to-normal-variant/Promo2.re b/docs/poly-to-normal-variant/Promo2.re new file mode 100644 index 00000000..4d7a0f76 --- /dev/null +++ b/docs/poly-to-normal-variant/Promo2.re @@ -0,0 +1,72 @@ +// #region model-module +module Model = { + type t('a) = + | CodeError(Discount.error) + | Discount(float) + | DiscountError('a) + | NoSubmittedCode; +}; +// #endregion model-module + +let _ = + (submittedCode, date, items) => { + // #region namespace-constructor + let discount = + switch (submittedCode) { + | None => Model.NoSubmittedCode + | Some(code) => + switch (Discount.getDiscountFunction(code, date)) { + | Error(error) => CodeError(error) + | Ok(discountFunction) => + switch (discountFunction(items)) { + | Error(error) => DiscountError(error) + | Ok(value) => Discount(value) + } + } + }; + // #endregion namespace-constructor + + ignore(discount); + }; + +let _ = + (submittedCode, date, items) => { + // #region annotate-constructor + let discount = + switch (submittedCode) { + | None => (NoSubmittedCode: Model.t('a)) + | Some(code) => + switch (Discount.getDiscountFunction(code, date)) { + | Error(error) => CodeError(error) + | Ok(discountFunction) => + switch (discountFunction(items)) { + | Error(error) => DiscountError(error) + | Ok(value) => Discount(value) + } + } + }; + // #endregion annotate-constructor + + ignore(discount); + }; + +let _ = + (submittedCode, date, items) => { + // #region annotate-variable + let discount: Model.t('a) = + switch (submittedCode) { + | None => NoSubmittedCode + | Some(code) => + switch (Discount.getDiscountFunction(code, date)) { + | Error(error) => CodeError(error) + | Ok(discountFunction) => + switch (discountFunction(items)) { + | Error(error) => DiscountError(error) + | Ok(value) => Discount(value) + } + } + }; + // #endregion annotate-variable + + ignore(discount); + }; diff --git a/docs/poly-to-normal-variant/index.md b/docs/poly-to-normal-variant/index.md index e6fd43d9..e83a430c 100644 --- a/docs/poly-to-normal-variant/index.md +++ b/docs/poly-to-normal-variant/index.md @@ -3,12 +3,15 @@ The `Promo` component works fine now, but let's take a moment to explore how to implement it with a normal variant instead of a polymorphic variant. This small refactor will give us more insight into OCaml's type system. Additionally, -normal variants are better than polymorphic variants at "documenting" the types -that will be used in your program, since they must always be explicitly defined -before you can use them. +you'll learn about the pros and cons of normal variants versus polymorphic +variants. ## Add `discount` type +Make a copy of your `Promo.re` file and rename that copy to `PolyPromo.re`. +We'll make use of `PolyPromo.re` later, but for most of this chapter, we'll +focus our attention on `Promo.re`. + When we hover over the `discount` variable in `Promo.make`, we see this type expression: @@ -22,8 +25,8 @@ brackets (`[]`) to turn them into variant constructors: ::: tip -The alternative forms of a normal variant are called *variant constructors*, -while the forms of a polymorphic variant are called *variant tags*. They are +The alternate forms of a normal variant are called *variant constructors*, while +the alternate forms of a polymorphic variant are called *variant tags*. They are easy to tell apart because variant tags always start with a backtick (`` ` ``). ::: @@ -33,21 +36,21 @@ However, this results in a compilation error: <<< type-error.txt We'll come back to this error message later. For now, observe that the error -disappears if we simply delete `>`: +disappears if we simply delete the remaining `>`: -<<< Types.re#delete-refinement +<<< Types.re#delete-refinement{5} This fixes the syntax error so that we now have a correctly-defined normal variant type. ## Refactor `discount` variable to use normal variant -Refactor the `discount` derived variable inside `Promo.make` to use our new -variant type by deleting all occurrences of `` ` `` in the switch expression: +Inside `Promo.make`, refactor the `discount` derived variable to use our new +variant type by deleting all backticks in the switch expression: <<< Promo.re#discount-variant{3,6,9-10} -Likewise, the render logic can be fixed by removing all the `` ` `` occurrences: +Likewise, the render logic can be fixed by removing all backticks: <<< Promo.re#discount-render{2-4,13} @@ -59,13 +62,13 @@ tags don't need to be fully specified: <<< Types.re#delete-refinement{4-11} -The variant tags inside `DiscountError` originate from our `Discount` module, so +The variant tags inside `DiscountError` originate from the `Discount` module, so they shouldn't be needlessly constrained by the `discount` type defined inside the `Promo` module. Change the `discount` type to this: -<<< Types.re#type-variable +<<< Types.re#type-variable{4} Now `discount` is a *type constructor* that takes a *type variable* named `'a`. A type constructor is not a fixed type---you can think of it as a function that @@ -84,11 +87,18 @@ discount([> `MissingSandwichTypes This is essentially the same type as before (other than the `>` symbol, explained in the next section), just written differently. By looking at the usage of the `discount` variable, OCaml can infer how to fill in the type -variable and produce the fixed type shown above. +variable and produce the type shown above. The advantage of using a type variable for the definition of the `Promo.discount` type is that when you add, rename, or delete variant tags in -`Discount`, you won't have to make corresponding edits to `Promo.discount`[^1]. +`Discount`, you won't have to make corresponding edits to the `Promo.discount` +type[^1]. + +::: tip + +Type variable names must start with apostrophe (`'`). + +::: ## `>` means open variant type @@ -133,14 +143,18 @@ Js.log(getNoise(fox)); ``` The `getNoise` function accepts a type with as many as four tags, but it will -happily accept types that only have one tag (as long as that one tag is one of the -four tags). +happily accept types that only have one tag (as long as that one tag is one of +the four tags). When defining your own polymorphic variant types, you will most often use *exact variant types*, which define all their tags, with no flexibility to add or remove tags. Exact variant types don't have `>` or `<` in their type expressions. +Now that you know about open and closed variants, you might have intuited that +polymorphic variants are quite complex. The OCaml compiler agrees with you! Code +containing polymorphic variants takes a little longer to compile and run, +although for most programs the difference is not noticeable. ## Implicit type variable @@ -149,10 +163,11 @@ definition was semantically invalid: <<< Types.re#bad-discount-type -The reason is that there's an implicit type variable around the `>`. The -above type expression is equivalent to: +The reason is that there's an implicit type variable around the open variant +(the part that starts with `[>` and ends with `]`). The above type expression is +equivalent to: -<<< Types.re#explicit-type-var{14} +<<< Types.re#explicit-type-var{10} Now the error message makes a bit more sense: @@ -173,11 +188,143 @@ all, but to be explicit, we can force it to be a polymorphic variant: <<< Types.re#must-be-poly{4} -The `[> ]` type expression means a polymorphic variant that has no tags, but -allows more tags, which basically means any polymorphic variant. Note that -adding this small restriction to the type doesn't make a real difference in this -program---it's just a way to make it clear that `DiscountError`'s argument -should be a polymorphic variant. +The `[> ]` type expression denotes an open variant type with no tags. Since open +variant types allow more tags, this expression is compatible with any +polymorphic variant. Adding this small restriction is just a way to make it +clear that `DiscountError`'s argument must be a polymorphic variant. + +## Type-related error messages + +Let's introduce some type errors and compare the error messages between the +normal variant and polymorphic variant versions of the code. + +In `Promo.re`, change the `NoSubmittedCode` branch of the +`getDiscount(submittedCode)` switch expression to introduce a misspelling: + +```reason{2} +{switch (getDiscount(submittedCode)) { + | NoSubmittedCo => React.null + | Discount(discount) => discount |> Float.neg |> RR.currency +``` + +Change the equivalent line in `PolyPromo.re` in the same way. You should see +these error messages (the order might be different): + +```text{25-26} +File "src/order-confirmation/PolyPromo.re", lines 52-75, characters 4-7: +52 | ....{switch (discount) { +53 | | `NoSubmittedCo => React.null +54 | | `Discount(discount) => discount |> Float.neg |> RR.currency +55 | | `CodeError(error) => +56 |
    +... +72 |
    +73 | {RR.s({j|Buy $buyWhat to enjoy this promotion|j})} +74 |
    ; +75 | }} +Error (warning 8 [partial-match]): this pattern-matching is not exhaustive. +Here is an example of a case that is not matched: +`NoSubmittedCode + +File "src/order-confirmation/Promo.re", line 66, characters 7-20: +66 | | NoSubmittedCo => React.null + ^^^^^^^^^^^^^ +Error: This variant pattern is expected to have type + [> `MissingSandwichTypes of string list + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers ] + discount + There is no constructor NoSubmittedCo within type discount +Hint: Did you mean NoSubmittedCode? +Had 2 errors, waiting for filesystem changes... +``` + +The two error messages are very similar, but the normal variant error is a +little nicer. It points out that there's no `NoSubmittedCo` constructor in type +`discount`, and it gives you a friendly hint that maybe you meant to use +`NoSubmittedCode` instead (which is the case). + +Restore your code to its previous state so that there are no compilation errors. +Then add a new `FooBar` branch to the `getDiscount(submittedCode)` switch +expression: + +```reason{2} +{switch (getDiscount(submittedCode)) { + | FooBar => React.null + | NoSubmittedCode => React.null +``` + +Make the equivalent change in `PolyPromo.re`, and you should see a single +compilation error: + +```text{10} +File "src/order-confirmation/Promo.re", line 66, characters 7-13: +66 | | FooBar => React.null + ^^^^^^ +Error: This variant pattern is expected to have type + [> `MissingSandwichTypes of string list + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers ] + discount + There is no constructor FooBar within type discount +Had 1 error, waiting for filesystem changes... +``` + +OCaml knows that the `Promo.discount` type doesn't have a `FooBar` constructor, +but it doesn't know that the polymorphic variant used in `PolyPromo` doesn't +have a `` `FooBar`` tag. + +In general, error messages involving polymorphic variants are a bit less +helpful, and some type-related mistakes won't lead to compilation errors. For +simple use cases, it's often better to use normal variants, as they force you to +be more disciplined. + +## `discount` type inside a module + +Let's refactor the `discount` type so that it's inside a submodule named +`Model`: + +<<< Promo2.re#model-module + +This will cause the following compilation error: + +```text +File "src/order-with-promo/Promo.re", line 34, characters 14-29: +34 | | None => NoSubmittedCode + ^^^^^^^^^^^^^^^ +Error: Unbound constructor NoSubmittedCode +``` + +OCaml no longer knows where to find the `NoSubmittedCode` constructor, because +it's no longer accessible within the scope of the `Promo.make` function. One way +to tell the compiler where to find the `NoSubmittedCode` constructor is to +namespace it with the module name: + +<<< Promo2.re#namespace-constructor{3} + +Note that you only need to namespace a single constructor, since type inference +will figure out that the other constructors must come from the same module. +Another way to solve the problem is to type-annotate the `NoSubmittedCode` +constructor: + +<<< Promo2.re#annotate-constructor{3} + +Note that we don't need to provide a concrete type. We can use `Model.t('a)`, a +type expression containing a type variable, because type inference can look at +the usage to figure out the exact type. Yet another way to skin this cat is to +type-annotate the `discount` variable: + +<<< Promo2.re#annotate-variable{1} + +When you use variant constructors that are defined in another module, you often +have to reference them by using namespacing or type annotation. Variant tags +don't have this restriction, because they don't belong to any module or type. + +Feel free to delete the `Model` submodule and move the `discount` type back to +the top-level of `Promo`. That was a just temporary edit to demonstrate a point +but isn't really needed for this program. ---