Skip to content

Commit 2b0f42b

Browse files
committed
Full update with the published vite-plugin-scalajs.
Also address comments about tests and about unclear things.
1 parent 70b9205 commit 2b0f42b

File tree

3 files changed

+167
-76
lines changed

3 files changed

+167
-76
lines changed

doc/tutorial/laminar.md

Lines changed: 151 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,12 @@ We declare `counter` as a `Var[Int]` initialized with `0`.
142142
We then use it in two *bindings*:
143143

144144
* In `child.text <-- counter`, we declare a text child of the button whose content will always reflect the value of `counter`.
145+
Together with the first (immutable) text child `"count is "`, it initially forms the text `"count is 0"`.
145146
As the value of `counter` changes over time, so does the text in the button.
146147
* In `counter.update(c => c + 1)`, we schedule an update of the value of `counter`, to be increased by 1.
147148
We schedule that as a result of the `onClick -->` event of the button.
148149

149-
We do not need to explicitly set the `innerHTML` attribute of the button.
150+
We do not need to explicitly set the `innerText` attribute of the button.
150151
That is taken care of by the `<--` binding.
151152

152153
Unlike frameworks based on a virtual DOM, Laminar bindings directly target the DOM element to update.
@@ -230,61 +231,156 @@ The ID will serve to uniquely identify rows of our table even if they happen to
230231
Think about a delete button on each row: if we click it, we would like the corresponding row to be removed, not another one with the same content.
231232

232233
Since we want our chart to be editable, we will need to change the table data over time.
233-
For that purpose, we put the entire `DataList` in a `Var`, as follows:
234+
For that purpose, we put the entire `DataList` in a `Var`, which we encapsulate in a `Model` class, as follows:
234235

235236
{% highlight scala %}
236-
val dataVar: Var[DataList] = Var(List(DataItem(DataItemID(), "one", 1.0, 1)))
237-
val dataSignal = dataVar.signal
237+
final class Model:
238+
val dataVar: Var[DataList] = Var(List(DataItem(DataItemID(), "one", 1.0, 1)))
239+
val dataSignal = dataVar.signal
240+
end Model
238241
{% endhighlight %}
239242

240243
We also define two functions that will add a new random item, and remove a specific item (given its ID):
241244

242245
{% highlight scala %}
243-
def addDataItem(item: DataItem): Unit =
244-
dataVar.update(data => data :+ item)
246+
final class Model:
247+
...
245248

