From 26cd2cf7d9f7709a2835a5961887c13a9563be98 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 18 Jun 2024 21:55:00 -0500 Subject: [PATCH 01/30] Add 'order with promo' chapter --- docs/.vitepress/config.js | 1 + docs/order-with-promo/Discount.re | 49 ++++ docs/order-with-promo/DiscountTests.re | 54 +++++ docs/order-with-promo/Index.re | 17 ++ docs/order-with-promo/Item.re | 26 ++ docs/order-with-promo/Order.re | 59 +++++ docs/order-with-promo/Promo.re | 316 +++++++++++++++++++++++++ docs/order-with-promo/RR.re | 19 ++ docs/order-with-promo/dune | 6 + docs/order-with-promo/index.md | 35 +++ index.html | 3 + src/order-with-promo/Array.re | 7 + src/order-with-promo/BurgerTests.re | 85 +++++++ src/order-with-promo/Discount.re | 118 +++++++++ src/order-with-promo/DiscountTests.re | 248 +++++++++++++++++++ src/order-with-promo/Index.re | 35 +++ src/order-with-promo/Item.re | 99 ++++++++ src/order-with-promo/ListSafe.re | 2 + src/order-with-promo/Order.re | 61 +++++ src/order-with-promo/Promo.re | 77 ++++++ src/order-with-promo/RR.re | 15 ++ src/order-with-promo/SandwichTests.re | 43 ++++ src/order-with-promo/dune | 11 + src/order-with-promo/index.html | 12 + src/order-with-promo/tests.t | 123 ++++++++++ vite.config.mjs | 1 + 26 files changed, 1522 insertions(+) create mode 100644 docs/order-with-promo/Discount.re create mode 100644 docs/order-with-promo/DiscountTests.re create mode 100644 docs/order-with-promo/Index.re create mode 100644 docs/order-with-promo/Item.re create mode 100644 docs/order-with-promo/Order.re create mode 100644 docs/order-with-promo/Promo.re create mode 100644 docs/order-with-promo/RR.re create mode 100644 docs/order-with-promo/dune create mode 100644 docs/order-with-promo/index.md create mode 100644 src/order-with-promo/Array.re create mode 100644 src/order-with-promo/BurgerTests.re create mode 100644 src/order-with-promo/Discount.re create mode 100644 src/order-with-promo/DiscountTests.re create mode 100644 src/order-with-promo/Index.re create mode 100644 src/order-with-promo/Item.re create mode 100644 src/order-with-promo/ListSafe.re create mode 100644 src/order-with-promo/Order.re create mode 100644 src/order-with-promo/Promo.re create mode 100644 src/order-with-promo/RR.re create mode 100644 src/order-with-promo/SandwichTests.re create mode 100644 src/order-with-promo/dune create mode 100644 src/order-with-promo/index.html create mode 100644 src/order-with-promo/tests.t diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index df450c14..44b7896d 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: 'Order with Promo', link: '/order-with-promo/' }, ] } ], diff --git a/docs/order-with-promo/Discount.re b/docs/order-with-promo/Discount.re new file mode 100644 index 00000000..69d5ea56 --- /dev/null +++ b/docs/order-with-promo/Discount.re @@ -0,0 +1,49 @@ +type error = + | InvalidCode + | ExpiredCode; + +let getFreeBurgers = (_: list(Item.t)) => Error(`NeedTwoBurgers); + +let getHalfOff = (_: list(Item.t), ~date as _: Js.Date.t) => + Error(`NeedMegaBurger); +let getSandwichHalfOff = getHalfOff; + +// #region get-discount-function +let getDiscountFunction = (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(getFreeBurgers) + | "HALF" when month == 4.0 && dayOfMonth == 28.0 => Ok(getHalfOff(~date)) + | "HALF" when month == 10.0 && dayOfMonth == 3.0 => + Ok(getSandwichHalfOff(~date)) + | "FREE" + | "HALF" => Error(ExpiredCode) + | _ => Error(InvalidCode) + }; +}; +// #endregion get-discount-function + +ignore(getDiscountFunction); + +// #region get-discount-function-pair +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); +// #endregion get-discount-function-pair diff --git a/docs/order-with-promo/DiscountTests.re b/docs/order-with-promo/DiscountTests.re new file mode 100644 index 00000000..42201153 --- /dev/null +++ b/docs/order-with-promo/DiscountTests.re @@ -0,0 +1,54 @@ +open Fest; + +module GetDiscount' = { + // #region test-half-promo + 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( + Discount.getDiscountFunction("HALF", date), + dayOfMonth == 28 + ? Ok(Discount.getHalfOff(~date)) : Error(ExpiredCode), + ); + } + }); + // #endregion test-half-promo +}; + +// #region use-discount-function-pair +module GetDiscount = { + let getDiscountFunction = (code, date) => + Discount.getDiscountPair(code, date) |> Result.map(fst); + + // ... + + 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), + ); + } + }); + // ... +}; +// #endregion use-discount-function-pair diff --git a/docs/order-with-promo/Index.re b/docs/order-with-promo/Index.re new file mode 100644 index 00000000..ce43114b --- /dev/null +++ b/docs/order-with-promo/Index.re @@ -0,0 +1,17 @@ +let items = []; + +module Order = { + [@react.component] + let make = (~items as _) =>
; +}; + +// #region make +[@react.component] +let make = () => +
+

{RR.s("Promo")}

+ +

{RR.s("Order confirmation")}

+ +
; +// #endregion make diff --git a/docs/order-with-promo/Item.re b/docs/order-with-promo/Item.re new file mode 100644 index 00000000..8b8a0f2a --- /dev/null +++ b/docs/order-with-promo/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 = (_t: t) => 0.; + +let toEmoji = (_t: t) => ""; diff --git a/docs/order-with-promo/Order.re b/docs/order-with-promo/Order.re new file mode 100644 index 00000000..0c2661e4 --- /dev/null +++ b/docs/order-with-promo/Order.re @@ -0,0 +1,59 @@ +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) => + + {item |> Item.toEmoji |> RR.s} + {item |> Item.toPrice |> 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) => { + let total = + items + |> ListLabels.fold_left(~init=0., ~f=(acc, order) => + acc +. Item.toPrice(order) + ); + + + + {items + |> List.mapi((index, item) => + + ) + |> RR.list} + + + + + +
{RR.s("Total")} {total |> RR.currency}
; +}; diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re new file mode 100644 index 00000000..d50b2250 --- /dev/null +++ b/docs/order-with-promo/Promo.re @@ -0,0 +1,316 @@ +// #region first-version +[@react.component] +let make = () => { + let (code, setCode) = React.useState(() => ""); + +
evt |> React.Event.Form.preventDefault}> + setCode(_ => RR.getValueFromEvent(evt))} + /> + {RR.s("Todo: Discount value or error")} +
; +}; +// #endregion first-version + +ignore(make); + +// #region use-reducer +[@react.component] +let make = () => { + let (code, dispatch) = + React.useReducer((_state, newState) => newState, ""); + +
evt |> React.Event.Form.preventDefault}> + dispatch(RR.getValueFromEvent(evt))} + /> + {RR.s("Todo: Discount value or error")} +
; +}; +// #endregion use-reducer + +ignore(make); + +// #region set-code +[@react.component] +let make = () => { + let (code, setCode) = + React.useReducer((_state, newState) => newState, ""); + +
evt |> React.Event.Form.preventDefault}> + evt |> RR.getValueFromEvent |> setCode} + /> + {RR.s("Todo: Discount value or error")} +
; +}; +// #endregion set-code + +ignore(make); + +// #region ignore +[@react.component] +let make = (~items: list(Item.t), ~date: Js.Date.t) => { + ignore(items); + ignore(date); + // #endregion ignore +
; +}; + +// #region style-submodule +module Style = { + let form = [%cx {| + display: flex; + flex-direction: column; + |}]; + + let input = [%cx + {| + font-family: monospace; + text-transform: uppercase; + |} + ]; +}; +// #endregion style-submodule + +let _ = + () => { + let (code, setCode) = RR.useStateValue(""); + // #region updated-jsx +
evt |> React.Event.Form.preventDefault}> + evt |> RR.getValueFromEvent |> setCode} + /> + {RR.s("Todo: Discount value or error")} +
; + // #endregion updated-jsx + }; + +[@warning "-27"] +let _ = + () => { + // #region submitted-code + let (code, setCode) = RR.useStateValue(""); + let (submittedCode, setSubmittedCode) = RR.useStateValue(None); + +
{ + evt |> React.Event.Form.preventDefault; + setSubmittedCode(Some(code)); + }}> + { + evt |> RR.getValueFromEvent |> setCode; + setSubmittedCode(None); + }} + /> + {RR.s("Todo: Discount value or error")} +
; + // #endregion submitted-code + }; + +let _ = + date => { + // #region discount-function + let (submittedCode, setSubmittedCode) = RR.useStateValue(None); + + let discountFunction = + submittedCode + |> Option.map(code => Discount.getDiscountFunction(code, date)); + // #endregion discount-function + + ignore(setSubmittedCode); + ignore(discountFunction); + }; + +module Css = { + let input = ""; +}; + +module Style' = { + // #region code-error-class-name + let codeError = [%cx {|color: red|}]; + // #endregion code-error-class-name + + // #region discount-error-class-name + let discountError = [%cx {|color: purple|}]; + // #endregion discount-error-class-name +}; + +let _ = + discountFunction => { + module Style = Style'; + + <> + // #region render-discount-function + + {switch (discountFunction) { + | None + | Some(Ok(_)) => React.null + | Some(Error(error)) => +
+ {let errorType = + switch (error) { + | Discount.InvalidCode => "Invalid" + | ExpiredCode => "Expired" + }; + {j|$errorType promo code|j} |> RR.s} +
+ }} + // #endregion render-discount-function + ; + }; + +let _ = + (submittedCode, date, items) => { + // #region discount + + let discountFunction = + submittedCode + |> Option.map(code => Discount.getDiscountFunction(code, date)); + + let discount = + switch (discountFunction) { + | None + | Some(Error(_)) => None + | Some(Ok(discountFunction)) => Some(discountFunction(items)) + }; + // #endregion discount + + ignore(discount); + }; + +[@warning "-8"] +let _ = + (discountFunction, discount) => { + module Style = Style'; + + <> + // #region render-discount + {switch (discountFunction) { + | None + | Some(Ok(_)) => React.null + /* ... */ + }} + {switch (discount) { + | None => React.null + | Some(Ok(value)) => value |> Float.neg |> RR.currency + | Some(Error(_code)) => +
+ {RR.s("Todo: discount error message")} +
+ }} + // #endregion render-discount + ; + }; + +let _ = + (discountFunction, discount) => { + module Style = Style'; + + <> + // #region render-tuple + {switch (discountFunction, discount) { + | (Some(_), Some(Ok(value))) => value |> Float.neg |> RR.currency + | (Some(Error(error)), _) => +
+ {let errorType = + switch (error) { + | Discount.InvalidCode => "Invalid" + | ExpiredCode => "Expired" + }; + {j|$errorType promo code|j} |> RR.s} +
+ | (_, Some(Error(_code))) => +
+ {RR.s("Todo: discount error message")} +
+ | (None, None) + | (Some(_), None) + | (None, Some(_)) => React.null + }} + // #endregion render-tuple + ; + }; + +let _ = + (submittedCode, date, items) => { + // #region discount-poly + 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-poly + + ignore(discount); + }; + +let _ = + discount => { + module Style = Style'; + + <> + // #region render-discount-poly + {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) => +
+ {RR.s("Todo: discount error message")} +
+ }} + // #endregion render-discount-poly + ; + }; + +let _ = + discount => { + module Style = Style'; + + <> + // #region discount-error-message + {switch (discount) { + /* ... */ + | `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-error-message + ; + }; diff --git a/docs/order-with-promo/RR.re b/docs/order-with-promo/RR.re new file mode 100644 index 00000000..54572fe4 --- /dev/null +++ b/docs/order-with-promo/RR.re @@ -0,0 +1,19 @@ +// #region initial-functions +/** 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; +// #endregion initial-functions + +// #region use-state-value +/** Like [React.useState] but doesn't use callback functions */ +let useStateValue = initial => + React.useReducer((_state, newState) => newState, initial); +// #endregion use-state-value diff --git a/docs/order-with-promo/dune b/docs/order-with-promo/dune new file mode 100644 index 00000000..e411ebad --- /dev/null +++ b/docs/order-with-promo/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/order-with-promo/index.md b/docs/order-with-promo/index.md new file mode 100644 index 00000000..6ef5a116 --- /dev/null +++ b/docs/order-with-promo/index.md @@ -0,0 +1,35 @@ +# Order with Promo + +intro + +## section + +tbd + +--- + +summary + +## Overview + +- tbd + +## Exercises + +1. tbd + +2. tbd + +3. tbd + +4. tbd + +----- + +View [source +code](https://github.com/melange-re/melange-for-react-devs/blob/main/src/order-with-promo/) +and [demo](https://react-book.melange.re/demo/src/order-with-promo/) for this chapter. + +----- + +footnotes diff --git a/index.html b/index.html index 3dd4c813..6565f5e4 100644 --- a/index.html +++ b/index.html @@ -50,6 +50,9 @@

