From b1e974a3d3000653a026f0acbfe19995a7e3b4f7 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Sun, 21 Jan 2024 19:46:22 -0600 Subject: [PATCH 1/5] Add better burgers chapter --- docs/.vitepress/config.js | 1 + docs/better-burgers/Item.re | 389 +++++++++++++++++++++++ docs/better-burgers/dune | 6 + docs/better-burgers/index.md | 345 ++++++++++++++++++++ index.html | 3 + src/better-burgers/Format.re | 2 + src/better-burgers/Index.re | 28 ++ src/better-burgers/Item.re | 101 ++++++ src/better-burgers/Order.re | 35 ++ src/better-burgers/dune | 8 + src/better-burgers/index.html | 12 + src/better-burgers/order-item.module.css | 11 + src/better-burgers/order.module.css | 13 + vite.config.js | 1 + 14 files changed, 955 insertions(+) create mode 100644 docs/better-burgers/Item.re create mode 100644 docs/better-burgers/dune create mode 100644 docs/better-burgers/index.md create mode 100644 src/better-burgers/Format.re create mode 100644 src/better-burgers/Index.re create mode 100644 src/better-burgers/Item.re create mode 100644 src/better-burgers/Order.re create mode 100644 src/better-burgers/dune create mode 100644 src/better-burgers/index.html create mode 100644 src/better-burgers/order-item.module.css create mode 100644 src/better-burgers/order.module.css diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index d02b670b..1eea69c0 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -37,6 +37,7 @@ export default defineConfig({ { text: 'Order Confirmation', link: '/order-confirmation/' }, { text: 'Styling with CSS', link: '/styling-with-css/' }, { text: 'Better Sandwiches', link: '/better-sandwiches/' }, + { text: 'Better Burgers', link: '/better-burgers/' }, ] } ], diff --git a/docs/better-burgers/Item.re b/docs/better-burgers/Item.re new file mode 100644 index 00000000..118665e5 --- /dev/null +++ b/docs/better-burgers/Item.re @@ -0,0 +1,389 @@ +module ItemV1 = { + // #region starting-item-t + type sandwich = + | Portabello + | Ham + | Unicorn + | Turducken; + + type t = + | Sandwich(sandwich) + | Burger + | Hotdog; + // #endregion starting-item-t +}; + +module ItemV2 = { + type sandwich = + | Portabello + | Ham + | Unicorn + | Turducken; + + // #region add-burger-type + type burger = { + lettuce: bool, + onions: int, + cheese: int, + }; + + type t = + | Sandwich(sandwich) + | Burger(burger) + | Hotdog; + // #endregion add-burger-type + + // #region to-emoji + let toEmoji = + fun + | Hotdog => {js|🌭|js} + | Sandwich(sandwich) => + Printf.sprintf( + {js|πŸ₯ͺ(%s)|js}, + switch (sandwich) { + | Portabello => {js|πŸ„|js} + | Ham => {js|🐷|js} + | Unicorn => {js|πŸ¦„|js} + | Turducken => {js|πŸ¦ƒπŸ¦†πŸ“|js} + }, + ) + | Burger(burger) => + Printf.sprintf( + {js|πŸ”{%s}|js}, + [| + burger.lettuce ? {js|πŸ₯¬|js} : "", + {js|πŸ§…Γ—|js} ++ string_of_int(burger.onions), + {js|πŸ§€Γ—|js} ++ string_of_int(burger.cheese), + |] + |> Js.Array.filter(str => str != "") + |> Js.Array.joinWith(", "), + ); + // #endregion to-emoji + + let toEmojiBurgerDestructure = + fun + | Hotdog + | Sandwich(_) => "fake" + // #region destructure-burger + | Burger(burger) => { + let {lettuce, onions, cheese} = burger; + Printf.sprintf( + {js|πŸ”{%s}|js}, + [| + lettuce ? {js|πŸ₯¬|js} : "", + {js|πŸ§…Γ—|js} ++ string_of_int(onions), + {js|πŸ§€Γ—|js} ++ string_of_int(cheese), + |] + |> Js.Array.filter(str => str != "") + |> Js.Array.joinWith(", "), + ); + }; + // #endregion destructure-burger + + let toEmojiBurgerDestructureBranch = + fun + | Hotdog + | Sandwich(_) => "fake" + // #region destructure-burger-branch + | Burger({lettuce, onions, cheese}) => + Printf.sprintf( + {js|πŸ”{%s}|js}, + [| + lettuce ? {js|πŸ₯¬|js} : "", + {js|πŸ§…Γ—|js} ++ string_of_int(onions), + {js|πŸ§€Γ—|js} ++ string_of_int(cheese), + |] + |> Js.Array.filter(str => str != "") + |> Js.Array.joinWith(", "), + ); + // #endregion destructure-burger-branch + + let toEmojiBurgerMultiple = + fun + | Hotdog + | Sandwich(_) => "fake" + // #region multiple + | Burger({lettuce, onions, cheese}) => { + let multiple = (emoji, count) => + Printf.sprintf({js|%sΓ—%d|js}, emoji, count); + + Printf.sprintf( + {js|πŸ”{%s}|js}, + [| + lettuce ? {js|πŸ₯¬|js} : "", + multiple({js|πŸ§…|js}, onions), + multiple({js|πŸ§€|js}, cheese), + |] + |> Js.Array.filter(str => str != "") + |> Js.Array.joinWith(", "), + ); + }; + // #endregion multiple +}; + +module ItemV3 = { + // #region burger-submodule + module Burger = { + type t = { + lettuce: bool, + onions: int, + cheese: int, + }; + + let toEmoji = ({lettuce, onions, cheese}) => { + let multiple = (emoji, count) => + Printf.sprintf({js|%sΓ—%d|js}, emoji, count); + + Printf.sprintf( + {js|πŸ”{%s}|js}, + [| + lettuce ? {js|πŸ₯¬|js} : "", + multiple({js|πŸ§…|js}, onions), + multiple({js|πŸ§€|js}, cheese), + |] + |> Js.Array.filter(str => str != "") + |> Js.Array.joinWith(", "), + ); + }; + + let toPrice = _burger => 15.; + }; + // #endregion burger-submodule + + type sandwich = + | Portabello + | Ham + | Unicorn + | Turducken; + + // #region t-and-functions + type t = + | Sandwich(sandwich) + | Burger(Burger.t) + | Hotdog; + + let toEmoji = + fun + | Hotdog => {js|🌭|js} + | Burger(burger) => Burger.toEmoji(burger) + | Sandwich(sandwich) => + Printf.sprintf( + {js|πŸ₯ͺ(%s)|js}, + switch (sandwich) { + | Portabello => {js|πŸ„|js} + | Ham => {js|🐷|js} + | Unicorn => {js|πŸ¦„|js} + | Turducken => {js|πŸ¦ƒπŸ¦†πŸ“|js} + }, + ); + + let toPrice = t => { + let day = Js.Date.make() |> Js.Date.getDay |> int_of_float; + + switch (t) { + | Sandwich(Portabello | Ham) => 10. + | Sandwich(Unicorn) => 80. + | Sandwich(Turducken) when day == 2 => 10. + | Sandwich(Turducken) => 20. + | Burger(burger) => Burger.toPrice(burger) + | Hotdog => 5. + }; + }; + // #endregion t-and-functions + + module BurgerFunctions = { + type t = { + lettuce: bool, + onions: int, + cheese: int, + }; + + // #region record-wildcard + let toPrice = ({onions, cheese, _}) => + 15. // base cost + +. float_of_int(onions) + *. 0.2 + +. float_of_int(cheese) + *. 0.1; + // #endregion record-wildcard + + toPrice({lettuce: false, onions: 0, cheese: 0}) |> ignore; + + // #region record-lettuce-wildcard + let toPrice = ({onions, cheese, lettuce: _}) => + 15. // base cost + +. float_of_int(onions) + *. 0.2 + +. float_of_int(cheese) + *. 0.1; + // #endregion record-lettuce-wildcard + + // #region ternary + let toEmoji = ({lettuce, onions, cheese}) => { + let multiple = (emoji, count) => + Printf.sprintf({js|%sΓ—%d|js}, emoji, count); + + !lettuce && onions == 0 && cheese == 0 + ? {js|πŸ”|js} + : Printf.sprintf( + {js|πŸ”{%s}|js}, + [| + lettuce ? {js|πŸ₯¬|js} : "", + multiple({js|πŸ§…|js}, onions), + multiple({js|πŸ§€|js}, cheese), + |] + |> Js.Array.filter(str => str != "") + |> Js.Array.joinWith(", "), + ); + }; + // #endregion ternary + + toEmoji({lettuce: false, onions: 0, cheese: 0}) |> ignore; + + // #region match-tuple + let toEmoji = ({lettuce, onions, cheese}) => { + let multiple = (emoji, count) => + Printf.sprintf({js|%sΓ—%d|js}, emoji, count); + + switch (lettuce, onions, cheese) { + | (false, 0, 0) => {js|πŸ”|js} + | (lettuce, onions, cheese) => + Printf.sprintf( + {js|πŸ”{%s}|js}, + [| + lettuce ? {js|πŸ₯¬|js} : "", + multiple({js|πŸ§…|js}, onions), + multiple({js|πŸ§€|js}, cheese), + |] + |> Js.Array.filter(str => str != "") + |> Js.Array.joinWith(", "), + ) + }; + }; + // #endregion match-tuple + + toEmoji({lettuce: false, onions: 0, cheese: 0}) |> ignore; + + // #region match-record + let toEmoji = t => { + let multiple = (emoji, count) => + Printf.sprintf({js|%sΓ—%d|js}, emoji, count); + + switch (t) { + | {lettuce: false, onions: 0, cheese: 0} => {js|πŸ”|js} + | {lettuce, onions, cheese} => + Printf.sprintf( + {js|πŸ”{%s}|js}, + [| + lettuce ? {js|πŸ₯¬|js} : "", + multiple({js|πŸ§…|js}, onions), + multiple({js|πŸ§€|js}, cheese), + |] + |> Js.Array.filter(str => str != "") + |> Js.Array.joinWith(", "), + ) + }; + }; + // #endregion match-record + }; +}; + +// #region sandwich-module +module Sandwich = { + type t = + | Portabello + | Ham + | Unicorn + | Turducken; + + let toPrice = t => { + let day = Js.Date.make() |> 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} + }, + ); +}; +// #endregion sandwich-module + +module BurgerTomatoBacon = { + // #region to-price-tomato-bacon + type t = { + lettuce: bool, + onions: int, + cheese: int, + tomatoes: bool, + bacon: int, + }; + + let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => + 15. // base cost + +. float_of_int(onions) + *. 0.2 + +. float_of_int(cheese) + *. 0.1 + +. (tomatoes ? 0.05 : 0.0) + +. float_of_int(bacon) + *. 0.5; + // #endregion to-price-tomato-bacon + + toPrice({lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0}) + |> ignore; + + // #region to-price-topping-cost + 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); + }; + // #endregion to-price-topping-cost + + // #region to-emoji-multiple + let multiple = (emoji, count) => + switch (count) { + | 0 => "" + | 1 => emoji + | count => Printf.sprintf({js|%sΓ—%d|js}, emoji, count) + }; + // #endregion to-emoji-multiple + + multiple("", 0) |> ignore; + + // #region to-emoji-multiple-fun + let multiple = emoji => + fun + | 0 => "" + | 1 => emoji + | count => Printf.sprintf({js|%sΓ—%d|js}, emoji, count); + // #endregion to-emoji-multiple-fun + + multiple("", 0) |> ignore; + + // #region to-emoji-multiple-fun-annotated + let multiple: (string, int) => string = + emoji => + fun + | 0 => "" + | 1 => emoji + | count => Printf.sprintf({js|%sΓ—%d|js}, emoji, count); + // #endregion to-emoji-multiple-fun-annotated +}; diff --git a/docs/better-burgers/dune b/docs/better-burgers/dune new file mode 100644 index 00000000..af61f6ee --- /dev/null +++ b/docs/better-burgers/dune @@ -0,0 +1,6 @@ +(melange.emit + (target output) + (libraries reason-react) + (preprocess + (pps melange.ppx reason-react-ppx)) + (module_systems es6)) diff --git a/docs/better-burgers/index.md b/docs/better-burgers/index.md new file mode 100644 index 00000000..a96df588 --- /dev/null +++ b/docs/better-burgers/index.md @@ -0,0 +1,345 @@ +# Better Burgers + +Cafe Emoji is still the hottest restaurant in town, despite the fact that all +burgers are exactly the same. Madame Jellobutter isn't one to rest on her +laurels, though, so she decides to kick it up a notch by allowing customers to +choose the toppings on their burgers. + +Currently, your `Item.t` variant type looks something like this: + +<<< Item.re#starting-item-t + +## `burger` record type + +Add a new `burger` *record* type and then add that new type as an argument for +the `Burger` constructor of `Item.t`: + +<<< Item.re#add-burger-type{1-5,9} + +The fields in the `burger` record are: + +| Name | Type | Meaning | +| ---- | ---- | ------- | +| lettuce | bool | if `true`, include lettuce | +| onions | int | the number of onion slices | +| cheese | int | the number of cheese slices | + +Records are similar to the [`Js.t` objects we've seen +before](/celsius-converter-exception/#js-t-object), but the biggest difference +is that record fields must be predefined. Both `Js.t` objects and records +ultimately become JavaScript objects in the transpiled code. + +::: info + +It might be better to give more semantic names to the record fields, such as +`hasLettuce` and `onionCount`, but we've kept the names short in order to make +the code listings shorter. + +::: + +## Update `Item.toEmoji` function + +Depending on what toppings were chosen and how many of toppings there are, we +want to show different emoji: + +| Burger | Return value of `Item.toEmoji` | +| ------ | ------------------------------ | +| `Burger({lettuce: true, onions: 1, cheese: 2})` | πŸ”{πŸ₯¬,πŸ§…Γ—1,πŸ§€Γ—2} | +| `Burger({lettuce: false, onions: 0, cheese: 0})` | πŸ”{πŸ§…Γ—0,πŸ§€Γ—0} | + +To support this logic, we need to add a `| Burger(burger)` branch to the `fun` +expression inside of `Item.toEmoji`: + +<<< Item.re#to-emoji{14-24} + +Note the use of `Js.Array.filter` to filter out empty strings in the array. + +todo: mention partial application + +## Destructuring records + +It's not necessary to write `burger.*` repeatedly, OCaml gives us a nice syntax +for destructuring records: + +<<< Item.re#destructure-burger{2,6-8} + +Even better would be to do the destructuring directly inside the `| Burger(_)` +branch: + +<<< Item.re#destructure-burger-branch{1} + +There is a little bit of redundancy to the logic for displaying onion and cheese +toppings, which we can remedy by adding a small helper function called +`multiple`: + +<<< Item.re#multiple{2-3,9-10} + +## `Burger` submodule + +The `Item.toEmoji` function is arguably getting a bit large and could stand to +be broken up into smaller functions. The most straightforward way to do it is to +add a new function called `Item.toBurgerEmoji` and call it from within +`Item.toEmoji`. However, in OCaml, we have another choice: create a new +submodule called `Burger` and put all the types and functions related to burgers +inside of it: + +<<< Item.re#burger-submodule + +Note that we renamed the `burger` type to `t` since, by convention, the +primary type of a module is called `t`. Also, we put the destructuring of the +burger record in the argument list of the `Burger.toEmoji` function. + +In order to get everything to compile again, we'll need to also update the +definitions for `Item.t`, `Item.toEmoji`, and `Item.toPrice`: + +<<< Item.re#t-and-functions{3,9,29} + +## Update `Item.Burger.toPrice` + +Let's update `Item.Burger.toPrice` so that burgers with more toppings cost more. +Specifically, each onion slice costs 20 cents and each cheese slice costs 10 +cents: + +```reason +let toPrice = ({onions, cheese, lettuce}) => { + 15. // base cost + +. float_of_int(onions) + *. 0.2 + +. float_of_int(cheese) + *. 0.1; +}; +``` + +You get an error from Melange: + +``` +File "src/better-burgers/Item.re", line 24, characters 34-41: +24 | let toPrice = ({onions, cheese, lettuce}) => { + ^^^^^^^ +Error (warning 27 [unused-var-strict]): unused variable lettuce. +``` + +Since Madame Jellobutter doesn't want to charge for lettuce, we don't need the +value of the `lettuce` variable and remove it: + +```reason +let toPrice = ({onions, cheese}) => { + 15. // base cost + +. float_of_int(onions) + *. 0.2 + +. float_of_int(cheese) + *. 0.1; +}; +``` + +However, this results in a different error: + +``` +File "src/better-burgers/Item.re", line 24, characters 17-33: +24 | let toPrice = ({onions, cheese}) => { + ^^^^^^^^^^^^^^^^ +Error (warning 9 [missing-record-field-pattern]): the following labels are not bound in this record pattern: +lettuce +Either bind these labels explicitly or add '; _' to the pattern. +``` + +OCaml doesn't like that you left the `lettuce` field out of the pattern match. +We could take the advice of the error message and add a wildcard to the end of +the record pattern match: + +<<< Item.re#record-wildcard + +But this makes our code less future-proof. For example, what if you added a +`bacon` field to the `Burger.t` record and it was expected that adding bacon +increases the price? With the wildcard in place, the compiler would not tell you +to update the `Burger.toPrice` function. A better way would be to match on +`lettuce` but put a wildcard for its value: + +<<< Item.re#record-lettuce-wildcard + +This tells the compiler that you expect a `lettuce` field but you don't intend +to use its value inside the function. Similarly to how it's preferable to match +on all constructors of a variant type, it's also a good idea to match on all +fields of a record type. + +## Pattern matching records + +Right now, if a customer doesn't add any toppings to a burger, `Burger.toPrice` +will return `πŸ”{πŸ§…Γ—0, πŸ§€Γ—0}`. It would be better if it just returned `πŸ”` to +indicate that it's a burger without embellishments. One way to handle this is to +use a ternary expression: + +<<< Item.re#ternary{5-7} + +Another approach is to pattern match on the tuple of `(lettuce, onions, +cheese)`: + +<<< Item.re#match-tuple{5-7} + +However, pattern matching on tuples of more than 2 elements tends to be hard to +read; it can even be error-prone when some of the elements of the tuple are of +the same type. For example, what if you accidentally changed the positions of +the `onions` and `cheese` variables in the second branch: + +```reason{3} +switch (lettuce, onions, cheese) { +| (false, 0, 0) => {js|πŸ”|js} +| (lettuce, cheese, onions) => +``` + +The compiler wouldn't complain but the ensuing logic would likely be wrong. + +The best approach here is to use the record itself as the input to the switch +expression: + +<<< Item.re#match-record{6-7} + +Note that destructuring of the record has been moved from the argument list to +the branches of the switch expression. Now `Burger.toEmoji` gets the name `t` +for its single argument. + +--- + +Felicitations! The order confirmation widget now supports burgers with different +toppings. In the next chapter, we'll refactor the code to use lists instead of +arrays. + +## Exercises + +1. Inside `Item.re`, create another submodule for sandwich-related types +and functions. + +2. Add `tomatoes: bool` and `bacon: int` fields to `Burger.t`. Let’s say +that adding tomatoes costs $0.05 and each piece of bacon[^1] costs $0.5. + +3. Make `Burger.toPrice` function more readable by writing a helper +function that calculates the cost of a topping by multiplying its price with its +quantity. + +```reason +let toPrice = ({onions, cheese, tomato, bacon, lettuce: _}) => { + let toppingCost = /* write me */; + + 15. // base cost + +. toppingCost(onions, 0.2) + +. toppingCost(cheese, 0.1) + /* some stuff involving tomato and bacon */; +}; +``` + +4. Right now, the `Burger.toEmoji` function shows more emojis than +absolutely necessary. Refactor the `multiple` inner function in `Burger.toEmoji` +so that it exhibits the following behavior: + +| Burger | Expected output | +| ------ | --------------- | +| `Burger({lettuce: true, onions: 1, cheese: 1})` | `πŸ”{πŸ₯¬,πŸ§…,πŸ§€}` | +| `Burger({lettuce: true, onions: 0, cheese: 0})` | `πŸ”{πŸ₯¬}` | + +::: details Hint + +Use a switch expression. + +::: + +## Overview + +- Record types are like `Js.t` objects but their fields must be explicitly + defined + - Records and `Js.t` objects are both JavaScript objects during runtime +- You can get the fields out of a record using destructuring and pattern + matching: + - `let` destructuring: + ```reason + let {a, b, c} = record; + ``` + - Switch expression branch pattern matching: + ```reason + switch (record) { + | {a: "foo", b: "bar", c: 42} => "Magnifique!" + | {a, b, c} => a ++ b ++ string_of_int(c) + }; + ``` +- It's common practice to group related types and functions into a submodule +- Try not to ignore record fields when pattern matching on records. Instead of + ```reason + | {a, b, _} => + ``` + + Prefer + + ```reason + | {a, b, c: _, d: _} => + ``` + Assuming `c` and `d` aren't used inside the branch. +- Try not to pattern match on tuples of more than 2 elements because it tends to + be hard to read + +## Solutions + +1. The `Item.Sandwich` submodule should look something like this: + +<<< Item.re#sandwich-module + +You also need to refactor `Item.toPrice` and `Item.toEmoji` functions to use the +new functions in `Item.Sandwich`. + +2. After adding `tomatoes` and `bacon` fields to `Item.Burger.t`, the +changes to `Item.Burger.toEmoji` are fairly mechanical, so let's focus on +`Item.Burger.toPrice`: + +<<< Item.re#to-price-tomato-bacon + +Note that we keep `lettuce: _` at the end of the pattern match since the value +of `lettuce` isn't ever used. + +3. The `toppingCost` helper function inside `Burger.toPrice` should look +something like this: + +<<< Item.re#to-price-topping-cost + +4. After refactoring the `multiple` helper function inside +`Burger.toEmoji` to avoid showing unnecessary emojis, it should look something +like this: + +<<< Item.re#to-emoji-multiple + +Since the body of this function only consists of a switch expression, you can +actually refacor the `multiple` function to use the `fun` syntax: + +<<< Item.re#to-emoji-multiple-fun + +This might look a little strange, but it works because a two-argument function +in OCaml is, in reality, a one-argument function that returns another function +that accepts a single argument[^2]. Manually adding a type annotation to the +function makes it more clear that it's entirely equivalent to the version that +uses a switch expression: + +<<< Item.re#to-emoji-multiple-fun-annotated + +----- + +View [source +code](https://github.com/melange-re/melange-for-react-devs/blob/main/src/better-burgers/) +and [demo](https://react-book.melange.re/demo/src/better-burgers/) for this chapter. + +----- + +[^1]: Of course, it's not just any kind of bacon. It's unicorn bacon. By the + way, the tomatoes are also magically delicious, because they're grown in a + special field fertilized by unicorn manure. + +[^2]: A simpler example that illustrates the reality of multi-argument functions + in OCaml would be: + + ```reason + let add = (x, y, z) => x + y + z; + // The above is an easier-to-read version of this: + let realAdd = x => y => z => x + y + z; + ``` + + See this [playground + snippet](https://melange.re/v2.2.0/playground/?language=Reason&code=Ly8gVXNlIGEgcmFuZG9tIHZhcmlhYmxlIHNvIGZ1bmN0aW9uIGludm9jYXRpb25zIGFyZW4ndCBvcHRpbWl6ZWQgYXdheQpsZXQgYyA9IFJhbmRvbS5pbnQoMTApOwoKbGV0IGFkZCA9ICh4LCB5LCB6KSA9PiB4ICsgeSArIHo7CkpzLmxvZyhhZGQoMSwgMiwgYykpOwoKLy8gQW4gZXF1aXZhbGVudCBkZWZpbml0aW9uIHRoYXQgZXhwbGljaXRseSByZXR1cm5zIGZ1bmN0aW9uczoKbGV0IHJlYWxBZGQgPSB4ID0%2BIHkgPT4geiA9PiB4ICsgeSArIHo7CkpzLmxvZyhyZWFsQWRkKDEsIDIsIGMpKTsKLy8gQ29uY2VwdHVhbGx5LCB0aGVyZSBhcmUgbXVsdGlwbGUgZnVuY3Rpb24gZGVmaW5pdGlvbnM6CkpzLmxvZyhyZWFsQWRkKDEpKDIpKGMpKTs%3D&live=off) + for an extended example and the [official OCaml + docs](https://ocaml.org/docs/values-and-functions#types-of-functions-of-multiple-parameters) + for more details. diff --git a/index.html b/index.html index b788ec21..341fa8b9 100644 --- a/index.html +++ b/index.html @@ -29,6 +29,9 @@

Melange for React Developers

  • Better Sandwiches
  • +
  • + Better Burgers +
  • diff --git a/src/better-burgers/Format.re b/src/better-burgers/Format.re new file mode 100644 index 00000000..1536313f --- /dev/null +++ b/src/better-burgers/Format.re @@ -0,0 +1,2 @@ +let currency = value => + value |> Js.Float.toFixedWithPrecision(~digits=2) |> React.string; diff --git a/src/better-burgers/Index.re b/src/better-burgers/Index.re new file mode 100644 index 00000000..f2a0f651 --- /dev/null +++ b/src/better-burgers/Index.re @@ -0,0 +1,28 @@ +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 = () => +
    +

    {React.string("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) => ReactDOM.render(, root) +}; diff --git a/src/better-burgers/Item.re b/src/better-burgers/Item.re new file mode 100644 index 00000000..240cd749 --- /dev/null +++ b/src/better-burgers/Item.re @@ -0,0 +1,101 @@ +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 + // todo: Move unicode characters back into format string after Melange 3 is released + | count => Printf.sprintf({js|%s%s%d|js}, emoji, {js|Γ—|js}, count) + }; + + switch (t) { + | {lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0} => {js|πŸ”|js} + | {lettuce, onions, cheese, tomatoes, bacon} => + // todo: Move unicode characters back into format string after Melange 3 is released + Printf.sprintf( + {js|%s{%s}|js}, + {js|πŸ”|js}, + [| + lettuce ? {js|πŸ₯¬|js} : "", + tomatoes ? {js|πŸ…|js} : "", + multiple({js|πŸ§…|js}, onions), + multiple({js|πŸ§€|js}, cheese), + multiple({js|πŸ₯“|js}, bacon), + |] + |> Js.Array.filter(str => str != "") + |> Js.Array.joinWith(", "), + ) + }; + }; + + 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 = t => { + let day = Js.Date.make() |> Js.Date.getDay |> int_of_float; + + switch (t) { + | Portabello + | Ham => 10. + | Unicorn => 80. + | Turducken when day == 2 => 10. + | Turducken => 20. + }; + }; + + let toEmoji = t => + // todo: Put πŸ₯ͺ in format string after Melange 3 is released + // https://github.com/melange-re/melange-for-react-devs/issues/12 + Printf.sprintf( + "%s(%s)", + {js|πŸ₯ͺ|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 = t => { + switch (t) { + | Sandwich(sandwich) => Sandwich.toPrice(sandwich) + | 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/better-burgers/Order.re b/src/better-burgers/Order.re new file mode 100644 index 00000000..bf5b4855 --- /dev/null +++ b/src/better-burgers/Order.re @@ -0,0 +1,35 @@ +type t = array(Item.t); + +module OrderItem = { + [@mel.module "./order-item.module.css"] + external css: Js.t({..}) = "default"; + + [@react.component] + let make = (~item: Item.t) => + + {item |> Item.toEmoji |> React.string} + {item |> Item.toPrice |> Format.currency} + ; +}; + +[@mel.module "./order.module.css"] external css: Js.t({..}) = "default"; + +[@react.component] +let make = (~items: t) => { + let total = + items |> Js.Array.reduce((acc, order) => acc +. Item.toPrice(order), 0.); + + + + {items + |> Js.Array.mapi((item, index) => + + ) + |> React.array} + + + + + +
    {React.string("Total")} {total |> Format.currency}
    ; +}; diff --git a/src/better-burgers/dune b/src/better-burgers/dune new file mode 100644 index 00000000..2ff19d7b --- /dev/null +++ b/src/better-burgers/dune @@ -0,0 +1,8 @@ +(melange.emit + (target output) + (libraries reason-react) + (preprocess + (pps melange.ppx reason-react-ppx)) + (module_systems es6) + (runtime_deps + (glob_files *.css))) diff --git a/src/better-burgers/index.html b/src/better-burgers/index.html new file mode 100644 index 00000000..4936a1c3 --- /dev/null +++ b/src/better-burgers/index.html @@ -0,0 +1,12 @@ + + + + + + Melange for React Devs + + + +
    + + diff --git a/src/better-burgers/order-item.module.css b/src/better-burgers/order-item.module.css new file mode 100644 index 00000000..c90296f2 --- /dev/null +++ b/src/better-burgers/order-item.module.css @@ -0,0 +1,11 @@ +.item { + border-top: 1px solid lightgray; +} + +.emoji { + font-size: 2em; +} + +.price { + text-align: right; +} diff --git a/src/better-burgers/order.module.css b/src/better-burgers/order.module.css new file mode 100644 index 00000000..0d6b4d9c --- /dev/null +++ b/src/better-burgers/order.module.css @@ -0,0 +1,13 @@ +table.order { + border-collapse: collapse; +} + +table.order td { + padding: 0.5em; +} + +.total { + border-top: 1px solid gray; + font-weight: bold; + text-align: right; +} diff --git a/vite.config.js b/vite.config.js index 48057f3f..e24d1182 100644 --- a/vite.config.js +++ b/vite.config.js @@ -24,6 +24,7 @@ export default defineConfig({ 'order-confirmation': resolve(__dirname, 'src/order-confirmation/index.html'), 'styling-with-css': resolve(__dirname, 'src/styling-with-css/index.html'), 'better-sandwiches': resolve(__dirname, 'src/better-sandwiches/index.html'), + 'better-burgers': resolve(__dirname, 'src/better-burgers/index.html'), }, }, }, From a88de9011fcd1d2b966f57503ce092996fac2dab Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 23 Jan 2024 21:11:17 -0600 Subject: [PATCH 2/5] Add more detail about differences between records and Js.t objects --- docs/better-burgers/index.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/better-burgers/index.md b/docs/better-burgers/index.md index a96df588..897e92c5 100644 --- a/docs/better-burgers/index.md +++ b/docs/better-burgers/index.md @@ -25,9 +25,23 @@ The fields in the `burger` record are: | cheese | int | the number of cheese slices | Records are similar to the [`Js.t` objects we've seen -before](/celsius-converter-exception/#js-t-object), but the biggest difference -is that record fields must be predefined. Both `Js.t` objects and records -ultimately become JavaScript objects in the transpiled code. +before](/celsius-converter-exception/#js-t-object), in that they both group a +collection of values into a single object with named fields. However, there are +a number of syntactic and practical differences between them: + +- Record fields must be predefined +- Records use `.` to access fields +- Records can be destructured and pattern matched) +- Record use [nominal + typing](https://en.wikipedia.org/wiki/Nominal_type_system), while `Js.t` + objects use [structural + typing](https://en.wikipedia.org/wiki/Structural_type_system). This means that + two record types with exactly the same fields are still considered different + types. + +The runtime representation of a record is a [plain JavaScript +object](https://melange.re/v2.2.0/communicate-with-javascript/#data-types-and-runtime-representation), +the same as for a `Js.t` object. ::: info @@ -54,8 +68,6 @@ expression inside of `Item.toEmoji`: Note the use of `Js.Array.filter` to filter out empty strings in the array. -todo: mention partial application - ## Destructuring records It's not necessary to write `burger.*` repeatedly, OCaml gives us a nice syntax From 9d8d87dd81be946d40d83a9061c5d88fd3d08f1f Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 23 Jan 2024 21:22:59 -0600 Subject: [PATCH 3/5] Fix headers of input/output tables --- docs/better-burgers/index.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/better-burgers/index.md b/docs/better-burgers/index.md index 897e92c5..dca2adf6 100644 --- a/docs/better-burgers/index.md +++ b/docs/better-burgers/index.md @@ -56,7 +56,7 @@ the code listings shorter. Depending on what toppings were chosen and how many of toppings there are, we want to show different emoji: -| Burger | Return value of `Item.toEmoji` | +| `item` | `Item.toEmoji(item)` | | ------ | ------------------------------ | | `Burger({lettuce: true, onions: 1, cheese: 2})` | πŸ”{πŸ₯¬,πŸ§…Γ—1,πŸ§€Γ—2} | | `Burger({lettuce: false, onions: 0, cheese: 0})` | πŸ”{πŸ§…Γ—0,πŸ§€Γ—0} | @@ -176,10 +176,10 @@ fields of a record type. ## Pattern matching records -Right now, if a customer doesn't add any toppings to a burger, `Burger.toPrice` -will return `πŸ”{πŸ§…Γ—0, πŸ§€Γ—0}`. It would be better if it just returned `πŸ”` to -indicate that it's a burger without embellishments. One way to handle this is to -use a ternary expression: +Right now, if a customer doesn't add any toppings to a burger, +`Item.Burger.toPrice` will return `πŸ”{πŸ§…Γ—0, πŸ§€Γ—0}`. It would be better if it +just returned `πŸ”` to indicate that it's a burger without embellishments. One +way to handle this is to use a ternary expression: <<< Item.re#ternary{5-7} @@ -207,8 +207,8 @@ expression: <<< Item.re#match-record{6-7} Note that destructuring of the record has been moved from the argument list to -the branches of the switch expression. Now `Burger.toEmoji` gets the name `t` -for its single argument. +the branches of the switch expression. Now `Item.Burger.toEmoji` gets the name +`t` for its single argument. --- @@ -221,10 +221,10 @@ arrays. 1. Inside `Item.re`, create another submodule for sandwich-related types and functions. -2. Add `tomatoes: bool` and `bacon: int` fields to `Burger.t`. Let’s say -that adding tomatoes costs $0.05 and each piece of bacon[^1] costs $0.5. +2. Add `tomatoes: bool` and `bacon: int` fields to `Item.Burger.t`. Let’s +say that adding tomatoes costs $0.05 and each piece of bacon[^1] costs $0.5. -3. Make `Burger.toPrice` function more readable by writing a helper +3. Make `Item.Burger.toPrice` function more readable by writing a helper function that calculates the cost of a topping by multiplying its price with its quantity. @@ -239,11 +239,11 @@ let toPrice = ({onions, cheese, tomato, bacon, lettuce: _}) => { }; ``` -4. Right now, the `Burger.toEmoji` function shows more emojis than +4. Right now, the `Item.Burger.toEmoji` function shows more emojis than absolutely necessary. Refactor the `multiple` inner function in `Burger.toEmoji` so that it exhibits the following behavior: -| Burger | Expected output | +| `item` | `Item.Burger.toEmoji(item)` | | ------ | --------------- | | `Burger({lettuce: true, onions: 1, cheese: 1})` | `πŸ”{πŸ₯¬,πŸ§…,πŸ§€}` | | `Burger({lettuce: true, onions: 0, cheese: 0})` | `πŸ”{πŸ₯¬}` | @@ -311,8 +311,8 @@ something like this: <<< Item.re#to-price-topping-cost 4. After refactoring the `multiple` helper function inside -`Burger.toEmoji` to avoid showing unnecessary emojis, it should look something -like this: +`Item.Burger.toEmoji` to avoid showing unnecessary emojis, it should look +something like this: <<< Item.re#to-emoji-multiple From 4f44abc0eb2808354fca6e0c3718054cded994e4 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 23 Jan 2024 21:58:23 -0600 Subject: [PATCH 4/5] Clean up function argument explanation --- docs/better-burgers/index.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/better-burgers/index.md b/docs/better-burgers/index.md index dca2adf6..b1a11cb9 100644 --- a/docs/better-burgers/index.md +++ b/docs/better-burgers/index.md @@ -317,15 +317,15 @@ something like this: <<< Item.re#to-emoji-multiple Since the body of this function only consists of a switch expression, you can -actually refacor the `multiple` function to use the `fun` syntax: +actually refactor the `multiple` function to use the `fun` syntax: <<< Item.re#to-emoji-multiple-fun -This might look a little strange, but it works because a two-argument function -in OCaml is, in reality, a one-argument function that returns another function -that accepts a single argument[^2]. Manually adding a type annotation to the -function makes it more clear that it's entirely equivalent to the version that -uses a switch expression: +This might look a little strange, but in OCaml, all functions take exactly one +argument. What looks like a two-argument function is actually a one-argument +function that returns another one-argument function[^2]. Manually adding a type +annotation to the function makes it more clear that it's entirely equivalent to +the version that uses a switch expression: <<< Item.re#to-emoji-multiple-fun-annotated @@ -341,17 +341,17 @@ and [demo](https://react-book.melange.re/demo/src/better-burgers/) for this chap way, the tomatoes are also magically delicious, because they're grown in a special field fertilized by unicorn manure. -[^2]: A simpler example that illustrates the reality of multi-argument functions - in OCaml would be: +[^2]: A simpler example that illustrates how function arguments work in OCaml + would be: ```reason let add = (x, y, z) => x + y + z; // The above is an easier-to-read version of this: - let realAdd = x => y => z => x + y + z; + let explicitAdd = x => y => z => x + y + z; ``` See this [playground - snippet](https://melange.re/v2.2.0/playground/?language=Reason&code=Ly8gVXNlIGEgcmFuZG9tIHZhcmlhYmxlIHNvIGZ1bmN0aW9uIGludm9jYXRpb25zIGFyZW4ndCBvcHRpbWl6ZWQgYXdheQpsZXQgYyA9IFJhbmRvbS5pbnQoMTApOwoKbGV0IGFkZCA9ICh4LCB5LCB6KSA9PiB4ICsgeSArIHo7CkpzLmxvZyhhZGQoMSwgMiwgYykpOwoKLy8gQW4gZXF1aXZhbGVudCBkZWZpbml0aW9uIHRoYXQgZXhwbGljaXRseSByZXR1cm5zIGZ1bmN0aW9uczoKbGV0IHJlYWxBZGQgPSB4ID0%2BIHkgPT4geiA9PiB4ICsgeSArIHo7CkpzLmxvZyhyZWFsQWRkKDEsIDIsIGMpKTsKLy8gQ29uY2VwdHVhbGx5LCB0aGVyZSBhcmUgbXVsdGlwbGUgZnVuY3Rpb24gZGVmaW5pdGlvbnM6CkpzLmxvZyhyZWFsQWRkKDEpKDIpKGMpKTs%3D&live=off) + snippet](https://melange.re/v2.2.0/playground/?language=Reason&code=Ly8gVXNlIGEgcmFuZG9tIHZhcmlhYmxlIHNvIGZ1bmN0aW9uIGludm9jYXRpb25zIGFyZW4ndCBvcHRpbWl6ZWQgYXdheQpsZXQgYyA9IFJhbmRvbS5pbnQoMTApOwoKbGV0IGFkZCA9ICh4LCB5LCB6KSA9PiB4ICsgeSArIHo7CkpzLmxvZyhhZGQoMSwgMiwgYykpOwoKLy8gQW4gZXF1aXZhbGVudCBkZWZpbml0aW9uIHRoYXQgZXhwbGljaXRseSByZXR1cm5zIGZ1bmN0aW9uczoKbGV0IGV4cGxpY2l0QWRkID0geCA9PiB5ID0%2BIHogPT4geCArIHkgKyB6OwpKcy5sb2coZXhwbGljaXRBZGQoMSwgMiwgYykpOwovLyBDb25jZXB0dWFsbHksIHRoZXJlIGFyZSBtdWx0aXBsZSBmdW5jdGlvbiBpbnZvY2F0aW9ucy4gQnV0IGluIHRoZSBKUyBvdXRwdXQsCi8vIGl0J3MgYSBzaW5nbGUgZnVuY3Rpb24gY2FsbC4KSnMubG9nKGV4cGxpY2l0QWRkKDEpKDIpKGMpKTs%3D&live=off) for an extended example and the [official OCaml docs](https://ocaml.org/docs/values-and-functions#types-of-functions-of-multiple-parameters) for more details. From ad5f95923b2b8cdbc117704b3b8b4434bdb92662 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Wed, 24 Jan 2024 10:40:28 -0600 Subject: [PATCH 5/5] Fix field table values and various typos --- docs/better-burgers/index.md | 12 ++++++------ docs/celsius-converter-exception/index.md | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/better-burgers/index.md b/docs/better-burgers/index.md index b1a11cb9..8d00590a 100644 --- a/docs/better-burgers/index.md +++ b/docs/better-burgers/index.md @@ -20,9 +20,9 @@ The fields in the `burger` record are: | Name | Type | Meaning | | ---- | ---- | ------- | -| lettuce | bool | if `true`, include lettuce | -| onions | int | the number of onion slices | -| cheese | int | the number of cheese slices | +| `lettuce` | `bool` | if `true`, include lettuce | +| `onions` | `int` | the number of onion slices | +| `cheese` | `int` | the number of cheese slices | Records are similar to the [`Js.t` objects we've seen before](/celsius-converter-exception/#js-t-object), in that they both group a @@ -30,9 +30,9 @@ collection of values into a single object with named fields. However, there are a number of syntactic and practical differences between them: - Record fields must be predefined -- Records use `.` to access fields -- Records can be destructured and pattern matched) -- Record use [nominal +- Records use `.` to access fields, while `Js.t` objects use `##` +- Records can be destructured and pattern matched +- Records use [nominal typing](https://en.wikipedia.org/wiki/Nominal_type_system), while `Js.t` objects use [structural typing](https://en.wikipedia.org/wiki/Structural_type_system). This means that diff --git a/docs/celsius-converter-exception/index.md b/docs/celsius-converter-exception/index.md index f2090077..dba55425 100644 --- a/docs/celsius-converter-exception/index.md +++ b/docs/celsius-converter-exception/index.md @@ -61,7 +61,7 @@ concatenation operator (`++`): However, there's a bug in this code: it will crash if you enter anything into the input that can't be converted to a float. We can remedy this by catching the -exception using a `switch` expression: +exception using a switch expression: <<< Snippets.re#catch-exception{4-10}