246-
def removeDataItem(id: DataItemID): Unit =
247-
dataVar.update(data => data.filter(_.id != id))
249+
def addDataItem(item: DataItem): Unit =
250+
dataVar.update(data => data :+ item)
251+
252+
def removeDataItem(id: DataItemID): Unit =
253+
dataVar.update(data => data.filter(_.id != id))
254+
end Model
255+
{% endhighlight %}
256+
257+
## Testing
258+
259+
This is a good time to introduce some unit tests to our application.
260+
We want to make sure that some of the model operations, like `DataItem.fullPrice` or `Model.addDataItem`, work as expected.
261+
262+
We first add the following dependency on [MUnit](https://scalameta.org/munit/), a Scala testing framework, in our `build.sbt`:
263+
264+
{% highlight diff %}
265+
// Depend on Laminar
266+
libraryDependencies += "com.raquo" %%% "laminar" % "0.14.2",
267+
+
268+
+ // Testing framework
269+
+ libraryDependencies += "org.scalameta" %%% "munit" % "0.7.29" % Test,
270+
)
271+
{% endhighlight %}
272+
273+
After re-importing the project in the IDE (which should be prompted), we create a new file `src/test/scala/livechart/ModelTest.scala`.
274+
We write an elementary test for `DataItem.fullPrice` as follows:
275+
276+
{% highlight scala %}
277+
package livechart
278+
279+
class ModelTest extends munit.FunSuite:
280+
test("fullPrice") {
281+
val item = DataItem(DataItemID(), "test", 0.5, 5)
282+
assert(item.fullPrice == 2.5)
283+
}
284+
end ModelTest
285+
{% endhighlight %}
286+
287+
We can run our test from the `sbt` prompt with the `test` command:
288+
289+
{% highlight none %}
290+
sbt:livechart> test
291+
livechart.ModelTest:
292+
+ fullPrice 0.00s
293+
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
294+
[success] Total time: 0 s, completed
295+
{% endhighlight %}
296+
297+
In order to test `addDataItem` and `removeDataItem`, we need to read the current value of `dataSignal`.
298+
For testing purposes, the most straightforward way to do so is to use the `now()` method.
299+
In the application code, we would prefer using `map()` and other combinators, as we will see later, but `now()` is good for tests.
300+
301+
{% highlight scala %}
302+
test("addDataItem") {
303+
val model = new Model
304+
305+
val item = DataItem(DataItemID(), "test", 0.5, 2)
306+
model.addDataItem(item)
307+
308+
val afterItems = model.dataSignal.now()
309+
assert(afterItems.size == 2)
310+
assert(afterItems.last == item)
311+
}
312+
313+
test("removeDataItem") {
314+
val model = new Model
315+
316+
model.addDataItem(DataItem(DataItemID(), "test", 0.5, 2))
317+
318+
val beforeItems = model.dataSignal.now()
319+
assert(beforeItems.size == 2)
320+
321+
model.removeDataItem(beforeItems.head.id)
322+
323+
val afterItems = model.dataSignal.now()
324+
assert(afterItems.size == 1)
325+
assert(afterItems == beforeItems.tail)
326+
}
327+
{% endhighlight %}
328+
329+
Running the tests now yields
330+
331+
{% highlight none %}
332+
sbt:livechart> test
333+
livechart.ModelTest:
334+
+ fullPrice 0.00s
335+
+ addDataItem 0.00s
336+
+ removeDataItem 0.00s
337+
[info] Passed: Total 3, Failed 0, Errors 0, Passed 3
338+
[success] Total time: 0 s, completed
248339
{% endhighlight %}
249340

250341
## Rendering as a table
251342

252-
For this article in the series, we focus on Laminar itself, and therefore on rendering the *table* view of out data.
343+
For this article in the series, we focus on Laminar itself, and therefore on rendering the *table* view of our data.
253344

254345
{% highlight scala %}
255-
def appElement(): Element =
256-
div(
257-
h1("Live Chart"),
258-
renderDataTable(),
259-
)
260-
end appElement
261-
262-
def renderDataTable(): Element =
263-
table(
264-
thead(tr(th("Label"), th("Price"), th("Count"), th("Full price"), th("Action"))),
265-
tbody(
266-
children <-- dataSignal.map(data => data.map { item =>
267-
renderDataItem(item.id, item)
268-
}),
269-
),
270-
tfoot(tr(
271-
td(button("➕", onClick --> (_ => addDataItem(DataItem())))),
272-
td(),
273-
td(),
274-
td(child.text <-- dataSignal.map(data => "%.2f".format(data.map(_.fullPrice).sum))),
275-
)),
276-
)
277-
end renderDataTable
278-
279-
def renderDataItem(id: DataItemID, item: DataItem): Element =
280-
tr(
281-
td(item.label),
282-
td(item.price),
283-
td(item.count),
284-
td("%.2f".format(item.fullPrice)),
285-
td(button("🗑️", onClick --> (_ => removeDataItem(id)))),
286-
)
287-
end renderDataItem
346+
object Main:
347+
val model = new Model
348+
import model.*
349+
350+
def appElement(): Element =
351+
div(
352+
h1("Live Chart"),
353+
renderDataTable(),
354+
)
355+
end appElement
356+
357+
def renderDataTable(): Element =
358+
table(
359+
thead(tr(th("Label"), th("Price"), th("Count"), th("Full price"), th("Action"))),
360+
tbody(
361+
children <-- dataSignal.map(data => data.map { item =>
362+
renderDataItem(item.id, item)
363+
}),
364+
),
365+
tfoot(tr(
366+
td(button("➕", onClick --> (_ => addDataItem(DataItem())))),
367+
td(),
368+
td(),
369+
td(child.text <-- dataSignal.map(data => "%.2f".format(data.map(_.fullPrice).sum))),
370+
)),
371+
)
372+
end renderDataTable
373+
374+
def renderDataItem(id: DataItemID, item: DataItem): Element =
375+
tr(
376+
td(item.label),
377+
td(item.price),
378+
td(item.count),
379+
td("%.2f".format(item.fullPrice)),
380+
td(button("🗑️", onClick --> (_ => removeDataItem(id)))),
381+
)
382+
end renderDataItem
383+
end Main
288384
{% endhighlight %}
289385

