From 45f81fe18fb9325088fe641fd11bed2633a15bc6 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Fri, 1 Dec 2023 10:11:07 -0600 Subject: [PATCH 01/12] Add order confirmation chapter --- docs/.vitepress/config.js | 1 + docs/order-confirmation/Snippets.re | 165 ++++++++++++++ docs/order-confirmation/dune | 6 + docs/order-confirmation/index.md | 333 ++++++++++++++++++++++++++++ package-lock.json | 234 +++++++++++++++++++ package.json | 2 + src/order-confirmation/Format.re | 2 + src/order-confirmation/Index.re | 17 ++ src/order-confirmation/Item.re | 25 +++ src/order-confirmation/Makefile | 2 + src/order-confirmation/Order.re | 23 ++ src/order-confirmation/dune | 8 + src/order-confirmation/item.css | 11 + src/order-confirmation/order.css | 13 ++ webpack.config.js | 10 +- 15 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 docs/order-confirmation/Snippets.re create mode 100644 docs/order-confirmation/dune create mode 100644 docs/order-confirmation/index.md create mode 100644 src/order-confirmation/Format.re create mode 100644 src/order-confirmation/Index.re create mode 100644 src/order-confirmation/Item.re create mode 100644 src/order-confirmation/Makefile create mode 100644 src/order-confirmation/Order.re create mode 100644 src/order-confirmation/dune create mode 100644 src/order-confirmation/item.css create mode 100644 src/order-confirmation/order.css diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 54a3a3f8..0737a463 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -33,6 +33,7 @@ export default defineConfig({ { text: 'Celsius Converter', link: '/celsius-converter-exception/' }, { text: 'Celsius Converter using Option', link: '/celsius-converter-option/' }, { text: 'Introduction to Dune', link: '/intro-to-dune/' }, + { text: 'Order Confirmation', link: '/order-confirmation/' }, ] } ], diff --git a/docs/order-confirmation/Snippets.re b/docs/order-confirmation/Snippets.re new file mode 100644 index 00000000..82287377 --- /dev/null +++ b/docs/order-confirmation/Snippets.re @@ -0,0 +1,165 @@ +module ItemV1 = { + // #region type-t + type t = + | Sandwich + | Burger; + // #endregion type-t + + // #region to-price + let toPrice = t => + switch (t) { + | Sandwich => 10. + | Burger => 15. + }; + // #endregion to-price +}; + +module Item = { + type t = + | Sandwich + | Burger; + + // #region to-price-fun + let toPrice = + fun + | Sandwich => 10. + | Burger => 15.; + // #endregion to-price-fun + + // #region to-emoji + let toEmoji = + fun + | Sandwich => {js|🥪|js} + | Burger => {js|🍔|js}; + // #endregion to-emoji + + // #region make + [@react.component] + let make = (~item: t) => + + {item |> toEmoji |> React.string} + + {item + |> toPrice + |> Js.Float.toFixedWithPrecision(~digits=2) + |> React.string} + + ; + // #endregion make + + let makeWithClasses = (~item: t) => { + ignore(); + // #region make-with-classes + + {item |> toEmoji |> React.string} + + {item + |> toPrice + |> Js.Float.toFixedWithPrecision(~digits=2) + |> React.string} + + ; + // #endregion make-with-classes + }; +}; + +module Order = { + // #region order + type t = array(Item.t); + + [@react.component] + let make = (~items: t) => { + let total = + items + |> Js.Array.reduce((acc, order) => acc +. Item.toPrice(order), 0.); + + + + {items |> Js.Array.map(item => ) |> React.array} + + + + + +
{React.string("Total")} + {total |> Js.Float.toFixedWithPrecision(~digits=2) |> React.string} +
; + }; + // #endregion order + + let makeWithClasses = (~items: t) => { + let total = 0.; + // #region order-make-with-classes + + + {items + |> Js.Array.mapi((item, index) => + + ) + |> React.array} + + + + + +
{React.string("Total")} + {total |> Js.Float.toFixedWithPrecision(~digits=2) |> React.string} +
; + // #endregion order-make-with-classes + }; +}; + +module Index = { + // #region index + module App = { + let items: Order.t = [|Sandwich, Burger, Sandwich|]; + + [@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) + }; + // #endregion index +}; + +let _ = { + let items = [||]; + // #region mapi + items + |> Js.Array.mapi((item, index) => + + ) + |> React.array + // #endregion mapi + |> ignore; +}; + +module ItemV2 = { + // #region hotdog + type t = + | Sandwich + | Burger + | Hotdog; + + let toPrice = + fun + | Sandwich => 10. + | Burger => 15. + | Hotdog => 5.; + + let toEmoji = + fun + | Sandwich => {js|🥪|js} + | Burger => {js|🍔|js} + | Hotdog => {js|🌭|js}; + // #endregion hotdog +}; diff --git a/docs/order-confirmation/dune b/docs/order-confirmation/dune new file mode 100644 index 00000000..af61f6ee --- /dev/null +++ b/docs/order-confirmation/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/order-confirmation/index.md b/docs/order-confirmation/index.md new file mode 100644 index 00000000..8d313e3b --- /dev/null +++ b/docs/order-confirmation/index.md @@ -0,0 +1,333 @@ +# Order Confirmation + +The famed restauranteur Madame Jellobutter has opened a hot new pop-up +restaurant called Emoji Cafe, and you've been commissioned to build the order +confirmation widget on its website. Feeling adventurous, you decide to build it +using Melange. + +Start by creating a new directory `src/order-confirmation` and give it the +same directory structure as we showed you in the previous chapter: + +``` +src/order-confirmation +├─ dune +├─ Index.re +├─ Makefile +└─ Item.re +``` + +The `dune` file can be copied from any of the existing projects. The `Makefile` +can also be copied over, but remember to update the value of the `app` +environment variable to `order-confirmation`. The `.re` files can be empty for +now. + +## Variant type `t` + +For the time being, there are only two items you can order at Emoji Cafe, the +sandwich or the burger. In `Item.re`, add a new type: + +<<< Snippets.re#type-t + +This is *variant type*[^1] named `t` with two *constructors*, `Sandwich` and +`Burger`. In OCaml, it is customary for the primary type of a module to be +called `t`. + +The `Item` module should contain modules that help us render an item, and to do +that, we'll need functions that can return the price and the emoji[^2] for a +given item. First, add the `toPrice` function: + +<<< Snippets.re#to-price + +If you decide to add, say, a hotdog, to the menu, you would need to: + +- Add a `Hotdog` constructor to `Order.t` +- Add a `| Hotdog` branch to the switch expression of `Order.toPrice` + +Your OCaml code wouldn't compile if you just added a new constructor to +`Item.t` without also updating `Item.toPrice`. Adding or removing constructors +from a variant usually forces you to change relevant parts of your code. + +## `fun` sugar syntax + +Incidentally, there's a sugar syntax for functions whose entire body is a switch +expression. It's called `fun`, and we can rewrite `Item.toPrice` to use it: + +<<< Snippets.re#to-price-fun + +We can also define `toEmoji` using the `fun` sugar syntax: + +<<< Snippets.re#to-emoji + +Using the `fun` syntax is completely equivalent to using a switch expression, +so it's up to your personal taste whether you want to use this sugar syntax. + +## `Order.make` + +Now we're ready to define the `make` function which will render the `Item` +component: + +<<< Snippets.re#make + +The `make` function has a single labeled argument, `~item`, of type `t`. This +effectively means the `Item` component has a single prop named `item`. + +Note that this renders a single row of a table. We'll need another component +to render a table containing all items in an order. + +## `Order` component + +Create a new file `src/order-confirmation/Order.re` and add the following code: + +<<< Snippets.re#order + +There's a lot going on here: + +- The primary type of `Order` is `array(Item.t)`, which is an array of + variants. +- The `make` function has a single labeled argument, `~items`, of type `t`. + This means the `Order` component has a single prop named `items`. +- We sum up the prices of all items using + [Js.Array.reduce](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-reduce). + This is identical to JavaScript's [Array.reduce + method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce), + except that the Melange version requires the initial value to be passed in. +- For each order, we render an `Item` component via + [Js.Array.map](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-map), + which is equivalent to the [Array.map + method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map). +- The `React.array` function has the type signature `array(React.element) => + React.element`, meaning it converts an array of `React.element`s to + `React.element`. All expressions in JSX must have the type `React.element`. + +## `Index.re` + +Render the `Order` component inside `Index.re`: + +<<< Snippets.re#index + +Run `make serve` inside `src/order-confirmation` to see your new app in action. +Open your browser's dev console, where you should see a warning: + +``` +Warning: Each child in a list should have a unique "key" prop. +``` + +Oops, we should've used +[Js.Array.mapi](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-mapi)[^3] +instead so we could set the `key` prop for each `Item` component: + +<<< Snippets.re#mapi + +You've got a basic component now, but it looks... not so great.[^4] + +## Style with CSS + +Let's add styling, but instead of using the `style` prop as we did in previous +chapters, let's use good old CSS. First, install `style-loader` and `css-loader` +webpack plugins: + +``` +npm install --save-dev css-loader style-loader +``` + +Add a new rule to `webpack.config.js` so that you can import .css files as +modules: + +<<< @/../webpack.config.js{5-12} + +Add a new file +`src/order-confirmation/item.css` and give it these styles: + +<<< @/../src/order-confirmation/item.css + +## `mel.raw` extension node + +In OCaml, there is no syntax to import from files, because all modules within a +project are visible to all other modules[^5]. However, we can make use of +JavaScript's `import` syntax by using the [mel.raw extension +node](https://melange.re/v2.1.0/communicate-with-javascript/#generate-raw-javascript), +which allows us to embed raw JavaScript in our OCaml code. Add the following +code to the top of `Item.re`: + +```reason +[%%mel.raw {|import "./item.css"|}]; +``` + +The `{|` and `|}` are string delimiters which allow you to define a string that +doesn't require you to escape characters like double-quote and newline. They are +similar to the `{js|` and `|js}` +delimiters we first saw in the [Celsius +Converter](/celsius-converter-exception/#solutions) chapter, with the +difference that they aren't JavaScript strings so they won't handle Unicode correctly. + +Unfortunately, in the terminal where we're running `make serve`, we see this webpack compilation error: + +``` +ERROR in ./_build/default/src/order-confirmation/output/src/order-confirmation/Item.js 5:0-6:1 +Module not found: Error: Can't resolve './item.css' in '/home/fhsu/work/melange-for-react-devs/_build/default/src/order-confirmation/output/src/order-confirmation' +``` + +## `runtime_deps` field of `melange.emit` + +The problem is that webpack is serving the app from the build directory at +`_build/default/src/order-confirmation/output/src/order-confirmation`, and the +`item.css` file isn't in the build directory. + +To solve this, we can use the [runtime_deps +field](https://melange.re/v2.1.0/build-system/#handling-assets) inside +`src/order-confirmation/dune`: + +```clj{7} +(melange.emit + (target output) + (libraries reason-react) + (preprocess + (pps melange.ppx reason-react-ppx)) + (module_systems es6) + (runtime_deps item.css)) +``` + +We also want to add styles for the `Order` component, so add a new file +`src/order-confirmation/order.css` with these styles: + +<<< @/../src/order-confirmation/order.css + +To ensure that `order.css` is also copied to the build directory, we can add +`order.css` to the value of `runtime_deps`: + +```clj +(runtime_deps item.css order.css) +``` + +A better way is to just tell `runtime_deps` to copy all `.css` files over: + +```clj +(runtime_deps (glob_files *.css)) +``` + +## Add classes to JSX + +Now we can add the appropriate classes to `Item.make`'s JSX: + +<<< Snippets.re#make-with-classes + +As well as `Order.make`'s JSX: + +<<< Snippets.re#order-make-with-classes + +Finally, add a `mel.raw` extension node at the top of `Order.re`: + +```reason +[%%mel.raw {|import "./order.css"|}]; +``` + +Excelsior! Madame Jellobutter is pleased with your progress on the order +confirmation widget. But she plans to add more options for her current menu +items, for example she'd like to more than one type of sandwich. We'll tackle +that in the next chapter. + +## Exercises + +1. We used the `%%mel.raw` extension node to import CSS files as modules, +but there is also a `%mel.raw` extension node. What happens if you use +`%mel.raw` instead? + +2. Add another a `Hotdog` constructor to `Item.t` variant type. Update +the `Item` module's helper functions to get your program to compile again. + +3. Instead of repeatedly using `value |> +Js.Float.toFixedWithPrecision(~digits=2) +|> React.string`, add a helper function `Format.currency` that does +the same thing. + +## Overview + +- By convention, the main type in a module is often named `t` +- A variant is a type that has one or more constructors + - Adding or removing constructors forces you to change the relevant parts of + your code +- The `fun` sugar syntax helps you save a little bit of typing when you have a + function whose entire body is a switch expression +- Adding props to a component by adding labeled arguments to its `make` function +- Useful array helper functions can be found in the + [Js.Array](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html) + module + - The `Js.Array.reduce` function is equivalent to JavaScript's `Array.reduce` + method + - The `Js.Array.map` and `Js.Array.mapi` functions can be used in place of + JavaScripts `Array.map` method +- The `React.array` function is needed when you want to convert an array of + `React.element`s to a single `React.element` +- `mel.raw` extension node is used to embed raw JavaScript in our OCaml code +- The `runtime_deps` field of the `melange.emit` stanza is used to copy assets + like `.css` files into our build directory + +## Solutions + +1. If we use `%mel.raw` instead of `%%mel.raw` in `Item.re`, we'll get this webpack +compilation error: + +```bash +ERROR in ./_build/default/src/order-confirmation/output/src/order-confirmation/Item.js 5:9 +Module parse failed: Unexpected token (5:9) +You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders +| import * as JsxRuntime from "react/jsx-runtime"; +| +> ((import "./item.css")); +``` + +So `%mel.raw` surrounds the JavaScript code you put into it because it wants to +treat it as an expression. Whereas `%%mel.raw` treats the JavaScript it's given +as a statement. If you change it back to `%%mel.raw`, the generated JavaScript +will look like this: + +```javascript +import "./item.css" +; +``` + +2. After you add a `HotDog` constructor to `Item.t`, your `Item` module +should look something like this: + +<<< Snippets.re#hotdog + +Of course, you may have chosen a different price for the hotdog. + +3. In order to create a helper function `Format.currency`, we must create +a new module file called `Format.re` and add a `currency` function: + +<<< @/../src/order-confirmation/Format.re + +Then we can use that function like this: + +```reason + {item |> toPrice |> Format.currency} +``` + +----- + +[Source code for this +chapter](https://github.com/melange-re/melange-for-react-devs/blob/main/src/order-confirmation/) +can be found in the [Melange for React Developers +repo](https://github.com/melange-re/melange-for-react-devs). + +[^1]: Variant types have no equivalent in JavaScript, but they are similar to + TypeScript's [union + enums](https://www.typescriptlang.org/docs/handbook/enums.html#union-enums-and-enum-member-types) + +[^2]: Recall that the name of this restaurant is Emoji Cafe, so everything from + the menu to the order confirmation must use emojis. + +[^3]: Note that unlike JavaScript's [Array.map + method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map), + we have to use + [Js.Array.mapi](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-mapi) + instead of + [Js.Array.map](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-map) + if we want to use the index value. + +[^4]: Madame Jellobutter was passing by and just happened to catch a glimpse of + the unstyled component over your shoulder and puked in her mouth a little. + +[^5]: Recall that in the `Index` modules you've written so far, you've never had + to import any of the components you used that were defined in other files. diff --git a/package-lock.json b/package-lock.json index 72dc799b..daa675e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "css-loader": "^6.8.1", + "style-loader": "^3.3.3", "webpack": "^5.73.0", "webpack-cli": "^4.9.2", "webpack-dev-server": "^4.9.1" @@ -934,6 +936,44 @@ "node": ">= 8" } }, + "node_modules/css-loader": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz", + "integrity": "sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==", + "dev": true, + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.21", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.3", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1625,6 +1665,18 @@ "node": ">=0.10.0" } }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/import-local": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", @@ -1898,6 +1950,18 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -2032,6 +2096,24 @@ "multicast-dns": "cli.js" } }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2287,6 +2369,112 @@ "node": ">=8" } }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.3.tgz", + "integrity": "sha512-2/u2zraspoACtrbFRnTijMiQtb4GW4BvatjaG/bCjYQo8kLTdevCUlwuBHx2sCnSyrI3x3qj4ZK1j5LQBgzmwA==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -2589,6 +2777,21 @@ "node": ">=10" } }, + "node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", @@ -2794,6 +2997,15 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -2907,6 +3119,22 @@ "node": ">=6" } }, + "node_modules/style-loader": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.3.tgz", + "integrity": "sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw==", + "dev": true, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -3521,6 +3749,12 @@ "optional": true } } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true } } } diff --git a/package.json b/package.json index 4bb82d80..41c24f44 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "css-loader": "^6.8.1", + "style-loader": "^3.3.3", "webpack": "^5.73.0", "webpack-cli": "^4.9.2", "webpack-dev-server": "^4.9.1" diff --git a/src/order-confirmation/Format.re b/src/order-confirmation/Format.re new file mode 100644 index 00000000..1536313f --- /dev/null +++ b/src/order-confirmation/Format.re @@ -0,0 +1,2 @@ +let currency = value => + value |> Js.Float.toFixedWithPrecision(~digits=2) |> React.string; diff --git a/src/order-confirmation/Index.re b/src/order-confirmation/Index.re new file mode 100644 index 00000000..35fc8fe8 --- /dev/null +++ b/src/order-confirmation/Index.re @@ -0,0 +1,17 @@ +module App = { + let items: Order.t = [|Sandwich, Burger, Sandwich, Hotdog|]; + + [@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/order-confirmation/Item.re b/src/order-confirmation/Item.re new file mode 100644 index 00000000..98851f9b --- /dev/null +++ b/src/order-confirmation/Item.re @@ -0,0 +1,25 @@ +[%%mel.raw {|import "./item.css"|}]; + +type t = + | Sandwich + | Burger + | Hotdog; + +let toPrice = + fun + | Sandwich => 10. + | Burger => 15. + | Hotdog => 5.; + +let toEmoji = + fun + | Sandwich => {js|🥪|js} + | Burger => {js|🍔|js} + | Hotdog => {js|🌭|js}; + +[@react.component] +let make = (~item: t) => + + {item |> toEmoji |> React.string} + {item |> toPrice |> Format.currency} + ; diff --git a/src/order-confirmation/Makefile b/src/order-confirmation/Makefile new file mode 100644 index 00000000..669cf99f --- /dev/null +++ b/src/order-confirmation/Makefile @@ -0,0 +1,2 @@ +serve: + app=order-confirmation make -C ../.. serve diff --git a/src/order-confirmation/Order.re b/src/order-confirmation/Order.re new file mode 100644 index 00000000..8d3796f6 --- /dev/null +++ b/src/order-confirmation/Order.re @@ -0,0 +1,23 @@ +[%%mel.raw {|import "./order.css"|}]; + +type t = array(Item.t); + +[@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/order-confirmation/dune b/src/order-confirmation/dune new file mode 100644 index 00000000..2ff19d7b --- /dev/null +++ b/src/order-confirmation/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/order-confirmation/item.css b/src/order-confirmation/item.css new file mode 100644 index 00000000..c90296f2 --- /dev/null +++ b/src/order-confirmation/item.css @@ -0,0 +1,11 @@ +.item { + border-top: 1px solid lightgray; +} + +.emoji { + font-size: 2em; +} + +.price { + text-align: right; +} diff --git a/src/order-confirmation/order.css b/src/order-confirmation/order.css new file mode 100644 index 00000000..0d6b4d9c --- /dev/null +++ b/src/order-confirmation/order.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/webpack.config.js b/webpack.config.js index 65609ce2..cfc2d6b2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,13 @@ module.exports = { devServer: { historyApiFallback: true, - } + }, + module: { + rules: [ + { + test: /\.css$/i, + use: ["style-loader", "css-loader"], + }, + ], + }, }; From 2d781b22683c4d4a73d09aaa99ed1b263fe69af2 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 5 Dec 2023 11:13:41 -0600 Subject: [PATCH 02/12] Revise variant type section, add wildcard section --- docs/order-confirmation/index.md | 59 +++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/order-confirmation/index.md b/docs/order-confirmation/index.md index 8d313e3b..7e3b10ea 100644 --- a/docs/order-confirmation/index.md +++ b/docs/order-confirmation/index.md @@ -21,19 +21,20 @@ can also be copied over, but remember to update the value of the `app` environment variable to `order-confirmation`. The `.re` files can be empty for now. -## Variant type `t` +## Variant type `Item.t` For the time being, there are only two items you can order at Emoji Cafe, the sandwich or the burger. In `Item.re`, add a new type: <<< Snippets.re#type-t -This is *variant type*[^1] named `t` with two *constructors*, `Sandwich` and +This is a *variant type*[^1] named `t` with two *constructors*, `Sandwich` and `Burger`. In OCaml, it is customary for the primary type of a module to be -called `t`. +called `t`. This convention makes sense because in other modules, this type will +be referred to as `Item.t`. -The `Item` module should contain modules that help us render an item, and to do -that, we'll need functions that can return the price and the emoji[^2] for a +The `Item` module should contain helper functions that help us render an item, +i.e. we'll need functions that can return the price and the emoji[^2] for a given item. First, add the `toPrice` function: <<< Snippets.re#to-price @@ -43,9 +44,51 @@ If you decide to add, say, a hotdog, to the menu, you would need to: - Add a `Hotdog` constructor to `Order.t` - Add a `| Hotdog` branch to the switch expression of `Order.toPrice` -Your OCaml code wouldn't compile if you just added a new constructor to -`Item.t` without also updating `Item.toPrice`. Adding or removing constructors -from a variant usually forces you to change relevant parts of your code. +Your OCaml code would fail to compile if you added `Hotdog` or removed +`Sandwich` from `Item.t` without also updating `Item.toPrice`. This is one of +the great advantages of variant types: changing the constructors will force you +to change the relevant parts of your code. + +## Wildcard in switch expressions + +Let's say that Madame Jellobutter decides to temporarily lower the price of +burgers so that they're the same price as sandwiches. You could then rewrite +`Item.toPrice` like this: + +```reason +let toPrice = t => + switch (t) { + | _ => 10. + }; +``` + +The underscore (`_`) here serves as a wildcard matching any constructor. +However, this would be a very bad idea! Now changing the constructors in +`Item.t` would not force you to change `Item.toPrice` accordingly. A superior +version would be: + +```reason +let toPrice = t => + switch (t) { + | Sandwich => 10. + | Burger => 10. + }; +``` + +OCaml's pattern-matching syntax allows you to combine branches, which would +simplify it to: + + +```reason +let toPrice = t => + switch (t) { + | Sandwich + | Burger => 10. + }; +``` + +In any case, you should strive to avoid wildcards and explicitly match all +constructors in your switch expressions. ## `fun` sugar syntax From 895b759401c4755a626d9ff3427daf49e5913ff0 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 5 Dec 2023 11:22:40 -0600 Subject: [PATCH 03/12] Stop referring to fun as sugar syntax --- docs/order-confirmation/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/order-confirmation/index.md b/docs/order-confirmation/index.md index 7e3b10ea..be57fc55 100644 --- a/docs/order-confirmation/index.md +++ b/docs/order-confirmation/index.md @@ -90,21 +90,21 @@ let toPrice = t => In any case, you should strive to avoid wildcards and explicitly match all constructors in your switch expressions. -## `fun` sugar syntax +## A `fun` syntax for switch -Incidentally, there's a sugar syntax for functions whose entire body is a switch +There's an alternate, shorter syntax for functions whose entire body is a switch expression. It's called `fun`, and we can rewrite `Item.toPrice` to use it: <<< Snippets.re#to-price-fun -We can also define `toEmoji` using the `fun` sugar syntax: +We can also define `toEmoji` using the `fun` syntax: <<< Snippets.re#to-emoji Using the `fun` syntax is completely equivalent to using a switch expression, -so it's up to your personal taste whether you want to use this sugar syntax. +so it's up to your personal taste whether you want to use one or the other. -## `Order.make` +## `Item.make` Now we're ready to define the `make` function which will render the `Item` component: From 68a424d719ae72ce8db6353e1abbed94ee0f2b8f Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 5 Dec 2023 20:19:01 -0600 Subject: [PATCH 04/12] Small fixes to Order component section --- docs/order-confirmation/index.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/order-confirmation/index.md b/docs/order-confirmation/index.md index be57fc55..b64660ee 100644 --- a/docs/order-confirmation/index.md +++ b/docs/order-confirmation/index.md @@ -127,16 +127,16 @@ There's a lot going on here: - The primary type of `Order` is `array(Item.t)`, which is an array of variants. -- The `make` function has a single labeled argument, `~items`, of type `t`. - This means the `Order` component has a single prop named `items`. +- The `Order.make` function has a single labeled argument, `~items`, of type + `t`. This means the `Order` component has a single prop named `items`. - We sum up the prices of all items using - [Js.Array.reduce](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-reduce). - This is identical to JavaScript's [Array.reduce - method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce), - except that the Melange version requires the initial value to be passed in. + [Js.Array.reduce](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-reduce), + which is the Melange binding to JavaScript's [Array.reduce + method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce). + Note that `Js.Array.reduce` requires the initial value to be passed in. - For each order, we render an `Item` component via [Js.Array.map](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-map), - which is equivalent to the [Array.map + which is the Melange binding to the [Array.map method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map). - The `React.array` function has the type signature `array(React.element) => React.element`, meaning it converts an array of `React.element`s to From c85c07af3b7a875e9de643730cabce55df6dbf3e Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Tue, 5 Dec 2023 21:52:53 -0600 Subject: [PATCH 05/12] Add a section dedicated to explaining React.array --- docs/order-confirmation/Snippets.re | 22 +++++++++++++++++ docs/order-confirmation/index.md | 37 ++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/order-confirmation/Snippets.re b/docs/order-confirmation/Snippets.re index 82287377..9d033919 100644 --- a/docs/order-confirmation/Snippets.re +++ b/docs/order-confirmation/Snippets.re @@ -87,6 +87,28 @@ module Order = { }; // #endregion order + let makeWithItemRows = (~items: t) => { + // #region order-make-item-rows + let total = + items + |> Js.Array.reduce((acc, order) => acc +. Item.toPrice(order), 0.); + + let itemRows = items |> Js.Array.map(item => ); + + + + {itemRows |> React.array} + + + + + +
{React.string("Total")} + {total |> Js.Float.toFixedWithPrecision(~digits=2) |> React.string} +
; + // #endregion order-make-item-rows + }; + let makeWithClasses = (~items: t) => { let total = 0.; // #region order-make-with-classes diff --git a/docs/order-confirmation/index.md b/docs/order-confirmation/index.md index b64660ee..3f49334f 100644 --- a/docs/order-confirmation/index.md +++ b/docs/order-confirmation/index.md @@ -138,9 +138,40 @@ There's a lot going on here: [Js.Array.map](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-map), which is the Melange binding to the [Array.map method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map). -- The `React.array` function has the type signature `array(React.element) => - React.element`, meaning it converts an array of `React.element`s to - `React.element`. All expressions in JSX must have the type `React.element`. + +## `React.array` + +You might have noticed that we need a call to `React.array` after the call to +`Js.Array.map`: + +```reason +{items |> Js.Array.map(item => ) |> React.array} +``` + +If we left off the call to `React.array`, we'd get this error: + +``` +File "src/order-confirmation/Order.re", lines 12, characters 6-12: +12 | {items |> Js.Array.map(item => )} + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Error: This expression has type React.element array + but an expression was expected of type React.element +``` + +We get this error primarily because collections in OCaml can only contain +elements of the same type. The `tbody` element expects children of type +`React.element`, but the call to `Js.Array.map` returns `array(React.element)`, +which creates a type mismatch. To make the actual type match the expected type, +we must add a call to `React.array` which turns `array(React.element)` to +`React.element`. + +To better see what types are at play, it might make sense to refactor +`Order.make` like so: + +<<< Snippets.re#order-make-item-rows{5,9} + +This way you can hover over `itemRows` and see that it has type +`array(React.element)`. ## `Index.re` From 3a7d0a0f735041e7b5f04dbf8edec45ea411a1b8 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Wed, 6 Dec 2023 10:42:34 -0600 Subject: [PATCH 06/12] Delete sections on styling with CSS --- docs/order-confirmation/Snippets.re | 40 +------ docs/order-confirmation/index.md | 171 +++++----------------------- src/order-confirmation/Item.re | 9 -- src/order-confirmation/Order.re | 13 ++- src/order-confirmation/dune | 4 +- src/order-confirmation/item.css | 11 -- src/order-confirmation/order.css | 13 --- 7 files changed, 40 insertions(+), 221 deletions(-) delete mode 100644 src/order-confirmation/item.css delete mode 100644 src/order-confirmation/order.css diff --git a/docs/order-confirmation/Snippets.re b/docs/order-confirmation/Snippets.re index 9d033919..c807a5bf 100644 --- a/docs/order-confirmation/Snippets.re +++ b/docs/order-confirmation/Snippets.re @@ -46,21 +46,6 @@ module Item = { ; // #endregion make - - let makeWithClasses = (~item: t) => { - ignore(); - // #region make-with-classes - - {item |> toEmoji |> React.string} - - {item - |> toPrice - |> Js.Float.toFixedWithPrecision(~digits=2) - |> React.string} - - ; - // #endregion make-with-classes - }; }; module Order = { @@ -95,10 +80,10 @@ module Order = { let itemRows = items |> Js.Array.map(item => ); - +
{itemRows |> React.array} - +
{React.string("Total")} {total |> Js.Float.toFixedWithPrecision(~digits=2) |> React.string} @@ -108,27 +93,6 @@ module Order = {
; // #endregion order-make-item-rows }; - - let makeWithClasses = (~items: t) => { - let total = 0.; - // #region order-make-with-classes - - - {items - |> Js.Array.mapi((item, index) => - - ) - |> React.array} - - - - - -
{React.string("Total")} - {total |> Js.Float.toFixedWithPrecision(~digits=2) |> React.string} -
; - // #endregion order-make-with-classes - }; }; module Index = { diff --git a/docs/order-confirmation/index.md b/docs/order-confirmation/index.md index 3f49334f..e2403d1e 100644 --- a/docs/order-confirmation/index.md +++ b/docs/order-confirmation/index.md @@ -148,7 +148,7 @@ You might have noticed that we need a call to `React.array` after the call to {items |> Js.Array.map(item => ) |> React.array} ``` -If we left off the call to `React.array`, we'd get this error: +If we leave off the call to `React.array`, we'd get this error: ``` File "src/order-confirmation/Order.re", lines 12, characters 6-12: @@ -160,7 +160,7 @@ Error: This expression has type React.element array We get this error primarily because collections in OCaml can only contain elements of the same type. The `tbody` element expects children of type -`React.element`, but the call to `Js.Array.map` returns `array(React.element)`, +`React.element`[^3], but the call to `Js.Array.map` returns `array(React.element)`, which creates a type mismatch. To make the actual type match the expected type, we must add a call to `React.array` which turns `array(React.element)` to `React.element`. @@ -187,124 +187,21 @@ Warning: Each child in a list should have a unique "key" prop. ``` Oops, we should've used -[Js.Array.mapi](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-mapi)[^3] +[Js.Array.mapi](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-mapi)[^4] instead so we could set the `key` prop for each `Item` component: <<< Snippets.re#mapi -You've got a basic component now, but it looks... not so great.[^4] - -## Style with CSS - -Let's add styling, but instead of using the `style` prop as we did in previous -chapters, let's use good old CSS. First, install `style-loader` and `css-loader` -webpack plugins: - -``` -npm install --save-dev css-loader style-loader -``` - -Add a new rule to `webpack.config.js` so that you can import .css files as -modules: - -<<< @/../webpack.config.js{5-12} - -Add a new file -`src/order-confirmation/item.css` and give it these styles: - -<<< @/../src/order-confirmation/item.css - -## `mel.raw` extension node - -In OCaml, there is no syntax to import from files, because all modules within a -project are visible to all other modules[^5]. However, we can make use of -JavaScript's `import` syntax by using the [mel.raw extension -node](https://melange.re/v2.1.0/communicate-with-javascript/#generate-raw-javascript), -which allows us to embed raw JavaScript in our OCaml code. Add the following -code to the top of `Item.re`: - -```reason -[%%mel.raw {|import "./item.css"|}]; -``` - -The `{|` and `|}` are string delimiters which allow you to define a string that -doesn't require you to escape characters like double-quote and newline. They are -similar to the `{js|` and `|js}` -delimiters we first saw in the [Celsius -Converter](/celsius-converter-exception/#solutions) chapter, with the -difference that they aren't JavaScript strings so they won't handle Unicode correctly. - -Unfortunately, in the terminal where we're running `make serve`, we see this webpack compilation error: - -``` -ERROR in ./_build/default/src/order-confirmation/output/src/order-confirmation/Item.js 5:0-6:1 -Module not found: Error: Can't resolve './item.css' in '/home/fhsu/work/melange-for-react-devs/_build/default/src/order-confirmation/output/src/order-confirmation' -``` - -## `runtime_deps` field of `melange.emit` - -The problem is that webpack is serving the app from the build directory at -`_build/default/src/order-confirmation/output/src/order-confirmation`, and the -`item.css` file isn't in the build directory. - -To solve this, we can use the [runtime_deps -field](https://melange.re/v2.1.0/build-system/#handling-assets) inside -`src/order-confirmation/dune`: - -```clj{7} -(melange.emit - (target output) - (libraries reason-react) - (preprocess - (pps melange.ppx reason-react-ppx)) - (module_systems es6) - (runtime_deps item.css)) -``` - -We also want to add styles for the `Order` component, so add a new file -`src/order-confirmation/order.css` with these styles: - -<<< @/../src/order-confirmation/order.css - -To ensure that `order.css` is also copied to the build directory, we can add -`order.css` to the value of `runtime_deps`: - -```clj -(runtime_deps item.css order.css) -``` - -A better way is to just tell `runtime_deps` to copy all `.css` files over: - -```clj -(runtime_deps (glob_files *.css)) -``` - -## Add classes to JSX - -Now we can add the appropriate classes to `Item.make`'s JSX: - -<<< Snippets.re#make-with-classes - -As well as `Order.make`'s JSX: - -<<< Snippets.re#order-make-with-classes - -Finally, add a `mel.raw` extension node at the top of `Order.re`: - -```reason -[%%mel.raw {|import "./order.css"|}]; -``` - -Excelsior! Madame Jellobutter is pleased with your progress on the order -confirmation widget. But she plans to add more options for her current menu -items, for example she'd like to more than one type of sandwich. We'll tackle -that in the next chapter. +Great, you've got a basic component now, but it looks... not so great[^5]. In +the next chapter, we'll see how ReasonReact components can be styled with plain +old CSS. ## Exercises -1. We used the `%%mel.raw` extension node to import CSS files as modules, -but there is also a `%mel.raw` extension node. What happens if you use -`%mel.raw` instead? +1. The `Item` component is only used inside the `Order` component and we +don't expect it to be used anywhere else (items rendered in a menu component +would look different). Rename it to `OrderItem` and move it inside the `Order` +module. 2. Add another a `Hotdog` constructor to `Item.t` variant type. Update the `Item` module's helper functions to get your program to compile again. @@ -320,45 +217,29 @@ the same thing. - A variant is a type that has one or more constructors - Adding or removing constructors forces you to change the relevant parts of your code -- The `fun` sugar syntax helps you save a little bit of typing when you have a +- The `fun` syntax helps you save a little bit of typing when you have a function whose entire body is a switch expression - Adding props to a component by adding labeled arguments to its `make` function - Useful array helper functions can be found in the [Js.Array](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html) module - - The `Js.Array.reduce` function is equivalent to JavaScript's `Array.reduce` + - The `Js.Array.reduce` function is the binding to JavaScript's `Array.reduce` method - - The `Js.Array.map` and `Js.Array.mapi` functions can be used in place of - JavaScripts `Array.map` method + - The `Js.Array.map` and `Js.Array.mapi` functions are both bindings to + JavaScript's `Array.map` method - The `React.array` function is needed when you want to convert an array of `React.element`s to a single `React.element` -- `mel.raw` extension node is used to embed raw JavaScript in our OCaml code -- The `runtime_deps` field of the `melange.emit` stanza is used to copy assets - like `.css` files into our build directory ## Solutions -1. If we use `%mel.raw` instead of `%%mel.raw` in `Item.re`, we'll get this webpack -compilation error: +1. To move the `Item` component from the `Item` module to the +`Order` module, you'll have to move the `Item.make` function to a submodule +called `Order.OrderItem`. Then you'll have to prefix the references to `t`, +`toPrice`, and `toEmoji` with `Item.` since they're now being referenced outside +the `Item` module. After you're done, `src/order-confirmation/Order.re` should +look something like this: -```bash -ERROR in ./_build/default/src/order-confirmation/output/src/order-confirmation/Item.js 5:9 -Module parse failed: Unexpected token (5:9) -You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders -| import * as JsxRuntime from "react/jsx-runtime"; -| -> ((import "./item.css")); -``` - -So `%mel.raw` surrounds the JavaScript code you put into it because it wants to -treat it as an expression. Whereas `%%mel.raw` treats the JavaScript it's given -as a statement. If you change it back to `%%mel.raw`, the generated JavaScript -will look like this: - -```javascript -import "./item.css" -; -``` +<<< @/../src/order-confirmation/Order.re 2. After you add a `HotDog` constructor to `Item.t`, your `Item` module should look something like this: @@ -392,7 +273,11 @@ repo](https://github.com/melange-re/melange-for-react-devs). [^2]: Recall that the name of this restaurant is Emoji Cafe, so everything from the menu to the order confirmation must use emojis. -[^3]: Note that unlike JavaScript's [Array.map +[^3]: All lowercase elements like `div`, `span`, `table`, etc expect their + children to be of type `React.element`. But React components (with uppercase + names) can take children of any type. + +[^4]: Note that unlike JavaScript's [Array.map method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map), we have to use [Js.Array.mapi](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-mapi) @@ -400,8 +285,6 @@ repo](https://github.com/melange-re/melange-for-react-devs). [Js.Array.map](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-map) if we want to use the index value. -[^4]: Madame Jellobutter was passing by and just happened to catch a glimpse of +[^5]: Madame Jellobutter was passing by and just happened to catch a glimpse of the unstyled component over your shoulder and puked in her mouth a little. -[^5]: Recall that in the `Index` modules you've written so far, you've never had - to import any of the components you used that were defined in other files. diff --git a/src/order-confirmation/Item.re b/src/order-confirmation/Item.re index 98851f9b..15af8c87 100644 --- a/src/order-confirmation/Item.re +++ b/src/order-confirmation/Item.re @@ -1,5 +1,3 @@ -[%%mel.raw {|import "./item.css"|}]; - type t = | Sandwich | Burger @@ -16,10 +14,3 @@ let toEmoji = | Sandwich => {js|🥪|js} | Burger => {js|🍔|js} | Hotdog => {js|🌭|js}; - -[@react.component] -let make = (~item: t) => - - {item |> toEmoji |> React.string} - {item |> toPrice |> Format.currency} - ; diff --git a/src/order-confirmation/Order.re b/src/order-confirmation/Order.re index 8d3796f6..7267f739 100644 --- a/src/order-confirmation/Order.re +++ b/src/order-confirmation/Order.re @@ -1,7 +1,14 @@ -[%%mel.raw {|import "./order.css"|}]; - type t = array(Item.t); +module OrderItem = { + [@react.component] + let make = (~item: Item.t) => + + {item |> Item.toEmoji |> React.string} + {item |> Item.toPrice |> Format.currency} + ; +}; + [@react.component] let make = (~items: t) => { let total = @@ -11,7 +18,7 @@ let make = (~items: t) => { {items |> Js.Array.mapi((item, index) => - + ) |> React.array} diff --git a/src/order-confirmation/dune b/src/order-confirmation/dune index 2ff19d7b..af61f6ee 100644 --- a/src/order-confirmation/dune +++ b/src/order-confirmation/dune @@ -3,6 +3,4 @@ (libraries reason-react) (preprocess (pps melange.ppx reason-react-ppx)) - (module_systems es6) - (runtime_deps - (glob_files *.css))) + (module_systems es6)) diff --git a/src/order-confirmation/item.css b/src/order-confirmation/item.css deleted file mode 100644 index c90296f2..00000000 --- a/src/order-confirmation/item.css +++ /dev/null @@ -1,11 +0,0 @@ -.item { - border-top: 1px solid lightgray; -} - -.emoji { - font-size: 2em; -} - -.price { - text-align: right; -} diff --git a/src/order-confirmation/order.css b/src/order-confirmation/order.css deleted file mode 100644 index 0d6b4d9c..00000000 --- a/src/order-confirmation/order.css +++ /dev/null @@ -1,13 +0,0 @@ -table.order { - border-collapse: collapse; -} - -table.order td { - padding: 0.5em; -} - -.total { - border-top: 1px solid gray; - font-weight: bold; - text-align: right; -} From 8e4c39fbd9849a3e57d9b01841fc85190b6d3adb Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Wed, 6 Dec 2023 11:37:33 -0600 Subject: [PATCH 07/12] Add Js.Array.mapi section along with many small fixes --- docs/order-confirmation/index.md | 77 +++++++++++++++++++------------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/docs/order-confirmation/index.md b/docs/order-confirmation/index.md index e2403d1e..64ee3cd6 100644 --- a/docs/order-confirmation/index.md +++ b/docs/order-confirmation/index.md @@ -33,13 +33,12 @@ This is a *variant type*[^1] named `t` with two *constructors*, `Sandwich` and called `t`. This convention makes sense because in other modules, this type will be referred to as `Item.t`. -The `Item` module should contain helper functions that help us render an item, -i.e. we'll need functions that can return the price and the emoji[^2] for a -given item. First, add the `toPrice` function: +The `Item` module should contain helper functions that return the price and the +emoji[^2] for a given item. First, add the `toPrice` function: <<< Snippets.re#to-price -If you decide to add, say, a hotdog, to the menu, you would need to: +If Madame Jellobutter decides to add a hotdog to the menu, you would need to: - Add a `Hotdog` constructor to `Order.t` - Add a `| Hotdog` branch to the switch expression of `Order.toPrice` @@ -51,9 +50,9 @@ to change the relevant parts of your code. ## Wildcard in switch expressions -Let's say that Madame Jellobutter decides to temporarily lower the price of -burgers so that they're the same price as sandwiches. You could then rewrite -`Item.toPrice` like this: +If Madame Jellobutter decides to do a promotion that lowers the price of burgers +so that they're the same price as sandwiches, you could rewrite `Item.toPrice` +to: ```reason let toPrice = t => @@ -75,7 +74,7 @@ let toPrice = t => }; ``` -OCaml's pattern-matching syntax allows you to combine branches, which would +Since OCaml's pattern-matching syntax allows you to combine branches, you can simplify it to: @@ -87,8 +86,8 @@ let toPrice = t => }; ``` -In any case, you should strive to avoid wildcards and explicitly match all -constructors in your switch expressions. +In any case, you should strive to avoid wildcards. The OCaml Way is to +explicitly match all constructors in your switch expressions. ## A `fun` syntax for switch @@ -175,26 +174,40 @@ This way you can hover over `itemRows` and see that it has type ## `Index.re` -Render the `Order` component inside `Index.re`: +Render the `Order` component inside `src/order-confirmation/Index.re`: <<< Snippets.re#index Run `make serve` inside `src/order-confirmation` to see your new app in action. + +## `Js.Array.mapi` + Open your browser's dev console, where you should see a warning: ``` Warning: Each child in a list should have a unique "key" prop. ``` -Oops, we should've used -[Js.Array.mapi](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-mapi)[^4] -instead so we could set the `key` prop for each `Item` component: +Oops, we forgot the set the `key` props! One way to fix this is to use +[Js.Array.mapi](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-mapi) +instead[^4] so we can set `key` based on the index of the element: <<< Snippets.re#mapi -Great, you've got a basic component now, but it looks... not so great[^5]. In -the next chapter, we'll see how ReasonReact components can be styled with plain -old CSS. +The `Js.Array.mapi` function is also a binding to the `Array.map` method, but +unlike `Js.Array.map`, it passes the element *and the index* into the callback. +If you hover over it, you'll see that it has the type signature + +``` +(('a, int) => 'b, array('a)) => array('b) +``` + +In practice, it's common for Melange bindings do not match one-to-one to their +JavaScript counterparts. + +Wunderbar! You've got a basic order confirmation component, but it looks... not +so great[^5]. In the next chapter, we'll see how ReasonReact components can be +styled with plain old CSS. ## Exercises @@ -203,8 +216,8 @@ don't expect it to be used anywhere else (items rendered in a menu component would look different). Rename it to `OrderItem` and move it inside the `Order` module. -2. Add another a `Hotdog` constructor to `Item.t` variant type. Update -the `Item` module's helper functions to get your program to compile again. +2. Add another constructor to `Item.t` variant type. Update the `Item` +module's helper functions to get your program to compile again. 3. Instead of repeatedly using `value |> Js.Float.toFixedWithPrecision(~digits=2) @@ -216,19 +229,22 @@ the same thing. - By convention, the main type in a module is often named `t` - A variant is a type that has one or more constructors - Adding or removing constructors forces you to change the relevant parts of - your code + your code, *unless* you use wildcards when pattern-matching on a variant + - Using wildcards in your switch expression makes your code less adaptable to + change - The `fun` syntax helps you save a little bit of typing when you have a function whose entire body is a switch expression -- Adding props to a component by adding labeled arguments to its `make` function -- Useful array helper functions can be found in the - [Js.Array](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html) - module +- Labeled arguments in a component's `make` function are treated as props by + ReasonReact. +- The [Js.Array](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html) + module contains useful array functions - The `Js.Array.reduce` function is the binding to JavaScript's `Array.reduce` method - The `Js.Array.map` and `Js.Array.mapi` functions are both bindings to JavaScript's `Array.map` method - The `React.array` function is needed when you want to convert an array of - `React.element`s to a single `React.element` + `React.element`s to a single `React.element`, e.g. after a call to + `Js.Array.map` ## Solutions @@ -277,13 +293,10 @@ repo](https://github.com/melange-re/melange-for-react-devs). children to be of type `React.element`. But React components (with uppercase names) can take children of any type. -[^4]: Note that unlike JavaScript's [Array.map - method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map), - we have to use - [Js.Array.mapi](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-mapi) - instead of - [Js.Array.map](https://melange.re/v2.1.0/api/re/melange/Js/Array/index.html#val-map) - if we want to use the index value. +[^4]: Using array indexes to set keys violates [React's rules of + keys](https://react.dev/learn/rendering-lists#rules-of-keys), which states + that you shouldn't generate keys while rendering. We'll see a better way to + do this [later](/todo). [^5]: Madame Jellobutter was passing by and just happened to catch a glimpse of the unstyled component over your shoulder and puked in her mouth a little. From ed2005d8b06209da4b6cf5b1ca667bed64457b21 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Wed, 6 Dec 2023 12:11:25 -0600 Subject: [PATCH 08/12] Remove accidental occurrences of className --- docs/order-confirmation/index.md | 6 ++++-- src/order-confirmation/Order.re | 10 +++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/order-confirmation/index.md b/docs/order-confirmation/index.md index 64ee3cd6..9451fce2 100644 --- a/docs/order-confirmation/index.md +++ b/docs/order-confirmation/index.md @@ -262,7 +262,9 @@ should look something like this: <<< Snippets.re#hotdog -Of course, you may have chosen a different price for the hotdog. +Of course, you may have chosen a different price for the hotdog. Or maybe you +didn't add a hotdog at all, and instead added `CannedFood` (🥫) or `PotOfFood` +(🍲). It's totally up to you! 3. In order to create a helper function `Format.currency`, we must create a new module file called `Format.re` and add a `currency` function: @@ -272,7 +274,7 @@ a new module file called `Format.re` and add a `currency` function: Then we can use that function like this: ```reason - {item |> toPrice |> Format.currency} + {item |> toPrice |> Format.currency} ``` ----- diff --git a/src/order-confirmation/Order.re b/src/order-confirmation/Order.re index 7267f739..0ac7391d 100644 --- a/src/order-confirmation/Order.re +++ b/src/order-confirmation/Order.re @@ -3,9 +3,9 @@ type t = array(Item.t); module OrderItem = { [@react.component] let make = (~item: Item.t) => - - {item |> Item.toEmoji |> React.string} - {item |> Item.toPrice |> Format.currency} + + {item |> Item.toEmoji |> React.string} + {item |> Item.toPrice |> Format.currency} ; }; @@ -14,14 +14,14 @@ 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} - + From d4aa3b780a75087567a3c498f27a3b00d93b3fba Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Thu, 7 Dec 2023 20:37:04 -0600 Subject: [PATCH 09/12] Add tip to say that {js||js} strings aren't available in native OCaml --- docs/celsius-converter-exception/index.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/celsius-converter-exception/index.md b/docs/celsius-converter-exception/index.md index f4a379bf..5c619165 100644 --- a/docs/celsius-converter-exception/index.md +++ b/docs/celsius-converter-exception/index.md @@ -195,6 +195,11 @@ rendered: "°C". We can't rely on OCaml strings to [deal with Unicode correctly](https://melange.re/v2.1.0/communicate-with-javascript/#strings), so any string that contains non-ASCII text must be delimited using `{js||js}`. +::: tip +Note that quoted string literals using the `js` identifier are specific to +Melange and are not available in native OCaml. +::: + 2. Rewriting `onChange` the handler to use a single expression creates a potential problem with stale values coming from the event object: From 6634e5973c6961f3385c4fdfc00a162c1a6de030 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Thu, 7 Dec 2023 21:06:36 -0600 Subject: [PATCH 10/12] Add explicit type annotation in order-make-item-rows snippet --- docs/order-confirmation/Snippets.re | 12 +++++++++++- docs/order-confirmation/index.md | 19 ++++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/order-confirmation/Snippets.re b/docs/order-confirmation/Snippets.re index c807a5bf..53ad2381 100644 --- a/docs/order-confirmation/Snippets.re +++ b/docs/order-confirmation/Snippets.re @@ -78,7 +78,8 @@ module Order = { items |> Js.Array.reduce((acc, order) => acc +. Item.toPrice(order), 0.); - let itemRows = items |> Js.Array.map(item => ); + let itemRows: array(React.element) = + items |> Js.Array.map(item => );
{React.string("Total")} {total |> Format.currency}
@@ -149,3 +150,12 @@ module ItemV2 = { | Hotdog => {js|🌭|js}; // #endregion hotdog }; + +let _ = { + // #region react-array-demo + let elemArray: array(React.element) = + [|"a", "b", "c"|] |> Js.Array.map(x => React.string(x)); + Js.log(elemArray); + Js.log(React.array(elemArray)); + // #endregion react-array-demo +}; diff --git a/docs/order-confirmation/index.md b/docs/order-confirmation/index.md index 9451fce2..0be0082c 100644 --- a/docs/order-confirmation/index.md +++ b/docs/order-confirmation/index.md @@ -167,10 +167,23 @@ we must add a call to `React.array` which turns `array(React.element)` to To better see what types are at play, it might make sense to refactor `Order.make` like so: -<<< Snippets.re#order-make-item-rows{5,9} +<<< Snippets.re#order-make-item-rows{5-6,10} -This way you can hover over `itemRows` and see that it has type -`array(React.element)`. +In reality, `React.array` is just there to make the OCaml compiler happy---it +doesn't actually change the the underlying JavaScript object. For example, try +running the [following code in the +playground](https://melange.re/v2.1.0/playground/?language=Reason&code=bGV0IGVsZW1BcnJheTogYXJyYXkoUmVhY3QuZWxlbWVudCkgPQogICAgW3wiYSIsICJiIiwgImMifF0gfD4gSnMuQXJyYXkubWFwKHggPT4gUmVhY3Quc3RyaW5nKHgpKTsKSnMubG9nKGVsZW1BcnJheSk7CkpzLmxvZyhSZWFjdC5hcnJheShlbGVtQXJyYXkpKTs%3D&live=off): + +<<< Snippets.re#react-array-demo + +If you look at the JavaScript output, you'll see that the two calls to `Js.log` +get compiled to + +```javascript +console.log(elemArray); + +console.log(elemArray); +``` ## `Index.re` From 132bda1fb279291c5568538cd73d505882f29947 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Thu, 7 Dec 2023 21:20:51 -0600 Subject: [PATCH 11/12] Sharpen the statement about bindings --- docs/order-confirmation/index.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/order-confirmation/index.md b/docs/order-confirmation/index.md index 0be0082c..b8cf8aa2 100644 --- a/docs/order-confirmation/index.md +++ b/docs/order-confirmation/index.md @@ -215,8 +215,12 @@ If you hover over it, you'll see that it has the type signature (('a, int) => 'b, array('a)) => array('b) ``` -In practice, it's common for Melange bindings do not match one-to-one to their -JavaScript counterparts. +When a JavaScript function has optional arguments, it's common to create +[multiple OCaml functions that bind to +it](https://melange.re/v2.1.0/communicate-with-javascript/#approach-1-multiple-external-functions). +We'll discuss this in more detail [later](/todo). + +----- Wunderbar! You've got a basic order confirmation component, but it looks... not so great[^5]. In the next chapter, we'll see how ReasonReact components can be From 1176139f21ab3f49ad208600197414645ab21349 Mon Sep 17 00:00:00 2001 From: Feihong Hsu Date: Thu, 7 Dec 2023 21:24:14 -0600 Subject: [PATCH 12/12] Change reference to prop type from t to Item.t --- docs/order-confirmation/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/order-confirmation/index.md b/docs/order-confirmation/index.md index b8cf8aa2..b0e03e05 100644 --- a/docs/order-confirmation/index.md +++ b/docs/order-confirmation/index.md @@ -110,8 +110,8 @@ component: <<< Snippets.re#make -The `make` function has a single labeled argument, `~item`, of type `t`. This -effectively means the `Item` component has a single prop named `item`. +The `make` function has a single labeled argument, `~item`, of type `Item.t`. +This effectively means the `Item` component has a single prop named `item`. Note that this renders a single row of a table. We'll need another component to render a table containing all items in an order.