Promo Component
Your work so far has left a deep impression on Madame Jellobutter, so much so that she even sends you on an all-expenses paid trip to an OCaml Hackathon. As soon as you arrive, you make several new friends, and you even meet MonadicFanatic1984 in person![1] You’re extremely psyched to start applying the things you’re learning during the hackathon.
RR
utility module
While having lunch during the hackathon, fellow attendee FunctorPunk[2] suggests that you create a dedicated module for your ReasonReact-related helper functions. Add a new file RR.re
:
/** Get string value from the given event's target */
let getValueFromEvent = (evt): string => React.Event.Form.target(evt)##value;
/** Alias for [React.string] */
let s = React.string;
/** Render a list of [React.element]s */
let list = list => list |> Stdlib.Array.of_list |> React.array;
/** Render a float as currency */
let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string;
/** Get string value from the given event's target */
let getValueFromEvent = (evt): string => React.Event.Form.target(evt)##value;
/** Alias for [React.string] */
let s = React.string;
/** Render a list of [React.element]s */
let list = list => list |> Stdlib.Array.of_list |> React.array;
/** Render a float as currency */
let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string;
These are all functions we used before, but now they’re in one handy place and also have documentation comments.
Documentation comment markup language
In a couple of the documentation comments, you see square brackets ([]
):
/** Alias for [React.string] */
/** Alias for [React.string] */
Whatever is inside the brackets gets rendered in a monospace source code font. This is one example of the markup language used inside documentation comments.
Promo
component
Add new file Promo.re
:
[@react.component]
let make = () => {
let (code, setCode) = React.useState(() => "");
<form onSubmit={evt => evt |> React.Event.Form.preventDefault}>
<input
value=code
onChange={evt => setCode(_ => RR.getValueFromEvent(evt))}
/>
{RR.s("Todo: Discount value or error")}
</form>;
};
[@react.component]
let make = () => {
let (code, setCode) = React.useState(() => "");
<form onSubmit={evt => evt |> React.Event.Form.preventDefault}>
<input
value=code
onChange={evt => setCode(_ => RR.getValueFromEvent(evt))}
/>
{RR.s("Todo: Discount value or error")}
</form>;
};
For now, it just shows an input that allows the user to enter a promo code.
React.useReducer
Another neat trick you learn from FunctorPunk is that you can substitute React.useState with React.useReducer[3] when defining state variables:
[@react.component]
let make = () => {
let (code, dispatch) =
React.useReducer((_state, newState) => newState, "");
<form onSubmit={evt => evt |> React.Event.Form.preventDefault}>
<input
value=code
onChange={evt => dispatch(RR.getValueFromEvent(evt))}
/>
{RR.s("Todo: Discount value or error")}
</form>;
};
[@react.component]
let make = () => {
let (code, dispatch) =
React.useReducer((_state, newState) => newState, "");
<form onSubmit={evt => evt |> React.Event.Form.preventDefault}>
<input
value=code
onChange={evt => dispatch(RR.getValueFromEvent(evt))}
/>
{RR.s("Todo: Discount value or error")}
</form>;
};
The advantage of this is that you can update state by passing a value instead of a callback. To make it even nicer, we can also rename dispatch
to setCode
and use function chaining inside the onChange
callback:
[@react.component]
let make = () => {
let (code, setCode) =
React.useReducer((_state, newState) => newState, "");
<form onSubmit={evt => evt |> React.Event.Form.preventDefault}>
<input
value=code
onChange={evt => evt |> RR.getValueFromEvent |> setCode}
/>
{RR.s("Todo: Discount value or error")}
</form>;
};
[@react.component]
let make = () => {
let (code, setCode) =
React.useReducer((_state, newState) => newState, "");
<form onSubmit={evt => evt |> React.Event.Form.preventDefault}>
<input
value=code
onChange={evt => evt |> RR.getValueFromEvent |> setCode}
/>
{RR.s("Todo: Discount value or error")}
</form>;
};
RR.useStateValue
helper function
This state variable pattern can be made more convenient by adding a new helper function called RR.useStateValue
:
/** Like [React.useState] but doesn't use callback functions */
let useStateValue = initial =>
React.useReducer((_state, newState) => newState, initial);
/** Like [React.useState] but doesn't use callback functions */
let useStateValue = initial =>
React.useReducer((_state, newState) => newState, initial);
The state setup code in Promo
then becomes very simple:
let (code, setCode) =
React.useReducer((_state, newState) => newState, "");
let (code, setCode) = RR.useStateValue("");
let (code, setCode) =
React.useReducer((_state, newState) => newState, "");
let (code, setCode) = RR.useStateValue("");
Add Promo
to Index
The Promo
component isn’t finished yet, but let’s add it to Index
so that we can see how it looks as we’re building it:
[@react.component]
let make = () =>
<div>
<h1> {RR.s("Promo")} </h1>
<Promo items date={Js.Date.fromString("2024-05-10T00:00")} />
<h1> {RR.s("Order confirmation")} </h1>
<Order items />
</div>;
[@react.component]
let make = () =>
<div>
<h1> {RR.s("Promo")} </h1>
<Promo items date={Js.Date.fromString("2024-05-10T00:00")} />
<h1> {RR.s("Order confirmation")} </h1>
<Order items />
</div>;
We know that, sooner or later, we must pass items
and date
to Promo
, so we just do it now. However, this breaks the build because Promo
doesn’t yet define those props.
Temporarily ignore props
We can quickfix it by adding the ~items
and ~date
props to Promo.make
and then immediately ignore
ing them in the body of the function:
[@react.component]
let make = (~items: list(Item.t), ~date: Js.Date.t) => {
ignore(items);
ignore(date);
[@react.component]
let make = (~items: list(Item.t), ~date: Js.Date.t) => {
ignore(items);
ignore(date);
A more concise way to temporarily ignore props is to use the as
keyword in the function argument list:
[@react.component]
let make = (~items as _: list(Item.t), ~date as _: Js.Date.t) => {
[@react.component]
let make = (~items as _: list(Item.t), ~date as _: Js.Date.t) => {
Here we rename ~items
to _
, effectively ignoring it, and also do the same for ~date
. Note that this renaming only extends to the scope of the function itself.
styled-ppx
package
During dinner, you meet a cool human named Dave who introduces you to styled-ppx, the package he authored to allow writing type-safe CSS inside OCaml. It’s inspired by Emotion, and also uses Emotion under the hood.
To install styled-ppx
, open your terminal and run:
npm install @emotion/css
opam install styled-ppx
npm install @emotion/css
opam install styled-ppx
After these commands finish, see what version of styled-ppx
was installed by running opam list
:
...
stdune 3.15.2 Dune's unstable standard library
stringext 1.6.0 Extra string functions for OCaml
styled-ppx 0.56.0 Type-safe styled components for ReScript and Melange
time_now v0.16.0 Reports the current time
topkg 1.0.7 The transitory OCaml software packager
...
...
stdune 3.15.2 Dune's unstable standard library
stringext 1.6.0 Extra string functions for OCaml
styled-ppx 0.56.0 Type-safe styled components for ReScript and Melange
time_now v0.16.0 Reports the current time
topkg 1.0.7 The transitory OCaml software packager
...
Add "styled-ppx" {>= "0.56.0"}
to the depends
section of your .opam
file. (Adjust the version based on what you see from ocaml list
.)
To make sure that the version of @emotion/css
you got from npm is compatible with the version of styled-ppx
you got from opam, run
npm run check-npm-deps
npm run check-npm-deps
You should see some output like this:
Ok: opam package "reason-react.0.14.0" requires npm package: "react-dom" with constraint "^18.0.0", version installed: "18.2.0"
Ok: opam package "reason-react.0.14.0" requires npm package: "react" with constraint "^18.0.0", version installed: "18.2.0"
Ok: opam package "styled-ppx.0.56.0" requires npm package: "@emotion/css" with constraint ">=11.0.0", version installed: "11.11.2"
Ok: opam package "reason-react.0.14.0" requires npm package: "react-dom" with constraint "^18.0.0", version installed: "18.2.0"
Ok: opam package "reason-react.0.14.0" requires npm package: "react" with constraint "^18.0.0", version installed: "18.2.0"
Ok: opam package "styled-ppx.0.56.0" requires npm package: "@emotion/css" with constraint ">=11.0.0", version installed: "11.11.2"
TIP
Underneath the covers, npm run check-npm-deps
is just running opam exec opam-check-npm-deps
.
WARNING
The output from npm run check-npm-deps
might not show anything for styled-ppx
yet, that should be fixed soon.
Add styles to Promo
Try adding a style to Promo
’s form
element:
<form
className=[%cx {|display: flex; flex-direction: column;|}]
onSubmit={evt => evt |> React.Event.Form.preventDefault}>
<form
className=[%cx {|display: flex; flex-direction: column;|}]
onSubmit={evt => evt |> React.Event.Form.preventDefault}>
You’ll get a compilation error:
File "src/order-confirmation/Promo.re", line 6, characters 16-18:
6 | className=[%cx {|display: flex; flex-direction: column;|}]
^^
Error: Uninterpreted extension 'cx'.
File "src/order-confirmation/Promo.re", line 6, characters 16-18:
6 | className=[%cx {|display: flex; flex-direction: column;|}]
^^
Error: Uninterpreted extension 'cx'.
styled-ppx
is installed, but it can’t be used until the libraries
and preprocess/pps
stanzas of your dune
file have been updated:
(libraries reason-react melange-fest styled-ppx.emotion)
(preprocess
(pps melange.ppx reason-react-ppx styled-ppx))
(libraries reason-react melange-fest styled-ppx.emotion)
(preprocess
(pps melange.ppx reason-react-ppx styled-ppx))
For reference:
- Add
styled-ppx.emotion
tolibraries
to enable theCss
andCssJs
modules - Add
styled-ppx
topreprocess/pps
to enable the%cx
extension node
If you run npm run serve
, you’ll see that the styles are now applied.
Promo.Style
submodule
Although you can use the %cx
extension node inline within your JSX, it’s usually a good idea to put all of them in a Style
submodule within your component module:
module Style = {
let form = [%cx {|
display: flex;
flex-direction: column;
|}];
let input = [%cx
{|
font-family: monospace;
text-transform: uppercase;
|}
];
};
module Style = {
let form = [%cx {|
display: flex;
flex-direction: column;
|}];
let input = [%cx
{|
font-family: monospace;
text-transform: uppercase;
|}
];
};
Then update your JSX:
<form
className=Style.form
onSubmit={evt => evt |> React.Event.Form.preventDefault}>
<input
className=Style.input
value=code
onChange={evt => evt |> RR.getValueFromEvent |> setCode}
/>
{RR.s("Todo: Discount value or error")}
</form>;
<form
className=Style.form
onSubmit={evt => evt |> React.Event.Form.preventDefault}>
<input
className=Style.input
value=code
onChange={evt => evt |> RR.getValueFromEvent |> setCode}
/>
{RR.s("Todo: Discount value or error")}
</form>;
Using a Style
submodule encourages reuse of class names and tends to make your JSX more readable.
styled-ppx
is type-safe
The strings we pass to the %cx
extension must be valid CSS and each property must have a valid value. To see this in action, change the value of the flex-direction
property:
flex-direction: column;
flex-direction: col;
flex-direction: column;
flex-direction: col;
You’ll get this compilation error:
File "src/order-confirmation/Promo.re", lines 3-4, characters 37-43:
2 | ...................
3 | .................;
4 | flex-direction: col.
Error: Property 'flex-direction' has an invalid value: 'col'
File "src/order-confirmation/Promo.re", lines 3-4, characters 37-43:
2 | ...................
3 | .................;
4 | flex-direction: col.
Error: Property 'flex-direction' has an invalid value: 'col'
Introduce some other errors to your CSS code and see what happens.
Install vscode-styled-ppx
extension
Now that we’re writing our CSS inside our OCaml code, the CSS is no longer highlighted. VS Code users can resolve this by simply installing Dave’s vscode-styled-ppx extension.
Add submittedCode
state variable
Add a new state variable named submittedCode
:
let (code, setCode) = RR.useStateValue("");
let (submittedCode, setSubmittedCode) = RR.useStateValue(None);
<form
className=Style.form
onSubmit={evt => {
evt |> React.Event.Form.preventDefault;
setSubmittedCode(Some(code));
}}>
<input
className=Style.input
value=code
onChange={evt => {
evt |> RR.getValueFromEvent |> setCode;
setSubmittedCode(None);
}}
/>
{RR.s("Todo: Discount value or error")}
</form>;
let (code, setCode) = RR.useStateValue("");
let (submittedCode, setSubmittedCode) = RR.useStateValue(None);
<form
className=Style.form
onSubmit={evt => {
evt |> React.Event.Form.preventDefault;
setSubmittedCode(Some(code));
}}>
<input
className=Style.input
value=code
onChange={evt => {
evt |> RR.getValueFromEvent |> setCode;
setSubmittedCode(None);
}}
/>
{RR.s("Todo: Discount value or error")}
</form>;
A breakdown:
- The initial value of
submittedCode
isNone
, meaning the user hasn’t yet submitted a promo code - Pressing the
Enter
key while editing the input submits the promo code, settingsubmittedCode
toSome(code)
- Editing the input cancels the submitted promo code by setting
submittedCode
back toNone
Add discountFunction
derived variable
Add a discountFunction
variable that derives its value from submittedCode
and date
:
let (submittedCode, setSubmittedCode) = RR.useStateValue(None);
let discountFunction =
submittedCode
|> Option.map(code => Discount.getDiscountFunction(code, date));
let (submittedCode, setSubmittedCode) = RR.useStateValue(None);
let discountFunction =
submittedCode
|> Option.map(code => Discount.getDiscountFunction(code, date));
We have to use Option.map
since submittedCode
’s type is option(string)
. Also, we must stop ignoring the date
prop in order to use its value:
[@react.component]
let make = (~items as _: list(Item.t), ~date as _: Js.Date.t) => {
let make = (~items as _: list(Item.t), ~date: Js.Date.t) => {
[@react.component]
let make = (~items as _: list(Item.t), ~date as _: Js.Date.t) => {
let make = (~items as _: list(Item.t), ~date: Js.Date.t) => {
Render discountFunction
Render discountFunction
under the input
:
<input /* ... */ />
{switch (discountFunction) {
| None
| Some(Ok(_)) => React.null
| Some(Error(error)) =>
<div className=Style.codeError>
{let errorType =
switch (error) {
| Discount.InvalidCode => "Invalid"
| ExpiredCode => "Expired"
};
{j|$errorType promo code|j} |> RR.s}
</div>
}}
<input /* ... */ />
{switch (discountFunction) {
| None
| Some(Ok(_)) => React.null
| Some(Error(error)) =>
<div className=Style.codeError>
{let errorType =
switch (error) {
| Discount.InvalidCode => "Invalid"
| ExpiredCode => "Expired"
};
{j|$errorType promo code|j} |> RR.s}
</div>
}}
We only render discountFunction
when it’s in the error state.
We also need to add the codeError
class name to the Style
submodule, which will cause promo code error messages to be rendered in red:
let codeError = [%cx {|color: red|}];
let codeError = [%cx {|color: red|}];
Add discount
derived variable
Add a discount
variable that derives its value from discountFunction
and items
:
let discountFunction =
submittedCode
|> Option.map(code => Discount.getDiscountFunction(code, date));
let discount =
switch (discountFunction) {
| None
| Some(Error(_)) => None
| Some(Ok(discountFunction)) => Some(discountFunction(items))
};
let discountFunction =
submittedCode
|> Option.map(code => Discount.getDiscountFunction(code, date));
let discount =
switch (discountFunction) {
| None
| Some(Error(_)) => None
| Some(Ok(discountFunction)) => Some(discountFunction(items))
};
To use the value of the items
prop, we must stop ignoring it:
[@react.component]
let make = (~items as _: list(Item.t), ~date: Js.Date.t) => {
let make = (~items: list(Item.t), ~date: Js.Date.t) => {
[@react.component]
let make = (~items as _: list(Item.t), ~date: Js.Date.t) => {
let make = (~items: list(Item.t), ~date: Js.Date.t) => {
Render discount
Render discount
under where we render discountFunction
:
{switch (discountFunction) {
| None
| Some(Ok(_)) => React.null
/* ... */
}}
{switch (discount) {
| None => React.null
| Some(Ok(value)) => value |> Float.neg |> RR.currency
| Some(Error(_code)) =>
<div className=Style.discountError>
{RR.s("Todo: discount error message")}
</div>
}}
{switch (discountFunction) {
| None
| Some(Ok(_)) => React.null
/* ... */
}}
{switch (discount) {
| None => React.null
| Some(Ok(value)) => value |> Float.neg |> RR.currency
| Some(Error(_code)) =>
<div className=Style.discountError>
{RR.s("Todo: discount error message")}
</div>
}}
- When
discount
is of the formSome(Ok(value))
, then the discount must exist and we show its value (with a minus sign in front to indicate that the amount will be subtracted from the total value). - When
discount
is of the formSome(Error(code))
, then an error occurred and we should render an error message based on the value ofcode
(this is left as an exercise at the end of the chapter).
We also need to add discountError
to the Style
submodule, which will cause the discount error to be rendered in purple:
let discountError = [%cx {|color: purple|}];
let discountError = [%cx {|color: purple|}];
Use one switch expression
The current rendering logic is unsatisfactory. On first glance, it looks as if discountFunction
is rendered first, and then discount
is rendered below it. But in reality, only one of the derived variables is ever rendered at a time. That suggests that we could use a single switch expression for the render logic. It’s helpful to list the four mutually-exclusive states we need to render:
State | What to render |
---|---|
No promo code submitted | Nothing |
Promo code invalid or expired | Error message, e.g. “Invalid promo code” |
Error applying discount | Error message, e.g. “Buy at least one more burger to enjoy this promotion” |
Discount | The value of the discount with a minus sign in front |
One way to refactor to a single switch expression is to change the input of the switch expression to be the tuple (discountFunction, discount)
:
{switch (discountFunction, discount) {
| (Some(_), Some(Ok(value))) => value |> Float.neg |> RR.currency
| (Some(Error(error)), _) =>
<div className=Style.codeError>
{let errorType =
switch (error) {
| Discount.InvalidCode => "Invalid"
| ExpiredCode => "Expired"
};
{j|$errorType promo code|j} |> RR.s}
</div>
| (_, Some(Error(_code))) =>
<div className=Style.discountError>
{RR.s("Todo: discount error message")}
</div>
| (None, None)
| (Some(_), None)
| (None, Some(_)) => React.null
}}
{switch (discountFunction, discount) {
| (Some(_), Some(Ok(value))) => value |> Float.neg |> RR.currency
| (Some(Error(error)), _) =>
<div className=Style.codeError>
{let errorType =
switch (error) {
| Discount.InvalidCode => "Invalid"
| ExpiredCode => "Expired"
};
{j|$errorType promo code|j} |> RR.s}
</div>
| (_, Some(Error(_code))) =>
<div className=Style.discountError>
{RR.s("Todo: discount error message")}
</div>
| (None, None)
| (Some(_), None)
| (None, Some(_)) => React.null
}}
This works, but it’s a lot of pattern matching to render just four states.
Use a single discount
derived variable
We cannot have better rendering logic without first refactoring the derived variables. Replace discountFunction
and discount
with just a single derived variable discount
:
let discount =
switch (submittedCode) {
| None => `NoSubmittedCode
| Some(code) =>
switch (Discount.getDiscountFunction(code, date)) {
| Error(error) => `CodeError(error)
| Ok(discountFunction) =>
switch (discountFunction(items)) {
| Error(error) => `DiscountError(error)
| Ok(value) => `Discount(value)
}
}
};
let discount =
switch (submittedCode) {
| None => `NoSubmittedCode
| Some(code) =>
switch (Discount.getDiscountFunction(code, date)) {
| Error(error) => `CodeError(error)
| Ok(discountFunction) =>
switch (discountFunction(items)) {
| Error(error) => `DiscountError(error)
| Ok(value) => `Discount(value)
}
}
};
Here we used three nested switch expressions to explicitly map the values from all the inputs to the four mutually-exclusive states we want to render. The flow of data goes like this:
- If
submittedCode
isNone
, return`NoSubmittedCode
- If
submittedCode
isSome(code)
, then invokeDiscount.getDiscountFunction(code, date)
- If the result of
Discount.getDiscountFunction(code, date)
isError(error)
, then return`CodeError(error)
- If the result of
Discount.getDiscountFunction(code, date)
isOk(discountFunction)
, then invokediscountFunction(items)
- If the result of
discountFunction(items)
isError(error)
, then return`DiscountError(error)
- If the result of
discountFunction(items)
isOk(value)
, then return`Discount(value)
- If the result of
- If the result of
Because we use polymorphic variant tags, we don’t need to define a new type. If you hover over the discount
variable in your editor you’ll see that its inferred type is:
[> `CodeError(Discount.error)
| `Discount(float)
| `DiscountError([> `MissingSandwichTypes
| `NeedMegaBurger
| `NeedOneBurger
| `NeedTwoBurgers ])
| `NoSubmittedCode ]
[> `CodeError(Discount.error)
| `Discount(float)
| `DiscountError([> `MissingSandwichTypes
| `NeedMegaBurger
| `NeedOneBurger
| `NeedTwoBurgers ])
| `NoSubmittedCode ]
Render discount
polymorphic variant
Now change the switch expression in your render logic to take the discount
polymorphic variant as its only input:
{switch (discount) {
| `NoSubmittedCode => React.null
| `Discount(discount) => discount |> Float.neg |> RR.currency
| `CodeError(error) =>
<div className=Style.codeError>
{let errorType =
switch (error) {
| Discount.InvalidCode => "Invalid"
| ExpiredCode => "Expired"
};
{j|$errorType promo code|j} |> RR.s}
</div>
| `DiscountError(_code) =>
<div className=Style.discountError>
{RR.s("Todo: discount error message")}
</div>
}}
{switch (discount) {
| `NoSubmittedCode => React.null
| `Discount(discount) => discount |> Float.neg |> RR.currency
| `CodeError(error) =>
<div className=Style.codeError>
{let errorType =
switch (error) {
| Discount.InvalidCode => "Invalid"
| ExpiredCode => "Expired"
};
{j|$errorType promo code|j} |> RR.s}
</div>
| `DiscountError(_code) =>
<div className=Style.discountError>
{RR.s("Todo: discount error message")}
</div>
}}
The pattern matching is now more concise and more readable, and it is still type-safe. Try commenting out the `NoSubmittedCode
branch and you’ll get a compilation error:
File "src/order-confirmation/Promo.re", lines 52-68, characters 4-7:
52 | ....{switch (discount) {
53 | // | `NoSubmittedCode => React.null
54 | | `Discount(discount) => discount |> Float.neg |> RR.currency
55 | | `CodeError(error) =>
56 | <div className=Style.codeError>
...
65 | <div className=Style.discountError>
66 | {RR.s("Todo: discount error message")}
67 | </div>
68 | }}
Error (warning 8 [partial-match]): this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
`NoSubmittedCode
File "src/order-confirmation/Promo.re", lines 52-68, characters 4-7:
52 | ....{switch (discount) {
53 | // | `NoSubmittedCode => React.null
54 | | `Discount(discount) => discount |> Float.neg |> RR.currency
55 | | `CodeError(error) =>
56 | <div className=Style.codeError>
...
65 | <div className=Style.discountError>
66 | {RR.s("Todo: discount error message")}
67 | </div>
68 | }}
Error (warning 8 [partial-match]): this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
`NoSubmittedCode
Muy bueno! You’ve created a Promo
component that can be used to submit promo codes and see the discounts they produce, along with any errors that might occur. In the next chapter, we’ll integrate this Promo
component into your Order
component.
Overview
- Documentation comments have a markup language
- Use
React.useReducer
to create state variables that can be set without callback functions- You should create a helper function to make this pattern more convenient to use
- Temporarily ignore function arguments using the
as
keyword, e.g.~items as _
will cause the~items
argument to be ignored - The
styled-ppx
package allows you to write type-safe CSS inside your OCaml code- You must add some extra values to the
libraries
andpreprocess/pps
fields of yourdune
file to usestyled-ppx
- Write your CSS inside the
%cx
extension node - It’s a good idea to group the class names you create using
%cx
inside a submodule of your component module (you can name itStyle
or something similar) - Install the
vscode-styled-ppx
VS Code extension to get syntax highlighting for CSS written inside%cx
- You must add some extra values to the
- Run
opam exec opam-check-npm-deps
to check that your NPM dependencies are compatible with your Melange packages - A good strategy for rendering mutually-exclusive states is to create a single switch expression that maps your inputs to each state, where each state is represented by a different variant tag.
Exercises
1. Render the different error messages for the `DiscountError
branch of Promo
’s switch expression. Here’s what message should be shown for each tag:
Variant tag | Message |
---|---|
`NeedOneBurger | Buy at least 1 more burger to enjoy this promotion |
`NeedTwoBurgers | Buy at least 1 more burgers to enjoy this promotion |
`NeedMegaBurger | Buy a burger with every topping to enjoy this promotion |
`MissingSandwichTypes | Buy every sandwich to enjoy this promotion |
Solution
{switch (discount) {
/* ... */
| `DiscountError(code) =>
let buyWhat =
switch (code) {
| `NeedOneBurger => "at least 1 more burger"
| `NeedTwoBurgers => "at least 2 burgers"
| `NeedMegaBurger => "a burger with every topping"
| `MissingSandwichTypes => "every sandwich"
};
<div className=Style.discountError>
{RR.s({j|Buy $buyWhat to enjoy this promotion|j})}
</div>;
}}
{switch (discount) {
/* ... */
| `DiscountError(code) =>
let buyWhat =
switch (code) {
| `NeedOneBurger => "at least 1 more burger"
| `NeedTwoBurgers => "at least 2 burgers"
| `NeedMegaBurger => "a burger with every topping"
| `MissingSandwichTypes => "every sandwich"
};
<div className=Style.discountError>
{RR.s({j|Buy $buyWhat to enjoy this promotion|j})}
</div>;
}}
2. Refactor the Order
component to use styled-ppx
and your new RR
helper function module.
Hint
styled-ppx
supports nested selectors.
Solution
After refactoring, Order.re
might look something like this:
type t = list(Item.t);
module OrderItem = {
module Style = {
let item = [%cx {|border-top: 1px solid lightgray;|}];
let emoji = [%cx {|font-size: 2em;|}];
let price = [%cx {|text-align: right;|}];
};
[@react.component]
let make = (~item: Item.t) =>
<tr className=Style.item>
<td className=Style.emoji> {item |> Item.toEmoji |> RR.s} </td>
<td className=Style.price> {item |> Item.toPrice |> RR.currency} </td>
</tr>;
};
module Style = {
let order = [%cx
{|
border-collapse: collapse;
td {
padding: 0.5em;
}
|}
];
let total = [%cx
{|
border-top: 1px solid gray;
font-weight: bold;
text-align: right;
|}
];
};
[@react.component]
let make = (~items: t) => {
let total =
items
|> ListLabels.fold_left(~init=0., ~f=(acc, order) =>
acc +. Item.toPrice(order)
);
<table className=Style.order>
<tbody>
{items
|> List.mapi((index, item) =>
<OrderItem key={"item-" ++ string_of_int(index)} item />
)
|> RR.list}
<tr className=Style.total>
<td> {RR.s("Total")} </td>
<td> {total |> RR.currency} </td>
</tr>
</tbody>
</table>;
};
type t = list(Item.t);
module OrderItem = {
module Style = {
let item = [%cx {|border-top: 1px solid lightgray;|}];
let emoji = [%cx {|font-size: 2em;|}];
let price = [%cx {|text-align: right;|}];
};
[@react.component]
let make = (~item: Item.t) =>
<tr className=Style.item>
<td className=Style.emoji> {item |> Item.toEmoji |> RR.s} </td>
<td className=Style.price> {item |> Item.toPrice |> RR.currency} </td>
</tr>;
};
module Style = {
let order = [%cx
{|
border-collapse: collapse;
td {
padding: 0.5em;
}
|}
];
let total = [%cx
{|
border-top: 1px solid gray;
font-weight: bold;
text-align: right;
|}
];
};
[@react.component]
let make = (~items: t) => {
let total =
items
|> ListLabels.fold_left(~init=0., ~f=(acc, order) =>
acc +. Item.toPrice(order)
);
<table className=Style.order>
<tbody>
{items
|> List.mapi((index, item) =>
<OrderItem key={"item-" ++ string_of_int(index)} item />
)
|> RR.list}
<tr className=Style.total>
<td> {RR.s("Total")} </td>
<td> {total |> RR.currency} </td>
</tr>
</tbody>
</table>;
};
You should do some cleanup:
- Delete
Format
module, since it has been replaced byRR
. - Delete
order-item.module.css
andorder.module.css
files, since they are no longer used. - Delete the
melange.emit/runtime_deps
stanza from yourdune
file, since there are no more.css
files to copy into the build directory.
3. Right now, Item.toPrice
is a nondeterministic function—it returns a different price for a turducken sandwich depending on the day of the week[4]. This makes writing some discount tests more complicated than necessary, and could cause more problems down the road. Refactor Item.toPrice
by adding a labeled argument ~date
, then:
- Fix the resulting compilation errors by adding
~date
arguments to functions higher on the call chain - Simplify the tests that have this comment:
// Don't use hardcoded value since Item.toPrice is non-deterministic
- You might break the tests in
DiscountTests.GetDiscount
, but don’t worry about that for now. We’ll fix those tests in the following exercise.
Hint
Let the compilation errors guide your refactoring.
Hint
Feel free to make liberal use of partial application.
Solution
Most of the changes are fairly mechanical, consisting of adding a ~date
argument up the function call chain until you get to Index.App.make
.
There is at least one point of interest—you can opt to not give Discount.getFreeBurgers
a ~date
argument, since it only computes prices of burgers, which don’t fluctuate with time. But then you must use partial application in Discount.getDiscountFunction
:
let getDiscountFunction = (code, date) => {
let month = date |> Js.Date.getMonth;
let dayOfMonth = date |> Js.Date.getDate;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Ok(getFreeBurgers)
| "HALF" when month == 4.0 && dayOfMonth == 28.0 => Ok(getHalfOff(~date))
| "HALF" when month == 10.0 && dayOfMonth == 3.0 =>
Ok(getSandwichHalfOff(~date))
| "FREE"
| "HALF" => Error(ExpiredCode)
| _ => Error(InvalidCode)
};
};
let getDiscountFunction = (code, date) => {
let month = date |> Js.Date.getMonth;
let dayOfMonth = date |> Js.Date.getDate;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Ok(getFreeBurgers)
| "HALF" when month == 4.0 && dayOfMonth == 28.0 => Ok(getHalfOff(~date))
| "HALF" when month == 10.0 && dayOfMonth == 3.0 =>
Ok(getSandwichHalfOff(~date))
| "FREE"
| "HALF" => Error(ExpiredCode)
| _ => Error(InvalidCode)
};
};
The indirect consequence of this is that tests like this fail:
test(
"HALF promo code returns getHalfOff on May 28 but not other days of May",
() => {
for (dayOfMonth in 1 to 31) {
let date =
Js.Date.makeWithYMD(
~year=2024.,
~month=4.0,
~date=float_of_int(dayOfMonth),
);
expect
|> deepEqual(
Discount.getDiscountFunction("HALF", date),
dayOfMonth == 28
? Ok(Discount.getHalfOff(~date)) : Error(ExpiredCode),
);
}
});
test(
"HALF promo code returns getHalfOff on May 28 but not other days of May",
() => {
for (dayOfMonth in 1 to 31) {
let date =
Js.Date.makeWithYMD(
~year=2024.,
~month=4.0,
~date=float_of_int(dayOfMonth),
);
expect
|> deepEqual(
Discount.getDiscountFunction("HALF", date),
dayOfMonth == 28
? Ok(Discount.getHalfOff(~date)) : Error(ExpiredCode),
);
}
});
Comparing two partially-applied functions for strict equality will always fail, because they are two newly-created objects. See this playground snippet for a concise demonstration of this.
The next exercise will explore how we can make the tests pass without adding an extraneous ~date
argument to Discount.getFreeBurgers
.
4. Fix the tests in DiscountTests.GetDiscount
that were broken by your refactoring in the previous exercise.
Hint
Make a helper function that is similar to Discount.getDiscountFunction
but can return something like this: Ok((`FreeBurgers, getFreeBurgers))
Hint
Use Result.map, fst, and snd.
Solution
Inside Discount
, define a helper function called getDiscountPair
, and redefine getDiscountFunction
using it:
let getDiscountPair = (code, date) => {
let month = date |> Js.Date.getMonth;
let dayOfMonth = date |> Js.Date.getDate;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Ok((`FreeBurgers, getFreeBurgers))
| "HALF" when month == 4.0 && dayOfMonth == 28.0 =>
Ok((`HalfOff, getHalfOff(~date)))
| "HALF" when month == 10.0 && dayOfMonth == 3.0 =>
Ok((`SandwichHalfOff, getSandwichHalfOff(~date)))
| "FREE"
| "HALF" => Error(ExpiredCode)
| _ => Error(InvalidCode)
};
};
let getDiscountFunction = (code, date) =>
getDiscountPair(code, date) |> Result.map(snd);
let getDiscountPair = (code, date) => {
let month = date |> Js.Date.getMonth;
let dayOfMonth = date |> Js.Date.getDate;
switch (code |> Js.String.toUpperCase) {
| "FREE" when month == 4.0 => Ok((`FreeBurgers, getFreeBurgers))
| "HALF" when month == 4.0 && dayOfMonth == 28.0 =>
Ok((`HalfOff, getHalfOff(~date)))
| "HALF" when month == 10.0 && dayOfMonth == 3.0 =>
Ok((`SandwichHalfOff, getSandwichHalfOff(~date)))
| "FREE"
| "HALF" => Error(ExpiredCode)
| _ => Error(InvalidCode)
};
};
let getDiscountFunction = (code, date) =>
getDiscountPair(code, date) |> Result.map(snd);
The type signature of getDiscountFunction
remains unchanged, returning a function encased in Ok
when it succeeds. In contrast, getDiscountPair
returns a 2-tuple encased in Ok
:
- The first element of the tuple is a variant tag indicating which function was returned, e.g.
`FreeBurger
,`HalfOff
, etc. - The second element of the tuple is the discount function
The variant tag serves to give test code something to compare against:
module GetDiscount = {
let getDiscountFunction = (code, date) =>
Discount.getDiscountPair(code, date) |> Result.map(fst);
// ...
test(
"HALF promo code returns getHalfOff on May 28 but not other days of May",
() => {
for (dayOfMonth in 1 to 31) {
let date =
Js.Date.makeWithYMD(
~year=2024.,
~month=4.0,
~date=float_of_int(dayOfMonth),
);
expect
|> deepEqual(
getDiscountFunction("HALF", date),
dayOfMonth == 28 ? Ok(`HalfOff) : Error(ExpiredCode),
);
}
});
// ...
};
module GetDiscount = {
let getDiscountFunction = (code, date) =>
Discount.getDiscountPair(code, date) |> Result.map(fst);
// ...
test(
"HALF promo code returns getHalfOff on May 28 but not other days of May",
() => {
for (dayOfMonth in 1 to 31) {
let date =
Js.Date.makeWithYMD(
~year=2024.,
~month=4.0,
~date=float_of_int(dayOfMonth),
);
expect
|> deepEqual(
getDiscountFunction("HALF", date),
dayOfMonth == 28 ? Ok(`HalfOff) : Error(ExpiredCode),
);
}
});
// ...
};
Recall that the runtime representation of a variant tag without arguments is just a string. They can be compared using Fest.deepEqual
without any surprises.
Run npm run test
to verify that all tests are passing again.
View source code and demo for this chapter.
MonadicFanatic1984 in real life is a genetically-modified gorilla who escaped from a mad scientist’s laboratory. He used to be in charge of developing the mad scientist’s website until he escaped because he wanted to migrate the website to Melange but the mad scientist didn’t approve. ↩︎
FunctorPunk is a cybernetically-enhanced wombat who escaped from a mad scientist’s laboratory (no, not the same one). He escaped because FREEDOM. ↩︎
React.useReducer
is the ReasonReact binding for React’s useReducer hook. ↩︎Recall the long-running Turducken Tuesdays promotion, in which turducken sandwiches are half off every Tuesday. ↩︎