290386
Let us pick apart the above.
@@ -566,7 +662,7 @@ It takes data model values as arguments, and returns a Laminar element manipulat
566662
This is what many UI frameworks call a *component*.
567663
In Laminar, components are nothing but methods manipulating time-varying data and returning Laminar elements.
568664

569-
## Editing prices and counts
665+
## Editing prices
570666

571667
To finish our application, we should also be able to edit *prices* and *counts*.
572668

@@ -594,10 +690,21 @@ For the prices, we start with a "component" method building an `Input` that mani
594690
It is more complicated than the one for `String`s because of the need for parsing and formatting.
595691
This complexity perhaps better highlights the benefit of encapsulating it in a dedicated method (or component).
596692

597-
We leave it to the reader to understand the details of this method.
693+
We leave it to the reader to understand the details of the transformations.
598694
We point out that we use an intermediate, local `Var[String]` to hold the actual text of the `input` element.
599695
We then write separate transformations to link that `Var[String]` to the string representation of the `Double` signal and updater.
600696

697+
Note that we put the `<--` and `-->` binders connecting `strValue` with `valueSignal` and `valueUpdater` as arguments to the Laminar `input` element.
698+
This may seem suspicious, as none of them nor their callbacks have any direct relationship to the DOM `input` element.
699+
We do this to tie the lifetime of the binders to the lifetime of the `input` element.
700+
When the latter gets unmounted, we release the binder connections, possibly allowing resources to be reclaimed.
701+
702+
In general, every binder must be *owned* by a Laminar element.
703+
It only gets *activated* when that element is mounted.
704+
This prevents memory leaks.
705+
706+
## Editing counts
707+
601708
For the counts, we want a component that manipulates `Int` values.
602709
For those, we would like a more *controlled* input.
603710
Instead of letting the user enter any string in the input, and only remember the last valid `Double` value, we now want to only allow valid `Int` values in the first place.

doc/tutorial/scalablytyped.md

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,19 @@ ScalablyTyped can read TypeScript type definition files and produce correspondin
2525

2626
We set up our new dependencies as follows.
2727

28-
In `project/plugins.sbt`, we add a dependency on ScalablyTyped:
28+
First, we install some npm packages: Chart.js as a regular dependency (with `-S`), and its TypeScript type definitions along with the TypeScript compiler---required by ScalablyTyped---as development dependencies (with `-D`):
2929

30-
{% highlight scala %}
31-
addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta40")
30+
{% highlight shell %}
31+
$ npm install -S chart.js@2.9.4
32+
...
33+
$ npm install -D @types/chart.js@2.9.29 typescript@4.9.5
34+
...
3235
{% endhighlight %}
3336

34-
In `package.json`, we add dependencies on Chart.js, its TypeScript type definitions, and on the TypeScript compiler, which is required by ScalablyTyped:
37+
In `project/plugins.sbt`, we add a dependency on ScalablyTyped:
3538

36-
{% highlight diff %}
37-
+ "dependencies": {
38-
+ "chart.js": "2.9.4"
39-
+ },
40-
"devDependencies": {
41-
"@scala-js/vite-plugin-scalajs": "1.0.0",
42-
- "vite": "^3.2.3"
43-
+ "vite": "^3.2.3",
44-
+ "typescript": "4.6.2",
45-
+ "@types/chart.js": "2.9.29"
46-
}
39+
{% highlight scala %}
40+
addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta41")
4741
{% endhighlight %}
4842