Melange for React Developers

  • Promo Component
  • +
  • + Order with Promo +
  • diff --git a/src/order-with-promo/Array.re b/src/order-with-promo/Array.re new file mode 100644 index 00000000..9582e9a9 --- /dev/null +++ b/src/order-with-promo/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/order-with-promo/BurgerTests.re b/src/order-with-promo/BurgerTests.re new file mode 100644 index 00000000..e720b427 --- /dev/null +++ b/src/order-with-promo/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/order-with-promo/Discount.re b/src/order-with-promo/Discount.re new file mode 100644 index 00000000..fcbac1e4 --- /dev/null +++ b/src/order-with-promo/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/order-with-promo/DiscountTests.re b/src/order-with-promo/DiscountTests.re new file mode 100644 index 00000000..05121a8a --- /dev/null +++ b/src/order-with-promo/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/order-with-promo/Index.re b/src/order-with-promo/Index.re new file mode 100644 index 00000000..996226b1 --- /dev/null +++ b/src/order-with-promo/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/order-with-promo/Item.re b/src/order-with-promo/Item.re new file mode 100644 index 00000000..9a09d9e0 --- /dev/null +++ b/src/order-with-promo/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/order-with-promo/ListSafe.re b/src/order-with-promo/ListSafe.re new file mode 100644 index 00000000..6eba66a7 --- /dev/null +++ b/src/order-with-promo/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/order-with-promo/Order.re b/src/order-with-promo/Order.re new file mode 100644 index 00000000..8f946f3d --- /dev/null +++ b/src/order-with-promo/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/order-with-promo/Promo.re b/src/order-with-promo/Promo.re new file mode 100644 index 00000000..842aad9b --- /dev/null +++ b/src/order-with-promo/Promo.re @@ -0,0 +1,77 @@ +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|}]; +}; + +[@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/order-with-promo/RR.re b/src/order-with-promo/RR.re new file mode 100644 index 00000000..5dce9616 --- /dev/null +++ b/src/order-with-promo/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/order-with-promo/SandwichTests.re b/src/order-with-promo/SandwichTests.re new file mode 100644 index 00000000..abaac03c --- /dev/null +++ b/src/order-with-promo/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/order-with-promo/dune b/src/order-with-promo/dune new file mode 100644 index 00000000..095fe4de --- /dev/null +++ b/src/order-with-promo/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/order-with-promo/index.html b/src/order-with-promo/index.html new file mode 100644 index 00000000..2d80c776 --- /dev/null +++ b/src/order-with-promo/index.html @@ -0,0 +1,12 @@ + + + + + + Melange for React Devs + + + +
    + + diff --git a/src/order-with-promo/tests.t b/src/order-with-promo/tests.t new file mode 100644 index 00000000..f5aef5df --- /dev/null +++ b/src/order-with-promo/tests.t @@ -0,0 +1,123 @@ +Sandwich tests + $ node ./output/src/order-with-promo/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/order-with-promo/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/order-with-promo/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 32316874..81dacdc3 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'), + 'order-with-promo': resolve(__dirname, 'src/order-with-promo/index.html'), }, }, }, From 7dfe202d530e6aac08593e11bcc8259ba60adf1d Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Thu, 20 Jun 2024 11:25:27 -0500 Subject: [PATCH 02/30] Add 'type constructor and type variable' section --- docs/order-with-promo/index.md | 130 ++++++++++++++++++++++++++++++++- src/order-with-promo/Promo.re | 9 ++- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 6ef5a116..090d12f2 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -1,10 +1,134 @@ # Order with Promo -intro +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 an explicit type for the `discount` derived +variable inside `Promo`. We do so not because we have to, but because it will +give us some more insight into OCaml's type system. + +Your first instinct might be to hover over the `discount` variable and copy and +paste the inferred type: + +```reason +type discount = [> + | `CodeError(Discount.error) + | `Discount(float) + | `DiscountError( + [> + | `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers + ], + ) + | `NoSubmittedCode +]; +``` + +However, this results in a compilation error: + +```text +Error: A type variable is unbound in this type declaration. + In type + [> `CodeError of Melange.Discount.error + | `Discount of float + | `DiscountError of + [> `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers ] + | `NoSubmittedCode ] + as 'a the variable 'a is unbound +``` + +We'll come back to this error message later. For now, observe that the error +disappears if we simply delete all the instances of `>`: + +```reason +type discount = [ + | `CodeError(Discount.error) + | `Discount(float) + | `DiscountError( + [ + | `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers + ], + ) + | `NoSubmittedCode +]; +``` + +This fixes the syntax error so that we now have a correctly-defined polymorphic +variant type. + +## Type annotate `discount` variable + +We just defined a new type, but our `discount` variable doesn't know anything +about it. Since polymorphic variants can be used without explicitly defining +them, type inference works differently for them. In particular, our `discount` +variable continues to use its own inferred type, despite there being a perfectly +good type within scope that uses the same tags. + +Type annotate the `discount` variable with the newly-created `discount` type: + +```reason +let discount = // [!code --] +let discount: discount = // [!code ++] +``` + +Now when you hover over the `discount` variable, you'll that its type is just +`discount`. + +## Type constructor and type variable + +Change the `discount` type to this: + +```reason +type discount('a) = [ + | `CodeError(Discount.error) + | `Discount(float) + | `DiscountError('a) + | `NoSubmittedCode +]; +``` + +Now `discount` is a *type constructor* that takes a *type variable* named `'a`. +A type constructor is not a concrete type---it's more useful to think of it as +function that takes a type and outputs a new type. This is reinforced by the +compilation error we now see: + +```text +31 | let discount: discount = + ^^^^^^^^ +Error: The type constructor discount expects 1 argument(s), + but is here applied to 0 argument(s) +``` + +You can fix it like so: + +```reason +let discount: discount = // [!code --] +let discount: discount(_) = // [!code ++] +``` + +We use `discount(_)` to tell the compiler that it should use the `discount` type +constructor, but the value of its argument should be inferred. Now if we hover +over the `discount` variable, we see that its type is: + +```reason +discount([> `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers ]) +``` -## section -tbd --- diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index 842aad9b..f1840dcb 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -16,12 +16,19 @@ module Style = { let discountError = [%cx {|color: purple|}]; }; +type discount('a) = [ + | `CodeError(Discount.error) + | `Discount(float) + | `DiscountError('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 = + let discount: discount(_) = switch (submittedCode) { | None => `NoSubmittedCode | Some(code) => From b8af14347dbb95e92e3a556d8f004df4639bbe3a Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Fri, 21 Jun 2024 11:51:14 -0500 Subject: [PATCH 03/30] Add 'implicit type variable' section --- docs/order-with-promo/Discount.re | 46 ---- docs/order-with-promo/DiscountTests.re | 53 ----- docs/order-with-promo/Index.re | 16 -- docs/order-with-promo/Order.re | 58 ----- docs/order-with-promo/Promo.re | 315 ------------------------- docs/order-with-promo/Types.re | 1 + docs/order-with-promo/index.md | 106 ++++++++- src/order-with-promo/Promo.re | 2 +- 8 files changed, 104 insertions(+), 493 deletions(-) create mode 100644 docs/order-with-promo/Types.re diff --git a/docs/order-with-promo/Discount.re b/docs/order-with-promo/Discount.re index 69d5ea56..5c980950 100644 --- a/docs/order-with-promo/Discount.re +++ b/docs/order-with-promo/Discount.re @@ -1,49 +1,3 @@ type error = | InvalidCode | ExpiredCode; - -let getFreeBurgers = (_: list(Item.t)) => Error(`NeedTwoBurgers); - -let getHalfOff = (_: list(Item.t), ~date as _: Js.Date.t) => - Error(`NeedMegaBurger); -let getSandwichHalfOff = getHalfOff; - -// #region get-discount-function -let getDiscountFunction = (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(getFreeBurgers) - | "HALF" when month == 4.0 && dayOfMonth == 28.0 => Ok(getHalfOff(~date)) - | "HALF" when month == 10.0 && dayOfMonth == 3.0 => - Ok(getSandwichHalfOff(~date)) - | "FREE" - | "HALF" => Error(ExpiredCode) - | _ => Error(InvalidCode) - }; -}; -// #endregion get-discount-function - -ignore(getDiscountFunction); - -// #region get-discount-function-pair -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); -// #endregion get-discount-function-pair diff --git a/docs/order-with-promo/DiscountTests.re b/docs/order-with-promo/DiscountTests.re index 42201153..8b137891 100644 --- a/docs/order-with-promo/DiscountTests.re +++ b/docs/order-with-promo/DiscountTests.re @@ -1,54 +1 @@ -open Fest; -module GetDiscount' = { - // #region test-half-promo - 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( - Discount.getDiscountFunction("HALF", date), - dayOfMonth == 28 - ? Ok(Discount.getHalfOff(~date)) : Error(ExpiredCode), - ); - } - }); - // #endregion test-half-promo -}; - -// #region use-discount-function-pair -module GetDiscount = { - let getDiscountFunction = (code, date) => - Discount.getDiscountPair(code, date) |> Result.map(fst); - - // ... - - 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), - ); - } - }); - // ... -}; -// #endregion use-discount-function-pair diff --git a/docs/order-with-promo/Index.re b/docs/order-with-promo/Index.re index ce43114b..8b137891 100644 --- a/docs/order-with-promo/Index.re +++ b/docs/order-with-promo/Index.re @@ -1,17 +1 @@ -let items = []; -module Order = { - [@react.component] - let make = (~items as _) =>
    ; -}; - -// #region make -[@react.component] -let make = () => -
    -

    {RR.s("Promo")}

    - -

    {RR.s("Order confirmation")}

    - -
    ; -// #endregion make diff --git a/docs/order-with-promo/Order.re b/docs/order-with-promo/Order.re index 0c2661e4..8b137891 100644 --- a/docs/order-with-promo/Order.re +++ b/docs/order-with-promo/Order.re @@ -1,59 +1 @@ -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) => - - {item |> Item.toEmoji |> RR.s} - {item |> Item.toPrice |> 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) => { - let total = - items - |> ListLabels.fold_left(~init=0., ~f=(acc, order) => - acc +. Item.toPrice(order) - ); - - - - {items - |> List.mapi((index, item) => - - ) - |> RR.list} - - - - - -
    {RR.s("Total")} {total |> RR.currency}
    ; -}; diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re index d50b2250..8b137891 100644 --- a/docs/order-with-promo/Promo.re +++ b/docs/order-with-promo/Promo.re @@ -1,316 +1 @@ -// #region first-version -[@react.component] -let make = () => { - let (code, setCode) = React.useState(() => ""); -
    evt |> React.Event.Form.preventDefault}> - setCode(_ => RR.getValueFromEvent(evt))} - /> - {RR.s("Todo: Discount value or error")} -
    ; -}; -// #endregion first-version - -ignore(make); - -// #region use-reducer -[@react.component] -let make = () => { - let (code, dispatch) = - React.useReducer((_state, newState) => newState, ""); - -
    evt |> React.Event.Form.preventDefault}> - dispatch(RR.getValueFromEvent(evt))} - /> - {RR.s("Todo: Discount value or error")} -
    ; -}; -// #endregion use-reducer - -ignore(make); - -// #region set-code -[@react.component] -let make = () => { - let (code, setCode) = - React.useReducer((_state, newState) => newState, ""); - -
    evt |> React.Event.Form.preventDefault}> - evt |> RR.getValueFromEvent |> setCode} - /> - {RR.s("Todo: Discount value or error")} -
    ; -}; -// #endregion set-code - -ignore(make); - -// #region ignore -[@react.component] -let make = (~items: list(Item.t), ~date: Js.Date.t) => { - ignore(items); - ignore(date); - // #endregion ignore -
    ; -}; - -// #region style-submodule -module Style = { - let form = [%cx {| - display: flex; - flex-direction: column; - |}]; - - let input = [%cx - {| - font-family: monospace; - text-transform: uppercase; - |} - ]; -}; -// #endregion style-submodule - -let _ = - () => { - let (code, setCode) = RR.useStateValue(""); - // #region updated-jsx -
    evt |> React.Event.Form.preventDefault}> - evt |> RR.getValueFromEvent |> setCode} - /> - {RR.s("Todo: Discount value or error")} -
    ; - // #endregion updated-jsx - }; - -[@warning "-27"] -let _ = - () => { - // #region submitted-code - let (code, setCode) = RR.useStateValue(""); - let (submittedCode, setSubmittedCode) = RR.useStateValue(None); - -
    { - evt |> React.Event.Form.preventDefault; - setSubmittedCode(Some(code)); - }}> - { - evt |> RR.getValueFromEvent |> setCode; - setSubmittedCode(None); - }} - /> - {RR.s("Todo: Discount value or error")} -
    ; - // #endregion submitted-code - }; - -let _ = - date => { - // #region discount-function - let (submittedCode, setSubmittedCode) = RR.useStateValue(None); - - let discountFunction = - submittedCode - |> Option.map(code => Discount.getDiscountFunction(code, date)); - // #endregion discount-function - - ignore(setSubmittedCode); - ignore(discountFunction); - }; - -module Css = { - let input = ""; -}; - -module Style' = { - // #region code-error-class-name - let codeError = [%cx {|color: red|}]; - // #endregion code-error-class-name - - // #region discount-error-class-name - let discountError = [%cx {|color: purple|}]; - // #endregion discount-error-class-name -}; - -let _ = - discountFunction => { - module Style = Style'; - - <> - // #region render-discount-function - - {switch (discountFunction) { - | None - | Some(Ok(_)) => React.null - | Some(Error(error)) => -
    - {let errorType = - switch (error) { - | Discount.InvalidCode => "Invalid" - | ExpiredCode => "Expired" - }; - {j|$errorType promo code|j} |> RR.s} -
    - }} - // #endregion render-discount-function - ; - }; - -let _ = - (submittedCode, date, items) => { - // #region discount - - let discountFunction = - submittedCode - |> Option.map(code => Discount.getDiscountFunction(code, date)); - - let discount = - switch (discountFunction) { - | None - | Some(Error(_)) => None - | Some(Ok(discountFunction)) => Some(discountFunction(items)) - }; - // #endregion discount - - ignore(discount); - }; - -[@warning "-8"] -let _ = - (discountFunction, discount) => { - module Style = Style'; - - <> - // #region render-discount - {switch (discountFunction) { - | None - | Some(Ok(_)) => React.null - /* ... */ - }} - {switch (discount) { - | None => React.null - | Some(Ok(value)) => value |> Float.neg |> RR.currency - | Some(Error(_code)) => -
    - {RR.s("Todo: discount error message")} -
    - }} - // #endregion render-discount - ; - }; - -let _ = - (discountFunction, discount) => { - module Style = Style'; - - <> - // #region render-tuple - {switch (discountFunction, discount) { - | (Some(_), Some(Ok(value))) => value |> Float.neg |> RR.currency - | (Some(Error(error)), _) => -
    - {let errorType = - switch (error) { - | Discount.InvalidCode => "Invalid" - | ExpiredCode => "Expired" - }; - {j|$errorType promo code|j} |> RR.s} -
    - | (_, Some(Error(_code))) => -
    - {RR.s("Todo: discount error message")} -
    - | (None, None) - | (Some(_), None) - | (None, Some(_)) => React.null - }} - // #endregion render-tuple - ; - }; - -let _ = - (submittedCode, date, items) => { - // #region discount-poly - 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-poly - - ignore(discount); - }; - -let _ = - discount => { - module Style = Style'; - - <> - // #region render-discount-poly - {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) => -
    - {RR.s("Todo: discount error message")} -
    - }} - // #endregion render-discount-poly - ; - }; - -let _ = - discount => { - module Style = Style'; - - <> - // #region discount-error-message - {switch (discount) { - /* ... */ - | `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-error-message - ; - }; diff --git a/docs/order-with-promo/Types.re b/docs/order-with-promo/Types.re new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/order-with-promo/Types.re @@ -0,0 +1 @@ + diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 090d12f2..cb47f7c9 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -10,8 +10,20 @@ But first, let's see how to create an explicit type for the `discount` derived variable inside `Promo`. We do so not because we have to, but because it will give us some more insight into OCaml's type system. -Your first instinct might be to hover over the `discount` variable and copy and -paste the inferred type: +When we hover over the `discount` variable, we see this: + +```reason +[> `CodeError(Discount.error) + | `Discount(float) + | `DiscountError([> `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers ]) + | `NoSubmittedCode ] +``` + +The easiest thing to do is to create a new `discount` type and assign it to that +type expression: ```reason type discount = [> @@ -99,9 +111,9 @@ type discount('a) = [ ``` Now `discount` is a *type constructor* that takes a *type variable* named `'a`. -A type constructor is not a concrete type---it's more useful to think of it as +A type constructor is not a fixed type---it's more useful to think of it as function that takes a type and outputs a new type. This is reinforced by the -compilation error we now see: +compilation error we get: ```text 31 | let discount: discount = @@ -128,6 +140,92 @@ discount([> `MissingSandwichTypes | `NeedTwoBurgers ]) ``` +## The meaning of `>` + +Once again, we see `>`. In polymorphic variant type expressions, it means "allow +more than". 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 ]) +``` + +Generally, you won't need to use `>` in your own type definitions, but it often +appears when the compiler is allowed to infer 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 type definition +is syntactically invalid: + +```reason +type discount = [> + | `CodeError(Discount.error) + | `Discount(float) + | `DiscountError( + [> + | `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers + ], + ) + | `NoSubmittedCode +]; +``` + +The reason is that whenever you have `>`, you implicitly have a type variable. +So the above code is equivalent to this: + +```reason{13} +type discount = [> + | `CodeError(Discount.error) + | `Discount(float) + | `DiscountError( + [> + | `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers + ], + ) + | `NoSubmittedCode +] as 'a; +``` + +Now the error message makes a bit more sense: + +> Error: A type variable is unbound in this type declaration. + +The type variable exists, but it doesn't appear as an argument of the `discount` +type constructor. Once it's added, it compiles: + +```reason{1} +type discount('a) = + [> + | `CodeError(Discount.error) + | `Discount(float) + | `DiscountError( + [> + | `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers + ], + ) + | `NoSubmittedCode + ] as 'a; +``` + +This is somewhat like accidentally using a variable in a function but forgetting +to add that variable to the function's argument list. + --- diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index f1840dcb..c36af609 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -28,7 +28,7 @@ let make = (~items: list(Item.t), ~date: Js.Date.t) => { let (code, setCode) = RR.useStateValue(""); let (submittedCode, setSubmittedCode) = RR.useStateValue(None); - let discount: discount(_) = + let discount = switch (submittedCode) { | None => `NoSubmittedCode | Some(code) => From 6ce0b07c35b4552ebe51fcc82135a936c410bac7 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Mon, 24 Jun 2024 21:59:14 -0500 Subject: [PATCH 04/30] Add type definitions from snippets drawn from Types.re --- docs/order-with-promo/Types.re | 81 ++++++++++++++++++++++++ docs/order-with-promo/index.md | 110 +++++---------------------------- 2 files changed, 96 insertions(+), 95 deletions(-) diff --git a/docs/order-with-promo/Types.re b/docs/order-with-promo/Types.re index 8b137891..929c9850 100644 --- a/docs/order-with-promo/Types.re +++ b/docs/order-with-promo/Types.re @@ -1 +1,82 @@ +/** +// #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 + ], + ) + | `NoSubmittedCode + ] as 'a; +// #endregion explicit-type-var +*/ +module AddTypeArg = { + // #region add-type-arg + type discount('a) = + [> + | `CodeError(Discount.error) + | `Discount(float) + | `DiscountError( + [> + | `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers + ], + ) + | `NoSubmittedCode + ] as 'a; + // #endregion add-type-arg +}; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index cb47f7c9..d72a8012 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -25,21 +25,7 @@ When we hover over the `discount` variable, we see this: The easiest thing to do is to create a new `discount` type and assign it to that type expression: -```reason -type discount = [> - | `CodeError(Discount.error) - | `Discount(float) - | `DiscountError( - [> - | `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers - ], - ) - | `NoSubmittedCode -]; -``` +<<< Types.re#bad-discount-type However, this results in a compilation error: @@ -60,21 +46,7 @@ Error: A type variable is unbound in this type declaration. We'll come back to this error message later. For now, observe that the error disappears if we simply delete all the instances of `>`: -```reason -type discount = [ - | `CodeError(Discount.error) - | `Discount(float) - | `DiscountError( - [ - | `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers - ], - ) - | `NoSubmittedCode -]; -``` +<<< Types.re#delete-refinement This fixes the syntax error so that we now have a correctly-defined polymorphic variant type. @@ -85,7 +57,7 @@ We just defined a new type, but our `discount` variable doesn't know anything about it. Since polymorphic variants can be used without explicitly defining them, type inference works differently for them. In particular, our `discount` variable continues to use its own inferred type, despite there being a perfectly -good type within scope that uses the same tags. +good type within scope that uses the same variant tags. Type annotate the `discount` variable with the newly-created `discount` type: @@ -94,26 +66,19 @@ let discount = // [!code --] let discount: discount = // [!code ++] ``` -Now when you hover over the `discount` variable, you'll that its type is just +Now when you hover over the `discount` variable, you see that its type is just `discount`. ## Type constructor and type variable Change the `discount` type to this: -```reason -type discount('a) = [ - | `CodeError(Discount.error) - | `Discount(float) - | `DiscountError('a) - | `NoSubmittedCode -]; -``` +<<< 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---it's more useful to think of it as -function that takes a type and outputs a new type. This is reinforced by the -compilation error we get: +A type constructor is not a fixed type---you can think of it as function that +takes a type and outputs a new type. This is reinforced by the compilation error +we get: ```text 31 | let discount: discount = @@ -130,8 +95,8 @@ let discount: discount(_) = // [!code ++] ``` We use `discount(_)` to tell the compiler that it should use the `discount` type -constructor, but the value of its argument should be inferred. Now if we hover -over the `discount` variable, we see that its type is: +constructor, but the value of its argument should be inferred. Now if you hover +over the `discount` variable, you see that its type is: ```reason discount([> `MissingSandwichTypes @@ -143,8 +108,8 @@ discount([> `MissingSandwichTypes ## The meaning of `>` Once again, we see `>`. In polymorphic variant type expressions, it means "allow -more than". It means that tags other than the four that are listed are allowed. -For example, this type would be allowed: +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 @@ -164,40 +129,12 @@ that uses polymorphic variants. Let's come back to the question of why the original attempt at a type definition is syntactically invalid: -```reason -type discount = [> - | `CodeError(Discount.error) - | `Discount(float) - | `DiscountError( - [> - | `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers - ], - ) - | `NoSubmittedCode -]; -``` +<<< Types.re#bad-discount-type The reason is that whenever you have `>`, you implicitly have a type variable. So the above code is equivalent to this: -```reason{13} -type discount = [> - | `CodeError(Discount.error) - | `Discount(float) - | `DiscountError( - [> - | `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers - ], - ) - | `NoSubmittedCode -] as 'a; -``` +<<< Types.re#explicit-type-var{13} Now the error message makes a bit more sense: @@ -206,28 +143,11 @@ Now the error message makes a bit more sense: The type variable exists, but it doesn't appear as an argument of the `discount` type constructor. Once it's added, it compiles: -```reason{1} -type discount('a) = - [> - | `CodeError(Discount.error) - | `Discount(float) - | `DiscountError( - [> - | `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers - ], - ) - | `NoSubmittedCode - ] as 'a; -``` +<<< 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. - - --- summary From a2d702b0906d8e8ed162e7ffdccf33cb8076f926 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 25 Jun 2024 23:41:08 -0500 Subject: [PATCH 05/30] Add 'add demo component' section --- docs/order-with-promo/DateInput.re | 13 ++++++++++++ docs/order-with-promo/Demo.re | 27 +++++++++++++++++++++++++ docs/order-with-promo/Index.re | 9 ++++++++- docs/order-with-promo/Order.re | 3 +++ docs/order-with-promo/Promo.re | 3 ++- docs/order-with-promo/index.md | 32 ++++++++++++++++++++++++++++-- 6 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 docs/order-with-promo/DateInput.re create mode 100644 docs/order-with-promo/Demo.re diff --git a/docs/order-with-promo/DateInput.re b/docs/order-with-promo/DateInput.re new file mode 100644 index 00000000..944580ea --- /dev/null +++ b/docs/order-with-promo/DateInput.re @@ -0,0 +1,13 @@ +let stringToDate = s => + // add "T00:00" to make sure the date is in local time + s ++ "T00:00" |> Js.Date.fromString; + +[@react.component] +let make = (~date: Js.Date.t, ~onChange: Js.Date.t => unit) => { + Js.Date.toISOString |> Js.String.substring(~end_=10)} + onChange={evt => evt |> RR.getValueFromEvent |> stringToDate |> onChange} + />; +}; diff --git a/docs/order-with-promo/Demo.re b/docs/order-with-promo/Demo.re new file mode 100644 index 00000000..25fa5c2a --- /dev/null +++ b/docs/order-with-promo/Demo.re @@ -0,0 +1,27 @@ +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("Promo")}

    + +

    {RR.s("Order")}

    + +
    ; +}; diff --git a/docs/order-with-promo/Index.re b/docs/order-with-promo/Index.re index 8b137891..71fe02d4 100644 --- a/docs/order-with-promo/Index.re +++ b/docs/order-with-promo/Index.re @@ -1 +1,8 @@ - +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/Order.re b/docs/order-with-promo/Order.re index 8b137891..24f5561d 100644 --- a/docs/order-with-promo/Order.re +++ b/docs/order-with-promo/Order.re @@ -1 +1,4 @@ +type t = list(Item.t); +[@react.component] +let make = (~items as _: list(Item.t), ~date as _: Js.Date.t) =>
    ; diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re index 8b137891..8870c67e 100644 --- a/docs/order-with-promo/Promo.re +++ b/docs/order-with-promo/Promo.re @@ -1 +1,2 @@ - +[@react.component] +let make = (~items as _: list(Item.t), ~date as _: Js.Date.t) =>
    ; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index d72a8012..07c68aa1 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -105,7 +105,7 @@ discount([> `MissingSandwichTypes | `NeedTwoBurgers ]) ``` -## The meaning of `>` +## `>` = "allow more than" Once again, we see `>`. In polymorphic variant type expressions, it means "allow more than". In this case, it means that tags other than the four that are listed @@ -134,7 +134,7 @@ is syntactically invalid: The reason is that whenever you have `>`, you implicitly have a type variable. So the above code is equivalent to this: -<<< Types.re#explicit-type-var{13} +<<< Types.re#explicit-type-var{14} Now the error message makes a bit more sense: @@ -148,6 +148,34 @@ type constructor. Once it's added, it compiles: This is somewhat like accidentally using a variable in a function but forgetting to add that variable to the function's argument list. +## Do you need to make a type for `discount`? + +When using a polymorphic variant type, you don't generally need to explicitly +define a type. [Later](/todo), we'll show you an example when you must define +types to take advantage of the more advanced features of polymorphic variants. + +## Add `DateInput` component + +To see different promotions in action, we want to be able to change easily +change the date in our demo, so add a new file `DateInput.re`: + +<<< DateInput.re + +Note how 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. + +## 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{16-17,21} + +Change `Index` to use the new `Demo` component: + +<<< Index.re{7} + --- summary From f1f7d52b7000623c03e2a13cdad293c635b278e8 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Wed, 26 Jun 2024 21:53:17 -0500 Subject: [PATCH 06/30] Add 'RR.useEffect1 helper function' section --- docs/order-with-promo/Promo.re | 62 +++++++++++++++++ docs/order-with-promo/RR.re | 20 ++---- docs/order-with-promo/index.md | 111 ++++++++++++++++++++++++++++-- src/order-with-promo/DateInput.re | 13 ++++ src/order-with-promo/Demo.re | 27 ++++++++ src/order-with-promo/Index.re | 29 +------- src/order-with-promo/Promo.re | 16 ++++- src/order-with-promo/RR.re | 3 + 8 files changed, 232 insertions(+), 49 deletions(-) create mode 100644 src/order-with-promo/DateInput.re create mode 100644 src/order-with-promo/Demo.re diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re index 8870c67e..4f904b2a 100644 --- a/docs/order-with-promo/Promo.re +++ b/docs/order-with-promo/Promo.re @@ -1,2 +1,64 @@ [@react.component] let make = (~items as _: list(Item.t), ~date as _: Js.Date.t) =>
    ; + +[@warning "-27"] +module AddOnApply = { + // #region add-on-apply + [@react.component] + let make = + (~items: list(Item.t), ~date: Js.Date.t, ~onApply: float => unit) => + // #endregion add-on-apply +
    ; +}; + +[@warning "-27"] +module DefaultValue = { + // #region default-value + [@react.component] + let make = + ( + ~items: list(Item.t), + ~date: Js.Date.t, + ~onApply: float => unit=_ => (), + ) => + // #endregion default-value +
    ; +}; + +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 + (); + }; diff --git a/docs/order-with-promo/RR.re b/docs/order-with-promo/RR.re index 54572fe4..2f785dcf 100644 --- a/docs/order-with-promo/RR.re +++ b/docs/order-with-promo/RR.re @@ -1,19 +1,11 @@ -// #region initial-functions -/** Get string value from the given event's target */ -let getValueFromEvent = (evt): string => React.Event.Form.target(evt)##value; +let getValueFromEvent = _evt => ""; -/** 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; -// #endregion initial-functions - -// #region use-state-value -/** Like [React.useState] but doesn't use callback functions */ let useStateValue = initial => React.useReducer((_state, newState) => newState, initial); -// #endregion use-state-value + +// #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 07c68aa1..2d183b24 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -150,14 +150,16 @@ to add that variable to the function's argument list. ## Do you need to make a type for `discount`? -When using a polymorphic variant type, you don't generally need to explicitly -define a type. [Later](/todo), we'll show you an example when you must define -types to take advantage of the more advanced features of polymorphic variants. +When using a polymorphic variant, you don't generally need to explicitly define +a type. But it doesn't hurt to do so, and can serve as a sort of "documentation" +by showing all your variant tags in one place. [Later](/todo), we'll show you an +example of when you must define types to take advantage of the more advanced +features of polymorphic variants. ## Add `DateInput` component -To see different promotions in action, we want to be able to change easily -change the date in our demo, so add a new file `DateInput.re`: +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`: <<< DateInput.re @@ -176,6 +178,100 @@ Change `Index` to use the new `Demo` component: <<< Index.re{7} +## Add `onApply` prop to `Promo` + +Add an `onApply` prop to `Promo`, which will be invoked when a promo code is +successfully submitted and results in a discount: + +<<< Promo.re#add-on-apply{3} + +This causes a compilation error because the `Order` component doesn't provide a +value for that prop yet. We can temporarily silence the error by giving +`onApply` a default value: + +<<< Promo.re#default-value{6} + +The default value we've provided, `_ => ()`, is basically a no-op. We can remove +this default value once we're ready to use `onApply` inside `Order`. + +## Add `React.useEffect1` + +Your first instinct might be to add a `useEffect` hook that invokes `onApply` +when `discount` has a value of the form `` `Discount(value) ``: + +<<< Promo.re#use-effect + +## `useEffect*` functions + +`React.useEffect1` is a 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, there are also `React.useEffect0`, `React.useEffect2`, etc, all the +way up to `React.useEffect7`[^1]. + +All `React.useEffect*` functions accept a callback as their first argument, the +type of which is: + +```reason +unit => option(unit => unit) +``` + +The callback's return type is `option(unit => unit)` so that you can return a +cleanup function encased in `Some`. When you don't need to return a cleanup +function, you can just return `None`. + +## `useEffect*` dependencies + +The second argument for all `React.useEffect*` functions except +`React.useEffect0` is for the dependencies. For example, the type of +`React.useEffect2` is: + +```reason +(unit => option(unit => unit), ('a, 'b)) => unit +``` + +And the type of `React.useEffect3` is: + +```reason +(unit => option(unit => unit), ('a, 'b, 'c)) => unit +``` + +You see that they both take a tuple as their dependencies argument. That's +because the elements of OCaml tuples can be of different types (unlike OCaml +arrays, whose elements must all be of the same type). In the JS runtime, though, +tuples just get turned into JS arrays. + +::: 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). + +::: + +## Why does `useEffect1` accept an array? + +As you've seen, `React.useEffect2`, `React.useEffect3`, etc all accept a tuple +argument for their dependencies. However, `React.useEffect1` is the odd man out, +because it accepts an array! 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 so that the +generated JS code does the right thing. However, this creates the unfortunate +possibility that you accidentally pass in an empty array as the dependency for +`React.useEffect1`. + +## `RR.useEffect1` helper function + +You can sidestep this possibility by adding a helper function to your `RR` +module: + +<<< RR.re#use-effect-1 + +You can refactor `Promo` to use your new helper function: + +<<< Promo.re#use-effect-helper{1,11} + + --- summary @@ -202,4 +298,7 @@ and [demo](https://react-book.melange.re/demo/src/order-with-promo/) for this ch ----- -footnotes +[^1]: 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). diff --git a/src/order-with-promo/DateInput.re b/src/order-with-promo/DateInput.re new file mode 100644 index 00000000..944580ea --- /dev/null +++ b/src/order-with-promo/DateInput.re @@ -0,0 +1,13 @@ +let stringToDate = s => + // add "T00:00" to make sure the date is in local time + s ++ "T00:00" |> Js.Date.fromString; + +[@react.component] +let make = (~date: Js.Date.t, ~onChange: Js.Date.t => unit) => { + Js.Date.toISOString |> Js.String.substring(~end_=10)} + onChange={evt => evt |> RR.getValueFromEvent |> stringToDate |> onChange} + />; +}; diff --git a/src/order-with-promo/Demo.re b/src/order-with-promo/Demo.re new file mode 100644 index 00000000..25fa5c2a --- /dev/null +++ b/src/order-with-promo/Demo.re @@ -0,0 +1,27 @@ +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("Promo")}

    + +

    {RR.s("Order")}

    + +
    ; +}; diff --git a/src/order-with-promo/Index.re b/src/order-with-promo/Index.re index 996226b1..71fe02d4 100644 --- a/src/order-with-promo/Index.re +++ b/src/order-with-promo/Index.re @@ -1,35 +1,8 @@ -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/Promo.re b/src/order-with-promo/Promo.re index c36af609..462de4ad 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -24,7 +24,8 @@ type discount('a) = [ ]; [@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); @@ -42,6 +43,19 @@ let make = (~items: list(Item.t), ~date: Js.Date.t) => { } }; + RR.useEffect1( + () => { + switch (discount) { + | `NoSubmittedCode + | `CodeError(_) + | `DiscountError(_) => () + | `Discount(value) => onApply(value) + }; + None; + }, + discount, + ); +
    { diff --git a/src/order-with-promo/RR.re b/src/order-with-promo/RR.re index 5dce9616..c6584867 100644 --- a/src/order-with-promo/RR.re +++ b/src/order-with-promo/RR.re @@ -13,3 +13,6 @@ 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); + +/** Helper for [React.useEffect1] */ +let useEffect1 = (func, dep) => React.useEffect1(func, [|dep|]); From 0191a0b641a93a75818b98b674db47b505dfd333 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Fri, 28 Jun 2024 12:51:45 -0500 Subject: [PATCH 07/30] Add section 'add styling for promo code row' --- docs/order-with-promo/Demo.re | 2 - docs/order-with-promo/Order.re | 66 ++++++++++++++++++++- docs/order-with-promo/Promo.re | 17 +----- docs/order-with-promo/RR.re | 4 ++ docs/order-with-promo/index.md | 103 +++++++++++++++++++++++---------- src/order-with-promo/Demo.re | 2 - src/order-with-promo/Order.re | 22 ++++++- src/order-with-promo/Promo.re | 3 +- 8 files changed, 166 insertions(+), 53 deletions(-) diff --git a/docs/order-with-promo/Demo.re b/docs/order-with-promo/Demo.re index 25fa5c2a..444ffd1e 100644 --- a/docs/order-with-promo/Demo.re +++ b/docs/order-with-promo/Demo.re @@ -19,8 +19,6 @@ let make = () => {

    {RR.s("Order confirmation")}

    -

    {RR.s("Promo")}

    -

    {RR.s("Order")}

    ; diff --git a/docs/order-with-promo/Order.re b/docs/order-with-promo/Order.re index 24f5561d..c202f33e 100644 --- a/docs/order-with-promo/Order.re +++ b/docs/order-with-promo/Order.re @@ -1,4 +1,68 @@ type t = list(Item.t); +module OrderItem = { + [@react.component] + let make = (~item as _: Item.t) =>
    ; +}; + +module Style = { + let order = ""; + let total = ""; + + // #region promo-class + let promo = [%cx + {| + border-top: 1px solid gray; + text-align: right; + vertical-align: top; + |} + ]; + // #endregion promo-class +}; + +// #region make [@react.component] -let make = (~items as _: list(Item.t), ~date as _: Js.Date.t) =>
    ; +let make = (~items: t, ~date: Js.Date.t) => { + let (discount, setDiscount) = RR.useStateValue(0.0); + + let subtotal = + items + |> ListLabels.fold_left(~init=0., ~f=(acc, order) => + acc +. Item.toPrice(order) + ); + + + + {items + |> List.mapi((index, item) => + + ) + |> RR.list} + + + + + + + + + + + + + +
    {RR.s("Subtotal")} {subtotal |> RR.currency}
    {RR.s("Promo code")}
    {RR.s("Total")} {subtotal -. discount |> RR.currency}
    ; +}; +// #endregion make + +let _ = + (setDiscount, items, date) => { + <> + // #region set-promo-class + + {RR.s("Promo code")} + + + // #endregion set-promo-class + ; + }; diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re index 4f904b2a..6380e840 100644 --- a/docs/order-with-promo/Promo.re +++ b/docs/order-with-promo/Promo.re @@ -1,5 +1,6 @@ [@react.component] -let make = (~items as _: list(Item.t), ~date as _: Js.Date.t) =>
    ; +let make = (~items as _: list(Item.t), ~date as _: Js.Date.t, ~onApply as _) => +
    ; [@warning "-27"] module AddOnApply = { @@ -11,20 +12,6 @@ module AddOnApply = {
    ; }; -[@warning "-27"] -module DefaultValue = { - // #region default-value - [@react.component] - let make = - ( - ~items: list(Item.t), - ~date: Js.Date.t, - ~onApply: float => unit=_ => (), - ) => - // #endregion default-value -
    ; -}; - let _ = (discount, onApply) => { // #region use-effect diff --git a/docs/order-with-promo/RR.re b/docs/order-with-promo/RR.re index 2f785dcf..b0cb672f 100644 --- a/docs/order-with-promo/RR.re +++ b/docs/order-with-promo/RR.re @@ -5,6 +5,10 @@ let s = React.string; let useStateValue = initial => React.useReducer((_state, newState) => newState, 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|]); diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 2d183b24..8c73c0d1 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -178,6 +178,20 @@ 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} + +A breakdown: + +- Create a new state variable called `discount` +- 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 + order + ## Add `onApply` prop to `Promo` Add an `onApply` prop to `Promo`, which will be invoked when a promo code is @@ -185,19 +199,10 @@ successfully submitted and results in a discount: <<< Promo.re#add-on-apply{3} -This causes a compilation error because the `Order` component doesn't provide a -value for that prop yet. We can temporarily silence the error by giving -`onApply` a default value: - -<<< Promo.re#default-value{6} - -The default value we've provided, `_ => ()`, is basically a no-op. We can remove -this default value once we're ready to use `onApply` inside `Order`. - ## Add `React.useEffect1` -Your first instinct might be to add a `useEffect` hook that invokes `onApply` -when `discount` has a value of the form `` `Discount(value) ``: +To invoke `onApply`, we can add a `useEffect` hook that invokes `onApply` when +`discount` has a value of the form `` `Discount(value) ``: <<< Promo.re#use-effect @@ -209,16 +214,17 @@ the function indicates how many dependencies this function is supposed to take. Accordingly, there are also `React.useEffect0`, `React.useEffect2`, etc, all the way up to `React.useEffect7`[^1]. -All `React.useEffect*` functions accept a callback as their first argument, the -type of which is: +All `React.useEffect*` functions accept a [setup +callback](https://react.dev/reference/react/useEffect#reference) as their first +argument, the type of which is: ```reason unit => option(unit => unit) ``` -The callback's return type is `option(unit => unit)` so that you can return a -cleanup function encased in `Some`. When you don't need to return a cleanup -function, you can just return `None`. +The setup callback's return type is `option(unit => unit)`, which allows you to +return a cleanup function encased in `Some`. When you don't need to return a +cleanup function, you can just return `None`. ## `useEffect*` dependencies @@ -236,11 +242,6 @@ And the type of `React.useEffect3` is: (unit => option(unit => unit), ('a, 'b, 'c)) => unit ``` -You see that they both take a tuple as their dependencies argument. That's -because the elements of OCaml tuples can be of different types (unlike OCaml -arrays, whose elements must all be of the same type). In the JS runtime, though, -tuples just get turned into JS arrays. - ::: tip Every time you add or remove a dependency from your `useEffect` hook, you'll @@ -249,28 +250,72 @@ how many dependencies you now have). ::: +## Tuples vs arrays + +Both functions take their dependencies as a tuple instead of an array (as would +be the case in ReactJS). To understand why, we need to understand the type +properties of tuples and arrays: + +- 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"|]` + +Therefore, we must use tuples to express the dependencies of `useEffect` hooks, +otherwise our dependencies would all have to be of the same type. + +## Tuples become arrays in JS + +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. + ## Why does `useEffect1` accept an array? As you've seen, `React.useEffect2`, `React.useEffect3`, etc all accept a tuple -argument for their dependencies. However, `React.useEffect1` is the odd man out, -because it accepts an array! The reason is that one-element OCaml tuples don't +argument for their dependencies. But `React.useEffect1` is the odd man out, +because it accepts an array. 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 so that the -generated JS code does the right thing. However, this creates the unfortunate -possibility that you accidentally pass in an empty array as the dependency for -`React.useEffect1`. +generated JS code does the right thing. ## `RR.useEffect1` helper function -You can sidestep this possibility by adding a helper function to your `RR` -module: +`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: <<< RR.re#use-effect-1 -You can refactor `Promo` to use your new helper function: +Refactor `Promo` to use your new helper function: <<< Promo.re#use-effect-helper{1,11} +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. + +## Add styling for promo code row + +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 of the + entire order. +- Change the date to something other than May 28. It should an error saying + "Expired promo code" + +However, the styling is a little bit off. Add the following class variable to +`Order.Style`: + +<<< Order.re#promo-class + +Then set the `className` of the promo code row to `Style.promo`: + +<<< Order.re#set-promo-class{1} --- diff --git a/src/order-with-promo/Demo.re b/src/order-with-promo/Demo.re index 25fa5c2a..444ffd1e 100644 --- a/src/order-with-promo/Demo.re +++ b/src/order-with-promo/Demo.re @@ -19,8 +19,6 @@ let make = () => {

    {RR.s("Order confirmation")}

    -

    {RR.s("Promo")}

    -

    {RR.s("Order")}

    ; diff --git a/src/order-with-promo/Order.re b/src/order-with-promo/Order.re index 8f946f3d..75742433 100644 --- a/src/order-with-promo/Order.re +++ b/src/order-with-promo/Order.re @@ -35,11 +35,21 @@ module Style = { text-align: right; |} ]; + + let promo = [%cx + {| + border-top: 1px solid gray; + text-align: right; + vertical-align: top; + |} + ]; }; [@react.component] let make = (~items: t, ~date: Js.Date.t) => { - let total = + let (discount, setDiscount) = RR.useStateValue(0.0); + + let subtotal = items |> ListLabels.fold_left(~init=0., ~f=(acc, order) => acc +. Item.toPrice(order, ~date) @@ -52,9 +62,17 @@ let make = (~items: t, ~date: Js.Date.t) => { ) |> RR.list} + + {RR.s("Subtotal")} + {subtotal |> RR.currency} + + + {RR.s("Promo code")} + + {RR.s("Total")} - {total |> RR.currency} + {subtotal -. discount |> RR.currency} ; diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index 462de4ad..40ae6b54 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -24,8 +24,7 @@ type discount('a) = [ ]; [@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, ~onApply: float => unit) => { let (code, setCode) = RR.useStateValue(""); let (submittedCode, setSubmittedCode) = RR.useStateValue(None); From 8cbf22d50d5e1270d2160c17fad25d7623a3978f Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Fri, 28 Jun 2024 13:56:21 -0500 Subject: [PATCH 08/30] Add section 'how often does the effect run?' --- docs/order-with-promo/Promo.re | 21 +++++++++++++++++++++ docs/order-with-promo/index.md | 18 ++++++++++++++++++ src/order-with-promo/Promo.re | 4 +++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re index 6380e840..d7eacc5d 100644 --- a/docs/order-with-promo/Promo.re +++ b/docs/order-with-promo/Promo.re @@ -49,3 +49,24 @@ let _ = // #endregion use-effect-helper (); }; + +let _ = + (discount, onApply) => { + // #region log + RR.useEffect1( + () => { + switch (discount) { + | `NoSubmittedCode + | `CodeError(_) + | `DiscountError(_) => () + | `Discount(value) => + Js.log2("useEffect1 depending on discount", value); + onApply(value); + }; + None; + }, + discount, + ); + // #endregion log + (); + }; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 8c73c0d1..8840b226 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -317,6 +317,24 @@ 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 +polymorphic tags with arguments are turned into objects in the JS runtime. +Because `discount` is a reactive value, it gets recreated on every render, and +even though its contents didn't necessary change, the [hook treats 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). + --- summary diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index 40ae6b54..c78450a0 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -48,7 +48,9 @@ let make = (~items: list(Item.t), ~date: Js.Date.t, ~onApply: float => unit) => | `NoSubmittedCode | `CodeError(_) | `DiscountError(_) => () - | `Discount(value) => onApply(value) + | `Discount(value) => + Js.log2("useEffect1 depending on discount", value); + onApply(value); }; None; }, From fc1e126b1f3f52e2c964200a63111dc285ee467b Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Fri, 28 Jun 2024 15:36:06 -0500 Subject: [PATCH 09/30] Add section 'use submittedCode as dependency' --- docs/order-with-promo/Promo.re | 21 +++++++++++++++++++++ docs/order-with-promo/index.md | 23 +++++++++++++++++++++++ src/order-with-promo/Promo.re | 2 +- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re index d7eacc5d..cc029ccf 100644 --- a/docs/order-with-promo/Promo.re +++ b/docs/order-with-promo/Promo.re @@ -70,3 +70,24 @@ let _ = // #endregion log (); }; + +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 + (); + }; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 8840b226..2664e77b 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -335,6 +335,26 @@ even though its contents didn't necessary change, the [hook treats 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). +## Use `submittedCode` as dependency + +The easiest fix is to simply change the dependency to `submittedCode` instead of +`discount`: + +<<< Promo.re#submitted-code-dep{13} + +This seems to do 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 +a `option`, and `option` is just another variant type?[^2] + +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 is never an object, and can be safely used as a +dependency for React hooks. + --- summary @@ -365,3 +385,6 @@ and [demo](https://react-book.melange.re/demo/src/order-with-promo/) for this ch 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). + +[^2]: Recall that variant constructors with arguments also get turned into + objects in the JS runtime. diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index c78450a0..cc1a6331 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -54,7 +54,7 @@ let make = (~items: list(Item.t), ~date: Js.Date.t, ~onApply: float => unit) => }; None; }, - discount, + submittedCode, ); Date: Fri, 28 Jun 2024 16:36:02 -0500 Subject: [PATCH 10/30] Add section 'you don't need an effect' --- docs/order-with-promo/Discount.re | 7 ++++++ docs/order-with-promo/Promo.re | 39 ++++++++++++++++++++++++++++++ docs/order-with-promo/index.md | 24 +++++++++++++++++++ src/order-with-promo/Promo.re | 40 +++++++++++-------------------- 4 files changed, 84 insertions(+), 26 deletions(-) diff --git a/docs/order-with-promo/Discount.re b/docs/order-with-promo/Discount.re index 5c980950..3a7efbac 100644 --- a/docs/order-with-promo/Discount.re +++ b/docs/order-with-promo/Discount.re @@ -1,3 +1,10 @@ type error = | InvalidCode | ExpiredCode; + +let getDiscountFunction = (code, _date) => { + switch (code) { + | "FREE" => Ok(_items => Ok(0.0)) + | _ => Error(InvalidCode) + }; +}; diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re index cc029ccf..840edf6b 100644 --- a/docs/order-with-promo/Promo.re +++ b/docs/order-with-promo/Promo.re @@ -91,3 +91,42 @@ let _ = // #endregion submitted-code-dep (); }; + +let _ = + (date, items) => { + // #region get-discount + let getDiscount = + fun + | 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) + } + }; + // #endregion get-discount + + ignore(getDiscount); + }; + +let _ = + (getDiscount, onApply, code, setSubmittedCode) => { + { + evt |> React.Event.Form.preventDefault; + let newSubmittedCode = Some(code); + setSubmittedCode(newSubmittedCode); + switch (getDiscount(newSubmittedCode)) { + | `NoSubmittedCode + | `CodeError(_) + | `DiscountError(_) => () + | `Discount(value) => onApply(value) + }; + }} + // #endregion on-submit + />; + }; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 2664e77b..8448d637 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -355,6 +355,30 @@ case](../burger-discounts/#runtime-representation-of-option): Therefore, an `option` value is never an object, and can be safely used as a dependency for React hooks. +## You don't need an Effect + +The above discussion was academic, because we [don't actually need an Effect 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` reactive value 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 ++] +``` + --- summary diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index cc1a6331..b89a86ad 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -28,50 +28,38 @@ 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) } - } - }; - - RR.useEffect1( - () => { - switch (discount) { - | `NoSubmittedCode - | `CodeError(_) - | `DiscountError(_) => () - | `Discount(value) => - Js.log2("useEffect1 depending on discount", value); - onApply(value); }; - None; - }, - submittedCode, - ); { evt |> React.Event.Form.preventDefault; - setSubmittedCode(Some(code)); + let newSubmittedCode = Some(code); + setSubmittedCode(newSubmittedCode); + switch (getDiscount(newSubmittedCode)) { + | `NoSubmittedCode + | `CodeError(_) + | `DiscountError(_) => () + | `Discount(value) => onApply(value) + }; }}> { - evt |> RR.getValueFromEvent |> setCode; - setSubmittedCode(None); - }} + onChange={evt => {evt |> RR.getValueFromEvent |> setCode}} /> - {switch (discount) { + {switch (getDiscount(submittedCode)) { | `NoSubmittedCode => React.null | `Discount(discount) => discount |> Float.neg |> RR.currency | `CodeError(error) => From e9253bb4ff030741c8ec51ddc14ee428b861f5a9 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Sat, 29 Jun 2024 14:18:27 -0500 Subject: [PATCH 11/30] Add section 'add datasets to demo' --- docs/order-with-promo/Demo.re | 94 ++++++++++++++++++++++++++++++++++ docs/order-with-promo/index.md | 27 ++++++++-- src/order-with-promo/Demo.re | 62 ++++++++++++++++++++++ 3 files changed, 179 insertions(+), 4 deletions(-) diff --git a/docs/order-with-promo/Demo.re b/docs/order-with-promo/Demo.re index 444ffd1e..42fc4822 100644 --- a/docs/order-with-promo/Demo.re +++ b/docs/order-with-promo/Demo.re @@ -1,3 +1,4 @@ +// #region initial let items: Order.t = [ Sandwich(Portabello), Sandwich(Unicorn), @@ -23,3 +24,96 @@ let make = () => {
    ; }; +// #endregion initial + +// #region datasets +let burger = + Item.Burger.{ + lettuce: false, + tomatoes: false, + onions: 0, + cheese: 0, + bacon: 0, + }; + +let datasets = { + [ + ( + "No burgers", + Item.[ + Sandwich(Unicorn), + Hotdog, + Sandwich(Ham), + Sandwich(Turducken), + Hotdog, + ], + ), + ( + "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), + ], + ), + ]; +}; +// #endregion datasets + +/** +let datasets': list((string, list(Item.t))) = [ + // #region burger-expression + { + 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}), + ]; + }, + ); + }, + // #endregion burger-expression +]; +*/; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 8448d637..5d26f0ec 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -6,9 +6,9 @@ on their orders. ## Add `discount` type -But first, let's see how to create an explicit type for the `discount` derived -variable inside `Promo`. We do so not because we have to, but because it will -give us some more insight into OCaml's type system. +But first, let's see how to create an explicit type for the `discount` reactive +value inside `Promo`. We do so not because we have to, but because it will give +us some more insight into OCaml's type system. When we hover over the `discount` variable, we see this: @@ -172,7 +172,7 @@ But don't worry, it will still say `type` in the generated JS output. Move the contents of `Index.App` into a new file called `Demo.re`. In the process, add our newly-created `DateInput` component: -<<< Demo.re{16-17,21} +<<< Demo.re#initial{16-17,21} Change `Index` to use the new `Demo` component: @@ -379,6 +379,25 @@ Inside the render logic, change the input of the switch expression from {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 + +::: tip + +OCaml makes it easy to move variables closer to where their used. Unlike in +`JavaScript`, you can use `let` anywhere, even inside an expression. + +::: + --- summary diff --git a/src/order-with-promo/Demo.re b/src/order-with-promo/Demo.re index 444ffd1e..1a4c55a3 100644 --- a/src/order-with-promo/Demo.re +++ b/src/order-with-promo/Demo.re @@ -11,6 +11,68 @@ let items: Order.t = [ Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 1, bacon: 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), + ], + ), + ]; +}; + [@react.component] let make = () => { let (date, setDate) = From 1673d2349750fa5d6f27aaaaeeb1530d568e81f9 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Sat, 29 Jun 2024 14:56:41 -0500 Subject: [PATCH 12/30] Add section 'refactor demo' --- docs/order-with-promo/Demo.re | 22 +++++++++++++++++++++- docs/order-with-promo/index.md | 12 +++++++++++- src/order-with-promo/Demo.re | 21 ++++++--------------- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/docs/order-with-promo/Demo.re b/docs/order-with-promo/Demo.re index 42fc4822..c5f4ec57 100644 --- a/docs/order-with-promo/Demo.re +++ b/docs/order-with-promo/Demo.re @@ -116,4 +116,24 @@ let datasets': list((string, list(Item.t))) = [ }, // #endregion burger-expression ]; -*/; +*/ +ignore(make); + +// #region refactor +[@react.component] +let make = () => { + let (date, setDate) = + RR.useStateValue(Js.Date.fromString("2024-05-28T00:00")); + +
    +

    {RR.s("Order Confirmation")}

    + +

    {RR.s("Order")}

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

    {RR.s(label)}

    + }) + |> RR.list} +
    ; +}; +// #endregion refactor diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 5d26f0ec..78282734 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -398,9 +398,19 @@ OCaml makes it easy to move variables closer to where their used. Unlike in ::: +## Refactor `Demo` + +Refactor `Demo` to render a different `Order` for each collection of items: + +<<< Demo.re#refactor + +You can delete the unused `items` value in `Order`. + --- -summary +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. ## Overview diff --git a/src/order-with-promo/Demo.re b/src/order-with-promo/Demo.re index 1a4c55a3..f10eef93 100644 --- a/src/order-with-promo/Demo.re +++ b/src/order-with-promo/Demo.re @@ -1,16 +1,3 @@ -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}), -]; - let datasets = { [ ( @@ -79,9 +66,13 @@ let make = () => { RR.useStateValue(Js.Date.fromString("2024-05-28T00:00"));
    -

    {RR.s("Order confirmation")}

    +

    {RR.s("Order Confirmation")}

    {RR.s("Order")}

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

    {RR.s(label)}

    + }) + |> RR.list}
    ; }; From f0e3820c4442efbbdaee1b60e3db5ca51d6539ab Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Sat, 29 Jun 2024 18:52:23 -0500 Subject: [PATCH 13/30] Change discount value to normal variant --- docs/order-with-promo/Promo.re | 112 ++++++++++++++++----- docs/order-with-promo/Types.re | 96 ++++++++++-------- docs/order-with-promo/index.md | 173 +++++++++++++++------------------ src/order-with-promo/Promo.re | 11 +-- 4 files changed, 227 insertions(+), 165 deletions(-) diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re index 840edf6b..e5e7c474 100644 --- a/docs/order-with-promo/Promo.re +++ b/docs/order-with-promo/Promo.re @@ -2,6 +2,70 @@ 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 + ; + }; + [@warning "-27"] module AddOnApply = { // #region add-on-apply @@ -18,10 +82,10 @@ let _ = React.useEffect1( () => { switch (discount) { - | `NoSubmittedCode - | `CodeError(_) - | `DiscountError(_) => () - | `Discount(value) => onApply(value) + | NoSubmittedCode + | CodeError(_) + | DiscountError(_) => () + | Discount(value) => onApply(value) }; None; }, @@ -37,10 +101,10 @@ let _ = RR.useEffect1( () => { switch (discount) { - | `NoSubmittedCode - | `CodeError(_) - | `DiscountError(_) => () - | `Discount(value) => onApply(value) + | NoSubmittedCode + | CodeError(_) + | DiscountError(_) => () + | Discount(value) => onApply(value) }; None; }, @@ -56,10 +120,10 @@ let _ = RR.useEffect1( () => { switch (discount) { - | `NoSubmittedCode - | `CodeError(_) - | `DiscountError(_) => () - | `Discount(value) => + | NoSubmittedCode + | CodeError(_) + | DiscountError(_) => () + | Discount(value) => Js.log2("useEffect1 depending on discount", value); onApply(value); }; @@ -77,10 +141,10 @@ let _ = RR.useEffect1( () => { switch (discount) { - | `NoSubmittedCode - | `CodeError(_) - | `DiscountError(_) => () - | `Discount(value) => + | NoSubmittedCode + | CodeError(_) + | DiscountError(_) => () + | Discount(value) => Js.log2("useEffect1 depending on discount", value); onApply(value); }; @@ -97,14 +161,14 @@ let _ = // #region get-discount let getDiscount = fun - | None => `NoSubmittedCode + | None => NoSubmittedCode | Some(code) => switch (Discount.getDiscountFunction(code, date)) { - | Error(error) => `CodeError(error) + | Error(error) => CodeError(error) | Ok(discountFunc) => switch (discountFunc(items)) { - | Error(error) => `DiscountError(error) - | Ok(value) => `Discount(value) + | Error(error) => DiscountError(error) + | Ok(value) => Discount(value) } }; // #endregion get-discount @@ -121,10 +185,10 @@ let _ = let newSubmittedCode = Some(code); setSubmittedCode(newSubmittedCode); switch (getDiscount(newSubmittedCode)) { - | `NoSubmittedCode - | `CodeError(_) - | `DiscountError(_) => () - | `Discount(value) => onApply(value) + | NoSubmittedCode + | CodeError(_) + | DiscountError(_) => () + | Discount(value) => onApply(value) }; }} // #endregion on-submit diff --git a/docs/order-with-promo/Types.re b/docs/order-with-promo/Types.re index 929c9850..1cebda0d 100644 --- a/docs/order-with-promo/Types.re +++ b/docs/order-with-promo/Types.re @@ -1,10 +1,24 @@ /** +// #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( +type discount = + | CodeError(Discount.error) + | Discount(float) + | DiscountError( [> | `MissingSandwichTypes | `NeedMegaBurger @@ -12,16 +26,16 @@ type discount = [> | `NeedTwoBurgers ], ) - | `NoSubmittedCode -]; + | NoSubmittedCode; // #endregion bad-discount-type + */ // #region delete-refinement -type discount = [ - | `CodeError(Discount.error) - | `Discount(float) - | `DiscountError( +type discount = + | CodeError(Discount.error) + | Discount(float) + | DiscountError( [ | `MissingSandwichTypes | `NeedMegaBurger @@ -29,54 +43,50 @@ type discount = [ | `NeedTwoBurgers ], ) - | `NoSubmittedCode -]; + | NoSubmittedCode; // #endregion delete-refinement module TypeVar = { // #region type-variable - type discount('a) = [ - | `CodeError(Discount.error) - | `Discount(float) - | `DiscountError('a) - | `NoSubmittedCode - ]; + 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( + | 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 - ] as 'a; -// #endregion explicit-type-var -*/ -module AddTypeArg = { - // #region add-type-arg - type discount('a) = - [> - | `CodeError(Discount.error) - | `Discount(float) - | `DiscountError( - [> - | `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers - ], - ) - | `NoSubmittedCode - ] as 'a; + | NoSubmittedCode; // #endregion add-type-arg }; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 78282734..c14180b0 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -6,24 +6,17 @@ on their orders. ## Add `discount` type -But first, let's see how to create an explicit type for the `discount` reactive -value inside `Promo`. We do so not because we have to, but because it will give -us some more insight into OCaml's type system. +But first, let's see how to create an explicit (normal) variant type for the +`discount` reactive value inside `Promo`. We do so not because we have to, but +because it will give us some more insight into OCaml's type system. When we hover over the `discount` variable, we see this: -```reason -[> `CodeError(Discount.error) - | `Discount(float) - | `DiscountError([> `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers ]) - | `NoSubmittedCode ] -``` +<<< Types.re#inferred-type The easiest thing to do is to create a new `discount` type and assign it to that -type expression: +type expression, then delete the `` ` `` from the variant tags to turn them into +variant constructors[^1]: <<< Types.re#bad-discount-type @@ -31,43 +24,32 @@ However, this results in a compilation error: ```text Error: A type variable is unbound in this type declaration. - In type - [> `CodeError of Melange.Discount.error - | `Discount of float - | `DiscountError of - [> `MissingSandwichTypes - | `NeedMegaBurger - | `NeedOneBurger - | `NeedTwoBurgers ] - | `NoSubmittedCode ] - as 'a the variable 'a is unbound + In case + DiscountError of ([> `MissingSandwichTypes + | `NeedMegaBurger + | `NeedOneBurger + | `NeedTwoBurgers ] + as 'a) the variable 'a is unbound ``` We'll come back to this error message later. For now, observe that the error -disappears if we simply delete all the instances of `>`: +disappears if we simply delete `>`: <<< Types.re#delete-refinement -This fixes the syntax error so that we now have a correctly-defined polymorphic -variant type. +This fixes the syntax error so that we now have a correctly-defined variant +type. -## Type annotate `discount` variable +## Refactor `discount` -We just defined a new type, but our `discount` variable doesn't know anything -about it. Since polymorphic variants can be used without explicitly defining -them, type inference works differently for them. In particular, our `discount` -variable continues to use its own inferred type, despite there being a perfectly -good type within scope that uses the same variant tags. +Refactor the `discount` reactive value to use our new variant type by deleting +all instances of `` ` ``: -Type annotate the `discount` variable with the newly-created `discount` type: +<<< Promo.re#discount-variant -```reason -let discount = // [!code --] -let discount: discount = // [!code ++] -``` +You can likewise refactor the switch expression inside the render logic: -Now when you hover over the `discount` variable, you see that its type is just -`discount`. +<<< Promo.re#discount-render ## Type constructor and type variable @@ -77,26 +59,15 @@ Change the `discount` type to this: 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 function that -takes a type and outputs a new type. This is reinforced by the compilation error -we get: +takes a type and outputs a new type. -```text -31 | let discount: discount = - ^^^^^^^^ -Error: The type constructor discount expects 1 argument(s), - but is here applied to 0 argument(s) -``` - -You can fix it like so: - -```reason -let discount: discount = // [!code --] -let discount: discount(_) = // [!code ++] -``` +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, it will happen in there. -We use `discount(_)` to tell the compiler that it should use the `discount` type -constructor, but the value of its argument should be inferred. Now if you hover -over the `discount` variable, you see that its type is: +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 @@ -105,11 +76,14 @@ discount([> `MissingSandwichTypes | `NeedTwoBurgers ]) ``` +OCaml can figure out the type of `discount` from its usage. + ## `>` = "allow more than" -Once again, we see `>`. 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: +Once again, we see `>` in an inferred type, 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 @@ -148,13 +122,13 @@ type constructor. Once it's added, it compiles: This is somewhat like accidentally using a variable in a function but forgetting to add that variable to the function's argument list. -## Do you need to make a type for `discount`? +## Normal variants vs polymorphicr variants -When using a polymorphic variant, you don't generally need to explicitly define -a type. But it doesn't hurt to do so, and can serve as a sort of "documentation" -by showing all your variant tags in one place. [Later](/todo), we'll show you an -example of when you must define types to take advantage of the more advanced -features of polymorphic variants. +Actually, we are not ready to settle the debate of when to use normal variants +and when to use polymorphic variants, because polymorphic variants have features +that we have yet to explore. But in the case of the `discount` reactive value, +we can say that either would be fine. Still, the version using normal variants +is a little more explicit and self-documenting, which is never a bad thing. ## Add `DateInput` component @@ -199,6 +173,13 @@ successfully submitted and results in a discount: <<< Promo.re#add-on-apply{3} +::: 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. + +::: + ## Add `React.useEffect1` To invoke `onApply`, we can add a `useEffect` hook that invokes `onApply` when @@ -206,13 +187,16 @@ To invoke `onApply`, we can add a `useEffect` hook that invokes `onApply` when <<< Promo.re#use-effect +Note that when `discount` has an error value, we return `()` from the switch +expression, which is essentially a no-op. + ## `useEffect*` functions `React.useEffect1` is a 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, there are also `React.useEffect0`, `React.useEffect2`, etc, all the -way up to `React.useEffect7`[^1]. +Accordingly, we also have `React.useEffect0`, `React.useEffect2`, etc, all the +way up to `React.useEffect7`[^2]. All `React.useEffect*` functions accept a [setup callback](https://react.dev/reference/react/useEffect#reference) as their first @@ -224,7 +208,7 @@ unit => option(unit => unit) The setup callback's return type is `option(unit => unit)`, which allows you to return a cleanup function encased in `Some`. When you don't need to return a -cleanup function, you can just return `None`. +cleanup function, you just return `None`. ## `useEffect*` dependencies @@ -252,8 +236,8 @@ how many dependencies you now have). ## Tuples vs arrays -Both functions take their dependencies as a tuple instead of an array (as would -be the case in ReactJS). To understand why, we need to understand the type +Both `React.useEffect2` and `React.useEffect3` take their dependencies as a +tuple instead of an array. To understand why, we need to understand the type properties of tuples and arrays: - The elements of tuples can have different types, e.g. `(1, "a", 23.5)` @@ -275,8 +259,9 @@ As you've seen, `React.useEffect2`, `React.useEffect3`, etc all accept a tuple argument for their dependencies. But `React.useEffect1` is the odd man out, because it accepts an array. 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 so that the -generated JS code does the right thing. +element. So in this case, `React.useEffect1` [must take an +array](https://reasonml.github.io/reason-react/docs/en/components#hooks) so that +the generated JS code does the right thing. ## `RR.useEffect1` helper function @@ -303,12 +288,12 @@ 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 of the +- 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 an error saying +- 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 class variable to +However, the styling is a little bit off. Add the following value to `Order.Style`: <<< Order.re#promo-class @@ -329,10 +314,10 @@ 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 -polymorphic tags with arguments are turned into objects in the JS runtime. -Because `discount` is a reactive value, it gets recreated on every render, and -even though its contents didn't necessary change, the [hook treats it as having -changed because the object is no longer the same one as +variant tags with arguments are turned into objects in the JS runtime. Because +`discount` is a reactive value, 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). ## Use `submittedCode` as dependency @@ -342,9 +327,9 @@ The easiest fix is to simply change the dependency to `submittedCode` instead of <<< Promo.re#submitted-code-dep{13} -This seems to do 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 -a `option`, and `option` is just another variant type?[^2] +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): @@ -352,13 +337,13 @@ case](../burger-discounts/#runtime-representation-of-option): - `None` becomes `undefined` - `Some(value)` becomes `value` -Therefore, an `option` value is never an object, and can be safely used as a -dependency for React hooks. +Therefore, an `option` value is never an object, and can always be used as a +dependency for React hooks as long as it wraps a primitive value. ## You don't need an Effect -The above discussion was academic, because we [don't actually need an Effect to -handle user +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. @@ -393,12 +378,12 @@ inside that expression: ::: tip -OCaml makes it easy to move variables closer to where their used. Unlike in -`JavaScript`, you can use `let` anywhere, even inside an expression. +OCaml makes it easy to move variables closer to where they are actually used. +Unlike in JavaScript, you can use `let` anywhere, even inside an expression. ::: -## Refactor `Demo` +## Refactor `Demo` to render multiple orders Refactor `Demo` to render a different `Order` for each collection of items: @@ -434,10 +419,14 @@ and [demo](https://react-book.melange.re/demo/src/order-with-promo/) for this ch ----- -[^1]: If you happen to need more than 7 dependencies, you can define your own +[^1]: In OCaml terminology, variant tags start with `` ` `` and correspond to + polymorphic variant types, while variant constructors correspond to normal + variant types. + +[^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). -[^2]: 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. diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index b89a86ad..513fe741 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -16,12 +16,11 @@ module Style = { let discountError = [%cx {|color: purple|}]; }; -type discount('a) = [ - | `CodeError(Discount.error) - | `Discount(float) - | `DiscountError('a) - | `NoSubmittedCode -]; +type discount('a) = + | CodeError(Discount.error) + | Discount(float) + | DiscountError('a) + | NoSubmittedCode; [@react.component] let make = (~items: list(Item.t), ~date: Js.Date.t, ~onApply: float => unit) => { From caeaf0202f72b6f3508e0b1876ae808dc719be2b Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Sat, 29 Jun 2024 19:23:51 -0500 Subject: [PATCH 14/30] Add overview --- docs/order-with-promo/index.md | 51 ++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index c14180b0..75fdb93a 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -95,8 +95,8 @@ discount([| `MissingSandwichTypes ``` Generally, you won't need to use `>` in your own type definitions, but it often -appears when the compiler is allowed to infer the type of a variable or function -that uses polymorphic variants. +appears when the compiler is infers the type of a variable or function that uses +polymorphic variants. ## Implicit type variable @@ -105,8 +105,8 @@ is syntactically invalid: <<< Types.re#bad-discount-type -The reason is that whenever you have `>`, you implicitly have a type variable. -So the above code is equivalent to this: +The reason is that there's an implicit type variable around the `>`. So the +above code is equivalent to this: <<< Types.re#explicit-type-var{14} @@ -314,10 +314,10 @@ 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 tags with arguments are turned into objects in the JS runtime. Because -`discount` is a reactive value, 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 +variant constructors with arguments are turned into objects in the JS runtime. +Because `discount` is a reactive value, 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). ## Use `submittedCode` as dependency @@ -378,8 +378,9 @@ inside that expression: ::: tip -OCaml makes it easy to move variables closer to where they are actually used. -Unlike in JavaScript, you can use `let` anywhere, even inside an expression. +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. ::: @@ -399,7 +400,35 @@ the next chapter, we'll further polish the sandwich promotion logic. ## Overview -- tbd +- 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 to that the polymorphic + variant can accept more than the tags that are listed + - Inferred type definitions that contain `>` also have an implicit type + variable + - You rarely need to use `>` in your own type definitions, but it often + appears in inferred types (that appear when you hover over variables and + functions) +- 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_`. +- ReasonReact has several binding functions for React's useEffect hook, e.g. + `React.useEffect0`, `React.useEffect1`, etc + - 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 define variables closer + to where they're used ## Exercises From 31c0333debfb84d6473606f27b0aa95ae0dbf022 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Sat, 29 Jun 2024 19:30:40 -0500 Subject: [PATCH 15/30] Add section 'force DiscountError argument to be polymorphic variant' --- docs/order-with-promo/Types.re | 10 ++++++++++ docs/order-with-promo/index.md | 12 +++++++++++- src/order-with-promo/Promo.re | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/order-with-promo/Types.re b/docs/order-with-promo/Types.re index 1cebda0d..682b3c76 100644 --- a/docs/order-with-promo/Types.re +++ b/docs/order-with-promo/Types.re @@ -90,3 +90,13 @@ module AddTypeArg = { | 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 75fdb93a..6b7312d3 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -122,7 +122,7 @@ type constructor. Once it's added, it compiles: This is somewhat like accidentally using a variable in a function but forgetting to add that variable to the function's argument list. -## Normal variants vs polymorphicr variants +## Normal variants vs polymorphic variants Actually, we are not ready to settle the debate of when to use normal variants and when to use polymorphic variants, because polymorphic variants have features @@ -130,6 +130,16 @@ that we have yet to explore. But in the case of the `discount` reactive value, we can say that either would be fine. Still, the version using normal variants is a little more explicit and self-documenting, which is never a bad thing. +## Force `DiscountError` argument to be polymorphic variant + +Right now the argument of the `DiscountError` constructor can be any type at +all, but we can force it to be polymorphic constructor: + +<<< 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. + ## Add `DateInput` component To see different promotions in action, we want to be able to easily change the diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index 513fe741..715179eb 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -19,7 +19,7 @@ module Style = { type discount('a) = | CodeError(Discount.error) | Discount(float) - | DiscountError('a) + | DiscountError([> ] as 'a) | NoSubmittedCode; [@react.component] From 71f60298fcdbf978988609d32721a5f4bc40e6c7 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Sat, 29 Jun 2024 22:59:07 -0500 Subject: [PATCH 16/30] Add exercises 1 and 2 --- docs/order-with-promo/DiscountTests.re | 1 - docs/order-with-promo/index.md | 67 +++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 3 deletions(-) delete mode 100644 docs/order-with-promo/DiscountTests.re diff --git a/docs/order-with-promo/DiscountTests.re b/docs/order-with-promo/DiscountTests.re deleted file mode 100644 index 8b137891..00000000 --- a/docs/order-with-promo/DiscountTests.re +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 6b7312d3..8811d2cb 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -442,9 +442,72 @@ the next chapter, we'll further polish the sandwich promotion logic. ## Exercises -1. tbd +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. -2. tbd +```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. + +::: + +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. tbd From bb21bbdc10659b1259c1ad2ac9a14191c1e315f0 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Sat, 29 Jun 2024 23:06:47 -0500 Subject: [PATCH 17/30] Add exercise 3 --- docs/order-with-promo/index.md | 53 +++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 8811d2cb..ce7068ca 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -509,7 +509,58 @@ than the listed variant tags. ::: -3. tbd +3. Fix the following code (playground) 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 and see what happens. + +::: + +::: 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 type annotation above. + +::: + 4. tbd From 97bc1568ba6c15fe0f4448c273cf09f4fc524b95 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Sat, 29 Jun 2024 23:18:01 -0500 Subject: [PATCH 18/30] Add exercise 4 --- docs/order-with-promo/Demo.re | 28 ++++++++++++++++++++++++++++ docs/order-with-promo/index.md | 22 +++++++++++++++++++++- src/order-with-promo/Demo.re | 23 +++++++++++++++-------- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/docs/order-with-promo/Demo.re b/docs/order-with-promo/Demo.re index c5f4ec57..e3d4e18c 100644 --- a/docs/order-with-promo/Demo.re +++ b/docs/order-with-promo/Demo.re @@ -137,3 +137,31 @@ let make = () => {
    ; }; // #endregion refactor + +// #region date-and-order +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)}

    + + +
    ; + }; +}; +// #endregion date-and-order + +// #region make +[@react.component] +let make = () => { +
    +

    {RR.s("Order Confirmation")}

    + {datasets + |> List.map(((label, items)) => ) + |> RR.list} +
    ; +}; +// #endregion make diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index ce7068ca..74388097 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -562,7 +562,27 @@ inferred type would be the same as type annotation above. ::: -4. tbd +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 + +::: ----- diff --git a/src/order-with-promo/Demo.re b/src/order-with-promo/Demo.re index f10eef93..7865c2f0 100644 --- a/src/order-with-promo/Demo.re +++ b/src/order-with-promo/Demo.re @@ -60,19 +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")}

    - -

    {RR.s("Order")}

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

    {RR.s(label)}

    - }) + |> List.map(((label, items)) => ) |> RR.list}
    ; }; From a698d71ac7c076cdeb3e45235fde2dc45382ab43 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Sat, 29 Jun 2024 23:34:24 -0500 Subject: [PATCH 19/30] Add exercise 5 (incomplete) --- docs/order-with-promo/Demo.re | 2 ++ docs/order-with-promo/index.md | 22 ++++++++++++++++++++++ src/order-with-promo/Discount.re | 11 ++++++++++- src/order-with-promo/DiscountTests.re | 2 +- src/order-with-promo/Promo.re | 4 +++- 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/order-with-promo/Demo.re b/docs/order-with-promo/Demo.re index e3d4e18c..c00259c8 100644 --- a/docs/order-with-promo/Demo.re +++ b/docs/order-with-promo/Demo.re @@ -138,6 +138,8 @@ let make = () => { }; // #endregion refactor +ignore(make); + // #region date-and-order module DateAndOrder = { [@react.component] diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 74388097..4256ec12 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -584,6 +584,28 @@ Then refactor `Demo.make` to use the new component: ::: +5. 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. + +::: details Hint 1 + +Add a `list(string)` argument to the `` `MissingSandwichTypes`` variant tag. + +::: + +::: details Hint 2 + +tbd + +::: + +::: details Solution + +tbd + +::: + ----- View [source diff --git a/src/order-with-promo/Discount.re b/src/order-with-promo/Discount.re index fcbac1e4..e8b5724d 100644 --- a/src/order-with-promo/Discount.re +++ b/src/order-with-promo/Discount.re @@ -94,7 +94,16 @@ 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..2b2fe4bc 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), + Error(`MissingSandwichTypes(["turducken"])), ) ); diff --git a/src/order-with-promo/Promo.re b/src/order-with-promo/Promo.re index 715179eb..30fd3be3 100644 --- a/src/order-with-promo/Promo.re +++ b/src/order-with-promo/Promo.re @@ -76,7 +76,9 @@ 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) => + (missing |> Stdlib.Array.of_list |> Js.Array.join(~sep=", ")) + ++ " sandwiches" };
    {RR.s({j|Buy $buyWhat to enjoy this promotion|j})} From 08f71de50c48d57b0a1bb0b1e674c5f13cbee93d Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Thu, 11 Jul 2024 18:44:58 +0800 Subject: [PATCH 20/30] Fix bug with date to string conversion --- src/order-with-promo/DateInput.re | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/order-with-promo/DateInput.re b/src/order-with-promo/DateInput.re index 944580ea..1a932b86 100644 --- a/src/order-with-promo/DateInput.re +++ b/src/order-with-promo/DateInput.re @@ -2,12 +2,20 @@ 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) => { Js.Date.toISOString |> Js.String.substring(~end_=10)} + value={dateToString(date)} onChange={evt => evt |> RR.getValueFromEvent |> stringToDate |> onChange} />; }; From ec6784354dbb5fa5059e77bb347f690f9a5761cc Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Fri, 12 Jul 2024 12:08:05 +0800 Subject: [PATCH 21/30] Explain float conversion specifications in DateInput --- docs/order-with-promo/DateInput.re | 10 +++++++++- docs/order-with-promo/index.md | 32 ++++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/docs/order-with-promo/DateInput.re b/docs/order-with-promo/DateInput.re index 944580ea..1a932b86 100644 --- a/docs/order-with-promo/DateInput.re +++ b/docs/order-with-promo/DateInput.re @@ -2,12 +2,20 @@ 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) => { Js.Date.toISOString |> Js.String.substring(~end_=10)} + value={dateToString(date)} onChange={evt => evt |> RR.getValueFromEvent |> stringToDate |> onChange} />; }; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 4256ec12..710f8100 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -145,11 +145,20 @@ allows more tags, which basically means any polymorphic variant. 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`: -<<< DateInput.re +<<< DateInput.re{7,16} -Note how 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. +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: + - 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 + - The float conversion specification `%02.0f` sets a minimum width of 2 + (left padded with 0) and 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. ## Add `Demo` component @@ -206,7 +215,7 @@ expression, which is essentially a no-op. 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]. +way up to `React.useEffect7`[^3]. All `React.useEffect*` functions accept a [setup callback](https://react.dev/reference/react/useEffect#reference) as their first @@ -339,7 +348,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?[^3] +`option`, and `option` is just another variant type?[^4] Although `option` is a variant type, its [runtime representation is a special case](../burger-discounts/#runtime-representation-of-option): @@ -618,10 +627,17 @@ and [demo](https://react-book.melange.re/demo/src/order-with-promo/) for this ch polymorphic variant types, while variant constructors correspond to normal variant types. -[^2]: If you happen to need more than 7 dependencies, you can define your own +[^2]: 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 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 +[^4]: Recall that variant constructors with arguments also get turned into objects in the JS runtime. From 7ecb9eaa5a42e9d5a57589db15960e6cfd0f998e Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Fri, 12 Jul 2024 19:06:22 +0800 Subject: [PATCH 22/30] Clarify the first paragraph of 'add discount type' section --- docs/order-with-promo/index.md | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 710f8100..e85bafde 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -6,9 +6,12 @@ on their orders. ## Add `discount` type -But first, let's see how to create an explicit (normal) variant type for the -`discount` reactive value inside `Promo`. We do so not because we have to, but -because it will give us some more insight into OCaml's type system. +But first, let's see how to create a normal variant type for the `discount` +reactive value 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: @@ -122,18 +125,10 @@ type constructor. Once it's added, it compiles: This is somewhat like accidentally using a variable in a function but forgetting to add that variable to the function's argument list. -## Normal variants vs polymorphic variants - -Actually, we are not ready to settle the debate of when to use normal variants -and when to use polymorphic variants, because polymorphic variants have features -that we have yet to explore. But in the case of the `discount` reactive value, -we can say that either would be fine. Still, the version using normal variants -is a little more explicit and self-documenting, which is never a bad thing. - ## Force `DiscountError` argument to be polymorphic variant Right now the argument of the `DiscountError` constructor can be any type at -all, but we can force it to be polymorphic constructor: +all, but we can force it to be a polymorphic variant: <<< Types.re#must-be-poly{4} From e40744328f952cb052000a7c3fa46db27ee30d9c Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 30 Jul 2024 16:21:14 +0800 Subject: [PATCH 23/30] Fix several typos --- docs/order-with-promo/index.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index e85bafde..49e0e3b7 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -61,7 +61,7 @@ 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 function that +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 @@ -98,7 +98,7 @@ discount([| `MissingSandwichTypes ``` Generally, you won't need to use `>` in your own type definitions, but it often -appears when the compiler is infers the type of a variable or function that uses +appears when the compiler infers the type of a variable or function that uses polymorphic variants. ## Implicit type variable @@ -206,7 +206,7 @@ expression, which is essentially a no-op. ## `useEffect*` functions -`React.useEffect1` is a one of the binding functions for React's [useEffect +`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 @@ -221,8 +221,8 @@ unit => option(unit => unit) ``` The setup callback's return type is `option(unit => unit)`, which allows you to -return a cleanup function encased in `Some`. When you don't need to return a -cleanup function, you just return `None`. +return a cleanup function encased in `Some`. When the effect doesn't need a +cleanup function, just return `None`. ## `useEffect*` dependencies @@ -441,8 +441,8 @@ the next chapter, we'll further polish the sandwich promotion logic. - 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 define variables closer - to where they're used +- You can use `let` inside expressions, which allows you to define variables + closer to where they're used ## Exercises From 407d00097197a8f12de648ade524ce8a6039a240 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 30 Jul 2024 19:05:38 +0800 Subject: [PATCH 24/30] Add note about equivalence of fixed polymorphic variants and normal variants --- docs/order-with-promo/index.md | 63 ++++++++++++++-------------- docs/order-with-promo/type-error.txt | 7 ++++ 2 files changed, 39 insertions(+), 31 deletions(-) create mode 100644 docs/order-with-promo/type-error.txt diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 49e0e3b7..a5e7ecb2 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -13,27 +13,19 @@ 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: +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 that -type expression, then delete the `` ` `` from the variant tags to turn them into -variant constructors[^1]: +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: -```text -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 -``` +<<< type-error.txt We'll come back to this error message later. For now, observe that the error disappears if we simply delete `>`: @@ -45,8 +37,8 @@ type. ## Refactor `discount` -Refactor the `discount` reactive value to use our new variant type by deleting -all instances of `` ` ``: +Refactor the `discount` reactive value inside `Promo.make` to use our new +variant type by deleting all occurrences of `` ` ``: <<< Promo.re#discount-variant @@ -67,7 +59,7 @@ 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, it will happen in there. +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: @@ -79,14 +71,15 @@ discount([> `MissingSandwichTypes | `NeedTwoBurgers ]) ``` -OCaml can figure out the type of `discount` from its usage. +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" -Once again, we see `>` in an inferred type, 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 `>`, 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 @@ -97,28 +90,36 @@ discount([| `MissingSandwichTypes | `KewpieMayo ]) ``` -Generally, you won't need to use `>` in your own type definitions, but it often -appears when the compiler infers the type of a variable or function that uses -polymorphic variants. +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 type definition -is syntactically invalid: +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 `>`. So the -above code is equivalent to this: +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: -> Error: A type variable is unbound in this type declaration. +<<< type-error.txt{7} -The type variable exists, but it doesn't appear as an argument of the `discount` -type constructor. Once it's added, it compiles: +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} diff --git a/docs/order-with-promo/type-error.txt b/docs/order-with-promo/type-error.txt new file mode 100644 index 00000000..317e4734 --- /dev/null +++ b/docs/order-with-promo/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 From 577c21cdcbc991c086ab6f47c3e5237e9a8a7c7c Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 30 Jul 2024 19:16:13 +0800 Subject: [PATCH 25/30] Add mid-chapter summary --- docs/order-with-promo/index.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index a5e7ecb2..bef780b8 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -136,6 +136,14 @@ all, but we can force it to be a polymorphic variant: The `[> ]` type expression means a polymorphic variant that has no tags, but allows more tags, which basically means any polymorphic variant. +## Quick summary + +You've refactored the `discount` reactive variable to use a normal variant +instead of a polymorphic variant. There weren't so many changes to the code, but +a basic knowledge of type constructors and type variables is needed to +understand them. In the next sections, we'll set types aside and cover the +UI-related 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 From 1ec3f75ba76a17973f4e40d79c80a8baaca10668 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 30 Jul 2024 22:16:46 +0800 Subject: [PATCH 26/30] Add link to OCaml docs explanation of () --- docs/order-with-promo/index.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index bef780b8..68054656 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -210,8 +210,9 @@ To invoke `onApply`, we can add a `useEffect` hook that invokes `onApply` when <<< Promo.re#use-effect -Note that when `discount` has an error value, we return `()` from the switch -expression, which is essentially a no-op. +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". ## `useEffect*` functions From e097107a84569dd1ef6249c5a388f77fa8de8ab4 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Wed, 31 Jul 2024 16:48:11 +0800 Subject: [PATCH 27/30] Rename 'reactive value' to 'derived variable' --- docs/order-with-promo/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 68054656..2b3a2aa7 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -7,7 +7,7 @@ on their orders. ## Add `discount` type But first, let's see how to create a normal variant type for the `discount` -reactive value inside `Promo`. We do not have to do this, because it works fine +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 @@ -37,7 +37,7 @@ type. ## Refactor `discount` -Refactor the `discount` reactive value inside `Promo.make` to use our new +Refactor the `discount` derived variable inside `Promo.make` to use our new variant type by deleting all occurrences of `` ` ``: <<< Promo.re#discount-variant @@ -339,7 +339,7 @@ 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 reactive value, it gets recreated on every render, and +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). @@ -372,7 +372,7 @@ events](https://react.dev/learn/you-might-not-need-an-effect#how-to-remove-unnec 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` reactive value with a `getDiscount` function: +callback. Replace the `discount` derived variable with a `getDiscount` function: <<< Promo.re#get-discount From f6964dec7944aee66945e8c6bb8e281f5a2e7331 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Wed, 31 Jul 2024 21:39:25 +0800 Subject: [PATCH 28/30] Explain reason for [> ] restriction --- docs/order-with-promo/index.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 2b3a2aa7..1428734e 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -129,20 +129,25 @@ 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 we can force it to be a polymorphic variant: +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. +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've refactored the `discount` reactive variable to use a normal variant -instead of a polymorphic variant. There weren't so many changes to the code, but -a basic knowledge of type constructors and type variables is needed to -understand them. In the next sections, we'll set types aside and cover the -UI-related changes you must make to add promo support to the `Order` component. +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 From 5c6a46f4e00b760e0d4df5236398c53181d15ecc Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Thu, 1 Aug 2024 18:47:32 +0800 Subject: [PATCH 29/30] Merge some sections and fix some typos --- docs/order-with-promo/index.md | 78 ++++++++++++++++------------------ 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index 1428734e..b18398f8 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -188,7 +188,8 @@ Add the `Promo` component to the `Order` component: A breakdown: -- Create a new state variable called `discount` +- Create a new state variable called `discount` (along with its attendant + `setDiscount` function) - 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 @@ -208,8 +209,6 @@ least type annotate your component's callback props as a form of documentation. ::: -## Add `React.useEffect1` - To invoke `onApply`, we can add a `useEffect` hook that invokes `onApply` when `discount` has a value of the form `` `Discount(value) ``: @@ -219,7 +218,7 @@ 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". -## `useEffect*` functions +## `React.useEffect*` functions `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 @@ -239,10 +238,8 @@ 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`. -## `useEffect*` dependencies - -The second argument for all `React.useEffect*` functions except -`React.useEffect0` is for the dependencies. For example, the type of +The second argument for all `React.useEffect*` functions (except +`React.useEffect0`) is for the dependencies. For example, the type of `React.useEffect2` is: ```reason @@ -263,34 +260,30 @@ how many dependencies you now have). ::: -## Tuples vs arrays +## Why does `React.useEffect2` accept a tuple? -Both `React.useEffect2` and `React.useEffect3` take their dependencies as a -tuple instead of an array. To understand why, we need to understand the type -properties of tuples and arrays: +`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: - 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"|]` Therefore, we must use tuples to express the dependencies of `useEffect` hooks, -otherwise our dependencies would all have to be of the same type. - -## Tuples become arrays in JS +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. -## Why does `useEffect1` accept an array? - -As you've seen, `React.useEffect2`, `React.useEffect3`, etc all accept a tuple -argument for their dependencies. But `React.useEffect1` is the odd man out, -because it accepts an array. 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 +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 -the generated JS code does the right thing. +it respects the API of the underlying `useEffect` function. ## `RR.useEffect1` helper function @@ -315,9 +308,9 @@ with ReasonReact. 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 +- 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 +- 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" @@ -349,8 +342,6 @@ 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). -## Use `submittedCode` as dependency - The easiest fix is to simply change the dependency to `submittedCode` instead of `discount`: @@ -366,8 +357,9 @@ case](../burger-discounts/#runtime-representation-of-option): - `None` becomes `undefined` - `Some(value)` becomes `value` -Therefore, an `option` value is never an object, and can always be used as a -dependency for React hooks as long as it wraps a primitive 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 @@ -403,7 +395,7 @@ create different collections of items. Add a `datasets` variable to `Demo`: Since the `burgers` value is only used in a single expression, we can move it inside that expression: -<<< Demo.re#burger-expression +<<< Demo.re#burger-expression{2-9} ::: tip @@ -413,13 +405,12 @@ expression. ::: -## Refactor `Demo` to render multiple orders - -Refactor `Demo` to render a different `Order` for each collection of items: +Now we can refactor `Demo` to render a different `Order` for each collection of +items: <<< Demo.re#refactor -You can delete the unused `items` value in `Order`. +Remember to delete the now-unused `Demo.items` variable. --- @@ -432,18 +423,18 @@ the next chapter, we'll further polish the sandwich promotion logic. - 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 to that the polymorphic - variant can accept more than the tags that are listed +- 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 - - You rarely need to use `>` in your own type definitions, but it often - appears in inferred types (that appear when you hover over variables and - functions) - 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_`. -- ReasonReact has several binding functions for React's useEffect hook, e.g. - `React.useEffect0`, `React.useEffect1`, etc + which must be rewritten as `type_`[^5]. +- 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 @@ -651,3 +642,6 @@ and [demo](https://react-book.melange.re/demo/src/order-with-promo/) for this ch [^4]: 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_`. From 116671df4ba8256756b78a1f68e428d972dcf84f Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Fri, 2 Aug 2024 22:20:19 +0800 Subject: [PATCH 30/30] Complete exercise 5 --- docs/order-with-promo/Discount.re | 37 +++++++++++++++ docs/order-with-promo/DiscountTests.re | 23 ++++++++++ docs/order-with-promo/Item.re | 2 +- docs/order-with-promo/Order.re | 2 +- docs/order-with-promo/Promo.re | 21 +++++++++ docs/order-with-promo/index.md | 62 ++++++++++++++++++++++---- 6 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 docs/order-with-promo/DiscountTests.re diff --git a/docs/order-with-promo/Discount.re b/docs/order-with-promo/Discount.re index 3a7efbac..978e2e4c 100644 --- a/docs/order-with-promo/Discount.re +++ b/docs/order-with-promo/Discount.re @@ -8,3 +8,40 @@ let getDiscountFunction = (code, _date) => { | _ => Error(InvalidCode) }; }; + +let getSandwichHalfOff = (~date as _: Js.Date.t, _items: list(Item.t)) => + Error(`MissingSandwichTypes([])); + +type sandwichTracker = { + portabello: bool, + ham: bool, + unicorn: bool, + turducken: bool, +}; + +let _ = + (~tracker, ~date, ~items) => { + let _ = + // #region missing-sandwich-types + 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); + | tracker => + let missing = + [ + tracker.portabello ? "" : "portabello", + tracker.ham ? "" : "ham", + tracker.unicorn ? "" : "unicorn", + tracker.turducken ? "" : "turducken", + ] + |> List.filter((!=)("")); + Error(`MissingSandwichTypes(missing)); + }; + // #endregion missing-sandwich-types + (); + }; diff --git a/docs/order-with-promo/DiscountTests.re b/docs/order-with-promo/DiscountTests.re new file mode 100644 index 00000000..e24ec5e3 --- /dev/null +++ b/docs/order-with-promo/DiscountTests.re @@ -0,0 +1,23 @@ +open Fest; + +let june3 = Js.Date.fromString("2024-06-03T00:00"); + +module SandwichHalfOff = { + // #region not-all-sandwiches + test("Not all sandwiches, return Error", () => + expect + |> deepEqual( + Discount.getSandwichHalfOff( + ~date=june3, + [ + Sandwich(Unicorn), + Hotdog, + Sandwich(Portabello), + Sandwich(Ham), + ], + ), + Error(`MissingSandwichTypes(["turducken"])), + ) + ); + // #endregion not-all-sandwiches +}; diff --git a/docs/order-with-promo/Item.re b/docs/order-with-promo/Item.re index 8b8a0f2a..57d188b9 100644 --- a/docs/order-with-promo/Item.re +++ b/docs/order-with-promo/Item.re @@ -21,6 +21,6 @@ type t = | Burger(Burger.t) | Hotdog; -let toPrice = (_t: t) => 0.; +let toPrice = (~date as _: Js.Date.t, _t: t) => 0.; let toEmoji = (_t: t) => ""; diff --git a/docs/order-with-promo/Order.re b/docs/order-with-promo/Order.re index c202f33e..1cd9b4ad 100644 --- a/docs/order-with-promo/Order.re +++ b/docs/order-with-promo/Order.re @@ -28,7 +28,7 @@ let make = (~items: t, ~date: Js.Date.t) => { let subtotal = items |> ListLabels.fold_left(~init=0., ~f=(acc, order) => - acc +. Item.toPrice(order) + acc +. Item.toPrice(order, ~date) ); diff --git a/docs/order-with-promo/Promo.re b/docs/order-with-promo/Promo.re index e5e7c474..74a0f952 100644 --- a/docs/order-with-promo/Promo.re +++ b/docs/order-with-promo/Promo.re @@ -194,3 +194,24 @@ let _ = // #endregion on-submit />; }; + +let _ = + (~thing) => { + switch (thing) { + // #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})} +
    ; + // #endregion show-missing-sandwich-types + }; + }; diff --git a/docs/order-with-promo/index.md b/docs/order-with-promo/index.md index b18398f8..09b7faed 100644 --- a/docs/order-with-promo/index.md +++ b/docs/order-with-promo/index.md @@ -486,6 +486,11 @@ 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 @@ -519,7 +524,7 @@ than the listed variant tags. ::: -3. Fix the following code (playground) which fails to compile: +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] */ @@ -544,7 +549,7 @@ Fix the type annotation. ::: details Hint 2 -Delete the type annotation and see what happens. +Delete the type annotation. ::: @@ -567,7 +572,7 @@ 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 type annotation above. +inferred type would be the same as the type annotation above. ::: @@ -594,25 +599,64 @@ Then refactor `Demo.make` to use the new component: ::: -5. 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. +5. 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{13} + +Note that the `` `MissingSandwichTypes`` variant tag now has an argument which +is a list of strings. ::: details Hint 1 -Add a `list(string)` argument to the `` `MissingSandwichTypes`` variant tag. +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`. ::: ::: details Hint 2 -tbd +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. ::: ::: details Solution -tbd +Change the switch expression inside `Discount.getSandwichHalfOff` so that when +there are missing sandwich types, they are collected in a list and returned as +the argument of `` `MissingSandwichTypes`` error tag: + +<<< Discount.re#missing-sandwich-types{9-18} + +Note that instead of using partial application in + +```reason +|> List.filter((!=)("")); +``` + +We could have instead written + +```reason +|> List.filter(s => s != "") +``` + +which is a little more verbose and arguably easier to understand. + +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. :::