4943
Finally, in `build.sbt`, we configure ScalablyTyped on our project:
@@ -53,10 +47,10 @@ Finally, in `build.sbt`, we configure ScalablyTyped on our project:
5347
.enablePlugins(ScalaJSPlugin) // Enable the Scala.js plugin in this project
5448
+ .enablePlugins(ScalablyTypedConverterExternalNpmPlugin)
5549
.settings(
56-
scalaVersion := "3.2.1",
50+
scalaVersion := "3.2.2",
5751
[...]
58-
// Depend on Laminar
59-
libraryDependencies += "com.raquo" %%% "laminar" % "0.14.2",
52+
// Testing framework
53+
libraryDependencies += "org.scalameta" %%% "munit" % "0.7.29" % Test,
6054
+
6155
+ // Tell ScalablyTyped that we manage `npm install` ourselves
6256
+ externalNpm := baseDirectory.value,
@@ -65,10 +59,8 @@ Finally, in `build.sbt`, we configure ScalablyTyped on our project:
6559

6660
For these changes to take effect, we have to perform the following steps:
6761

68-
1. Stop sbt and Vite, if they are running
69-
1. Run `npm install` to install the new npm dependencies
7062
1. Restart sbt and the `~fastLinkJS` task (this will take a while the first time, as ScalablyTyped performs its magic)
71-
1. Restart `npm run dev`
63+
1. Restart `npm run dev` if it was running
7264
1. Possibly re-import the project in your IDE of choice
7365

7466
## Chart configuration
@@ -240,6 +232,7 @@ We store the resulting `chart` instance in a local `var optChart: Option[Chart]`
240232
We will use it later to update the `chart`'s imperative data model when our FRP `dataSignal` changes.
241233

242234
In order to achieve that, we use a `dataSignal -->` binder.
235+
We give it as an argument to the Laminar `canvas` element to tie the binder to the canvas lifetime, as you may [recall from the Laminar tutorial](laminar.html#editing-prices).
243236
Once the `canvas` gets mounted, every time the value of `dataSignal` changes, the callback is executed.
244237

245238
{% highlight scala %}
@@ -256,15 +249,6 @@ dataSignal --> { data =>
256249
In the callback, we get access to the `chart: Chart` instance and update its data model.
257250
This `-->` binder allows to bridge the FRP world of `dataSignal` with the imperative world of Chart.js.
258251

259-
Note that we put the `-->` binder as an argument to the Laminar `canvas` element.
260-
This may seem suspicious, as neither `dataSignal` nor the callback have any direct relationship to the DOM canvas element.
261-
We do this to tie the lifetime of the binder to the lifetime of the `canvas` element.
262-
When the latter gets unmounted, we release the binder connection, possibly allowing resources to be reclaimed.
263-
264-
In general, every binder must be *owned* by a Laminar element.
265-
It only gets *activated* when that element is mounted.
266-
This prevents memory leaks.
267-
268252
Our application now properly renders the data model as a chart.
269253
When we add or remove data items, the chart is automatically updated, thanks to the connection established by the `dataSignal -->` binder.
270254

doc/tutorial/scalajs-vite.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ In the subdirectory `livechart/project/`, we add two files: `build.properties` a
104104
* `project/build.properties`: set the version of sbt
105105

106106
{% highlight plaintext %}
107-
sbt.version=1.8.0
107+
sbt.version=1.8.2
108108
{% endhighlight %}
109109

110110
* `project/plugins.sbt`: declare sbt plugins; in this case, only sbt-scalajs
@@ -352,6 +352,6 @@ We used sbt as our build tool, but the same effect can be achieved with any othe
352352
Our setup features the following properties:
353353

354354
* Development mode with live reloading: changing Scala source files automatically triggers recompilation and browser refresh.
355-
* Production mode, wired to automatically take the fully optimized output of Scala.js, and producing a unique `.js` file.
355+
* Production mode taking the fully optimized output of Scala.js and producing a unique `.js` file.
356356

357357
In our [next tutorial about Laminar](./laminar.html), we will learn how to write UIs in idiomatic Scala code.

0 commit comments

Comments
 (0)